使用 Rust 编写贪吃蛇游戏

最近我用 Rust 和 Piston 写了一个贪吃蛇。这个项目表面上很小:棋盘、蛇、食物、方向键,再加一个窗口,似乎很快就能完成。但真正动手以后,我发现它正好包含了一组非常适合练习游戏程序设计的问题:

我不想先写一个大而全的 Game,再逐渐把代码拆出去,而是从最底层的数据开始设计。项目最终分为:

这也是本文的叙述顺序。前两个文件决定了游戏规则与状态不变量,是文章的重点;render.rs 只负责把已经确定的状态显示出来。

snake.rs:实现格点、食物与蛇

snake.rs 是整个项目最底层的模块。它不需要知道窗口有多大,也不需要知道一秒钟会更新多少次。它只回答几个领域问题:

先把这些规则写清楚,game.rs 才能在上层组合它们。

Point:用逻辑格点隔离像素细节

蛇不应该直接保存像素坐标。假如每个格子宽 50 像素,蛇从一个格点移动到下一个格点,在逻辑上只是 𝑥 或 𝑦 增减一,而不是增减 50。

因此我定义了泛型 Point<T>:

#[derive(PartialEq, Clone, Copy, Eq, Hash)]
pub struct Point<T> {
    x: T,
    y: T,
}

实际游戏使用 Point<i32>。使用有符号整数是因为棋盘采用以原点为中心的数学坐标系,格点可以位于负半轴。

这个小结构体派生的 trait 都有具体用途:

如果没有 Eq + Hash,后面就不能使用 HashSet<Point<i32>> 管理空闲格点。由此我第一次很具体地感受到:trait 不是为了形式上“给类型贴标签”,而是在声明这个类型能参加哪些操作。

Point 的字段保持私有,通过方法读取:

impl<T> Point<T> {
    pub fn new(x: T, y: T) -> Self {
        Self { x, y }
    }

    pub fn x(&self) -> &T {
        &self.x
    }

    pub fn y(&self) -> &T {
        &self.y
    }
}

x() 和 y() 返回引用而不是强制复制,使这个泛型实现不要求任意 T 都实现 Copy。尽管当前的 i32 可以复制,这种写法仍然保持了泛型接口的完整性。

Direction 与一步移动

方向是一个有限集合,适合使用枚举:

#[derive(PartialEq, Clone, Copy)]
pub enum Direction {
    Up,
    Down,
    Left,
    Right,
}

真正的移动规则属于 Point<i32>:

impl Point<i32> {
    pub fn step(&self, direction: &Direction) -> Self {
        match direction {
            Direction::Up =>
                Point::new(self.x, self.y + 1),
            Direction::Down =>
                Point::new(self.x, self.y - 1),
            Direction::Left =>
                Point::new(self.x - 1, self.y),
            Direction::Right =>
                Point::new(self.x + 1, self.y),
        }
    }
}

这里明确选择了数学坐标约定:

这看起来只是四行匹配,却提前决定了后面 Board::toPixel 必须翻转 𝑦 轴。若底层采用屏幕坐标的“向下为正”,坐标映射会简单一点,但逻辑层就不再是我希望使用的 𝑥𝑂𝑦 坐标系。

我选择让逻辑模型保持数学意义,把与屏幕有关的不一致全部留给 game.rs 中的 Board 处理。

FoodType 与三种食物等级

普通贪吃蛇往往只有一种食物。为了增加选择,我设计了三种等级:

#[derive(PartialEq, Clone, Copy)]
pub enum FoodType {
    Normal,
    Medium,
    Huge,
}

三类食物对应不同的分数和真实时间寿命:

奖励越高,留给玩家的时间越短。Food 保存位置、分数、绝对过期时间和类型:

pub struct Food {
    pub position: Point<i32>,
    pub score: i32,
    pub expireAt: Instant,
    pub foodType: FoodType,
}

构造食物时,根据枚举一次性确定属性:

