前一篇简单的介绍了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
这个时候浏览器是一片空白
好了,环境搞定。现在正式开始
编码
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。声音的创建采用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做动画是一条捷径