From d7d8549fed4fd24d61001271195234cf2b625853 Mon Sep 17 00:00:00 2001 From: levonn-dev Date: Sun, 7 Jun 2026 17:17:55 -0400 Subject: [PATCH] feat: add konami code game --- .github/workflows/ci.yml | 4 +- Taskfile.yml | 8 +- pages/runner/game.js | 203 ++++++++++++++++++++++++++++++++ pages/runner/index.html | 2 + pages/runner/mechanics.js | 21 ++++ pages/runner/runner.css | 36 ++++++ pages/runner/runner.js | 17 +++ scripts/konami.js | 36 ++++++ scripts/main.js | 10 ++ tests/konami.test.mjs | 57 +++++++++ tests/runner-mechanics.test.mjs | 32 +++++ 11 files changed, 423 insertions(+), 3 deletions(-) create mode 100644 pages/runner/game.js create mode 100644 pages/runner/index.html create mode 100644 pages/runner/mechanics.js create mode 100644 pages/runner/runner.css create mode 100644 pages/runner/runner.js create mode 100644 scripts/konami.js create mode 100644 tests/konami.test.mjs create mode 100644 tests/runner-mechanics.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20753fa..c783565 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,5 +21,5 @@ jobs: version: 3.x repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Syntax-check JS - run: task check + - name: Test JS + run: task test diff --git a/Taskfile.yml b/Taskfile.yml index e213f20..4c6c84b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -24,6 +24,12 @@ tasks: done < <(find scripts pages -name "*.js" | sort) $all_ok && echo "All files OK" || exit 1 + test: + desc: Run unit tests + deps: [check] + cmds: + - node --test + serve: desc: Start local HTTP server (required as ES modules don't work over file://) cmds: @@ -32,6 +38,6 @@ tasks: dev: desc: Serve and open in browser - deps: [check] + deps: [test] cmds: - task: serve diff --git a/pages/runner/game.js b/pages/runner/game.js new file mode 100644 index 0000000..8e5d52b --- /dev/null +++ b/pages/runner/game.js @@ -0,0 +1,203 @@ +import { aabb, speedForScore, bestScore } from './mechanics.js'; + +const HERO_W = 17; // assets/cursors/default.png natural size +const HERO_H = 25; +const HERO_X = 44; // fixed horizontal position of the hero +const GROUND_Y = 150; // y of the ground line (hero stands on it) +const GRAVITY = 0.7; // downward accel per 60fps frame +const JUMP_V = -11; // upward velocity applied on a jump +const OB_SIZE = 28; // drawn obstacle size (icons are 32x32) +const SCORE_RATE = 0.25; // score gained per 60fps frame while running +const MIN_GAP = 60; // min frames between obstacle spawns +const MAX_GAP = 130; // max frames between obstacle spawns +const HS_KEY = 'runner.highScore'; + +const HERO_SRC = 'assets/cursors/default.png'; +const OBSTACLE_SRCS = [ + 'assets/icons/folder.png', + 'assets/icons/computer.png', + 'assets/icons/desktop.png', + 'assets/icons/terminal.png', + 'assets/icons/wordpad.png', + 'assets/icons/mediaplayer.png', + 'assets/icons/ie.png', +]; + +function loadImage(src) { + const img = new Image(); + img.src = src; + return img; +} + +function readHighScore() { + try { + return Number(localStorage.getItem(HS_KEY)) || 0; + } catch { + return 0; // storage unavailable (private mode); session-only high score + } +} + +function writeHighScore(value) { + try { + localStorage.setItem(HS_KEY, String(value)); + } catch { + /* storage unavailable; keep session-only */ + } +} + +export class Game { + constructor(canvas, windowEl) { + this.canvas = canvas; + this.windowEl = windowEl; + this.ctx = canvas.getContext('2d'); + if (this.ctx) this.ctx.imageSmoothingEnabled = false; + + this.hero = loadImage(HERO_SRC); + this.obstacleImgs = OBSTACLE_SRCS.map(loadImage); + + this.high = readHighScore(); + this.lastTs = 0; + this.reset(); + + this._onKey = this._onKey.bind(this); + this._onPointer = this._onPointer.bind(this); + this._frame = this._frame.bind(this); + } + + reset() { + this.state = 'ready'; // 'ready' | 'running' | 'over' + this.heroY = GROUND_Y - HERO_H; + this.heroVy = 0; + this.onGround = true; + this.obstacles = []; + this.spawnTimer = MAX_GAP; + this.score = 0; + } + + start() { + document.addEventListener('keydown', this._onKey); + this.canvas.addEventListener('pointerdown', this._onPointer); + requestAnimationFrame(this._frame); + } + + // Jump / start / restart depending on current state. + _action() { + if (this.state === 'ready') { + this.state = 'running'; + } else if (this.state === 'over') { + this.reset(); + this.state = 'running'; + } else if (this.onGround) { + this.heroVy = JUMP_V; + this.onGround = false; + } + } + + _onKey(e) { + if (e.key !== ' ' && e.key !== 'ArrowUp') return; + // Only react when the runner window is focused, so we never steal keys from + // other windows (e.g. the terminal). + if (!this.windowEl.classList.contains('focused')) return; + e.preventDefault(); + this._action(); + } + + _onPointer(e) { + e.preventDefault(); + this._action(); + } + + _frame(ts) { + requestAnimationFrame(this._frame); + + // Pause cleanly when the window is closed/hidden/minimized or the tab is + // backgrounded. The module runs only once, so this is how reopening the + // window via the Konami code resumes the same loop. + if (this.canvas.offsetParent === null || document.hidden) { + this.lastTs = ts; + return; + } + + const dt = this.lastTs ? ts - this.lastTs : 1000 / 60; + this.lastTs = ts; + const f = Math.min(dt / (1000 / 60), 2); // frame factor, capped vs tunneling + + if (this.state === 'running') this._update(f); + this._draw(); + } + + _update(f) { + // Hero physics + this.heroVy += GRAVITY * f; + this.heroY += this.heroVy * f; + const floor = GROUND_Y - HERO_H; + if (this.heroY >= floor) { + this.heroY = floor; + this.heroVy = 0; + this.onGround = true; + } + + // Obstacles: spawn, move, cull + const speed = speedForScore(this.score); + this.spawnTimer -= f; + if (this.spawnTimer <= 0) { + const img = this.obstacleImgs[Math.floor(Math.random() * this.obstacleImgs.length)]; + this.obstacles.push({ x: this.canvas.width, y: GROUND_Y - OB_SIZE, w: OB_SIZE, h: OB_SIZE, img }); + this.spawnTimer = MIN_GAP + Math.random() * (MAX_GAP - MIN_GAP); + } + for (const o of this.obstacles) o.x -= speed * f; + this.obstacles = this.obstacles.filter((o) => o.x + o.w > 0); + + // Score (incremented before the collision check so the value saved as the + // high score matches what is displayed on the death frame) + this.score += SCORE_RATE * f; + + // Collision -> game over + const heroBox = { x: HERO_X, y: this.heroY, w: HERO_W, h: HERO_H }; + if (this.obstacles.some((o) => aabb(heroBox, o))) { + this.state = 'over'; + this.high = bestScore(this.high, Math.floor(this.score)); + writeHighScore(this.high); + } + } + + _draw() { + const { ctx, canvas } = this; + if (!ctx) return; + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Ground line + ctx.strokeStyle = '#2b2b2b'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(0, GROUND_Y); + ctx.lineTo(canvas.width, GROUND_Y); + ctx.stroke(); + + // Obstacles + for (const o of this.obstacles) { + if (o.img.complete) ctx.drawImage(o.img, Math.round(o.x), o.y, o.w, o.h); + } + + // Hero + if (this.hero.complete) ctx.drawImage(this.hero, HERO_X, Math.round(this.heroY), HERO_W, HERO_H); + + // Score / high score (top-right) + ctx.fillStyle = '#2b2b2b'; + ctx.font = '14px "MononokiNerdFont", monospace'; + ctx.textAlign = 'right'; + const hi = String(this.high).padStart(5, '0'); + const cur = String(Math.floor(this.score)).padStart(5, '0'); + ctx.fillText(`HI ${hi} ${cur}`, canvas.width - 8, 22); + + // Ready / over overlay + if (this.state !== 'running') { + ctx.textAlign = 'center'; + const msg = this.state === 'ready' + ? 'Press Space to start' + : 'Game Over - Space to retry'; + ctx.fillText(msg, canvas.width / 2, GROUND_Y / 2); + ctx.textAlign = 'right'; + } + } +} diff --git a/pages/runner/index.html b/pages/runner/index.html new file mode 100644 index 0000000..2d7c3c6 --- /dev/null +++ b/pages/runner/index.html @@ -0,0 +1,2 @@ + + diff --git a/pages/runner/mechanics.js b/pages/runner/mechanics.js new file mode 100644 index 0000000..3892458 --- /dev/null +++ b/pages/runner/mechanics.js @@ -0,0 +1,21 @@ +// Pure game mechanics with no DOM dependency, unit-tested under `node --test`. + +// Axis-aligned bounding-box overlap. Boxes are { x, y, w, h } with x,y at the +// top-left. Edge-only contact does not count as a collision. +export function aabb(a, b) { + return a.x < b.x + b.w && + a.x + a.w > b.x && + a.y < b.y + b.h && + a.y + a.h > b.y; +} + +// World scroll speed (pixels per 60fps frame) as a function of score: starts at +// `base`, grows linearly, capped at `max` so the game stays playable. +export function speedForScore(score, base = 4, perPoint = 0.0025, max = 12) { + return Math.min(max, base + score * perPoint); +} + +// The larger of two scores; used to track the high score. +export function bestScore(a, b) { + return Math.max(a, b); +} diff --git a/pages/runner/runner.css b/pages/runner/runner.css new file mode 100644 index 0000000..9fc67e1 --- /dev/null +++ b/pages/runner/runner.css @@ -0,0 +1,36 @@ +.runner-canvas { + display: block; + width: 100%; + height: auto; + image-rendering: pixelated; + background: #6ba4ff; /* retro sky */ + touch-action: manipulation; /* avoid double-tap-zoom delaying tap-to-jump */ +} + +/* Mobile portrait: the runner window is fullscreen and taller than it is wide, + which squashes the 600x180 landscape game into a thin strip. Rotate the + canvas 90deg so the game runs along the long (vertical) screen axis. The game + reads no pointer coordinates (tap anywhere = jump; keyboard is + document-level), so this visual-only rotation needs no input remapping. The + 640px breakpoint matches the site's mobile boundary in scripts/viewport.js. */ +@media (max-width: 640px) and (orientation: portrait) { + /* Become the positioning context and clip the rotated canvas's (unrotated) + bounding box so it cannot introduce scrollbars. */ + .win98-content:has(> canvas.runner-canvas) { + position: relative; + overflow: hidden; + } + + .runner-canvas { + position: absolute; + top: 50%; + left: 50%; + /* Pre-rotation width becomes the on-screen height: fill the content height + (viewport minus the ~48px mobile taskbar, ~28px title bar, and chrome). + The 300vw cap keeps the on-screen width (= 0.3 x this) under ~90vw on + very narrow screens. height:auto preserves the 10:3 aspect ratio. */ + width: min(calc(100vh - 88px), 300vw); + height: auto; + transform: translate(-50%, -50%) rotate(90deg); + } +} diff --git a/pages/runner/runner.js b/pages/runner/runner.js new file mode 100644 index 0000000..2e025c3 --- /dev/null +++ b/pages/runner/runner.js @@ -0,0 +1,17 @@ +import { Game } from './game.js'; + +const root = document.querySelector('.win98-window[data-window-id="runner"]'); +const content = root?.querySelector('.win98-content'); + +if (content) { + const canvas = document.createElement('canvas'); + canvas.className = 'runner-canvas'; + canvas.width = 600; + canvas.height = 180; + content.appendChild(canvas); + + if (canvas.getContext('2d')) { + const game = new Game(canvas, root); + game.start(); + } +} diff --git a/scripts/konami.js b/scripts/konami.js new file mode 100644 index 0000000..51a0513 --- /dev/null +++ b/scripts/konami.js @@ -0,0 +1,36 @@ +// Konami code detector. The pure matcher (createKonamiMatcher) carries no DOM +// dependency so it is unit-testable under `node --test`; initKonami is the thin +// browser wrapper that feeds real keydown events into it. + +export const KONAMI_SEQUENCE = [ + 'ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', + 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'b', 'a', +]; + +// Single-character keys compare case-insensitively; named keys (ArrowUp, ...) +// are left as-is. +export function normalizeKey(key) { + return key.length === 1 ? key.toLowerCase() : key; +} + +// Returns a push(key) function tracking a rolling buffer of the last +// sequence.length keys; calls onMatch() whenever the buffer equals the sequence. +export function createKonamiMatcher(onMatch, sequence = KONAMI_SEQUENCE) { + const buf = []; + return function push(key) { + buf.push(normalizeKey(key)); + if (buf.length > sequence.length) buf.shift(); + if (buf.length === sequence.length && sequence.every((k, i) => k === buf[i])) { + buf.length = 0; + onMatch(); + } + }; +} + +// Attaches a single passive, bubble-phase keydown listener. Passive => it never +// calls preventDefault, so normal typing (including the terminal's ArrowUp +// history) is untouched; it only observes. +export function initKonami(onTrigger) { + const push = createKonamiMatcher(onTrigger); + document.addEventListener('keydown', (e) => push(e.key), { passive: true }); +} diff --git a/scripts/main.js b/scripts/main.js index 8ec9d07..1b3ba0e 100644 --- a/scripts/main.js +++ b/scripts/main.js @@ -1,6 +1,7 @@ import { WINDOWS } from './windows.js'; import { extractAssets } from './dom.js'; import { isMobile, onViewportChange } from './viewport.js'; +import { initKonami } from './konami.js'; // Exposed globally so page scripts (e.g. terminal.js) can call windowManager.open() window.windowManager = null; @@ -14,6 +15,15 @@ document.addEventListener('DOMContentLoaded', () => { iconGrid.render(); taskbar.render(); clock.start(); + + initKonami(() => window.windowManager.openDynamic({ + id: 'runner', + title: 'Cursor Runner', + icon: 'assets/cursors/default.png', + width: 640, + height: 280, + src: 'pages/runner/index.html', + })); }); // === WindowManager ============================================================ diff --git a/tests/konami.test.mjs b/tests/konami.test.mjs new file mode 100644 index 0000000..5bda51d --- /dev/null +++ b/tests/konami.test.mjs @@ -0,0 +1,57 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createKonamiMatcher, normalizeKey, KONAMI_SEQUENCE } from '../scripts/konami.js'; + +test('normalizeKey lowercases single characters and preserves named keys', () => { + assert.equal(normalizeKey('B'), 'b'); + assert.equal(normalizeKey('a'), 'a'); + assert.equal(normalizeKey('ArrowUp'), 'ArrowUp'); +}); + +test('fires onMatch exactly once for the full sequence', () => { + let count = 0; + const push = createKonamiMatcher(() => { count += 1; }); + for (const key of KONAMI_SEQUENCE) push(key); + assert.equal(count, 1); +}); + +test('accepts uppercase B/A via normalization', () => { + let count = 0; + const push = createKonamiMatcher(() => { count += 1; }); + const keys = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', + 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'B', 'A']; + for (const key of keys) push(key); + assert.equal(count, 1); +}); + +test('does not fire on a wrong/incomplete sequence', () => { + let count = 0; + const push = createKonamiMatcher(() => { count += 1; }); + for (const key of ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', 'b', 'a']) push(key); + assert.equal(count, 0); +}); + +test('matches when preceded by noise (rolling buffer)', () => { + let count = 0; + const push = createKonamiMatcher(() => { count += 1; }); + for (const key of ['x', 'ArrowDown', 'ArrowUp']) push(key); + for (const key of KONAMI_SEQUENCE) push(key); + assert.equal(count, 1); +}); + +test('does not fire on a near-miss (one pair swapped)', () => { + let count = 0; + const push = createKonamiMatcher(() => { count += 1; }); + const wrong = ['ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown', + 'ArrowRight', 'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'b', 'a']; + for (const key of wrong) push(key); + assert.equal(count, 0); +}); + +test('fires again when the sequence is entered a second time', () => { + let count = 0; + const push = createKonamiMatcher(() => { count += 1; }); + for (const key of KONAMI_SEQUENCE) push(key); + for (const key of KONAMI_SEQUENCE) push(key); + assert.equal(count, 2); +}); diff --git a/tests/runner-mechanics.test.mjs b/tests/runner-mechanics.test.mjs new file mode 100644 index 0000000..884e170 --- /dev/null +++ b/tests/runner-mechanics.test.mjs @@ -0,0 +1,32 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { aabb, speedForScore, bestScore } from '../pages/runner/mechanics.js'; + +test('aabb detects overlapping boxes', () => { + assert.equal(aabb({ x: 0, y: 0, w: 10, h: 10 }, { x: 5, y: 5, w: 10, h: 10 }), true); +}); + +test('aabb returns false for separated boxes', () => { + assert.equal(aabb({ x: 0, y: 0, w: 10, h: 10 }, { x: 20, y: 0, w: 10, h: 10 }), false); +}); + +test('aabb treats edge-only contact as no collision', () => { + assert.equal(aabb({ x: 0, y: 0, w: 10, h: 10 }, { x: 10, y: 0, w: 10, h: 10 }), false); +}); + +test('speedForScore returns the base speed at score 0', () => { + assert.equal(speedForScore(0), 4); +}); + +test('speedForScore increases with score but is capped', () => { + assert.ok(speedForScore(1000) > speedForScore(0)); + assert.equal(speedForScore(400), 5); // 4 + 400 * 0.0025 = 5 (exact slope) + assert.ok(speedForScore(3199) < 12); // just below the cap crossover + assert.equal(speedForScore(3200), 12); // cap reached exactly at crossover + assert.equal(speedForScore(1_000_000), 12); // far past the cap +}); + +test('bestScore returns the larger value', () => { + assert.equal(bestScore(3, 7), 7); + assert.equal(bestScore(9, 2), 9); +});