Featured image of post 【练手日志】Unity小游戏集锦 1

【练手日志】Unity小游戏集锦 1

用Unity实现的小游戏项目的笔记,通过练习希望让自己快速熟悉Unity的各个组件以及常用API,积累一些游戏常用逻辑的编程套路

目录

这一练手系列中的游戏都是非常小的,旨在帮我自己熟悉API的用法、锻炼思维逻辑能力,以及拓展思路,所以首要任务是快速完成原型、或者总结出一些小块的复用代码;项目整体架构和可扩展性等不在此类项目范围之内。

合成大西瓜

教程参考及素材来源B站-游池汇

源码on Gitee

游戏简介

《合成大西瓜》是一款由微伞游戏(Weisun Games)2021年初推出的休闲益智类网页小游戏,因其简单上手、魔性玩法和社交传播迅速走红。

核心玩法

  • 玩家通过点击屏幕,从上方随机掉落不同种类的水果(如葡萄、橙子、苹果等)。
  • 相同水果碰撞后会合成更大的水果,例如两个葡萄合成一个樱桃,两个樱桃合成一个橙子,依此类推,最终目标是合成出最大的“西瓜”。
  • 游戏采用类似“俄罗斯方块 + 2048”的机制:水果受重力影响堆叠,若堆积超过屏幕顶部,则游戏结束

游戏/功能拆解

内容

背景图片、墙壁和地板、水果(运动中水果、待命水果)、死亡判定线、UI(分数)、音效(落地、合成)

逻辑

  1. 待命水果,鼠标按下时在x轴跟随鼠标,松开鼠标水果落下
  2. 水果落地时,水果之间互相碰撞、带弹性效果,相同的水果碰撞在一起会合成更大的水果
  3. 水果如果抵达了死亡判定线,游戏失败,重新加载场景
  4. 显示当前的分数

开发步骤

  1. 场景摆放
  2. 给水果和场景等游戏物体添加重力、碰撞、弹性等组件
  3. 将水果做成预制体
  4. 创建待命水果跟随鼠标移动
  5. 待命水果在鼠标松开后落下
  6. 相同水果合成
  7. 水果碰到/超出死亡线的逻辑
  8. 显示UI分数
  9. 音效

遇到的问题及解决方法

1. 没有思路

仿照“把一只大象放进冰箱需要几步”,将要做的事尽可能拆成细到不能再细的步骤。开发步骤4为例,可以进一步细化为以下的步骤:

  • 待命水果(1-4号水果)数组
  • 待命水果生成点
  • 随机从数组中生成待命水果
  • 鼠标点下后获取鼠标的位置,并赋值给待命水果(只改变x轴的值,y和z轴保持不变)
  • 边界检查(水果不能随鼠标一起超过两边墙壁)

2. 屏幕坐标与世界坐标的转换

1
2
3
4
5
6
7
// 屏幕坐标 -> 世界坐标
Vector3 screenPoint = Input.mousePosition; // 例如鼠标位置
Vector3 worldPoint = Camera.main.ScreenToWorldPoint(screenPoint);

// 世界坐标 -> 屏幕坐标
Vector3 worldPos = player.transform.position;
Vector3 screenPos = Camera.main.WorldToScreenPoint(worldPos);

需要注意的是,因为main camera在世界坐标中z轴的位置在-10,所以2D的普通游戏物体从屏幕坐标转为世界坐标要处理z轴(设置为0),否则会看不见(在main camera处)。

Unity的屏幕坐标原点在左下角

3. 延迟执行

1
Invoke("funcName", second);

4. 单例模式的赋值

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// 在指定一个类为单例模式后,不要忘记在Awake中赋值
public class PlayerManager : MonoBehaviour
{
    public static PlayerManager Instance;
    
    public GameObject[] fruits;
    public Transform createPoint;
    [HideInInspector]
    public GameObject waitingFruit;

    private void Awake()
    {
        Instance = this;
    }
    
    // Other codes
}

5. 水果合成的逻辑

每个游戏物体的id都是唯一的,所以使用id来标识每一个物体。为了避免每个水果都合成一个更高级水果的情况,只允许id较大的那个水果生成更高等级的水果。

6. 自定义字体无法缩放大小

通过整体缩放解决(Scale)。

7. 落地时音效的频繁播放

设定当velocity超过一定数值时才播放音效,避免因为一点移动就播放音效从而感觉很杂乱的问题。

复盘

第一个练手的项目,代码上尽量都是自己写的,没有思路的时候才去看教程的思路,然后再自己写。遇到不会的API就翻阅自己以前的笔记或者查官方文档,感觉确实印象深刻了不少。

Demo完成了核心逻辑,虽然有一些小bug,比如在有几次测试的时候发现即使水果堆积超过了死亡线,还是没有重新加载的情况。还有合成到了大西瓜之后,需要做一个胜利的页面和场景等。待再练几个项目之后再回来处理吧。

代码复用

1. 各类“Manager”都可使用单例模式
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// 以PlayerManager为例
public class PlayerManager : MonoBehaviour
{
    public static PlayerManager Instance;

    private void Awake()
    {
        Instance = this;
    }
    // Other codes
}
2. 游戏物体跟随鼠标移动
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
private void PlaceFruit()
{
    // 获取鼠标位置(屏幕坐标)
    // 将其转换为世界坐标
    Vector3 mousePos = Camera.main.ScreenToWorldPoint(Input.mousePosition);
    // 鼠标左键按下,待命水果随鼠标移动
    // 本例中水果只随鼠标位置在x轴移动
    if (Input.GetMouseButton(0))	
    {
        waitingFruit.transform.position = new Vector3(mousePos.x, waitingFruit.transform.position.y, 0);
    }
}

