• 游戏循环
    • 示例代码
      • 辅助 js 文件
      • html
  • 使用到的操作符

    游戏循环

    作者 @barryrowe

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

    游戏循环 - 图1

    示例代码

    (
    StackBlitz
    )

    1. import { BehaviorSubject } from 'rxjs/BehaviorSubject';
    2. import { Observable } from 'rxjs/Observable';
    3. import { of } from 'rxjs/observable/of';
    4. import { fromEvent } from 'rxjs/observable/fromEvent';
    5. import { buffer, bufferCount, expand, filter, map, share, tap, withLatestFrom } from 'rxjs/operators';
    6. import { IFrameData } from './frame.interface';
    7. import { KeyUtil } from './keys.util';
    8. import { clampMag, runBoundaryCheck, clampTo30FPS } from './game.util';
    9. const boundaries = {
    10. left: 0,
    11. top: 0,
    12. bottom: 300,
    13. right: 400
    14. };
    15. const bounceRateChanges = {
    16. left: 1.1,
    17. top: 1.2,
    18. bottom: 1.3,
    19. right: 1.4
    20. }
    21. const baseObjectVelocity = {
    22. x: 30,
    23. y: 40,
    24. maxX: 250,
    25. maxY: 200
    26. };
    27. const gameArea: HTMLElement = document.getElementById('game');
    28. const fps: HTMLElement = document.getElementById('fps');
    29. /**
    30. * 这是游戏循环的核心逻辑。每一帧都更新对象和游戏状态。
    31. * 传入的 `deltaTime` 以秒为单位,我们还给定了当前状态和任意的输入状态。
    32. * 返回值为更新后的游戏状态。
    33. */
    34. const update = (deltaTime: number, state: any, inputState: any): any => {
    35. //console.log("Input State: ", inputState);
    36. if(state['objects'] === undefined) {
    37. state['objects'] = [
    38. {
    39. // 变形属性
    40. x: 10, y: 10, width: 20, height: 30,
    41. // 状态属性
    42. isPaused: false, toggleColor: '#FF0000', color: '#000000',
    43. // 移动属性
    44. velocity: baseObjectVelocity
    45. },
    46. {
    47. // 变形属性
    48. x: 200, y: 249, width: 50, height: 20,
    49. // 状态属性
    50. isPaused: false, toggleColor: '#00FF00', color: '#0000FF',
    51. // 移动属性
    52. velocity: {x: -baseObjectVelocity.x, y: 2*baseObjectVelocity.y} }
    53. ];
    54. } else {
    55. state['objects'].forEach((obj) => {
    56. // 处理输入
    57. if (inputState['spacebar']) {
    58. obj.isPaused = !obj.isPaused;
    59. let newColor = obj.toggleColor;
    60. obj.toggleColor = obj.color;
    61. obj.color = newColor;
    62. }
    63. // 处理游戏循环的更新
    64. if(!obj.isPaused) {
    65. // 应用速率运动
    66. obj.x = obj.x += obj.velocity.x*deltaTime;
    67. obj.y = obj.y += obj.velocity.y*deltaTime;
    68. // 边界检查
    69. const didHit = runBoundaryCheck(obj, boundaries);
    70. // 处理边界调整
    71. if(didHit){
    72. if(didHit === 'right' || didHit === 'left') {
    73. obj.velocity.x *= -bounceRateChanges[didHit];
    74. } else {
    75. obj.velocity.y *= -bounceRateChanges[didHit];
    76. }
    77. }
    78. }
    79. // 如果我们的边界反弹使得我们的速度变得太快,就钳制速度。
    80. obj.velocity.x = clampMag(obj.velocity.x, 0, baseObjectVelocity.maxX);
    81. obj.velocity.y = clampMag(obj.velocity.y, 0, baseObjectVelocity.maxY);
    82. });
    83. }
    84. return state;
    85. }
    86. /**
    87. * 这是渲染函数。我们接收给定的游戏状态并根据它们的最新属性来渲染页面。
    88. */
    89. const render = (state: any) => {
    90. const ctx: CanvasRenderingContext2D = (/*<HTMLCanvasElement>*/gameArea).getContext('2d');
    91. // 清除 canvas
    92. ctx.clearRect(0, 0, gameArea.clientWidth, gameArea.clientHeight);
    93. // 渲染所有对象 (都是简单的矩形)
    94. state['objects'].forEach((obj) => {
    95. ctx.fillStyle = obj.color;
    96. ctx.fillRect(obj.x, obj.y, obj.width, obj.height);
    97. });
    98. };
    99. /**
    100. * 这个函数返回一个 observable,一旦浏览器返回一个动画帧步骤,该 observable 将发出下一个帧。
    101. * 鉴于前一帧计算得出的增量时间,我们将其钳制至30FPS,以防长帧的出现。
    102. */
    103. const calculateStep: (prevFrame: IFrameData) => Observable<IFrameData> = (prevFrame: IFrameData) => {
    104. return Observable.create((observer) => {
    105. requestAnimationFrame((frameStartTime) => {
    106. // 毫秒转化成秒
    107. const deltaTime = prevFrame ? (frameStartTime - prevFrame.frameStartTime)/1000 : 0;
    108. observer.next({
    109. frameStartTime,
    110. deltaTime
    111. });
    112. })
    113. })
    114. .pipe(
    115. map(clampTo30FPS)
    116. )
    117. };
    118. /**
    119. * 这是帧的核心流。我们使用 `expand` 操作符来递归调用上面的 `calculateStep` 函数,
    120. * 它会基于 `window.requestAnimationFrame` 的调用返回每一个新帧。
    121. * `expand` 发出被调用函数返回的 observable 的值,并递归调用具有相同发出值的函数。
    122. * 这非常适合计算我们的帧步骤,因为每个步骤都需要知道上一帧的时间来计算下一帧。
    123. * 一旦当前请求的帧已经返回,我们还想要求一个新的帧。
    124. */
    125. const frames$ = of(undefined)
    126. .pipe(
    127. expand((val) => calculateStep(val)),
    128. // expand 发出提供给它的第一个值,
    129. // 在这里我们只想忽略值为 undefined 的输入帧
    130. filter(frame => frame !== undefined),
    131. map((frame: IFrameData) => frame.deltaTime),
    132. share()
    133. )
    134. // 这是 keyDown 输入事件的核心流。
    135. // 每次按键后它会发出类似 `{"spacebar": 32}` 的对象
    136. const keysDown$ = fromEvent(document, 'keydown')
    137. .pipe(
    138. map((event: KeyboardEvent) => {
    139. const name = KeyUtil.codeToKey(''+event.keyCode);
    140. if (name !== ''){
    141. let keyMap = {};
    142. keyMap[name] = event.code;
    143. return keyMap;
    144. } else {
    145. return undefined;
    146. }
    147. }),
    148. filter((keyMap) => keyMap !== undefined)
    149. );
    150. // 这里我们将 keyDown 流缓冲起来,直到发出新的帧。
    151. // 我们将得到自从上一帧发出后的所有 keyDown 事件。
    152. // 我们将其归并为单个对象。
    153. const keysDownPerFrame$ = keysDown$
    154. .pipe(
    155. buffer(frames$),
    156. map((frames: Array<any>) => {
    157. return frames.reduce((acc, curr) => {
    158. return Object.assign(acc, curr);
    159. }, {});
    160. })
    161. );
    162. // 因为每一帧我们都会更新游戏状态,所以可以使用 Observable 作为一系列状态
    163. // 进行追踪,最新的发出值即为当前游戏状态。
    164. const gameState$ = new BehaviorSubject({});
    165. // 这是运行游戏的代码!
    166. // 我们订阅 `frames$` 流以开始,并确保组合了输入流的最新发出,以获取游戏状态更新所
    167. // 必须的数据。
    168. frames$
    169. .pipe(
    170. withLatestFrom(keysDownPerFrame$, gameState$),
    171. // 课后作业: 处理 keyUp 并映射成真正的按键状态变化对象
    172. map(([deltaTime, keysDown, gameState]) => update(deltaTime, gameState, keysDown)),
    173. tap((gameState) => gameState$.next(gameState))
    174. )
    175. .subscribe((gameState) => {
    176. render(gameState);
    177. });
    178. // 平均每10帧计算一下FPS
    179. frames$
    180. .pipe(
    181. bufferCount(10),
    182. map((frames) => {
    183. const total = frames
    184. .reduce((acc, curr) => {
    185. acc += curr;
    186. return acc;
    187. }, 0);
    188. return 1/(total/frames.length);
    189. })
    190. ).subscribe((avg) => {
    191. fps.innerHTML = Math.round(avg) + '';
    192. })
    辅助 js 文件
    • game.util.ts
    • keys.util.ts
    • frame.interface.ts
    html
    1. <canvas width="400px" height="300px" id="game"></canvas>
    2. <div id="fps"></div>
    3. <p class="instructions">
    4. 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.
    5. </p>

    使用到的操作符

    • buffer
    • bufferCount
    • expand
    • filter
    • fromEvent
    • map
    • share
    • tap
    • withLatestFrom