aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2026-04-22 08:40:56 +0530
committerBobby <[email protected]>2026-04-22 08:40:56 +0530
commit0423e12d4b38727ffc37f403f5b8bdedb148e711 (patch)
tree335d0be4a49042f668bbad1eeb49b2fd805185a1
parent6a7fcc4992989d28fe895d1c461600b7bee0e1b7 (diff)
downloadhollowdark-0423e12d4b38727ffc37f403f5b8bdedb148e711.tar.xz
hollowdark-0423e12d4b38727ffc37f403f5b8bdedb148e711.zip
Add falling-leaf physics and periodic wind gusts to the Begin screen
-rw-r--r--lib/components/LeafScene.svelte193
-rw-r--r--lib/leaves/physics.ts137
-rw-r--r--lib/leaves/spawn.ts48
-rw-r--r--lib/leaves/types.ts53
-rw-r--r--lib/leaves/variants.ts56
-rw-r--r--lib/screens/BeginScreen.svelte22
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 {