前言:
前段时间在学习Angular
,接触到了RxJS
,然后利用上班摸鱼的时间看了相关的学习资料,于是想写个小游戏练练手。
本文不对RxJS
的概念和使用作介绍,想要学习的强烈推荐阅读程墨老师的《深入浅出RxJS》一书,非常适合新手阅读。本实践即参考了该书最后的《break-out》实践项目。
先给出预览图:
试玩地址
用到的技术/语法:
- RxJS 6.x
- Es6
没有使用babel转译,所以建议在最新浏览器中打开
简单的项目搭建:
index.html
文件
直接通过RxJS
官网提供的cdn链接引入。
<!DOCTYPE html>
<html>
<head>
<title>Snake</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div id="container">
<h1>贪吃蛇</h1>
<p>Enter:开始游戏</p>
<p>Space:加速</p>
<p>方向键控制转向</p>
<p>分数:<span id='score'>0</span></p>
<canvas width="400" height="400" id="game"></canvas>
</div>
<script src="https://unpkg.com/rxjs/bundles/rxjs.umd.min.js" type="text/javascript"></script>
<script src="./snack.js" type="text/javascript"></script>
</body>
</html>
snake.js
文件
使用RxJS 6.x
,操作符都需要通过rxjs.operators.x
和实例的管道操作符才能调用。
使用canvas2d绘制游戏
const Rx = rxjs;
const $ = rxjs.operators;
const stage = document.getElementById('game');
const scoreEl = document.getElementById('score');
const context = stage.getContext('2d');
style.css
文件
#container {
display: flex;
flex-flow: column nowrap;
justify-content: center;
align-items: center;
}
#game {
background-color: bisque;
}
下面开始编写snake.js
中的游戏逻辑代码。
游戏流程介绍:
- 玩家按
Enter
键开始游戏 - 方向键控制蛇头转向
- 按下
Space
时给蛇加速 - 蛇头吃到食物身体增长、分数增加,同时随机生成下一个食物位置
- 蛇头碰到边界或自己的身体则结束游戏
游戏状态设计:
游戏中的状态包括:
- 蛇的速度
- 蛇当前移动的方向
- 蛇头的位置
- 蛇的身体
- 食物的位置
其中身体的状态。这里采用如下数据结构:
- 将蛇的身体分为多段,使用列表存储。
- 每段包含两个属性:
direction
为该段身体的方向,len
为该段的长度。
这样做的优点:
- 能流畅地绘制蛇的前进路线
- 存储简单,内存占用少
缺点在于,碰撞检测及绘制身体的代码会比较复杂
代码实现:
定义游戏中所需的常量:
const FPS = 60; // 每秒刷新多少次
const LOW_SPEED = 100; // 低速
const HIGH_SPEED = 300; // 高速
const BODY_WIDTH = 10; // 身体宽度
const HALF_BODY_WIDTH = BODY_WIDTH/2; // 身体宽度的一半
const FOOD_RADIUS = 10; // 食物半径
const PER_FOOD_INCRE = 20; // 每个食物提供的增长值
const INIT_LEN = 100; // 蛇的初始长度
const DIRECTIONS = { // 方向键码
up: 38,
down: 40,
left: 37,
right: 39
};
定义数据源:
游戏中的数据源包括:
keydown
事件:按下方向键能改变蛇的方向状态,按下Space
能使蛇加速keyup
事件:抬起Space
能使蛇减速- 时间:蛇的位置随着时间变化
const keyDown$ = Rx.fromEvent(document, 'keydown').pipe($.distinctUntilChanged());
const keyUp$ = Rx.fromEvent(document, 'keyup');
const ticker$ = Rx.interval(1000/FPS, Rx.animationFrameScheduler).pipe(
$.map(() => ({
time: Date.now(),
delay: 0
})),
$.scan((prev, cur) => ({
time: cur.time,
delay: (cur.time - prev.time)/1000
}))
);
蛇的方向:
蛇的方向只依赖方向键按下这个事件:
const directions = [DIRECTIONS.left, DIRECTIONS.up, DIRECTIONS.right, DIRECTIONS.down];
const direction$ = keyDown$.pipe(
$.map(e => e.keyCode),
$.filter(code => directions.indexOf(code) !== -1),
$.scan((prev, cur) => {
//如果按键方向与当前方向相同或相反则还是当前方向
if (prev === cur || Math.abs(cur - prev) === 2) return prev;
return cur;
}),
$.startWith(directions[0]),
$.distinctUntilChanged(), // 此时发出的方向只会与之前的方向不同
);
蛇的速度:
蛇的速度也可以直接根据Space
键的按下和抬起来确定:
const speed$ = keyDown$.pipe(
$.filter(e => e.keyCode === 32),
$.map(() => HIGH_SPEED),
$.merge(
keyUp$.pipe(
$.map(() => LOW_SPEED)
)
)
)
蛇头的位置、身体数据及食物位置
这三个状态是最后需要绘制出来的状态,而且存在互相依赖的关系。 这部分的逻辑包括:
- 蛇头遇到食物时,食物的状态需要更新,身体需要增长。
- 蛇头遇到边界时,游戏停止。
- 蛇头遇到身体部分,游戏停止。
所以无法将每个状态抽离出来。
游戏的初始状态:
const initState = {
direction: DIRECTIONS.left,
position: [stage.width/2, stage.height/2],
body: [
{
direction: DIRECTIONS.right,
len: INIT_LEN
}
],
food: createFood()
}
游戏整体逻辑实现:
const snake$ = ticker$.pipe(
$.withLatestFrom(speed$, direction$),
$.scan((state, [ticker, speed, direction]) => {
const move = ticker.delay*speed;
let body = state.body;
let position = state.position;
let food = state.food;
// 检测到食物
if (detectFood(state)) {
const nail = body[body.length - 1];
body = [...body.slice(0, -1), { len: nail.len + PER_FOOD_INCRE, direction: nail.direction }];
food = createFood();
}
/**
* 处理头部位置
*/
switch (direction) {
case DIRECTIONS.up:
position = [position[0], position[1]-move];
break;
case DIRECTIONS.down:
position = [position[0], position[1]+move];
break;
case DIRECTIONS.left:
position = [position[0] - move, position[1]];
break;
case DIRECTIONS.right:
position = [position[0] + move, position[1]];
break;
default: break;
}
/**
* 处理身体移动问题
*/
if (direction === state.direction) {
// 与之前方向相同
body = [{direction: body[0].direction, len: body[0].len + move}, ...body.slice(1)];
} else {
const newHead = {
direction: direction,
len: move + BODY_WIDTH
};
switch (direction) {
case DIRECTIONS.up:
newHead.direction = DIRECTIONS.down;
break;
case DIRECTIONS.down:
newHead.direction = DIRECTIONS.up;
break;
case DIRECTIONS.left:
newHead.direction = DIRECTIONS.right;
break;
case DIRECTIONS.right:
newHead.direction = DIRECTIONS.left;
break;
default: break;
}
const oldHead = {
direction: body[0].direction,
len: body[0].len - BODY_WIDTH
};
body = [newHead, oldHead, ...body.slice(1)];
}
/**
* 处理尾部
*/
let restMove = move;
while(1) {
const nail = body[body.length - 1];
if (nail.len > restMove) {
body = [ ...body.slice(0, -1), {
direction: nail.direction,
len: nail.len - restMove
}];
break;
} else if (nail.len === restMove) {
body = body.slice(0, -1);
break;
}
restMove -= nail.len;
body = body.slice(0, -1);
}
return {
direction,
body,
position,
food
}
}, initState),
$.takeWhile(state => { // 游戏结束条件
// 碰到边界
if (detectEdge(state) && detectSelf(state)) {
return true;
}
return false;
})
);
随机创造一个食物:
function createFood() {
return [ Math.random()*stage.width, Math.random()*stage.height ];
}
边界碰撞检测:
function detectEdge(state) {
const { position, direction } = state;
switch(direction) {
case DIRECTIONS.up:
if (position[1] <= HALF_BODY_WIDTH) return false;
break;
case DIRECTIONS.down:
if (position[1] >= stage.height - HALF_BODY_WIDTH) return false;
break;
case DIRECTIONS.left:
if (position[0] <= HALF_BODY_WIDTH) return false;
break;
case DIRECTIONS.right:
if (position[0] >= stage.width - HALF_BODY_WIDTH) return false;
break;
default:;
}
return true;
}
自身碰撞检测:
function detectSelf(state) {
const direction = state.direction;
const headx = state.position[0], heady = state.position[1];
let edgeStart = state.position; //身体每节的起点
return !state.body.some((edge, idx) => {
const len = idx === 0 ? edge.len - BODY_WIDTH : edge.len;
let nextEdgeStart; //下一节的起点
switch (edge.direction) {
case DIRECTIONS.up:
nextEdgeStart = [edgeStart[0], edgeStart[1] - len]
break;
case DIRECTIONS.down:
nextEdgeStart = [edgeStart[0], edgeStart[1] + len];
break;
case DIRECTIONS.left:
nextEdgeStart = [edgeStart[0] - len, edgeStart[1]];
break;
case DIRECTIONS.right:
nextEdgeStart = [edgeStart[0] + len, edgeStart[1]];
break;
}
if (idx > 1 && edge.direction !== direction && Math.abs(edge.direction - direction) !== 2) {
// 身体的前两节不可能相撞
// 只考虑与当前方向非平行的情况
let left, right, top, bottom;
switch (edge.direction) {
case DIRECTIONS.up:
left = edgeStart[0] - HALF_BODY_WIDTH;
right = edgeStart[0] + HALF_BODY_WIDTH;
bottom = edgeStart[1] + HALF_BODY_WIDTH;
top = bottom - edge.len - BODY_WIDTH;
break;
case DIRECTIONS.down:
left = edgeStart[0] - HALF_BODY_WIDTH;
right = edgeStart[0] + HALF_BODY_WIDTH;
top = edgeStart[1] - HALF_BODY_WIDTH;
bottom = top + edge.len + BODY_WIDTH;
break;
case DIRECTIONS.left:
top = edgeStart[1] - HALF_BODY_WIDTH;
bottom = edgeStart[1] + HALF_BODY_WIDTH;
right = edgeStart[0] + HALF_BODY_WIDTH;
left = right - edge.len - BODY_WIDTH;
break;
case DIRECTIONS.right:
top = edgeStart[1] - HALF_BODY_WIDTH;
bottom = edgeStart[1] + HALF_BODY_WIDTH;
left = edgeStart[0] - HALF_BODY_WIDTH;
right = left + edge.len + BODY_WIDTH;
break;
default:;
}
if (
headx >= left - HALF_BODY_WIDTH && headx <= right + HALF_BODY_WIDTH &&
heady >= top - HALF_BODY_WIDTH && heady <= bottom + HALF_BODY_WIDTH
) {
return true;
}
}
edgeStart = nextEdgeStart;
return false;
})
}
渲染(绘制)函数:
function drawSnake(state) {
const { body, position, food } = state;
context.clearRect(0, 0, stage.width, stage.height);
context.fillStyle='#222222';
context.beginPath();
// 画蛇的身体
let totalLen = 0;
let start = position;
body.forEach((item, idx) => {
const len = idx === 0 ? item.len : (item.len + BODY_WIDTH);
let left, top, width, height;
totalLen += item.len;
switch(item.direction) {
case DIRECTIONS.up:
left = start[0] - HALF_BODY_WIDTH;
top = start[1] + HALF_BODY_WIDTH - len;
width = BODY_WIDTH;
height = len;
start = [start[0], top + HALF_BODY_WIDTH];
break;
case DIRECTIONS.down:
left = start[0] - HALF_BODY_WIDTH;
top = start[1] - HALF_BODY_WIDTH;
width = BODY_WIDTH;
height = len;
start = [start[0], top + len - HALF_BODY_WIDTH];
break;
case DIRECTIONS.left:
left = start[0] + HALF_BODY_WIDTH - len;
top = start[1] - HALF_BODY_WIDTH;
width = len;
height = BODY_WIDTH;
start = [left + HALF_BODY_WIDTH, start[1]];
break;
case DIRECTIONS.right:
left = start[0] - HALF_BODY_WIDTH;
top = start[1] - HALF_BODY_WIDTH;
width = len;
height = BODY_WIDTH;
start = [left + len - HALF_BODY_WIDTH, start[1]];
break;
default:;
}
context.fillRect(left, top, width, height);
});
// 分数
scoreEl.innerText= Math.ceil(totalLen - INIT_LEN);
// 画蛇头
context.closePath();
context.fillStyle='red';
context.beginPath();
context.arc(position[0], position[1], FOOD_RADIUS, 0, Math.PI * 2, true);
context.closePath();
context.fill();
// 画食物
context.fillStyle='green';
context.beginPath();
context.arc(food[0], food[1], FOOD_RADIUS, 0, Math.PI * 2, true);
context.closePath();
context.fill();
}
按Enter键开始游戏:
const game$ = keyDown$
.pipe(
$.filter(e => e.keyCode === 13),
$.switchMap(() => snake$)
)
game$.subscribe(drawSnake);
至此,贪吃蛇的全部功能已经实现了。可见,使用RxJS
使代码确实清晰简洁。