Featured image of post 【Unity】从一个入门级小游戏学习商业化代码的写法

【Unity】从一个入门级小游戏学习商业化代码的写法

从一个入门级小游戏,学习专业独立游戏开发者如何架构和写一个项目

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

项目简介

《Lua Lander》是一个受经典游戏《Lunar Lander》启发的 2D 物理小游戏,核心玩法是操控小型飞船在外星地表安全着陆。

经典街机游戏《lunar Lander》的主要机制是:

  • 使用推力与旋转控制飞船方向与速度
  • 避免高速或倾斜角度着陆,否则会坠毁
  • 在指定着陆点轻柔降落可获得更高分数
  • 需要管理速度、角度与燃料

《Lua Lander》是油管游戏开发大佬Code Monkey在此基础上做的复刻和扩展。除了保留以上核心机制外,项目的扩展玩法包括:

  • 更复杂的着陆判定(速度+角度)
  • 多种着陆平台 + 分数倍率系统
  • 燃料系统(飞行成本)
  • 金币收集系统(额外得分)
  • 推进器视觉特效(粒子系统 + 事件驱动)

此外,项目还做了系统级的扩展

  • 完整 UI 系统
  • 游戏状态机(Start → Playing → GameOver)
  • 多关卡系统(Prefab 关卡,而非多 Scene)
  • 关卡预览(Cinemachine + 自定义 2D Zoom)
  • 多输入系统(键盘 + 手柄 + 触屏)
  • 主菜单、暂停菜单、游戏结束场景
  • 音效与音乐系统

学习目标

Code Monkey喜欢做的游戏一般系统复杂、项目规模大、对可维护性要求高,所以他的教程不管是哪个级别的都会采用更专业的结构,展现如何将clean code、good practice融合进他的项目之中。我希望从这个项目中不仅能学到他解决问题的思路、怎么写干净的代码,还能学到架构项目的正确方式、真正的游戏工程师思维,为以后自己做复杂项目打下基础。

所以在这个项目中我将采用步步跟随的方式,总结他的方法和思路,以便可以用在以后自己的项目中。

开发步骤

整个游戏大概按照下面步骤开发:

  • post processing处理(可以放在任意步骤)

  • ander的开发

    • lander的运动
    • 动力的视觉效果
  • terrain的开发

    • 使用sprite shape
  • 摄像机跟随

    • cinemachine
  • 背景的设置

  • landing

    • landing的判定
    • landing在平台上
  • landing pad预制体

    • prefab variant (大小不同、得分倍率不同的平台)
    • 得分倍率动态指示
  • 燃料机制的实现

    • 设置燃料额度
    • lander拾取燃料
    • 金币(与燃料类似)
  • GameManager

  • UI

    • 数据统计板
    • 加速指示箭头
    • 燃料进度条
    • 游戏结束UI
      • 结果和数据统计面板
      • Restart按钮
  • 游戏状态设置

    • Start
    • Restart
    • Gameover
  • 爆炸特效

  • 新关卡

    • 使用prefab的方法
  • 全景地图

  • Input System

    • button/ Key
    • Touch
    • Joy Stick
  • Main Menu Scene

  • PauseUI

    • 点击按钮
    • 按下 Esc 键
  • GameOverScene

    • total score
    • 返回主菜单按钮
  • SoundManager 和 MusicManager

    • 保持背景音乐在关卡切换后仍然持续播放
    • 音量按钮
  • Polish

思路及关键步骤

PostProcessing

可以通过 Volume => Global Volume => Add Override 来对游戏中的图片和 Game 界面的背景进行视觉效果的调整。虽然此项目只调整了 Bloom 和 Vignette,但是还有大量的参数可调,到时可以根据需要去探索使用。

visual 与 logic 分离