太空射击

教程参考及素材来源B站-齐齐课-Plane

源码on Gitee

游戏简介

《太空射击》(Space Shooter)是Unity官方的一个3D入门教程项目,旨在帮助初学者快速掌握 Unity 的基础开发流程,包括场景搭建、玩家控制、敌人生成、碰撞检测、UI 显示和音效集成等核心概念。此版本在原教程基础上进行了升级,加入了会发射子弹的敌人等。

核心玩法

  • 类型:2D太空射击游戏(3D俯视视角)
  • 目标:操控飞船在太空中生存尽可能长时间,击落不断出现的敌人和小行星。
  • 操作方式
    • 移动:使用 WASD 或方向键控制飞船在屏幕内自由移动
    • 射击:按下鼠标左键发射子弹
  • 敌人行为
    • 小行星和敌人从屏幕上方随机位置生成,以不同速度向下坠落,敌人会左右移动并发射子弹
    • 玩家需躲避或摧毁它们,避免被撞击或者被敌人的子弹击中
  • 失败条件:飞船被小行星/子弹击中即游戏结束
  • 计分系统:每摧毁一个敌人获得分数,UI 实时显示当前得分

游戏/功能拆解

内容

场景搭建(背景缓慢向下移动)、动画(敌人爆炸、小行星爆炸、玩家左右移动时偏转、小行星翻转)、UI(分数)、音效(背景音乐、射击、击中)

逻辑

  1. 玩家可以上下左右移动、发射子弹
  2. 小行星在屏幕上方随机生成,匀速下落
  3. 敌人在屏幕上方随机生成、以更快的速度向下移动、同时左右移动并发射子弹
  4. 显示当前的分数
  5. 玩家被敌人子弹击中,或者玩家被小行星/敌人碰撞,玩家死亡,重新加载游戏场景

开发步骤

  1. 搭建背景环境

    • 背景的循环移动
    • 星尘特效添加
    • 背景音乐添加
  2. 玩家部分的实现

    • 玩家运动控制

    • 玩家左右移动的偏转效果,速度越大偏转角度越大

    • 实现玩家的射击

    • 添加玩家子弹射击和音效

  3. 敌人和小行星部分的实现

    • 随机生成小行星和敌人
    • 小行星随机旋转效果
    • 实现敌人的射击
    • 实现敌人的左右移动(闪避)
  4. 特效:实现攻击和撞击效果

  5. UI部分

    • 搭建UI界面

    • 实现UI显示逻辑

遇到的问题及解决方法

1. 背景的移动

让背景移动还是摄像机移动呢?通常选择让背景移动,因为游戏场景中还会有小行星、玩家、敌人等各种物体,保持摄像机不动是一种更简单的方式。

需要注意的是,和做“往复”有关的计算时,因为要与绝对时间同步,所以使用 Time.time;在做一般位移、计时、累加时,因为需要确保与帧率无法,使用Time.deltaTime

1
2
3
4
5
6
7
8
9
// 位移的计算(一般使用API)
this.transform.Translate(Vector3.forward * 1 * Time.deltaTime, space.World); // 如果是自己坐标系:Space.Self

// 背景循环往复
private void ScrollBG()
{
    float distance = Mathf.Repeat(_scrollSpeed * Time.time, 30);
    transform.position = _startPos + distance * Vector3.forward * (-1);
}

2. 对刚体的运算要放在FixedUpdate()

因为Update()中每一帧的时间可能不同,取决于设备的性能、运算的复杂程度等,所以如果将刚体运算放置其中可能会出现一些卡顿等情况。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 通过对刚体的操作来控制玩家的运动
private void FixedUpdate()
{
    MovePlayer();
}

private void MovePlayer()
{
    float horizontalVel = Input.GetAxis("Horizontal"); // 0-1
    float verticalVel = Input.GetAxis("Vertical");
    Vector3 velocity = new Vector3(horizontalVel, 0, verticalVel);
    _playerRB.velocity = velocity * _speed;
    // Other codes
}

3. 边界检查的一种方法

通过在面板中直观地检查边界的位置,感觉比纯用代码控制简便。先在类内创建一个结构体并序列化,然后在代码中调用。(不要忘记将Border类实例化)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class PlayerController : MonoBehaviour
{
    [Serializable]
    public struct Border
    {
        public float maxZ;
        public float minZ;
        public float minX;
        public float maxX;
    }
    
    [SerializeField] private Border _border;
    
    // Other codes
	private void MovePlayer()
    {
      	// Other codes
        
        // Border check
        float posX = Mathf.Clamp(_playerRB.position.x, _border.minX, _border.maxX);
        float posZ = Mathf.Clamp(_playerRB.position.z, _border.minZ, _border.maxZ);
        _playerRB.position = new Vector3(posX, 0, posZ);
    }
}

4. 利用刚体实现物体的偏转

1
2
3
4
5
// 物体绕y轴旋转30°
Quaternion rotation = Quaternion.Euler(0, 30, 0);

// 可以直接利用刚体的rotation属性
_playerRb.rotation = Quaternion.Euler(0, 0, _playerRb.velocity.x * (-1) * tilt);

5. “组件化(功能挂钩)”模式

把可复用的功能拆成小组件(例如 MoveController),再由具体行为脚本(例如 PlayerController / EnemyController)组合调用。