impl Food {
    pub fn new(
        position: Point<i32>,
        foodType: FoodType,
    ) -> Self {
        let score = match foodType {
            FoodType::Normal => 1,
            FoodType::Medium => 3,
            FoodType::Huge => 5,
        };

        let expireAt = match foodType {
            FoodType::Normal =>
                Instant::now() + Duration::from_secs(60),
            FoodType::Medium =>
                Instant::now() + Duration::from_secs(30),
            FoodType::Huge =>
                Instant::now() + Duration::from_secs(15),
        };

        Self {
            position,
            score,
            expireAt,
            foodType,
        }
    }

    pub fn isExpired(&self) -> bool {
        Instant::now() >= self.expireAt
    }
}

我一开始想到的方案是保存一个整数 lifeTime,每次更新减一。但这样食物寿命会依赖游戏更新频率;两个食物若在不同时间产生,还需要格外注意各自减去了多少次。

保存 expireAt: Instant 后,食物不需要启动线程,也不需要“主动倒计时”。真实时间会自然流逝,查询时只需比较 Instant::now() 和到期时刻。Instant 还是单调时钟,不会因为用户调整系统日期而突然前进或后退。

这里还要区分“知道自己过期”和“删除自己”。Food::isExpired() 只能报告状态;真正删除食物的权力属于拥有它的 Game。这一点会在 game.rs 中继续讨论。

Snake:为什么选择 VecDeque

蛇每次移动的结构变化非常固定:

  1. 在头部插入一个新格点;
  2. 如果没有吃到食物,删除尾部;
  3. 如果吃到食物,保留尾部,长度增加一。

如果使用普通 Vec,并把蛇头放在下标零,那么每次 insert(0, newHead) 都可能移动后面的全部元素。VecDeque 正好支持两端操作,因此更符合蛇的模型:

pub struct Snake {
    body: VecDeque<Point<i32>>,
    direction: Direction,
    pendingDirection: Direction,
    score: i32,
    speed: f64,
}

初始蛇从原点向负 𝑥 轴延伸:

Self {
    body: VecDeque::from([
        Point::new(0, 0),
        Point::new(-1, 0),
        Point::new(-2, 0),
    ]),
    direction: Direction::Right,
    pendingDirection: Direction::Right,
    score: 0,
    speed: 1.5,
}

队首始终是蛇头,队尾始终是蛇尾。外部模块不直接取得 body 的所有权,只能借用迭代器:

pub fn points(
    &self,
) -> impl Iterator<Item = &Point<i32>> {
    self.body.iter()
}

这既隐藏了具体容器,也保证渲染器和 Game 遍历蛇身时不会把内部集合移动出去。将来即使改变蛇身容器,只要仍能返回相同迭代器,调用方也不必跟着修改。

Snake::step:用 Option 表达增长

最初我把“吃食物”写成一个独立方法,直接把食物坐标追加到蛇尾。这个思路是不正确的:食物位于蛇头前方,它的位置和当前尾巴没有关系;直接把食物位置作为新尾节,会让蛇身瞬间断裂。

正确的增长方式是:

当前实现让 Game 提前判断本次是否吃到食物,然后把分数作为 Option<i32> 传入:

pub fn step(
    &mut self,
    score: Option<i32>,
) -> (Point<i32>, Option<Point<i32>>) {
    self.direction = self.pendingDirection;

    let newHead =
        self.getHead().step(&self.direction);

    self.body.push_front(newHead);

    if let Some(s) = score {
        self.score += s;
        self.speed = self.calculateSpeed();
        (newHead, None)
    } else {
        let oldTail = self.body.pop_back();
        (newHead, oldTail)
    }
}

参数的含义是:

返回值中的 Option<Point<i32>> 则表示:

这让我认识到,Option<T> 不只是空指针的替代品。它还可以表达一次状态转移中“某件事是否发生,并在发生时携带什么数据”,从而避免再维护一组容易失去同步的布尔字段。

蛇长与速度

我希望蛇越长,移动越快,但又不能无限加速。当前速度按蛇长线性增长:

fn calculateSpeed(&self) -> f64 {
    let newSpeed =
        1.5 + (self.length() - 3) as f64 * 0.1;

    newSpeed.min(5.0)
}

初始长度为三,速度为每秒 1.5 格;每增长一节增加 0.1 格每秒;最大速度为每秒 5 格。