创建一个空游戏物体,将sprite作为子物体挂载在它下面,只负责渲染。而这个空游戏物体会挂载所有的脚本/行为组件,负责所有的逻辑。如果以后有更多的视觉效果(比如粒子特效等),也都同样以单独的子物体的方式挂载。这种做法是我第一次看到的。这样做的好处是:

  • 逻辑与表现解耦,代码更干净

    如果逻辑脚本和 Sprite Renderer 在同一个 GameObject 上,你的脚本就会天然依赖视觉组件。分开以后,逻辑脚本只处理数据、状态、行为;子物体负责显示,不影响逻辑。这是“解耦”的核心。

  • 更容易替换视觉资源(Sprite → 动画 → 3D 模型)

    如果视觉和逻辑绑在一起,你要改脚本、改结构、改引用。如果分开,视觉只是子物体,那么可以直接把子物体删掉换一个新的,而逻辑脚本完全不用动。这对做长期的、需要不断扩展的项目来说非常重要。

  • 更容易做动画、旋转、缩放

    视觉作为子物体,你可以:

    • 让视觉旋转,而逻辑不旋转(例如子弹旋转但逻辑方向不变)
    • 让视觉抖动、缩放,而逻辑位置保持稳定
    • 让视觉做动画,而逻辑不受影响
  • 碰撞体、逻辑中心点更好控制

    逻辑物体通常需要一个“干净的 Transform”来作为移动中心/ 碰撞中心/ 旋转中心/ AI逻辑参考点;而视觉往往不是严格的对齐中心,还需要特别处理。

  • 更容易做 Prefab 复用

    逻辑 prefab 不变,视觉 prefab 可以换皮肤、换主题、换颜色、换模型等。

代码要尽可能地清晰

  • 访问修饰符要写出来

    默认的访问修饰符(如类中的private和接口中的public),Unity并不会显示,也要写上

Input的方法

在Unity6 中新旧两套 Input 的方法都可以用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
private void Update()
{
    // 老版本
    if(Input.GetKey(KeyCode.UpArrow))
    {
        Debug.Log("Up");
    }
    // Unity6
    if(Keyboard.current.UpArrowKey.isPressed)
    {
         Debug.Log("Up");
    }
}

Input放的位置

一般来说,需要放在 Update() 里面,因为 Update会每帧监听是否有输入。但是在这个项目中,可以直接放在 FixedUpdate() 里,因为我们使用的是 isPressed,就是说如果一直有按键的动作(isPressed/ GetKey),那么即使在固定帧率(FixedUpdate)监听的情况下,也是可以被检测到的。但是如果使用监听按下/抬起键一瞬间的API(GetKeyDown/ GetKeyUp),那么建议放在 Update 中,因为放在FixedUpdate中可能会错过。

此外,关于物理特性的更新,一律需要放在 FixedUpdate 中。

如果不得不放在 Update 中,一定要乘以 Time.deltaTime,以确保在不同帧率的计算机上移动的速度是一样的。

虽然在 FixedUp 里无需加上 Time.fixedDeltaTime,但是加上也无所谓

坐标系的设定

由于采用了visual和logic分离的方式,所以要确保坐标系设置为 Pivot 和 Local。因为如果设置为 Center,那么中心点将是子物体和父物体之间的中心位置,设置为 Pivot,中心会是各个物体本身的中心点。

而在此项目中我们希望按下 向上箭头键 后,lander朝着自己本身的上方移动,那么需要将坐标参考设置为 Local。如果设置为 Global,那么不管lander本身朝向何方,我们按下 向上箭头键,lander会永远朝着屏幕上方移动。

Landing 的实现思路

landing的判定有两个层面:

  • 飞船需要以保持直立的状态着陆
  • 飞船需要较为轻柔地着陆

从着陆的轻重层面,使用 Collision.relativeVelocity)来判断。这是Collision的一个属性,描述两个碰撞对象的相对线性速度(只读)。

着陆角度判定的实现,则使用两个向量(物体本身的向上向量transform.up和世界坐标的向上向量Vector2.up)的点乘值来进行判断。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
//============ Lander.cs ===========
private void OnCollisionEnter2D(Collision2D other)
{
    float softLandingVelocityMagnitude = 3f;	// 根据游戏表现的数值设定
    if (other.relativeVelocity.magnitude > softLandingVelocityMagnitude)
    {
        print("Landing too hard!");
        return;
    }

    float minDotVector = 0.92f;	// 根据游戏表现的数值设定
    float dotVector = Vector2.Dot(Vector2.up, transform.up);
    print(dotVector);
    if (dotVector < minDotVector)
    {
        print("landing angle too steep!");
        return;
    }
    print("Safe landing!");
}