优点:复用性高、职责清晰、易测试、易维护;遵循 composition over inheritance。 缺点:某些对象会挂多个小脚本,但这是合理的单一职责拆分,性能影响很小。 实践要点:保持组件单一职责、用接口/事件解耦、用 RequireComponent、把共享数据放到 ScriptableObject 或配置类、用 Prefab 组合复用。

“实践要点”有点难以理解。在“2048”项目中曾经看到过一次,以后对这方面的实践需要多加留意体会。

6. GetComponent 调用的位置/时机

在实践中推荐的做法是,如果同一物体上获取并缓存组件用 Awake(),如果依赖别的对象在 Awake() 中完成的初始化则放到 Start()

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 要获得同一个物体MoveController身上的刚体组件
public class MoveController : MonoBehaviour
{
    private Rigidbody rb;

    private void Awake()	// 在Awake()中获取刚体组件
    {
        rb = GetComponent<Rigidbody>();
    }

    public void Move(Vector3 dir, float speed)
    {
        rb.velocity = dir.normalized * speed;
    }
}

// 依赖别的对象在 Awake() 中初始化的值,所以在 Start() 使用
public class EnemyAI : MonoBehaviour
{
    private MoveController move;
    private Transform player; // 假设 Player 在 Awake() 设置好

    private void Start()
    {
        move = GetComponent<MoveController>();	// 另一个对象MoveController在Start()中获取组件
        player = GameObject.FindWithTag("Player")?.transform; // 等待所有 Awake 完成后再查找使用更安全
    }

    private void Update()
    {
        if (player == null) return;
        Vector3 dir = (player.position - transform.position);
        dir.y = 0f;
        move.Move(dir, 3f);
    }
}

7. 协程的一般写法

需要注意以下几点:

  • 定义返回类型为 IEnumerator 的方法,方法内使用 yield return 返回等待(nullnew WaitForSeconds(...)WaitForEndOfFrame 等)
  • StartCoroutine(MyCoroutine()) 启动;可用 StopCoroutine 停止
  • 不要在主线程(比如 Start())里写不会 yieldwhile(true),那会卡死主线程
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 利用协程生成和销毁敌人
// 把循环放到协程里并用 Instantiate 生成敌人,用另一个协程在延时后销毁
private void Start()
{
    StartCoroutine(SpawnEnemyWave());
}

private IEnumerator SpawnEnemyWave()
{
    yield return new WaitForSeconds(startSpawnTime);
    while (true)
    {
        for (int i = 0; i < enemyCount; i++)
        {
            GameObject prefab = enemies[Random.Range(0, enemies.Length)];
            GameObject newEnemy = Instantiate(prefab);
            newEnemy.transform.position = new Vector3(Random.Range(-4, 5), 2, 10);
        }
        yield return new WaitForSeconds(spawnWaitingTime);  
    }
}

// Enemy和其子弹全部和Player的子弹一样,用KillBox统一销毁

8. 小行星自身旋转效果的实现

使用APIRandom.insideUnitSphere, 返回一个随机的 Vector3,方向和大小在单位球体内随机分布;然后通过乘以 rotationSpeed 会把这个向量缩放到期望的角速度大小;最后赋值给Rigidbody.angularVelocity:向量的方向表示旋转轴,向量的长度表示角速度(弧度/秒),所以刚体会绕该随机轴以该速度旋转。

1
2
3
4
 private void Start()
 {
     _rb.angularVelocity = Random.insideUnitSphere * rotationSpeed;
 }

9. 实现敌人发射子弹的两种方法

方法一:和玩家发射子弹的思路类似,用累加时间的方法。

方法二:使用APIInvokeRepeating(string methodName, float time, float repeatRate) 。含义:在 time 秒后调用 methodName 方法,然后每 repeatRate 秒调用一次。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 方法一的实现
private void Update()
{
    SpawnBullet();
}

private void SpawnBullet()
{
    _waitingTime += Time.deltaTime;
    if (_waitingTime > cooldown)
    {
        Instantiate(bullet, shootPos);
        AudioMgr.Instance.PlayAudio(AudioMgr.Instance.clips[1]);
        _waitingTime = 0;
    }
}

// 方法二的实现
private void Start()
{
    InvokeRepeating("SpawnBullet", startingTime, cooldown);
}

private void SpawnBullet()
{
    Instantiate(bullet, shootPos);
    AudioMgr.Instance.PlayAudio(AudioMgr.Instance.clips[1]);
}

10. 敌人闪避的实现思路

敌人在生成一段时间(随机值)后进行闪避,如果敌人生成在屏幕的左半边则向右移动,如果在右半边则向左移动,一段时间(随机值)后停止左/右移动,继续向下移动,然后进入下一个闪避的循环,直到被自然销毁。

  • 使用协程实现一段时间闪避然后继续保持竖直下行的行动
  • 为了让敌人的闪避看起来平滑和自然,使用一个加速度通过APIMathf.Lerp(float a, float b, float t)来实现。a为起点值,b为终点值,t为两个浮点数之间的插值
  • 敌人在移动过程中注意边界检查

教程中使用Mathf.MoveTowards(float current, float target, float maxDelta)实现,这个API本质上与Mathf.Lerp相同,将值current平滑地向target靠近。但是使用这个方法需要大幅提高加速度的值,否则敌人会不产生闪避的行为。原因在于这两者的本质差别:Lerp 用的是比例插值(t 是个 0~1 的比率),会按比例“拉”向目标,哪怕目标在另一侧也会生成小的反向速度;MoveTowards 用的是绝对步长(maxDelta),每帧只移动固定的量。如果用于 MoveTowardsmaxDelta 太小(比如 acceleration 很小),每帧的速度变化被其他物理因素(摩擦、重置、阈值判断)淹没,看起来像“没有发生闪避”。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
private void Start()
{
    StartCoroutine(Dodge());
}

