|
| 1 | +--- |
| 2 | +/* |
| 3 | + * Begin sketch — 起 / ripple pond |
| 4 | + * |
| 5 | + * Stage for essays that want a quiet water-surface accompaniment. Listens |
| 6 | + * for `stage:cue` events dispatched by EssayLayout's IntersectionObserver |
| 7 | + * and shifts between named states. The cue names are pure conventions — |
| 8 | + * any essay whose frontmatter sets stage: 'begin' can use them by |
| 9 | + * annotating its sections with data-cue="<name>". |
| 10 | + * |
| 11 | + * Core cues: |
| 12 | + * empty — nothing spawns; a held breath. |
| 13 | + * drop — one strong centered drop on entry, then quiet. |
| 14 | + * many — frequent random spawns across the whole surface. |
| 15 | + * still — slow, weak spawns that fade gently. |
| 16 | + * |
| 17 | + * Grid-wave cues — ripples spawn at vertices of a tiled lattice, |
| 18 | + * triggered in a staggered sweep. The first wave fires on entry; a |
| 19 | + * fresh wave repeats every ~3.5s until the cue changes. Pattern is |
| 20 | + * "<tiling>-<sweep>": |
| 21 | + * sq-rows / sq-cols / sq-diag |
| 22 | + * hex-rows / hex-cols / hex-diag |
| 23 | + * |
| 24 | + * Size modifiers (persist across cue changes until another modifier |
| 25 | + * cue fires — they affect whatever spawns next, grid or otherwise): |
| 26 | + * small — ripples at ~55% of their normal radius. |
| 27 | + * large — ripples at ~155%. |
| 28 | + * (default is 100%; any non-modifier cue does not reset this.) |
| 29 | + */ |
| 30 | +--- |
| 31 | + |
| 32 | +<canvas id="begin"></canvas> |
| 33 | + |
| 34 | +<script> |
| 35 | + // See ParticleSketch for the init/cleanup lifecycle rationale. Same pattern. |
| 36 | + let currentCleanup: (() => void) | null = null; |
| 37 | + |
| 38 | + type Tiling = 'sq' | 'hex'; |
| 39 | + type Sweep = 'rows' | 'cols' | 'diag'; |
| 40 | + |
| 41 | + function init() { |
| 42 | + if (currentCleanup) return; |
| 43 | + const canvas = document.getElementById('begin') as HTMLCanvasElement | null; |
| 44 | + if (!canvas) return; |
| 45 | + |
| 46 | + const ctx = canvas.getContext('2d')!; |
| 47 | + const dpr = Math.min(window.devicePixelRatio || 1, 1.5); |
| 48 | + let w = 0, h = 0; |
| 49 | + |
| 50 | + function resize() { |
| 51 | + const rect = canvas!.getBoundingClientRect(); |
| 52 | + w = rect.width; |
| 53 | + h = rect.height; |
| 54 | + canvas!.width = w * dpr; |
| 55 | + canvas!.height = h * dpr; |
| 56 | + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); |
| 57 | + } |
| 58 | + resize(); |
| 59 | + |
| 60 | + // A ripple carries its OWN maxR at birth so a cue change mid-flight |
| 61 | + // doesn't resize it. Without this, a tall `drop` ripple whose radius |
| 62 | + // is still expanding would snap to the smaller radius of `many` the |
| 63 | + // instant the cue changes — reads as the animation getting cut. |
| 64 | + type Ripple = { cx: number; cy: number; born: number; strength: number; maxR: number }; |
| 65 | + const ripples: Ripple[] = []; |
| 66 | + const LIFETIME = 8000; |
| 67 | + const RING_COUNT = 4; |
| 68 | + const RING_STAGGER = 0.07; |
| 69 | + const EXPIRE_AT = 1 + (RING_COUNT - 1) * RING_STAGGER + 0.02; |
| 70 | + |
| 71 | + // Base radii for the simple cues. `gap` null ⇒ no random auto-spawn |
| 72 | + // (either disarmed or superseded by a grid wave). `maxR` is a |
| 73 | + // fraction of min(w, h), resolved at spawn time. |
| 74 | + const CUE = { |
| 75 | + empty: { gap: null, maxR: 0.20, strength: 1 }, |
| 76 | + drop: { gap: null, maxR: 0.36, strength: 1 }, |
| 77 | + many: { gap: { min: 900, span: 1800 }, maxR: 0.22, strength: 1 }, |
| 78 | + still: { gap: { min: 4200, span: 5200 }, maxR: 0.18, strength: 0.6 }, |
| 79 | + } as const; |
| 80 | + type SimpleCue = keyof typeof CUE; |
| 81 | + |
| 82 | + // Grid-wave parameters. |
| 83 | + const GRID_RIPPLE_RATIO = 0.09; // per-vertex ripple maxR (frac of min(w,h)) |
| 84 | + const GRID_UNIT_DIV = 5.5; // canvas-short-side / UNIT_DIV ≈ rows across |
| 85 | + const SWEEP_DURATION = 1400; // ms from first vertex fires to last |
| 86 | + const WAVE_INTERVAL = 12000; // ms between repeating waves while cue is held |
| 87 | + |
| 88 | + // Size modifier — multiplies every new ripple's maxR, whether from |
| 89 | + // drop / many / still / grid / pointer. Persists across cue changes |
| 90 | + // until `small` or `large` (or a reset back to 1, currently none). |
| 91 | + let sizeMultiplier = 1; |
| 92 | + |
| 93 | + function spawn(cx: number, cy: number, strength: number, maxRRatio: number) { |
| 94 | + ripples.push({ |
| 95 | + cx, cy, |
| 96 | + born: t, |
| 97 | + strength, |
| 98 | + maxR: Math.min(w, h) * maxRRatio * sizeMultiplier, |
| 99 | + }); |
| 100 | + } |
| 101 | + |
| 102 | + // Time of the most recent centered drop, so re-entering the `drop` |
| 103 | + // cue while the previous drop's ripple is still alive doesn't stack |
| 104 | + // a second set of concentric rings on top of the first. |
| 105 | + let lastDropAt = -Infinity; |
| 106 | + |
| 107 | + // Current primary cue — tracked as free-form string because the |
| 108 | + // grid-wave names are parsed rather than pre-enumerated. |
| 109 | + let cue: string = 'empty'; |
| 110 | + let t = 0; |
| 111 | + let lastNow = performance.now(); |
| 112 | + let paused = false; |
| 113 | + |
| 114 | + // Simple-cue auto-spawn scheduling (many / still). |
| 115 | + let nextSpawnAt = Infinity; |
| 116 | + |
| 117 | + // Grid-wave state. |
| 118 | + let gridTiling: Tiling | null = null; |
| 119 | + let gridSweep: Sweep | null = null; |
| 120 | + let nextWaveAt = Infinity; |
| 121 | + // Scheduled ripples with future `at` timestamps. Drained each frame |
| 122 | + // as `t` crosses their fire time. Cleared on any non-modifier cue |
| 123 | + // change so a stale wave from the previous state can't continue |
| 124 | + // spawning under the new state. |
| 125 | + const schedule: Array<{ x: number; y: number; at: number; maxRRatio: number }> = []; |
| 126 | + |
| 127 | + function computeGridPoints(tiling: Tiling): Array<{ i: number; j: number; x: number; y: number }> { |
| 128 | + const unit = Math.max(48, Math.min(w, h) / GRID_UNIT_DIV); |
| 129 | + const cols = Math.max(3, Math.floor(w / unit)); |
| 130 | + const out: Array<{ i: number; j: number; x: number; y: number }> = []; |
| 131 | + if (tiling === 'sq') { |
| 132 | + const rowStep = unit; |
| 133 | + const rows = Math.max(3, Math.floor(h / rowStep)); |
| 134 | + const xMargin = (w - (cols - 1) * unit) / 2; |
| 135 | + const yMargin = (h - (rows - 1) * rowStep) / 2; |
| 136 | + for (let j = 0; j < rows; j++) |
| 137 | + for (let i = 0; i < cols; i++) |
| 138 | + out.push({ i, j, x: xMargin + i * unit, y: yMargin + j * rowStep }); |
| 139 | + } else { |
| 140 | + // Hex: row step = unit × √3/2, every other row offset by unit/2. |
| 141 | + // Pull the whole field back by unit/4 so the two row variants |
| 142 | + // share a visual center. |
| 143 | + const rowStep = unit * Math.sqrt(3) / 2; |
| 144 | + const rows = Math.max(3, Math.floor(h / rowStep)); |
| 145 | + const xMargin = (w - (cols - 1) * unit) / 2 - unit * 0.25; |
| 146 | + const yMargin = (h - (rows - 1) * rowStep) / 2; |
| 147 | + for (let j = 0; j < rows; j++) { |
| 148 | + const offset = (j % 2) * (unit * 0.5); |
| 149 | + for (let i = 0; i < cols; i++) |
| 150 | + out.push({ i, j, x: xMargin + i * unit + offset, y: yMargin + j * rowStep }); |
| 151 | + } |
| 152 | + } |
| 153 | + return out; |
| 154 | + } |
| 155 | + |
| 156 | + function scheduleWave() { |
| 157 | + if (!gridTiling || !gridSweep) return; |
| 158 | + const points = computeGridPoints(gridTiling); |
| 159 | + let maxRow = 0, maxCol = 0; |
| 160 | + for (const p of points) { |
| 161 | + if (p.j > maxRow) maxRow = p.j; |
| 162 | + if (p.i > maxCol) maxCol = p.i; |
| 163 | + } |
| 164 | + for (const p of points) { |
| 165 | + let frac: number; |
| 166 | + if (gridSweep === 'rows') frac = maxRow > 0 ? p.j / maxRow : 0; |
| 167 | + else if (gridSweep === 'cols') frac = maxCol > 0 ? p.i / maxCol : 0; |
| 168 | + else frac = (maxRow + maxCol) > 0 ? (p.i + p.j) / (maxRow + maxCol) : 0; |
| 169 | + schedule.push({ |
| 170 | + x: p.x, y: p.y, |
| 171 | + at: t + frac * SWEEP_DURATION, |
| 172 | + maxRRatio: GRID_RIPPLE_RATIO, |
| 173 | + }); |
| 174 | + } |
| 175 | + } |
| 176 | + |
| 177 | + function applyCue(next: string) { |
| 178 | + // Size modifiers are pure parameter tweaks — they don't change the |
| 179 | + // active cue or clear schedules. Affect the NEXT ripple only. |
| 180 | + if (next === 'small') { sizeMultiplier = 0.55; return; } |
| 181 | + if (next === 'large') { sizeMultiplier = 1.55; return; } |
| 182 | + |
| 183 | + // Everything below is a cue transition. Clear pending grid ripples |
| 184 | + // so a previous wave doesn't bleed into the new state. |
| 185 | + const wasDrop = cue === 'drop'; |
| 186 | + schedule.length = 0; |
| 187 | + gridTiling = null; |
| 188 | + gridSweep = null; |
| 189 | + nextWaveAt = Infinity; |
| 190 | + |
| 191 | + // Grid-wave cue — format: "<sq|hex>-<rows|cols|diag>" |
| 192 | + const m = next.match(/^(sq|hex)-(rows|cols|diag)$/); |
| 193 | + if (m) { |
| 194 | + gridTiling = m[1] as Tiling; |
| 195 | + gridSweep = m[2] as Sweep; |
| 196 | + cue = next; |
| 197 | + nextSpawnAt = Infinity; // grid overrides random auto-spawn |
| 198 | + nextWaveAt = t + 20; // fire first wave almost immediately |
| 199 | + return; |
| 200 | + } |
| 201 | + |
| 202 | + // Simple cue (empty / drop / many / still). Unknown cue names are |
| 203 | + // swallowed — keeps author typos from blowing up the stage. |
| 204 | + if (!(next in CUE)) return; |
| 205 | + cue = next; |
| 206 | + const g = CUE[next as SimpleCue].gap; |
| 207 | + nextSpawnAt = g ? t + g.min * 0.4 : Infinity; |
| 208 | + |
| 209 | + // Entering `drop` plants one strong centered ripple — that's the |
| 210 | + // whole point of this state. Guards: |
| 211 | + // - wasDrop: re-firing the same cue doesn't re-trigger. |
| 212 | + // - lastDropAt: bouncing through drop quickly won't stack rings |
| 213 | + // on a ripple that's still alive. |
| 214 | + if (next === 'drop' && !wasDrop && t - lastDropAt > LIFETIME) { |
| 215 | + spawn(w * 0.5, h * 0.5, 1.5, CUE.drop.maxR); |
| 216 | + lastDropAt = t; |
| 217 | + } |
| 218 | + } |
| 219 | + |
| 220 | + const onCue = (e: Event) => { |
| 221 | + const d = (e as CustomEvent).detail as { cue?: string } | undefined; |
| 222 | + if (typeof d?.cue === 'string') applyCue(d.cue); |
| 223 | + }; |
| 224 | + const onPointerDown = (e: PointerEvent) => { |
| 225 | + const rect = canvas!.getBoundingClientRect(); |
| 226 | + // Pointer drops take the current cue's radius — they feel like |
| 227 | + // they belong to whatever mood the canvas is in. For grid cues we |
| 228 | + // use the smaller grid-ripple size so a click doesn't overwhelm |
| 229 | + // the ongoing wave. |
| 230 | + const maxR = (cue in CUE) ? CUE[cue as SimpleCue].maxR : GRID_RIPPLE_RATIO; |
| 231 | + spawn(e.clientX - rect.left, e.clientY - rect.top, 1.1, maxR); |
| 232 | + }; |
| 233 | + const onPause = (e: Event) => { |
| 234 | + paused = !!(e as CustomEvent).detail?.paused; |
| 235 | + }; |
| 236 | + |
| 237 | + window.addEventListener('stage:cue', onCue); |
| 238 | + window.addEventListener('stage:pause', onPause); |
| 239 | + window.addEventListener('resize', resize); |
| 240 | + canvas.addEventListener('pointerdown', onPointerDown); |
| 241 | + |
| 242 | + function easeOut(u: number) { return 1 - Math.pow(1 - u, 2.2); } |
| 243 | + |
| 244 | + const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches; |
| 245 | + let rafId = 0; |
| 246 | + let aborted = false; |
| 247 | + |
| 248 | + function drawRipples() { |
| 249 | + ctx.fillStyle = '#0f0f10'; |
| 250 | + ctx.fillRect(0, 0, w, h); |
| 251 | + |
| 252 | + ctx.strokeStyle = '#e6e2d6'; |
| 253 | + ctx.lineWidth = 0.7; |
| 254 | + for (let i = ripples.length - 1; i >= 0; i--) { |
| 255 | + const r = ripples[i]; |
| 256 | + const baseAge = (t - r.born) / LIFETIME; |
| 257 | + if (baseAge >= EXPIRE_AT) { |
| 258 | + ripples.splice(i, 1); |
| 259 | + continue; |
| 260 | + } |
| 261 | + // Use the ripple's OWN maxR, captured at spawn — see Ripple type. |
| 262 | + for (let k = 0; k < RING_COUNT; k++) { |
| 263 | + const ringAge = baseAge - k * RING_STAGGER; |
| 264 | + if (ringAge <= 0 || ringAge >= 1) continue; |
| 265 | + const eased = easeOut(ringAge); |
| 266 | + const radius = eased * r.maxR * r.strength; |
| 267 | + const alpha = Math.sin(ringAge * Math.PI) * 0.34 * (1 - k * 0.18) * r.strength; |
| 268 | + ctx.globalAlpha = alpha; |
| 269 | + ctx.beginPath(); |
| 270 | + ctx.arc(r.cx, r.cy, radius, 0, Math.PI * 2); |
| 271 | + ctx.stroke(); |
| 272 | + } |
| 273 | + } |
| 274 | + ctx.globalAlpha = 1; |
| 275 | + } |
| 276 | + |
| 277 | + function frame() { |
| 278 | + if (aborted) return; |
| 279 | + rafId = requestAnimationFrame(frame); |
| 280 | + |
| 281 | + const now = performance.now(); |
| 282 | + let dt = now - lastNow; |
| 283 | + if (dt > 100) dt = 100; // tab-return guard, same reason as LobbySketch |
| 284 | + lastNow = now; |
| 285 | + if (!paused) t += dt; |
| 286 | + |
| 287 | + // Grid-wave scheduler: while a grid cue is active, enqueue a fresh |
| 288 | + // wave every WAVE_INTERVAL. The wave itself takes SWEEP_DURATION |
| 289 | + // to finish firing; the gap between the END of one wave and the |
| 290 | + // start of the next is ~(WAVE_INTERVAL - SWEEP_DURATION). |
| 291 | + if (!paused && gridTiling && gridSweep && t >= nextWaveAt) { |
| 292 | + scheduleWave(); |
| 293 | + nextWaveAt = t + WAVE_INTERVAL; |
| 294 | + } |
| 295 | + |
| 296 | + // Drain scheduled grid ripples whose fire time has arrived. |
| 297 | + for (let i = schedule.length - 1; i >= 0; i--) { |
| 298 | + if (schedule[i].at <= t) { |
| 299 | + const s = schedule[i]; |
| 300 | + spawn(s.x, s.y, 0.9, s.maxRRatio); |
| 301 | + schedule.splice(i, 1); |
| 302 | + } |
| 303 | + } |
| 304 | + |
| 305 | + // Simple-cue random auto-spawn (many / still). The while-loop |
| 306 | + // catches up if we were tab-backgrounded — but note the dt clamp |
| 307 | + // already prevents a huge burst here. |
| 308 | + while (!paused && t >= nextSpawnAt) { |
| 309 | + if (!(cue in CUE)) { nextSpawnAt = Infinity; break; } |
| 310 | + const g = CUE[cue as SimpleCue].gap; |
| 311 | + if (!g) { nextSpawnAt = Infinity; break; } |
| 312 | + // Capture cue params on the ripple at birth — if cue flips before |
| 313 | + // this ripple dies, it keeps the shape it was born into. |
| 314 | + spawn( |
| 315 | + Math.random() * w, |
| 316 | + Math.random() * h, |
| 317 | + CUE[cue as SimpleCue].strength, |
| 318 | + CUE[cue as SimpleCue].maxR, |
| 319 | + ); |
| 320 | + nextSpawnAt = t + g.min + Math.random() * g.span; |
| 321 | + } |
| 322 | + |
| 323 | + drawRipples(); |
| 324 | + } |
| 325 | + |
| 326 | + if (reduced) { |
| 327 | + // Render a single still frame of a couple mid-life ripples and stop. |
| 328 | + canvas.removeEventListener('pointerdown', onPointerDown); |
| 329 | + ctx.fillStyle = '#0f0f10'; |
| 330 | + ctx.fillRect(0, 0, w, h); |
| 331 | + ctx.strokeStyle = '#e6e2d6'; |
| 332 | + ctx.lineWidth = 0.7; |
| 333 | + const maxR = Math.min(w, h) * 0.22; |
| 334 | + const frozen = [ |
| 335 | + { cx: w * 0.5, cy: h * 0.5, age: 0.35 }, |
| 336 | + { cx: w * 0.32, cy: h * 0.62, age: 0.6 }, |
| 337 | + ]; |
| 338 | + for (const r of frozen) { |
| 339 | + for (let k = 0; k < RING_COUNT; k++) { |
| 340 | + const ringAge = r.age - k * RING_STAGGER; |
| 341 | + if (ringAge <= 0 || ringAge >= 1) continue; |
| 342 | + const eased = easeOut(ringAge); |
| 343 | + ctx.globalAlpha = Math.sin(ringAge * Math.PI) * 0.34 * (1 - k * 0.18); |
| 344 | + ctx.beginPath(); |
| 345 | + ctx.arc(r.cx, r.cy, eased * maxR, 0, Math.PI * 2); |
| 346 | + ctx.stroke(); |
| 347 | + } |
| 348 | + } |
| 349 | + ctx.globalAlpha = 1; |
| 350 | + currentCleanup = () => { |
| 351 | + aborted = true; |
| 352 | + window.removeEventListener('stage:cue', onCue); |
| 353 | + window.removeEventListener('stage:pause', onPause); |
| 354 | + window.removeEventListener('resize', resize); |
| 355 | + currentCleanup = null; |
| 356 | + }; |
| 357 | + document.addEventListener('astro:before-swap', () => currentCleanup?.(), { once: true }); |
| 358 | + return; |
| 359 | + } |
| 360 | + |
| 361 | + frame(); |
| 362 | + |
| 363 | + currentCleanup = () => { |
| 364 | + aborted = true; |
| 365 | + cancelAnimationFrame(rafId); |
| 366 | + window.removeEventListener('stage:cue', onCue); |
| 367 | + window.removeEventListener('stage:pause', onPause); |
| 368 | + window.removeEventListener('resize', resize); |
| 369 | + currentCleanup = null; |
| 370 | + }; |
| 371 | + document.addEventListener('astro:before-swap', () => currentCleanup?.(), { once: true }); |
| 372 | + } |
| 373 | + |
| 374 | + init(); |
| 375 | + document.addEventListener('astro:page-load', init); |
| 376 | +</script> |
| 377 | + |
| 378 | +<style> |
| 379 | + canvas { |
| 380 | + display: block; |
| 381 | + width: 100%; |
| 382 | + height: 100%; |
| 383 | + background: var(--bg); |
| 384 | + touch-action: none; |
| 385 | + } |
| 386 | +</style> |
0 commit comments