如何设定某些判定的值?

如果某些值不知道设置多少合适,可以在 console 中打印出来,然后在游戏中看不同的情况显示不同的值来决定。比如此项目中,关于landing/ crash的判定就是这样做的。

不要使用字符串来识别游戏物体

字符串只能用在文本中,不要用它来查找物体或者使用Unity自带的Tag,因为非常容易出错,比如大小写没搞清、拼错或者不当心在名字后带了一个空格,都会被判定为查找失败。

使用脚本来查询物体。

1
2
3
4
5
// 识别是否是landing pad
if (other.gameObject.TryGetComponent<LandingPad>(out LandingPad landingPad))
{
    print("landing pad");
}

推力特效的实现

同样出于解耦的考虑,lander.cs 里不应该有 visual 相关的代码,所以使用事件来处理。

另外,粒子系统和 transform.position 一样是只读的,意味能无法直接修改的,所以需要通过 EmissionModule 来间接得修改:

1
2
ParticleSystem.EmissionModule emissionModule = leftThrusterParticalSystem.emission;
emissionModule.enabled = false;

Time.deltaTimeTime.fixedDeltaTime

deltaTimeUpdate() 中的使用

• 目的:补偿可变帧率 • Update() 的调用频率取决于帧率(30fps、60fps、144fps 等都不同) • deltaTime 记录每帧的实际时间间隔 • 通过乘以 deltaTime,确保物体在不同帧率下以相同的实际速度移动

Time.fixedDeltaTimeFixedUpdate() 中的使用

• 目的:使用固定时间步长进行物理模拟 • FixedUpdate() 以固定间隔调用(默认 0.02 秒,即 50 次/秒),不受帧率影响 • Time.fixedDeltaTime 是这个固定值(通常是常量) • 物理引擎需要固定时间步长来保证模拟的稳定性和可预测性

两者的关键区别

Update() + deltaTime FixedUpdate() + fixedDeltaTime
时间间隔 可变(取决于帧率) 固定(默认 0.02 秒)
作用 补偿帧率差异 保证物理模拟稳定
适用场景 渲染、输入、非物理逻辑 物理计算、Rigidbody 操作

EventHandler

因为这个项目规模较小,且 GameManager 统管所有 游戏数据,而且 Lander 又是游戏的核心,所以在 Lander.cs 中使用 GameManager的单例没有问题(LanderGameManager紧密耦合)。另一种策略是,仍然使用监听的方式,通过 EventHandlerGameManager中的数据根据在 Lander.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
33
34
35
36
//=============== Lander.cs ===============
private void OnTriggerEnter2D(Collider2D other)
{
    // ...

    if (other.gameObject.TryGetComponent(out CoinPickup coinPickup))
    {
        OnCoinPickup?.Invoke(this, EventArgs.Empty);
        coinPickup.DestroySelf();
    }
}

//============== GameManager.cs =============
public class GameManager : MonoBehaviour
{
    private int score;

    [SerializeField] private Lander lander; 

    private void Start()
    {
        lander = lander.GetComponent<Lander>(); // 因为是获取另一个无关联的游戏物体的组件,所以放在Start里
        lander.OnCoinPickup += Lander_PickupCoin;
    }

    private void Lander_PickupCoin(object sender, EventArgs e)
    {
        AddScore(500);
    }

    private void AddScore(int addScoreAmount)
    {
        score += addScoreAmount;
        print(score);
    }
}

其中 Lander_PickupCoin 中的参数 object senderEventArgs e 即使在方法体内没有被使用,也必须保留在参数列表中。原因是:

  1. 匹配事件签名:lander.OnCoinPickup 事件期望一个特定格式的处理器方法
  2. C# 事件模式:这是 .NET 的标准事件处理器模式,必须匹配 EventHandler 委托的签名

