谢夏戈 @ xiexiage.com

原型1:蛇是一个数组

Sep 3

贪吃蛇玩法早就定了,为什么还要"白块原型"?为了逻辑与表现分离:先让数据对,再让画面好看。这样出 bug 时能一眼定位——数组里坐标就是错的 → 逻辑 bug;数组对但画错位置 → 渲染 bug。一上来就连美术一起做,出了问题得同时怀疑两层,调试时间翻倍。

先换脑子:贪吃蛇和小鸟的根本区别

小鸟(连续)贪吃蛇(离散)
位置任意小数 (317.4, 892.1)只能在格子上 (5, 8)
移动每帧挪一点点每 0.15 秒跳一整格
谁在驱动_physics_process(delta) 每秒 60 次Timer 到点发一次信号
碰撞物理引擎算比较两个格子坐标相不相等

这是整个项目最重要的观念转变:贪吃蛇里几乎没有"物理",只有"坐标"和"时间"。

搭场景

scenes/main.tscn,根节点 Node2D 命名 Main,下面 8 个子节点:

Main (Node2D)                    ← 挂脚本 scripts/main.gd
├── Board (ColorRect)            ← 棋盘底色,Size 1920×1080
├── SnakeCells (Node2D)          ← 空容器,蛇的方块生成在它下面
├── FoodCells (Node2D)           ← 空容器,食物的方块生成在它下面
├── StarCell (ColorRect)         ← 星星,Size 20×20,Visible 先关掉
├── MoveTimer (Timer)            ← Wait Time 0.15,Autostart
├── FoodTimer (Timer)            ← Wait Time 3,Autostart
├── StarSpawnTimer (Timer)       ← Wait Time 12,Autostart
└── StarLifeTimer (Timer)        ← Wait Time 5,One Shot,不 Autostart

为什么蛇和食物是"空容器",星星却直接是一个方块?数量不固定的东西(蛇身 3~400 节,食物 0~3 个)用"容器 + 动态生成子节点"来画;数量固定最多 1 个的,直接放一个节点,用显示/隐藏控制。"有几个"决定"怎么画"。

为什么 4 个 Timer 不合成一个?一个 Timer 只管一件事,名字就是说明书:看到 StarLifeTimer 在跑,就知道"星星还活着"。合成一个省 3 个节点,换来一堆 if 判断"这次响是为了什么"。节点便宜,脑子贵。

数据结构

extends Node2D

## ===== 常量:整个游戏只认这几个数 =====
const GRID_W := 96                     # 横向几格
const GRID_H := 54                     # 纵向几格
const CELL_SIZE := 20                  # 一格是几个像素   96 × 20 = 1920,54 × 20 = 1080
const MAX_FOOD := 3                    # 场上最多几个普通食物
const NO_CELL := Vector2i(-1, -1)      # 「没有」—— 一个棋盘上不存在的格子

## ===== 数据:蛇 = 坐标数组,第 0 个是头 =====
var snake_body: Array[Vector2i] = [
    Vector2i(48, 27),   # 头(正中间)
    Vector2i(47, 27),
    Vector2i(46, 27),   # 尾
]
var direction := Vector2i.RIGHT        # 现在正在往哪走
var next_direction := Vector2i.RIGHT   # 玩家刚按的方向,下一步才生效

## ===== 数据:食物 =====
var foods: Array[Vector2i] = [Vector2i(60, 27)]   # 普通食物,先手放一个
var star := NO_CELL                    # 星星,最多 1 颗;NO_CELL 表示现在没有

func _ready() -> void:
    print(snake_body)

几行关键的解释:

  • Vector2i:Vector 2 integer,只存整数的二维坐标。格子坐标天然是整数;用 Vector2 会出现 (5.0000001, 8),比较"相等"会失败
  • Array[Vector2i]:类型化数组,只装 Vector2i,塞别的当场报错
  • 第 0 个是头:移动 = 头部插一格 + 尾部删一格。头放数组开头,insert(0, ...) 最直观
  • foods 是数组,star 不是:和场景结构一一对应——数据结构和场景结构长得一样
  • NO_CELL = (-1, -1):哨兵值。Vector2i 类型的变量不能存 null,那"现在没有星星"怎么表示?用一个棋盘上不可能出现的坐标当"没有"

F5,输出面板打印 [(48, 27), (47, 27), (46, 27)]。画面还是没东西,正常——先验数据,再画。

让蛇"动"起来(先在控制台动)

选中 MoveTimer → 节点面板 → 信号 → 双击 timeout() → 连接到 Main

func _on_move_timer_timeout() -> void:
    direction = next_direction          # 玩家最近按的方向,现在才真正生效
    var new_head := snake_body[0] + direction
    snake_body.insert(0, new_head)      # ① 头部插入新格子
    snake_body.pop_back()               # ② 删掉尾巴
    print(snake_body)

为什么用 Timer,不用小鸟那种 _physics_process_physics_process 每秒跑 60 次,适合"每帧挪一点点"。贪吃蛇是"每 0.15 秒跳一格",用 Timer 到点才动一次,天然就是离散的。

为什么移动是"插头删尾",不是"每一节往前挪"

移动前  [头(48,27)] [(47,27)] [(46,27)]
        ↓ 向右
① 插头  [新头(49,27)] [(48,27)] [(47,27)] [(46,27)]
② 删尾  [新头(49,27)] [(48,27)] [(47,27)]

逐节挪:蛇 50 节要算 50 次,而且要从尾往头算(不然会覆盖)。插头删尾:永远只操作 2 个元素,不管多长。而且"吃到食物"只要"不删尾"就自动变长——一行都不用多写。 这是贪吃蛇最漂亮的设计。

F5,输出面板每 0.15 秒打印一次,头的 x 一直加 1。数据先对了,下一章画出来。

2023-PRESENT © 谢夏戈