前一篇簡單的介紹了RxJS並不過癮,對於新同學來講多多少少還是有點疑惑,沒有案例的支持並不能很好的理解他。受codeopen.io的啓發,藉助上面的小遊戲《打轉塊》來學習下RxJS相關api來加深理解,先看下最後的效果
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
在沒有錯誤的情況下就說明環境是沒問題的。打開瀏覽器輸入http://localhost:8000
這個時候瀏覽器是一片空白
好了,環境搞定。現在正式開始
先定義一個畫布
<canvas id="stage" width="480" height="320"></canvas>
然後導入Rx包和樣式文件
import Rx from 'rxjs/Rx' import './style.scss'
現在我們定義畫布上元素的相關屬性
// 獲取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來替我們完成
這個彈方塊多少都玩過,小球在發生碰撞的時候是有聲音的。那麼就需要創建一個音效的可觀察對象Observable。聲音的創建採用HTML5AudioContext
API,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();
同時綁定了keydown
和keyup
事件,按左方向鍵返回-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做動畫是一條捷徑