| 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 | } |