diff --git a/API.md b/API.md index 8b6a024..f791f4a 100644 --- a/API.md +++ b/API.md @@ -644,9 +644,9 @@ overlays. | `createEnvironmentFrostEffectDefinition` | Edge frost, crystal clusters, and breath mist. | | `createEnvironmentFireEffectDefinition` | Edge flames, embers, smoke, and warm flicker. | | `createEnvironmentUnderwaterEffectDefinition` | Blue-green tint, slow wave distortion, bubbles, and debris. | -| `AtmosphericRainEffect` / `createAtmosphericRainEffect` | Pixel rain streaks, wind slant, and small splashes. | -| `AtmosphericSnowEffect` / `createAtmosphericSnowEffect` | Layered snow drift with optional accumulation. | -| `AtmosphericAshEmberEffect` / `createAtmosphericAshEmberEffect` | Drifting ash plus rising flickering embers. | +| `AtmosphericRainEffect` / `createAtmosphericRainEffect` | Pixel rain streaks, wind slant, small splashes, and optional player-relative motion. | +| `AtmosphericSnowEffect` / `createAtmosphericSnowEffect` | Layered snow drift with optional accumulation and optional player-relative motion. | +| `AtmosphericAshEmberEffect` / `createAtmosphericAshEmberEffect` | Drifting ash plus rising flickering embers with optional player-relative motion. | ```ts const screenEffects = new ScreenEffectManager(); diff --git a/README.md b/README.md index 81b411d..45e0f56 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,8 @@ helper systems could support other arcade-style browser games too. and runtime-boosted presentation settings. - Pixel-art screen effects for camera-surface droplets, player-state feedback, and environmental conditions. -- World-space atmospheric effects for rain, snow, ash, and embers. +- World-space atmospheric effects for rain, snow, ash, and embers, with + optional player-relative motion. - Procedural background starfields with player-relative x/y scrolling and z-axis fly-through motion. - Canvas-rendered FPS performance overlay with target-relative graph coloring. @@ -404,7 +405,9 @@ Atmospheric effects live in `atmospheric-effects.ts` and render between the game world and HUD/screen overlays. Use `createAtmosphericRainEffect`, `createAtmosphericSnowEffect`, or `createAtmosphericAshEmberEffect` when the player should feel inside rain, snow, ash, or embers rather than looking through -a wet or damaged camera surface. +a wet or damaged camera surface. Pass `playerMotion: { enabled: true, ... }` or +call `setPlayerMotion()` when the particles should react to player velocity, +forward movement, or turning. ### Background Stars diff --git a/WHATSNEW.md b/WHATSNEW.md index fc4a84a..03b5c12 100644 --- a/WHATSNEW.md +++ b/WHATSNEW.md @@ -85,7 +85,7 @@ heat, environmental frost, environmental fire, and underwater feedback. - Added atmospheric Canvas 2D effects for rain, snow, ash, and embers, including density presets, wind, splashes, layered flakes, optional snow accumulation, - and ash/ember balancing. + ash/ember balancing, and optional player-relative motion. - Added procedural background stars for generated pixel starfields with player-relative lateral movement and z-axis fly-through/receding motion. - Added `PerformanceSampler` and `FpsOverlay` for Canvas 2D frame telemetry, diff --git a/package-lock.json b/package-lock.json index 96bcd5e..7860f4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "arcade-engine", - "version": "4.5.3", + "version": "4.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "arcade-engine", - "version": "4.5.3", + "version": "4.7.0", "license": "MIT", "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/package.json b/package.json index 5b57b9e..8449793 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "arcade-engine", "description": "A small browser arcade-game engine for canvas games.", - "version": "4.5.3", + "version": "4.7.0", "license": "MIT", "readmeFilename": "README.md", "type": "module", diff --git a/src/README.md b/src/README.md index 15661cf..5ba1109 100644 --- a/src/README.md +++ b/src/README.md @@ -165,7 +165,8 @@ Use it for: speed-boost feedback. - Pixel-snapped environment heat shimmer, frost masks, fire glow, and underwater distortion. -- Pixel-snapped atmospheric rain drops, wind slant, and small splashes. +- Pixel-snapped atmospheric rain drops, wind slant, optional player-relative + motion, and small splashes. - Layered atmospheric snowflakes, wind drift, and optional accumulation. - Drifting ash and rising flickering embers for fire, volcano, industrial, and destroyed-city scenes. diff --git a/src/__tests__/atmospheric-effects.test.ts b/src/__tests__/atmospheric-effects.test.ts index 0ec66a9..7b1e98c 100644 --- a/src/__tests__/atmospheric-effects.test.ts +++ b/src/__tests__/atmospheric-effects.test.ts @@ -9,6 +9,31 @@ import { createCanvasContextMock } from "../test/setup.js"; const createContext = (): CanvasRenderingContext2D => createCanvasContextMock() as unknown as CanvasRenderingContext2D; +type CanvasCall = { + args: unknown[]; + method: string; +}; + +const getFirstFillRectX = (context: CanvasRenderingContext2D): number => { + const calls = (context as unknown as { calls: CanvasCall[] }).calls; + const firstFillRect = calls.find((call) => call.method === "fillRect"); + + if (!firstFillRect || typeof firstFillRect.args[0] !== "number") { + throw new Error("Expected at least one fillRect call with a numeric x position."); + } + + return firstFillRect.args[0]; +}; + +const stepEffect = ( + effect: { update: (deltaTime: number, viewport: { height: number; width: number }) => void }, + count: number +): void => { + for (let index = 0; index < count; index += 1) { + effect.update(0.1, viewport); + } +}; + const viewport = { height: 180, width: 320 }; const tallViewport = { height: 10000, width: 320 }; @@ -101,6 +126,30 @@ describe("atmospheric rain effect", () => { expect(effect.getActiveDropCount()).toBe(170); }); + + it("keeps player-relative rain motion disabled unless the flag is enabled", () => { + const staticRain = createAtmosphericRainEffect({ + maxDrops: 5, + playerMotion: { velocityZ: 240 }, + random: () => 0.25, + spawnRate: 50, + }); + const movingRain = createAtmosphericRainEffect({ + maxDrops: 5, + playerMotion: { enabled: true, velocityZ: 240 }, + random: () => 0.25, + spawnRate: 50, + }); + const staticContext = createContext(); + const movingContext = createContext(); + + stepEffect(staticRain, 3); + stepEffect(movingRain, 3); + staticRain.render(staticContext, viewport); + movingRain.render(movingContext, viewport); + + expect(getFirstFillRectX(movingContext)).toBeLessThan(getFirstFillRectX(staticContext)); + }); }); describe("atmospheric snow effect", () => { @@ -192,6 +241,30 @@ describe("atmospheric snow effect", () => { expect(effect.getActiveFlakeCount()).toBe(160); }); + + it("applies player-relative motion to snow only when enabled", () => { + const staticSnow = createAtmosphericSnowEffect({ + maxFlakes: 1, + playerMotion: { velocityZ: 240 }, + random: () => 0.25, + spawnRate: 10, + }); + const movingSnow = createAtmosphericSnowEffect({ + maxFlakes: 1, + playerMotion: { enabled: true, velocityZ: 240 }, + random: () => 0.25, + spawnRate: 10, + }); + const staticContext = createContext(); + const movingContext = createContext(); + + stepEffect(staticSnow, 7); + stepEffect(movingSnow, 7); + staticSnow.render(staticContext, viewport); + movingSnow.render(movingContext, viewport); + + expect(getFirstFillRectX(movingContext)).toBeLessThan(getFirstFillRectX(staticContext)); + }); }); describe("atmospheric ash and ember effect", () => { @@ -279,4 +352,30 @@ describe("atmospheric ash and ember effect", () => { expect(effect.getActiveParticleCount()).toBe(140); }); + + it("applies player-relative motion to ash and embers only when enabled", () => { + const staticAsh = createAtmosphericAshEmberEffect({ + emberRatio: 0, + maxParticles: 1, + playerMotion: { velocityZ: 240 }, + random: () => 0.25, + spawnRate: 10, + }); + const movingAsh = createAtmosphericAshEmberEffect({ + emberRatio: 0, + maxParticles: 1, + playerMotion: { enabled: true, velocityZ: 240 }, + random: () => 0.25, + spawnRate: 10, + }); + const staticContext = createContext(); + const movingContext = createContext(); + + stepEffect(staticAsh, 7); + stepEffect(movingAsh, 7); + staticAsh.render(staticContext, viewport); + movingAsh.render(movingContext, viewport); + + expect(getFirstFillRectX(movingContext)).toBeLessThan(getFirstFillRectX(staticContext)); + }); }); diff --git a/src/atmospheric-effects.ts b/src/atmospheric-effects.ts index 055b612..d799051 100644 --- a/src/atmospheric-effects.ts +++ b/src/atmospheric-effects.ts @@ -3,18 +3,38 @@ export interface AtmosphericEffectViewport { width: number; } +export interface AtmosphericPlayerMotion { + enabled?: boolean; + influence?: number; + turnVelocity?: number; + velocityX?: number; + velocityY?: number; + velocityZ?: number; +} + +type NormalizedAtmosphericPlayerMotion = { + enabled: boolean; + influence: number; + turnVelocity: number; + velocityX: number; + velocityY: number; + velocityZ: number; +}; + export type AtmosphericRainDensity = "light" | "medium" | "heavy" | "storm"; export interface AtmosphericRainOptions { density?: AtmosphericRainDensity; maxDrops?: number; pixelSize?: number; + playerMotion?: AtmosphericPlayerMotion; random?: () => number; spawnRate?: number; wind?: number; } export type AtmosphericRainDrop = { + depth: number; length: number; speed: number; x: number; @@ -33,6 +53,7 @@ type NormalizedAtmosphericRainOptions = { density: AtmosphericRainDensity; maxDrops: number; pixelSize: number; + playerMotion: NormalizedAtmosphericPlayerMotion; random: () => number; spawnRate: number; wind: number; @@ -95,6 +116,83 @@ const getFiniteNumber = ( return Math.max(minimum, value); }; +const normalizePlayerMotion = ( + motion: AtmosphericPlayerMotion | undefined, + previous?: NormalizedAtmosphericPlayerMotion +): NormalizedAtmosphericPlayerMotion => ({ + enabled: motion?.enabled ?? previous?.enabled ?? false, + influence: getFiniteNumber(motion?.influence, previous?.influence ?? 1), + turnVelocity: getFiniteNumber(motion?.turnVelocity, previous?.turnVelocity ?? 0, -Infinity), + velocityX: getFiniteNumber(motion?.velocityX, previous?.velocityX ?? 0, -Infinity), + velocityY: getFiniteNumber(motion?.velocityY, previous?.velocityY ?? 0, -Infinity), + velocityZ: getFiniteNumber(motion?.velocityZ, previous?.velocityZ ?? 0, -Infinity), +}); + +type AtmosphericMotionParticle = { + depth: number; + x: number; + y: number; +}; + +const applyPlayerMotionToParticle = ( + particle: AtmosphericMotionParticle, + motion: NormalizedAtmosphericPlayerMotion, + viewport: AtmosphericEffectViewport, + delta: number, + depthMultiplier = 1 +): void => { + if (!motion.enabled || motion.influence <= 0) { + return; + } + + const centerX = viewport.width / 2; + const centerY = viewport.height / 2; + const depth = Math.max(0.01, Math.min(1.9, particle.depth)); + const parallax = (0.12 + depth * 1.18) * depthMultiplier * motion.influence; + const forwardScale = motion.velocityZ * 0.012 * parallax * delta; + + particle.x += (particle.x - centerX) * forwardScale; + particle.y += (particle.y - centerY) * forwardScale * 0.18; + particle.x -= motion.velocityX * 0.62 * parallax * delta; + particle.y -= motion.velocityY * 0.46 * parallax * delta; + particle.x -= motion.turnVelocity * (0.72 + depth * 1.15) * depthMultiplier * motion.influence * delta; + particle.depth = Math.max( + 0.01, + Math.min(1.9, particle.depth + motion.velocityZ * 0.0055 * depthMultiplier * motion.influence * delta) + ); +}; + +const getMotionVisibleDepth = (depth: number): number => + Math.max(0, (depth - 0.08) / 0.58); + +const getMotionSpawnSpread = ( + motion: NormalizedAtmosphericPlayerMotion, + viewport: AtmosphericEffectViewport, + multiplier = 1 +): number => { + if (!motion.enabled || motion.velocityZ <= 0) { + return 0; + } + + return viewport.width * multiplier + Math.max(0, motion.velocityZ) * 0.9 * motion.influence; +}; + +const getMotionDensityBoost = ( + motion: NormalizedAtmosphericPlayerMotion, + maximumBoost = 1.1, + forwardScale = 0.0022, + turnScale = 0.0012 +): number => { + if (!motion.enabled || motion.influence <= 0) { + return 1; + } + + const forwardBoost = Math.max(0, motion.velocityZ) * forwardScale; + const turnBoost = Math.abs(motion.turnVelocity) * turnScale; + + return 1 + Math.min(maximumBoost, (forwardBoost + turnBoost) * motion.influence); +}; + const normalizeRainOptions = ( options: AtmosphericRainOptions | undefined ): NormalizedAtmosphericRainOptions => { @@ -108,6 +206,7 @@ const normalizeRainOptions = ( Math.floor(getFiniteNumber(options?.maxDrops, densitySettings.maxDrops, 1)) ), pixelSize: Math.max(1, Math.round(getFiniteNumber(options?.pixelSize, 2, 1))), + playerMotion: normalizePlayerMotion(options?.playerMotion), random: typeof options?.random === "function" ? options.random : Math.random, spawnRate: getFiniteNumber(options?.spawnRate, densitySettings.spawnRate), wind: getFiniteNumber(options?.wind, 0, -Infinity), @@ -139,12 +238,15 @@ export class AtmosphericRainEffect { } setOptions(options: AtmosphericRainOptions): void { + const playerMotion = normalizePlayerMotion(options.playerMotion, this.options.playerMotion); + this.options = normalizeRainOptions({ density: this.options.density, pixelSize: this.options.pixelSize, random: this.options.random, wind: this.options.wind, ...options, + playerMotion, }); if (this.drops.length > this.options.maxDrops) { @@ -152,6 +254,13 @@ export class AtmosphericRainEffect { } } + setPlayerMotion(motion: AtmosphericPlayerMotion): void { + this.options = { + ...this.options, + playerMotion: normalizePlayerMotion(motion, this.options.playerMotion), + }; + } + update(deltaTime: number, viewport: AtmosphericEffectViewport): void { const delta = Math.min(0.1, Math.max(0, deltaTime)); @@ -159,28 +268,47 @@ export class AtmosphericRainEffect { return; } - this.spawnAccumulator += delta * this.options.spawnRate; + const densityBoost = getMotionDensityBoost(this.options.playerMotion); + const maxDrops = Math.ceil(this.options.maxDrops * densityBoost); + + this.spawnAccumulator += delta * this.options.spawnRate * densityBoost; - while (this.spawnAccumulator >= 1 && this.drops.length < this.options.maxDrops) { + while (this.spawnAccumulator >= 1 && this.drops.length < maxDrops) { this.spawnDrop(viewport); this.spawnAccumulator -= 1; } for (let index = this.drops.length - 1; index >= 0; index -= 1) { const drop = this.drops[index]; + const motionFallSpeed = this.options.playerMotion.enabled + ? Math.max(0, this.options.playerMotion.velocityZ) * + (0.28 + drop.depth * 1.45) * + this.options.playerMotion.influence + : 0; drop.x += this.options.wind * delta; - drop.y += drop.speed * delta; + drop.y += (drop.speed + motionFallSpeed) * delta; + applyPlayerMotionToParticle(drop, this.options.playerMotion, viewport, delta); if (drop.y >= viewport.height) { this.spawnSplash(drop, viewport); this.drops.splice(index, 1); + } else if (this.isDropOutside(drop, viewport)) { + this.drops.splice(index, 1); } } for (let index = this.splashes.length - 1; index >= 0; index -= 1) { const splash = this.splashes[index]; + if (this.options.playerMotion.enabled) { + splash.x -= + (this.options.playerMotion.velocityX * 0.28 + + this.options.playerMotion.turnVelocity * 0.48) * + this.options.playerMotion.influence * + delta; + } + splash.age += delta; if (splash.age >= splash.life) { this.splashes.splice(index, 1); @@ -210,11 +338,30 @@ export class AtmosphericRainEffect { private spawnDrop(viewport: AtmosphericEffectViewport): void { const densitySettings = rainDensitySettings[this.options.density]; + const motion = this.options.playerMotion; + const backgroundSpawn = motion.enabled && motion.velocityZ > 0; + const farSpawn = backgroundSpawn && this.options.random() < 0.34; + const motionSpread = getMotionSpawnSpread(motion, viewport, 1.35); const windLead = Math.max(0, Math.abs(this.options.wind) * 0.3); - const x = this.getRandomRange(-windLead, viewport.width + windLead); - const y = this.getRandomRange(-viewport.height * 0.18, -this.options.pixelSize); + const x = farSpawn + ? this.getRandomRange( + viewport.width / 2 - motionSpread, + viewport.width / 2 + motionSpread + ) + : this.getRandomRange(-windLead, viewport.width + windLead); + const y = farSpawn + ? this.getRandomRange(-viewport.height * 1.35, viewport.height * 0.28) + : backgroundSpawn + ? this.getRandomRange(-viewport.height * 0.18, viewport.height * 0.98) + : this.getRandomRange(-viewport.height * 0.18, -this.options.pixelSize); + const depth = farSpawn + ? this.getRandomRange(0.01, 0.18) + : backgroundSpawn + ? this.getRandomRange(0.34, 1.02) + : this.getRandomRange(0.08, 0.92); this.drops.push({ + depth, length: this.getRandomRange(2, 8), speed: this.getRandomRange(densitySettings.speedMin, densitySettings.speedMax), x: this.snap(x), @@ -248,13 +395,35 @@ export class AtmosphericRainEffect { ): void { const x = this.snap(drop.x); const y = this.snap(drop.y); - const length = Math.max(2, Math.round(drop.length)); + const motion = this.options.playerMotion; + const visibleDepth = motion.enabled ? getMotionVisibleDepth(drop.depth) : 1; + + if (motion.enabled && visibleDepth <= 0) { + return; + } + + const forwardStretch = motion.enabled + ? Math.min(4.2, Math.abs(motion.velocityZ) * 0.01 * drop.depth) + : 0; + const depthScale = motion.enabled ? 0.65 + drop.depth * 1.25 + forwardStretch : 1; + const length = Math.max(2, Math.round(drop.length * depthScale)); const segments = Math.max(1, Math.ceil(length / pixel)); - const slant = Math.max(-pixel * 3, Math.min(pixel * 3, this.options.wind * 0.016)); + const motionSlant = motion.enabled + ? -motion.turnVelocity * 0.01 - motion.velocityX * 0.006 + : 0; + const slant = Math.max( + -pixel * 6, + Math.min(pixel * 6, this.options.wind * 0.016 + motionSlant) + ); + const alphaBoost = motion.enabled + ? Math.min(0.36, drop.depth * 0.18 + Math.abs(motion.velocityZ) * 0.00055) + : 0; + const depthFade = motion.enabled ? Math.min(1, visibleDepth) : 1; - context.fillStyle = this.options.density === "storm" - ? "rgba(156, 190, 210, 0.58)" - : "rgba(142, 178, 198, 0.48)"; + context.fillStyle = + this.options.density === "storm" + ? `rgba(156, 190, 210, ${(0.58 + alphaBoost) * depthFade})` + : `rgba(142, 178, 198, ${(0.48 + alphaBoost) * depthFade})`; for (let index = 0; index < segments; index += 1) { const progress = segments <= 1 ? 0 : index / (segments - 1); @@ -265,6 +434,23 @@ export class AtmosphericRainEffect { } } + private isDropOutside( + drop: AtmosphericRainDrop, + viewport: AtmosphericEffectViewport + ): boolean { + const motion = this.options.playerMotion; + const margin = motion.enabled + ? Math.max(this.options.pixelSize * 12, getMotionSpawnSpread(motion, viewport, 1.55)) + : this.options.pixelSize * 12; + + return ( + drop.x < -margin || + drop.x > viewport.width + margin || + drop.y < (motion.enabled ? -viewport.height * 4 : -viewport.height * 0.3) || + (motion.enabled && motion.velocityZ < 0 && drop.depth <= 0.05) + ); + } + private renderSplash( context: CanvasRenderingContext2D, splash: AtmosphericRainSplash, @@ -310,6 +496,7 @@ export interface AtmosphericSnowOptions { density?: AtmosphericSnowDensity; maxFlakes?: number; pixelSize?: number; + playerMotion?: AtmosphericPlayerMotion; random?: () => number; spawnRate?: number; wind?: number; @@ -333,6 +520,7 @@ type NormalizedAtmosphericSnowOptions = { density: AtmosphericSnowDensity; maxFlakes: number; pixelSize: number; + playerMotion: NormalizedAtmosphericPlayerMotion; random: () => number; spawnRate: number; wind: number; @@ -410,6 +598,7 @@ const normalizeSnowOptions = ( Math.floor(getFiniteNumber(options?.maxFlakes, densitySettings.maxFlakes, 1)) ), pixelSize: Math.max(1, Math.round(getFiniteNumber(options?.pixelSize, 2, 1))), + playerMotion: normalizePlayerMotion(options?.playerMotion), random: typeof options?.random === "function" ? options.random : Math.random, spawnRate: getFiniteNumber(options?.spawnRate, densitySettings.spawnRate), wind: getFiniteNumber(options?.wind, windFallback, -Infinity), @@ -443,6 +632,8 @@ export class AtmosphericSnowEffect { } setOptions(options: AtmosphericSnowOptions): void { + const playerMotion = normalizePlayerMotion(options.playerMotion, this.options.playerMotion); + this.options = normalizeSnowOptions({ accumulationEnabled: this.options.accumulationEnabled, accumulationLimit: this.options.accumulationLimit, @@ -452,6 +643,7 @@ export class AtmosphericSnowEffect { random: this.options.random, wind: this.options.wind, ...options, + playerMotion, }); if (this.flakes.length > this.options.maxFlakes) { @@ -461,6 +653,13 @@ export class AtmosphericSnowEffect { this.accumulation = Math.min(this.accumulation, this.options.accumulationLimit); } + setPlayerMotion(motion: AtmosphericPlayerMotion): void { + this.options = { + ...this.options, + playerMotion: normalizePlayerMotion(motion, this.options.playerMotion), + }; + } + update(deltaTime: number, viewport: AtmosphericEffectViewport): void { const delta = Math.min(0.1, Math.max(0, deltaTime)); @@ -469,9 +668,12 @@ export class AtmosphericSnowEffect { } this.time += delta; - this.spawnAccumulator += delta * this.options.spawnRate; + const densityBoost = getMotionDensityBoost(this.options.playerMotion, 1.85, 0.0032, 0.0017); + const maxFlakes = Math.ceil(this.options.maxFlakes * densityBoost); - while (this.spawnAccumulator >= 1 && this.flakes.length < this.options.maxFlakes) { + this.spawnAccumulator += delta * this.options.spawnRate * densityBoost; + + while (this.spawnAccumulator >= 1 && this.flakes.length < maxFlakes) { this.spawnFlake(viewport); this.spawnAccumulator -= 1; } @@ -480,14 +682,20 @@ export class AtmosphericSnowEffect { const flake = this.flakes[index]; const depthSpeed = 0.48 + flake.depth * 0.72; const sway = Math.sin(this.time * flake.sway + flake.seed * 12) * this.options.pixelSize; + const motionFallSpeed = this.options.playerMotion.enabled + ? Math.max(0, this.options.playerMotion.velocityZ) * + (0.08 + flake.depth * 0.62) * + this.options.playerMotion.influence + : 0; flake.x += (this.options.wind * (0.2 + flake.depth * 0.75) + sway * 8) * delta; - flake.y += flake.speed * depthSpeed * delta; + flake.y += (flake.speed * depthSpeed + motionFallSpeed) * delta; + applyPlayerMotionToParticle(flake, this.options.playerMotion, viewport, delta, 0.75); if (flake.y >= viewport.height - this.accumulation) { this.collectFlake(flake); this.flakes.splice(index, 1); - } else if (flake.x < -viewport.width * 0.15 || flake.x > viewport.width * 1.15) { + } else if (this.isFlakeOutside(flake, viewport)) { this.flakes.splice(index, 1); } } @@ -550,8 +758,16 @@ export class AtmosphericSnowEffect { ): void { const x = this.snap(flake.x); const y = this.snap(flake.y); - const alpha = 0.22 + flake.depth * 0.48; - const size = Math.max(pixel, this.snap(flake.size)); + const motion = this.options.playerMotion; + const visibleDepth = motion.enabled ? getMotionVisibleDepth(flake.depth) : 1; + + if (motion.enabled && visibleDepth <= 0) { + return; + } + + const alpha = (0.22 + flake.depth * 0.48) * Math.min(1, visibleDepth); + const motionSize = motion.enabled ? 0.8 + flake.depth * 0.65 : 1; + const size = Math.max(pixel, this.snap(flake.size * motionSize)); context.fillStyle = `rgba(230, 246, 250, ${alpha})`; @@ -577,10 +793,27 @@ export class AtmosphericSnowEffect { private spawnFlake(viewport: AtmosphericEffectViewport): void { const densitySettings = snowDensitySettings[this.options.density]; - const depth = this.options.random(); + const motion = this.options.playerMotion; + const backgroundSpawn = motion.enabled && motion.velocityZ > 0; + const farSpawn = backgroundSpawn && this.options.random() < 0.24; + const depth = farSpawn + ? this.getRandomRange(0.01, 0.16) + : backgroundSpawn + ? this.getRandomRange(0.32, 1.05) + : this.options.random(); + const motionSpread = getMotionSpawnSpread(motion, viewport, 1.45); const windLead = Math.max(0, Math.abs(this.options.wind) * 0.6); - const x = this.getRandomRange(-windLead, viewport.width + windLead); - const y = this.getRandomRange(-viewport.height * 0.16, -this.options.pixelSize); + const x = farSpawn + ? this.getRandomRange( + viewport.width / 2 - motionSpread, + viewport.width / 2 + motionSpread + ) + : this.getRandomRange(-windLead, viewport.width + windLead); + const y = farSpawn + ? this.getRandomRange(-viewport.height * 0.95, viewport.height * 0.42) + : backgroundSpawn + ? this.getRandomRange(-viewport.height * 0.16, viewport.height * 0.96) + : this.getRandomRange(-viewport.height * 0.16, -this.options.pixelSize); const baseSize = this.options.pixelSize * (depth > 0.66 ? 2 : 1); this.flakes.push({ @@ -595,6 +828,26 @@ export class AtmosphericSnowEffect { }); } + private isFlakeOutside( + flake: AtmosphericSnowFlake, + viewport: AtmosphericEffectViewport + ): boolean { + if (!this.options.playerMotion.enabled) { + return flake.x < -viewport.width * 0.15 || flake.x > viewport.width * 1.15; + } + + const margin = this.options.pixelSize * 18; + const motionMargin = getMotionSpawnSpread(this.options.playerMotion, viewport, 1.55); + const horizontalMargin = Math.max(margin, motionMargin); + + return ( + flake.x < -horizontalMargin || + flake.x > viewport.width + horizontalMargin || + flake.y < -viewport.height * 3 || + (this.options.playerMotion.velocityZ < 0 && flake.depth <= 0.05) + ); + } + private getRandomRange(minimum: number, maximum: number): number { return minimum + (maximum - minimum) * this.options.random(); } @@ -615,6 +868,7 @@ export interface AtmosphericAshEmberOptions { intensity?: AtmosphericAshEmberIntensity; maxParticles?: number; pixelSize?: number; + playerMotion?: AtmosphericPlayerMotion; random?: () => number; spawnRate?: number; wind?: number; @@ -637,6 +891,7 @@ type NormalizedAtmosphericAshEmberOptions = { intensity: AtmosphericAshEmberIntensity; maxParticles: number; pixelSize: number; + playerMotion: NormalizedAtmosphericPlayerMotion; random: () => number; spawnRate: number; wind: number; @@ -707,6 +962,7 @@ const normalizeAshEmberOptions = ( Math.floor(getFiniteNumber(options?.maxParticles, intensitySettings.maxParticles, 1)) ), pixelSize: Math.max(1, Math.round(getFiniteNumber(options?.pixelSize, 2, 1))), + playerMotion: normalizePlayerMotion(options?.playerMotion), random: typeof options?.random === "function" ? options.random : Math.random, spawnRate: getFiniteNumber(options?.spawnRate, intensitySettings.spawnRate), wind: getFiniteNumber(options?.wind, intensitySettings.wind, -Infinity), @@ -742,12 +998,15 @@ export class AtmosphericAshEmberEffect { } setOptions(options: AtmosphericAshEmberOptions): void { + const playerMotion = normalizePlayerMotion(options.playerMotion, this.options.playerMotion); + this.options = normalizeAshEmberOptions({ intensity: this.options.intensity, pixelSize: this.options.pixelSize, random: this.options.random, wind: this.options.wind, ...options, + playerMotion, }); if (this.particles.length > this.options.maxParticles) { @@ -755,6 +1014,13 @@ export class AtmosphericAshEmberEffect { } } + setPlayerMotion(motion: AtmosphericPlayerMotion): void { + this.options = { + ...this.options, + playerMotion: normalizePlayerMotion(motion, this.options.playerMotion), + }; + } + update(deltaTime: number, viewport: AtmosphericEffectViewport): void { const delta = Math.min(0.1, Math.max(0, deltaTime)); @@ -763,9 +1029,12 @@ export class AtmosphericAshEmberEffect { } this.time += delta; - this.spawnAccumulator += delta * this.options.spawnRate; + const densityBoost = getMotionDensityBoost(this.options.playerMotion, 1.75, 0.003, 0.0016); + const maxParticles = Math.ceil(this.options.maxParticles * densityBoost); - while (this.spawnAccumulator >= 1 && this.particles.length < this.options.maxParticles) { + this.spawnAccumulator += delta * this.options.spawnRate * densityBoost; + + while (this.spawnAccumulator >= 1 && this.particles.length < maxParticles) { this.spawnParticle(viewport); this.spawnAccumulator -= 1; } @@ -785,6 +1054,14 @@ export class AtmosphericAshEmberEffect { particle.y += Math.sin(this.time * 0.9 + particle.seed * 9) * particle.speed * 0.08 * delta; particle.x += (this.options.wind * (0.18 + particle.depth * 0.34) + wobble * 7) * delta; } + if (this.options.playerMotion.enabled) { + particle.y += + Math.max(0, this.options.playerMotion.velocityZ) * + (0.04 + particle.depth * 0.34) * + this.options.playerMotion.influence * + delta; + } + applyPlayerMotionToParticle(particle, this.options.playerMotion, viewport, delta, 0.72); if (particle.age >= particle.life || this.isOutside(particle, viewport)) { this.particles.splice(index, 1); @@ -820,13 +1097,21 @@ export class AtmosphericAshEmberEffect { particle: AtmosphericAshEmberParticle, viewport: AtmosphericEffectViewport ): boolean { - const margin = this.options.pixelSize * 8; + const margin = this.options.playerMotion.enabled + ? Math.max( + this.options.pixelSize * 18, + getMotionSpawnSpread(this.options.playerMotion, viewport, 1.5) + ) + : this.options.pixelSize * 8; return ( particle.x < -margin || particle.x > viewport.width + margin || - particle.y < -margin || - particle.y > viewport.height + margin + particle.y < (this.options.playerMotion.enabled ? -viewport.height * 3 : -margin) || + particle.y > viewport.height + margin || + (this.options.playerMotion.enabled && + this.options.playerMotion.velocityZ < 0 && + particle.depth <= 0.05) ); } @@ -836,12 +1121,21 @@ export class AtmosphericAshEmberEffect { pixel: number ): void { const progress = Math.min(1, particle.age / particle.life); - const alpha = (1 - progress) * (0.18 + particle.depth * 0.22); + const visibleDepth = this.options.playerMotion.enabled + ? getMotionVisibleDepth(particle.depth) + : 1; + + if (this.options.playerMotion.enabled && visibleDepth <= 0) { + return; + } + + const alpha = (1 - progress) * (0.18 + particle.depth * 0.22) * Math.min(1, visibleDepth); const x = this.snap(particle.x); const y = this.snap(particle.y); + const size = this.options.playerMotion.enabled && particle.depth > 1.05 ? pixel * 2 : pixel; context.fillStyle = `rgba(74, 76, 76, ${alpha})`; - context.fillRect(x, y, pixel, pixel); + context.fillRect(x, y, size, pixel); if (particle.seed > 0.34) { context.fillRect(x + pixel, y, pixel, pixel); @@ -859,10 +1153,23 @@ export class AtmosphericAshEmberEffect { ): void { const progress = Math.min(1, particle.age / particle.life); const flicker = 0.58 + Math.sin(this.time * 18 + particle.seed * 20) * 0.28; - const alpha = Math.max(0, 1 - progress) * (0.35 + particle.depth * 0.35) * flicker; + const visibleDepth = this.options.playerMotion.enabled + ? getMotionVisibleDepth(particle.depth) + : 1; + + if (this.options.playerMotion.enabled && visibleDepth <= 0) { + return; + } + + const alpha = + Math.max(0, 1 - progress) * + (0.35 + particle.depth * 0.35) * + flicker * + Math.min(1, visibleDepth); const x = this.snap(particle.x); const y = this.snap(particle.y); - const size = Math.max(pixel, this.snap(particle.size)); + const motionSize = this.options.playerMotion.enabled ? 0.85 + particle.depth * 0.8 : 1; + const size = Math.max(pixel, this.snap(particle.size * motionSize)); context.fillStyle = `rgba(236, 76, 28, ${alpha})`; context.fillRect(x, y, size, pixel); @@ -877,23 +1184,44 @@ export class AtmosphericAshEmberEffect { private spawnParticle(viewport: AtmosphericEffectViewport): void { const isEmber = this.options.random() < this.options.emberRatio; + const motion = this.options.playerMotion; + const backgroundSpawn = motion.enabled && motion.velocityZ > 0; + const farSpawn = backgroundSpawn && this.options.random() < 0.22; const depth = isEmber - ? this.getRandomRange(0.45, 1) - : this.getRandomRange(0.05, 0.72); + ? farSpawn + ? this.getRandomRange(0.01, 0.18) + : backgroundSpawn + ? this.getRandomRange(0.42, 1.08) + : this.getRandomRange(0.45, 1) + : farSpawn + ? this.getRandomRange(0.01, 0.16) + : backgroundSpawn + ? this.getRandomRange(0.3, 0.9) + : this.getRandomRange(0.05, 0.72); const pixel = this.options.pixelSize; + const motionSpread = getMotionSpawnSpread(motion, viewport, 1.4); const windLead = Math.max(0, Math.abs(this.options.wind) * 0.3); - const x = this.getRandomRange(-windLead, viewport.width + windLead); + const x = farSpawn + ? this.getRandomRange( + viewport.width / 2 - motionSpread, + viewport.width / 2 + motionSpread + ) + : this.getRandomRange(-windLead, viewport.width + windLead); const y = isEmber - ? this.getRandomRange(viewport.height * 0.65, viewport.height + pixel * 6) - : this.getRandomRange(0, viewport.height); + ? farSpawn + ? this.getRandomRange(viewport.height * 0.05, viewport.height + pixel * 10) + : this.getRandomRange(viewport.height * 0.65, viewport.height + pixel * 6) + : farSpawn + ? this.getRandomRange(-viewport.height * 0.85, viewport.height * 1.1) + : this.getRandomRange(0, viewport.height); this.particles.push({ age: 0, depth, kind: isEmber ? "ember" : "ash", life: isEmber - ? this.getRandomRange(0.75, 1.8) - : this.getRandomRange(2.4, 5.2), + ? this.getRandomRange(0.75, 1.8) * (farSpawn ? 1.8 : 1) + : this.getRandomRange(2.4, 5.2) * (farSpawn ? 1.45 : 1), seed: this.options.random(), size: pixel * (isEmber && depth > 0.72 ? 2 : 1), speed: isEmber diff --git a/src/index.ts b/src/index.ts index c90712d..c0cbfde 100644 --- a/src/index.ts +++ b/src/index.ts @@ -315,6 +315,7 @@ export type { AtmosphericAshEmberOptions, AtmosphericAshEmberParticle, AtmosphericEffectViewport, + AtmosphericPlayerMotion, AtmosphericRainDensity, AtmosphericRainDrop, AtmosphericRainOptions, diff --git a/src/stories/systems/ComboEffects.stories.ts b/src/stories/systems/ComboEffects.stories.ts new file mode 100644 index 0000000..5545f91 --- /dev/null +++ b/src/stories/systems/ComboEffects.stories.ts @@ -0,0 +1,17 @@ +import type { Meta } from "@storybook/html-vite"; + +import { + FireAshCombo as FireAshComboStory, + FrostSnowCombo as FrostSnowComboStory, + RainCombo as RainComboStory, +} from "./systems-demos.js"; + +const meta = { + title: "Engine/Systems/Combo Effects", +} satisfies Meta; + +export default meta; + +export const RainCombo = RainComboStory; +export const FireAshCombo = FireAshComboStory; +export const FrostSnowCombo = FrostSnowComboStory; diff --git a/src/stories/systems/README.md b/src/stories/systems/README.md index 382f740..da33588 100644 --- a/src/stories/systems/README.md +++ b/src/stories/systems/README.md @@ -148,9 +148,17 @@ The stories demonstrate: - Pixel-snapped falling rain streaks. - Layered pixel snowflakes with soft drift and sway. - Slow drifting ash and rising flickering embers. +- Optional player-relative motion so weather reacts to forward movement and + turning. - Density and wind controls. - Small bottom-edge splashes. - Optional bottom-edge snow accumulation. + +## 🧪 Combo Effects + +The Combo Effects stories layer atmospheric world effects below player-facing +screen effects, such as rain with screen droplets, fire with ash and embers, and +frost with snow. - Rendering above the world and below HUD-style overlays. Use this pattern when the player should feel inside a weather condition without diff --git a/src/stories/systems/systems-demos.ts b/src/stories/systems/systems-demos.ts index 1930f45..1efd65c 100644 --- a/src/stories/systems/systems-demos.ts +++ b/src/stories/systems/systems-demos.ts @@ -51,6 +51,7 @@ import { type AchievementDefinition, type AchievementState, type AtmosphericAshEmberIntensity, + type AtmosphericPlayerMotion, type AtmosphericRainDensity, type AtmosphericSnowDensity, type HighScoreEntry, @@ -77,6 +78,7 @@ import { import { drawFpsDemoScene } from "../fps-demo-scene.js"; type DemoAchievementId = "first-sortie" | "wave-breaker" | "precision-run"; +type AtmosphericMotionPreset = "still" | "walk-forward" | "turn-left" | "turn-right" | "patrol-loop"; type SystemsStoryArgs = { baseScoreBudget?: number; @@ -89,6 +91,8 @@ type SystemsStoryArgs = { onAchievementNotification?: (name: string) => void; onAchievementReset?: () => void; onAchievementUnlock?: (id: DemoAchievementId) => void; + comboMotionEnabled?: boolean; + comboMotionPreset?: AtmosphericMotionPreset; onAshEmberChange?: ( intensity: AtmosphericAshEmberIntensity, wind: number, @@ -100,7 +104,13 @@ type SystemsStoryArgs = { onHighScoreSave?: (entry: HighScoreEntry) => void; onHighScoreTamper?: (accepted: boolean, label: string) => void; onHighScoreValidate?: (accepted: boolean, label: string) => void; - onRainChange?: (density: AtmosphericRainDensity, wind: number) => void; + onRainChange?: ( + density: AtmosphericRainDensity, + wind: number, + motionPreset: AtmosphericMotionPreset, + motionEnabled: boolean + ) => void; + onAtmosphericMotionChange?: (effect: string, preset: AtmosphericMotionPreset, enabled: boolean) => void; onScreenEffectChange?: (intensity: number) => void; onSnowChange?: ( density: AtmosphericSnowDensity, @@ -109,11 +119,17 @@ type SystemsStoryArgs = { ) => void; onStarfieldMotionChange?: (label: string) => void; rainDensity?: AtmosphericRainDensity; + rainMotionEnabled?: boolean; + rainMotionPreset?: AtmosphericMotionPreset; rainWind?: number; + snowMotionEnabled?: boolean; + snowMotionPreset?: AtmosphericMotionPreset; screenEffectIntensity?: number; snowAccumulationEnabled?: boolean; snowDensity?: AtmosphericSnowDensity; snowWind?: number; + ashEmberMotionEnabled?: boolean; + ashEmberMotionPreset?: AtmosphericMotionPreset; onUserOptionsChange?: (options: Record) => void; precisionGoal?: number; waveGoal?: number; @@ -1163,6 +1179,30 @@ type ScreenEffectStoryOptions = { title: string; }; +type AtmosphericComboEffect = { + clear: () => void; + render: (context: CanvasRenderingContext2D, viewport: { height: number; width: number }) => void; + setPlayerMotion: (motion: AtmosphericPlayerMotion) => void; + update: (deltaTime: number, viewport: { height: number; width: number }) => void; +}; + +type ComboEffectStoryOptions = { + atmosphericLabel: string; + createAtmosphericEffect: () => AtmosphericComboEffect; + getAtmosphericMetric: (effect: AtmosphericComboEffect) => string; + heavyLabel: string; + heavyScreenIntensity: number; + lightLabel: string; + lightScreenIntensity: number; + screenEffectId: string; + screenSettings?: Record; + setClearAtmosphericPreset: (effect: AtmosphericComboEffect) => void; + setHeavyAtmosphericPreset: (effect: AtmosphericComboEffect) => void; + setLightAtmosphericPreset: (effect: AtmosphericComboEffect) => void; + theme?: "sciFi" | "concrete" | "industrial"; + title: string; +}; + const screenDropletStorySettings = { focusMode: "arcade", gravity: 96, @@ -1176,6 +1216,148 @@ const screenDropletStorySettings = { trailLength: 12, }; +const createComboEffectStory = ({ + atmosphericLabel, + createAtmosphericEffect, + getAtmosphericMetric, + heavyLabel, + heavyScreenIntensity, + lightLabel, + lightScreenIntensity, + screenEffectId, + screenSettings, + setClearAtmosphericPreset, + setHeavyAtmosphericPreset, + setLightAtmosphericPreset, + theme = "sciFi", + title, +}: ComboEffectStoryOptions): Story => ({ + args: { + comboMotionEnabled: true, + comboMotionPreset: "patrol-loop", + onAtmosphericMotionChange: fn(), + onScreenEffectChange: fn(), + screenEffectIntensity: lightScreenIntensity, + }, + argTypes: { + comboMotionEnabled: { + control: "boolean", + name: "Player motion", + }, + comboMotionPreset: { + control: "select", + name: "Motion preset", + options: ["still", "walk-forward", "turn-left", "turn-right", "patrol-loop"], + }, + screenEffectIntensity: { + control: { max: 1, min: 0, step: 0.05, type: "range" }, + name: "Screen intensity", + }, + }, + render: (args) => { + const { canvas, metrics, shell } = createSystemsLayout(title); + const context = canvas.getContext("2d"); + const screenValue = createValue("screen"); + const motionValue = createValue("motion"); + const atmosphericValue = createValue(atmosphericLabel); + const usesValue = createValue("uses", "ScreenEffectManager + atmospheric effect"); + const manager = new ScreenEffectManager(); + const atmosphericEffect = createAtmosphericEffect(); + let screenIntensity = args.screenEffectIntensity ?? lightScreenIntensity; + let motionEnabled = args.comboMotionEnabled ?? true; + let motionPreset = args.comboMotionPreset ?? "patrol-loop"; + let animationFrame = 0; + let lastTime = performance.now(); + + manager.enable(screenEffectId, { + fadeMs: 0, + intensity: screenIntensity, + settings: screenSettings, + }); + setLightAtmosphericPreset(atmosphericEffect); + + const setCombo = ( + nextScreenIntensity: number, + configureAtmosphere: (effect: AtmosphericComboEffect) => void + ): void => { + screenIntensity = Math.min(1, Math.max(0, nextScreenIntensity)); + manager.setIntensity(screenEffectId, screenIntensity, 260); + configureAtmosphere(atmosphericEffect); + setValue(screenValue, screenIntensity.toFixed(2)); + setValue(atmosphericValue, getAtmosphericMetric(atmosphericEffect)); + args.onScreenEffectChange?.(screenIntensity); + }; + + const setMotion = ( + nextMotionPreset = motionPreset, + nextMotionEnabled = motionEnabled + ): void => { + motionPreset = nextMotionPreset; + motionEnabled = nextMotionEnabled; + setValue(motionValue, motionEnabled ? motionPreset : "disabled"); + args.onAtmosphericMotionChange?.(title, motionPreset, motionEnabled); + }; + + const controls = document.createElement("div"); + controls.className = "ae-controls"; + controls.append( + createButton(lightLabel, () => setCombo(lightScreenIntensity, setLightAtmosphericPreset)), + createButton(heavyLabel, () => setCombo(heavyScreenIntensity, setHeavyAtmosphericPreset)), + createButton("Clear", () => setCombo(0, setClearAtmosphericPreset)), + createButton("Walk", () => setMotion("walk-forward", true)), + createButton("Turn Left", () => setMotion("turn-left", true)), + createButton("Turn Right", () => setMotion("turn-right", true)), + createButton("Patrol Loop", () => setMotion("patrol-loop", true)), + createButton("Motion Off", () => setMotion(motionPreset, false)) + ); + metrics.append(screenValue, motionValue, atmosphericValue, usesValue, controls); + setCombo(screenIntensity, setLightAtmosphericPreset); + setMotion(motionPreset, motionEnabled); + + const render = (): void => { + if (!context) { + return; + } + + const now = performance.now(); + const delta = Math.min(0.05, (now - lastTime) / 1000); + const viewport = { height: canvas.height, width: canvas.width }; + + lastTime = now; + atmosphericEffect.setPlayerMotion({ + enabled: motionEnabled, + ...getAtmosphericMotionPreset(motionPreset, now), + }); + drawFpsDemoScene(context, { + height: canvas.height, + pixelScale: 3, + routeSpeed: 1.2, + theme, + timeMs: now, + width: canvas.width, + }); + + atmosphericEffect.update(delta, viewport); + atmosphericEffect.render(context, viewport); + manager.update(delta, viewport); + manager.render(context, viewport); + + setValue(screenValue, screenIntensity.toFixed(2)); + setValue(atmosphericValue, getAtmosphericMetric(atmosphericEffect)); + animationFrame = window.requestAnimationFrame(render); + }; + + animationFrame = window.requestAnimationFrame(render); + onRemove(shell, () => { + window.cancelAnimationFrame(animationFrame); + atmosphericEffect.clear(); + manager.clear(); + }); + + return shell; + }, +}); + const createScreenEffectStory = ({ effectId, heavyLabel, @@ -1336,10 +1518,191 @@ export const ScreenSpeedBoost: Story = createScreenEffectStory({ title: "Speed boost", }); +export const RainCombo: Story = createComboEffectStory({ + atmosphericLabel: "rain", + createAtmosphericEffect: () => + createAtmosphericRainEffect({ + density: "medium", + pixelSize: 2, + spawnRate: 120, + wind: 52, + }), + getAtmosphericMetric: (effect) => { + const rain = effect as ReturnType; + + return `${rain.getActiveDropCount()} drops`; + }, + heavyLabel: "Storm Soaked", + heavyScreenIntensity: 1, + lightLabel: "Wet Lens", + lightScreenIntensity: 0.22, + screenEffectId: screenDropletsEffectId, + screenSettings: screenDropletStorySettings, + setClearAtmosphericPreset: (effect) => { + const rain = effect as ReturnType; + + rain.setOptions({ density: "light", spawnRate: 0, wind: 0 }); + rain.clear(); + }, + setHeavyAtmosphericPreset: (effect) => { + const rain = effect as ReturnType; + + rain.setOptions({ density: "storm", spawnRate: 240, wind: 120 }); + }, + setLightAtmosphericPreset: (effect) => { + const rain = effect as ReturnType; + + rain.setOptions({ density: "medium", spawnRate: 120, wind: 52 }); + }, + title: "Rain combo", +}); + +export const FireAshCombo: Story = createComboEffectStory({ + atmosphericLabel: "air", + createAtmosphericEffect: () => + createAtmosphericAshEmberEffect({ + emberRatio: 0.32, + intensity: "burning", + pixelSize: 2, + spawnRate: 72, + wind: 28, + }), + getAtmosphericMetric: (effect) => { + const ashAndEmbers = effect as ReturnType; + + return `${ashAndEmbers.getActiveAshCount()} ash / ${ashAndEmbers.getActiveEmberCount()} embers`; + }, + heavyLabel: "Burning Air", + heavyScreenIntensity: 0.92, + lightLabel: "Near Fire", + lightScreenIntensity: 0.42, + screenEffectId: screenFireEffectId, + setClearAtmosphericPreset: (effect) => { + const ashAndEmbers = effect as ReturnType; + + ashAndEmbers.setOptions({ emberRatio: 0, intensity: "smolder", spawnRate: 0, wind: 0 }); + ashAndEmbers.clear(); + }, + setHeavyAtmosphericPreset: (effect) => { + const ashAndEmbers = effect as ReturnType; + + ashAndEmbers.setOptions({ + emberRatio: 0.48, + intensity: "inferno", + spawnRate: 150, + wind: 68, + }); + }, + setLightAtmosphericPreset: (effect) => { + const ashAndEmbers = effect as ReturnType; + + ashAndEmbers.setOptions({ + emberRatio: 0.32, + intensity: "burning", + spawnRate: 72, + wind: 28, + }); + }, + theme: "industrial", + title: "Fire and ash combo", +}); + +export const FrostSnowCombo: Story = createComboEffectStory({ + atmosphericLabel: "snow", + createAtmosphericEffect: () => + createAtmosphericSnowEffect({ + accumulationEnabled: false, + density: "heavy-snow", + pixelSize: 2, + spawnRate: 115, + wind: 36, + }), + getAtmosphericMetric: (effect) => { + const snow = effect as ReturnType; + + return `${snow.getActiveFlakeCount()} flakes`; + }, + heavyLabel: "Whiteout", + heavyScreenIntensity: 0.9, + lightLabel: "Freezing Air", + lightScreenIntensity: 0.4, + screenEffectId: screenFrostEffectId, + setClearAtmosphericPreset: (effect) => { + const snow = effect as ReturnType; + + snow.setOptions({ + accumulationEnabled: false, + density: "light-flurry", + spawnRate: 0, + wind: 0, + }); + snow.clear(); + }, + setHeavyAtmosphericPreset: (effect) => { + const snow = effect as ReturnType; + + snow.setOptions({ + accumulationEnabled: false, + density: "blizzard", + spawnRate: 230, + wind: 120, + }); + }, + setLightAtmosphericPreset: (effect) => { + const snow = effect as ReturnType; + + snow.setOptions({ + accumulationEnabled: false, + density: "heavy-snow", + spawnRate: 115, + wind: 36, + }); + }, + title: "Frost and snow combo", +}); + +const getAtmosphericMotionPreset = ( + preset: AtmosphericMotionPreset, + timeMs: number +): Required> => { + if (preset === "walk-forward") { + return { influence: 1.45, turnVelocity: 0, velocityX: 0, velocityY: 0, velocityZ: 420 }; + } + + if (preset === "turn-left") { + return { influence: 1.35, turnVelocity: -360, velocityX: 0, velocityY: 0, velocityZ: 260 }; + } + + if (preset === "turn-right") { + return { influence: 1.35, turnVelocity: 360, velocityX: 0, velocityY: 0, velocityZ: 260 }; + } + + if (preset === "patrol-loop") { + const phase = (timeMs / 1000) % 7.2; + const rightTurn = phase > 1.8 && phase < 2.55 ? 340 : 0; + const leftTurn = phase > 5.0 && phase < 5.75 ? -340 : 0; + + return { + influence: 1.35, + turnVelocity: rightTurn + leftTurn, + velocityX: 0, + velocityY: 0, + velocityZ: rightTurn || leftTurn ? 250 : 390, + }; + } + + return { influence: 1, turnVelocity: 0, velocityX: 0, velocityY: 0, velocityZ: 0 }; +}; + export const AtmosphericRain: Story = { args: { onRainChange: fn(), rainDensity: "medium", + rainMotionEnabled: true, + rainMotionPreset: "patrol-loop", rainWind: 42, }, argTypes: { @@ -1348,6 +1711,15 @@ export const AtmosphericRain: Story = { name: "Density", options: ["light", "medium", "heavy", "storm"], }, + rainMotionEnabled: { + control: "boolean", + name: "Player motion", + }, + rainMotionPreset: { + control: "select", + name: "Motion preset", + options: ["still", "walk-forward", "turn-left", "turn-right", "patrol-loop"], + }, rainWind: { control: { max: 260, min: -260, step: 10, type: "range" }, name: "Wind", @@ -1358,29 +1730,41 @@ export const AtmosphericRain: Story = { const context = canvas.getContext("2d"); const densityValue = createValue("density"); const windValue = createValue("wind"); + const motionValue = createValue("motion"); const dropsValue = createValue("drops"); const splashesValue = createValue("splashes"); const usesValue = createValue("uses", "AtmosphericRainEffect"); const rain = createAtmosphericRainEffect({ density: args.rainDensity ?? "medium", pixelSize: 2, + playerMotion: { + enabled: args.rainMotionEnabled ?? true, + ...getAtmosphericMotionPreset(args.rainMotionPreset ?? "patrol-loop", performance.now()), + }, wind: args.rainWind ?? 42, }); let density = args.rainDensity ?? "medium"; + let motionEnabled = args.rainMotionEnabled ?? true; + let motionPreset = args.rainMotionPreset ?? "patrol-loop"; let wind = args.rainWind ?? 42; let animationFrame = 0; let lastTime = performance.now(); const updateRainOptions = ( nextDensity: AtmosphericRainDensity, - nextWind: number + nextWind: number, + nextMotionPreset = motionPreset, + nextMotionEnabled = motionEnabled ): void => { density = nextDensity; + motionEnabled = nextMotionEnabled; + motionPreset = nextMotionPreset; wind = nextWind; rain.setOptions({ density, wind }); setValue(densityValue, density); + setValue(motionValue, motionEnabled ? motionPreset : "disabled"); setValue(windValue, `${wind}`); - args.onRainChange?.(density, wind); + args.onRainChange?.(density, wind, motionPreset, motionEnabled); }; const controls = document.createElement("div"); @@ -1392,9 +1776,14 @@ export const AtmosphericRain: Story = { createButton("Storm", () => updateRainOptions("storm", wind)), createButton("Wind Left", () => updateRainOptions(density, -180)), createButton("Calm", () => updateRainOptions(density, 0)), - createButton("Wind Right", () => updateRainOptions(density, 180)) + createButton("Wind Right", () => updateRainOptions(density, 180)), + createButton("Walk", () => updateRainOptions(density, wind, "walk-forward", true)), + createButton("Turn Left", () => updateRainOptions(density, wind, "turn-left", true)), + createButton("Turn Right", () => updateRainOptions(density, wind, "turn-right", true)), + createButton("Patrol Loop", () => updateRainOptions(density, wind, "patrol-loop", true)), + createButton("Motion Off", () => updateRainOptions(density, wind, motionPreset, false)) ); - metrics.append(densityValue, windValue, dropsValue, splashesValue, usesValue, controls); + metrics.append(densityValue, windValue, motionValue, dropsValue, splashesValue, usesValue, controls); updateRainOptions(density, wind); const render = (): void => { @@ -1406,6 +1795,10 @@ export const AtmosphericRain: Story = { const delta = Math.min(0.05, (now - lastTime) / 1000); lastTime = now; + rain.setPlayerMotion({ + enabled: motionEnabled, + ...getAtmosphericMotionPreset(motionPreset, now), + }); drawFpsDemoScene(context, { height: canvas.height, pixelScale: 3, @@ -1435,9 +1828,12 @@ export const AtmosphericRain: Story = { export const AtmosphericSnow: Story = { args: { + onAtmosphericMotionChange: fn(), onSnowChange: fn(), snowAccumulationEnabled: true, snowDensity: "snow", + snowMotionEnabled: true, + snowMotionPreset: "patrol-loop", snowWind: 18, }, argTypes: { @@ -1450,6 +1846,15 @@ export const AtmosphericSnow: Story = { name: "Density", options: ["light-flurry", "snow", "heavy-snow", "blizzard"], }, + snowMotionEnabled: { + control: "boolean", + name: "Player motion", + }, + snowMotionPreset: { + control: "select", + name: "Motion preset", + options: ["still", "walk-forward", "turn-left", "turn-right", "patrol-loop"], + }, snowWind: { control: { max: 220, min: -220, step: 10, type: "range" }, name: "Wind", @@ -1460,6 +1865,7 @@ export const AtmosphericSnow: Story = { const context = canvas.getContext("2d"); const densityValue = createValue("density"); const windValue = createValue("wind"); + const motionValue = createValue("motion"); const flakesValue = createValue("flakes"); const accumulationValue = createValue("accumulation"); const usesValue = createValue("uses", "AtmosphericSnowEffect"); @@ -1467,10 +1873,16 @@ export const AtmosphericSnow: Story = { accumulationEnabled: args.snowAccumulationEnabled ?? true, density: args.snowDensity ?? "snow", pixelSize: 2, + playerMotion: { + enabled: args.snowMotionEnabled ?? true, + ...getAtmosphericMotionPreset(args.snowMotionPreset ?? "patrol-loop", performance.now()), + }, wind: args.snowWind ?? 18, }); let accumulationEnabled = args.snowAccumulationEnabled ?? true; let density = args.snowDensity ?? "snow"; + let motionEnabled = args.snowMotionEnabled ?? true; + let motionPreset = args.snowMotionPreset ?? "patrol-loop"; let wind = args.snowWind ?? 18; let animationFrame = 0; let lastTime = performance.now(); @@ -1478,15 +1890,21 @@ export const AtmosphericSnow: Story = { const updateSnowOptions = ( nextDensity: AtmosphericSnowDensity, nextWind: number, - nextAccumulationEnabled: boolean + nextAccumulationEnabled: boolean, + nextMotionPreset = motionPreset, + nextMotionEnabled = motionEnabled ): void => { accumulationEnabled = nextAccumulationEnabled; density = nextDensity; + motionEnabled = nextMotionEnabled; + motionPreset = nextMotionPreset; wind = nextWind; snow.setOptions({ accumulationEnabled, density, wind }); setValue(densityValue, density); + setValue(motionValue, motionEnabled ? motionPreset : "disabled"); setValue(windValue, `${wind}`); args.onSnowChange?.(density, wind, accumulationEnabled); + args.onAtmosphericMotionChange?.("snow", motionPreset, motionEnabled); }; const controls = document.createElement("div"); @@ -1499,9 +1917,14 @@ export const AtmosphericSnow: Story = { createButton("Wind Left", () => updateSnowOptions(density, -130, accumulationEnabled)), createButton("Calm", () => updateSnowOptions(density, 0, accumulationEnabled)), createButton("Build Up", () => updateSnowOptions(density, wind, true)), - createButton("No Build Up", () => updateSnowOptions(density, wind, false)) + createButton("No Build Up", () => updateSnowOptions(density, wind, false)), + createButton("Walk", () => updateSnowOptions(density, wind, accumulationEnabled, "walk-forward", true)), + createButton("Turn Left", () => updateSnowOptions(density, wind, accumulationEnabled, "turn-left", true)), + createButton("Turn Right", () => updateSnowOptions(density, wind, accumulationEnabled, "turn-right", true)), + createButton("Patrol Loop", () => updateSnowOptions(density, wind, accumulationEnabled, "patrol-loop", true)), + createButton("Motion Off", () => updateSnowOptions(density, wind, accumulationEnabled, motionPreset, false)) ); - metrics.append(densityValue, windValue, flakesValue, accumulationValue, usesValue, controls); + metrics.append(densityValue, windValue, motionValue, flakesValue, accumulationValue, usesValue, controls); updateSnowOptions(density, wind, accumulationEnabled); const render = (): void => { @@ -1513,6 +1936,10 @@ export const AtmosphericSnow: Story = { const delta = Math.min(0.05, (now - lastTime) / 1000); lastTime = now; + snow.setPlayerMotion({ + enabled: motionEnabled, + ...getAtmosphericMotionPreset(motionPreset, now), + }); drawFpsDemoScene(context, { height: canvas.height, pixelScale: 3, @@ -1543,8 +1970,11 @@ export const AtmosphericSnow: Story = { export const AtmosphericAshAndEmbers: Story = { args: { ashEmberIntensity: "burning", + ashEmberMotionEnabled: true, + ashEmberMotionPreset: "patrol-loop", ashEmberRatio: 0.24, ashEmberWind: 24, + onAtmosphericMotionChange: fn(), onAshEmberChange: fn(), }, argTypes: { @@ -1553,6 +1983,15 @@ export const AtmosphericAshAndEmbers: Story = { name: "Intensity", options: ["smolder", "burning", "wildfire", "inferno"], }, + ashEmberMotionEnabled: { + control: "boolean", + name: "Player motion", + }, + ashEmberMotionPreset: { + control: "select", + name: "Motion preset", + options: ["still", "walk-forward", "turn-left", "turn-right", "patrol-loop"], + }, ashEmberRatio: { control: { max: 0.65, min: 0, step: 0.05, type: "range" }, name: "Ember ratio", @@ -1567,6 +2006,7 @@ export const AtmosphericAshAndEmbers: Story = { const context = canvas.getContext("2d"); const intensityValue = createValue("intensity"); const windValue = createValue("wind"); + const motionValue = createValue("motion"); const ashValue = createValue("ash"); const emberValue = createValue("embers"); const usesValue = createValue("uses", "AtmosphericAshEmberEffect"); @@ -1574,10 +2014,16 @@ export const AtmosphericAshAndEmbers: Story = { emberRatio: args.ashEmberRatio ?? 0.24, intensity: args.ashEmberIntensity ?? "burning", pixelSize: 2, + playerMotion: { + enabled: args.ashEmberMotionEnabled ?? true, + ...getAtmosphericMotionPreset(args.ashEmberMotionPreset ?? "patrol-loop", performance.now()), + }, wind: args.ashEmberWind ?? 24, }); let emberRatio = args.ashEmberRatio ?? 0.24; let intensity = args.ashEmberIntensity ?? "burning"; + let motionEnabled = args.ashEmberMotionEnabled ?? true; + let motionPreset = args.ashEmberMotionPreset ?? "patrol-loop"; let wind = args.ashEmberWind ?? 24; let animationFrame = 0; let lastTime = performance.now(); @@ -1585,15 +2031,21 @@ export const AtmosphericAshAndEmbers: Story = { const updateAshEmberOptions = ( nextIntensity: AtmosphericAshEmberIntensity, nextWind: number, - nextEmberRatio: number + nextEmberRatio: number, + nextMotionPreset = motionPreset, + nextMotionEnabled = motionEnabled ): void => { emberRatio = nextEmberRatio; intensity = nextIntensity; + motionEnabled = nextMotionEnabled; + motionPreset = nextMotionPreset; wind = nextWind; ashAndEmbers.setOptions({ emberRatio, intensity, wind }); setValue(intensityValue, intensity); + setValue(motionValue, motionEnabled ? motionPreset : "disabled"); setValue(windValue, `${wind}`); args.onAshEmberChange?.(intensity, wind, emberRatio); + args.onAtmosphericMotionChange?.("ash-embers", motionPreset, motionEnabled); }; const controls = document.createElement("div"); @@ -1606,9 +2058,14 @@ export const AtmosphericAshAndEmbers: Story = { createButton("Ash Only", () => updateAshEmberOptions(intensity, wind, 0)), createButton("More Embers", () => updateAshEmberOptions(intensity, wind, 0.52)), createButton("Wind Left", () => updateAshEmberOptions(intensity, -80, emberRatio)), - createButton("Wind Right", () => updateAshEmberOptions(intensity, 80, emberRatio)) + createButton("Wind Right", () => updateAshEmberOptions(intensity, 80, emberRatio)), + createButton("Walk", () => updateAshEmberOptions(intensity, wind, emberRatio, "walk-forward", true)), + createButton("Turn Left", () => updateAshEmberOptions(intensity, wind, emberRatio, "turn-left", true)), + createButton("Turn Right", () => updateAshEmberOptions(intensity, wind, emberRatio, "turn-right", true)), + createButton("Patrol Loop", () => updateAshEmberOptions(intensity, wind, emberRatio, "patrol-loop", true)), + createButton("Motion Off", () => updateAshEmberOptions(intensity, wind, emberRatio, motionPreset, false)) ); - metrics.append(intensityValue, windValue, ashValue, emberValue, usesValue, controls); + metrics.append(intensityValue, windValue, motionValue, ashValue, emberValue, usesValue, controls); updateAshEmberOptions(intensity, wind, emberRatio); const render = (): void => { @@ -1620,6 +2077,10 @@ export const AtmosphericAshAndEmbers: Story = { const delta = Math.min(0.05, (now - lastTime) / 1000); lastTime = now; + ashAndEmbers.setPlayerMotion({ + enabled: motionEnabled, + ...getAtmosphericMotionPreset(motionPreset, now), + }); drawFpsDemoScene(context, { height: canvas.height, pixelScale: 3,