如果移除这些参数,代码将无法编译。

参数的用途:

object sender:告诉你是哪个对象触发了事件(这里是 lander 实例) • EventArgs e:传递事件数据(比如捡到的金币价值)

如果需要明确表示“不需要使用这些参数”,可以用“弃元(Discards)”的写法表示:

1
2
3
4
private void Lander_PickupCoin(object _, EventArgs _)
{
    AddScore(500);
}

EventHandler 中使用参数的例子

比如,在“根据飞船着陆质量时叠加不同的分数”这一机制的实现上:

 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
//========== Lander.cs ==========
public event EventHandler<OnLandedEventArgs> OnLanded;     // 不同着陆效果有不同的分数,所以给EventArgs传入一个分数的参数
public class OnLandedEventArgs : EventArgs
{
    public int score;
}

// 飞船在平台上的着陆
private void OnCollisionEnter2D(Collision2D other)
{
	// ...

    int score = Mathf.RoundToInt(landingSpeedScore + landingAngleScore) * landingPad.GetScoreMultiplier();
    print("score: " + score);
    OnLanded?.Invoke(this, new OnLandedEventArgs	// 在Invoke时传入score参数
    {
        score = score,
    });
}

//============ GameManager.cs ===========
public class GameManager : MonoBehaviour
{
    // ...

    // 实际使用score参数
    private void Lander_OnLanded(object sender, Lander.OnLandedEventArgs e)
    {
        AddScore(e.score);
    }
}

临时给字体添加颜色的方法

1
 titleTxtMesh.text = "<color=#ff0000>CRUSH!</color>";

使用状态机来设置游戏的状态

enum设置几个游戏状态,然后和按键结合。这个过程在Lander/ Player脚本中完成,还是非常简便的。

 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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
//============ Lander.cs ============
public enum State
{
    WaitingToStart,
    Normal,
    Gameover
}

 private void FixedUpdate()
 {
     switch(state)
     {
         default:
         case State.WaitingToStart:
             if (Keyboard.current.upArrowKey.isPressed ||
                Keyboard.current.rightArrowKey.isPressed ||
                Keyboard.current.leftArrowKey.isPressed)
             {
                 // 如果任意的键按下
                 // 重力设为正常并跳转到游戏状态
                 landerRigidbody2D.gravityScale = GRAVITY_NORMAL;
                 SetState(State.Normal);
             }
             break;

         case State.Normal:
             if (fuelAmount <= 0)
                 return;

             if (Keyboard.current.upArrowKey.isPressed ||
                Keyboard.current.rightArrowKey.isPressed ||
                Keyboard.current.leftArrowKey.isPressed)
             {
                 ConsumeFuel();
             }

             if (Keyboard.current.upArrowKey.isPressed)
             {
                 landerRigidbody2D.AddForce(force * transform.up * Time.fixedDeltaTime);
                 // 播放消息
                 OnUpForce?.Invoke(this, EventArgs.Empty);
             }

             if (Keyboard.current.leftArrowKey.isPressed)
             {
                 landerRigidbody2D.AddTorque(turnSpeed * Time.fixedDeltaTime);
                 OnLeftForce?.Invoke(this, EventArgs.Empty);
             }

             if (Keyboard.current.rightArrowKey.isPressed)
             {
                 landerRigidbody2D.AddTorque(-turnSpeed * Time.fixedDeltaTime);
                 OnRightForce?.Invoke(this, EventArgs.Empty);
             }
             break;
         case State.Gameover:
             break;
     }      
 }

爆炸特效的实现

同样通过事件的方法完成。但是需要注意的是,它(OnLanded)的注册并没有像推力特效那样直接注册在 Awake 中,而是注册在 Start 中。因为以下三个原因:

  • 一次性事件:整个游戏过程只触发一次
  • 可能依赖其他系统:爆炸效果、游戏状态管理等可能需要在其他对象的 Awake() 中初始化
  • 不需要立即准备:着陆不会在游戏开始的瞬间发生

