一个Unity新手的3D贪吃蛇开发历程

前言

刚好放寒假的时候,在大致进行了系统的Unity与C#的学习之后,我觉得我是时候做出一款游戏出来了。尽管之前跟随网上的教程完成过一两个简单的小项目,可那时候毕竟只能算是照猫画虎,学习了这么多知识之后也有必要通过一些项目来把这些知识巩固在脑海中,所以我就打算从头开始,自己做一款小游戏,不管是怎样的,至少先做出来再说吧!
我打算从相对简单的开始,不能急于求成。于是我打算做一种3D的贪吃蛇小游戏。因为我认为我这个项目的目的不是做的有多精美,多好玩,而是加强我的基本程序能力、熟练的使用unity编辑器的基础功能,在我需要的时候我能自己学会用什么、点什么、在哪里写些什么,而不是像最初稀里糊涂的跟着教程有样学样。
我打算在一个大场景中生成各种障碍物和食物,让贪吃蛇在场景中吃到食物来增加身体长度。在经过一段时间的研究与学习,经历了很多困难的解决与想法的改变后,最终我如愿完成了LecSnake(我们是卷王)-3D贪吃蛇小游戏
在这个项目中,我不再是教程的模仿者,而是真正成为了系统的设计者。每一个技术决策背后,都是对Unity编辑器的深入探索和C#编程思维的锤炼。

Login

游戏截图

项目链接


开发历程

🐍 贪吃蛇的进化

第一阶段:物理系统

理想:

  • 用Rigidbody实现头部运动控制
  • 通过Joint连接身体部件
  • 期待呈现自然的物理摆动效果

现实:

  • 刚体的摩擦力和重力导致速度不稳定
  • 多节身体相互碰撞产生”卡顿链式反应”
  • 惯性作用使转弯时身体完全失控

一开始,我用刚体圆球来代表蛇的身体,用WASD控制蛇头的前后左右移动,然后用Joint组件来连接身体与身体,需要增加长度时,就在蛇尾部生成一个body球,然后连接到最后。我本想以此模拟顺滑的物理效果,可我发现并非如此。我发现刚体是有摩擦力和重力的,随着身体越来越多移动会越来越困难,这导致速度不平均。于是我花了很长时间磨合摩擦力与重力的数值,想通过调节数值来解决这个问题,最后我发现即使不使用重力不使用摩擦力都不能解决这个问题,于是我又将蛇头的移动方式从按下按键施加力变为简单的每帧改变坐标,我甚至锁定了所有身体的y轴使它们不再接触地面,但即使解决了速度问题依然还有其他问题,例如身体总会不规则的旋转,用线连接的身体也会有惯性导致蛇的身体乱动,而且最终也并没有很好的实现转弯时平滑的物理效果。所以我放弃了使用刚体来模拟物理效果,再次经过了一段时间的探索后,我找到了足够实现我需求的理想方法:

第二阶段:返璞归真的坐标追踪法

经过两周的反复试验,终于找到优雅的解决方案:

  • 改用立方体消除不规则旋转
  • 创建历史坐标队列记录头部轨迹
  • 身体部件按固定间隔跟随对应坐标

我将球形身体改为方形,这样连起来更美观且能解决很多问题,然后在蛇的移动上,我使用了一个historyPosition列表存储蛇头走过的位置,然后将身体的坐标逐帧改为对应的坐标,而蛇头则是自动向前,由AD键控制左转和右转,空格加速。

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
void Move()
{
    // 向前移动
    transform.position += transform.forward * moveSpeed * Time.deltaTime;
    // 加速
    if (Input.GetKey(KeyCode.Space))
    {
        moveSpeed = 40;
        bodySpeed = 40;
fastCount += Time.deltaTime;
if (fastCount > 2)
        {
if (score > 0) score--;
            fastCount = 0;
        }
    }
    else
    {
      moveSpeed = 20;
        bodySpeed = 20;
    }

    // 方向操控
float steerDirection = Input.GetAxis("Horizontal");
    transform.Rotate(Vector3.up * steerDirection * steerSpeed * Time.deltaTime);

    // 保存位置移动史
positionHistory.Insert(0, transform.position);
    while (positionHistory.Count > bodyList.Count * Gap)
        positionHistory.RemoveAt(positionHistory.Count - 1);

    // 移动身体
    int index = 0;
    foreach (var body in bodyList)
    {
        Vector3 point = positionHistory[Mathf.Clamp(index * Gap, 0, positionHistory.Count - 1)];

        // 让贪吃蛇的身体沿头部轨迹运动
        Vector3 moveDirection = point - body.transform.position;
        body.transform.position += moveDirection * bodySpeed * Time.deltaTime;

        // 让身体朝向头部移动的方向
        body.transform.LookAt(point);

index++;
    }
}

