From d34c763047d911d60f3d52603ec76efa8a86a443 Mon Sep 17 00:00:00 2001 From: "precisit-brain-builder[bot]" Date: Sun, 23 Aug 2026 01:02:29 +0000 Subject: [PATCH] feat(juice): particle system, screen shake, hit flash, squash & stretch, bloom hooks --- src/effects/BloomPulse.ts | 122 +++++++++ src/effects/HitFlash.ts | 144 ++++++++++ src/effects/JuiceSystem.ts | 151 +++++++++++ src/effects/Particles.ts | 507 +++++++++++++++++++++++++++++++++++ src/effects/ScreenShake.ts | 174 ++++++++++++ src/effects/SquashStretch.ts | 170 ++++++++++++ src/main.ts | 146 +++++++++- src/style.css | 8 + src/ui/Hud.ts | 3 + tests/juice.test.ts | 221 +++++++++++++++ tests/particles.test.ts | 129 +++++++++ tests/screenShake.test.ts | 122 +++++++++ tests/squashStretch.test.ts | 103 +++++++ 13 files changed, 1988 insertions(+), 12 deletions(-) create mode 100644 src/effects/BloomPulse.ts create mode 100644 src/effects/HitFlash.ts create mode 100644 src/effects/JuiceSystem.ts create mode 100644 src/effects/Particles.ts create mode 100644 src/effects/ScreenShake.ts create mode 100644 src/effects/SquashStretch.ts create mode 100644 tests/juice.test.ts create mode 100644 tests/particles.test.ts create mode 100644 tests/screenShake.test.ts create mode 100644 tests/squashStretch.test.ts diff --git a/src/effects/BloomPulse.ts b/src/effects/BloomPulse.ts new file mode 100644 index 0000000..ccf1a62 --- /dev/null +++ b/src/effects/BloomPulse.ts @@ -0,0 +1,122 @@ +import type { BloomParams } from '../renderer/BloomPass'; + +/** + * BloomPulse (B1: "pump bloom intensity on big events"). + * + * Owns a decaying energy envelope and translates it into bloom `intensity` + * patches through a callback — in the demo that callback is + * `renderer.setBloomOptions(patch)`; tests use a spy. Baseline intensity is + * restored exactly once when the envelope empties, so the post-pass never + * stays "hot" after an explosion. + * + * Deliberately renderer-free so it runs headless in unit tests. + */ + +export interface BloomPulseOptions { + /** Resting composite intensity (matches DEFAULT_BLOOM_PARAMS ≈ 0.95). */ + baseIntensity?: number; + /** Intensity at full energy. Default 2.6. */ + peakIntensity?: number; + /** Seconds for full energy to drain to zero. Default 0.45. */ + decaySeconds?: number; +} + +export const DEFAULT_BLOOM_PULSE_OPTIONS: Readonly> = { + baseIntensity: 0.95, + peakIntensity: 2.6, + decaySeconds: 0.45, +}; + +/** Signature compatible with WebGPURenderer.setBloomOptions(). */ +export type BloomApplyFn = (patch: Partial) => void; + +function clamp01(value: number): number { + return Number.isFinite(value) ? (value < 0 ? 0 : value > 1 ? 1 : value) : 0; +} + +function pickPositive(v: number | undefined, fallback: number): number { + return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : fallback; +} + +export class BloomPulse { + private readonly apply: BloomApplyFn | null; + private readonly baseIntensity: number; + private readonly peakIntensity: number; + private readonly decaySeconds: number; + private energy = 0; + + public constructor( + apply: BloomApplyFn | null = null, + opts: BloomPulseOptions = {}, + ) { + this.apply = apply; + this.baseIntensity = opts.baseIntensity ?? DEFAULT_BLOOM_PULSE_OPTIONS.baseIntensity; + this.peakIntensity = Math.max( + this.baseIntensity, + opts.peakIntensity ?? DEFAULT_BLOOM_PULSE_OPTIONS.peakIntensity, + ); + this.decaySeconds = pickPositive(opts.decaySeconds, DEFAULT_BLOOM_PULSE_OPTIONS.decaySeconds); + } + + /** Current envelope energy [0, 1]. */ + public get energyLevel(): number { + return this.energy; + } + + public get isActive(): boolean { + return this.energy > 0; + } + + /** + * Intensity the last update emitted, or the baseline while idle. + * Useful for HUD readouts and tests. + */ + public get currentIntensity(): number { + return ( + this.baseIntensity + + (this.peakIntensity - this.baseIntensity) * (this.energy * (2 - this.energy)) + ); + } + + /** + * Pump the envelope toward full. `strength` ∈ [0, 1] scales how much of the + * gap to peak is used (small pickups blip, boss warnings slam); repeated + * calls stack up to 1. + */ + public pulse(strength = 1): void { + const s = clamp01(strength); + // Additive stacking with diminishing returns near full. + this.energy = Math.min(1, this.energy + s * Math.max(0.25, 1 - this.energy)); + } + + /** + * Drain the envelope and emit intensity patches through the callback. + * Emits at most one patch per call; emits the baseline once on settle. + */ + public update(dtSeconds: number): void { + const dt = Number.isFinite(dtSeconds) ? Math.max(0, dtSeconds) : 0; + if (!this.isActive) return; + + if (dt >= this.energy * this.decaySeconds) { + this.settle(); + return; + } + this.energy -= dt / this.decaySeconds; + if (this.energy <= 1e-4) { + this.settle(); + return; + } + this.apply?.({ intensity: this.currentIntensity }); + } + + /** Immediately drop to baseline (level transitions). */ + public reset(): void { + if (this.isActive) this.settle(); + else this.energy = 0; + } + + private settle(): void { + this.energy = 0; + this.apply?.({ intensity: this.baseIntensity }); + } +} diff --git a/src/effects/HitFlash.ts b/src/effects/HitFlash.ts new file mode 100644 index 0000000..f6f4f0c --- /dev/null +++ b/src/effects/HitFlash.ts @@ -0,0 +1,144 @@ +import type { Rgba } from '../renderer/types'; + +/** + * Hit flash helpers (PLAN.md §4: "hit-flash"). + * + * Two complementary effects: + * - {@link HitFlash} — **sprite-level** white flash on damage. The damaged + * entity mixes its tint toward white by `amount` for a few frames: + * `sprite.tint = hitFlash.tint(baseColor)` — no shader changes needed. + * - {@link ScreenFlash} — brief fullscreen tint driven by one additive quad + * covering the view (explosions, boss warnings). The game layer draws it; + * this class only owns the envelope + color. + * + * Both are pure CPU envelopes; rendering stays the renderer's job. + */ + +/** Clamp helper shared below. */ +function clamp01(value: number): number { + return Number.isFinite(value) ? (value < 0 ? 0 : value > 1 ? 1 : value) : 0; +} + +/** + * Mix `base` toward white by `amount` ∈ [0, 1]. Writes into `out` when given + * (allocation-free path for per-frame use); returns the mixed color. + */ +export function mixToWhite(base: Rgba, amount: number, out?: [number, number, number, number]): Rgba { + const t = clamp01(amount); + const target: [number, number, number, number] = out ?? [0, 0, 0, 0]; + target[0] = base[0] + (1 - base[0]) * t; + target[1] = base[1] + (1 - base[1]) * t; + target[2] = base[2] + (1 - base[2]) * t; + target[3] = base[3]; + return target; +} + +/** + * Sprite-level white flash envelope. `flash()` jumps `amount` to its peak + * (constructor strength, optionally overridden per call), then it decays + * linearly to 0 over `durationSeconds`. + */ +export class HitFlash { + private readonly durationSeconds: number; + private readonly defaultStrength: number; + private peak: number; + private remaining = 0; + + public constructor(durationSeconds = 0.09, strength = 1) { + this.durationSeconds = Math.max(0.001, durationSeconds); + this.defaultStrength = clamp01(strength); + this.peak = this.defaultStrength; + } + + /** Current flash amount in [0, 1]; 0 = no flash. */ + public get amount(): number { + if (this.remaining <= 0) return 0; + return this.peak * (this.remaining / this.durationSeconds); + } + + public get isActive(): boolean { + return this.remaining > 0; + } + + /** Trigger the flash; re-flashing while active restarts the envelope. */ + public flash(strengthOverride?: number): void { + this.remaining = this.durationSeconds; + this.peak = strengthOverride === undefined ? this.defaultStrength : clamp01(strengthOverride); + } + + /** Advance the envelope. Safe with dt ≤ 0 (no-op). */ + public update(dtSeconds: number): void { + if (!Number.isFinite(dtSeconds) || dtSeconds <= 0) return; + if (this.remaining > 0) this.remaining = Math.max(0, this.remaining - dtSeconds); + } + + /** + * Convenience for render code: mix `base` by the current amount into `out` + * (or a fresh tuple). Pass a pooled tuple per sprite to avoid allocation. + */ + public tint(base: Rgba, out?: [number, number, number, number]): Rgba { + return mixToWhite(base, this.amount, out); + } +} + +/** + * Fullscreen flash envelope: color + fading alpha, drawn as one additive + * quad over the frame (see demo wiring in main.ts). + */ +export class ScreenFlash { + private readonly durationSeconds: number; + private remaining = 0; + private peakAlpha: number; + private _color: Rgba; + + public constructor(durationSeconds = 0.14) { + this.durationSeconds = Math.max(0.001, durationSeconds); + this.peakAlpha = 0; + this._color = WHITE; + } + + /** Current alpha in [0, peakAlpha]; 0 when idle. */ + public get amount(): number { + if (this.remaining <= 0) return 0; + return this.peakAlpha * (this.remaining / this.durationSeconds); + } + + /** Base RGB (alpha is owned by the envelope). */ + public get color(): Rgba { + return this._color; + } + + public get isActive(): boolean { + return this.remaining > 0; + } + + /** + * Trigger a flash. `strength` scales peak alpha (default 0.55); repeated + * calls while active keep the strongest remaining envelope. + */ + public flash(color: Rgba = WHITE, strength = 0.55, durationOverride?: number): void { + const duration = Math.max(0.001, durationOverride ?? this.durationSeconds); + const candidate = duration * clamp01(strength); + if (this.isActive && this.remaining >= candidate) return; // stronger already running + this._color = color; + this.peakAlpha = clamp01(strength); + this.remaining = duration; + } + + /** Advance the envelope. */ + public update(dtSeconds: number): void { + if (!Number.isFinite(dtSeconds) || dtSeconds <= 0) return; + if (this.remaining > 0) this.remaining = Math.max(0, this.remaining - dtSeconds); + } + + /** Write the current display color (`color.rgb`, alpha = amount) into `out`. */ + public currentColor(out: [number, number, number, number]): Rgba { + out[0] = this._color[0]; + out[1] = this._color[1]; + out[2] = this._color[2]; + out[3] = this.amount; + return out; + } +} + +const WHITE: Rgba = [1, 1, 1, 1]; diff --git a/src/effects/JuiceSystem.ts b/src/effects/JuiceSystem.ts new file mode 100644 index 0000000..f636fe7 --- /dev/null +++ b/src/effects/JuiceSystem.ts @@ -0,0 +1,151 @@ +import type { Rgba } from '../renderer/types'; +import { BloomPulse, type BloomApplyFn, type BloomPulseOptions } from './BloomPulse'; +import { HitFlash, ScreenFlash } from './HitFlash'; +import { ParticleSystem, DEFAULT_PARTICLE_CAPACITY } from './Particles'; +import { ScreenShake, type ScreenShakeOptions } from './ScreenShake'; +import { SquashStretch, type SquashStretchOptions } from './SquashStretch'; + +/** + * JuiceSystem (B1) — one typed façade over every arcade-feel effect so the + * game layer (player/enemies being built in parallel) has a single object to + * consume: + * + * const juice = new JuiceSystem({ setBloom: p => renderer.setBloomOptions(p) }); + * juice.update(dt); // once per fixed step + * juice.enemyDeath(x, y); // particles + shake + bloom in one call + * renderer.drawSprites('white', juice.particles.buildDraws()); + * + * Individual subsystems stay public and independently usable. Everything is + * pooled/simulated on the CPU; rendering is the caller's job. + */ +export class JuiceSystem { + public readonly particles: ParticleSystem; + public readonly shake: ScreenShake; + public readonly hitFlash: HitFlash; + public readonly screenFlash: ScreenFlash; + /** Default squash & stretch instance for the player sprite. */ + public readonly playerSquash: SquashStretch; + public readonly bloom: BloomPulse; + + public constructor(opts: JuiceSystemOptions = {}) { + this.particles = new ParticleSystem( + opts.capacity ?? DEFAULT_PARTICLE_CAPACITY, + opts.seed ?? 0x600dc0de, + ); + this.shake = new ScreenShake(opts.shake); + this.hitFlash = new HitFlash(); + this.screenFlash = new ScreenFlash(0.14); + this.playerSquash = new SquashStretch(opts.squash); + this.bloom = new BloomPulse(opts.setBloom ?? null, opts.bloom); + } + + /** Advance all envelopes/particles. Call once per fixed update step. */ + public update(dtSeconds: number): void { + this.particles.update(dtSeconds); + this.shake.update(dtSeconds); + this.hitFlash.update(dtSeconds); + this.screenFlash.update(dtSeconds); + this.playerSquash.update(dtSeconds); + this.bloom.update(dtSeconds); + } + + // ------------------------------------------------- gameplay event recipes + // Each wires together the subsystem calls a moment deserves, so gameplay + // code stays declarative ("what happened") instead of mechanical + // ("emit 19 particles, kick trauma 0.25…"). + + /** Memory-fragment collected: sparkle + soft bloom blip. */ + public fragmentPickup(x: number, y: number): void { + this.particles.fragmentPickup(x, y); + this.bloom.pulse(0.18); + } + + /** Enemy destroyed: neon burst, medium shake, bloom pump. */ + public enemyDeath(x: number, y: number): void { + this.particles.enemyDeath(x, y); + this.shake.addTrauma(0.28); + this.bloom.pulse(0.4); + } + + /** Player died: fragment explosion, heavy shake, red screen flash. */ + public playerDeath(x: number, y: number): void { + this.particles.playerDeath(x, y); + this.shake.addTrauma(0.85); + this.screenFlash.flash(DEATH_FLASH, 0.5); + this.bloom.pulse(1); + } + + /** Weapon fired: muzzle flash + recoil micro-shake + bloom blip. */ + public shoot(x: number, y: number, angle: number): void { + this.particles.muzzleFlash(x, y, angle); + this.shake.addTrauma(0.07); + this.bloom.pulse(0.14); + } + + /** Player jumped: thruster puffs + stretch pose. */ + public jump(x: number, y: number): void { + this.particles.jumpPuff(x, y); + this.playerSquash.stretch(0.32); + } + + /** Player landed: dust scaled by impact + squash pose + thud shake. */ + public land(x: number, y: number, impact = 1): void { + this.particles.landingDust(x, y, impact); + this.playerSquash.squash(Math.min(0.6, 0.22 + 0.22 * Math.max(0, impact))); + this.shake.addTrauma(Math.min(0.2, 0.05 * Math.max(0, impact))); + } + + /** Big boom (Nova weapon, barrels…): explosion preset + strong shake. */ + public explosion(x: number, y: number): void { + this.particles.explosion(x, y); + this.shake.addTrauma(0.55); + this.screenFlash.flash([1, 0.85, 0.55, 1], 0.3); + this.bloom.pulse(0.75); + } + + /** Player took damage: white hit flash + hurt sparks + shake. */ + public hurt(x: number, y: number): void { + this.hitFlash.flash(); + this.particles.burst({ + count: 8, x, y, + speed: [120, 300], life: [0.2, 0.4], + size: [2.5, 5], endSize: 0, + color: [1, 0.35, 0.35, 1], endColor: [1, 0.35, 0.35, 0], + gravity: 600, drag: 1, glow: 1.2, + }); + this.shake.addTrauma(0.32); + } + + /** Boss warning stinger: long bloom surge + ominous pink screen flash. */ + public bossWarning(): void { + this.bloom.pulse(1); + this.screenFlash.flash(WARNING_PINK, 0.4, 0.6); + this.shake.addTrauma(0.2); + } + + /** + * One-line HUD/console summary, e.g. `"P 143/1024 · shake 32% · bloom 1.84"`. + */ + public statsLine(): string { + return [ + `P ${this.particles.aliveCount}/${this.particles.capacity}`, + `shake ${Math.round(this.shake.trauma * 100)}%`, + `bloom ${this.bloom.currentIntensity.toFixed(2)}`, + ].join(' · '); + } +} + +export interface JuiceSystemOptions { + /** Particle pool ceiling. Default {@link DEFAULT_PARTICLE_CAPACITY}. */ + capacity?: number; + /** Seed for deterministic effect randomness. */ + seed?: number; + shake?: ScreenShakeOptions; + squash?: SquashStretchOptions; + bloom?: BloomPulseOptions; + /** Wire to `renderer.setBloomOptions`; omit for headless use. */ + setBloom?: BloomApplyFn; +} + +const DEATH_FLASH: Rgba = [1, 0.3, 0.45, 1]; +const WARNING_PINK: Rgba = [1, 0.25, 0.75, 1]; diff --git a/src/effects/Particles.ts b/src/effects/Particles.ts new file mode 100644 index 0000000..329c94a --- /dev/null +++ b/src/effects/Particles.ts @@ -0,0 +1,507 @@ +import { SeededRng } from '../core/Rng'; +import type { Rgba, SpriteDraw } from '../renderer/types'; + +/** + * ParticleSystem (PLAN.md §4 "Juice & effekter", B1). + * + * Object-pooled particle simulation for the arcade-feel layer: fragments, + * sparks, dust, flashes and explosions. The pool is fully preallocated at + * construction — after warmup, emit/burst/update/buildDraws perform **zero + * allocations** (dead particles are recycled, draw quads are reused objects), + * which keeps GC pressure off the 120 Hz fixed-timestep loop (PLAN.md §6 + * "Objektpooling"). + * + * Rendering integrates through SpriteBatch: each live particle becomes one + * `SpriteDraw` quad; glow particles use `'additive'` blending so they light + * up the frame and feed the bloom post-pass. + * + * All randomness flows through a seeded {@link SeededRng}, so effect tests + * and replays are deterministic (repo convention: no `Math.random()`). + */ + +/** Default maximum simultaneous particles. */ +export const DEFAULT_PARTICLE_CAPACITY = 1024; + +/** + * A number, or an inclusive `[min, max)` range rolled per particle. + * Ranges give bursts organic variety without extra API surface. + */ +export type NumOrRange = number | readonly [number, number]; + +export interface EmitOptions { + /** Spawn position, world pixels. */ + x: number; + y: number; + /** Initial velocity, px/s. Defaults to 0. */ + vx?: number; + vy?: number; + /** Downward acceleration, px/s². Positive = falls. Defaults to 0. */ + gravity?: number; + /** + * Exponential velocity damping coefficient (1/s). Higher = punchier stops. + * Defaults to 0 (undamped). + */ + drag?: number; + /** Lifetime in seconds. Defaults to 0.5. */ + life?: NumOrRange; + /** Start size in px (quads are square). Defaults to 6. */ + size?: NumOrRange; + /** End size in px; interpolated linearly over life. Defaults to start size. */ + endSize?: NumOrRange; + /** Start color. Defaults to opaque white. */ + color?: Rgba; + /** End color (lerped over life); presets typically fade alpha → 0. Defaults to `color`. */ + endColor?: Rgba; + /** + * Neon-glow strength fed to the sprite glow channel (bloom fuel). + * 0 disables the halo. Defaults to 0. + */ + glow?: number; + /** Blend mode. Defaults to `'additive'` (glow sparks read best lit up). */ + additive?: boolean; +} + +export interface BurstOptions extends Omit { + /** Number of particles to spawn (recycles the oldest when at capacity). */ + count: number; + /** Center aim angle in radians; screen space is y-down so `-π/2` is up. */ + angle?: number; + /** Total angular spread in radians (`2π` = omni, `0` = laser-straight). */ + spread?: number; + /** Launch speed in px/s — scalar or range. Defaults to 120. */ + speed?: NumOrRange; + /** Radial positional jitter in px. Defaults to 0. */ + jitter?: number; +} + +/** Mutable pooled particle record — flat fields, no nested allocations. */ +export interface Particle { + x: number; + y: number; + vx: number; + vy: number; + gravity: number; + drag: number; + /** Seconds of life remaining. */ + life: number; + maxLife: number; + size0: number; + size1: number; + r0: number; + g0: number; + b0: number; + a0: number; + r1: number; + g1: number; + b1: number; + a1: number; + glowStrength: number; + additive: boolean; +} + +function finiteOr(value: number | undefined, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback; +} + +function resolveRange(rng: SeededRng, value: NumOrRange | undefined, fallback: number): number { + if (value === undefined) return fallback; + if (typeof value === 'number') return Number.isFinite(value) ? value : fallback; + const [lo, hi] = value; + const min = finiteOr(lo, fallback); + const max = finiteOr(hi, min); + return max < min ? min : rng.range(min, max); +} + +function clamp01(value: number): number { + return value < 0 ? 0 : value > 1 ? 1 : value; +} + +const DEFAULT_LIFE_S = 0.5; +const DEFAULT_SIZE_PX = 6; +const DEFAULT_SPEED_PX_PER_S = 120; +const TWO_PI = Math.PI * 2; + +export class ParticleSystem { + private readonly rng: SeededRng; + private readonly pool: Particle[]; + private alive = 0; + private recycleCursor = 0; + private readonly draws: SpriteDraw[]; + private readonly drawTints: Array<[number, number, number, number]>; + private readonly drawGlows: Array<[number, number, number, number]>; + /** Reused by burst() so per-particle emission never allocates. */ + private readonly scratchEmit: EmitOptions = { x: 0, y: 0 }; + + public constructor( + capacity: number = DEFAULT_PARTICLE_CAPACITY, + seed: number = 0x600dc0de, + ) { + this.rng = new SeededRng(seed); + const cap = Math.max(1, Math.floor(capacity)); + this.pool = new Array(cap); + this.draws = new Array(cap); + this.drawTints = new Array<[number, number, number, number]>(cap); + this.drawGlows = new Array<[number, number, number, number]>(cap); + for (let i = 0; i < cap; i++) { + this.pool[i] = { + x: 0, y: 0, vx: 0, vy: 0, + gravity: 0, drag: 0, + life: 0, maxLife: 1, + size0: 0, size1: 0, + r0: 1, g0: 1, b0: 1, a0: 1, + r1: 1, g1: 1, b1: 1, a1: 1, + glowStrength: 0, + additive: true, + }; + this.drawTints[i] = [1, 1, 1, 1]; + this.drawGlows[i] = [0, 0, 0, 0]; + this.draws[i] = { + x: 0, y: 0, width: 0, height: 0, + tint: this.drawTints[i], + blend: 'additive', + glow: undefined, + }; + } + } + + /** Hard ceiling on simultaneous particles (pool size). */ + public get capacity(): number { + return this.pool.length; + } + + /** Currently simulated particles. */ + public get aliveCount(): number { + return this.alive; + } + + public get isEmpty(): boolean { + return this.alive === 0; + } + + /** + * The pooled particle backing array (alive particles occupy indices + * `[0, aliveCount)`). The array and its records are never reallocated — + * exposed so tests can assert the zero-allocation reuse contract. + */ + public get poolView(): readonly Particle[] { + return this.pool; + } + + /** + * Spawn one particle. When the pool is full the slot under the round-robin + * cursor is overwritten (fair recycling; O(1), never grows). + */ + public emit(options: EmitOptions): void { + const p = this.alive < this.capacity + ? this.pool[this.alive++]! + : this.pool[this.recycleCursor++ % this.capacity]!; + + const color = options.color ?? WHITE; + const endColor = options.endColor ?? color; + const size0 = resolveRange(this.rng, options.size, DEFAULT_SIZE_PX); + const life = resolveRange(this.rng, options.life, DEFAULT_LIFE_S); + + p.x = finiteOr(options.x, 0); + p.y = finiteOr(options.y, 0); + p.vx = finiteOr(options.vx, 0); + p.vy = finiteOr(options.vy, 0); + p.gravity = finiteOr(options.gravity, 0); + p.drag = Math.max(0, finiteOr(options.drag, 0)); + p.maxLife = Math.max(1e-4, life); + p.life = p.maxLife; + p.size0 = size0; + p.size1 = resolveRange(this.rng, options.endSize, size0); + p.r0 = clamp01(color[0]); + p.g0 = clamp01(color[1]); + p.b0 = clamp01(color[2]); + p.a0 = clamp01(color[3]); + p.r1 = clamp01(endColor[0]); + p.g1 = clamp01(endColor[1]); + p.b1 = clamp01(endColor[2]); + p.a1 = clamp01(endColor[3]); + p.glowStrength = Math.max(0, finiteOr(options.glow, 0)); + p.additive = options.additive ?? true; + } + + /** + * Spawn `count` particles around `(x, y)` with randomized direction/speed. + * Returns the number emitted (always `count`; over-capacity bursts recycle). + * + * Uses a preallocated scratch options record so bursts never allocate. + */ + public burst(options: BurstOptions): number { + const count = Math.max(0, Math.floor(finiteOr(options.count, 0))); + if (count === 0) return 0; + const angle = finiteOr(options.angle, 0); + const spread = Math.min(TWO_PI, Math.abs(finiteOr(options.spread, TWO_PI))); + const jitter = Math.max(0, finiteOr(options.jitter, 0)); + + // Copy shared fields once into the scratch record (no per-particle alloc). + const s = this.scratchEmit; + s.gravity = options.gravity; + s.drag = options.drag; + s.life = options.life; + s.size = options.size; + s.endSize = options.endSize; + s.color = options.color; + s.endColor = options.endColor; + s.glow = options.glow; + s.additive = options.additive; + + for (let i = 0; i < count; i++) { + const theta = spread >= TWO_PI + ? this.rng.range(0, TWO_PI) + : angle + this.rng.range(-spread / 2, spread / 2); + const speed = resolveRange(this.rng, options.speed, DEFAULT_SPEED_PX_PER_S); + s.x = options.x + (jitter > 0 ? this.rng.range(-jitter, jitter) : 0); + s.y = options.y + (jitter > 0 ? this.rng.range(-jitter, jitter) : 0); + s.vx = Math.cos(theta) * speed; + s.vy = Math.sin(theta) * speed; + this.emit(s); + } + return count; + } + + /** + * Advance the simulation by `dtSeconds`. Dead particles are removed with + * swap-delete (order within the pool is not stable across frames). + */ + public update(dtSeconds: number): void { + if (!Number.isFinite(dtSeconds) || dtSeconds <= 0) return; + let i = 0; + while (i < this.alive) { + const p = this.pool[i]!; + p.life -= dtSeconds; + if (p.life <= 0) { + // Swap with the last alive particle and shrink — no splice allocation. + const last = this.pool[this.alive - 1]!; + this.pool[i] = last; + this.pool[this.alive - 1] = p; + this.alive -= 1; + continue; // re-process index i (now holds `last`) + } + const damp = Math.exp(-p.drag * dtSeconds); + p.vx *= damp; + p.vy = p.vy * damp + p.gravity * dtSeconds; + p.x += p.vx * dtSeconds; + p.y += p.vy * dtSeconds; + i += 1; + } + } + + /** + * Fill the reusable draw list with one quad per alive particle and return + * it. The returned array identity is stable for the lifetime of the system + * (only contents change), so callers may pass it straight to + * `renderer.drawSprites('white', …)` every frame without allocating. + */ + public buildDraws(): readonly SpriteDraw[] { + const draws = this.draws; + let n = 0; + for (let i = 0; i < this.alive; i++) { + const p = this.pool[i]!; + const t = 1 - p.life / p.maxLife; // age fraction 0→1 + const size = p.size0 + (p.size1 - p.size0) * t; + if (size <= 0.05) continue; + + const half = size / 2; + const d = draws[n]!; + d.x = p.x - half; + d.y = p.y - half; + d.width = size; + d.height = size; + d.blend = p.additive ? 'additive' : 'normal'; + + const tint = this.drawTints[n]!; + tint[0] = p.r0 + (p.r1 - p.r0) * t; + tint[1] = p.g0 + (p.g1 - p.g0) * t; + tint[2] = p.b0 + (p.b1 - p.b0) * t; + tint[3] = p.a0 + (p.a1 - p.a0) * t; + d.tint = tint; + + if (p.glowStrength > 0 && p.additive) { + const glow = this.drawGlows[n]!; + glow[0] = tint[0]; + glow[1] = tint[1]; + glow[2] = tint[2]; + glow[3] = p.glowStrength; + d.glow = glow; + } else { + d.glow = undefined; + } + n += 1; + } + draws.length = n; + return draws; + } + + // ------------------------------------------------------- scene presets -- + // Out-of-the-box recipes for the game layer's common moments. Each returns + // the number of particles spawned so callers/tests can assert counts. + + /** Collectible sparkle: tight upward fan of cyan/pink glints. */ + public fragmentPickup(x: number, y: number): number { + this.burst({ + count: 9, x, y, + angle: -Math.PI / 2, spread: Math.PI * 0.9, + speed: [70, 210], life: [0.25, 0.5], + size: [3, 6], endSize: 0, + color: CYAN, endColor: [0.4, 1, 1, 0], + glow: 1.6, jitter: 4, + }); + this.emit({ + x, y, life: 0.18, size: 14, endSize: 30, + color: [1, 1, 1, 0.9], endColor: PINK_FADE, + glow: 2, + }); + return 10; + } + + /** Enemy pop: neon shards plus a bright core flash. */ + public enemyDeath(x: number, y: number): number { + this.burst({ + count: 12, x, y, + speed: [90, 340], life: [0.35, 0.7], + size: [3, 7], endSize: 0, + color: MAGENTA, endColor: MAGENTA_FADE, + gravity: 500, drag: 0.8, glow: 1.2, jitter: 6, + }); + this.burst({ + count: 6, x, y, + speed: [160, 420], life: [0.15, 0.3], + size: [2, 4], endSize: 0, + color: CYAN, endColor: CYAN_FADE, + drag: 0.5, glow: 1.8, + }); + this.emit({ + x, y, life: 0.14, size: 26, endSize: 52, + color: [1, 1, 1, 1], endColor: MAGENTA_FADE, + glow: 2.2, + }); + return 19; + } + + /** + * Player death: the droid bursts into memory-fragments — white-hot flash, + * shockwave ring, then glowing debris that tumbles down under gravity. + */ + public playerDeath(x: number, y: number): number { + this.emit({ + x, y, life: 0.22, size: 40, endSize: 110, + color: [1, 1, 1, 1], endColor: [1, 0.4, 0.85, 0], + glow: 2.5, + }); + this.burst({ + count: 16, x, y, + speed: [120, 380], life: [0.5, 0.9], + size: [4, 9], endSize: [1, 3], + color: PINK, endColor: PINK_FADE, + gravity: 700, drag: 1.1, glow: 1.4, jitter: 8, + }); + this.burst({ + count: 16, x, y, + speed: [180, 520], life: [0.4, 0.8], + size: [3, 6], endSize: 0, + color: CYAN, endColor: CYAN_FADE, + gravity: 850, drag: 0.7, glow: 2, jitter: 8, + }); + this.burst({ + count: 10, x, y, + speed: [260, 620], life: [0.25, 0.45], + size: [2, 4], endSize: 0, + color: WHITE_HOT, endColor: WHITE_FADE, + drag: 0.4, glow: 2.4, + }); + return 43; + } + + /** Thruster kick at the feet on jump: soft non-additive puffs. */ + public jumpPuff(x: number, y: number): number { + this.burst({ + count: 7, x, y, + angle: Math.PI / 2, spread: Math.PI * 0.85, + speed: [50, 150], life: [0.22, 0.42], + size: [5, 9], endSize: [14, 20], + color: PUFF, endColor: PUFF_FADE, + drag: 3, additive: false, + }); + return 7; + } + + /** Ground dust kicked sideways on landing; `impact` (≥0) scales the kick. */ + public landingDust(x: number, y: number, impact = 1): number { + const power = Math.min(3, Math.max(0, impact)); + const side = (dir: 1 | -1, count: number): void => { + this.burst({ + count, x: x + dir * 6, y, + angle: dir > 0 ? -0.35 : Math.PI + 0.35, + spread: 1.1, + speed: [60 + 70 * power, 140 + 130 * power], life: [0.28, 0.5], + size: [5 + 2 * power, 9 + 3 * power], endSize: [16, 24], + color: DUST, endColor: DUST_FADE, + drag: 2.4, additive: false, + }); + }; + const perSide = 4 + Math.round(3 * power); + side(1, perSide); + side(-1, perSide); + return perSide * 2; + } + + /** Weapon report: one hot flash plus directional spark cone. */ + public muzzleFlash(x: number, y: number, angle: number): number { + this.emit({ + x, y, life: 0.06, size: 18, endSize: 6, + color: WHITE_HOT, endColor: [1, 0.85, 0.4, 0.6], + glow: 2.5, + }); + this.burst({ + count: 6, x, y, + angle, spread: 0.55, + speed: [280, 520], life: [0.08, 0.16], + size: [2, 3.5], endSize: 0, + color: [1, 0.95, 0.6, 1], endColor: [1, 0.6, 0.15, 0], + drag: 2, glow: 2, + }); + return 7; + } + + /** Big orange blast: core flash, fireball puff, fast debris. */ + public explosion(x: number, y: number): number { + this.emit({ + x, y, life: 0.2, size: 60, endSize: 130, + color: WHITE_HOT, endColor: [1, 0.55, 0.1, 0], + glow: 2.6, + }); + this.burst({ + count: 18, x, y, + speed: [30, 240], life: [0.4, 0.75], + size: [10, 22], endSize: [26, 44], + color: [1, 0.62, 0.15, 0.9], endColor: [0.45, 0.08, 0.1, 0], + gravity: -60, drag: 2.2, jitter: 10, additive: false, + }); + this.burst({ + count: 16, x, y, + speed: [200, 480], life: [0.3, 0.65], + size: [2.5, 5], endSize: 0, + color: [1, 0.8, 0.35, 1], endColor: EMBER_FADE, + gravity: 900, drag: 0.6, glow: 2.2, jitter: 8, + }); + return 35; + } +} + +const WHITE: Rgba = [1, 1, 1, 1]; +const WHITE_HOT: Rgba = [1, 1, 1, 1]; +const WHITE_FADE: Rgba = [1, 1, 1, 0]; +const CYAN: Rgba = [0.35, 0.95, 1, 1]; +const CYAN_FADE: Rgba = [0.35, 0.95, 1, 0]; +const MAGENTA: Rgba = [1, 0.3, 0.85, 1]; +const MAGENTA_FADE: Rgba = [0.6, 0.1, 0.5, 0]; +const PINK: Rgba = [1, 0.42, 0.88, 1]; +const PINK_FADE: Rgba = [1, 0.42, 0.88, 0]; +const EMBER_FADE: Rgba = [0.9, 0.25, 0.05, 0]; +const PUFF: Rgba = [0.75, 0.92, 1, 0.5]; +const PUFF_FADE: Rgba = [0.75, 0.92, 1, 0]; +const DUST: Rgba = [0.82, 0.85, 0.95, 0.45]; +const DUST_FADE: Rgba = [0.82, 0.85, 0.95, 0]; diff --git a/src/effects/ScreenShake.ts b/src/effects/ScreenShake.ts new file mode 100644 index 0000000..a15cde6 --- /dev/null +++ b/src/effects/ScreenShake.ts @@ -0,0 +1,174 @@ +/** + * ScreenShake (PLAN.md §4 "Juice & effekter": "mjuk skärmshake vid explosioner"). + * + * Trauma-based shake (industry-standard "juice" model): + * - gameplay events add **trauma** ∈ [0, 1] instead of raw offsets; + * - trauma decays linearly over time; + * - the visible offset is `maxOffset · trauma² · noise(t)` — squaring makes + * small hits gentle and big explosions violent while sharing one scalar; + * - noise is smooth value noise (hashed lattice + quintic fade), i.e. + * Perlin-ish: continuous, non-repeating-feeling, deterministic. + * + * Pure math (`decayTrauma`, `valueNoise1D`, `sampleShake`) is exported for + * unit testing without any DOM/GPU involvement. The renderer camera consumes + * `offsetX/offsetY` each frame after `update(dt)`. + */ + +export interface ScreenShakeOptions { + /** Peak horizontal offset at full trauma, px. Default 22. */ + maxOffsetX?: number; + /** Peak vertical offset at full trauma, px. Default 16. */ + maxOffsetY?: number; + /** Trauma units drained per second. Default 1.5 (≈0.67 s from full). */ + decayPerSecond?: number; + /** Horizontal noise frequency (lattice steps/s). Default 26. */ + frequencyX?: number; + /** Vertical noise frequency. Default 31 (prime-ish vs X → no visible lock). */ + frequencyY?: number; +} + +export type RequiredScreenShakeOptions = Required; + +export const DEFAULT_SHAKE_OPTIONS: Readonly = { + maxOffsetX: 22, + maxOffsetY: 16, + decayPerSecond: 1.5, + frequencyX: 26, + frequencyY: 31, +}; + +function clamp01(value: number): number { + return Number.isFinite(value) ? (value < 0 ? 0 : value > 1 ? 1 : value) : 0; +} + +/** + * Linear decay of trauma by `decayPerSecond · dt`, clamped to [0, 1]. + */ +export function decayTrauma(trauma: number, dtSeconds: number, decayPerSecond: number): number { + const t = clamp01(trauma); + const dt = Number.isFinite(dtSeconds) ? Math.max(0, dtSeconds) : 0; + const decay = Number.isFinite(decayPerSecond) ? Math.max(0, decayPerSecond) : 0; + return clamp01(t - decay * dt); +} + +/** Deterministic hash → [0, 1). Sin-based lattice hash: stable within a session. */ +function hash1(n: number): number { + const s = Math.sin(n * 127.1 + 311.7) * 43758.5453123; + return s - Math.floor(s); +} + +function smootherstep(t: number): number { + return t * t * t * (t * (t * 6 - 15) + 10); +} + +/** + * Smooth 1D value noise in [-1, 1]: interpolate hashed lattice points with a + * quintic fade. Continuous everywhere, cheap, and deterministic. + */ +export function valueNoise1D(t: number, seedOffset = 0): number { + if (!Number.isFinite(t)) return 0; + const x = t + seedOffset; + const i = Math.floor(x); + const f = x - i; + const a = hash1(i); + const b = hash1(i + 1); + return (a + (b - a) * smootherstep(f)) * 2 - 1; +} + +export interface ShakeSample { + x: number; + y: number; +} + +/** + * Offset for a given trauma/time: `maxOffset · trauma² · noise`. + * Zero at zero trauma; bounded by ±maxOffset at full trauma. + */ +export function sampleShake( + trauma: number, + timeSeconds: number, + opts: Readonly = DEFAULT_SHAKE_OPTIONS, +): ShakeSample { + const o = { ...DEFAULT_SHAKE_OPTIONS, ...opts }; + const t = clamp01(trauma); + if (t <= 0) return { x: 0, y: 0 }; + const amp = t * t; + return { + x: o.maxOffsetX * amp * valueNoise1D(timeSeconds * o.frequencyX, 0), + y: o.maxOffsetY * amp * valueNoise1D(timeSeconds * o.frequencyY, 57.3), + }; +} + +/** + * Trauma accumulator + noise sampler. Typical use per frame: + * + * shake.addTrauma(0.4); // on explosion + * shake.update(dt); // in fixed update + * const { offsetX, offsetY } = shake; // feed into camera transform + */ +export class ScreenShake { + private opts: RequiredScreenShakeOptions; + private _trauma = 0; + private time = 0; + private sample: ShakeSample = { x: 0, y: 0 }; + + public constructor(opts: ScreenShakeOptions = {}) { + this.opts = sanitizeOptions(opts); + } + + /** Current trauma level [0, 1]. */ + public get trauma(): number { + return this._trauma; + } + + /** Camera offset X in px for this frame (valid after update()). */ + public get offsetX(): number { + return this.sample.x; + } + + /** Camera offset Y in px for this frame (valid after update()). */ + public get offsetY(): number { + return this.sample.y; + } + + /** + * Add trauma (clamped to [0, 1]). Rough guide: 0.1 hit-tap, 0.25 enemy + * death, 0.5 explosion, 0.8+ player death / boss slams. + */ + public addTrauma(amount: number): void { + this._trauma = clamp01(this._trauma + (Number.isFinite(amount) ? amount : 0)); + } + + /** Alias used by event-style call sites (`shake.kick(0.3)`). */ + public kick(amount: number): void { + this.addTrauma(amount); + } + + /** Advance time, decay trauma, recompute this frame's offset. */ + public update(dtSeconds: number): void { + const dt = Number.isFinite(dtSeconds) ? Math.max(0, dtSeconds) : 0; + this.time += dt; + this._trauma = decayTrauma(this._trauma, dt, this.opts.decayPerSecond); + this.sample = sampleShake(this._trauma, this.time, this.opts); + } + + /** Zero everything (level transitions). */ + public reset(): void { + this._trauma = 0; + this.time = 0; + this.sample.x = 0; + this.sample.y = 0; + } +} + +function sanitizeOptions(opts: Readonly): RequiredScreenShakeOptions { + const pick = (v: number | undefined, fb: number, min: number): number => + typeof v === 'number' && Number.isFinite(v) ? Math.max(min, v) : fb; + return { + maxOffsetX: pick(opts.maxOffsetX, DEFAULT_SHAKE_OPTIONS.maxOffsetX, 0), + maxOffsetY: pick(opts.maxOffsetY, DEFAULT_SHAKE_OPTIONS.maxOffsetY, 0), + decayPerSecond: pick(opts.decayPerSecond, DEFAULT_SHAKE_OPTIONS.decayPerSecond, 0.001), + frequencyX: pick(opts.frequencyX, DEFAULT_SHAKE_OPTIONS.frequencyX, 0.001), + frequencyY: pick(opts.frequencyY, DEFAULT_SHAKE_OPTIONS.frequencyY, 0.001), + }; +} diff --git a/src/effects/SquashStretch.ts b/src/effects/SquashStretch.ts new file mode 100644 index 0000000..a65b1a2 --- /dev/null +++ b/src/effects/SquashStretch.ts @@ -0,0 +1,170 @@ +/** + * SquashStretch (PLAN.md §4: "Squash-and-stretch på hopp"). + * + * A reusable scale component for arcade feel: gameplay sets impulses + * (`squash` on land, `stretch` on jump), and two damped springs pull + * scaleX/scaleY back to rest (1, 1) with a lively overshoot. + * + * Impulses are volume-preserving by default (`sx · sy ≈ 1`) so the sprite + * reads as elastic rather than inflating. The player entity (wave B) owns one + * instance and applies `(scaleX, scaleY)` around its feet anchor when drawing. + * + * The spring integrator is a pure exported function for unit tests. + */ + +export interface SquashStretchOptions { + /** Spring stiffness toward rest scale (1/s²). Default 190. */ + stiffness?: number; + /** Spring damping; ~12 gives a slight overshoot, ~2·√stiffness is critical. Default 13. */ + damping?: number; + /** Clamp for |scale − 1| in either direction. Default 0.65 (never inside-out). */ + maxDeform?: number; +} + +export const DEFAULT_SQUASH_OPTIONS: Readonly> = { + stiffness: 190, + damping: 13, + maxDeform: 0.65, +}; + +export interface SpringState { + value: number; + velocity: number; +} + +/** + * One semi-implicit Euler spring step toward `target`. + * Stable at 120 Hz fixed timestep with the default constants. + */ +export function springStep( + state: SpringState, + target: number, + stiffness: number, + damping: number, + dtSeconds: number, +): void { + if (!Number.isFinite(dtSeconds) || dtSeconds <= 0) return; + const k = Number.isFinite(stiffness) ? stiffness : DEFAULT_SQUASH_OPTIONS.stiffness; + const c = Number.isFinite(damping) ? damping : DEFAULT_SQUASH_OPTIONS.damping; + const accel = (target - state.value) * k - state.velocity * c; + state.velocity += accel * dtSeconds; + state.value += state.velocity * dtSeconds; +} + +function clamp01(value: number): number { + return Number.isFinite(value) ? (value < 0 ? 0 : value > 1 ? 1 : value) : 0; +} + +/** Clamp a scale value into `[1 - maxDeform, 1 + maxDeform]`. */ +function clampScale(scale: number, maxDeform: number): number { + if (!Number.isFinite(scale)) return 1; + return Math.min(1 + maxDeform, Math.max(1 - maxDeform, scale)); +} + +/** + * Spring-driven scaleX/scaleY with squash/stretch impulses. + * Start values are rest (1, 1); `update(dt)` converges back to rest. + */ +export class SquashStretch { + private readonly stiffness: number; + private readonly damping: number; + private readonly maxDeform: number; + private readonly sx: SpringState = { value: 1, velocity: 0 }; + private readonly sy: SpringState = { value: 1, velocity: 0 }; + + public constructor(opts: SquashStretchOptions = {}) { + this.stiffness = pickPositive(opts.stiffness, DEFAULT_SQUASH_OPTIONS.stiffness); + this.damping = pickPositive(opts.damping, DEFAULT_SQUASH_OPTIONS.damping); + this.maxDeform = clamp01(opts.maxDeform ?? DEFAULT_SQUASH_OPTIONS.maxDeform); + } + + /** Current horizontal scale (multiply the sprite's width). */ + public get scaleX(): number { + return this.sx.value; + } + + /** Current vertical scale (multiply the sprite's height). */ + public get scaleY(): number { + return this.sy.value; + } + + /** True once both springs have settled at rest (within 0.001). */ + public get isAtRest(): boolean { + return ( + Math.abs(this.sx.value - 1) < 1e-3 && + Math.abs(this.sy.value - 1) < 1e-3 && + Math.abs(this.sx.velocity) < 1e-3 && + Math.abs(this.sy.velocity) < 1e-3 + ); + } + + /** + * Landing-style impulse: wide + short. `amount` ∈ [0, 1] scales intensity + * (scaleX up to 1 + maxDeform). Volume preserved via reciprocal pairing. + */ + public squash(amount = 0.35): void { + this.setVolumePreserved(this.sx, this.sy, 1 + clamp01(amount)); + } + + /** + * Jump-style impulse: tall + thin. `amount` ∈ [0, 1] scales intensity. + */ + public stretch(amount = 0.35): void { + this.setVolumePreserved(this.sy, this.sx, 1 + clamp01(amount)); + } + + /** + * Direct impulse: immediately set both scales (clamped into + * `[1 ± maxDeform]`; NaN leaves an axis untouched) and let the springs + * carry them back to rest. Volume is *not* forced here; prefer + * {@link squash}/{@link stretch} for elastic reads. + */ + public impulse(scaleX: number, scaleY: number): void { + if (Number.isFinite(scaleX)) this.setAxis(this.sx, clampScale(scaleX, this.maxDeform)); + if (Number.isFinite(scaleY)) this.setAxis(this.sy, clampScale(scaleY, this.maxDeform)); + } + + /** Advance both springs toward rest. Call once per fixed step. */ + public update(dtSeconds: number): void { + springStep(this.sx, 1, this.stiffness, this.damping, dtSeconds); + springStep(this.sy, 1, this.stiffness, this.damping, dtSeconds); + // Snap tiny residuals so isAtRest becomes exactly true. + if (Math.abs(this.sx.value - 1) < 5e-4 && Math.abs(this.sx.velocity) < 5e-4) { + this.sx.value = 1; + this.sx.velocity = 0; + } + if (Math.abs(this.sy.value - 1) < 5e-4 && Math.abs(this.sy.velocity) < 5e-4) { + this.sy.value = 1; + this.sy.velocity = 0; + } + } + + /** Immediately return to rest (respawn, level change). */ + public reset(): void { + this.sx.value = 1; + this.sx.velocity = 0; + this.sy.value = 1; + this.sy.velocity = 0; + } + + private setAxis(axis: SpringState, scale: number): void { + // A small outward kick sells impact better than teleporting alone. + axis.velocity += (scale - axis.value) * 6; + axis.value = scale; + } + + /** Set one axis to `scale` and its pair to the reciprocal (volume ≈ 1). */ + private setVolumePreserved( + primary: SpringState, + other: SpringState, + scale: number, + ): void { + const safe = Math.max(1e-4, scale); + this.setAxis(primary, clampScale(safe, this.maxDeform)); + this.setAxis(other, clampScale(1 / safe, this.maxDeform)); + } +} + +function pickPositive(v: number | undefined, fallback: number): number { + return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : fallback; +} diff --git a/src/main.ts b/src/main.ts index 95aea0a..da081dc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,7 @@ import { GameLoop } from './core/GameLoop'; import { GameStateMachine, GameStateName } from './core/GameState'; import { AudioEngine } from './audio/AudioEngine'; +import { JuiceSystem } from './effects/JuiceSystem'; import { InputAction, InputManager } from './input/InputManager'; import { getLevel } from './levels/levels'; import { Level } from './levels/Level'; @@ -23,11 +24,22 @@ import './style.css'; * level 1 ("Mnemosynes fall") and starts a fixed-timestep loop that slowly * pans the camera across it. Background + tiles are visible; the player entity arrives in * wave A. State machine: BOOT → MENU → (Space) PLAYING ⇄ PAUSED. + * + * B1 juice demo (PLAN.md §4 "Juice & effekter"): a floating test-droid + * exercises the effects API — SPACE jumps (stretch + puffs), J shoots + * (muzzle flash), K detonates an explosion preset, ←/→ pump screen shake + * manually. Everything routes through {@link JuiceSystem}, the same API the + * real player/enemies will consume. */ /** Demo camera pan speed in world px/s. */ const CAMERA_SPEED_PX_PER_S = 140; +/** Demo droid physics (simple bounce rig to exercise jump/land juice). */ +const DROID_GRAVITY_PX_PER_S2 = 2400; +const DROID_JUMP_VELOCITY_PX_PER_S = -680; +const DROID_HALF_SIZE = 14; + // Neon tile palette for the test level (tints over the white 1×1 texture). const TINT_SOLID_A: Rgba = [0.16, 0.9, 1.0, 1]; const TINT_SOLID_B: Rgba = [0.1, 0.62, 0.95, 1]; @@ -41,9 +53,21 @@ const GLOW_MARKER_GOAL: Rgba = [0.55, 1.0, 0.45, 2.6]; const TINT_MARKER_PICKUP: Rgba = [1.0, 0.4, 0.9, 0.8]; const GLOW_MARKER_PICKUP: Rgba = [1.0, 0.4, 0.9, 2.4]; +/** World-Y the demo droid hovers at (its "ground" for landing juice). */ +const DROID_HOVER_Y = 560; + +/** Tint of the juice test-droid quad (warm AURORA core). */ +const TINT_DROID: Rgba = [1.0, 0.75, 0.35, 1]; +const GLOW_DROID: Rgba = [1.0, 0.7, 0.3, 2.2]; + interface DemoState { cameraX: number; cameraDir: 1 | -1; + /** Juice test-droid (world px). Hovers at screen center; bounces on demand. */ + droidX: number; + droidY: number; + droidVY: number; + droidGrounded: boolean; } async function boot(): Promise { @@ -77,16 +101,35 @@ async function boot(): Promise { const state = new GameStateMachine(); state.transition(GameStateName.Menu); // assets are ready → show menu - const demo: DemoState = { cameraX: 0, cameraDir: 1 }; + // ---- B1 juice ------------------------------------------------------------- + const juice = new JuiceSystem({ + setBloom: (patch) => renderer.setBloomOptions(patch), + }); + console.info( + '[juice] demo controls: SPACE/W jump · J/X shoot · K/C explosion · ←/→ screen shake · P pause', + ); - /** Build sprite list for tiles currently visible through the virtual view. */ - const buildTileSprites = (): SpriteDraw[] => { + const demo: DemoState = { + cameraX: 0, + cameraDir: 1, + droidX: VIRTUAL_WIDTH / 2, + droidY: DROID_HOVER_Y, + droidVY: 0, + droidGrounded: true, + }; + + /** + * Build sprite list for tiles currently visible through the virtual view. + * `panX` is the (shake-modified) camera X; `offsetY` is the shake Y applied + * to every world sprite so vertical trauma moves the gameplay layer too. + */ + const buildTileSprites = (panX: number, offsetY: number): SpriteDraw[] => { const sprites: SpriteDraw[] = []; const bounds = renderer.viewBounds; - // Camera maps world→view by simple translation: viewX = worldX - cameraX. - const worldLeft = demo.cameraX + bounds.left; - const worldRight = demo.cameraX + bounds.right; + // Camera maps world→view by simple translation: viewX = worldX - panX. + const worldLeft = panX + bounds.left; + const worldRight = panX + bounds.right; const tx0 = Math.max(0, Math.floor(worldLeft / TILE_SIZE)); const tx1 = Math.min(level.widthTiles - 1, Math.ceil(worldRight / TILE_SIZE)); @@ -97,8 +140,8 @@ async function boot(): Promise { const tile = level.tileAt(tx, ty); if (tile === TileType.Empty) continue; - const x = Level.tileToWorldX(tx) - demo.cameraX; - const y = Level.tileToWorldY(ty); + const x = Level.tileToWorldX(tx) - panX; + const y = Level.tileToWorldY(ty) + offsetY; switch (tile) { case TileType.Solid: @@ -135,8 +178,8 @@ async function boot(): Promise { const isExit = spawn.kind === 'exit'; const height = TILE_SIZE * 2; sprites.push({ - x: Level.tileToWorldX(spawn.tx) + 10 - demo.cameraX, - y: Level.tileToWorldY(spawn.ty + 1) - height, + x: Level.tileToWorldX(spawn.tx) + 10 - panX, + y: Level.tileToWorldY(spawn.ty + 1) - height + offsetY, width: 12, height, tint: isCheckpoint @@ -156,6 +199,38 @@ async function boot(): Promise { return sprites; }; + /** + * The B1 test-droid: a glowing quad deformed by the shared squash & + * stretch spring, so S&S is visible without the real player entity. + */ + const buildDroidSprite = (panX: number, offsetY: number): SpriteDraw => { + const size = DROID_HALF_SIZE * 2; + return { + x: demo.droidX - panX - (size * juice.playerSquash.scaleX) / 2, + y: demo.droidY + offsetY - (size * juice.playerSquash.scaleY) / 2, + width: size * juice.playerSquash.scaleX, + height: size * juice.playerSquash.scaleY, + tint: TINT_DROID, + glow: GLOW_DROID, + }; + }; + + /** Fullscreen additive quad while the screen-flash envelope is active. */ + const flashQuad: SpriteDraw[] = [{ x: 0, y: 0, width: 0, height: 0 }]; + const flashColor: [number, number, number, number] = [1, 1, 1, 0]; + const drawScreenFlash = (): void => { + if (!juice.screenFlash.isActive) return; + const bounds = renderer.viewBounds; + const quad = flashQuad[0]!; + quad.x = bounds.left; + quad.y = bounds.top; + quad.width = bounds.right - bounds.left; + quad.height = bounds.bottom - bounds.top; + quad.tint = juice.screenFlash.currentColor(flashColor); + quad.blend = 'additive'; + renderer.drawSprites('white', flashQuad); + }; + // ---- Fixed-timestep loop -------------------------------------------------- const loop = new GameLoop({ update(stepMs) { @@ -193,14 +268,60 @@ async function boot(): Promise { demo.cameraX = 0; demo.cameraDir = 1; } + + // ---- B1 juice demo: droid rig + input-triggered effects ---------- + demo.droidX = demo.cameraX + VIRTUAL_WIDTH / 2; + + if (input.wasPressed(InputAction.Jump) && demo.droidGrounded) { + demo.droidVY = DROID_JUMP_VELOCITY_PX_PER_S; + demo.droidGrounded = false; + juice.jump(demo.droidX, demo.droidY + DROID_HALF_SIZE); + } + if (input.wasPressed(InputAction.Shoot)) { + juice.shoot( + demo.droidX + demo.cameraDir * DROID_HALF_SIZE, + demo.droidY, + demo.cameraDir > 0 ? 0 : Math.PI, + ); + } + if (input.wasPressed(InputAction.SwapWeapon)) { + juice.explosion(demo.droidX + demo.cameraDir * 90, demo.droidY - 40); + } + if (input.wasPressed(InputAction.Left)) juice.shake.addTrauma(0.35); + if (input.wasPressed(InputAction.Right)) juice.shake.addTrauma(0.65); + + // Simple gravity bounce against the hover altitude → landing juice. + if (!demo.droidGrounded) { + demo.droidVY += DROID_GRAVITY_PX_PER_S2 * stepSeconds; + demo.droidY += demo.droidVY * stepSeconds; + if (demo.droidY >= DROID_HOVER_Y && demo.droidVY > 0) { + const impact = Math.min(2, Math.abs(demo.droidVY) / 700); + demo.droidY = DROID_HOVER_Y; + demo.droidVY = 0; + demo.droidGrounded = true; + juice.land(demo.droidX, demo.droidY + DROID_HALF_SIZE, impact); + } + } + + juice.update(stepSeconds); } input.endFrame(); }, render() { + // Screen shake hooks into the camera offset: everything world-space + // (parallax pan + tile/marker/droid positions) shifts by the trauma + // noise; the HUD stays steady. + const panX = demo.cameraX - juice.shake.offsetX; + const offsetY = juice.shake.offsetY; + renderer.beginFrame([0.03, 0.01, 0.09, 1]); - parallax.draw(demo.cameraX); - renderer.drawSprites('white', buildTileSprites()); // tiles on top of parallax + parallax.draw(panX); + const sprites = buildTileSprites(panX, offsetY); // tiles on top of parallax + sprites.push(buildDroidSprite(panX, offsetY)); + renderer.drawSprites('white', sprites); + renderer.drawSprites('white', juice.particles.buildDraws()); + drawScreenFlash(); renderer.endFrame(); hud.update({ @@ -209,6 +330,7 @@ async function boot(): Promise { fps: loop.fps, cameraX: demo.cameraX, message: stateMessage(state.current), + juiceLine: juice.statsLine(), }); }, }); diff --git a/src/style.css b/src/style.css index ba2ddd6..661313c 100644 --- a/src/style.css +++ b/src/style.css @@ -62,6 +62,14 @@ body { color: var(--neon-cyan); } +/* B1 juice telemetry (particles / shake / bloom). */ +.hud-juice { + color: #b6ffcf; + text-shadow: + 0 0 6px rgba(182, 255, 207, 0.55), + 0 0 14px rgba(65, 246, 255, 0.3); +} + /* ---- WebGPU fallback page -------------------------------------------- */ #fallback { diff --git a/src/ui/Hud.ts b/src/ui/Hud.ts index a92caa8..c3f5845 100644 --- a/src/ui/Hud.ts +++ b/src/ui/Hud.ts @@ -12,6 +12,8 @@ export interface HudState { cameraX: number; /** Transient message (menu hints, pause text); null hides it. */ message: string | null; + /** B1 "juice" telemetry line (particles/shake/bloom); null hides it. */ + juiceLine?: string | null; } export interface Hud { @@ -46,6 +48,7 @@ export class DomHud implements Hud { ]; let html = `${parts[0]}${parts.slice(1).join(' · ')}`; if (state.message) html += `${escapeHtml(state.message)}`; + if (state.juiceLine) html += `${escapeHtml(state.juiceLine)}`; this.root.innerHTML = html; } diff --git a/tests/juice.test.ts b/tests/juice.test.ts new file mode 100644 index 0000000..630e6a2 --- /dev/null +++ b/tests/juice.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from 'vitest'; +import { BloomPulse } from '../src/effects/BloomPulse'; +import { HitFlash, ScreenFlash, mixToWhite } from '../src/effects/HitFlash'; +import { JuiceSystem } from '../src/effects/JuiceSystem'; + +const STEP = 1 / 120; + +describe('mixToWhite (sprite hit-flash tint)', () => { + it('is identity at 0 and full white at 1 (alpha preserved)', () => { + const base = [0.2, 0.5, 0.8, 0.9] as const; + const zero = mixToWhite(base, 0); + expect(zero).toEqual([0.2, 0.5, 0.8, 0.9]); + const full = mixToWhite(base, 1); + expect(full).toEqual([1, 1, 1, 0.9]); + }); + + it('interpolates linearly and clamps out-of-range amounts', () => { + const base = [0, 0, 0, 1] as const; + const half = mixToWhite(base, 0.5); + expect(half).toEqual([0.5, 0.5, 0.5, 1]); + expect(mixToWhite(base, 42)[0]).toBe(1); + expect(mixToWhite(base, -3)[0]).toBe(0); + }); + + it('writes into a caller-supplied tuple (allocation-free path)', () => { + const out: [number, number, number, number] = [0, 0, 0, 0]; + const result = mixToWhite([0.5, 0.5, 0.5, 1], 0.5, out); + expect(result).toBe(out); + expect(out[0]).toBeCloseTo(0.75, 12); + }); +}); + +describe('HitFlash envelope', () => { + it('peaks at flash time and decays linearly to zero', () => { + const hf = new HitFlash(0.09); + expect(hf.amount).toBe(0); + hf.flash(); + expect(hf.amount).toBeCloseTo(1, 9); + + hf.update(0.045); // half the duration + expect(hf.amount).toBeCloseTo(0.5, 9); + + for (let i = 0; i < 40; i++) hf.update(STEP); // well past duration + expect(hf.amount).toBe(0); + expect(hf.isActive).toBe(false); + }); + + it('tints sprites toward white by the current amount', () => { + const hf = new HitFlash(0.1); + const base = [0.25, 0.25, 0.25, 1] as const; + hf.flash(0.8); + const tinted = hf.tint(base); + expect(tinted[0]).toBeGreaterThan(0.25); // pulled toward white + for (let i = 0; i < 30; i++) hf.update(STEP); + expect(hf.tint(base)).toEqual([0.25, 0.25, 0.25, 1]); // back to base + }); +}); + +describe('ScreenFlash envelope', () => { + it('fades from peak alpha to zero over its duration', () => { + const sf = new ScreenFlash(0.14); + sf.flash([1, 0, 0, 1], 0.5); + expect(sf.amount).toBeCloseTo(0.5, 9); + expect(sf.color[0]).toBe(1); + + sf.update(0.07); + expect(sf.amount).toBeCloseTo(0.25, 9); + sf.update(0.07); + expect(sf.amount).toBe(0); + }); + + it('keeps the strongest running flash instead of restarting weak ones', () => { + const sf = new ScreenFlash(0.6); + sf.flash([1, 1, 1, 1], 0.9); + sf.update(0.1); + const before = sf.amount; + sf.flash([0, 1, 0, 1], 0.2); // weaker + shorter than what remains + expect(sf.amount).toBe(before); + expect(sf.color[0]).toBe(1); // original color kept + }); + + it('currentColor writes rgb + animated alpha into an out tuple', () => { + const sf = new ScreenFlash(); + sf.flash([0.2, 0.4, 0.9, 1], 0.5); + const out: [number, number, number, number] = [0, 0, 0, 0]; + const c = sf.currentColor(out); + expect(c).toBe(out); + expect(out[0]).toBeCloseTo(0.2, 12); + expect(out[3]).toBeCloseTo(0.5, 9); + }); +}); + +describe('BloomPulse (renderer.setBloomOptions wiring)', () => { + it('pumps intensity toward the peak then restores the baseline exactly once', () => { + const patches: Array> = []; + const bloom = new BloomPulse((patch) => patches.push({ ...patch }), { + baseIntensity: 0.95, + peakIntensity: 2.6, + decaySeconds: 0.4, + }); + + bloom.pulse(1); + bloom.update(STEP); + const active = patches.at(-1) as { intensity?: number } | undefined; + expect(active?.intensity).toBeGreaterThan(2.5); // near-peak right after pulse + + // Drain fully → last patch must restore the baseline. + for (let i = 0; i < Math.ceil(0.5 / STEP); i++) bloom.update(STEP); + expect(bloom.isActive).toBe(false); + const last = patches.at(-1) as { intensity?: number } | undefined; + expect(last?.intensity).toBe(0.95); + // Baseline emitted exactly once at settle. + const baselineCount = patches.filter((p) => (p as { intensity?: number }).intensity === 0.95).length; + expect(baselineCount).toBe(1); + }); + + it('stacks pulses up to full energy and scales with strength', () => { + const small = new BloomPulse(null); + small.pulse(0.1); + expect(small.energyLevel).toBeLessThan(0.5); + + const big = new BloomPulse(null); + big.pulse(1); + expect(big.energyLevel).toBeCloseTo(1, 9); + big.pulse(1); // capped at 1 + expect(big.energyLevel).toBeLessThanOrEqual(1); + }); + + it('works headless without an apply callback', () => { + const bloom = new BloomPulse(null); + bloom.pulse(0.8); + bloom.update(10); + expect(bloom.isActive).toBe(false); + expect(bloom.currentIntensity).toBeCloseTo(0.95, 9); + }); +}); + +describe('JuiceSystem facade (headless integration)', () => { + function makeSystem(): { juice: JuiceSystem; patches: Array<{ intensity?: number }> } { + const patches: Array<{ intensity?: number }> = []; + const juice = new JuiceSystem({ + setBloom: (patch) => patches.push({ ...patch }), + capacity: 512, + }); + return { juice, patches }; + } + + it('routes gameplay events into particles/shake/bloom in one call', () => { + const { juice } = makeSystem(); + + juice.enemyDeath(100, 200); + expect(juice.particles.aliveCount).toBeGreaterThan(0); + expect(juice.shake.trauma).toBeGreaterThan(0); + expect(juice.bloom.isActive).toBe(true); + + const before = juice.particles.aliveCount; + juice.playerDeath(100, 200); + expect(juice.particles.aliveCount).toBeGreaterThan(before); + expect(juice.screenFlash.isActive).toBe(true); + expect(juice.shake.trauma).toBeGreaterThan(0.5); + }); + + it('jump/land drive the shared squash & stretch component', () => { + const { juice } = makeSystem(); + juice.jump(50, 50); + expect(juice.playerSquash.scaleY).toBeGreaterThan(1); // stretched + + for (let i = 0; i < 60; i++) juice.update(STEP); // settle mid-air pose + juice.land(50, 50, 1.5); + expect(juice.playerSquash.scaleX).toBeGreaterThan(1); // squashed wide + expect(juice.playerSquash.scaleY).toBeLessThan(1); + + for (let i = 0; i < Math.round(3 / STEP); i++) juice.update(STEP); + expect(juice.playerSquash.isAtRest).toBe(true); + expect(juice.particles.isEmpty).toBe(true); // dust settled too + }); + + it('hurt() raises the sprite-level hit-flash and decays it', () => { + const { juice } = makeSystem(); + juice.hurt(0, 0); + expect(juice.hitFlash.amount).toBeGreaterThan(0); + for (let i = 0; i < Math.round(0.5 / STEP); i++) juice.update(STEP); + expect(juice.hitFlash.amount).toBe(0); + }); + + it('update() drains everything to idle; statsLine reports live state', () => { + const { juice, patches } = makeSystem(); + juice.explosion(320, 240); + const line = juice.statsLine(); + expect(line).toMatch(/P \d+\/\d+/); + expect(line).toMatch(/shake \d+%/); + expect(line).toMatch(/bloom [\d.]+/); + + for (let i = 0; i < Math.round(4 / STEP); i++) juice.update(STEP); + expect(juice.particles.isEmpty).toBe(true); + expect(juice.shake.trauma).toBe(0); + expect(juice.screenFlash.isActive).toBe(false); + expect(juice.bloom.isActive).toBe(false); + // Bloom was restored to baseline at settle. + expect(patches.at(-1)?.intensity).toBeDefined(); + }); + + it('bossWarning produces a long bloom surge', () => { + const patches: Array<{ intensity?: number }> = []; + const juice = new JuiceSystem({ + setBloom: (patch) => patches.push({ ...patch }), + capacity: 512, + bloom: { decaySeconds: 1 }, // boss warnings surge longer than combat blips + }); + juice.bossWarning(); + let activeFrames = 0; + for (let i = 0; i < Math.round(2 / STEP); i++) { + juice.update(STEP); + if (juice.bloom.isActive) activeFrames++; + } + // Full-energy pulse drains over decaySeconds: active for ~1 s of steps + // (settling exactly when dt covers the remaining energy). + expect(activeFrames).toBeGreaterThanOrEqual(115); + expect(activeFrames).toBeLessThanOrEqual(125); + }); +}); diff --git a/tests/particles.test.ts b/tests/particles.test.ts new file mode 100644 index 0000000..518aacb --- /dev/null +++ b/tests/particles.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; +import { ParticleSystem } from '../src/effects/Particles'; + +/** Fixed step matching the game loop (120 Hz). */ +const STEP = 1 / 120; + +describe('ParticleSystem pool reuse (no allocation after warmup)', () => { + it('keeps a fixed-capacity pool that never grows', () => { + const ps = new ParticleSystem(64); + expect(ps.capacity).toBe(64); + + // Far more emissions than capacity — must recycle, never grow. + for (let i = 0; i < 100; i++) { + ps.burst({ count: 20, x: i, y: 0, speed: 50, life: 0.1 }); + expect(ps.aliveCount).toBeLessThanOrEqual(64); + } + expect(ps.capacity).toBe(64); + }); + + it('reuses the same pool records and draw quads across frames', () => { + const ps = new ParticleSystem(32); + const poolBefore = ps.poolView; + const firstRecord = poolBefore[0]; + + ps.burst({ count: 10, x: 0, y: 0 }); + const drawsA = ps.buildDraws(); + ps.update(STEP); + const drawsB = ps.buildDraws(); + + expect(drawsA).toBe(drawsB); // stable backing array identity + expect(ps.poolView).toBe(poolBefore); // same array object + expect(firstRecord).toBeDefined(); + + // Kill everything and refill — records are recycled objects, not clones. + for (let i = 0; i < 60; i++) ps.update(0.2); // well past max life + expect(ps.isEmpty).toBe(true); + ps.burst({ count: 10, x: 5, y: 5 }); + expect(ps.aliveCount).toBe(10); + let reused = false; + for (const p of ps.poolView) if (p === firstRecord) reused = true; + expect(reused).toBe(true); + }); + + it('caps over-capacity bursts at capacity without dropping below reuse', () => { + const ps = new ParticleSystem(16); + const emitted = ps.burst({ count: 100, x: 0, y: 0, life: 5 }); + expect(emitted).toBe(100); // burst reports requested count + expect(ps.aliveCount).toBe(16); // but the pool stays capped + }); +}); + +describe('ParticleSystem emission & lifetime', () => { + it('presets spawn their documented counts and report what they emitted', () => { + const ps = new ParticleSystem(1024); + const presetCount = (fn: () => number): number => { + const before = ps.aliveCount; + const reported = fn(); + expect(ps.aliveCount - before).toBe(reported); + return reported; + }; + expect(presetCount(() => ps.fragmentPickup(0, 0))).toBe(10); + expect(presetCount(() => ps.enemyDeath(0, 0))).toBe(19); + expect(presetCount(() => ps.playerDeath(0, 0))).toBe(43); + expect(presetCount(() => ps.jumpPuff(0, 0))).toBe(7); + expect(presetCount(() => ps.muzzleFlash(0, 0, Math.PI / 2))).toBe(7); + expect(presetCount(() => ps.explosion(0, 0))).toBe(35); + }); + + it('gives every particle a finite positive life ≤ its maxLife', () => { + const ps = new ParticleSystem(); + ps.burst({ count: 25, x: 0, y: 0, life: [0.3, 0.8] }); + for (let i = 0; i < ps.aliveCount; i++) { + const p = ps.poolView[i]!; + expect(p.life).toBeGreaterThan(0); + expect(p.life).toBeLessThanOrEqual(0.8 + 1e-9); + } + }); + + it('kills all particles once their life has elapsed', () => { + const ps = new ParticleSystem(); + ps.burst({ count: 30, x: 0, y: 0, life: [0.4, 0.6] }); + + // Simulate just under the minimum life → still alive. + for (let i = 0; i < Math.floor(0.39 / STEP); i++) ps.update(STEP); + expect(ps.aliveCount).toBeGreaterThan(0); + + // Simulate past the maximum life → all dead. + for (let i = 0; i < Math.ceil(0.3 / STEP) + 2; i++) ps.update(STEP); + expect(ps.isEmpty).toBe(true); + }); + + it('applies gravity and drag deterministically for a fixed seed', () => { + const make = (): ParticleSystem => new ParticleSystem(128, 12345); + const a = make(); + const b = make(); + + for (const ps of [a, b]) { + ps.burst({ count: 12, x: 0, y: 0, gravity: 900, drag: 2, speed: [100, 300], life: 1 }); + for (let i = 0; i < 60; i++) ps.update(STEP); + } + + expect(a.aliveCount).toBe(b.aliveCount); + for (let i = 0; i < a.aliveCount; i++) { + expect(a.poolView[i]!.x).toBeCloseTo(b.poolView[i]!.x, 9); + expect(a.poolView[i]!.y).toBeCloseTo(b.poolView[i]!.y, 9); + } + }); + + it('builds one additive quad per particle centered on its position', () => { + const ps = new ParticleSystem(); + ps.burst({ count: 5, x: 100, y: 200, size: 8, speed: 0, glow: 1.5 }); + const draws = ps.buildDraws(); + + expect(draws.length).toBe(5); + for (const d of draws) { + expect(d.blend).toBe('additive'); + expect(d.width).toBeLessThanOrEqual(8); + expect(d.x + d.width / 2).toBeGreaterThanOrEqual(96); // jitter ±0 at speed 0 + expect(d.glow).toBeDefined(); + expect(d.glow![3]).toBeGreaterThan(0); + } + }); + + it('landing dust scales with impact', () => { + const light = new ParticleSystem(); + const heavy = new ParticleSystem(1024, 99); + expect(light.landingDust(0, 0, 0)).toBeLessThan(heavy.landingDust(0, 0, 2)); + }); +}); diff --git a/tests/screenShake.test.ts b/tests/screenShake.test.ts new file mode 100644 index 0000000..7bbecd0 --- /dev/null +++ b/tests/screenShake.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_SHAKE_OPTIONS, + ScreenShake, + decayTrauma, + sampleShake, + valueNoise1D, +} from '../src/effects/ScreenShake'; + +describe('trauma decay math', () => { + it('decays linearly by decayPerSecond · dt', () => { + expect(decayTrauma(1, 0.5, 1.4)).toBeCloseTo(0.3, 9); + expect(decayTrauma(0.8, 0.25, 1.5)).toBeCloseTo(0.425, 9); + }); + + it('clamps at zero and never goes negative', () => { + expect(decayTrauma(0.2, 10, 1.5)).toBe(0); + expect(decayTrauma(0, 1, 1.5)).toBe(0); + }); + + it('clamps trauma above 1 and ignores negative dt', () => { + expect(decayTrauma(5, 0, 1.5)).toBe(1); + expect(decayTrauma(0.5, -1, 1.5)).toBe(0.5); + }); + + it('ScreenShake.update matches the pure decay function over time', () => { + const shake = new ScreenShake({ decayPerSecond: 1.5 }); + shake.addTrauma(1); + const dt = 1 / 120; + let expected = 1; + for (let i = 0; i < 120; i++) { + shake.update(dt); + expected = decayTrauma(expected, dt, 1.5); + expect(shake.trauma).toBeCloseTo(expected, 9); + } + // 1 s of decay at 1.5/s drains fully. + expect(shake.trauma).toBeCloseTo(0, 6); + }); +}); + +describe('shake offset sampling', () => { + it('is exactly zero at zero trauma', () => { + for (let t = 0; t < 2; t += 0.13) { + const s = sampleShake(0, t); + expect(s.x).toBe(0); + expect(s.y).toBe(0); + } + }); + + it('scales with the square of trauma (small hits stay gentle)', () => { + const half = sampleShake(0.5, 0.37); + const full = sampleShake(1, 0.37); + // Same noise phase ratio; amplitude ratio must equal (0.5² / 1²) = 0.25. + if (Math.abs(full.x) > 1e-9) { + expect(Math.abs(half.x / full.x)).toBeCloseTo(0.25, 9); + } + expect(Math.abs(half.x)).toBeLessThanOrEqual(Math.abs(full.x) + 1e-12); + }); + + it('is bounded by maxOffset in both axes', () => { + let maxX = 0; + let maxY = 0; + for (let t = 0; t <= 3; t += 1 / 240) { + const s = sampleShake(1, t); + maxX = Math.max(maxX, Math.abs(s.x)); + maxY = Math.max(maxY, Math.abs(s.y)); + } + expect(maxX).toBeLessThanOrEqual(DEFAULT_SHAKE_OPTIONS.maxOffsetX + 1e-9); + expect(maxY).toBeLessThanOrEqual(DEFAULT_SHAKE_OPTIONS.maxOffsetY + 1e-9); + // And actually reaches a meaningful magnitude somewhere (not dead noise). + expect(maxX).toBeGreaterThan(DEFAULT_SHAKE_OPTIONS.maxOffsetX * 0.4); + }); + + it('is deterministic and continuous in time', () => { + const a = sampleShake(0.7, 1.234); + const b = sampleShake(0.7, 1.234); + expect(a.x).toBeCloseTo(b.x, 12); + expect(a.y).toBeCloseTo(b.y, 12); + + let prev = sampleShake(0.7, 0); + for (let t = 1 / 240; t <= 1; t += 1 / 240) { + const s = sampleShake(0.7, t); + // Bounded per-step delta at 240 Hz (max noise slope × amplitude/frequency + // ≈ 22·26·1.875/240 + 16·31·1.875/240 ≈ 8 px) — no teleporting offsets. + expect(Math.abs(s.x - prev.x)).toBeLessThan(10); + prev = s; + } + }); + + it('valueNoise1D stays in [-1, 1] and is continuous', () => { + for (let x = -20; x <= 20; x += 0.05) { + const v = valueNoise1D(x); + expect(v).toBeGreaterThanOrEqual(-1 - 1e-9); + expect(v).toBeLessThanOrEqual(1 + 1e-9); + } + const l = valueNoise1D(4.999); + const r = valueNoise1D(5.001); + expect(Math.abs(r - l)).toBeLessThan(0.01); + }); +}); + +describe('ScreenShake accumulator API', () => { + it('clamps addTrauma to [0, 1]', () => { + const shake = new ScreenShake(); + shake.addTrauma(0.5); + shake.addTrauma(0.9); + expect(shake.trauma).toBe(1); + }); + + it('exposes offsets only after update and resets cleanly', () => { + const shake = new ScreenShake(); + shake.addTrauma(1); + shake.update(1 / 60); + const mag = Math.hypot(shake.offsetX, shake.offsetY); + expect(mag).toBeGreaterThan(0); + + shake.reset(); + expect(shake.trauma).toBe(0); + expect(shake.offsetX).toBe(0); + expect(shake.offsetY).toBe(0); + }); +}); diff --git a/tests/squashStretch.test.ts b/tests/squashStretch.test.ts new file mode 100644 index 0000000..06a8abd --- /dev/null +++ b/tests/squashStretch.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_SQUASH_OPTIONS, + SquashStretch, + springStep, + type SpringState, +} from '../src/effects/SquashStretch'; + +const STEP = 1 / 120; + +function settle(spring: SquashStretch, seconds = 4): void { + const steps = Math.round(seconds / STEP); + for (let i = 0; i < steps; i++) spring.update(STEP); +} + +describe('springStep (pure integrator)', () => { + it('converges to the target and stays there', () => { + const s: SpringState = { value: 0.5, velocity: 0 }; + for (let i = 0; i < 120 * 5; i++) springStep(s, 1, DEFAULT_SQUASH_OPTIONS.stiffness, DEFAULT_SQUASH_OPTIONS.damping, STEP); + expect(s.value).toBeCloseTo(1, 6); + expect(s.velocity).toBeCloseTo(0, 6); + // Settled: further steps change nothing. + const frozen = s.value; + springStep(s, 1, DEFAULT_SQUASH_OPTIONS.stiffness, DEFAULT_SQUASH_OPTIONS.damping, STEP); + expect(s.value).toBeCloseTo(frozen, 12); + }); + + it('overshoots an underdamped impulse before returning', () => { + const s: SpringState = { value: 1, velocity: -6 }; + let maxBelow = 0; + for (let i = 0; i < 240; i++) { + springStep(s, 1, DEFAULT_SQUASH_OPTIONS.stiffness, DEFAULT_SQUASH_OPTIONS.damping, STEP); + maxBelow = Math.min(maxBelow, s.value); + } + expect(maxBelow).toBeLessThan(1 - 1e-3); // actually dipped past target + expect(s.value).toBeCloseTo(1, 6); // ...and came back + }); + + it('is stable under a large single step (no explosion)', () => { + const s: SpringState = { value: 0.35, velocity: 0 }; + springStep(s, 1, DEFAULT_SQUASH_OPTIONS.stiffness, DEFAULT_SQUASH_OPTIONS.damping, 0.25); + expect(Number.isFinite(s.value)).toBe(true); + expect(Math.abs(s.value)).toBeLessThan(10); + }); +}); + +describe('SquashStretch component', () => { + it('starts at rest scale (1, 1)', () => { + const sq = new SquashStretch(); + expect(sq.scaleX).toBe(1); + expect(sq.scaleY).toBe(1); + expect(sq.isAtRest).toBe(true); + }); + + it('squash widens + shortens with volume preserved; springs back', () => { + const sq = new SquashStretch(); + sq.squash(0.4); + expect(sq.scaleX).toBeGreaterThan(1); + expect(sq.scaleY).toBeLessThan(1); + // Reciprocal pairing keeps area ≈ constant. + expect(sq.scaleX * sq.scaleY).toBeCloseTo(1, 6); + + settle(sq); + expect(sq.isAtRest).toBe(true); + expect(sq.scaleX).toBeCloseTo(1, 3); + expect(sq.scaleY).toBeCloseTo(1, 3); + }); + + it('stretch lengthens + narrows; converges within ~2 s', () => { + const sq = new SquashStretch(); + sq.stretch(0.32); + expect(sq.scaleY).toBeGreaterThan(1); + expect(sq.scaleX).toBeLessThan(1); + + for (let i = 0; i < Math.round(2 / STEP); i++) sq.update(STEP); + expect(Math.abs(sq.scaleX - 1)).toBeLessThan(1e-3); + expect(Math.abs(sq.scaleY - 1)).toBeLessThan(1e-3); + expect(sq.isAtRest).toBe(true); + }); + + it('clamps impulses to the deform limit (never inside-out or huge)', () => { + const sq = new SquashStretch(); + sq.squash(50); + sq.stretch(-42); // clamped to 0 amount + expect(sq.scaleX).toBeLessThanOrEqual(1 + DEFAULT_SQUASH_OPTIONS.maxDeform + 1e-9); + expect(sq.scaleY).toBeGreaterThanOrEqual(1 - DEFAULT_SQUASH_OPTIONS.maxDeform - 1e-9); + settle(sq); + expect(sq.isAtRest).toBe(true); + }); + + it('direct impulses clamp and reset returns instantly to rest', () => { + const sq = new SquashStretch(); + sq.impulse(3, Number.NaN); // NaN leaves the Y axis untouched + expect(sq.scaleX).toBeCloseTo(1 + DEFAULT_SQUASH_OPTIONS.maxDeform, 9); + expect(sq.scaleY).toBe(1); + sq.impulse(Number.NaN, -2); // negatives clamp to the deform floor + expect(sq.scaleY).toBeCloseTo(1 - DEFAULT_SQUASH_OPTIONS.maxDeform, 9); + settle(sq); + expect(sq.isAtRest).toBe(true); + sq.reset(); + expect(sq.isAtRest).toBe(true); + }); +});