feat: init
2cca90f6
35 file(s) · +1210 −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 |
| 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 | + |
| 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 | + |
| 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 | + | } |
| 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> |
| 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"); |
| 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 | + | } |
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
Binary file — no preview.
| 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"} |
Binary file — no preview.
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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) |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |