src/game/DinoGame.js 11.1 K raw
1
import Bird from '../actors/Bird.js'
2
import Cactus from '../actors/Cactus.js'
3
import Cloud from '../actors/Cloud.js'
4
import Dino from '../actors/Dino.js'
5
import sprites from '../sprites.js'
6
import { playSound } from '../sounds.js'
7
import {
8
  loadFont,
9
  loadImage,
10
  getImageData,
11
  invertImage,
12
  randBoolean,
13
  randInteger,
14
} from '../utils.js'
15
import GameRunner from './GameRunner.js'
16
import { GAME_CONFIG } from '../config.js'
17
18
export default class DinoGame extends GameRunner {
19
  constructor(width, height, container) {
20
    super()
21
22
    this.width = null
23
    this.height = null
24
    this.container = container || document.body
25
    this.canvas = this.createCanvas(width, height)
26
    this.canvasCtx = this.canvas.getContext('2d')
27
    this.spriteImage = null
28
    this.spriteImageData = null
29
30
    /*
31
     * units
32
     * fpa: frames per action
33
     * ppf: pixels per frame
34
     * px: pixels
35
     */
36
    this.defaultSettings = {
37
      bgSpeed: 8, // ppf
38
      birdSpeed: 7.2, // ppf
39
      birdSpawnRate: 240, // fpa
40
      birdWingsRate: 15, // fpa
41
      cactiSpawnRate: 50, // fpa
42
      cloudSpawnRate: 200, // fpa
43
      cloudSpeed: 2, // ppf
44
      dinoGravity: 0.5, // ppf
45
      dinoGroundOffset: 4, // px
46
      dinoLegsRate: 6, // fpa
47
      dinoLift: 10, // ppf
48
      scoreBlinkRate: 20, // fpa
49
      scoreIncreaseRate: GAME_CONFIG.SCORE_INCREASE_RATE, // fpa
50
    }
51
52
    this.state = {
53
      settings: { ...this.defaultSettings },
54
      birds: [],
55
      cacti: [],
56
      clouds: [],
57
      dino: null,
58
      gameOver: false,
59
      groundX: 0,
60
      groundY: 0,
61
      isRunning: false,
62
      level: 0,
63
      score: {
64
        blinkFrames: 0,
65
        blinks: 0,
66
        isBlinking: false,
67
        value: 0,
68
      },
69
    }
70
71
    this.gameToken = null
72
    this.onGameOver = null
73
    this.onGameStart = null
74
  }
75
76
  createCanvas(width, height) {
77
    const canvas = document.createElement('canvas')
78
    const ctx = canvas.getContext('2d')
79
    const scale = window.devicePixelRatio
80
81
    this.width = width
82
    this.height = height
83
    canvas.width = Math.floor(width * scale)
84
    canvas.height = Math.floor(height * scale)
85
    ctx.scale(scale, scale)
86
87
    this.container.appendChild(canvas)
88
    return canvas
89
  }
90
91
  async preload() {
92
    const { settings } = this.state
93
    const [originalImage] = await Promise.all([
94
      loadImage('/assets/sprite.png'),
95
      loadFont('/assets/PressStart2P-Regular.ttf', 'PressStart2P'),
96
    ])
97
    this.spriteImage = await invertImage(originalImage)
98
    this.spriteImageData = getImageData(originalImage)
99
    const dino = new Dino(this.spriteImageData)
100
101
    dino.legsRate = settings.dinoLegsRate
102
    dino.lift = settings.dinoLift
103
    dino.gravity = settings.dinoGravity
104
    dino.x = 25
105
    dino.baseY = this.height - settings.dinoGroundOffset
106
    this.state.dino = dino
107
    this.state.groundY = this.height - sprites.ground.h / 2
108
  }
109
110
  onFrame() {
111
    const { state } = this
112
113
    this.drawBackground()
114
    this.drawGround()
115
    this.drawClouds()
116
    this.drawDino()
117
    this.drawScore()
118
119
    if (state.isRunning) {
120
      this.drawCacti()
121
122
      if (state.level > 3) {
123
        this.drawBirds()
124
      }
125
126
      if (state.dino.hits([state.cacti[0], state.birds[0]])) {
127
        playSound('game-over')
128
        state.gameOver = true
129
      }
130
131
      if (state.gameOver) {
132
        this.endGame()
133
      } else {
134
        this.updateScore()
135
      }
136
    }
137
  }
138
139
  onInput(type) {
140
    const { state } = this
141
142
    switch (type) {
143
      case 'jump': {
144
        if (state.isRunning) {
145
          if (state.dino.jump()) {
146
            playSound('jump')
147
          }
148
        } else {
149
          this.resetGame()
150
          state.dino.jump()
151
          playSound('jump')
152
        }
153
        break
154
      }
155
156
      case 'duck': {
157
        if (state.isRunning) {
158
          state.dino.duck(true)
159
        }
160
        break
161
      }
162
163
      case 'stop-duck': {
164
        if (state.isRunning) {
165
          state.dino.duck(false)
166
        }
167
        break
168
      }
169
    }
170
  }
171
172
  resetGame() {
173
    this.frameCount = 0
174
    this.gameToken = fetch('/api/challenge')
175
      .then(r => r.json())
176
      .then(d => d.token)
177
      .catch(() => null)
178
179
    this.state.dino.reset()
180
    Object.assign(this.state, {
181
      settings: { ...this.defaultSettings },
182
      birds: [],
183
      cacti: [],
184
      gameOver: false,
185
      isRunning: true,
186
      level: 0,
187
      score: {
188
        blinkFrames: 0,
189
        blinks: 0,
190
        isBlinking: false,
191
        value: 0,
192
      },
193
    })
194
195
    if (typeof this.onGameStart === 'function') {
196
      this.onGameStart()
197
    }
198
199
    this.start()
200
  }
201
202
  endGame() {
203
    if (typeof this.onGameOver === 'function') {
204
      this.onGameOver(this.state.score.value, this.gameToken)
205
    }
206
207
    const iconSprite = sprites.replayIcon
208
    const padding = 15
209
210
    this.paintText(
211
      'G A M E  O V E R',
212
      this.width / 2,
213
      this.height / 2 - padding,
214
      {
215
        font: 'PressStart2P',
216
        size: '12px',
217
        align: 'center',
218
        baseline: 'bottom',
219
        color: '#E8E8E8',
220
      }
221
    )
222
223
    this.paintSprite(
224
      'replayIcon',
225
      this.width / 2 - iconSprite.w / 4,
226
      this.height / 2 - iconSprite.h / 4 + padding
227
    )
228
229
    this.state.isRunning = false
230
    this.drawScore()
231
    this.stop()
232
  }
233
234
  increaseDifficulty() {
235
    const { birds, cacti, clouds, dino, settings } = this.state
236
    const { bgSpeed, cactiSpawnRate, dinoLegsRate } = settings
237
    const { level } = this.state
238
239
    if (level > 4 && level < 8) {
240
      settings.bgSpeed++
241
      settings.birdSpeed = settings.bgSpeed * 0.8
242
    } else if (level > 7) {
243
      settings.bgSpeed = Math.ceil(bgSpeed * 1.1)
244
      settings.birdSpeed = settings.bgSpeed * 0.9
245
      settings.cactiSpawnRate = Math.floor(cactiSpawnRate * 0.98)
246
247
      if (level > 7 && level % 2 === 0 && dinoLegsRate > 3) {
248
        settings.dinoLegsRate--
249
      }
250
    }
251
252
    for (const bird of birds) {
253
      bird.speed = settings.birdSpeed
254
    }
255
256
    for (const cactus of cacti) {
257
      cactus.speed = settings.bgSpeed
258
    }
259
260
    for (const cloud of clouds) {
261
      cloud.speed = settings.bgSpeed
262
    }
263
264
    dino.legsRate = settings.dinoLegsRate
265
  }
266
267
  updateScore() {
268
    const { state } = this
269
270
    if (this.frameCount % state.settings.scoreIncreaseRate === 0) {
271
      const oldLevel = state.level
272
273
      state.score.value++
274
      state.level = Math.floor(state.score.value / 100)
275
276
      if (state.level !== oldLevel) {
277
        playSound('level-up')
278
        this.increaseDifficulty()
279
        state.score.isBlinking = true
280
      }
281
    }
282
  }
283
284
  drawBackground() {
285
    this.canvasCtx.fillStyle = '#121113'
286
    this.canvasCtx.fillRect(0, 0, this.width, this.height)
287
  }
288
289
  drawGround() {
290
    const { state } = this
291
    const { bgSpeed } = state.settings
292
    const groundImgWidth = sprites.ground.w / 2
293
294
    this.paintSprite('ground', state.groundX, state.groundY)
295
    state.groundX -= bgSpeed
296
297
    // append second image until first is fully translated
298
    if (state.groundX <= -groundImgWidth + this.width) {
299
      this.paintSprite('ground', state.groundX + groundImgWidth, state.groundY)
300
301
      if (state.groundX <= -groundImgWidth) {
302
        state.groundX = -bgSpeed
303
      }
304
    }
305
  }
306
307
  drawClouds() {
308
    const { clouds, settings } = this.state
309
310
    this.progressInstances(clouds)
311
    if (this.frameCount % settings.cloudSpawnRate === 0) {
312
      const newCloud = new Cloud()
313
      newCloud.speed = settings.bgSpeed
314
      newCloud.x = this.width
315
      newCloud.y = randInteger(20, 80)
316
      clouds.push(newCloud)
317
    }
318
    this.paintInstances(clouds)
319
  }
320
321
  drawDino() {
322
    const { dino } = this.state
323
324
    dino.nextFrame()
325
    this.paintSprite(dino.sprite, dino.x, dino.y)
326
  }
327
328
  drawCacti() {
329
    const { state } = this
330
    const { cacti, settings } = state
331
332
    this.progressInstances(cacti)
333
    if (this.frameCount % settings.cactiSpawnRate === 0) {
334
      // randomly either do or don't add cactus
335
      if (!state.birds.length && randBoolean()) {
336
        const newCacti = new Cactus(this.spriteImageData)
337
        newCacti.speed = settings.bgSpeed
338
        newCacti.x = this.width
339
        newCacti.y = this.height - newCacti.height - 2
340
        cacti.push(newCacti)
341
      }
342
    }
343
    this.paintInstances(cacti)
344
  }
345
346
  drawBirds() {
347
    const { birds, settings } = this.state
348
349
    this.progressInstances(birds)
350
    if (this.frameCount % settings.birdSpawnRate === 0) {
351
      // randomly either do or don't add bird
352
      if (randBoolean()) {
353
        const newBird = new Bird(this.spriteImageData)
354
        newBird.speed = settings.birdSpeed
355
        newBird.wingsRate = settings.birdWingsRate
356
        newBird.x = this.width
357
        // lowest y: just above a ducking dino
358
        const minY =
359
          this.height -
360
          Bird.maxBirdHeight -
361
          Bird.wingSpriteYShift -
362
          5 -
363
          sprites.dinoDuckLeftLeg.h / 2 -
364
          settings.dinoGroundOffset
365
        // highest y: near top of canvas
366
        const maxY = 20
367
        newBird.y = randInteger(maxY, minY)
368
        birds.push(newBird)
369
      }
370
    }
371
    this.paintBirdInstances(birds)
372
  }
373
374
  drawScore() {
375
    const { canvasCtx, state } = this
376
    const { isRunning, score, settings } = state
377
    const fontSize = 12
378
    let shouldDraw = true
379
    let drawValue = score.value
380
381
    if (isRunning && score.isBlinking) {
382
      score.blinkFrames++
383
384
      if (score.blinkFrames % settings.scoreBlinkRate === 0) {
385
        score.blinks++
386
      }
387
388
      if (score.blinks > 7) {
389
        score.blinkFrames = 0
390
        score.blinks = 0
391
        score.isBlinking = false
392
      } else {
393
        if (score.blinks % 2 === 0) {
394
          drawValue = Math.floor(drawValue / 100) * 100
395
        } else {
396
          shouldDraw = false
397
        }
398
      }
399
    }
400
401
    if (shouldDraw) {
402
      canvasCtx.fillStyle = '#121113'
403
      canvasCtx.fillRect(this.width - fontSize * 5, 0, fontSize * 5, fontSize)
404
405
      this.paintText((drawValue + '').padStart(5, '0'), this.width, 0, {
406
        font: 'PressStart2P',
407
        size: `${fontSize}px`,
408
        align: 'right',
409
        baseline: 'top',
410
        color: '#E8E8E8',
411
      })
412
    }
413
  }
414
415
  progressInstances(instances) {
416
    for (let i = instances.length - 1; i >= 0; i--) {
417
      const instance = instances[i]
418
419
      instance.nextFrame()
420
      if (instance.rightX <= 0) {
421
        instances.splice(i, 1)
422
      }
423
    }
424
  }
425
426
  paintInstances(instances) {
427
    for (const instance of instances) {
428
      this.paintSprite(instance.sprite, instance.x, instance.y)
429
    }
430
  }
431
432
  paintBirdInstances(instances) {
433
    const { canvasCtx } = this
434
    for (const instance of instances) {
435
      const { h, w, x, y } = sprites[instance.sprite]
436
      const drawW = w / 2
437
      const drawH = h / 2
438
      canvasCtx.save()
439
      canvasCtx.scale(-1, 1)
440
      canvasCtx.drawImage(this.spriteImage, x, y, w, h, -(instance.x + drawW), instance.y, drawW, drawH)
441
      canvasCtx.restore()
442
    }
443
  }
444
445
  paintSprite(spriteName, dx, dy) {
446
    const { h, w, x, y } = sprites[spriteName]
447
    this.canvasCtx.drawImage(this.spriteImage, x, y, w, h, dx, dy, w / 2, h / 2)
448
  }
449
450
  paintText(text, x, y, opts) {
451
    const { font = 'serif', size = '12px' } = opts
452
    const { canvasCtx } = this
453
454
    canvasCtx.font = `${size} ${font}`
455
    if (opts.align) canvasCtx.textAlign = opts.align
456
    if (opts.baseline) canvasCtx.textBaseline = opts.baseline
457
    if (opts.color) canvasCtx.fillStyle = opts.color
458
    canvasCtx.fillText(text, x, y)
459
  }
460
}