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
56 changes: 54 additions & 2 deletions src/game.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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'],
];
Expand Down Expand Up @@ -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',
Expand Down
118 changes: 118 additions & 0 deletions src/ghost.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
}
2 changes: 2 additions & 0 deletions src/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export type GameKey =
| 'restart'
| 'mute'
| 'reducedMotion'
| 'ghost'
| 'debug'
| 'pause'
| 'primary'
Expand Down Expand Up @@ -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');
Expand Down
30 changes: 30 additions & 0 deletions src/store.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<GhostTrack | null>(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<T>(key: string, fallback: T): T {
Expand Down
44 changes: 43 additions & 1 deletion tests/game.test.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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();
});
});
Loading
Loading