aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2026-04-22 09:07:14 +0530
committerBobby <[email protected]>2026-04-22 09:07:14 +0530
commit2c89a4173b5705e329d6a1e1dbdb408ac5a43811 (patch)
treed5e94a14273cb293f618046f6558b80222386cbd
parentc34418910ee3ec9da3b4c1c9bdaaa0960c93f8bd (diff)
downloadhollowdark-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.svelte85
-rw-r--r--lib/leaves/physics.ts124
-rw-r--r--lib/leaves/spawn.ts57
-rw-r--r--lib/leaves/types.ts79
-rw-r--r--lib/leaves/variants.ts32
-rw-r--r--lib/leaves/wind.ts259
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
+}