谢夏戈 @ xiexiage.com

原型2:画面是数据的镜子

Sep 4

画出来

加在 main.gd 里,并在 _ready()_on_move_timer_timeout() 末尾各调一次 redraw()

## 照着数据画方块。每次都先清空再重画 —— 简单,先跑通
func redraw() -> void:
    for cell in $SnakeCells.get_children():
        cell.queue_free()                       # 删掉上一次画的蛇
    for cell in $FoodCells.get_children():
        cell.queue_free()                       # 删掉上一次画的食物

    for grid_pos in snake_body:
        _draw_cell($SnakeCells, grid_pos, Color.GREEN)
    for grid_pos in foods:
        _draw_cell($FoodCells, grid_pos, Color.RED)

    $StarCell.visible = star != NO_CELL         # 有星星才显示
    $StarCell.size = Vector2(CELL_SIZE, CELL_SIZE)   # 尺寸也由代码定,别信场景里的数
    $StarCell.position = Vector2(star) * CELL_SIZE

## 在某个容器下画一个格子。蛇和食物都用它
func _draw_cell(parent: Node, grid_pos: Vector2i, color: Color) -> void:
    var rect := ColorRect.new()
    rect.size = Vector2(CELL_SIZE, CELL_SIZE)
    rect.position = Vector2(grid_pos) * CELL_SIZE   # 格子坐标 → 像素坐标
    rect.color = color
    parent.add_child(rect)
  • Vector2(grid_pos) * CELL_SIZE:格子 (5, 8) × 20 = 像素 (100, 160)positionVector2(小数型),所以先把 Vector2i 转过去再乘
  • queue_free():把节点标记为"这一帧结束后删掉",比立刻删安全
  • $StarCell.visible = star != NO_CELL:右边算出来是 true / false,直接赋给 visible,一行顶四行 if
  • 为什么抽一个 _draw_cell():蛇和食物画法一模一样,只是颜色和放哪不同。同样的 5 行要写第二遍的时候,就该抽成函数

为什么每次都清空重画,不是挪现有方块?白块阶段追求"最不容易错",不追求快。清空重画的逻辑只有一句话:画面 = 数据。你改数组,画面跟着变;绝对不要反过来(读方块的位置去推蛇在哪)。这就是"数据驱动""单一数据源"在代码里的样子。

这里踩过一个坑:星星画出来是 4 格。因为 StarCell 是场景里摆好的节点,尺寸是当时在检查器里填的 40(Godot 新建 ColorRect 的默认值),40 ÷ 20 = 2,2×2 就是 4 格。同一个数存了两份——代码里一份、场景里一份——迟早对不上。所以上面 redraw() 里那句 $StarCell.size = ... 把尺寸也收归代码管。

04-draw-白块

方向控制

func _input(event: InputEvent) -> void:
    if event.is_action_pressed("move_up") and direction != Vector2i.DOWN:
        next_direction = Vector2i.UP
    elif event.is_action_pressed("move_down") and direction != Vector2i.UP:
        next_direction = Vector2i.DOWN
    elif event.is_action_pressed("move_left") and direction != Vector2i.RIGHT:
        next_direction = Vector2i.LEFT
    elif event.is_action_pressed("move_right") and direction != Vector2i.LEFT:
        next_direction = Vector2i.RIGHT

这一节全是为了两个 bug:

① 不能 180° 掉头。 向右跑时按左,新头会正好落在第二节上,瞬间"撞到自己"暴毙。所以每个方向都判断 direction != 相反方向

② 输入只改 next_direction,不直接改 direction 想象你向右跑,在同一个 0.15 秒里连按 ↑ 再按 ←:如果直接改 direction,第一下把它改成 UP,第二下检查"UP 的反方向是 DOWN,← 不是 DOWN",于是放行 → direction 变成 LEFT → 但蛇还没真的动过,等于原地从向右掉头成向左,撞死自己。用 next_direction 暂存,真正移动那一刻才 direction = next_direction,判断永远基于"上一步真的走了的方向"。

插上手柄,十字键和摇杆同样能转——代码一个字没为手柄多写,因为用的是第 2 章起的动作名。

顺便说一个和代码无关、但害我查了半小时的事:Godot 4.4 起游戏默认嵌在编辑器里跑,「游戏」标签的工具栏有 输入 / 2D / 3D 三个模式,只有「输入」模式按键才进游戏;2D / 3D 是运行时点选节点用的,会把输入截走。代码没错的时候,先看这个开关。

三种碰撞 + 吃

_on_move_timer_timeout() 改成完整版。注意判定要在"插头"之前——先算出下一格是哪,再看那一格能不能去,能去才真的走:

func _on_move_timer_timeout() -> void:
    direction = next_direction
    var new_head := snake_body[0] + direction

    # ① 撞墙:任何一个分量跑出范围
    if new_head.x < 0 or new_head.x >= GRID_W \
    or new_head.y < 0 or new_head.y >= GRID_H:
        die()
        return

    # ② 撞自己:新头的位置已经在身体里
    if new_head in snake_body:
        die()
        return

    snake_body.insert(0, new_head)

    # ③ 吃:三种情况的共同点 —— 吃到了就不删尾
    if new_head in foods:
        foods.erase(new_head)                   # 从食物列表里拿走这一个
        print("+1")
    elif new_head == star:
        star = NO_CELL
        $StarLifeTimer.stop()                   # 它的寿命倒计时也不用跑了
        print("+2")
    else:
        snake_body.pop_back()                   # 什么都没吃到 → 删尾

    redraw()

func die() -> void:
    $MoveTimer.stop()
    print("died, length = ", snake_body.size())
  • new_head in snake_body:数组支持 in,"里面有没有这个元素"。「撞到自己」这么复杂的问题一行解决——因为蛇身本来就是坐标数组,这就是选对数据结构的价值
  • 为什么吃食物用 in,吃星星用 ==:和第 1 章那张实体表一一对应——食物有好几个,要问"在不在列表里";星星只有一颗,直接问"是不是它"。数据结构决定判定写法
  • return:死了就立刻退出,后面的插头、重画都不执行。不写它,死了还会再走一步

随机挑一个空格子

## 在所有空格子里随机挑一个;棋盘满了返回 NO_CELL
func random_empty_cell() -> Vector2i:
    var empty_cells: Array[Vector2i] = []
    for x in GRID_W:
        for y in GRID_H:
            var cell := Vector2i(x, y)
            if cell in snake_body or cell in foods or cell == star:
                continue                        # 被占了,跳过这格
            empty_cells.append(cell)
    if empty_cells.is_empty():
        return NO_CELL
    return empty_cells.pick_random()

为什么"先列出所有空格再随机挑",不是"随机挑一格,被占了就重挑"?蛇很长时随机撞到蛇身的概率极高,可能循环几百次,理论上会死循环。先列空格再挑,一次搞定,而且顺便处理了"填满通关"这个边界情况。

  • is_empty():数组是不是空的,等于 size() == 0,但读起来是一句人话。没有空格子 = 棋盘被填满了,从空数组里拿东西会报错,所以先问一句
  • pick_random():等概率返回其中一个元素,不删除它。能自己写 empty_cells[randi() % empty_cells.size()],但一个词说清楚的事就别写一行算式——引擎自带的方法先查有没有,再自己写
  • continue:跳过这一轮循环剩下的部分,直接看下一个格子。和 return(退出整个函数)不一样

到这里,撞墙会停、吃到会变长、绕回来撞自己会停。下一章让食物自己长出来、让星星自己消失。

2023-PRESENT © 谢夏戈