本文共 9392 字,大约阅读时间需要 31 分钟。
前一篇简单的介绍了RxJS并不过瘾,对于新同学来讲多多少少还是有点疑惑,没有案例的支持并不能很好的理解他。受codeopen.io的启发,借助上面的小游戏《打转块》来学习下RxJS相关api来加深理解,先看下最后的效果
RxJS可以在客户端用也可以在服务器端用,安装的方式有很多种(在此)。我这里习惯了工程化的方式,就以的方式来启动这个项目,源码链接放在了文章底部
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
先定义一个画布
然后导入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源码: RxJS完全避免了异步回掉问题代码的可读性变得更强,当然,RxJS是可异步可同步的
可以更好的实现模块化,代码的复用度变得更高学习RxJS做动画是一条捷径转载地址:http://jkhwm.baihongyu.com/