做Unity小游戏项目的时候,我个人特别反感那种“写完就扔”的Demo式代码。贪吃蛇这个题材看起来简单,但正因为规则清晰、状态明确,反而特别适合用来搭一套能复用的基础框架。这篇文章我想完整拆解一下我用Unity实现贪吃蛇基础框架时的设计思路、代码结构和实操细节,把每个关键决策背后的原因也一并讲清楚。不管你是刚接触Unity想找个小项目练手,还是已经在做游戏开发、想看看别人怎么组织代码,这篇文章应该都能给你一些实际可用的东西。
1. 项目定位与整体设计思路
1.1 为什么选贪吃蛇作为框架载体
你可能觉得贪吃蛇不过是入门级的小游戏,有什么好聊的。但恰恰是这种“规则少、逻辑清晰”的项目,最适合用来打磨一套通用基础框架。它里面包含了游戏开发最核心的几个模块:场景管理、对象生命周期、输入处理、碰撞检测、UI状态刷新、事件通信。这些东西在任何商业项目里都会遇到,只是在小游戏里它们被简化到了刚好能看透本质的程度。
如果把贪吃蛇直接写成一个大脚本,把所有逻辑塞进Update方法里,那当然也能跑,而且可能还更快。但这种写法一旦项目规模变大,哪怕是增加一个暂停功能、一个道具系统,都会让你头皮发麻。所以我在这个项目里刻意做了模块拆分,把“蛇”“食物”“分数”“输入”“地图”这些概念全部独立成类,通过事件和单向依赖来组织它们之间的关系。
1.2 基础框架的设计目标
我在动手之前给自己定了几个目标,这些目标直接影响了我后面的每个设计决策:
- 单向依赖:上层可以依赖下层,下层绝不可以反过来依赖上层。具体来说,GameManager可以知道SnakeController的存在,但SnakeController不应该反向依赖GameManager。
- 可替换性:输入系统可以自由切换键盘、触屏、手柄,而改动范围只限于输入模块内部,不影响蛇的逻辑。
- 可扩展性:以后想加道具、穿墙模式、障碍物,或者改成联机版,不需要推翻现有结构,只需要增加新模块或者修改部分配置。
- 可调试性:每个模块都能单独验证,出问题的时候能快速定位到具体是哪一层出了问题。
带着这些目标,我搭建的框架最终包含以下几个核心部分:游戏管理器(GameManager)、蛇控制器(SnakeController)、食物生成器(FoodSpawner)、输入处理器(InputHandler)、地图管理器(GridManager)、UI管理器(UIManager)。它们之间通过事件和公开接口通信,下面我会逐一拆解。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心模块拆分与数据流设计
2.1 场景与物体层级规划
一个清晰的物体层级结构,有时候比代码本身更重要。我见过太多小项目,所有物体全部平铺在Hierarchy面板里,找东西全靠眼睛扫描。这个项目里我的物体层级长这样:
code复制GameRoot
├── Managers
│ ├── GameManager
│ ├── GridManager
│ └── UIManager
├── Snake
│ ├── SnakeHead
│ └── SnakeBodyRoot
├── Food
│ └── FoodItem
└── Environment
├── Background
└── Borders
这种组织方式带来的直接好处是:当场景里挂载的脚本越来越多时,你依然能一眼看出这个物体的职责是什么。GameRoot是空物体,只挂GameManager作为整个游戏的入口。SnakeHead单独一个节点,因为它需要挂碰撞体和玩家控制的脚本,而蛇身的每一节则由SnakeController在运行时动态生成,统一放在SnakeBodyRoot下面,保持Hierarchy整洁。
这里有一个细节值得注意:地图的边界碰撞体没有挂在单独的Border物体上,而是挂在Environment下面。这样设计是因为后续如果想做“穿墙模式”,只需要禁用Environment下的Border碰撞体即可,蛇的逻辑代码完全不用改。这就是把边界判断从蛇的移动逻辑里剥离出来的收益。
2.2 核心类职责划分
我最终把代码拆成了六个核心类,每个类只负责一件事:
GameManager:游戏的最高层调度器。负责初始化整个游戏流程,监听游戏状态变化(Start、Playing、Paused、GameOver),并对外广播状态变更事件。它持有其他核心模块的引用,但只做“调度”这件事,不直接参与蛇的移动或食物生成。
GridManager:负责把3D场景坐标转成逻辑网格坐标,以及反向转换。贪吃蛇本质上是跑在网格上的游戏,蛇头每一帧移动的方向只有上下左右四种,蛇身每一节的位置也是离散的网格坐标。GridManager存在的意义就是把“世界坐标(Vector3)”和“逻辑坐标(Vector2Int)”之间做一个清晰的映射。
SnakeController:蛇的所有行为逻辑,包括移动、转向、生长、死亡检测。它只关心自己收到的方向指令,不关心这个指令是怎么来的——是键盘、触屏还是AI发来的,它一律不感兴趣。
FoodSpawner:负责在空闲网格上生成食物,并在食物被吃掉后重新生成。它还需要维护一个“当前食物是否存在”的状态,避免同一帧被重复触发两次生成。
InputHandler:封装所有输入来源,将物理输入(键盘、触摸)转换成游戏内部的方向事件。这样做的好处是,如果以后要支持手柄或者虚拟摇杆,只需要改这一个类。
UIManager:负责刷新分数、显示游戏状态、绑定按钮事件。它监听GameManager广播的状态事件,根据状态切换UI界面。
你可能会问,为什么FoodSpawner要独立出来而不是直接挂在GameManager里?因为食物和蛇的最大区别在于:蛇是有状态的对象,它有长度、方向、速度这些随时间变化的属性;而食物是纯被动对象,它只需要响应“被吃掉”这唯一一个事件。把它们耦合在同一个类里会让代码变得难以维护,而拆开之后各自都很纯粹。
2.3 状态机与事件通信
游戏状态管理用了一个非常轻量的状态机。不用写复杂的状态机库,一个枚举加两个事件就足够了。我定义了GamePhase枚举,包含Ready、Playing、Paused、GameOver四种状态。GameManager是唯一允许修改这个枚举的类,其他模块只能监听状态变化事件。
事件通信我用了C#内置的Action和event关键字,没有引入UnityEngine.Events,也没用委托链工具库。原因很简单:项目规模不需要引入额外依赖,而C#原生的event语法足够清晰可靠。比如食物被吃掉这件事,SnakeController在检测到蛇头与食物碰撞后,不直接调用FoodSpawner的方法,而是广播一个FoodEaten事件。FoodSpawner订阅这个事件,在回调里把自己管理的那份食物销毁,并生成新的一个。
这种事件驱动的方式比直接调用方法多了一层间接性,但换来的是模块解耦。假设以后要加“音效播放器”,那你只需要让SoundManager订阅FoodEaten事件就行,完全不需要改动SnakeController的任何代码。这就是事件系统的核心价值——它让模块之间的依赖关系从“你认识我所以调用我”变成了“你只需要发出信号,关心你的人自然会回应”。
3. 从零实现:关键代码与实操步骤
3.1 网格地图与坐标系设计
贪吃蛇的移动实质上是在网格上跳变,而不是连续平滑移动。我采用的方案是:逻辑上使用Vector2Int网格坐标,表现层再把网格坐标映射到3D场景坐标。
网格大小我设定为16×16,每个格子边长1个单位。世界坐标与网格坐标的映射公式如下:
code复制世界坐标 = 网格坐标 * 格子边长 - 地图中心偏移
地图中心偏移是7.5,因为16个格子,中心点在第7.5格的位置,这样地图原点正好落在左下角。代码实现在GridManager中:
csharp复制public class GridManager : MonoBehaviour
{
public int gridWidth = 16;
public int gridHeight = 16;
public float cellSize = 1f;
public Vector3 GridToWorld(Vector2Int gridPos)
{
float offsetX = (gridWidth - 1) * cellSize * 0.5f;
float offsetZ = (gridHeight - 1) * cellSize * 0.5f;
return new Vector3(gridPos.x * cellSize - offsetX, 0, gridPos.y * cellSize - offsetZ);
}
public Vector2Int WorldToGrid(Vector3 worldPos)
{
float offsetX = (gridWidth - 1) * cellSize * 0.5f;
float offsetZ = (gridHeight - 1) * cellSize * 0.5f;
int x = Mathf.RoundToInt((worldPos.x + offsetX) / cellSize);
int y = Mathf.RoundToInt((worldPos.z + offsetZ) / cellSize);
return new Vector2Int(x, y);
}
}
这里有个很重要的设计决策:我把地图放在XZ平面上,Y轴固定为0。也就是说蛇是在水平面上移动的。这么做有两点考虑:第一,3D游戏里绝大多数地面移动逻辑都在XZ平面上,沿用这个习惯未来扩展3D化会更容易;第二,Unity中XZ平面是水平面,碰撞检测、物理射线等系统在这个平面上工作最稳定,不容易出现浮点精度问题。
网格坐标用的Vector2Int而不是普通Vector2,是因为移动计算中涉及大量坐标加减,整数运算在大量计算时性能更好,而且能天然避免浮点累积误差。贪吃蛇的坐标是非常精确的网格坐标,用浮点数纯属自找麻烦。
3.2 蛇身数据结构与移动逻辑
蛇身我用了一个List
每次移动时,我会把蛇头坐标向当前方向推进一步,然后在列表头部插入这个新坐标,再移除列表尾部的坐标,这样蛇就整体前进了一格。如果蛇吃到食物,只需要跳过“移除尾部”这一步,蛇就自然长了一节。核心逻辑大概是:
csharp复制private void MoveSnake()
{
Vector2Int newHead = snakeBody[0] + currentDirection;
snakeBody.Insert(0, newHead);
if (hasEatenFood)
{
hasEatenFood = false;
// 不删除尾部,长度加一
}
else
{
snakeBody.RemoveAt(snakeBody.Count - 1);
}
UpdateSnakeVisual();
}
这里有一个需要特别注意的细节:Insert操作是O(n)复杂度的,在蛇身长度很长时可能会有性能问题。但实测下来,即使蛇长到100节,每帧一次O(n)操作对现代设备来说完全可以忽略不计。如果以后要做大世界超高蛇身长度,可以考虑改用LinkedList或者分段存储,但现阶段List已经是最好的选择——它在调试、序列化、扩展上都有优势。
移动驱动方式我也纠结过:是用Update加FixedUpdate,还是用协程?最终我选择在Update里做逻辑移动,并用一个移动间隔timer来控制速度。具体做法是:每帧累加时间增量,当达到当前移动间隔时执行一次MoveSnake,并重置计时器。
csharp复制void Update()
{
if (gamePhase != GamePhase.Playing) return;
moveTimer += Time.deltaTime;
if (moveTimer >= moveInterval)
{
moveTimer -= moveInterval;
MoveSnake();
}
}
选择Update而不是FixedUpdate的原因在于:FixedUpdate适合物理系统,而贪吃蛇的移动完全是逻辑驱动的,不需要物理引擎参与。Update模式下移动间隔可以由你自由控制,不受物理帧率影响,实现“分数越高速度越快”这个需求也更直接——只需要修改moveInterval变量即可。我设置的默认移动间隔是0.2秒,也就是每秒5格,感觉速度比较适中。每次吃到食物后移动间隔减少0.005秒,下限设为0.05秒,防止速度过快完全无法操作。
3.3 蛇身节点的对象池管理
蛇身的视觉表现我用了Cube作为单节节点。如果每长一节就Instantiate一个新Cube,蛇吃到死时场景里会堆满物体。所以我给蛇身节点做了一个简单的对象池。
对象池的核心思想是:创建过的物体不销毁,而是放进池子里复用。当蛇身增长时,从池子取出一个隐藏节点并显示出来;当蛇身缩短时(虽然这个游戏里不会缩短,但做其他游戏时可能会),把节点放回池子并隐藏。这个池子我放在了SnakeBodyRoot下面,用Queue
csharp复制private Queue<GameObject> bodyPool = new Queue<GameObject>();
private GameObject GetBodyNode()
{
if (bodyPool.Count > 0)
{
GameObject node = bodyPool.Dequeue();
node.SetActive(true);
return node;
}
return Instantiate(bodyPrefab, bodyRoot);
}
private void ReleaseBodyNode(GameObject node)
{
node.SetActive(false);
bodyPool.Enqueue(node);
}
严格来说,贪吃蛇这个游戏里蛇身只会变长不会变短,对象池的必要性看起来没那么强。但你想想后面要做道具系统(减速、缩短蛇身、穿墙),对象池就有用了。而且养成用对象池的习惯,在未来做任何有大量物体频繁生成销毁的项目时(子弹、敌人、特效),都能直接复用这套思路。我见过太多新手在制作射击游戏时因为频繁Instantiate和Destroy导致卡顿,根源就是早期没养成对象池思维。
3.4 蛇的转向控制与输入缓冲
贪吃蛇的转向有一个经典陷阱:如果你允许蛇在一帧内连续转向180度,它就会直接撞上自己的脖子。举个例子,蛇正在向右移动,玩家在极短时间内按下上键再按左键,如果两个输入都在同一帧内被处理,蛇会先向上再向左,看起来就像是直接反向穿越了身体。
解法方案是引入一个输入缓冲队列,每一帧最多只消费一个方向指令。具体做法是:InputHandler接收到合法方向输入后,不直接修改蛇的方向,而是推入一个队列。SnakeController的移动逻辑在每次移动前从队列里取出最早的那个有效方向进行处理,并且要校验新方向不能与当前移动方向相反。
csharp复制private Queue<Vector2Int> directionBuffer = new Queue<Vector2Int>();
public void SetDirection(Vector2Int newDir)
{
if (directionBuffer.Count >= 2) return; // 最多缓存两个方向
Vector2Int lastDir = directionBuffer.Count > 0
? directionBuffer.Peek()
: currentDirection;
// 不允许掉头
if (newDir == -lastDir) return;
directionBuffer.Enqueue(newDir);
}
private void ConsumeDirection()
{
if (directionBuffer.Count > 0)
{
currentDirection = directionBuffer.Dequeue();
}
}
这个缓冲队列还会解决另一个问题:玩家在快速连续操作时,输入指令不会丢失。比如蛇正在向右移动,玩家快速按了“上”和“左”,正确行为应该是蛇先向上,再在下一格向左。如果没有缓冲队列,第二次按左可能因为还没到下一格就被丢弃,玩家就觉得操作“吞键”了,体验很糟糕。
输入源封装我这里支持了键盘和触摸两种方式。键盘用传统的WASD或方向键,触摸则是监听屏幕滑动方向。InputHandler把两种源统一转换成方向事件,SnakeController完全不关心输入来自哪里。
3.5 食物生成与碰撞检测
食物生成有一个很容易忽略的边界条件:新生成的食物绝对不能落在蛇身上。最直接暴力的做法是从所有空闲网格里随机选一个,但网格是16×16,蛇越长空闲格子越少,不能简单随机碰运气。我的做法是维护一个空闲网格列表:
csharp复制public Vector2Int GetValidFoodPosition(List<Vector2Int> occupiedGrids)
{
List<Vector2Int> validCells = new List<Vector2Int>();
for (int x = 0; x < gridWidth; x++)
{
for (int y = 0; y < gridHeight; y++)
{
Vector2Int cell = new Vector2Int(x, y);
if (!occupiedGrids.Contains(cell))
{
validCells.Add(cell);
}
}
}
return validCells[Random.Range(0, validCells.Count)];
}
当网格数量很大、蛇身较长时,这个O(n²)的遍历可能会有点浪费。但16×16的网格总共才256个格子,每次食物生成遍历一遍根本毫无压力。如果你把地图扩大到100×100,那才需要考虑用空间哈希表来维护空闲格子集合。做框架的意义就在于:选择恰好的复杂度,不为不存在的性能问题提前架构。
碰撞检测我采用了逻辑层面的网格坐标比较,而不是依赖Unity的物理碰撞体。当蛇头移动后,我会检查蛇头坐标是否与以下对象重合:
- 地图边界(x < 0 || x >= width || y < 0 || y >= height)
- 蛇身自身(snakeBody.Contains(newHead) && newHead != snakeBody[last])
- 食物坐标(newHead == foodPosition)
为什么不直接用物理碰撞体?因为这个游戏里所有物体都在网格上,逻辑判断比物理运算要精确、轻量、可控得多。物理碰撞适合的是不规则形状、连续运动、需要反弹/摩擦等复杂交互的场景。贪吃蛇的判定是纯离散逻辑,用物理系统反而是杀鸡用牛刀。
3.6 分数、速度与UI联动
分数系统我放在了GameManager里,由SnakeController在吃到食物时通过FoodEaten事件上报,GameManager统一更新分数和速度。UI部分只负责显示GameManager里最新的数值,不做任何逻辑计算。这种“逻辑驱动表现”的模式是整个框架的核心原则之一。
UI更新频率也是个可以优化的细节。我不会在每次分数变化时都直接操作Text组件的text属性,而是只在分数确实发生变化时刷新。原因很简单:直接改UGUI的Text会触发网格重建,频繁重建会带来不必要的CPU开销。虽然这个游戏里分数变化频率很低,但养成这个习惯能避免以后做HUD频繁刷新(比如血量、金币)时卡顿。
同时我做了计分与速度的联动:每吃到一个食物得分加10分,同时移动间隔缩短0.005秒。这里需要注意,移动间隔的修改应该在移动后的下一帧生效,不能在移动进行中修改,否则可能出现两个移动逻辑在同一帧内执行两次。我的做法是修改moveInterval后立即重置moveTimer为0,确保下一帧重新计时。
3.7 完整玩法的缝合成型
把以上模块组合在一起,需要一个总控脚本负责把它们串起来。我在GameManager的Start方法中做了以下初始化顺序:
- 创建GridManager并初始化网格;
- 创建SnakeController,传入初始位置和初始方向;
- 创建FoodSpawner,生成第一个食物;
- 注册InputHandler的方向事件回调;
- 将SnakeController的FoodEaten事件绑定到GameManager的OnFoodEaten上;
- 初始化UI,订阅GameManager的状态广播事件,切换Ready界面。
这个顺序是有讲究的:必须先有网格,蛇才能确定自己的初始位置;必须先有蛇,食物生成器才能拿到蛇身坐标来避开生成。事件绑定必须放在所有模块初始化完成之后,否则可能出现SnakeController已经发出事件但还没有订阅者接收的情况。
运行流程整体是这样的:游戏启动后处于Ready状态,玩家点击开始按钮后切换到Playing状态,SnakeController开始按移动间隔前进,InputHandler接收玩家输入并缓冲方向指令,蛇头碰到食物时触发FoodEaten事件,FoodSpawner生成新食物,GameManager更新分数和速度,蛇头碰到边界或自身时触发GameOver事件,UI切换到结束界面并显示最终分数。整个流程是闭环的单向依赖,任何一环出问题都能快速定位。
4. 常见问题与排查技巧实录
4.1 蛇身反向移动导致“自杀”
这是贪吃蛇最容易踩的坑。你以为已经加了方向校验,但实际测试时仍然会出现蛇头反向撞上身体的诡异情况。我排查了很久才发现问题出在校验时机上——即使方向缓冲队列的逻辑正确,如果输入处理和移动逻辑在同一个Update中执行,且顺序不对,依然可能产生反向移动。
比如当前Update中,移动逻辑先执行,蛇向右移动了一格,然后输入逻辑后执行,收到“向左”的指令。此时蛇的方向已经是右,向左就是反向。但因为你只在输入入队时校验方向,而这个校验发生在蛇移动之后,所以校验通过了,等到下一帧执行移动时才会发现方向变成了反向,但为时已晚。
解决办法是把方向校验从入队时改为消费时再做一次校验。即在ConsumeDirection中取出方向后,与当前方向对比,如果是反向则直接丢弃并继续取下一个。双重校验可以彻底杜绝掉头问题。
4.2 移动卡顿和帧率不稳
我一开始直接把蛇的移动放在了FixedUpdate里,结果发现速度一旦调快就会出现明显的抖动。排查后发现问题在于FixedUpdate的默认频率是每秒50次,如果你的移动间隔是0.2秒,那么移动的时机在不同帧之间会有微小偏差,表现出来就是视觉上的卡顿。
后来我改成Update加计时器,移动间隔严格由计时器控制,卡顿问题消失了。还有一个隐藏问题:如果游戏逻辑在同一帧内执行了多次移动(比如移动间隔设置得比帧耗时要短,一帧内需要移动两格),需要把“移动”抽成一个独立方法,在循环中调用,而不是依赖Update循环自然触发。
4.3 输入误触发和虚拟按键冲突
在真机上测试时,滑动屏幕的方向判定很容易误触发。我最初用的是Vector2的magnitude阈值来判断是否算一次有效滑动,但发现手指轻微抖动也会被识别为一次滑动。后来我改用“滑动距离超过一定阈值且持续超过一定时间”才算有效滑动,同时记录滑动方向时取起点到终点的最大分量方向,效果好了很多。
另一个问题是键盘输入时如果按住方向键不放,会连续触发移动。这个在Unity中是正常现象,因为Input.GetKeyDown在按住时不会反复触发,但如果用了GetKey就会每帧触发。对于贪吃蛇来说,玩家按住一个方向键应该持续移动,所以这个行为其实是合理的。但如果以后要接虚拟摇杆,需要注意摇杆的输入值通常带模拟量,需要做死区处理,避免漂移。
4.4 对象池使用中的陷阱
对象池有一个很经典的问题:从池子里取出的对象如果忘了重置它的状态,会出现各种诡异的“复用后遗症”。比如以前设置过缩放、旋转、颜色,复用的时候没重置,蛇身节点就会出现忽大忽小、颜色错乱的情况。我的经验是:每个对象在放入池子之前,要把所有与初始状态相关的属性保存好,或者在取用的时候统一调用一个Reset方法。
另一个问题是池子里的对象如果被场景重置(比如重新开始游戏),池子里的对象会被销毁,但池子本身不知道。我实现的ReleaseBodyNode是先将节点设到bodyRoot下,如果不是null才入队,这样即使场景被卸载也不会报错。这属于防御式编程,虽然多写了几行,但能避免很多线上bug。
4.5 重新开始游戏的坑
很多小项目在“重新开始”这个功能上翻车,因为只重置了蛇的位置和分数,但忘了清理上一次游戏留下的残留对象。我的做法是:重新开始时不直接复用场景里的物体,而是调用一个ResetAll方法,把所有模块内对象恢复到初始状态。
比如SnakeController的ResetAll逻辑是:清空蛇身列表,释放所有蛇身节点到对象池,创建初始长度的蛇身,重置方向和移动间隔。FoodSpawner的ResetAll逻辑是:销毁当前食物,重新生成一个。GameManager的ResetAll逻辑是:分数归零,游戏状态切换到Playing,广播一场新游戏开始事件。
只要每个模块的ResetAll做好了,重新开始游戏就是一个干净的全新开局,不会出现蛇身残留、食物躲在边界外、分数异常这样的问题。
4.6 场景物体命名与目录组织
最后说一个看起来不痛不痒但实际上影响深远的事情:物体命名的规范。我曾见过一个项目里所有的Cube都叫Cube,所有空物体都叫GameObject,后期维护的时候找东西找到怀疑人生。在这个项目里我要求所有节点名字直观反映功能:蛇头叫SnakeHead、食物叫FoodItem、背景叫Background、边界叫BorderTop/BorderBottom/BorderLeft/BorderRight。别小看这个习惯,当你在Unity的Profiler里看到一句“SnakeHead.Update()”和一句“GameObject.Update()”时,前者能让你瞬间定位到问题,后者只会让你抓狂。
脚本命名也有一套约定:每个核心脚本都用模块名做前缀,GameManager、GridManager、SnakeController、FoodSpawner、UIManager。这样在Project窗口按名称排序时,同属于这个游戏的核心脚本会自然聚在一起,脚本间引用关系也是一目了然。
5. 这份框架的延伸空间
基础框架做完后,我试着在它上面加了一些功能来验证扩展性。第一个尝试是“穿墙模式”:只需要在GameManager里加一个bool字段,在检测边界碰撞前判断一下这个字段,如果为true就把蛇头从另一端传送过来,几行代码就搞定了。第二个尝试是“加速道具”:在地上随机生成一个特殊食物,蛇吃到后移动间隔减半,持续5秒。这个功能只需要增加一个SpeedUpItem类,订阅FoodEaten事件,在特定概率下生成特殊食物,再让GameManager处理减速逻辑,现有模块几乎无需改动。
这也是我一直坚持模块化设计的原因。当你能在几小时内快速实现新玩法,而不是每次都要大规模改动既有代码,你才会真正感受到“框架”带来的价值。对于Unity小项目来说,框架的意义从来不是增加代码量或者显得很专业,而是让你后续每一次改需求都能像在工具箱里找对的工具那样,精准、快速、不费力。
这个贪吃蛇基础框架我还在持续完善,下一步想加入的是回放功能和AI自动寻路,用来做测试对比。如果各位在实际搭建过程中有什么更好的方案,或者踩到什么我没提到的坑,欢迎一起讨论。
