玩游戏,学RxJS

前一篇简单的介绍了RxJS并不过瘾,对于新同学来讲多多少少还是有点疑惑,没有案例的支持并不能很好的理解他。受codeopen.io的启发,借助上面的小游戏《打转块》来学习下RxJS相关api来加深理解,先看下最后的效果

玩游戏,学RxJS

搭环境

RxJS可以在客户端用也可以在服务器端用,安装的方式有很多种(传送门在此)。我这里习惯了工程化的方式,就以webpack的方式来启动这个项目,源码链接放在了文章底部
webpack的配置大概是这样:

module.exports = {

    entry: {
        "app": "./src/index.js"
    },

    output: {
        filename: '[name].boundle.js',
        path: path.resolve(__dirname,'../dist'),
        publicPath:'/'
    },

    module: {
        rules: [
            {
                test: /\.scss$/,
                use: [
                    'css-loader',
                    'sass-loader'
                ]
            },
            {
                test: /\.js$/,
                exclude: /node_modules/,
                use: [
                    'babel-loader'
                ]
            }
        ]
    },
    plugins: [
        new CleanWebpackPlugin('../dist'),
        new HtmlWebpackPlugin({
            title:'A game intro to RxJS',
            template: 'src/index.html'
        })
    ]
}

然后运行:

npm start

玩游戏,学RxJS

在没有错误的情况下就说明环境是没问题的。打开浏览器输入http://localhost:8000
这个时候浏览器是一片空白
好了,环境搞定。现在正式开始

编码

Step1

先定义一个画布

<canvas id="stage" width="480" height="320"></canvas>

然后导入Rx包和样式文件

import Rx from 'rxjs/Rx'
import './style.scss'

Step2

现在我们定义画布上元素的相关属性

// 获取canvas对象
const canvas = document.getElementById('stage');
// 创建2D的运行环境
const context = canvas.getContext('2d');
// 给画布上色
context.fillStyle = 'pink';
// 桨
const PADDLE_WIDTH = 100;
const PADDLE_HEIGHT = 20;
// 定义球的大小
const BALL_RADIUS = 10;
// 定义砖块
const BRICK_ROWS = 5;
const BRICK_COLUMNS = 7;
const BRICK_HEIGHT = 20;
const BRICK_GAP = 3;

接着把各种元素给画上去,如砖块,球等

function drawTitle() {
    context.textAlign = 'center';
    context.font = '24px Courier New';
    context.fillText('rxjs breakout', canvas.width / 2, canvas.height / 2 - 24);
}

function drawControls() {
    context.textAlign = 'center';
    context.font = '16px Courier New';
    context.fillText('press [<] and [>] to play', canvas.width / 2, canvas.height / 2);
}

function drawGameOver(text) {
    context.clearRect(canvas.width / 4, canvas.height / 3, canvas.width / 2, canvas.height / 3);
    context.textAlign = 'center';
    context.font = '24px Courier New';
    context.fillText(text, canvas.width / 2, canvas.height / 2);
}

function drawAuthor() {
    context.textAlign = 'center';
    context.font = '16px Courier New';
    context.fillText('by Manuel Wieser', canvas.width / 2, canvas.height / 2 + 24);
}

function drawScore(score) {
    context.textAlign = 'left';
    context.font = '16px Courier New';
    context.fillText(score, BRICK_GAP, 16);
}

function drawPaddle(position) {
    context.beginPath();
    context.rect(
        position - PADDLE_WIDTH / 2,
        context.canvas.height - PADDLE_HEIGHT,
        PADDLE_WIDTH,
        PADDLE_HEIGHT
    );
    context.fill();
    context.closePath();
}

function drawBall(ball) {
    context.beginPath();
    context.arc(ball.position.x, ball.position.y, BALL_RADIUS, 0, Math.PI * 2);
    context.fill();
    context.closePath();
}

function drawBrick(brick) {
    context.beginPath();
    context.rect(
        brick.x - brick.width / 2,
        brick.y - brick.height / 2,
        brick.width,
        brick.height
    );
    context.fill();
    context.closePath();
}

function drawBricks(bricks) {
    bricks.forEach((brick) => drawBrick(brick));
}

