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); + }); +});