From ea62e3c3f8f1cae64b732de37358748e5ac16a7c Mon Sep 17 00:00:00 2001 From: Chris Malpass Date: Thu, 27 Aug 2026 08:47:36 -0400 Subject: [PATCH] Add Duskfen: Level 5 with a rising-tide mechanic and dusk theme - New level "Duskfen: The Drowned Vale" (7600px, 5 sections): dry intro, beetle bog with lava pit, canopy with spring + spitter + pterodactyls, flooded lowland with crumble bridge + plate/door gate, and the drowned vale with a continuous dry-hop route up to the nest rock. - Rising-tide mechanic: the waterline climbs 68px over ~136s while the run is live (keeps pressure through knockback/death, frozen on victory); submersion costs a heart and splashes Rex back up; one-shot "The tide is rising!" ping with a new low-whoosh SFX. Water renders in screen space with a gradient and animated sine surface. - Dusk theme: twilight sky palette with warm horizon, purple moon, twinkling stars, ember-dimmed volcano, soft purple clouds; firefly weather (16 blinking glows drifting over the marsh); a slow minor-key 76bpm music track. - Victory flourish for finishing Duskfen: "The fen falls silent. The nest is home." status + tide swell + 40 drifting particles. - 3 new Duskfen field notes (waterline / fireflies / silent fen); menu card accent + 6-card layout; daily generator untouched (explicit 3-theme list, no tide). - Tests: 8 new unit tests in tests/dusk.test.ts (registry, level structure, tide advance/cap/freeze, damage + bounce + one-shot ping, reset, dusk theme plumbing); legacy level-count assertions updated. 264/264 unit tests, build 155.56kB (47.72kB gzip), lint clean, 7/7 headless Playwright E2E checks with screenshot verification. --- src/audio.ts | 12 ++++ src/background.ts | 32 +++++++-- src/config.ts | 2 + src/game.ts | 71 +++++++++++++++++-- src/level-data.ts | 144 ++++++++++++++++++++++++++++++++++++++- src/level.ts | 11 +++ src/lore.ts | 20 +++++- src/player.ts | 27 ++++++-- src/weather.ts | 37 +++++++++- tests/dusk.test.ts | 122 +++++++++++++++++++++++++++++++++ tests/game.test.ts | 2 +- tests/level-data.test.ts | 11 +-- tests/lore.test.ts | 4 +- 13 files changed, 468 insertions(+), 27 deletions(-) create mode 100644 tests/dusk.test.ts diff --git a/src/audio.ts b/src/audio.ts index 30ae601..c38f06a 100644 --- a/src/audio.ts +++ b/src/audio.ts @@ -450,6 +450,11 @@ export class AudioManager { this.tone({ freq: 784, dur: 0.14, type: 'triangle', vol: 0.22, delay: 0.18 }); this.tone({ freq: 1046, dur: 0.26, type: 'sine', vol: 0.16, delay: 0.28 }); break; + case 'tide': + // Low whoosh of rising water: filtered noise bed + a slow swell tone. + this.noiseBurst({ dur: 0.5, vol: 0.12, freq: 420 }); + this.tone({ freq: 120, to: 220, dur: 0.5, type: 'sine', vol: 0.1 }); + break; } } @@ -487,4 +492,11 @@ const MUSIC: Record VW + 160) continue; - if (night) { + if (night && this.theme === 'volcanic') { const a = 0.4 + 0.3 * Math.sin(t * 2 + c.x); ctx.fillStyle = `rgba(255,128,54,${a.toFixed(2)})`; ctx.beginPath(); ctx.arc(cx, c.y + Math.sin(t * 0.8 + c.x) * 6, 2.2 * c.s, 0, TAU); ctx.fill(); + } else if (this.theme === 'dusk') { + // Twilight clouds: soft purple slivers catching the last light + const a = 0.25 + 0.15 * Math.sin(t * 0.6 + c.x); + ctx.fillStyle = `rgba(226,150,120,${a.toFixed(2)})`; + ctx.beginPath(); + ctx.ellipse(cx, c.y + 40 * c.s, 52 * c.s, 7 * c.s, 0, 0, TAU); + ctx.ellipse(cx + 34 * c.s, c.y + 36 * c.s, 30 * c.s, 5 * c.s, 0, 0, TAU); + ctx.fill(); } else { ctx.fillStyle = 'rgba(255,255,255,0.92)'; ctx.beginPath(); @@ -270,7 +288,7 @@ export class Background { private drawVolcano(ctx: CanvasRenderingContext2D, off: number, worldX: number, t: number, scale: number): void { const x = worldX - off; if (x < -400 || x > VW + 400) return; - const night = this.theme === 'volcanic'; + const night = this.theme === 'volcanic' || this.theme === 'dusk'; ctx.fillStyle = night ? '#241830' : '#b0826a'; ctx.beginPath(); ctx.moveTo(x - 170 * scale, 520); @@ -279,8 +297,8 @@ export class Background { ctx.lineTo(x + 170 * scale, 520); ctx.closePath(); ctx.fill(); - // lava streak (brighter when the volcano theme is active) - ctx.strokeStyle = night ? '#ff8a3c' : '#e0703a'; + // lava streak (bright when active, dimmed as the volcano cools at dusk) + ctx.strokeStyle = this.theme === 'volcanic' ? '#ff8a3c' : night ? '#c25a2e' : '#e0703a'; ctx.lineWidth = (night ? 6 : 5) * scale; ctx.beginPath(); ctx.moveTo(x - 12 * scale, 255 - 40 * scale); diff --git a/src/config.ts b/src/config.ts index e9f4334..7ed4150 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,6 +23,8 @@ export const CFG = { invulnTime: 1.6, maxHearts: 3, lavaBounce: 580, + /** Upward splash when the rising tide claims Rex. */ + tideBounce: 300, respawnInvuln: 2.2, }, camera: { lerp: 5.5, lookAhead: 46, maxShake: 14 }, diff --git a/src/game.ts b/src/game.ts index 1cf1cbd..22d692a 100644 --- a/src/game.ts +++ b/src/game.ts @@ -749,6 +749,24 @@ export class Game implements GameCtx { vrot: (Math.random() - 0.5) * 12, })); } + // Duskfen flourish: the fen falls silent as the nest comes home + if (this.levelIdx === 4 && !this.daily) { + this.addStatus('The fen falls silent. The nest is home.', '#c9a0ff'); + this.audio.play('tide'); + for (let i = 0; i < (this.reducedMotion ? 12 : 40); i++) { + this.particles.push(new Particle({ + x: this.level!.goal.x - 140 + Math.random() * 280, + y: 40 + Math.random() * 80, + vx: (Math.random() - 0.5) * 30, + vy: 20 + Math.random() * 40, + life: 2 + Math.random(), + size: 4, + color: ['#c9a0ff', '#8fd0ff', '#ffd257'][i % 3], + grav: 40, + type: 'dot', + })); + } + } // Final score const timeBonus = Math.max(0, CFG.score.timeBonusBase - Math.floor(this.elapsed) * CFG.score.timeBonusPerSec); const heartBonus = this.player!.hearts * CFG.score.heartBonus; @@ -844,6 +862,22 @@ export class Game implements GameCtx { for (const d of this.level?.doors ?? []) d.latched = true; } + /** + * Rising tide (Duskfen): the waterline climbs while the run is live + * (playing or dying — the clock keeps pressure mid-knockback) and + * pings once when it first reaches the player. + */ + private updateTide(dt: number): void { + const lvl = this.level; + if (!lvl?.tide) return; + lvl.waterY = Math.max(lvl.tide.toY, lvl.waterY - lvl.tide.rate * dt); + if (!lvl.tideWarned && this.player && lvl.waterY < this.player.rect.y + this.player.rect.h) { + lvl.tideWarned = true; + this.addStatus('The tide is rising!', '#8fd0ff'); + this.audio.play('tide'); + } + } + update(dt: number): void { this.input.pollGamepad(); this.audio.update(dt); @@ -854,6 +888,7 @@ export class Game implements GameCtx { this.ghost?.update(this.elapsed); this.level!.update(dt, this.time, this.player!); this.player!.update(dt, this.time, this.input, this.level!); + this.updateTide(dt); this.weather.update(dt, this.player!); this.camera.update(dt, this.player!, this.level!.width, this); this.updateAdaptive(); @@ -888,6 +923,7 @@ export class Game implements GameCtx { this.dyingT += dt; this.level!.update(dt, this.time, this.player!); this.player!.update(dt, this.time, this.input, this.level!); + this.updateTide(dt); this.weather.update(dt, this.player!); this.camera.update(dt, this.player!, this.level!.width, this); if (this.dyingT > 1.15) { @@ -1031,6 +1067,27 @@ export class Game implements GameCtx { ctx.restore(); + // Rising tide (screen space — the water spans the whole viewport) + if (this.level?.tide) { + const top = this.level.waterY; + if (Number.isFinite(top) && top < VH) { + const wg = ctx.createLinearGradient(0, top, 0, VH); + wg.addColorStop(0, 'rgba(72,138,186,0.72)'); + wg.addColorStop(1, 'rgba(14,40,78,0.94)'); + ctx.fillStyle = wg; + ctx.fillRect(0, top, VW, VH - top); + ctx.beginPath(); + for (let x = 0; x <= VW; x += 12) { + const y = top + Math.sin(x * 0.02 + this.time * 2.4) * 2.5; + if (x === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.strokeStyle = 'rgba(190,225,255,0.85)'; + ctx.lineWidth = 2; + ctx.stroke(); + } + } + // Vignette-ish bottom fade for depth const vg = ctx.createLinearGradient(0, VH - 60, 0, VH); vg.addColorStop(0, 'rgba(40,30,20,0)'); @@ -1751,11 +1808,12 @@ export class Game implements GameCtx { // Draw a slice of the level floor for grounding const volcanic = this.bg.theme === 'volcanic'; const frost = this.bg.theme === 'frost'; - ctx.fillStyle = volcanic ? '#42304a' : frost ? '#8fa8c4' : '#8a5f3c'; + const dusk = this.bg.theme === 'dusk'; + ctx.fillStyle = volcanic ? '#42304a' : frost ? '#8fa8c4' : dusk ? '#3a3050' : '#8a5f3c'; ctx.fillRect(0, 460, VW, 80); - ctx.fillStyle = volcanic ? '#5c4258' : frost ? '#e8f4fc' : '#5da854'; + ctx.fillStyle = volcanic ? '#5c4258' : frost ? '#e8f4fc' : dusk ? '#54436e' : '#5da854'; ctx.fillRect(0, 460, VW, 12); - ctx.fillStyle = volcanic ? '#8a5a78' : frost ? '#ffffff' : '#6fbe62'; + ctx.fillStyle = volcanic ? '#8a5a78' : frost ? '#ffffff' : dusk ? '#8a6f9e' : '#6fbe62'; ctx.fillRect(0, 460, VW, 5); // Decor drawDecor(ctx, { type: 'tree', x: 130, s: 1.1 }, this.time, 460); @@ -1789,7 +1847,7 @@ export class Game implements GameCtx { ctx.font = '800 64px ' + FONT_STACK; ctx.lineJoin = 'round'; ctx.lineWidth = 6; - ctx.strokeStyle = volcanic ? 'rgba(60,25,20,0.85)' : 'rgba(30,50,30,0.85)'; + ctx.strokeStyle = volcanic ? 'rgba(60,25,20,0.85)' : dusk ? 'rgba(30,18,44,0.85)' : 'rgba(30,50,30,0.85)'; ctx.strokeText('TINY REX', VW / 2, 118 + bounce); const tg = ctx.createLinearGradient(0, 56, 0, 124); if (volcanic) { @@ -1798,6 +1856,9 @@ export class Game implements GameCtx { } else if (frost) { tg.addColorStop(0, '#eaf7ff'); tg.addColorStop(1, '#7fb5e6'); + } else if (dusk) { + tg.addColorStop(0, '#f0c880'); + tg.addColorStop(1, '#8a5fa8'); } else { tg.addColorStop(0, '#c8f0a0'); tg.addColorStop(1, '#5da854'); @@ -1882,7 +1943,7 @@ export class Game implements GameCtx { const cardGap = 14; const cardW = Math.floor((680 - cardGap * (cardCount - 1)) / cardCount); const cardX0 = VW / 2 - (cardW * cardCount + cardGap * (cardCount - 1)) / 2; - const cardAccents = ['#9ff0a8', '#ff9d7a', '#8fd8ff', '#ff7a5c']; + const cardAccents = ['#9ff0a8', '#ff9d7a', '#8fd8ff', '#ff7a5c', '#a9c2ff']; for (let i = 0; i < cardCount; i++) { const isDaily = i === LEVELS.length; const cx0 = cardX0 + i * (cardW + cardGap); diff --git a/src/level-data.ts b/src/level-data.ts index 9e250c5..2cad33a 100644 --- a/src/level-data.ts +++ b/src/level-data.ts @@ -82,9 +82,14 @@ export interface LevelDef { boss?: { x: number; y: number; minX: number; maxX: number }; /** Stompable crystal orbs that stun the boss (boss arenas only). */ orbs?: Point[]; + /** + * Rising tide: the waterline climbs from `fromY` to `toY` at `rate` px/s + * while the run is live. Standing in the water damages the player. + */ + tide?: { fromY: number; toY: number; rate: number }; } -export type LevelTheme = 'meadow' | 'volcanic' | 'frost'; +export type LevelTheme = 'meadow' | 'volcanic' | 'frost' | 'dusk'; export interface LevelInfo { id: number; @@ -576,11 +581,148 @@ const LEVEL_4: LevelDef = { ], }; +// Duskfen — Level 5. A drowned valley at twilight: the tide creeps in from +// the first second and, by the end of the run, has submerged the low ground +// (tops at 460) entirely. Safe route: keep to the canopy and elevated ledges +// (tops <= 446), which the waterline never reaches. +const LEVEL_5: LevelDef = { + width: 7600, + startX: 120, + startY: 414, + startGroundY: 460, + tide: { fromY: 520, toY: 452, rate: 0.5 }, + platforms: [ + // ---- Section A: the fen's edge — dry intro (teach move + jump) ---- + { x: 0, y: 460, w: 1500, h: 120, type: 'ground' }, + { x: 320, y: 372, w: 130, h: 24, type: 'wood' }, + { x: 520, y: 300, w: 130, h: 24, type: 'wood' }, + { x: 760, y: 372, w: 130, h: 24, type: 'wood' }, + // ---- Section B: beetle bog — lowland with a water pit ---- + { x: 1500, y: 460, w: 520, h: 120, type: 'ground' }, + { x: 2140, y: 460, w: 1060, h: 120, type: 'ground' }, + { x: 2045, y: 398, w: 90, h: 24, type: 'wood' }, // step over the pit + { x: 1780, y: 318, w: 120, h: 24, type: 'stone' }, // high fossil ledge + { x: 1900, y: 420, w: 90, h: 24, type: 'wood' }, // dry hop (late-tide safe) + { x: 2400, y: 420, w: 90, h: 24, type: 'wood' }, // dry hop + { x: 2800, y: 420, w: 90, h: 24, type: 'wood' }, // dry hop + // ---- Section C: the canopy — elevated route (always dry) ---- + { x: 3200, y: 460, w: 1400, h: 120, type: 'ground' }, + { x: 3300, y: 398, w: 140, h: 24, type: 'wood' }, + { x: 3540, y: 344, w: 140, h: 24, type: 'wood' }, + { x: 3790, y: 290, w: 150, h: 24, type: 'wood' }, // crown of the canopy + { x: 4050, y: 344, w: 140, h: 24, type: 'wood' }, + { x: 4290, y: 398, w: 140, h: 24, type: 'wood' }, + // ---- Section D: flooded lowland — bridge, plate & door ---- + { x: 4600, y: 460, w: 600, h: 120, type: 'ground' }, + { x: 5560, y: 460, w: 1440, h: 120, type: 'ground' }, + { x: 4700, y: 420, w: 100, h: 24, type: 'wood' }, // dry hop + { x: 4950, y: 420, w: 100, h: 24, type: 'wood' }, // plate ledge (dry hop) + { x: 5230, y: 430, w: 80, h: 20, type: 'crumble' }, // the crumble bridge + { x: 5350, y: 430, w: 80, h: 20, type: 'crumble' }, + { x: 5470, y: 430, w: 90, h: 20, type: 'crumble' }, + { x: 5300, y: 330, w: 110, h: 24, type: 'stone' }, // fossil perch over the gap + // ---- Section E: the drowned vale — dry hops to the spring & nest rock ---- + { x: 7140, y: 460, w: 460, h: 120, type: 'ground' }, + { x: 6200, y: 420, w: 110, h: 24, type: 'wood' }, // dry hop + { x: 6500, y: 420, w: 110, h: 24, type: 'wood' }, // dry hop + { x: 6800, y: 420, w: 110, h: 24, type: 'wood' }, // dry hop + { x: 7200, y: 340, w: 160, h: 24, type: 'stone' }, // the nest rock (goal) + ], + springs: [ + { x: 3255, y: 460 }, // up into the canopy + { x: 6950, y: 460 }, // the final launch onto the nest rock + ], + plates: [ + { x: 4980, y: 420, door: 0 }, + ], + doors: [ + { x: 5150, y: 310, w: 40, h: 150 }, // the sunken gate + ], + crystals: [ + // Section A + { x: 190, y: 425 }, { x: 260, y: 425 }, + { x: 385, y: 335 }, { x: 585, y: 263 }, { x: 825, y: 335 }, + { x: 1100, y: 425 }, { x: 1240, y: 425 }, { x: 1380, y: 425 }, + // Section B + { x: 1560, y: 425 }, { x: 1660, y: 425 }, + { x: 1840, y: 282 }, // high ledge + { x: 2090, y: 362 }, // pit arc + { x: 2300, y: 425 }, { x: 2430, y: 425 }, { x: 2600, y: 425 }, + { x: 2870, y: 425 }, { x: 3050, y: 425 }, + // Section C (canopy) + { x: 3350, y: 362 }, { x: 3590, y: 308 }, { x: 3860, y: 254 }, + { x: 4100, y: 308 }, { x: 4340, y: 362 }, + { x: 3400, y: 425 }, { x: 4000, y: 425 }, { x: 4230, y: 425 }, + // Section D + { x: 4680, y: 425 }, { x: 4800, y: 384 }, { x: 5000, y: 384 }, + { x: 5250, y: 393 }, { x: 5370, y: 393 }, { x: 5490, y: 393 }, // bridge + { x: 5620, y: 425 }, { x: 5850, y: 425 }, + // Section E + { x: 6100, y: 425 }, { x: 6300, y: 384 }, { x: 6500, y: 384 }, + { x: 6700, y: 425 }, { x: 6900, y: 425 }, + { x: 7050, y: 340 }, { x: 7160, y: 300 }, // spring flight + { x: 7280, y: 304 }, { x: 7380, y: 304 }, // on the nest rock + ], + hazards: [ + { type: 'lava', x: 2020, y: 520, w: 120 }, // the bog pit (the tide will drown it) + { type: 'spikes', x: 5750, y: 460, w: 70 }, // sunken teeth on the low ground + ], + checkpoints: [ + { x: 1520, y: 460 }, // the bog + { x: 4230, y: 460 }, // under the canopy crown + { x: 6050, y: 460 }, // the drowned vale + ], + enemies: [ + { type: 'beetle', x: 2500, y: 432, minX: 2350, maxX: 2900 }, + { type: 'ptero', x: 3700, y: 240, range: 140 }, + { type: 'spitter', x: 4080, y: 306 }, + { type: 'ptero', x: 4350, y: 250, range: 120 }, + { type: 'trike', x: 5700, y: 424, minX: 5600, maxX: 6000 }, + { type: 'beetle', x: 6300, y: 432, minX: 6150, maxX: 6600 }, + { type: 'ptero', x: 6600, y: 240, range: 130 }, + ], + hearts: [ + { x: 2700, y: 428 }, // the bog + { x: 3990, y: 425 }, // under the canopy + { x: 5620, y: 425 }, // past the crumble bridge + { x: 6900, y: 425 }, // by the final spring + { x: 7240, y: 306 }, // on the nest rock + ], + fossils: [ + { x: 1842, y: 294 }, // high ledge in the bog + { x: 3855, y: 256 }, // crown of the canopy + { x: 5345, y: 306 }, // perch over the sunken gate + ], + notes: [ + { x: 2210, y: 436 }, // the bog + { x: 3560, y: 310 }, // mid-canopy + { x: 7350, y: 428 }, // on the nest ground + ], + goal: { x: 7280, y: 340 }, + decor: [ + { type: 'sign', x: 120 }, + { type: 'tree', x: 220, s: 1.1 }, { type: 'flower', x: 420, color: '#c9a0ff' }, + { type: 'tuft', x: 650 }, { type: 'tree', x: 900, s: 0.95 }, + { type: 'crystalrock', x: 1150, s: 1.0 }, { type: 'tuft', x: 1400 }, + { type: 'tuft', x: 1550 }, { type: 'tree', x: 1950, s: 1.05 }, { type: 'bush', x: 2180 }, + { type: 'tuft', x: 2350 }, { type: 'bush', x: 2650 }, { type: 'tuft', x: 2950 }, + { type: 'flower', x: 3100, color: '#ff8fa3' }, { type: 'tree', x: 3450, s: 1.2 }, + { type: 'tuft', x: 3650 }, { type: 'tree', x: 3950, s: 1.1 }, { type: 'tuft', x: 4150 }, + { type: 'bush', x: 4400 }, { type: 'tuft', x: 4550 }, { type: 'rock', x: 4680 }, + { type: 'tuft', x: 4880 }, { type: 'bush', x: 5100 }, { type: 'tuft', x: 5420 }, + { type: 'rock', x: 5520 }, { type: 'tuft', x: 5700 }, { type: 'tree', x: 5950, s: 1.05 }, + { type: 'tuft', x: 6150 }, { type: 'bush', x: 6400 }, { type: 'tuft', x: 6650 }, + { type: 'rock', x: 6850 }, { type: 'tuft', x: 7050 }, { type: 'crystalrock', x: 7450, s: 1.15 }, + { type: 'tuft', x: 7520 }, + ], +}; + export const LEVELS: LevelInfo[] = [ { id: 0, name: 'Crystal Valley', subtitle: 'CRYSTAL VALLEY', theme: 'meadow', def: LEVEL_1 }, { id: 1, name: 'Volcanic Depths', subtitle: 'VOLCANIC DEPTHS', theme: 'volcanic', def: LEVEL_2 }, { id: 2, name: 'Frostpeak Pass', subtitle: 'FROSTPEAK PASS', theme: 'frost', def: LEVEL_3 }, { id: 3, name: 'Molten Nest', subtitle: 'MOLTEN NEST', theme: 'volcanic', def: LEVEL_4 }, + { id: 4, name: 'Duskfen', subtitle: 'DUSKFEN', theme: 'dusk', def: LEVEL_5 }, ]; /** Backward-compatible handle for the original level (Crystal Valley). */ diff --git a/src/level.ts b/src/level.ts index 60d9880..5016828 100644 --- a/src/level.ts +++ b/src/level.ts @@ -46,6 +46,12 @@ export class Level { boss: MagmaKing | null; /** Power-up capsules dropped by stomped enemies. */ powerups: PowerUp[]; + /** Rising-tide config (null on levels without a tide). */ + tide: { fromY: number; toY: number; rate: number } | null; + /** Current waterline y (Infinity when the level has no tide). */ + waterY: number; + /** One-shot "the tide is rising" ping. */ + tideWarned: boolean; readonly game: GameCtx; constructor(d: LevelDef, game: GameCtx, enemySpeed = 1, levelIdx = 0) { @@ -86,6 +92,9 @@ export class Level { ? new MagmaKing(d.boss, this, enemySpeed, d.orbs ?? []) : null; this.powerups = []; + this.tide = d.tide ?? null; + this.waterY = this.tide ? this.tide.fromY : Infinity; + this.tideWarned = false; } /** Spawn a power-up capsule (enemy-kill drop). */ @@ -168,5 +177,7 @@ export class Level { cp.active = false; cp.pulse = 0; } + if (this.tide) this.waterY = this.tide.fromY; + this.tideWarned = false; } } diff --git a/src/lore.ts b/src/lore.ts index b1241c6..927c4dd 100644 --- a/src/lore.ts +++ b/src/lore.ts @@ -8,7 +8,7 @@ export interface NoteEntry { } /** - * The dino-archaeologist's field notes: 3 per hand-built level (12 total). + * The dino-archaeologist's field notes: 3 per hand-built level (15 total). * Discovered in the world, read on the menu codex screen. */ export const NOTES: NoteEntry[][] = [ @@ -84,6 +84,24 @@ export const NOTES: NoteEntry[][] = [ hint: 'Behind the gate, near the nest', }, ], + // Duskfen + [ + { + title: 'Day 38 — The waterline', + text: 'The fen is not a swamp. It is a flooded floor. The water rises on a clock — I timed it, a finger-width every long while — and when the last low ground goes under, only the canopy keeps its promises. Whatever is down there, it is patient. It does not chase. It simply waits to be whole water.', + hint: 'Just past the first flag', + }, + { + title: 'Day 40 — Fireflies with a job', + text: 'Every firefly in Duskfen blinks on the same beat, three slow pulses, then dark. I counted a hundred of them keeping time in the dark. They are not insects here. They are lanterns, left burning for someone coming home.', + hint: 'Walk the middle of the canopy', + }, + { + title: 'Day 42 — The silent fen', + text: 'I reached the nest rock while the last of the water was still climbing the reeds. No wind, no wings, no water sound at all — the fen just stopped, the way a held breath stops. The egg in the nest was warm. I am going home. Some things are better guarded than explained.', + hint: 'On the nest ground, by the goal', + }, + ], ]; export function totalNotes(): number { diff --git a/src/player.ts b/src/player.ts index 81f77e6..63bdc12 100644 --- a/src/player.ts +++ b/src/player.ts @@ -9,7 +9,7 @@ import { rollDrop } from './powerup'; import type { PowerUpType } from './powerup'; export type PlayerState = 'idle' | 'run' | 'jump' | 'fall' | 'hurt' | 'dead' | 'victory'; -export type DamageKind = 'spikes' | 'lava' | 'enemy' | 'rock' | 'pit' | 'spit'; +export type DamageKind = 'spikes' | 'lava' | 'enemy' | 'rock' | 'pit' | 'spit' | 'tide'; /** Haptic pulse where supported (mobile vibration + gamepad rumble); silent no-op elsewhere. */ function vibrate(pattern: number | number[]): void { @@ -331,6 +331,12 @@ export class Player { } } + // --- Rising tide (Duskfen): submersion past the waterline damages --- + if (level.tide && this.invulnT <= 0 && this.rect.y + this.rect.h > level.waterY + 6) { + this.damage({ x: this.x, w: this.w }, 'tide'); + if (!this.dead) this.vy = -P.tideBounce; // splash back up + } + // --- Crystals (the Magnet power-up pulls them in first) --- for (const c of level.crystals) { if (c.collected) continue; @@ -480,7 +486,11 @@ export class Player { this.game.audio.play('hurt'); this.game.addShake(6); vibrate([60, 40, 60]); - this.game.burst(this.x + this.w / 2, this.y + this.h / 2, 10, ['#ff8a5c', '#ffd257'], 'dot', 180); + if (kind === 'tide') { + this.game.burst(this.x + this.w / 2, this.y + this.h / 2, 20, ['#8fd0ff', '#fff', '#4a90d9'], 'dot', 200); + } else { + this.game.burst(this.x + this.w / 2, this.y + this.h / 2, 10, ['#ff8a5c', '#ffd257'], 'dot', 180); + } if (this.hearts <= 0) { this.die(kind); return; @@ -488,9 +498,14 @@ export class Player { // Knockback away from the source (every caller passes x/w) const cx = this.x + this.w / 2; let dir = cx < source.x + source.w / 2 ? -1 : 1; - if (kind === 'lava') dir = 0; + if (kind === 'lava' || kind === 'tide') dir = 0; this.vx = dir * CFG.player.knockX; - this.vy = kind === 'lava' ? -CFG.player.lavaBounce : -CFG.player.knockY; + this.vy = + kind === 'lava' + ? -CFG.player.lavaBounce + : kind === 'tide' + ? -CFG.player.tideBounce + : -CFG.player.knockY; const msgs: Record = { spikes: 'Ouch!', lava: 'Sizzling!', @@ -498,8 +513,10 @@ export class Player { rock: 'Bonked!', pit: 'Whoa!', spit: 'Yuck!', + tide: 'Splash!', }; - this.game.addStatus(msgs[kind], '#ff9d7a'); + const splash = kind === 'tide'; + this.game.addStatus(msgs[kind], splash ? '#8fd0ff' : '#ff9d7a'); } die(kind: DamageKind): void { diff --git a/src/weather.ts b/src/weather.ts index 85d088a..9bdc0cb 100644 --- a/src/weather.ts +++ b/src/weather.ts @@ -1,7 +1,7 @@ import type { LevelTheme } from './level-data'; import type { Player } from './player'; import { overlap } from './util'; -import { TAU } from './config'; +import { TAU, VW } from './config'; /* --- Weather tuning --- */ export const GUST_INTERVAL = 8; // seconds between frost gusts (nominal) @@ -127,6 +127,9 @@ export class Weather { if (theme === 'meadow') { for (let i = 0; i < 18; i++) this.motes.push(this.spawnMote(playerX)); } + if (theme === 'dusk') { + for (let i = 0; i < 16; i++) this.motes.push(this.spawnFirefly(playerX)); + } } /** Hitbox of a live eruption column. */ @@ -161,6 +164,18 @@ export class Weather { }; } + /** Fireflies hover low over the marsh and drift lazily. */ + private spawnFirefly(playerX: number): Mote { + return { + x: playerX - 520 + this.rng() * 1040, + y: 280 + this.rng() * 240, + vx: 4 + this.rng() * 10, + vy: 2 + this.rng() * 6, + phase: this.rng() * TAU, + size: 1.6 + this.rng() * 1.6, + }; + } + private updateFrost(dt: number, player: Player | null): void { if (this.gusting > 0) { this.gusting -= dt; @@ -248,9 +263,29 @@ export class Weather { draw(ctx: CanvasRenderingContext2D, camX: number): void { if (this.theme === 'frost') this.drawStreaks(ctx, camX); else if (this.theme === 'volcanic') this.drawVents(ctx, camX); + else if (this.theme === 'dusk') this.drawFireflies(ctx, camX); else this.drawMotes(ctx, camX); } + private drawFireflies(ctx: CanvasRenderingContext2D, camX: number): void { + for (const m of this.motes) { + const x = m.x - camX; + if (x < -20 || x > VW + 20) continue; + // Slow blink: a firefly's glow swells and fades over ~3 s. + const blink = 0.5 + 0.5 * Math.sin(this.t * 2.1 + m.phase); + const a = 0.25 + 0.6 * blink; + const glow = ctx.createRadialGradient(x, m.y, 0.5, x, m.y, 7 * m.size); + glow.addColorStop(0, `rgba(214,255,140,${(a * 0.8).toFixed(3)})`); + glow.addColorStop(1, 'rgba(214,255,140,0)'); + ctx.fillStyle = glow; + ctx.fillRect(x - 8 * m.size, m.y - 8 * m.size, 16 * m.size, 16 * m.size); + ctx.fillStyle = `rgba(232,255,190,${a.toFixed(3)})`; + ctx.beginPath(); + ctx.arc(x, m.y, m.size, 0, TAU); + ctx.fill(); + } + } + private drawStreaks(ctx: CanvasRenderingContext2D, camX: number): void { if (this.streaks.length === 0) return; ctx.save(); diff --git a/tests/dusk.test.ts b/tests/dusk.test.ts new file mode 100644 index 0000000..f0490a9 --- /dev/null +++ b/tests/dusk.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect } from 'vitest'; +import { Game } from '../src/game'; +import { LEVELS, type LevelDef } from '../src/level-data'; +import { Level } from '../src/level'; +import { AudioManager } from '../src/audio'; +import { makeCtx } from './mock-ctx'; + +function makeGame(): Game { + const canvas = document.createElement('canvas'); + document.body.appendChild(canvas); + return new Game(canvas); +} + +const dusk = LEVELS[4]; + +describe('Duskfen (level 5) registry', () => { + it('is the fifth level with the dusk theme and a rising tide', () => { + expect(LEVELS.length).toBe(5); + expect(dusk.id).toBe(4); + expect(dusk.name).toBe('Duskfen'); + expect(dusk.theme).toBe('dusk'); + const t = dusk.def.tide!; + expect(t.fromY).toBeGreaterThan(t.toY); + expect(t.toY).toBeGreaterThan(0); + expect(t.rate).toBeGreaterThan(0); + // the tide must be able to submerge the low ground (ground top = 460) + expect(t.toY).toBeLessThan(460); + }); + + it('has a valid level structure', () => { + const def: LevelDef = dusk.def; + expect(def.width).toBeGreaterThan(5000); + expect(def.goal.x).toBeGreaterThan(0); + expect(def.goal.x).toBeLessThan(def.width); + expect(def.goal.y).toBeLessThanOrEqual(460); + for (const c of def.crystals) { + expect(c.x).toBeGreaterThan(0); + expect(c.x).toBeLessThan(def.width); + expect(c.y).toBeGreaterThan(0); + } + expect(def.springs?.length ?? 0).toBeGreaterThanOrEqual(2); + expect(def.tide).toBeDefined(); + }); +}); + +describe('rising tide mechanic', () => { + it('climbs while the run is live and stops at the final level', () => { + const game = makeGame(); + game.levelIdx = 4; + game.handleKey('primary'); + const lvl = game.level!; + const t = dusk.def.tide!; + expect(lvl.waterY).toBe(t.fromY); + game.update(1); + expect(lvl.waterY).toBeLessThan(t.fromY); + expect(lvl.waterY).toBeCloseTo(t.fromY - t.rate, 5); + game.update(1000); + expect(lvl.waterY).toBe(t.toY); + }); + + it('does not advance once the run is over', () => { + const game = makeGame(); + game.levelIdx = 4; + game.handleKey('primary'); + const lvl = game.level!; + game.update(1); + const w = lvl.waterY; + expect(w).toBeLessThan(dusk.def.tide!.fromY); + game.state = 'victory'; + game.update(1); + expect(lvl.waterY).toBe(w); + }); + + it('standing in the water costs a heart, bounces Rex up, and pings once', () => { + const game = makeGame(); + game.levelIdx = 4; + game.handleKey('primary'); + const lvl = game.level!; + const p = game.player!; + const feet = p.rect.y + p.rect.h; + expect(feet).toBeGreaterThan(0); + lvl.waterY = feet - 10; // water just above Rex's feet + const hearts = p.hearts; + game.update(0.016); + expect(p.hearts).toBe(hearts - 1); + expect(p.invulnT).toBeGreaterThan(0); + expect(p.vy).toBeLessThan(0); // bounced out of the water + expect(lvl.tideWarned).toBe(true); + // still submerged, but invulnerable: no second hit + game.update(0.016); + expect(p.hearts).toBe(hearts - 1); + }); + + it('reset restores the waterline', () => { + const game = makeGame(); + game.levelIdx = 4; + game.handleKey('primary'); + const lvl = game.level!; + game.update(1); + expect(lvl.waterY).toBeLessThan(dusk.def.tide!.fromY); + lvl.reset(); + expect(lvl.waterY).toBe(dusk.def.tide!.fromY); + expect(lvl.tideWarned).toBe(false); + }); +}); + +describe('dusk theme plumbing', () => { + it('dusk is a valid LevelTheme and the Level builds with tide state', () => { + const level = new Level(dusk.def, makeCtx(), 1, 2); + expect(level.tide).not.toBeNull(); + expect(level.waterY).toBe(dusk.def.tide!.fromY); + expect(level.tideWarned).toBe(false); + }); + + it('dusk music and tide SFX boot without throwing', () => { + const audio = new AudioManager(); + expect(() => { + audio.startMusic('dusk'); + audio.play('tide'); + }).not.toThrow(); + }); +}); diff --git a/tests/game.test.ts b/tests/game.test.ts index 40f1edd..8bb02b3 100644 --- a/tests/game.test.ts +++ b/tests/game.test.ts @@ -330,7 +330,7 @@ describe('Fossil discoveries', () => { expect(game.totalFossils()).toBe( LEVELS.reduce((n, l) => n + (l.def.fossils?.length ?? 0), 0), ); - expect(game.totalFossils()).toBe(12); + expect(game.totalFossils()).toBe(15); }); it('awards score on pickup and persists the first discovery', () => { diff --git a/tests/level-data.test.ts b/tests/level-data.test.ts index 64ddcdf..d57f511 100644 --- a/tests/level-data.test.ts +++ b/tests/level-data.test.ts @@ -67,8 +67,8 @@ describe('LEVEL_DATA — Crystal Valley integrity', () => { }); describe('LEVELS registry', () => { - it('exposes all four levels with distinct themes', () => { - expect(LEVELS).toHaveLength(4); + it('exposes all five levels with distinct themes', () => { + expect(LEVELS).toHaveLength(5); expect(LEVELS[0].id).toBe(0); expect(LEVELS[0].theme).toBe('meadow'); expect(LEVELS[1].id).toBe(1); @@ -78,6 +78,9 @@ describe('LEVELS registry', () => { expect(LEVELS[3].id).toBe(3); expect(LEVELS[3].name).toBe('Molten Nest'); expect(LEVELS[3].theme).toBe('volcanic'); + expect(LEVELS[4].id).toBe(4); + expect(LEVELS[4].name).toBe('Duskfen'); + expect(LEVELS[4].theme).toBe('dusk'); expect(LEVELS[0].def).toBe(LEVEL_DATA); }); }); @@ -381,11 +384,11 @@ describe('LEVEL_4 — Molten Nest integrity', () => { }); describe('Hidden fossils (all hand-built levels)', () => { - it('places exactly three fossils per level (twelve total)', () => { + it('places exactly three fossils per level (fifteen 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(12); + expect(LEVELS.reduce((n, l) => n + (l.def.fossils?.length ?? 0), 0)).toBe(15); }); it('keeps every fossil inside the level bounds, above the ground line', () => { diff --git a/tests/lore.test.ts b/tests/lore.test.ts index 676a825..0f7ef73 100644 --- a/tests/lore.test.ts +++ b/tests/lore.test.ts @@ -42,10 +42,10 @@ describe('FieldNote entity', () => { }); describe('Field note content', () => { - it('has exactly 3 notes per hand-built level (12 total)', () => { + it('has exactly 3 notes per hand-built level (15 total)', () => { expect(NOTES.length).toBe(LEVELS.length); for (const levelNotes of NOTES) expect(levelNotes).toHaveLength(3); - expect(totalNotes()).toBe(12); + expect(totalNotes()).toBe(15); }); it('every note has a title, a paragraph of text and a hint', () => {