总结将爆炸特效放在 Start 中的好处

  1. 代码组织:将初始化逻辑(高频事件)和游戏逻辑(一次性事件)分开
  2. 避免潜在问题:如果其他脚本的 Awake() 中也订阅了 OnLanded,使用 Start() 可以确保订阅顺序更可控

点击按钮的操作

项目中没有使用“在面板中绑定按钮”的方法,而是直接写在代码中:

1
2
3
4
5
6
7
8
9
[SerializeField] private Button nextBtn;

private void Awake()
{
    nextBtn.onClick.AddListener(() =>	// 使用lambda表达式写法,因为没有参数、没有返回值,这样写最简便
    {
    	SceneManager.LoadScene(0);
    });
}

在新增了关卡后,使用一个回调函数来决定,点击按钮是进入下一关还是重玩这一关。用Action来存储函数,而不是使用它的事件功能,这种做法很灵活。

 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
//=========== LandedUI.cs ============
public class LandedUI : MonoBehaviour
{
    // ...
    
    [SerializeField] private Button nextBtn;

    private Action nextBtnClickAction;

    private void Awake()
    {
        nextBtn.onClick.AddListener(() =>
        {
            nextBtnClickAction();
        });
    }

    private void Start()
    {
        Lander.Instance.OnLanded += Lander_OnLanded;

        Hide();
    }

    private void Lander_OnLanded(object sender, Lander.OnLandedEventArgs e)
    {
        if(e.landingType == Lander.LandingType.Success)
        {
            titleTxtMesh.text = "SUCCESSFUL LANDING!";
            nextBtnClickAction = GameManager.Instance.GoToNextLevel;	// 根据不同的情况决定回调哪个函数
        }
        else
        {
            titleTxtMesh.text = "<color=#ff0000>CRUSH!</color>";
            nextBtnClickAction = GameManager.Instance.RetryLevel;
        }

       // ...
    }
}

Level number 不变化的处理

levelNumber设置为普通变量,因为在场景重载时会销毁其中的 GameManager,所以会导致场景默认值仍为1,无法进入下一场景的情况。

教程中使用的处理方式是将 levelNumber设置为static,这样它保留在内存中,当场景中所有的东西被新建时,新的GameManager会重新读取到它,从而加载到下一关。

在关卡开始时展示全景地图

因为每一关的地图都不一样,所以这个脚本需要挂在每一关上。使用控制CinemachineCamera的方法来切换游戏未开始时和游戏开始时的跟随对象。

由于 Unity 的 Cinemachine 2D(CinemachineVirtualCamera + Framing Transposer 2D)本身没有zoom的功能,所以需要自己写脚本来实现。

而在3D的模式下,CinemachineVirtualCamera 有内建的 Lens → Field of View(FOV)或 Orthographic Size 控制,可以直接当作 Zoom 使用。

新建一个空游戏物体 CinemachineCamera2D 挂载脚本

 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
//============== CinemachineCameraZoom2D.cs ===============
public class CinemachineCameraZoom2D : MonoBehaviour
{
    private const int NORMAL_ORTHOGRAPHIC_SIZE = 10;

    public static CinemachineCameraZoom2D Instance { get; private set; }

    [SerializeField] private CinemachineCamera cinemachineCamera;

    private float targetOrthographicSize = 10f;

    private void Awake()
    {
        Instance = this;
    }

    private void Update()
    {
        cinemachineCamera.Lens.OrthographicSize = targetOrthographicSize;
    }

    public void SetTargetOrthographicSize(float targetOrthographicSize)
    {
        this.targetOrthographicSize = targetOrthographicSize;
    }

    public void SetNormalOrthographicSize()
    {
        SetTargetOrthographicSize(NORMAL_ORTHOGRAPHIC_SIZE);
    }
}

在 GameLevel 里设置放大的值,使得每一关的设置都很灵活

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
//============== GameLevel.cs ===============
public class GameLevel : MonoBehaviour
{
    [SerializeField] private float zoomedOutOrthographicSize;   // 这个值放在GameLevel里,这样每个level都可以自由设置

