用rust编写一个wasm贪吃蛇小游戏:day3

260 阅读4分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第3天,点击查看活动详情

在上一章中,我们成功在canvas地图上初始化了蛇头,同时也留下了课后作业-如何渲染奖励点。 那么,答案现在揭晓:

渲染奖励点

index.ts文件加上这个函数:

//奖励
const drawReward = (world, context, worldWidth, cell_size) => {
  const index = world.get_reward_cell()
  const row = Math.floor(index / worldWidth)
  const col = index - row * worldWidth

  context.beginPath()
  context.fillStyle = '#FF0000'
  context.fillRect(col * cell_size, row * cell_size, cell_size, cell_size)
  context.stroke()
}

其实渲染方法和蛇头是一样的,不过参数改为了随机生成的奖励点编号。

这时候,地图初始化的要素已经筹齐了,我们写个公共函数把初始化的元素整合起来:

//游戏地图初始化
const initMap = (wasm, world, context, worldWidth, worldHeight, cell_size) => {
  initCanvas(context, worldWidth, worldHeight, cell_size)
  drawSnakeCells(wasm, world, context, worldWidth, cell_size)
  drawReward(world, context, worldWidth, cell_size)
}

改造一下init函数:

init().then(wasm => {
  ....
  //清除栅格
  context.clearRect(0, 0, canvas.width, canvas.height)
  initMap(wasm, world, context, worldWidth, worldHeight, cell_size)
})

然后打包一下js文件(打包方法前面已经提过,不记得的可以回去看第二篇文章)。蛇头和奖励点都渲染出来了。

1665468660084.png

蛇的运动

蛇的运动,这个就是整个游戏的核心逻辑了。

在编码之前,我们先来分析一下这个蛇的运动:

  1. 蛇头是火车头,带动整条蛇往前运动。但是运动方向不能与蛇身冲突(即180度原地掉头),也不能接触蛇身或者地图边界
  2. 目前蛇身结构为一维数组,蛇前进时,后面的蛇身会接替前一格的位置。
  3. 蛇头运动方向为上下左右,蛇头坐标位置通过可以编号进行计算得到。

梳理清楚思路之后,接下来怎么要编码呢?

这个问题就像如何把大象关进冰箱一样:

  1. 蛇头运动的坐标计算
  2. 蛇身交替前行
  3. 蛇头触碰地图边界或者蛇身,运动停止

那么编码开始,在lib.rs中新增游戏状态枚举:

#[wasm_bindgen]
#[derive(Debug, Clone, Copy)]
pub enum GameState {
    //失败
    Failed,
    //暂停
    Stop,
    //运行
    Run,
}

World结构体中新增更新状态函数(即蛇的运动函数):

#[wasm_bindgen]
impl World {
       ...
       pub fn snake_spawn(&self) -> i32 {
        self.snake.body[0].0
       }
    
      pub fn update_snake(&mut self, input: Option<Direction>) -> GameState {
        //游戏状态
        let mut state = GameState::Run;
        //蛇头
        let snake_head = self.snake_spawn();
        //缓存蛇身旧数据
        let temp = self.snake.body.clone();

        //蛇头运动方向
        let direction = match input {
            Some(direction) => direction,
            None => self.snake.direction,
        };

        //蛇头坐标
        let (row, col) = (
            snake_head / self.width,
            snake_head - self.width * (snake_head / self.width),
        );
        let (x, y) = match self.snake.direction {
            Direction::Up if direction != Direction::Down => {
                self.snake.direction = direction;
                if row - 1 < 0 {
                    state = GameState::Failed;
                }
                ((row - 1), col)
            }
            Direction::Down if direction != Direction::Up => {
                self.snake.direction = direction;
                if row + 1 >= self.height {
                    state = GameState::Failed;
                }
                ((row + 1), col)
            }
            Direction::Left if direction != Direction::Right => {
                self.snake.direction = direction;
                if col - 1 < 0 {
                    state = GameState::Failed;
                }
                (row, (col - 1))
            }
            Direction::Right if direction != Direction::Left => {
                self.snake.direction = direction;
                if col + 1 >= self.width {
                    state = GameState::Failed;
                }
                (row, (col + 1))
            }
            _ => {
                state = GameState::Stop;
                (row, col)
            }
        };

        if state == GameState::Run {
            //蛇头前进
            self.snake.body[0].0 = (self.width * x) + y;
            //蛇身交替前行
            let len = self.snake.body.len();
            for i in 1..len {
                self.snake.body[i] = temp[i - 1];
            }
            //蛇头触碰蛇身
            if self.snake.body[1..len].contains(&self.snake.body[0]) {
                state = GameState::Failed;
            }
        }

        state
    }
}