这个方案不仅实现了顺滑的蛇体运动,更让我深刻理解了数据驱动的设计哲学。当放弃对物理引擎的执念后,问题反而迎刃而解。
这样就实现了贪吃蛇的平滑移动了。这是我认为最耗脑筋的一个点,也是用了最长时间的一个点。


🍎 食物与障碍物

老实说这个地方没什么好讲的,当时做的时候也是简单完成,唯一值得思考的点就是在随机生成的时候要考虑到重叠生成,比如一个食物生成到一个树(障碍物)里面去了,于是我还思考了一下,考虑将场景分成很多格子来生成物体,随机格子的位置即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int num = 0;
while (num < 1000)
{
    int x = Random.Range(1, 32);
    int z = Random.Range(1, 32);
    Quaternion spawnRotation = Quaternion.Euler(0, Random.Range(0, 360), 0);

    if (!map[z, x])
    {
        map[z, x] = true;

        x = x <= 16 ? ((x - 17) * 2 + 1) : ((x - 16) * 2 - 1);
        z = z <= 16 ? ((17 - z) * 2 - 1) : ((16 - z) * 2 + 1);

        Vector3 spawnPosition = new Vector3(x * 25, 0.5f, z * 25);
        Instantiate(Treelist[0], spawnPosition, spawnRotation);

        num++;
    }
}

应用后才发现按这样生成出来的位置都太有规律性了,有时候太整齐不是什么好事。如果贪吃蛇只能四向移动的话这样的生成方法明显更优。

在贪吃蛇碰到食物或障碍物后会将该物体销毁并执行对应的加分减分操作,然后再在场景一个一个随机位置生成一个对应物体使之不会越来越少。但后来我发现,其实可以在碰到物体时直接随机更改一下物体的坐标就可以了,而不用销毁再生成,省去了不必要的步骤与消费。


🗺️ 小地图的实现

之后我想到在左上角放一个缩略地图的功能,可以更方便玩家查看附近的情况。具体步骤在之前的文章介绍过:
Unity日记:小地图

这个功能的实现,让我对Unity的多摄像机协作有了更深理解。


⚙️ 暂停与设置

我添加了暂停按钮,通过Time.timeScale调整游戏的时间状态,并添加了一些选项设置。通过调节摄像机与音频给玩家带来不同的体验。

暂停的设置界面

这个模块的开发,让我体会到系统隔离的重要性。


🔄 主界面场景切换

做到这里主要的功能基本就完成了,最后我想给游戏添加一个开始界面与背景,于是便在游戏启动时默认播放一个描述背景故事的视频,点击可以跳过,随后便进入开始界面。
开始界面

我将它放在了一个独立的场景里,在场景中房里几颗树木烘托氛围。当玩家点击开始后就会跳转到游戏的主场景了。关于场景切换,在之前的文章亦有记载:

Unity日记:场景切换与并发编程-异步加载

在这个过程中,我真正理解了游戏状态管理的艺术。


💾 存档系统

在最后的最后,一款能让人玩下去的游戏一定离不开存档,于是我了解并学习了有关数据持久化的相关知识,并为我的贪吃蛇游戏添加了存档。存档实现的详情见以下文章:

Unity日记:存档系统-JSON-示例

我存储了食物和障碍物的坐标列表、historyPosition列表以及得分等数据简单实现了贪吃蛇游戏的存档系统。

2024年12月续:值得一提的是,我在面试微派的时候,面试官曾问我有没有想过不存食物和障碍物的坐标且仍能实现存档,当时我还云里雾里,知道面试官提了随机数种子的概念,我才想到可以用相同的随机数种子,生成的随机坐标就是和上一次一样的坐标了,障碍物和食物也会在原先的位置,这样只需要存以下种子,就依然可以实现存档。妙哉!


结语

这个项目最宝贵的收获,是让我建立起独立解决问题的自信。当看到自己构思的系统通过自己的努力真正运转起来时,那种喜悦远超复制十个教程项目。期待在下一个项目中挑战更复杂的游戏程序设计,相信我的程序水平会越来越成熟!

—end—