From 9be93c6932137ba7a45bc1b0a362a760913dfa0c Mon Sep 17 00:00:00 2001 From: Chris Malpass Date: Wed, 26 Aug 2026 21:38:04 -0400 Subject: [PATCH] Dynamic weather per theme: frost gusts, lava geysers, pollen drift Each theme now has gameplay-tied environmental dynamics (new src/weather.ts): - Frostpeak: timed wind gusts (every ~8 s) streak across the screen, whoosh SFX, and shove Rex sideways (~55 px/s drift) for a couple of seconds. - Volcanic: big lava pools grow geyser vents that bubble to telegraph, then erupt a 1.4 s hot column that damages Rex and kicks him out. Vents derive from level geometry (pools >= 180 px wide), capped at 5. - Crystal Valley: slow pollen motes drift around Rex with a subtle sway. Forces use velocity blending so they survive the player's air drag. Reduced-motion mode suppresses the decorative particles but keeps the gust force and geyser damage. Weather updates during play and dying, draws in the foreground, and resets per level via buildLevel(). Validated: typecheck, 225/225 tests (+16 new: pure ventState/gusts/geysers/ drift + game integration), build (135.93 kB / 41.81 kB gzip), lint, and headless E2E (gust shove -50px, geyser eruption burns Rex 3->2 hearts, pollen present, zero page errors). --- src/audio.ts | 4 + src/game.ts | 8 ++ src/weather.ts | 317 ++++++++++++++++++++++++++++++++++++++++++ tests/weather.test.ts | 234 +++++++++++++++++++++++++++++++ 4 files changed, 563 insertions(+) create mode 100644 src/weather.ts create mode 100644 tests/weather.test.ts diff --git a/src/audio.ts b/src/audio.ts index 292d77b..6c584d6 100644 --- a/src/audio.ts +++ b/src/audio.ts @@ -433,6 +433,10 @@ export class AudioManager { this.noiseBurst({ dur: 0.08, vol: 0.16, freq: 1600 }); this.tone({ freq: 900, to: 300, dur: 0.16, type: 'sine', vol: 0.2 }); break; + case 'wind': + // Low whoosh when a frostpeak gust kicks in. + this.noiseBurst({ dur: 0.9, vol: 0.15, freq: 260 }); + break; } } diff --git a/src/game.ts b/src/game.ts index 925d5a0..4efa71a 100644 --- a/src/game.ts +++ b/src/game.ts @@ -19,6 +19,7 @@ import { GhostRecorder, GhostPlayer } from './ghost'; import { drawDecor } from './decor'; import { Sprite, SKINS, skinUnlocked } from './sprite'; import { adaptiveFlags } from './adaptive'; +import { Weather } from './weather'; import { drawPowerUpIcon, POWERUP_COLORS } from './powerup'; import type { PowerUpType } from './powerup'; import type { GameCtx } from './ctx'; @@ -90,6 +91,7 @@ export class Game implements GameCtx { readonly input = new Input(); readonly bg = new Background(); readonly camera = new Camera(); + readonly weather = new Weather(); level: Level | null = null; player: Player | null = null; state: GameState = 'menu'; // menu | playing | paused | dying | gameover | victory @@ -170,6 +172,7 @@ export class Game implements GameCtx { // Stamp the jump buffer with the unpaused game clock so buffering // works across pause boundaries. this.input.now = () => this.time; + this.weather.onGust = () => this.audio.play('wind'); this.bindPointer(); this.resize(); this.loadRecords(); @@ -305,6 +308,8 @@ export class Game implements GameCtx { // 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; + this.weather.reducedMotion = this.reducedMotion; + this.weather.apply(info.theme, this.level!.hazards, this.level!.start.x); } /** Total hidden fossils across the hand-built levels. */ @@ -786,6 +791,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.weather.update(dt, this.player!); this.camera.update(dt, this.player!, this.level!.width, this); this.updateAdaptive(); // track crystal count @@ -810,6 +816,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.weather.update(dt, this.player!); this.camera.update(dt, this.player!, this.level!.width, this); if (this.dyingT > 1.15) { this.state = 'gameover'; @@ -943,6 +950,7 @@ export class Game implements GameCtx { // Particles & floating text for (const p of this.particles) p.draw(ctx); for (const t of this.texts) t.draw(ctx); + this.weather.draw(ctx, camX); ctx.restore(); diff --git a/src/weather.ts b/src/weather.ts new file mode 100644 index 0000000..85d088a --- /dev/null +++ b/src/weather.ts @@ -0,0 +1,317 @@ +import type { LevelTheme } from './level-data'; +import type { Player } from './player'; +import { overlap } from './util'; +import { TAU } from './config'; + +/* --- Weather tuning --- */ +export const GUST_INTERVAL = 8; // seconds between frost gusts (nominal) +export const GUST_DUR = 2.4; // seconds an active gust lasts +export const GUST_PUSH = 150; // target drift speed while gusting (px/s) +export const MEADOW_DRIFT = 14; // gentle pollen sway amplitude (px/s) +export const GEYSER_PERIOD = 6; // full volcanic vent cycle (s) +export const GEYSER_ERUPT = 1.4; // seconds the eruption column is live +export const GEYSER_BUBBLE = 1.3; // telegraph bubbles before each eruption +export const GEYSER_W = 46; // eruption column width +export const GEYSER_H = 170; // eruption column height + +export type VentState = 'idle' | 'bubbling' | 'erupting'; + +export interface GeyserVent { + x: number; + surfaceY: number; + phase: number; + state: VentState; +} + +export interface Streak { + x: number; + y: number; + len: number; + spd: number; + life: number; + maxLife: number; +} + +export interface Mote { + x: number; + y: number; + vx: number; + vy: number; + phase: number; + size: number; +} + +export interface EruptPart { + x: number; + y: number; + vx: number; + vy: number; + life: number; + maxLife: number; + size: number; +} + +interface HazardLike { + type: string; + x: number; + y: number; + w: number; +} + +/** Pure vent cycle: which phase of the eruption cycle a vent is in. */ +export function ventState(t: number, phase: number): VentState { + const cycle = (t + phase) % GEYSER_PERIOD; + if (cycle < GEYSER_ERUPT) return 'erupting'; + if (cycle > GEYSER_PERIOD - GEYSER_BUBBLE) return 'bubbling'; + return 'idle'; +} + +/** + * Per-theme environmental dynamics: + * - frost: timed wind gusts that streak across the screen and shove Rex + * - volcanic: lava geysers above the big pools (bubbling telegraph, hot column) + * - meadow: slow pollen drift with a barely-there sway + * + * The class keeps its own lightweight particle arrays and is drawn in the + * foreground. Forces are applied to the player each frame; geyser columns + * deal damage through the normal player.damage path. + */ +export class Weather { + theme: LevelTheme = 'meadow'; + t = 0; + reducedMotion = false; + /** Called once when a frost gust kicks in (the game plays a whoosh). */ + onGust: (() => void) | null = null; + /** Test/determinism hook; defaults to Math.random. */ + rng: () => number = Math.random; + + /* frost */ + gusts = 0; // gusts started since apply() — handy for tests and the HUD + gusting = 0; // seconds of gust remaining + gustT = 0; // countdown to the next gust + gustDir: 1 | -1 = 1; + streaks: Streak[] = []; + + /* volcanic */ + vents: GeyserVent[] = []; + eruptParts: EruptPart[] = []; + + /* meadow */ + motes: Mote[] = []; + + /** (Re)configure weather for a level. Resets all state. */ + apply(theme: LevelTheme, hazards: HazardLike[], playerX: number): void { + this.theme = theme; + this.t = 0; + this.gusts = 0; + this.gusting = 0; + this.gustT = 3 + this.rng() * 5; + this.gustDir = 1; + this.streaks = []; + this.eruptParts = []; + this.motes = []; + this.vents = []; + if (theme === 'volcanic') { + let n = 0; + for (const hz of hazards) { + if (hz.type !== 'lava' || hz.w < 180) continue; + this.vents.push({ + x: hz.x + hz.w / 2, + surfaceY: hz.y, + phase: this.rng() * GEYSER_PERIOD, + state: 'idle', + }); + if (++n >= 5) break; // keep it readable + } + } + if (theme === 'meadow') { + for (let i = 0; i < 18; i++) this.motes.push(this.spawnMote(playerX)); + } + } + + /** Hitbox of a live eruption column. */ + columnRect(v: GeyserVent): { x: number; y: number; w: number; h: number } { + return { x: v.x - GEYSER_W / 2, y: v.surfaceY - GEYSER_H, w: GEYSER_W, h: GEYSER_H }; + } + + update(dt: number, player: Player | null): void { + this.t += dt; + if (this.theme === 'frost') this.updateFrost(dt, player); + else if (this.theme === 'volcanic') this.updateVolcanic(player); + else this.updateMeadow(dt, player); + + for (const p of this.eruptParts) { + p.x += p.vx * dt; + p.y += p.vy * dt; + p.vy += 620 * dt; + p.life -= dt; + } + this.eruptParts = this.eruptParts.filter((p) => p.life > 0); + if (this.eruptParts.length > 120) this.eruptParts.splice(0, this.eruptParts.length - 120); + } + + private spawnMote(playerX: number): Mote { + return { + x: playerX - 520 + this.rng() * 1040, + y: this.rng() * 540, + vx: 10 + this.rng() * 22, + vy: 4 + this.rng() * 10, + phase: this.rng() * TAU, + size: 1.5 + this.rng() * 2, + }; + } + + private updateFrost(dt: number, player: Player | null): void { + if (this.gusting > 0) { + this.gusting -= dt; + // Blend Rex's velocity toward the gust: the drag model in player.ts + // would otherwise eat a plain force almost instantly. + if (player && !player.dead) { + // Strong blend so the drift survives the player's air drag: + // steady-state drift ≈ GUST_PUSH * 40 / airDrag (~55 px/s). + const target = this.gustDir * GUST_PUSH; + player.vx += (target - player.vx) * Math.min(1, dt * 40); + } + if (!this.reducedMotion && player) { + for (let i = 0; i < 2; i++) { + this.streaks.push({ + x: player.x - 480 + this.rng() * 960, + y: 50 + this.rng() * 430, + len: 40 + this.rng() * 70, + spd: 500 + this.rng() * 300, + life: 0.9, + maxLife: 0.9, + }); + } + } + } else { + this.gustT -= dt; + if (this.gustT <= 0) { + this.gusts += 1; + this.gusting = GUST_DUR; + this.gustDir = this.rng() < 0.5 ? -1 : 1; + this.gustT = GUST_INTERVAL * (0.75 + this.rng() * 0.5); + if (this.onGust) this.onGust(); + } + } + for (const s of this.streaks) { + s.x += s.spd * this.gustDir * dt; + s.life -= dt; + } + this.streaks = this.streaks.filter((s) => s.life > 0); + } + + private updateVolcanic(player: Player | null): void { + for (const v of this.vents) { + v.state = ventState(this.t, v.phase); + if (v.state === 'erupting') { + if (!this.reducedMotion) { + this.eruptParts.push({ + x: v.x + (this.rng() - 0.5) * GEYSER_W * 0.7, + y: v.surfaceY - 4, + vx: (this.rng() - 0.5) * 70, + vy: -(320 + this.rng() * 220), + life: 0.8 + this.rng() * 0.4, + maxLife: 1.2, + size: 2 + this.rng() * 3, + }); + } + if (player && !player.dead && player.invulnT <= 0 && overlap(player.rect, this.columnRect(v))) { + player.damage({ x: v.x - GEYSER_W / 2, w: GEYSER_W }, 'lava'); + if (!player.dead) player.vy = -300; // hot air kicks Rex out + } + } + } + } + + private updateMeadow(dt: number, player: Player | null): void { + if (player && !player.dead) { + const sway = Math.sin(this.t * 0.6) * MEADOW_DRIFT; + player.vx += (sway - player.vx) * Math.min(1, dt * 20); + } + for (const m of this.motes) { + m.phase += dt; + m.x += m.vx * dt; + m.y += (m.vy + Math.sin(m.phase * 1.7) * 6) * dt; + if (player) { + const px = player.x; + if (m.x < px - 520) m.x += 1040; + else if (m.x > px + 520) m.x -= 1040; + if (m.y < -20) m.y += 560; + else if (m.y > 580) m.y -= 560; + } + } + } + + /* ---------- rendering (world space, caller passes camera x) ---------- */ + + draw(ctx: CanvasRenderingContext2D, camX: number): void { + if (this.theme === 'frost') this.drawStreaks(ctx, camX); + else if (this.theme === 'volcanic') this.drawVents(ctx, camX); + else this.drawMotes(ctx, camX); + } + + private drawStreaks(ctx: CanvasRenderingContext2D, camX: number): void { + if (this.streaks.length === 0) return; + ctx.save(); + ctx.lineCap = 'round'; + for (const s of this.streaks) { + const a = (s.life / s.maxLife) * 0.5; + ctx.strokeStyle = `rgba(235,245,255,${a.toFixed(3)})`; + ctx.lineWidth = 2; + ctx.beginPath(); + const x0 = s.x - camX; + ctx.moveTo(x0, s.y); + ctx.lineTo(x0 + s.len * this.gustDir, s.y - s.len * 0.08); + ctx.stroke(); + } + ctx.restore(); + } + + private drawVents(ctx: CanvasRenderingContext2D, camX: number): void { + for (const v of this.vents) { + if (v.x + GEYSER_W < camX - 60 || v.x - GEYSER_W > camX + 1020) continue; + const x = v.x - camX; + if (v.state === 'bubbling') { + ctx.fillStyle = 'rgba(255,150,60,0.55)'; + for (let i = 0; i < 4; i++) { + const by = v.surfaceY - 5 - ((this.t * 34 + i * 11) % 26); + ctx.beginPath(); + ctx.arc(x + (i - 1.5) * 9, by, 1.6 + (i % 2), 0, TAU); + ctx.fill(); + } + } else if (v.state === 'erupting') { + const grad = ctx.createLinearGradient(0, v.surfaceY - GEYSER_H, 0, v.surfaceY); + grad.addColorStop(0, 'rgba(255,80,20,0)'); + grad.addColorStop(0.45, 'rgba(255,110,35,0.65)'); + grad.addColorStop(1, 'rgba(255,150,50,0.95)'); + ctx.fillStyle = grad; + ctx.fillRect(x - GEYSER_W / 2, v.surfaceY - GEYSER_H, GEYSER_W, GEYSER_H); + const core = ctx.createLinearGradient(0, v.surfaceY - GEYSER_H, 0, v.surfaceY); + core.addColorStop(0, 'rgba(255,220,140,0)'); + core.addColorStop(1, 'rgba(255,235,170,0.9)'); + ctx.fillStyle = core; + ctx.fillRect(x - GEYSER_W * 0.22, v.surfaceY - GEYSER_H, GEYSER_W * 0.44, GEYSER_H); + } + } + for (const p of this.eruptParts) { + const a = Math.max(0, p.life / p.maxLife); + ctx.fillStyle = `rgba(255,${120 + Math.floor(90 * a)},40,${(a * 0.9).toFixed(3)})`; + ctx.beginPath(); + ctx.arc(p.x - camX, p.y, p.size, 0, TAU); + ctx.fill(); + } + } + + private drawMotes(ctx: CanvasRenderingContext2D, camX: number): void { + ctx.save(); + for (const m of this.motes) { + ctx.globalAlpha = 0.35 + 0.25 * Math.sin(m.phase * 1.7); + ctx.fillStyle = '#ffe9a8'; + ctx.beginPath(); + ctx.arc(m.x - camX, m.y, m.size, 0, TAU); + ctx.fill(); + } + ctx.restore(); + } +} diff --git a/tests/weather.test.ts b/tests/weather.test.ts new file mode 100644 index 0000000..94ad1f7 --- /dev/null +++ b/tests/weather.test.ts @@ -0,0 +1,234 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Game } from '../src/game'; +import { + Weather, + ventState, + GUST_DUR, + GEYSER_PERIOD, + GEYSER_ERUPT, + GEYSER_BUBBLE, + GEYSER_W, + GEYSER_H, + type GeyserVent, +} from '../src/weather'; +import type { Player } from '../src/player'; + +const HAZARDS = [ + { type: 'lava', x: 2420, y: 520, w: 560 }, // big pool -> vent + { type: 'lava', x: 5430, y: 520, w: 130 }, // too small -> no vent + { type: 'spikes', x: 4330, y: 460, w: 70 }, // not lava -> no vent +]; + +function fakePlayer(x = 0, y = 400): { p: Player; damage: ReturnType } { + const damage = vi.fn(); + const p = { + dead: false, + invulnT: 0, + vx: 0, + vy: 0, + x, + y, + w: 24, + h: 46, + rect: { x, y, w: 24, h: 46 }, + damage, + } as unknown as Player; + return { p, damage }; +} + +describe('ventState', () => { + it('erupts at the start of each cycle', () => { + expect(ventState(0, 0)).toBe('erupting'); + expect(ventState(GEYSER_ERUPT - 0.01, 0)).toBe('erupting'); + expect(ventState(GEYSER_ERUPT + 0.01, 0)).not.toBe('erupting'); + }); + + it('bubbles just before the next eruption', () => { + expect(ventState(GEYSER_PERIOD - GEYSER_BUBBLE - 0.01, 0)).toBe('idle'); + expect(ventState(GEYSER_PERIOD - GEYSER_BUBBLE + 0.01, 0)).toBe('bubbling'); + }); + + it('is idle in the middle of the cycle', () => { + expect(ventState(GEYSER_PERIOD / 2, 0)).toBe('idle'); + }); + + it('offsets by the vent phase', () => { + // same cycle point, shifted by phase + expect(ventState(2, 3)).toBe(ventState(5, 0)); + }); +}); + +describe('Weather.apply', () => { + it('creates a vent per large lava pool, centered on it', () => { + const w = new Weather(); + w.rng = () => 0.5; + w.apply('volcanic', HAZARDS, 100); + expect(w.vents).toHaveLength(1); + expect(w.vents[0].x).toBe(2420 + 560 / 2); + expect(w.vents[0].surfaceY).toBe(520); + expect(w.vents[0].phase).toBeCloseTo(0.5 * GEYSER_PERIOD); + }); + + it('spawns pollen motes for the meadow and nothing else', () => { + const w = new Weather(); + w.rng = () => 0.5; + w.apply('meadow', [], 100); + expect(w.motes).toHaveLength(18); + expect(w.vents).toHaveLength(0); + }); + + it('has no vents or motes in the frost theme', () => { + const w = new Weather(); + w.apply('frost', HAZARDS, 100); + expect(w.vents).toHaveLength(0); + expect(w.motes).toHaveLength(0); + }); +}); + +describe('frost gusts', () => { + it('starts a gust when the countdown elapses and fires onGust', () => { + const w = new Weather(); + w.rng = () => 0.5; // dir: 0.5 is not < 0.5 -> +1 + const onGust = vi.fn(); + w.onGust = onGust; + w.apply('frost', [], 100); + w.gustT = 0.5; + w.update(0.6, null); + expect(w.gusts).toBe(1); + expect(w.gusting).toBeCloseTo(GUST_DUR); + expect(w.gustDir).toBe(1); + expect(onGust).toHaveBeenCalledTimes(1); + }); + + it('pushes the player sideways while gusting', () => { + const w = new Weather(); + w.rng = () => 0.5; + w.apply('frost', [], 100); + w.gustT = 0.1; + const { p } = fakePlayer(100); + w.update(0.2, p); // gust starts this frame + w.update(0.5, p); + expect(p.vx).toBeGreaterThan(10); + }); + + it('spawns streaks while gusting, unless reduced motion', () => { + const w = new Weather(); + w.rng = () => 0.5; + w.apply('frost', [], 100); + w.gustT = 0.1; + const { p } = fakePlayer(100); + w.update(0.2, p); + w.update(0.5, p); + expect(w.streaks.length).toBeGreaterThan(0); + + const calm = new Weather(); + calm.rng = () => 0.5; + calm.reducedMotion = true; + calm.apply('frost', [], 100); + calm.gustT = 0.1; + calm.update(0.2, null); + calm.update(0.5, null); + expect(calm.streaks.length).toBe(0); + }); +}); + +describe('volcanic geysers', () => { + it('erupts on schedule and damages a player standing in the column', () => { + const w = new Weather(); + w.rng = () => 0.5; + w.apply('volcanic', HAZARDS, 100); + const v = w.vents[0]; + // choose t so the cycle is just inside the eruption window + const t = ((GEYSER_PERIOD - v.phase) % GEYSER_PERIOD) + 0.1; + w.t = t; + const col = w.columnRect(v); + const { p, damage } = fakePlayer(col.x + col.w / 2 - 12, col.y + col.h - 50); + w.update(0.016, p); + expect(v.state).toBe('erupting'); + expect(damage).toHaveBeenCalledTimes(1); + expect(damage.mock.calls[0][1]).toBe('lava'); + expect(p.vy).toBe(-300); // kicked out of the column + }); + + it('does not damage players outside the column or while invulnerable', () => { + const w = new Weather(); + w.rng = () => 0.5; + w.apply('volcanic', HAZARDS, 100); + const v = w.vents[0]; + const t = ((GEYSER_PERIOD - v.phase) % GEYSER_PERIOD) + 0.1; + w.t = t; + const col = w.columnRect(v); + const far = fakePlayer(col.x + 400); + w.update(0.016, far.p); + expect(far.damage).not.toHaveBeenCalled(); + + const inv = fakePlayer(col.x + col.w / 2 - 12, col.y + 10); + inv.p.invulnT = 0.5; + w.update(0.016, inv.p); + expect(inv.damage).not.toHaveBeenCalled(); + }); + + it('column geometry matches the tuning constants', () => { + const w = new Weather(); + const v: GeyserVent = { x: 100, surfaceY: 500, phase: 0, state: 'idle' }; + const r = w.columnRect(v); + expect(r).toEqual({ x: 100 - GEYSER_W / 2, y: 500 - GEYSER_H, w: GEYSER_W, h: GEYSER_H }); + }); +}); + +describe('meadow drift', () => { + it('sways the player and keeps motes near them', () => { + const w = new Weather(); + w.rng = () => 0.5; + w.apply('meadow', [], 100); + const { p } = fakePlayer(100); + const x0 = p.x; + for (let i = 0; i < 120; i++) w.update(1 / 60, p); // 2s + expect(Math.abs(p.vx)).toBeGreaterThan(0); + for (const m of w.motes) { + expect(Math.abs(m.x - p.x)).toBeLessThanOrEqual(530); + } + expect(p.x).toBe(x0); // drift is a force, not a teleport + }); +}); + +describe('Game integration', () => { + let game: Game; + + function makeGame(): Game { + const canvas = document.createElement('canvas'); + document.body.appendChild(canvas); + return new Game(canvas); + } + + beforeEach(() => { + localStorage.clear(); + game = makeGame(); + }); + + it('configures weather for the level being started', () => { + game.handleKey('primary'); // Crystal Valley (meadow) + expect(game.weather.theme).toBe('meadow'); + expect(game.weather.motes.length).toBe(18); + + game.levelIdx = 1; + game.handleKey('restart'); // Volcanic Depths + expect(game.weather.theme).toBe('volcanic'); + expect(game.weather.vents.length).toBeGreaterThanOrEqual(1); + + game.levelIdx = 2; + game.handleKey('restart'); // Frostpeak Pass + expect(game.weather.theme).toBe('frost'); + expect(game.weather.gustT).toBeGreaterThan(0); + }); + + it('pushes the player on a gust while playing', () => { + game.levelIdx = 2; + game.handleKey('primary'); + game.weather.gustT = 0; + game.update(0.016); // gust kicks in + game.update(0.2); // force applied on the following frame + expect(Math.abs(game.player!.vx)).toBeGreaterThan(0); + expect(game.weather.gusts).toBe(1); + }); +});