feat: init 2cca90f6
Steve · 2026-03-26 19:31 35 file(s) · +1210 −0
.gitignore (added) +34 −0
1 +
# dependencies (bun install)
2 +
node_modules
3 +
4 +
# output
5 +
out
6 +
dist
7 +
*.tgz
8 +
9 +
# code coverage
10 +
coverage
11 +
*.lcov
12 +
13 +
# logs
14 +
logs
15 +
_.log
16 +
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
17 +
18 +
# dotenv environment variable files
19 +
.env
20 +
.env.development.local
21 +
.env.test.local
22 +
.env.production.local
23 +
.env.local
24 +
25 +
# caches
26 +
.eslintcache
27 +
.cache
28 +
*.tsbuildinfo
29 +
30 +
# IntelliJ based IDEs
31 +
.idea
32 +
33 +
# Finder (MacOS) folder config
34 +
.DS_Store
LICENSE (added) +22 −0
1 +
MIT License
2 +
3 +
Copyright (c) 2026 Steve Simkins
4 +
5 +
Permission is hereby granted, free of charge, to any person obtaining a copy
6 +
of this software and associated documentation files (the "Software"), to deal
7 +
in the Software without restriction, including without limitation the rights
8 +
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 +
copies of the Software, and to permit persons to whom the Software is
10 +
furnished to do so, subject to the following conditions:
11 +
12 +
The above copyright notice and this permission notice shall be included in all
13 +
copies or substantial portions of the Software.
14 +
15 +
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 +
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 +
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 +
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 +
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 +
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 +
SOFTWARE.
22 +
README.md (added) +16 −0
1 +
# dino
2 +
3 +
A port of [chrisdothtml/chrome-dino](https://github.com/chrisdothtml/chrome-dino)
4 +
5 +
To install dependencies:
6 +
7 +
```bash
8 +
bun install
9 +
```
10 +
11 +
To run:
12 +
13 +
```bash
14 +
bun dev
15 +
```
16 +
bun.lock (added) +26 −0
1 +
{
2 +
  "lockfileVersion": 1,
3 +
  "configVersion": 1,
4 +
  "workspaces": {
5 +
    "": {
6 +
      "name": "dino",
7 +
      "devDependencies": {
8 +
        "@types/bun": "latest",
9 +
      },
10 +
      "peerDependencies": {
11 +
        "typescript": "^5",
12 +
      },
13 +
    },
14 +
  },
15 +
  "packages": {
16 +
    "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
17 +
18 +
    "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
19 +
20 +
    "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
21 +
22 +
    "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
23 +
24 +
    "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
25 +
  }
26 +
}
index.html (added) +29 −0
1 +
<!DOCTYPE html>
2 +
<html lang="en">
3 +
  <head>
4 +
    <meta charset="UTF-8" />
5 +
    <meta
6 +
      name="viewport"
7 +
      content="width=device-width, initial-scale=1.0, user-scalable=no"
8 +
    />
9 +
    <link rel="apple-touch-icon" sizes="180x180" href="https://dino.stevedylan.dev/assets/apple-touch-icon.png">
10 +
    <link rel="icon" type="image/png" sizes="32x32" href="https://dino.stevedylan.dev/assets/favicon-32x32.png">
11 +
    <link rel="icon" type="image/png" sizes="16x16" href="https://dino.stevedylan.dev/assets/favicon-16x16.png">
12 +
    <link rel="manifest" href="https://dino.stevedylan.dev/assets/site.webmanifest">
13 +
    <link rel="shortcut icon" type="image/png" href="https://dino.stevedylan.dev/assets/favicon.png" />
14 +
    <title>Dino</title>
15 +
    <meta property="og:title" content="Dino" />
16 +
    <meta property="og:description" content="Just the dino" />
17 +
    <meta property="og:image" content="https://dino.stevedylan.dev/assets/og.png" />
18 +
    <meta property="og:type" content="website" />
19 +
    <meta name="twitter:card" content="summary_large_image" />
20 +
    <meta name="twitter:title" content="Dino" />
21 +
    <meta name="twitter:description" content="Just the dino" />
22 +
    <meta name="twitter:image" content="https://dino.stevedylan.dev/assets/og.png" />
23 +
    <link rel="stylesheet" href="./src/style.css" />
24 +
  </head>
25 +
  <body>
26 +
    <div id="game-container"></div>
27 +
    <script type="module" src="./src/index.js"></script>
28 +
  </body>
29 +
</html>
index.ts (added) +18 −0
1 +
import index from "./index.html";
2 +
3 +
Bun.serve({
4 +
  routes: {
5 +
    "/": index,
6 +
    "/assets/*": async (req) => {
7 +
      const url = new URL(req.url);
8 +
      const file = Bun.file(`./public${url.pathname}`);
9 +
      return new Response(file);
10 +
    },
11 +
  },
12 +
  development: {
13 +
    hmr: true,
14 +
    console: true,
15 +
  },
16 +
});
17 +
18 +
console.log("Dino server running at http://localhost:3000");
package.json (added) +16 −0
1 +
{
2 +
  "name": "dino",
3 +
  "module": "index.ts",
4 +
  "type": "module",
5 +
  "private": true,
6 +
  "scripts": {
7 +
    "dev": "bun --hot index.ts",
8 +
    "build": "bun build ./index.html --outdir ./dist && cp -r ./public/assets ./dist/assets"
9 +
  },
10 +
  "devDependencies": {
11 +
    "@types/bun": "latest"
12 +
  },
13 +
  "peerDependencies": {
14 +
    "typescript": "^5"
15 +
  }
16 +
}
public/assets/PressStart2P-Regular.ttf (added) +0 −0

Binary file — no preview.

public/assets/android-chrome-192x192.png (added) +0 −0

Binary file — no preview.

public/assets/android-chrome-512x512.png (added) +0 −0

Binary file — no preview.

public/assets/apple-touch-icon.png (added) +0 −0

Binary file — no preview.

public/assets/favicon-16x16.png (added) +0 −0

Binary file — no preview.

public/assets/favicon-32x32.png (added) +0 −0

Binary file — no preview.

public/assets/favicon.ico (added) +0 −0

Binary file — no preview.

public/assets/favicon.png (added) +0 −0

Binary file — no preview.

public/assets/game-over.mp3 (added) +0 −0

Binary file — no preview.

public/assets/icon.png (added) +0 −0

Binary file — no preview.

public/assets/jump.mp3 (added) +0 −0

Binary file — no preview.

public/assets/level-up.mp3 (added) +0 −0

Binary file — no preview.

public/assets/og.png (added) +0 −0

Binary file — no preview.

public/assets/site.webmanifest (added) +1 −0
1 +
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
public/assets/sprite.png (added) +0 −0

Binary file — no preview.

src/actors/Actor.js (added) +120 −0
1 +
import sprites from '../sprites.js'
2 +
3 +
const cache = new Map()
4 +
5 +
// analyze the pixels and create a map of the transparent
6 +
// and non-transparent pixels for hit testing
7 +
function getSpriteAlphaMap(imageData, name) {
8 +
  if (cache.has(name)) {
9 +
    return cache.get(name)
10 +
  }
11 +
12 +
  const sprite = sprites[name]
13 +
  const lines = []
14 +
  const initIVal = imageData.width * sprite.y * 4
15 +
16 +
  // for each line of pixels
17 +
  for (
18 +
    let i = initIVal;
19 +
    i < initIVal + sprite.h * imageData.width * 4;
20 +
    // (increments by 8 because it skips every other pixel due to pixel density)
21 +
    i += imageData.width * 8
22 +
  ) {
23 +
    const line = []
24 +
    const initJVal = i + sprite.x * 4
25 +
    // for each pixel in the line
26 +
    // (increments by 8 because it skips every other pixel due to pixel density)
27 +
    for (let j = initJVal; j < initJVal + sprite.w * 4; j += 8) {
28 +
      // 0 for transparent, 1 for not
29 +
      line.push(imageData.data[j + 3] === 0 ? 0 : 1)
30 +
    }
31 +
32 +
    lines.push(line)
33 +
  }
34 +
35 +
  cache.set(name, lines)
36 +
  return lines
37 +
}
38 +
39 +
export default class Actor {
40 +
  constructor(imageData) {
41 +
    this._sprite = null
42 +
    this.height = 0
43 +
    this.width = 0
44 +
    this.x = 0
45 +
    this.y = 0
46 +
47 +
    // the spriteImage should only be passed into actors that will
48 +
    // use hit detection; otherwise don't waste cpu on generating
49 +
    // the alpha map every time the sprite is set
50 +
    if (imageData) {
51 +
      this.imageData = imageData
52 +
      this.alphaMap = []
53 +
    }
54 +
  }
55 +
56 +
  set sprite(name) {
57 +
    this._sprite = name
58 +
    this.height = sprites[name].h / 2
59 +
    this.width = sprites[name].w / 2
60 +
61 +
    if (this.imageData) {
62 +
      this.alphaMap = getSpriteAlphaMap(this.imageData, name)
63 +
    }
64 +
  }
65 +
66 +
  get sprite() {
67 +
    return this._sprite
68 +
  }
69 +
70 +
  // the x value of the right side of it
71 +
  get rightX() {
72 +
    return this.width + this.x
73 +
  }
74 +
75 +
  // the y value of the bottom of it
76 +
  get bottomY() {
77 +
    return this.height + this.y
78 +
  }
79 +
80 +
  hits(actors) {
81 +
    return actors.some((actor) => {
82 +
      if (!actor) return false
83 +
84 +
      if (this.x >= actor.rightX || actor.x >= this.rightX) {
85 +
        return false
86 +
      }
87 +
88 +
      if (this.y >= actor.bottomY || actor.y >= this.bottomY) {
89 +
        return false
90 +
      }
91 +
92 +
      // actors' coords are intersecting, but they still might not be hitting
93 +
      // each other if they intersect at transparent pixels
94 +
      if (this.alphaMap && actor.alphaMap) {
95 +
        const startY = Math.round(Math.max(this.y, actor.y))
96 +
        const endY = Math.round(Math.min(this.bottomY, actor.bottomY))
97 +
        const startX = Math.round(Math.max(this.x, actor.x))
98 +
        const endX = Math.round(Math.min(this.rightX, actor.rightX))
99 +
        const thisY = Math.round(this.y)
100 +
        const actorY = Math.round(actor.y)
101 +
        const thisX = Math.round(this.x)
102 +
        const actorX = Math.round(actor.x)
103 +
104 +
        for (let y = startY; y < endY; y++) {
105 +
          for (let x = startX; x < endX; x++) {
106 +
            // doesn't hit if either are transparent at these coords
107 +
            if (this.alphaMap[y - thisY][x - thisX] === 0) continue
108 +
            if (actor.alphaMap[y - actorY][x - actorX] === 0) continue
109 +
110 +
            return true
111 +
          }
112 +
        }
113 +
114 +
        return false
115 +
      }
116 +
117 +
      return true
118 +
    })
119 +
  }
120 +
}
src/actors/Bird.js (added) +49 −0
1 +
import sprites from '../sprites.js'
2 +
import Actor from './Actor.js'
3 +
4 +
export default class Bird extends Actor {
5 +
  static maxBirdHeight = Math.max(sprites.birdUp.h, sprites.birdDown.h) / 2
6 +
7 +
  // pixels that are added/removed to `y` when switching between wings up and wings down
8 +
  static wingSpriteYShift = 6
9 +
10 +
  constructor(imageData) {
11 +
    super(imageData)
12 +
    this.wingFrames = 0
13 +
    this.wingDirection = 'Up'
14 +
    this.sprite = `bird${this.wingDirection}`
15 +
    // these are dynamically set by the game
16 +
    this.x = null
17 +
    this.y = null
18 +
    this.speed = null
19 +
    this.wingsRate = null
20 +
  }
21 +
22 +
  nextFrame() {
23 +
    this.x -= this.speed
24 +
    this.determineSprite()
25 +
  }
26 +
27 +
  determineSprite() {
28 +
    const oldHeight = this.height
29 +
30 +
    if (this.wingFrames >= this.wingsRate) {
31 +
      this.wingDirection = this.wingDirection === 'Up' ? 'Down' : 'Up'
32 +
      this.wingFrames = 0
33 +
    }
34 +
35 +
    this.sprite = `bird${this.wingDirection}`
36 +
    this.wingFrames++
37 +
38 +
    // if we're switching sprites, y needs to be
39 +
    // updated for the height difference
40 +
    if (this.height !== oldHeight) {
41 +
      let adjustment = Bird.wingSpriteYShift
42 +
      if (this.wingDirection === 'Up') {
43 +
        adjustment *= -1
44 +
      }
45 +
46 +
      this.y += adjustment
47 +
    }
48 +
  }
49 +
}
src/actors/Cactus.js (added) +19 −0
1 +
import { randItem } from '../utils.js'
2 +
import Actor from './Actor.js'
3 +
4 +
const VARIANTS = ['cactus', 'cactusDouble', 'cactusDoubleB', 'cactusTriple']
5 +
6 +
export default class Cactus extends Actor {
7 +
  constructor(imageData) {
8 +
    super(imageData)
9 +
    this.sprite = randItem(VARIANTS)
10 +
    // these are dynamically set by the game
11 +
    this.speed = null
12 +
    this.x = null
13 +
    this.y = null
14 +
  }
15 +
16 +
  nextFrame() {
17 +
    this.x -= this.speed
18 +
  }
19 +
}
src/actors/Cloud.js (added) +18 −0
1 +
import { randInteger } from '../utils.js'
2 +
import Actor from './Actor.js'
3 +
4 +
export default class Cloud extends Actor {
5 +
  constructor(canvasWidth) {
6 +
    super()
7 +
    this.sprite = 'cloud'
8 +
    this.speedMod = randInteger(6, 14) / 10
9 +
    // these are dynamically set by the game
10 +
    this.speed = null
11 +
    this.x = null
12 +
    this.y = null
13 +
  }
14 +
15 +
  nextFrame() {
16 +
    this.x -= this.speed * this.speedMod
17 +
  }
18 +
}
src/actors/Dino.js (added) +84 −0
1 +
import Actor from './Actor.js'
2 +
3 +
export default class Dino extends Actor {
4 +
  constructor(imageData) {
5 +
    super(imageData)
6 +
    this.isDucking = false
7 +
    this.legFrames = 0
8 +
    this.legShowing = 'Left'
9 +
    this.sprite = `dino${this.legShowing}Leg`
10 +
    this.vVelocity = null
11 +
    this.baseY = 0
12 +
    this.relativeY = 0
13 +
    // these are dynamically set by the game
14 +
    this.legsRate = null
15 +
    this.lift = null
16 +
    this.gravity = null
17 +
  }
18 +
19 +
  get y() {
20 +
    return this.baseY - this.height + this.relativeY
21 +
  }
22 +
23 +
  set y(value) {
24 +
    this.baseY = value
25 +
  }
26 +
27 +
  reset() {
28 +
    this.isDucking = false
29 +
    this.legFrames = 0
30 +
    this.legShowing = 'Left'
31 +
    this.sprite = `dino${this.legShowing}Leg`
32 +
    this.vVelocity = null
33 +
    this.relativeY = 0
34 +
  }
35 +
36 +
  jump() {
37 +
    if (this.relativeY === 0) {
38 +
      this.vVelocity = -this.lift
39 +
      return true
40 +
    }
41 +
    return false
42 +
  }
43 +
44 +
  duck(value) {
45 +
    this.isDucking = Boolean(value)
46 +
  }
47 +
48 +
  nextFrame() {
49 +
    if (this.vVelocity !== null) {
50 +
      // use gravity to gradually decrease vVelocity
51 +
      this.vVelocity += this.gravity
52 +
      this.relativeY += this.vVelocity
53 +
    }
54 +
55 +
    // stop falling once back down to the ground
56 +
    if (this.relativeY > 0) {
57 +
      this.vVelocity = null
58 +
      this.relativeY = 0
59 +
    }
60 +
61 +
    this.determineSprite()
62 +
  }
63 +
64 +
  determineSprite() {
65 +
    if (this.relativeY < 0) {
66 +
      // in the air stiff
67 +
      this.sprite = 'dino'
68 +
    } else {
69 +
      // on the ground running
70 +
      if (this.legFrames >= this.legsRate) {
71 +
        this.legShowing = this.legShowing === 'Left' ? 'Right' : 'Left'
72 +
        this.legFrames = 0
73 +
      }
74 +
75 +
      if (this.isDucking) {
76 +
        this.sprite = `dinoDuck${this.legShowing}Leg`
77 +
      } else {
78 +
        this.sprite = `dino${this.legShowing}Leg`
79 +
      }
80 +
81 +
      this.legFrames++
82 +
    }
83 +
  }
84 +
}
src/game/DinoGame.js (added) +425 −0
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 +
17 +
export default class DinoGame extends GameRunner {
18 +
  constructor(width, height, container) {
19 +
    super()
20 +
21 +
    this.width = null
22 +
    this.height = null
23 +
    this.container = container || document.body
24 +
    this.canvas = this.createCanvas(width, height)
25 +
    this.canvasCtx = this.canvas.getContext('2d')
26 +
    this.spriteImage = null
27 +
    this.spriteImageData = null
28 +
29 +
    /*
30 +
     * units
31 +
     * fpa: frames per action
32 +
     * ppf: pixels per frame
33 +
     * px: pixels
34 +
     */
35 +
    this.defaultSettings = {
36 +
      bgSpeed: 8, // ppf
37 +
      birdSpeed: 7.2, // ppf
38 +
      birdSpawnRate: 240, // fpa
39 +
      birdWingsRate: 15, // fpa
40 +
      cactiSpawnRate: 50, // fpa
41 +
      cloudSpawnRate: 200, // fpa
42 +
      cloudSpeed: 2, // ppf
43 +
      dinoGravity: 0.5, // ppf
44 +
      dinoGroundOffset: 4, // px
45 +
      dinoLegsRate: 6, // fpa
46 +
      dinoLift: 10, // ppf
47 +
      scoreBlinkRate: 20, // fpa
48 +
      scoreIncreaseRate: 6, // fpa
49 +
    }
50 +
51 +
    this.state = {
52 +
      settings: { ...this.defaultSettings },
53 +
      birds: [],
54 +
      cacti: [],
55 +
      clouds: [],
56 +
      dino: null,
57 +
      gameOver: false,
58 +
      groundX: 0,
59 +
      groundY: 0,
60 +
      isRunning: false,
61 +
      level: 0,
62 +
      score: {
63 +
        blinkFrames: 0,
64 +
        blinks: 0,
65 +
        isBlinking: false,
66 +
        value: 0,
67 +
      },
68 +
    }
69 +
  }
70 +
71 +
  createCanvas(width, height) {
72 +
    const canvas = document.createElement('canvas')
73 +
    const ctx = canvas.getContext('2d')
74 +
    const scale = window.devicePixelRatio
75 +
76 +
    this.width = width
77 +
    this.height = height
78 +
    canvas.width = Math.floor(width * scale)
79 +
    canvas.height = Math.floor(height * scale)
80 +
    ctx.scale(scale, scale)
81 +
82 +
    this.container.appendChild(canvas)
83 +
    return canvas
84 +
  }
85 +
86 +
  async preload() {
87 +
    const { settings } = this.state
88 +
    const [originalImage] = await Promise.all([
89 +
      loadImage('/assets/sprite.png'),
90 +
      loadFont('/assets/PressStart2P-Regular.ttf', 'PressStart2P'),
91 +
    ])
92 +
    this.spriteImage = await invertImage(originalImage)
93 +
    this.spriteImageData = getImageData(originalImage)
94 +
    const dino = new Dino(this.spriteImageData)
95 +
96 +
    dino.legsRate = settings.dinoLegsRate
97 +
    dino.lift = settings.dinoLift
98 +
    dino.gravity = settings.dinoGravity
99 +
    dino.x = 25
100 +
    dino.baseY = this.height - settings.dinoGroundOffset
101 +
    this.state.dino = dino
102 +
    this.state.groundY = this.height - sprites.ground.h / 2
103 +
  }
104 +
105 +
  onFrame() {
106 +
    const { state } = this
107 +
108 +
    this.drawBackground()
109 +
    this.drawGround()
110 +
    this.drawClouds()
111 +
    this.drawDino()
112 +
    this.drawScore()
113 +
114 +
    if (state.isRunning) {
115 +
      this.drawCacti()
116 +
117 +
      if (state.level > 3) {
118 +
        this.drawBirds()
119 +
      }
120 +
121 +
      if (state.dino.hits([state.cacti[0], state.birds[0]])) {
122 +
        playSound('game-over')
123 +
        state.gameOver = true
124 +
      }
125 +
126 +
      if (state.gameOver) {
127 +
        this.endGame()
128 +
      } else {
129 +
        this.updateScore()
130 +
      }
131 +
    }
132 +
  }
133 +
134 +
  onInput(type) {
135 +
    const { state } = this
136 +
137 +
    switch (type) {
138 +
      case 'jump': {
139 +
        if (state.isRunning) {
140 +
          if (state.dino.jump()) {
141 +
            playSound('jump')
142 +
          }
143 +
        } else {
144 +
          this.resetGame()
145 +
          state.dino.jump()
146 +
          playSound('jump')
147 +
        }
148 +
        break
149 +
      }
150 +
151 +
      case 'duck': {
152 +
        if (state.isRunning) {
153 +
          state.dino.duck(true)
154 +
        }
155 +
        break
156 +
      }
157 +
158 +
      case 'stop-duck': {
159 +
        if (state.isRunning) {
160 +
          state.dino.duck(false)
161 +
        }
162 +
        break
163 +
      }
164 +
    }
165 +
  }
166 +
167 +
  resetGame() {
168 +
    this.state.dino.reset()
169 +
    Object.assign(this.state, {
170 +
      settings: { ...this.defaultSettings },
171 +
      birds: [],
172 +
      cacti: [],
173 +
      gameOver: false,
174 +
      isRunning: true,
175 +
      level: 0,
176 +
      score: {
177 +
        blinkFrames: 0,
178 +
        blinks: 0,
179 +
        isBlinking: false,
180 +
        value: 0,
181 +
      },
182 +
    })
183 +
184 +
    this.start()
185 +
  }
186 +
187 +
  endGame() {
188 +
    const iconSprite = sprites.replayIcon
189 +
    const padding = 15
190 +
191 +
    this.paintText(
192 +
      'G A M E  O V E R',
193 +
      this.width / 2,
194 +
      this.height / 2 - padding,
195 +
      {
196 +
        font: 'PressStart2P',
197 +
        size: '12px',
198 +
        align: 'center',
199 +
        baseline: 'bottom',
200 +
        color: '#E8E8E8',
201 +
      }
202 +
    )
203 +
204 +
    this.paintSprite(
205 +
      'replayIcon',
206 +
      this.width / 2 - iconSprite.w / 4,
207 +
      this.height / 2 - iconSprite.h / 4 + padding
208 +
    )
209 +
210 +
    this.state.isRunning = false
211 +
    this.drawScore()
212 +
    this.stop()
213 +
  }
214 +
215 +
  increaseDifficulty() {
216 +
    const { birds, cacti, clouds, dino, settings } = this.state
217 +
    const { bgSpeed, cactiSpawnRate, dinoLegsRate } = settings
218 +
    const { level } = this.state
219 +
220 +
    if (level > 4 && level < 8) {
221 +
      settings.bgSpeed++
222 +
      settings.birdSpeed = settings.bgSpeed * 0.8
223 +
    } else if (level > 7) {
224 +
      settings.bgSpeed = Math.ceil(bgSpeed * 1.1)
225 +
      settings.birdSpeed = settings.bgSpeed * 0.9
226 +
      settings.cactiSpawnRate = Math.floor(cactiSpawnRate * 0.98)
227 +
228 +
      if (level > 7 && level % 2 === 0 && dinoLegsRate > 3) {
229 +
        settings.dinoLegsRate--
230 +
      }
231 +
    }
232 +
233 +
    for (const bird of birds) {
234 +
      bird.speed = settings.birdSpeed
235 +
    }
236 +
237 +
    for (const cactus of cacti) {
238 +
      cactus.speed = settings.bgSpeed
239 +
    }
240 +
241 +
    for (const cloud of clouds) {
242 +
      cloud.speed = settings.bgSpeed
243 +
    }
244 +
245 +
    dino.legsRate = settings.dinoLegsRate
246 +
  }
247 +
248 +
  updateScore() {
249 +
    const { state } = this
250 +
251 +
    if (this.frameCount % state.settings.scoreIncreaseRate === 0) {
252 +
      const oldLevel = state.level
253 +
254 +
      state.score.value++
255 +
      state.level = Math.floor(state.score.value / 100)
256 +
257 +
      if (state.level !== oldLevel) {
258 +
        playSound('level-up')
259 +
        this.increaseDifficulty()
260 +
        state.score.isBlinking = true
261 +
      }
262 +
    }
263 +
  }
264 +
265 +
  drawBackground() {
266 +
    this.canvasCtx.fillStyle = '#121113'
267 +
    this.canvasCtx.fillRect(0, 0, this.width, this.height)
268 +
  }
269 +
270 +
  drawGround() {
271 +
    const { state } = this
272 +
    const { bgSpeed } = state.settings
273 +
    const groundImgWidth = sprites.ground.w / 2
274 +
275 +
    this.paintSprite('ground', state.groundX, state.groundY)
276 +
    state.groundX -= bgSpeed
277 +
278 +
    // append second image until first is fully translated
279 +
    if (state.groundX <= -groundImgWidth + this.width) {
280 +
      this.paintSprite('ground', state.groundX + groundImgWidth, state.groundY)
281 +
282 +
      if (state.groundX <= -groundImgWidth) {
283 +
        state.groundX = -bgSpeed
284 +
      }
285 +
    }
286 +
  }
287 +
288 +
  drawClouds() {
289 +
    const { clouds, settings } = this.state
290 +
291 +
    this.progressInstances(clouds)
292 +
    if (this.frameCount % settings.cloudSpawnRate === 0) {
293 +
      const newCloud = new Cloud()
294 +
      newCloud.speed = settings.bgSpeed
295 +
      newCloud.x = this.width
296 +
      newCloud.y = randInteger(20, 80)
297 +
      clouds.push(newCloud)
298 +
    }
299 +
    this.paintInstances(clouds)
300 +
  }
301 +
302 +
  drawDino() {
303 +
    const { dino } = this.state
304 +
305 +
    dino.nextFrame()
306 +
    this.paintSprite(dino.sprite, dino.x, dino.y)
307 +
  }
308 +
309 +
  drawCacti() {
310 +
    const { state } = this
311 +
    const { cacti, settings } = state
312 +
313 +
    this.progressInstances(cacti)
314 +
    if (this.frameCount % settings.cactiSpawnRate === 0) {
315 +
      // randomly either do or don't add cactus
316 +
      if (!state.birds.length && randBoolean()) {
317 +
        const newCacti = new Cactus(this.spriteImageData)
318 +
        newCacti.speed = settings.bgSpeed
319 +
        newCacti.x = this.width
320 +
        newCacti.y = this.height - newCacti.height - 2
321 +
        cacti.push(newCacti)
322 +
      }
323 +
    }
324 +
    this.paintInstances(cacti)
325 +
  }
326 +
327 +
  drawBirds() {
328 +
    const { birds, settings } = this.state
329 +
330 +
    this.progressInstances(birds)
331 +
    if (this.frameCount % settings.birdSpawnRate === 0) {
332 +
      // randomly either do or don't add bird
333 +
      if (randBoolean()) {
334 +
        const newBird = new Bird(this.spriteImageData)
335 +
        newBird.speed = settings.birdSpeed
336 +
        newBird.wingsRate = settings.birdWingsRate
337 +
        newBird.x = this.width
338 +
        // ensure birds are always at least 5px higher than a ducking dino
339 +
        newBird.y =
340 +
          this.height -
341 +
          Bird.maxBirdHeight -
342 +
          Bird.wingSpriteYShift -
343 +
          5 -
344 +
          sprites.dinoDuckLeftLeg.h / 2 -
345 +
          settings.dinoGroundOffset
346 +
        birds.push(newBird)
347 +
      }
348 +
    }
349 +
    this.paintInstances(birds)
350 +
  }
351 +
352 +
  drawScore() {
353 +
    const { canvasCtx, state } = this
354 +
    const { isRunning, score, settings } = state
355 +
    const fontSize = 12
356 +
    let shouldDraw = true
357 +
    let drawValue = score.value
358 +
359 +
    if (isRunning && score.isBlinking) {
360 +
      score.blinkFrames++
361 +
362 +
      if (score.blinkFrames % settings.scoreBlinkRate === 0) {
363 +
        score.blinks++
364 +
      }
365 +
366 +
      if (score.blinks > 7) {
367 +
        score.blinkFrames = 0
368 +
        score.blinks = 0
369 +
        score.isBlinking = false
370 +
      } else {
371 +
        if (score.blinks % 2 === 0) {
372 +
          drawValue = Math.floor(drawValue / 100) * 100
373 +
        } else {
374 +
          shouldDraw = false
375 +
        }
376 +
      }
377 +
    }
378 +
379 +
    if (shouldDraw) {
380 +
      canvasCtx.fillStyle = '#121113'
381 +
      canvasCtx.fillRect(this.width - fontSize * 5, 0, fontSize * 5, fontSize)
382 +
383 +
      this.paintText((drawValue + '').padStart(5, '0'), this.width, 0, {
384 +
        font: 'PressStart2P',
385 +
        size: `${fontSize}px`,
386 +
        align: 'right',
387 +
        baseline: 'top',
388 +
        color: '#E8E8E8',
389 +
      })
390 +
    }
391 +
  }
392 +
393 +
  progressInstances(instances) {
394 +
    for (let i = instances.length - 1; i >= 0; i--) {
395 +
      const instance = instances[i]
396 +
397 +
      instance.nextFrame()
398 +
      if (instance.rightX <= 0) {
399 +
        instances.splice(i, 1)
400 +
      }
401 +
    }
402 +
  }
403 +
404 +
  paintInstances(instances) {
405 +
    for (const instance of instances) {
406 +
      this.paintSprite(instance.sprite, instance.x, instance.y)
407 +
    }
408 +
  }
409 +
410 +
  paintSprite(spriteName, dx, dy) {
411 +
    const { h, w, x, y } = sprites[spriteName]
412 +
    this.canvasCtx.drawImage(this.spriteImage, x, y, w, h, dx, dy, w / 2, h / 2)
413 +
  }
414 +
415 +
  paintText(text, x, y, opts) {
416 +
    const { font = 'serif', size = '12px' } = opts
417 +
    const { canvasCtx } = this
418 +
419 +
    canvasCtx.font = `${size} ${font}`
420 +
    if (opts.align) canvasCtx.textAlign = opts.align
421 +
    if (opts.baseline) canvasCtx.textBaseline = opts.baseline
422 +
    if (opts.color) canvasCtx.fillStyle = opts.color
423 +
    canvasCtx.fillText(text, x, y)
424 +
  }
425 +
}
src/game/GameRunner.js (added) +91 −0
1 +
export default class GameRunner {
2 +
  constructor() {
3 +
    this.looping = false
4 +
    this.preloaded = false
5 +
    this.targetFrameRate = 60
6 +
    this.frameCount = 0
7 +
    this.frameRate = 0
8 +
    this.paused = false
9 +
    this.stepFrames = null
10 +
    this._lastFrameTime = window.performance.now()
11 +
12 +
    // store this bound function so we don't have to create
13 +
    // one every single time we call requestAnimationFrame
14 +
    this.__loop = this._loop.bind(this)
15 +
  }
16 +
17 +
  async start(paused = false) {
18 +
    if (!this.preloaded) {
19 +
      if (this.preload) {
20 +
        await this.preload()
21 +
      }
22 +
      this.preloaded = true
23 +
    }
24 +
25 +
    if (paused) {
26 +
      this.paused = paused
27 +
    }
28 +
29 +
    this.looping = true
30 +
31 +
    if (!paused) {
32 +
      window.requestAnimationFrame(this.__loop)
33 +
    }
34 +
  }
35 +
36 +
  stop() {
37 +
    this.looping = false
38 +
  }
39 +
40 +
  pause() {
41 +
    this.paused = true
42 +
  }
43 +
44 +
  unpause() {
45 +
    this.paused = false
46 +
  }
47 +
48 +
  step(frames = 1) {
49 +
    if (typeof this.stepFrames === 'number') {
50 +
      this.stepFrames += frames
51 +
    } else {
52 +
      this.stepFrames = frames
53 +
    }
54 +
55 +
    this.__loop(window.performance.now())
56 +
  }
57 +
58 +
  _loop(timestamp) {
59 +
    const now = window.performance.now()
60 +
    const timeSinceLast = now - this._lastFrameTime
61 +
    const targetTimeBetweenFrames = 1000 / this.targetFrameRate
62 +
63 +
    if (timeSinceLast >= targetTimeBetweenFrames - 5) {
64 +
      this.onFrame()
65 +
      this.frameRate = 1000 / (now - this._lastFrameTime)
66 +
      this._lastFrameTime = now
67 +
      this.frameCount++
68 +
    }
69 +
70 +
    if (this.looping) {
71 +
      let shouldLoop = true
72 +
73 +
      if (this.paused) {
74 +
        if (typeof this.stepFrames === 'number') {
75 +
          if (this.stepFrames === 0) {
76 +
            this.stepFrames = null
77 +
            shouldLoop = false
78 +
          } else {
79 +
            this.stepFrames--
80 +
          }
81 +
        } else {
82 +
          shouldLoop = false
83 +
        }
84 +
      }
85 +
86 +
      if (shouldLoop) {
87 +
        window.requestAnimationFrame(this.__loop)
88 +
      }
89 +
    }
90 +
  }
91 +
}
src/index.js (added) +43 −0
1 +
import DinoGame from './game/DinoGame.js'
2 +
3 +
const game = new DinoGame(600, 150, document.getElementById('game-container'))
4 +
5 +
// Touch controls
6 +
document.addEventListener(
7 +
  'touchstart',
8 +
  (e) => {
9 +
    e.preventDefault()
10 +
    if (e.touches.length === 1) {
11 +
      game.onInput('jump')
12 +
    } else if (e.touches.length === 2) {
13 +
      game.onInput('duck')
14 +
    }
15 +
  },
16 +
  { passive: false }
17 +
)
18 +
19 +
document.addEventListener('touchend', (e) => {
20 +
  game.onInput('stop-duck')
21 +
})
22 +
23 +
// Keyboard controls
24 +
const keycodes = {
25 +
  JUMP: { 38: 1, 32: 1 },
26 +
  DUCK: { 40: 1 },
27 +
}
28 +
29 +
document.addEventListener('keydown', ({ keyCode }) => {
30 +
  if (keycodes.JUMP[keyCode]) {
31 +
    game.onInput('jump')
32 +
  } else if (keycodes.DUCK[keyCode]) {
33 +
    game.onInput('duck')
34 +
  }
35 +
})
36 +
37 +
document.addEventListener('keyup', ({ keyCode }) => {
38 +
  if (keycodes.DUCK[keyCode]) {
39 +
    game.onInput('stop-duck')
40 +
  }
41 +
})
42 +
43 +
game.start().catch(console.error)
src/sounds.js (added) +37 −0
1 +
const AudioContext = window.AudioContext || window.webkitAudioContext
2 +
const audioContext = new AudioContext()
3 +
const soundNames = ['game-over', 'jump', 'level-up']
4 +
const soundBuffers = {}
5 +
let SOUNDS_LOADED = false
6 +
7 +
loadSounds().catch(console.error)
8 +
export function playSound(name) {
9 +
  if (SOUNDS_LOADED) {
10 +
    audioContext.resume()
11 +
    playBuffer(soundBuffers[name])
12 +
  }
13 +
}
14 +
15 +
async function loadSounds() {
16 +
  await Promise.all(
17 +
    soundNames.map(async (soundName) => {
18 +
      soundBuffers[soundName] = await loadBuffer(`/assets/${soundName}.mp3`)
19 +
    })
20 +
  )
21 +
22 +
  SOUNDS_LOADED = true
23 +
}
24 +
25 +
async function loadBuffer(filepath) {
26 +
  const response = await fetch(filepath)
27 +
  const arrayBuffer = await response.arrayBuffer()
28 +
  return audioContext.decodeAudioData(arrayBuffer)
29 +
}
30 +
31 +
function playBuffer(buffer) {
32 +
  const source = audioContext.createBufferSource()
33 +
34 +
  source.buffer = buffer
35 +
  source.connect(audioContext.destination)
36 +
  source.start()
37 +
}
src/sprites.js (added) +16 −0
1 +
export default {
2 +
  birdUp: { h: 52, w: 84, x: 708, y: 31 },
3 +
  birdDown: { h: 60, w: 84, x: 708, y: 85 },
4 +
  cactus: { h: 92, w: 46, x: 70, y: 31 },
5 +
  cactusDouble: { h: 66, w: 64, x: 118, y: 31 },
6 +
  cactusDoubleB: { h: 92, w: 80, x: 184, y: 31 },
7 +
  cactusTriple: { h: 66, w: 82, x: 266, y: 31 },
8 +
  cloud: { h: 28, w: 92, x: 794, y: 31 },
9 +
  dino: { h: 86, w: 80, x: 350, y: 31 },
10 +
  dinoDuckLeftLeg: { h: 52, w: 110, x: 596, y: 31 },
11 +
  dinoDuckRightLeg: { h: 52, w: 110, x: 596, y: 85 },
12 +
  dinoLeftLeg: { h: 86, w: 80, x: 432, y: 31 },
13 +
  dinoRightLeg: { h: 86, w: 80, x: 514, y: 31 },
14 +
  ground: { h: 28, w: 2400, x: 0, y: 2 },
15 +
  replayIcon: { h: 60, w: 68, x: 0, y: 31 },
16 +
}
src/style.css (added) +35 −0
1 +
* {
2 +
  margin: 0;
3 +
  padding: 0;
4 +
  box-sizing: border-box;
5 +
}
6 +
7 +
html,
8 +
body {
9 +
  height: 100%;
10 +
  overflow: hidden;
11 +
  touch-action: none;
12 +
}
13 +
14 +
body {
15 +
  background-color: #121113;
16 +
  display: flex;
17 +
  align-items: center;
18 +
  justify-content: center;
19 +
}
20 +
21 +
#game-container {
22 +
  width: 100%;
23 +
  max-width: 600px;
24 +
  padding: 0 16px;
25 +
  display: flex;
26 +
  justify-content: center;
27 +
}
28 +
29 +
#game-container canvas {
30 +
  width: 100%;
