响应式编程的思维艺术-4从打飞机游戏理解并发与流的融合

104次阅读

共计 7617 个字符,预计需要花费 20 分钟才能阅读完成。

本文是 Rxjs 响应式编程 - 第三章: 构建并发程序这篇文章的学习笔记。

示例代码托管在:http://www.github.com/dashnowords/blogs

更多博文:《大史住在大前端》原创博文目录

一. 划重点

  • 尽量避免外部状态

    在基本的函数式编程中,纯函数可以保障构建出的数据管道得到确切的可预测的结果,响应式编程中有着同样的要求,博文中的示例可以很清楚地看到,当依赖于外部状态时,多个订阅者在观察同一个流时就容易互相影响而引发混乱。

    当不同的流之间出现共享的外部依赖时,一般的实现思路有两种:

    • 将这个外部状态独立生成一个可观察对象,然后根据实际逻辑需求使用正确的流合并方法将其合并。
    • 将这个外部状态独立生成一个可观察对象,然后使用 Subject 来将其和其他逻辑流联系起来。
  • 管道的执行效率

    在上一节中通过 compose 运算符组合纯函数就可以看到,容器相关的方法几乎全都是高阶函数,这样的做法就使得管道在构建过程中并不不会被启用,而是缓存组合在了一起(从上一篇的 IO 容器的示例中就可以看到延缓执行的形式),当它被订阅时才会真正启动。

  • Subject 类

    Subject同时具备 Observableobserver的功能,可订阅消息,也可产生数据,一般作为流和观察者的代理来使用,可以用来实现流的解耦。为了实现更精细的订阅控制,Subject还提供了以下几种方法。

    • AsyncSubject

      AsyncSubject观察的序列完成后它才会发出最后一个值,并永远缓存这个值,之后订阅这个 AsyncSubject 的观察者都会立刻得到这个值。

    • BehaviorSubject

      Observer 在订阅 BehaviorSubject 时,它接收最后发出的值,然后接收后续发出的值,一般要求提供一个初始值,观察者接收到的消息就是距离订阅时间最近的那个数据以及流后续产生的数据。

    • ReplaySubject

      ReplaySubject会缓存它监听的流发出的值,然后将其发送给任何较晚的 Observer,它可以通过在构造函数中传入参数来实现缓冲时间长度的设定。

二. 从理论到实践

原文中提供了一个非常详细的打飞机游戏的代码,但我仍然建议你在熟悉了其基本原理和思路后自己将它实现出来,然后去和原文中的代码作对比,好搞清楚哪些东西是真的理解了,哪些只是 你以为自己理解了,接着找一些很明显的优化点,继续使用响应式编程的思维模式来试着实现它们,起初不知道从何下手是非常正常的(当然也可能是笔者的自我安慰),但这对于培养响应式编程思维习惯大有裨益。笔者在自己的实现中又加入了右键切换飞船类型的功能,必须得说开发游戏的确比写业务逻辑要有意思。

由于没有精确计算雪碧图的坐标,所以在碰撞检测时会有一些偏差

三. 问题及反思

  1. 关于 canvas 的尺寸问题

    建议通过以下方式来设置:

    <!-- 推荐方式 1 -->
    <canvas height="300" width="400"></canvas>
    // 推荐方式 2
    canvas = document.getElementById('canvas');
    canvas.height = 300;
    canvas.width = 300;

    需要避免的几种方式(都是只改变画板尺寸,不改变画布尺寸,会造成绘图被拉伸):

    //1.CSS 设置
    #mycanvas{
       height:300px;
       width:300px;
    }
    //2.DOM 元素 API 设置
    canvas = document.getElementById('canvas');
    canvas.style.height = 300;
    canvas.style.width= 300;
    //3.Jquery 设置
    $('#mycanvas').width(300);

    同时需要注意 canvas 的宽高不支持百分比设定。

  2. Rx.Observable.combineLatest以后整体的流不自动触发了

    combineLatest这个运算符需要等所有的流都 emit 一次数据以后才会开始 emit 数据,因为它需要为整合在一起的每一个流保持一个最新值。所以自动启动的方法也很简单,为那些不容易触发首次数据的流添加一个初始值就可以了,就像笔者在上述实现右键来更换飞船外观时所实现的那样,使用 startWith 运算符提供一个初始值后,在鼠标移动时 combineLatest 生成的飞船流就会开始生产数据了。另外一点需要注意的就是 combineLatest 结合在一起后,其中任何一个流产生数据都会导致合成后的流产生数据,由于图例数据的坐标是在绘制函数中实现的,所以被动的触发可能会打乱原有流的预期频率,使得一些舞台元素的位置或形状变化更快,这种情况可以使用 sample() 运算符对合并后的流进行取样操作来限制数据触发频率。

  3. 一段越来越快的流

    笔者自己在生成敌机的时候,第一次写出这样一段代码:

    let enemyShipStream = Rx.Observable.interval(1500)
    .scan((prev)=>{// 敌机信息需要一个数组来记录,所以通过 scan 运算符将随机出现的敌机信息聚合
         prev.push({shape:[238,178,120,76],
            x:parseInt(Math.random() * canvas.width,10),
            y:50
         });
         return prev
    },[])
    .flatMap((enemies)=>{return Rx.Observable.interval(40).map(()=>{enemies.forEach(function (enemy) {enemy.y = enemy.y + 2;});
           return enemies;
       })
    });

    运行的时候发现敌机的速度变得越来越快,很诡异,如果你看不出问题在哪,建议画一下大理石图,看看 flatMap 汇聚的总的数据流是如何构成的,就很容易看到随着时间推移,多个流都在操作最初的源数据,所以坐标自增的频率越来越快。

  4. 限制 scan 操作符聚合结果的大小

    自己写代码时多处使用 scan 操作符对产生的数据进行聚合,如果聚合的形式是集合形式的,其所占空间就会随着时间推移越来越大,解决的办法就是在 scan 操作符接收的回调函数中利用数组的 filter 方法对聚合结果进行过滤,生成新的数组并返回,以此来控制聚合结果的大小。

  5. 碰撞检测的实现思路

    碰撞检测是即时生效的,所以每一帧都需要进行,最终汇总的流每次发射数据时都可以拿到所有待绘制元素的坐标信息,此时即是实现碰撞检测的时机,当检测到碰撞时,只需要在坐标数据中加个标记,然后在最初的 scan 的聚合方法中将符合标记的数据清除掉就可以了,检测碰撞的逻辑和碰撞发生后的数据清除以及绘制判断是编写在不同地方的,在笔者提供的示例中就可以看到。

