笔记内容转载自 AcWing 的 SpringBoot 框架课讲义,课程链接:AcWing SpringBoot 框架课。
本节实现两名玩家即两条蛇的绘制与人工操作移动功能。
之前我们设计的地图尺寸是13×13,两名玩家的起点横纵坐标之和均为偶数,因此可能在同一时刻走到同一个格子上,为了避免这种情况,可以将地图改为13×14的大小(即将 GameMap.js
中的 this.cols
改为14),这样两名玩家就不会在同一时刻走到同一个格子上。这样修改完之后就不能用轴对称了,需要改为中心对称:
// 添加地图内部的随机障碍物,需要有对称性因此枚举一半即可,另一半对称生成
for (let i = 0; i < this.inner_walls_count / 2; i++) {
for (let j = 0; j < 10000; j++) {
let r = parseInt(Math.random() * this.rows);
let c = parseInt(Math.random() * this.cols);
if (g[r][c] || g[this.rows - 1 - r][this.cols - 1 - c]) continue;
if (r == this.rows - 2 && c == 1 || r == 1 && c == this.cols - 2) continue; // 判断是否覆盖到出生地
g[r][c] = g[this.rows - 1 - r][this.cols - 1 - c] = true;
break;
}
}
刚开始玩家占一个格子,我们可以规定一下前十步的每一步将蛇的长度加一,之后改为每三步长度加一。每条蛇其实就是一堆格子的序列,我们可以将一个格子定义成一个 Cell
对象,在 scripts
目录下创建 Cell.js
记录格子的坐标。
我们在每个格子中绘制的是一个圆,若格子的左上角坐标为 (x, y)
则圆的圆心坐标应该是 (x + 0.5, y + 0.5)
,Cell.js
如下:
export class Cell {
constructor(r, c) {
this.r = r;
this.c = c;
this.x = c + 0.5;
this.y = r + 0.5;
}
}
此外每条蛇也可以定义成一个对象 Snake.js
:
import { AcGameObject } from "./AcGameObject";
import { Cell } from "./Cell";
export class Snake extends AcGameObject {
constructor(info, gamemap) {
super();
this.id = info.id; // 每条蛇有唯一的id进行区分
this.color = info.color; // 颜色
this.gamemap = gamemap;
this.cells = [new Cell(info.r, info.c)]; // 初始化时只有一个点即cells[0]表示蛇头
}
start() {}
update() {
this.render();
}
render() {
const L = this.gamemap.L;
const ctx = this.gamemap.ctx;
ctx.fillStyle = this.color;
for (const cell of this.cells) {
ctx.beginPath();
ctx.arc(cell.x * L, cell.y * L, L / 2, 0, Math.PI * 2); // 半径为半个单元格
ctx.fill();
}
}
}
然后我们在 GameMap.js
中创建两条蛇:
import { AcGameObject } from "./AcGameObject";
import { Wall } from "./Wall";
import { Snake } from "./Snake";
export class GameMap extends AcGameObject {
constructor(ctx, parent) { // ctx表示画布,parent表示画布的父元素
super();
this.ctx = ctx;
this.parent = parent;
this.L = 0; // 一个单位的绝对长度
this.rows = 13; // 地图的行数
this.cols = 14; // 地图的列数
this.inner_walls_count = 20; // 地图内部的随机障碍物数量,需要是偶数
this.walls = []; // 所有的障碍物
this.snakes = [
new Snake({ id: 0, color: "#4876EC", r: this.rows - 2, c: 1 }, this),
new Snake({ id: 1, color: "#F94848", r: 1, c: this.cols - 2 }, this),
];
}
check_connectivity(g, sx, sy, tx, ty) { // 用flood fill算法判断两名玩家是否连通
...
}
create_walls() {
...
}
start() {
...
}
update_size() { // 每一帧更新地图大小
...
}
update() {
...
}
render() {
...
}
}
为了实现蛇移动的连续性,我们不对每个格子进行更新,只更新头部和尾部,头部创建一个新的点往前动,尾部直接往前动。首先在 Snake
对象中设置一些移动的属性:
import { AcGameObject } from "./AcGameObject";
import { Cell } from "./Cell";
export class Snake extends AcGameObject {
constructor(info, gamemap) {
super();
this.id = info.id; // 每条蛇有唯一的id进行区分
this.color = info.color; // 颜色
this.gamemap = gamemap;
this.cells = [new Cell(info.r, info.c)]; // 初始化时只有一个点即cells[0]表示蛇头
this.speed = 2; // 蛇每秒走2个格子
this.direction = -1; // 下一步移动的指令,-1表示没有指令,0、1、2、3分别表示上、右、下、左
this.status = "idle"; // 蛇的状态,idle表示静止,move表示正在移动,die表示死亡
this.next_cell = null; // 下一步的目标位置
this.step = 0; // 回合数
this.dr = [-1, 0, 1, 0];
this.dc = [0, 1, 0, -1];
}
start() {}
next_step() { // 将蛇的状态变为走下一步
const d = this.direction;
this.next_cell = new Cell(this.cells[0].r + this.dr[d], this.cells[0].c + this.dc[d]);
this.direction = -1; // 复原
this.status = "move";
this.step++;
}
set_direction(d) { // 由于未来不一定只会从键盘获取输入,因此实现一个接口修改direction
this.direction = d;
}
update_move() {
}
update() {
if (this.status === "move") {
this.update_move();
}
this.render();
}
render() {
...
}
}
由于游戏是回合制的,因此移动的判定条件应该是获取到了两名玩家的指示后才能移动一次,且该指令既可以由键盘输入也可以由 AI 代码输入,判定两条蛇是否准备好执行下一步不能各自判断,需要由上层也就是 GameMap
判定,判定条件是两条蛇都处于静止状态且都已经获取到了下一步指令:
import { AcGameObject } from "./AcGameObject";
import { Wall } from "./Wall";
import { Snake } from "./Snake";
export class GameMap extends AcGameObject {
constructor(ctx, parent) { // ctx表示画布,parent表示画布的父元素
...
}
check_connectivity(g, sx, sy, tx, ty) { // 用flood fill算法判断两名玩家是否连通
...
}
create_walls() {
...
}
start() {
...
}
update_size() { // 每一帧更新地图大小
...
}
check_ready() { // 判断两条蛇是否都准备好下一回合了
for (const snake of this.snakes) {
if (snake.status !== 'idle' || snake.direction === -1) return false;
}
return true;
}
next_step() { // 让两条蛇进入下一回合
for (const snake of this.snakes) {
snake.next_step();
}
}
update() {
this.update_size();
if (this.check_ready()) {
this.next_step();
}
this.render();
}
render() {
...
}
}
现在我们只能从前端获得用户的操作,即获取用户的键盘输入。为了能够让 Canvas 获取键盘输入,需要添加一个 tabindex
属性,在 GameMap.vue
中进行修改:
<template>
<div ref="parent" class="gamemap">
<canvas ref="canvas" tabindex="0">canvas>
div>
template>
<script>
...
script>
<style scoped>
...
style>
这样我们就能够在 GameMap.js
中绑定键盘的监听事件:
import { AcGameObject } from "./AcGameObject";
import { Wall } from "./Wall";
import { Snake } from "./Snake";
export class GameMap extends AcGameObject {
...
add_listening_events() {
this.ctx.canvas.focus(); // 使Canvas聚焦
const [snake0, snake1] = this.snakes;
this.ctx.canvas.addEventListener("keydown", e => {
if (e.key === "w") snake0.set_direction(0);
else if (e.key === "d") snake0.set_direction(1);
else if (e.key === "s") snake0.set_direction(2);
else if (e.key === "a") snake0.set_direction(3);
else if (e.key === "ArrowUp") snake1.set_direction(0);
else if (e.key === "ArrowRight") snake1.set_direction(1);
else if (e.key === "ArrowDown") snake1.set_direction(2);
else if (e.key === "ArrowLeft") snake1.set_direction(3);
});
}
start() {
for (let i = 0; i < 10000; i++) { // 暴力枚举直至生成合法的地图
if (this.create_walls())
break;
}
this.add_listening_events();
}
...
}
现在我们即可在 Snake.js
中实现蛇的移动:
import { AcGameObject } from "./AcGameObject";
import { Cell } from "./Cell";
export class Snake extends AcGameObject {
constructor(info, gamemap) {
super();
this.id = info.id; // 每条蛇有唯一的id进行区分
this.color = info.color; // 颜色
this.gamemap = gamemap;
this.cells = [new Cell(info.r, info.c)]; // 初始化时只有一个点即cells[0]表示蛇头
this.speed = 2; // 蛇每秒走2个格子
this.direction = -1; // 下一步移动的指令,-1表示没有指令,0、1、2、3分别表示上、右、下、左
this.status = "idle"; // 蛇的状态,idle表示静止,move表示正在移动,die表示死亡
this.next_cell = null; // 下一步的目标位置
this.step = 0; // 回合数
this.dr = [-1, 0, 1, 0];
this.dc = [0, 1, 0, -1];
this.eps = 0.01; // 误差
}
start() {}
next_step() { // 将蛇的状态变为走下一步
const d = this.direction;
this.next_cell = new Cell(this.cells[0].r + this.dr[d], this.cells[0].c + this.dc[d]);
this.direction = -1; // 复原
this.status = "move";
this.step++;
const k = this.cells.length;
for (let i = k; i > 0; i--) { // 将所有节点向后移动一位,因为要在头节点前面插入新的头节点
this.cells[i] = JSON.parse(JSON.stringify(this.cells[i - 1])); // 注意要深层复制一份,还有一个细节是JS的数组越界不会出错
}
}
set_direction(d) { // 由于未来不一定只会从键盘获取输入,因此实现一个接口修改direction
this.direction = d;
}
update_move() { // 将头节点cells[0]向目标节点next_cell移动
const dx = this.next_cell.x - this.cells[0].x; // 在x方向上与目的地的偏移量
const dy = this.next_cell.y - this.cells[0].y; // 在y方向上与目的地的偏移量
const distance = Math.sqrt(dx * dx + dy * dy); // 与目的地的距离
if (distance < this.eps) { // 已经走到目标点
this.status = "idle"; // 状态变为静止
this.cells[0] = this.next_cell; // 将头部更新为目标点
this.next_cell = null;
} else {
const move_length = this.speed * this.timedelta / 1000; // 每一帧移动的距离
const cos_theta = dx / distance; // cos值
const sin_theta = dy / distance; // sin值
this.cells[0].x += move_length * cos_theta;
this.cells[0].y += move_length * sin_theta;
}
}
update() {
if (this.status === "move") { // 只有移动状态才执行update_move函数
this.update_move();
}
this.render();
}
render() {
...
}
}
接着我们还需要实现蛇尾的移动,如果蛇的长度增加了一个单位,那么尾部不用动即可,否则尾部需要向前一个节点移动,且当移动完成后需要将尾部节点对象删去:
import { AcGameObject } from "./AcGameObject";
import { Cell } from "./Cell";
export class Snake extends AcGameObject {
...
check_tail_increasing() { // 检测当前回合蛇的长度是否增加
if (this.step <= 7) return true; // 前7回合每一回合长度都增加
if (this.step % 3 === 1) return true; // 之后每3回合增加一次长度
return false;
}
update_move() { // 将头节点cells[0]向目标节点next_cell移动
const dx = this.next_cell.x - this.cells[0].x; // 在x方向上与目的地的偏移量
const dy = this.next_cell.y - this.cells[0].y; // 在y方向上与目的地的偏移量
const distance = Math.sqrt(dx * dx + dy * dy); // 与目的地的距离
if (distance < this.eps) { // 已经走到目标点
this.status = "idle"; // 状态变为静止
this.cells[0] = this.next_cell; // 将头部更新为目标点
this.next_cell = null;
if (!this.check_tail_increasing()) { // 尾部没有变长则移动完成后要删去尾部
this.cells.pop();
}
} else {
const move_length = this.speed * this.timedelta / 1000; // 每一帧移动的距离
const cos_theta = dx / distance; // cos值
const sin_theta = dy / distance; // sin值
this.cells[0].x += move_length * cos_theta;
this.cells[0].y += move_length * sin_theta;
if (!this.check_tail_increasing()) {
const k = this.cells.length;
const tail = this.cells[k - 1], tail_target = this.cells[k - 2];
const tail_dx = tail_target.x - tail.x;
const tail_dy = tail_target.y - tail.y;
tail.x += move_length * tail_dx / distance; // 此处就不分开计算cos和sin了
tail.y += move_length * tail_dy / distance;
}
}
}
update() {
if (this.status === "move") { // 只有移动状态才执行update_move函数
this.update_move();
}
this.render();
}
...
}
现在我们蛇的身体还是分开的若干个圆球,没有连续感。我们可以在两个相邻的圆球中间绘制一个矩形覆盖一遍即可。然后我们这边再做个小优化,将蛇的半径缩小一点,不然贴在一起时就会融合在一起不好看:
import { AcGameObject } from "./AcGameObject";
import { Cell } from "./Cell";
export class Snake extends AcGameObject {
constructor(info, gamemap) {
...
this.radius = 0.4; // 蛇中每个节点的半径
...
}
...
render() {
const L = this.gamemap.L;
const ctx = this.gamemap.ctx;
ctx.fillStyle = this.color;
for (const cell of this.cells) {
ctx.beginPath();
ctx.arc(cell.x * L, cell.y * L, L * this.radius, 0, Math.PI * 2);
ctx.fill();
}
// 将相邻的两个球连在一起
for (let i = 1; i < this.cells.length; i++) {
const a = this.cells[i - 1], b = this.cells[i];
if (Math.abs(a.x - b.x) < this.eps && Math.abs(a.y - b.y) < this.eps)
continue;
if (Math.abs(a.x - b.x) < this.eps) { // 上下排列,即x相同,左上角的点的y值为两者的最小值,因为越往上y越小
ctx.fillRect((a.x - this.radius) * L, Math.min(a.y, b.y) * L, 2 * this.radius * L, Math.abs(a.y - b.y) * L);
} else { // 左右排列,画法同理
ctx.fillRect(Math.min(a.x, b.x) * L, (a.y - this.radius) * L, Math.abs(a.x - b.x) * L, 2 * this.radius * L);
}
}
}
}