← back to Scarlet Riverboat Masquerade
Scarlet Riverboat Masquerade: working single-file animated gallery
a1ca1d2e3ae863d359c1d2bbdac03c16c26340eb · 2026-09-01 16:30:01 -0700 · Steve Abrams
Files touched
A .gitignoreA README.mdA index.html
Diff
commit a1ca1d2e3ae863d359c1d2bbdac03c16c26340eb
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Sep 1 16:30:01 2026 -0700
Scarlet Riverboat Masquerade: working single-file animated gallery
---
.gitignore | 9 +
README.md | 33 +++
index.html | 972 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 1014 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..169aa3b
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+.playwright-mcp/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..df7e7ae
--- /dev/null
+++ b/README.md
@@ -0,0 +1,33 @@
+# Scarlet Riverboat Masquerade — An Animated Gallery
+
+A single-file generative canvas gallery: a fantastical paddlewheel riverboat
+glides through a hand-painted crimson canyon at dusk while masked dancers fill
+three decks and animals loop along the shoreline. Every movement is **authored
+visual rhythm** — a cadence written into the drawing loop — **not** audio
+analysis. There is no sound.
+
+## Run
+Open `index.html` in any modern browser. No build, no dependencies.
+
+## What you can steer
+- **Cameras** — Wide River · Deck · Overhead
+- **Sliders** — Visual Tempo · Lantern Glow · Crowd Density · Animal Visibility
+- **Toggles** — Pause/Play · Restart · Reduced Motion · Motion Trails
+- **Chapters** — I Departure · II Moon Bend · III Dawn Landing
+ (palette *and* choreography change between them; the boat also drifts
+ through them on a gentle auto-journey until you pick a chapter yourself)
+
+## Keyboard
+`Space` play/pause · `R` restart · `M` reduced motion · `T` trails ·
+`1/2/3` cameras · `← →` chapters · `L` museum label. Focus states are visible
+throughout; a live region announces state changes for screen readers.
+
+## Choreography (original visual language)
+- Couples pivot in shared arcs beneath the lanterns (lower deck)
+- A linked circle turns about the smokestack (middle deck / overhead)
+- Soloists spin trailing scarves; musicians appear only as abstract silhouettes
+- Shoreline: a trotting fox, a slow-stepping heron, a squash-and-stretch
+ rabbit, and a bear conducting fireflies — each a distinct weighted loop
+
+No band logos, album art, or borrowed emblems — an original homage to the
+communal, exploratory spirit of the scene.
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..cd04390
--- /dev/null
+++ b/index.html
@@ -0,0 +1,972 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<meta name="description" content="Scarlet Riverboat Masquerade — an authored, animated generative gallery of a paddlewheel riverboat drifting through a crimson canyon at dusk. Steer tempo, lanterns, crowd, animals, cameras and chapters.">
+<meta name="color-scheme" content="dark">
+<title>Scarlet Riverboat Masquerade — An Animated Gallery</title>
+<style>
+ :root{
+ --ink:#f4e7d6;
+ --ink-dim:#c9a98c;
+ --gold:#f2b64a;
+ --crimson:#7c1522;
+ --panel:rgba(24,8,12,0.72);
+ --panel-edge:rgba(242,182,74,0.28);
+ --focus:#ffd27a;
+ --serif:"Iowan Old Style","Palatino Linotype",Palatino,"Book Antiqua",Georgia,serif;
+ --sans:"Avenir Next","Segoe UI",system-ui,-apple-system,sans-serif;
+ }
+ *{box-sizing:border-box}
+ html,body{margin:0;height:100%;background:#0a0406;color:var(--ink);font-family:var(--sans);overflow:hidden}
+ #stage{position:fixed;inset:0;display:block}
+ canvas{position:absolute;inset:0;width:100%;height:100%}
+
+ /* ---- Skip link ---- */
+ .skip{
+ position:fixed;top:-60px;left:12px;z-index:20;background:var(--gold);color:#2a0a0e;
+ padding:10px 16px;border-radius:8px;font-weight:600;text-decoration:none;transition:top .2s;
+ }
+ .skip:focus{top:12px}
+
+ /* ---- Title plate ---- */
+ .titleplate{
+ position:fixed;top:22px;left:26px;z-index:6;max-width:min(46vw,520px);
+ pointer-events:none;text-shadow:0 2px 18px rgba(0,0,0,.7);
+ }
+ .titleplate .kicker{
+ font-family:var(--sans);letter-spacing:.42em;text-transform:uppercase;
+ font-size:11px;color:var(--gold);opacity:.9;margin:0 0 6px 2px;
+ }
+ .titleplate h1{
+ font-family:var(--serif);font-weight:600;font-style:italic;
+ font-size:clamp(26px,4.4vw,52px);line-height:.98;margin:0;color:var(--ink);
+ }
+ .titleplate .chap{
+ font-family:var(--serif);font-size:14px;color:var(--ink-dim);margin-top:8px;font-style:italic;
+ }
+
+ /* ---- Chapter rail ---- */
+ .chapters{
+ position:fixed;top:22px;right:26px;z-index:6;display:flex;gap:8px;flex-direction:column;align-items:flex-end;
+ }
+ .chapters button{
+ font-family:var(--serif);font-size:13px;font-style:italic;
+ background:var(--panel);color:var(--ink-dim);border:1px solid transparent;
+ border-radius:999px;padding:7px 15px;cursor:pointer;backdrop-filter:blur(6px);
+ transition:color .3s,border-color .3s,background .3s;
+ display:flex;align-items:center;gap:8px;
+ }
+ .chapters button .dot{width:7px;height:7px;border-radius:50%;background:currentColor;opacity:.6}
+ .chapters button[aria-current="true"]{color:var(--gold);border-color:var(--panel-edge);background:rgba(40,12,16,.86)}
+ .chapters button:hover{color:var(--ink)}
+
+ /* ---- Control dock ---- */
+ .dock{
+ position:fixed;left:50%;bottom:20px;transform:translateX(-50%);z-index:7;
+ display:flex;gap:18px;align-items:stretch;flex-wrap:wrap;justify-content:center;
+ max-width:min(96vw,1080px);
+ background:var(--panel);border:1px solid var(--panel-edge);border-radius:16px;
+ padding:14px 20px;backdrop-filter:blur(9px);box-shadow:0 12px 40px rgba(0,0,0,.5);
+ }
+ .group{display:flex;gap:12px;align-items:center}
+ .group + .group{border-left:1px solid rgba(242,182,74,.16);padding-left:18px}
+ .field{display:flex;flex-direction:column;gap:5px;min-width:96px}
+ .field label{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-dim)}
+ .field .val{font-family:var(--serif);font-style:italic;color:var(--gold);font-size:12px}
+ input[type=range]{
+ -webkit-appearance:none;appearance:none;height:4px;border-radius:4px;
+ background:linear-gradient(90deg,var(--crimson),var(--gold));outline:none;width:100%;
+ }
+ input[type=range]::-webkit-slider-thumb{
+ -webkit-appearance:none;width:16px;height:16px;border-radius:50%;
+ background:var(--ink);border:2px solid var(--gold);cursor:pointer;box-shadow:0 0 8px rgba(242,182,74,.6);
+ }
+ input[type=range]::-moz-range-thumb{
+ width:16px;height:16px;border-radius:50%;background:var(--ink);border:2px solid var(--gold);cursor:pointer;
+ }
+ .btn{
+ font-family:var(--sans);font-size:12px;letter-spacing:.05em;
+ background:rgba(40,12,16,.7);color:var(--ink);border:1px solid var(--panel-edge);
+ border-radius:10px;padding:9px 13px;cursor:pointer;transition:background .2s,color .2s;
+ display:inline-flex;align-items:center;gap:7px;white-space:nowrap;
+ }
+ .btn:hover{background:rgba(70,20,26,.9);color:var(--gold)}
+ .btn[aria-pressed="true"]{background:var(--gold);color:#2a0a0e;border-color:var(--gold);font-weight:600}
+ .toggle{display:flex;flex-direction:column;gap:6px}
+
+ /* ---- Camera pills ---- */
+ .cams{position:fixed;left:26px;bottom:20px;z-index:7;display:flex;gap:8px;flex-direction:column}
+ .cams .btn{justify-content:flex-start;min-width:118px}
+
+ /* ---- Museum label ---- */
+ .label-toggle{position:fixed;right:26px;bottom:20px;z-index:7}
+ .museum{
+ position:fixed;right:26px;bottom:66px;z-index:8;width:min(88vw,368px);max-height:74vh;overflow:auto;
+ background:linear-gradient(180deg,rgba(28,10,14,.96),rgba(16,6,9,.96));
+ border:1px solid var(--panel-edge);border-radius:14px;padding:20px 22px;
+ box-shadow:0 18px 50px rgba(0,0,0,.6);transform-origin:bottom right;
+ transition:opacity .3s,transform .3s;font-size:13px;line-height:1.55;color:var(--ink-dim);
+ }
+ .museum[hidden]{display:none}
+ .museum h2{font-family:var(--serif);font-style:italic;color:var(--gold);font-size:18px;margin:0 0 4px}
+ .museum .meta{font-size:10px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-dim);margin-bottom:12px;opacity:.8}
+ .museum p{margin:0 0 10px}
+ .museum strong{color:var(--ink);font-weight:600}
+ .museum .keys{margin:14px 0 0;padding:12px 0 0;border-top:1px solid rgba(242,182,74,.16);
+ font-size:11px;line-height:1.7;letter-spacing:.02em}
+ .museum .keys b{display:inline-block;min-width:74px;color:var(--gold);font-family:var(--serif);font-style:italic;font-weight:600}
+
+ :focus-visible{outline:3px solid var(--focus);outline-offset:2px;border-radius:6px}
+ .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
+
+ @media (max-width:820px){
+ .titleplate{max-width:64vw}
+ .dock{gap:10px;padding:10px 12px}
+ .group + .group{padding-left:10px}
+ .field{min-width:74px}
+ .cams{flex-direction:row;left:12px;bottom:100px}
+ .cams .btn{min-width:auto;padding:7px 10px}
+ .label-toggle{bottom:100px;right:12px}
+ .museum{bottom:148px;right:12px}
+ .chapters{top:auto;bottom:100px;right:12px;flex-direction:row}
+ }
+ @media (prefers-reduced-motion: reduce){
+ .museum,.chapters button,.btn,.skip{transition:none}
+ }
+</style>
+</head>
+<body>
+<a href="#dock" class="skip">Skip to controls</a>
+<main id="stage" role="application" aria-label="Scarlet Riverboat Masquerade — an animated visual gallery you can steer with the controls and keyboard">
+ <h1 class="sr">Scarlet Riverboat Masquerade — an animated generative gallery</h1>
+ <canvas id="sky" aria-hidden="true"></canvas>
+ <canvas id="scene" aria-hidden="true"></canvas>
+
+ <div class="titleplate">
+ <p class="kicker">An Animated Gallery</p>
+ <h1 aria-hidden="true">Scarlet Riverboat<br>Masquerade</h1>
+ <p class="chap" id="chapLabel">Chapter I · Departure</p>
+ </div>
+
+ <nav class="chapters" aria-label="Chapters">
+ <button data-chapter="0" aria-current="true"><span class="dot" aria-hidden="true"></span>I · Departure</button>
+ <button data-chapter="1"><span class="dot" aria-hidden="true"></span>II · Moon Bend</button>
+ <button data-chapter="2"><span class="dot" aria-hidden="true"></span>III · Dawn Landing</button>
+ </nav>
+
+ <div class="cams" role="group" aria-label="Camera view">
+ <button class="btn" data-cam="wide" aria-pressed="true">◈ Wide River</button>
+ <button class="btn" data-cam="deck" aria-pressed="false">◈ Deck</button>
+ <button class="btn" data-cam="over" aria-pressed="false">◈ Overhead</button>
+ </div>
+
+ <div class="dock" id="dock" role="group" aria-label="Visual controls">
+ <div class="group">
+ <div class="toggle">
+ <button class="btn" id="playBtn" aria-pressed="true">❚❚ Pause</button>
+ <button class="btn" id="restartBtn">↻ Restart</button>
+ </div>
+ <div class="toggle">
+ <button class="btn" id="reduceBtn" aria-pressed="false">◐ Reduced Motion</button>
+ <button class="btn" id="trailBtn" aria-pressed="true">✧ Motion Trails</button>
+ </div>
+ </div>
+ <div class="group">
+ <div class="field">
+ <label for="tempo">Visual Tempo <span class="val" id="tempoV">Moderato</span></label>
+ <input type="range" id="tempo" min="0" max="200" value="100" aria-valuetext="Moderato">
+ </div>
+ <div class="field">
+ <label for="lantern">Lantern Glow <span class="val" id="lanternV">Warm</span></label>
+ <input type="range" id="lantern" min="0" max="100" value="70" aria-valuetext="Warm">
+ </div>
+ <div class="field">
+ <label for="crowd">Crowd Density <span class="val" id="crowdV">Full</span></label>
+ <input type="range" id="crowd" min="0" max="100" value="80" aria-valuetext="Full">
+ </div>
+ <div class="field">
+ <label for="animals">Animal Visibility <span class="val" id="animalsV">All</span></label>
+ <input type="range" id="animals" min="0" max="100" value="100" aria-valuetext="All">
+ </div>
+ </div>
+ </div>
+
+ <button class="btn label-toggle" id="labelBtn" aria-expanded="false" aria-controls="museum">🏷 Museum Label</button>
+ <aside class="museum" id="museum" hidden aria-label="Museum label">
+ <h2>Scarlet Riverboat Masquerade</h2>
+ <div class="meta">Generative Canvas · Authored Visual Rhythm · Single File</div>
+ <p>A fantastical paddlewheel riverboat drifts through a hand-painted crimson canyon while masked revelers fill three decks. <strong>Every movement here is composed, not measured</strong> — the tempo you steer is an <em>authored visual cadence</em> written into the drawing loop itself, never an analysis of sound. There is no audio; the "rhythm" is pure choreography of light and line.</p>
+ <p>The <strong>original visual language</strong> reduces each dancer to a weighted silhouette: couples pivot in shared arcs, a linked circle turns about the smokestack, soloists trail scarves, and musicians appear only as abstract shadow-forms — a communal, exploratory spirit rendered without any borrowed logo, emblem, or album art.</p>
+ <p>On the shore, four creatures each carry a distinct loop with believable weight — a <strong>trotting fox</strong>, a <strong>slow-stepping heron</strong>, a <strong>squash-and-stretch rabbit</strong>, and a <strong>bear conducting fireflies</strong>. Palette <em>and</em> choreography change across the three chapters as the boat travels from dusk (Departure) through moonrise (Moon Bend) into sunrise (Dawn Landing).</p>
+ <div class="keys">
+ <div><b>Space</b> pause / play</div>
+ <div><b>R</b> restart the journey</div>
+ <div><b>M</b> reduced motion</div>
+ <div><b>T</b> motion trails</div>
+ <div><b>1 2 3</b> wide · deck · overhead</div>
+ <div><b>← →</b> previous / next chapter</div>
+ <div><b>L</b> this museum label</div>
+ </div>
+ </aside>
+
+ <p class="sr" aria-live="polite" id="live">Wide river view. Chapter one, Departure.</p>
+</main>
+
+<script>
+(() => {
+"use strict";
+const sky = document.getElementById('sky');
+const scene = document.getElementById('scene');
+const sctx = sky.getContext('2d');
+const ctx = scene.getContext('2d');
+const live = document.getElementById('live');
+
+// ---------- State ----------
+const S = {
+ t: 0, // authored master clock (visual beats)
+ playing: true,
+ reduced: false,
+ trails: true,
+ tempo: 1.0, // 0..2
+ lantern: 0.7,
+ crowd: 0.8,
+ animals: 1.0,
+ cam: 'wide', // wide | deck | over
+ chapter: 0, // 0..2 (target)
+ chapterMix: 0, // eased 0..2 for palette blend
+ autoJourney: true, // gentle auto-advance until the visitor picks a chapter
+ journeyClock: 0, // seconds accrued toward the next auto-advance
+ W: 0, H: 0, DPR: 1
+};
+const CHAPTER_SECONDS = 55; // dwell before the boat drifts to the next chapter
+
+// Respect OS reduced-motion at load
+if (window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches){
+ S.reduced = true;
+}
+
+// ---------- Palettes + per-chapter choreography ----------
+const CHAPTERS = [
+ { // Departure — dusk crimson/gold, couples lead, easy circle
+ name:'Departure',
+ sky:['#2a0a12','#6d1220','#c0432b','#e6893f'],
+ canyon:['#3a0d16','#7a1a25','#521018'],
+ river:'#5a1420', riverHi:'#c95a3a',
+ lantern:'#ffcf6e', dancer:'rgba(20,4,8,0.92)', accent:'#f2b64a',
+ choreo:{ ringDir:1, ringSpeed:0.45, coupleTurn:1.0, scarf:0.9, ringSway:1.0 }
+ },
+ { // Moon Bend — deep violet/blue night, the linked circle dominates and quickens
+ name:'Moon Bend',
+ sky:['#0a0620','#241245','#4a1c52','#7a2b46'],
+ canyon:['#180a2a','#341a44','#20102e'],
+ river:'#1a1030', riverHi:'#8a6bd0',
+ lantern:'#ffe08a', dancer:'rgba(8,4,18,0.92)', accent:'#c9a2ff',
+ choreo:{ ringDir:1, ringSpeed:0.9, coupleTurn:0.7, scarf:1.15, ringSway:1.4 }
+ },
+ { // Dawn Landing — rose/amber sunrise, soloists + scarves take the floor, circle reverses
+ name:'Dawn Landing',
+ sky:['#3a1428','#8a3550','#d97a5a','#f6c88a'],
+ canyon:['#4a1a2a','#8a3a44','#5c2230'],
+ river:'#6a2438', riverHi:'#f6b06a',
+ lantern:'#fff0c0', dancer:'rgba(28,10,14,0.9)', accent:'#ffcf8a',
+ choreo:{ ringDir:-1, ringSpeed:0.4, coupleTurn:1.25, scarf:1.5, ringSway:0.8 }
+ }
+];
+
+function lerp(a,b,t){return a+(b-a)*t;}
+function clamp(v,lo,hi){return Math.max(lo,Math.min(hi,v));}
+function hexToRgb(h){h=h.replace('#','');return [parseInt(h.substr(0,2),16),parseInt(h.substr(2,2),16),parseInt(h.substr(4,2),16)];}
+function mixHex(h1,h2,t){const a=hexToRgb(h1),b=hexToRgb(h2);return `rgb(${Math.round(lerp(a[0],b[0],t))},${Math.round(lerp(a[1],b[1],t))},${Math.round(lerp(a[2],b[2],t))})`;}
+function pal(key,i){ // blended palette between integer chapters
+ const m = S.chapterMix; const lo=Math.floor(m), hi=Math.min(2,lo+1), f=m-lo;
+ const A=CHAPTERS[lo][key], B=CHAPTERS[hi][key];
+ if(Array.isArray(A)) return mixHex(A[i],B[i],f);
+ return mixHex(A,B,f);
+}
+// Choreography snaps to the nearest chapter (palette blends, steps change crisply)
+function CH(){ return CHAPTERS[clamp(Math.round(S.chapterMix),0,2)].choreo; }
+
+// ---------- Resize ----------
+function resize(){
+ S.DPR = Math.min(2, window.devicePixelRatio||1);
+ S.W = window.innerWidth; S.H = window.innerHeight;
+ [sky,scene].forEach(c=>{
+ c.width = Math.max(1,Math.round(S.W*S.DPR)); c.height = Math.max(1,Math.round(S.H*S.DPR));
+ c.getContext('2d').setTransform(S.DPR,0,0,S.DPR,0,0);
+ });
+ buildSky();
+}
+window.addEventListener('resize', resize);
+
+// ---------- Sky (redrawn on chapter change / resize) ----------
+let skyDirty = true;
+function buildSky(){ skyDirty = true; }
+function drawSky(){
+ const W=S.W,H=S.H;
+ const g = sctx.createLinearGradient(0,0,0,H*0.72);
+ g.addColorStop(0, pal('sky',0));
+ g.addColorStop(0.45, pal('sky',1));
+ g.addColorStop(0.8, pal('sky',2));
+ g.addColorStop(1, pal('sky',3));
+ sctx.clearRect(0,0,W,H);
+ sctx.fillStyle=g; sctx.fillRect(0,0,W,H*0.74);
+
+ // Moon / sun — position drifts with the journey
+ const cx = lerp(W*0.78, W*0.28, S.chapterMix/2);
+ const cy = lerp(H*0.18, H*0.30, Math.sin(S.chapterMix/2*Math.PI));
+ const r = lerp(46, 62, S.chapterMix/2);
+ const glow = sctx.createRadialGradient(cx,cy,4,cx,cy,r*3.2);
+ glow.addColorStop(0, pal('lantern',0)); glow.addColorStop(0.15, pal('accent',0));
+ glow.addColorStop(1,'rgba(0,0,0,0)');
+ sctx.fillStyle=glow; sctx.beginPath(); sctx.arc(cx,cy,r*3.2,0,7); sctx.fill();
+ sctx.fillStyle=pal('lantern',0); sctx.beginPath(); sctx.arc(cx,cy,r,0,7); sctx.fill();
+
+ // Stars, densest at Moon Bend
+ const starA = Math.max(0, 1-Math.abs(S.chapterMix-1)) * 0.9;
+ if(starA>0.02){
+ sctx.fillStyle=`rgba(255,240,210,${starA})`;
+ for(let i=0;i<120;i++){
+ const sx=(i*137.5)%W, sy=((i*89.3)%(H*0.5));
+ const tw = 0.4+0.6*Math.abs(Math.sin(i*1.7));
+ sctx.globalAlpha=starA*tw; sctx.fillRect(sx,sy,1.5,1.5);
+ }
+ sctx.globalAlpha=1;
+ }
+ skyDirty=false;
+}
+
+// ---------- Canyon layers (parallax, far -> near) ----------
+function canyonSilhouette(baseY, amp, seed, color, offset){
+ const W=S.W,H=S.H;
+ ctx.fillStyle=color;
+ ctx.beginPath();
+ ctx.moveTo(0,H);
+ ctx.lineTo(0,baseY);
+ const step = W/14;
+ for(let x=0;x<=W+step;x+=step){
+ const n = Math.sin((x*0.006)+seed) + 0.5*Math.sin((x*0.017)+seed*2.3);
+ const y = baseY - amp*(0.6+0.4*n) - offset;
+ ctx.lineTo(x, y);
+ }
+ ctx.lineTo(W,H);
+ ctx.closePath();
+ ctx.fill();
+}
+
+// ---------- Boat transform for the active camera ----------
+function boatTransform(){
+ const W=S.W,H=S.H;
+ const bob = S.reduced?0:Math.sin(S.t*0.6)*6;
+ const sway = S.reduced?0:Math.sin(S.t*0.33)*0.012;
+ let cx=W*0.5, cy=H*0.60+bob, scale=Math.min(W,H)/620, rot=sway;
+ if(S.cam==='deck'){ cx=W*0.5; cy=H*0.66+bob; scale=Math.min(W,H)/300; rot=sway*0.6; }
+ if(S.cam==='over'){ cx=W*0.5; cy=H*0.52; scale=Math.min(W,H)/540; rot=0; }
+ return {cx,cy,scale,rot,bob};
+}
+
+// ---------- Dancers ----------
+function drawFigure(x,y,s,phase,type,accent){
+ const t=S.t, ch=CH();
+ ctx.save();
+ ctx.translate(x,y);
+ ctx.scale(s,s);
+
+ let turn=0, lean=0, bob=0, scarf=0, armSwing=0;
+ const ph = phase;
+ if(type==='couple'){
+ turn = Math.sin(t*1.1+ph)*0.9*ch.coupleTurn; // shared pivot, chapter-weighted
+ lean = Math.sin(t*1.1+ph)*0.12*ch.coupleTurn;
+ bob = Math.abs(Math.sin(t*1.1+ph))* -3; // rise on the turn
+ armSwing = 0.7;
+ } else if(type==='circle'){
+ turn = Math.sin(t*0.8+ph)*0.35;
+ bob = Math.abs(Math.sin(t*1.6+ph))*-2.4;
+ lean = Math.sin(t*0.8+ph)*0.05;
+ armSwing=0.4;
+ } else if(type==='solo'){
+ turn = (t*1.8+ph)%(Math.PI*2); // continuous spin
+ scarf = 1; bob = Math.abs(Math.sin(t*2.2+ph))*-2;
+ armSwing=1.0;
+ } else if(type==='musician'){
+ bob = Math.sin(t*1.4+ph)*-1.6;
+ armSwing = 0.5+0.3*Math.sin(t*2+ph);
+ }
+ ctx.rotate(lean);
+ ctx.translate(0,bob);
+
+ // scarf trail for soloists — length/opacity swell across chapters
+ if(type==='solo' && scarf){
+ ctx.save();
+ ctx.rotate(turn);
+ ctx.strokeStyle=accent; ctx.globalAlpha=0.5*ch.scarf; ctx.lineWidth=2.2;
+ ctx.beginPath();
+ const seg = Math.round(10*ch.scarf);
+ for(let i=0;i<seg;i++){
+ const a=i*0.5, rr=8+i*2.4;
+ const px=Math.cos(a+t*3)* rr, py=-14+Math.sin(a+t*3)*rr*0.4;
+ i===0?ctx.moveTo(px,py):ctx.lineTo(px,py);
+ }
+ ctx.stroke(); ctx.globalAlpha=1; ctx.restore();
+ }
+
+ ctx.rotate(turn*0.28);
+ ctx.fillStyle=pal('dancer',0);
+
+ // head (with a small mask mark)
+ ctx.beginPath(); ctx.arc(0,-22,4.6,0,7); ctx.fill();
+ ctx.fillStyle=accent; ctx.globalAlpha=0.85;
+ ctx.fillRect(-3.4,-24,6.8,2.4); // mask band
+ ctx.globalAlpha=1; ctx.fillStyle=pal('dancer',0);
+
+ // torso (tapered)
+ ctx.beginPath();
+ ctx.moveTo(-4,-18); ctx.quadraticCurveTo(0,-6,-3,4);
+ ctx.lineTo(3,4); ctx.quadraticCurveTo(0,-6,4,-18);
+ ctx.closePath(); ctx.fill();
+
+ // arms — swing conveys weight/effort
+ const sw = Math.sin(t*(type==='musician'?2:1.4)+ph)*armSwing;
+ ctx.lineWidth=2.6; ctx.strokeStyle=pal('dancer',0); ctx.lineCap='round';
+ ctx.beginPath();
+ ctx.moveTo(-3,-15); ctx.lineTo(-9-sw*4,-10+sw*3);
+ ctx.moveTo(3,-15); ctx.lineTo(9-sw*4,-10-sw*3);
+ ctx.stroke();
+
+ // legs — bent-knee push (the source of the bob)
+ const legPush = Math.abs(Math.sin(t*(type==='solo'?2.2:1.1)+ph));
+ ctx.beginPath();
+ ctx.moveTo(-2,3); ctx.lineTo(-3-legPush*1.5, 16);
+ ctx.moveTo(2,3); ctx.lineTo(3+ (1-legPush)*1.5, 16);
+ ctx.stroke();
+
+ // musician's instrument = abstract shadow bar
+ if(type==='musician'){
+ ctx.strokeStyle=accent; ctx.globalAlpha=0.5; ctx.lineWidth=2;
+ ctx.beginPath(); ctx.moveTo(4,-14); ctx.lineTo(14,-18); ctx.stroke();
+ ctx.globalAlpha=1;
+ }
+ ctx.restore();
+}
+
+// Little lantern with glow
+function lantern(x,y,s,glow,color){
+ if(glow>0.02){
+ const g=ctx.createRadialGradient(x,y,1,x,y,26*s*(0.5+glow));
+ g.addColorStop(0,color); g.addColorStop(0.4,`rgba(255,190,90,${0.35*glow})`);
+ g.addColorStop(1,'rgba(0,0,0,0)');
+ ctx.fillStyle=g; ctx.beginPath(); ctx.arc(x,y,26*s*(0.5+glow),0,7); ctx.fill();
+ }
+ ctx.fillStyle=color; ctx.beginPath(); ctx.arc(x,y,2.6*s,0,7); ctx.fill();
+}
+
+// ---------- The Riverboat ----------
+function drawBoat(){
+ const {cx,cy,scale,rot}=boatTransform();
+ const accent=pal('accent',0), lan=pal('lantern',0);
+ const glow=S.lantern;
+ ctx.save();
+ ctx.translate(cx,cy); ctx.rotate(rot); ctx.scale(scale,scale);
+
+ if(S.cam==='over'){ drawBoatOverhead(accent,lan,glow); ctx.restore(); return; }
+
+ // ----- hull -----
+ ctx.fillStyle='rgba(30,8,12,0.96)';
+ ctx.beginPath();
+ ctx.moveTo(-230,40);
+ ctx.quadraticCurveTo(-260,10,-210,4);
+ ctx.lineTo(210,4);
+ ctx.quadraticCurveTo(262,10,232,40);
+ ctx.quadraticCurveTo(0,78,-230,40);
+ ctx.closePath(); ctx.fill();
+ // waterline highlight
+ ctx.strokeStyle=pal('riverHi',0); ctx.globalAlpha=0.5; ctx.lineWidth=2;
+ ctx.beginPath(); ctx.moveTo(-215,42); ctx.quadraticCurveTo(0,72,215,42); ctx.stroke();
+ ctx.globalAlpha=1;
+
+ // ----- three decks -----
+ const deckDefs=[
+ {y:4, w:200, h:-34, n:9}, // lower
+ {y:-30,w:160, h:-30, n:7}, // middle
+ {y:-60,w:120, h:-26, n:5} // upper
+ ];
+ deckDefs.forEach((d,di)=>{
+ ctx.fillStyle='rgba(46,14,18,0.95)';
+ ctx.fillRect(-d.w, d.y+d.h, d.w*2, 6);
+ ctx.strokeStyle='rgba(70,24,28,0.9)'; ctx.lineWidth=2;
+ for(let px=-d.w; px<=d.w; px+=d.w/6){
+ ctx.beginPath(); ctx.moveTo(px,d.y+d.h); ctx.lineTo(px,d.y+d.h+6); ctx.stroke();
+ }
+ const nl = 6;
+ for(let i=0;i<=nl;i++){
+ const lx=-d.w + (2*d.w)*(i/nl);
+ const flick = S.reduced?1:(0.85+0.15*Math.sin(S.t*3+i+di));
+ lantern(lx, d.y+d.h-4, 1, glow*flick, lan);
+ }
+ });
+
+ // ----- smokestacks -----
+ ctx.fillStyle='rgba(24,6,10,0.98)';
+ [-40,40].forEach(sx=>{
+ ctx.fillRect(sx-7,-140,14,86);
+ ctx.fillStyle=accent; ctx.globalAlpha=0.6; ctx.fillRect(sx-9,-142,18,6); ctx.globalAlpha=1;
+ ctx.fillStyle='rgba(24,6,10,0.98)';
+ });
+ // smoke drift
+ if(!S.reduced){
+ ctx.fillStyle=`rgba(200,140,120,0.10)`;
+ for(let i=0;i<6;i++){
+ const t=S.t*0.5+i; const sx=-40+Math.sin(t)*8;
+ ctx.beginPath(); ctx.arc(sx-Math.sin(t*0.4)*30, -150-i*16-(S.t*4%20), 8+i*2,0,7); ctx.fill();
+ }
+ }
+
+ // ----- pilot house -----
+ ctx.fillStyle='rgba(40,12,16,0.98)';
+ ctx.fillRect(-30,-104,60,30);
+ ctx.fillStyle=accent; ctx.globalAlpha=0.35; ctx.fillRect(-24,-98,48,10); ctx.globalAlpha=1;
+
+ // ----- paddlewheel (right side) -----
+ drawPaddlewheel(232,26,accent);
+
+ // ----- populate decks with dancers -----
+ populateDecks(deckDefs,accent);
+
+ ctx.restore();
+}
+
+function drawPaddlewheel(x,y,accent){
+ const spin = S.reduced? 0 : S.t*2.1;
+ ctx.save(); ctx.translate(x,y);
+ ctx.strokeStyle='rgba(60,20,24,0.9)'; ctx.lineWidth=3;
+ ctx.beginPath(); ctx.arc(0,0,34,0,7); ctx.stroke();
+ ctx.save(); ctx.rotate(spin);
+ ctx.strokeStyle=accent; ctx.lineWidth=2.4; ctx.globalAlpha=0.85;
+ for(let i=0;i<8;i++){
+ ctx.rotate(Math.PI/4);
+ ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(0,-32); ctx.stroke();
+ ctx.fillStyle='rgba(50,16,20,0.9)';
+ ctx.fillRect(-9,-34,18,7);
+ }
+ ctx.restore();
+ ctx.globalAlpha=1;
+ ctx.fillStyle=accent; ctx.beginPath(); ctx.arc(0,0,4,0,7); ctx.fill();
+ if(!S.reduced){
+ ctx.fillStyle='rgba(240,200,170,0.18)';
+ for(let i=0;i<5;i++){ const a=(S.t*3+i)%1; ctx.beginPath(); ctx.arc(6+a*24,34+Math.sin(i)*3,3-a*2,0,7); ctx.fill();}
+ }
+ ctx.restore();
+}
+
+function populateDecks(deckDefs, accent){
+ const density = S.crowd, ch = CH();
+ // Deck 0 (lower): couples turning beneath lanterns
+ const d0=deckDefs[0];
+ const nCouples = Math.round(lerp(0,5,density));
+ for(let i=0;i<nCouples;i++){
+ const gx = -d0.w+30 + (2*d0.w-60)*(nCouples<=1?0.5:i/(nCouples-1));
+ const gap = 9+Math.sin(S.t*1.1+i)*3;
+ drawFigure(gx-gap, d0.y+d0.h-2, 0.62, i*1.3, 'couple', accent);
+ drawFigure(gx+gap, d0.y+d0.h-2, 0.62, i*1.3+Math.PI, 'couple', accent);
+ }
+ // Deck 1 (middle): linked circle around the smokestack + a soloist
+ const d1=deckDefs[1];
+ const ringN = Math.round(lerp(3,8,density));
+ const ringR = 46, ringSpin = S.reduced?0:S.t*0.5*ch.ringSpeed*ch.ringDir;
+ // linking ribbon between the circle dancers
+ ctx.strokeStyle=accent; ctx.globalAlpha=0.28; ctx.lineWidth=1.6;
+ ctx.beginPath();
+ for(let i=0;i<=ringN;i++){
+ const a=(i/ringN)*Math.PI*2+ringSpin;
+ const rx=Math.cos(a)*ringR, ry=d1.y+d1.h-2+Math.sin(a)*6*ch.ringSway;
+ i===0?ctx.moveTo(rx,ry):ctx.lineTo(rx,ry);
+ }
+ ctx.stroke(); ctx.globalAlpha=1;
+ for(let i=0;i<ringN;i++){
+ const a = (i/ringN)*Math.PI*2 + ringSpin;
+ const rx = Math.cos(a)*ringR, rz=Math.sin(a)*0.42;
+ const fx = rx, fy = d1.y+d1.h-2 + Math.sin(a)*6*ch.ringSway;
+ drawFigure(fx, fy, 0.5*(0.8+rz*0.2+0.2), i, 'circle', accent);
+ }
+ if(density>0.3) drawFigure(d1.w-30, d1.y+d1.h-2, 0.6, 2.0, 'solo', accent);
+
+ // Deck 2 (upper): musicians as abstract silhouettes + a soloist
+ const d2=deckDefs[2];
+ const nMus = Math.round(lerp(1,4,density));
+ for(let i=0;i<nMus;i++){
+ const gx=-d2.w+26 + (2*d2.w-52)*(i/Math.max(1,nMus));
+ drawFigure(gx, d2.y+d2.h-2, 0.55, i*2.1, 'musician', accent);
+ }
+ if(density>0.5) drawFigure(0, d2.y+d2.h-2, 0.58, 1.1, 'solo', accent);
+}
+
+// Overhead camera: top-down of the circle around the smokestack
+function drawBoatOverhead(accent,lan,glow){
+ const ch=CH();
+ ctx.fillStyle='rgba(38,12,16,0.92)';
+ ctx.beginPath(); ctx.ellipse(0,0,190,110,0,0,7); ctx.fill();
+ ctx.strokeStyle='rgba(70,24,28,0.8)'; ctx.lineWidth=3;
+ ctx.beginPath(); ctx.ellipse(0,0,190,110,0,0,7); ctx.stroke();
+ // bow marker
+ ctx.fillStyle=accent; ctx.globalAlpha=0.5;
+ ctx.beginPath(); ctx.moveTo(0,-118); ctx.lineTo(-10,-102); ctx.lineTo(10,-102); ctx.closePath(); ctx.fill();
+ ctx.globalAlpha=1;
+ // central smokestack (top)
+ ctx.fillStyle='rgba(20,6,10,0.98)';
+ ctx.beginPath(); ctx.arc(0,0,16,0,7); ctx.fill();
+ ctx.strokeStyle=accent; ctx.globalAlpha=0.6; ctx.beginPath(); ctx.arc(0,0,16,0,7); ctx.stroke(); ctx.globalAlpha=1;
+ // linked circle rotating around it — the marquee choreography, seen from above
+ const ringN=Math.round(lerp(4,12,S.crowd)); const R=78; const spin=S.reduced?0:S.t*0.55*ch.ringSpeed*ch.ringDir;
+ ctx.strokeStyle=accent; ctx.globalAlpha=0.35; ctx.lineWidth=2;
+ ctx.beginPath();
+ for(let i=0;i<=ringN;i++){const a=(i/ringN)*Math.PI*2+spin; const x=Math.cos(a)*R,y=Math.sin(a)*R*0.62; i===0?ctx.moveTo(x,y):ctx.lineTo(x,y);}
+ ctx.closePath(); ctx.stroke(); ctx.globalAlpha=1;
+ for(let i=0;i<ringN;i++){
+ const a=(i/ringN)*Math.PI*2+spin;
+ const x=Math.cos(a)*R, y=Math.sin(a)*R*0.62;
+ ctx.save(); ctx.translate(x,y);
+ ctx.fillStyle=pal('dancer',0); ctx.beginPath(); ctx.arc(0,0,5,0,7); ctx.fill();
+ ctx.fillStyle=accent; ctx.globalAlpha=0.8; ctx.fillRect(-4,-1.5,8,3); ctx.globalAlpha=1;
+ ctx.restore();
+ lantern(Math.cos(a)*(R+22),Math.sin(a)*(R+22)*0.62,0.7,glow*0.8,lan);
+ }
+ // couples as small paired dots on the outer deck
+ const nc=Math.round(lerp(0,6,S.crowd));
+ for(let i=0;i<nc;i++){
+ const a=(i/Math.max(1,nc))*Math.PI*2 - spin*0.6; const r=150;
+ const x=Math.cos(a)*r, y=Math.sin(a)*r*0.6;
+ const g=6+Math.sin(S.t*1.1+i)*3;
+ ctx.fillStyle=pal('dancer',0);
+ ctx.beginPath(); ctx.arc(x-g,y,3.4,0,7); ctx.fill();
+ ctx.beginPath(); ctx.arc(x+g,y,3.4,0,7); ctx.fill();
+ }
+}
+
+// ---------- Shoreline animals ----------
+function drawAnimals(){
+ if(S.animals<=0.02) return;
+ const W=S.W,H=S.H;
+ const baseY=H*0.78;
+ const accent=pal('accent',0);
+ ctx.save();
+ ctx.globalAlpha=Math.min(1,S.animals);
+ // shore band
+ ctx.fillStyle=pal('canyon',0);
+ ctx.beginPath(); ctx.moveTo(0,baseY);
+ for(let x=0;x<=W;x+=40){ctx.lineTo(x,baseY+Math.sin(x*0.02)*6);}
+ ctx.lineTo(W,H); ctx.lineTo(0,H); ctx.fill();
+
+ const t=S.t;
+ const scaleA = S.cam==='deck'?1.15:1;
+
+ fox(W*0.16, baseY-6, 1*scaleA, t, accent);
+ heron(W*0.40, baseY-4, 1*scaleA, t, accent);
+ rabbit(W*0.63, baseY-4, 1*scaleA, t, accent);
+ bearAndFireflies(W*0.85, baseY-2, 1.1*scaleA, t, accent);
+
+ ctx.restore();
+}
+
+function fox(x,y,s,t,accent){
+ const gait=Math.sin(t*3.2);
+ const bob=Math.abs(Math.sin(t*3.2))*-4;
+ ctx.save(); ctx.translate(x + Math.sin(t*0.4)*30, y+bob); ctx.scale(s,s);
+ ctx.fillStyle='rgba(30,8,12,0.95)';
+ ctx.beginPath(); ctx.ellipse(0,-10,15,7,0,0,7); ctx.fill();
+ ctx.beginPath(); ctx.moveTo(12,-14); ctx.lineTo(24,-16); ctx.lineTo(13,-8); ctx.closePath(); ctx.fill();
+ ctx.beginPath(); ctx.moveTo(14,-18); ctx.lineTo(17,-24); ctx.lineTo(19,-17);ctx.closePath(); ctx.fill();
+ ctx.strokeStyle=accent; ctx.lineWidth=5; ctx.lineCap='round'; ctx.globalAlpha=0.8;
+ ctx.beginPath(); ctx.moveTo(-14,-10); ctx.quadraticCurveTo(-26,-14-gait*5,-30,-4-gait*3); ctx.stroke();
+ ctx.globalAlpha=1;
+ ctx.strokeStyle='rgba(30,8,12,0.95)'; ctx.lineWidth=2.4;
+ const lg=gait*4;
+ ctx.beginPath();
+ ctx.moveTo(-8,-4); ctx.lineTo(-8-lg,3);
+ ctx.moveTo(8,-4); ctx.lineTo(8+lg,3);
+ ctx.moveTo(-4,-4); ctx.lineTo(-4+lg,3);
+ ctx.moveTo(4,-4); ctx.lineTo(4-lg,3);
+ ctx.stroke();
+ ctx.restore();
+}
+
+function heron(x,y,s,t,accent){
+ const step=Math.sin(t*0.9);
+ const wing=Math.sin(t*1.2)*0.5;
+ ctx.save(); ctx.translate(x + Math.sin(t*0.25)*18, y); ctx.scale(s,s);
+ ctx.strokeStyle='rgba(24,8,14,0.95)'; ctx.fillStyle='rgba(24,8,14,0.95)';
+ ctx.lineWidth=2;
+ ctx.beginPath();
+ ctx.moveTo(-2,-18); ctx.lineTo(-4,2+ (step>0?0:step*8));
+ ctx.moveTo(3,-18); ctx.lineTo(5,2+ (step>0?step*8:0));
+ ctx.stroke();
+ ctx.beginPath(); ctx.ellipse(0,-24,10,7,-0.2,0,7); ctx.fill();
+ ctx.save(); ctx.translate(0,-26); ctx.rotate(wing*0.6);
+ ctx.beginPath(); ctx.moveTo(0,0); ctx.quadraticCurveTo(-14,-4,-20,4); ctx.quadraticCurveTo(-10,2,0,3); ctx.closePath(); ctx.fill();
+ ctx.restore();
+ ctx.lineWidth=2.4;
+ ctx.beginPath(); ctx.moveTo(6,-28);
+ ctx.quadraticCurveTo(16,-34,12,-46); ctx.stroke();
+ ctx.beginPath(); ctx.arc(12,-48,3,0,7); ctx.fill();
+ ctx.strokeStyle=accent; ctx.beginPath(); ctx.moveTo(14,-48); ctx.lineTo(24,-49); ctx.stroke();
+ ctx.restore();
+}
+
+function rabbit(x,y,s,t,accent){
+ const cyc=(t*1.6)%(Math.PI*2);
+ const air=Math.max(0,Math.sin(cyc));
+ const stretch=1+air*0.4, squash=1-Math.max(0,-Math.sin(cyc))*0.35;
+ const hopY=-air*26;
+ ctx.save();
+ ctx.translate(x + ((t*22)% (S.W*0.14)) - S.W*0.05, y+hopY);
+ ctx.scale(s*(2-stretch)*0.9 + s*0.1, s*stretch*squash);
+ ctx.fillStyle='rgba(34,10,14,0.95)';
+ ctx.beginPath(); ctx.ellipse(0,-8,9,7,0,0,7); ctx.fill();
+ ctx.beginPath(); ctx.arc(8,-12,4.5,0,7); ctx.fill();
+ const ear = air*0.8;
+ ctx.save(); ctx.translate(8,-15); ctx.rotate(ear);
+ ctx.beginPath(); ctx.ellipse(0,-6,2,6,0.2,0,7); ctx.fill();
+ ctx.beginPath(); ctx.ellipse(4,-6,2,6,0.4,0,7); ctx.fill();
+ ctx.restore();
+ ctx.fillStyle=accent; ctx.globalAlpha=0.85;
+ ctx.beginPath(); ctx.arc(-9,-8,3,0,7); ctx.fill(); ctx.globalAlpha=1;
+ ctx.fillStyle='rgba(34,10,14,0.95)';
+ ctx.beginPath(); ctx.ellipse(-3,-2+air*2,4,2,air*0.6,0,7); ctx.fill();
+ ctx.restore();
+}
+
+function bearAndFireflies(x,y,s,t,accent){
+ const wob=Math.sin(t*0.35)*3;
+ ctx.save(); ctx.translate(x+wob,y); ctx.scale(s,s);
+ ctx.fillStyle='rgba(20,6,10,0.97)';
+ ctx.beginPath(); ctx.ellipse(0,-16,18,20,0,0,7); ctx.fill();
+ ctx.beginPath(); ctx.ellipse(-14,-2,10,8,0,0,7); ctx.fill();
+ ctx.beginPath(); ctx.ellipse(14,-2,10,8,0,0,7); ctx.fill();
+ ctx.beginPath(); ctx.arc(0,-36,11,0,7); ctx.fill();
+ ctx.beginPath(); ctx.arc(-9,-45,4,0,7); ctx.fill();
+ ctx.beginPath(); ctx.arc(9,-45,4,0,7); ctx.fill();
+ ctx.strokeStyle='rgba(20,6,10,0.97)'; ctx.lineWidth=6; ctx.lineCap='round';
+ const ax=Math.sin(t*0.7)*16, ay=-Math.abs(Math.cos(t*0.7))*10;
+ ctx.beginPath(); ctx.moveTo(6,-24); ctx.quadraticCurveTo(16,-34,18+ax,-40+ay); ctx.stroke();
+ ctx.strokeStyle=accent; ctx.lineWidth=2; ctx.globalAlpha=0.8;
+ ctx.beginPath(); ctx.moveTo(18+ax,-40+ay); ctx.lineTo(18+ax+6,-44+ay); ctx.stroke();
+ ctx.globalAlpha=1;
+ ctx.restore();
+
+ // fireflies respond to the conducting motion (authored, not audio)
+ const n=14;
+ for(let i=0;i<n;i++){
+ const ph=i*0.7;
+ const orbit=30+ (i%5)*10;
+ const a=t*0.7*(0.6+ (i%3)*0.2) + ph;
+ const fx=x + Math.cos(a)*orbit + Math.sin(t*0.7)*14;
+ const fy=y-40 + Math.sin(a*1.3)*orbit*0.5 - (i%4)*6;
+ const blink=0.35+0.65*Math.abs(Math.sin(t*3+ph));
+ const g=ctx.createRadialGradient(fx,fy,0,fx,fy,7);
+ g.addColorStop(0,`rgba(255,230,140,${blink})`); g.addColorStop(1,'rgba(255,200,90,0)');
+ ctx.fillStyle=g; ctx.beginPath(); ctx.arc(fx,fy,7,0,7); ctx.fill();
+ }
+}
+
+// ---------- River foreground with parallax reflections ----------
+function drawRiver(){
+ const W=S.W,H=S.H;
+ const top=H*0.72;
+ const g=ctx.createLinearGradient(0,top,0,H);
+ g.addColorStop(0, pal('river',0));
+ g.addColorStop(1, mixHex(pal('river',0),'#000000',0.5));
+ ctx.fillStyle=g; ctx.fillRect(0,top,W,H-top);
+ const hi=pal('riverHi',0);
+ ctx.strokeStyle=hi;
+ for(let layer=0;layer<3;layer++){
+ const alpha=0.16-layer*0.04, speed=(layer+1)*0.5;
+ ctx.globalAlpha=alpha; ctx.lineWidth=1.5+layer;
+ for(let i=0;i<12;i++){
+ const y=top+ (H-top)*((i+ (S.t*speed*0.02*(layer+1))%1)/12);
+ const amp=(y-top)*0.06;
+ ctx.beginPath();
+ for(let x=0;x<=W;x+=24){
+ const yy=y+Math.sin((x*0.02)+S.t*speed+layer)*amp*(S.reduced?0.3:1);
+ x===0?ctx.moveTo(x,yy):ctx.lineTo(x,yy);
+ }
+ ctx.stroke();
+ }
+ }
+ ctx.globalAlpha=1;
+ if(S.lantern>0.05 && S.cam!=='over'){
+ const {cx}=boatTransform();
+ const rg=ctx.createRadialGradient(cx,top+30,4,cx,top+30,120);
+ rg.addColorStop(0,`rgba(255,190,90,${0.22*S.lantern})`); rg.addColorStop(1,'rgba(0,0,0,0)');
+ ctx.fillStyle=rg; ctx.beginPath(); ctx.ellipse(cx,top+40,120,40,0,0,7); ctx.fill();
+ }
+}
+
+// ---------- Main loop ----------
+let last=performance.now();
+function frame(now){
+ const dt=Math.min(0.05,(now-last)/1000); last=now;
+
+ if(S.playing){
+ const spd = S.reduced ? 0.28 : 1.0;
+ S.t += dt * S.tempo * spd * 1.4;
+
+ // Gentle auto-journey through the three chapters (until the visitor takes over)
+ if(S.autoJourney && !S.reduced){
+ S.journeyClock += dt;
+ if(S.journeyClock >= CHAPTER_SECONDS){
+ S.journeyClock = 0;
+ setChapter((S.chapter+1)%3, {auto:true});
+ }
+ }
+ }
+
+ // ease chapter mix toward target
+ const prevMix=S.chapterMix;
+ S.chapterMix += (S.chapter - S.chapterMix)*Math.min(1,dt*2.2);
+ if(Math.abs(S.chapter-S.chapterMix)>0.001 || Math.abs(prevMix-S.chapterMix)>0.001) skyDirty=true;
+
+ if(skyDirty) drawSky();
+
+ const W=S.W,H=S.H;
+ if(S.trails && !S.reduced && S.playing){
+ ctx.fillStyle='rgba(6,2,4,0.16)';
+ ctx.fillRect(0,0,W,H);
+ } else {
+ ctx.clearRect(0,0,W,H);
+ }
+
+ // parallax canyon (farthest -> nearest)
+ canyonSilhouette(H*0.54, 150, 6.1, mixHex(pal('canyon',0),'#000000',0.35), Math.sin(S.t*0.03)*3);
+ canyonSilhouette(H*0.62, 120, 1.2, pal('canyon',0), Math.sin(S.t*0.05)*4);
+ canyonSilhouette(H*0.70, 90, 4.5, pal('canyon',2), Math.sin(S.t*0.05+2)*3);
+
+ drawRiver();
+ drawAnimals();
+ drawBoat();
+
+ requestAnimationFrame(frame);
+}
+
+// ---------- UI wiring ----------
+const tempoWords=['Adagio','Andante','Moderato','Allegro','Vivace'];
+const lanternWords=['Doused','Dim','Warm','Bright','Blazing'];
+const crowdWords=['Empty','Sparse','Lively','Full','Teeming'];
+const animalWords=['Hidden','Few','Some','Most','All'];
+function word(arr,v){return arr[Math.min(arr.length-1,Math.floor(v*arr.length))];}
+
+const tempoEl=document.getElementById('tempo');
+const tempoV=document.getElementById('tempoV');
+tempoEl.addEventListener('input',e=>{
+ S.tempo=e.target.value/100; // 0..2
+ const w=word(tempoWords, Math.min(0.999,S.tempo/2));
+ tempoV.textContent=w;
+ e.target.setAttribute('aria-valuetext',w);
+});
+
+function wireSlider(id,valId,arr,setter){
+ const el=document.getElementById(id), v=document.getElementById(valId);
+ el.addEventListener('input',e=>{
+ const val=e.target.value/100; setter(val);
+ const w=word(arr,val); v.textContent=w; e.target.setAttribute('aria-valuetext',w);
+ });
+}
+wireSlider('lantern','lanternV',lanternWords,val=>S.lantern=val);
+wireSlider('crowd','crowdV',crowdWords,val=>S.crowd=val);
+wireSlider('animals','animalsV',animalWords,val=>S.animals=val);
+
+// Sync every label to its true computed word on load (no hardcoded mismatch)
+['tempo','lantern','crowd','animals'].forEach(id=>{
+ document.getElementById(id).dispatchEvent(new Event('input'));
+});
+
+const playBtn=document.getElementById('playBtn');
+playBtn.addEventListener('click',()=>{
+ S.playing=!S.playing;
+ playBtn.setAttribute('aria-pressed',S.playing);
+ playBtn.textContent=S.playing?'❚❚ Pause':'► Play';
+ announce(S.playing?'Playing.':'Paused.');
+});
+document.getElementById('restartBtn').addEventListener('click',()=>{
+ S.t=0; S.playing=true; S.autoJourney=true; S.journeyClock=0;
+ playBtn.setAttribute('aria-pressed','true'); playBtn.textContent='❚❚ Pause';
+ setChapter(0,{silent:true});
+ ctx.clearRect(0,0,S.W,S.H);
+ announce('Restarted the journey from Departure.');
+});
+const reduceBtn=document.getElementById('reduceBtn');
+reduceBtn.addEventListener('click',()=>{
+ S.reduced=!S.reduced; reduceBtn.setAttribute('aria-pressed',S.reduced);
+ if(S.reduced) ctx.clearRect(0,0,S.W,S.H);
+ announce(S.reduced?'Reduced motion on. Movement gentled.':'Reduced motion off.');
+});
+if(S.reduced) reduceBtn.setAttribute('aria-pressed','true');
+const trailBtn=document.getElementById('trailBtn');
+trailBtn.addEventListener('click',()=>{
+ S.trails=!S.trails; trailBtn.setAttribute('aria-pressed',S.trails);
+ if(!S.trails) ctx.clearRect(0,0,S.W,S.H);
+ announce(S.trails?'Motion trails on.':'Motion trails off.');
+});
+
+// cameras
+document.querySelectorAll('[data-cam]').forEach(b=>{
+ b.addEventListener('click',()=>{
+ S.cam=b.dataset.cam;
+ document.querySelectorAll('[data-cam]').forEach(x=>x.setAttribute('aria-pressed', x===b));
+ ctx.clearRect(0,0,S.W,S.H);
+ const names={wide:'Wide river',deck:'Deck',over:'Overhead'};
+ announce(names[S.cam]+' view.');
+ });
+});
+
+// chapters
+const chapLabel=document.getElementById('chapLabel');
+const romans=['I','II','III'];
+function setChapter(i, opts){
+ opts=opts||{};
+ S.chapter=i; skyDirty=true;
+ if(!opts.auto){ S.autoJourney=false; } // a manual pick takes the wheel from the auto-journey
+ S.journeyClock=0;
+ document.querySelectorAll('[data-chapter]').forEach(b=>b.setAttribute('aria-current', String(+b.dataset.chapter===i)));
+ chapLabel.textContent=`Chapter ${romans[i]} · ${CHAPTERS[i].name}`;
+ if(!opts.silent) announce(`Chapter ${romans[i]}, ${CHAPTERS[i].name}. Palette and choreography shifting.`);
+}
+document.querySelectorAll('[data-chapter]').forEach(b=>{
+ b.addEventListener('click',()=>setChapter(+b.dataset.chapter));
+});
+
+// museum label
+const labelBtn=document.getElementById('labelBtn');
+const museum=document.getElementById('museum');
+labelBtn.addEventListener('click',()=>{
+ const open=museum.hidden;
+ museum.hidden=!open;
+ labelBtn.setAttribute('aria-expanded',String(open));
+});
+
+function announce(msg){ live.textContent=msg; }
+
+// keyboard shortcuts
+window.addEventListener('keydown',e=>{
+ // Let native controls handle their own Space/Enter activation
+ if(e.target.matches('input,button')&&(e.key===' '||e.key==='Enter')) return;
+ switch(e.key.toLowerCase()){
+ case ' ': e.preventDefault(); playBtn.click(); break;
+ case 'r': document.getElementById('restartBtn').click(); break;
+ case 'm': reduceBtn.click(); break;
+ case 't': trailBtn.click(); break;
+ case '1': document.querySelector('[data-cam="wide"]').click(); break;
+ case '2': document.querySelector('[data-cam="deck"]').click(); break;
+ case '3': document.querySelector('[data-cam="over"]').click(); break;
+ case 'arrowright': e.preventDefault(); setChapter(Math.min(2,S.chapter+1)); break;
+ case 'arrowleft': e.preventDefault(); setChapter(Math.max(0,S.chapter-1)); break;
+ case 'l': labelBtn.click(); break;
+ }
+});
+
+// init
+resize();
+requestAnimationFrame(frame);
+})();
+</script>
+</body>
+</html>
(oldest)
·
back to Scarlet Riverboat Masquerade
·
Fix NaN-color crash that froze the render loop (pal() rgb st f968af5 →