From 882d9613b3ed2dc015cbd72b0aedc6be331810d5 Mon Sep 17 00:00:00 2001 From: Chris Malpass Date: Wed, 26 Aug 2026 14:52:54 -0400 Subject: [PATCH] Add ghost race: replay your best run - GhostRecorder decimates player position to ~10 Hz tracks (capped 6000 pts) - Track persists per level when the run sets a new best score - Daily Rex tracks are date-stamped and only replay for the same daily seed - Translucent ghost Rex with a small tag races alongside the player - G key / menu button toggles the ghost (persisted, default on) - 12 new unit tests + 2 integration tests (123 total, all passing) --- src/game.ts | 56 ++++++++++++++++++++- src/ghost.ts | 118 ++++++++++++++++++++++++++++++++++++++++++++ src/input.ts | 2 + src/store.ts | 30 +++++++++++ tests/game.test.ts | 44 ++++++++++++++++- tests/ghost.test.ts | 115 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 362 insertions(+), 3 deletions(-) create mode 100644 src/ghost.ts create mode 100644 tests/ghost.test.ts diff --git a/src/game.ts b/src/game.ts index ed86ebb..dc0cd4d 100644 --- a/src/game.ts +++ b/src/game.ts @@ -1,7 +1,7 @@ import { CFG, VW, VH, TAU, FONT_STACK, DIFFICULTIES, STARS } from './config'; import type { Difficulty } from './config'; import { clamp, easeOutBack, fmtTime } from './util'; -import { Store, getStats, getBest, getBestStars, getDailyBest, getDailyStars } from './store'; +import { Store, getStats, getBest, getBestStars, getDailyBest, getDailyStars, getGhostEnabled, setGhostEnabled, getGhostTrack, saveGhostTrack } from './store'; import type { GameStats } from './store'; import { AudioManager } from './audio'; import { Input } from './input'; @@ -15,6 +15,7 @@ import type { ParticleType } from './particles'; import { LEVELS } from './level-data'; import type { LevelInfo } from './level-data'; import { generateDailyLevel, dailySeed, dailyLabel, rexCode } from './daily'; +import { GhostRecorder, GhostPlayer } from './ghost'; import { drawDecor } from './decor'; import { Sprite } from './sprite'; import type { GameCtx } from './ctx'; @@ -119,6 +120,10 @@ export class Game implements GameCtx { godMode = false; /** Cheat: rainbow Rex skin (persists across runs). */ rainbow = false; + /** Ghost race: replay the stored best run alongside the player. */ + ghostOn: boolean = getGhostEnabled(); + private ghost: GhostPlayer | null = null; + private ghostRec: GhostRecorder | null = null; /** Cheat queue: apply max hearts once a player exists. */ private maxHeartsCheat = false; private readonly cheats = new CheatSystem(); @@ -237,6 +242,13 @@ export class Game implements GameCtx { this.audio.play('ui'); } + /** Toggle the ghost race (menu button or G key). */ + toggleGhost(): void { + this.ghostOn = !this.ghostOn; + setGhostEnabled(this.ghostOn); + this.audio.play('ui'); + } + /** Advance to the next level and start a fresh run. */ nextLevel(): void { this.levelIdx = (this.levelIdx + 1) % LEVELS.length; @@ -281,6 +293,13 @@ export class Game implements GameCtx { this.checkpoint = null; this.results = null; this.victoryT = 0; + // Ghost race: record this run and replay the stored best alongside it + this.ghostRec = new GhostRecorder(); + this.ghost = null; + if (this.ghostOn) { + const track = getGhostTrack(this.daily ? -1 : this.levelIdx, this.daily ? dailySeed() : 0); + if (track) this.ghost = new GhostPlayer(track); + } this.camera.x = 0; this.camera.shake = 0; this.state = 'playing'; @@ -356,6 +375,10 @@ export class Game implements GameCtx { this.audio.play('ui'); return; } + if (k === 'ghost') { + this.toggleGhost(); + return; + } if (k === 'debug') { this.debug = !this.debug; return; @@ -584,6 +607,16 @@ export class Game implements GameCtx { Store.set(this.daily ? 'tinyrex_best_daily' : 'tinyrex_best_' + this.levelIdx, newBest); this.best = newBest; } + // Ghost race: keep this run's track when it sets a new best score + const rec = this.ghostRec; + this.ghostRec = null; + if (rec && isBestScore) { + const track = rec.finish(this.score, this.elapsed); + if (track) { + track.date = this.daily ? dailySeed() : -1; + saveGhostTrack(this.daily ? -1 : this.levelIdx, track); + } + } // Lifetime stats const s = getStats(); s.victories += 1; @@ -613,6 +646,8 @@ export class Game implements GameCtx { if (this.state === 'playing') { this.time += dt; this.elapsed += dt; + this.ghostRec?.sample(this.elapsed, this.player!.x, this.player!.y); + this.ghost?.update(this.elapsed); this.level!.update(dt, this.time, this.player!); this.player!.update(dt, this.time, this.input, this.level!); this.camera.update(dt, this.player!, this.level!.width, this); @@ -735,6 +770,18 @@ export class Game implements GameCtx { if (pl.x + pl.w > camX - 40 && pl.x < camX + VW + 40) pl.draw(ctx); } + // Ghost race replay (translucent, with a small tag) + if (this.ghost) { + ctx.save(); + ctx.globalAlpha = 0.4; + Sprite.drawRex(ctx, this.ghost.view, this.time); + ctx.restore(); + ctx.font = '800 9px ' + FONT_STACK; + ctx.textAlign = 'center'; + ctx.fillStyle = 'rgba(205,228,255,0.55)'; + ctx.fillText('GHOST', this.ghost.x + 17, this.ghost.y - 8); + } + // Player if (this.player) Sprite.drawRex(ctx, this.player, this.time); @@ -1462,7 +1509,7 @@ export class Game implements GameCtx { ['Restart', 'R'], ['Levels', '← / →'], ['Difficulty', '↑ / ↓'], - ['Mute · Calm', 'M · V'], + ['Mute · Calm · Ghost', 'M · V · G'], ['Gamepad', 'A jump · B go'], ['Debug', 'F2'], ]; @@ -1597,6 +1644,11 @@ export class Game implements GameCtx { }, // Tappable difficulty pills (drawn above) ...pills.map((p) => ({ x: p.x, y: 394, w: 90, h: 30, label: p.label, card: true, action: () => this.selectDifficulty(p.d) })), + { + x: 38, y: 474, w: 150, h: 40, label: 'Ghost: ' + (this.ghostOn ? 'On' : 'Off') + ' · G', + color: '#8fa8ba', + action: () => this.toggleGhost(), + }, { x: VW / 2 - 100, y: 470, w: 200, h: 48, label: 'Start Game', action: () => this.startGame() }, { x: 648, y: 474, w: 132, h: 40, label: (this.audio.muted ? 'Sound: Off' : 'Sound: On') + ' · M', diff --git a/src/ghost.ts b/src/ghost.ts new file mode 100644 index 0000000..877a663 --- /dev/null +++ b/src/ghost.ts @@ -0,0 +1,118 @@ +import { clamp } from './util'; +import type { RexView } from './sprite'; + +/** One recorded player position. t = seconds since run start. */ +export interface GhostPoint { + t: number; + x: number; + y: number; +} + +export interface GhostTrack { + /** Daily Rex: the dailySeed() the track was recorded on; hand-built: -1. */ + date: number; + score: number; + time: number; + pts: GhostPoint[]; +} + +/** Seconds between stored samples (~10 Hz). */ +const SAMPLE_DT = 0.1; +/** Hard cap (~10 min at 10 Hz) so the persisted track stays small. */ +const MAX_POINTS = 6000; +/** Tracks shorter than this are discarded (accidental taps, instant deaths). */ +export const MIN_TRACK_POINTS = 4; + +/** Records the player position during a run, decimated to ~10 Hz. */ +export class GhostRecorder { + private pts: GhostPoint[] = []; + /** Time of the last pushed point; the trailing point's position keeps + * tracking the player between pushes without advancing this. */ + private lastPushT = -1; + + /** + * Sample the player (call every frame). Positions land on the trailing + * point until a full SAMPLE_DT elapses, then a new point is pushed. + */ + sample(t: number, x: number, y: number): void { + const last = this.pts[this.pts.length - 1]; + if (last && t - this.lastPushT < SAMPLE_DT) { + last.x = x; + last.y = y; + return; + } + if (this.pts.length >= MAX_POINTS) return; + this.pts.push({ t, x, y }); + this.lastPushT = t; + } + + get count(): number { + return this.pts.length; + } + + /** Build the track, or null when the run is too short to be useful. */ + finish(score: number, time: number): GhostTrack | null { + if (this.pts.length < MIN_TRACK_POINTS) return null; + return { date: 0, score, time, pts: this.pts }; + } +} + +/** Replays a recorded track: linear interpolation between 10 Hz samples. */ +export class GhostPlayer { + x = 0; + y = 0; + facing = 1; + runPhase = 0; + private idx = 0; + private pts: GhostPoint[]; + private endT: number; + private finished = false; + + constructor(track: GhostTrack) { + this.pts = track.pts; + this.endT = track.pts[track.pts.length - 1].t; + this.x = track.pts[0].x; + this.y = track.pts[0].y; + } + + /** Advance the replay to game time t (clamped past the track's end). */ + update(t: number): void { + const pts = this.pts; + while (this.idx < pts.length - 2 && pts[this.idx + 1].t <= t) this.idx++; + const a = pts[this.idx]; + const b = pts[this.idx + 1]; + const span = b.t - a.t; + const k = span > 0 ? clamp((t - a.t) / span, 0, 1) : 1; + if (Math.abs(b.x - a.x) / Math.max(span, 0.001) > 1) { + this.facing = b.x > a.x ? 1 : -1; + } + this.x = a.x + (b.x - a.x) * k; + this.y = a.y + (b.y - a.y) * k; + this.runPhase = t * 12; + this.finished = t >= this.endT; + } + + /** True while the replay still has samples left to play out. */ + get moving(): boolean { + return !this.finished; + } + + /** RexView for Sprite.drawRex (Player satisfies the same shape). */ + get view(): RexView { + return { + x: this.x, + y: this.y, + w: 34, + h: 46, + facing: this.facing, + state: this.finished ? 'idle' : 'run', + runPhase: this.runPhase, + vy: 0, + squashX: 1, + squashY: 1, + invulnT: 0, + dead: false, + rot: 0, + }; + } +} diff --git a/src/input.ts b/src/input.ts index 0ad0ee2..fba8907 100644 --- a/src/input.ts +++ b/src/input.ts @@ -2,6 +2,7 @@ export type GameKey = | 'restart' | 'mute' | 'reducedMotion' + | 'ghost' | 'debug' | 'pause' | 'primary' @@ -55,6 +56,7 @@ export class Input { if (e.code === 'KeyR') this.onGameKey?.('restart'); if (e.code === 'KeyM') this.onGameKey?.('mute'); if (e.code === 'KeyV') this.onGameKey?.('reducedMotion'); + if (e.code === 'KeyG') this.onGameKey?.('ghost'); if (e.code === 'F2') { e.preventDefault(); this.onGameKey?.('debug'); diff --git a/src/store.ts b/src/store.ts index c2debaf..fc16e3d 100644 --- a/src/store.ts +++ b/src/store.ts @@ -1,3 +1,6 @@ +import type { GhostTrack } from './ghost'; +import { MIN_TRACK_POINTS } from './ghost'; + /** Lifetime play statistics shown on the main menu. */ export interface GameStats { runs: number; @@ -45,6 +48,33 @@ export function getDailyStars(): number { return Store.get('tinyrex_stars_daily', 0); } +/** Ghost race toggle (default on). */ +export function getGhostEnabled(): boolean { + return Store.get('tinyrex_ghost_on', true); +} + +export function setGhostEnabled(on: boolean): void { + Store.set('tinyrex_ghost_on', on); +} + +/** + * Best-run ghost track for a selection. idx = level index for hand-built + * levels, -1 for Daily Rex (whose track is only valid for the seed it was + * recorded on, `date`). + */ +export function getGhostTrack(idx: number, date: number): GhostTrack | null { + const key = idx === -1 ? 'tinyrex_ghost_daily' : 'tinyrex_ghost_' + idx; + const t = Store.get(key, null); + if (!t || !Array.isArray(t.pts) || t.pts.length < MIN_TRACK_POINTS) return null; + if (idx === -1 && t.date !== date) return null; + return t; +} + +export function saveGhostTrack(idx: number, track: GhostTrack): void { + const key = idx === -1 ? 'tinyrex_ghost_daily' : 'tinyrex_ghost_' + idx; + Store.set(key, track); +} + /** Safe localStorage wrapper (settings + best records). */ export const Store = { get(key: string, fallback: T): T { diff --git a/tests/game.test.ts b/tests/game.test.ts index 8c2172b..07ae411 100644 --- a/tests/game.test.ts +++ b/tests/game.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { Game } from '../src/game'; import { LEVEL_DATA } from '../src/level-data'; -import { Store, type GameStats } from '../src/store'; +import { Store, getGhostEnabled, type GameStats } from '../src/store'; function makeGame(): Game { const canvas = document.createElement('canvas'); @@ -273,3 +273,45 @@ describe('Run end: stars & per-level records', () => { expect(game.starChime).toBe(3); }); }); + +describe('Ghost race', () => { + let game: Game; + + beforeEach(() => { + localStorage.clear(); + game = makeGame(); + }); + + it('records a track on a new best score and replays it on the next run', () => { + game.handleKey('primary'); + expect(game.ghostOn).toBe(true); + for (let i = 0; i < 12; i++) game.update(0.1); // ~1.2 s of play + game.onPlayerVictory(); + expect(game.results!.isBestScore).toBe(true); + const stored = Store.get<{ pts: unknown[] } | null>('tinyrex_ghost_0', null); + expect(stored).not.toBeNull(); + expect(stored!.pts.length).toBeGreaterThanOrEqual(4); + + // The next run replays the stored ghost + game.victoryT = 2; + game.handleKey('primary'); + const ghost = (game as unknown as { ghost: { x: number } | null }).ghost; + expect(ghost).not.toBeNull(); + expect(ghost!.x).toBeGreaterThan(0); + }); + + it('skips the ghost when the toggle is off', () => { + game.handleKey('primary'); + for (let i = 0; i < 12; i++) game.update(0.1); + game.onPlayerVictory(); + expect(Store.get('tinyrex_ghost_0', null)).not.toBeNull(); + + game.handleKey('ghost'); + expect(game.ghostOn).toBe(false); + expect(getGhostEnabled()).toBe(false); + + game.victoryT = 2; + game.handleKey('primary'); + expect((game as unknown as { ghost: unknown }).ghost).toBeNull(); + }); +}); diff --git a/tests/ghost.test.ts b/tests/ghost.test.ts new file mode 100644 index 0000000..44ca3bb --- /dev/null +++ b/tests/ghost.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { GhostRecorder, GhostPlayer, MIN_TRACK_POINTS } from '../src/ghost'; +import type { GhostTrack } from '../src/ghost'; +import { Store, getGhostTrack, saveGhostTrack, getGhostEnabled, setGhostEnabled } from '../src/store'; + +function mkTrack( + pts: Array<[number, number, number]>, + score = 100, + time = 10, + date = -1, +): GhostTrack { + return { date, score, time, pts: pts.map(([t, x, y]) => ({ t, x, y })) }; +} + +describe('GhostRecorder', () => { + it('decimates to ~10 Hz at 60 fps sampling, keeping the trailing point fresh', () => { + const r = new GhostRecorder(); + // Simulate a 60 fps frame stream for 0.6 s + for (let i = 0; i <= 36; i++) r.sample(i / 60, i, 0); + // ~10 Hz → 6–7 pushes over 0.6 s (±1 for float boundaries) + expect(r.count).toBeGreaterThanOrEqual(6); + expect(r.count).toBeLessThanOrEqual(8); + const t = r.finish(9, 0.6)!; + expect(t.pts[0].t).toBe(0); + for (let i = 1; i < t.pts.length; i++) { + const gap = t.pts[i].t - t.pts[i - 1].t; + expect(gap).toBeGreaterThanOrEqual(0.1 - 1e-9); + expect(gap).toBeLessThanOrEqual(0.2 + 1e-9); + } + // Trailing point tracks the latest position even before the next push + expect(t.pts[t.pts.length - 1].x).toBe(36); + }); + + it('discards runs shorter than the minimum track length', () => { + const r = new GhostRecorder(); + for (let i = 0; i < MIN_TRACK_POINTS - 1; i++) r.sample(i * 0.2, i * 10, 0); + expect(r.finish(1, 1)).toBeNull(); + r.sample((MIN_TRACK_POINTS - 1) * 0.2, (MIN_TRACK_POINTS - 1) * 10, 0); + const t = r.finish(1, 1); + expect(t).not.toBeNull(); + expect(t!.pts.length).toBe(MIN_TRACK_POINTS); + }); + + it('caps the track length so storage stays small', () => { + const r = new GhostRecorder(); + for (let i = 0; i < 6001; i++) r.sample(i * 0.2, i, 0); + expect(r.count).toBe(6000); + }); +}); + +describe('GhostPlayer', () => { + it('interpolates linearly between samples', () => { + const g = new GhostPlayer(mkTrack([[0, 0, 414], [1, 100, 414], [2, 200, 400]])); + g.update(0.5); + expect(g.x).toBeCloseTo(50); + expect(g.y).toBeCloseTo(414); + g.update(1.5); + expect(g.x).toBeCloseTo(150); + g.update(3); // past the end → clamped at the final sample + expect(g.x).toBeCloseTo(200); + expect(g.y).toBeCloseTo(400); + }); + + it('tracks facing from the sample direction', () => { + const g = new GhostPlayer(mkTrack([[0, 100, 414], [1, 0, 414]])); + g.update(0.5); + expect(g.facing).toBe(-1); + expect(g.view.facing).toBe(-1); + }); + + it('view reports a run pose mid-track and idle at the end', () => { + const g = new GhostPlayer(mkTrack([[0, 0, 414], [1, 100, 414]])); + g.update(0.5); + expect(g.view.state).toBe('run'); + expect(g.moving).toBe(true); + g.update(99); + expect(g.view.state).toBe('idle'); + expect(g.moving).toBe(false); + }); +}); + +describe('ghost persistence', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('round-trips per level', () => { + const t = mkTrack([[0, 0, 0], [1, 10, 0], [2, 20, 0], [3, 30, 0]]); + saveGhostTrack(2, t); + expect(getGhostTrack(2, 0)).toEqual(t); + expect(getGhostTrack(0, 0)).toBeNull(); + }); + + it('daily tracks are only valid for the seed they were recorded on', () => { + const t = mkTrack([[0, 0, 0], [1, 10, 0], [2, 20, 0], [3, 30, 0]], 50, 10, 20260826); + saveGhostTrack(-1, t); + expect(getGhostTrack(-1, 20260826)).toEqual(t); + expect(getGhostTrack(-1, 20260827)).toBeNull(); + }); + + it('rejects malformed stored tracks', () => { + Store.set('tinyrex_ghost_1', { date: -1, score: 1, time: 1, pts: [{ t: 0, x: 0, y: 0 }] }); + expect(getGhostTrack(1, 0)).toBeNull(); + Store.set('tinyrex_ghost_1', 'garbage'); + expect(getGhostTrack(1, 0)).toBeNull(); + }); + + it('toggle persists and defaults on', () => { + expect(getGhostEnabled()).toBe(true); + setGhostEnabled(false); + expect(getGhostEnabled()).toBe(false); + setGhostEnabled(true); + expect(getGhostEnabled()).toBe(true); + }); +});