private void FixedUpdate()
{
    // float xVel = Mathf.MoveTowards(_rb.velocity.x, _targetDodgeSpeed, Time.fixedDeltaTime * acceleration);  // 100
    float xVel = Mathf.Lerp(_rb.velocity.x, _targetDodgeSpeed, Time.fixedDeltaTime * acceleration); // 20
    _rb.velocity = new Vector3(xVel, _rb.velocity.y, _rb.velocity.z);
}

private IEnumerator Dodge()
{
    while (true)
    {
        yield return new WaitForSeconds(Random.Range(startingTimeMin, startingTimeMax));

        _targetDodgeSpeed = Random.Range(dodgeSpeedMin, dodgeSpeedMax);
        // 如果敌人出现在屏幕中线右侧向左闪避
        // 如果出现在左侧,那么自然速度是正,向左闪避,不用改
        if (_rb.position.x >= 0)   
        {
            _targetDodgeSpeed = -_targetDodgeSpeed;
        }

        float targetTime = Random.Range(movingTimeMin, movingTimeMax);

        yield return new WaitForSeconds(targetTime);
        _targetDodgeSpeed = 0;
    }
}

11. OnTriggerOnCollison的使用时机

两者都可以用于碰撞检测。

OnCollision用于实际碰撞并产生物理反应的场景,所以开销较大,适用于物理交互时,如碰撞、反弹、受力等。

OnTrigger只检测重叠,不自动产生物理反应,因为通常不计算接触点/冲量,所以开销比OnCollision小些,适用于范围检测、触发器区域、捡取、伤害区域等。

12. 特效的销毁的方式方法(关注点分离原则)

特效的销毁放在一个单独的脚本中,不要和CollisionCheck 脚本放在一起。这样可以是关注点分离(CollisionCheck 只负责触发/判定),可复用性高,便于切换为对象池而不是每次销毁。

复盘

教程提供了一些很新颖的思路,在这个游戏中开始尝试自己写协程,并且尽量练习了上一个游戏中学到的单例模式。在一些我有疑问的时候或者觉得教程的处理超出我认知的时候问了AI,基本都得到满意的答复,感觉用这种项目驱动的学习方法真的很高效且因为有很多即时反馈,所以心理上也不太容易有疲惫感。

也有一些自己看了AI的回答和教程的讲解也不太明白的地方,比如“组件单一职责、用接口/事件解耦”等,留待在以后的游戏实战项目中慢慢实践体会。

还有很棒的一点是,学到了原来Unity开始界面的logo是可以替换的,马上做了一张小logo替换,效果真的不错!

代码复用

1. 滚动的无限背景
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
[SerializeField] private float _scrollSpeed;
private Vector3 _startPos;

private void Start()
{
	_startPos = transform.position;
}

private void Update()
{
	ScrollBG();
}

private void ScrollBG()
{
    float distance = Mathf.Repeat(_scrollSpeed * Time.time, 30);
    transform.position = _startPos + distance * Vector3.forward * (-1);
}
2. 旋转效果
1
2
3
4
5
6
7
// 球体的随机旋转
_rb.angularVelocity = Random.insideUnitSphere * rotationSpeed;

// 3D物体随某个轴的速度发生倾斜(此例中是随x轴上的速度)
 _rb.rotation = Quaternion.Euler(0, 0, _rb.velocity.x * (-1) * tilt);
float posX = Mathf.Clamp(_rb.position.x, border.minX, border.maxX);
_rb.position = new(posX, _rb.position.y, _rb.position.z);
3. MoveController

使用刚体,可以复用在所有在z轴方向运动的物体。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public class MoveController : MonoBehaviour
{
   [SerializeField] private float flySpeed;
   private Rigidbody _rb;
   private void Awake()
   {
      _rb = GetComponent<Rigidbody>();
   }

   private void FixedUpdate()
   {
      _rb.velocity = Vector3.forward * flySpeed;
   }
}
4. DestroyByTime

复用在特效的自动销毁上。

1
2
3
4
5
6
7
8
9
public class DestroyByTime : MonoBehaviour
{
    [SerializeField] private float delay;

    private void Start()
    {
        Destroy(gameObject, delay);
    }
}
5. AudioManager

复用在全部音效的管理和播放上。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class AudioMgr : MonoBehaviour
{
    public static AudioMgr Instance;
    public AudioClip[] clips;
    private AudioSource _audioSource;

    private void Awake()
    {
        Instance = this;
        _audioSource = GetComponent<AudioSource>();
    }

    public void PlayAudio(AudioClip clip)
    {
        _audioSource.PlayOneShot(clip);
    }
}

飞翔的小鸟

教程参考及素材来源油管-Zigurous

源码on Gitee

游戏简介

《飞翔的小鸟》(Flappy Bird)是一款由越南开发者Dong Nguyen于2013年发行的简易2D手机游戏。游戏因其极低的画面复杂性、极高的难度和令人“上瘾”的挫败感而迅速走红,一度成为全球现象级游戏。

核心玩法

  • 类型:像素风格2D游戏,背景为卷轴式滚动,角色是一只简单的像素小鸟
  • 目标:控制小鸟穿越由上下管道组成的障碍,飞得越远得分越高
  • 操作方式
    • 点击屏幕(或按任意键),小鸟向上扇动翅膀,短暂上升
    • 不操作时,小鸟受重力影响持续下落
  • 障碍机制
    • 场景中随机出现高低不一的“绿色管道”,小鸟需从管道间隙穿过
    • 触碰到管道、地面都会导致游戏立即结束
  • 失败条件:小鸟碰到障碍物或地面则失败
  • 计分系统:每成功穿过一对管道得1分,无最高分限制