我们一步步来看蛇的运动函数:

  1. 假设游戏在运行中,获取蛇头编号之后,通过蛇头编号计算出在地图上的坐标
  2. 根据(可能会输入)运动方向对坐标进行进一步计算,同时判断蛇头超出地图边界的失败情况
  3. 将最新的坐标转换为编号并赋值回去
  4. 没有失败则蛇身交替前行,后一个接替前一个的位置
  5. 判断蛇头是否接触到蛇身
  6. 最后返回游戏状态

就这样短短几十行代码,浓缩了整个游戏的精华!

动态页面渲染

前面我们已经渲染了静态的初始化地图、蛇头、奖励点,接下来如何让蛇头动起来呢? 同样是如何把大象放进冰箱的问题。。。

  1. 触发蛇头的运动函数,蛇就会前进
  2. 需要定时器重复触发,蛇才能不断前进
  3. 每次前进都要重新渲染蛇和地图

进入index.ts继续对init的函数内容进行修改:

import init, { World, Direction, GameState } from 'wasm_snake'

init().then(wasm => {
  ....
  //清除栅格
  context.clearRect(0, 0, canvas.width, canvas.height)
  let gameRunner: number
  let gameCanvas: number
  const run = () => {
    gameRunner = setTimeout(() => {
      context.clearRect(0, 0, canvas.width, canvas.height)
      let state = world.update_snake()
      if (state === GameState.Run) {
        initMap(wasm, world, context, worldWidth, worldHeight, cell_size)
        gameCanvas = window.requestAnimationFrame(run)
      } else if (state === GameState.Failed) {
        clearTimeout(gameRunner)
        window.cancelAnimationFrame(gameCanvas)
        alert('游戏失败')
      }
    }, 1000)
    
  initMap(wasm, world, context, worldWidth, worldHeight, cell_size)
  game_control(run)
  snake_move(world)
})

const game_control = run => {
  let controlBtn = <HTMLElement>document.getElementById('game_control')
  controlBtn.addEventListener('click', () => {
    run()
  })
}

这里最主要的就是新增了requestAnimationFramecancelAnimationFrame这两个方法,用来处理蛇前进动画的。有兴趣的同学可以跳转链接去MDN查看更详细的描述。

简单讲述一下新增的代码内容,我们把蛇的运动和画面渲染封装在定时器里面,通过requestAnimationFrame方法来执行游戏动画的更新。游戏失败则通过cancelAnimationFrame方法取消游戏动画。另外,把开始游戏来用触发蛇头开始运行。

接着再加上运动方向的控制:

//控制方向
const snake_move = world => {
  document.addEventListener('keyup', e => {
    switch (e.code) {
      case 'ArrowDown':
        world.update_snake(Direction.Down)
        break
      case 'ArrowUp':
        world.update_snake(Direction.Up)
        break
      case 'ArrowLeft':
        world.update_snake(Direction.Left)
        break
      case 'ArrowRight':
        world.update_snake(Direction.Right)
        break
    }
  })
}

重新打包一下项目,打开index.html文件。点击开始游戏,蛇头就会往下走,可以用键盘的方向键控制运动方向。当触碰到地图边界,则弹窗提示游戏失败!

好了,我们的贪吃蛇终于可以动起来了!但是,吃奖励的功能还没实现!如何让蛇在运动的时候吃上奖励并且身体变长呢?我们下期继续探究。