游戏循环

作者 @barryrowe

本食谱演示了使用组合流来创建游戏循环的一种方式。本食谱旨在突出如何用响应式的方式来重新思考现有问题。在这个示例中,我们将提供整体循环以及自上帧以来的增量时间。与此相结合的是用户输入流,以及当前的游戏状态,我们可以用它来更新我们的对象,并根据每帧的发出来将其渲染到屏幕上。

示例代码

( StackBlitz )

import { BehaviorSubject, Observable, of, fromEvent } from 'rxjs';
import { buffer, bufferCount, expand, filter, map,  share, tap, withLatestFrom } from 'rxjs/operators';

import { IFrameData } from './frame.interface';
import { KeyUtil } from './keys.util';
import { clampMag, runBoundaryCheck, clampTo30FPS } from './game.util';

const boundaries = {
  left: 0,
  top: 0,
  bottom: 300,
  right: 400
};
const bounceRateChanges = {
  left: 1.1,
  top: 1.2,
  bottom: 1.3,
  right: 1.4
}
const baseObjectVelocity = {
  x: 30,
  y: 40,
  maxX: 250,
  maxY: 200
};

const gameArea: HTMLElement = document.getElementById('game');
const fps: HTMLElement = document.getElementById('fps');

/**
 * 这是游戏循环的核心逻辑。每一帧都更新对象和游戏状态。
 * 传入的 `deltaTime` 以秒为单位,我们还给定了当前状态和任意的输入状态。
 * 返回值为更新后的游戏状态。
 */
const update = (deltaTime: number, state: any, inputState: any): any => {
  //console.log("Input State: ", inputState);
  if(state['objects'] === undefined) {
    state['objects'] = [
      {
        // 变形属性
        x: 10, y: 10, width: 20, height: 30,
        // 状态属性
        isPaused: false, toggleColor: '#FF0000', color: '#000000',
        // 移动属性
        velocity: baseObjectVelocity
      },
      {
        // 变形属性
        x: 200, y: 249, width: 50, height: 20,
        // 状态属性
        isPaused: false, toggleColor: '#00FF00', color: '#0000FF',
        // 移动属性
        velocity: {x: -baseObjectVelocity.x, y: 2*baseObjectVelocity.y} }
    ];
  } else {

    state['objects'].forEach((obj) => {
      // 处理输入
      if (inputState['spacebar']) {
        obj.isPaused = !obj.isPaused;
        let newColor = obj.toggleColor;
        obj.toggleColor = obj.color;
        obj.color = newColor;
      }

      // 处理游戏循环的更新
      if(!obj.isPaused) {

        // 应用速率运动
        obj.x = obj.x += obj.velocity.x*deltaTime;
        obj.y = obj.y += obj.velocity.y*deltaTime;  

        // 边界检查
        const didHit = runBoundaryCheck(obj, boundaries);
        // 处理边界调整
        if(didHit){
          if(didHit === 'right' || didHit === 'left') {
            obj.velocity.x *= -bounceRateChanges[didHit];
          } else {
            obj.velocity.y *= -bounceRateChanges[didHit];
          }
        }
      }

      // 如果我们的边界反弹使得我们的速度变得太快,就钳制速度。
      obj.velocity.x = clampMag(obj.velocity.x, 0, baseObjectVelocity.maxX);
      obj.velocity.y = clampMag(obj.velocity.y, 0, baseObjectVelocity.maxY);
    });
  }

  return state;
}

/**
 * 这是渲染函数。我们接收给定的游戏状态并根据它们的最新属性来渲染页面。
 */
const render = (state: any) => {
  const ctx: CanvasRenderingContext2D = (/*<HTMLCanvasElement>*/gameArea).getContext('2d');
  // 清除 canvas
  ctx.clearRect(0, 0, gameArea.clientWidth, gameArea.clientHeight);

  // 渲染所有对象 (都是简单的矩形)
  state['objects'].forEach((obj) => {
    ctx.fillStyle = obj.color;
    ctx.fillRect(obj.x, obj.y, obj.width, obj.height);
  });  
};