游戏/功能拆解

内容

场景摆放(背景视差效果、无限滚动)、动画(小鸟飞翔)、UI(分数)、音效(得分、撞击)

逻辑

  1. 小鸟自动前进并随重力作用自然下落
  2. 玩家可以按下W键、上方向键或者点击鼠标左键让小鸟向上运动
  3. 如果小鸟在前进过程中碰到了管道或地面则游戏失败
  4. 显示当前的分数

开发步骤

  1. 小鸟部分

    • 小鸟的移动

    • 小鸟飞翔动画的实现

  2. 背景部分

    • 背景的制作

    • 背景视差的实现

  3. 管道部分

    • 管道预制体的制作
    • 管道的生成
    • 管道的运动
  4. 得分

  5. UI的设计/设置

  6. 菜单交互功能的实现

  7. 添加音效

遇到的问题及解决方法

1. 触屏模式的写法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 以控制小鸟向上移动为例
// 触屏模式
if (Input.touchCount > 0)   // 只要手指接触到屏幕
{
    Touch touch = Input.GetTouch(0);    // 获取首次屏幕按触
    if (touch.phase == TouchPhase.Began)     // 只要刚开始按触屏幕
    {
        _dir = Vector3.up * strength;
    }
}

2. 用InvokeRepeating 实现简单的动画

需要注意的是,InvokeRepeating 是在 Unity 的主循环中按时间间隔调度调用指定方法,每次调用结束后会返回给引擎,等到下一个间隔再被调用。 虽然它本质不是一个死循环,但是如果在被调的方法里写 while(true)(或其他不会退出的循环),该方法就不会返回,主线程会被阻塞,程序/编辑器会卡死。

另外,通常把 InvokeRepeating 放在 Start(或 Awake 后的一次性初始化处),只需要调用一次来安排重复调用,Unity 会按指定间隔循环调用该方法。如果把 InvokeRepeating 放在 Update 中,每帧都会再次注册一个重复调用,结果会累积大量并发的定时调用,导致方法被多次/更频繁执行、逻辑错误和性能问题。

InvokeRepeating实现小鸟飞翔的动画:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
private void Start()
{
    InvokeRepeating(nameof(AnimateSprite), 0.15f, 0.15f);
}

private void AnimateSprite()
{
    _sr.sprite = sprites[_index];
    _index++;
    if (_index >= sprites.Length)
        _index = 0;
}

3. 视差+无尽背景的一种实现思路

教程中将背景全部按照3D的Quad制作,然后将2D的图片以shader:Unit/Texture的方式做成Material 。利用material中自带的Offset实现移动效果。这种方法非常简单,复用性强。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public class Parallax : MonoBehaviour
{
    [SerializeField] private float speed;
    private MeshRenderer _meshRenderer;

    private void Awake()
    {
        _meshRenderer = GetComponent<MeshRenderer>();
    }

    private void Update()
    {
        _meshRenderer.material.mainTextureOffset += new Vector2(speed * Time.deltaTime, 0);
    }
}

4. 管道销毁的另一种思路

按照通常的做法会在左侧屏幕边缘增加一个触发器,管道预制体碰到这个触发器后执行 Destroy。教程中使用了一个更简单的方法,根据像素位置来进行销毁:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
private float _killEdge;

private void Start()
{
    _killEdge = Camera.main.ScreenToWorldPoint(Vector3.zero).x - 1;
}

private void Update()
{
    // Other codes

    if (transform.position.x < _killEdge)
    {
        Destroy(gameObject);
    }
}

5. Unity中的几个坐标系

World 坐标(World)
  • Unity 的世界坐标以世界原点 (0,0,0) 为参考,单位是 Unity unit(通常当作米)
  • 轴方向:X 向右,Y 向上,Z 向前(正 Z 朝向 Scene 视图的前方)
  • GameObjecttransform.position 是世界坐标;transform.localPosition 是相对于父对象的局部坐标
屏幕坐标(Screen)
  • 以像素为单位,原点在屏幕左下角 (0,0),右上角是 (Screen.width, Screen.height)

  • 旧的 IMGUI(OnGUI)以左上角为原点;UI 的坐标行为还会受 Canvas Render Mode(Screen Space / World Space)影响

视口坐标(Viewport)
  • 归一化坐标,范围在 [0,1],左下为 (0,0),右上为 (1,1)

  • 可用 Camera.ViewportToWorldPoint / WorldToViewportPoint 转换

在上述管道销毁的例子中,使用 Camera.ScreenToWorldPoint(Vector3 screenPoint) 对左下角屏幕坐标原点,(0, 0 ,0)即坐标原点Vector3.zero 进行了转换,这样才能和Update中的transform.position.x处于同一坐标系下,才有比较的可能。

6. 游戏的重启和暂停

最简单的方法就是使用 Time.timeScale

复盘

因为是2D游戏,所以可以直接使用诸如Vector3.upVector3.left这样的代码来表示方向,大大简化了代码。教程中的一些设计思路非常简便好用,比如把背景做成3D quad,运用API _meshRenderer.material.mainTextureOffset 来实现无尽背景和视差效果;还有通过将某一个点的坐标从屏幕坐标转换为世界坐标,物体通过这个点后销毁,而不是依靠传统的碰撞检测,可以节省性能;还有关于简单的动画,不用Unity自带的动画系统,转而使用 InvokeRepeating 配合遍历图片的方式,简单高效地解决了问题。