四. 参考代码及 Demo 说明

demo 中的 index.html 是学习原文时拷贝的代码,mygame 中的代码是笔者写的,有需要的读者自行使用即可。

myspace.js- 星空背景流

/**
 * 背景
 * 扩展思考:如何融入全屏 resize 事件来自动调整星空
 */
// 将全屏初始化为画布舞台
let canvas = document.getElementById('canvas');
    canvas.height = window.innerHeight;
    canvas.width = window.innerWidth;
    canvas.style.backgroundColor = 'black';
let ctx = canvas.getContext('2d');
    ctx.fillStyle = '#FFFFFF';
let spaceShipImg = new Image();
    spaceShipImg.src = 'plane2.png';

// 生成星空
// 每个数据点希望得到的数据形式是[{x:1,y:1,size:1},{}]
let starStream = Rx.Observable.range(1,250)
.map(function(data){
   return {x:Math.ceil(Math.random()*canvas.width),
      y:Math.ceil(Math.random()*canvas.height),
      size: Math.ceil((Math.random()*4))
   }
})
.toArray()
.flatMap(function(stars){
    /* 此处是默写时的难点,静态生成的数组流需要一直保持
    * 后续的结果都是在此之上不断累加的
    */
    return Rx.Observable.interval(40).map(function () {stars.forEach(function (star) {star.y = (star.y+2)  % canvas.height; 
        });
        return stars;
    })
})

// 绘制星空
function paintStar(stars){
    // 暴力清屏,如果不清除则上次的星星不会被擦除
    ctx.fillStyle = '#000000';
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = '#FFFFFF';
    // 绘制星星
    stars.forEach(function (star) {ctx.fillRect(star.x, star.y, star.size, star.size);  
    });
}

myship.js- 我方飞船流

/**
 * 自己的飞船
 * 扩展思考:如何实现右键点击时更换飞船类型?
 */

// 鼠标移动流
let mouseMoveStream = Rx.Observable.fromEvent(window, 'mousemove')
.distinct() // 位置发生变化时触发
.map(function (data) {
    return {
        x:data.clientX,
        y:canvas.height - 100
    }
});

// 飞船类型静态流
let shipTypeStream = Rx.Observable.from([[0,0,130,90],
       [135,0,130,100],
       [265,0,126,100],
       [0,170,110,100]
    ]).toArray();

// 鼠标右键流 - 实现类型切换, 每次生成一个序号,然后从静态飞船流中拿出图形数据
let mouseRightStream = Rx.Observable.fromEvent(window, 'contextmenu')
.map(function (event) {event.preventDefault();// 禁止右键弹出菜单
})
.scan(count=>count+1,0)// 记录点击次数
.map(count=>count % 4).startWith(0);// 将次数转换为飞船类型序号


// 鼠标左键流 - 实现子弹发射
let mouseClickStream = Rx.Observable.fromEvent(canvas, 'click')
.sample(200)
.scan((prev,cur)=>{
   prev.push({
       x:cur.clientX,
       y:canvas.height - 50,
       used:false // 标记是否已经击中某个飞船
   });
   return prev.filter((bullet)=>{return bullet.y || !bullet.used});
},[])
.startWith([{x:0,y:0}]);

// 玩家飞船流
let myShipStream = Rx.Observable.combineLatest(mouseMoveStream,
                                               shipTypeStream,
                                               mouseRightStream,
                                               mouseClickStream,
                                               function(pos,typeArr,typeIndex,bullets){
                                                  return {
                                                    x:pos.x,
                                                    y:pos.y,
                                                    shape:typeArr[typeIndex],
                                                    bullets:bullets
                                                  }
                                               });