/**
 * 这个函数返回一个 observable,一旦浏览器返回一个动画帧步骤,该 observable 将发出下一个帧。
 * 鉴于前一帧计算得出的增量时间,我们将其钳制至30FPS,以防长帧的出现。
 */
const calculateStep: (prevFrame: IFrameData) => Observable<IFrameData> = (prevFrame: IFrameData) => {
  return Observable.create((observer) => {

    requestAnimationFrame((frameStartTime) => {
      // 毫秒转化成秒
      const deltaTime = prevFrame ? (frameStartTime - prevFrame.frameStartTime)/1000 : 0;
      observer.next({
        frameStartTime,
        deltaTime
      });
    })
  })
  .pipe(
    map(clampTo30FPS)
  )
};

/**
 * 这是帧的核心流。我们使用 `expand` 操作符来递归调用上面的 `calculateStep` 函数,
 * 它会基于 `window.requestAnimationFrame` 的调用返回每一个新帧。
 * `expand` 发出被调用函数返回的 observable 的值,并递归调用具有相同发出值的函数。
 * 这非常适合计算我们的帧步骤,因为每个步骤都需要知道上一帧的时间来计算下一帧。
 * 一旦当前请求的帧已经返回,我们还想要求一个新的帧。
 */
const frames$ = of(undefined)
  .pipe(
    expand((val) => calculateStep(val)),
    // expand 发出提供给它的第一个值,
    // 在这里我们只想忽略值为 undefined 的输入帧
    filter(frame => frame !== undefined),
    map((frame: IFrameData) => frame.deltaTime),
    share()
  )

// 这是 keyDown 输入事件的核心流。
// 每次按键后它会发出类似 `{"spacebar": 32}` 的对象
const keysDown$ = fromEvent(document, 'keydown')
  .pipe(
    map((event: KeyboardEvent) => {
      const name = KeyUtil.codeToKey(''+event.keyCode);
      if (name !== ''){
        let keyMap = {};
        keyMap[name] = event.code;
        return keyMap;
      } else {
        return undefined;
      }
    }),
    filter((keyMap) => keyMap !== undefined)
  );

// 这里我们将 keyDown 流缓冲起来,直到发出新的帧。
// 我们将得到自从上一帧发出后的所有 keyDown 事件。
// 我们将其归并为单个对象。
const keysDownPerFrame$ = keysDown$
  .pipe(
    buffer(frames$),
    map((frames: Array<any>) => {
      return frames.reduce((acc, curr) => {
        return Object.assign(acc, curr);
      }, {});
    })
  );

// 因为每一帧我们都会更新游戏状态,所以可以使用 Observable 作为一系列状态
// 进行追踪,最新的发出值即为当前游戏状态。
const gameState$ = new BehaviorSubject({});

// 这是运行游戏的代码!
// 我们订阅 `frames$` 流以开始,并确保组合了输入流的最新发出,以获取游戏状态更新所
// 必须的数据。
frames$
  .pipe(
    withLatestFrom(keysDownPerFrame$, gameState$),
    // 课后作业: 处理 keyUp 并映射成真正的按键状态变化对象
    map(([deltaTime, keysDown, gameState]) => update(deltaTime, gameState, keysDown)),
    tap((gameState) => gameState$.next(gameState))

  )
  .subscribe((gameState) => {
    render(gameState);
  });


// 平均每10帧计算一下FPS
frames$ 
  .pipe(
    bufferCount(10),
    map((frames) => {
      const total = frames
        .reduce((acc, curr) => {
          acc += curr;
          return acc;
        }, 0);

        return 1/(total/frames.length);
    })
  ).subscribe((avg) => {
    fps.innerHTML = Math.round(avg) + '';
  })
辅助 js 文件
html
<canvas width="400px" height="300px" id="game"></canvas>
<div id="fps"></div>
<p class="instructions">
  Each time a block hits a wall, it gets faster. You can hit SPACE to pause the boxes. They will change colors to show they are paused.
</p>

使用到的操作符

results matching ""

    No results matching ""