Game Loop

By @barryrowe

This recipe demonstrates one way you might create a Game Loop as a combined set of streams. The recipe is intended to highlight how you might re-think existing problems with a reactive approach. In this recipe we provide the overall loop as a stream of frames and their deltaTimes since the previous frames. Combined with this is a stream of user inputs, and the current gameState, which we can use to update our objects, and render to to the screen on each frame emission.

Game Loop - 图1

Example Code

(
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. * This is our core game loop logic. We update our objects and gameState here
  31. * each frame. The deltaTime passed in is in seconds, we are givent our current state,
  32. * and any inputStates. Returns the updated Game State
  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. // Transformation Props
  40. x: 10, y: 10, width: 20, height: 30,
  41. // State Props
  42. isPaused: false, toggleColor: '#FF0000', color: '#000000',
  43. // Movement Props
  44. velocity: baseObjectVelocity
  45. },
  46. {
  47. // Transformation Props
  48. x: 200, y: 249, width: 50, height: 20,
  49. // State Props
  50. isPaused: false, toggleColor: '#00FF00', color: '#0000FF',
  51. // Movement Props
  52. velocity: {x: -baseObjectVelocity.x, y: 2*baseObjectVelocity.y} }
  53. ];
  54. } else {
  55. state['objects'].forEach((obj) => {
  56. // Process Inputs
  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. // Process GameLoop Updates
  64. if(!obj.isPaused) {
  65. // Apply Velocity Movements
  66. obj.x = obj.x += obj.velocity.x*deltaTime;
  67. obj.y = obj.y += obj.velocity.y*deltaTime;
  68. // Check if we exceeded our boundaries
  69. const didHit = runBoundaryCheck(obj, boundaries);
  70. // Handle boundary adjustments
  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. // Clamp Velocities in case our boundary bounces have gotten
  80. // us going tooooo fast.
  81. obj.velocity.x = clampMag(obj.velocity.x, 0, baseObjectVelocity.maxX);
  82. obj.velocity.y = clampMag(obj.velocity.y, 0, baseObjectVelocity.maxY);
  83. });
  84. }
  85. return state;
  86. }
  87. /**
  88. * This is our rendering function. We take the given game state and render the items
  89. * based on their latest properties.
  90. */
  91. const render = (state: any) => {
  92. const ctx: CanvasRenderingContext2D = (<HTMLCanvasElement>gameArea).getContext('2d');
  93. // Clear the canvas
  94. ctx.clearRect(0, 0, gameArea.clientWidth, gameArea.clientHeight);
  95. // Render all of our objects (simple rectangles for simplicity)
  96. state['objects'].forEach((obj) => {
  97. ctx.fillStyle = obj.color;
  98. ctx.fillRect(obj.x, obj.y, obj.width, obj.height);
  99. });
  100. };
  101. /**
  102. * This function returns an observable that will emit the next frame once the
  103. * browser has returned an animation frame step. Given the previous frame it calculates
  104. * the delta time, and we also clamp it to 30FPS in case we get long frames.
  105. */
  106. const calculateStep: (prevFrame: IFrameData) => Observable<IFrameData> = (prevFrame: IFrameData) => {
  107. return Observable.create((observer) => {
  108. requestAnimationFrame((frameStartTime) => {
  109. // Millis to seconds
  110. const deltaTime = prevFrame ? (frameStartTime - prevFrame.frameStartTime)/1000 : 0;
  111. observer.next({
  112. frameStartTime,
  113. deltaTime
  114. });
  115. })
  116. })
  117. .pipe(
  118. map(clampTo30FPS)
  119. )
  120. };
  121. // This is our core stream of frames. We use expand to recursively call the
  122. // `calculateStep` function above that will give us each new Frame based on the
  123. // window.requestAnimationFrame calls. Expand emits the value of the called functions
  124. // returned observable, as well as recursively calling the function with that same
  125. // emitted value. This works perfectly for calculating our frame steps because each step
  126. // needs to know the lastStepFrameTime to calculate the next. We also only want to request
  127. // a new frame once the currently requested frame has returned.
  128. const frames$ = of(undefined)
  129. .pipe(
  130. expand((val) => calculateStep(val)),
  131. // Expand emits the first value provided to it, and in this
  132. // case we just want to ignore the undefined input frame
  133. filter(frame => frame !== undefined),
  134. map((frame: IFrameData) => frame.deltaTime),
  135. share()
  136. )
  137. // This is our core stream of keyDown input events. It emits an object like `{"spacebar": 32}`
  138. // each time a key is pressed down.
  139. const keysDown$ = fromEvent(document, 'keydown')
  140. .pipe(
  141. map((event: KeyboardEvent) => {
  142. const name = KeyUtil.codeToKey(''+event.keyCode);
  143. if (name !== ''){
  144. let keyMap = {};
  145. keyMap[name] = event.code;
  146. return keyMap;
  147. } else {
  148. return undefined;
  149. }
  150. }),
  151. filter((keyMap) => keyMap !== undefined)
  152. );
  153. // Here we buffer our keyDown stream until we get a new frame emission. This
  154. // gives us a set of all the keyDown events that have triggered since the previous
  155. // frame. We reduce these all down to a single dictionary of keys that were pressed.
  156. const keysDownPerFrame$ = keysDown$
  157. .pipe(
  158. buffer(frames$),
  159. map((frames: Array<any>) => {
  160. return frames.reduce((acc, curr) => {
  161. return Object.assign(acc, curr);
  162. }, {});
  163. })
  164. );
  165. // Since we will be updating our gamestate each frame we can use an Observable
  166. // to track that as a series of states with the latest emission being the current
  167. // state of our game.
  168. const gameState$ = new BehaviorSubject({});
  169. // This is where we run our game!
  170. // We subscribe to our frames$ stream to kick it off, and make sure to
  171. // combine in the latest emission from our inputs stream to get the data
  172. // we need do perform our gameState updates.
  173. frames$
  174. .pipe(
  175. withLatestFrom(keysDownPerFrame$, gameState$),
  176. // HOMEWORK_OPPORTUNITY: Handle Key-up, and map to a true KeyState change object
  177. map(([deltaTime, keysDown, gameState]) => update(deltaTime, gameState, keysDown)),
  178. tap((gameState) => gameState$.next(gameState))
  179. )
  180. .subscribe((gameState) => {
  181. render(gameState);
  182. });
  183. // Average every 10 Frames to calculate our FPS
  184. frames$
  185. .pipe(
  186. bufferCount(10),
  187. map((frames) => {
  188. const total = frames
  189. .reduce((acc, curr) => {
  190. acc += curr;
  191. return acc;
  192. }, 0);
  193. return 1/(total/frames.length);
  194. })
  195. ).subscribe((avg) => {
  196. fps.innerHTML = Math.round(avg) + '';
  197. })
supporting 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>

Operators Used