// 绘制飞船
function paintMyShip(ship) {
    // 绘制飞船
    ctx.drawImage(spaceShipImg,ship.shape[0],ship.shape[1],ship.shape[2],ship.shape[3], ship.x - 50, ship.y, ship.shape[2],ship.shape[3]);
    // 绘制自己子弹
    ship.bullets.forEach(function (bullet) {  
         bullet.y = bullet.y - 10;
         ctx.drawImage(spaceShipImg, ship.shape[0],ship.shape[1],ship.shape[2],ship.shape[3], bullet.x , bullet.y, ship.shape[2] / 4 ,ship.shape[3] / 4);
    });
}

enemy.js- 敌机流

/**
 * 敌方飞船
 */

// 辅助函数 - 判断是否超出画布范围
function isVisible(obj) {return obj.x > -60 && obj.x < canvas.width + 60 && obj.y > -60 && obj.y < canvas.height + 60;}

// 每 2 秒在随机横向位置产生一个敌机
let enemyShipStream = Rx.Observable.interval(2000)
.scan((prev)=>{// 敌机信息需要一个数组来记录,所以通过 scan 运算符将随机出现的敌机信息聚合   
     let newEnemy = {shape:[238,178,120,76],
        x:parseInt(Math.random() * canvas.width,10),
        y:50,
        isDead:false,// 标记敌机是否被击中
        bullets:[]}

     // 定时生成子弹
     Rx.Observable.interval(1500).subscribe(()=>{if (!newEnemy.isDead) {// 被击中的敌人不再产生子弹
            newEnemy.bullets.push({x: newEnemy.x, y: newEnemy.y});
         }
         newEnemy.bullets = newEnemy.bullets.filter(isVisible);
     });
      
     prev.push(newEnemy);
     return prev.filter(isVisible);
},[]);

// 绘制飞船
function paintEnemy(enemies) {enemies.forEach(function (enemy) {
       // 绘制时增量改变敌机坐标
       enemy.y = enemy.y + 3;
       enemy.x = enemy.x + parseInt(Math.random()*8 - 4,10);
       // 绘制时增量改变敌机子弹坐标
       enemy.bullets.forEach(function(bullet){bullet.y = bullet.y + 16;});
       // 如果敌机没挂则绘制飞机
       if (!enemy.isDead) {ctx.save();
         ctx.translate(enemy.x, enemy.y);
         ctx.rotate(Math.PI);
         // 绘制敌机
         ctx.drawImage(spaceShipImg,enemy.shape[0],enemy.shape[1],enemy.shape[2],enemy.shape[3], 0, 0, enemy.shape[2] * 0.8 ,enemy.shape[3] * 0.8);
         ctx.restore();}
       // 绘制子弹
       enemy.bullets.forEach(function (bullet) {ctx.save();
          ctx.translate(bullet.x, bullet.y);
          ctx.rotate(Math.PI);
          ctx.drawImage(spaceShipImg,enemy.shape[0],enemy.shape[1],enemy.shape[2],enemy.shape[3], 0, 0, enemy.shape[2] / 4,enemy.shape[3] / 4);
          ctx.restore();});
       ctx.restore();});
}

collision.js- 碰撞检测

// 辅助函数
function isCollision(target1, target2) {return (target1.x > target2.x - 50 && target1.x < target2.x + 50) && (target1.y > target2.y - 20 && target1.y < target2.y + 20);
}

// 碰撞检测方法
function checkCollision(myship, enemies) {
    let gameOver = false;
    myship.bullets.forEach(function(bullet) {enemies.forEach(function (enemy) { 
            // 检查是否击中了敌机
            if (isCollision(bullet, enemy)) {
                 bullet.used = true;
                 enemy.isDead = true;
            };
            // 检查是否被击中,被击中则游戏结束
            enemy.bullets.forEach(function (enemyBullet) {if (isCollision(myship, enemyBullet)) {gameOver = true;}
            })
        })
    });
    return gameOver;
}

combineAll.js- 融合最终的游戏流

/**
 * 集合所有流
 */

let gameStream = Rx.Observable.combineLatest(starStream,
                                            myShipStream,
                                            enemyShipStream,
                                            function (stars,myship,enemies) {
                                             return {stars,myship,enemies}
})
.sample(40);//sample 函数来规避鼠标移动事件过快触发导致坐标数据更新过快

// 绘制所有元素
function paintAll(data) {
     let isGameOver;
     isGameOver = checkCollision(data.myship, data.enemies);// 检查子弹是否击中敌人
     if (!isGameOver) {paintStar(data.stars);
         paintMyShip(data.myship);
         paintEnemy(data.enemies);
     }else{gameSubscription.dispose();
        alert('被击中了');
     }
}

// 订阅所有汇总的流来启动游戏
let gameSubscription = gameStream.subscribe(paintAll);

正文完
 0