    public float GetZoomOutOrthographicSize()
    {
        return zoomedOutOrthographicSize;
    }
    
    // ...
}

在 GameManger 中具体设置

在关卡刚加载、玩家还未开始游戏时让 Cinemachine zoom out,显示全景;当玩家按下按键游戏开始时,设置Cinemachine的镜头范围恢复正常。

 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
//============== GameManager.cs ==============
public class GameManager : MonoBehaviour
{
 	// ...

    private void LoadCurrentLevel()
    {
        foreach (GameLevel level in gameLevelList)
        {
            if (level.GetLevelNumber() == levelNumber)
            {
                GameLevel spawnedLevel = Instantiate(level, Vector3.zero, Quaternion.identity);
                Lander.Instance.transform.position = spawnedLevel.GetLanderStartingPos();

                // 用 cinemachine 展示地图
                // 本质就是将cinemachine的tracking target设置到地图中心
                // 而不是一开始就跟随lander
                cinemachineCamera.Target.TrackingTarget = spawnedLevel.GetCameraStartTargetTransform();
                // zoom out
                CinemachineCameraZoom2D.Instance.SetTargetOrthographicSize(spawnedLevel.GetZoomOutOrthographicSize());
            }
        }
    }

    private void Lander_OnStateChange(object sender, Lander.OnStateChangeEventArgs e)
    {
        // ...

        // 在游戏正常启动后,cinemachine继续跟随lander
        cinemachineCamera.Target.TrackingTarget = Lander.Instance.transform;
        // 取消zoom out, 镜头恢复正常设置
        CinemachineCameraZoom2D.Instance.SetNormalOrthographicSize();
    }
}

在镜头切换时实现平滑效果

1
2
3
4
5
6
7
8
9
//============== CinemachineCameraZoom2D.cs ===============
private void Update()
{
    // 实现镜头转换的平滑效果,使用Lerp
    float zoomSpeed = 2f;

    cinemachineCamera.Lens.OrthographicSize = 
        Mathf.Lerp(cinemachineCamera.Lens.OrthographicSize, targetOrthographicSize, Time.deltaTime * zoomSpeed);
}

Input 的改进(支持多个输入系统)

  1. 自己创建 InputActions => Edit Asset => Action Maps,在Action Maps中为 Player 添加输入,对 player 的三种操作(up/ left/ right)绑定对应的按键:

然后在面板上勾选创建C# class,这样可以直接通过脚本控制,是Code Monkey推荐的方法,而不是使用字符串。由此,我们自己创建的InputActions生成了一个自己的C#脚本。

  1. 创建自己的GameInput脚本

新建一个空游戏物体,在该物体上挂载一个新的脚本。这个脚本作为中间层,避免其他脚本直接依赖 InputActions.cs (紧密耦合)。这样的好处有:

  • 解耦 - 游戏逻辑不直接依赖 InputActions 实现
  • 统一入口 - 所有输入通过一个地方管理
  • 易于测试 - 可以轻松模拟输入(Mock GameInput)
  • 灵活扩展 - 未来想添加输入记录、重放功能时只需修改 GameInput

扩展

旧输入系统的缺点

Unity的旧输入系统(Input.GetKey(), Input.GetAxis()等)存在以下问题:

  • 硬编码输入检测 - 代码中直接写死键位(如 Input.GetKey(KeyCode.W))
  • 难以重新绑定 - 玩家无法自定义键位
  • 多设备支持差 - 处理手柄、触摸屏等不同设备很复杂
  • 缺乏灵活性 - 输入逻辑分散在各处,难以维护
新 Input System的优势

InputActions 提供:

  • 配置驱动 - 通过可视化编辑器配置输入,无需修改代码
  • 设备无关 - 同一个Action可以同时支持键盘、手柄、触摸
  • 易于重新绑定 - 运行时动态修改键位
  • 更好的性能 - 事件驱动而非每帧轮询
良好实践

因为输入的本质也是一种实践,所以需要相应地调用 Enable 和 Disable :

