玩遊戲,學RxJS

前一篇簡單的介紹了RxJS並不過癮,對於新同學來講多多少少還是有點疑惑,沒有案例的支持並不能很好的理解他。受codeopen.io的啓發,藉助上面的小遊戲《打轉塊》來學習下RxJS相關api來加深理解,先看下最後的效果

jdfw

搭環境

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

image

在沒有錯誤的情況下就說明環境是沒問題的。打開瀏覽器輸入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做動畫是一條捷徑