diff --git a/src/audio.ts b/src/audio.ts index a72ef92..940bc60 100644 --- a/src/audio.ts +++ b/src/audio.ts @@ -307,6 +307,13 @@ export class AudioManager { this.tone({ freq: 340, to: 130, dur: 0.16, type: 'sawtooth', vol: 0.14 }); this.noiseBurst({ dur: 0.08, vol: 0.08, freq: 700 }); break; + case 'fossil': { + // Deep unearth thud followed by a warm two-note chime. + this.noiseBurst({ dur: 0.1, vol: 0.14, freq: 420 }); + this.tone({ freq: 392, dur: 0.14, type: 'triangle', vol: 0.2, delay: 0.04 }); + this.tone({ freq: 587, dur: 0.2, type: 'sine', vol: 0.18, delay: 0.14 }); + break; + } case 'cheat': [784, 988, 1175, 1568].forEach((f, i) => this.tone({ freq: f, dur: 0.09, type: 'sine', vol: 0.16, delay: i * 0.05 }), diff --git a/src/config.ts b/src/config.ts index 282e794..31840ca 100644 --- a/src/config.ts +++ b/src/config.ts @@ -34,6 +34,8 @@ export const CFG = { heartBonus: 400, /** Points a healing heart is worth when collected at full health. */ heartFull: 200, + /** Points a hidden fossil is worth (re-collectable each run). */ + fossil: 150, timeBonusBase: 2400, timeBonusPerSec: 10, }, diff --git a/src/ctx.ts b/src/ctx.ts index be3a0ea..57a193f 100644 --- a/src/ctx.ts +++ b/src/ctx.ts @@ -51,4 +51,9 @@ export interface GameCtx { * the player is already at full health. */ collectHeart(x: number, y: number): void; + /** + * A hidden fossil was unearthed: score + sparkle, and the first discovery + * is persisted to the fossil codex (id = ":"). + */ + collectFossil(x: number, y: number, id: string): void; } diff --git a/src/fossil.ts b/src/fossil.ts new file mode 100644 index 0000000..c3c9be1 --- /dev/null +++ b/src/fossil.ts @@ -0,0 +1,82 @@ +import { TAU } from './config'; + +/** + * A hidden fossil: a persistent meta-collectible. Found once (stored across + * runs by id ":"), but re-collectable for score on later runs. + */ +export class Fossil { + x: number; + y: number; + w = 28; + h = 20; + 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 - 15, y: this.y - 13, w: 30, h: 26 }; + } + + draw(ctx: CanvasRenderingContext2D, t: number): void { + const bob = Math.sin(t * 1.8 + this.phase) * 2.5; // heavy — bobs less than crystals + const cx = this.x; + const cy = this.y + bob; + const pulse = 0.5 + 0.5 * Math.sin(t * 2.2 + this.phase); + ctx.save(); + ctx.translate(cx, cy); + // warm unearthed glow + ctx.globalAlpha = 0.22 + 0.16 * pulse; + ctx.fillStyle = '#e8dcc0'; + ctx.beginPath(); + ctx.arc(0, 0, 17 + 2 * pulse, 0, TAU); + ctx.fill(); + ctx.globalAlpha = 1; + // bone shaft + const grad = ctx.createLinearGradient(0, -5, 0, 5); + grad.addColorStop(0, '#f4ecd9'); + grad.addColorStop(1, '#cbb98f'); + ctx.fillStyle = grad; + ctx.fillRect(-10, -3.5, 20, 7); + // knob ends (two circles per side) + for (const sx of [-1, 1]) { + for (const sy of [-3.2, 3.2]) { + ctx.beginPath(); + ctx.arc(sx * 11, sy, 4, 0, TAU); + ctx.fill(); + } + } + // weathering crack + ctx.strokeStyle = 'rgba(110,90,55,0.55)'; + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.moveTo(-4, -3); + ctx.lineTo(-1, 0); + ctx.lineTo(-4, 3); + ctx.stroke(); + // outline + ctx.strokeStyle = 'rgba(110,90,55,0.4)'; + ctx.lineWidth = 1; + ctx.strokeRect(-10, -3.5, 20, 7); + ctx.restore(); + // sparkle + if (Math.sin(t * 1.9 + this.phase * 3) > 0.88) { + ctx.save(); + ctx.translate(cx + 10, cy - 11); + 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(); + } + } +} diff --git a/src/game.ts b/src/game.ts index dc0cd4d..860556f 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 } from './store'; +import { Store, getStats, getBest, getBestStars, getDailyBest, getDailyStars, getGhostEnabled, setGhostEnabled, getGhostTrack, saveGhostTrack, getFoundFossils, findFossil } from './store'; import type { GameStats } from './store'; import { AudioManager } from './audio'; import { Input } from './input'; @@ -122,6 +122,8 @@ export class Game implements GameCtx { rainbow = false; /** Ghost race: replay the stored best run alongside the player. */ ghostOn: boolean = getGhostEnabled(); + /** Fossil ids discovered so far (persistent meta-progress). */ + fossilsFound: string[] = getFoundFossils(); private ghost: GhostPlayer | null = null; private ghostRec: GhostRecorder | null = null; /** Cheat queue: apply max hearts once a player exists. */ @@ -260,10 +262,16 @@ export class Game implements GameCtx { /* ---------- lifecycle ---------- */ buildLevel(): void { const info = this.currentInfo(); - this.level = new Level(info.def, this, DIFFICULTIES[this.difficulty].enemySpeed); + // Daily levels have no fossils; the sentinel keeps fossil ids stable. + this.level = new Level(info.def, this, DIFFICULTIES[this.difficulty].enemySpeed, this.daily ? -1 : this.levelIdx); this.bg.theme = info.theme; } + /** Total hidden fossils across the hand-built levels. */ + totalFossils(): number { + return LEVELS.reduce((n, l) => n + (l.def.fossils?.length ?? 0), 0); + } + startGame(): void { this.buildLevel(); this.player = new Player(this.level!.start.x, this.level!.start.y, this); @@ -524,6 +532,25 @@ export class Game implements GameCtx { } } + /** Fossil pickup (GameCtx): persistent discovery + re-collectable score. */ + collectFossil(x: number, y: number, id: string): void { + const first = !this.fossilsFound.includes(id); + if (first) { + findFossil(id); + this.fossilsFound = getFoundFossils(); + } + this.addScore(CFG.score.fossil, x, y - 14); + this.audio.play('fossil'); + if (first) { + this.addStatus('Fossil found! ' + this.fossilsFound.length + '/' + this.totalFossils(), '#e8dcc0'); + this.addShake(2); + this.burst(x, y, 22, ['#f4ecd9', '#e8dcc0', '#cbb98f', '#fff'], 'dot', 170); + this.texts.push(new FloatingText(x, y - 34, 'NEW FOSSIL!', '#f4ecd9')); + } else { + this.burst(x, y, 10, ['#f4ecd9', '#e8dcc0'], '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++) { @@ -753,6 +780,9 @@ export class Game implements GameCtx { for (const h of this.level!.hearts) { if (!h.collected) h.draw(ctx, this.time); } + for (const f of this.level!.fossils) { + if (!f.collected) f.draw(ctx, this.time); + } for (const e of this.level!.enemies) { if (e.dead) continue; if (e.x + e.w < camX - 60 || e.x > camX + VW + 60) continue; @@ -1048,17 +1078,34 @@ export class Game implements GameCtx { ctx.font = '800 17px ' + FONT_STACK; ctx.textAlign = 'left'; ctx.fillText('× ' + (this.crystalsGot || 0) + '/' + (this.level ? this.level.totalCrystals : 0), cx + 14, 38); - // Score & time (right) + // Score, time & fossil count (right) ctx.textAlign = 'right'; ctx.fillStyle = 'rgba(20,30,45,0.55)'; - this.roundRect(ctx, VW - 190, 10, 180, 62, 12); + this.roundRect(ctx, VW - 190, 10, 180, 84, 12); ctx.fill(); ctx.fillStyle = '#ffe28a'; ctx.font = '800 19px ' + FONT_STACK; ctx.fillText('Score ' + this.score, VW - 24, 34); ctx.fillStyle = '#cfe8ff'; ctx.font = '700 15px ' + FONT_STACK; - ctx.fillText('Time ' + fmtTime(this.elapsed), VW - 24, 58); + ctx.fillText('Time ' + fmtTime(this.elapsed), VW - 24, 56); + // Fossil meta-progress with a tiny bone glyph + ctx.font = '700 13px ' + FONT_STACK; + const fossilTxt = 'Fossils ' + this.fossilsFound.length + '/' + this.totalFossils(); + const fossilW = ctx.measureText(fossilTxt).width; + ctx.fillText(fossilTxt, VW - 24, 80); + ctx.save(); + ctx.translate(VW - 24 - fossilW - 16, 75); + ctx.fillStyle = '#e8dcc0'; + ctx.fillRect(-6, -1.8, 12, 3.6); + for (const kx of [-7, 7]) { + for (const ky of [-2.2, 2.2]) { + ctx.beginPath(); + ctx.arc(kx, ky, 2.2, 0, TAU); + ctx.fill(); + } + } + ctx.restore(); // Progress toward the nest (top centre, between the panels) if (this.player && this.level) this.drawProgress(ctx); // Combo chip while a crystal chain is alive @@ -1495,7 +1542,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 + ' · SINCE ' + since, + 'PLAYS ' + this.stats.runs + ' · DEATHS ' + this.stats.deaths + ' · CRYSTALS ' + this.stats.crystals + ' · HEARTS ' + this.stats.hearts + ' · FOSSILS ' + this.fossilsFound.length + '/' + this.totalFossils() + ' · SINCE ' + since, VW / 2, 205, ); @@ -1532,6 +1579,7 @@ export class Game implements GameCtx { 'Stomp beetles, trikes & pteros', 'Watch for lava, spikes & rocks', 'Touch flags to save progress', + 'Unearth the hidden fossils', ]; ctx.font = '600 13px ' + FONT_STACK; quest.forEach((r, i) => { @@ -1582,13 +1630,30 @@ export class Game implements GameCtx { ctx.stroke(); } } + // Fossil dots (hand-built levels only: one per hidden fossil) + if (!isDaily) { + const fCount = LEVELS[i].def.fossils?.length ?? 0; + for (let f = 0; f < fCount; f++) { + const found = this.fossilsFound.includes(i + ':' + f); + const fx = cx0 + cw / 2 + (f - (fCount - 1) / 2) * 18; + ctx.fillStyle = found ? '#f4ecd9' : 'rgba(255,255,255,0.16)'; + ctx.fillRect(fx - 5, cardY + 82, 10, 2.6); + for (const kx of [-5, 5]) { + for (const ky of [-2.4, 2.4]) { + ctx.beginPath(); + ctx.arc(fx + kx, cardY + 83.3 + ky, 2.4, 0, TAU); + ctx.fill(); + } + } + } + } // Records const bb = isDaily ? getDailyBest() : getBest(i); ctx.font = '600 12px ' + FONT_STACK; ctx.textAlign = 'center'; ctx.fillStyle = 'rgba(220,232,245,0.85)'; - ctx.fillText('Score ' + (bb.score || '—'), cx0 + cw / 2, cardY + 98); - ctx.fillText('Time ' + (bb.time === null ? '—' : fmtTime(bb.time)), cx0 + cw / 2, cardY + 118); + ctx.fillText('Score ' + (bb.score || '—'), cx0 + cw / 2, cardY + 104); + ctx.fillText('Time ' + (bb.time === null ? '—' : fmtTime(bb.time)), cx0 + cw / 2, cardY + 122); ctx.font = '600 11px ' + FONT_STACK; ctx.fillStyle = 'rgba(255,255,255,0.45)'; ctx.fillText(isDaily ? dailyLabel(dailySeed()) : 'TAP · ←/→', cx0 + cw / 2, cardY + 138); diff --git a/src/level-data.ts b/src/level-data.ts index a45bd13..c27125a 100644 --- a/src/level-data.ts +++ b/src/level-data.ts @@ -70,6 +70,8 @@ export interface LevelDef { decor: DecorDef[]; /** Spring pads: {x, groundTopY}. Launch the player upward. */ springs?: Point[]; + /** Hidden fossils: persistent meta-collectibles (id = ":"). */ + fossils?: 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. */ @@ -199,6 +201,11 @@ const LEVEL_1: LevelDef = { { x: 2660, y: 330 }, // stone ledge before the spikes { x: 5080, y: 428 }, // just past the spike gauntlet ], + fossils: [ + { x: 3255, y: 163 }, // high bonus route — top of the jump chain + { x: 3230, y: 363 }, // stepping stone over lava pool A + { x: 4990, y: 323 }, // stone ledge right above the spike pit + ], goal: { x: 7150, y: 460 }, decor: [ { type: 'sign', x: 120 }, @@ -321,6 +328,11 @@ const LEVEL_2: LevelDef = { { x: 4300, y: 428 }, // safe ground after the lava moat { x: 6340, y: 332 }, // stone ledge above the rock gauntlet ], + fossils: [ + { x: 2830, y: 393 }, // stepping stone mid-moat, lava on both sides + { x: 5685, y: 313 }, // stone above the twin falling-rock gauntlet + { x: 7010, y: 275 }, // high stone of the home-stretch jump chain + ], goal: { x: 7350, y: 460 }, decor: [ { type: 'sign', x: 120 }, @@ -447,6 +459,11 @@ const LEVEL_3: LevelDef = { { x: 3720, y: 428 }, // just past the first gate { x: 7720, y: 428 }, // past the final gate, near the goal ], + fossils: [ + { x: 3860, y: 425 }, // just behind the first gate + { x: 5925, y: 263 }, // bonus spring ledge, high above the rockfall ridge + { x: 7700, y: 425 }, // behind the final gate, patrolled by a beetle + ], goal: { x: 7850, y: 460 }, decor: [ { type: 'sign', x: 120 }, diff --git a/src/level.ts b/src/level.ts index 3171369..fda9979 100644 --- a/src/level.ts +++ b/src/level.ts @@ -13,6 +13,7 @@ import { SpringPad } from './spring'; import { PressurePlate } from './plate'; import { Door } from './door'; import { Projectile } from './projectile'; +import { Fossil } from './fossil'; export class Level { width: number; @@ -33,12 +34,14 @@ export class Level { plates: PressurePlate[]; doors: Door[]; projectiles: Projectile[]; + fossils: Fossil[]; readonly game: GameCtx; - constructor(d: LevelDef, game: GameCtx, enemySpeed = 1) { + constructor(d: LevelDef, game: GameCtx, enemySpeed = 1, levelIdx = 0) { this.width = d.width; this.enemySpeed = enemySpeed; this.game = game; + this.fossils = (d.fossils ?? []).map((f, i) => new Fossil(f.x, f.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)); @@ -114,6 +117,7 @@ export class Level { this.projectiles = []; 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 e of this.enemies) e.reset(); // Restore each hazard's own interval (the original hard-coded 2.2, // clobbering hazards configured with a different one). diff --git a/src/player.ts b/src/player.ts index 4c2d260..2016bd1 100644 --- a/src/player.ts +++ b/src/player.ts @@ -310,6 +310,15 @@ export class Player { } } + // --- Fossils (persistent discoveries, re-collectable for score) --- + for (const f of level.fossils) { + if (f.collected) continue; + if (overlap(this.rect, f.rect)) { + f.collected = true; + this.game.collectFossil(f.x, f.y, f.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 fc16e3d..b551a83 100644 --- a/src/store.ts +++ b/src/store.ts @@ -75,6 +75,23 @@ export function saveGhostTrack(idx: number, track: GhostTrack): void { Store.set(key, track); } +/** + * Found fossil ids (":"), persistent across runs. A fossil can + * be re-collected for score on later runs, but only the first discovery + * counts toward the codex. + */ +export function getFoundFossils(): string[] { + const v = Store.get('tinyrex_fossils', null); + return Array.isArray(v) ? v : []; +} + +/** Record a fossil discovery; no-op when it is already in the codex. */ +export function findFossil(id: string): void { + const found = getFoundFossils(); + if (found.includes(id)) return; + Store.set('tinyrex_fossils', [...found, id]); +} + /** Safe localStorage wrapper (settings + best records). */ export const Store = { get(key: string, fallback: T): T { diff --git a/tests/fossil.test.ts b/tests/fossil.test.ts new file mode 100644 index 0000000..d991839 --- /dev/null +++ b/tests/fossil.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from 'vitest'; +import { Fossil } from '../src/fossil'; +import { LEVELS } from '../src/level-data'; +import { Level } from '../src/level'; +import { makeCtx } from './mock-ctx'; +import { Store, getFoundFossils, findFossil } from '../src/store'; + +describe('Fossil entity', () => { + it('exposes a collision rect centred on its position', () => { + const f = new Fossil(300, 400, '0:0'); + expect(f.rect).toEqual({ x: 285, y: 387, w: 30, h: 26 }); + expect(f.collected).toBe(false); + expect(f.id).toBe('0:0'); + }); + + it('draws without error at several animation phases', () => { + const f = new Fossil(300, 400, '0:0'); + const ctx = document.createElement('canvas').getContext('2d')!; + for (const t of [0, 0.5, 1.2, 3.9, 9.7]) f.draw(ctx, t); + }); + + it('builds stable "levelIdx:i" ids from level data', () => { + const level = new Level(LEVELS[1].def, makeCtx(), 1, 1); + expect(level.fossils).toHaveLength(3); + expect(level.fossils.map((f) => f.id)).toEqual(['1:0', '1:1', '1:2']); + }); + + it('re-collects after a respawn reset', () => { + const level = new Level(LEVELS[0].def, makeCtx(), 1, 0); + const f = level.fossils[0]; + f.collected = true; + level.reset(); + expect(f.collected).toBe(false); + }); +}); + +describe('Fossil store (persistent codex)', () => { + it('round-trips discoveries and ignores duplicates', () => { + localStorage.clear(); + expect(getFoundFossils()).toEqual([]); + findFossil('0:0'); + findFossil('0:0'); // duplicate is a no-op + findFossil('2:1'); + expect(getFoundFossils()).toEqual(['0:0', '2:1']); + expect(Store.get('tinyrex_fossils', null)).toEqual(['0:0', '2:1']); + }); + + it('guards against corrupted storage', () => { + localStorage.clear(); + localStorage.setItem('tinyrex_fossils', 'not-an-array'); + expect(getFoundFossils()).toEqual([]); + // A corrupted read still allows new discoveries + findFossil('1:2'); + expect(getFoundFossils()).toEqual(['1:2']); + }); +}); diff --git a/tests/game.test.ts b/tests/game.test.ts index 07ae411..2a4c11c 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, getGhostEnabled, type GameStats } from '../src/store'; +import { LEVEL_DATA, LEVELS } from '../src/level-data'; +import { Store, getGhostEnabled, getFoundFossils, type GameStats } from '../src/store'; function makeGame(): Game { const canvas = document.createElement('canvas'); @@ -315,3 +315,60 @@ describe('Ghost race', () => { expect((game as unknown as { ghost: unknown }).ghost).toBeNull(); }); }); + +describe('Fossil discoveries', () => { + let game: Game; + + beforeEach(() => { + localStorage.clear(); + game = makeGame(); + }); + + it('counts every hidden fossil for the HUD and menu', () => { + expect(game.totalFossils()).toBe( + LEVELS.reduce((n, l) => n + (l.def.fossils?.length ?? 0), 0), + ); + expect(game.totalFossils()).toBe(9); + }); + + it('awards score on pickup and persists the first discovery', () => { + game.handleKey('primary'); + const f = game.level!.fossils[0]; + expect(f.id).toBe('0:0'); + game.collectFossil(f.x, f.y, f.id); + expect(game.score).toBe(150); + expect(game.fossilsFound).toContain('0:0'); + expect(getFoundFossils()).toContain('0:0'); + expect(Store.get('tinyrex_fossils', null)).toContain('0:0'); + }); + + it('pays score on re-collection but records the discovery once', () => { + game.handleKey('primary'); + game.collectFossil(0, 0, '0:1'); + game.collectFossil(0, 0, '0:1'); + expect(game.score).toBe(300); + expect(game.fossilsFound.filter((id) => id === '0:1')).toHaveLength(1); + expect(getFoundFossils()).toEqual(['0:1']); + }); + + it('collects fossils through player collisions during play', () => { + game.handleKey('primary'); + const f = game.level!.fossils[0]; // (3255, 163) on the stone ledge {3220, 200, 130} + // Land the player standing on the ledge, overlapping the fossil. + game.player!.x = 3245; + game.player!.y = 200 - game.player!.h; + game.player!.vx = 0; + game.player!.vy = 0; + game.update(0.05); + expect(f.collected).toBe(true); + expect(game.score).toBeGreaterThanOrEqual(150); + }); + + it('re-collects fossils after a respawn reset', () => { + game.handleKey('primary'); + const f = game.level!.fossils[0]; + f.collected = true; + game.level!.reset(); + expect(f.collected).toBe(false); + }); +}); diff --git a/tests/level-data.test.ts b/tests/level-data.test.ts index c5be674..bf7304c 100644 --- a/tests/level-data.test.ts +++ b/tests/level-data.test.ts @@ -287,3 +287,36 @@ describe('LEVEL_3 — Frostpeak Pass integrity', () => { } }); }); + +describe('Hidden fossils (all hand-built levels)', () => { + it('places exactly three fossils per level (nine total)', () => { + for (const l of LEVELS) { + expect(l.def.fossils, l.name).toHaveLength(3); + } + expect(LEVELS.reduce((n, l) => n + (l.def.fossils?.length ?? 0), 0)).toBe(9); + }); + + it('keeps every fossil inside the level bounds, above the ground line', () => { + for (const l of LEVELS) { + for (const f of l.def.fossils ?? []) { + expect(f.x, l.name + ' fossil x=' + f.x).toBeGreaterThan(0); + expect(f.x, l.name + ' fossil x=' + f.x).toBeLessThan(l.def.width); + expect(f.y, l.name + ' fossil y=' + f.y).toBeGreaterThan(100); + expect(f.y, l.name + ' fossil y=' + f.y).toBeLessThan(460); + } + } + }); + + it('perches every fossil on a solid platform a standing player can reach', () => { + // Player is 46px tall: standing on a platform top at p.y, its rect spans + // [p.y-46, p.y]. The fossil rect [f.y-13, f.y+13] must overlap it. + for (const l of LEVELS) { + for (const f of l.def.fossils ?? []) { + const perch = (l.def.platforms ?? []).some( + (p) => f.x >= p.x && f.x <= p.x + p.w && p.y > f.y - 13 && p.y < f.y + 59, + ); + expect(perch, l.name + ' fossil at (' + f.x + ',' + f.y + ') has no perch').toBe(true); + } + } + }); +}); diff --git a/tests/mock-ctx.ts b/tests/mock-ctx.ts index de150e6..10abdba 100644 --- a/tests/mock-ctx.ts +++ b/tests/mock-ctx.ts @@ -75,6 +75,12 @@ export function makeCtx(): MockCtx { ctx.burst(x, y, 12, ['#ff8fa3', '#ffd9e2', '#fff'], 'dot', 140); ctx.audio.play('heart'); }, + collectFossil: (x, y, id) => { + ctx.addScore(150, x, y); + ctx.burst(x, y, 10, ['#f4ecd9', '#e8dcc0'], 'dot', 130); + ctx.audio.play('fossil'); + ctx.statuses.push('fossil:' + id); + }, }; return ctx; }