游戏循环

作者 @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 文件
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>

使用到的操作符