RxJS实践-贪吃蛇

1,427 阅读3分钟

前言:

前段时间在学习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中的游戏逻辑代码。

游戏流程介绍:

  1. 玩家按Enter键开始游戏
  2. 方向键控制蛇头转向
  3. 按下Space时给蛇加速
  4. 蛇头吃到食物身体增长、分数增加,同时随机生成下一个食物位置
  5. 蛇头碰到边界或自己的身体则结束游戏

游戏状态设计:

游戏中的状态包括:

  • 蛇的速度
  • 蛇当前移动的方向
  • 蛇头的位置
  • 蛇的身体
  • 食物的位置

其中身体的状态。这里采用如下数据结构:

  • 将蛇的身体分为多段,使用列表存储。
  • 每段包含两个属性: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使代码确实清晰简洁。