[{"content":"教程参考及素材来源：油管-Code Monkey\n项目简介 《Kitchen Chaos》是一款轻量版的 Overcooked 风格游戏。它是一个完整的项目，非常适合用来学习架构、模块化和事件系统。这个版本是单人厨房管理游戏，玩家扮演厨师，在限定时间内制作顾客点单的菜品。游戏节奏快、任务多，需要玩家在混乱的厨房中合理分工。\n学习目标 这个课程是油管上评分最高的免费课程，有一些没有编程经验的人甚至跟随课程之后在一年时间内上线自己首款独立游戏并赚了几十到几百万美金。这个课程里，Code Monkey同样遵循他一贯的代码设计理念，只写干净的、商业级的游戏代码。因为这个项目是中等规模的程序，所以包括的可复用的系统和架构模式十分丰富。这些我都可以复用在以后自己的游戏中。\n另外，本小站的笔记与其说是分享型的，不如说是私人定制型的，所以一些本人已经熟悉的好的设计，不会记录在笔记中；之前已经记录过的觉得好的点也不会重复记录。\n系统包括：\n玩家系统 交互系统 食材系统 厨房工作台系统 订单系统 UI 系统 架构模式包括：\n事件系统（C# Events） 接口驱动的交互（IInteractable） ScriptableObject 配置数据 状态机（Cooking State / Cutting State） 此外，由于代码整体拥有良好的可扩展性，也适合我以后做工程化扩展，可以轻松加入：\n更多菜品 更多工作台 多人联机（未来你做网络版时非常有用） 难度曲线 厨房布局随机生成 核心玩法结构 接收订单（Orders）\n顾客会随机生成订单（如汉堡） 玩家需要根据订单要求准备对应的食材组合 订单有时间限制，超时会扣分或失去奖励 处理食材（Ingredients）\n食材需要经过不同的处理方式： 切菜（Chopping） 烹饪（Cooking / Frying） 组合（Assembling） 每种处理方式都有对应的工作台（Counter） 厨房操作（Kitchen Stations）\n课程中会实现多个交互点，这些系统都通过事件系统 + ScriptableObjects实现解耦，是课程的亮点，需要重点学习\n切菜台：按住键切到进度条完成 炉灶/平底锅：加热食材，有烧焦风险 垃圾桶：丢弃错误食材 盘子台：获取盘子 上菜窗口：提交完成的菜品 玩家控制（Player Movement \u0026amp; Interaction）\n课程使用**接口（Interfaces）**统一交互逻辑，让所有可交互物体共享同一套规则。\n玩家可以移动、拾取、放下、交互。 得分系统（Scoring）\n完成订单 → 加分 超时或错误 → 扣分 连续完成订单 → Combo 奖励 开发步骤 项目设置\nPost Processing\n角色的控制与动画\n角色控制 角色动画 Cinemachine Input System（输入部分代码重构）\n碰撞检测\nCast 柜台\n空柜台 交互（Raycast + LayerMask, events） 选中柜台的视觉效果 厨房物品\n在空柜台上的生成位置 Scriptable Objects 厨房物体的父对象 玩家与厨房物品的交互\n捡起 放下 不同类型的柜台（prefab variant）\n空柜台（重新做成预制体变量） container counter cutting counter 切菜的实现（整颗生成对应的片片） 切菜进度及UI 切菜进度条始终正对玩家 Trash Counter 思路及关键步骤 输入和逻辑分离 这样的好处在此项目中，最显而易见的是在后期input重构时，逻辑部分可以在不同平台下复用。\n总结这种策略的好处：\n提高代码可读性与可维护性：将输入处理（如按键检测）与游戏逻辑（如角色移动、状态更新）分开，使代码结构更清晰，便于理解和修改 增强模块化与复用性：输入处理和核心逻辑解耦后，逻辑部分可在不同平台或输入方式（如手柄、触屏）下复用，只需更换输入模块 便于测试与调试：逻辑部分不依赖具体输入，可独立进行单元测试；调试时也更容易定位问题是出在输入还是逻辑 支持更灵活的架构设计：为后续引入ECS、状态机或命令模式等高级架构打下基础，提升项目扩展性 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 private void Update() { Vector2 inputVector = new Vector2(0, 0); if (Input.GetKey(KeyCode.W)) { inputVector.y = 1; } if (Input.GetKey(KeyCode.S)) { inputVector.y = -1; } if (Input.GetKey(KeyCode.A)) { inputVector.x = -1; } if (Input.GetKey(KeyCode.D)) { inputVector.x = 1; } inputVector = inputVector.normalized; // 为了让对角线的速度也是1 Vector3 moveDirection = new Vector3(inputVector.x, 0, inputVector.y); transform.position += moveDirection * moveSpeed * Time.deltaTime; } 使用 Slerp 处理角色的转向 Lerp 和Slerp 都可用于插值计算，两者的关键区别如下：\n特性 Lerp Slerp 插值路径 直线（欧氏空间） 球面大圆弧（旋转空间） 常用于 位置、颜色、缩放等 旋转（Quaternion） 速度均匀性 线性均匀 角速度均匀（更自然的旋转） 性能 更快 稍慢（涉及三角函数计算） 角色的动画 处理动画的注意点 点击“录制”后放置关键帧才有效。另外，如果要在不动物体的情况下设置关键帧，需要改变一下物体的位置再还原，否则无法添加关键帧。\n动画的名称是区分大小写的，而且动画的部分必须使用字符串名称，无法绕开，所以要十分小心拼写、大小写等。\n动画中的 visual 与 logic 分离原则 根据 visual 和 logic 分离原则，将 animator 添加到 PlayerVisual子物体上。根据松耦合的原则，不要将动画的逻辑直接放到 Player.cs 中，而是创建 PlayerAnimator.cs，挂在PlayerVisual子物体上。\n拓展：CInemachine工作原理 基于“虚拟相机（Virtual Camera） + 主相机（Main Camera）”的架构：\n主相机（Main Camera）\n场景中通常只保留一个主相机（带有 CinemachineBrain 组件）\n它本身不直接设置位置或旋转，而是被动接收来自虚拟相机的指令\n虚拟相机（Virtual Camera）\n虚拟相机是 Cinemachine 的核心组件，代表一种“镜头意图”（如跟随玩家、聚焦目标、过场运镜等） 每个虚拟相机可以设置： Follow：自动跟随某个目标（如玩家角色） Look At：朝向某个焦点（如敌人或交互对象） Body / Aim 设置：控制相机的位置偏移（如轨道、环绕、自由跟随）和朝向逻辑（如平滑对准） Priority：决定多个虚拟相机之间的切换优先级 CinemachineBrain\n附加在主相机上，负责： 根据虚拟相机的 Priority 选择当前激活的虚拟相机 在多个虚拟相机之间进行平滑过渡（blend） 将选中虚拟相机计算出的最终位置/旋转应用到主相机 高级功能支持\nNoise（抖动）：模拟手持摄像机效果 Impulse（冲击反馈）：配合 CinemachineImpulseSource 实现爆炸、跳跃等镜头震动 Timeline 集成：与 Unity 的 Timeline 工具无缝协作，制作电影化过场动画 所以，添加了Cinemachine后，无需/不能控制主摄像机，控制Cinamachine就可以了。本项目中，Cinemachine的初始参数设置成和原来的主摄像机一样就可以了。\n输入部分的重构 为了让input和玩家的代码逻辑解耦，创建一个空的游戏物体，在其上挂载单独的脚本 GameInput.cs。这里遵循的是“单一职责原则”，一个类/函数只做一件事情。好代码原则是：管理和最小化项目的复杂度。\n同时保留新旧两套输入系统：Edit =\u0026gt; Project Settings =\u0026gt; Player =\u0026gt; Configuration =\u0026gt; Active Input Handling =\u0026gt; Both\n新建一个 Input Actions，在这里我们将管理 Player 所有的输入。\nWASD 创建Action（取名为 Move）=\u0026gt; Action Type(Value) =\u0026gt; 删掉默认的 Binding =\u0026gt; 新建选择 Up/Down/Left/Right Composite\n生成C# class 点击之前创建的 PlayerInputActions ，勾选 Generate C# class\n重构后的输入代码 使用 Input System 后重构的代码只有短短几句。用 InputSystem来控制的玩家移动都大同小异，这个脚本可以作为“积木”使用。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public class GameInput : MonoBehaviour { private PlayerInputActions playerInputActions; private void Awake() { playerInputActions = new PlayerInputActions(); playerInputActions.Enable(); } public Vector2 GetInputVectorNormalized() { Vector2 inputVector = playerInputActions.Player.Move.ReadValue\u0026lt;Vector2\u0026gt;(); inputVector = inputVector.normalized; // 为了让对角线的速度也是1 return inputVector; } } 此外，代码中的 normalized 也可以交由 Input System中的 Processors 来做，真的很周到了。\n3D的碰撞检测 在3D中一般使用Cast和Raycast来处理碰撞检测，它们的含义和使用场景有一些区别。\nRaycast（射线检测） 从某一点沿指定方向发射一条无限细、无体积的直线（射线），检测它是否与场景中的碰撞体（Collider）相交。常用于瞄准、点击检测、视线判断等。\n特点：\n只检测“线”是否碰到物体 性能开销小 无法检测空心区域或复杂形状内部 Cast（投射 / 形状检测） 发射一个有体积的几何形状（如球体、胶囊体、盒子等），沿方向移动一段距离，检测是否与碰撞体重叠。常见的类型有 SphereCast、\nCapsuleCast、BoxCast 等。用于角色移动预测和更真实的碰撞预判（比如角色跳跃前检测前方是否有障碍）。\n特点：\n考虑物体的体积和形状，比 Raycast更贴近实际碰撞 性能开销略高于 Raycast，但更精确 关于碰撞时如果同时按下两个键，玩家移动的处理 主要的思路是将速度分解，先看x轴方向能不能走，能走就走x方向的；然后再看z轴方向能不能走，能走就走z方向的。需要注意的时，由于在分解单个轴方向时矢量会变短（斜线方向矢量更大的相反情况），所以单个轴方向上的矢量仍然需要归一化，以保证分解后的速度是一样大小的。\n1 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 Vector3 moveDirection = new Vector3(inputVector.x, 0, inputVector.y); float playerHeight = 2f; float playerRadius = 0.7f; float moveDistance = moveSpeed * Time.deltaTime; // 碰撞检测 Cast bool canMove = !Physics.CapsuleCast(transform.position, transform.position + Vector3.up * playerHeight, playerRadius, moveDirection, moveDistance); if(!canMove) { // 不能移动的情况下，分解速度到每个方向上 // 只有在x轴方向能移动 Vector3 moveDirectionX = new Vector3(moveDirection.x, 0, 0); canMove = !Physics.CapsuleCast(transform.position,transform.position +Vector3.up*playerHeight,playerRadius, moveDirectionX, moveDistance); if (canMove) { moveDirection = moveDirectionX.normalized; } else { // 只能在z轴移动 Vector3 moveDirectionZ = new Vector3(0,0,moveDirection.z); canMove = !Physics.CapsuleCast(transform.position, transform.position+Vector3.up*playerHeight,playerRadius, moveDirectionZ, moveDistance); if(canMove) { moveDirection = moveDirectionZ.normalized; } else { // 一点都不能移动 } } } if(canMove) transform.position += moveDirection * moveDistance; 与柜台的交互 使用Raycast来检测玩家是否碰到了柜台。这里需要处理的细节是，记录上一次和玩家和柜台交互的方向，这样可以解决玩家必须一直运动才显示和柜台交互的问题。另外，使用LayerMask来确保玩家和柜台交互。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 private void HandleInteraction() { Vector2 inputVector = gameInput.GetInputVectorNormalized(); Vector3 moveDirection = new Vector3(inputVector.x, 0, inputVector.y); if (moveDirection != Vector3.zero) { lastInteractDirection = moveDirection; // 在运动，就更新lastInteractDirection } float interactDistance = 2f; if (Physics.Raycast(transform.position, lastInteractDirection, out RaycastHit raycastHit, interactDistance, counterLayerMask)) { if (raycastHit.transform.TryGetComponent(out ClearCounter clearCounter)) { clearCounter.Interact(); } } } 进一步重构：\n使用事件来监听player与counter的交互，从而达到解耦的目的。\n在 GameInput.cs 中我们使用委托为Interact Action添加一个调用的函数Interact_performed()。performed是 InputAction 提供的事件之一。Input System 对每个 action 提供三个常见阶段的回调：started（开始），performed（触发/完成），canceled（取消）。使用 performed 表示当输入动作被视为“完成”时（例如按下按钮，或按键触发的交互条件满足时）调用你的回调。\n具体调用的方式参照我们在设置Input Action资产时所命名的：\n1 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 public class GameInput : MonoBehaviour { private PlayerInputActions playerInputActions; public event EventHandler OnInteractAction; private void Awake() { playerInputActions = new PlayerInputActions(); playerInputActions.Enable(); // 监听player 与 counter的交互 playerInputActions.Player.Interact.performed += Interact_performed; } private void Interact_performed(InputAction.CallbackContext context) { OnInteractAction?.Invoke(this, EventArgs.Empty); } private void OnDestroy() { playerInputActions?.Disable(); playerInputActions.Player.Interact.performed -= Interact_performed; } 随后在 Player.cs 中根据消息做出相应的反应：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 private void Start() { gameInput.OnInteractAction += GameInput_OnInteraction; } private void OnDestroy() { gameInput.OnInteractAction -= GameInput_OnInteraction; } private void GameInput_OnInteraction(object sender, EventArgs e) { HandleInteraction(); } 柜台选中时的视觉效果 我们复制 ClearCouter 下的子物体 ClearCounter_Visual，并将其命名为 Selected，为其设置对应的材质。一个需要注意的点是：\n由于两个大小完全一样的物体的网格重叠时可能会形成闪烁的效果，为了避免这种情况，需要将选中效果的柜台稍微放大一些，例如本项目中将其扩大了 1%。\n另外，需要事先将 选中效果 隐藏，只需要使 选中柜台中单纯负责渲染的子物体失活即可。\n在处理选中的视觉效果，有两种思路可以选择：\n让当前 selectedCounter 发送通知，在处理视觉表现的脚本中订阅该事件并且做出相应的视觉效果的变化。也就是在ClearCounter.cs 中发送事件，在 SeletedCounterVisual.cs 中订阅该事件 让当前操作的角色发送通知，在处理视觉表现的脚本中订阅该事件并做出视觉效果的变化 两种方法有各自的优缺点：\n第一种方法 好处：SeletedCounterVisual.cs 只订阅当前选中柜台的事件，如果我们需要选中时添加其他效果也可以很方便地添加 坏处：控制视觉效果的代码又需要出现在专门处理逻辑的ClearCounter.cs的脚本中，造成耦合 第二种方法 好处：更方便。控制视觉效果的代码不会出现在处理逻辑的脚本中，解耦 坏处：可能存在性能问题。因为所有的柜台都需要订阅由角色发送的事件 鉴于此项目规模较小，并不会带来性能瓶颈，所以此处选择了第二种方法。\n因为此项目是单人版，所以我们索性将玩家设置为单例。\n踩的坑 在局部优化的时候，我在 SelectedCounterVisual.cs 中，将来自 player 的事件注册到了 Enable 中：\n1 2 3 4 5 6 7 8 9 10 11 //============ SelectedCounterVisual.cs ============ private void OnEnabe() { Player.Instance.OnSelectedCounterChange += Player_OnSelectedCounterChange; Hide(); } private void OnDisable() { Player.Instance.OnSelectedCounterChange -= Player_OnSelectedCounterChange; } 这引起了空引用报错。\n因为在 Unity 的生命周期顺序里，OnEnable() 可能会在很多情况下很早触发（比如物体/组件一开始就是 Enabled、或者 SetActive(true) 时），它可能早于 Player 的 Awake() / Start() 执行。\n虽然我的 Player.Instance = this; 是在 Awake() 里做的，那理论上应该很早，但仍可能因为 SelectedCounterVisual 启用时， Player.Instance还没赋值，导致空引用异常。所以更安全的做法是将注册事件的部分移到 Start 中：\n1 2 3 4 5 6 7 8 9 10 private void Start() { Player.Instance.OnSelectedCounterChange += Player_OnSelectedCounterChange; Hide(); } private void OnDestroy() { Player.Instance.OnSelectedCounterChange -= Player_OnSelectedCounterChange; } 有别的脚本参与的注册事件最好放在 Start 中（类似于 GetComponent的处理一样），不要放在 Enable 中，避免空引用问题（其他脚本还没准备好就开始注册和该脚本有关的事件）\n使用Scriptable Object来管理厨房物品 这是我第一次在项目中系统使用 ScriptableObject 来管理资源，所以将这部分的内容详细总结如下：\nScriptableObject 是 Unity 中 的一种轻量级、数据驱动的资源类，它不依附于场景中的 GameObject，而是作为 Project 窗口中的独立资产（.asset 文件） 存在。\n好处 解耦数据与逻辑 将配置数据（如角色属性、关卡参数、技能效果）从 MonoBehaviour 脚本中剥离，避免硬编码 修改数值无需改动代码，便于策划或美术协作 跨场景复用 一个 ScriptableObject 资产可在多个场景、Prefab 或脚本中引用，保证数据一致性（如全局游戏设置） 编辑器友好 在 Inspector 中直观编辑，支持自定义绘制（PropertyDrawer）、Undo/Redo、多选批量修改 节省内存 数据集中存储，避免每个实例重复持有相同配置（相比在 MonoBehaviour 中复制粘贴） 版本控制友好 .asset 文件为纯文本（YAML 格式），便于 Git 等工具 diff 和合并 典型使用场景 场景 示例 游戏配置 玩家初始生命值、敌人掉落表、武器伤害参数 关卡/任务数据 关卡目标、任务奖励、对话树节点 状态机/行为树 AI 行为配置、动画状态参数 本地化文本 多语言词条表（Key-Value 对） 经济系统 商店商品价格、货币兑换规则 原型快速迭代 设计师直接调整数值，无需程序员介入 注意事项 不可用于运行时动态生成的数据（如玩家存档）→ 应使用 JSON / PlayerPrefs / Save System 不包含 MonoBehaviour 生命周期方法（如 Update）→ 仅用于静态数据容器 引用其他对象（如 Prefab、Texture）时需注意资源依赖和打包 创建 KitchenObjectSO 脚本 新建专门的脚本，继承自 ScriptableObject，然后把需要的属性设为字段。为了规范，还是采用私有字段，公开Get使其可以被外部访问。\n1 2 3 4 5 6 7 8 9 10 11 [CreateAssetMenu()] public class KitchenObjectSO : ScriptableObject { [SerializeField] GameObject prefab; [SerializeField] Sprite sprite; [SerializeField] string objectName; public GameObject Prefab =\u0026gt; prefab; public Sprite Sprite =\u0026gt; sprite; public string ObjectName =\u0026gt; objectName; } 通过鼠标右键创建了资产后，就可以在面板上进行相应设置了：\n创建 KitchenObject 脚本 为了能够识别不同的厨房物品，创建 KitchenObject .cs，绑定相应的可编程对象资产，这样玩家和柜台互动时就能识别不同的物体了。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 //=========== KitchenObject .cs =========== public class KitchenObject : MonoBehaviour { [SerializeField] private KitchenObjectSO kitchenObjectSO; public KitchenObjectSO GetKitchenObjectSO() { return kitchenObjectSO; } } //============ ClearCounter.cs ============= public class ClearCounter : MonoBehaviour { [SerializeField] private KitchenObjectSO kitchenObjectSO; [SerializeField] private Transform counterTopPointTransform; public void Interact() { print(\u0026#34;clear counter is interacting!\u0026#34;); Instantiate(kitchenObjectSO.Prefab, counterTopPointTransform.position, Quaternion.identity); print(kitchenObjectSO.Prefab.transform.GetComponent\u0026lt;KitchenObject\u0026gt;().GetKitchenObjectSO().ObjectName); } } 厨房物体的父对象 游戏的设计是：玩家可以拿起物体，放在不同的柜子上，这就需要通过改变这个厨房物体的父物体来实现对厨房对象做各种不同的操作。\n我们从两个方面来实现，它们是一一对应的：\n空柜台需要知道自己上面是否有厨房物品 厨房物品需要知道自己在哪个柜台上 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 //=========== ClearCounter.cs =========== public class ClearCounter : MonoBehaviour { [SerializeField] private KitchenObjectSO kitchenObjectSO; [SerializeField] private Transform counterTopPointTransform; private KitchenObject kitchenObject; public void Interact() { if (kitchenObject == null) { GameObject kitchenObjectGameObject = Instantiate(kitchenObjectSO.Prefab, counterTopPointTransform); kitchenObjectGameObject.transform.localPosition = Vector3.zero; kitchenObject = kitchenObjectGameObject.GetComponent\u0026lt;KitchenObject\u0026gt;(); kitchenObject.SetClearCounter(this); } else { print(kitchenObject.GetClearCounter()); } } } //============= KitchenObject.cs ============== public class KitchenObject : MonoBehaviour { [SerializeField] private KitchenObjectSO kitchenObjectSO; private ClearCounter clearCounter; public KitchenObjectSO GetKitchenObjectSO() { return kitchenObjectSO; } public void SetClearCounter(ClearCounter clearCounter) { this.clearCounter = clearCounter; } public ClearCounter GetClearCounter() { return this.clearCounter; } } 将厨房物品移动到另一个柜台上去，也就是说，此时厨房物品的父对象改变了。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 //=========== ClearCounter.cs =========== // 因为厨房物品是放在 counterTopPointTransform 上的 // 所以用一个函数返回 public Transform GetKitchenObjectFollowTransform() { return counterTopPointTransform; } //============= KitchenObject.cs ============== public void SetClearCounter(ClearCounter clearCounter) { this.clearCounter = clearCounter; // 设置厨房物品的父对象 transform.parent = clearCounter.GetKitchenObjectFollowTransform(); transform.localPosition = Vector3.zero; } 通过这样的操作，厨房物品的transform虽然已经可以到另一个柜台了，但是厨房物品的数据信息仍然留在原来的柜台上。所以也需要对其进行处理。这里有两种方法：\n在ClearCounter.cs中，设置kitchenObject的父级后让新的父级去更新\n在KitchenObject.cs中，当该厨房物品被设置到新的父级上时自己去更新父级\n此项目中采用了第二种方法。厨房物品告诉老的父级“它已经不在那里了”，再告诉新的父级“它现在在那里”。\n1 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 //=========== ClearCounter.cs =========== // 增加一些helper函数 // 厨房物品的 getter 和 setter，用以通知父级它的更新 public void SetKitchenObject(KitchenObject kitchenObject) { this.kitchenObject = kitchenObject; } public KitchenObject GetKitchenObject() { return kitchenObject; } public void ClearKitchenObject() { kitchenObject = null; } public bool HasKitchenObject() { return kitchenObject != null; } //============= KitchenObject.cs ============== // 有厨房物品本身通知它的两个父级自己状态的变化 public void SetClearCounter(ClearCounter clearCounter) { // 通知老的柜台：这个厨房物品已经不在了 // 老柜台：this.clearCounter // 新柜台：参数列表中的 clearCounter（没有this） if(this.clearCounter != null) { // 将老柜台上的厨房物品清空 this.clearCounter.ClearKitchenObject(); } // 设置新柜台 this.clearCounter = clearCounter; // 确保这真的是一个新的空柜台，上面没有任何厨房物品 if (clearCounter.HasKitchenObject()) { Debug.LogError(\u0026#34;Counter already has a kitchen object\u0026#34;); } // 在新柜台上设置厨房物品 clearCounter.SetKitchenObject(this); // 设置厨房物品的父对象 // Update visual transform.parent = clearCounter.GetKitchenObjectFollowTransform(); transform.localPosition = Vector3.zero; } 避坑：\n这里的新老柜台因为名字都是 kitchenObjectParent ，所以非常容易搞错。\n我第一次就搞乱了新老柜台，导致后面玩家无法在空柜台上正常放下物品。\n所以为了保险起见，虽然很多情况下 this 都是可以省略的，但是为了清晰，以后还是需要加上。\n在此过程中，Code Monkey进行了测试，复制另一个空柜台，看：\n厨房物品有没有从视觉上从老柜台移动新柜台上 老柜台和新柜台中，厨房物品的数据有没有更新正确 1 2 3 4 5 6 7 8 9 10 11 12 13 14 //========= testing ========== [SerializeField] ClearCounter clearCounter2; [SerializeField] bool testing; private void Update() { if (testing \u0026amp;\u0026amp; Input.GetKeyDown(KeyCode.T)) { if (kitchenObject != null) { kitchenObject.SetClearCounter(clearCounter2); } } } 在此项目中，Code Monkey展示了大量这样的测试过程，用打印信息，或者在播放状态下把面板调成 Debug 状态来看私有变量或者其他数据的变化情况。这是一种高效的开发方式，因为 Unity 中的断点调试不太容易，所以用这两者结合，在开始先将逻辑跑通，避免将bug堆积到后期。\n玩家与厨房物品的交互 由于厨房物品既需要与空柜台交互，又需要与玩家交互，两者的逻辑某些方法是重合的——可以将空柜台和玩家都看成是厨房物品的父对象，所以设计一个接口 IKitchenObjectParent ，这样，厨房物品能同时实现针对柜台和玩家的方法，又不至于需要继承（在逻辑上也说不通）它们。\n1 2 3 4 5 6 7 8 9 10 11 12 13 public interface IKitchenObjectParent { public Transform GetKitchenObjectFollowTransform(); public void SetKitchenObject(KitchenObject kitchenObject); public KitchenObject GetKitchenObject(); public void ClearKitchenObject(); public bool HasKitchenObject(); } 这样，只需将原来在 ClearCounter.cs里换个名字，在 Player.cs 里也做类似实现即可，非常方便高效。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 public Transform GetKitchenObjectFollowTransform() { return counterTopPointTransform; } public void SetKitchenObject(KitchenObject kitchenObject) { this.kitchenObject = kitchenObject; } public KitchenObject GetKitchenObject() { return kitchenObject; } public void ClearKitchenObject() { kitchenObject = null; } public bool HasKitchenObject() { return kitchenObject != null; } 不同类型的柜台 教程采用了prefab variant的方式来实现。具体的做法是先从复制一个空柜台，将它设置为 _BaseCounter（名字带下划线的好处是它可以自动排在文件夹的最前面）。_BaseCounter 只需保留最基本的东西，其他的根据不同柜台的需要添加：CounterTopPoint 子物体和 BoxCollider。于是，包括前面做的 ClearCounter，都可以通过 prefab variant来实现。\n使用 Prefab Variant 实现高效复用与差异化管理 好处 继承 + 差异覆盖（Inheritance with Overrides）\nVariant 自动继承父 Prefab 的所有属性（组件、参数、子对象结构等） 仅需修改有差异的部分（如材质、数值、是否启用某个组件），其余保持同步 批量更新，一处修改，多处生效\n若基础逻辑或公共结构变更（如给所有敌人加一个新脚本），只需修改原始 Prefab，所有 Variant 自动继承更新（除非该属性已被覆盖）\n清晰的差异化标识\n在 Inspector 中，被修改的属性会显示为 粗体 + 覆盖标记（Override），一目了然哪些地方做了定制\n减少重复资产，节省维护成本\n避免复制多个几乎相同的 Prefab 导致“配置漂移”（例如：10 个敌人只有血量不同，却要维护 10 份完整 Prefab）\n支持嵌套与层级化设计\n可创建多级 Variant（如 Enemy_Base → Enemy_Ranged → Enemy_Ranged_Elite），构建灵活的配置树\n（比如本教程中的存放不同厨房物品的container counter就是用container counter 的 prefab variant制作的）\n适用场景 除了本项目中柜台的案例外，再举几个适用的场景加深印象：\n场景 示例 角色/敌人变种 基础士兵 → 精英士兵（更高血量、不同武器） 道具系统 基础药水 → 治疗药水 / 魔力药水（仅图标和效果值不同） UI 元素 按钮模板 → 确认按钮 / 取消按钮（仅颜色和文本不同） 关卡物件 基础灯柱 → 损坏灯柱 / 闪烁灯柱（仅动画状态或材质不同） 注意事项 避免过度嵌套：Variant 层级过深会增加理解成本 慎改结构：若在 Variant 中增删子 GameObject，可能影响后续从父 Prefab 继承的更新 适合“小差异”：若两个预制体差异过大（\u0026gt;50% 内容不同），应考虑拆分为独立 Prefab 不同类型柜台的关系设计 由于玩家和每种类型的柜台交互是大同小异的：按下按键，在柜台上生成物品，转移物品的父级对象（柜台/玩家），所以原来玩家和空柜台的交互代码 if (raycastHit.transform.TryGetComponent(out ClearCounter clearCounter)) 需要改为玩家和它们上一级的交互。这里可以用接口或者父类实现。由于各个柜台都大同小异，并且它们确实属于同一个大类，所以用一个柜台的父类来与玩家交互，其他各种类型的柜台都继承自这个父类，不管从逻辑上还是从功能上都能说得通，所以教程使用了这种做法。\n拓展 virtual 和 abstract 两者都可用于支持继承和多态，但它们在实现要求、使用场景和类约束上有所区别：\n特性 virtual（虚方法） abstract（抽象方法） 是否必须有实现 必须提供默认实现 不能有实现（只有声明） 子类是否必须重写 可选（用 override 覆盖） 必须重写（否则编译报错） 所在类是否可实例化 所在类可以是普通类（非抽象），能直接 new 所在类必须是 abstract 类，不能直接实例化 设计目的 提供可选扩展点（有默认行为） 定义强制契约（子类必须实现特定功能） 本项目中两种方法都可以，Code Monkey采用了 virtual 的策略。需要注意的是，此时在其子类 ClearCounter.cs 中同样的方法会有警告，这代表子类中的这个具有相同签名的方法会覆盖父类中的方法，需要加上override 代表我们重写了父类中的方法而非覆盖。\n重构 由于两个柜台子类继承了父类 BaseCounter 又同时实现了接口 IKithchenObjectParent，所以索性让 BaseCounter 实现接口，这样就无需每增加一个新类型的柜台就重写一遍实现接口的代码了。\n1 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 //=============== BaseCounter.cs ================ public class BaseCounter : MonoBehaviour, IKitchenObjectParent { [SerializeField] private Transform counterTopPointTransform; private KitchenObject kitchenObject; public virtual void Interact(Player player) { Debug.LogError(\u0026#34;BaseCounter.Interact();\u0026#34;); } public Transform GetKitchenObjectFollowTransform() { return counterTopPointTransform; } public void SetKitchenObject(KitchenObject kitchenObject) { this.kitchenObject = kitchenObject; } public KitchenObject GetKitchenObject() { return kitchenObject; } public void ClearKitchenObject() { kitchenObject = null; } public bool HasKitchenObject() { return kitchenObject != null; } } 切菜柜台的实现 这个柜台主要需要解决3件事情：\n整颗菜需要变成对应的片片 有些菜是可以切的（卷心菜、芝士、西红柿），有些是不能的（面包、肉饼） 已经是片片的菜不能再次被切 Cutting Recipe 照例使用 Scriptable Object 来实现。我们通过同一种菜不同形态的一一对应的组合来实现切菜的功能。\n1 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 //============ CuttingRecipeSO.cs ============ [CreateAssetMenu] public class CuttingRecipeSO : ScriptableObject { [SerializeField] private KitchenObjectSO input; [SerializeField] private KitchenObjectSO output; public KitchenObjectSO Input =\u0026gt; input; public KitchenObjectSO Output =\u0026gt; output; } //============= CuttingCounter.cs ============== public override void InteractAlternate(Player player) { if (this.HasKitchenObject()) { // 切菜柜台上有东西，切它 // 1）销毁整个的；2）生成切好的 KitchenObjectSO outputKitchenObjectSO = GetOutputFromInput(GetKitchenObject().GetKitchenObjectSO()); // 必须放在Destroy之前 this.GetKitchenObject().DestroySelf(); KitchenObject.SpawnKitchenObject(outputKitchenObjectSO, this); } } // 从输入的 cutting recipe SO 中找到对应的输出的 private KitchenObjectSO GetOutputFromInput(KitchenObjectSO inputKitchenObjectSO) { foreach (CuttingRecipeSO cuttingRecipeSO in cutKichenObjectSOArray) { if (cuttingRecipeSO.Input == inputKitchenObjectSO) return cuttingRecipeSO.Output; } return null; } 对于可以被切的菜的限定 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 //========= CuttingCounter.cs ========== //======== 只有可以被切的菜才能放到切菜台上 ======== public override void Interact(Player player) { // 玩家捡起/放下厨房物品 if (!HasKitchenObject()) // 如果柜台上没有厨房物品 { // 那么可以放下 if (player.HasKitchenObject()) // 玩家手里有厨房物品 { // 只有在这个东西都可以被切的情况下才能放到切菜台上 if (HasRecipeWithInput(player.GetKitchenObject().GetKitchenObjectSO())) { player.GetKitchenObject().SetKitchenObjectParent(this); } else // 玩家手里没有物品 { } } } else // 如果柜台上有厨房物品 { if (player.HasKitchenObject()) // 玩家手上有东西 { } else // 玩家手上没东西，把柜台上的厨房物品转到玩家手上 { GetKitchenObject().SetKitchenObjectParent(player); } } } //=========== 只有可以被切的菜才能被执行切菜的动作 ============ public override void InteractAlternate(Player player) { if (this.HasKitchenObject() \u0026amp;\u0026amp; HasRecipeWithInput(GetKitchenObject().GetKitchenObjectSO())) // 玩家手上有东西，并且它可以被切的情况下才能执行切菜动作 { // 切菜柜台上有东西，切它 // 1）销毁整个的；2）生成切好的 KitchenObjectSO outputKitchenObjectSO = GetOutputFromInput(GetKitchenObject().GetKitchenObjectSO()); // 必须放在Destroy之前 this.GetKitchenObject().DestroySelf(); KitchenObject.SpawnKitchenObject(outputKitchenObjectSO, this); } } 切菜的进度 因为每种菜的大小不同，所以将 cuttingProgressMax 这个参数放到 CuttingRecipeSO.cs 中。\n美化切菜进度条的小技巧：\n给进度条的 background 的添加 shadow 或者 outline\n当然，切菜进度条的显示也是按照Code Monkey一贯的风格，使用 EventHandler 的方式来处理的。\n切菜进度条正对摄像机显示 关于摄像机的更新一般都放在 LateUpdate 中，等待所有的 Update 完成后再进行，以确保没有奇怪的运动发生。\n1 2 3 4 5 6 7 public class LookAtCamera : MonoBehaviour { private void LateUpdate() { transform.LookAt(Camera.main.transform); } } 如果简单这样处理，虽然看似达到了效果，但是仔细一看切菜条是反过来的。原因是进度条UI是切菜柜的子物体，会跟着它旋转，我们从柜子背面看进度条就会呈现从右向左的状态。所以索性写一个完善的脚本来应对各种LookAt的情况：\n1 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 LookAtCamera : MonoBehaviour { private enum Mode { LookAt, LookAtInverted, CameraForward, // 永远正对摄像机的方向 CameraForwardInverted // 永远顺着摄像机的方向 } [SerializeField] private Mode mode; private void LateUpdate() { switch(mode) { case Mode.LookAt: transform.LookAt(Camera.main.transform); break; case Mode.LookAtInverted: Vector3 dir = -(Camera.main.transform.position - transform.position); transform.LookAt(dir + transform.position); break; case Mode.CameraForward: transform.forward = Camera.main.transform.forward; break; case Mode.CameraForwardInverted: transform.forward = -(Camera.main.transform.forward); break; } } } 由于笔记太长，所以分为两个部分，下半部分请见 这里\n","date":"2026-02-10T19:13:30+08:00","image":"https://nullshowjl.github.io/p/unity%E4%BB%8E%E4%B8%80%E4%B8%AA%E4%B8%AD%E5%9E%8B%E6%B8%B8%E6%88%8F%E5%AD%A6%E4%B9%A0%E5%95%86%E4%B8%9A%E5%8C%96%E4%BB%A3%E7%A0%81%E7%9A%84%E5%86%99%E6%B3%95%E4%B8%8A/cover_hu_aeab2fcc56cdabe1.webp","permalink":"https://nullshowjl.github.io/p/unity%E4%BB%8E%E4%B8%80%E4%B8%AA%E4%B8%AD%E5%9E%8B%E6%B8%B8%E6%88%8F%E5%AD%A6%E4%B9%A0%E5%95%86%E4%B8%9A%E5%8C%96%E4%BB%A3%E7%A0%81%E7%9A%84%E5%86%99%E6%B3%95%E4%B8%8A/","title":"【Unity】从一个中型游戏学习商业化代码的写法（上）"},{"content":"教程参考及素材来源：油管-Code Monkey\n项目简介 《Lua Lander》是一个受经典游戏《Lunar Lander》启发的 2D 物理小游戏，核心玩法是操控小型飞船在外星地表安全着陆。\n经典街机游戏《lunar Lander》的主要机制是：\n使用推力与旋转控制飞船方向与速度 避免高速或倾斜角度着陆，否则会坠毁 在指定着陆点轻柔降落可获得更高分数 需要管理速度、角度与燃料 《Lua Lander》是油管游戏开发大佬Code Monkey在此基础上做的复刻和扩展。除了保留以上核心机制外，项目的扩展玩法包括：\n更复杂的着陆判定（速度+角度） 多种着陆平台 + 分数倍率系统 燃料系统（飞行成本） 金币收集系统（额外得分） 推进器视觉特效（粒子系统 + 事件驱动） 此外，项目还做了系统级的扩展：\n完整 UI 系统 游戏状态机（Start → Playing → GameOver） 多关卡系统（Prefab 关卡，而非多 Scene） 关卡预览（Cinemachine + 自定义 2D Zoom） 多输入系统（键盘 + 手柄 + 触屏） 主菜单、暂停菜单、游戏结束场景 音效与音乐系统 学习目标 Code Monkey喜欢做的游戏一般系统复杂、项目规模大、对可维护性要求高，所以他的教程不管是哪个级别的都会采用更专业的结构，展现如何将clean code、good practice融合进他的项目之中。我希望从这个项目中不仅能学到他解决问题的思路、怎么写干净的代码，还能学到架构项目的正确方式、真正的游戏工程师思维，为以后自己做复杂项目打下基础。\n所以在这个项目中我将采用步步跟随的方式，总结他的方法和思路，以便可以用在以后自己的项目中。\n开发步骤 整个游戏大概按照下面步骤开发：\npost processing处理（可以放在任意步骤）\nander的开发\nlander的运动 动力的视觉效果 terrain的开发\n使用sprite shape 摄像机跟随\ncinemachine 背景的设置\nlanding\nlanding的判定 landing在平台上 landing pad预制体\nprefab variant （大小不同、得分倍率不同的平台） 得分倍率动态指示 燃料机制的实现\n设置燃料额度 lander拾取燃料 金币（与燃料类似） GameManager\nUI\n数据统计板 加速指示箭头 燃料进度条 游戏结束UI 结果和数据统计面板 Restart按钮 游戏状态设置\nStart Restart Gameover 爆炸特效\n新关卡\n使用prefab的方法 全景地图\nInput System\nbutton/ Key Touch Joy Stick Main Menu Scene\nPauseUI\n点击按钮 按下 Esc 键 GameOverScene\ntotal score 返回主菜单按钮 SoundManager 和 MusicManager\n保持背景音乐在关卡切换后仍然持续播放 音量按钮 Polish\n思路及关键步骤 PostProcessing 可以通过 Volume =\u0026gt; Global Volume =\u0026gt; Add Override 来对游戏中的图片和 Game 界面的背景进行视觉效果的调整。虽然此项目只调整了 Bloom 和 Vignette，但是还有大量的参数可调，到时可以根据需要去探索使用。\nvisual 与 logic 分离 创建一个空游戏物体，将sprite作为子物体挂载在它下面，只负责渲染。而这个空游戏物体会挂载所有的脚本/行为组件，负责所有的逻辑。如果以后有更多的视觉效果（比如粒子特效等），也都同样以单独的子物体的方式挂载。这种做法是我第一次看到的。这样做的好处是：\n逻辑与表现解耦，代码更干净\n如果逻辑脚本和 Sprite Renderer 在同一个 GameObject 上，你的脚本就会天然依赖视觉组件。分开以后，逻辑脚本只处理数据、状态、行为；子物体负责显示，不影响逻辑。这是“解耦”的核心。\n更容易替换视觉资源（Sprite → 动画 → 3D 模型）\n如果视觉和逻辑绑在一起，你要改脚本、改结构、改引用。如果分开，视觉只是子物体，那么可以直接把子物体删掉换一个新的，而逻辑脚本完全不用动。这对做长期的、需要不断扩展的项目来说非常重要。\n更容易做动画、旋转、缩放\n视觉作为子物体，你可以：\n让视觉旋转，而逻辑不旋转（例如子弹旋转但逻辑方向不变） 让视觉抖动、缩放，而逻辑位置保持稳定 让视觉做动画，而逻辑不受影响 碰撞体、逻辑中心点更好控制\n逻辑物体通常需要一个“干净的 Transform”来作为移动中心/ 碰撞中心/ 旋转中心/ AI逻辑参考点；而视觉往往不是严格的对齐中心，还需要特别处理。\n更容易做 Prefab 复用\n逻辑 prefab 不变，视觉 prefab 可以换皮肤、换主题、换颜色、换模型等。\n代码要尽可能地清晰 访问修饰符要写出来\n默认的访问修饰符（如类中的private和接口中的public），Unity并不会显示，也要写上\nInput的方法 在Unity6 中新旧两套 Input 的方法都可以用:\n1 2 3 4 5 6 7 8 9 10 11 12 13 private void Update() { // 老版本 if(Input.GetKey(KeyCode.UpArrow)) { Debug.Log(\u0026#34;Up\u0026#34;); } // Unity6 if(Keyboard.current.UpArrowKey.isPressed) { Debug.Log(\u0026#34;Up\u0026#34;); } } Input放的位置 一般来说，需要放在 Update() 里面，因为 Update会每帧监听是否有输入。但是在这个项目中，可以直接放在 FixedUpdate() 里，因为我们使用的是 isPressed，就是说如果一直有按键的动作（isPressed/ GetKey），那么即使在固定帧率（FixedUpdate）监听的情况下，也是可以被检测到的。但是如果使用监听按下/抬起键一瞬间的API（GetKeyDown/ GetKeyUp），那么建议放在 Update 中，因为放在FixedUpdate中可能会错过。\n此外，关于物理特性的更新，一律需要放在 FixedUpdate 中。\n如果不得不放在 Update 中，一定要乘以 Time.deltaTime，以确保在不同帧率的计算机上移动的速度是一样的。\n虽然在 FixedUp 里无需加上 Time.fixedDeltaTime，但是加上也无所谓\n坐标系的设定 由于采用了visual和logic分离的方式，所以要确保坐标系设置为 Pivot 和 Local。因为如果设置为 Center，那么中心点将是子物体和父物体之间的中心位置，设置为 Pivot，中心会是各个物体本身的中心点。\n而在此项目中我们希望按下 向上箭头键 后，lander朝着自己本身的上方移动，那么需要将坐标参考设置为 Local。如果设置为 Global，那么不管lander本身朝向何方，我们按下 向上箭头键，lander会永远朝着屏幕上方移动。\nLanding 的实现思路 landing的判定有两个层面：\n飞船需要以保持直立的状态着陆 飞船需要较为轻柔地着陆 从着陆的轻重层面，使用 Collision.relativeVelocity)来判断。这是Collision的一个属性，描述两个碰撞对象的相对线性速度（只读）。\n着陆角度判定的实现，则使用两个向量（物体本身的向上向量transform.up和世界坐标的向上向量Vector2.up）的点乘值来进行判断。\n1 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;\t// 根据游戏表现的数值设定 if (other.relativeVelocity.magnitude \u0026gt; softLandingVelocityMagnitude) { print(\u0026#34;Landing too hard!\u0026#34;); return; } float minDotVector = 0.92f;\t// 根据游戏表现的数值设定 float dotVector = Vector2.Dot(Vector2.up, transform.up); print(dotVector); if (dotVector \u0026lt; minDotVector) { print(\u0026#34;landing angle too steep!\u0026#34;); return; } print(\u0026#34;Safe landing!\u0026#34;); } 如何设定某些判定的值？\n如果某些值不知道设置多少合适，可以在 console 中打印出来，然后在游戏中看不同的情况显示不同的值来决定。比如此项目中，关于landing/ crash的判定就是这样做的。\n不要使用字符串来识别游戏物体 字符串只能用在文本中，不要用它来查找物体或者使用Unity自带的Tag，因为非常容易出错，比如大小写没搞清、拼错或者不当心在名字后带了一个空格，都会被判定为查找失败。\n使用脚本来查询物体。\n1 2 3 4 5 // 识别是否是landing pad if (other.gameObject.TryGetComponent\u0026lt;LandingPad\u0026gt;(out LandingPad landingPad)) { print(\u0026#34;landing pad\u0026#34;); } 推力特效的实现 同样出于解耦的考虑，lander.cs 里不应该有 visual 相关的代码，所以使用事件来处理。\n另外，粒子系统和 transform.position 一样是只读的，意味能无法直接修改的，所以需要通过 EmissionModule 来间接得修改：\n1 2 ParticleSystem.EmissionModule emissionModule = leftThrusterParticalSystem.emission; emissionModule.enabled = false; Time.deltaTime 和 Time.fixedDeltaTime deltaTime 在 Update() 中的使用 •\t目的：补偿可变帧率 •\tUpdate() 的调用频率取决于帧率（30fps、60fps、144fps 等都不同） •\tdeltaTime 记录每帧的实际时间间隔 •\t通过乘以 deltaTime，确保物体在不同帧率下以相同的实际速度移动\nTime.fixedDeltaTime 在 FixedUpdate() 中的使用 •\t目的：使用固定时间步长进行物理模拟 •\tFixedUpdate() 以固定间隔调用（默认 0.02 秒，即 50 次/秒），不受帧率影响 •\tTime.fixedDeltaTime 是这个固定值（通常是常量） •\t物理引擎需要固定时间步长来保证模拟的稳定性和可预测性\n两者的关键区别 Update() + deltaTime FixedUpdate() + fixedDeltaTime 时间间隔 可变（取决于帧率） 固定（默认 0.02 秒） 作用 补偿帧率差异 保证物理模拟稳定 适用场景 渲染、输入、非物理逻辑 物理计算、Rigidbody 操作 EventHandler 因为这个项目规模较小，且 GameManager 统管所有 游戏数据，而且 Lander 又是游戏的核心，所以在 Lander.cs 中使用 GameManager的单例没有问题（Lander与GameManager紧密耦合）。另一种策略是，仍然使用监听的方式，通过 EventHandler让GameManager中的数据根据在 Lander.cs中的监听结果做出反应。此项目采用了第二种方式。\n1 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\u0026lt;Lander\u0026gt;(); // 因为是获取另一个无关联的游戏物体的组件，所以放在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 sender 和 EventArgs e 即使在方法体内没有被使用，也必须保留在参数列表中。原因是：\n匹配事件签名：lander.OnCoinPickup 事件期望一个特定格式的处理器方法 C# 事件模式：这是 .NET 的标准事件处理器模式，必须匹配 EventHandler 委托的签名 如果移除这些参数，代码将无法编译。\n参数的用途：\n•\tobject sender：告诉你是哪个对象触发了事件（这里是 lander 实例） •\tEventArgs e：传递事件数据（比如捡到的金币价值）\n如果需要明确表示“不需要使用这些参数”，可以用“弃元（Discards）”的写法表示：\n1 2 3 4 private void Lander_PickupCoin(object _, EventArgs _) { AddScore(500); } EventHandler 中使用参数的例子\n比如，在“根据飞船着陆质量时叠加不同的分数”这一机制的实现上：\n1 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\u0026lt;OnLandedEventArgs\u0026gt; OnLanded; // 不同着陆效果有不同的分数，所以给EventArgs传入一个分数的参数 public class OnLandedEventArgs : EventArgs { public int score; } // 飞船在平台上的着陆 private void OnCollisionEnter2D(Collision2D other) { // ... int score = Mathf.RoundToInt(landingSpeedScore + landingAngleScore) * landingPad.GetScoreMultiplier(); print(\u0026#34;score: \u0026#34; + score); OnLanded?.Invoke(this, new OnLandedEventArgs\t// 在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 = \u0026#34;\u0026lt;color=#ff0000\u0026gt;CRUSH!\u0026lt;/color\u0026gt;\u0026#34;; 使用状态机来设置游戏的状态 用enum设置几个游戏状态，然后和按键结合。这个过程在Lander/ Player脚本中完成，还是非常简便的。\n1 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 \u0026lt;= 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; } } 爆炸特效的实现：\n同样通过事件的方法完成。但是需要注意的是，它（OnLanded）的注册并没有像推力特效那样直接注册在 Awake 中，而是注册在 Start 中。因为以下三个原因：\n一次性事件：整个游戏过程只触发一次 可能依赖其他系统：爆炸效果、游戏状态管理等可能需要在其他对象的 Awake() 中初始化 不需要立即准备：着陆不会在游戏开始的瞬间发生 总结将爆炸特效放在 Start 中的好处：\n代码组织：将初始化逻辑（高频事件）和游戏逻辑（一次性事件）分开 避免潜在问题：如果其他脚本的 Awake() 中也订阅了 OnLanded，使用 Start() 可以确保订阅顺序更可控 点击按钮的操作 项目中没有使用“在面板中绑定按钮”的方法，而是直接写在代码中：\n1 2 3 4 5 6 7 8 9 [SerializeField] private Button nextBtn; private void Awake() { nextBtn.onClick.AddListener(() =\u0026gt;\t// 使用lambda表达式写法，因为没有参数、没有返回值，这样写最简便 { SceneManager.LoadScene(0); }); } 在新增了关卡后，使用一个回调函数来决定，点击按钮是进入下一关还是重玩这一关。用Action来存储函数，而不是使用它的事件功能，这种做法很灵活。\n1 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(() =\u0026gt; { 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 = \u0026#34;SUCCESSFUL LANDING!\u0026#34;; nextBtnClickAction = GameManager.Instance.GoToNextLevel;\t// 根据不同的情况决定回调哪个函数 } else { titleTxtMesh.text = \u0026#34;\u0026lt;color=#ff0000\u0026gt;CRUSH!\u0026lt;/color\u0026gt;\u0026#34;; nextBtnClickAction = GameManager.Instance.RetryLevel; } // ... } } Level number 不变化的处理 将 levelNumber设置为普通变量，因为在场景重载时会销毁其中的 GameManager，所以会导致场景默认值仍为1，无法进入下一场景的情况。\n教程中使用的处理方式是将 levelNumber设置为static，这样它保留在内存中，当场景中所有的东西被新建时，新的GameManager会重新读取到它，从而加载到下一关。\n在关卡开始时展示全景地图 因为每一关的地图都不一样，所以这个脚本需要挂在每一关上。使用控制CinemachineCamera的方法来切换游戏未开始时和游戏开始时的跟随对象。\n由于 Unity 的 Cinemachine 2D（CinemachineVirtualCamera + Framing Transposer 2D）本身没有zoom的功能，所以需要自己写脚本来实现。\n而在3D的模式下，CinemachineVirtualCamera 有内建的 Lens → Field of View（FOV）或 Orthographic Size 控制，可以直接当作 Zoom 使用。\n新建一个空游戏物体 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的镜头范围恢复正常。\n1 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 的改进（支持多个输入系统） 自己创建 InputActions =\u0026gt; Edit Asset =\u0026gt; Action Maps，在Action Maps中为 Player 添加输入，对 player 的三种操作（up/ left/ right）绑定对应的按键： 然后在面板上勾选创建C# class，这样可以直接通过脚本控制，是Code Monkey推荐的方法，而不是使用字符串。由此，我们自己创建的InputActions生成了一个自己的C#脚本。\n创建自己的GameInput脚本 新建一个空游戏物体，在该物体上挂载一个新的脚本。这个脚本作为中间层，避免其他脚本直接依赖 InputActions.cs (紧密耦合)。这样的好处有：\n解耦 - 游戏逻辑不直接依赖 InputActions 实现 统一入口 - 所有输入通过一个地方管理 易于测试 - 可以轻松模拟输入（Mock GameInput） 灵活扩展 - 未来想添加输入记录、重放功能时只需修改 GameInput 扩展 旧输入系统的缺点 Unity的旧输入系统（Input.GetKey(), Input.GetAxis()等）存在以下问题：\n硬编码输入检测 - 代码中直接写死键位（如 Input.GetKey(KeyCode.W)） 难以重新绑定 - 玩家无法自定义键位 多设备支持差 - 处理手柄、触摸屏等不同设备很复杂 缺乏灵活性 - 输入逻辑分散在各处，难以维护 新 Input System的优势 InputActions 提供：\n配置驱动 - 通过可视化编辑器配置输入，无需修改代码 设备无关 - 同一个Action可以同时支持键盘、手柄、触摸 易于重新绑定 - 运行时动态修改键位 更好的性能 - 事件驱动而非每帧轮询 良好实践 因为输入的本质也是一种实践，所以需要相应地调用 Enable 和 Disable ：\n当调用Enable时，内部会发生：\n向 Unity 的输入系统注册监听器 开始接收来自硬件的输入事件（键盘、手柄等） 消耗系统资源和内存 当调用Disable时，内部会发生：\n取消所有监听器的注册 停止处理输入事件 释放相关资源 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 的绑定和键盘绑定没有区别。触屏输入稍有区别。\n在 Canvas 中新建一个空游戏物体 TouchUI 在 TouchUI 下面创建三个 UIImage 的圆 给三个圆设置 On-Screen Button 组件 可以通过simulator来查看在不同触摸设备上的效果 JoyStick输入 不同于按键，手柄的 Action 类型要选择 Value =\u0026gt; Vector2。\n然后和按键的输入一样，在相应的脚本中添加即可。\n使用 SceneLoader 来管理场景 为了避免在加载场景时使用“magic number” SceneManager.LoadScene(0);，我们使用脚本来控制场景的加载。\n因为它会在全局存在，所以将之设置为一个无需继承 MonoBehavior 的静态类。注意，这样一来，类里的所有成员都必须是静态的。\n此外，教程中为了避免使用 magic number 和直接使用 string，采用了将场景设置为 enum 的策略。\n1 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(() =\u0026gt; { SceneLoader.LoadScene(SceneLoader.Scene.GameScene); }); quitBtn.onClick.AddListener(() =\u0026gt; { Application.Quit(); }); } } //============== SceneLoader.cs =============== public static class SceneLoader { public enum Scene { MainMenuScene, GameScene } public static void LoadScene(Scene scene) { SceneManager.LoadScene(scene.ToString()); } } 保持背景音乐在关卡切换时不从头播放 可以通过将音乐播放的时间设置为静态的来处理。（需要跨越关卡保持统一数据的，此教程中都是通过设置为static来解决）。\n1 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\u0026lt;AudioSource\u0026gt;(); musicAudioSource.time = musicTime; } private void Update() { musicTime = musicAudioSource.time; } } 扩展 关于事件的取消注册 在这个项目中多处用到了事件机制来实现代码的解耦，但是绝大多数情况没有进行取消注册的操作。但这并非最佳实践。\n在以下情况下，不取消操作暂时是安全的：\n相同的生命周期：LanderVisual 和 lander 组件在同一个 GameObject 上 1 lander = GetComponent\u0026lt;Lander\u0026gt;(); // 同一个对象 同时销毁：当 lander 爆炸时，整个 GameObject 被禁用 1 gameObject.SetActive(false); // LanderVisual 和 Lander 一起被禁用 Unity 会在对象销毁时自动清理这些引用 最佳实践是使对象在被销毁时取消注册：\n1 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; } } 以下情况下必须进行取消注册的操作： 订阅者生命周期比发布者短 订阅者可能被多次创建/销毁（不取消注册会导致重复订阅） 跨场景的事件 养成取消注册的习惯是更安全的做法，可以避免：\n内存泄漏 重复触发 难以追踪的 bug 应该始终遵循：注册在哪里，就在对应的清理方法中取消注册\n取消注册的地方 取消注册的地方一般有 Destroy 和 Disable 两个，具体应该怎么选择？根据事件取消注册的时机选择。\n核心原则：配对原则\n事件的注册和取消注册应该在对应的生命周期方法中配对：\n注册位置 取消注册位置 使用场景 Awake() / Start() OnDestroy() 对象生命周期内只需注册一次 OnEnable() OnDisable() 对象会被重复启用/禁用 visual 和 logic 分开 在此项目里，Code Monkey将visual的所有脚本也都挂在主游戏物体上，而sprite只是一张干净的图片。这种做法适用于：\n小型项目 视觉简单 没有皮肤系统 没有复杂动画 没有多层视觉组件 逻辑和视觉的耦合度低 还有另一种做法，是将负责visual的脚本放在sprite的子物体上，这种做法在大型项目中常用，适用于：\n角色有多层视觉 有皮肤系统 有动画状态机 有 UI 绑定 有粒子、特效、灯光 有多人同步（视觉不参与同步） 优点：\n完全解耦 视觉可替换 逻辑可复用 Prefab 更模块化 更适合大型项目 Polish Code Monkey一再强调polish的重要性，我也非常认同，但是我学习这门课程主要是为了看专业独立游戏开发者如何用clean code、以模块化的方式开发松耦合的、可扩展的游戏，所以polish部分就先不做了。\n关于polish的各种技巧，我计划学习一门课程去掌握。\n在此记录一下code monkey在课程中所用的插件，今天我要用的时候可以参考使用：\nFeel（Code Monkey有review视频） All in 1 Sprite Shader （Code Monkey有review视频） Text Animatior Hot Reload Code Monkey Toolkit 复盘和下一步 即使这只是一门入门课程，我还是学到了很多东西，最主要的包括：\nvisual 和 logic 分离的原则\n就这个体量的项目而言，sprite上不挂任何脚本，全部放在空游戏物体中\n单一职责原则\n每个类、每个函数都相对小，只负责一件事件\n观察者模式\n通过观察者模式进行了大量解耦，包括UI、音效等\n使用C#标准的EventHandler的优点\n除了可以正常命名外，还可以添加参数，有时非常有用\nInputSystem的用法\n相较于老的输入系统而言，新的输入系统连接不同输入设备时几乎不需要改代码，很方便\n用prefab的方式来新增关卡\n我第一次知道这种方式。总结对比在关卡设计时使用scene和prefab：\n项目 Scene 做关卡 Prefab 做关卡 适合的游戏类型 大型场景、剧情关卡、独立世界 多关卡、重复结构、程序生成、roguelike 加载方式 切换场景（LoadScene） 在同一场景中实例化 Prefab 编辑体验 直观、所见即所得 模块化、可组合 性能 场景切换有开销 Prefab 实例化更快 复用性 低（每个 Scene 独立） 高（Prefab 可重复使用） 动态生成 不方便 非常方便 多人协作 场景容易冲突 Prefab 冲突更少 关卡数量 少量关卡 大量关卡 下一步：\n原版课程里还有一些Bonus的游戏机制：\n货物运输 钥匙开关 风区 动态陨石 大炮射击 我计划先放一下，等再学习练习一段时间后尝试自己实现。\n另外，Code Monkey是我跟随学习的第一位专业游戏开发者，我会再学习2名专业游戏开发者的课程，学习他们的代码风格和对游戏整体的架构，然后尝试形成我自己的风格。\n","date":"2026-01-31T08:43:30+08:00","image":"https://nullshowjl.github.io/p/unity%E4%BB%8E%E4%B8%80%E4%B8%AA%E5%85%A5%E9%97%A8%E7%BA%A7%E5%B0%8F%E6%B8%B8%E6%88%8F%E5%AD%A6%E4%B9%A0%E5%95%86%E4%B8%9A%E5%8C%96%E4%BB%A3%E7%A0%81%E7%9A%84%E5%86%99%E6%B3%95/cover_hu_8984f1a785aad36c.jpg","permalink":"https://nullshowjl.github.io/p/unity%E4%BB%8E%E4%B8%80%E4%B8%AA%E5%85%A5%E9%97%A8%E7%BA%A7%E5%B0%8F%E6%B8%B8%E6%88%8F%E5%AD%A6%E4%B9%A0%E5%95%86%E4%B8%9A%E5%8C%96%E4%BB%A3%E7%A0%81%E7%9A%84%E5%86%99%E6%B3%95/","title":"【Unity】从一个入门级小游戏学习商业化代码的写法"},{"content":"教程参考：Udemy-Niraj Vishwakarma\n素材来源：\nUnity Asset Store-HobiSoLoved（场景）\nUnity Asset Store-Hippo （坦克）\nUnity Asset Store-Crehera（飞机）\nUnity Asset Store-kΩsmaragd（UI）\nSOLID原则简介 意大利面条式代码 这种代码指的是一团乱麻，所有行为都高度耦合的代码。比如，在 PlayerController.cs 这个脚本中，玩家的 Move(), Attack(), Jump()\u0026hellip;等等行为都写到了一起，此外，还有 PlayAudio 等其他功能。\n这样的代码极其脆弱且难以扩展，它就像意大利面一样，由于各个部分都缠绕在一起，你抽取了其中的一根，可能导致多个bug的出现。\n解决方案：\n抛弃意大利面条式的代码结构，改为模块化结构。\n在快速制作demo或者小型游戏时可以使用意大利面条式代码以快速验证效果。\n但是如果预计将来项目会越来越大，新引入的游戏物体和功能会越来越多，那么事先建构良好的架构和模块化将是非常重要的。\nSOLID原则是什么 总的来说它共有五大原则：\n单一职责原则 Single Responsibility Principle (SRP) 开闭原则 Open-Closed Principle (OCP) 里氏替换原则 Liskov Substitution Principle (LSP) 接口隔离原则 Interface Segregation Principle (ISP) 依赖倒置原则 Dependency Inversion Principle (DIP) 单一职责原则 基本原则 一个脚本，一个功能。SRP将大类的功能拆分成一个个小类，这些小类拥有独立的功能，可以复用且相互之间没有影响。就像乐高的小块积木一样，你可以用它来搭建各种风格各异的大模型。\n好处 当每个脚本都有清晰的职责时，你就能随意替换它而不害怕破坏游戏的其他部分。\n开闭原则 基本原则 类/模块/函数，对扩展开放、对修改关闭。\n好处 通过创建新的类来添加新功能，从来避免破坏原来类的独立性，以此提升代码的模块化。\n如何执行 为新的功能创建新的类 创建接口/抽象类，在具体的功能类中去实现接口/抽象类中的方法 用抽象方法去与不同的功能交互，而不是直接调用它们 提升多态的应用 里氏替换原则 基本原则 超类/父类物体应该可以被其子类替代而不影响其功能。\n好处 允许超类/父类物体可以被其子类替代，而不用直接引用每个子类，这样可以确保系统在引入新的变量时的灵活性和可扩展性。\n如何执行 创建基类并声明所有的方法 给具有相似行为的物体创建为子类 在其他需要交互的脚本中，直接引用父类而非子类 接口隔离原则 基本原则 一个类不应该去依赖一个它不会使用的方法。\n总结 只写需要的、只执行相关的。\n依赖倒置原则 基本原则 高级模块不应该与低级模块耦合，两者都应该依赖于抽象函数。\n总结 依赖于抽象函数，而不是具体的实现。\n如何执行 用接口创建抽象函数 通过具体的类来实现接口 用接口与具体的类交互 实践项目 我们通过一个2D的坦克飞机大战小游戏来展示在Unity游戏开发中如何实践SOLID原则。\n核心玩法：\n屏幕左侧有两种坦克，一种轻型坦克，玩家可以通过按下“L”来操作它；另一种是重型坦克，通过“H”键来操作 轻型坦克可以左右移动、旋转炮管、发射轻型炮弹 重型坦克不能移动，但是可以旋转炮管、发射重型炮弹 屏幕右侧有可以向左移动的飞机，玩家通过击中它们来增加屏幕上的分数 失败条件：如果飞机撞到坦克，坦克受损失活（无法通过按键操作）。两辆坦克均失活时，玩家失败 游戏/功能拆解 内容 背景图片、坦克（轻型和重型）、子弹（轻型和重型）、飞机、UI（分数、开始界面、游戏界面、结束界面）、音效（开火、飞机被击中、坦克被击中）\n逻辑 游戏开始，按下L键可以激活轻型坦克（移动、旋转、发射炮弹） 按下H键可以激活重型坦克（旋转、发射炮弹） 飞机从右往左飞，右侧屏幕飞入、左侧屏幕飞出 坦克被飞机撞到即失活 两家坦克都失活，游戏结束 模块整理 开发步骤 场景摆放 坦克部分的开发 移动和旋转 开火 实现两种坦克的选择 生命值 飞机部分的开发 飞机预制体 飞机的生成 生命值 游戏管理部分开发 配置Game Events AudioManager UIManager GameManager 功能实现及遵循的原则 1. 坦克的移动 在坦克的设计上遵循ISP（接口隔离）和DSP（依赖倒置）。\n2. 坦克的开火 在开火时两种弹药的设计上遵循开闭原则（OCP）。我们在每次增加新的种类弹药时应该只做扩展，而不应该对 TankFire.cs 进行修改。在 TankFire.cs 中，应该通过接口而非直接调用相应炮弹的预制体。\n此处需要注意的是，由于两种子弹都是经过接口与 TankFire.cs 交互的，但是接口不继承自 MonoBehaviour，无法实例化，所以需要将其隐式转换为 Component 来解决。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public class TankFire : MonoBehaviour { [SerializeField] private IProjectile iProjectile; [SerializeField] private Component projectileComponent; [SerializeField] private Transform firePos; private void Start() { iProjectile = projectileComponent as IProjectile; } private void Update() { if (Input.GetKeyDown(KeyCode.Space)) { if (iProjectile != null) iProjectile.Fire(firePos); } } 3. 坦克选择器的实现 在这一部分遵循里氏替换（LSP）的原则进行设计。\n如果项目比较小的话不用创建单独的 LightEngine.cs 和 HeavyEngine.cs ，全部写在 TankEngine 这个类中也可以。\n4. 坦克生命值的实现 遵循依赖倒置原则（DIP），使用一个 IDamageable 的接口来和具体的类交互。这个具体的类可以是飞机、也可以是其他的碰撞物，如子弹、导弹等。\n5. 飞机的移动 这部分仿照坦克移动的方式，复用 ImoveUp.cs 的代码，同样采用接口的方式实现，为将来新种类飞机的加入留下空间。\n6. 飞机和子弹的自动销毁 我在此处采用统一的脚本 DestroyByTime.cs ，遵循单一职责原则，并未按照课程的方式分别处理。\n7. 坦克、子弹、飞机的碰撞检测和生命值联动 这是对第四点的依赖倒置原则的最终实现。由于子弹和飞机都是碰撞后即销毁的情况，所以并没有直接实现 IDamageable接口，在坦克的生命值系统中实现了接口，这样以后即使新增了不同的子弹，对坦克造成不同的伤害，都能轻松实现。在对飞机或者其他类型的敌人中，如果需要设计不同类型的伤害，也可以设计为继承接口的方式灵活实现。\n1 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 //========== IDamageable接口 ========== public interface IDamageable { public void TakeDamage(float damageValue); } //================ 坦克的生命值系统 =============== public class HealthSystem : MonoBehaviour, IDamageable { private float _health = 100f; [SerializeField] private Image healthProgressBar; [SerializeField] private TankEngineBase currentTankEngine; public void TakeDamage(float damageValue) { _health -= damageValue; if (_health \u0026lt;= 0) { _health = 0; currentTankEngine.StopEngine(); } float progressBarValue = _health / 100; healthProgressBar.fillAmount = progressBarValue; } } //================ 子弹生命值 ================= public class ShellHealth : MonoBehaviour { [SerializeField] private float damage = 100f; private void OnCollisionEnter2D(Collision2D other) { IDamageable damageable = other.gameObject.GetComponent\u0026lt;IDamageable\u0026gt;(); if(damageable != null) { damageable.TakeDamage(damage); } Destroy(gameObject); } } //============= 飞机的生命值 ============== public class DroneHealth : MonoBehaviour { [SerializeField] private float damage = 20f; private void OnCollisionEnter2D(Collision2D other) { // 通过接口调用TakeDamage IDamageable damageable = other.gameObject.GetComponent\u0026lt;IDamageable\u0026gt;(); if (damageable != null) { damageable.TakeDamage(damage); } Destroy(gameObject); } } 8. 用观察者模式对UI、音效、游戏状态进行管理 把下面的事件进行注册，这样通过统一的更新接口，观察者们在事件发生变化的时候可以相应被调用。本质是一个监听和响应系统。同时，这样遵循了单一职责原则，在 AudioManager.cs 和 UIManager.cs 等脚本中，只负责播放音效或管理UI的作用。\n关于SOLID原则的建议 先想简单的解决方案 永远不要强迫自己使用SOLID原则，只在需要的时候使用（这也是我的体会） 不要过分设计，尤其是你的设计已经超过了项目本身的范围 永远试着使用模块化方式，让每个独立的模块像乐高玩具一样可以被重复使用（这是我在其他课程中学到的最有用的方法之一，真的非常省时省力） 最初在代码设计上使用SOLID原则肯定会多花时间，但是如果是长远的扩展性很多的项目，这些投入肯定是值得的 应用 =\u0026gt; 失败 =\u0026gt; 学习 =\u0026gt; 应用 =\u0026gt; 你会得到惊喜！ 项目拓展与复盘 拓展 在本项目基础上增加了以下部分：\n增加中型坦克 玩家通过按下“M”键来操作 可上下移动，但是移动速度慢于轻型坦克、移动范围小于轻型坦克 根据坦克类型给坦克增加不同的子弹：激光、导弹 轻型坦克：激光（最少cooldown time），给敌人造成伤害最小，同时发射扇形的5束 中型坦克：子弹（中等cooldown time），给敌人造成伤害中等 重型坦克：导弹（cooldown time最长），给敌人造成伤害最大 增加重型飞机 在飞行中有上下随机小范围移动的变化 改变飞机的生成规则，让游戏的难度随时间而增加 游戏开始，重型飞机生成概率较低，随着时间而升高 不同坦克收到伤害程度不同，重型坦克最慢 项目架构总览 项目采用了组件式架构 + 事件驱动 + 策略模式的混合设计。\n文件夹结构 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 Assets/ ├── Audio/ # 音效资源（射击、爆炸、受伤） ├── Fonts/ # 字体资源 ├── OtherResources/ # 第三方美术资源 ├── Prefabs/ # 预制体 │ ├── Drone/ # 无人机预制体（Light、Heavy） │ ├── Projectile/ # 弹药预制体（3种弹药类型） │ └── Tank/ # 坦克预制体（Light、Medium、Heavy） ├── Scenes/ # 场景文件 ├── Scripts/ # 脚本核心 │ ├── Drone/ # 无人机逻辑 │ ├── GameUtil/ # 游戏事件系统 │ ├── Mgr/ # 管理器（Game、Audio、UI） │ ├── Projectile/ # 弹药逻辑 │ └── Tank/ # 坦克逻辑 │ ├── Engine/ # 引擎/选择系统 │ ├── Fire/ # 射击系统 │ ├── Health/ # 生命系统 │ └── Move/ # 移动系统 └── Sprites/ # 精灵图资源 架构总览 模块 职责 核心类 Tank/Engine 坦克引擎切换系统，玩家可通过 L/M/H 键切换不同类型坦克 TankEngineBase → TankEngineLight/Medium/Heavy，TankEngineSelector Tank/Move 坦克移动系统，不同坦克有不同移动逻辑 TankMoveBase → LightTankMove/MediumTankMove/HeavyTankMove，IMoveY，TankRotate Tank/Fire 射击系统，不同弹药类型通过 IProjectile 接口多态化 TankFire，IProjectile → LightShell/MediumShell/RocketShell/LaserShellProjectile Tank/Health 生命系统，使用 IDamageable 接口实现伤害统一接口 HealthSystem，IDamageable Drone 敌方无人机系统，含生成、移动、受击 DroneMoveBase → LightDroneMove/HeavyDroneMove，DroneHealth，DroneSpawner Mgr 全局管理器 GameMgr（游戏流程）、UIMgr（UI面板/计分）、AudioMgr（音效） GameUtil 全局事件总线 GameEvents（静态事件类） 架构图 代码复用 观察者模式的写法 套路都是一样的，以 AudioManager 为例:\n1 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 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 //======== 1. 注册事件 ========= //========= GameEvents.cs ========= public static class GameEvents { public static Action OnGameStarted; public static Action OnGameOver; public static Action OnTankFired; public static Action OnTankDamaged; public static Action OnDroneDestroy; } //========= 2. 在各个所需的地方监听事件 ========= //========= TankFire.cs ========= private void Update() { if (Input.GetKeyDown(KeyCode.Space)) { GameEvents.OnTankFired?.Invoke(); if (projectile != null) { projectile.Fire(firePos); } } } //========== HealthSystem.cs ========== public void TakeDamage(int damageValue) { GameEvents.OnTankDamaged?.Invoke(); _health -= damageValue; if (_health \u0026lt;= 0) { _health = 0; currentTankEngine.StopEngine(); } float progressBarValue = _health / 100; healthProgressBar.fillAmount = progressBarValue; } //========== DroneHealth.cs =========== public void TakeDamage(int damageValue) { GameEvents.OnDroneDestroy?.Invoke(); // 飞机被子弹击中才会播放声音 Destroy(gameObject); } //========== 3. 统一在AudioManager中根据监听的结果做出反应 ========== //========== AudioMgr.cs ========= public class AudioMgr : MonoBehaviour { [SerializeField] private AudioClip droneDestroyClip; [SerializeField] private AudioClip tankFiredClip; [SerializeField] private AudioClip tankDamagedClip; [SerializeField] private AudioSource audiosource; private void PlayDroneDetroyAudio() { audiosource.PlayOneShot(droneDestroyClip); } private void PlayTankFiredAudio() { audiosource.PlayOneShot(tankFiredClip); } private void PlayTankDamagedAudio() { audiosource.PlayOneShot(tankDamagedClip); } private void OnEnable() { SubscribeEvents(); } private void OnDisable() { UnSubscribeEvents(); } private void SubscribeEvents() { GameEvents.OnDroneDestroy += PlayDroneDetroyAudio; GameEvents.OnTankFired += PlayTankFiredAudio; GameEvents.OnTankDamaged += PlayTankDamagedAudio; } private void UnSubscribeEvents() { GameEvents.OnDroneDestroy -= PlayDroneDetroyAudio; GameEvents.OnTankFired -= PlayTankFiredAudio; GameEvents.OnTankDamaged -= PlayTankDamagedAudio; } } 事件总线系统 — GameEvents 在任何项目中创建类似的静态事件类，用于模块间解耦通信。只需修改事件名称即可适配不同游戏。\n1 2 3 4 5 6 7 8 public static class GameEvents { public static Action OnGameStarted; public static Action OnGameOver; public static Action OnTankFired; public static Action OnTankDamaged; public static Action OnDroneDestroy; } 接口驱动的伤害系统— IDamageable 所有需要受伤的对象（玩家、敌人、可破坏物）都实现此接口。攻击方只需调用 GetComponent\u0026lt;IDamageable\u0026gt;()?.TakeDamage()，完全解耦。\n1 2 3 4 public interface IDamageable { void TakeDamage(int damage); } 项目中SOLID 原则应用分析 S — 单一职责原则 (Single Responsibility Principle) 坦克的移动、射击、旋转、生命被拆分为独立组件，而非放在一个巨大的 \u0026ldquo;Tank\u0026rdquo; 类中。这是 SRP 的典型实践。\n类 职责 TankMoveBase 只负责移动逻辑 TankFire 只负责射击触发 TankRotate 只负责旋转瞄准 HealthSystem 只负责生命值管理 AudioMgr 只负责音效播放 UIMgr 只负责UI显示与计分 GameMgr 只负责游戏流程判定 DroneSpawner 只负责无人机生成 DroneHealth 只负责无人机受击逻辑 O — 开闭原则 (Open/Closed Principle) 弹药系统：IProjectile 接口使得添加新弹药类型（如 LaserShellProjectile、 RocketShellProjectile）时，不需要修改 TankFire 的代码，只需新建一个实现 IProjectile 的类 移动系统： IMoveY接口 + TankMoveBase允许新增坦克移动方式而不修改基类 无人机移动： DroneMoveBase允许通过继承扩展新的飞行行为（如 HeavyDroneMove 的随机偏移飞行） L — 里氏替换原则 (Liskov Substitution Principle) TankEngineBase的三个子类（TankEngineLight、TankEngineMedium、TankEngineHeavy）可以在TankEngineSelector 和 GameMgr中被无差别替换使用。 GameMgr.CheckEngine() 遍历 List\u0026lt;TankEngineBase\u0026gt;，调用 GetStatus()，不关心具体是哪种引擎 DroneMoveBase的子类同理，可以在需要 DroneMoveBase 的地方互换 I — 接口隔离原则 (Interface Segregation Principle) IMoveY：只定义 MoveY(float) 一个方法，不强迫实现者实现不相关的功能（如旋转、射击） IProjectile：只定义 Fire(Transform) 一个方法，每种弹药只需关注\u0026quot;如何发射\u0026quot; IDamageable：只定义 TakeDamage(int) 一个方法，任何可受伤对象实现它即可 接口粒度非常小，每个接口只包含一个方法，完美遵循 ISP。\nD — 依赖倒置原则 (Dependency Inversion Principle) DroneHealth中对伤害的处理依赖的是 IDamageable 接口而非具体的 HealthSystem 类： 1 2 IDamageable damageable = other.gameObject.GetComponent\u0026lt;IDamageable\u0026gt;(); damageable?.TakeDamage(damage); TankFire依赖 IProjectile 接口来发射弹药，而非依赖具体的弹药类 TankMoveBase通过 this as IMoveY 将自身转为接口调用，高层逻辑依赖抽象而非具体实现 复盘 SOLID原则扩展性强，但是脚本量也是成倍增加。在最后用观察者模式写UI的时候几乎花了一整天，调试一个bug花了一个下午。对于初学者而言，在使用观察者模式的时候，监听代码放置的位置和其他代码的顺序也很重要，是很容易踩的坑。\n另外，这是我第一次系统性地学习游戏的架构方式，在很多时候还是感觉比较抽象的，很多时候很难一下子想到。在做这个项目的时候，最直观的感受是项目扩展性确实很好，无需去老的脚本中改，只需要写新脚本新功能就可以了，也不存在新增一个功能多了三个bug的情况，这一点对大型项目或者是网络手游，真的非常重要。希望能随着我能力的不断提高、对优秀的复杂/大型项目见识的增加和个人项目复杂度的不断增加，提高我对架构的认知和实践能力。\n","date":"2026-01-19T09:54:30+08:00","image":"https://nullshowjl.github.io/p/solidsolid%E5%8E%9F%E5%88%99%E9%A1%B9%E7%9B%AE%E5%AE%9E%E8%B7%B5/cover_hu_4a51a32524760ddd.webp","permalink":"https://nullshowjl.github.io/p/solidsolid%E5%8E%9F%E5%88%99%E9%A1%B9%E7%9B%AE%E5%AE%9E%E8%B7%B5/","title":"【SOLID】SOLID原则项目实践"},{"content":"目录\n合成大西瓜 太空射击 飞翔的小鸟 乒乓球 小行星 这一练手系列中的游戏都是非常小的，旨在帮我自己熟悉API的用法、锻炼思维逻辑能力，以及拓展思路，所以首要任务是快速完成原型、或者总结出一些小块的复用代码；项目整体架构和可扩展性等不在此类项目范围之内。\n合成大西瓜 教程参考及素材来源：B站-游池汇\n源码： on Gitee\n游戏简介 《合成大西瓜》是一款由微伞游戏（Weisun Games）2021年初推出的休闲益智类网页小游戏，因其简单上手、魔性玩法和社交传播迅速走红。\n核心玩法：\n玩家通过点击屏幕，从上方随机掉落不同种类的水果（如葡萄、橙子、苹果等）。 相同水果碰撞后会合成更大的水果，例如两个葡萄合成一个樱桃，两个樱桃合成一个橙子，依此类推，最终目标是合成出最大的“西瓜”。 游戏采用类似“俄罗斯方块 + 2048”的机制：水果受重力影响堆叠，若堆积超过屏幕顶部，则游戏结束 游戏/功能拆解 内容 背景图片、墙壁和地板、水果（运动中水果、待命水果）、死亡判定线、UI（分数）、音效（落地、合成）\n逻辑 待命水果，鼠标按下时在x轴跟随鼠标，松开鼠标水果落下 水果落地时，水果之间互相碰撞、带弹性效果，相同的水果碰撞在一起会合成更大的水果 水果如果抵达了死亡判定线，游戏失败，重新加载场景 显示当前的分数 开发步骤 场景摆放 给水果和场景等游戏物体添加重力、碰撞、弹性等组件 将水果做成预制体 创建待命水果跟随鼠标移动 待命水果在鼠标松开后落下 相同水果合成 水果碰到/超出死亡线的逻辑 显示UI分数 音效 遇到的问题及解决方法 1. 没有思路 仿照“把一只大象放进冰箱需要几步”，将要做的事尽可能拆成细到不能再细的步骤。开发步骤4为例，可以进一步细化为以下的步骤：\n待命水果（1-4号水果）数组 待命水果生成点 随机从数组中生成待命水果 鼠标点下后获取鼠标的位置，并赋值给待命水果（只改变x轴的值，y和z轴保持不变） 边界检查（水果不能随鼠标一起超过两边墙壁） 2. 屏幕坐标与世界坐标的转换 1 2 3 4 5 6 7 // 屏幕坐标 -\u0026gt; 世界坐标 Vector3 screenPoint = Input.mousePosition; // 例如鼠标位置 Vector3 worldPoint = Camera.main.ScreenToWorldPoint(screenPoint); // 世界坐标 -\u0026gt; 屏幕坐标 Vector3 worldPos = player.transform.position; Vector3 screenPos = Camera.main.WorldToScreenPoint(worldPos); 需要注意的是，因为main camera在世界坐标中z轴的位置在-10，所以2D的普通游戏物体从屏幕坐标转为世界坐标要处理z轴（设置为0），否则会看不见（在main camera处）。\nUnity的屏幕坐标原点在左下角\n3. 延迟执行 1 Invoke(\u0026#34;funcName\u0026#34;, 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较大的那个水果生成更高等级的水果。\n6. 自定义字体无法缩放大小 通过整体缩放解决（Scale）。\n7. 落地时音效的频繁播放 设定当velocity超过一定数值时才播放音效，避免因为一点移动就播放音效从而感觉很杂乱的问题。\n复盘 第一个练手的项目，代码上尽量都是自己写的，没有思路的时候才去看教程的思路，然后再自己写。遇到不会的API就翻阅自己以前的笔记或者查官方文档，感觉确实印象深刻了不少。\nDemo完成了核心逻辑，虽然有一些小bug，比如在有几次测试的时候发现即使水果堆积超过了死亡线，还是没有重新加载的情况。还有合成到了大西瓜之后，需要做一个胜利的页面和场景等。待再练几个项目之后再回来处理吧。\n代码复用 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))\t{ waitingFruit.transform.position = new Vector3(mousePos.x, waitingFruit.transform.position.y, 0); } } 太空射击 教程参考及素材来源：B站-齐齐课-Plane\n源码： on Gitee\n游戏简介 《太空射击》（Space Shooter）是Unity官方的一个3D入门教程项目，旨在帮助初学者快速掌握 Unity 的基础开发流程，包括场景搭建、玩家控制、敌人生成、碰撞检测、UI 显示和音效集成等核心概念。此版本在原教程基础上进行了升级，加入了会发射子弹的敌人等。\n核心玩法：\n类型：2D太空射击游戏（3D俯视视角） 目标：操控飞船在太空中生存尽可能长时间，击落不断出现的敌人和小行星。 操作方式： 移动：使用 WASD 或方向键控制飞船在屏幕内自由移动 射击：按下鼠标左键发射子弹 敌人行为： 小行星和敌人从屏幕上方随机位置生成，以不同速度向下坠落，敌人会左右移动并发射子弹 玩家需躲避或摧毁它们，避免被撞击或者被敌人的子弹击中 失败条件：飞船被小行星/子弹击中即游戏结束 计分系统：每摧毁一个敌人获得分数，UI 实时显示当前得分 游戏/功能拆解 内容 场景搭建（背景缓慢向下移动）、动画（敌人爆炸、小行星爆炸、玩家左右移动时偏转、小行星翻转）、UI（分数）、音效（背景音乐、射击、击中）\n逻辑 玩家可以上下左右移动、发射子弹 小行星在屏幕上方随机生成，匀速下落 敌人在屏幕上方随机生成、以更快的速度向下移动、同时左右移动并发射子弹 显示当前的分数 玩家被敌人子弹击中，或者玩家被小行星/敌人碰撞，玩家死亡，重新加载游戏场景 开发步骤 搭建背景环境\n背景的循环移动 星尘特效添加 背景音乐添加 玩家部分的实现\n玩家运动控制\n玩家左右移动的偏转效果，速度越大偏转角度越大\n实现玩家的射击\n添加玩家子弹射击和音效\n敌人和小行星部分的实现\n随机生成小行星和敌人 小行星随机旋转效果 实现敌人的射击 实现敌人的左右移动（闪避） 特效：实现攻击和撞击效果\nUI部分\n搭建UI界面\n实现UI显示逻辑\n遇到的问题及解决方法 1. 背景的移动 让背景移动还是摄像机移动呢？通常选择让背景移动，因为游戏场景中还会有小行星、玩家、敌人等各种物体，保持摄像机不动是一种更简单的方式。\n需要注意的是，和做“往复”有关的计算时，因为要与绝对时间同步，所以使用 Time.time；在做一般位移、计时、累加时，因为需要确保与帧率无法，使用Time.deltaTime。\n1 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()中每一帧的时间可能不同，取决于设备的性能、运算的复杂程度等，所以如果将刚体运算放置其中可能会出现一些卡顿等情况。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 // 通过对刚体的操作来控制玩家的运动 private void FixedUpdate() { MovePlayer(); } private void MovePlayer() { float horizontalVel = Input.GetAxis(\u0026#34;Horizontal\u0026#34;); // 0-1 float verticalVel = Input.GetAxis(\u0026#34;Vertical\u0026#34;); Vector3 velocity = new Vector3(horizontalVel, 0, verticalVel); _playerRB.velocity = velocity * _speed; // Other codes } 3. 边界检查的一种方法 通过在面板中直观地检查边界的位置，感觉比纯用代码控制简便。先在类内创建一个结构体并序列化，然后在代码中调用。(不要忘记将Border类实例化)\n1 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）组合调用。\n优点：复用性高、职责清晰、易测试、易维护；遵循 composition over inheritance。 缺点：某些对象会挂多个小脚本，但这是合理的单一职责拆分，性能影响很小。 实践要点：保持组件单一职责、用接口/事件解耦、用 RequireComponent、把共享数据放到 ScriptableObject 或配置类、用 Prefab 组合复用。\n“实践要点”有点难以理解。在“2048”项目中曾经看到过一次，以后对这方面的实践需要多加留意体会。\n6. GetComponent 调用的位置/时机 在实践中推荐的做法是，如果同一物体上获取并缓存组件用 Awake()，如果依赖别的对象在 Awake() 中完成的初始化则放到 Start()。\n1 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()\t// 在Awake()中获取刚体组件 { rb = GetComponent\u0026lt;Rigidbody\u0026gt;(); } 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\u0026lt;MoveController\u0026gt;();\t// 另一个对象MoveController在Start()中获取组件 player = GameObject.FindWithTag(\u0026#34;Player\u0026#34;)?.transform; // 等待所有 Awake 完成后再查找使用更安全 } private void Update() { if (player == null) return; Vector3 dir = (player.position - transform.position); dir.y = 0f; move.Move(dir, 3f); } } 7. 协程的一般写法 需要注意以下几点：\n定义返回类型为 IEnumerator 的方法，方法内使用 yield return 返回等待（null、new WaitForSeconds(...)、WaitForEndOfFrame 等） 用 StartCoroutine(MyCoroutine()) 启动；可用 StopCoroutine 停止 不要在主线程（比如 Start()）里写不会 yield 的 while(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 \u0026lt; 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：向量的方向表示旋转轴，向量的长度表示角速度（弧度/秒），所以刚体会绕该随机轴以该速度旋转。\n1 2 3 4 private void Start() { _rb.angularVelocity = Random.insideUnitSphere * rotationSpeed; } 9. 实现敌人发射子弹的两种方法 方法一：和玩家发射子弹的思路类似，用累加时间的方法。\n方法二：使用APIInvokeRepeating(string methodName, float time, float repeatRate) 。含义：在 time 秒后调用 methodName 方法，然后每 repeatRate 秒调用一次。\n1 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 \u0026gt; cooldown) { Instantiate(bullet, shootPos); AudioMgr.Instance.PlayAudio(AudioMgr.Instance.clips[1]); _waitingTime = 0; } } // 方法二的实现 private void Start() { InvokeRepeating(\u0026#34;SpawnBullet\u0026#34;, startingTime, cooldown); } private void SpawnBullet() { Instantiate(bullet, shootPos); AudioMgr.Instance.PlayAudio(AudioMgr.Instance.clips[1]); } 10. 敌人闪避的实现思路 敌人在生成一段时间（随机值）后进行闪避，如果敌人生成在屏幕的左半边则向右移动，如果在右半边则向左移动，一段时间（随机值）后停止左/右移动，继续向下移动，然后进入下一个闪避的循环，直到被自然销毁。\n使用协程实现一段时间闪避然后继续保持竖直下行的行动 为了让敌人的闪避看起来平滑和自然，使用一个加速度通过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），每帧只移动固定的量。如果用于 MoveTowards 的 maxDelta 太小（比如 acceleration 很小），每帧的速度变化被其他物理因素（摩擦、重置、阈值判断）淹没，看起来像“没有发生闪避”。\n1 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 \u0026gt;= 0) { _targetDodgeSpeed = -_targetDodgeSpeed; } float targetTime = Random.Range(movingTimeMin, movingTimeMax); yield return new WaitForSeconds(targetTime); _targetDodgeSpeed = 0; } } 11. OnTrigger和OnCollison的使用时机 两者都可以用于碰撞检测。\nOnCollision用于实际碰撞并产生物理反应的场景，所以开销较大，适用于物理交互时，如碰撞、反弹、受力等。\nOnTrigger只检测重叠，不自动产生物理反应，因为通常不计算接触点/冲量，所以开销比OnCollision小些，适用于范围检测、触发器区域、捡取、伤害区域等。\n12. 特效的销毁的方式方法（关注点分离原则） 特效的销毁放在一个单独的脚本中，不要和CollisionCheck 脚本放在一起。这样可以是关注点分离（CollisionCheck 只负责触发/判定），可复用性高，便于切换为对象池而不是每次销毁。\n复盘 教程提供了一些很新颖的思路，在这个游戏中开始尝试自己写协程，并且尽量练习了上一个游戏中学到的单例模式。在一些我有疑问的时候或者觉得教程的处理超出我认知的时候问了AI，基本都得到满意的答复，感觉用这种项目驱动的学习方法真的很高效且因为有很多即时反馈，所以心理上也不太容易有疲惫感。\n也有一些自己看了AI的回答和教程的讲解也不太明白的地方，比如“组件单一职责、用接口/事件解耦”等，留待在以后的游戏实战项目中慢慢实践体会。\n还有很棒的一点是，学到了原来Unity开始界面的logo是可以替换的，马上做了一张小logo替换，效果真的不错！\n代码复用 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轴方向运动的物体。\n1 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\u0026lt;Rigidbody\u0026gt;(); } private void FixedUpdate() { _rb.velocity = Vector3.forward * flySpeed; } } 4. DestroyByTime 复用在特效的自动销毁上。\n1 2 3 4 5 6 7 8 9 public class DestroyByTime : MonoBehaviour { [SerializeField] private float delay; private void Start() { Destroy(gameObject, delay); } } 5. AudioManager 复用在全部音效的管理和播放上。\n1 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\u0026lt;AudioSource\u0026gt;(); } public void PlayAudio(AudioClip clip) { _audioSource.PlayOneShot(clip); } } 飞翔的小鸟 教程参考及素材来源：油管-Zigurous\n源码： on Gitee\n游戏简介 《飞翔的小鸟》（Flappy Bird）是一款由越南开发者Dong Nguyen于2013年发行的简易2D手机游戏。游戏因其极低的画面复杂性、极高的难度和令人“上瘾”的挫败感而迅速走红，一度成为全球现象级游戏。\n核心玩法：\n类型：像素风格2D游戏，背景为卷轴式滚动，角色是一只简单的像素小鸟 目标：控制小鸟穿越由上下管道组成的障碍，飞得越远得分越高 操作方式： 点击屏幕（或按任意键），小鸟向上扇动翅膀，短暂上升 不操作时，小鸟受重力影响持续下落 障碍机制： 场景中随机出现高低不一的“绿色管道”，小鸟需从管道间隙穿过 触碰到管道、地面都会导致游戏立即结束 失败条件：小鸟碰到障碍物或地面则失败 计分系统：每成功穿过一对管道得1分，无最高分限制 游戏/功能拆解 内容 场景摆放（背景视差效果、无限滚动）、动画（小鸟飞翔）、UI（分数）、音效（得分、撞击）\n逻辑 小鸟自动前进并随重力作用自然下落 玩家可以按下W键、上方向键或者点击鼠标左键让小鸟向上运动 如果小鸟在前进过程中碰到了管道或地面则游戏失败 显示当前的分数 开发步骤 小鸟部分\n小鸟的移动\n小鸟飞翔动画的实现\n背景部分\n背景的制作\n背景视差的实现\n管道部分\n管道预制体的制作 管道的生成 管道的运动 得分\nUI的设计/设置\n菜单交互功能的实现\n添加音效\n遇到的问题及解决方法 1. 触屏模式的写法 1 2 3 4 5 6 7 8 9 10 // 以控制小鸟向上移动为例 // 触屏模式 if (Input.touchCount \u0026gt; 0) // 只要手指接触到屏幕 { Touch touch = Input.GetTouch(0); // 获取首次屏幕按触 if (touch.phase == TouchPhase.Began) // 只要刚开始按触屏幕 { _dir = Vector3.up * strength; } } 2. 用InvokeRepeating 实现简单的动画 需要注意的是，InvokeRepeating 是在 Unity 的主循环中按时间间隔调度调用指定方法，每次调用结束后会返回给引擎，等到下一个间隔再被调用。 虽然它本质不是一个死循环，但是如果在被调的方法里写 while(true)（或其他不会退出的循环），该方法就不会返回，主线程会被阻塞，程序/编辑器会卡死。\n另外，通常把 InvokeRepeating 放在 Start（或 Awake 后的一次性初始化处），只需要调用一次来安排重复调用，Unity 会按指定间隔循环调用该方法。如果把 InvokeRepeating 放在 Update 中，每帧都会再次注册一个重复调用，结果会累积大量并发的定时调用，导致方法被多次/更频繁执行、逻辑错误和性能问题。\n用 InvokeRepeating实现小鸟飞翔的动画：\n1 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 \u0026gt;= sprites.Length) _index = 0; } 3. 视差+无尽背景的一种实现思路 教程中将背景全部按照3D的Quad制作，然后将2D的图片以shader：Unit/Texture的方式做成Material 。利用material中自带的Offset实现移动效果。这种方法非常简单，复用性强。\n1 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\u0026lt;MeshRenderer\u0026gt;(); } private void Update() { _meshRenderer.material.mainTextureOffset += new Vector2(speed * Time.deltaTime, 0); } } 4. 管道销毁的另一种思路 按照通常的做法会在左侧屏幕边缘增加一个触发器，管道预制体碰到这个触发器后执行 Destroy。教程中使用了一个更简单的方法，根据像素位置来进行销毁：\n1 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 \u0026lt; _killEdge) { Destroy(gameObject); } } 5. Unity中的几个坐标系 World 坐标（World） Unity 的世界坐标以世界原点 (0,0,0) 为参考，单位是 Unity unit（通常当作米） 轴方向：X 向右，Y 向上，Z 向前（正 Z 朝向 Scene 视图的前方） GameObject 的 transform.position 是世界坐标；transform.localPosition 是相对于父对象的局部坐标 屏幕坐标（Screen） 以像素为单位，原点在屏幕左下角 (0,0)，右上角是 (Screen.width, Screen.height)\n旧的 IMGUI（OnGUI）以左上角为原点；UI 的坐标行为还会受 Canvas Render Mode（Screen Space / World Space）影响\n视口坐标（Viewport） 归一化坐标，范围在 [0,1]，左下为 (0,0)，右上为 (1,1)\n可用 Camera.ViewportToWorldPoint / WorldToViewportPoint 转换\n在上述管道销毁的例子中，使用 Camera.ScreenToWorldPoint(Vector3 screenPoint) 对左下角屏幕坐标原点，（0, 0 ,0）即坐标原点Vector3.zero 进行了转换，这样才能和Update中的transform.position.x处于同一坐标系下，才有比较的可能。\n6. 游戏的重启和暂停 最简单的方法就是使用 Time.timeScale。\n复盘 因为是2D游戏，所以可以直接使用诸如Vector3.up、Vector3.left这样的代码来表示方向，大大简化了代码。教程中的一些设计思路非常简便好用，比如把背景做成3D quad，运用API _meshRenderer.material.mainTextureOffset 来实现无尽背景和视差效果；还有通过将某一个点的坐标从屏幕坐标转换为世界坐标，物体通过这个点后销毁，而不是依靠传统的碰撞检测，可以节省性能；还有关于简单的动画，不用Unity自带的动画系统，转而使用 InvokeRepeating 配合遍历图片的方式，简单高效地解决了问题。\n在我自己的实践方面，练习了 GameManager 和 AudioManager 的单例模式，并重温了音效的播放和UI的交互设计。这个游戏虽然是个入门级的小游戏，但是我还是学到了很多。\n代码复用 Spawner的一种写法 使用 InvokeRepeating 达到间隔时间生成游戏物体的效果。\n1 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); } 其他一些可复用的代码在上面的“问题与解决方法”部分都有提及，此处不再赘述。\n乒乓球 教程参考及素材来源：油管-Zigurous\n完整源码： on Gitee\n游戏简介 《乒乓球》（Pong） 是电子游戏史上最早、最具影响力的街机游戏之一，由 Atari 公司于 1972 年发布。它模拟了现实中的乒乓球（Table Tennis）运动，被认为是第一个取得商业成功的电子游戏。\n核心玩法：\n游戏画面为一个二维平面，左右两侧各有一个垂直的“球拍”（paddle），中间是一个移动的小球 玩家控制一侧的球拍上下移动（通常通过摇杆或按键），目标是将球反弹回对方场地 如果一方未能接到球，对方得一分 小球碰到上下边界会反弹，碰到左右边界则判定为得分 随着游戏进行，球速可能逐渐加快，增加难度 游戏/功能拆解 内容 场景摆放、玩家球拍、球、电脑球拍、UI（分数）\n逻辑 玩家通过 W键/上箭头键 和 S键/下箭头键 移动球拍击打乒乓球 乒乓球碰到上下边界回弹 乒乓球如果落入自己边界，对方得一分，反之亦然 显示当前的分数 开发步骤 场景摆放 玩家球拍的移动 球移动的实现 电脑球拍AI的实现 球逐渐回弹增速 显示UI分数 遇到的问题及解决方法 1. Angular Drag 和 Linear Drag 在 Rigidbody 中的 Angular Drag 代表角阻力系数，如果不想要旋转的效果，要把它手动设置为0（默认为0.05），同时在 Constraints 中禁止 z轴 的旋转。 在类似这个游戏的2D游戏中一般需要这样的操作。\nLinear Drag 则适用于位置移动，较高的阻力值会让对象受碰撞或力影响后的旋转更快停止。\n2. FixedUpdate的使用说明 不要在 FixedUpdate 直接读取输入。原因是FixedUpdate 按物理步长调用，可能丢失输入事件或导致输入响应不稳定。\n正确做法是在 Update() 读取输入并把结果存到字段，随后在 FixedUpdate() 根据该字段施加力。\n1 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 主要用于与物理系统同步的更新。\n适合放在 FixedUpdate 里的任务：\n应用力/扭矩（Rb.AddForce / AddRelativeForce）或直接设置 Rigidbody.velocity 使用 Rigidbody.MovePosition / MoveRotation（针对 Kinematic Rigidbody） 与物理步长一致的计时器、物理约束与求解逻辑（确定性模拟、网络服务器物理步） 基于物理状态的查询（在物理步长内做的碰撞检测/Overlap/射线检测） 物理预测/插值/外推逻辑（以固定步长进行同步） 不适合放在 FixedUpdate 里的任务：(需要以帧为单位或更低延迟处理)\n输入读取 渲染/摄像机跟随 UI 更新 动画（通常在 Update 或 LateUpdate） 3. 小球接触回弹效果的实现 使用这样的代码无法达到效果，原因可能是方向计算错误，使用 -rb.velocity 并不等同于基于碰撞法线的反射，小球可能沿着错误的方向被推动。\n1 2 3 4 5 6 7 8 9 10 private void OnCollisionEnter2D(Collision2D other) { if (other.transform.CompareTag(\u0026#34;Ball\u0026#34;)) { Rigidbody2D rb = other.gameObject.GetComponent\u0026lt;Rigidbody2D\u0026gt;(); _dir = rb.velocity.normalized; rb.AddForce(-_dir * strength); } } 这里应该使用碰撞接触点的法线解决：\n1 2 3 4 5 6 7 8 9 10 private void OnCollisionEnter2D(Collision2D other) { if (other.transform.CompareTag(\u0026#34;Ball\u0026#34;)) { Rigidbody2D rb = other.gameObject.GetComponent\u0026lt;Rigidbody2D\u0026gt;(); _dir = other.GetContact(0).normal; // 碰撞点的法线 rb.AddForce(-_dir * strength); } } 4. 使用 EventSystem 实现玩家和电脑的得分 感觉实现的方式和按钮很像。\n代码解释：\nTriggerEvent 本质上是 UnityEvent\u0026lt;BaseEventData\u0026gt; 的一个别名/子类，可以在 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(\u0026#34;Ball\u0026#34;)) { BaseEventData eventData = new BaseEventData(EventSystem.current); scoreTrigger.Invoke(eventData); // 调用已注册的eventData } } } 复盘 教程中使用了父类 Paddle 和两个子类PlayerPaddle和ComputerPaddle的方法，一些公共的代码，如获取组件等放在父类中，在两个子类中分别写自己的逻辑。在C++的项目里经常会用到继承的方法，但是在做Unity项目的时候我经常会忘记使用，在以后的实践中要提醒自己在合适的时机多多使用。\n代码复用 本游戏中可复用的代码已在“问题和解决方法”部分提及：\n小球回弹的实现 使用 EventSystem 灵活实现多个函数的回调 小行星 教程参考及素材来源：油管-Zigurous\n源码： on Gitee\n游戏简介 《小行星》（Asteroids）是由 Atari 公司于 1979 年推出的一款经典街机射击游戏，以其简洁的矢量图形和紧张刺激的玩法成为电子游戏史上的里程碑之作。\n核心玩法：\n玩家控制一艘小型飞船，在无重力的太空中自由移动（可推进、旋转、射击） 按下 W键/ 上箭头 或者 S键/下箭头 可以旋转 按下 A键/ 左键头、D键/右箭头可以左/右推进 按下 空格 或者点击 鼠标左键 可以发射子弹 屏幕中不断出现大小不一的“小行星”，它们会从边缘进入并随机漂移 目标：用激光炮击碎所有小行星 大/中行星被击中后会分裂成更小的碎片（例如：大 → 中 → 小 → 消失） 失败： 飞船碰到小行星会立即毁灭（通常有有限生命次数） 其他特点： 飞船有动量，即使停止推进也会继续滑行，需反向推进减速 游戏/功能拆解 内容 场景摆放、玩家、行星、爆炸特效\n逻辑 玩家移动和射击 小行星随机生成和移动 小行星被玩家击中后分裂，直至消失 玩家碰到小行星后，这一局失败 显示当前的分数 全部局数用尽，游戏结束 开发步骤 场景设置 玩家部分 移动 射击 小行星部分 行星预制体（不同的形状、大小、质量、初始角度） 生成行星 实现分裂/销毁逻辑 一局的结束 玩家死亡与复活 玩家的爆炸特效 显示UI分数和玩家剩余的生命数 全部局数用尽，游戏结束 遇到的问题及解决方法 1. 物体间物理碰撞层的设置 通过 Project Setting 中的 Physics 2D 来忽略玩家和子弹、子弹与子弹之间的碰撞检测。\n2. 小行星初始状态的设置 因为此项目中的小行星有不同的变体，包括大小、图片、出现时的初始角度等多个参数都不同，所以采取的策略时在小行星的预制体中就将其全部设置好。这样做的好处是在小行星的 Spawner.cs 脚本里，预制体可以直接生成，做到了脚本的各司其职。Asteroid.cs 负责小行星本身的属性，Spawner.cs 脚本负责小行星的生成。\n1 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\u0026lt;SpriteRenderer\u0026gt;(); _rb = GetComponent\u0026lt;Rigidbody2D\u0026gt;(); } 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); } 小行星初始的旋转角度：\n使用 Random.value，这个值在0~1之间，小行星在 z轴 旋转，所以可以简单处理为 transform.eulerAngles = new Vector3(0, 0, Random.value * 360);\n小行星的size部分处理：\n不能放在 Asteroid.cs 里，因为这个脚本后面会进行小行星分裂的逻辑，如果在 Start() 里对 size 取随机值，会导致分裂的逻辑错误。随机size的逻辑放在 Spawner.cs 里处理。这是我在这个项目遇到的一个大坑。\n3. 小行星生成和移动的实现思路 小行星生成在以屏幕为圆心，一定半径的圆内。它们向着圆心运动。为了增加随机性，增加了朝向圆心的偏移角度。因为是沿着 z轴 进行偏移的，所以可以用 Quaternion.AngleAxis(float angle, Vector3.forward) 实现。\n在小行星的移动上，本身方法由 Asteroid.cs 负责，Spawner.cs 通过获得一个Asteroid 的实例化对象调用。\n1 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\u0026lt;Asteroid\u0026gt;(); asteroid.size = Random.Range(asteroid.minSize, asteroid.maxSize);\t// 在此处处理生成小行星的随机尺寸问题 asteroid.SetTrajectory(rotation * dir); } 需要注意的是，最终小行星的轨道方向是 rotaton * dir，rotation本身是一个四元数，这里将旋转作用于向量，即 旋转后的向量 = Quaternion * 原始向量\nQuaternion 必须在乘号左边！\n其中的原理在数学的相关学习中再做详尽探讨，此处不再赘述。\n关于 Quaternion的补充 应该避免直接修改 Quaternion 的x, y, z, w 分量，因为它们不是角度而是复杂的复数数学，直接修改会导致异常。\n始终使用Unity提供的API！\n常用的API：\n场景 方法 旋转一个向量 rotatedVector = quaternion * vector; 让物体看向某个方向 Quaternion.LookRotation(vector); 把(0,90,0)转为四元数 Quaternion.Euler(0, 90, 0); 获取四元数的度数 quaternion.eulerAngles; 4. 玩家无敌时间的设计思路 通过更改玩家物理碰撞层的方法 LayerMask.NameToLayer(layerName);实现，非常简单明了，再使用 Invoke 来设定需要等待的时间即可。\n5. 粒子系统的设置 因为我在UI的逻辑中使用了 Time.scaleTime 来控制游戏的结束和开始，为了避免粒子系统拖到下一轮开始游戏的那一瞬间播放，需要在面板里将粒子系统的 Delta Time 设置为 Unscaled 。\n复盘 这个项目中需要大量使用关于旋转的方法，常用的API要熟练使用。另外相关的数学知识也需要补充一下。\n这个项目还有两个方法值得注意，可以经常尝试使用：\n通过传参获得另一个脚本中的对象：\n1 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 \u0026lt;= 0.75f) { score += 5; } else if (asteroid.size \u0026lt;= 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(\u0026#34;Bullet\u0026#34;)) { if (size * 0.5f \u0026gt;= minSize) { CreateHalf(); CreateHalf(); } Destroy(other.gameObject); Destroy(gameObject); GameManager.Instance.AsteroidDestroyed(this); } } 在一个脚本中调用另一个脚本中的函数：\n可以通过GameObject或者直接通过另一个类的游戏物体来获得：\n1 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\u0026lt;Asteroid\u0026gt;(); asteroid.size = Random.Range(asteroid.minSize, asteroid.maxSize); // 在此处处理生成小行星的随机尺寸问题 asteroid.SetTrajectory(rotation * dir); } 代码复用 1. 玩家无敌时间的设计 通过更改物理碰撞层，配合Invoke的使用：\n1 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(\u0026#34;Asteroid\u0026#34;)) { 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(\u0026#34;No Collision\u0026#34;); Invoke(nameof(ResetLayer), invulnerableTime); } private void ResetLayer() { gameObject.layer = LayerMask.NameToLayer(\u0026#34;Player\u0026#34;); } 2. 清除场景中所有物体 在《飞翔的小鸟》和《小行星》的游戏重置中，都用到了同样的方法，使用 FindObjectsOfType 通过数组遍历场景中所有需要查找的物体进行销毁即可。\n1 2 3 4 5 Asteroid[] asteroids = FindObjectsOfType\u0026lt;Asteroid\u0026gt;(); for (int i = 0; i \u0026lt; asteroids.Length; i++) { Destroy(asteroids[i].gameObject); } ","date":"2026-01-10T09:19:30+08:00","image":"https://nullshowjl.github.io/p/%E7%BB%83%E6%89%8B%E6%97%A5%E5%BF%97unity%E5%B0%8F%E6%B8%B8%E6%88%8F%E9%9B%86%E9%94%A6-1/cover_hu_8e05e89d2fea4000.webp","permalink":"https://nullshowjl.github.io/p/%E7%BB%83%E6%89%8B%E6%97%A5%E5%BF%97unity%E5%B0%8F%E6%B8%B8%E6%88%8F%E9%9B%86%E9%94%A6-1/","title":"【练手日志】Unity小游戏集锦 1"},{"content":"课程来源：B站-Voidmatrix\nSDL程序库 简介 SDL全名是“Simple Direct Media Layer”，是一个简易的媒体层。它允许我们在不同的操作系统或者硬件平台上运行同一套函数，也就是说，用SDL编写的项目在完成后可以在不同的平台上编译运行。它的接口是C语言写的，非常简明。此套课程常用的SDL库包括SDL image、ttf、mixer等，比如要做图片解码就是用SDL image，需要播放音乐就用SDL mixer。此外，SDL还包含SDL Renderer，它是一个渲染器，对底层（OpenGL、Vulkan、DX等）渲染接口的封装。在不同的平台上，SDL有不同的渲染后端支持不同的渲染接口。\nSDL是经过行业验证的，很多游戏引擎和作品都是在SDL基础上做的，是一个很成熟的技术。\n获取/ 下载 进入SDL的GitHub仓库，点击进入“Releases”下的最新版本“Latest”。\n进入后根据自己的电脑系统选择需要的版本。此项目因为使用的是SDL2（目前已经有了SDL3），所以选择最新的以2开头的版本即可。此项目中使用visual studio开发，所以选择下面版本。\n同理安装 SDL_ttf（文本渲染）、SDL_image（图片渲染）、SDL_mixer（音频解码）、SDL_gfx（简单的图元绘制），注意此项目安装的全部是SDL2对应的版本，如果SDL安装的是SDL3，上述库也应该安装3对应的版本；另外图元绘制在一个单独的网站上，前三个都在GitHub上。\n此外，还需要 cJSON，也用类似方法获取。\n开发环境搭建 通用配置 右击项目名称（注意不是“解决方案名称”），找到“属性”，找到C/C++下的“代码生成/ Code Generation”，将“运行库/ Runtime Library”改为“MT”，点击对话框右下角的“确定”或“应用”。这样可以避免程序在一些没有安装VS或者C++相关库的电脑上发生dll缺失的报错。\n配置第三方库 将SDL的相关文件统一放到一个文件夹中，方便后续管理。在下面的示例中，解决方案的名字是“TowerDefence”，它下面有两个项目/ Project，分别是“Demo”和“TowerDefence”。\n接下来按照C++编译的三个顺序：头文件、库文件、动态链接库分别进行相关设置。\n头文件的配置 用和上面一样的方法打开“属性”界面，点击“C/C++”，右侧第一行，将相关的SDL相关的头文件添加其中。注意，因为默认添加时会用文件的绝对路径，为了灵活，我们将之改为相对路径：\n完成后，VS就能识别 SDL.h 等头文件了。需要注意的是，由于SDL中也包括一个 main 函数，所以我们在最开头要添加这样一句 #define SDL_MAIN_HANDLED 来避免冲突。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 #define SDL_MAIN_HANDLED\t// SDL有自己的main函数定义，使用这个宏来避免冲突 #include \u0026lt;iostream\u0026gt; #include \u0026lt;SDL.h\u0026gt; #include \u0026lt;SDL_image.h\u0026gt; #include \u0026lt;SDL_mixer.h\u0026gt; #include \u0026lt;SDL_ttf.h\u0026gt; int main() { std::cout \u0026lt;\u0026lt; \u0026#34;Welcome to Demo!\u0026#34; \u0026lt;\u0026lt; std::endl; // Game initialization and main loop would go here return 0; } 链接器中库文件的设置 同样在“属性”界面，找到“Linker / 链接器”-“General / 常规”，右侧“Additional Library Directories”\n由于我使用的电脑是64位（x64）的，所以32位的（x86）相关文件不会用到，可以直接删除（无需添加）。\n动态链接库的设置 分别打开SDL各个文件夹中的“lib”文件夹，找到其中的dll文件，复制到项目文件夹（示例中是“Demo”项目）中。\n注意，由于gfx没有动态链接库，所以没有dll文件。\ncJSON的设置 直接在“Solution Explore”界面-“源文件”下添加一个筛选器，将从JSON文件直接拖拽于此即可。\n至此，SDL全家桶在Visual Studio中的配置全部完成。\n","date":"2025-11-22T14:00:30+02:00","image":"https://nullshowjl.github.io/p/%E7%8E%AF%E5%A2%83%E6%90%AD%E5%BB%BAsdl2/cover_hu_a2b45d05472e531f.webp","permalink":"https://nullshowjl.github.io/p/%E7%8E%AF%E5%A2%83%E6%90%AD%E5%BB%BAsdl2/","title":"【环境搭建】SDL2"},{"content":"目录\n算法简介 规则详解 完整实现 学习资源参考：\nB站-Voidmatrix 油管-The Coding Train 算法简介 计算机专家克雷格 · 雷诺兹在1987年发表了一篇关于模拟鸟类集群行为的论文，论文中首次介绍了名为 Boids 的算法，随后这种算法快速被应用于电影或者游戏中，比如92年《蝙蝠侠归来》的蝙蝠群和企鹅群，98年《半条命》中的鸟型生物群中。\nBoids算法是一种去中心化思想，它负责描述每个个体的行为规则，而不关心所谓的“集群”。“集群”是它所表现出来的外观而非最小规则。对于每个个体，只遵循三条简单的规则：\n分离规则：个体会自主移动避开拥挤处 对齐规则：个体会朝着周围同伴的平均移动方向前进 聚集规则：个体会朝着周围同伴的平均位置移动 规则详解 分离规则 集群中个体的一个关键行为就是向着远离周围其他个体的趋势运动，就像磁铁的同极相斥，离得越近这种斥力就会越大。这是个体间不会发生碰撞和重叠的基础。\n分离规则的代码可以这样写：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 // 分离规则 Vector2D separation(const std::vector\u0026lt;Boid\u0026gt;\u0026amp; boids) { Vector2D separation(0, 0); for (const Boid\u0026amp; b : boids) { float distance = (b.position - position).length(); if (distance \u0026gt; 0 \u0026amp;\u0026amp; distance \u0026lt; separation_distance) { Vector2D diff = position - b.position; separation = separation + diff * (1.0f / distance); } } return separation; } 对齐规则 “对齐”在群体模拟中起到了控制个体移动转向的作用。从直觉上，我们根据判断一个群体是否具有一致的行动朝向，来判断它是否是一个“集群”还是“乌合之众”。\n对齐规则可以这样实现：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 // 对齐规则 Vector2D alignment(const std::vector\u0026lt;Boid\u0026gt;\u0026amp; boids) { Vector2D avg_velocity(0, 0); int total_neighbors = 0; for (const Boid\u0026amp; b : boids) { float distance = (b.position - position).length(); if (distance \u0026gt; 0 \u0026amp;\u0026amp; distance \u0026lt; neighbor_distance) { avg_velocity = avg_velocity + b.velocity; total_neighbors++; } } if (total_neighbors \u0026gt; 0) { avg_velocity = avg_velocity * (1.0f / total_neighbors); return avg_velocity - velocity; } return Vector2D(0, 0); } 聚集规则 与分离规则相反，聚集规则是一种凝聚力，像磁铁的异性相吸。它保证了集群中的个体不会因为分离规则而过度分散，导致无法产生“邻居个体”。\n聚集规则的实现代码：我们先得到个体邻居包围得到的质心位置，然后让它拥有向着质心移动的趋势，从而产生聚集的效果。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 // 聚集规则 Vector2D cohesion(const std::vector\u0026lt;Boid\u0026gt;\u0026amp; boids) { Vector2D center_of_mass(0, 0); int total_neighbors = 0; for (const Boid\u0026amp; b : boids) { float distance = (b.position - position).length(); if (distance \u0026gt; 0 \u0026amp;\u0026amp; distance \u0026lt; neighbor_distance) { center_of_mass = center_of_mass + b.position; total_neighbors++; } } if (total_neighbors \u0026gt; 0) { center_of_mass = center_of_mass * (1.0f / total_neighbors); return (center_of_mass - position); } return Vector2D(0, 0); } 这三条规则互相博弈，仅依靠最基础的数学和向量运算，就让简单个体涌现出复杂的 群体智慧。 在不同情景下的集群模拟，本质上是对这三条规则的“参数微调”。比如鱼群，我们常需要模拟水中阻力导致的间距不规则，这可以通过降低“对齐规则”强度实现，让鱼群在拐弯时出现“尾部延迟”的真实感；如一些鼠群或者僵尸群，则可以加大“聚集规则”的权重，让怪群可以如流水般快速绕过障碍物；在一些需要打造较强科技感的场景中，我们可以让群体尽量保持尖锐的三角锥形态，这可以通过在聚集规则之上添加形状进行约束。 完整实现 实现思路和流程 创建 Boid 类 对于每一个个体而言，它需要三个成员变量：位置、速度和加速度，这三者都是一个二维向量（此项目实现2D图像Boids算法）。\n于是 Boid 类可以这样写：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // boid.js class Boid { constructor() { this.position = createVector(width / 2, height / 2); // boid的位置 this.velocity = p5.Vector.random2D(); // boid的速度 this.velocity.setMag(random(0.5, 1.5 )) // 设置群体中每个boid的速度不同 this.acceleration = createVector();\t// boid的加速度 } update(){ this.position.add(this.velocity); this.velocity.add(this.acceleration); } show(){ strokeWeight(16); stroke(255); point(this.position.x, this.position.y); } } 假设我们共有100个boid，那么在 sketch.js 中可以用一个数组来表示它们：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // sketch.js const flock = []; function setup() { createCanvas(640, 360); for (let i = 0; i \u0026lt; 100; i++){ flock.push(new Boid()); } } function draw() { background(51); for (let boid of flock){ boid.show(); boid.update(); } } 现在可以看到这样的效果：\n“对齐规则”的实现 “对齐”是三个规则中最简单的，所以我们先来实现它。\n实现思路：我们以个体boid为中心，检测以它为圆心一定距离为半径的圆，计算这个圆里的所有其他个体（邻居）的平均速度，这个“平均速度”就是目标boid需要转向的目标速度，从而可以得到这个boid转到目标速度所需要的“转向力”。\n1 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 // boid.js align(boids){ let perceptionRadius = 50; // 检测半径为50像素的圆 let steering = createVector(); // boid待转向的速度 let total = 0; // 邻居数量 for (let other of boids){ let d = dist(this.position.x, this.position.y, other.position.x, other.position.y); if (other != this \u0026amp;\u0026amp; d \u0026lt; perceptionRadius) { steering.add(other.velocity); // 把邻居的速度加起来 total++; } } if (total \u0026gt; 0){ steering.div(total); // 计算邻居们的平均速度（小群体的目标速度） steering.setMag(this.maxSpeed); // 限制平均速度 steering.sub(this.velocity); // 二维向量计算：目标速度 - 当前boid速度 = 当前boid需要转向的速度 steering.limit(this.maxForce); // 将转向力限制在一定范围内 } return steering; } flock(boids){ let alignment = this.align(boids); this.acceleration = alignment; // 将对齐行为的转向力赋值为当前加速度（假设质量为1） // 力 = 加速度 * 质量，此处质量为1，所以加速度其实就是boid朝向目标朝向的转向力 } 再给集群加上“飞进飞出”的效果：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 // boid.js edges(){ if (this.position.x \u0026gt; width){ this.position.x = 0; } else if (this.position.x \u0026lt; 0){ this.position.x = width; } if (this.position.y \u0026gt; height){ this.position.y = 0; } else if (this.position.y \u0026lt; 0){ this.position.y = height; } } 于是可以得到这样的“对齐”效果：\n“聚集规则”的实现 这一规则的实现和“对齐规则”类似，只不过我们不需要邻居们的平均速度了，取而代之，需要邻居们的平均位置作为“目标位置”来进行实际的移动“\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 cohesion(boids){ let perceptionRadius = 50; let steering = createVector(); let total = 0; for (let other of boids){ let d = dist(this.position.x, this.position.y, other.position.x, other.position.y); if (other != this \u0026amp;\u0026amp; d \u0026lt; perceptionRadius) { steering.add(other.position); // 此处需要计算位置而非速度了 total++; } } if (total \u0026gt; 0){ steering.div(total); // 计算邻居们的平均位置（目标位置） steering.sub(this.position); // 目标位置 - 当前boid位置 = boid需要运动的位移 steering.setMag(this.maxSpeed); // 限制平均速度 steering.sub(this.velocity); // 二维向量计算：目标速度 - 当前boid速度 = 当前boid需要转向的速度 steering.limit(this.maxForce); // 将聚集力限制在一定范围内 } return steering; } 在”聚集规则“中，maxForce 不是特别重要，重要的是 perceptionRadius 的数值大小，决定了boid能够”看到多大范围“，从而产生聚集行为。\n将两条规则结合起来也非常简单，使用初中物理知识：作用在一个物体上的作用力等于两个作用力之和。\n1 2 3 4 5 6 7 8 9 10 // boid.js flock(boids){ let alignment = this.align(boids); let cohesion = this.cohesion(boids); // 将两个力相加，得到共同作用力 this.acceleration.add(alignment); this.acceleration.add(cohesion); } 在两条规则的加持下，集群的运动轨迹目前是这样的：\n“分离规则”的实现 在分离规则下，我们可以这样得到boid想要的速度：\n于是，separation 是 cohesion 的相反的过程，所以我们可以基于 cohesion 这样来写 separation:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // boid.js separation(boids){ let perceptionRadius = 50; let steering = createVector(); let total = 0; for (let other of boids){ let d = dist(this.position.x, this.position.y, other.position.x, other.position.y); if (other != this \u0026amp;\u0026amp; d \u0026lt; perceptionRadius) { let diff = p5.Vector.sub(this.position, other.position); // boid的位置-邻居的位置，得到boid想要的速度 diff.div(d); // 距离越远的邻居对boid产生的影响越小，距离越近影响越大 steering.add(diff); total++; } } if (total \u0026gt; 0){ steering.div(total); steering.setMag(this.maxSpeed); steering.sub(this.velocity); steering.limit(this.maxForce); } return steering; } 最终得到“分离规则”的效果是这样的：\n至此，简单版的boids算法已经实现，我们可以通过调节三条规则的权重来得到想要的集群行为：\n完整源码 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 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 // boid.js class Boid { constructor() { this.position = createVector(random(width), random(height)); // boid的初始位置 this.velocity = p5.Vector.random2D(); // boid的速度 this.velocity.setMag(random(0.5, 2)) // 设置flock中每个boid的速度不同 this.acceleration = createVector(); // boid的加速度 this.maxForce = 0.2; // boid的最大转向力 this.maxSpeed = 2; // boid的最大速度 } // 在屏幕上显示“飞进飞出”的效果 edges(){ if (this.position.x \u0026gt; width){ this.position.x = 0; } else if (this.position.x \u0026lt; 0){ this.position.x = width; } if (this.position.y \u0026gt; height){ this.position.y = 0; } else if (this.position.y \u0026lt; 0){ this.position.y = height; } } align(boids){ let perceptionRadius = 50; // 检测半径为50像素的圆 let steering = createVector(); // boid待转向的速度 let total = 0; // 邻居数量 for (let other of boids){ let d = dist(this.position.x, this.position.y, other.position.x, other.position.y); if (other != this \u0026amp;\u0026amp; d \u0026lt; perceptionRadius) { steering.add(other.velocity); // 把邻居的速度加起来 total++; } } if (total \u0026gt; 0){ steering.div(total); // 计算邻居们的平均速度（小群体的目标速度） steering.setMag(this.maxSpeed); // 限制平均速度 steering.sub(this.velocity); // 二维向量计算：目标速度 - 当前boid速度 = 当前boid需要转向的速度 steering.limit(this.maxForce); // 将转向力限制在一定范围内 } return steering; } cohesion(boids){ let perceptionRadius = 100; let steering = createVector(); let total = 0; for (let other of boids){ let d = dist(this.position.x, this.position.y, other.position.x, other.position.y); if (other != this \u0026amp;\u0026amp; d \u0026lt; perceptionRadius) { steering.add(other.position); // 此处需要计算位置而非速度了 total++; } } if (total \u0026gt; 0){ steering.div(total); // 计算邻居们的平均位置（目标位置） steering.sub(this.position); // 目标位置 - 当前boid位置 = boid需要运动的位移 steering.setMag(this.maxSpeed); // 限制平均速度 steering.sub(this.velocity); // 二维向量计算：目标速度 - 当前boid速度 = 当前boid需要转向的速度 steering.limit(this.maxForce); // 将聚集力限制在一定范围内 } return steering; } separation(boids){ let perceptionRadius = 50; let steering = createVector(); let total = 0; for (let other of boids){ let d = dist(this.position.x, this.position.y, other.position.x, other.position.y); if (other != this \u0026amp;\u0026amp; d \u0026lt; perceptionRadius) { let diff = p5.Vector.sub(this.position, other.position); // boid的位置-邻居的位置，得到boid想要的速度 diff.div(d); // 距离越远的邻居对boid产生的影响越小，距离越近影响越大 steering.add(diff); total++; } } if (total \u0026gt; 0){ steering.div(total); steering.setMag(this.maxSpeed); steering.sub(this.velocity); steering.limit(this.maxForce); } return steering; } flock(boids){ let alignment = this.align(boids); let cohesion = this.cohesion(boids); let separation = this.separation(boids); // 设置规则权重 alignment.mult(alignSlider.value()); cohesion.mult(cohesionSlider.value()); separation.mult(separationSlider.value()); // 将两个力相加，得到共同作用力 this.acceleration.add(alignment); this.acceleration.add(cohesion); this.acceleration.add(separation); } update(){ this.position.add(this.velocity); this.velocity.add(this.acceleration); this.velocity.limit(this.maxSpeed); this.acceleration.set(0, 0); // 重置加速度（不让加速度越来越快） } show() { strokeWeight(8); stroke(255); point(this.position.x, this.position.y); } } 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 // sketch.js const flock = []; let alignSlider, cohesionSlider, separationSlider function setup() { createCanvas(640, 360); alignSlider = createSlider(0, 5, 1, 0.1); cohesionSlider = createSlider(0, 5, 1, 0.1); separationSlider = createSlider(0, 5, 1, 0.1); for (let i = 0; i \u0026lt; 100; i++){ flock.push(new Boid()); } } function draw() { background(51); for (let boid of flock){ boid.edges(); boid.flock(flock); boid.show(); boid.update(); } } ","date":"2025-10-18T20:37:14+02:00","image":"https://nullshowjl.github.io/p/%E7%AE%97%E6%B3%95%E5%AE%9E%E7%8E%B0boids%E7%BE%A4%E4%BD%93%E8%A1%8C%E4%B8%BA%E7%AE%97%E6%B3%95/cover_hu_7b47dee7eb99188.webp","permalink":"https://nullshowjl.github.io/p/%E7%AE%97%E6%B3%95%E5%AE%9E%E7%8E%B0boids%E7%BE%A4%E4%BD%93%E8%A1%8C%E4%B8%BA%E7%AE%97%E6%B3%95/","title":"【算法实现】Boids群体行为算法"},{"content":"目录\n为什么需要智能指针? 智能指针的基本原理 智能指针的使用 智能指针的发展历史 定制删除器 知识补充一：RAII思想的应用 - 锁守卫 知识补充二：内存泄漏 学习资源参考：C++ 比特就业课\n为什么需要智能指针？ 先来看一个例子：\n1 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 int div() { int a, b; cin \u0026gt;\u0026gt; a \u0026gt;\u0026gt; b; if (b == 0) throw invalid_argument(\u0026#34;Error: Divident is 0\u0026#34;); return a / b; } void func() { int* p = new int(); cout \u0026lt;\u0026lt; div() \u0026lt;\u0026lt; endl; delete p; } int main() { // 捕获异常 try { func(); } catch(exception\u0026amp; e) { cout \u0026lt;\u0026lt; e.what() \u0026lt;\u0026lt; endl; } return 0; } 由于 div 函数中存在抛异常的行为，程序的正常执行流被打断了， div 没有执行完导致指针p没有被释放。一般异常是在最外层去捕的，但是面对上面的情况，在没有智能指针的情况下，只能在 func 中再次添加捕异常的代码，来避免指针p不被释放的问题：\n1 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 int div() { int a, b; cin \u0026gt;\u0026gt; a \u0026gt;\u0026gt; b; if (b == 0) throw invalid_argument(\u0026#34;Error: Divident is 0\u0026#34;); return a / b; } void func() { int* p = new int(); try\t// 在func中加捕异常，避免p不被释放 { cout \u0026lt;\u0026lt; div() \u0026lt;\u0026lt; endl; } catch (exception\u0026amp; e)\t// 此处也可以写为捕获任意异常：catch (...) { delete p; p = nullptr; throw e; } delete p; p = nullptr; } int main() { // 捕获异常 try { func(); } catch(exception\u0026amp; e) { cout \u0026lt;\u0026lt; e.what() \u0026lt;\u0026lt; endl; } return 0; } 在涉及多个指针的代码时，为了防止内存泄漏就得层层添加抛异常的代码。于是，大佬们想出了一种更简洁优雅的方式处理：智能指针。\n智能指针的基本原理 智能指针的实现原理非常简单：在构造函数的时候把指针保存起来，在析构函数的时候把指针释放。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 template\u0026lt;class T\u0026gt; class SmartPtr { public: SmartPtr(T* ptr) :_ptr(ptr) {} ~SmartPtr() { if (_ptr) { delete _ptr; _ptr = nullptr; } } private: T*_ptr; }; 于是，上述的不简洁优雅的代码在引入了智能指针后可以改为：\n1 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 int div() { int a, b; cin \u0026gt;\u0026gt; a \u0026gt;\u0026gt; b; if (b == 0) throw invalid_argument(\u0026#34;Error: Divident is 0\u0026#34;); return a / b; } void func() { int* p = new int(); // 不用捕异常，改用智能指针 SmartPtr\u0026lt;int\u0026gt; sp(p); cout \u0026lt;\u0026lt; div() \u0026lt;\u0026lt; endl; } int main() { // 捕获异常 try { func(); } catch(exception\u0026amp; e) { cout \u0026lt;\u0026lt; e.what() \u0026lt;\u0026lt; endl; } return 0; } 不管最终有没有抛异常，p都被释放了。也就是说，借助了 SmartPtr 对象的 生命周期 解决了抛异常产生的内存泄漏问题（指针未被删除）。简而言之, SmartPtr 帮我们管理了资源的释放。无论函数是正常结束还是抛异常被中断执行，都会导致 sp 对象的生命周期到了以后，调用析构函数，从而释放p指针。我们实际上是 将资源托管给智能指针 管理，智能指针的构造函数保存资源，析构函数释放这个资源。\nRAII RAII（Resource Acquisition Is Initialization）是一种 利用对象生命周期来控制程序资源（如内存、文件句柄、网络连接、互斥锁等等）的技术。\n也就是说，在对象构造时获取资源，接着控制对资源的访问使之在对象的生命周期内始终保持有效，最后在对象析构的时候释放资源。借此，我们实际上把管理一份资源的责任 托管 给了一个对象。\n这种做法有两大好处：\n不需要显式地释放资源 采用这种方式，对象所需的资源在其生命期内始终保持有效 这从某种程度上间接解决了C++没有垃圾回收机制的问题。\nRAII是一种托管资源的思想，除了智能指针外，unique_lock 和 lock_guard 也是依托这种思想实现的 智能指针是基于RAII思想设计的一个类 智能指针的使用 上述的SmartPtr 还不能将其称为智能指针，因为它还不具有指针的行为。所以我们必须再添加一些代码，让它真正可以进行指针的相关操作：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 template\u0026lt;class T\u0026gt; class SmartPtr { public: SmartPtr(T* ptr = nullptr) : _ptr(ptr) {} ~SmartPtr() { if(_ptr) { delete _ptr; _ptr = nullptr; }\t} // 重载操作符\u0026amp;和*，让SmartPtr具备指针的行为 T\u0026amp; operator*() {return *_ptr;} T* operator-\u0026gt;() {return _ptr;} private: T* _ptr; }; 智能指针的潜在问题 先举例子：\n1 2 3 4 5 6 7 int main() { SmartPtr\u0026lt;int\u0026gt; sp1(new int()); SmartPtr\u0026lt;int\u0026gt; sp2 = sp1; return 0; } sp1 和 sp2 指向了同一块空间，调用了两次析构函数，也就是说，同一个资源被释放了两次。\n为了解决这个问题，智能指针提供了三个解决方案：\n管理权转移 auto_ptr 防拷贝 unique_ptr 引用计数的共享拷贝 shared_ptr 会产生 循环引用 的问题，引入 weak_ptr 解决 auto_ptr 总的来说是一种比较失败的设计，背离了“指针”的行为，很多公司明确要求不能使用 auto_ptr。\nauto_ptr 的实现原理 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 60 61 62 // C++98 管理权转移 auto_ptr namespace my_smart_pointer { template\u0026lt;class T\u0026gt; class auto_ptr { public: auto_ptr(T* ptr) :_ptr(ptr) {} auto_ptr(auto_ptr\u0026lt;T\u0026gt;\u0026amp; sp) :_ptr(sp._ptr) { // 管理权转移 sp._ptr = nullptr; } auto_ptr\u0026lt;T\u0026gt;\u0026amp; operator=(auto_ptr\u0026lt;T\u0026gt;\u0026amp; ap) { // 是否自己给自己赋值 if (this != \u0026amp;ap) { // 释放当前对象中资源 if (_ptr) { delete _ptr; _ptr = nullptr; } // 转移ap中资源到当前对象中 _ptr = ap._ptr; ap._ptr = nullptr; } return *this; } ~auto_ptr() { if (_ptr) { cout \u0026lt;\u0026lt; \u0026#34;delete:\u0026#34; \u0026lt;\u0026lt; _ptr \u0026lt;\u0026lt; endl; delete _ptr; _ptr = nullptr; } } // 像指针一样使用 T\u0026amp; operator*() { return *_ptr; } T* operator-\u0026gt;() { return _ptr; } private: T* _ptr; }; } 可能会引发空指针错误：\n1 2 3 4 5 6 7 8 9 10 11 12 13 // main.cpp int main() { std::auto_ptr\u0026lt;int\u0026gt; sp1(new int()); std::auto_ptr\u0026lt;int\u0026gt; sp2(sp1); // 管理权转移 // sp1悬空，不能再赋值了 //*sp1 = 10; *sp2 = 10; cout \u0026lt;\u0026lt; *sp2 \u0026lt;\u0026lt; endl; cout \u0026lt;\u0026lt; *sp1 \u0026lt;\u0026lt; endl; return 0; } unique_ptr 用了简单粗暴的方法：防止拷贝。推荐使用，但是如果存在需要拷贝的场景，那么就没法使用了。\nunique_ptr 的实现原理 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 namespace my_smart_pointer { template\u0026lt;class T\u0026gt; class unique_ptr { public: unique_ptr(T* ptr) :_ptr(ptr) {} ~unique_ptr() { if (_ptr) { cout \u0026lt;\u0026lt; \u0026#34;delete:\u0026#34; \u0026lt;\u0026lt; _ptr \u0026lt;\u0026lt; endl; delete _ptr; _ptr = nullptr; } } // 像指针一样使用 T\u0026amp; operator*() { return *_ptr; } T* operator-\u0026gt;() { return _ptr; } unique_ptr(const unique_ptr\u0026lt;T\u0026gt;\u0026amp; sp) = delete; unique_ptr\u0026lt;T\u0026gt;\u0026amp; operator=(const unique_ptr\u0026lt;T\u0026gt;\u0026amp; sp) = delete; private: T* _ptr; }; } shared_ptr 通过引用计数的方式实现多个 shared_ptr 对象之间共享资源。\nshared_ptr 的实现原理 四个要点：\nshared_ptr 在其内部，给每个资源都维护了着一份计数，用来记录该份资源被几个对象共享 在对象被销毁时(也就是析构函数调用)，就说明自己不使用该资源了，对象的引用计数减1 如果引用计数是0，就说明自己是最后一个使用该资源的对象，必须释放该资源 如果引用计数不是0，就说明除了自己还有其他对象在使用该份资源，不能释放该资源，否则其他对象就成野指针了 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 60 61 62 63 64 namespace my_smart_pointer { template\u0026lt;class T\u0026gt; class shared_ptr { public: shared_ptr(T* ptr = nullptr) :_ptr(ptr) , _pRefCount(new int(1)) {} shared_ptr(const shared_ptr\u0026lt;T\u0026gt;\u0026amp; sp) :_ptr(sp._ptr) , _pRefCount(sp._pRefCount) { ++(*_pRefCount); } ~shared_ptr() { Release(); } shared_ptr\u0026lt;T\u0026gt;\u0026amp; operator=(const shared_ptr\u0026lt;T\u0026gt;\u0026amp; sp) { //if (this != \u0026amp;sp) if (_ptr != sp._ptr) { Release();\t// 先释放自己先前在管理着的资源 _ptr = sp._ptr; _pRefCount = sp._pRefCount; ++(*_pRefCount); } return *this; } void Release() { if (--(*_pRefCount) == 0 \u0026amp;\u0026amp; _ptr) { cout \u0026lt;\u0026lt; \u0026#34;delete:\u0026#34; \u0026lt;\u0026lt; _ptr \u0026lt;\u0026lt; endl; delete _ptr; _ptr = nullptr; delete _pRefCount; _pRefCount = nullptr; } } // 像指针一样使用 T\u0026amp; operator*() { return *_ptr; } T* operator-\u0026gt;() { return _ptr; } private: T* _ptr; int* _pRefCount; }; } shared_ptr在拷贝赋值时的线程安全问题 因为其引用计数在堆上，如果存在两个线程t1和t2，其中的每个线程中各有一个智能指针同时拷贝赋值了同一个智能指针sp。可能会存在两个线程同时增加/减少引用计数，导致结果不对的情况。也就是下面这样子：\n我们从两个方面来考虑 shared_ptr 的线程安全问题：\n智能指针对象中引用计数是多个智能指针对象共享的，两个线程中智能指针的引用计数同时 ++ 或 --，这个操作不是原子的，引用计数原来是1，++ 了两次，可能还是2，这样引用计数就错乱了，会导致资源未释放或者程序崩溃的问题。所以智能指针中引用计数 ++ 、-- 是需要加锁的，也就是说引用计数的操作是线程安全的 智能指针管理的对象存放在堆上，两个线程中同时去访问，会导致线程安全问题 改写后的代码是这样的：\n1 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 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 namespace my_smart_pointer { template\u0026lt;class T\u0026gt; class shared_ptr { public: shared_ptr(T* ptr = nullptr) :_ptr(ptr) , _pRefCount(new int(1)) , _pmtx(new mutex) {} shared_ptr(const shared_ptr\u0026lt;T\u0026gt;\u0026amp; sp) :_ptr(sp._ptr) , _pRefCount(sp._pRefCount) , _pmtx(sp._pmtx) { AddRef(); } void Release()\t// 减计数需要锁保护 { _pmtx-\u0026gt;lock(); bool flag = false; if (--(*_pRefCount) == 0 \u0026amp;\u0026amp; _ptr) { cout \u0026lt;\u0026lt; \u0026#34;delete:\u0026#34; \u0026lt;\u0026lt; _ptr \u0026lt;\u0026lt; endl; delete _ptr; _ptr = nullptr; delete _pRefCount; _pRefCount = nullptr; flag = true; } _pmtx-\u0026gt;unlock(); if (flag == true)\t{ delete _pmtx; _pmtx = nullptr; } } void AddRef()\t// 加计数也需要锁保护 { _pmtx-\u0026gt;lock(); ++(*_pRefCount); _pmtx-\u0026gt;unlock(); } shared_ptr\u0026lt;T\u0026gt;\u0026amp; operator=(const shared_ptr\u0026lt;T\u0026gt;\u0026amp; sp) { //if (this != \u0026amp;sp) if (_ptr != sp._ptr) { Release(); _ptr = sp._ptr; _pRefCount = sp._pRefCount; _pmtx = sp._pmtx; AddRef(); } return *this; } int use_count() { return *_pRefCount; } ~shared_ptr() { Release(); } // 像指针一样使用 T\u0026amp; operator*() { return *_ptr; } T* operator-\u0026gt;() { return _ptr; } T* get() const { return _ptr; } private: T* _ptr; int* _pRefCount; mutex* _pmtx; }; } C++库里的智能指针实现考虑了相关问题，是线程安全的。\nshared_ptr 的缺陷：循环引用 什么是“循环引用”？ 先来看一个场景：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 struct ListNode { int _data; shared_ptr\u0026lt;ListNode\u0026gt; _prev; shared_ptr\u0026lt;ListNode\u0026gt; _next; ~ListNode(){ cout \u0026lt;\u0026lt; \u0026#34;~ListNode()\u0026#34; \u0026lt;\u0026lt; endl; } }; int main() { shared_ptr\u0026lt;ListNode\u0026gt; node1(new ListNode); shared_ptr\u0026lt;ListNode\u0026gt; node2(new ListNode); cout \u0026lt;\u0026lt; node1.use_count() \u0026lt;\u0026lt; endl; cout \u0026lt;\u0026lt; node2.use_count() \u0026lt;\u0026lt; endl; // 循环引用，导致资源无法被释放 node1-\u0026gt;_next = node2; node2-\u0026gt;_prev = node1; cout \u0026lt;\u0026lt; node1.use_count() \u0026lt;\u0026lt; endl; cout \u0026lt;\u0026lt; node2.use_count() \u0026lt;\u0026lt; endl; return 0; } 在这个场景中，出现了这样的情况：\nnode1 和 node2 两个智能指针对象指向两个节点，引用计数变成1，我们不需要手动 delete\nnode1 的 _next 指向 node2，node2 的_prev指向 node1 ，两者的引用计数均变成2\n当 node1 和 node2 析构时，引用计数均减到1，但是它们的 _next 还指向下一个节点，_prev 还指向上一个节点。也就是说 _next 析构了， node2 就释放了；_prev 析构了，node1 就释放了\n但是 _next属于 node的成员，node1 释放了，_next 才会析构，而 node1 由 _prev 管理，_prev 属于 node2 成员，这就叫 循环引用，最终导致谁也不会被释放。是一个类似于“鸡生蛋还是蛋生鸡”的问题。\n如何解决：weak_ptr 严格来说，weak_ptr 不是智能指针，因为它并没有依托RAII的思想，它只是具有指针的行为。它不负责释放资源，只是为了专门解决 shared_ptr 的循环引用问题而设计的。\n结合前面的 shared_ptr 的实现，一个简化版的 weak_ptr 是这样的（C++库里的会复杂很多）：\n1 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 namespace my_smart_pointer { template\u0026lt;class T\u0026gt; class weak_ptr { public: weak_ptr() :_ptr(nullptr) {} weak_ptr(const shared_ptr\u0026lt;T\u0026gt;\u0026amp; sp) :_ptr(sp.get()) {} weak_ptr\u0026lt;T\u0026gt;\u0026amp; operator=(const shared_ptr\u0026lt;T\u0026gt;\u0026amp; sp) { _ptr = sp.get(); return *this; } T\u0026amp; operator*() { return *_ptr; } T* operator-\u0026gt;() { return _ptr; } private: T* _ptr; }; } 于是，前面的场景例子中的代码可以改为使用 weak_ptr ，这样一来，node1-\u0026gt;_next = node2; 和 node2-\u0026gt;_prev = node1; 时 weak_ptr 的 _next 和 _prev 不会增加 node1和 node2 的引用计数，就避开了循环引用的问题。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 struct ListNode { int _data; weak_ptr\u0026lt;ListNode\u0026gt; _prev;\t// 此处的 shared_ptr 改为 weak_ptr weak_ptr\u0026lt;ListNode\u0026gt; _next; ~ListNode(){ cout \u0026lt;\u0026lt; \u0026#34;~ListNode()\u0026#34; \u0026lt;\u0026lt; endl; } }; int main() { shared_ptr\u0026lt;ListNode\u0026gt; node1(new ListNode); shared_ptr\u0026lt;ListNode\u0026gt; node2(new ListNode); cout \u0026lt;\u0026lt; node1.use_count() \u0026lt;\u0026lt; endl; cout \u0026lt;\u0026lt; node2.use_count() \u0026lt;\u0026lt; endl; node1-\u0026gt;_next = node2; node2-\u0026gt;_prev = node1; cout \u0026lt;\u0026lt; node1.use_count() \u0026lt;\u0026lt; endl; cout \u0026lt;\u0026lt; node2.use_count() \u0026lt;\u0026lt; endl; return 0; } 智能指针的发展历史 C++没有GC（垃圾回收机制），申请资源需要释放是一个问题，尤其是碰到异常安全的问题时，很难处理，稍不注意就会出现内存泄漏。内存泄漏会导致程序可用的内存越来越少，但是程序中很多的操作都是需要内存的，所以要尽量杜绝内存泄漏问题。\n所以基于RAII的思想，引入了智能指针。\n阶段一：\n在C++98中首次推出 auto_ptr，但它的设计存在重大缺陷，不建议使用。\n阶段二：\nC++官方在接下来的十几年中没有作为，于是一帮大佬搞了一个非官方社区，写了一个叫做 boost 的库（包含很多内容）。在 boost 库中， 他们重写了智能指针：\nscoped_ptr / scoped_array （防拷贝版本）\nshared_ptr / shared_array （引用计数版本）\nweak_ptr （解决循环引用问题）\nscoped_ptr 针对的是new出来的单个对象，对应的在析构函数中的释放用的是 delete\nscoped_array 针对的是new出来的多个对象（数组），对应的在析构函数中的释放用的是 delete[]，并且运算符重载了 operator[]\n阶段三：\nC++11中引入了智能指针，参考了 boost 的实现，稍微改了一下。C++11中的“右值引用”、“移动语句”等也都参考了 boost 库。\n定制删除器 C++官方库并未参考“boost”库中 scoped_array 和 shared_array 的实现，那么它是怎么处理new多个对象的情况的呢？我们可以通过写一个仿函数的删除器来实现：\n1 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 // 仿函数的删除器 template\u0026lt;class T\u0026gt; struct FreeFunc { void operator()(T* ptr) { cout \u0026lt;\u0026lt; \u0026#34;free:\u0026#34; \u0026lt;\u0026lt; ptr \u0026lt;\u0026lt; endl; free(ptr); } }; template\u0026lt;class T\u0026gt; struct DeleteArrayFunc { void operator()(T* ptr) { cout \u0026lt;\u0026lt; \u0026#34;delete[]\u0026#34; \u0026lt;\u0026lt; ptr \u0026lt;\u0026lt; endl; delete[] ptr; } }; int main() { FreeFunc\u0026lt;int\u0026gt; freeFunc; std::shared_ptr\u0026lt;int\u0026gt; sp1((int*)malloc(4), freeFunc); DeleteArrayFunc\u0026lt;int\u0026gt; deleteArrayFunc; std::shared_ptr\u0026lt;int\u0026gt; sp2((int*)malloc(4), deleteArrayFunc); std::shared_ptr\u0026lt;A\u0026gt; sp4(new A[10], [](A* p){delete[] p; }); std::shared_ptr\u0026lt;FILE\u0026gt; sp5(fopen(\u0026#34;test.txt\u0026#34;, \u0026#34;w\u0026#34;), [](FILE* p) { fclose(p); }); return 0; } 本质上，就是传一个实现释放方式的仿函数对象给智能指针。因为C++库里的智能指针默认的释放资源方式是单个对象的 delete，那么对于不是单个对象delete的情况，我们通过写仿函数的方式都可以分别应对实现。\n知识补充一：RAII思想的应用 - 锁守卫 抛异常不仅可能引起内存泄漏的问题，还可能引起死锁问题，比如下面的场景：\n1 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 #include \u0026lt;mutex\u0026gt; int div() { int a, b; cin \u0026gt;\u0026gt; a \u0026gt;\u0026gt; b; if (b == 0) throw invalid_argument(\u0026#34;Error: Divident is 0\u0026#34;); return a / b; } void func() { mutex mtx; mtx.lock(); cout \u0026lt;\u0026lt; div() \u0026lt;\u0026lt; endl;\t// 如果div抛异常，会导致下面的unlock不执行（死锁） mtx.unlock(); } int main() { // 捕获异常 try { func(); } catch(exception\u0026amp; e) { cout \u0026lt;\u0026lt; e.what() \u0026lt;\u0026lt; endl; } return 0; } 用RAII思想，将加锁和解锁放在一个类的构造函数中，即可解决上面的问题：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 template\u0026lt;class Lock\u0026gt; class LockGuard { public: LockGuard(Lock\u0026amp; lock) :_lock(lock) { _lock.lock();\t// 将加锁放在构造函数中 } ~LockGuard() { cout \u0026lt;\u0026lt; \u0026#34;解锁\u0026#34; \u0026lt;\u0026lt; endl; _lock.unlock();\t// 将解锁放在析构函数中 } // 不允许拷贝，不允许赋值 LockGuard(LockGuard\u0026lt;Lock\u0026gt;\u0026amp;) = delete; LockGuard\u0026lt;Lock\u0026gt;\u0026amp; operator=(LockGuard\u0026lt;Lock\u0026gt;\u0026amp;) = delete; private: Lock\u0026amp; _lock;\t// 锁不允许拷贝，这是成员变量设为引用的一个例子 // 这样也可以确保程序所用的是同一把锁 } 将 LockGuard 类用于 func 函数中：\n1 2 3 4 5 6 7 void func() { mutex mtx; LockGuard\u0026lt;mutex\u0026gt; lock_guard(mtx);\t// 使用LockGuard管理加锁和解锁 cout \u0026lt;\u0026lt; div() \u0026lt;\u0026lt; endl;\t} 知识补充二：内存泄漏 什么是内存泄漏？\n内存泄漏指因为疏忽、错误或者异常安全等原因，造成程序未能释放已经不再使用的内存/资源的情况。内存泄漏并不是指内存在物理上的消失，而是应用程序分配某段内存后，因为设计错误，失去了对该段内存的控制，因而造成了内存的浪费。\n如果进程正常结束，那么这个内存也会释放。所以一般程序碰到内存泄漏，重启后就可以解决。\n内存泄漏的危害\n长期运行的程序出现内存泄漏，影响很大，如操作系统、后台服务之类的不能随便重启的程序，出现内存泄漏会导致响应越来越慢、最终卡死，或者很多服务操作失败（如容器存数据、打开文件、创建套接字、发送数据等都需要内存）。\n如何避免造成内存泄漏问题\n写C/ C++代码时小心谨慎 不容易处理的地方多使用智能指针去管理（事前预防） 如果怀疑已经存在内存泄漏，可以使用内存泄漏工具去检测（事后解决） 工具推荐：Valgrind（是Linux下的强大工具） ","date":"2025-10-17T20:42:14+02:00","image":"https://nullshowjl.github.io/p/c-%E8%AF%AD%E6%B3%95%E6%99%BA%E8%83%BD%E6%8C%87%E9%92%88/cover_hu_28c908cd458e5272.webp","permalink":"https://nullshowjl.github.io/p/c-%E8%AF%AD%E6%B3%95%E6%99%BA%E8%83%BD%E6%8C%87%E9%92%88/","title":"【C++语法】智能指针"},{"content":"目录\n开发流程 - Gameplay层 关键步骤和解决思路 - Gameplay层 代码总体回顾及源码 复盘和总结 这一篇将详细展示Gameplay的实现思路，并在结尾处附上项目的完整源码。如需回顾整个项目的概况和整体框架的实现，请阅读本站文章《植物明星大乱斗之框架设计》。\n开发流程 - Gameplay层 主菜单界面和玩家选择界面的实现 问题1：此项目封装了摄像机类，那么负责游戏渲染的主摄像机应该放在哪里呢？ 解决思路：将摄像机以参数的形式传递给渲染的函数 问题2：如何让文本看起来有立体效果？ 解决思路：渲染两遍文本（比如一遍在原始位置渲染白色，另一遍在斜下方偏移一点的地方渲染灰色） 问题3：如何让选择界面的背景上有滚动剪影的效果 解决思路：同一张图片绘制两次 游戏局内场景搭建和物理模拟实现 问题1：如何模拟“重力”效果？ 解决思路：”重力“的体现为下坠+停止，封装平台类 问题2：如何对物理碰撞数据进行可视化检查？ 解决思路：实现一个简单的”调试模式“ 问题3：玩家在某些下落情况下会出现突然向上瞬移的情况 解决思路：确保上一帧玩家的脚底位置，决定是否要在这一帧修正玩家的脚底位置 问题4：玩家出现连环跳 解决思路：确保玩家在竖直方向速度为0时，才能继续执行跳跃逻辑 子弹基类的实现 游戏玩法的本质是通过抛射物似的子弹给对手造成伤害 思路：创建子弹基类，不同的具体子弹继承自这个基类 问题：子弹在碰撞后消失的逻辑该怎么写？ 解决思路：与玩家死亡播放动画的情况类似，使用回调函数解决 子弹删除逻辑的优化 豌豆子弹类的实现 日光炸弹类、超级日光炸弹类的实现 注意：爆炸动画时素材位置的偏移调整 攻击技能的实现 使用定时器来记录冷却时间 无敌帧的实现 思路：在播放动画时，间隔显示不同的图片：正常图片 和 纯白的剪影图片 玩家状态栏的实现（生命值、能量值） 粒子系统的实现 胜负检定和结算动效 关键步骤和解决思路 - Gameplay层 主菜单界面和角色选择界面的搭建 摄像机的处理 主摄像机怎么放比较好呢？\n因为我们在此项目中封装了摄像机类，那么就需要确保每一个场景在执行渲染时，都可以获取到游戏的摄像机对象，从而根据它的位置实时渲染游戏画面。有三个思路实现：\n思路 1： 将摄像机定义在场景内部，让它作为场景类的成员变量存在。但这样的设计就很难在不同场景间共享摄像机的数据了。\n思路 2： 把摄像机对象定义为全局变量，和此项目中图片等资源一样，我们可以通过 extern 关键字获取它。不过，全局变量终究是一种杂乱的设计，与我们想要尽量封装数据、减少全局变量的目标相悖。\n思路 3： 参考帧更新中将时间的流逝 delta 以参数的方式传递给函数的思路 void on_update(int delta) ，我们选择把摄像机对象也作为参数在渲染时传递进来 void on_draw(const Camera\u0026amp; camera)。\n主菜单界面的实现 确定了摄像机的处理方法后，主菜单的完整代码是这样的：\n1 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 extern IMAGE img_menu_background; extern SceneManager scene_manager; class MenuScene : public Scene { public: MenuScene() = default; ~MenuScene() = default; // 重写Scene基类中必要的虚函数 void on_enter() { mciSendString(_T(\u0026#34;play bgm_menu repeat from 0\u0026#34;), NULL, 0, NULL); } void on_update(int delta) { } void on_draw(const Camera\u0026amp; camera) { putimage(0, 0, \u0026amp;img_menu_background); } void on_input(const ExMessage\u0026amp; msg) { if (msg.message == WM_KEYUP) { mciSendString(_T(\u0026#34;play ui_confirm from 0\u0026#34;), NULL, 0, NULL); scene_manager.switch_to(SceneManager::SceneType::Selector); } } void on_exit() { } private: }; 角色选择界面的实现 角色种类的设计技巧 我们首先用枚举定义了玩家可选择的角色种类。此处为什么要加上一个名为“无效”的玩家呢？它可以作为确保我们在实现选择玩家的时候，我们的选择不会越界。\n1 2 3 4 5 6 enum class PlayerType { Peashooter = 0, Sunflower, Invalid }; 以玩家1的向左切换为例，我们首先将玩家角色类型的枚举变量转为 int 类型并减小1，然后加上同样转为 int 类型的 Invalid 枚举，来确保这个值始终大于等于0。随后将这个得到的值对 Invalid 枚举对应的 int 类型进行取模，确保最终的结果不会大于或等于 Invalid 的值。最后，将这一波操作得到的 int 值再转为 PlayerType 枚举类后赋值给玩家1的角色类型变量。这样我们就可以确保在按下向左切换键后，玩家类型发生了变化，同时这个值还在枚举类的第一个值和最后一个值之间，即 [Peashooter, Invalid)的前闭后开区间。\n1 2 3 4 5 6 7 8 9 10 case WM_KEYUP: switch (msg.vkcode) { case 0x41: // \u0026#39;A\u0026#39; is_btn_1P_left_down = false; player_type_1 = (PlayerType)(((int)PlayerType::Invalid + (int)player_type_1 - 1) % (int)PlayerType::Invalid); mciSendString(_T(\u0026#34;play ui_switch from 0\u0026#34;), NULL, 0, NULL); break; // ...... } 将摄像机封装进Animation类 因为所有的动画渲染时都需要先获取摄像机的位置，并且这部分逻辑在所有渲染动画时都要使用。那么根据 面向对象的封装特性，我们干脆把获取摄像机位置、并与自身坐标作差的这部分逻辑，直接放到Animation类中，于是动画类的 on_draw方法需要改成 void on_draw(const Camera\u0026amp; camera, int x, int y) const 。\n背景剪影滚动效果的实现 一种比较简单的实现思路是将这张图片渲染两次。我们想象一条用来定位的竖直线条，这根线条从矩形的左侧滚向矩形的右侧。当达到矩形右侧时，线条又会闪现回到矩形左侧。我们在线的左侧绘制一次图片，然后在线的右侧又绘制一次图片，这样就实现了图片的连续滚动效果。\n数据更新部分具体的代码是这样的：\n1 2 3 4 5 6 7 8 9 10 11 12 void on_update(int delta) { animation_peashooter.on_update(delta); animation_sunflower.on_update(delta); // 背景滚动效果 selector_background_scroll_offset_x += 5; if (selector_background_scroll_offset_x \u0026gt;= img_peashooter_selector_background_left.getwidth()) { selector_background_scroll_offset_x = 0; } } 在绘制部分，我们首先需要重载一个 putimage_alpha 函数来对图片进行裁剪绘制：\n1 2 3 4 5 6 7 8 inline void putimage_alpha(int dst_x, int dst_y, int width, int height, IMAGE* img, int src_x, int src_y) { int w = width \u0026gt; 0 ? width : img-\u0026gt;getwidth(); int h = height \u0026gt; 0 ? height : img-\u0026gt;getheight(); AlphaBlend(GetImageHDC(GetWorkingImage()), dst_x, dst_y, w, h, GetImageHDC(img), src_x, src_y, w, h, { AC_SRC_OVER, 0 , 255, AC_SRC_ALPHA }); } 然后在玩家1的背景图上滚动玩家2的剪影，在玩家2的背景图上滚动玩家1的剪影。所以最终的绘制部分代码是这样的：\n1 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 void on_draw(const Camera\u0026amp; camera) { IMAGE* img_P1_selector_background = nullptr; IMAGE* img_P2_selector_background = nullptr; // 互相为对方的滚动背景图赋值 switch (player_type_2) { case PlayerType::Peashooter: img_P1_selector_background = \u0026amp;img_peashooter_selector_background_right; break; case PlayerType::Sunflower: img_P1_selector_background = \u0026amp;img_sunflower_selector_background_right; break; default: img_P1_selector_background = \u0026amp;img_peashooter_selector_background_right; break; } switch (player_type_1) { case PlayerType::Peashooter: img_P2_selector_background = \u0026amp;img_peashooter_selector_background_left; break; case PlayerType::Sunflower: img_P2_selector_background = \u0026amp;img_sunflower_selector_background_left; break; default: img_P2_selector_background = \u0026amp;img_peashooter_selector_background_left; break; } putimage(0, 0, \u0026amp;img_selector_background); // 绘制动态背景图 putimage_alpha(getwidth() - selector_background_scroll_offset_x, 0, img_P2_selector_background); putimage_alpha(getwidth() - img_P2_selector_background-\u0026gt;getwidth(), 0, img_P2_selector_background-\u0026gt;getwidth() - selector_background_scroll_offset_x, 0, img_P2_selector_background, selector_background_scroll_offset_x, 0); putimage_alpha(selector_background_scroll_offset_x - img_P1_selector_background-\u0026gt;getwidth(), 0, img_P1_selector_background); putimage_alpha(selector_background_scroll_offset_x, 0, img_P1_selector_background-\u0026gt;getwidth() - selector_background_scroll_offset_x, 0, img_P1_selector_background, 0, 0); putimage_alpha(pos_img_VS.x, pos_img_VS.y, \u0026amp;img_VS); // ...... } 物理引擎的实现 平台类的设计 从功能入手考虑”平台“碰撞器的设计。在大多数2D平台类游戏的设计中，平台大多数被设计成 单向碰撞，也就是说，玩家从上方坠落时可以正常落到平台上；而当玩家从平台下方向上跳跃时，就可以穿过平台站立在平台上。所以我们只需要考虑玩家可以”站在哪里“即可，于是将平台碰撞器抽象为 一条线。\n此处需要注意的是，虽然我们在碰撞器的结构体中已经记录了平台的位置，但是还是需要单独去记录平台的渲染位置。这是因为平台的图片素材是有厚度的，并且这些图片的最顶部不一定就是碰撞线所处的位置。一般来说，碰撞的检测线位于平台图片内部稍偏上一点 的地方，这样在画面上才更符合玩家的直觉。同时，这种平台类的设计也和我们总体的设计思路 数据逻辑和渲染分离 相一致。当我们在碰撞检测时，我们只需要关注CollisionShape 的数据部分；当我们进行绘图时，我们也只需要关注图片和绘制位置的信息即可。这就进一步实践了 解耦合。\n为物理碰撞添加一个简单的调式模式，只需要在该模式下绘制出碰撞器的形状即可。最终，平台类的完整代码是这样的：\n1 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 extern bool is_debug; class Platform { public: struct CollisionShape // 描述碰撞器形状 { float y; float x_left; float x_right; }; public: CollisionShape shape; // 数据逻辑和渲染分离，实现解耦合 IMAGE* img = nullptr; POINT render_position = { 0 }; public: Platform() = default; ~Platform() = default; void on_draw(const Camera\u0026amp; camera) const { putimage_alpha(camera, render_position.x, render_position.y, img); // 根据是否在debug模式来决定是否要绘制碰撞线 if (is_debug) { setlinecolor(RGB(255, 0, 0)); line(camera, (int)shape.x_left, (int)shape.y, (int)shape.x_right, (int)shape.y); } } private: }; 玩家基类的设计 把所有玩家都具备的数据和逻辑封装在玩家基类中。而豌豆射手、向日葵等具体的角色，则分别继承自Player基类，实现自己的逻辑。\n如何实例化玩家对象？\n因为有两个类别的玩家角色，游戏中具体实例化哪个玩家类，取决于玩家在游戏的选角阶段选择了哪一个角色，所以我们需要在玩家角色选择界面执行这部分的逻辑。同时，我们还需要在游戏局内的场景中使用它们。所以最简单的思路就是把它和之前那些生命周期同样跨越多个场景的对象一样，定义在全局环境中。所以在角色选择界面的相关代码是这样的：\n1 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 // SelectorScene.h class SelectorScene : public Scene { public: // ...... void on_exit() { // 根据玩家选择的角色，实例化对应的玩家对象，用于Game Scene中 switch (player_type_1) { case PlayerType::Peashooter: player_1 = new PeashooterPlayer(); break; case PlayerType::Sunflower: player_1 = new SunflowerPlayer(); break; } switch (player_type_2) { case PlayerType::Peashooter: player_2 = new PeashooterPlayer(); break; case PlayerType::Sunflower: player_2 = new SunflowerPlayer(); break; } } // ...... } 另外，我们还需要记录玩家在世界坐标中的位置 Vector2 position; 另外需要添加玩家角色的动画相关的定义，这些动画具体使用哪个图集渲染，放到具体的子类中实现。读取玩家的按键操作并将按键消息映射到对应的逻辑这部分代码也应该放到玩家基类中。那么与玩家键位控制相关的玩家序号该怎么写呢？在设计上，我们把 玩家序号 作为玩家对象的一个成员，只需要在按键操作时根据序号执行不同逻辑即可。\n平台单向碰撞检测和重力模拟 重力模拟 在自由落体的过程中，有两个关键的值：重力加速度和物体当前的速度。在整个重力模拟的过程中，场景中的物体始终都在受到重力加速度的牵引，并且有着向着竖直方向加速的趋势。所以在玩家类中还需要定义重力常量 const float gravity = 1.6e-3f;\t注意，这个数值看似很大，但其实是在游戏开发中很常见的情况。受限于我们游戏世界的尺寸、画面比例乃至于玩家手感的优化，重力极有可能被调整为一个“四不像”的数值，作为开发者，我们只需要让玩家等物体在这个值的影响下展现出正确的效果即可。和物理相关的所有代码，我们都会写在 void move_and_collide(int delta) 这个函数中。于是，重力的模拟只需要用两行代码即可实现：\n1 2 3 4 5 6 7 8 9 // Player.h // ...... protected: void move_and_collide(int delta) { velocity.y += gravity * delta; position += velocity * (float)delta; } // ...... 平台单向碰撞检测 玩家和平台的碰撞只需要判断直线和矩形是否在水平方向发生重合即可。具体方法：取二者最右边界的值和二者最左边界的值作差，如果结果小于二者宽度之和，那么这两个图形在水平方向上有重合部分，也就是说，玩家角色和该平台在水平方向上发生了碰撞：\n竖直方向上的碰撞更为简单：只需要确保平台碰撞检测线的 y 坐标，是否处于玩家矩形的上下边界之间即可。而当玩家和平台在水平和竖直方向上都有重合时，我们才能判断二者发生了碰撞。\n接下来，我们需要考虑如何对玩家的坐标进行修正了。既然玩家已经碰到了平台，那么玩家无论已经落下多少距离，都应该停在平台之上。如果我们只是简单粗暴地将玩家的脚底位置设置到平台上，那么在某些情况下可能会发生“穿模”的bug：玩家向上跳起，但是只有身体的一部分穿过了平台，然后开始下落，这时玩家的速度向下，同时和平台发生了碰撞，满足了目前代码的碰撞条件，如果此时直接将玩家设置到平台之上的话，那么会出现刚刚开始下落的玩家一瞬间被移到了平台之上。\n所以，我们需要确保只有玩家的整个身体都穿过了平台，随后开始下落时，才能判断玩家和平台发生了碰撞。也就是说，我们需要先获取上一帧玩家脚底的位置，只有它位于平台上方了，并且这一帧满足我们先前讨论的所有条件，才会执行修正玩家脚底坐标的逻辑。于是，完善后的代码是这样的：\n1 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 void move_and_collide(int delta) { velocity.y += gravity * delta; position += velocity * (float)delta; // 碰撞检测 if (velocity.y \u0026gt; 0) { for (const Platform\u0026amp; platform : platform_list) { const Platform::CollisionShape\u0026amp; shape = platform.shape; bool is_collide_x = (max(position.x + size.x, shape.x_right) - min(position.x, shape.x_left) \u0026lt;= size.x + (shape.x_right - shape.x_left)); bool is_collide_y = (shape.y \u0026gt;= position.y \u0026amp;\u0026amp; shape.y \u0026lt;= position.y + size.y); if (is_collide_x \u0026amp;\u0026amp; is_collide_y) { // 需要根据前一帧的脚底位置是否高于平台位置，来判断要不要将玩家的最终位置放在平台上 float delta_pos_y = velocity.y * delta; float last_tick_foot_pos_y = position.y + size.y - delta_pos_y; if (last_tick_foot_pos_y \u0026lt;= shape.y) // 玩家在上一帧跳跃高度高于平台 { position.y = shape.y - size.y; velocity.y = 0; break; } } } } } 角色技能设计 玩家技能的程序功能需求 此项目中有两种玩家角色：豌豆射手和向日葵。这两种角色都有普通攻击和特殊攻击两种攻击方式。\n豌豆射手：可以向自己的面朝的方向发射豌豆子弹，子弹命中对方可以积攒能量。能量值蓄满后可以释放特殊攻击技能，快速喷射出一连串的豌豆子弹。\n向日葵：普通攻击向着自己面朝的斜上方抛射日光炸弹。日光炸弹会受重力影响呈不太容易瞄准的曲线运动，但它在击中敌人后造成的伤害和奖励的能量更高。向日葵同样可以在能量集满后释放特殊攻击技能——在对手的头顶出召唤出巨大的日光炸弹，这个大日光炸弹有着更大的伤害范围和更高额的能量回馈。\n从游戏设计的角度，无论何种类型的玩家角色，其核心都是使用抛射物给对手造成伤害的玩法。于是，我们就可以对场景中所有的抛射物进行大一统，它们的差异无非是在动画贴图和伤害范围等数值上等等。我们可以创建Bullet基类，豌豆子弹和日光炸弹、超大型日光炸弹则分别继承这个子弹基类，然后实现各自具体的更新和渲染逻辑。\n子弹碰到敌人后消失的逻辑实现 类似于在动画类设计时玩家死亡消失的思路，子弹在碰到敌人后就应该被立即设置为无效的状态，从而防止其在后续的帧更新时与敌人发生多次碰撞。但是由于我们需要播放豌豆子弹的破裂动画和日光炸弹的爆炸动画，所以不能立即将子弹对象从场景中删除。也就是说，场景中的每一个子弹对象都有三个阶段：正常状态、无效状态、可以被删除的状态。\n正常状态 中，我们播放子弹的动画，并在每一帧中检测它与玩家的碰撞。当它与目标玩家发生碰撞后，进入到 无效状态，此时，我们不再对子弹进行碰撞检测，同时播放子弹销毁的动画。在动画播放结束后，子弹进入到可以被删除的状态，从而在场景更新时被移除掉。所以子弹的成员变量可以这样设计：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 // Bullet.h protected: Vector2 size;\t// 子弹碰撞器尺寸 Vector2 position;\t// 子弹位置 Vector2 velocity;\t// 子弹速度 int damage = 10;\t// 子弹杀伤力 // 子弹的三个状态：有效、无效、可以被删除 bool valid = true;\t// 子弹是否有效 bool can_remove = false;\t// 子弹是否可以被移除 function\u0026lt;void()\u0026gt; callback;\t// 子弹碰撞的回调函数 PlayerID target_id = PlayerID::P1;\t// 子弹碰撞的目标玩家的ID 子弹消失逻辑的优化 如果子弹只有在碰撞到玩家后才被销毁，那么没有发生碰撞的子弹就永远不会被删除掉，造成内存泄漏。所以我们还需要对已经飞到屏幕外不可见的子弹对象进行销毁。这部分的逻辑同样是所有继承自Bullet 基类的子类通用的内容，所以我们还需要定义 check_if_exceeds_screen 这一方法，在内部检测子弹的矩形边界是否已经位于屏幕矩形之外。\n完整的子弹基类的代码是这样的：\n1 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 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 extern bool is_debug; // Bullet基类 class Bullet { public: Bullet() = default; ~Bullet() = default; virtual void on_collide() { if (callback) callback(); } virtual bool check_collision(const Vector2\u0026amp; position, const Vector2\u0026amp; size) // 子弹中心坐标是否进入玩家碰撞体内部 { return this-\u0026gt;position.x + this-\u0026gt;size.x / 2 \u0026gt;= position.x \u0026amp;\u0026amp; this-\u0026gt;position.x + this-\u0026gt;size.x / 2 \u0026lt;= position.x + size.x \u0026amp;\u0026amp; this-\u0026gt;position.y + this-\u0026gt;size.y / 2 \u0026gt;= position.y \u0026amp;\u0026amp; this-\u0026gt;position.y + this-\u0026gt;size.y / 2 \u0026lt;= position.y + size.y; } virtual void on_update(int delta) { } virtual void on_draw(const Camera\u0026amp; camera) const { if (is_debug) { setfillcolor(RGB(255, 255, 255)); setlinecolor(RGB(255, 255, 255)); rectangle((int)position.x, (int)position.y, (int)(position.x + size.x), (int)(position.y + size.y)); solidcircle((int)(position.x + size.x / 2), (int)(position.y + size.y / 2), 5); } } void set_damage(int val) { damage = val; } int get_damage() { return damage; } void set_position(float x, float y) { position.x = x; position.y = y; } const Vector2\u0026amp; get_position() const { return position; } const Vector2\u0026amp; get_size() const { return size; } void set_velocity(float x, float y) { velocity.x = x; velocity.y = y; } void set_collide_target(PlayerID target) { target_id = target; } PlayerID get_collide_target() const { return target_id; } void set_callback(function\u0026lt;void()\u0026gt; callback) { this-\u0026gt;callback = callback; } // 设置子弹是否可以继续碰撞 void set_valid(bool flag) { valid = flag; } bool get_valid() const { return valid; } bool check_can_remove() const { return can_remove; } protected: bool check_if_exceeds_screen() { return (position.x + size.x \u0026lt;= 0 || position.x \u0026gt;= getwidth() || position.y + size.y \u0026lt;= 0 || position.y \u0026gt;= getheight()); } protected: Vector2 size;\t// 子弹碰撞器尺寸 Vector2 position; Vector2 velocity; int damage = 10;\t// 子弹杀伤力 // 子弹的三个状态：有效、无效、可以被删除 bool valid = true;\t// 子弹是否有效 bool can_remove = false;\t// 子弹是否可以被移除 function\u0026lt;void()\u0026gt; callback;\t// 子弹碰撞的回调函数 PlayerID target_id = PlayerID::P1;\t// 子弹碰撞的目标玩家的ID }; 注意：刚开始时，我们通常无法一次性地、 自顶而下 地这样设计一个游戏对象类。类的设计往往是 边写边改，然后通过后续使用时的查漏补缺来完善基类的内容。自顶而下地设计，在很多程度上是对编程经验的考验，随着不断地练习和经验的累积，会对这种思路越来越熟悉。\n豌豆子弹类的设计 豌豆射手只会发射一种类型的子弹，普通攻击和特殊攻击无非是子弹发射频率的不同。我们用三种不同的音效资源在豌豆子弹发生碰撞时进行随机播放，这种设计在游戏开发中很常见。比如游戏中角色的脚步声或者是射击时的枪声，都可以使用不同的音效进行播放，这种随机的效果音会让游戏显得更加自然和灵动。所以，我们需要在豌豆子弹子类中重写 on_colide 方法来为豌豆子弹添加不同的破碎声音。需要注意的是，我们在 重写父类方法且还是需要执行父类逻辑时，需要显式地调用父类的方法。于是，完整的豌豆子弹子类是这样的：\n1 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 60 61 62 63 64 65 // PeaBullet.h extern IMAGE img_pea; extern Atlas atlas_pea_break; class PeaBullet : public Bullet { public: PeaBullet() { size.x = 64, size.y = 64; damage = 10; animation_break.set_atlas(\u0026amp;atlas_pea_break); animation_break.set_interval(100); animation_break.set_loop(false); animation_break.set_callback([\u0026amp;]() { can_remove = true; }); } ~PeaBullet() = default; void on_update(int delta) { position += velocity * (float)delta; if (!valid) animation_break.on_update(delta); if (check_if_exceeds_screen()) can_remove = true; } void on_draw(const Camera\u0026amp; camera) const { if (valid) putimage_alpha(camera, (int)position.x, (int)position.y, \u0026amp;img_pea); else animation_break.on_draw(camera, (int)position.x, (int)position.y); Bullet::on_draw(camera); } void on_collide() { Bullet::on_collide(); switch (rand() % 3) { case 0: mciSendString(_T(\u0026#34;play pea_break_1 from 0\u0026#34;), NULL, 0, NULL); break; case 1: mciSendString(_T(\u0026#34;play pea_break_2 from 0\u0026#34;), NULL, 0, NULL); break; case 3: mciSendString(_T(\u0026#34;play pea_break_3 from 0\u0026#34;), NULL, 0, NULL); break; } } private: Animation animation_break;\t// 豌豆子弹破碎动画 }; 在 Bullet 基类的基础上， PeaBullet 子类只是简单扩展了一部分自己独有的逻辑，就可以相对简明地实现完整的子弹功能了。这正是面向对象中 继承 的魅力所在。\n日光炸弹类的实现 需要注意的是，爆炸动画的序列帧素材尺寸时略大于日光炸弹默认动画序列帧尺寸的，所以为了确保日光炸弹在爆炸时渲染的效果正确，我们就要保证这两个动画所在的矩形的中心对齐。而在EasyX中，我们渲染的坐标原点是矩形左上角的坐标，所以我们在爆炸动画渲染时加上一个小小的位置偏移，确保画面效果正确。\n另外，在豌豆子弹的 on_update 方法中，我们根据其速度不断更新它的位置，也就是说，无论豌豆子弹是否发生了碰撞，都会一直向前飞翔。配合豌豆子弹破碎后的动画，我们就顺手实现了子弹碰撞后的飞溅效果。日光炸弹爆炸后的动画有所不同，它应该留在原地而不会继续受重力的影响而运动。\n完整的日光炸弹类的代码是这样的：\n1 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 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 // SunBullet.h extern Atlas atlas_sun; extern Atlas atlas_sun_explode; extern Camera main_camera; class SunBullet : public Bullet { public: SunBullet() { size.x = 96, size.y = 96; damage = 20; animation_idle.set_atlas(\u0026amp;atlas_sun); animation_idle.set_interval(50); animation_explode.set_atlas(\u0026amp;atlas_sun_explode); animation_explode.set_interval(50); animation_explode.set_loop(false); animation_explode.set_callback([\u0026amp;]() { can_remove = true; }); // 处理爆炸和idle时图片的偏移问题 IMAGE* frame_idle = animation_idle.get_frame(); IMAGE* frame_explode = animation_explode.get_frame(); explode_render_offset.x = (frame_idle-\u0026gt;getwidth() - frame_explode-\u0026gt;getwidth()) / 2.0f; explode_render_offset.y = (frame_idle-\u0026gt;getheight() - frame_explode-\u0026gt;getheight()) / 2.0f; } ~SunBullet() = default; void on_update(int delta) { if (valid) { velocity.y += gravity * delta; position += velocity * (float)delta; } // 更新动画状态 if (!valid) animation_explode.on_update(delta); else animation_idle.on_update(delta); if (check_if_exceeds_screen()) can_remove = true; } void on_draw(const Camera\u0026amp; camera) const { if (valid) animation_idle.on_draw(camera, (int)position.x, (int)position.y); else { animation_explode.on_draw(camera, (int)(position.x + explode_render_offset.x), (int)(position.y + explode_render_offset.y)); } Bullet::on_draw(camera); } void on_collide() { Bullet::on_collide(); main_camera.shake(5, 250);\t// 摄像机在爆炸时的抖动效果 mciSendString(_T(\u0026#34;play sun_explode from 0\u0026#34;), NULL, 0, NULL); } private: const float gravity = 1e-3f;\t// 日光炸弹的重力 private: Animation animation_idle;\t// 日光炸弹的默认动画 Animation animation_explode;\t// 日光炸弹的爆炸动画 Vector2 explode_render_offset;\t// 爆炸时动画的渲染偏移（爆炸的图片略大于idle图片 // 使其图片中心点重合，更符合玩家的直觉 }; 超级日光炸弹类的实现 在我们的游戏中，向日葵的特殊技能是可以从屏幕外召唤超级大的日光炸弹。比起普通攻击召唤的小型炸弹，这个炸弹更大更强。它不会受到重力影响，而是缓慢下落，更多的是起到封走位的作用，方便抛射的小型日光炸弹更容易命中对手。\n我们希望超级日光炸弹的碰撞检测范围更大一些，那么就不再将子弹的中心位置坐标作为碰撞检测点了，而是使用矩形边界作为碰撞检测的范围。于是我们就需要重写 check_collision 的方法了。\n超级日光炸弹类是这样的：\n1 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 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 extern Atlas atlas_sun_ex; extern Atlas atlas_sun_ex_explode; extern Camera main_camera; class SunBulletEx : public Bullet { public: SunBulletEx() { size.x = 288, size.y = 288; damage = 20; animation_idle.set_atlas(\u0026amp;atlas_sun_ex); animation_idle.set_interval(50); animation_explode.set_atlas(\u0026amp;atlas_sun_ex_explode); animation_explode.set_interval(50); animation_explode.set_loop(false); animation_explode.set_callback([\u0026amp;]() { can_remove = true; }); // 处理爆炸和idle时图片的偏移问题 IMAGE* frame_idle = animation_idle.get_frame(); IMAGE* frame_explode = animation_explode.get_frame(); explode_render_offset.x = (frame_idle-\u0026gt;getwidth() - frame_explode-\u0026gt;getwidth()) / 2.0f; explode_render_offset.y = (frame_idle-\u0026gt;getheight() - frame_explode-\u0026gt;getheight()) / 2.0f; } ~SunBulletEx() = default; void on_update(int delta) { if (valid) { position += velocity * (float)delta; } if (!valid) animation_explode.on_update(delta); else animation_idle.on_update(delta); if (check_if_exceeds_screen()) can_remove = true; } void on_draw(const Camera\u0026amp; camera) const { if (valid) animation_idle.on_draw(camera, (int)position.x, (int)position.y); else { animation_explode.on_draw(camera, (int)(position.x + explode_render_offset.x), (int)(position.y + explode_render_offset.y)); } Bullet::on_draw(camera); } void on_collide() { Bullet::on_collide(); main_camera.shake(20, 350);\tmciSendString(_T(\u0026#34;play sun_explode_ex from 0\u0026#34;), NULL, 0, NULL); } bool check_collision(const Vector2\u0026amp; position, const Vector2\u0026amp; size)\t// 将碰撞范围扩展为一个矩形，而不是子弹的中心点 { bool is_collide_x = (max(this-\u0026gt;position.x + this-\u0026gt;size.x, position.x + size.x) - min(this-\u0026gt;position.x, position.x) \u0026lt;= this-\u0026gt;size.x + size.x); bool is_collide_y = (max(this-\u0026gt;position.y + this-\u0026gt;size.y, position.y + size.y) - min(this-\u0026gt;position.y, position.y) \u0026lt;= this-\u0026gt;size.y + size.y); return is_collide_x \u0026amp;\u0026amp; is_collide_y; } private: Animation animation_idle; Animation animation_explode; Vector2 explode_render_offset; }; 从设计角度讲，超级日光炸弹完全可以通过继承普通日光炸弹来简化代码。但此项目还是采用这种相对扁平的类继承关系，确保整体的代码结构易于理解。\n技能系统 在 Player 基类中添加 virtual void on_attack 和 virtual void on_attack_ex 两个虚函数，然后在具体的玩家子类中重写这两个虚函数的逻辑，就可以完成角色的普通攻击和特殊攻击了。\n冷却时间的实现思路 角色的普通攻击一般是存在冷却时间的，在冷却时间内，无论如何按键都无法触发攻击效果，所以我们定义一个布尔变量 can_attack 用来标识角色当前是否可以释放普通攻击，使用一个定时器来记录角色普通攻击的冷却时间，用一个 int 变量来标识玩家普通攻击的冷却时间毫秒数。这样，我们就只需要在按键消息触发时，检查当前是否可以执行普通攻击，如果可以执行，则翻转这个布尔变量并重置定时器的时间，当定时器时间到达后，再恢复玩家可攻击的状态。这样，我们就实现了普通攻击的冷却效果。\n无敌状态的实现 我们用两个布尔变量来标识角色当前是否处于“无敌状态” bool is_invulnaerable = false; 和当前帧是否需要显示玩家剪影 bool is_showing_sketch_frame = false; 我们希望当玩家处于无敌帧时，出现闪烁的动画效果，那么就需要不断切换显示当前动画序列帧和剪影动画序列帧：\n我们通过添加两个定时器来控制玩家退出无敌状态以及闪烁时两种不同序列帧的切换功能：Timer timer_ivulnerable; Timer timer_invulnerable_blink; 于是在定时器的部分在 Player 类的构造函数里进行初始化：\n1 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 // Player.h Player() { current_animation = is_facing_right; // 初始化普通攻击冷却相关成员变量 timer_attack_cd.set_wait_time(attack_cd); timer_attack_cd.set_one_shot(true); timer_attack_cd.set_callback([\u0026amp;]() { can_attack = true; }); // 初始化无敌时间定时器 timer_invulnerable.set_wait_time(750); timer_invulnerable.set_one_shot(true); timer_invulnerable.set_callback([\u0026amp;]() { is_invulnerable = false; is_showing_sketch_frame = false; // 无敌结束时确保不再显示剪影 }); // 初始化无敌动画闪烁定时器 timer_invulnerable_blink.set_wait_time(75); timer_invulnerable_blink.set_callback([\u0026amp;]() { is_showing_sketch_frame = !is_showing_sketch_frame; }); // ...... } 在 on_update 中对定时器进行更新。在 Util.h 添加一个工具函数，实现将图片处理为纯白色的剪影效果。我们读取图片对象的像素色彩缓存区，将所有像素设置为白色即可，详细的解释请见本站文章《提瓦特幸存者》的“番外篇”部分。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // Util.h inline void sketch_image(IMAGE* src, IMAGE* dst) { int w = src-\u0026gt;getwidth(); int h = src-\u0026gt;getheight(); Resize(dst, w, h); DWORD* src_buffer = GetImageBuffer(src); DWORD* dst_buffer = GetImageBuffer(dst); for (int y = 0; y \u0026lt; h; y++) { for (int x = 0; x \u0026lt; w; x++) { int idx = y * w + x; dst_buffer[idx] = BGR(RGB(255, 255, 255)) | (src_buffer[idx] \u0026amp; 0xFF000000); } } } 那么，在 on_update 中就可以添加是否需要显示剪影图片的代码：\n1 2 3 4 5 6 7 8 9 10 11 12 // Player.h virtual void on_update(int delta) { // ...... // 是否需要显示剪影图片 // 仅在无敌且需要显示剪影的帧，生成剪影图片 if (is_showing_sketch_frame) sketch_image(current_animation-\u0026gt;get_frame(), \u0026amp;img_sketch); // ...... } 并在 on_draw 中进行相应绘制：\n1 2 3 4 5 6 7 8 9 10 11 // Player.h virtual void on_draw(const Camera\u0026amp; camera) { // ...... if (hp \u0026gt; 0 \u0026amp;\u0026amp; is_invulnerable \u0026amp;\u0026amp; is_showing_sketch_frame) putimage_alpha(camera, (int)position.x, (int)position.y, \u0026amp;img_sketch); else current_animation-\u0026gt;on_draw(camera, (int)position.x, (int)position.y); // ...... } 最后，在 Player 类中添加触发无敌状态的逻辑入口：\n添加一个让玩家进入无敌状态的方法：\n1 2 3 4 5 6 7 8 9 // Player.h void make_invulnerable() { is_invulnerable = true; timer_invulnerable.restart(); is_showing_sketch_frame = true; // 立即进入“显示剪影”的半帧 timer_invulnerable_blink.restart(); // 开始闪烁 } 然后在 move_and collide 方法的子弹碰撞部分，在子弹发生碰撞后添加让玩家进入无敌状态的代码：\n1 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 // Player.h void move_and_collide(int delta) { // ...... // 玩家被子弹射中受到伤害 if(!is_invulnerable) { for (Bullet* bullet : bullet_list) { if (!bullet-\u0026gt;get_valid() || bullet-\u0026gt;get_collide_target() != id) continue; if (bullet-\u0026gt;check_collision(position, size)) { make_invulnerable();\t// 开启角色无敌状态 bullet-\u0026gt;on_collide(); bullet-\u0026gt;set_valid(false); hp -= bullet-\u0026gt;get_damage(); // ...... } } // ...... }\t粒子系统的实现 粒子系统 是一种使用大量微小粒子的图元作为基本元素来描述不规则对象的技术。在很多游戏中，烟雾、火焰、雨雪等效果的实现都是依托在粒子系统上完成的。在分析和解剖粒子系统时，我们可以从两个角度入手：粒子对象 本身和 粒子发射器。\n粒子对象：通常由动画、物理和生命周期等众多属性来描述。\n粒子发射器：决定粒子对象的生成方式。如粒子的发生频率、发射方向和初始速度等。\n我们将尝试封装一个简单可用的粒子系统。粒子对象可以看作是一个特殊的动画对象，它与角色、子弹等动画的区别在于，粒子在发射后，世界坐标的位置就固定下来了，不会随着游戏的更新而移动。在播放完自身的动画后，粒子的寿命便终止了，就可以从场景中被移除掉了。\n完整的粒子类代码是这样的：\n1 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 class Particle { public: Particle() = default; Particle(const Vector2\u0026amp; position, Atlas* atlas, int lifespan) : position(position), atlas(atlas), lifespan(lifespan) { } ~Particle() = default; void on_update(int delta) { timer += delta; if (timer \u0026gt;= lifespan) { timer = 0; idx_frame++; if (idx_frame \u0026gt;= atlas-\u0026gt;get_size()) { idx_frame = atlas-\u0026gt;get_size() - 1; valid = false; } } } void on_draw(const Camera\u0026amp; camera) const { putimage_alpha(camera, (int)position.x, (int)position.y, atlas-\u0026gt;get_image(idx_frame)); } void set_atlas(Atlas* new_atlas) { atlas = new_atlas; } void set_position(const Vector2\u0026amp; new_position) { position = new_position; } void set_lifespan(int ms) { lifespan = ms; } bool check_valid() const { return valid; } private: int timer = 0;\t// 粒子动画播放定时器 int lifespan = 0;\t// 单帧粒子动画持续时间 int idx_frame = 0;\t// 当前正在播放的动画帧 Vector2 position;\t// 粒子的世界坐标 bool valid = true;\t// 粒子对象是否有效 Atlas* atlas = nullptr;\t// 粒子动画所使用的图集 }; 代码总体回顾及源码 Scene 基类 在这个项目中首次引入了场景设计的概念。游戏的不同阶段和不同界面对应到不同的场景中。在场景基类 Scene.h 中，on_enter 和 on_exit 方法分别对应着场景的初始化逻辑和退出逻辑。on_input、on_update和 on_draw 分别对应主循环的输入、更新和绘图三个阶段并传入了各自所需的参数。具体的场景子类只需要继承后重写自己的逻辑即可。而场景管理器的设计则用来控制游戏当前正在运行的场景。我们在跳转场景时使用了枚举来屏蔽具体的场景指针，而场景管理器同样有 on_input、on_update和 on_draw 三个方法来调用当前场景实例的对应逻辑 。\nmain 函数 在 main.cpp 中，主要实现了两部分功能：资源加载 和 游戏入口函数。首先加载游戏所需的像素字体，随后加载游戏中动画的图片资源并对需要进行左右朝向翻转的素材进行了处理，最后再载入游戏的背景音乐和音效。在进程的入口函数中，我们调用资源加载函数并实例化此场景，最后在游戏的主循环中调用场景管理器的各个阶段逻辑入口方法。\nAtlas 图集类 在游戏的动画实现上，首先封装了Atlas 图集类。图集更像是容器的概念，用来批量加载和存储一套动画所需的图片素材。而 Animation 动画类则更像是一个只记录动画当前播放进度的轻量管理器。在渲染时具体绘制哪张图片则需要实时地去对应的图集类中获取。除此之外，Animation类 还记录着帧间隔、循环播放和播放结束逻辑等动画数据。\nCamera 摄像机类 在动画渲染时，使用自行封装的 Camera 摄像机对象。摄像机在此项目的设计中只是充当了绘图时的相对定位锚点，当这个定位点的位置在一定范围内随机跳跃时，我们绘图的内容也在快速地抖动，这就实现了摄像机震动的效果。为了更简洁地编写使用摄像机进行绘图的代码，在 Util.h 中实现了使用摄像机进行透明贴图绘制的函数重载，以及翻转图片和生成纯白剪影效果的图像处理代码逻辑。\nTimer 定时器类 和 Vector2 二维向量类 除此之外，游戏框架基础架构还有两个很关键的封装：定时器类和二维向量类。在 Timer.h 中封装了定时器的逻辑：定时器是一个在游戏中记录时间流逝的对象，当时间到达预设的时间的那一帧便会调用指定的回调函数逻辑。二维向量类则封装着和2D向量有关的数学运算：除去四则运算的重载外，还有获取向量长度以及标准化向量的方法。\n场景子类：MenuScene、SelectorScene和GameScene 游戏启动后首先来到菜单场景。菜单场景十分简单，只需要播放音乐并处理跳转逻辑即可。而在随后的角色选择场景，我们使用了大量代码来实现硬编码的界面元素布局、在绘图时需要根据玩家选择的不同角色枚举值绘制不同的角色对象和动态的背景图、在输入时也是根据玩家1和玩家2的不同键位的键码值将角色的枚举值循环切换。而在角色选择界面退出时，我们根据当前两位玩家的枚举值来实例化不同的角色并设置头像和玩家ID。玩家ID也是使用枚举进行记录的，我们在 PlayerID.h 中进行了定义。\n在游戏的局内场景中，我们需要处理的逻辑可以分为三部分：\n与游戏世界相关的内容，如背景图和平台\n与玩家角色相关的内容，如角色本身和飞行在场景中的子弹\n与游戏胜负相关的内容，如实时监测角色的位置和生命值，并在游戏结束时渲染结算条幅\n调试模式的开启与关闭也是在游戏场景中进行控制的。\nPlayer 玩家基类 在玩家角色的设计中，我们定义了Player基类来描述共有的逻辑。在画面表现上，玩家角色拥有闲置、奔跑、攻击、死亡等不同的动画效果，以及起跳、落地和奔跑时的粒子特效等内容。在数据逻辑上，玩家的普通攻击和特殊攻击分别由冷却时间定时器和能量值进行控制。奔跑、起跳和落地等逻辑也封装成对应的方法，方便触发时在其中修改角色速度和更新特效动画。除此之外，玩家角色在受伤后会有一小段无敌时间并产生闪烁的画面效果，我们同样使用定时器来进行控制。\n与物理模拟相关的逻辑封装到了 move_and_collide 方法中：我们根据重力和当前的速度值更新了玩家的位置 ，并进行了平台和子弹的碰撞检测。\nPeashooter 子类 和 Sunflower 子类 有了Player基类的基础，豌豆射手的子类实现就简单很多了，只需要配置其动画属性并设置攻击时角色在场景中生成子弹的逻辑。我们还用了随机音效来优化游戏效果。\n向日葵子类的实现也是同理：在配置完成动画属性后，重写其普通攻击和特殊攻击的逻辑。只不过向日葵在特殊攻击时头顶会有额外的“日”字动画，所以我们重写了它的渲染方法。\nBullet 子弹基类 对于玩家角色所发射的子弹同样定义了Bullet基类进行抽象。子弹与玩家一样，需要在游戏场景中连贯运动，还需要模拟受重力影响的抛射，所以我们还是优先描述其速度，并在更新时记录其位置。 除此之外，子弹还要有伤害值、碰撞目标和碰撞回调等字段。\nPeaBullet 子类 和 SunBullet 子类、SunBulletEx 子类 在豌豆子弹的实现中，我们重写了破碎的方法，播放了随机的音效，并在渲染时根据当前是否已经破碎选择了不同的渲染逻辑。小型热光炸弹也是相似的思路，只不过我们需要在它未碰到爆炸时模拟受重力影响的坠落运动，并在爆炸时调整爆炸动画的中心位置对齐。大型热光炸弹与小型热光炸弹除去尺寸、动画等数值字段不同外，更新时的区别是需要让它匀速竖直下落而不是在重力影响下加速运动。\nParticle 粒子类 粒子对象在目前的设计中可以看作是一种特殊的动画对象，它在自身的动画播放结束后生命周期便结束了。所以在更新时的逻辑与动画对象十分相似。\nPlatform 平台类 平台对象Platform在数据层面本质是一条存在于世界中的水平直线，在渲染时我们使用平台图片素材进行绘制并根据当前是否启用了调试选择绘制额外的信息。\nStatusBar 玩家状态类 玩家的状态栏组件有三部分构成：玩家头像、生命值状态条和能量值状态条。在游戏场景运行的过程中，我们实时地从对应的玩家对象中读取这些字段的数据并显示到界面组件上。\n至此，这一项目的笔记全部完结。\n完整源码 on Gitee\n复盘和总结 这是我第一次学习一个完整的游戏项目是如何自顶而下设计的。一开始的框架部分感觉很难想到，也因为缺少像游戏层这样可以频繁而又直观快捷地进行测试而感到没有把握。但是一旦基础的框架搭建完成，后面的游戏层实现就变成了水到渠成的事情，有一种顺风顺水的畅快感。即使有了前面 提瓦特幸存者 项目的基础，这个项目还是充斥着大量的实现细节，稍不留神就会出现意想不到的bug，有一些实现思路如果让我自己来想的话也很难想到或者很容易踩坑，好在老师讲解清晰、代码简明易懂，让人很好理解。这个项目再次让我深深意识到自己目前在编程的认知阶段：懂了一点后发现，自己不懂的和不足的还有很多很多。所以需要持续保持锤炼和学习。\n","date":"2025-10-12T09:17:30+02:00","image":"https://nullshowjl.github.io/p/%E4%BB%8E%E9%9B%B6%E5%BC%80%E5%A7%8B%E7%9A%84c-%E6%B8%B8%E6%88%8F%E5%BC%80%E5%8F%91%E6%A4%8D%E7%89%A9%E6%98%8E%E6%98%9F%E5%A4%A7%E4%B9%B1%E6%96%97%E4%B9%8Bgameplay%E5%B1%82%E5%AE%9E%E7%8E%B0/cover_hu_28ce2cc1238db636.webp","permalink":"https://nullshowjl.github.io/p/%E4%BB%8E%E9%9B%B6%E5%BC%80%E5%A7%8B%E7%9A%84c-%E6%B8%B8%E6%88%8F%E5%BC%80%E5%8F%91%E6%A4%8D%E7%89%A9%E6%98%8E%E6%98%9F%E5%A4%A7%E4%B9%B1%E6%96%97%E4%B9%8Bgameplay%E5%B1%82%E5%AE%9E%E7%8E%B0/","title":"【从零开始的C++游戏开发】植物明星大乱斗之Gameplay层实现"},{"content":"目录\n概览 核心玩法 设计思路 开发流程 - 框架部分 关键步骤和解决思路 - 框架部分 此项目大约有两千余行代码且知识点繁多，所以分为两篇来写。这一篇是上半部分，介绍整个项目的概况，在关键步骤的内容上则侧重框架部分的设计和实现。\n概览 技术栈：C++ + EasyX\n项目目标：使用多个头文件的项目组织结构，引入“场景”这一设计来划分游戏的不同阶段，实现功能间的解耦合。了解简单的物理模拟实现思路，并完成单向碰撞平台的功能。封装 摄像机、定时器 和 粒子系统 等在游戏开发中热度很高的功能，进一步推进项目作品水准的专业化。同时，实现更灵动的动画效果和更丰富的音效，提升项目的完成度。\n课程来源：B站-Voidmatrix\n核心玩法 两位玩家可以使用不同的角色进行本地双人对战，在平台间跳跃和移动，使用效果各异的普通攻击和特殊攻击给对方造成伤害，并获取能量奖励，能量蓄满后可以释放不同技能。生命值归零或坠落到平台下方的玩家会被击败。\n设计思路 多个头文件的使用 随着项目体量的增大，如果将全部的代码都放到 main.cpp 中会显得十分臃肿，不仅会导致命名空间和依赖关系混乱，在调试和修改时也不容易定位具体的代码位置，所以需要将它们“分而治之”。所以本项目开始会将游戏不同阶段的代码放到不同的类中进行封装，并将不同的类代码存放在不同的头文件中。\n雷点——重复引用：\n当我们使用 #include 去包含头文件时，编译器会将所有的这个头文件中的内容原封不动地替换在 #include 地位置，这个过程是一个纯粹的文本内容的复制粘贴。所以，我们一个头文件 A.h 中包含了头文件B.h，main.cpp又同时包含了A.h和B.h，由于A.h中已经有了B.h的内容，所以在main.cpp中会有两份B.h的内容，如果涉及到类定义等代码，就会出现重复定义的问题，导致编译器无法编译。\n避坑方法：\n使用预处理指令 #pragma once：编译器会确保同一个头文件里的内容不会被重复包含两次\n使用 #ifndef指令对头文件的内容进行包含：如果没有定义对应头文件名称的宏就定义这个宏并包含代码文件，反之则不进行任何操作\n完整的写法示例：\n1 2 3 4 #ifndef _SCENE_H_ #define _SCENE_H_ #endlif // !_SCENE_H_ 这两种方法在绝大多数情况下是可以互相替换的。\n两者的区别：\n特性 #ifndef / #define #pragma once 兼容性 所有标准 C/C++ 编译器都支持 大多数现代编译器支持，但不是标准 实现方式 通过宏定义判断是否已包含 编译器内部记录是否已处理该文件 文件名依赖 不依赖文件名，只依赖宏名 依赖文件路径，可能受硬链接或符号链接影响 冲突风险 宏名需唯一，命名冲突可能导致问题 无需命名，避免冲突 编译速度 稍慢（需解析宏） 更快（直接跳过） 游戏主框架的设计 创建窗口、创建游戏主循环和稳定帧率，这个写法基本是游戏开发框架的固定格式：\n1 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 int main() { //========= 初始化数据 ========== initgraph(1280, 720); bool is_running = true; const int FPS = 60; ExMessage msg; BeginBatchDraw(); while (is_running)\t// 游戏主循环 { DWORD start_time = GetTickCount(); //========= 处理输入 ========= while (peekmessage(\u0026amp;msg)) { }\t//======== 处理更新 ========= cleardevice(); //======== 处理渲染 ========= FlushBatchDraw(); //========= 稳定帧率 ========= DWORD end_time = GetTickCount(); DWORD delta_time = end_time - start_time; if (delta_time \u0026lt; 1000 / FPS) { Sleep(1000 / FPS - delta_time); } } EndBatchDraw(); return 0; } 各个对象/类的设计 本项目开始引入架构概念，从分析功能入手，设计良好的程序结构，让项目扩展性更好且更利于在开发中调试。\n场景基类 游戏主菜单 玩家角色选择菜单 局内场景 场景管理器 图集类 动画类 二维向量类 摄像机类 定时器类 平台类 玩家基类 豌豆射手类 向日葵类 玩家ID枚举类 子弹基类 豌豆子弹类 日光炸弹类 超级日光炸弹类 玩家状态栏类 粒子类 开发流程 - 框架部分 游戏框架的设计 场景框架的设计 解决思路：使用各个游戏子类继承基类Scene的方式，对场景进行更灵活的管理 问题：如何实现场景间的切换 解决思路：使用场景管理器 资源加载的实现 问题1：如何方便动画管理，并实现资源的可复用性 解决思路：Atlas类和Animation类 问题2：敌人死亡时的动画逻辑应该怎么写才能播放死亡动画？ 解决思路：使用回调函数 问题：怎样让游戏画面更加灵活和自然 解决思路：实现摄像机类 问题：如何更精确地记录摄像机的位置 解决思路：不使用EasyX自带的POINT类（坐标数据类型为整型），封装一个Vector2类，其坐标位置为浮点数类型 问题：如何表达打击的视觉特效 解决思路：实现摄像机抖动效果 问题：除了动画和摄像机抖动外，还有很多场景需要用到定时器（比如玩家特殊技能、攻击冷却时间等） 解决思路：为了复用的方便，封装定时器类，对有时效性的功能提供相对统一的管理模式 关键步骤和解决思路 - 框架部分 场景设计 如果将场景比喻成舞台上的一幕，那么在不同的慕中会有不同的“剧本”逻辑和不同的角色登场，这些角色就是游戏开发中常说的 GameObject，玩家、敌人、子弹、道具\u0026hellip;\u0026hellip;等等这些从概念上都属于GameObject的范畴，接受着不同的场景剧本的指挥、进行不同逻辑的演出。\n所以一个游戏从程序的流程上可以划分出 游戏主菜单、玩家角色选择界面、游戏局内界面，由此可以定义Scene这个基类，主菜单、角色选择场景和局内游戏场景分别可以继承自Scene这个基类，实现不同的事件处理和绘图逻辑。\nScene基类 将所有的成员方法都定义为虚方法，那么具体的游戏场景类在继承了这个基类后，就可以通过重写自己相应的方法实现自己的逻辑。Scene基类在这个过程中，就像是其他具体用于实例化的场景子类的模板。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 class Scene { public: Scene() = default; ~Scene() = default; virtual void on_enter() {}; // 进入场景 virtual void on_update(int delta) {};\t// 处理更新 virtual void on_draw() {};\t// 处理渲染 virtual void on_input(const ExMessage\u0026amp; msg) {};\t// 处理玩家输入 virtual void on_exit() {}; // 退出场景 private: }; 主菜单场景类MenuScene 可以重写所需要的成员方法：\n1 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 class MenuScene : public Scene { public: MenuScene() = default; ~MenuScene() = default; // 重写Scene基类中必要的虚函数 void on_enter() { cout \u0026lt;\u0026lt; \u0026#34;进入主菜单\u0026#34; \u0026lt;\u0026lt; endl; } void on_update(int delta) { cout \u0026lt;\u0026lt; \u0026#34;主菜单正在运行......\u0026#34; \u0026lt;\u0026lt; endl; } void on_draw() { outtxtxy(10, 10, _T(\u0026#34;主菜单绘制内容\u0026#34;))； } void on_input(const ExMessage\u0026amp; msg) { } void on_exit() { cout \u0026lt;\u0026lt; \u0026#34;主菜单退出\u0026#34; \u0026lt;\u0026lt; endl; } private: }; 在 main.cpp 中实例化：\n1 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 int main() { initgraph(1280, 720, EW_SHOWCONSOLE);\t// 保留控制台界面 BeginBatchDraw(); Scene* scene = new MenuScene();\t//在主循环前实例化 while (running) { DWORD frame_start_time = GetTickCount(); while (peekmessage(\u0026amp;msg)) { scene-\u0026gt;on_input(msg); } // 游戏逻辑的更新 scene-\u0026gt;on_update(); cleardevice(); // 绘制游戏页面 scene-\u0026gt;on_draw(); FlushBatchDraw(); DWORD frame_end_time = GetTickCount(); DWORD frame_delta_time = frame_end_time - frame_start_time; if (frame_delta_time \u0026lt; 1000 / FPS) { Sleep(1000 / FPS - frame_delta_time); } } EndBatchDraw(); return 0; } 所有的场景子类的设计逻辑都一样。\n场景管理器的实现 游戏程序是一个巨大的死循环，也是一个巨大的状态机。不同的游戏场景代表着不同状态，管理着这些状态的状态机，在游戏开发中被称为 场景管理器。\n1 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 class SceneManager { public: enum class SceneType\t// 标记当前场景状态 { Menu, Game, Selector }; public: SceneManager() = default; ~SceneManager() = default; // 设置当前场景 void set_current_scene(Scene* scene) { current_scene = scene; current_scene-\u0026gt;on_enter();\t// 确保场景执行流程完整 } // 切换场景（先退出现在的场景，然后选择、进入新的场景） void switch_to(SceneType type) { current_scene-\u0026gt;on_exit();\t// 先退出当前场景 switch (type) { case SceneType::Menu: current_scene = menu_scene; break; case SceneType::Game: current_scene = game_scene; break; case SceneType::Selector: current_scene = selector_scene; break; default: break; } current_scene-\u0026gt;on_enter();\t// 找到需要的新的场景后，进入 } void on_update(int delta) { current_scene-\u0026gt;on_update(delta); } void on_draw() { current_scene-\u0026gt;on_draw(); } void on_input(const ExMessage\u0026amp; msg) { current_scene-\u0026gt;on_input(msg); } private: Scene* current_scene = nullptr; }; on_enter 和 on_exit在功能上和构造函数和析构函数类似，都是用来初始化和释放资源的，所以为什么不直接使用构造函数和析构函数呢？原因是，构造函数和析构函数决定着场景对象在内存中的生命周期。如果直接使用构造函数和析构函数来执行进入和退出的全部逻辑，那么我们在场景跳转时就需要不断地构造新的对象、释放旧的对象，这并不是性能友好的行为。同时，随着程序逻辑越来越复杂，可能会存在有些资源在不同场景之间都有引用，也就是说，场景内对象的生命周期可能会长于场景对象本身的生命周期 的情况，这也对内存管理提出了更高的要求。所以采用这种方式是更通用且简明的设计思路：场景对象的生命周期与游戏的生命周期相同。也就是说，在游戏初始化时创建所有的场景对象，在游戏退出时释放所有的场景对象，而在游戏内部的场景跳转过程中，我们避免对场景类构造函数和析构函数的调用，转而提供语义明确的 on_exit 和 on_enter 方法。在这两个方法中，我们也要尽可能避免对场景内部的成员的对象进行构造和析构，而是取而代之，重置它们的状态。\n举个例子：在游戏场景对象中有一个玩家对象成员，当玩家生命值归零时，程序从游戏场景跳转到主菜单场景。如果我们使用构造函数和析构函数的设计思路，那么就需要在这时 delete 游戏场景对象并且 new 一个主菜单场景对象，这样才能确保当我们再次回到游戏场景时，游戏场景中的玩家对象生命值是正常的，而不是上次退出时的0。而如果我们使用on_exit 和 on_enter 的思路，就不需要频繁地构造和释放场景对象：在场景进入时只需重置玩家生命值变量的值，也就是重置场景内部的状态，就可以同样达到“焕然一新”的效果。\n为什么在设置当前场景时参数是场景指针，而跳转场景时用场景枚举呢？\n采用这样设计的原因：设置当前场景的方法一般只在游戏初始化时设置场景管理器的入口场景时才被调用，与子场景实例化几乎同时进行，所以直接使用指针更加方便。而跳转场景的方法一般是在不同的子场景内部更新时被调用，子场景之间持有彼此的引用，如果也用指针容易出现内存问题，所以使用枚举来屏蔽管理器内部的指针操作。\n至此，main 中的场景部分代码就可以完全交给场景管理器执行了：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 menu_scene = new MenuScene(); game_scene = new GameScene(); selector_scene = new SelectorScene(); scene_manager.set_current_scene(menu_scene); BeginBatchDraw(); while (running) { DWORD frame_start_time = GetTickCount(); while (peekmessage(\u0026amp;msg)) { scene_manager.on_input(msg); } scene_manager.on_update(); cleardevice(); scene_manager.on_draw(main_camera); FlushBatchDraw(); 具体的场景跳转逻辑则放到各个场景的on_input 消息处理逻辑中完成：\n1 2 3 4 5 6 7 8 // MenuScene.h void on_input(const ExMessage\u0026amp; msg) { if (msg.message == WM_KEYUP) { scene_manager.switch_to(SceneManager::SceneType::Selector); } } 资源加载部分的设计 Atlas类的实现 Atlas类可以看作是有一个装载具有相关性的一系列图片资源的容器：\n1 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 class Atlas { public: Atlas() = default; ~Atlas() = default; void load_from_file(LPCTSTR path_template, int num) { // 注意：在加载前，先清空图片对象列表 // 并将列表长度设置为指定大小 // 这样可以避免load_from_file函数在重复调用时出现内部数据与预期不符的问题 img_list.clear();\timg_list.resize(num); TCHAR path_file[256]; for (int i = 0; i \u0026lt; num; i++) { _stprintf_s(path_file, path_template, i + 1); loadimage(\u0026amp;img_list[i], path_file); } } void clear()\t// 清空图集中已加载的图片对象 { img_list.clear(); } size_t get_size()\t// 获取图集中图片的数量 { return img_list.size(); } IMAGE* get_image(int idx)\t// 获取实际渲染的动画帧 { if (idx \u0026lt; 0 || idx \u0026gt;= img_list.size()) return nullptr; return \u0026amp;img_list[idx];\t// 返回对应索引图片的地址 } // 向图集中添加已有的图片对象 // 看似和从文件加载图片的方法功能上有重复 // 但在生成水平翻转的动画图集时很有用 void add_image(const IMAGE\u0026amp; img)\t{ img_list.push_back(img); } private: vector\u0026lt;IMAGE\u0026gt; img_list; }; 资源的加载 在正式开始写Animation类前，还需要一个处理动画帧的水平翻转，这样就无需准备两套动画素材了。图片素材的翻转需要对图片像素进行逐个处理，是一个比较耗时的操作，所以需要放在 游戏初始化阶段 进行，不要放到游戏已经开始的帧更新中进行。这个函数是一个工具类函数，我们放到 util.h 中方便所有工程中的成员调用。具体的实现在本站另一篇文章 提瓦特幸存者 中有详细讲解，此处不再赘述。在 main.cpp 中，我们把 flip_atlas作为全局函数，这样方便在游戏主循环前直接调用：\n1 2 3 4 5 6 7 8 9 10 11 12 // main.cpp void flip_atlas(Atlas\u0026amp; src, Atlas\u0026amp; dst) { dst.clear(); // 避免重复使用同一个容器产生问题 for (int i = 0; i \u0026lt; src.get_size(); i++) { IMAGE img_flipped; flip_image(src.get_image(i), \u0026amp;img_flipped); dst.add_image(img_flipped); } } 此项目暂时在 main.cpp 中通过一个全局函数加载了全部资源。需要注意的是，资源的名称务必要做到“有意义”、“有规律”。可以这样命名：文件类型_角色名_朝向，比如 Atlas atlas_peashooter_idle_left; 力求 见名知意，虽然看着有些冗长，但实际上将大大提高我们在开发时借助编辑器查找的效率，并方便我们在出现问题时根据它们的语义进行排查。资源加载部分的逻辑包括三个部分：加载游戏字体、加载和处理图片素材、加载音效。音效的处理同样使用 mciSendString，不要忘记包含对应的库。\nAnimation类的实现 Animation类可以看作是决定实际渲染图集的轻量控制器。它的所有功能都是在Atlas类之上实现的。关于这个类的设计同样从 成员变量 和 成员函数 两方面入手。成员变量决定这个类的数据属性，成员函数则是根据这些数据属性选择对外提供何种的 增删查改 接口。需要注意的是，由于动画在播放时，帧索引的推进是自动进行的，不需要外部进行set设置，所以成员方法对帧相关的方法只提供了 get_idx_frame 和 get_frame 两个get方法。动画类最重要的更新和绘制两个函数 on_update 和 on_draw，也在本站文章《提瓦特幸存者》中有详细讲解，此处也不再赘述。\n游戏中诸如敌人和子弹等物体在生命周期结束时消失的动画逻辑应该怎么写呢？\n要解决这个问题，我们不能在敌人死亡时直接删除Enemy对象，而是延后这些存在消失动画的物体被删除的时间。也就是说，当敌人生命值归零时进入死亡状态，同时播放死亡动画，而当死亡动画播放结束后，再将敌人对象从内存中移除。这就需要 动画层面提供一个动画播放结束的消息。这里提供一个思路：使用 回调函数。\n回调函数 回调函数就是一个使用参数传递的函数对象，我们可以把它保存起来，然后再合适的时候调用它。这样就可以让函数内部的逻辑在这个“合适的时候”才被执行。比如，处理敌人死亡的情景为例，我们可以把删除敌人的逻辑定义为函数，以回调函数的形式保存在动画对象内部。当死亡动画播放结束后，这个函数被调用，删除敌人的逻辑被执行，这样就达成了我们的目的。记得先在开始处包含头文件 #include \u0026lt;functional\u0026gt;，具体的实现可以这样写：\n1 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 class Animation { public: ...... void on_update(int delta) { timer += delta; if (timer \u0026gt; interval) { timer = 0; idx_frame++; if (idx_frame \u0026gt;= atlas-\u0026gt;get_size()) { idx_frame = is_loop ? 0 : atlas-\u0026gt;get_size() - 1; if (!is_loop \u0026amp;\u0026amp; callback) // 如果动画无需循环播放，且回调函数存在，那么调用回调函数 { callback(); } } } } void set_callback(function\u0026lt;void()\u0026gt; callback) { this-\u0026gt;callback = callback; } private: ...... function\u0026lt;void()\u0026gt; callback; // 利用回调函数实现玩家在播放完动画后再死亡（销毁） }; Animation类的完整代码是这样的：\n1 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 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 class Animation { public: Animation() = default; ~Animation() = default; void reset() { timer = 0; idx_frame = 0; } void set_atlas(Atlas* new_atlas) { reset(); atlas = new_atlas; } void set_loop(bool flag) { is_loop = flag; } void set_interval(int ms) { interval = ms; } int get_idx_frame() { return idx_frame; } IMAGE* get_frame() { return atlas-\u0026gt;get_image(idx_frame); } bool check_finished() { if (is_loop) return false; return (idx_frame == atlas-\u0026gt;get_size() - 1); } void on_update(int delta) { timer += delta; if (timer \u0026gt; interval) { timer = 0; idx_frame++; if (idx_frame \u0026gt;= atlas-\u0026gt;get_size()) { idx_frame = is_loop ? 0 : atlas-\u0026gt;get_size() - 1; if (!is_loop \u0026amp;\u0026amp; callback) // 如果动画无需循环播放，且回调函数存在，那么调用回调函数 { callback(); } } } } void on_draw(const Camera\u0026amp; camera, int x, int y) const { putimage_alpha(camera, x, y, atlas-\u0026gt;get_image(idx_frame)); } void set_callback(function\u0026lt;void()\u0026gt; callback) { this-\u0026gt;callback = callback; } private: Atlas* atlas = nullptr; bool is_loop = true; // 是否循环播放 int timer = 0;\t// 计时器 int interval = 0;\t// 帧间隔 int idx_frame = 0;\t// 帧索引 function\u0026lt;void()\u0026gt; callback; // 利用回调函数实现玩家在播放完动画后再死亡（销毁） }; 摄像机的设计 窗口坐标系和世界坐标系 EasyX的窗口原点在平面左上角，它的窗口坐标系就是这样的：\nEasyX窗口坐标系 而世界坐标系则是更广阔的坐标系。就像我们把场景中包括玩家角色在内的诸多物体放置到一个虚拟的世界中一样。这个世界有多大，那么世界坐标系的取值范围就有多大。玩家的移动、碰撞，各类机关、道具的触发，乃至于整个游戏世界的运行逻辑，都是在世界坐标系这套框架下运转的。而只有在需要绘制游戏画面时，我们才需要考虑将它们放置到窗口坐标系下进行绘图等操作。而摄像机可以看作是一个世界坐标系和窗口坐标系之间转换的媒介。这个思路也和游戏开发的设计理念 数据和渲染分离 一致。\n那么，在不考虑画面缩放的情况下，也就是摄像机的宽高和窗口的宽高一致时，我们就可以将摄像机看作是整个世界中的一个点，当我们需要实现摄像机跟随玩家移动的横板卷轴游戏时，只需要让摄像机跟随玩家移动，也就是这个点的坐标和玩家坐标保持一致。在渲染游戏内其他内容时，我们只需要把场景中其他物体的世界坐标与这个摄像机位点的世界坐标作差，得出的坐标就是我们传递给绘图函数的窗口坐标。注意，这是一个非常重要的概念：窗口坐标 = 世界坐标 - 摄像机坐标。\nVector2类的实现 为了使用浮点数来更精确地控制摄像机的位置，我们先来封装一个在游戏中极其常用的二维向量类。为了方便我们和常见的数字类型进行相似的运算操作，我们在这个类里进行了运算符重载：\n1 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 60 61 62 63 64 65 66 67 class Vector2 { public: Vector2() = default; ~Vector2() = default; Vector2(float x, float y) // 再写一个带参的构造函数，方便直接初始化x和y的值 : x(x),y(y){ } //======== 运算符重载 ======== // 方便执行和常见运算符类似的操作 Vector2 operator+(const Vector2\u0026amp; vec) const { return Vector2(x + vec.x, y + vec.y); } void operator+=(const Vector2\u0026amp; vec) { x += vec.x, y += vec.y; } Vector2 operator-(const Vector2\u0026amp; vec) const { return Vector2(x - vec.x, y - vec.y); } void operator-=(const Vector2\u0026amp; vec) { x -= vec.x, y -= vec.y; } Vector2 operator*(const Vector2\u0026amp; vec) const { return Vector2(x * vec.x, y * vec.y); } Vector2 operator*(float val) const { return Vector2(x * val, y * val); } void operator*=(float val) { x *= val, y *= val; } float length() { return sqrt(x * x + y * y); } Vector2 normalize() { float len = length(); if (len == 0) { return Vector2(0, 0); } return Vector2(x / len, y / len); } public: float x = 0.0f; float y = 0.0f; }; 摄像机类的实现 摄像机抖动效果 在游戏中的应用十分常见。在使用枪械射击或者表达爆炸或冲击波时，游戏设计者们通常会让玩家的画面快速震动一段时间，来透过屏幕表达一种力量感。这是一种实现起来比较简单但表现效果很不错的视觉特效。\n实现思路 摄像机的抖动只会持续一段时间，在抖动一段时间后我们需要结束这种效果。所以和动画的实现类似，我们需要一个定时器来控制抖动特效开始和结束的时刻。\n定时器类的实现 通用定时器类的设计思路有两种：一种是 继承，另一种是 回调。\n继承的实现思路：\n提供一个定时器基类，在 on_update 中执行定时器时间到达时的逻辑，这部分具体的逻辑封装在 callback 成员方法中。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 class Timer { public: Timer() = default; ~Timer() = default; void on_update(int delta) { // ...... callback(); } protected: virtual void callback() { // 执行定时器时间 } }； 如果我们想实现特定的定时器逻辑，那么只需要将这个特定的定时器继承自Timer基类，并重写 callback 方法。在使用时就能借助多态特性，执行重写后的定时器逻辑。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 class MyTimer : public Timer { public: MyTimer() = default; ~MyTimer() = default; protected: void callback() override { // 执行自定义的定时器逻辑 } }； Timer* my_timer = new MyTimer(); 回调的实现思路：\n和Animation类播放结束的回调函数类似。通过 set_call_back 成员方法将回调函数保存在对象内部，并在合适的时候调用它。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 class Timer { public: Timer() = default; ~Timer() = default; void on_update(int delta) { // ...... callback(); } void set_callback(function\u0026lt;void()\u0026gt; callback) { this-\u0026gt;callback = callback; } protected: function\u0026lt;void()\u0026gt; callback; } 在使用时只需向对象中注册自己的回调函数即可。\n1 2 3 4 5 Timer my_timer; mu_timer.set_back([]() { // 执行自定义的定时器逻辑 })； 两相对比，使用回调函数的定时器实现在代码上更加简洁。如果我们需要多个执行不同逻辑的定时器，如果使用继承的思路，那么就需要写多个不同的定时器子类。而使用回调的方法，我们只需要实例化定时器对象后编写一个lambda函数。所以从代码设计的角度，像通用定时器这种只需扩展回调方法逻辑而无需扩展数据成员内容的类，我们就会更倾向于使用回调函数的思路而不是使用类继承的思路去处理。这不仅让代码编写的量更少更轻松、在语义上也更加明确。\n定时器类的完整代码：\n1 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 60 61 62 63 // 通用定时器类 // 不同的定时后的逻辑用回调函数实现 class Timer { public: Timer() = default; ~Timer() = default; void restart() // 重置定时器 { pass_time = 0; shotted = false; } void set_wait_time(int val) { wait_time = val; } void set_one_shot(bool flag) { one_shot = flag; } void set_callback(function\u0026lt;void()\u0026gt; callback) { this-\u0026gt;callback = callback; } void pause() { paused = true; } void resume() { paused = false; } void on_update(int delta) { if (paused) return; pass_time += delta; if (pass_time \u0026gt;= wait_time) { if (!one_shot || (one_shot \u0026amp;\u0026amp; !shotted) \u0026amp;\u0026amp; callback) callback(); shotted = true; pass_time = 0; } } private: int pass_time = 0;\t// 已经过去的时间 int wait_time = 0;\t// 等待时间 bool paused = false;\t// 是否暂停 bool shotted = false;\t// 是否触发 bool one_shot = false;\t// 是否单次触发 function\u0026lt;void()\u0026gt; callback;\t// 触发回调函数 }; 包含通用定时器的摄像机类 摄像机抖动效果的设计思路：\n如果想让整个世界，即屏幕上所有的内容都进行抖动的话，只需要让摄像机坐标抖动即可。简而言之，摄像机抖动的特效只需要快速改变Camera对象的坐标即可。那么我们以什么样的规律进行改变呢？一种相对简单的思路是，我们可以在摄像机仍在抖动的帧中，在以抖动强度为半径的圆内随机设置摄像机的位置，因为帧更新的速度很快，所以在宏观上就可以做到抖动的效果。除此之外，如果希望在更大幅度的抖动时更加平滑，我们可以使用 柏林函数 等噪声算法取代随机数，这部分内容涉及到更多算法的实现。在本项目这种小幅度摄像机抖动的场景下画面效果提升并不明显，所以此项目采用随机数的实现思路。\n摄像机类的完整代码：\n1 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 class Camera { public: Camera() { timer_shake.set_one_shot(true); timer_shake.set_callback([\u0026amp;]() { is_shaking = false; reset(); } ); } ~Camera() = default; const Vector2\u0026amp; get_position() const // 前一个const：返回一个不可修改的 Vector2 引用；后一个const：表示该函数不会修改类成员，可用于 const 对象 { return position; } void reset() // 将摄像机的位置归零 { position.x = 0; position.y = 0; } void on_update(int delta) { timer_shake.on_update(delta); if (is_shaking) { position.x = (-50 + rand() % 101) / 50.0f * shaking_strength; position.y = (-50 + rand() % 101) / 50.0f * shaking_strength; } } // 设置摄像机开始抖动的属性 // 参数：strength-抖动强度；duration-抖动持续时间 void shake(float strength, int duration) { is_shaking = true; shaking_strength = strength; timer_shake.set_wait_time(duration); timer_shake.restart(); } private: Vector2 position;\t// 摄像机的位置 // 实现摄像机的抖动效果 Timer timer_shake;\t// 摄像机抖动定时器 bool is_shaking = false; float shaking_strength = 0; // 摄像机抖动幅度 }; 在 on_update 中，将摄像机的位置在抖动强度为半径的圆内进行随机设置。在抖动强度 shaking_strength 前乘以的这个随机系数是描述了一个单位圆的范围，取值范围为 -1.0 到 1.0 的浮点数。\n至此，游戏框架部分的搭建完成，这一项目的下半部分笔记请继续阅读本站文章《植物明星大乱斗之Gameplay层实现》。\n","date":"2025-10-11T09:47:30+02:00","image":"https://nullshowjl.github.io/p/%E4%BB%8E%E9%9B%B6%E5%BC%80%E5%A7%8B%E7%9A%84c-%E6%B8%B8%E6%88%8F%E5%BC%80%E5%8F%91%E6%A4%8D%E7%89%A9%E6%98%8E%E6%98%9F%E5%A4%A7%E4%B9%B1%E6%96%97%E4%B9%8B%E6%A1%86%E6%9E%B6%E8%AE%BE%E8%AE%A1/cover_hu_9e7638bb04de852.webp","permalink":"https://nullshowjl.github.io/p/%E4%BB%8E%E9%9B%B6%E5%BC%80%E5%A7%8B%E7%9A%84c-%E6%B8%B8%E6%88%8F%E5%BC%80%E5%8F%91%E6%A4%8D%E7%89%A9%E6%98%8E%E6%98%9F%E5%A4%A7%E4%B9%B1%E6%96%97%E4%B9%8B%E6%A1%86%E6%9E%B6%E8%AE%BE%E8%AE%A1/","title":"【从零开始的C++游戏开发】植物明星大乱斗之框架设计"},{"content":"目录\n概览 核心玩法 项目主体开发 番外篇：角色动画特效和像素缓冲区 项目主体部分的完整源码 复盘和总结 概览 技术栈：C++ + EasyX\n项目目标：设计游戏的动画框架，接入键盘的按键操作，提供更具交互性的玩法。在数据和逻辑层面，实现简单的2D平面内的碰撞检查；实现敌人的随机生成和索敌跟踪逻辑。添加音乐和音效，丰富游戏的完成度。添加主菜单界面。使用享元模式来优化程序性能。\n课程来源：B站-Voidmatrix\n核心玩法 玩家点击 开始 按钮进入游戏，使用 上 下 左 右 按键控制角色移动。在玩家角色的周围会有一圈子弹，野猪敌人会从屏幕外源源不断地涌向玩家。当子弹碰到敌人后，会击杀敌人并增加游戏得分。当敌人碰触到玩家角色时，游戏结束。\n项目主体开发 设计思路 游戏主框架的设计 创建窗口、创建游戏主循环和稳定帧率，这个写法基本是游戏开发框架的固定格式，因为在 系列笔记第0集 基础 中有详细的解释，此处就不再赘述。\n1 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 int main() { //========= 初始化数据 ========== initgraph(1280, 720); bool is_running = true; const int FPS = 60; ExMessage msg; BeginBatchDraw(); while (is_running)\t// 游戏主循环 { DWORD start_time = GetTickCount(); //========= 处理输入 ========= while (peekmessage(\u0026amp;msg)) { }\t//======== 处理更新 ========= cleardevice(); //======== 处理渲染 ========= FlushBatchDraw(); //========= 稳定帧率 ========= DWORD end_time = GetTickCount(); DWORD delta_time = end_time - start_time; if (delta_time \u0026lt; 1000 / FPS) { Sleep(1000 / FPS - delta_time); } } EndBatchDraw(); return 0; } 各个对象/类的设计 本项目并没有使用多态来抽象出更上层的对象。基本按照一个对象设计一个类的思路进行。为了更加符合初学者的直觉和理解，将所有的代码放在了main.cpp 中，使用了大量的全局变量实现项目。\n玩家类 子弹类（玩家周围的一圈子弹） 敌人类 动画类和图集类（实现和优化动画效果） 按钮基类 开始游戏按钮 退出游戏按钮 开发流程 游戏框架的设计\n图片的加载和渲染\n解决思路：使用EasyX的库函数：loadimage, putimage 问题：png图片会产生黑边 解决思路：自己在putimage的基础上重载一个含有透明通道的函数 注意：要包含对应的库 pragma comment(lib, \u0026quot;MSIMG32.LIB\u0026quot;) 动画的实现\n问题1：如何让图片动起来？ 解决思路：计数器 ===\u0026gt; 优化：计时器 问题2：动画的实现出现大量重复代码 解决思路：封装Animation类 角色移动的实现\n问题1：玩家移动时出现“卡顿感” 解决思路：使用bool变量来标识按键的按下和抬起，间接控制玩家的运动 问题2：玩家在斜线运动的速度快于水平/竖直方向 解决思路：使用单位向量统一各个方向的速度 问题：在试图实现敌人动画时发现和玩家的数据会一起混在Animation类中\n解决思路：使用面向对象的思想，创建各类管理各自的数据和逻辑 Player类的实现\nBullet类的实现\nEnemy类的实现\n问题1：敌人刷新机制的实现 解决思路：将敌人随机生成在地图外的一条边上 注意点：函数中如果需要传入别的类的对象，要用引用传入，如果不会改变这个参数，就加上const 问题2：敌人自动寻路机制的实现 解决思路：玩家位置与敌人位置之差，计算其单位向量，就是敌人的移动方向 2D碰撞检测的实现\n问题1：敌人和子弹的碰撞 解决思路：将敌人看作一个矩形处理，而子弹则按照点来处理 问题2：玩家和敌人的碰撞 解决思路：将敌人的中心点作为碰撞点，与玩家的矩形进行碰撞检测 子弹的更新、视觉效果的提升\n问题：让子弹的运动更加炫酷 解决思路：给子弹一个切向速度、一个径向速度 删除被击杀的敌人\n解决思路：先用 swap 将待删除的敌人移到vector末尾，再使用 pop_back，最后使用 delete 释放内存。这是一种 在元素次序无关 时性能较好的删除方法。 添加和绘制玩家得分\n添加音效\n解决思路：使用Windows的库函数：mciSendString 注意：要包含对应的库 pragma comment(lib, \u0026quot;Winmm.lib\u0026quot;) 优化：性能提升\n解决思路：使用 享元模式 优化资源加载 注意：不能在Animation中释放其持有的Atlas，因为它是共享的，所以只能由更上层的代码控制 添加主菜单UI、按钮类的设计\n关键步骤和解决思路 图片的加载和渲染 实现读取/加载图片 阅读文档，可以看到EasyX使用 loadimage 这个函数来加载图片，这个函数有一个重载，此处我们直接使用较简单的 从图片文件中获取图像 这一个：\n1 2 3 4 5 6 7 8 // 从图片文件获取图像(bmp/gif/jpg/png/tif/emf/wmf/ico) int loadimage( IMAGE* pDstImg,\t// 保存图像的 IMAGE 对象指针 LPCTSTR pImgFile,\t// 图片文件名 int nWidth = 0,\t// 图片的拉伸宽度 int nHeight = 0,\t// 图片的拉伸高度 bool bResize = false\t// 是否调整 IMAGE 的大小以适应图片 ); 此处需要注意的是，第一个参数是一个图片的指针对象。我们不直接将图片加载到窗口中，而是先保存到变量里，然后在后续绘制中使用，所以要传入要给非空指针；第二个参数是图片的路径字符串；后三个参数均提供了默认值，用来设置图片的缩放属性，此项目中用不到，所以暂时忽略。那么，要加载程序目录下的图片 text.jpg , 可以这样表示：\n1 2 IMAGE img; loadimage(\u0026amp;img, _T(\u0026#34;test.jpg\u0026#34;)); 实现渲染图片 同样阅读文档，我们发现可以使用 putimage 这个函数来绘制图片：\n1 2 3 4 5 6 7 // 绘制图像 void putimage( int dstX,\t// 绘制位置的 x 坐标 int dstY,\t// 绘制位置的 y 坐标 IMAGE *pSrcImg,\t// 要绘制的 IMAGE 对象指针 DWORD dwRop = SRCCOPY\t// 三元光栅操作码 ); 其中，第一二个参数是图片绘制在世界坐标中的位置。最后一个参数 三元光栅操作码 我们在这个项目中也忽略不计。那么如果要绘制刚刚加载的图片，这样我们可以写：\n1 2 3 4 5 6 // 加载图片 IMAGE img; loadimage(\u0026amp;img, _T(\u0026#34;test.jpg\u0026#34;)); // 绘制图片 putimage(100, 200, \u0026amp;img); 如果这张图片的像素是 300 * 300 ，那么这张图片在游戏界面中的坐标是这样的：\n需要注意的是，我们需要自己封装带有透明通道的 putimage函数，来解决显示的 png 图片带有黑框的问题：\n1 2 3 4 5 6 7 8 9 #pragma comment(lib, \u0026#34;MSIMG32.LIB\u0026#34;) void putimage_alpha(int x, int y, IMAGE* img) { int w = img-\u0026gt;getwidth(); int h = img-\u0026gt;getheight(); AlphaBlend(GetImageHDC(NULL), x, y, w, h, GetImageHDC(img), 0, 0, w, h, { AC_SRC_OVER,0,255,AC_SRC_ALPHA }); } 在函数之前（最好放在文件的开始处，要链接相关的库 #pragma comment(lib, \u0026quot;MSIMG32.LIB\u0026quot;) 。\n实现动画及渲染 如何让画面动起来？ 游戏中角色动画的常见实现可以笼统地分为两类：序列帧动画 和 关键帧动画 。\n序列帧动画 ：通常由一组图片素材构成，在程序中随着时间推移，我们不断地切换显示这一序列的图片，借助视觉暂留效应，产生动画效果。\n关键帧动画 如骨骼动画等，因为涉及更复杂的图形学技术，此处暂不讨论。\n注意，我们不能通过调用 Sleep() 函数来解决这个问题。因为当调用 Sleep() 函数时，程序会卡在那里，等待一定时间，这是一个“阻塞式”的行为，而在我们的游戏框架设计中，所有的画面渲染等操作，都应该式在一次又一次的循环中进行，每次循环的时间都控制在 1/60秒（FPS） 内。也就是说，我们切换动画轮播的任务，应该分摊在多帧之间进行，而不是在一次循环内全部结束。此处涉及到一个游戏编程中的核心思想：主循环内应尽量避免阻塞式的行为或者过于繁重且耗时过长的任务。\n为了确保动画序列帧可以在间隔固定的时间进行切换，采用了计时器的思路来做这样的一个计数器：\n1 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 int idx_current_anim = 0;\t// 1. 存储当前动画帧的帧索引 const int PLAYER_ANIM_NUM = 6;\t// 动画帧总数量 int main() { ..... while (is_running) { while (peekmessage(\u0026amp;msg)) { } static int counter = 0;\t// 2. 记录当前动画帧一共播放了几个游戏帧 // 用static修饰可以确保计数器只有在第一个游戏帧时被初始化为0 // 每5个游戏帧切换一个动画帧 if (++counter % 5 == 0) idx_current_anim++; if（idx_current_anim % PLAYER_ANIM_NUM == 0) idx_current_anim = 0; } ...... } 动画的渲染本质就是将IMAGE数组中的图片依次绘制即可。先加载：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 const int PLAYER_ANIM_NUM = 6;\t// 动画帧总数量 IMAGE img_player_left[PLAYER_ANIM_NUM]; IMAGE img_player_right[PLAYER_ANIM_NUM]; void load_animation() { for (size_t i = 0; i \u0026lt; PLAYER_ANIM_NUM; i++) { std::wstring path = L\u0026#34;img/player_left_\u0026#34; + std::to_wstring(i) + L\u0026#34;.png\u0026#34;; loadimage(\u0026amp;img_player_left[i], path.c_str()); } for (size_t i = 0; i \u0026lt; PLAYER_ANIM_NUM; i++) { std::wstring path = L\u0026#34;img/player_right_\u0026#34; + std::to_wstring(i) + L\u0026#34;.png\u0026#34;; loadimage(\u0026amp;img_player_right[i], path.c_str()); } } 然后在 main 中绘制这一系列动画图片的数组：\n1 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 int main() { ...... while (is_running)\t// 游戏主循环 { DWORD start_time = GetTickCount(); //========= 处理输入 ========= while (peekmessage(\u0026amp;msg)) { }\t//======== 处理更新 ========= cleardevice(); //======== 处理渲染 ========= putimage_alpha(500, 500, \u0026amp;img_player_left[idx_current_anim]); FlushBatchDraw(); ...... } } 将动画的实现封装成动画类 数据结构层面，用vector来存储动画所需图片的指针vector\u0026lt;IMAGE*\u0026gt; 。在构造函数中，需要加载动画所需的图片资源同时为其分配了内存空间，那么对应的，需要在析构函数中释放图片资源被释放内存空间.\n在播放动画时，参数中除了需要知道动画播放的位置外，还增加了一个参数int delta用来表示距离上一次调用Play函数过去了多久时间，这是将“计数器”概念转为了“计时器”。之所以优化为这种设计，是因为一个动画的播放速度也就是 帧间隔，应该是与实际时间有关的，而不是与游戏的帧率有关，我们希望的是无论游戏帧的频率有多快，动画的播放速度是一致的，而不是画面刷新越快，动画播放越快。所以使用与实际时间有关的定时器，会比每一下调用都累加一次的计数器更能满足这种需求。\n1 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 class Animation { public: Animation(LPCTSTR path, int num, int interval)\t// 加载动画帧图片资源 { interval_ms = interval; TCHAR path_file[256]; for (size_t i = 0; i \u0026lt; num; i++) { _sprintf_s(path_file, path, i); IMAGE* frame = new IMAGE(); loadimage(frame, path_file); frame_list.push_bacl(frame); } } void Play(int x, int y, int delta)\t// 播放动画 { timer += delta; if (timer \u0026gt;= interval_ms) { idx_frame = (idx_frame + 1) % frame_list.size(); timer = 0; } // 绘制每一帧图片 pitimage_alpha(x, y, frame_list[idx_frame]); } ~Animation()\t// 释放资源 { for (size_t i = 0; i \u0026lt; frame_list.size(); i++) { delete frame_list[i]; } } private: vector\u0026lt;IMAGE*\u0026gt; frame_list; int interval_ms = 0;\t// 帧间隔 int timer = 0; int idx_frame = 0; } 实现角色移动 如果我们直接在按下按键时，用在不同坐标轴上累加位移的方式来控制玩家，会出现手感上的“卡顿感”。这是因为 WM_KEYDOWN 消息的产生是与我们的主循环 异步进行 的，且触发的频率与操作系统和硬件设备相关，这就导致在有些游戏帧中事件处理部分对多个WM_KEYDOWN消息进行了处理，而在其余游戏帧中WM_KEYDOWN消息较少或没有，这就导致角色在某些游戏帧中前进的距离较远/近一些，在宏观上展现为移动过程中的卡顿感。另外，当我们按下方向键时，会首先有一个 WM_KEYDOWN 消息进入消息事件队列中，随后，当我们保持按键按下状态一段时间后，才会有接连不断的 WM_KEYDOWN 消息被触发。\n所以，要确保角色在每一个游戏帧中都连贯地移动相同的距离，从玩家的角色触发，就是：当玩家按下按键时，WM_KEYDOWN 消息触发，角色开始移动；当玩家抬起按键时，WM_KEYUP 消息触发，角色结束移动。所以使用相应的四个布尔变量来代表玩家的移动方向，我们通过改变按键的按下和抬起来改变这四个布尔变量的值，然后根据布尔变量的值来实现玩家在坐标轴上的位移：\n1 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 void ProcessEvent(const ExMessage\u0026amp; msg) { if (msg.message == WM_KEYDOWN) { switch (msg.vkcode) { case VK_UP: is_moving_up = true; break; case VK_DOWN: is_moving_down = true; break; case VK_LEFT: is_moving_left = true; break; case VK_RIGHT: is_moving_right = true; break; } } if (msg.message == WM_KEYUP) { switch (msg.vkcode) { case VK_UP: is_moving_up = false; break; case VK_DOWN: is_moving_down = false; break; case VK_LEFT: is_moving_left = false; break; case VK_RIGHT: is_moving_right = false; break; } }\t} void Move() { if (is_moving_up) position.y -= SPEED; if (is_moving_down) position.y += SPEED; if (is_moving_left) position.x -= SPEED; if (is_moving_right) position.x += SPEED; } 另外，为了避免玩家在斜角运动时位移更多的问题，需要使其在该速度方向的向量是单位向量：\n1 2 3 4 5 6 7 8 9 10 int dir_x = is_move_right - is_move_left; int dir_y = is_move_down - is_move_up; double len_dir = sqrt(dir_x * dir_x + dir_y + dir_y); if(len_dir != 0) { double normalized_x = dir_x / len_dir; double normalized_y = dir_y / len_dir; player_pos.x += (int)(PLAYER_SPEED * normalized_x); player_pos.y += (int)(PLAYER_SPEED * normalized_y); } 各个类的封装 为了避免各个数据糅杂散落在项目的各处，将每个对象相关的逻辑和数据封装到各组的类中。以玩家类为例，大致的类的设计：\n1 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 class Player { public: Player() { // 初始化资源：动画资源、图片资源等 } ~Player() { // 释放资源 } void ProcessEvent(const ExMessage\u0026amp; msg) { // 玩家的输入逻辑 } void Move() { // 处理玩家移动 } void Draw(int delta) { // 绘制玩家 } private: ...... } 敌人类的实现细节 敌人的随机生成机制 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 // 敌人生成的边界 enum class SpawnEdge { Up = 0, Down, Left, Right }; // 将敌人生成在四条边界中随机的一条上 SpawnEdge edge = (SpawnEdge)(rand() % 4); // 具体的随机坐标值 switch (edge) { case SpawnEdge::Up: position.x = rand() % WINDOW_WIDTH; position.y = -FRAME_HEIGHT; break; case SpawnEdge::Down: position.x = rand() % WINDOW_WIDTH; position.y = WINDOW_HEIGHT; break; case SpawnEdge::Left: position.x = -FRAME_WIDTH; position.y = rand() % WINDOW_HEIGHT; break; case SpawnEdge::Right: position.x = WINDOW_WIDTH; position.y = rand () % WINDOW_HEIGHT; break; default: break; } 敌人的寻路机制 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 void Move(const Player\u0026amp; player) { const POINT\u0026amp; player_position = player.GetPosition(); int dir_x = player_position.x - position.x; int dir_y = player_position.y - position.y; double dir_len = sqrt(dir_x * dir_x + dir_y * dir_y); if (dir_len != 0) { double normalized_x = dir_x / dir_len; double normalized_y = dir_y / dir_len; position.x += (int)(normalized_x * SPEED); position.y += (int)(normalized_y * SPEED); } if (dir_x \u0026gt; 0) facing_left = false; else if (dir_x \u0026lt; 0) facing_left = true; } 2D碰撞检测的实现 这部分都放在敌人类中实现，玩家或者子弹的对象作为引用传入函数中，避免不必要的拷贝。\n敌人和子弹 1 2 3 4 5 6 7 8 bool CheckBulletCollision(const Bullet\u0026amp; bullet) // 在参数前添加const：这个参数在函数中不会被修改 { // 将子弹等效为一个点，判断该点是否在敌人的矩形内 bool is_overlap_x = bullet.position.x \u0026gt;= position.x \u0026amp;\u0026amp; bullet.position.x \u0026lt;= position.x + FRAME_WIDTH; bool is_overlap_y = bullet.position.y \u0026gt;= position.y \u0026amp;\u0026amp; bullet.position.y \u0026lt;= position.y + FRAME_HEIGHT; return is_overlap_x \u0026amp;\u0026amp; is_overlap_y; } 敌人和玩家 在游戏的碰撞检测中，一般不会太严格。如果将敌人和玩家均作为一个矩形去检测碰撞，可能会发生两者只有一个角落重叠但是从视觉上看并未发生碰撞的情况，让玩家困惑。所以一般情况下，受击碰撞器都会小于图片尺寸。此处采用将敌人中心作为一个碰撞点来处理。\n1 2 3 4 5 6 7 8 9 bool CheckPlayerCollision(const Player\u0026amp; player) { // 将敌人的中心点位置视为敌人的碰撞点 POINT check_position = { position.x + FRAME_WIDTH / 2, position.y + FRAME_HEIGHT / 2 }; bool is_overlap_x = check_position.x \u0026gt;= player.GetPosition().x \u0026amp;\u0026amp; check_position.x \u0026lt;= player.GetPosition().x + player.FRAME_WIDTH; bool is_overlap_y = check_position.y \u0026gt;= player.GetPosition().y \u0026amp;\u0026amp; check_position.y \u0026lt;= player.GetPosition().y + player.FRAME_HEIGHT; return is_overlap_x \u0026amp;\u0026amp; is_overlap_y; } 子弹的更新和视觉效果的提升 因为围绕玩家的子弹是由一圈3颗子弹组成的，所以将其作为全局函数处理。\n关于子弹的运动，可以通过随着时间的推移改变α的值来表示，为了计算方便，此处的角度单位均为弧度：\n相应的代码如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // 更新子弹位置 void UpdateBullets(vector\u0026lt;Bullet\u0026gt;\u0026amp; bullet_list, const Player\u0026amp; player) { // 让子弹有一个不断收缩的效果，视觉上更加炫酷 const double RADIAL_SPEED = 0.0045; // 径向波动速度 const double TANGENT_SPEED = 0.0055; // 切向波动速度 double radian_interval = 2 * PI / bullet_list.size(); // 三颗子弹间的弧度间隔 // 根据玩家的位置，依次更新每颗子弹的位置 POINT player_position = player.GetPosition(); double radius = BULLET_BASE_RADIUS + BULLET_RADIUS_CHANGE_RANGE * sin(GetTickCount() * RADIAL_SPEED); for (size_t i = 0; i \u0026lt; bullet_list.size(); i++) { double radian = GetTickCount() * TANGENT_SPEED + radian_interval * i; bullet_list[i].position.x = player_position.x + player.FRAME_WIDTH / 2 + (int)(radius * sin(radian)); bullet_list[i].position.y = player_position.y + player.FRAME_HEIGHT / 2 + (int)(radius * cos(radian)); } } 删除被击杀的敌人 1 2 3 4 5 6 7 8 9 10 11 12 13 // 依次检查敌人列表，移除被击杀的敌人 for (size_t i = 0; i \u0026lt; enemy_list.size(); i++) // 因为此处会动容器本身，所以不能用迭代器遍历 { Enemy* enemy = enemy_list[i]; if (!enemy-\u0026gt;CheckAlive()) { // 和容器最后一个元素交换后，移除最后一个 // * 是元素顺序无关紧要时，性能较好的一种删除方法 swap(enemy_list[i], enemy_list.back()); enemy_list.pop_back(); delete enemy; } } 播放音效 此项目使用Windows的库函数实现播放音效。可以写成这样的代码：\n1 2 3 4 5 // 打开项目文件夹mus下的bgm.mp3文件，并且以后就叫它“bgm” mciSendString(_T(\u0026#34;open mus/bgm.mp3 alias bgm\u0026#34;), NULL, 0, NULL);\t// 加载音效 // 播放别名叫bgm的音效，并从开头处循环播放 mciSendString(_T(\u0026#34;play bgm repeat from 0\u0026#34;), NULL, 0, NULL);\t// 如果不需要循环，就不要加repeat\t提升性能：使用享元模式优化资源加载 游戏中的模型和贴图资源所占比例很高，对硬盘空间和游戏启动时间的消耗都很大。享元模式在游戏开发中非常常用。比如，游戏中的一棵树的资源，一般的写法和使用享元模式的写法对比：\n1 2 3 4 5 6 7 8 //========= 一般写法 ========= // 树的结构体 struct Tree { Model model;\t// 树的模型 Texture texture;// 树的贴图 int x, y, z;\t// 树的位置 } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 //======= 享元模式写法 ======= // 树的资产结构体 // 无论树有多少棵，均使用同一个TreeAsset对象中的数据 // 这也是绘制一棵树所需数据中最庞大的那部分 struct TreeAsset { Model model;\t// 树的模型 Texture texture;// 树的贴图 } // 树结构体 struct Tree { TreeAsset* asset;\t// 树的资产的指针 int x, y, z;\t// 树的位置 } 在此项目中，对Animation类进行重新拆分和设计，提取游戏中每一个个体敌人可以共享的数据 std::vector\u0026lt;IMAGE*\u0026gt; frame_list ，而其他三个私有成员，则是状态信息，是每一个敌人独有的。\n每个个体持有共享数据的图集类：\n1 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 // 资源加载优化 class Atlas { public: Atlas(LPCTSTR path, int num) { // 加载图片 TCHAR path_file[256]; for (int i = 0; i \u0026lt; num; i++) { _stprintf_s(path_file, path, i); IMAGE* frame = new IMAGE(); loadimage(frame, path_file); frame_list.push_back(frame); } } ~Atlas() { for (int i = 0; i \u0026lt; frame_list.size(); i++) { delete frame_list[i]; } } public: vector\u0026lt;IMAGE*\u0026gt; frame_list; }; 每个个体独有的数据封装在Animation类中：\n1 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 class Animation { public: Animation(Atlas* atlas, int interval) { anim_atlas = atlas; interval_ms = interval; } ~Animation() = default; // atlas是Animation类对象共享的公共资产，所以不能在Animation的析构函数中使用delete释放atlas指针 // 需要在更上一层释放（main） // 况且这里也没有new // 播放动画 void Play(int x, int y, int delta_time) { timer += delta_time; if (timer \u0026gt;= interval_ms) { idx_frame = (idx_frame + 1) % anim_atlas-\u0026gt;frame_list.size(); timer = 0; } putimage_alpha(x, y, anim_atlas-\u0026gt;frame_list[idx_frame]); } private: int interval_ms = 0; // 帧间隔 int timer = 0; // 动画计时器 int idx_frame = 0; // 动画帧索引 private: Atlas* anim_atlas; // 需要持有Atlas类的指针 }; 按钮类的设计 按钮的状态有三种：Idle、Hover和Pushed，理清这三者的跳转关系来编写玩家输入的代码：\n相应的，我们需要处理的情况也有三种：鼠标移动、左键按下、左键抬起：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 void ProcessEvent(const ExMessage\u0026amp; msg) { switch (msg.message) { case WM_MOUSEMOVE: if (status == Status::Idle \u0026amp;\u0026amp; CheckCursorHit(msg.x, msg.y)) status = Status::Hovered; else if (status == Status::Idle \u0026amp;\u0026amp; !CheckCursorHit(msg.x, msg.y)) status = Status::Idle; else if (status == Status::Hovered \u0026amp;\u0026amp; !CheckCursorHit(msg.x, msg.y)) status = Status::Idle; break; case WM_LBUTTONDOWN: if (CheckCursorHit(msg.x, msg.y)) status = Status::Pushed; break; case WM_LBUTTONUP: if (status == Status::Pushed) OnClick(); break; default: break; } } 番外篇：角色动画特效和像素缓冲区 色彩知识补充 图片的最小单位是像素，像素的色彩由三原色红、绿、蓝（Red Green Blue），也就是我们常说的“RGB”，经过不同强度的各原色混合得到。如果用一个Color的结构体，可以这样表示：\n1 2 3 4 5 6 struct Color { int r; int g; int b; }; “图片”本质上就是由这些像素所构成的二维数组。一张像素为 100*100 的图片可以等价为 Color image[100][100]; 那么在窗口中绘制一张图片的过程就是将这个小一点的二维数组拷贝到窗口这个大一点的二维数组的过程。而绘制的坐标则决定了像素数组位置的索引EasyX的 IMAGE 类中，有一个 DWORD* m_pBuffer; 的指针，用来指向存储图片像素色彩的缓冲区地址。由因为二维数组在内存中的存储其实是按照从左到右、从上到下的方式连续存储的，所以如果我们想要访问 (x, y) 位置的像素颜色的值，用二维数组表示的代码是 Color pix_color = image[y][x] ，在EasyX中需要转化为 DWORD pix_color = buffer[y * width + x] （width为图片宽度）。而这个指向像素缓冲区的指针，则可以由EasyX提供的接口 DWORD* GetImageBuffer(IMAGE* PImg = NULL) 获取。所以，如果要获得 image 这张图片的色彩缓冲区，可以这样写 DWORD* buffer = GetImageBuffer(\u0026amp;image)，缓冲区中的每一个 DWORD 类型的元素都占据四个字节，存储RGBA（RGB色彩元素和透明通道）的信息。\n图像翻转效果的实现 首先加载原始的图片素材，此例中为玩家向左的动画图片。然后定义玩家角色向右的动画序列帧数组，遍历每一张向左的图片并进行翻转。需要注意的是，首先我们需要使用了 Resize 来向右的序列帧图片调整为相同的大小，因为定义的IMAGE 对象如果没有使用其他对象进行拷贝构造或者使用 loadimage 函数进行加载的话，内部的像素缓存区默认是不存在的，所以 Resize 的过程同时也是对 IMAGE 对象进行内存分配的过程。接着，我们获取向左的图片和向右的图片的像素缓冲区，遍历图片像素缓冲区中的每一个像素，将它从向左的图片中拷贝到向右的图片中对应的位置上。那么在x轴上的元素在翻转后的位置索引为 width - 1 - x。具体的实现是这样的：\n加载向左的动画帧图片：\n1 2 3 4 5 6 7 8 9 IMAGE img_player_left[6]; // 加载玩家向左的动画 for (int i = 0; i \u0026lt; 6; i++) { static TCHAR img_path[256]; _stprintf_s(img_path, _T(\u0026#34;img/paimon_left_\u0026amp;d.png\u0026#34;), i); loadimage(\u0026amp;img_player_left[i], img_path); } 实现向右翻转的动画帧图片：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 IMAGE img_player_right[6]; for (int i = 0; i \u0026lt; 6; i++) { int width = img_player_left[i].getwidth(); int height = img_player_left[i].getheight(); Resize(\u0026amp;img_player_right[i], width, height);\t// 调整向右动画图片尺寸，同时为向右的图片开辟内存空间 // 遍历两套图片的色彩缓冲区，逐行进行水平翻转 // 并将相应索引上的像素信息拷贝给向右图片，完成翻转 DWORD* color_buffer_left_img = GetImageBuffer(\u0026amp;img_player_left[i]); DWORD* color_buffer_right_img = GetImageBuffer(\u0026amp;img_player_right[i]); for (int y = 0; y \u0026lt; height; y++) { for (int x = 0; x \u0026lt; width; x++) { int idx_left_img = y * width + x;\t// 源像素索引（向左图片） int idx_right_img = y * width + (width - 1 - x);\t// 目标像素索引（向右图片） color_buffer_right_img[idx_right_img] = color_buffer_left_img[idx_left_img]; } } } 获取每一个像素颜色的RGB分量 EasyX提供了 GetGValue GetRValue 和GetBValue 这三个宏来分别获取分量值。但需要特别注意的是，常用的COLORREF（一种表示颜色的类型，本质上是32位的整数）在内存中的表示形式为 0xbbggrr ，里面的红色和蓝色是对调的，所以我们在用EasyX的三个宏获取RGB分量时要需要调换R和B的位置：\n1 2 3 4 DWORD pix_color = buffer[y * width + x]; BYTE r = GetBValue(pix_color); BYTE g = GetGValue(pix_color); BYTE b = GetRValue(pix_color); 图片闪烁效果的实现 本质上就是切换图片正常状态的序列帧和其纯白色的剪影序列帧即可。剪影序列帧也是可以通过操作渲染缓冲区进行动态生成。那么如何将像素的颜色设置为纯白色呢？首先可以使用RGB宏组合出COLORREF类型的值，然后使用BGR交换红色和蓝色的顺序，最后设置其透明通道的值：\n1 2 3 4 // 前半部分获得标准顺序RGB的白色：0x00FFFFFF // 后半部分构造alpha通道（透明度）：(BYTE)(255)是8位的255，代表完全不透明，向左位移24位，得到0xFF000000 // 前后部分用按位或进行合并，得到0xFFFFFFFF，即完全不透明的纯白色 DWORD white_pix = BGR(RGB(255, 255, 255)) | (((DWORD)(BYTE)(255)) \u0026lt;\u0026lt; 24); 那么实现图片闪烁的具体思路就是：先定义一个剪影序列帧的图片数组，然后同样遍历每一张原始图片素材，使用 Resize 将它们设置为相同大小并开辟内存空间。然后分别获取它们的色彩缓冲区，在两层循环中先判断当前位置的颜色是否为白色，如果不是则设置为白色：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 IMAGE img_player_left_sketch[6]; // 生成玩家向左动画的剪影 for (int i = 0; i \u0026lt; 6; i++) { // 调整剪影动画图片尺寸大小同时开辟内存 int width = img_player_left[i].getwidth(); int height = img_player_left[i].getheight(); Resize(\u0026amp;img_player_left_sketch[i], width, height); // 获取两套图片的色彩缓冲区 DWORD* color_buffer_raw_img = GetImageBuffer(\u0026amp;img_player_left[i]); DWORD* color_buffer_sketch_img = GetImageBuffer(\u0026amp;img_player_left_sketch[i]); // 遍历图片的色彩缓冲区，将透明度不为0的像素设置为白色 for (int y = 0; y \u0026lt; height; y++) { for (int x = 0; x \u0026lt; width; x++) { int idx = y * width + x; if ((color_buffer_raw_img[idx] \u0026amp; 0xFF000000) \u0026gt;\u0026gt; 24)\t// if的判断条件为真，即像素不为0（白色） color_buffer_sketch_img[idx] = BGR(RGB(255, 255, 255)) | (((DWORD)(BYTE)(255)) \u0026lt;\u0026lt; 24); } } } 冻结效果的实现 Alpha混合的原理 EasyX在绘制图片是不考虑透明度的，那么透明度存在时的色彩计算公式为 最终颜色 = 源颜色 * Alpha + 目标颜色 * （1 - Alpha） 此处的Alpha值不是0 - 255，而是0 - 1的浮点数。 也就是说，如果将一张纯绿色的图片绘制到一张纯红色背景图片上时，覆盖位置的颜色可以进行这样的计算：\n不考虑 Alpha 混合的图片叠加效果 考虑 Alpha 混合的图片叠加效果及其计算公式 所以冰冻效果的实现思路，可以将一张冰冻的图片，以半透明的方式贴附在正常显示的图片之上。\n具体实现 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 // 拷贝当前帧用于后续处理 IMAGE img_current_frame(img_player_left[counter]); int width = img_curent_frame.getwidth(); int height = img_curent_frame.getheight(); // 获取当前帧图片和冰冻图片的色彩缓冲区 DWORD* color_buffer_ice_img = GetImageBuffer(\u0026amp;img_ice); DWORD* color_buffer_frame_img = GetImageBuffer(\u0026amp;img_current_frame); // 遍历当前帧的色彩缓冲区，将不透明的区域进行混叠 for (int y = 0; y \u0026lt; height; y++) { for (int x = 0; x \u0026lt; width; x++) { int idx = y * width + x; static const float RATIO = 0.25f;\t// 混叠比例 DWORD color_ice_img = color_buffer_ice_img[idx]; DWORD color_frame_img = color_buffer_frame_img[idx]; if ((color_frame_img \u0026amp; 0xFF000000) \u0026gt;\u0026gt; 24)\t// 0xFF000000为透明通道 { // 注意：色彩缓冲区中颜色存储的顺序是BGR，所以获取时需要调换B和R的位置 BYTE r = (BYTE)(GetBValue(color_frame_img) * RATIO + GetBValue(color_ice_img) * (1 - RATIO)); BYTE g = (BYTE)(GetGValue(color_frame_img) * RATIO + GetGValue(color_ice_img) * (1 - RATIO)); BYTE b = (BYTE)(GetRValue(color_frame_img) * RATIO + GetRValue(color_ice_img) * (1 - RATIO)); // 与透明通道叠加 color_buffer_frame_img[idx] = (BGR(RGB(r, g, b)) | (((DWORD)(BYTE)(255)) \u0026lt;\u0026lt; 24); } } } 优化：给冰冻状态增加高亮效果 思路：用一条白色扫描线从上至下扫描图片，为了让效果更逼真，我们对混叠后的贴图先进行亮度提取，只有亮度大于一定阈值的部分才会显示为白色条带，所以调整后的完整代码是这样的：\n1 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 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 void RenderFrozenPlayer() { static const POINT position = { 1075, 345}; static int counter = 0;\t// 动画帧索引 static int anim_timer = 0;\t// 动画计时器 static int frozen_timer = 0;\t// 冰冻状态计时器 static const int THICKNESS = 5;\t// 扫描线宽度 static int hightlight_pos_y = 0;// 扫描线竖直坐标 static bool is_frozen = false;\t// 当前是否正在冰冻 // 如果没有处于冰冻状态则更新动画计时器 if ((!is_frozen) \u0026amp;\u0026amp; (++anim_timer % 3 == 0)) counter = (counter + 1) % 6; // 更新冻结计时器并重置扫描线位置 if (++frozen_timer % 100 == 0) { is_frozen = !is_frozen; highlight_pos_y = -THICKNESS; } // 绘制玩家脚底阴影 putimage_alpha(position.x + (80 - 32) / 2, position.y + 80, \u0026amp;img_shadow); // 根据当前是否处于冰冻状态渲染不同的动画帧 if (is_frozen) { // 拷贝当前帧用于后续处理 IMAGE img_current_frame(img_player_left[counter]); int width = img_curent_frame.getwidth(); int height = img_curent_frame.getheight(); // 更新高亮扫描线竖直坐标 highlight_pos_y = (highlight_pos_y + 2) % height; // 获取当前帧图片和冰冻图片的色彩缓冲区 DWORD* color_buffer_ice_img = GetImageBuffer(\u0026amp;img_ice); DWORD* color_buffer_frame_img = GetImageBuffer(\u0026amp;img_current_frame); for (int y = 0; y \u0026lt; height; y++) { for (int x = 0; x \u0026lt; width; x++) { int idx = y * width + x; static const float RATIO = 0.25f;\t// 混叠比例 static const float THRESHOLD = 0.84f;\t// 高亮阈值 DWORD color_ice_img = color_buffer_ice_img[idx]; DWORD color_frame_img = color_buffer_frame_img[idx]; if ((color_frame_img \u0026amp; 0xFF000000) \u0026gt;\u0026gt; 24)\t// 0xFF000000为透明通道 { // 注意：色彩缓冲区中颜色存储的顺序是BGR，所以获取时需要调换B和R的位置 BYTE r = (BYTE)(GetBValue(color_frame_img) * RATIO + GetBValue(color_ice_img) * (1 - RATIO)); BYTE g = (BYTE)(GetGValue(color_frame_img) * RATIO + GetGValue(color_ice_img) * (1 - RATIO)); BYTE b = (BYTE)(GetRValue(color_frame_img) * RATIO + GetRValue(color_ice_img) * (1 - RATIO)); // 如果高亮扫描线处的像素亮度大于阈值，则直接将该像素设置为白色 if ((y \u0026gt;= hightlight_pos_y \u0026amp;\u0026amp; y \u0026lt; = highlight_pos_y + THICKNESS) \u0026amp;\u0026amp; ((r / 255.0f) * 0.2126f + (g / 255.0f) * 0.7152f + (b / 255.0f) * 0.0722f \u0026gt;= TRESHOLD)) { color_buffer_frame_img[idx] = (BGR(RGB(255, 255, 255)) | (((DWORD)(BYTE)(255)) \u0026lt;\u0026lt; 24); continue; } color_buffer_frame_img[idx] = (BGR(RGB(r, g, b)) | (((DWORD)(BYTE)(255)) \u0026lt;\u0026lt; 24); } } } putimage_alpha(position.x, position.y, \u0026amp;img_current_frame);\t} else putimage_alpha(position.x, position.y, \u0026amp;img_player_left[counter]); } 对于RGB色彩分量的亮度计算时，每个颜色前的系数来自于经验公式。\n项目主体部分的完整源码 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 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 #include \u0026lt;graphics.h\u0026gt; #include \u0026lt;string\u0026gt; #include \u0026lt;vector\u0026gt; using namespace std; /* =============== 知识点 =============== * 1.主循环内应尽量避免阻塞式行为或者过于繁重且耗时过长的任务 * 2.用计数器和用计时器来控制动画的帧更新的区别： *\t计数器：会存在电脑的刷新速度越快，帧更新就越快的情况 *\t计时器：不管什么电脑，帧更新都和实际的时间流逝一致 * 3.利用享元模式对资源加载进行优化 */ const int WINDOW_WIDTH = 1280; const int WINDOW_HEIGHT = 720; const int FPS = 60; const double PI = 3.14159; const int BULLET_BASE_RADIUS = 100; const int BULLET_RADIUS_CHANGE_RANGE = 25; const int PLAYER_ANIM_NUM = 6; const int ENEMY_ANIM_NUM = 6; const int BUTTON_WIDTH = 192; const int BUTTON_HEIGHT = 75; bool is_game_started = false; bool is_running = true; #pragma comment(lib, \u0026#34;MSIMG32.LIB\u0026#34;) // pragma comment作用：链接库 #pragma comment(lib, \u0026#34;Winmm.lib\u0026#34;) // 音频播放的库 // 自己定义一个可以处理透明度的图片绘制函数 void putimage_alpha(int x, int y, IMAGE* img); // 资源加载优化 class Atlas { public: Atlas(LPCTSTR path, int num) { // 加载图片 TCHAR path_file[256]; for (int i = 0; i \u0026lt; num; i++) { _stprintf_s(path_file, path, i); IMAGE* frame = new IMAGE(); loadimage(frame, path_file); frame_list.push_back(frame); } } ~Atlas() { for (int i = 0; i \u0026lt; frame_list.size(); i++) { delete frame_list[i]; } } public: vector\u0026lt;IMAGE*\u0026gt; frame_list; }; Atlas* atlas_player_left; // 放在main中初始化 Atlas* atlas_player_right; Atlas* atlas_enemy_left; Atlas* atlas_enemy_right; class Animation { public: Animation(Atlas* atlas, int interval) { anim_atlas = atlas; interval_ms = interval; } ~Animation() = default; // atlas是Animation类对象共享的公共资产，所以不能在Animation的析构函数中使用delete释放atlas指针 // 需要在更上一层释放（main） // 况且这里也没有new // 播放动画 void Play(int x, int y, int delta_time) { timer += delta_time; if (timer \u0026gt;= interval_ms) { idx_frame = (idx_frame + 1) % anim_atlas-\u0026gt;frame_list.size(); timer = 0; } putimage_alpha(x, y, anim_atlas-\u0026gt;frame_list[idx_frame]); } private: int interval_ms = 0; // 帧间隔 int timer = 0; // 动画计时器 int idx_frame = 0; // 动画帧索引 private: Atlas* anim_atlas; // 需要持有Atlas类的指针 }; class Player { public: Player() { loadimage(\u0026amp;img_shadow, _T(\u0026#34;img/shadow_player.png\u0026#34;)); anim_left = new Animation(atlas_player_left, 45); anim_right = new Animation(atlas_player_right, 45); } ~Player() { delete anim_left; delete anim_right; } void ProcessEvent(const ExMessage\u0026amp; msg) { if (msg.message == WM_KEYDOWN) { switch (msg.vkcode) { case VK_UP: is_moving_up = true; break; case VK_DOWN: is_moving_down = true; break; case VK_LEFT: is_moving_left = true; break; case VK_RIGHT: is_moving_right = true; break; } } if (msg.message == WM_KEYUP) { switch (msg.vkcode) { case VK_UP: is_moving_up = false; break; case VK_DOWN: is_moving_down = false; break; case VK_LEFT: is_moving_left = false; break; case VK_RIGHT: is_moving_right = false; break; } }\t} void Move() { if (is_moving_up) position.y -= SPEED; if (is_moving_down) position.y += SPEED; if (is_moving_left) position.x -= SPEED; if (is_moving_right) position.x += SPEED; // 解决斜线移动速度更快的问题 int dir_x = is_moving_right - is_moving_left; // 向右为x轴正方向 int dir_y = is_moving_down - is_moving_up; // 向下为y轴正方向 double len_dir = sqrt(dir_x * dir_x + dir_y * dir_y); if (len_dir != 0) { double normalized_x = dir_x / len_dir; double normalized_y = dir_y / len_dir; position.x += (int)(SPEED * normalized_x); position.y += (int)(SPEED * normalized_y); } // 限制玩家移动范围 if (position.x \u0026lt; 0) position.x = 0; if (position.y \u0026lt; 0) position.y = 0; if (position.x + FRAME_WIDTH \u0026gt; WINDOW_WIDTH) position.x = WINDOW_WIDTH - FRAME_WIDTH; if (position.y + FRAME_HEIGHT \u0026gt; WINDOW_HEIGHT) position.y = WINDOW_HEIGHT - FRAME_HEIGHT; } void Draw(int delta_time) { // 在绘制玩家之前绘制阴影 int shadow_pos_x = position.x + (FRAME_WIDTH / 2 - SHADOW_WIDTH / 2); int shadow_pos_y = position.y + FRAME_HEIGHT - 8; putimage_alpha(shadow_pos_x, shadow_pos_y, \u0026amp;img_shadow); static bool facing_left = false; int dir_x = is_moving_right - is_moving_left; if (dir_x \u0026lt; 0) facing_left = true; else if (dir_x \u0026gt; 0) facing_left = false; if (facing_left) anim_left-\u0026gt;Play(position.x, position.y, delta_time); else anim_right-\u0026gt;Play(position.x, position.y, delta_time); } const POINT\u0026amp; GetPosition() const { return position; } public: const int FRAME_WIDTH = 80; const int FRAME_HEIGHT = 80; private: const int SPEED = 3; // player移动的速度 const int SHADOW_WIDTH = 32;\tprivate:\tIMAGE img_shadow; // 玩家脚下阴影 Animation* anim_left; Animation* anim_right; POINT position = { 500, 500 }; // 玩家坐标 // 解决消息处理和按键异步造成的玩家移动卡顿问题 bool is_moving_up = false; bool is_moving_down = false; bool is_moving_left = false; bool is_moving_right = false; }; class Bullet { public: Bullet() = default; ~Bullet() = default; void Draw() const // 在成员方法后面加上const：这个方法不会修改类的成员变量 { setlinecolor(RGB(255, 155, 50)); setfillcolor(RGB(200, 75, 10)); fillcircle(position.x, position.y, RADIUS); } public: POINT position = { 0, 0 }; private: const int RADIUS = 10; }; class Enemy { public: Enemy() { loadimage(\u0026amp;img_shadow, _T(\u0026#34;img/shadow_enemy.png\u0026#34;)); anim_left = new Animation(atlas_enemy_left, 45); anim_right = new Animation(atlas_enemy_right, 45); // 敌人生成的边界 enum class SpawnEdge { Up = 0, Down, Left, Right }; // 将敌人生成在四条边界中随机的一条上 SpawnEdge edge = (SpawnEdge)(rand() % 4); // 具体的随机坐标值 switch (edge) { case SpawnEdge::Up: position.x = rand() % WINDOW_WIDTH; position.y = -FRAME_HEIGHT; break; case SpawnEdge::Down: position.x = rand() % WINDOW_WIDTH; position.y = WINDOW_HEIGHT; break; case SpawnEdge::Left: position.x = -FRAME_WIDTH; position.y = rand() % WINDOW_HEIGHT; break; case SpawnEdge::Right: position.x = WINDOW_WIDTH; position.y = rand () % WINDOW_HEIGHT; break; default: break; } } ~Enemy() { delete anim_left; delete anim_right; } bool CheckBulletCollision(const Bullet\u0026amp; bullet) // 在参数前添加const：这个参数在函数中不会被修改 { // 将子弹等效为一个点，判断该点是否在敌人的矩形内 bool is_overlap_x = bullet.position.x \u0026gt;= position.x \u0026amp;\u0026amp; bullet.position.x \u0026lt;= position.x + FRAME_WIDTH; bool is_overlap_y = bullet.position.y \u0026gt;= position.y \u0026amp;\u0026amp; bullet.position.y \u0026lt;= position.y + FRAME_HEIGHT; return is_overlap_x \u0026amp;\u0026amp; is_overlap_y; } bool CheckPlayerCollision(const Player\u0026amp; player) { // 将敌人的中心点位置视为敌人的碰撞点 POINT check_position = { position.x + FRAME_WIDTH / 2, position.y + FRAME_HEIGHT / 2 }; bool is_overlap_x = check_position.x \u0026gt;= player.GetPosition().x \u0026amp;\u0026amp; check_position.x \u0026lt;= player.GetPosition().x + player.FRAME_WIDTH; bool is_overlap_y = check_position.y \u0026gt;= player.GetPosition().y \u0026amp;\u0026amp; check_position.y \u0026lt;= player.GetPosition().y + player.FRAME_HEIGHT; return is_overlap_x \u0026amp;\u0026amp; is_overlap_y; } void Move(const Player\u0026amp; player) { const POINT\u0026amp; player_position = player.GetPosition(); int dir_x = player_position.x - position.x; int dir_y = player_position.y - position.y; double dir_len = sqrt(dir_x * dir_x + dir_y * dir_y); if (dir_len != 0) { double normalized_x = dir_x / dir_len; double normalized_y = dir_y / dir_len; position.x += (int)(normalized_x * SPEED); position.y += (int)(normalized_y * SPEED); } if (dir_x \u0026gt; 0) facing_left = false; else if (dir_x \u0026lt; 0) facing_left = true; } void Draw(int delta_time) { int shadow_pos_x = position.x + (FRAME_WIDTH / 2 - SHADOW_WIDTH / 2); int shadow_pos_y = position.y + FRAME_HEIGHT - 35; putimage_alpha(shadow_pos_x, shadow_pos_y, \u0026amp;img_shadow); if (facing_left) anim_left-\u0026gt;Play(position.x, position.y, delta_time); else anim_right-\u0026gt;Play(position.x, position.y, delta_time); } void Hurt() { alive = false; } bool CheckAlive() { return alive; } private: const int SPEED = 2; const int FRAME_WIDTH = 80; const int FRAME_HEIGHT = 80; const int SHADOW_WIDTH = 48;\tprivate: IMAGE img_shadow; Animation* anim_left; Animation* anim_right; POINT position = { 0, 0 }; bool facing_left = false; bool alive = true; }; // Button的基类 class Button { public: Button(RECT rect, LPCTSTR path_imag_idle, LPCTSTR path_imag_hovered, LPCTSTR path_imag_pushed) // 加载图片 { region = rect; loadimage(\u0026amp;img_idle, path_imag_idle); loadimage(\u0026amp;img_hovered, path_imag_hovered); loadimage(\u0026amp;img_pushed, path_imag_pushed); } ~Button() = default; void Draw() { switch (status) { case Status::Idle: putimage(region.left, region.top, \u0026amp;img_idle); break; case Status::Hovered: putimage(region.left, region.top, \u0026amp;img_hovered); break; case Status::Pushed: putimage(region.left, region.top, \u0026amp;img_pushed); break; } } void ProcessEvent(const ExMessage\u0026amp; msg) { switch (msg.message) { case WM_MOUSEMOVE: if (status == Status::Idle \u0026amp;\u0026amp; CheckCursorHit(msg.x, msg.y)) status = Status::Hovered; else if (status == Status::Idle \u0026amp;\u0026amp; !CheckCursorHit(msg.x, msg.y)) status = Status::Idle; else if (status == Status::Hovered \u0026amp;\u0026amp; !CheckCursorHit(msg.x, msg.y)) status = Status::Idle; break; case WM_LBUTTONDOWN: if (CheckCursorHit(msg.x, msg.y)) status = Status::Pushed; break; case WM_LBUTTONUP: if (status == Status::Pushed) OnClick(); break; default: break; } } protected: virtual void OnClick() = 0; private: bool CheckCursorHit(int x, int y) { return x \u0026gt;= region.left \u0026amp;\u0026amp; x \u0026lt;= region.right \u0026amp;\u0026amp; y \u0026gt;= region.top \u0026amp;\u0026amp; y \u0026lt;= region.bottom; } private: enum class Status { Idle = 0, Hovered, Pushed }; private: RECT region; IMAGE img_idle; IMAGE img_hovered; IMAGE img_pushed; Status status = Status::Idle; }; // 开始游戏按钮 class StartGameButton : public Button { public: StartGameButton(RECT rect, LPCTSTR path_imag_idle, LPCTSTR path_imag_hovered, LPCTSTR path_imag_pushed) : Button(rect, path_imag_idle, path_imag_hovered, path_imag_pushed) {} ~StartGameButton() = default; protected: void OnClick() { is_game_started = true; mciSendString(_T(\u0026#34;play bgm repeat from 0\u0026#34;), NULL, 0, NULL); // 重复播放bgm } }; // 退出游戏按钮 class QuitGameButton : public Button { public: QuitGameButton(RECT rect, LPCTSTR path_imag_idle, LPCTSTR path_imag_hovered, LPCTSTR path_imag_pushed) : Button(rect, path_imag_idle, path_imag_hovered, path_imag_pushed) {} ~QuitGameButton() = default; protected: void OnClick() { is_running = false; } }; void TryGenerateEnemy(vector\u0026lt;Enemy*\u0026gt;\u0026amp; enemy_list); void UpdateBullets(vector\u0026lt;Bullet\u0026gt;\u0026amp; bullet_list, const Player\u0026amp; player); void DrawPlayerScore(int score); int main() { initgraph(WINDOW_WIDTH, WINDOW_HEIGHT); mciSendString(_T(\u0026#34;open mus/bgm.mp3 alias bgm\u0026#34;), NULL, 0, NULL); // 加载 mciSendString(_T(\u0026#34;open mus/hit.wav alias hit\u0026#34;), NULL, 0, NULL); // player和enemy的构造函数都要用到atlas，所以atlas的初始化必须放在这两者之前 atlas_player_left = new Atlas(_T(\u0026#34;img/player_left_%d.png\u0026#34;), PLAYER_ANIM_NUM); atlas_player_right = new Atlas(_T(\u0026#34;img/player_right_%d.png\u0026#34;), PLAYER_ANIM_NUM); atlas_enemy_left = new Atlas(_T(\u0026#34;img/enemy_left_%d.png\u0026#34;), ENEMY_ANIM_NUM); atlas_enemy_right = new Atlas(_T(\u0026#34;img/enemy_right_%d.png\u0026#34;), ENEMY_ANIM_NUM); Player player; vector\u0026lt;Enemy*\u0026gt; enemy_list; vector\u0026lt;Bullet\u0026gt; bullet_list(3); // 子弹只有三颗，所以不使用指针的形式，避免内存泄漏的风险\tExMessage msg; IMAGE img_menu;\tIMAGE img_background;\tint score = 0; RECT region_btn_start_game, region_btn_quit_game; // ================ UI ================ region_btn_start_game.left = (WINDOW_WIDTH - BUTTON_WIDTH) / 2; region_btn_start_game.right = region_btn_start_game.left + BUTTON_WIDTH; region_btn_start_game.top = 430; region_btn_start_game.bottom = region_btn_start_game.top + BUTTON_HEIGHT; region_btn_quit_game.left = (WINDOW_WIDTH - BUTTON_WIDTH) / 2; region_btn_quit_game.right = region_btn_quit_game.left + BUTTON_WIDTH; region_btn_quit_game.top = 550; region_btn_quit_game.bottom = region_btn_quit_game.top + BUTTON_HEIGHT; StartGameButton btn_start_game = StartGameButton(region_btn_start_game, _T(\u0026#34;img/ui_start_idle.png\u0026#34;), _T(\u0026#34;img/ui_start_hovered.png\u0026#34;), _T(\u0026#34;img/ui_start_pushed.png\u0026#34;)); QuitGameButton btn_quit_game = QuitGameButton(region_btn_quit_game, _T(\u0026#34;img/ui_quit_idle.png\u0026#34;), _T(\u0026#34;img/ui_quit_hovered.png\u0026#34;), _T(\u0026#34;img/ui_quit_pushed.png\u0026#34;)); loadimage(\u0026amp;img_menu, _T(\u0026#34;img/menu.png\u0026#34;)); loadimage(\u0026amp;img_background, _T(\u0026#34;img/background.png\u0026#34;)); BeginBatchDraw(); while (is_running) { DWORD start_time = GetTickCount(); while (peekmessage(\u0026amp;msg)) { if (is_game_started) { player.ProcessEvent(msg); } else { btn_start_game.ProcessEvent(msg); btn_quit_game.ProcessEvent(msg); } }\tif (is_game_started) { player.Move(); UpdateBullets(bullet_list, player); TryGenerateEnemy(enemy_list); for (Enemy* enemy : enemy_list) enemy-\u0026gt;Move(player); // 检测敌人与玩家的碰撞 for (Enemy* enemy : enemy_list) { if (enemy-\u0026gt;CheckPlayerCollision(player)) { static TCHAR text[128]; _stprintf_s(text, _T(\u0026#34;最终得分：%d！\u0026#34;), score); MessageBox(GetHWnd(), text, _T(\u0026#34;游戏结束\u0026#34;), MB_OK); is_running = false; break; } } // 检测敌人与子弹的碰撞 for (Enemy* enemy : enemy_list) { for (const Bullet\u0026amp; bullet : bullet_list) { if (enemy-\u0026gt;CheckBulletCollision(bullet)) { mciSendString(_T(\u0026#34;play hit from 0\u0026#34;), NULL, 0, NULL); enemy-\u0026gt;Hurt(); score++; } } } // 依次检查敌人列表，移除被击杀的敌人 for (size_t i = 0; i \u0026lt; enemy_list.size(); i++) // 因为此处会动容器本身，所以不能用迭代器遍历 { Enemy* enemy = enemy_list[i]; if (!enemy-\u0026gt;CheckAlive()) { // 和容器最后一个元素交换后，移除最后一个 // * 是元素顺序无关紧要时，性能较好的一种删除方法 swap(enemy_list[i], enemy_list.back()); enemy_list.pop_back(); delete enemy; } } } cleardevice(); // ======= Draw ======= if (is_game_started) { putimage(0, 0, \u0026amp;img_background); player.Draw(1000 / FPS); for (Enemy* enemy : enemy_list) enemy-\u0026gt;Draw(1000 / FPS); for (Bullet\u0026amp; bullet : bullet_list) bullet.Draw(); DrawPlayerScore(score); } else { putimage(0, 0, \u0026amp;img_menu); btn_start_game.Draw(); btn_quit_game.Draw(); } FlushBatchDraw(); DWORD end_time = GetTickCount(); DWORD delta_time = end_time - start_time; if (delta_time \u0026lt; 1000 / FPS) { Sleep(1000 / FPS - delta_time); } } // atlas指针需在游戏主循环结束后释放 delete atlas_player_left; delete atlas_player_right; delete atlas_enemy_left; delete atlas_enemy_right; EndBatchDraw(); return 0; } void putimage_alpha(int x, int y, IMAGE* img) { int w = img-\u0026gt;getwidth(); int h = img-\u0026gt;getheight(); AlphaBlend(GetImageHDC(NULL), x, y, w, h, GetImageHDC(img), 0, 0, w, h, { AC_SRC_OVER,0,255,AC_SRC_ALPHA }); } void TryGenerateEnemy(vector\u0026lt;Enemy*\u0026gt;\u0026amp; enemy_list) { const int INTERVAL = 100; static int counter = 0; if (++counter % INTERVAL == 0) { enemy_list.push_back(new Enemy()); } } // 更新子弹位置 void UpdateBullets(vector\u0026lt;Bullet\u0026gt;\u0026amp; bullet_list, const Player\u0026amp; player) { // 让子弹有一个不断收缩的效果，视觉上更加炫酷 const double RADIAL_SPEED = 0.0045; // 径向波动速度 const double TANGENT_SPEED = 0.0055; // 切向波动速度 double radian_interval = 2 * PI / bullet_list.size(); // 三颗子弹间的弧度间隔 // 根据玩家的位置，依次更新每颗子弹的位置 POINT player_position = player.GetPosition(); double radius = BULLET_BASE_RADIUS + BULLET_RADIUS_CHANGE_RANGE * sin(GetTickCount() * RADIAL_SPEED); for (size_t i = 0; i \u0026lt; bullet_list.size(); i++) { double radian = GetTickCount() * TANGENT_SPEED + radian_interval * i; bullet_list[i].position.x = player_position.x + player.FRAME_WIDTH / 2 + (int)(radius * sin(radian)); bullet_list[i].position.y = player_position.y + player.FRAME_HEIGHT / 2 + (int)(radius * cos(radian)); } } void DrawPlayerScore(int score) { static TCHAR text[64]; _stprintf_s(text, _T(\u0026#34;当前玩家得分：%d\u0026#34;), score); setbkmode(TRANSPARENT); settextcolor(RGB(255, 85, 185)); outtextxy(10, 10, text); } 复盘和总结 虽然这个项目的实现还是基于初学者的直觉进行开发，并未做太多架构上的设计，但是我还是学到了很多。老师同样是从游戏的框架开始，渐渐细化每个模块，同时他清晰展示了每一部分遇到了怎样的问题，可以用怎样的思路解决问题，对我自己的开发具有很大的参考价值。同时还让我详细了解了动画的底层实现，补充了颜色和像素的知识，帮我复习了向量运动和2D碰撞。以一个非常浅显又实操的例子让我明白了享元模式和设计模式的作用。经常会有一种醍醐灌顶、拨开云雾见青天的感觉。接下来需要进一步补充的地方，一是关于3D的碰撞检测等，3D的内容在学校里以非常传统的读教科书做题的方式学过一遍，但是没有开发中的例子所以印象不深，感觉现在已经全忘了。二是游戏开发的设计模式，在读书的时候也是按照最传统的方式看书回答问题，但那时的感觉基本是看天书一般，蒙着做的题目，所以应该会继续学习这位老师相关的设计模式课程，让自己在实战中有更深刻的理解。\n","date":"2025-10-08T10:47:30+02:00","image":"https://nullshowjl.github.io/p/%E4%BB%8E%E9%9B%B6%E5%BC%80%E5%A7%8B%E7%9A%84c-%E6%B8%B8%E6%88%8F%E5%BC%80%E5%8F%91%E6%8F%90%E7%93%A6%E7%89%B9%E5%B9%B8%E5%AD%98%E8%80%85/cover_hu_fb747906c8c0cf08.webp","permalink":"https://nullshowjl.github.io/p/%E4%BB%8E%E9%9B%B6%E5%BC%80%E5%A7%8B%E7%9A%84c-%E6%B8%B8%E6%88%8F%E5%BC%80%E5%8F%91%E6%8F%90%E7%93%A6%E7%89%B9%E5%B9%B8%E5%AD%98%E8%80%85/","title":"【从零开始的C++游戏开发】提瓦特幸存者"},{"content":"目录\n术语 行业表达 表达习惯 面试沟通 术语 英文表达 中文解释 使用场景 / 备注 curly braces 花括号 {} 常用于代码块、函数体 greater than or equal to (\u0026gt;=) 大于等于 条件判断 polymorphism 多态 面向对象编程核心概念 inheritance 继承 类之间的关系 encapsulation 封装 数据隐藏与接口暴露 constructor / destructor 构造函数 / 析构函数 C++类生命周期管理 reference / pointer 引用 / 指针 C++变量访问方式 overload / override 重载 / 重写 函数或运算符扩展 compile-time / runtime 编译时 / 运行时 性能与错误定位相关 stack / heap 栈 / 堆 内存管理 thread-safe 线程安全 并发编程 undefined behavior 未定义行为 编译器无法保证结果 template / generic 模板 / 泛型 C++/TypeScript等通用编程 STL (Standard Template Library) 标准模板库 C++常用数据结构与算法 lambda expression 匿名函数表达式 函数式编程风格 scope / lifetime 作用域 / 生命周期 变量可见性与存活时间 行业表达 英文表达 中文解释 使用场景 / 备注 MVP (Minimum Viable Product) 最小可行产品 产品开发初期 pivot 战略转向 产品或业务方向调整 iteration 迭代 敏捷开发周期 sprint 冲刺周期 敏捷开发术语 backlog 待办事项列表 项目管理 playtest 游戏测试 游戏开发流程 asset pipeline 资源处理流程 游戏美术与技术协作 hitbox / collision detection 碰撞盒 / 碰撞检测 游戏物理逻辑 frame rate / FPS 帧率 游戏性能指标 latency / lag 延迟 / 卡顿 网络性能问题 live ops 运营活动 游戏上线后的持续运营 monetization 变现策略 游戏商业模式 sandbox environment 沙盒环境 安全测试或实验环境 scalability 可扩展性 系统设计目标 CI/CD (Continuous Integration / Deployment) 持续集成 / 部署 DevOps 流程 表达习惯 英文表达 中文解释 使用场景 / 备注 Let\u0026rsquo;s refactor this module. 我们来重构这个模块。 团队协作时建议优化代码结构 This function is too tightly coupled. 这个函数耦合度太高。 表达设计问题 Can we abstract this logic? 我们能抽象这段逻辑吗？ 提高复用性 This breaks the single responsibility principle. 这违反了单一职责原则。 代码设计讨论 Let\u0026rsquo;s keep it DRY (Don\u0026rsquo;t Repeat Yourself). 避免重复代码。 编程原则 This is a bit verbose. 这段代码有点啰嗦。 表达代码简洁性问题 It\u0026rsquo;s more idiomatic to use\u0026hellip; 更符合语言习惯的是用\u0026hellip; 语言风格建议 Let\u0026rsquo;s decouple the UI from the logic. 把 UI 和逻辑分离。 前端架构优化 This is a good candidate for a helper function. 可以提取为辅助函数。 提高可读性 We should avoid side effects here. 应避免副作用。 函数式编程建议 面试沟通 英文表达 中文解释 使用场景 / 备注 I led the implementation of\u0026hellip; 我主导了\u0026hellip;的实现 项目介绍 We optimized the performance by\u0026hellip; 我们通过\u0026hellip;优化了性能 技术成果展示 I collaborated with cross-functional teams. 我与跨职能团队合作 展现沟通能力 One challenge we faced was\u0026hellip; 我们遇到的一个挑战是\u0026hellip; 问题分析 I proposed a solution that\u0026hellip; 我提出了一个解决方案\u0026hellip; 展现主动性 The project was deployed to production in\u0026hellip; 项目已上线于\u0026hellip; 项目成果说明 I ensured code quality through\u0026hellip; 我通过\u0026hellip;保障了代码质量 展现工程规范意识 I’m comfortable working in agile environments. 我适应敏捷开发环境 团队协作能力 I’m currently exploring C++ for backend performance. 我正在研究 C++ 提升后端性能 展现学习动力 I’d love to contribute to scalable systems. 我希望参与可扩展系统的开发 职业目标表达 ","date":"2025-09-28T10:28:14+02:00","image":"https://nullshowjl.github.io/p/%E4%B8%93%E4%B8%9A%E8%8B%B1%E8%AF%AD%E5%B8%B8%E7%94%A8%E8%A1%A8%E8%BE%BE/cover_hu_accc02fcfc28c4ca.webp","permalink":"https://nullshowjl.github.io/p/%E4%B8%93%E4%B8%9A%E8%8B%B1%E8%AF%AD%E5%B8%B8%E7%94%A8%E8%A1%A8%E8%BE%BE/","title":"【专业英语】常用表达"},{"content":"目录\n概览 时间线——阶段一 阶段小结一 概览 项目目标：从零开始开发一个个人博客网站，用来记录和分享我在学习、开发中的笔记 、历程和感悟，也是 portfolio 的一部分 技术栈：GitHub Page + Hugo，在Stack theme基础上进行轻量定制修改 开始时间：2025-09-14 当前状态：进行中 学习资源：Hugo官方文档、stack主题文档、B站及网上各位大佬的教程 仓库链接：GitHub 时间线——阶段一 2025-09-14 目标： 创建网站并完成自动部署 添加基础版 看板娘 遇到的问题： 中英语切换时找不到网页 icon小图标在网站标签不显示 解决方法： 在VS Code中使用Github copilot，帮我写了转换语言的js文件 用在别人网页中成功显示的小图标进行测试，排除了图片本身的问题；后来因为处理别的模块，没有在意icon小图标，一段时间后自动显示出来了 思考/收获： 需要边开发边系统学习web programming，才能“知其然，知其所以然”，而不是百分之百依赖AI Github Pages的部署很快，但是在网页显示没有那么及时；如果检查了仓库已经推送上去了，如果此时效果还未显示，不要着急，等一等 2025-09-16 目标： 左边侧栏添加 更新日志 上传第一篇博客（中文版） 修改字体 使用自定义鼠标 遇到的问题： 自定义鼠标不显示 解决方法： 查看了教程博客的评论区，发现可能是图片的问题。使用了教程博主的图片素材测试，发现确实如此 思考/收获： 遇到问题可以看文章或者视频的评论区，常有启发和收获，因为我遇到过的问题也许别人早就遇到并解决了 2025-09-18 目标：做完一系列基础美化\n遇到的问题：\n不知道如何能使每个语言、代码都显示对应的字体 自动部署时遇到 语法报错 失败 解决方法：\n使用Copilot生成代码建议解决了字体问题 语法报错是和缩进有关的，用VS Code它会自动按照它认为对的格式，但其实是错的。换成 记事本 解决 思考/收获：\n前端的语言和缩进也是有关系的，以后需要用 记事本 或者其他编辑器操作，尽量不用VS Code避免类似情况发生 2025-09-19 目标：\n继续做基础美化 调整 背景颜色 图标和文本 添加 动态背景 添加 文章浏览量 统计 遇到的问题：\n调整背景颜色时先遇到了两个图标同时显示的问题，解决后发现 文本格式 没有和左侧菜单栏的其他项目文本对齐 备注：\n尝试解决了很久没有成功，先放一放，等学一段时间前端后再回过头来尝试解决\n2025-09-20 目标：\n继续做基础美化 添加 主页加载动画 添加 网站底部动画 添加 博客发表热力图 遇到的问题：\n网站底部出现不是博客内容的文字 想为 头像 下的小图标增加动画效果，但是没有实现效果 解决方法：\n利用AI排查出是custom.html的注释问题，我想当然地使用了C++的注释，但是被浏览器当做文本处理 备注：\n小图标 的动画效果在学完一些前端后再尝试解决（9-23已解决） 思考/收获：\n每种语言的注释格式看来相差挺大的，需要小心严格遵循对应的语法 2025-09-22 目标：\n继续做基础美化 遇到的问题：\n链接 页面的卡片图片无法统一大小 备注：\n尝试解决后仍然失败，先用调整每张图片的方法暂时处理，等学完一些前端后再继续尝试统一处理 2025-09-23 目标：\n添加RSS，实现根据语言订阅不同版本 虚拟人物widget2D改为 moc3 版本 添加 网站底部动画 添加 博客发表热力图 遇到的问题：\n遇到的问题：\n虚拟人物无法加载 解决方法：\n通过浏览器 开发者工具 debug，获取error信息后结合AI排查出model存放地址的文件夹后少写\\ 思考/收获：\n在写文件地址时要小心核对 使用开发者工具debug很有用 2025-09-24 目标：\n引入新的虚拟人物模型，并将所有模型参数配置成自己喜欢的样式 遇到的问题：\n新的虚拟人物无法加载 通过CDN工具 jsDelivr 加载时发现它不加载新推送的文件 解决方法：\n通过浏览器 开发者工具 的报错信息发现，新模型加载失败是因为该模型过大导致，所以决定放弃使用这个新模型 询问AI得知，jsDelivr 出于稳定性的考虑，一旦tag（缓存）建立，就不会自动更新，即使删除并重建tag。如果版本号一致，那么它只会加载老版本的快照。最后采用了 精准 加载的方法处理，因为不想在仓库留下一堆无用的文件和tag 思考/收获：\n第一次意识到文件大小在网络传输时的重要性。就像我在玩Brotato这样Roguelike的后面几关时注意到大量游戏资源的管理是游戏开发的一个重点一样，每一个编程方向虽有其共性，但也有其特别需要关注的点，这些点需要在实战中慢慢体会 2025-09-29 目标：\n网页底部添加 运行时间、文章篇数 统计等信息 遇到的问题：\n中文显示乱码 解决方法：\n听从AI的建议用VS Code打开文件检查，发现在UTF-8下是乱码。修改后正常显示 思考/收获：\n发现用VS 2022打开并没有出现乱码，但是VS Code可以显示这一问题 只是在使用完VS Code，一点击 保存，它又会擅自更改格式，导致hugo无法编译 阶段小结一 目前进展： 完成个人技术博客的初步开发，包括自动部署和一定程度的美化 收获： 了解静态网站生成器的基本原理（配置 → 模板 → 渲染） 初步掌握GitHub Pages 部署流程 这一阶段没有学习过任何前端的内容，所以所有开发都是参考了网上大佬们的教程、以及在AI的帮助下完成的。在这一过程中学会了 如何向AI更精准地提问，让它来帮助我解决问题的能力 下一步计划： 系统学习前端，并尝试解决阶段一未解决的问题 阅读/ 理解 魔改部分 代码 按照 TODO list 持续更新本站 ","date":"2025-09-24T09:01:30+02:00","image":"https://nullshowjl.github.io/p/%E5%BC%80%E5%8F%91%E6%97%A5%E5%BF%97%E6%88%91%E7%9A%84%E6%8A%80%E6%9C%AF%E5%8D%9A%E5%AE%A2%E7%BD%91%E7%AB%99/cover_hu_4a54ada85fd3b358.webp","permalink":"https://nullshowjl.github.io/p/%E5%BC%80%E5%8F%91%E6%97%A5%E5%BF%97%E6%88%91%E7%9A%84%E6%8A%80%E6%9C%AF%E5%8D%9A%E5%AE%A2%E7%BD%91%E7%AB%99/","title":"【开发日志】我的技术博客网站"},{"content":"目录\n概览 环境配置 Demo1 - 跟随鼠标的圆 Demo2 - 井字棋游戏 复盘和总结 概览 技术栈：C++ + EasyX\n项目目标：完成 EasyX 环境搭建，完成两个小demo（跟随鼠标的小球、井字棋），理解游戏循环的雏形\n课程来源：B站-Voidmatrix\n环境配置 EasyX：直接搜索“EasyX”，官网直接下载安装\n需要在头文件中包含\u0026lt;graphics.h\u0026gt;来使用EasyX中的库函数。\nDemo1 - 跟随鼠标的圆 设计思路 创建窗口并创建游戏主循环\n绘制圆并实现圆跟随鼠标移动\n用双缓冲对绘图进行优化\n开发流程 初始化窗口与主循环 我们使用initgraph()来初始化窗口，然后用一个死循环来避免窗口一闪而过。\n1 2 3 4 5 6 7 8 9 int main() { initgraph(1280, 720); while (true) { } return 0; } 这个死循环是所有游戏的通用框架。所有的输入处理和更新等都会在这个游戏主循环中进行：\n1 2 3 4 5 while (true) { // 玩家的输入 // 画面的更新 } 绘制圆 使用solidcircle() 来绘制圆。\n输入处理 使用peekmessage() 来处理输入。\n在EasyX中，鼠标的移动、点击，键盘的输入等，都被称之为“message”。每次我们有输入操作，EasyX都会将这些“消息”放入消息队列中，每次我们调用 peekmessage()，EasyX就会尝试从消息队列中拉取一个消息，成功返回 true，失败返回 false。所以我们另一个循环不断地从消息队列中拉取消息进行处理，直到消息队列中没有消息为止。\n查阅文档，peekmessge() 必须带有一个参数 msg，msg 是一个指向消息结构体 ExMessage 的指针，用来保存获取到的消息。而 ExMessage 中的一个成员 message 代表“消息标识”，它对应相应的鼠标和键盘等输入操作。于是整个输入处理可以写为：\n1 2 3 4 5 6 7 8 9 10 11 while (true) { ExMessage msg; // 实例化一个ExMessage结构体对象 while (peekmessage(\u0026amp;msg)) { if (msg == WM_MOUSEMOVE) { // 处理鼠标移动逻辑 } } } 清屏 如果不清屏的话，会连续绘制出的小球会展现鼠标移动的轨迹，所以在每次绘制小球前都需要先进行清屏。调用 cleardevice() 进行清屏。\n用双缓冲对绘图进行优化处理 使用BeginBatchDraw()、 FlushBatchDraw()和EndBatchDraw()双缓冲处理。\n1 2 3 4 5 6 7 8 9 10 11 12 13 BeginBatchDraw(); while (true) // 游戏主循环 { // 玩家输入 // 游戏更新 cleardevice(); // 清屏 // 绘制 FlushBatchDraw(); } EndBatchDraw(); 关键步骤 initgraph() 初始化图形窗口\npeekmessage() 获取鼠标移动消息\ncleardevice() 清屏\nsolidcircle(x, y, r) 绘制圆\nBeginBatchDraw()、 FlushBatchDraw()、EndBatchDraw()双缓冲优化绘图\n知识点 EasyX 的坐标系 EasyX 的坐标系在屏幕左上方，x 轴的正方向向右，y 轴的正方向向下。\n渲染缓冲区 可以把渲染缓冲区想象成一块巨大的画布，调用Draw逻辑的过程就是在“画布”上画画。先绘制的内容会被后绘制的内容覆盖，而调用cleardevice()就等于是用当前的填充颜色（默认黑色）将“画布”覆盖了一遍。\n调用BeginBatchDraw()就相当于EasyX为我们新建了一块画布，而这块画布就是新的渲染缓冲区。它和窗口的渲染缓冲区不一样的是，它是不可见的。随后执行的所有Draw逻辑，都会绘制在这块新的画布上。当调用FlushBatchDraw()和EndBatchDraw()时，EasyX会将这两块画布进行迅速交换，这一过程非常迅速以至于我们是看不见的。所以这样就解决因为绘制频繁而导致的画面闪烁问题。\n游戏主循环 在一个死循环中，不断执行：读取操作、处理数据、绘制画面的过程。\n1 2 3 4 5 6 while (true) { // 读取操作(); // 处理数据(); // 绘制画面(); } 在主循环开始之前，我们需要先对游戏数据进行初始化，所以这部分一般放置于BeginBatchDraw()之前。\n在主循环结束之后，需要相应地对游戏资源进行释放。\nDemo2 - 井字棋游戏 游戏介绍 玩家在3*3的棋盘上轮流下子，用O和X表示各自的棋子，如果一方任意三个相同的棋子连成一条直线或斜线时，这一方获胜；如果9个网格均被棋子填充但是却未出现获胜方时，则为平局。此demo只实现本地人人对战。\n设计思路 游戏主循环中三要素的设计 读取操作:\n对鼠标左键按下的消息进行处理。当鼠标左键点击空白的棋盘格子时，执行落子操作。\n数据处理:\n对游戏结束条件进行检测。游戏结束条件为三颗同类型的棋子连成一条直线/斜线，或者棋盘被填满。游戏结束时，使用弹窗。告诉玩家游戏结果，然后退出主循环。\n绘制画面:\n用line()将窗口绘制为3*3的网格棋盘。同样用line()在网格上绘制出交叉的对角线代表一方玩家执的X棋子。用circle()绘制出另一方玩家执的O棋子。\n在窗口左上角输出一行文字，告诉玩家即将落子的棋子类型。\n数据结构 棋盘和棋子:\n用二维数组char board_data[3][3]代表棋盘，用字符char 'X'和char 'O'代表棋子，而还未落子的格子用char '-'表示。\n游戏结束 分类讨论游戏结束的情况。\n情况一：某一方获胜。只需对X字符和O字符分别进行穷举判定。共8种。\n情况二：平局。棋盘中没有一个格子是字符-且没有玩家获胜的情况。\n开发流程 遵循的总体思路 先框架后细化。\n先把游戏主框架用代码表示 这样可以确保我们在写代码是不会被突然出现的细节打扰。使用一个bool running来判断游戏是否运行。使用CheckWin()和CheckDraw()来判断游戏结束是哪一种情况。\n1 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 bool running = true; ExMessage msg; BeginBatchDraw(); // 开启批量绘图 while (running) // 游戏主循环 { while (peekmessage(\u0026amp;msg)) // 读取操作 { } // 处理数据 if (CheckWin(\u0026#39;x\u0026#39;)) { // 跳出弹窗消息，结束游戏 MessageBox(GetHWnd(), _T(\u0026#34;x 玩家获胜\u0026#34;), _T(\u0026#34;游戏结束\u0026#34;), MB_OK); running = false; } else if (CheckWin(\u0026#39;o\u0026#39;)) { // 类似上述处理 } else if (CheckDraw()) { // 类似上述处理 } cleardevice(); // 每次绘图前先清空画面 // 绘制所有的东西 DrawBoard(); // 绘制棋盘 DrawPiece(); // 绘制棋子 DrawPrompt(); // 绘制即将落子的棋子信息 FlushBatchDraw(); // 每次游戏循环后，都刷新批量绘图的缓冲区 } EndBatchDraw(); // 游戏结束，结束批量绘图 细化读取操作部分逻辑 鼠标的坐标在窗口其实是像素点的坐标，于是需要将鼠标的坐标转化为棋盘的网格。用图展示其对应的关系：\n对应的代码可以表示为：\n1 2 3 4 5 6 int x = msg.x; // 鼠标的像素坐标 int y = msg.y; int index_x = x / 200; // 鼠标的棋盘坐标 int index_y = y / 200; 然后，就可以进行落子的逻辑了。最后，记得在成功落子后切换为另一种类型的棋子。\n细化数据处理部分的逻辑 CheckWin()函数就按之前提到的穷举8种情况即可。CheckDraw()部分的代码，需要遍历棋盘的每一个格子，检查是否有\u0026rsquo;-\u0026rsquo;。需要注意的是，如果是一行一行检查的话，外层循环是列，而内层循环是行：\n1 2 3 4 5 6 7 8 9 10 for (int col = 0; col \u0026lt; 3; col++) { for (int row = 0; row \u0026lt; 3; row++) { if (board[row][col] == \u0026#39;-\u0026#39;) { // 相应逻辑 } } } 细化绘制部分的逻辑 棋盘的绘制：直接只用line()对应相应的像素坐标。\nX 棋子的绘制：同样使用line()绘制，只不过使用的是对角线的像素坐标。\nO 棋子的绘制：使用circle()绘制，圆心的x和y分别要在各自棋盘格子像素点的基础上多加100个像素单位。\n提示信息的绘制：为了在更通用的编码环境下使用，用了并不常见的类型和函数，但是和C中的printf()类似。\n1 2 static TCHAR str[64]; _stprintf_s(str, _T(\u0026#34;当前棋子类型：%c\u0026#34;), current_piece); 一些美化字体的函数：\n1 2 settextcolor(RGB(225, 175, 45)); // 把颜色变为橙色，在视觉上更醒目 outtextxy(0, 0, str); // 在指定位置输出str字符串 优化 最后一枚落子不绘制：\n如果胜负判断放在最后，那么由于MB_OK弹窗会等待玩家点击，点击后直接退出程序，会跳过最后一枚棋子的绘制。为了解决这个问题，这里简单粗暴的将绘制函数放在胜负判断的逻辑之前。\nCPU占用高：\n计算机在执行while循环时速度较快，我们编写的主循环在顷刻间已经执行完了成千上万次，占用了大量的CPU时间片。对于大部分物理刷新率仅有60Hz的显示设备来说，这是一种性能浪费。简单粗暴的解决方法是使用sleep(15)，让程序在每一次循环结束后强制等待15ms。但这不是推荐的做法。因为随游戏体量的增大，程序每次执行主循环所执行的计算任务可能是不同的，涉及到操作系统CPU计算资源的分配，会导致每次执行主循环所实际消耗的时间可能是不一样的。所以我们需要根据每一帧执行的实际耗时，动态计算在这之后要休眠多长时间。推荐的做法是我们自己设置帧率。用到的函数是GetTickCount()，它可以获取程序自运行开始以来到现在的毫秒数。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 while (running) { DWORD start_time = GetTickCount(); // 获取此次循环初始时间 读取操作(); 处理数据(); 绘制画面(); DWORD end_time = GetTickCount(); // 获取此次循环结束时间 DWORD delta_time = end_time - start_time; // 计算间隔时间 // 依据间隔时间动态分配休眠时间 // 按每秒60帧刷新页面 if (delta_time \u0026lt; 1000 / 60) // 如果间隔时间\u0026lt;每秒60帧，要进行休眠；否则不需要。 { Sleep(1000 / 60 - delta_time); } } 释放资源(); } 完整源码 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 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 #include \u0026lt;graphics.h\u0026gt; char board_data[3][3] = { {\u0026#39;-\u0026#39;, \u0026#39;-\u0026#39;, \u0026#39;-\u0026#39;}, {\u0026#39;-\u0026#39;, \u0026#39;-\u0026#39;, \u0026#39;-\u0026#39;}, {\u0026#39;-\u0026#39;, \u0026#39;-\u0026#39;, \u0026#39;-\u0026#39;} }; char current_piece = \u0026#39;O\u0026#39;; bool CheckWin(char c); bool CheckDraw(); void DrawBoard(); void DrawPiece(); void DrawPrompt(); int main() { //======= 初始化数据 ======= initgraph(600, 600); ExMessage msg; bool running = true; // 双缓冲，解决图形闪烁问题 BeginBatchDraw(); //======= 游戏主循环 ======= while (running) { DWORD start_time = GetTickCount(); while (peekmessage(\u0026amp;msg)) { //======= 读取操作 ======= // 检测鼠标左键按下后的消息 if (msg.message == WM_LBUTTONDOWN) { // 计算点击位置 int x = msg.x; int y = msg.y; int index_x = y / 200; int index_y = x / 200; //========= 处理数据 ========= // 落子 if (board_data[index_y][index_x] == \u0026#39;-\u0026#39;) { board_data[index_y][index_x] = current_piece;\t// 切换棋子类型 if (current_piece == \u0026#39;O\u0026#39;) { current_piece = \u0026#39;X\u0026#39;; } else if (current_piece == \u0026#39;X\u0026#39;) { current_piece = \u0026#39;O\u0026#39;; } } } }\tcleardevice(); //===== 绘制 ===== DrawBoard();\tDrawPiece(); DrawPrompt(); FlushBatchDraw(); // 判断胜负，出于解决最后一枚棋子绘制的问题而放在绘制逻辑之后 if (CheckWin(\u0026#39;X\u0026#39;)) { MessageBox(GetHWnd(), _T(\u0026#34;X 玩家获胜\u0026#34;), _T(\u0026#34;游戏结束\u0026#34;), MB_OK); running = false; } else if (CheckWin(\u0026#39;O\u0026#39;)) { MessageBox(GetHWnd(), _T(\u0026#34;O 玩家获胜\u0026#34;), _T(\u0026#34;游戏结束\u0026#34;), MB_OK); running = false; } else if (CheckDraw()) { MessageBox(GetHWnd(), _T(\u0026#34;平局\u0026#34;), _T(\u0026#34;游戏结束\u0026#34;), MB_OK); running = false; } //======= 设置帧率（优化）======= DWORD end_time = GetTickCount(); DWORD delta_time = end_time - start_time; if (delta_time \u0026lt; 1000 / 60) { Sleep(1000 / 60 - delta_time); // 节约性能，补足帧率至60，不要运行得太快 } } EndBatchDraw(); return 0; } bool CheckWin(char c) { if (board_data[0][0] == c \u0026amp;\u0026amp; board_data[0][1] == c \u0026amp;\u0026amp; board_data[0][2] == c) return true; if (board_data[1][0] == c \u0026amp;\u0026amp; board_data[1][1] == c \u0026amp;\u0026amp; board_data[1][2] == c) return true; if (board_data[2][0] == c \u0026amp;\u0026amp; board_data[2][1] == c \u0026amp;\u0026amp; board_data[2][2] == c) return true; if (board_data[0][0] == c \u0026amp;\u0026amp; board_data[1][0] == c \u0026amp;\u0026amp; board_data[2][0] == c) return true; if (board_data[0][1] == c \u0026amp;\u0026amp; board_data[1][1] == c \u0026amp;\u0026amp; board_data[2][1] == c) return true; if (board_data[0][2] == c \u0026amp;\u0026amp; board_data[1][2] == c \u0026amp;\u0026amp; board_data[2][2] == c) return true; if (board_data[2][0] == c \u0026amp;\u0026amp; board_data[1][1] == c \u0026amp;\u0026amp; board_data[0][2] == c) return true; if (board_data[0][0] == c \u0026amp;\u0026amp; board_data[1][1] == c \u0026amp;\u0026amp; board_data[2][2] == c) return true; return false; } bool CheckDraw() { for (int col = 0; col \u0026lt; 3; col++) { for (int row = 0; row \u0026lt; 3; row++) { if (board_data[row][col] == \u0026#39;-\u0026#39;) { return false; } } } return true; } void DrawBoard() { line(0, 200, 600, 200); line(0, 400, 600, 400); line(200, 0, 200, 600); line(400, 0, 400, 600); } void DrawPiece() { for (int col = 0; col \u0026lt; 3; col++) { for (int row = 0; row \u0026lt; 3; row++) { switch (board_data[row][col]) { case \u0026#39;-\u0026#39;: break; case \u0026#39;O\u0026#39;: circle(200 * row + 100, 200 * col + 100, 100); break; case \u0026#39;X\u0026#39;: line(200 * row, 200 * col, 200 * (row + 1), 200 * (col + 1)); line(200 * (row + 1), 200 * col, 200 * row, 200 * (col + 1)); } } }\t} void DrawPrompt() { static TCHAR str[64]; _stprintf_s(str, _T(\u0026#34;当前棋子类型：%c\u0026#34;), current_piece); settextcolor(RGB(225, 175, 45)); outtextxy(0, 0, str); } 复盘和总结 这是我第一次如此透彻地理解游戏主循环、双缓冲机制、游戏中的坐标系以及帧率的设置。之前虽然也用C++和raylib做过一些小游戏，但是都是照猫画虎、囫囵吞枣、一知半解。这次的学习是跟着教程的思路和节奏做的，所以并未严格按照面向对象的思路去封装类，而是专注于用最简单、最快的实现方法，体会游戏的通用架构。按照top-down的思路，从框架到具体的一步步分解问题，逐个解决。计划跟完这位大佬的所有课程，在提升自己编程能力的同时，加深对游戏开发编程的理解，以及让自己的代码风格符合业界的惯例。\n","date":"2025-09-18T09:01:30+02:00","image":"https://nullshowjl.github.io/p/%E4%BB%8E%E9%9B%B6%E5%BC%80%E5%A7%8B%E7%9A%84c-%E6%B8%B8%E6%88%8F%E5%BC%80%E5%8F%91%E5%9F%BA%E7%A1%80/cover_hu_d5ce1d5c701e4148.webp","permalink":"https://nullshowjl.github.io/p/%E4%BB%8E%E9%9B%B6%E5%BC%80%E5%A7%8B%E7%9A%84c-%E6%B8%B8%E6%88%8F%E5%BC%80%E5%8F%91%E5%9F%BA%E7%A1%80/","title":"【从零开始的C++游戏开发】基础"}]