在我自己的实践方面,练习了 GameManagerAudioManager 的单例模式,并重温了音效的播放和UI的交互设计。这个游戏虽然是个入门级的小游戏,但是我还是学到了很多。

代码复用

Spawner的一种写法

使用 InvokeRepeating 达到间隔时间生成游戏物体的效果。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
private void OnEnable()
{
    InvokeRepeating(nameof(Spawn), startSpawn, spawnRate);
}

private void OnDisable()
{
    CancelInvoke();
}

private void Spawn()
{
    GameObject pipe = Instantiate(prefab, transform.position, Quaternion.identity);
    pipe.transform.position += Vector3.up * Random.Range(minHeight, maxHeight);
}

其他一些可复用的代码在上面的“问题与解决方法”部分都有提及,此处不再赘述。

乒乓球

教程参考及素材来源油管-Zigurous

完整源码on Gitee

游戏简介

《乒乓球》(Pong) 是电子游戏史上最早、最具影响力的街机游戏之一,由 Atari 公司于 1972 年发布。它模拟了现实中的乒乓球(Table Tennis)运动,被认为是第一个取得商业成功的电子游戏。

核心玩法

  • 游戏画面为一个二维平面,左右两侧各有一个垂直的“球拍”(paddle),中间是一个移动的小球
  • 玩家控制一侧的球拍上下移动(通常通过摇杆或按键),目标是将球反弹回对方场地
  • 如果一方未能接到球,对方得一分
  • 小球碰到上下边界会反弹,碰到左右边界则判定为得分
  • 随着游戏进行,球速可能逐渐加快,增加难度

游戏/功能拆解

内容

场景摆放、玩家球拍、球、电脑球拍、UI(分数)

逻辑

  1. 玩家通过 W键/上箭头键S键/下箭头键 移动球拍击打乒乓球
  2. 乒乓球碰到上下边界回弹
  3. 乒乓球如果落入自己边界,对方得一分,反之亦然
  4. 显示当前的分数

开发步骤

  1. 场景摆放
  2. 玩家球拍的移动
  3. 球移动的实现
  4. 电脑球拍AI的实现
  5. 球逐渐回弹增速
  6. 显示UI分数

遇到的问题及解决方法

1. Angular DragLinear Drag

Rigidbody 中的 Angular Drag 代表角阻力系数,如果不想要旋转的效果,要把它手动设置为0(默认为0.05),同时在 Constraints 中禁止 z轴 的旋转。 在类似这个游戏的2D游戏中一般需要这样的操作。

Linear Drag 则适用于位置移动,较高的阻力值会让对象受碰撞或力影响后的旋转更快停止。

2. FixedUpdate的使用说明

不要在 FixedUpdate 直接读取输入。原因是FixedUpdate 按物理步长调用,可能丢失输入事件或导致输入响应不稳定。

正确做法是在 Update() 读取输入并把结果存到字段,随后在 FixedUpdate() 根据该字段施加力。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private void Update()
{
    if (Input.GetKey(KeyCode.W) || Input.GetKey(KeyCode.UpArrow))
    {
        _dir = Vector2.up;
    }
    else if (Input.GetKey(KeyCode.S) || Input.GetKey(KeyCode.DownArrow))
    {
        _dir = Vector2.down;
    }
    else
    {
        _dir = Vector2.zero;
    }
}

private void FixedUpdate()
{
    if (_dir.sqrMagnitude != 0)
    {
        Rb.AddForce(_dir * strength);
    }
}

FixedUpdate 主要用于与物理系统同步的更新。