当调用Enable时,内部会发生:

  • 向 Unity 的输入系统注册监听器
  • 开始接收来自硬件的输入事件(键盘、手柄等)
  • 消耗系统资源和内存

当调用Disable时,内部会发生:

  • 取消所有监听器的注册
  • 停止处理输入事件
  • 释放相关资源
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
private void Awake()
{
    // ...
    
    inputActions = new InputActions();
    inputActions.Enable();
}

private void OnDestroy()
{
    inputActions?.Disable(); // 记得清理

    // ...
}

触屏输入

GamePad 的绑定和键盘绑定没有区别。触屏输入稍有区别。

  1. 在 Canvas 中新建一个空游戏物体 TouchUI
  2. 在 TouchUI 下面创建三个 UIImage 的圆
  3. 给三个圆设置 On-Screen Button 组件
  4. 可以通过simulator来查看在不同触摸设备上的效果

JoyStick输入

不同于按键,手柄的 Action 类型要选择 Value => Vector2。

然后和按键的输入一样,在相应的脚本中添加即可。

使用 SceneLoader 来管理场景

为了避免在加载场景时使用“magic number” SceneManager.LoadScene(0);,我们使用脚本来控制场景的加载。

因为它会在全局存在,所以将之设置为一个无需继承 MonoBehavior 的静态类。注意,这样一来,类里的所有成员都必须是静态的。

此外,教程中为了避免使用 magic number 和直接使用 string,采用了将场景设置为 enum 的策略。

 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
//============ MainMenuUI.cs ============
public class MainMenuUI : MonoBehaviour
{
    [SerializeField] private Button playBtn;
    [SerializeField] private Button quitBtn;

    private void Awake()
    {
        playBtn.onClick.AddListener(() =>
        {
            SceneLoader.LoadScene(SceneLoader.Scene.GameScene);
        });

        quitBtn.onClick.AddListener(() =>
        {
            Application.Quit();
        });
    }
}

//============== SceneLoader.cs ===============
public static class SceneLoader 
{
    public enum Scene
    {
        MainMenuScene,
        GameScene
    }

    public static void LoadScene(Scene scene)
    {
        SceneManager.LoadScene(scene.ToString());
    }
}

保持背景音乐在关卡切换时不从头播放

可以通过将音乐播放的时间设置为静态的来处理。(需要跨越关卡保持统一数据的,此教程中都是通过设置为static来解决)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public class MusicManager : MonoBehaviour
{
    private AudioSource musicAudioSource;
    private static float musicTime = 0f;

    private void Awake()
    {
        musicAudioSource = GetComponent<AudioSource>();
        musicAudioSource.time = musicTime;
    }
    private void Update()
    {
        musicTime = musicAudioSource.time;
    }
}

扩展

关于事件的取消注册

在这个项目中多处用到了事件机制来实现代码的解耦,但是绝大多数情况没有进行取消注册的操作。但这并非最佳实践。

在以下情况下,不取消操作暂时是安全的:

相同的生命周期:LanderVisual 和 lander 组件在同一个 GameObject 上

1
lander = GetComponent<Lander>();  // 同一个对象

同时销毁:当 lander 爆炸时,整个 GameObject 被禁用

1
gameObject.SetActive(false); // LanderVisual 和 Lander 一起被禁用

Unity 会在对象销毁时自动清理这些引用

最佳实践是使对象在被销毁时取消注册:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
private void OnDestroy()
{
    if (lander != null)
    {
        lander.OnUpForce -= Lander_OnUpForce;
        lander.OnLeftForce -= Lander_OnLeftForce;
        lander.OnRightForce -= Lander_OnRightForce;
        lander.OnBeforeForce -= Lander_OnBeforeForce;
        lander.OnLanded -= Lander_OnLanded;
    }
}

以下情况下必须进行取消注册的操作:

  1. 订阅者生命周期比发布者短
  2. 订阅者可能被多次创建/销毁(不取消注册会导致重复订阅)
  3. 跨场景的事件