好,现在我们已经准备好了场景里面静态元素,接下来的动态的事情会让RxJS来替我们完成

Step3

这个弹方块多少都玩过,小球在发生碰撞的时候是有声音的。那么就需要创建一个音效的可观察对象Observable。声音的创建采用HTML5AudioContextAPI,Observable通过Subject来创建,Subject是一个特殊的Observable它继承于Observable,它和Observable最大的区别就是他可以多路传播共享一个可观察环境。小球会在多个地方发生碰撞,每次碰撞都需要发一次不同的声音表示碰撞的不同区域,那么我们需要完全手动控制next()方法去触发声音,这样的场景用Subject来创建可观察对象再合适不过了。

onst audio = new (window.AudioContext || window.webkitAudioContext)();
const beeper = new Rx.Subject();
beeper.subscribe((key) => {

    let oscillator = audio.createOscillator();
    oscillator.connect(audio.destination);
    // 设置音频影调
    oscillator.type = 'square';

    // https://en.wikipedia.org/wiki/Piano_key_frequencies
    // 设置音频频率
    oscillator.frequency.value = Math.pow(2, (key - 49) / 12) * 440;

    oscillator.start();
    oscillator.stop(audio.currentTime + 0.100);

});

这样在需要发声的地方执行beeper.next(value)(注:在老的版本onNext已经替换为next)碰撞的声音就有了

那么接下来就该创建动画,绑定键盘事件,做碰撞检测等等。最早我们创建逐帧动画是用setInterval后来有了requsetAnimation,在RxJS中做逐帧动画需要用到Scheduler(调度)。
调度器的作用就是可以让你规定 Observable 在什么样的执行上下文中发送通知给它的观察者。结合interval操作符就能实现平滑的帧动画

const TICKER_INTERVAL = 17;

const ticker$ = Rx.Observable
    .interval(TICKER_INTERVAL, Rx.Scheduler.requestAnimationFrame)
    .map(() => ({
        time: Date.now(),
        deltaTime: 
    }))
    .scan(
        (previous, current) => ({
            time: current.time,
            deltaTime: (current.time - previous.time) / 1000
        })
    );

一般在变量后添加$表示Observable对象。然后绑定键盘事件

const PADDLE_SPEED = 240;
const PADDLE_KEYS = {
    left: 37,
    right: 39
};

const input$ = Rx.Observable
    .merge(
        Rx.Observable.fromEvent(document, 'keydown', event => {
            switch (event.keyCode) {
                case PADDLE_KEYS.left:
                    return -1;
                case PADDLE_KEYS.right:
                    return 1;
                default:
                    return 0;
            }
        }),
        Rx.Observable.fromEvent(document, 'keyup', event => 0)
    )
    .distinctUntilChanged();

const paddle$ = ticker$
    .withLatestFrom(input$)
    .scan((position, [ticker, direction]) => {

        let next = position + direction * ticker.deltaTime * PADDLE_SPEED;
        return Math.max(Math.min(next, canvas.width - PADDLE_WIDTH / 2), PADDLE_WIDTH / 2);

    }, canvas.width / 2)
    .distinctUntilChanged();

同时绑定了keydownkeyup事件,按左方向键返回-1,按右方向键返回1,松开则返回0,最后通过distinctUntilChanged把结果进行比对输出。相比传统addEventListener代码优雅了不少。withLatestFrom获取球拍的实时坐标,然后通过scan进行过度对坐标做累加或累减操作。
接下来就是做小球的动画,小球的动画要复杂一些,需要做球与球拍,球与砖块,球与墙体的碰撞检测。碰撞检测的原理也很简单主要是比对物体之间的坐标

const BALL_SPEED = 60;
const INITIAL_OBJECTS = {
    ball: {
        position: {
            x: canvas.width / 2,
            y: canvas.height / 2
        },
        direction: {
            x: 2,
            y: 2
        }
    },
    bricks: factory(),
    score: 0
};
// 球与球拍的碰撞检测
function hit(paddle, ball) {
    return ball.position.x > paddle - PADDLE_WIDTH / 2
        && ball.position.x < paddle + PADDLE_WIDTH / 2
        && ball.position.y > canvas.height - PADDLE_HEIGHT - BALL_RADIUS / 2;
}