适合放在 FixedUpdate 里的任务

  • 应用力/扭矩(Rb.AddForce / AddRelativeForce)或直接设置 Rigidbody.velocity
  • 使用 Rigidbody.MovePosition / MoveRotation(针对 Kinematic Rigidbody
  • 与物理步长一致的计时器、物理约束与求解逻辑(确定性模拟、网络服务器物理步)
  • 基于物理状态的查询(在物理步长内做的碰撞检测/Overlap/射线检测)
  • 物理预测/插值/外推逻辑(以固定步长进行同步)

不适合放在 FixedUpdate 里的任务:(需要以帧为单位或更低延迟处理)

  • 输入读取
  • 渲染/摄像机跟随
  • UI 更新
  • 动画(通常在 Update 或 LateUpdate)

3. 小球接触回弹效果的实现

使用这样的代码无法达到效果,原因可能是方向计算错误,使用 -rb.velocity 并不等同于基于碰撞法线的反射,小球可能沿着错误的方向被推动。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
private void OnCollisionEnter2D(Collision2D other)
{
    if (other.transform.CompareTag("Ball"))
    {
        Rigidbody2D rb = other.gameObject.GetComponent<Rigidbody2D>();
        _dir = rb.velocity.normalized;

        rb.AddForce(-_dir * strength);
    }
}

这里应该使用碰撞接触点的法线解决:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
private void OnCollisionEnter2D(Collision2D other)
{
    if (other.transform.CompareTag("Ball"))
    {
        Rigidbody2D rb = other.gameObject.GetComponent<Rigidbody2D>();
        _dir = other.GetContact(0).normal;  // 碰撞点的法线

        rb.AddForce(-_dir * strength);
    }
}

4. 使用 EventSystem 实现玩家和电脑的得分

感觉实现的方式和按钮很像。

代码解释:

  • TriggerEvent 本质上是 UnityEvent<BaseEventData> 的一个别名/子类,可以在 Inspector 或代码里注册接收 BaseEventData 的回调
  • 构造一个 BaseEventData ,并把当前 EventSystem 作为来源,callback 可以读取这个 eventData 来获取上下文
  • 通过 scoreTrigger.Invoke 触发已注册的所有回调,传入 eventData
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public class ScoringArea : MonoBehaviour
{
    [SerializeField] private EventTrigger.TriggerEvent scoreTrigger;
    private void OnCollisionEnter2D(Collision2D other)
    {
        if (other.gameObject.CompareTag("Ball"))
        {
            BaseEventData eventData = new BaseEventData(EventSystem.current);
            scoreTrigger.Invoke(eventData); // 调用已注册的eventData
        }
    }
}

复盘

教程中使用了父类 Paddle 和两个子类PlayerPaddleComputerPaddle的方法,一些公共的代码,如获取组件等放在父类中,在两个子类中分别写自己的逻辑。在C++的项目里经常会用到继承的方法,但是在做Unity项目的时候我经常会忘记使用,在以后的实践中要提醒自己在合适的时机多多使用。

代码复用

本游戏中可复用的代码已在“问题和解决方法”部分提及:

  • 小球回弹的实现
  • 使用 EventSystem 灵活实现多个函数的回调

小行星

教程参考及素材来源油管-Zigurous

源码on Gitee

游戏简介

《小行星》(Asteroids)是由 Atari 公司于 1979 年推出的一款经典街机射击游戏,以其简洁的矢量图形和紧张刺激的玩法成为电子游戏史上的里程碑之作。

核心玩法

  • 玩家控制一艘小型飞船,在无重力的太空中自由移动(可推进、旋转、射击)
    • 按下 W键/ 上箭头 或者 S键/下箭头 可以旋转
    • 按下 A键/ 左键头D键/右箭头可以左/右推进
    • 按下 空格 或者点击 鼠标左键 可以发射子弹
  • 屏幕中不断出现大小不一的“小行星”,它们会从边缘进入并随机漂移
  • 目标:用激光炮击碎所有小行星
    • 大/中行星被击中后会分裂成更小的碎片(例如:大 → 中 → 小 → 消失)
  • 失败:
    • 飞船碰到小行星会立即毁灭(通常有有限生命次数)
  • 其他特点:
    • 飞船有动量,即使停止推进也会继续滑行,需反向推进减速

游戏/功能拆解

内容

场景摆放、玩家、行星、爆炸特效

逻辑

  1. 玩家移动和射击
  2. 小行星随机生成和移动
  3. 小行星被玩家击中后分裂,直至消失
  4. 玩家碰到小行星后,这一局失败
  5. 显示当前的分数
  6. 全部局数用尽,游戏结束

开发步骤

  1. 场景设置
  2. 玩家部分
    • 移动
    • 射击
  3. 小行星部分
    • 行星预制体(不同的形状、大小、质量、初始角度)
    • 生成行星
    • 实现分裂/销毁逻辑
  4. 一局的结束
    • 玩家死亡与复活
    • 玩家的爆炸特效
  5. 显示UI分数和玩家剩余的生命数
  6. 全部局数用尽,游戏结束

遇到的问题及解决方法

1. 物体间物理碰撞层的设置

通过 Project Setting 中的 Physics 2D 来忽略玩家和子弹、子弹与子弹之间的碰撞检测。

2. 小行星初始状态的设置

因为此项目中的小行星有不同的变体,包括大小、图片、出现时的初始角度等多个参数都不同,所以采取的策略时在小行星的预制体中就将其全部设置好。这样做的好处是在小行星的 Spawner.cs 脚本里,预制体可以直接生成,做到了脚本的各司其职。Asteroid.cs 负责小行星本身的属性,Spawner.cs 脚本负责小行星的生成。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class Asteroid : MonoBehaviour
{
    [SerializeField] private Sprite[] sprites;
    [SerializeField] private float size = 1f;
    [SerializeField] private float speed = 1f;
    public float minSize = 0.5f;
    public float maxSize = 1.5f;
    private SpriteRenderer _sr;
    private Rigidbody2D _rb;

    private void Awake()
    {
        _sr = GetComponent<SpriteRenderer>();
        _rb = GetComponent<Rigidbody2D>();
    }

    private void Start()
    {
        _sr.sprite = sprites[Random.Range(0, sprites.Length)];
        transform.eulerAngles = new Vector3(0, 0, Random.value * 360);

        //size = Random.Range(minSize, maxSize);

        transform.localScale = Vector3.one * size;
        _rb.mass = size; // mass随着size变化

    }

    public void SetTrajectory(Vector2 dir)
    {
        _rb.AddForce(dir * speed);
    }

小行星初始的旋转角度

使用 Random.value,这个值在0~1之间,小行星在 z轴 旋转,所以可以简单处理为 transform.eulerAngles = new Vector3(0, 0, Random.value * 360);

小行星的size部分处理

不能放在 Asteroid.cs 里,因为这个脚本后面会进行小行星分裂的逻辑,如果在 Start() 里对 size 取随机值,会导致分裂的逻辑错误。随机size的逻辑放在 Spawner.cs 里处理。这是我在这个项目遇到的一个大坑。

3. 小行星生成和移动的实现思路

小行星生成在以屏幕为圆心,一定半径的圆内。它们向着圆心运动。为了增加随机性,增加了朝向圆心的偏移角度。因为是沿着 z轴 进行偏移的,所以可以用 Quaternion.AngleAxis(float angle, Vector3.forward) 实现。

在小行星的移动上,本身方法由 Asteroid.cs 负责,Spawner.cs 通过获得一个Asteroid 的实例化对象调用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
 private void Start()
 {
     InvokeRepeating(nameof(Spawn), spawnInterval, spawnInterval);
 }

// 行星生成在离spawner为圆心的一定半径的位置
private void Spawn()
{
    Vector2 spawnOffset = Random.insideUnitCircle * spawnRadius;
    Vector2 spawnPos = (Vector2)transform.position + spawnOffset;   // 以Spawner为圆心、一定半径内的某个随机位置
    Vector2 dir = ((Vector2)transform.position - spawnPos).normalized;

    // 在方向上加上一个角度的偏移量
    float variance = Random.Range(-trajectoryAngle, trajectoryAngle);
    Quaternion rotation = Quaternion.AngleAxis(variance, Vector3.forward);

    GameObject obj = Instantiate(asteroidPrefab, spawnPos, Quaternion.identity);
    Asteroid asteroid = obj.GetComponent<Asteroid>();
    asteroid.size = Random.Range(asteroid.minSize, asteroid.maxSize);	// 在此处处理生成小行星的随机尺寸问题
    asteroid.SetTrajectory(rotation * dir);
}

需要注意的是,最终小行星的轨道方向是 rotaton * dirrotation本身是一个四元数,这里将旋转作用于向量,即 旋转后的向量 = Quaternion * 原始向量

Quaternion 必须在乘号左边!

其中的原理在数学的相关学习中再做详尽探讨,此处不再赘述。

关于 Quaternion的补充

应该避免直接修改 Quaternion 的x, y, z, w 分量,因为它们不是角度而是复杂的复数数学,直接修改会导致异常。

始终使用Unity提供的API!

常用的API

场景 方法
旋转一个向量 rotatedVector = quaternion * vector;
让物体看向某个方向 Quaternion.LookRotation(vector);
把(0,90,0)转为四元数 Quaternion.Euler(0, 90, 0);
获取四元数的度数 quaternion.eulerAngles;

4. 玩家无敌时间的设计思路

通过更改玩家物理碰撞层的方法 LayerMask.NameToLayer(layerName);实现,非常简单明了,再使用 Invoke 来设定需要等待的时间即可。

5. 粒子系统的设置

因为我在UI的逻辑中使用了 Time.scaleTime 来控制游戏的结束和开始,为了避免粒子系统拖到下一轮开始游戏的那一瞬间播放,需要在面板里将粒子系统的 Delta Time 设置为 Unscaled

复盘

这个项目中需要大量使用关于旋转的方法,常用的API要熟练使用。另外相关的数学知识也需要补充一下。

这个项目还有两个方法值得注意,可以经常尝试使用:

通过传参获得另一个脚本中的对象

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
//======= GameManager.cs =======
// 函数的定义放在这里,因为里面很多用到的参数由GameManager统一管理
public void AsteroidDestroyed(Asteroid asteroid)
{
    if (asteroid.size <= 0.75f)
    {
        score += 5;
    }
    else if (asteroid.size <= 1.3f)
    {
        score += 2;
    }
    else
    {
        score += 1;
    }

    Vector2 pos = asteroid.transform.position;
    explosion.transform.position = pos;
    explosion.Play();
}

//========== Asteroid.cs =========
// 方法的调用由Asteroid类本身管理,在小行星碰撞后调用
private void OnCollisionEnter2D(Collision2D other)
{
    if (other.gameObject.CompareTag("Bullet"))
    {
        if (size * 0.5f >= minSize)
        {
            CreateHalf();
            CreateHalf();
        }

        Destroy(other.gameObject);
        Destroy(gameObject);

        GameManager.Instance.AsteroidDestroyed(this);
    }
}

在一个脚本中调用另一个脚本中的函数

可以通过GameObject或者直接通过另一个类的游戏物体来获得:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
//=========== Spawner.cs ============
// 通过实例化Asteroid对象来调用它的SetTrejactory方法
private void Spawn()
{
	// Other codes

    GameObject obj = Instantiate(asteroidPrefab, spawnPos, Quaternion.identity);
    Asteroid asteroid = obj.GetComponent<Asteroid>();
    asteroid.size = Random.Range(asteroid.minSize, asteroid.maxSize);   // 在此处处理生成小行星的随机尺寸问题
    asteroid.SetTrajectory(rotation * dir);
}

代码复用

1. 玩家无敌时间的设计

通过更改物理碰撞层,配合Invoke的使用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private void OnCollisionEnter2D(Collision2D other)
{
    if (other.gameObject.CompareTag("Asteroid"))
    {
        GameManager.Instance.lives--;
        gameObject.SetActive(false);

        Invoke(nameof(Respawn), respawnTime);
    }
}

private void Respawn()
{
    gameObject.SetActive(true);
    gameObject.transform.position = Vector3.zero;
    gameObject.layer = LayerMask.NameToLayer("No Collision");

    Invoke(nameof(ResetLayer), invulnerableTime);
}

private void ResetLayer()
{
    gameObject.layer = LayerMask.NameToLayer("Player");
}
2. 清除场景中所有物体

在《飞翔的小鸟》和《小行星》的游戏重置中,都用到了同样的方法,使用 FindObjectsOfType 通过数组遍历场景中所有需要查找的物体进行销毁即可。

1
2
3
4
5
Asteroid[] asteroids = FindObjectsOfType<Asteroid>();
for (int i = 0; i < asteroids.Length; i++)
{
    Destroy(asteroids[i].gameObject);
}
Licensed under CC BY-NC-SA 4.0
最后更新于 Jan 24, 2026 10:28 +0800
发表了13篇文章 · 总计9万0千字
本博客已运行
使用 Hugo 构建
主题 StackJimmy 设计