31 +
  aspect-ratio: 4 / 1;
32 +
  display: block;
33 +
  image-rendering: pixelated;
34 +
  image-rendering: crisp-edges;
35 +
}
src/utils.js (added) +82 −0
1 +
export function getImageData(image) {
2 +
  const { width, height } = image
3 +
  const tmpCanvas = document.createElement('canvas')
4 +
  const ctx = tmpCanvas.getContext('2d')
5 +
  let result
6 +
7 +
  tmpCanvas.width = width
8 +
  tmpCanvas.height = height
9 +
  ctx.drawImage(image, 0, 0)
10 +
11 +
  result = ctx.getImageData(0, 0, width, height)
12 +
  tmpCanvas.remove()
13 +
  return result
14 +
}
15 +
16 +
export async function loadImage(url) {
17 +
  return new Promise((resolve, reject) => {
18 +
    const image = new Image()
19 +
20 +
    image.onload = () => resolve(image)
21 +
    image.onerror = reject
22 +
    image.src = url
23 +
  })
24 +
}
25 +
26 +
function getFontName(url) {
27 +
  const ext = url.slice(url.lastIndexOf('.'))
28 +
  const pathParts = url.split('/')
29 +
30 +
  return pathParts[pathParts.length - 1].slice(0, -1 * ext.length)
31 +
}
32 +
33 +
export async function loadFont(url, fontName) {
34 +
  if (!fontName) fontName = getFontName(url)
35 +
  const styleEl = document.createElement('style')
36 +
37 +
  styleEl.innerHTML = `
38 +
    @font-face {
39 +
      font-family: ${fontName};
40 +
      src: url(${url});
41 +
    }
42 +
  `
43 +
  document.head.appendChild(styleEl)
44 +
  await document.fonts.load(`12px ${fontName}`)
45 +
}
46 +
47 +
export async function invertImage(image) {
48 +
  const canvas = document.createElement('canvas')
49 +
  const ctx = canvas.getContext('2d')
50 +
  canvas.width = image.width
51 +
  canvas.height = image.height
52 +
  ctx.drawImage(image, 0, 0)
53 +
54 +
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
55 +
  const data = imageData.data
56 +
  for (let i = 0; i < data.length; i += 4) {
57 +
    if (data[i + 3] > 0) {
58 +
      data[i] = 255 - data[i]
59 +
      data[i + 1] = 255 - data[i + 1]
60 +
      data[i + 2] = 255 - data[i + 2]
61 +
    }
62 +
  }
63 +
  ctx.putImageData(imageData, 0, 0)
64 +
65 +
  return new Promise((resolve) => {
66 +
    const img = new Image()
67 +
    img.onload = () => resolve(img)
68 +
    img.src = canvas.toDataURL()
69 +
  })
70 +
}
71 +
72 +
export function randInteger(min, max) {
73 +
  return Math.floor(Math.random() * (max - min + 1)) + min
74 +
}
75 +
76 +
export function randBoolean() {
77 +
  return Boolean(randInteger(0, 1))
78 +
}
79 +
80 +
export function randItem(arr) {
81 +
  return arr[randInteger(0, arr.length - 1)]
82 +
}
tsconfig.json (added) +29 −0
1 +
{
2 +
  "compilerOptions": {
3 +
    // Environment setup & latest features
4 +
    "lib": ["ESNext"],
5 +
    "target": "ESNext",
6 +
    "module": "Preserve",
7 +
    "moduleDetection": "force",
8 +
    "jsx": "react-jsx",
9 +
    "allowJs": true,
10 +
11 +
    // Bundler mode
12 +
    "moduleResolution": "bundler",
13 +
    "allowImportingTsExtensions": true,
14 +
    "verbatimModuleSyntax": true,
15 +
    "noEmit": true,
16 +
17 +
    // Best practices
18 +
    "strict": true,
19 +
    "skipLibCheck": true,
20 +
    "noFallthroughCasesInSwitch": true,
21 +
    "noUncheckedIndexedAccess": true,
22 +
    "noImplicitOverride": true,
23 +
24 +
    // Some stricter flags (disabled by default)
25 +
    "noUnusedLocals": false,
26 +
    "noUnusedParameters": false,
27 +
    "noPropertyAccessFromIndexSignature": false
28 +
  }
29 +
}