Snake 只保存和计算“每秒走几格”,并不自行计时。何时调用 Snake::step 是 Game 和 Timer 的职责。这样蛇的领域模型不会依赖 Piston 的帧率。

更稳妥的写法还可以使用:

let extraLength =
    self.length().saturating_sub(3) as f64;

虽然当前蛇只会增长,不会短于三节,但 saturating_sub 能防止将来加入缩短机制后发生 usize 下溢。

快速连续输入:为什么需要两个方向

“新方向不能和当前方向相反”看似简单,直接在按键事件中修改 direction 却有一个漏洞。

假设蛇实际上向右移动。在下一次逻辑步发生前,玩家快速输入:

Up → Left

如果第一个按键立即把 direction 改为 Up,第二个 Left 相对 Up 仍然合法。蛇还没有真正向上走过,却最终从向右直接变成向左,相当于实际反向。

解决办法是区分:

输入始终和上一次实际方向比较:

pub fn changeDirection(
    &mut self,
    newDirection: Direction,
) {
    let isOppositeToCurrentDirection =
        (self.direction == Direction::Up
            && newDirection == Direction::Down)
        || (self.direction == Direction::Down
            && newDirection == Direction::Up)
        || (self.direction == Direction::Left
            && newDirection == Direction::Right)
        || (self.direction == Direction::Right
            && newDirection == Direction::Left);

    if !isOppositeToCurrentDirection {
        self.pendingDirection = newDirection;
    }
}

预判下一蛇头时使用 pendingDirection:

pub fn nextHead(&self) -> Point<i32> {
    self.getHead().step(&self.pendingDirection)
}

真正执行 step() 时才提交:

self.direction = self.pendingDirection;

于是 Right → Up → Left 中,最后的 Left 会因为与实际方向 Right 相反而被拒绝,本次移动仍然向上。

这个问题不依赖窗口,很适合单元测试:

#[test]
fn rapid_inputs_cannot_reverse_before_a_step() {
    let mut snake = Snake::new();

    snake.changeDirection(Direction::Up);
    snake.changeDirection(Direction::Left);

    assert!(snake.nextHead() == Point::new(0, 1));

    snake.step(None);
    assert!(snake.getHead() == Point::new(0, 1));
}

至此,snake.rs 已经能够独立描述格点、方向、食物和一次蛇移动。下一步才是让 Game 组织这些对象。

game.rs:实现棋盘与状态转移

game.rs 是整个项目的核心。snake.rs 只描述“蛇怎样走一步”,而 Game 必须决定:

我把 Board、Timer 和 Game 放在这个模块中。它们分别负责空间、时间和完整状态转移。

Board:只保存空间规则,不复制实体状态

一开始我考虑过让 Board 保存二维格点数组,每个格子记录“空白、蛇头、蛇身或食物”。但 Snake 已经保存身体,Food 已经保存位置。如果棋盘再保存一份内容,每次移动就必须同步多个真相来源。

重复状态最危险的地方不是多占一点内存,而是它们可能不一致:蛇已经移动,Snake.body 是新位置,棋盘数组却仍然是上一帧。

所以 Board 最终只保存空间参数:

pub struct Board {
    xMax: i32,
    yMax: i32,
    cellSize: i32,
}

棋盘逻辑范围为:

x ∈ [-xMax, xMax)
y ∈ [-yMax, yMax)

边界判断为:

pub fn isInBounds(
    &self,
    point: &Point<i32>,
) -> bool {
    *point.x() >= -self.xMax
        && *point.x() < self.xMax
        && *point.y() >= -self.yMax
        && *point.y() < self.yMax
}

这里采用左闭右开区间,格子数量正好是 2 * xMax 乘 2 * yMax。

从 𝑥𝑂𝑦 格点映射到屏幕像素

这是我写代码前就预想到的难点之一。

逻辑坐标系使用:

但屏幕坐标系的原点位于左上角,𝑦 向下增加。映射必须同时完成平移和纵轴翻转:

pub fn toPixel(
    &self,
    point: &Point<i32>,
) -> [f64; 4] {
    let cell = self.cellSize as f64;

    let pixelX =
        (*point.x() + self.xMax) as f64 * cell;

    let pixelY =
        (self.yMax - *point.y() - 1) as f64
            * cell;

    [pixelX, pixelY, cell, cell]
}

其中:

例如 (0,0) 代表数学原点右上方的 [0,1) × [0,1),因此它应该绘制在窗口中轴线的上方。

窗口大小同样由 Board 计算,而不是在 main 中另写一套常量:

pub fn pixelSize(&self) -> [u32; 2] {
    [
        (2 * self.xMax * self.cellSize) as u32,
        (2 * self.yMax * self.cellSize) as u32,
    ]
}

这样修改棋盘半宽、半高或格子像素大小时,窗口、网格和实体都会一起变化。

Timer:把 Piston 更新频率与蛇速解耦

蛇不能在每个渲染帧移动一次,否则 60 FPS 和 144 FPS 下会成为两个不同的游戏。即使使用 Piston 的固定更新事件,也不应该把 UPS 直接当作蛇速。

Timer 只保存累计时间:

pub struct Timer {
    pub accumulator: f64,
}

impl Timer {
    pub fn update(&mut self, dt: f64) {
        self.accumulator += dt;
    }

    pub fn consume(&mut self, interval: f64) -> bool {
        if self.accumulator >= interval {
            self.accumulator -= interval;
            true
        } else {
            false
        }
    }

    pub fn reset(&mut self) {
        self.accumulator = 0.0;
    }
}

若蛇速为每秒 𝑣 格,则移动间隔为:

𝑇step=1𝑣

达到间隔后,计时器减去一个间隔,而不是把累计值清零。这样超出的零碎时间会保留下来,长期运行不会因为反复舍去余数而逐渐变慢。

Timer 不持有 Snake,也不回调 Game。它只报告“是否积累够了一个间隔”。这种设计避免了 self.timer 已被可变借用时,又从回调中借用整个 self 的问题。

Game:拥有全部游戏实体

完整状态为:

pub struct Game {
    pub board: Board,
    pub snake: Snake,
    pub foods: [Option<Food>; 2],

    pub state: GameState,
    pub timer: Timer,

    pub blankCells: HashSet<Point<i32>>,
}

Game 是 Snake、Food、Board 和 Timer 的所有者。渲染器只借用 &Game,输入和更新逻辑通过 &mut Game 修改状态。

游戏阶段也用枚举表达:

pub enum GameState {
    Initial,
    Running,
    Paused,
    GameOver,
    Victory,
}

这样 pause()、resume()、start() 和 gameOver() 都可以限制合法状态转移,而不是散落多个布尔变量,例如 isPaused、isOver、hasStarted。多个布尔量很容易组合出“既暂停又结束”之类的非法状态,枚举则一次只能处于一个阶段。

blankCells:维护空闲格点不变量

为了避免食物生成在蛇身或另一个食物上,最简单的做法是不断随机坐标,若被占用就重试。棋盘比较空时这种方法很快,但蛇接近填满棋盘时,尝试次数会越来越多。

这个项目维护:

pub blankCells: HashSet<Point<i32>>,

它满足不变量:

blankCells
= 棋盘全部格点
- 蛇占用格点
- 食物占用格点

初始化时先插入棋盘全部格点,再借用蛇身迭代器移除初始蛇:

for x in -xMax..xMax {
    for y in -yMax..yMax {
        blankCells.insert(Point::new(x, y));
    }
}

for point in snake.points() {
    blankCells.remove(point);
}

这里的 point 是 &Point<i32>。循环只借用蛇身,不会导致 Snake 丢失集合的所有权;HashSet::remove(point) 删除的是空闲集合中相等的元素,不是蛇内部的点。

蛇普通移动时:

蛇增长时没有尾巴离开,只删除新蛇头。食物被吃时,它所在的格点从“食物占用”直接变成“蛇头占用”,所以不能短暂加入空闲集合。

概率双食物是一批,而不是两个独立生成器

我设计的食物规则是“批次生成”:

所以我选择固定数组:

pub foods: [Option<Food>; 2],