const objects$ = ticker$
    .withLatestFrom(paddle$)
    .scan(({ball, bricks, collisions, score}, [ticker, paddle]) => {

        let survivors = [];
        collisions = {
            paddle: false,
            floor: false,
            wall: false,
            ceiling: false,
            brick: false
        };

        ball.position.x = ball.position.x + ball.direction.x * ticker.deltaTime * BALL_SPEED;
        ball.position.y = ball.position.y + ball.direction.y * ticker.deltaTime * BALL_SPEED;

        bricks.forEach((brick) => {
            if (!collision(brick, ball)) {
                survivors.push(brick);
            } else {
                collisions.brick = true;
                score = score + 10;
            }
        });

        collisions.paddle = hit(paddle, ball);

        if (ball.position.x < BALL_RADIUS || ball.position.x > canvas.width - BALL_RADIUS) {
            ball.direction.x = -ball.direction.x;
            collisions.wall = true;
        }

        collisions.ceiling = ball.position.y < BALL_RADIUS;

        if (collisions.brick || collisions.paddle || collisions.ceiling ) {
            ball.direction.y = -ball.direction.y;
        }

        return {
            ball: ball,
            bricks: survivors,
            collisions: collisions,
            score: score
        };

    }, INITIAL_OBJECTS);

小球与砖块


function factory() {
    let width = (canvas.width - BRICK_GAP - BRICK_GAP * BRICK_COLUMNS) / BRICK_COLUMNS;
    let bricks = [];

    for (let i = 0; i < BRICK_ROWS; i++) {
        for (let j = 0; j < BRICK_COLUMNS; j++) {
            bricks.push({
                x: j * (width + BRICK_GAP) + width / 2 + BRICK_GAP,
                y: i * (BRICK_HEIGHT + BRICK_GAP) + BRICK_HEIGHT / 2 + BRICK_GAP + 20,
                width: width,
                height: BRICK_HEIGHT
            });
        }
    }

    return bricks;
}
//小球与砖块的碰撞检测
function collision(brick, ball) {
    return ball.position.x + ball.direction.x > brick.x - brick.width / 2
        && ball.position.x + ball.direction.x < brick.x + brick.width / 2
        && ball.position.y + ball.direction.y > brick.y - brick.height / 2
        && ball.position.y + ball.direction.y < brick.y + brick.height / 2;
}

基本工作已经做完,剩下的就是绘制场景让游戏跑起来。

drawTitle();
drawControls();
drawAuthor();

function update([ticker, paddle, objects]) {

    context.clearRect(0, 0, canvas.width, canvas.height);

    drawPaddle(paddle);
    drawBall(objects.ball);
    drawBricks(objects.bricks);
    drawScore(objects.score);

    if (objects.ball.position.y > canvas.height - BALL_RADIUS) {
        beeper.next(28);
        drawGameOver('GAME OVER');
        game.unsubscribe();
    }

    if (!objects.bricks.length) {
        beeper.next(52);
        drawGameOver('CONGRATULATIONS');
        game.unsubscribe();
    }

    if (objects.collisions.paddle) beeper.next(40);
    if (objects.collisions.wall || objects.collisions.ceiling) beeper.next(45);
    if (objects.collisions.brick) beeper.next(47 + Math.floor(objects.ball.position.y % 12));

}

const game = Rx.Observable
    .combineLatest(ticker$, paddle$, objects$)
    .subscribe(update);

通过combineLatest组合 ticker$,paddle$
objects$三个Observable,他们的输出是一个数组。通过.subscribe集中处理我们小球的运动逻辑。每次动画重绘canvas区域,碰撞不同的区域触发beeper.next()发出不同的声音。
github源码:https://github.com/zedwang/RxJS-Breakout

总结

RxJS完全避免了异步回掉问题代码的可读性变得更强,当然,RxJS是可异步可同步的
可以更好的实现模块化,代码的复用度变得更高
学习RxJS做动画是一条捷径

上一篇:IBM与多家消费性电子厂联手以Watson认知运算强化物联网服务


下一篇:2 关于数据仓库维度数据处理的方法探究系列——无变化维度处理