养成取消注册的习惯是更安全的做法,可以避免:

  • 内存泄漏
  • 重复触发
  • 难以追踪的 bug

应该始终遵循:注册在哪里,就在对应的清理方法中取消注册

取消注册的地方

取消注册的地方一般有 Destroy 和 Disable 两个,具体应该怎么选择?根据事件取消注册的时机选择

核心原则:配对原则

事件的注册和取消注册应该在对应的生命周期方法中配对:

注册位置 取消注册位置 使用场景
Awake() / Start() OnDestroy() 对象生命周期内只需注册一次
OnEnable() OnDisable() 对象会被重复启用/禁用

visual 和 logic 分开

在此项目里,Code Monkey将visual的所有脚本也都挂在主游戏物体上,而sprite只是一张干净的图片。这种做法适用于:

  • 小型项目
  • 视觉简单
  • 没有皮肤系统
  • 没有复杂动画
  • 没有多层视觉组件
  • 逻辑和视觉的耦合度低

还有另一种做法,是将负责visual的脚本放在sprite的子物体上,这种做法在大型项目中常用,适用于:

  • 角色有多层视觉
  • 有皮肤系统
  • 有动画状态机
  • 有 UI 绑定
  • 有粒子、特效、灯光
  • 有多人同步(视觉不参与同步)

优点:

  • 完全解耦
  • 视觉可替换
  • 逻辑可复用
  • Prefab 更模块化
  • 更适合大型项目

Polish

Code Monkey一再强调polish的重要性,我也非常认同,但是我学习这门课程主要是为了看专业独立游戏开发者如何用clean code、以模块化的方式开发松耦合的、可扩展的游戏,所以polish部分就先不做了。

关于polish的各种技巧,我计划学习一门课程去掌握。

在此记录一下code monkey在课程中所用的插件,今天我要用的时候可以参考使用:

  • Feel(Code Monkey有review视频)
  • All in 1 Sprite Shader (Code Monkey有review视频)
  • Text Animatior
  • Hot Reload
  • Code Monkey Toolkit

复盘和下一步

即使这只是一门入门课程,我还是学到了很多东西,最主要的包括:

  • visual 和 logic 分离的原则

    就这个体量的项目而言,sprite上不挂任何脚本,全部放在空游戏物体中

  • 单一职责原则

    每个类、每个函数都相对小,只负责一件事件

  • 观察者模式

    通过观察者模式进行了大量解耦,包括UI、音效等

  • 使用C#标准的EventHandler的优点

    除了可以正常命名外,还可以添加参数,有时非常有用

  • InputSystem的用法

    相较于老的输入系统而言,新的输入系统连接不同输入设备时几乎不需要改代码,很方便

  • 用prefab的方式来新增关卡

    我第一次知道这种方式。总结对比在关卡设计时使用scene和prefab:

    项目 Scene 做关卡 Prefab 做关卡
    适合的游戏类型 大型场景、剧情关卡、独立世界 多关卡、重复结构、程序生成、roguelike
    加载方式 切换场景(LoadScene) 在同一场景中实例化 Prefab
    编辑体验 直观、所见即所得 模块化、可组合
    性能 场景切换有开销 Prefab 实例化更快
    复用性 低(每个 Scene 独立) 高(Prefab 可重复使用)
    动态生成 不方便 非常方便
    多人协作 场景容易冲突 Prefab 冲突更少
    关卡数量 少量关卡 大量关卡

下一步

原版课程里还有一些Bonus的游戏机制:

  • 货物运输
  • 钥匙开关
  • 风区
  • 动态陨石
  • 大炮射击

我计划先放一下,等再学习练习一段时间后尝试自己实现。

另外,Code Monkey是我跟随学习的第一位专业游戏开发者,我会再学习2名专业游戏开发者的课程,学习他们的代码风格和对游戏整体的架构,然后尝试形成我自己的风格。

Licensed under CC BY-NC-SA 4.0
最后更新于 Feb 22, 2026 11:13 +0800
发表了13篇文章 · 总计9万0千字
本博客已运行
使用 Hugo 构建
主题 StackJimmy 设计