它比 Vec<Food> 更直接地表达“最多两个槽位”,而 Option<Food> 表达每个槽位当前是否有实体。

当前生成逻辑为:

let possibilityOfTwoFoods =
    rng.gen_range(1..=10);

let possibilityOfFoodType =
    rng.gen_range(1..=10);

if possibilityOfTwoFoods == 10 {
    let foodType =
        if possibilityOfFoodType < 8 {
            FoodType::Medium
        } else {
            FoodType::Huge
        };

    self.foods[0] =
        self.generateOneFood(FoodType::Normal);
    self.foods[1] =
        self.generateOneFood(foodType);
} else {
    // 生成一个随机等级的食物
}

1..=10 正好包含十个等概率整数,因此 == 10 表示 10% 概率。单食物分支中还会按区间选择普通、中型或大型食物。

生成一个食物时,不需要随机碰撞重试,而是直接从空闲集合抽取:

if let Some(&position) =
    self.blankCells.iter().choose(&mut rng)
{
    self.blankCells.remove(&position);
    Some(Food::new(position, foodType))
} else {
    None
}

整批是否结束可以直接由类型结构判断:

if self.foods
    .iter()
    .all(|slot| slot.is_none())
{
    self.generateFood();
}

双食物中只消失一个时,另一个槽位仍是 Some,因此不会补充;当两个槽位都变为 None,才产生下一批。这正好对应设计规则,不需要另外保存 remainingFoods。

食物过期、所有权与 take

Food 可以判断自己过期,但所有权属于:

Game → [Option<Food>; 2] → Food

因此必须由 Game 删除过期实体:

fn removeExpiredFoods(&mut self) {
    let mut expiredIndices = Vec::new();

    for (index, slot) in
        self.foods.iter().enumerate()
    {
        if slot
            .as_ref()
            .is_some_and(|food| food.isExpired())
        {
            expiredIndices.push(index);
        }
    }

    for index in expiredIndices {
        if let Some(food) =
            self.foods[index].take()
        {
            self.blankCells.insert(food.position);
        }
    }
}

这里先收集下标,再进行修改,是为了避免一边不可变遍历 self.foods,一边修改同一个数组。

Option::take() 会:

  1. 把槽位从 Some(food) 变成 None;
  2. 把 Food 的所有权移动到局部变量;
  3. 局部变量离开作用域后自动析构。

不需要手动 free,也不需要给 Food 启动后台线程。真正要维护的是:食物过期后,它原来的坐标必须重新加入 blankCells。

对于真实时间过期,还有一个值得继续收紧的细节:同一次逻辑更新最好只取得一次 Instant::now(),让清理与进食判断使用相同的时间快照。否则食物可能在两个相邻检查之间恰好到期。更完善的接口可以是:

food.isExpiredAt(now)

然后由 Game::looping 将同一个 now 传给本轮全部判断。

Game::step:一次移动的准确时序

这是整个实现中最重要的部分。

问题首先在于:“吃到食物”究竟应该在移动前还是移动后判断?

解决办法是:在修改蛇身之前计算 nextHead,检查的是本次将进入的目标位置,而不是当前蛇头。

一次 Game::step 的顺序是:

1. 使用 pendingDirection 计算 nextHead
2. 在两个食物槽位中查找 nextHead
3. 提前取得食物分数
4. 调用 Snake::step(Some(score)) 或 Snake::step(None)
5. 移除被吃掉的 Food
6. 将普通移动产生的旧蛇尾放回 blankCells
7. 从 blankCells 删除新蛇头
8. 判断胜利、越界和自撞
9. 整批食物结束后生成下一批

关键实现是:

let nextHead = self.snake.nextHead();

let eatenIndex =
    self.foods.iter().position(|slot| {
        slot.as_ref().is_some_and(|food| {
            !food.isExpired()
                && food.position == nextHead
        })
    });

let gainedScore =
    eatenIndex.and_then(|index| {
        self.foods[index]
            .as_ref()
            .map(|food| food.score)
    });

let (newHead, oldTail) =
    self.snake.step(gainedScore);

