diff options
| author | Bobby <[email protected]> | 2026-04-22 09:07:14 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2026-04-22 09:07:14 +0530 |
| commit | 2c89a4173b5705e329d6a1e1dbdb408ac5a43811 (patch) | |
| tree | d5e94a14273cb293f618046f6558b80222386cbd | |
| parent | c34418910ee3ec9da3b4c1c9bdaaa0960c93f8bd (diff) | |
| download | hollowdark-2c89a4173b5705e329d6a1e1dbdb408ac5a43811.tar.xz hollowdark-2c89a4173b5705e329d6a1e1dbdb408ac5a43811.zip | |
Redesign leaves with real silhouettes and localized gusts driven by a prevailing wind
| -rw-r--r-- | lib/components/LeafScene.svelte | 85 | ||||
| -rw-r--r-- | lib/leaves/physics.ts | 124 | ||||
| -rw-r--r-- | lib/leaves/spawn.ts | 57 | ||||
| -rw-r--r-- | lib/leaves/types.ts | 79 | ||||
| -rw-r--r-- | lib/leaves/variants.ts | 32 | ||||
| -rw-r--r-- | lib/leaves/wind.ts | 259 |
6 files changed, 450 insertions, 186 deletions
diff --git a/lib/components/LeafScene.svelte b/lib/components/LeafScene.svelte index 2eb1fe2..d60b555 100644 --- a/lib/components/LeafScene.svelte +++ b/lib/components/LeafScene.svelte @@ -3,15 +3,16 @@ import { createRNG } from '@hollowdark/rng/seeded' import { LEAF_VARIANTS } from '@hollowdark/lib/leaves/variants' import { spawnLeaf } from '@hollowdark/lib/leaves/spawn' + import { isLeafOffscreen, stepLeaf } from '@hollowdark/lib/leaves/physics' import { - initialWindState, - isLeafOffscreen, - stepLeaf, - updateWind - } from '@hollowdark/lib/leaves/physics' - import type { Leaf, SceneDimensions, WindState } from '@hollowdark/lib/leaves/types' - - const TARGET_LEAF_COUNT = 12 + initialWindSystem, + particleOpacity, + stepWind, + type IdSource + } from '@hollowdark/lib/leaves/wind' + import type { Leaf, SceneDimensions, WindSystem } from '@hollowdark/lib/leaves/types' + + const TARGET_LEAF_COUNT = 14 const GROUND_Y_RATIO = 0.86 const rng = createRNG(Math.floor(performance.now() * 1000) | 0) @@ -19,13 +20,20 @@ let container: HTMLDivElement | null = null let scene: SceneDimensions = $state({ width: 0, height: 0, groundY: 0 }) let leaves: Leaf[] = $state([]) - let wind: WindState = $state(initialWindState(rng)) + let wind: WindSystem = $state(initialWindSystem(rng)) let rafId: number | null = null let lastTimeMs = 0 let nextLeafId = 0 + let nextGustId = 0 + let nextParticleId = 0 let resizeObserver: ResizeObserver | null = null + const ids: IdSource = { + nextGustId: () => nextGustId++, + nextParticleId: () => nextParticleId++ + } + function measure(): void { if (!container) return const rect = container.getBoundingClientRect() @@ -54,7 +62,7 @@ const dt = dtMs / 1000 const timeS = nowMs / 1000 - wind = updateWind(wind, dtMs, rng) + wind = stepWind(wind, dtMs, scene, rng, ids) const next: Leaf[] = [] for (const leaf of leaves) { @@ -89,7 +97,13 @@ <defs> {#each LEAF_VARIANTS as variant (variant.id)} <symbol id="leaf-{variant.id}" viewBox={variant.viewBox}> - <path d={variant.pathData} fill="currentColor" /> + <path + d={variant.pathData} + fill="currentColor" + stroke="currentColor" + stroke-width="0.6" + stroke-linejoin="round" + /> </symbol> {/each} </defs> @@ -97,6 +111,19 @@ <div class="ground" style:top="{scene.groundY}px"></div> + {#if wind.activeGust} + {#each wind.activeGust.particles as particle (particle.id)} + <div + class="wind-particle" + style:left="{particle.x}px" + style:top="{particle.y}px" + style:width="{particle.size}px" + style:height="{particle.size}px" + style:opacity={particleOpacity(particle)} + ></div> + {/each} + {/if} + {#each leaves as leaf (leaf.id)} <svg class="leaf" @@ -111,8 +138,6 @@ <use href="#leaf-{leaf.variantId}" /> </svg> {/each} - - <div class="wind-veil" class:active={wind.active} style:--wind-dir={wind.direction}></div> </div> <style> @@ -143,7 +168,6 @@ rgba(232, 226, 213, 0) 100% ); opacity: 0.6; - pointer-events: none; } .leaf { @@ -153,36 +177,13 @@ filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.25)); } - .wind-veil { + .wind-particle { position: absolute; - inset: 0; + border-radius: 50%; + background: rgba(232, 226, 213, 0.45); + transform: translate(-50%, -50%); pointer-events: none; - opacity: 0; - transition: opacity 800ms ease-out; - background-image: repeating-linear-gradient( - calc(90deg + 6deg * var(--wind-dir, 1)), - rgba(232, 226, 213, 0.03) 0px, - rgba(232, 226, 213, 0.03) 1px, - transparent 1px, - transparent 60px - ); - background-size: 120px 120px; - animation: drift 3.5s linear infinite; - animation-play-state: paused; - } - - .wind-veil.active { - opacity: 1; - animation-play-state: running; - } - - @keyframes drift { - from { - background-position: 0 0; - } - to { - background-position: calc(120px * var(--wind-dir, 1)) 0; - } + box-shadow: 0 0 4px rgba(232, 226, 213, 0.2); } @media (prefers-reduced-motion: reduce) { diff --git a/lib/leaves/physics.ts b/lib/leaves/physics.ts index 94d1b90..9510311 100644 --- a/lib/leaves/physics.ts +++ b/lib/leaves/physics.ts @@ -1,97 +1,50 @@ -import type { SeededRNG } from '@hollowdark/rng/seeded' -import type { Leaf, SceneDimensions, WindState } from '@hollowdark/lib/leaves/types' +import type { Leaf, SceneDimensions, WindSystem } from '@hollowdark/lib/leaves/types' +import { windForceAt } from '@hollowdark/lib/leaves/wind' -const GRAVITY_PX_PER_S2 = 22 -const TERMINAL_VELOCITY_PX_PER_S = 48 -const HORIZONTAL_DRAG = 1.4 -const SWAY_AMPLITUDE_PX_PER_S = 18 -const SETTLED_VX_DAMPING = 3.5 -const SETTLED_ROTATION_DAMPING = 3.5 -const LEAF_OFFSCREEN_MARGIN = 120 - -const WIND_MIN_IDLE_MS = 14_000 -const WIND_MAX_IDLE_MS = 28_000 -const WIND_MIN_DURATION_MS = 3_000 -const WIND_MAX_DURATION_MS = 5_000 -const WIND_MIN_STRENGTH_PX_PER_S2 = 80 -const WIND_MAX_STRENGTH_PX_PER_S2 = 180 - -/** - * Construct the first wind state. Time-until-first-gust is picked from - * the idle range so the scene doesn't immediately gust. - */ -export function initialWindState(rng: SeededRNG): WindState { - return { - active: false, - direction: rng.nextBool(0.5) ? 1 : -1, - strength: 0, - remainingMs: 0, - nextGustInMs: WIND_MIN_IDLE_MS + rng.next() * (WIND_MAX_IDLE_MS - WIND_MIN_IDLE_MS) - } -} +const GRAVITY_PX_PER_S2 = 14 +const TERMINAL_VY_PX_PER_S = 35 +const WIND_FORCE_SCALE = 0.55 +const SWAY_AMPLITUDE_PX_PER_S = 16 +const HORIZONTAL_DRAG_PER_FRAME = 0.98 +const VERTICAL_DRAG_PER_FRAME = 0.995 +const SETTLED_HORIZONTAL_DRAG_PER_FRAME = 0.88 +const SETTLED_ROT_DAMPING_PER_FRAME = 0.92 +const SETTLE_LIFT_THRESHOLD = 30 +const OFFSCREEN_MARGIN_PX = 140 /** - * Advance the wind state by `dtMs`. Triggers a new gust when the idle - * timer expires; ends a gust when its duration runs out. - */ -export function updateWind(wind: WindState, dtMs: number, rng: SeededRNG): WindState { - if (wind.active) { - const remaining = wind.remainingMs - dtMs - if (remaining <= 0) { - return { - active: false, - direction: rng.nextBool(0.5) ? 1 : -1, - strength: 0, - remainingMs: 0, - nextGustInMs: WIND_MIN_IDLE_MS + rng.next() * (WIND_MAX_IDLE_MS - WIND_MIN_IDLE_MS) - } - } - return { ...wind, remainingMs: remaining } - } - - const nextIdle = wind.nextGustInMs - dtMs - if (nextIdle <= 0) { - return { - active: true, - direction: rng.nextBool(0.5) ? 1 : -1, - strength: - WIND_MIN_STRENGTH_PX_PER_S2 + - rng.next() * (WIND_MAX_STRENGTH_PX_PER_S2 - WIND_MIN_STRENGTH_PX_PER_S2), - remainingMs: - WIND_MIN_DURATION_MS + rng.next() * (WIND_MAX_DURATION_MS - WIND_MIN_DURATION_MS), - nextGustInMs: 0 - } - } - return { ...wind, nextGustInMs: nextIdle } -} - -/** - * Step one leaf forward by `dt` seconds. Applies gravity, horizontal - * sway, wind, rotation, and ground-settle damping. Returns a new leaf - * object — never mutates the input. + * Step one leaf forward by `dt` seconds. Applies wind-derived acceleration, + * gravity, drag, and rotation. Ground collision settles the leaf; strong + * upward force from a nearby gust can lift it again. */ export function stepLeaf( leaf: Leaf, dt: number, timeS: number, - wind: WindState, + wind: WindSystem, scene: SceneDimensions ): Leaf { - const windAccel = wind.active ? wind.strength * wind.direction : 0 - const swayAccel = leaf.settled + const { fx, fy } = windForceAt(wind, leaf.x, leaf.y) + + const sway = leaf.settled ? 0 : SWAY_AMPLITUDE_PX_PER_S * leaf.swayFrequency * Math.cos(leaf.swayPhase + timeS * leaf.swayFrequency * Math.PI * 2) - let vx = leaf.vx + (windAccel + swayAccel) * dt - let vy = leaf.vy + let vx = leaf.vx + (fx * WIND_FORCE_SCALE + sway) * dt + let vy = leaf.vy + fy * WIND_FORCE_SCALE * dt + + if (!leaf.settled) { + vy = Math.min(TERMINAL_VY_PX_PER_S, vy + GRAVITY_PX_PER_S2 * dt) + } + + const frames = dt * 60 + vx *= Math.pow(HORIZONTAL_DRAG_PER_FRAME, frames) + vy *= Math.pow(VERTICAL_DRAG_PER_FRAME, frames) if (leaf.settled) { - vx -= vx * Math.min(1, SETTLED_VX_DAMPING * dt) - } else { - vx -= vx * Math.min(1, HORIZONTAL_DRAG * dt) - vy = Math.min(TERMINAL_VELOCITY_PX_PER_S, vy + GRAVITY_PX_PER_S2 * dt) + vx *= Math.pow(SETTLED_HORIZONTAL_DRAG_PER_FRAME, frames) } const x = leaf.x + vx * dt @@ -101,16 +54,16 @@ export function stepLeaf( if (!settled && y >= scene.groundY) { settled = true y = scene.groundY - vy = 0 + vy = Math.min(0, vy) } - if (settled && wind.active && Math.abs(vx) > 40) { + if (settled && vy < -SETTLE_LIFT_THRESHOLD) { settled = false } let rotationSpeed = leaf.rotationSpeed - if (leaf.settled) { - rotationSpeed -= rotationSpeed * Math.min(1, SETTLED_ROTATION_DAMPING * dt) + if (settled) { + rotationSpeed *= Math.pow(SETTLED_ROT_DAMPING_PER_FRAME, frames) } const rotation = leaf.rotation + rotationSpeed * dt @@ -126,12 +79,11 @@ export function stepLeaf( } } -/** Test predicate: true when a leaf has drifted so far off-screen that - * it's safe to drop it from the array. */ +/** A leaf has drifted far enough off-screen to drop from the array. */ export function isLeafOffscreen(leaf: Leaf, scene: SceneDimensions): boolean { return ( - leaf.x < -LEAF_OFFSCREEN_MARGIN || - leaf.x > scene.width + LEAF_OFFSCREEN_MARGIN || - leaf.y > scene.height + LEAF_OFFSCREEN_MARGIN + leaf.x < -OFFSCREEN_MARGIN_PX || + leaf.x > scene.width + OFFSCREEN_MARGIN_PX || + leaf.y > scene.height + OFFSCREEN_MARGIN_PX ) } diff --git a/lib/leaves/spawn.ts b/lib/leaves/spawn.ts index a2b6fc1..631239d 100644 --- a/lib/leaves/spawn.ts +++ b/lib/leaves/spawn.ts @@ -3,32 +3,41 @@ import { LEAF_COLORS, LEAF_VARIANTS } from '@hollowdark/lib/leaves/variants' import type { Leaf, SceneDimensions } from '@hollowdark/lib/leaves/types' const MIN_SIZE = 18 -const MAX_SIZE = 38 -const MIN_ROTATION_SPEED = -40 -const MAX_ROTATION_SPEED = 40 -const MIN_SWAY_FREQ_HZ = 0.2 -const MAX_SWAY_FREQ_HZ = 0.55 -const INITIAL_VY_MIN = 8 -const INITIAL_VY_MAX = 22 +const MAX_SIZE = 36 +const MIN_ROT_SPEED = -35 +const MAX_ROT_SPEED = 35 +const MIN_SWAY_FREQ_HZ = 0.18 +const MAX_SWAY_FREQ_HZ = 0.5 +const FROM_RIGHT_PROBABILITY = 0.72 /** - * Create a fresh leaf at the top of the scene. `id` must be unique across - * all leaves currently on stage; the caller holds the counter so it can - * be strictly monotonic. + * Create a fresh leaf entering the scene from the upwind edge. With + * `FROM_RIGHT_PROBABILITY`, leaves spawn off the right side at a random y + * above the ground; the rest drop in from above at a random x. This + * matches a horizontal-wind feel rather than vertical snowfall. */ export function spawnLeaf(id: number, scene: SceneDimensions, rng: SeededRNG): Leaf { const variant = rng.pick(LEAF_VARIANTS) const color = rng.pick(LEAF_COLORS) const size = MIN_SIZE + rng.next() * (MAX_SIZE - MIN_SIZE) - const x = rng.next() * scene.width - const y = -size - rng.next() * 40 - const vy = INITIAL_VY_MIN + rng.next() * (INITIAL_VY_MAX - INITIAL_VY_MIN) - const rotation = rng.next() * 360 - const rotationSpeed = - MIN_ROTATION_SPEED + rng.next() * (MAX_ROTATION_SPEED - MIN_ROTATION_SPEED) - const swayFrequency = - MIN_SWAY_FREQ_HZ + rng.next() * (MAX_SWAY_FREQ_HZ - MIN_SWAY_FREQ_HZ) - const swayPhase = rng.next() * Math.PI * 2 + const fromRight = rng.nextBool(FROM_RIGHT_PROBABILITY) + + let x: number + let y: number + let vx: number + let vy: number + + if (fromRight) { + x = scene.width + size + y = rng.next() * scene.groundY * 0.88 + vx = -18 - rng.next() * 12 + vy = 2 + rng.next() * 8 + } else { + x = rng.next() * scene.width + y = -size - rng.next() * 40 + vx = -4 + rng.next() * 4 + vy = 12 + rng.next() * 10 + } return { id, @@ -37,12 +46,12 @@ export function spawnLeaf(id: number, scene: SceneDimensions, rng: SeededRNG): L size, x, y, - vx: 0, + vx, vy, - rotation, - rotationSpeed, - swayPhase, - swayFrequency, + rotation: rng.next() * 360, + rotationSpeed: MIN_ROT_SPEED + rng.next() * (MAX_ROT_SPEED - MIN_ROT_SPEED), + swayPhase: rng.next() * Math.PI * 2, + swayFrequency: MIN_SWAY_FREQ_HZ + rng.next() * (MAX_SWAY_FREQ_HZ - MIN_SWAY_FREQ_HZ), settled: false } } diff --git a/lib/leaves/types.ts b/lib/leaves/types.ts index 36dd7f3..869a296 100644 --- a/lib/leaves/types.ts +++ b/lib/leaves/types.ts @@ -1,7 +1,7 @@ /** * An individual leaf silhouette. `pathData` is the `d` attribute of an * `<path>` inside a `<symbol>` with the given viewBox. Rendered at various - * pixel sizes with `width`; the aspect ratio derives from viewBox. + * pixel sizes; the aspect ratio derives from viewBox. */ export interface LeafVariant { readonly id: string @@ -11,9 +11,8 @@ export interface LeafVariant { /** * One leaf in flight. `x` / `y` are in CSS pixels from the top-left of the - * scene container. `rotation` is degrees. `swayPhase` is the phase offset - * for the horizontal sway oscillation — different per leaf so the cloud - * doesn't drift in sync. + * scene container. `rotation` is degrees. `swayPhase` offsets the horizontal + * sway oscillation per leaf so the cloud doesn't drift in sync. */ export interface Leaf { readonly id: number @@ -31,23 +30,67 @@ export interface Leaf { readonly settled: boolean } -/** - * Periodic horizontal wind gusts that sweep across the scene. Direction - * is -1 (rightward blown leftwards, odd convention — here we use +1 for - * rightward, -1 for leftward). `remainingMs` counts down while a gust is - * active. `nextGustInMs` counts down between gusts. - */ -export interface WindState { - readonly active: boolean - readonly direction: 1 | -1 - readonly strength: number - readonly remainingMs: number - readonly nextGustInMs: number -} - /** Viewport dimensions and the y-coordinate where leaves settle. */ export interface SceneDimensions { readonly width: number readonly height: number readonly groundY: number } + +/** + * The three gust flavours the wind system can emit. Each has its own + * duration, strength, and particle-spawning profile. + * + * breeze small, slow, gentle, frequent — common idle motion + * gust medium, strong, mostly straight — occasional sweep + * whirl small, circular — rare twirl that spirals around its centre + */ +export type GustKind = 'breeze' | 'gust' | 'whirl' + +/** + * A wisp of visible air inside a gust — a short-lived dot that traces the + * gust's flow. Rendered as a small semi-transparent circle with opacity + * computed from `ageMs / lifetimeMs`. + */ +export interface WindParticle { + readonly id: number + readonly x: number + readonly y: number + readonly vx: number + readonly vy: number + readonly ageMs: number + readonly lifetimeMs: number + readonly size: number +} + +/** + * A localised wind event. Lives for `totalDurationMs`, during which it + * applies force to leaves within `radius` of (`centerX`, `centerY`) and + * spawns `particles` that trace the wind's motion. + */ +export interface Gust { + readonly id: number + readonly kind: GustKind + readonly centerX: number + readonly centerY: number + readonly radius: number + readonly strength: number + readonly direction: number + readonly elapsedMs: number + readonly totalDurationMs: number + readonly particles: readonly WindParticle[] + readonly nextParticleSpawnInMs: number +} + +/** + * The overall wind state. A constant horizontal prevailing wind drifts + * every leaf leftward at a low speed. Gusts are seldom, one at a time, + * and superimposed on the prevailing flow — they only apply within their + * own radius. + */ +export interface WindSystem { + readonly prevailingVx: number + readonly prevailingVy: number + readonly activeGust: Gust | null + readonly nextGustInMs: number +} diff --git a/lib/leaves/variants.ts b/lib/leaves/variants.ts index a620b6b..d948184 100644 --- a/lib/leaves/variants.ts +++ b/lib/leaves/variants.ts @@ -1,47 +1,47 @@ import type { LeafVariant } from '@hollowdark/lib/leaves/types' /** - * Six hand-drawn leaf silhouettes. Each variant is a single path rendered - * with `currentColor`, so the leaf colour is driven by the element's - * inline color style rather than baked into the path. All share a - * 40×48 viewBox for consistent scaling. + * Six recognisable leaf silhouettes sharing a 40×48 viewBox. Each path + * includes both the leaf body and a stem line so it reads as a leaf at + * small render sizes. Rendered with `currentColor` so the leaf's tint is + * set by the host element's inline color style. */ export const LEAF_VARIANTS: readonly LeafVariant[] = [ { - id: 'oval', + id: 'maple', viewBox: '0 0 40 48', pathData: - 'M20 2 C 10 6 6 20 10 36 C 14 44 26 44 30 36 C 34 20 30 6 20 2 Z M20 34 L20 46' + 'M 20 3 L 22 13 L 30 11 L 26 19 L 34 22 L 26 24 L 30 33 L 22 29 L 20 40 L 18 29 L 10 33 L 14 24 L 6 22 L 14 19 L 10 11 L 18 13 Z M 20 40 L 20 47' }, { - id: 'willow', + id: 'oak', viewBox: '0 0 40 48', pathData: - 'M20 2 C 17 10 17 24 18 36 C 19 42 21 42 22 36 C 23 24 23 10 20 2 Z' + 'M 20 3 C 17 4 14 6 12 9 C 10 8 7 8 7 11 C 7 14 10 15 12 14 C 10 16 7 18 7 22 C 8 24 11 23 13 21 C 12 24 11 28 14 30 C 16 29 18 26 18 23 C 19 27 20 34 20 40 C 20 34 21 27 22 23 C 22 26 24 29 26 30 C 29 28 28 24 27 21 C 29 23 32 24 33 22 C 33 18 30 16 28 14 C 30 15 33 14 33 11 C 33 8 30 8 28 9 C 26 6 23 4 20 3 Z M 20 35 L 20 47' }, { - id: 'wide', + id: 'birch', viewBox: '0 0 40 48', pathData: - 'M20 4 C 6 8 4 18 8 28 C 10 36 16 38 20 36 C 24 38 30 36 32 28 C 36 18 34 8 20 4 Z M20 36 L20 44' + 'M 20 3 C 12 6 8 14 8 22 C 8 32 14 40 20 43 C 26 40 32 32 32 22 C 32 14 28 6 20 3 Z M 20 10 L 20 47' }, { - id: 'curled', + id: 'aspen', viewBox: '0 0 40 48', pathData: - 'M18 4 C 8 10 6 24 14 30 C 22 34 28 30 30 24 C 26 28 20 28 18 24 C 20 18 26 14 30 16 C 26 8 22 4 18 4 Z' + 'M 20 5 C 27 6 34 12 32 22 C 30 30 24 36 20 40 C 16 36 10 30 8 22 C 6 12 13 6 20 5 Z M 20 12 L 20 46' }, { - id: 'round', + id: 'willow', viewBox: '0 0 40 48', pathData: - 'M20 8 C 8 10 6 22 10 30 C 14 36 26 36 30 30 C 34 22 32 10 20 8 Z M20 30 L20 42' + 'M 20 2 C 17 10 16 22 17 34 C 18 42 22 42 23 34 C 24 22 23 10 20 2 Z M 20 10 L 20 47' }, { - id: 'jagged', + id: 'linden', viewBox: '0 0 40 48', pathData: - 'M20 2 L24 10 L32 10 L28 18 L34 24 L26 26 L28 34 L20 30 L12 34 L14 26 L6 24 L12 18 L8 10 L16 10 Z' + 'M 20 3 C 13 5 7 12 8 22 C 9 30 14 38 20 46 C 26 38 31 30 32 22 C 33 12 27 5 20 3 Z M 20 14 L 20 45' } ] diff --git a/lib/leaves/wind.ts b/lib/leaves/wind.ts new file mode 100644 index 0000000..5bcac7f --- /dev/null +++ b/lib/leaves/wind.ts @@ -0,0 +1,259 @@ +import type { SeededRNG } from '@hollowdark/rng/seeded' +import type { + Gust, + GustKind, + SceneDimensions, + WindParticle, + WindSystem +} from '@hollowdark/lib/leaves/types' + +const PREVAILING_VX_PX_PER_S = -28 +const PREVAILING_VY_PX_PER_S = 8 + +const NEXT_GUST_MIN_MS = 9_000 +const NEXT_GUST_MAX_MS = 22_000 + +const GUST_KIND_WEIGHTS: readonly (readonly [GustKind, number])[] = [ + ['breeze', 6], + ['gust', 3], + ['whirl', 1] +] + +const BREEZE_DURATION_MS = [2_400, 4_200] as const +const GUST_DURATION_MS = [3_200, 5_500] as const +const WHIRL_DURATION_MS = [4_500, 7_500] as const + +const BREEZE_RADIUS = [70, 140] as const +const GUST_RADIUS = [150, 260] as const +const WHIRL_RADIUS = [90, 170] as const + +const BREEZE_STRENGTH = [25, 55] as const +const GUST_STRENGTH = [70, 130] as const +const WHIRL_STRENGTH = [55, 95] as const + +/** Construct the initial wind system. Schedules the first gust a few + * seconds out so the scene opens on the gentle prevailing wind. */ +export function initialWindSystem(rng: SeededRNG): WindSystem { + return { + prevailingVx: PREVAILING_VX_PX_PER_S, + prevailingVy: PREVAILING_VY_PX_PER_S, + activeGust: null, + nextGustInMs: 4_000 + rng.next() * 6_000 + } +} + +/** + * Force per unit mass at a given point. Returns the prevailing wind plus, + * if inside a gust, the gust's contribution with linear radial falloff. + */ +export function windForceAt( + wind: WindSystem, + x: number, + y: number +): { readonly fx: number; readonly fy: number } { + let fx = wind.prevailingVx + let fy = wind.prevailingVy + + const g = wind.activeGust + if (g) { + const dx = x - g.centerX + const dy = y - g.centerY + const dist = Math.sqrt(dx * dx + dy * dy) + if (dist < g.radius) { + const falloff = 1 - dist / g.radius + if (g.kind === 'whirl') { + const safe = Math.max(0.001, dist) + const tx = -dy / safe + const ty = dx / safe + fx += tx * g.strength * falloff + fy += ty * g.strength * falloff + } else { + fx += Math.cos(g.direction) * g.strength * falloff + fy += Math.sin(g.direction) * g.strength * falloff + } + } + } + return { fx, fy } +} + +/** Counter-holder so the caller can issue unique monotonic ids. */ +export interface IdSource { + nextGustId(): number + nextParticleId(): number +} + +/** + * Step the wind system by `dtMs`. Advances particles inside an active + * gust, spawns new particles, retires the gust when its duration ends, + * and schedules the next gust after an idle pause. + */ +export function stepWind( + wind: WindSystem, + dtMs: number, + scene: SceneDimensions, + rng: SeededRNG, + ids: IdSource +): WindSystem { + if (wind.activeGust) { + const g = wind.activeGust + const elapsed = g.elapsedMs + dtMs + + if (elapsed >= g.totalDurationMs) { + return { + ...wind, + activeGust: null, + nextGustInMs: NEXT_GUST_MIN_MS + rng.next() * (NEXT_GUST_MAX_MS - NEXT_GUST_MIN_MS) + } + } + + const advanced = advanceParticles(g, dtMs) + let particles = advanced + let nextSpawnInMs = g.nextParticleSpawnInMs - dtMs + + if (nextSpawnInMs <= 0) { + particles = [...advanced, spawnParticle(g, ids.nextParticleId(), rng)] + nextSpawnInMs = particleSpawnIntervalMs(g.kind) + } + + return { + ...wind, + activeGust: { + ...g, + elapsedMs: elapsed, + particles, + nextParticleSpawnInMs: nextSpawnInMs + } + } + } + + const remaining = wind.nextGustInMs - dtMs + if (remaining <= 0) { + return { + ...wind, + activeGust: createGust(ids.nextGustId(), scene, rng), + nextGustInMs: 0 + } + } + return { ...wind, nextGustInMs: remaining } +} + +function createGust(id: number, scene: SceneDimensions, rng: SeededRNG): Gust { + const kind = rng.weightedPick(GUST_KIND_WEIGHTS) + const centerX = rng.next() * scene.width + const centerY = scene.height * (0.25 + rng.next() * 0.55) + + let radius: number + let strength: number + let duration: number + let direction: number + + if (kind === 'breeze') { + radius = BREEZE_RADIUS[0] + rng.next() * (BREEZE_RADIUS[1] - BREEZE_RADIUS[0]) + strength = BREEZE_STRENGTH[0] + rng.next() * (BREEZE_STRENGTH[1] - BREEZE_STRENGTH[0]) + duration = BREEZE_DURATION_MS[0] + rng.next() * (BREEZE_DURATION_MS[1] - BREEZE_DURATION_MS[0]) + direction = Math.PI + (rng.next() - 0.5) * 0.5 + } else if (kind === 'gust') { + radius = GUST_RADIUS[0] + rng.next() * (GUST_RADIUS[1] - GUST_RADIUS[0]) + strength = GUST_STRENGTH[0] + rng.next() * (GUST_STRENGTH[1] - GUST_STRENGTH[0]) + duration = GUST_DURATION_MS[0] + rng.next() * (GUST_DURATION_MS[1] - GUST_DURATION_MS[0]) + direction = Math.PI + (rng.next() - 0.5) * 0.35 + } else { + radius = WHIRL_RADIUS[0] + rng.next() * (WHIRL_RADIUS[1] - WHIRL_RADIUS[0]) + strength = WHIRL_STRENGTH[0] + rng.next() * (WHIRL_STRENGTH[1] - WHIRL_STRENGTH[0]) + duration = WHIRL_DURATION_MS[0] + rng.next() * (WHIRL_DURATION_MS[1] - WHIRL_DURATION_MS[0]) + direction = rng.nextBool(0.5) ? 1 : -1 + } + + return { + id, + kind, + centerX, + centerY, + radius, + strength, + direction, + elapsedMs: 0, + totalDurationMs: duration, + particles: [], + nextParticleSpawnInMs: 0 + } +} + +function particleSpawnIntervalMs(kind: GustKind): number { + return kind === 'breeze' ? 160 : kind === 'gust' ? 110 : 140 +} + +function spawnParticle(gust: Gust, id: number, rng: SeededRNG): WindParticle { + const angle = rng.next() * Math.PI * 2 + const r = rng.next() * gust.radius * 0.9 + const x = gust.centerX + Math.cos(angle) * r + const y = gust.centerY + Math.sin(angle) * r + + let vx: number + let vy: number + if (gust.kind === 'whirl') { + const safe = Math.max(0.001, r) + vx = (-Math.sin(angle) * gust.direction * gust.strength) / Math.max(1, safe / 40) + vy = (Math.cos(angle) * gust.direction * gust.strength) / Math.max(1, safe / 40) + } else { + vx = Math.cos(gust.direction) * gust.strength * 0.55 + vy = Math.sin(gust.direction) * gust.strength * 0.55 + } + + return { + id, + x, + y, + vx, + vy, + ageMs: 0, + lifetimeMs: 800 + rng.next() * 1_200, + size: 1.2 + rng.next() * 1.6 + } +} + +function advanceParticles(gust: Gust, dtMs: number): readonly WindParticle[] { + const dt = dtMs / 1000 + const out: WindParticle[] = [] + for (const p of gust.particles) { + const age = p.ageMs + dtMs + if (age >= p.lifetimeMs) continue + + let vx = p.vx + let vy = p.vy + + if (gust.kind === 'whirl') { + const dx = p.x - gust.centerX + const dy = p.y - gust.centerY + const dist = Math.sqrt(dx * dx + dy * dy) + if (dist > 0) { + const safe = Math.max(0.001, dist) + vx += (-dy / safe) * gust.direction * 45 * dt + vy += (dx / safe) * gust.direction * 45 * dt + } + } + + const drag = Math.pow(0.94, dt * 60) + vx *= drag + vy *= drag + + out.push({ + ...p, + x: p.x + vx * dt, + y: p.y + vy * dt, + vx, + vy, + ageMs: age + }) + } + return out +} + +/** Opacity envelope for a particle: fade in the first 20%, hold, fade out + * the last 30%. */ +export function particleOpacity(p: WindParticle): number { + const phase = p.ageMs / p.lifetimeMs + if (phase < 0.2) return phase / 0.2 + if (phase > 0.7) return Math.max(0, (1 - phase) / 0.3) + return 1 +} |
