Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 7 additions & 1 deletion Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -32,6 +38,6 @@ tasks:

dev:
desc: Serve and open in browser
deps: [check]
deps: [test]
cmds:
- task: serve
203 changes: 203 additions & 0 deletions pages/runner/game.js
Original file line number Diff line number Diff line change
@@ -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';
}
}
}
2 changes: 2 additions & 0 deletions pages/runner/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<link rel="stylesheet" href="pages/runner/runner.css">
<script type="module" src="pages/runner/runner.js"></script>
21 changes: 21 additions & 0 deletions pages/runner/mechanics.js
Original file line number Diff line number Diff line change
@@ -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);
}
36 changes: 36 additions & 0 deletions pages/runner/runner.css
Original file line number Diff line number Diff line change
@@ -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);
}
}
17 changes: 17 additions & 0 deletions pages/runner/runner.js
Original file line number Diff line number Diff line change
@@ -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();
}
}
36 changes: 36 additions & 0 deletions scripts/konami.js
Original file line number Diff line number Diff line change
@@ -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 });
}
10 changes: 10 additions & 0 deletions scripts/main.js
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 ============================================================
Expand Down
Loading
Loading