diff options
| author | LLLL Colonq <llll@colonq> | 2026-07-14 14:20:41 -0400 |
|---|---|---|
| committer | LLLL Colonq <llll@colonq> | 2026-07-14 14:20:41 -0400 |
| commit | 951b316773265052fbc5fa546bbcf31bef277062 (patch) | |
| tree | 139ec93946d7103f5ae102bd15f964d8d977dbae /2026/games/ellg/game.js | |
| parent | 9f8c3904e77f3a6a8a341542665c1988c8582930 (diff) | |
Update
Diffstat (limited to '2026/games/ellg/game.js')
| -rw-r--r-- | 2026/games/ellg/game.js | 378 |
1 files changed, 378 insertions, 0 deletions
diff --git a/2026/games/ellg/game.js b/2026/games/ellg/game.js new file mode 100644 index 0000000..a82d24b --- /dev/null +++ b/2026/games/ellg/game.js @@ -0,0 +1,378 @@ +import { Vector } from "./vector.js"; + +const WIDTH = 240; +const HEIGHT = 160; +const STANDALONE = window.self === window.top; +const DIFFICULTY = 4; + +const canvas = document.getElementById("game"); + +const ctx = canvas.getContext("2d"); +ctx.imageSmoothingEnabled = false; + +function notifyHarness(msg) { + if (!STANDALONE) window.parent.postMessage(msg); +} + +const music = new Audio("./assets/song.mp3"); +music.loop = true; +music.volume = 0.5; + +function playMusic() { + music.currentTime = 0; + music.play().catch(() => { }); +} + +function stopMusic() { + music.pause(); +} + +export async function loadImage(url) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = url; + }); +}; + +export async function loadAssets() { + const [guy1, guy2, guy3, guy4, pot, table, beef1, beef2, hat, win, lose, hungry] = await Promise.all([ + loadImage("./assets/guy1.png"), + loadImage("./assets/guy2.png"), + loadImage("./assets/guy3.png"), + loadImage("./assets/guy4.png"), + loadImage("./assets/pot.png"), + loadImage("./assets/table.png"), + loadImage("./assets/beef1.png"), + loadImage("./assets/beef2.png"), + loadImage("./assets/chefhat.png"), + loadImage("./assets/win.png"), + loadImage("./assets/lose.png"), + loadImage("./assets/hungry.png"), + ]); + + return { + guy1, + guy2, + guy3, + guy4, + pot, + table, + beef1, + beef2, + hat, + win, + lose, + hungry + }; +}; + +let lastTime = 0; +let assets = {}; +let state = { + scene: null, +}; + +class Scene { + constructor() { } + + draw(dt) { } + update(dt) { } + handleInput(dt) { } + onClick(p) { } +} + +const END_SCREEN_TIME = 3; + +class EndScreen extends Scene { + constructor(win) { + super(); + + stopMusic(); + + this.win = win; + this.t = 0; + this.sent = false; + } + + update(dt) { + this.t += dt; + + if (!this.sent && this.t >= END_SCREEN_TIME) { + this.sent = true; + notifyHarness({ op: "done", win: this.win }); + } + } +} + +class WinScreen extends EndScreen { + constructor() { super(true) } + + draw() { + ctx.drawImage(assets.win, 0, 0); + } +} + +class GameOverScreen extends EndScreen { + constructor(endType = "death") { + super(false); + this.endType = endType; + } + + draw() { + ctx.drawImage(this.endType === "death" ? assets.lose : assets.hungry, 0, 0); + ctx.drawImage(assets.guy4, 50, 75); + } +} + +const POT = { x: 25, y: 90, size: 64 }; +const POT_CENTER = Vector.of([POT.x + POT.size / 2, POT.y + POT.size / 2]); +const COLLECT_TIME = 0.4; + +const BOUNDS = { x: 8, y: 8, w: 125, h: 96 }; + +class Icon { + constructor(img, pos, correct) { + this.img = img; + this.pos = pos; + this.size = 32; + this.correct = correct; + this.vel = new Vector(0, 0); + this.collect = null; + this.dead = false; + } + + static spawn(img, correct, speed) { + const icon = new Icon(img, Vector.of([0, 0]), correct); + icon.pos = Vector.of([ + BOUNDS.x + Math.random() * (BOUNDS.w - icon.size), + BOUNDS.y + Math.random() * (BOUNDS.h - icon.size), + ]); + const a = Math.random() * Math.PI * 2; + icon.vel = new Vector(Math.cos(a), Math.sin(a)).multiply(speed); + return icon; + } + + center() { return this.pos.add(new Vector(this.size / 2, this.size / 2)); } + + contains(p) { + return p.x >= this.pos.x && p.x <= this.pos.x + this.size && + p.y >= this.pos.y && p.y <= this.pos.y + this.size; + } + + startCollect() { + if (this.collect) return; + this.collect = { t: 0, fromCenter: this.center() }; + } + + update(dt) { + if (this.collect) { + this.collect.t += dt; + const k = Math.min(1, this.collect.t / COLLECT_TIME); + const e = k * k; + this._drawCenter = this.collect.fromCenter.add( + POT_CENTER.subtract(this.collect.fromCenter).multiply(e)); + this._scale = 1 - k; + if (k >= 1) this.dead = true; + return; + } + + this.pos = this.pos.add(this.vel.multiply(dt)); + const maxX = BOUNDS.x + BOUNDS.w - this.size; + const maxY = BOUNDS.y + BOUNDS.h - this.size; + if (this.pos.x < BOUNDS.x) { this.pos.x = BOUNDS.x; this.vel.x *= -1; } + if (this.pos.x > maxX) { this.pos.x = maxX; this.vel.x *= -1; } + if (this.pos.y < BOUNDS.y) { this.pos.y = BOUNDS.y; this.vel.y *= -1; } + if (this.pos.y > maxY) { this.pos.y = maxY; this.vel.y *= -1; } + } + + draw(ctx) { + const c = this._drawCenter ?? this.center(); + const s = this.size * (this._scale ?? 1); + ctx.drawImage( + this.img, + Math.round(c.x - s / 2), Math.round(c.y - s / 2), + Math.round(s), Math.round(s), + ); + } +} + +function difficultyConfig(raw) { + const K = 5; + const d = raw / (raw + K); + const lerp = (a, b) => a + (b - a) * d; + return { + correct: Math.round(lerp(3, 5)), + decoys: Math.round(lerp(3, 10)), + speed: lerp(20, 90), + timeLimit: lerp(8, 4), + }; +} + +function spawnIcons(cfg) { + const icons = []; + for (let i = 0; i < cfg.correct; i++) icons.push(Icon.spawn(assets.beef1, true, cfg.speed)); + for (let i = 0; i < cfg.decoys; i++) icons.push(Icon.spawn(assets.beef2, false, cfg.speed)); + return icons; +} + +class GameScreen extends Scene { + constructor(cfg) { + super(); + this.cfg = cfg; + this.timeTotal = cfg.timeLimit; + this.timeLeft = cfg.timeLimit; + this.state = { + guy: { + img: assets.guy2, + pos: Vector.of([125, 50]), + reactImg: null, + reactT: 0, + }, + icons: spawnIcons(cfg), + }; + } + + draw() { + const guy = this.state.guy; + + ctx.drawImage(guy.reactImg ?? guy.img, guy.pos.x, guy.pos.y); + ctx.drawImage(assets.hat, guy.pos.x + 45, guy.pos.y - 25, 50, 50); + ctx.drawImage(assets.table, 0, 50); + + for (const icon of this.state.icons) icon.draw(ctx); + + ctx.drawImage(assets.pot, POT.x, POT.y, POT.size, POT.size); + + const frac = this.timeLeft / this.timeTotal; + ctx.fillStyle = "#3a2a1a"; + ctx.fillRect(0, 0, WIDTH, 6); + ctx.fillStyle = frac < 0.25 ? "#e44" : "#f2c14e"; + ctx.fillRect(0, 0, Math.round(WIDTH * frac), 6); + } + + update(dt, ct) { + if (this.state.guy.reactT > 0) { + this.state.guy.reactT -= dt; + if (this.state.guy.reactT <= 0) this.state.guy.reactImg = null; + } + + this.timeLeft -= dt; + if (this.timeLeft <= 0) { + this.timeLeft = 0; + this.lose("hungry"); + return; + } + + const baseY = 50; + const amp = 4; + const speed = 0.01; + this.state.guy.pos.y = baseY + Math.round(Math.sin(ct * speed) * amp); + + for (const icon of this.state.icons) icon.update(dt); + this.state.icons = this.state.icons.filter(i => !i.dead); + } + + lose(endType = "death") { + state.scene = new GameOverScreen(endType); + } + + win() { + state.scene = new WinScreen(); + } + + onClick(p) { + for (let i = this.state.icons.length - 1; i >= 0; i--) { + const icon = this.state.icons[i]; + if (icon.collect) continue; + if (icon.contains(p)) { + if (!icon.correct) { this.lose() } + + icon.startCollect(); + + const correctLeft = this.state.icons.filter(x => x.correct && !x.collect).length; + if (correctLeft === 0) this.win() + + if (icon.correct) { + this.state.guy.reactImg = assets.guy3; + this.state.guy.reactT = 2; + }; + + return; + } + } + } +} + +async function init() { + assets = await loadAssets(); + notifyHarness({ op: "ready" }); +} + +function update(dt, ct) { + if (state.scene) { + state.scene.update(dt, Math.floor(ct)); + } +} + +function draw(dt) { + ctx.fillStyle = "#222"; + ctx.fillRect(0, 0, WIDTH, HEIGHT); + + if (state.scene) { + state.scene.draw(dt); + } +} + +function handleInput(dt) { + if (state.scene) { + state.scene.handleInput(dt); + } +} + +function main(currentTime) { + if (!lastTime) lastTime = currentTime; + const dt = Math.min((currentTime - lastTime) / 1000, 0.1); + + lastTime = currentTime; + + handleInput(dt); + update(dt, currentTime); + draw(dt); + + requestAnimationFrame(main); +} + +function startGame(difficulty = DIFFICULTY) { + state.scene = new GameScreen(difficultyConfig(difficulty)); + notifyHarness({ op: "started", verb: "make some chili!" }); + playMusic(); +} + +canvas.addEventListener("click", (ev) => { + if (music.paused && state.scene instanceof GameScreen) music.play().catch(() => { }); + + const r = canvas.getBoundingClientRect(); + const p = Vector.of([ + (ev.clientX - r.left) / r.width * WIDTH, + (ev.clientY - r.top) / r.height * HEIGHT, + ]); + state.scene?.onClick(p); +}); + +window.addEventListener("message", ev => { + switch (ev.data.op) { + case "start": + startGame(ev.data.difficulty ?? 0); + break; + default: console.log(`unknown event: ${ev}`); break; + } +}); + +await init(); + +requestAnimationFrame(main); + +if (STANDALONE) startGame(); + |