因为 nextHead() 使用 pendingDirection,所以如果玩家在移动发生前改变方向,这次食物判断会使用新目标格点。输入事件若发生在逻辑步之后,则自然应用到下一步。

随后同步所有权和空闲格点:

if let Some(index) = eatenIndex {
    self.foods[index].take();
}

if let Some(oldTail) = oldTail {
    self.blankCells.insert(oldTail);
}

self.blankCells.remove(&newHead);

一个容易遗漏的边界情况是:蛇头可以进入本次正要离开的旧蛇尾格。在不增长时这是合法的。代码先插入 oldTail,再删除 newHead;即使二者相等,最终该格仍然不在 blankCells 中。

自撞检查必须跳过队首:

pub fn hasSelfCollision(&self) -> bool {
    let head = self.getHead();

    self.body
        .iter()
        .skip(1)
        .any(|point| *point == head)
}

如果直接执行 body.contains(&head),结果永远为真,因为蛇头本来就是身体集合的第一个元素。

当 blankCells 为空时,说明棋盘已经没有任何未占用格点,可以进入 Victory;若新蛇头越界或与身体重叠,则进入 GameOver。

Game::looping:什么时候调用一次 step

Piston 更新事件会把 dt 传给:

game.looping(args.dt);

looping 先清理过期食物,再检查是否处于 Running。整批食物结束后生成下一批,然后累计时间:

pub fn looping(&mut self, dt: f64) {
    self.removeExpiredFoods();

    if self.state != GameState::Running {
        return;
    }

    if self.foods
        .iter()
        .all(|slot| slot.is_none())
    {
        self.generateFood();
    }

    self.timer.update(dt.min(0.25));

    // 根据累计时间执行零次、一次或多次 step
}

移动间隔由当前蛇速动态计算:

let interval = 1.0 / self.snake.getSpeed();

然后循环消费时间:

let mut performedSteps = 0;

loop {
    let speed = self.snake.getSpeed();
    let interval = 1.0 / speed;

    if !self.timer.consume(interval) {
        break;
    }

    self.step();
    performedSteps += 1;

    if self.state != GameState::Running
        || performedSteps >= 8
    {
        break;
    }
}

使用 while 式循环而不是单个 if,是为了在短暂卡顿后补足应该发生的逻辑步;最多补八步,则防止调试断点后蛇瞬间移动几十格。dt.min(0.25) 也限制了单次异常更新带来的时间增量。

蛇若在某个 step 中增长,Snake::step 会立即重新计算速度。下一次循环重新读取 getSpeed(),因此新的移动间隔马上生效。

暂停期间不向 Timer 累加 dt,所以恢复后不会补跑整个暂停时段。至于食物使用真实时间,暂停时是否继续过期,则是另一个规则选择:当前清理发生在 Running 判断之前,因此食物会继续按真实时间到期。

render.rs:把状态绘制出来

当 snake.rs 和 game.rs 已经把状态与时序确定以后,render.rs 的职责就很单纯:

它不负责移动蛇、不负责删除食物,也不修改 blankCells。

窗口尺寸由 Board 决定

pub fn createWindow(
    title: &str,
    board: &Board,
) -> GlutinWindow {
    let opengl = OpenGL::V3_2;

    WindowSettings::new(title, board.pixelSize())
        .graphics_api(opengl)
        .exit_on_esc(true)
        .resizable(false)
        .build()
        .expect("failed to create window")
}

这样渲染层不需要重复知道棋盘半宽、半高和格子大小。

为什么每帧完整重绘

我没有采用“只擦除旧尾巴、只绘制新蛇头”的增量绘制。OpenGL 通常使用双缓冲,上一帧并不是一块适合长期局部修改的可靠画布;而贪吃蛇每帧只需要绘制少量矩形,完整重绘更简单也更稳定。

一帧中先清屏,然后遍历 foods 和 snake.points():

gl.draw(args.viewport(), |context, graphics| {
    clear(BACKGROUND_COLOR, graphics);

    for slot in &game.foods {
        let Some(food) = slot else {
            continue;
        };

        if food.isExpired() {
            continue;
        }

        let rect =
            game.board.toPixel(&food.position);

        rectangle(
            foodColor(food.foodType),
            insetRect(rect, 2.0),
            context.transform,
            graphics,
        );
    }

    for (index, point) in
        game.snake.points().enumerate()
    {
        let color =
            if index == 0 {
                HEAD_COLOR
            } else {
                BODY_COLOR
            };

        rectangle(
            color,
            insetRect(game.board.toPixel(point), 2.0),
            context.transform,
            graphics,
        );
    }

    drawGrid(
        &game.board,
        context.transform,
        graphics,
    );
});

网格线只依赖 Board::columns()、rows() 和 cellSize()。数学原点对应窗口中央,因此还可以在普通网格上画两条更粗的坐标轴。

Piston 事件循环只是连接三类事件

程序使用 120 UPS 和最高 60 FPS:

let mut events = Events::new(
    EventSettings::new()
        .ups(120)
        .max_fps(60),
);

主循环分别处理输入、更新和渲染:

while let Some(event) = events.next(&mut window) {
    if let Some(Button::Keyboard(key)) =
        event.press_args()
    {
        // 只更新输入意图或暂停状态
    }

    if let Some(args) = event.update_args() {
        game.looping(args.dt);
    }

    if let Some(args) = event.render_args() {
        render::renderFrame(&mut gl, &args, &game);
    }
}

输入事件修改 pendingDirection,更新事件推进真实状态,渲染事件只读取状态。渲染慢一点不会改变游戏规则,按键也不会直接让蛇越过计时器移动。

在这个项目中学到的 Rust

虽然程序规模不大,它却把不少 Rust 概念连在了一起。

所有权同时也是职责边界

Game 拥有食物,所以由 Game 调用 Option::take() 删除食物;Food 只能报告自己是否过期。Snake 拥有身体,因此外部只取得借用迭代器,不能移动内部 VecDeque。

当我发现一个对象很难被安全删除时,问题往往不在于 Rust “太严格”,而在于我还没有确定谁才是真正的所有者。

借用区分读取与修改

渲染函数接收 &Game,只能读取;Game::looping 和 Game::step 接收 &mut self,负责状态转移。函数签名直接表达了模块权限。

Option 可以消除非法状态

这些含义若分别使用值和布尔变量表达,很容易出现“标记为没有食物,但仍保存一份食物对象”的矛盾组合。

枚举适合有限规则

Direction、FoodType、GameState 都是有限集合。使用枚举和 match 后,编译器会提醒遗漏分支,也避免依赖难以理解的整数常量。

容器应匹配主要操作

选择数据结构不是为了追求理论上最复杂的优化,而是让最常见的状态变化能够自然表达。

包名不一定等于 Rust 中的 crate 名

Cargo.toml 中写:

piston2d-opengl_graphics = "0.81.0"
pistoncore-glutin_window = "0.69.0"

源码导入却是:

use opengl_graphics::{GlGraphics, OpenGL};
use glutin_window::GlutinWindow;

Piston 生态的版本也必须彼此匹配。来自两个版本的同名 Graphics trait 仍然是不同类型。处理依赖问题让我更理解 Cargo 包名、库名和版本解析之间的区别。

单元测试不一定需要启动整个游戏

快速反向、格点移动和速度计算都属于纯逻辑,可以直接测试 snake.rs。把底层模型与窗口隔离以后,很多关键规则都不需要图形环境就能验证。

还可以继续改进什么?

当前结构已经形成了完整主线,但仍有一些可以继续收紧的地方:

结语

这个项目真正困难的地方并不是画出一个绿色矩形,而是把规则分层:

注 1.
snake.rs 先定义稳定的领域对象和一步移动;game.rs 再决定时间、空间、所有权和状态转移;render.rs 最后把结果完整地显示出来。

当顺序是从底层模型到上层协调,再到视图以后,很多问题会自然得到答案:

Rust 的所有权和借用检查在这个过程中不只是编译限制,更像是一种设计反馈:谁拥有数据,谁才能决定它的生命周期;谁只有共享引用,谁就只能观察。把这些边界理顺以后,一个小小的贪吃蛇也能拥有清晰、可扩展且可测试的结构。