diff options
| author | Bobby <[email protected]> | 2026-04-22 08:40:56 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2026-04-22 08:40:56 +0530 |
| commit | 0423e12d4b38727ffc37f403f5b8bdedb148e711 (patch) | |
| tree | 335d0be4a49042f668bbad1eeb49b2fd805185a1 | |
| parent | 6a7fcc4992989d28fe895d1c461600b7bee0e1b7 (diff) | |
| download | hollowdark-0423e12d4b38727ffc37f403f5b8bdedb148e711.tar.xz hollowdark-0423e12d4b38727ffc37f403f5b8bdedb148e711.zip | |
Add falling-leaf physics and periodic wind gusts to the Begin screen
| -rw-r--r-- | lib/components/LeafScene.svelte | 193 | ||||
| -rw-r--r-- | lib/leaves/physics.ts | 137 | ||||
| -rw-r--r-- | lib/leaves/spawn.ts | 48 | ||||
| -rw-r--r-- | lib/leaves/types.ts | 53 | ||||
| -rw-r--r-- | lib/leaves/variants.ts | 56 | ||||
| -rw-r--r-- | lib/screens/BeginScreen.svelte | 22 |
6 files changed, 505 insertions, 4 deletions
diff --git a/lib/components/LeafScene.svelte b/lib/components/LeafScene.svelte new file mode 100644 index 0000000..2eb1fe2 --- /dev/null +++ b/lib/components/LeafScene.svelte @@ -0,0 +1,193 @@ +<script lang="ts"> + import { onDestroy, onMount } from 'svelte' + import { createRNG } from '@hollowdark/rng/seeded' + import { LEAF_VARIANTS } from '@hollowdark/lib/leaves/variants' + import { spawnLeaf } from '@hollowdark/lib/leaves/spawn' + 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 + const GROUND_Y_RATIO = 0.86 + + const rng = createRNG(Math.floor(performance.now() * 1000) | 0) + + 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 rafId: number | null = null + let lastTimeMs = 0 + let nextLeafId = 0 + let resizeObserver: ResizeObserver | null = null + + function measure(): void { + if (!container) return + const rect = container.getBoundingClientRect() + scene = { + width: rect.width, + height: rect.height, + groundY: rect.height * GROUND_Y_RATIO + } + } + + onMount(() => { + measure() + + for (let i = 0; i < TARGET_LEAF_COUNT; i++) { + leaves = [...leaves, spawnLeaf(nextLeafId++, scene, rng)] + } + + if (container && typeof ResizeObserver !== 'undefined') { + resizeObserver = new ResizeObserver(measure) + resizeObserver.observe(container) + } + + const tick = (nowMs: number): void => { + const dtMs = Math.min(50, nowMs - lastTimeMs) + lastTimeMs = nowMs + const dt = dtMs / 1000 + const timeS = nowMs / 1000 + + wind = updateWind(wind, dtMs, rng) + + const next: Leaf[] = [] + for (const leaf of leaves) { + const advanced = stepLeaf(leaf, dt, timeS, wind, scene) + if (!isLeafOffscreen(advanced, scene)) { + next.push(advanced) + } + } + + while (next.length < TARGET_LEAF_COUNT) { + next.push(spawnLeaf(nextLeafId++, scene, rng)) + } + + leaves = next + rafId = requestAnimationFrame(tick) + } + + rafId = requestAnimationFrame((nowMs) => { + lastTimeMs = nowMs + rafId = requestAnimationFrame(tick) + }) + }) + + onDestroy(() => { + if (rafId !== null) cancelAnimationFrame(rafId) + resizeObserver?.disconnect() + }) +</script> + +<div class="leaf-scene" bind:this={container} aria-hidden="true"> + <svg class="defs" width="0" height="0" focusable="false"> + <defs> + {#each LEAF_VARIANTS as variant (variant.id)} + <symbol id="leaf-{variant.id}" viewBox={variant.viewBox}> + <path d={variant.pathData} fill="currentColor" /> + </symbol> + {/each} + </defs> + </svg> + + <div class="ground" style:top="{scene.groundY}px"></div> + + {#each leaves as leaf (leaf.id)} + <svg + class="leaf" + viewBox="0 0 40 48" + style:left="{leaf.x}px" + style:top="{leaf.y}px" + style:width="{leaf.size}px" + style:color={leaf.color} + style:transform="translate(-50%, -50%) rotate({leaf.rotation}deg)" + focusable="false" + > + <use href="#leaf-{leaf.variantId}" /> + </svg> + {/each} + + <div class="wind-veil" class:active={wind.active} style:--wind-dir={wind.direction}></div> +</div> + +<style> + .leaf-scene { + position: absolute; + inset: 0; + overflow: hidden; + pointer-events: none; + z-index: 0; + } + + .defs { + position: absolute; + width: 0; + height: 0; + } + + .ground { + position: absolute; + left: 0; + right: 0; + height: 1px; + background: linear-gradient( + to right, + rgba(232, 226, 213, 0) 0%, + rgba(232, 226, 213, 0.04) 30%, + rgba(232, 226, 213, 0.04) 70%, + rgba(232, 226, 213, 0) 100% + ); + opacity: 0.6; + pointer-events: none; + } + + .leaf { + position: absolute; + display: block; + will-change: transform, top, left; + filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.25)); + } + + .wind-veil { + position: absolute; + inset: 0; + 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; + } + } + + @media (prefers-reduced-motion: reduce) { + .leaf-scene { + display: none; + } + } +</style> diff --git a/lib/leaves/physics.ts b/lib/leaves/physics.ts new file mode 100644 index 0000000..94d1b90 --- /dev/null +++ b/lib/leaves/physics.ts @@ -0,0 +1,137 @@ +import type { SeededRNG } from '@hollowdark/rng/seeded' +import type { Leaf, SceneDimensions, WindState } from '@hollowdark/lib/leaves/types' + +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) + } +} + +/** + * 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. + */ +export function stepLeaf( + leaf: Leaf, + dt: number, + timeS: number, + wind: WindState, + scene: SceneDimensions +): Leaf { + const windAccel = wind.active ? wind.strength * wind.direction : 0 + const swayAccel = 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 + + 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) + } + + const x = leaf.x + vx * dt + let y = leaf.y + (leaf.settled ? 0 : vy * dt) + + let settled = leaf.settled + if (!settled && y >= scene.groundY) { + settled = true + y = scene.groundY + vy = 0 + } + + if (settled && wind.active && Math.abs(vx) > 40) { + settled = false + } + + let rotationSpeed = leaf.rotationSpeed + if (leaf.settled) { + rotationSpeed -= rotationSpeed * Math.min(1, SETTLED_ROTATION_DAMPING * dt) + } + const rotation = leaf.rotation + rotationSpeed * dt + + return { + ...leaf, + x, + y, + vx, + vy, + rotation, + rotationSpeed, + settled + } +} + +/** Test predicate: true when a leaf has drifted so far off-screen that + * it's safe to drop it 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 + ) +} diff --git a/lib/leaves/spawn.ts b/lib/leaves/spawn.ts new file mode 100644 index 0000000..a2b6fc1 --- /dev/null +++ b/lib/leaves/spawn.ts @@ -0,0 +1,48 @@ +import type { SeededRNG } from '@hollowdark/rng/seeded' +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 + +/** + * 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. + */ +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 + + return { + id, + variantId: variant.id, + color, + size, + x, + y, + vx: 0, + vy, + rotation, + rotationSpeed, + swayPhase, + swayFrequency, + settled: false + } +} diff --git a/lib/leaves/types.ts b/lib/leaves/types.ts new file mode 100644 index 0000000..36dd7f3 --- /dev/null +++ b/lib/leaves/types.ts @@ -0,0 +1,53 @@ +/** + * 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. + */ +export interface LeafVariant { + readonly id: string + readonly viewBox: string + readonly pathData: string +} + +/** + * 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. + */ +export interface Leaf { + readonly id: number + readonly variantId: string + readonly color: string + readonly size: number + readonly x: number + readonly y: number + readonly vx: number + readonly vy: number + readonly rotation: number + readonly rotationSpeed: number + readonly swayPhase: number + readonly swayFrequency: number + 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 +} diff --git a/lib/leaves/variants.ts b/lib/leaves/variants.ts new file mode 100644 index 0000000..a620b6b --- /dev/null +++ b/lib/leaves/variants.ts @@ -0,0 +1,56 @@ +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. + */ +export const LEAF_VARIANTS: readonly LeafVariant[] = [ + { + id: 'oval', + 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' + }, + { + id: 'willow', + 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' + }, + { + id: 'wide', + 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' + }, + { + id: 'curled', + 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' + }, + { + id: 'round', + 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' + }, + { + id: 'jagged', + 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' + } +] + +/** Autumn palette — muted warm tones that sit against the dark background. */ +export const LEAF_COLORS: readonly string[] = [ + '#b8884a', + '#a67035', + '#6e4923', + '#8b6a3a', + '#c4975a', + '#7a4e22' +] diff --git a/lib/screens/BeginScreen.svelte b/lib/screens/BeginScreen.svelte index 08ab565..e79528d 100644 --- a/lib/screens/BeginScreen.svelte +++ b/lib/screens/BeginScreen.svelte @@ -4,6 +4,7 @@ import AppVersion from '@hollowdark/lib/components/AppVersion.svelte' import AudioPlayer from '@hollowdark/lib/components/AudioPlayer.svelte' import BeginActions from '@hollowdark/lib/components/BeginActions.svelte' + import LeafScene from '@hollowdark/lib/components/LeafScene.svelte' import type { BeginState } from '@hollowdark/loading/session' interface Props { @@ -26,11 +27,15 @@ </script> <section class="begin"> - <div class="top"> - <AppTitle size={38} subtitle="A Literary Life Simulation" /> - </div> + <LeafScene /> + + <div class="content"> + <div class="top"> + <AppTitle size={38} subtitle="A Literary Life Simulation" /> + </div> - <BeginActions {state} {onBegin} {onContinue} {onSettings} {onCredits} /> + <BeginActions {state} {onBegin} {onContinue} {onSettings} {onCredits} /> + </div> <AppVersion /> @@ -46,6 +51,15 @@ justify-content: center; padding: var(--space-12); position: relative; + overflow: hidden; + } + + .content { + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; } .top { |
