Unity 小游戏开发记录
这是北京某公司给的 Demo 测试,类似于竖版雷霆战机,玩家控制角色击倒无数场景中随机生成的敌人,无尽版一直到玩家生命值耗尽,但其实好多抖微小游戏设计思路差不多,这里简单整理一下。
hr 给的预完成时间是 48 个小时,因为自己原因并没按规定时间交付。从拿到素材浅看过一眼觉得应该十个小时左右可以完成,就忙着别家的笔试面试内容。因为过程比较崎岖或者说从创建项目到打包远超过自己的预期时间十小时(但肯定是因为自己太菜了),所以记录一下中间遇到的问题和踩过的坑。如果有类似小游戏开发学习的想法也可以参考一下。这里附一张完成的图片,但后续我自己做了好多延伸为了整理市面上大多数游戏的共性功能统一实现迎合各种类型的抖微小游戏开发。

开发设计思路
先从头到尾讲一下这个小案例,设计步骤如下:
第一步 :拿到项目介绍文档,选择引擎(U3D)和合适的unity版本新建项目,导入相应的美术资源。IDE是Cursor,这里建议先写关于 UI 的逻辑,自己构建一个简单的 UI 框架,方便后续写 UI、跳转的逻辑,这里有一张简单的UI跳转关系
第二步:先理清 UI 跳转关系,然后整理一下项目需求,写一个大概的项目框架。类似音频、UI、特效、事件中心、数据存储这些基础功能的封装。如果是大型项目可以找一些封装好的完善的项目框架,因为是小游戏赶工的原因就手搓了一个 BaseUI 和 UIManager。
第三步 :需求提到要注重效果,所以导了一个比较常用的三方插件 DOTween 完成 UI 面板切换的显隐逻辑,包括后续的一些拾取物品都能用得上。考量一下项目规模决定 UI 是动态加载还是预生成在场景中存到字典里统一管理。
第四步:写完初步的 UI 逻辑后搭建场景,首先确定是 3D 还是 2D。大多数同类游戏(跑酷、射击、2D 闯关)都是在 3D 场景下的 2D 游戏。
第五步:搭建游戏后写游戏的主循环逻辑,仅限该项目设计流程大概是:
- 玩家逻辑
- 敌人逻辑
- 特殊技能道具逻辑
- 互相的碰撞检测和交互后的逻辑
- 场景中随机刷新生成的逻辑
- 动画相关
- 音频相关
- 数组持久化存储相关
第六步:写完游戏主循环,完善 UI,然后简单自测就可以打包发给 HR 了。但提前问下有没有包体大小限制,看看涉不涉及包体优化相关内容。
大致思路讲完,现在说下可能遇到的问题。
一、无限延伸循环场景实现
类似这种和跑酷类都会涉及到,但只说这个简单的小案例。
踩坑经历:第一次上手把背景和场景中游戏对象放到 Canvas 下。本来想着这只是简单的游戏场景,为了简单应付交包把场景对象放在 GamePanel 下写好了完整一版的 2D 背景无限延伸的脚本,测试都还 OK。但加上玩家 Player 之后问题就显现出来了:
由于场景在 Canvas 下,所有场景对象都属于 UI 层,所以游戏对象无法正常渲染到 UI 层级前面,导致后续所有游戏对象都不能正确显示。
后面把背景延伸脚本一整个大改掉,改成基本适配大多数场景的延伸逻辑。
大致思路
场景中拼出三块相同的场景用来营造看起来没有痕迹的无限循环:
- 场景中拼出三块相同的背景,这样可以做到无缝衔接循环,达到无尽地图的效果
- 游戏逻辑玩家一直向前移动,也就是背景一直向后,与跑酷游戏类似
- 在 Update 中写一直向后移动的逻辑
- 判断两个相同背景之间的 x 轴(左右边距),为了后续已经淡出屏幕的背景重新回到待出现在屏幕的位置
- 得到摄像机也就是屏幕中心位置的 x,通过计算屏幕或者某分辨率的半宽得到屏幕的左边界
- 如果某一块背景的 x 轴加上背景宽度(背景右边界)小于屏幕左边界,这时该块背景已经完全移出屏幕
- 我们要重置屏幕到最右边,遍历所有背景找到最大的 x 坐标值
- 将需要移动的背景移动到相应位置(最大的 x 坐标加上单位背景宽度减重叠量,为了防止背景之间出现因渲染问题的间隙)
时间线示意
t=0: [ A ][ B ][ C ] 三个背景紧密排列
←←←←←←←←←←←←←←←←←←←←←
持续向左滚动
t=1: [ A ][ B ][ C ] A滚出屏幕左侧
(A正在消失)
t=2: [ B ][ C ][ A' ] A移到B右边,A'是新的A
←←←←←←←←←←←←
A'加入继续滚动
t=3: [ C ][ A' ][ B' ] B也移出...
←←←←←←←←←←←←←←
... 无限循环下去
完整代码
cs
using UnityEngine;
using System.Collections.Generic;
/// <summary>
/// 背景滚动管理器
/// 功能:控制多个背景无限循环滚动,通过检测位置实现无缝拼接
/// 使用方法:在场景中放置3个背景预制件,拖入本脚本的 Backgrounds 列表
/// 功能描述:背景滚动管理器,首先拼出三块相同的背景这样可以做到无缝衔接循环达到无尽地图的效果,
/// 游戏逻辑玩家一直向前移动,也就是背景一直向后与跑酷游戏类似,在Update中写一直向后移动的逻辑
/// 判断两个相同背景之间的x轴也就是左右边距,为了后续已经淡出屏幕的背景重新回到待出现在屏幕的位置
/// 得到摄像机也就是屏幕中心位置的x,通过计算屏幕或者某分辨率的半宽得到屏幕的左边界,
/// 如果某一块背景的x轴加上背景宽度也就是背景右边界小于屏幕左边界,这时该块背景已经完全移出屏幕,
/// 我们要重置屏幕到最右边,扣除本身遍历所有背景找到最大的x坐标值,
/// 将需要移动的背景移动到相应位置(最大的x坐标加上单位背景宽度减重叠量(为了防止背景之间出现因渲染问题的间隙))
///
/// </summary>
public class BackgroundScroller : MonoBehaviour
{
/// <summary>
/// 背景物体列表,需放置3个背景
/// </summary>
[SerializeField] private List<GameObject> backgrounds;
/// <summary>
/// 背景滚动速度(世界单位/秒)
/// </summary>
[SerializeField] private float scrollSpeed = 160f;
/// <summary>
/// 单个背景宽度(设为0则自动从场景中计算)
/// </summary>
[Header("背景宽度(设为0则自动计算)")]
[SerializeField] private float backgroundWidth = 0f;
/// <summary>
/// 主摄像机引用
/// </summary>
private Camera mainCam;
/// <summary>
/// 是否已完成初始化(避免重复排列)
/// </summary>
private bool initialized = false;
/// <summary>
/// Awake:在游戏对象创建时调用(比 Start 更早)
/// 用途:提前获取主摄像机,避免每次使用时查找
/// </summary>
private void Awake()
{
mainCam = Camera.main;
}
/// <summary>
/// Start:在游戏开始第一帧调用
/// 用途:初始化背景位置,确保游戏开始时背景排列正确
/// </summary>
private void Start()
{
ArrangeBackgrounds();
}
/// <summary>
/// 初始化并排列背景
/// 逻辑:
/// 1. 计算背景实际宽度(从两个相邻背景的距离得出)
/// 2. 获取屏幕左边界位置
/// 3. 将三个背景从屏幕左边缘开始紧密排列
/// 4. 背景之间略微重叠,避免滚动时出现缝隙
/// </summary>
private void ArrangeBackgrounds()
{
// 防御检查:至少需要3个背景
if (backgrounds.Count < 3 || backgrounds[0] == null) return;
// Step 1: 计算背景实际宽度
// 取前两个背景的 X 坐标差值作为实际宽度
float actualWidth = 0f;
if (backgrounds.Count >= 2 && backgrounds[1] != null)
{
actualWidth = Mathf.Abs(backgrounds[1].transform.position.x - backgrounds[0].transform.position.x);
}
// 防御:如果无法计算(背景重叠或位置相同),使用预设值
if (actualWidth <= 0)
{
actualWidth = backgroundWidth > 0 ? backgroundWidth : 1264f;
}
// Step 2: 计算屏幕左边界
// 屏幕左边界 = 摄像机X位置 - 屏幕半宽
float screenLeft = mainCam != null ? mainCam.transform.position.x - GetScreenHalfWidth() : 0f;
// Step 3: 紧密排列背景(略微重叠避免缝隙)
float overlapOffset = 2f; // 重叠单位,相邻背景重叠2个世界单位
for (int i = 0; i < backgrounds.Count; i++)
{
if (backgrounds[i] != null)
{
// 第0个背景紧贴屏幕左边缘
// 第1个背景 = 屏幕左边缘 + (宽度 - 重叠量)
// 第2个背景 = 屏幕左边缘 + 2 × (宽度 - 重叠量)
backgrounds[i].transform.position = new Vector3(
screenLeft + i * (actualWidth - overlapOffset),
backgrounds[i].transform.position.y,
backgrounds[i].transform.position.z
);
}
}
// 保存计算出的宽度供后续使用
backgroundWidth = actualWidth;
initialized = true;
Debug.Log($"[背景] 排列完成,宽度={actualWidth:F2}");
}
/// <summary>
/// 用途:持续移动背景并检测是否需要循环
/// 注意:只有游戏状态为 Playing 时才执行滚动
/// </summary>
private void Update()
{
// 仅在游戏进行中滚动,非Playing状态(如暂停、结束)时停止
if (GameManager.Instance != null &&
GameManager.Instance.CurrentState != GameState.Playing) return;
// 遍历所有背景
foreach (var bg in backgrounds)
{
if (bg == null) continue;
// Step 1: 背景向左移动
// scrollSpeed × deltaTime = 每帧移动的距离(帧率无关)
bg.transform.position -= Vector3.right * scrollSpeed * Time.deltaTime;
// Step 2: 获取屏幕左边界
float screenLeft = mainCam.transform.position.x - GetScreenHalfWidth();
// Step 3: 检测背景是否完全移出屏幕左侧
// 背景右边缘位置 = 背景X位置 + 宽度的一半
// 但这里用背景X + 宽度(因为检测的是背景左边缘的移动)
if (bg.transform.position.x + backgroundWidth < screenLeft)
{
// 移出屏幕,调用 MoveToRightmost 将其移到最右边
MoveToRightmost(bg);
}
}
}
/// <summary>
/// 将指定背景移到最右边
/// 逻辑:
/// 1. 遍历找出当前所有背景中最右边的X坐标
/// 2. 将目标背景放到最右边背景的右侧(减去重叠量保持衔接)
/// 这样背景会继续向左滚动,形成无限循环效果
/// </summary>
/// <param name="bg">需要移到最右边的背景物体</param>
private void MoveToRightmost(GameObject bg)
{
// Step 1: 找出所有背景中最右边的X坐标
float maxX = float.MinValue; // 从极小值开始,确保能覆盖正常坐标
foreach (var other in backgrounds)
{
// 跳过自己和空对象
if (other == null || other == bg) continue;
maxX = Mathf.Max(maxX, other.transform.position.x);
}
// Step 2: 计算新位置
// 新X = 最右边X + 背景宽度 - 重叠量
// 减去重叠量是为了让新背景与现有背景保持重叠衔接
float overlapOffset = 1f;
float newX = maxX + backgroundWidth - overlapOffset;
// 保持Y和Z坐标不变,只改变X
bg.transform.position = new Vector3(newX, bg.transform.position.y, bg.transform.position.z);
}
/// <summary>
/// 获取屏幕半宽(水平方向)
/// 计算公式:orthographicSize × aspect
/// - orthographicSize:正交相机的视口半高(垂直方向世界单位的一半)
/// - aspect:屏幕宽高比(width / height)
/// - 两者相乘 = 屏幕半宽(水平方向世界单位的一半)
/// </summary>
/// <returns>屏幕半宽(世界单位)</returns>
private float GetScreenHalfWidth()
{
return mainCam != null ? mainCam.orthographicSize * mainCam.aspect : 10f;
}
}
二、面试遇到的问题和实际开发会出现的问题
面试被问到1:渲染层级
Q:如果三个物体 A、B、C,怎么保证 B、C 在 A 前面显示?
| 方法 | 说明 |
|---|---|
| 3D 对象 | 在 2D 视图中展示可以调整 Z 轴 |
| 2D 对象 | 调整 SpriteRenderer 的 Sorting Layer 和 Order in Layer,数字大的优先级高 |
| 渲染队列 | 调整 RenderQueue,数字小的优先级高,优先渲染 |
面试被问到2:触发器与碰撞器
Q:你觉得触发器和碰撞器有什么区别?
A:简单总结就是一个能穿过去、一个不能。
具体展开:
- 两个物体产生碰撞的条件是都必须有碰撞体,其中一个物体必须有刚体
- 就算是触发器不需要物理效果,也必须有刚体
- 两个碰撞产生的回调
OnTrigger/OnCollision是 Unity 生命周期中的重要方法,包含碰撞开始、碰撞发生、碰撞结束三种 - 只要参与碰撞到的其中一个物体勾选了 is Trigger,就会走触发器
- 根本区别:是否产生物理效果
面试被问到3:碰撞检测排查
Q:玩家包括玩家发射的子弹和敌人并没有产生碰撞逻辑,而是穿过去了,怎么排查?
排查流程:
- 碰撞体和刚体组件检查 --- 看刚体类型是否为静态,静态刚体不会产生碰撞
- 检查代码逻辑 --- 写一个打印看看是否真的没发生碰撞
- 检查物理设置的碰撞矩阵 --- 保证两个层级可以产生碰撞
三、技能系统实现方案
场景中随机刷新获得技能,或者玩家会得到的特殊能力,有以下几种实现方式:
评估项目规模决定哪种实现方式更合适。
| 方案 | 说明 |
|---|---|
| 枚举 + Switch | 最轻量级,用一个脚本写技能枚举,switch case 判断写具体技能逻辑 |
| 接口/继承 | 实现状态接口约束规范,或者提取一个技能父类继承实现 |
| ScriptableObject | 组合实现技能配置表(具体实现下来可能会有点麻烦) |
四、ScriptableObject 用法与优缺点
什么是 ScriptableObject
ScriptableObject 是 Unity 中一种不继承自 MonoBehaviour 的数据容器类,通常用于存储配置数据。它不绑定到场景中的游戏对象,而是作为资源文件存在于项目中。
基本用法
1. 创建脚本(继承 ScriptableObject)
cs
using UnityEngine;
[CreateAssetMenu(fileName = "NewItem", menuName = "Game/Item")]
public class ItemData : ScriptableObject
{
public string itemName;
public int id;
public Sprite icon;
public float value;
}
2. 创建资源实例
- 在 Project 窗口右键 → Create → Game → Item(菜单名来自 CreateAssetMenu)
- 或通过 Assets → Create → Game → Item
3. 在其他脚本中使用
cs
public class Example : MonoBehaviour
{
// 直接引用(Inspector 面板拖拽赋值)
public ItemData itemData;
void Start()
{
Debug.Log(itemData.itemName);
}
}
4. 动态加载
cs
// 从 Resources 加载
ItemData data = Resources.Load<ItemData>("Items/Item1");
// 或通过 AssetDatabase(在编辑器模式)
#if UNITY_EDITOR
ItemData data = UnityEditor.AssetDatabase.LoadAssetAtPath<ItemData>("Assets/Items/Item1.asset");
#endif
优点
| 优点 | 说明 |
|---|---|
| 数据与逻辑分离 | 配置数据独立存储,修改数据无需改动代码 |
| 可在 Inspector 编辑 | 设计师无需写代码即可配置数值 |
| 节省内存 | 同一实例可被多个对象引用,不会重复实例化 |
| 易于版本控制 | 作为 .asset 文件存储,可追踪修改历史 |
| 支持导入/导出 | 可以打包或迁移到其他项目 |
缺点
| 缺点 | 说明 |
|---|---|
| 不存储运行时状态 | 场景切换或退出后数据不会自动保存,需手动序列化 |
| 依赖 Unity 编辑器 | 虽然可打包,但本质上仍是编辑器资源 |
| 无默认实例化方法 | 需要手动通过 CreateInstance 或引用使用 |
| 不适合频繁修改的数据 | 每次修改都是永久改动,不像运行时变量灵活 |
| 隐式共享风险 | 多个对象引用同一实例时,修改会影响所有引用者 |