From 8e4436c810d2a6bef9ee489d74be171cca829f1a Mon Sep 17 00:00:00 2001 From: Chris Malpass Date: Wed, 26 Aug 2026 22:47:05 -0400 Subject: [PATCH] Add field-notes lore codex (c2) 12 persistent lore collectibles (3 per level) that glow like little parchment pages in the world; discovering them unlocks short dino-archaeologist field notes in a new codex screen on the menu (C or the "Field Notes" button). Undiscovered entries show "???" with a hint to keep exploration rewarding. - src/lore.ts: NOTE content + FieldNote entity (cool glow, distinct from the warm fossil glow) - level-data: notes[] per level, all placed on solid ground - pickup loop in player, collectNote in game/store (tinyrex_notes) - renderCodex: 4 parchment columns, found vs hint, wrapText - stats line NOTES n/12 in menu - 'note' SFX; tests: 14 new (placement validation for all 4 levels, persistence, codex toggle); 239 total --- src/audio.ts | 6 ++ src/ctx.ts | 5 ++ src/game.ts | 137 +++++++++++++++++++++++++++++++- src/input.ts | 2 + src/level-data.ts | 22 ++++++ src/level.ts | 5 ++ src/lore.ts | 189 +++++++++++++++++++++++++++++++++++++++++++++ src/player.ts | 9 +++ src/store.ts | 17 ++++ tests/lore.test.ts | 160 ++++++++++++++++++++++++++++++++++++++ tests/mock-ctx.ts | 6 ++ 11 files changed, 555 insertions(+), 3 deletions(-) create mode 100644 src/lore.ts create mode 100644 tests/lore.test.ts diff --git a/src/audio.ts b/src/audio.ts index 6c584d6..f90f69a 100644 --- a/src/audio.ts +++ b/src/audio.ts @@ -437,6 +437,12 @@ export class AudioManager { // Low whoosh when a frostpeak gust kicks in. this.noiseBurst({ dur: 0.9, vol: 0.15, freq: 260 }); break; + case 'note': + // Soft page rustle followed by a bright two-note pencil chime. + this.noiseBurst({ dur: 0.12, vol: 0.12, freq: 900 }); + this.tone({ freq: 659, dur: 0.12, type: 'triangle', vol: 0.18, delay: 0.06 }); + this.tone({ freq: 880, dur: 0.18, type: 'sine', vol: 0.16, delay: 0.16 }); + break; } } diff --git a/src/ctx.ts b/src/ctx.ts index 9dd9f0b..49e94d9 100644 --- a/src/ctx.ts +++ b/src/ctx.ts @@ -68,4 +68,9 @@ export interface GameCtx { * is persisted to the fossil codex (id = ":"). */ collectFossil(x: number, y: number, id: string): void; + /** + * A field-note page was picked up: score + sparkle, and the first + * discovery is persisted to the notes codex (id = ":"). + */ + collectNote(x: number, y: number, id: string): void; } diff --git a/src/game.ts b/src/game.ts index 4efa71a..27a39e6 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, getGhostEnabled, setGhostEnabled, getGhostTrack, saveGhostTrack, getFoundFossils, findFossil, getSkinId, setSkinId } from './store'; +import { Store, getStats, getBest, getBestStars, getDailyBest, getDailyStars, getGhostEnabled, setGhostEnabled, getGhostTrack, saveGhostTrack, getFoundFossils, findFossil, getFoundNotes, findNote, getSkinId, setSkinId } from './store'; import type { GameStats } from './store'; import { AudioManager } from './audio'; import { Input } from './input'; @@ -20,6 +20,7 @@ import { drawDecor } from './decor'; import { Sprite, SKINS, skinUnlocked } from './sprite'; import { adaptiveFlags } from './adaptive'; import { Weather } from './weather'; +import { NOTES, totalNotes as countNotes } from './lore'; import { drawPowerUpIcon, POWERUP_COLORS } from './powerup'; import type { PowerUpType } from './powerup'; import type { GameCtx } from './ctx'; @@ -139,6 +140,10 @@ export class Game implements GameCtx { ghostOn: boolean = getGhostEnabled(); /** Fossil ids discovered so far (persistent meta-progress). */ fossilsFound: string[] = getFoundFossils(); + /** Field-note ids discovered so far (persistent meta-progress). */ + notesFound: string[] = getFoundNotes(); + /** Menu sub-screen: the main menu or the field-notes codex. */ + menuScreen: 'main' | 'codex' = 'main'; private ghost: GhostPlayer | null = null; private ghostRec: GhostRecorder | null = null; /** Cheat queue: apply max hearts once a player exists. */ @@ -317,6 +322,11 @@ export class Game implements GameCtx { return LEVELS.reduce((n, l) => n + (l.def.fossils?.length ?? 0), 0); } + /** Total field notes across the hand-built levels. */ + totalNotes(): number { + return countNotes(); + } + startGame(): void { this.buildLevel(); this.player = new Player(this.level!.start.x, this.level!.start.y, this); @@ -444,6 +454,11 @@ export class Game implements GameCtx { this.cycleSkin(k === 'skinNext' ? 1 : -1); return; } + if (k === 'codex' && this.state === 'menu') { + this.menuScreen = this.menuScreen === 'codex' ? 'main' : 'codex'; + this.audio.play('ui'); + return; + } if (k === 'debug') { this.debug = !this.debug; return; @@ -612,6 +627,25 @@ export class Game implements GameCtx { } } + /** Field-note pickup (GameCtx): persistent discovery + re-collectable score. */ + collectNote(x: number, y: number, id: string): void { + const first = !this.notesFound.includes(id); + if (first) { + findNote(id); + this.notesFound = getFoundNotes(); + } + this.addScore(CFG.score.fossil, x, y - 14); + this.audio.play('note'); + if (first) { + this.addStatus('Field note found! ' + this.notesFound.length + '/' + this.totalNotes(), '#cfe6ff'); + this.addShake(2); + this.burst(x, y, 22, ['#fbf6ea', '#cfe6ff', '#e7dcc2', '#fff'], 'dot', 170); + this.texts.push(new FloatingText(x, y - 34, 'NEW NOTE!', '#cfe6ff')); + } else { + this.burst(x, y, 10, ['#fbf6ea', '#cfe6ff'], 'dot', 130); + } + } + burst(x: number, y: number, n: number, colors: string[], type: ParticleType, speed: number): void { if (this.reducedMotion) n = Math.max(1, Math.floor(n * 0.4)); for (let i = 0; i < n; i++) { @@ -856,7 +890,8 @@ export class Game implements GameCtx { ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); if (!this.level) this.buildLevel(); if (this.state === 'menu') { - this.renderMenu(ctx); + if (this.menuScreen === 'codex') this.renderCodex(ctx); + else this.renderMenu(ctx); ctx.restore(); return; } @@ -900,6 +935,9 @@ export class Game implements GameCtx { for (const f of this.level!.fossils) { if (!f.collected) f.draw(ctx, this.time); } + for (const n of this.level!.notes) { + if (!n.collected) n.draw(ctx, this.time); + } // Power-up capsules (enemy drops) for (const pw of this.level!.powerups) { if (pw.collected) continue; @@ -1747,7 +1785,7 @@ export class Game implements GameCtx { ctx.font = '600 12px ' + FONT_STACK; ctx.fillStyle = 'rgba(255,255,255,0.6)'; ctx.fillText( - 'PLAYS ' + this.stats.runs + ' · DEATHS ' + this.stats.deaths + ' · CRYSTALS ' + this.stats.crystals + ' · HEARTS ' + this.stats.hearts + ' · FOSSILS ' + this.fossilsFound.length + '/' + this.totalFossils() + ' · SINCE ' + since, + 'PLAYS ' + this.stats.runs + ' · DEATHS ' + this.stats.deaths + ' · CRYSTALS ' + this.stats.crystals + ' · HEARTS ' + this.stats.hearts + ' · FOSSILS ' + this.fossilsFound.length + '/' + this.totalFossils() + ' · NOTES ' + this.notesFound.length + '/' + this.totalNotes() + ' · SINCE ' + since, VW / 2, 205, ); @@ -1976,6 +2014,14 @@ export class Game implements GameCtx { color: '#8fa8ba', action: () => this.toggleGhost(), }, + { + x: 202, y: 474, w: 168, h: 40, label: 'Field Notes · C', + color: '#8fa8ba', + action: () => { + this.menuScreen = 'codex'; + this.audio.play('ui'); + }, + }, { 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', @@ -2006,6 +2052,91 @@ export class Game implements GameCtx { ctx.globalAlpha = 1; } + /** The field-notes codex: one parchment column per level. */ + renderCodex(ctx: CanvasRenderingContext2D): void { + // Scenic backdrop (same auto-pan as the menu), dimmed for reading + const camX = (Math.sin(this.time * 0.06) * 0.5 + 0.5) * 900; + this.bg.draw(ctx, camX, this.time); + ctx.fillStyle = 'rgba(8,12,22,0.8)'; + ctx.fillRect(0, 0, VW, VH); + // Header + ctx.textAlign = 'center'; + ctx.font = '800 34px ' + FONT_STACK; + ctx.fillStyle = '#f4ecd9'; + this.drawTracked(ctx, 'FIELD NOTES', VW / 2, 50, 3, false); + ctx.font = '600 13px ' + FONT_STACK; + ctx.fillStyle = 'rgba(220,210,180,0.75)'; + ctx.fillText(this.notesFound.length + '/' + this.totalNotes() + ' recovered', VW / 2, 72); + // One panel per hand-built level + const pw = 224, gap = 14, py = 92, ph = 388; + const x0 = (VW - (pw * LEVELS.length + gap * (LEVELS.length - 1))) / 2; + LEVELS.forEach((li, i) => { + const px = x0 + i * (pw + gap); + this.drawInfoPanel(ctx, px, py, pw, ph, li.subtitle); + const found = (n: number) => this.notesFound.includes(i + ':' + n); + NOTES[i].forEach((entry, n) => { + const ey = py + 62 + n * 108; + // divider above entries 2 and 3 + if (n > 0) { + ctx.strokeStyle = 'rgba(255,255,255,0.12)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(px + 16, ey - 10); + ctx.lineTo(px + pw - 16, ey - 10); + ctx.stroke(); + } + if (found(n)) { + ctx.textAlign = 'left'; + ctx.font = '800 12px ' + FONT_STACK; + ctx.fillStyle = '#ffd257'; + ctx.fillText(entry.title, px + 16, ey); + ctx.font = '600 10.5px ' + FONT_STACK; + ctx.fillStyle = '#d8cfae'; + this.wrapText(ctx, entry.text, px + 16, ey + 16, pw - 32, 13); + } else { + ctx.textAlign = 'center'; + ctx.font = '800 24px ' + FONT_STACK; + ctx.fillStyle = 'rgba(255,255,255,0.28)'; + ctx.fillText('???', px + pw / 2, ey + 22); + ctx.font = '600 10px ' + FONT_STACK; + ctx.fillStyle = 'rgba(220,210,180,0.55)'; + ctx.fillText(entry.hint, px + pw / 2, ey + 44); + } + }); + }); + // Back button + this.uiButtons = [ + { + x: VW / 2 - 110, y: 490, w: 220, h: 36, label: 'Back to menu · C', + color: '#8fa8ba', + action: () => { + this.menuScreen = 'main'; + this.audio.play('ui'); + }, + }, + ]; + for (const b of this.uiButtons) this.drawUIButton(ctx, b); + } + + /** Word-wrap helper; draws the text line by line, returns the count drawn. */ + wrapText(ctx: CanvasRenderingContext2D, text: string, x: number, y: number, maxWidth: number, lineHeight: number): number { + const words = text.split(' '); + let line = ''; + let lines = 0; + for (const w of words) { + const test = line ? line + ' ' + w : w; + if (ctx.measureText(test).width > maxWidth && line) { + ctx.fillText(line, x, y + lines * lineHeight); + lines += 1; + line = w; + } else { + line = test; + } + } + ctx.fillText(line, x, y + lines * lineHeight); + return lines + 1; + } + drawDebug(ctx: CanvasRenderingContext2D): void { ctx.fillStyle = 'rgba(10,20,15,0.85)'; this.roundRect(ctx, VW - 250, VH - 118, 240, 108, 8); diff --git a/src/input.ts b/src/input.ts index ee7a1ab..56ec02f 100644 --- a/src/input.ts +++ b/src/input.ts @@ -1,4 +1,5 @@ export type GameKey = + | 'codex' | 'skinPrev' | 'skinNext' | 'restart' @@ -59,6 +60,7 @@ export class Input { if (e.code === 'KeyM') this.onGameKey?.('mute'); if (e.code === 'KeyV') this.onGameKey?.('reducedMotion'); if (e.code === 'KeyG') this.onGameKey?.('ghost'); + if (e.code === 'KeyC') this.onGameKey?.('codex'); if (e.code === 'BracketLeft') this.onGameKey?.('skinPrev'); if (e.code === 'BracketRight') this.onGameKey?.('skinNext'); if (e.code === 'F2') { diff --git a/src/level-data.ts b/src/level-data.ts index 0eca51b..9e250c5 100644 --- a/src/level-data.ts +++ b/src/level-data.ts @@ -72,6 +72,8 @@ export interface LevelDef { springs?: Point[]; /** Hidden fossils: persistent meta-collectibles (id = ":"). */ fossils?: Point[]; + /** Field-note pages: lore collectibles read in the menu codex (id = ":"). */ + notes?: Point[]; /** Pressure plates: hold to keep the referenced door (index into `doors`) open. */ plates?: { x: number; y: number; door: number }[]; /** Sliding gates: {x, y, w, h}; y is the top, bottom meets the ground. */ @@ -210,6 +212,11 @@ const LEVEL_1: LevelDef = { { x: 3230, y: 363 }, // stepping stone over lava pool A { x: 4990, y: 323 }, // stone ledge right above the spike pit ], + notes: [ + { x: 2400, y: 436 }, // by the first flag + { x: 4630, y: 436 }, // by the last flag + { x: 3264, y: 363 }, // beside the stepping stone over lava pool A + ], goal: { x: 7150, y: 460 }, decor: [ { type: 'sign', x: 120 }, @@ -337,6 +344,11 @@ const LEVEL_2: LevelDef = { { x: 5685, y: 313 }, // stone above the twin falling-rock gauntlet { x: 7010, y: 275 }, // high stone of the home-stretch jump chain ], + notes: [ + { x: 2270, y: 436 }, // by the first flag + { x: 5719, y: 313 }, // beside the stone above the twin falling-rock gauntlet + { x: 6380, y: 436 }, // by the last flag + ], goal: { x: 7350, y: 460 }, decor: [ { type: 'sign', x: 120 }, @@ -468,6 +480,11 @@ const LEVEL_3: LevelDef = { { x: 5925, y: 263 }, // bonus spring ledge, high above the rockfall ridge { x: 7700, y: 425 }, // behind the final gate, patrolled by a beetle ], + notes: [ + { x: 2850, y: 436 }, // by the first flag + { x: 5891, y: 263 }, // beside the bonus spring ledge, high above the rockfall ridge + { x: 7666, y: 425 }, // beside the fossil behind the final gate + ], goal: { x: 7850, y: 460 }, decor: [ { type: 'sign', x: 120 }, @@ -535,6 +552,11 @@ const LEVEL_4: LevelDef = { { x: 3320, y: 428 }, // arena nook between boss patrol and right wall { x: 3720, y: 428 }, // behind the gate, near the nest ], + notes: [ + { x: 2110, y: 436 }, // by the flag before the arena + { x: 3286, y: 428 }, // arena nook beside the right wall + { x: 3686, y: 428 }, // behind the gate, near the nest + ], boss: { x: 2760, y: 356, minX: 2320, maxX: 3160 }, orbs: [ { x: 2435, y: 286 }, diff --git a/src/level.ts b/src/level.ts index 163616c..60d9880 100644 --- a/src/level.ts +++ b/src/level.ts @@ -15,6 +15,7 @@ import { Door } from './door'; import { Projectile } from './projectile'; import type { ProjectileKind } from './projectile'; import { Fossil } from './fossil'; +import { FieldNote } from './lore'; import { MagmaKing } from './boss'; import { PowerUp } from './powerup'; import type { PowerUpType } from './powerup'; @@ -39,6 +40,8 @@ export class Level { doors: Door[]; projectiles: Projectile[]; fossils: Fossil[]; + /** Field-note pages (lore codex). */ + notes: FieldNote[]; /** The Magma King (Molten Nest only). */ boss: MagmaKing | null; /** Power-up capsules dropped by stomped enemies. */ @@ -50,6 +53,7 @@ export class Level { this.enemySpeed = enemySpeed; this.game = game; this.fossils = (d.fossils ?? []).map((f, i) => new Fossil(f.x, f.y, levelIdx + ':' + i)); + this.notes = (d.notes ?? []).map((n, i) => new FieldNote(n.x, n.y, levelIdx + ':' + i)); this.springs = (d.springs ?? []).map((s) => new SpringPad(s.x, s.y)); this.plates = (d.plates ?? []).map((p) => new PressurePlate(p.x, p.y, game)); this.doors = (d.doors ?? []).map((dr) => new Door(dr.x, dr.y, dr.w, dr.h, game)); @@ -150,6 +154,7 @@ export class Level { for (const c of this.crystals) c.collected = false; for (const h of this.hearts) h.collected = false; for (const f of this.fossils) f.collected = false; + for (const n of this.notes) n.collected = false; for (const e of this.enemies) e.reset(); this.boss?.reset(); // Restore each hazard's own interval (the original hard-coded 2.2, diff --git a/src/lore.ts b/src/lore.ts new file mode 100644 index 0000000..b1241c6 --- /dev/null +++ b/src/lore.ts @@ -0,0 +1,189 @@ +import { TAU } from './config'; + +export interface NoteEntry { + title: string; + text: string; + /** Shown in the codex for undiscovered notes. */ + hint: string; +} + +/** + * The dino-archaeologist's field notes: 3 per hand-built level (12 total). + * Discovered in the world, read on the menu codex screen. + */ +export const NOTES: NoteEntry[][] = [ + // Crystal Valley + [ + { + title: 'Day 1 — A grown valley', + text: 'The valley is older than it looks. Under the meadow grass I found a geode the size of an egg, and inside it a perfect crystal seed. Whatever made this valley grew things on purpose.', + hint: 'Rest by the first flag', + }, + { + title: 'Day 3 — The gardeners', + text: 'The beetles here do not attack. They graze. I watched a trike nap in the shadow of a geode. This is not a habitat, it is a garden — and someone was tending it.', + hint: 'Rest by the last flag', + }, + { + title: 'Day 5 — A bone that was a tool', + text: 'At the top of the jump chain I found a fossil. It is not a dino bone — it is a tool, worn smooth by a grip. The valley\u2019s builders had hands. Or something like hands.', + hint: 'Take the high route over the lava', + }, + ], + // Volcanic Depths + [ + { + title: 'Day 12 — The geyser clock', + text: 'The geysers erupt on a schedule. I counted: every six minutes, same vent, same arc. Volcanoes do not keep schedules. People do.', + hint: 'Rest by the first flag', + }, + { + title: 'Day 14 — The waiting ribcage', + text: 'Behind the lava moat I found a fossil ribcage fused into a cooling flow. The animal stood its ground when the lava came. It did not run. It was waiting for something to arrive.', + hint: 'Stand above the falling rocks', + }, + { + title: 'Day 17 — A creature that counted', + text: 'The falling rocks have a pattern too. Count the bubbles before the vent, count your steps after. The deep passages were built for a creature that could count.', + hint: 'Rest by the last flag', + }, + ], + // Frostpeak Pass + [ + { + title: 'Day 21 — The mountain breathes', + text: 'The wind gusts are stronger than physics should allow. They blow on the minute, always from the west, always from the pass. I am no longer sure the mountain is asleep.', + hint: 'Rest by the first flag', + }, + { + title: 'Day 24 — The egg in the ice', + text: 'I found a fossil egg in the ice shelf. It is not empty. Something small is moving in there, and it has been dreaming for a hundred thousand years. I did not dig any deeper.', + hint: 'Take the high route over the spike pit', + }, + { + title: 'Day 27 — Warm snow', + text: 'Past the final gate the snow is warm to the touch. Something large sleeps beneath this peak, and the gates keep the draft out. I left it sleeping. Some things are better that way.', + hint: 'Go behind the last gate', + }, + ], + // Molten Nest + [ + { + title: 'Day 30 — The king is a teacher', + text: 'The Magma King does not charge in anger. He charges in a pattern, and he warns you with a glow. Even a boss is a teacher. I wrote down the rhythm of his strikes.', + hint: 'Rest by the flag before the arena', + }, + { + title: 'Day 33 — A lullaby of light', + text: 'The crystal orbs are not weapons. They are a lullaby. When all three burn bright the King slows his breath and the arena goes quiet. Whoever built this nest built a way to put him to sleep.', + hint: 'In the arena nook by the right wall', + }, + { + title: 'Day 35 — The empty nest', + text: 'After the last battle the nest was not empty. Under the ash lay a single warm egg. I am leaving it here, guarded. Some things are too big for a journal, so I am writing it down anyway.', + hint: 'Behind the gate, near the nest', + }, + ], +]; + +export function totalNotes(): number { + return NOTES.reduce((n, level) => n + level.length, 0); +} + +/** + * A field-note page: a persistent meta-collectible. Found once (stored + * across runs by id ":"), re-collectable for score on later + * runs — the codex keeps the first discovery. + */ +export class FieldNote { + x: number; + y: number; + w = 26; + h = 30; + id: string; + collected = false; + phase = Math.random() * TAU; + + constructor(x: number, y: number, id: string) { + this.x = x; + this.y = y; + this.id = id; + } + + get rect(): { x: number; y: number; w: number; h: number } { + return { x: this.x - 13, y: this.y - 15, w: 26, h: 30 }; + } + + draw(ctx: CanvasRenderingContext2D, t: number): void { + const bob = Math.sin(t * 1.7 + this.phase) * 2.5; + const sway = Math.sin(t * 1.3 + this.phase) * 0.14; + const cx = this.x; + const cy = this.y + bob; + const pulse = 0.5 + 0.5 * Math.sin(t * 2.1 + this.phase); + ctx.save(); + ctx.translate(cx, cy); + // cool parchment glow (fossils glow warm, notes glow cool) + ctx.globalAlpha = 0.2 + 0.16 * pulse; + ctx.fillStyle = '#cfe6ff'; + ctx.beginPath(); + ctx.arc(0, 0, 17 + 2 * pulse, 0, TAU); + ctx.fill(); + ctx.globalAlpha = 1; + ctx.rotate(sway); + // page + const grad = ctx.createLinearGradient(0, -12, 0, 12); + grad.addColorStop(0, '#fbf6ea'); + grad.addColorStop(1, '#e7dcc2'); + ctx.fillStyle = grad; + this.roundedPage(ctx, -10, -12, 20, 24, 3); + ctx.fill(); + // ruled lines + ctx.strokeStyle = 'rgba(90,110,150,0.5)'; + ctx.lineWidth = 1; + for (let i = 0; i < 4; i++) { + ctx.beginPath(); + ctx.moveTo(-6, -6 + i * 5); + ctx.lineTo(6, -6 + i * 5); + ctx.stroke(); + } + // wax seal + ctx.fillStyle = '#c0555f'; + ctx.beginPath(); + ctx.arc(5, 8, 2.6, 0, TAU); + ctx.fill(); + // outline + ctx.strokeStyle = 'rgba(110,100,70,0.5)'; + ctx.lineWidth = 1; + this.roundedPage(ctx, -10, -12, 20, 24, 3); + ctx.stroke(); + ctx.restore(); + // sparkle + if (Math.sin(t * 1.9 + this.phase * 3) > 0.88) { + ctx.save(); + ctx.translate(cx + 11, cy - 13); + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 1.3; + ctx.beginPath(); + ctx.moveTo(-3.5, 0); + ctx.lineTo(3.5, 0); + ctx.moveTo(0, -3.5); + ctx.lineTo(0, 3.5); + ctx.stroke(); + ctx.restore(); + } + } + + private roundedPage(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); + ctx.arcTo(x + w, y, x + w, y + r, r); + ctx.lineTo(x + w, y + h - r); + ctx.arcTo(x + w, y + h, x + w - r, y + h, r); + ctx.lineTo(x + r, y + h); + ctx.arcTo(x, y + h, x, y + h - r, r); + ctx.lineTo(x, y + r); + ctx.arcTo(x, y, x + r, y, r); + ctx.closePath(); + } +} diff --git a/src/player.ts b/src/player.ts index a2c9e33..81f77e6 100644 --- a/src/player.ts +++ b/src/player.ts @@ -359,6 +359,15 @@ export class Player { } } + // --- Field notes (persistent lore, re-collectable for score) --- + for (const n of level.notes) { + if (n.collected) continue; + if (overlap(this.rect, n.rect)) { + n.collected = true; + this.game.collectNote(n.x, n.y, n.id); + } + } + // --- Enemies: stomp from above, hurt from the side --- for (const e of level.enemies) { if (e.dead) continue; diff --git a/src/store.ts b/src/store.ts index f779a7c..bb33547 100644 --- a/src/store.ts +++ b/src/store.ts @@ -95,6 +95,23 @@ export function findFossil(id: string): void { Store.set('tinyrex_fossils', [...found, id]); } +/** + * Found field-note ids (":"), persistent across runs. A note + * can be re-collected for score on later runs, but only the first + * discovery counts toward the codex. + */ +export function getFoundNotes(): string[] { + const v = Store.get('tinyrex_notes', null); + return Array.isArray(v) ? v : []; +} + +/** Record a note discovery; no-op when it is already in the codex. */ +export function findNote(id: string): void { + const found = getFoundNotes(); + if (found.includes(id)) return; + Store.set('tinyrex_notes', [...found, id]); +} + /** Selected Rex skin id; unknown/corrupt values fall back to Classic. */ export function getSkinId(): string { const v = Store.get('tinyrex_skin', null); diff --git a/tests/lore.test.ts b/tests/lore.test.ts new file mode 100644 index 0000000..676a825 --- /dev/null +++ b/tests/lore.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { FieldNote, NOTES, totalNotes } from '../src/lore'; +import { LEVELS } from '../src/level-data'; +import { Level } from '../src/level'; +import { makeCtx } from './mock-ctx'; +import { Store, getFoundNotes, findNote } from '../src/store'; +import { Game } from '../src/game'; + +function makeGame(): Game { + const canvas = document.createElement('canvas'); + document.body.appendChild(canvas); + return new Game(canvas); +} + +describe('FieldNote entity', () => { + it('exposes a collision rect centred on its position', () => { + const n = new FieldNote(300, 400, '0:0'); + expect(n.rect).toEqual({ x: 287, y: 385, w: 26, h: 30 }); + expect(n.collected).toBe(false); + expect(n.id).toBe('0:0'); + }); + + it('draws without error at several animation phases', () => { + const n = new FieldNote(300, 400, '0:0'); + const ctx = document.createElement('canvas').getContext('2d')!; + for (const t of [0, 0.5, 1.2, 3.9, 9.7]) n.draw(ctx, t); + }); + + it('builds stable "levelIdx:i" ids from level data', () => { + const level = new Level(LEVELS[2].def, makeCtx(), 1, 2); + expect(level.notes).toHaveLength(3); + expect(level.notes.map((n) => n.id)).toEqual(['2:0', '2:1', '2:2']); + }); + + it('re-collects after a respawn reset', () => { + const level = new Level(LEVELS[0].def, makeCtx(), 1, 0); + const n = level.notes[0]; + n.collected = true; + level.reset(); + expect(n.collected).toBe(false); + }); +}); + +describe('Field note content', () => { + it('has exactly 3 notes per hand-built level (12 total)', () => { + expect(NOTES.length).toBe(LEVELS.length); + for (const levelNotes of NOTES) expect(levelNotes).toHaveLength(3); + expect(totalNotes()).toBe(12); + }); + + it('every note has a title, a paragraph of text and a hint', () => { + for (const levelNotes of NOTES) { + for (const n of levelNotes) { + expect(n.title.length).toBeGreaterThan(4); + expect(n.text.length).toBeGreaterThanOrEqual(40); + expect(n.hint.length).toBeGreaterThan(4); + } + } + }); + + it('places every marker on solid ground', () => { + for (let i = 0; i < LEVELS.length; i++) { + for (let j = 0; j < (LEVELS[i].def.notes?.length ?? 0); j++) { + const n = LEVELS[i].def.notes![j]; + const supported = (LEVELS[i].def.platforms ?? []).some( + (p) => + p.x <= n.x + 13 && + p.x + p.w >= n.x - 13 && + p.y >= n.y - 10 && + p.y <= n.y + 45, + ); + expect(supported, `note ${i}:${j} at ${n.x},${n.y} has ground below`).toBe(true); + } + } + }); +}); + +describe('Field-note store (persistent codex)', () => { + beforeEach(() => localStorage.clear()); + + it('round-trips discoveries and ignores duplicates', () => { + expect(getFoundNotes()).toEqual([]); + findNote('0:0'); + findNote('0:0'); // duplicate is a no-op + findNote('3:2'); + expect(getFoundNotes()).toEqual(['0:0', '3:2']); + expect(Store.get('tinyrex_notes', null)).toEqual(['0:0', '3:2']); + }); + + it('guards against corrupted storage', () => { + localStorage.setItem('tinyrex_notes', 'not-an-array'); + expect(getFoundNotes()).toEqual([]); + findNote('1:1'); + expect(getFoundNotes()).toEqual(['1:1']); + }); +}); + +describe('Field-note discovery (Game)', () => { + let game: Game; + + beforeEach(() => { + localStorage.clear(); + game = makeGame(); + }); + + it('collecting a note records it in the persistent codex and scores', () => { + game.handleKey('primary'); // start level 0 + const note = game.level!.notes[0]; + game.player!.x = note.x - 12; + game.player!.y = note.y - 20; + game.player!.vy = 0; + game.update(0.016); + expect(note.collected).toBe(true); + expect(game.notesFound).toContain('0:0'); + expect(getFoundNotes()).toContain('0:0'); + expect(game.score).toBeGreaterThan(0); + }); + + it('re-collecting a note does not duplicate the codex entry', () => { + game.handleKey('primary'); + const note = game.level!.notes[0]; + game.player!.x = note.x - 12; + game.player!.y = note.y - 20; + game.player!.vy = 0; + game.update(0.016); + // Pick it up again from a fresh level build + game.handleKey('restart'); + const note2 = game.level!.notes[0]; + game.player!.x = note2.x - 12; + game.player!.y = note2.y - 20; + game.player!.vy = 0; + game.update(0.016); + expect(note2.collected).toBe(true); + expect(game.notesFound.filter((id) => id === '0:0')).toHaveLength(1); + expect(getFoundNotes().filter((id) => id === '0:0')).toHaveLength(1); + }); + + it('loads previously discovered notes on boot', () => { + findNote('2:1'); + const g2 = makeGame(); + expect(g2.notesFound).toContain('2:1'); + }); + + it('toggles the codex screen from the menu with the codex key', () => { + expect(game.state).toBe('menu'); + expect(game.menuScreen).toBe('main'); + game.handleKey('codex'); + expect(game.menuScreen).toBe('codex'); + game.handleKey('codex'); + expect(game.menuScreen).toBe('main'); + }); + + it('ignores the codex key while playing', () => { + game.handleKey('primary'); + expect(game.state).toBe('playing'); + game.handleKey('codex'); + expect(game.state).toBe('playing'); + expect(game.menuScreen).toBe('main'); + }); +}); diff --git a/tests/mock-ctx.ts b/tests/mock-ctx.ts index bc316d8..4c90567 100644 --- a/tests/mock-ctx.ts +++ b/tests/mock-ctx.ts @@ -91,6 +91,12 @@ export function makeCtx(): MockCtx { ctx.audio.play('fossil'); ctx.statuses.push('fossil:' + id); }, + collectNote: (x, y, id) => { + ctx.addScore(150, x, y); + ctx.burst(x, y, 10, ['#fbf6ea', '#cfe6ff'], 'dot', 130); + ctx.audio.play('note'); + ctx.statuses.push('note:' + id); + }, }; return ctx; }