最近疫情反复,公司提倡在家办公,虽然住的离公司比较近,但是也没办法去公司薅羊毛。公司餐厅这段时间不提供餐饮、零食、饮料,无奈只能呆在家里吃外卖。上周刚完成一个大需求的上线,剩下的工作都是一些内部优化的“主动型”任务,无聊之际想写一些代码。。。突然感觉有点可怜,实在没有什么爱好了,可能年龄到了一定的坎,对于谈情说爱游戏人生,实在提不起什么兴趣,实在不该是年轻人的状态吧。
其实我对代码也没什么兴趣了,可能只是相对而言吧。画UI是没什么意思了,大部分情况下,能还原就行,谁还在乎你的 HTML 语义化和 CSS 优雅程度呢?可能唯一还会在乎的只有你的 JS 了吧。毕竟,只要你不跑路,你的山还是你的山,舔也得舔完啊。这么一想,或许我还是有一点追求的。
两段废话应该凑了一些字数,那下面就正式进入正题
目标
抽象游戏逻辑,把渲染和玩法扩展交给其他开发者,毕竟核心逻辑是不变的,UI是千变万化的。作为这个系列的第一篇文章,就以经单贪吃蛇为切入点,一步步完善它。
贪吃蛇是什么
我们先要搞清楚经典贪吃蛇是什么
想起来没有,对,就是诺基亚最强的时候,哈哈哈。我记得我第一个诺基亚手机,600多块钱,初中九键时代,单手口袋发短信。
游戏开发非常适合 OOP,所以我们要搞清楚有哪些 O
一句话描述
这个游戏(Game)就是在一片草地(Map)上有一条蛇(Snake),玩家(Player)控制蛇的移动方向(Direction),通过“吃”各种随机出现的”东西“(Obstacle),呈现出不同的效果。
- 蛇撞到边界会死
- 蛇撞到障碍物(Obstacle),情况不一定,所以这里可以发挥的地方很多。
- 蛇撞到自己的身体会死
对象分析
对象出现的顺序,不代表思路的顺序,前置某些对象,是为了大家看的时候顺畅一些。
通过分析每一个对象的特点,我们才能够知道它到底是什么。再分析第一个对象之前,我这边前置一个很容易理解的对象 Point。在游戏开发中,物体的位置其实是一个很通用的属性,所以这边我们先实现了一下。
class Point {
public x = 0;
public y = 0;
public static create(x: number, y: number) {
const point = new Point();
point.x = x;
point.y = y;
return point;
}
public constructor() {}
}
移动的方向,正常情况是上下左右四个方向,我这里提供了八个方向
class Direction extends Point {
public static readonly UP = Direction.create(0, -1);
public static readonly DOWN = Direction.create(0, 1);
public static readonly LEFT = Direction.create(-1, 0);
public static readonly RIGHT = Direction.create(1, 0);
public static readonly LEFT_UP = Direction.create(-1, -1);
public static readonly RIGHT_UP = Direction.create(1, -1);
public static readonly LEFT_DOWN = Direction.create(-1, 1);
public static readonly RIGHT_DOWN = Direction.create(1, 1);
public static create(x: number, y: number) {
const direction = new Direction();
direction.x = x;
direction.y = y;
return direction;
}
}
地图 Map
地图是一个矩形,自然有宽度和高度。地图默认有边界,宽度为:0-width,高度为 0-height。有边界的情况下,需要提供一个碰撞检测方法,入参为:检测点(蛇头的位置)
class GameMap {
constructor(public readonly width: number, public readonly height: number) {}
public isCollision(point: Point) {
const isCollision =
point.x < 0 ||
point.y < 0 ||
point.x >= this.width ||
point.y >= this.height;
if (isCollision) {
console.error("撞墙了");
}
return isCollision;
}
}
蛇 Snake
- 蛇头:引领前进的方向
- 蛇身:关节构成
- 长度:关节的总数
- 成长:加一个关节
- 缩小:减一个关节
- 移动:前进一个单位
这里引出一个新的概念,关节(SnakeNode),那我们先实现一下:
class SnakeNode extends Point {
public static create(x: number, y: number) {
const node = new SnakeNode();
node.x = x;
node.y = y;
return node;
}
}
抛个问题,这里为什么我们不直接使用 Point ,而要搞出一个 SnakeNode ?
有了关节,那我们就可以创造一条蛇了
/**
* 贪吃蛇
*/
export class Snake {
/**
* 蛇身节点数组
*/
public body!: SnakeNode[];
/**
* 蛇头,其实就是第一个关节
*/
public get head() {
return this.body[0];
}
/**
* 蛇身长度
*/
public get size() {
return this.body.length;
}
/**
* 初始化,默认在地图左上角
* @param bodyLength 初始蛇身长度
*/
public init(bodyLength: number) {
this.body = [];
for (let i = 0; i < bodyLength; i++) {
this.prepend(SnakeNode.create(i, 0));
}
}
/**
* 蛇头下一个位置
* @param direction
* @returns
*/
public getNextPosition(direction: Direction) {
const { head } = this;
const nextPosition = new Point();
nextPosition.x = head.x + direction.x;
nextPosition.y = head.y + direction.y;
return nextPosition;
}
/**
* 蛇身生长
* @param x
* @param y
*/
public grow(x: number, y: number) {
this.append(SnakeNode.create(x, y));
}
/**
* 蛇身减小
*/
public reduce() {
this.body.pop();
}
/**
* 尾部增加
* @param node
*/
private append(node: SnakeNode) {
this.body.push(node);
}
/**
* 头部增加
* @param node
*/
private prepend(node: SnakeNode) {
this.body.unshift(node);
}
/**
* 判断是否会发生身体碰撞
* @param nextPosition
* @returns
*/
public isCollision(nextPosition: Point) {
for (let i = 0; i < this.body.length; i++) {
const point = this.body[i];
if (point.x === nextPosition.x && point.y === nextPosition.y) {
console.error("撞自己了");
return true;
}
}
return false;
}
/**
* 移动到下一个位置
* @param nextPosition
*/
public move(nextPosition: Point) {
// 加入头
this.prepend(SnakeNode.create(nextPosition.x, nextPosition.y));
// 去掉尾
this.body.pop();
}
}
到这里为止,游戏的核心元素:蛇、地图都已经准许就绪,障碍物(食物)我认为是可选对象,我们放到最后讲解。我们来组织一下游戏逻辑
游戏 Game
目前为止,我们支持的游戏配置如下:
type GameConfig = {
/** 地图配置 */
map: {
/** 地图宽度 */
width: number;
/** 地图高度 */
height: number;
};
/** 贪吃蛇配置 */
snake: {
/** 蛇身初始长度 */
bodyLength: number;
};
};
游戏就是需要在每一帧里面更新状态,反馈结果,至于更新的频率,我们大可交给开发者自己去控制。
/**
* 游戏类
*/
class Game {
/** 贪吃蛇 */
public snake: Snake;
/** 地图 */
public map: GameMap;
/** 运动方向 */
public direction!: Direction;
/** 配置 */
private config: GameConfig;
public constructor(config: GameConfig) {
this.config = config;
this.snake = new Snake();
this.map = new GameMap(this.config.map.width, this.config.map.height);
}
/**
* 改变方向
* @param direction
*/
public setDirection(direction: Direction) {
this.direction = direction;
}
/**
* 游戏初始化
*/
public init() {
// 贪吃蛇初始化
this.snake.init(this.config.snake.bodyLength);
// 默认方向
this.direction = Direction.RIGHT;
}
/**
* 每一帧更新
* @returns {boolean} false: 游戏结束/true: 游戏继续
*/
public update() {
const nextPosition = this.snake.getNextPosition(this.direction);
// 是否发生地图碰撞
if (this.map.isCollision(nextPosition)) {
return false;
}
// 是否发生蛇身碰撞
if (this.snake.isCollision(nextPosition)) {
return false;
}
// 移动
this.snake.move(nextPosition);
return true;
}
}
在 update 函数中,我们根据移动方向,计算出蛇头下一位置。
- 判断是否发生地图碰撞,如果是,返回false
- 判断蛇身是否发生碰撞,如果是,返回false
- 否则说明蛇可以安全移动到下一位置
引入渲染
游戏逻辑完成了,其实不 care 渲染方式是什么,你可以使用 Vue/React/Webgl 等等任何你喜欢的方式。因为工作中我是使用 React的,所以这里我使用 Vue3 来举例如何渲染。
import { Game } from '@game/greedySnake'
import { onMounted } from "vue";
const game = new Game({
map: {
width: 30,
height: 30,
},
snake: {
bodyLength: 3,
},
});
game.init();
onMounted(() => {
setInterval(() => {
game.update()
}, 300)
})
实例化一个游戏,然后初始化,在 Dom 挂载之后,用 300 ms的频率去启动游戏的更新,当然可以更快,完全由你决定。
地图渲染
我们没有图片,那最简单的地图就是一个二维网格,每个网格大小为 20x20 的正方形。或者你用一张图片代替也不是不可以。
<script setup lang="ts">
const map = new Array(game.config.width * game.config.height)
</script>
<template>
<div
:style="{
display: 'flex',
flexWrap: 'wrap',
width: `${width * 20}px`,
height: `${height * 20}px`,
}"
>
<div
:key="index"
v-for="(_, index) in "
style="width: 20px; border: 1px solid #ccc; box-sizing: border-box"
></div>
</div>
</template>
蛇身渲染
但我们缺少蛇身数据,那就让 Game 提供一下吧。
class Game {
/**
* 蛇身
*/
public get body() {
return this.snake.body.map((b) => Point.create(b.x, b.y));
}
}
这里我们用 getter 拿到蛇身的坐标信息,对于渲染而言足够了。太多的信息暴露不是好事情,构造新的数据也是以防外部去修改它本身。
<template>
<div
:key="index"
v-for="(item, index) in game.body"
:style="{
position: 'absolute',
width: '20px',
height: '20px',
left: `${item.x * 20}px`,
top: `${item.y * 20}px`,
backgroundColor: index === 0 ? 'green' : 'blue',
}"
></div>
</template>
但意外出现了,蛇并不会动,因为用于渲染的 game.body 并不是一个响应式对象,我们需要处理一下。
<script setup lang="ts">
import { reactive } from "vue";
const data = reactive({
body: []
})
onMounted(() => {
setInterval(() => {
game.update()
data.body = game.body
}, 300)
})
</script>
我们用 data.body 去渲染就行了,同理障碍物渲染也是一样的,这里就不加代码演示了。
更多渲染
纯2D游戏,用3D渲染出来,会不会感觉很不一样?摄像机跟随蛇头的位置,在天空盒内自由翱翔,会不会有一种第一人称骑着龙在天空飞翔的感觉,但我们没有模型,只能使用小方块来代替,如果有人感兴趣的话,可以使用 Three.js实现一下。
当然任何支持UI的地方,我们都可以去做。
障碍物
所有在地图上随机出现的“东西”,我把它定义为障碍物。
抽象类
障碍物如何被生产(放到地图某个位置),如何被消费(被蛇头撞到后,产生什么作用),因为需要位置属性,所以继承了 Point,抽象之后如下:
/**
* 障碍物抽象基类
*/
abstract class Obstacle extends Point {
/**
* 障碍物类型
*/
public abstract type: string;
/**
* 障碍物生成,一般是设置障碍物的位置
*/
public abstract produce(): void;
/**
* 障碍物消费,一般是障碍物和蛇头发生碰撞之后,要做的事情;
* 返回值决定了是否继续游戏,true表示继续,false表示结束游戏
*/
public abstract consume(): boolean;
public constructor(protected game: Game) {
super();
}
}
所有障碍物都必须继承基类,并实现相应的抽象方法和属性。
障碍物管理器
考虑到地图上可能同时存在多个障碍物,我们实现一个管理器,用于管理障碍物的添加、碰撞、生产、消费等。
/**
* 障碍物管理器
*/
export class ObstacleManager {
public obstacles: Obstacle[] = [];
/**
* 添加障碍物
* @param obstacle
*/
public add(obstacle: Obstacle) {
this.obstacles.push(obstacle);
}
/**
* 发生碰撞的障碍物
* @param point
* @returns
*/
public isCollision(point: Point) {
return this.obstacles.filter((o) => o.x === point.x && o.y === point.y);
}
public init() {
this.obstacles.forEach((o) => o.produce());
}
}
障碍物(食物)
在经典贪吃蛇里面,食物是最基础的障碍物了,蛇吃了食物才会变长,越来越长,但不会越来越粗!
/**
* 食物
*/
export class Food extends Obstacle {
public type = "food";
public produce() {
const point = this.getRandomPoint();
this.x = point.x;
this.y = point.y;
}
public consume() {
// 吃掉食物,蛇身长度加1
this.game.snake.grow(this.x, this.y);
// 计数加1
this.game.count++;
return true;
}
}
其实大部分障碍物的 produce 实现都是一样的,无非就是重新随机生成一个位置。这里“随机”还是有一定限制:
- 在地图内
- 不能在蛇身上
- 不能和其他障碍物重合
所以 getRandomPoint 算法实现需要递归,会不会在游戏后面产生 BadCase 我也不确定。至于效率方面,如果有更好的算法,请评论告诉我一下呀。
function getRandomPoint(maxX: number, maxY: number, points: Point[]) {
let x = 0,
y = 0;
random();
function random() {
x = Math.floor(Math.random() * maxX);
y = Math.floor(Math.random() * maxY);
for (let i = 0; i < points.length; i++) {
if (x === points[i].x && y === points[i].y) {
random();
break;
}
}
}
return Point.create(x, y);
}
障碍物(炸弹)
这里只是为了举个例子来说明,障碍物的行为被抽象之后,那游戏的玩法就很多了,可以很多种类的障碍物参与到游戏当中,而不需要改任何游戏逻辑。
/**
* 炸弹
*/
export class Bomb extends Obstacle {
public type = "bomb";
public produce() {
const point = this.getRandomPoint();
this.x = point.x;
this.y = point.y;
}
public consume() {
// 吃掉食物,蛇身长度减1
this.game.snake.reduce();
// 计数减1
this.game.count-=1;
return true;
}
}
障碍物的局限
因为这里障碍物消费后的结果是个 Boolean 类型,也就导致外部无法形成更多的玩法控制。如果可以的话,可以将消费后的结果封装城一个 ConsumeResult 类,那外部可以根据不同的 ConsumeResult 做出不同的反应了。
游戏逻辑完善
上面完成的游戏逻辑还记得吗,上面我们只完成了基本的边界碰撞、自身碰撞和移动,并没有引入障碍物,这里我们完善一下。
/**
* 游戏类
*/
class Game {
/** 障碍物管理器 */
private obstacleManager = new ObstacleManager();
/**
* 添加障碍物
* @param obstacle
*/
public addObstacle(obstacle: Obstacle) {
this.obstacleManager.add(obstacle);
}
/**
* 游戏初始化
*/
public init() {
// 计分清零
this.count = 0;
// 贪吃蛇初始化
this.snake.init(this.config.snake.bodyLength);
// 默认方向
this.direction = Direction.RIGHT;
// 如果开发者没有添加任何障碍物,那么我们添加一种默认的基础食物
if (this.obstacles.length === 0) {
this.addObstacle(new Food(this));
}
// 障碍物初始化
this.obstacleManager.init();
}
/**
* 每一帧更新
* @returns {boolean} false: 游戏结束/true: 游戏继续
*/
public update() {
const nextPosition = this.snake.getNextPosition(this.direction);
// 是否发生地图碰撞
if (this.map.isCollision(nextPosition)) {
return false;
}
// 是否发生蛇身碰撞
if (this.snake.isCollision(nextPosition)) {
return false;
}
// 是否发生障碍物碰撞,这里拿到所有发生碰撞的障碍物
const obstacles = this.obstacleManager.isCollision(nextPosition);
if (obstacles.map((o) => o.consume()).filter((v) => !v).length > 0) {
return false;
}
// 发生碰撞的障碍物需要重置,即再次被生产
obstacles.forEach((o) => o.produce());
// 移动
this.snake.move(nextPosition);
return true;
}
}
开发者可以根据 update 的返回值,决定游戏是否结束。这里的障碍物碰撞检测,原则上应该同一时刻只会存在一个,因为我们在障碍物生产的时候,剔除了重合的位置,也就是同一个位置不可能存在1个以上的障碍物。
控制
写到最后才发现了漏了这一趴,没有复杂的操作,就是控制移动的方向,不过推荐一个好用的库 hotkeys-js
import hotkeys from "hotkeys-js";
hotkeys("w", function () {
game.setDirection(Direction.UP);
});
hotkeys("s", function () {
game.setDirection(Direction.DOWN);
});
hotkeys("a", function () {
game.setDirection(Direction.LEFT);
});
hotkeys("d", function () {
game.setDirection(Direction.RIGHT);
});
当然如果愿意的话,其他四个方向也可以加上。在操作过程中,你会发现,如果蛇在向右移动的过程中,你突然让它向左移动,会怎么样?没错,蛇头撞到蛇身了,游戏结束。这是不合理的,我们应该不允许这么操作。
/**
* 改变方向
* @param direction
*/
public setDirection(direction: Direction) {
if (
this.direction.x + direction.x === 0 &&
this.direction.y + direction.y === 0
) {
// 不允许前后操作方向相反
return;
}
this.direction = direction;
}
所以只要判断一下前后操作的方向是不是相反就可以了,判断条件很简单:各个方向相加是不是为 0 就行了。如果是,就不响应玩家的操作。
总结
通过对游戏逻辑的抽象,开发者不用关心具体的游戏实现是怎样的,只要用一个时钟去驱动游戏,根据游戏数据去改变视图即可。并且通过障碍物抽象基类,开发者可以自由扩展游戏的玩法,而不用去改动核心逻辑。但世界上没有完全的自由,所谓的自由不过是在某种规矩下的自由,比如无论怎么扩展,这始终是一个经典贪吃蛇,蛇只能一步步移动,你不能让它飞。
思考
如果我们要实现多人对战,那么就一个 Game 实例中就存在多个 Snake 实例,那么就需要一个 SnakeManager 来负责 snake 的管理。
- 在障碍物生成的时候剔除所有更多蛇身位置
- 在 update 中检测每条蛇的蛇头与其他任意蛇身的碰撞
- 计分系统应该挂载在 Snake 身上
附上链接: game-greedy-snake,欢迎对小游戏感兴趣的朋友一起策划,将在下个版本完善。