From a76742de2169dd2f1c41babeb8cfb26b3ee7fc16 Mon Sep 17 00:00:00 2001 From: eti Date: Tue, 1 Sep 2026 10:47:34 +0200 Subject: [PATCH] web/error: turn the error page into SheepGame Signed-off-by: eti --- .../components/game/SheepGame.stories.svelte | 47 ++ web/src/lib/components/game/SheepGame.svelte | 297 ++++++++++++ .../components/game/Sprites.stories.svelte | 95 ++++ web/src/lib/components/game/engine.test.ts | 444 ++++++++++++++++++ web/src/lib/components/game/engine.ts | 330 +++++++++++++ web/src/lib/components/game/render.ts | 135 ++++++ web/src/lib/components/game/sprites.ts | 98 ++++ web/src/routes/+error.svelte | 41 +- 8 files changed, 1465 insertions(+), 22 deletions(-) create mode 100644 web/src/lib/components/game/SheepGame.stories.svelte create mode 100644 web/src/lib/components/game/SheepGame.svelte create mode 100644 web/src/lib/components/game/Sprites.stories.svelte create mode 100644 web/src/lib/components/game/engine.test.ts create mode 100644 web/src/lib/components/game/engine.ts create mode 100644 web/src/lib/components/game/render.ts create mode 100644 web/src/lib/components/game/sprites.ts diff --git a/web/src/lib/components/game/SheepGame.stories.svelte b/web/src/lib/components/game/SheepGame.stories.svelte new file mode 100644 index 000000000..2b295243d --- /dev/null +++ b/web/src/lib/components/game/SheepGame.stories.svelte @@ -0,0 +1,47 @@ + + + +
+ +
+
+ + +
+ + + +
+
+ + +
+ +
+
+ + +
+ +
+
diff --git a/web/src/lib/components/game/SheepGame.svelte b/web/src/lib/components/game/SheepGame.svelte new file mode 100644 index 000000000..ce1e29638 --- /dev/null +++ b/web/src/lib/components/game/SheepGame.svelte @@ -0,0 +1,297 @@ + + + diff --git a/web/src/lib/components/game/Sprites.stories.svelte b/web/src/lib/components/game/Sprites.stories.svelte new file mode 100644 index 000000000..9797c7533 --- /dev/null +++ b/web/src/lib/components/game/Sprites.stories.svelte @@ -0,0 +1,95 @@ + + +{#snippet sprite(name: SpriteName, scale: number)} + {@const def = SPRITES[name]} + + + +{/snippet} + +{#snippet scene()} + + + + + + + + + + + + + + + + + + + + + +{/snippet} + + +
+ {#each names as name (name)} +
+ {@render sprite(name, 3)} +
+ {@render sprite(name, 1)} + + {name} · {SPRITES[name].w}×{SPRITES[name].h} + +
+
+ {/each} +
+
+ + +
+ {#each names as name (name)} +
+ {@render sprite(name, 1)} + {name} +
+ {/each} +
+
+ + +
+
+ {@render scene()} +
+
+ {@render scene()} +
+
+
diff --git a/web/src/lib/components/game/engine.test.ts b/web/src/lib/components/game/engine.test.ts new file mode 100644 index 000000000..57f07c5d3 --- /dev/null +++ b/web/src/lib/components/game/engine.test.ts @@ -0,0 +1,444 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { OBSTACLES, PLAYER, type ObstacleKind } from "./sprites"; +import { + SheepEngine, + TUNING, + VIEW, + obstacleBox, + playerBox, + type GameState, + type ObstacleState +} from "./engine"; + +function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const V = -TUNING.jumpVelocity; +const AIR_TIME = (2 * V) / TUNING.gravity; + +function timeToRise(height: number): number { + return (V - Math.sqrt(V * V - 2 * TUNING.gravity * height)) / TUNING.gravity; +} + +function timeToFall(height: number): number { + return (V + Math.sqrt(V * V - 2 * TUNING.gravity * height)) / TUNING.gravity; +} + +function clearHeight(kind: ObstacleKind): number { + const def = OBSTACLES[kind]; + return def.h - def.inset + 1; +} + +const PLAYER_BOX_W = PLAYER.w - PLAYER.inset.left - PLAYER.inset.right; + +function boxWidth(kind: ObstacleKind): number { + const def = OBSTACLES[kind]; + return def.w - 2 * def.inset; +} + +function earliestJump(kind: ObstacleKind, speed: number): number { + return speed * timeToRise(clearHeight(kind)); +} + +function latestJump(kind: ObstacleKind, speed: number): number { + return speed * timeToFall(clearHeight(kind)) - boxWidth(kind) - PLAYER_BOX_W; +} + +function shouldJump(state: GameState): boolean { + const player = playerBox(state.player); + for (const obstacle of state.obstacles) { + const box = obstacleBox(obstacle); + if (box.left <= player.right) continue; + const min = earliestJump(obstacle.kind, state.speed); + const max = latestJump(obstacle.kind, state.speed); + return box.left - player.right <= (min + max) / 2; + } + return false; +} + +function passed(kind: ObstacleKind): ObstacleState { + return { kind, x: TUNING.playerX - OBSTACLES[kind].w - 1, counted: false }; +} + +function running(rng: () => number = () => 0.5): SheepEngine { + const engine = new SheepEngine({ rng }); + engine.start(); + return engine; +} + +function jumpApex(engine: SheepEngine, releaseAfterMs = 0): number { + engine.jumpPress(); + let apex = engine.state.player.y; + for (let ms = 0; ms < 2000 && !engine.state.player.grounded; ms++) { + engine.tick(1); + if (releaseAfterMs > 0 && ms === releaseAfterMs) engine.jumpRelease(); + apex = Math.min(apex, engine.state.player.y); + } + return VIEW.ground - apex; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("phases", () => { + it("starts idle and ignores ticks until started", () => { + const engine = new SheepEngine({ rng: () => 0.5 }); + engine.tick(100); + engine.tick(100); + expect(engine.state.phase).toBe("idle"); + expect(engine.state.groundOffset).toBe(0); + expect(engine.state.obstacles).toHaveLength(0); + }); + + it("runs after start and the first press only starts the game", () => { + const engine = new SheepEngine({ rng: () => 0.5 }); + engine.jumpPress(); + expect(engine.state.phase).toBe("running"); + expect(engine.state.player.grounded).toBe(true); + engine.jumpPress(); + expect(engine.state.player.grounded).toBe(false); + }); + + it("only restarts from dead once the lockout has elapsed", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + const engine = running(); + engine.state.obstacles.push({ kind: "standing", x: TUNING.playerX, counted: false }); + engine.tick(16); + expect(engine.state.phase).toBe("dead"); + + vi.setSystemTime(TUNING.restartLockoutMs - 1); + engine.start(); + expect(engine.state.phase).toBe("dead"); + + vi.setSystemTime(TUNING.restartLockoutMs + 1); + engine.start(); + expect(engine.state.phase).toBe("running"); + expect(engine.state.score).toBe(0); + expect(engine.state.obstacles).toHaveLength(0); + }); + + it("ignores ticks while dead", () => { + const engine = running(); + engine.state.obstacles.push({ kind: "standing", x: TUNING.playerX, counted: false }); + engine.tick(16); + const offset = engine.state.groundOffset; + engine.tick(100); + expect(engine.state.phase).toBe("dead"); + expect(engine.state.groundOffset).toBe(offset); + }); + + it("reset restores a fresh state", () => { + const engine = running(mulberry32(7)); + for (let i = 0; i < 100; i++) engine.tick(16); + engine.jumpPress(); + engine.tick(16); + engine.state.obstacles.push(passed("pair")); + engine.tick(16); + expect(engine.state.score).toBe(2); + + engine.reset(); + const s = engine.state; + expect(s.phase).toBe("idle"); + expect(s.score).toBe(0); + expect(s.night).toBe(false); + expect(s.speed).toBe(TUNING.baseSpeed); + expect(s.obstacles).toHaveLength(0); + expect(s.groundOffset).toBe(0); + expect(s.player).toEqual({ y: VIEW.ground, vy: 0, frame: 0, grounded: true }); + expect(s.clouds).toHaveLength(TUNING.cloudCount); + expect(s.stars).toHaveLength(TUNING.starCount); + }); +}); + +describe("physics", () => { + it("returns exactly to the ground and re-grounds after a jump", () => { + const engine = running(); + const apex = jumpApex(engine); + expect(engine.state.player.grounded).toBe(true); + expect(engine.state.player.y).toBe(VIEW.ground); + expect(engine.state.player.vy).toBe(0); + expect(apex).toBeGreaterThan(115); + expect(apex).toBeLessThan(125); + }); + + it("stays airborne for roughly the analytic air time", () => { + const engine = running(); + engine.jumpPress(); + let ms = 0; + while (!engine.state.player.grounded && ms < 2000) { + engine.tick(1); + ms++; + } + expect(ms).toBeGreaterThan(AIR_TIME * 1000 - 20); + expect(ms).toBeLessThan(AIR_TIME * 1000 + 20); + }); + + it("short hop lowers the apex", () => { + const full = jumpApex(running()); + const short = jumpApex(running(), 40); + expect(short).toBeLessThan(full * 0.75); + expect(short).toBeGreaterThan(0); + }); + + it("ignores a release while grounded or descending", () => { + const engine = running(); + engine.jumpRelease(); + expect(engine.state.player.vy).toBe(0); + engine.jumpPress(); + for (let i = 0; i < 400; i++) engine.tick(1); + const descending = engine.state.player.vy; + expect(descending).toBeGreaterThan(0); + engine.jumpRelease(); + expect(engine.state.player.vy).toBe(descending); + }); + + it("clamps dt to the tick cap", () => { + const engine = running(); + engine.jumpPress(); + engine.tick(5000); + const dt = TUNING.maxDtMs / 1000; + const vy = TUNING.jumpVelocity + TUNING.gravity * dt; + expect(engine.state.player.vy).toBeCloseTo(vy, 6); + expect(engine.state.player.y).toBeCloseTo(VIEW.ground + vy * dt, 6); + expect(engine.state.player.grounded).toBe(false); + }); + + it("toggles the run frame while grounded", () => { + const engine = running(); + expect(engine.state.player.frame).toBe(0); + engine.tick(60); + engine.tick(59); + expect(engine.state.player.frame).toBe(0); + engine.tick(2); + expect(engine.state.player.frame).toBe(1); + engine.tick(60); + engine.tick(60); + expect(engine.state.player.frame).toBe(0); + }); + + it("ramps speed with score up to the cap", () => { + const engine = running(); + engine.tick(16); + expect(engine.state.speed).toBe(TUNING.baseSpeed); + engine.state.score = 10; + engine.tick(16); + expect(engine.state.speed).toBe(TUNING.baseSpeed + 60); + engine.state.score = 1000; + engine.tick(16); + expect(engine.state.speed).toBe(TUNING.maxSpeed); + }); +}); + +describe("collision", () => { + it("dies on overlap and reports the score once", () => { + const engine = running(); + const overs: number[] = []; + engine.onGameOver = (score) => overs.push(score); + engine.state.obstacles.push(passed("standing")); + engine.tick(1); + expect(engine.state.score).toBe(1); + engine.state.obstacles.push({ kind: "standing", x: TUNING.playerX, counted: false }); + engine.tick(1); + engine.tick(1); + engine.tick(1); + expect(engine.state.phase).toBe("dead"); + expect(overs).toEqual([1]); + }); + + it("lets a horizontal graze through thanks to the insets", () => { + const engine = running(); + const raw = TUNING.playerX + PLAYER.w - 3; + engine.state.obstacles.push({ kind: "standing", x: raw, counted: false }); + expect(raw).toBeLessThan(TUNING.playerX + PLAYER.w); + engine.tick(1); + expect(engine.state.phase).toBe("running"); + }); + + it("lets a vertical graze through thanks to the insets", () => { + const engine = running(); + const def = OBSTACLES.standing; + engine.state.obstacles.push({ kind: "standing", x: TUNING.playerX, counted: false }); + engine.state.player.y = VIEW.ground - def.h + def.inset - 1; + engine.state.player.grounded = false; + engine.state.player.vy = 0; + expect(engine.state.player.y).toBeGreaterThan(VIEW.ground - def.h); + engine.tick(1); + expect(engine.state.phase).toBe("running"); + }); + + it("dies when the same box actually overlaps after the insets", () => { + const engine = running(); + const def = OBSTACLES.standing; + engine.state.obstacles.push({ kind: "standing", x: TUNING.playerX, counted: false }); + engine.state.player.y = VIEW.ground - def.h + def.inset + 1; + engine.state.player.grounded = false; + engine.state.player.vy = 0; + engine.tick(1); + expect(engine.state.phase).toBe("dead"); + }); +}); + +describe("scoring", () => { + it("awards the sheep count of each kind", () => { + const cases: [ObstacleKind, number][] = [ + ["standing", 1], + ["sleeping", 1], + ["pair", 2], + ["stack", 2] + ]; + for (const [kind, count] of cases) { + const engine = running(); + const scores: number[] = []; + engine.onScore = (score) => scores.push(score); + engine.state.obstacles.push(passed(kind)); + engine.tick(1); + engine.tick(1); + expect(scores).toEqual([count]); + expect(engine.state.score).toBe(count); + } + }); + + it("fires a milestone every 10 and flips night every 25", () => { + const engine = running(); + const milestones: number[] = []; + const flips: boolean[] = []; + engine.onMilestone = (score) => milestones.push(score); + engine.onNightFlip = (night) => flips.push(night); + for (let i = 0; i < 50; i++) { + engine.state.obstacles.push(passed("standing")); + engine.tick(1); + } + expect(engine.state.score).toBe(50); + expect(milestones).toEqual([10, 20, 30, 40, 50]); + expect(flips).toEqual([true, false]); + expect(engine.state.night).toBe(false); + }); + + it("crosses a milestone that a two-sheep obstacle skips over", () => { + const engine = running(); + const milestones: number[] = []; + engine.onMilestone = (score) => milestones.push(score); + for (let i = 0; i < 9; i++) { + engine.state.obstacles.push(passed("standing")); + engine.tick(1); + } + expect(milestones).toEqual([]); + engine.state.obstacles.push(passed("pair")); + engine.tick(1); + expect(engine.state.score).toBe(11); + expect(milestones).toEqual([11]); + }); +}); + +describe("spawner", () => { + it("keeps stacks out until the score threshold", () => { + const engine = running(mulberry32(3)); + const seen = new Set(); + for (let i = 0; i < 12000; i++) { + for (const obstacle of engine.state.obstacles) seen.add(obstacle.kind); + engine.state.obstacles.length = 0; + engine.state.score = TUNING.stackMinScore - 1; + engine.tick(16); + } + expect(seen).toEqual(new Set(["standing", "sleeping", "pair"])); + }); + + it("spawns every kind once stacks are unlocked", () => { + const engine = running(mulberry32(11)); + engine.state.score = TUNING.stackMinScore; + const seen = new Set(); + for (let i = 0; i < 12000; i++) { + for (const obstacle of engine.state.obstacles) seen.add(obstacle.kind); + engine.state.obstacles.length = 0; + engine.state.score = TUNING.stackMinScore; + engine.tick(16); + } + expect(seen).toEqual(new Set(["standing", "sleeping", "pair", "stack"])); + }); + + it("leaves every minimum gap clearable at both ends of the speed ramp", () => { + const kinds = Object.keys(OBSTACLES) as ObstacleKind[]; + for (const speed of [TUNING.baseSpeed, TUNING.maxSpeed]) { + const gap = TUNING.spawnGapBase + speed * TUNING.spawnGapSpeedFactor; + for (const first of kinds) { + const min = earliestJump(first, speed); + const max = latestJump(first, speed); + expect(max).toBeGreaterThan(min); + for (const second of kinds) { + const landing = speed * AIR_TIME - min; + expect(landing).toBeLessThan(gap - earliestJump(second, speed)); + } + } + } + }); + + it("never kills a perfect jumper over 200 obstacles up to cap speed", () => { + const engine = running(mulberry32(1337)); + let cleared = 0; + engine.onScore = () => { + cleared++; + }; + for (let i = 0; i < 40000 && engine.state.phase === "running" && cleared < 220; i++) { + if (engine.state.player.grounded && shouldJump(engine.state)) engine.jumpPress(); + engine.tick(16); + } + expect(engine.state.phase).toBe("running"); + expect(cleared).toBeGreaterThanOrEqual(200); + expect(engine.state.speed).toBe(TUNING.maxSpeed); + }); + + it("survives the tightest gaps a minimal rng can produce", () => { + const engine = running(() => 0); + let cleared = 0; + engine.onScore = () => { + cleared++; + }; + for (let i = 0; i < 40000 && engine.state.phase === "running" && cleared < 220; i++) { + if (engine.state.player.grounded && shouldJump(engine.state)) engine.jumpPress(); + engine.tick(16); + } + expect(engine.state.phase).toBe("running"); + expect(cleared).toBeGreaterThanOrEqual(200); + }); + + it("drops obstacles once they leave the field", () => { + const engine = running(mulberry32(5)); + for (let i = 0; i < 3000; i++) { + engine.state.player.y = VIEW.ground - 200; + engine.state.player.grounded = false; + engine.state.player.vy = 0; + engine.tick(16); + } + expect(engine.state.phase).toBe("running"); + expect(engine.state.obstacles.length).toBeLessThan(6); + for (const obstacle of engine.state.obstacles) { + expect(obstacle.x + OBSTACLES[obstacle.kind].w).toBeGreaterThanOrEqual(0); + } + }); +}); + +describe("scenery", () => { + it("drifts clouds and stars slower than the ground and wraps them", () => { + const engine = running(mulberry32(9)); + const cloud = { ...engine.state.clouds[0] }; + const star = { ...engine.state.stars[0] }; + engine.tick(100); + const dx = engine.state.speed * 0.1; + expect(engine.state.groundOffset).toBeCloseTo(dx % TUNING.groundTile, 6); + expect(cloud.x - engine.state.clouds[0].x).toBeCloseTo(dx * TUNING.cloudSpeedFactor, 6); + expect(star.x - engine.state.stars[0].x).toBeCloseTo(dx * TUNING.starSpeedFactor, 6); + + for (let i = 0; i < 4000; i++) engine.tick(16); + for (const c of engine.state.clouds) expect(c.x).toBeGreaterThan(-40); + for (const s of engine.state.stars) expect(s.x).toBeGreaterThan(-10); + }); +}); diff --git a/web/src/lib/components/game/engine.ts b/web/src/lib/components/game/engine.ts new file mode 100644 index 000000000..4097a8b3f --- /dev/null +++ b/web/src/lib/components/game/engine.ts @@ -0,0 +1,330 @@ +import { OBSTACLES, PLAYER, SPRITES, type ObstacleKind } from "./sprites"; + +export type Phase = "idle" | "running" | "dead"; + +export interface PlayerState { + y: number; + vy: number; + frame: 0 | 1; + grounded: boolean; +} + +export interface ObstacleState { + kind: ObstacleKind; + x: number; + counted: boolean; +} + +export interface GameState { + phase: Phase; + score: number; + night: boolean; + speed: number; + player: PlayerState; + obstacles: ObstacleState[]; + clouds: { x: number; y: number }[]; + stars: { x: number; y: number }[]; + groundOffset: number; +} + +export const VIEW = { w: 640, h: 200, ground: 170 }; + +export const TUNING = { + gravity: 2400, + jumpVelocity: -760, + shortHopFactor: 0.45, + maxDtMs: 100, + runFrameMs: 120, + playerX: 56, + baseSpeed: 240, + speedPerPoint: 6, + maxSpeed: 520, + firstSpawnDistance: 240, + spawnGapBase: 260, + spawnGapSpeedFactor: 0.35, + spawnGapJitter: 220, + pairMinScore: 4, + stackMinScore: 8, + milestoneEvery: 10, + nightEvery: 25, + restartLockoutMs: 250, + groundTile: 48, + cloudCount: 3, + cloudSpeedFactor: 0.25, + cloudBandTop: 26, + cloudBandHeight: 52, + starCount: 14, + starSpeedFactor: 0.06, + starBandTop: 10, + starBandHeight: 96 +}; + +const KIND_WEIGHTS: { kind: ObstacleKind; weight: number }[] = [ + { kind: "standing", weight: 45 }, + { kind: "sleeping", weight: 25 }, + { kind: "pair", weight: 20 }, + { kind: "stack", weight: 10 } +]; + +export interface Box { + left: number; + right: number; + top: number; + bottom: number; +} + +export function playerBox(player: PlayerState): Box { + const inset = PLAYER.inset; + return { + left: TUNING.playerX + inset.left, + right: TUNING.playerX + PLAYER.w - inset.right, + top: player.y - PLAYER.h + inset.top, + bottom: player.y - inset.bottom + }; +} + +export function obstacleBox(obstacle: ObstacleState): Box { + const def = OBSTACLES[obstacle.kind]; + return { + left: obstacle.x + def.inset, + right: obstacle.x + def.w - def.inset, + top: VIEW.ground - def.h + def.inset, + bottom: VIEW.ground - def.inset + }; +} + +function overlaps(a: Box, b: Box): boolean { + return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; +} + +export interface SheepEngineOptions { + rng?: () => number; +} + +export class SheepEngine { + readonly state: GameState; + + onScore?: (score: number) => void; + onMilestone?: (score: number) => void; + onNightFlip?: (night: boolean) => void; + onGameOver?: (score: number) => void; + + private readonly rng: () => number; + private spawnCountdown = 0; + private frameTimer = 0; + private diedAt = 0; + + constructor(opts: SheepEngineOptions = {}) { + this.rng = opts.rng ?? Math.random; + this.state = { + phase: "idle", + score: 0, + night: false, + speed: TUNING.baseSpeed, + player: { y: VIEW.ground, vy: 0, frame: 0, grounded: true }, + obstacles: [], + clouds: [], + stars: [], + groundOffset: 0 + }; + this.reset(); + } + + start(): void { + const s = this.state; + if (s.phase === "running") return; + if (s.phase === "dead") { + if (Date.now() - this.diedAt < TUNING.restartLockoutMs) return; + this.reset(); + } + s.phase = "running"; + } + + reset(): void { + const s = this.state; + s.phase = "idle"; + s.score = 0; + s.night = false; + s.speed = TUNING.baseSpeed; + s.player.y = VIEW.ground; + s.player.vy = 0; + s.player.frame = 0; + s.player.grounded = true; + s.obstacles.length = 0; + s.groundOffset = 0; + this.spawnCountdown = TUNING.firstSpawnDistance; + this.frameTimer = 0; + this.diedAt = 0; + this.seedScenery(); + } + + jumpPress(): void { + const s = this.state; + if (s.phase !== "running") { + this.start(); + return; + } + if (!s.player.grounded) return; + s.player.vy = TUNING.jumpVelocity; + s.player.grounded = false; + s.player.frame = 0; + this.frameTimer = 0; + } + + jumpRelease(): void { + const p = this.state.player; + if (this.state.phase !== "running") return; + if (p.grounded || p.vy >= 0) return; + p.vy *= TUNING.shortHopFactor; + } + + tick(dtMs: number): void { + const s = this.state; + if (s.phase !== "running") return; + const dt = Math.min(dtMs, TUNING.maxDtMs) / 1000; + if (dt <= 0) return; + + s.speed = Math.min(TUNING.maxSpeed, TUNING.baseSpeed + s.score * TUNING.speedPerPoint); + const dx = s.speed * dt; + + this.stepPlayer(dt); + this.stepScenery(dx); + this.stepObstacles(dx); + + if (this.hitsObstacle()) { + s.phase = "dead"; + s.player.frame = 0; + this.diedAt = Date.now(); + this.onGameOver?.(s.score); + return; + } + + this.countPassed(); + } + + private stepPlayer(dt: number): void { + const p = this.state.player; + if (p.grounded) { + this.frameTimer += dt * 1000; + while (this.frameTimer >= TUNING.runFrameMs) { + this.frameTimer -= TUNING.runFrameMs; + p.frame = p.frame === 0 ? 1 : 0; + } + return; + } + p.vy += TUNING.gravity * dt; + p.y += p.vy * dt; + if (p.y >= VIEW.ground) { + p.y = VIEW.ground; + p.vy = 0; + p.grounded = true; + this.frameTimer = 0; + } + } + + private stepScenery(dx: number): void { + const s = this.state; + s.groundOffset = (s.groundOffset + dx) % TUNING.groundTile; + const cloudW = SPRITES.cloud.w; + for (const cloud of s.clouds) { + cloud.x -= dx * TUNING.cloudSpeedFactor; + if (cloud.x + cloudW < 0) { + cloud.x = VIEW.w + this.rng() * 80; + cloud.y = TUNING.cloudBandTop + this.rng() * TUNING.cloudBandHeight; + } + } + for (const star of s.stars) { + star.x -= dx * TUNING.starSpeedFactor; + if (star.x < -SPRITES.star.w) star.x += VIEW.w + SPRITES.star.w; + } + } + + private stepObstacles(dx: number): void { + const s = this.state; + for (const obstacle of s.obstacles) obstacle.x -= dx; + for (let i = s.obstacles.length - 1; i >= 0; i--) { + const obstacle = s.obstacles[i]; + if (obstacle.x + OBSTACLES[obstacle.kind].w < 0) s.obstacles.splice(i, 1); + } + this.spawnCountdown -= dx; + while (this.spawnCountdown <= 0) { + s.obstacles.push({ + kind: this.pickKind(), + x: VIEW.w + this.spawnCountdown, + counted: false + }); + this.spawnCountdown += this.nextGap(); + } + } + + private nextGap(): number { + const minGap = TUNING.spawnGapBase + this.state.speed * TUNING.spawnGapSpeedFactor; + return minGap + this.rng() * TUNING.spawnGapJitter; + } + + private pickKind(): ObstacleKind { + const pool = KIND_WEIGHTS.filter((entry) => { + if (entry.kind === "stack") return this.state.score >= TUNING.stackMinScore; + if (entry.kind === "pair") return this.state.score >= TUNING.pairMinScore; + return true; + }); + const total = pool.reduce((sum, entry) => sum + entry.weight, 0); + let roll = this.rng() * total; + for (const entry of pool) { + roll -= entry.weight; + if (roll < 0) return entry.kind; + } + return pool[pool.length - 1].kind; + } + + private hitsObstacle(): boolean { + const box = playerBox(this.state.player); + for (const obstacle of this.state.obstacles) { + if (overlaps(box, obstacleBox(obstacle))) return true; + } + return false; + } + + private countPassed(): void { + for (const obstacle of this.state.obstacles) { + if (obstacle.counted) continue; + if (obstacle.x + OBSTACLES[obstacle.kind].w >= TUNING.playerX) continue; + obstacle.counted = true; + this.award(OBSTACLES[obstacle.kind].count); + } + } + + private award(gain: number): void { + const s = this.state; + const before = s.score; + s.score = before + gain; + this.onScore?.(s.score); + const milestone = TUNING.milestoneEvery; + if (Math.floor(s.score / milestone) > Math.floor(before / milestone)) { + this.onMilestone?.(s.score); + } + const night = TUNING.nightEvery; + if (Math.floor(s.score / night) > Math.floor(before / night)) { + s.night = !s.night; + this.onNightFlip?.(s.night); + } + } + + private seedScenery(): void { + const s = this.state; + s.clouds.length = 0; + for (let i = 0; i < TUNING.cloudCount; i++) { + s.clouds.push({ + x: (VIEW.w / TUNING.cloudCount) * i + this.rng() * 120, + y: TUNING.cloudBandTop + this.rng() * TUNING.cloudBandHeight + }); + } + s.stars.length = 0; + for (let i = 0; i < TUNING.starCount; i++) { + s.stars.push({ + x: this.rng() * VIEW.w, + y: TUNING.starBandTop + this.rng() * TUNING.starBandHeight + }); + } + } +} diff --git a/web/src/lib/components/game/render.ts b/web/src/lib/components/game/render.ts new file mode 100644 index 000000000..d33413ba6 --- /dev/null +++ b/web/src/lib/components/game/render.ts @@ -0,0 +1,135 @@ +import { VIEW, type GameState } from "./engine"; +import { OBSTACLES, SPRITES, type ObstacleKind, type SpriteName } from "./sprites"; + +export interface Palette { + canvas: string; + ink: string; + muted: string; + dark: boolean; +} + +export interface Dims { + w: number; + h: number; +} + +export const PLAYER_X = 56; + +const OBSTACLE_SPRITE: Record = { + standing: "sheepStanding", + sleeping: "sheepSleeping", + pair: "sheepStanding", + stack: "sheepStack" +}; + +const PEBBLE_PERIOD = 960; + +const PEBBLES = (() => { + let seed = 1337; + const rand = () => (seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296; + return Array.from({ length: 48 }, () => ({ + d: rand() * PEBBLE_PERIOD, + len: 2 + Math.floor(rand() * 7), + dy: 4 + Math.floor(rand() * 7) + })); +})(); + +const paths = new Map(); + +const pathFor = (name: SpriteName) => { + let path = paths.get(name); + if (!path) { + path = new Path2D(SPRITES[name].path); + paths.set(name, path); + } + return path; +}; + +const drawSprite = (ctx: CanvasRenderingContext2D, name: SpriteName, x: number, y: number) => { + ctx.save(); + ctx.translate(Math.round(x), Math.round(y)); + ctx.fill(pathFor(name), "evenodd"); + ctx.restore(); +}; + +const drawOnGround = (ctx: CanvasRenderingContext2D, name: SpriteName, x: number) => + drawSprite(ctx, name, x, VIEW.ground - SPRITES[name].h); + +const readVar = (styles: CSSStyleDeclaration, name: string, fallback: string) => + styles.getPropertyValue(name).trim() || fallback; + +const luminance = (color: string): number | null => { + const rgb = color.match(/^rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)/); + if (rgb) return (0.2126 * +rgb[1] + 0.7152 * +rgb[2] + 0.0722 * +rgb[3]) / 255; + if (!color.startsWith("#")) return null; + let hex = color.slice(1); + if (hex.length === 3) hex = hex.replace(/./g, (c) => c + c); + if (hex.length < 6) return null; + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + if ([r, g, b].some(Number.isNaN)) return null; + return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255; +}; + +export const readPalette = (el: HTMLElement): Palette => { + const styles = getComputedStyle(el); + const canvas = readVar(styles, "--color-background-canvas", "#fff"); + return { + canvas, + ink: readVar(styles, "--color-foreground-default", "#000"), + muted: readVar(styles, "--color-foreground-muted", "#666"), + dark: (luminance(canvas) ?? 1) < 0.5 + }; +}; + +const drawGround = (ctx: CanvasRenderingContext2D, offset: number, width: number) => { + ctx.fillRect(0, VIEW.ground, width, 1); + for (const pebble of PEBBLES) { + const x = (((pebble.d - offset) % PEBBLE_PERIOD) + PEBBLE_PERIOD) % PEBBLE_PERIOD; + if (x > width) continue; + ctx.fillRect(Math.round(x), VIEW.ground + pebble.dy, pebble.len, 1); + } +}; + +const drawObstacle = (ctx: CanvasRenderingContext2D, kind: ObstacleKind, x: number) => { + const sprite = OBSTACLE_SPRITE[kind]; + drawOnGround(ctx, sprite, x); + if (kind === "pair") drawOnGround(ctx, sprite, x + OBSTACLES.pair.w - SPRITES[sprite].w); +}; + +const playerSprite = (state: GameState): SpriteName => { + if (state.phase === "dead") return "runnerDead"; + if (!state.player.grounded) return "runnerJump"; + return state.player.frame === 1 ? "runnerB" : "runnerA"; +}; + +export const renderFrame = ( + ctx: CanvasRenderingContext2D, + state: GameState, + palette: Palette, + dims: Dims +) => { + const inverted = state.night && !palette.dark; + const sky = inverted ? palette.ink : palette.canvas; + const ink = inverted ? palette.canvas : palette.ink; + + ctx.clearRect(0, 0, dims.w, dims.h); + ctx.fillStyle = sky; + ctx.fillRect(0, 0, dims.w, dims.h); + ctx.fillStyle = ink; + + if (state.night) { + for (const star of state.stars) drawSprite(ctx, "star", star.x, star.y); + drawSprite(ctx, "moon", dims.w - 88, 24); + } else { + for (const cloud of state.clouds) drawSprite(ctx, "cloud", cloud.x, cloud.y); + } + + drawGround(ctx, state.groundOffset, dims.w); + + for (const obstacle of state.obstacles) drawObstacle(ctx, obstacle.kind, obstacle.x); + + const sprite = playerSprite(state); + drawSprite(ctx, sprite, PLAYER_X, state.player.y - SPRITES[sprite].h); +}; diff --git a/web/src/lib/components/game/sprites.ts b/web/src/lib/components/game/sprites.ts new file mode 100644 index 000000000..700c22b4a --- /dev/null +++ b/web/src/lib/components/game/sprites.ts @@ -0,0 +1,98 @@ +export type SpriteName = + | "runnerA" + | "runnerB" + | "runnerJump" + | "runnerDead" + | "sheepStanding" + | "sheepSleeping" + | "sheepStack" + | "cloud" + | "moon" + | "star"; + +export interface SpriteDef { + path: string; + w: number; + h: number; +} + +export type ObstacleKind = "standing" | "sleeping" | "pair" | "stack"; + +export interface ObstacleDef { + w: number; + h: number; + count: number; + inset: number; +} + +export interface PlayerDef { + w: number; + h: number; + inset: { left: number; right: number; top: number; bottom: number }; +} + +export const PLAYER: PlayerDef = { + w: 44, + h: 32, + inset: { left: 4, right: 4, top: 3, bottom: 0 } +}; + +export const OBSTACLES: Record = { + standing: { w: 40, h: 30, count: 1, inset: 3 }, + sleeping: { w: 44, h: 22, count: 1, inset: 3 }, + pair: { w: 82, h: 30, count: 2, inset: 3 }, + stack: { w: 40, h: 50, count: 2, inset: 3 } +}; + +export const SPRITES: Record = { + runnerA: { + path: "M1.8 17A5.27 5.27 0 0 1 6.5 8A4.77 4.77 0 0 1 15 4.5A4.92 4.92 0 0 1 24 7A2.9 2.9 0 0 0 28.6 9.8A2.66 2.66 0 0 1 31.2 5.2A3.68 3.68 0 0 1 37.4 6.2A4.84 4.84 0 0 1 42.8 12A4.01 4.01 0 0 1 37.6 16.8A3.21 3.21 0 0 0 31.8 15.4A4 4 0 0 1 29.4 22.6L33.4 32L28.6 32L25.4 25A4.76 4.76 0 0 1 19.5 26.4A3.87 3.87 0 0 1 14.6 25.8L10.2 32L5.4 32L7.4 23.8A3.38 3.38 0 0 1 2.4 20A1.55 1.55 0 0 1 1.8 17ZM36.38 12.84A2.2 1.05 -68 0 1 38.02 8.76A2.2 1.05 -68 0 1 36.38 12.84Z", + w: 44, + h: 32 + }, + runnerB: { + path: "M1.8 17A5.27 5.27 0 0 1 6.5 8A4.77 4.77 0 0 1 15 4.5A4.92 4.92 0 0 1 24 7A2.9 2.9 0 0 0 28.6 9.8A2.66 2.66 0 0 1 31.2 5.2A3.68 3.68 0 0 1 37.4 6.2A4.84 4.84 0 0 1 42.8 12A4.01 4.01 0 0 1 37.6 16.8A3.21 3.21 0 0 0 31.8 15.4A4 4 0 0 1 29.4 22.6L29.8 32L25 32L25.4 25A4.76 4.76 0 0 1 19.5 26.4A3.87 3.87 0 0 1 14.6 25.8L15.8 32L11 32L7.4 23.8A3.38 3.38 0 0 1 2.4 20A1.55 1.55 0 0 1 1.8 17ZM36.38 12.84A2.2 1.05 -68 0 1 38.02 8.76A2.2 1.05 -68 0 1 36.38 12.84Z", + w: 44, + h: 32 + }, + runnerJump: { + path: "M1.8 17A5.27 5.27 0 0 1 6.5 8A4.77 4.77 0 0 1 15 4.5A4.92 4.92 0 0 1 24 7A2.9 2.9 0 0 0 28.6 9.8A2.66 2.66 0 0 1 31.2 5.2A3.68 3.68 0 0 1 37.4 6.2A4.84 4.84 0 0 1 42.8 12A4.01 4.01 0 0 1 37.6 16.8A3.21 3.21 0 0 0 31.8 15.4A4 4 0 0 1 29.4 22.6L32.8 28L28.8 30L25.4 25A4.76 4.76 0 0 1 19.5 26.4A3.87 3.87 0 0 1 14.6 25.8L12.2 30.4L8.2 28.4L7.4 23.8A3.38 3.38 0 0 1 2.4 20A1.55 1.55 0 0 1 1.8 17ZM36.38 12.84A2.2 1.05 -68 0 1 38.02 8.76A2.2 1.05 -68 0 1 36.38 12.84Z", + w: 44, + h: 32 + }, + runnerDead: { + path: "M1.8 17A5.27 5.27 0 0 1 6.5 8A4.77 4.77 0 0 1 15 4.5A4.92 4.92 0 0 1 24 7A2.9 2.9 0 0 0 28.6 9.8A2.66 2.66 0 0 1 31.2 5.2A3.68 3.68 0 0 1 37.4 6.2A4.84 4.84 0 0 1 42.8 12A4.01 4.01 0 0 1 37.6 16.8A3.21 3.21 0 0 0 31.8 15.4A4 4 0 0 1 29.4 22.6L31.4 32L26.6 32L25.4 25A4.76 4.76 0 0 1 19.5 26.4A3.87 3.87 0 0 1 14.6 25.8L11.6 32L6.8 32L7.4 23.8A3.38 3.38 0 0 1 2.4 20A1.55 1.55 0 0 1 1.8 17ZM35.04 8.86A2.9 0.8 42 0 1 39.36 12.74A2.9 0.8 42 0 1 35.04 8.86ZM35.04 12.74A2.9 0.8 -42 0 1 39.36 8.86A2.9 0.8 -42 0 1 35.04 12.74Z", + w: 44, + h: 32 + }, + sheepStanding: { + path: "M1.4 14A3.3 3.3 0 0 1 3.6 8.6A2.86 2.86 0 0 1 8 5.6A2.23 2.23 0 0 1 12.4 5A4.37 4.37 0 0 1 20.5 3.2A4.54 4.54 0 0 1 29 4.6A4.65 4.65 0 0 1 35.8 9.6A3.77 3.77 0 0 1 37.6 16L34.8 30L31 30L30.4 21.8A2.11 2.11 0 0 1 26.6 22.8L25.8 30L22.2 30L23.2 21.8A4.34 4.34 0 0 1 15.6 22.8L14.8 30L11.2 30L12.2 20.8A1.77 1.77 0 0 1 9 20L8.8 30L5.2 30L6.4 18.4A2.21 2.21 0 0 1 3.4 15.6L1.2 16.4A1.08 1.08 0 0 1 0.6 14.6A0.57 0.57 0 0 1 1.4 14ZM4.81 8.65A2.1 1 68 0 1 6.39 12.55A2.1 1 68 0 1 4.81 8.65Z", + w: 40, + h: 30 + }, + sheepSleeping: { + path: "M1.4 16.4A2.57 2.57 0 0 1 3.2 12.4A2.78 2.78 0 0 1 7.4 9.4A2.47 2.47 0 0 0 11.6 9.2A3.92 3.92 0 0 1 18 5.4A4.76 4.76 0 0 1 27 4.8A4.43 4.43 0 0 1 35 6.8A4.63 4.63 0 0 1 41.6 12A4.17 4.17 0 0 1 42.8 18.4L42.8 22L8.2 22L6 20.2A3.5 3.5 0 0 1 1.4 16.4ZM2.93 13.04A2.1 0.6 10 0 1 7.07 13.76A2.1 0.6 10 0 1 2.93 13.04Z", + w: 44, + h: 22 + }, + sheepStack: { + path: "M1.4 34A3.3 3.3 0 0 1 3.6 28.6A2.86 2.86 0 0 1 8 25.6A2.23 2.23 0 0 1 12.4 25A4.37 4.37 0 0 1 20.5 23.2A4.54 4.54 0 0 1 29 24.6A4.65 4.65 0 0 1 35.8 29.6A3.77 3.77 0 0 1 37.6 36L34.8 50L31 50L30.4 41.8A2.11 2.11 0 0 1 26.6 42.8L25.8 50L22.2 50L23.2 41.8A4.34 4.34 0 0 1 15.6 42.8L14.8 50L11.2 50L12.2 40.8A1.77 1.77 0 0 1 9 40L8.8 50L5.2 50L6.4 38.4A2.21 2.21 0 0 1 3.4 35.6L1.2 36.4A1.08 1.08 0 0 1 0.6 34.6A0.57 0.57 0 0 1 1.4 34ZM4.81 28.65A2.1 1 68 0 1 6.39 32.55A2.1 1 68 0 1 4.81 28.65ZM4.84 15.24A2.36 2.36 0 0 1 6.64 11.64A2.31 2.31 0 0 1 10.24 9.3A1.9 1.9 0 0 0 13.48 9.48A3.24 3.24 0 0 1 18.7 6.24A3.8 3.8 0 0 1 25.9 6.06A3.56 3.56 0 0 1 32.02 8.58A3.01 3.01 0 0 1 34.54 13.26A3.73 3.73 0 0 1 30.4 17.76A8.75 8.75 0 0 1 21.4 19.02A8.67 8.67 0 0 1 12.4 18.84A2.74 2.74 0 0 1 8.44 17.22A2.51 2.51 0 0 1 4.84 15.24ZM6.13 12.95A1.8 0.54 10 0 1 9.67 13.57A1.8 0.54 10 0 1 6.13 12.95Z", + w: 40, + h: 50 + }, + cloud: { + path: "M2.2 9.6A3.09 3.09 0 0 1 6.2 5.2A4.01 4.01 0 0 1 13.6 3A4.74 4.74 0 0 1 22.4 3.4A4.29 4.29 0 0 1 29.8 6.4A2.88 2.88 0 0 1 33.4 10.2A8.57 8.57 0 0 1 26 12.6A14.3 14.3 0 0 1 15 12.8A9.72 9.72 0 0 1 6.2 12.2A3.06 3.06 0 0 1 2.2 9.6Z", + w: 36, + h: 14 + }, + moon: { + path: "M0 8A8 8 0 0 1 16 8A8 8 0 0 1 0 8ZM4.6 5.6A6.6 6.6 0 0 1 17.8 5.6A6.6 6.6 0 0 1 4.6 5.6Z", + w: 16, + h: 16 + }, + star: { + path: "M3 0C3.35 2 4 2.65 6 3C4 3.35 3.35 4 3 6C2.65 4 2 3.35 0 3C2 2.65 2.65 2 3 0Z", + w: 6, + h: 6 + } +}; diff --git a/web/src/routes/+error.svelte b/web/src/routes/+error.svelte index ed8749f43..e2769d00a 100644 --- a/web/src/routes/+error.svelte +++ b/web/src/routes/+error.svelte @@ -1,7 +1,9 @@