diff --git a/client/src/components/FourZero/NoFearShow.tsx b/client/src/components/FourZero/NoFearShow.tsx index bf968dbbe..8c775195b 100644 --- a/client/src/components/FourZero/NoFearShow.tsx +++ b/client/src/components/FourZero/NoFearShow.tsx @@ -5,6 +5,7 @@ import { placesApi, tripsApi } from '../../api/client' import apiClient from '../../api/client' import { useTranslation } from '../../i18n' import type { Place, Trip } from '../../types' +import { TextAssembly } from './noFearAssembly' import { NoFearAudio, type NoFearAct } from './noFearAudio' import { NoFearScene, type SceneState } from './noFearScene' import { ANTHEM_CASCADE, noFearCopy } from './noFearLines' @@ -79,10 +80,15 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) { const sceneRef = useRef(null) const startRef = useRef(performance.now()) const statsRef = useRef<{ line: string } | null>(null) + const assemblyRef = useRef(null) + const anthemWordRef = useRef(null) const [cueIdx, setCueIdx] = useState(-1) const [flashIdx, setFlashIdx] = useState(-1) const [muted, setMuted] = useState(false) const [anthem, setAnthem] = useState(false) + // A fear-act line that was just replaced — rendered once more while its + // letters decay away (the words of fear literally fall apart). + const [ghost, setGhost] = useState<{ text: string; id: number } | null>(null) const reducedMotion = useMemo(() => window.matchMedia('(prefers-reduced-motion: reduce)').matches, []) const skipToEnd = useCallback(() => { @@ -183,11 +189,25 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) { let idx = -1 for (let i = 0; i < CUES.length; i++) if (t >= CUES[i].at) idx = i if (idx !== lastCue && idx >= 0) { + // A replaced fear-act line decays letter by letter instead of vanishing. + if (lastCue >= 0 && lastCue <= 3) { + const prevKey = CUES[lastCue].line + if (prevKey) { + setGhost({ text: copy.lines[prevKey], id: lastCue }) + window.setTimeout(() => setGhost(g => (g?.id === lastCue ? null : g)), 1400) + } + } lastCue = idx const cue = CUES[idx] if (cue.act) audioRef.current?.setAct(cue.act) if (cue.impact) audioRef.current?.impact(cue.impact) if (cue.swell) audioRef.current?.swell() + // The climax hit shakes the whole frame with the boom. + if ((cue.impact ?? 0) >= 0.8 && overlayRef.current) { + const el = overlayRef.current + el.classList.add('fz-shake') + window.setTimeout(() => el.classList.remove('fz-shake'), 700) + } setCueIdx(idx) if (t >= ANTHEM_AT) setAnthem(true) } @@ -200,6 +220,29 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) { setFlashIdx(flash) } if (ctx) sceneRef.current?.draw(ctx, sceneAt(t), t) + // The finale condenses out of light: once the anthem title is measurable, + // sample its letterforms and gather rising particles into them; the real + // DOM text takes over as they settle. + if (ctx && cv && t >= ANTHEM_AT) { + const word = anthemWordRef.current + if (!assemblyRef.current && word) { + const assembly = new TextAssembly() + const style = window.getComputedStyle(word) + const rect = word.getBoundingClientRect() + assembly.init( + word.textContent ?? '', + `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`, + { left: rect.left, top: rect.top, width: rect.width, height: rect.height }, + cv.clientWidth, + cv.clientHeight, + ) + assemblyRef.current = assembly + } + const progress = ramp(t, ANTHEM_AT + 0.4, ANTHEM_AT + 3.6) + const fade = ramp(t, ANTHEM_AT + 3.4, ANTHEM_AT + 4.8) + assemblyRef.current?.draw(ctx, progress, fade, t) + word?.classList.toggle('fz-word-hidden', fade < 0.15) + } raf = requestAnimationFrame(tick) } raf = requestAnimationFrame(tick) @@ -241,20 +284,54 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) { )} - {/* The lines. */} - {!anthem && lineText && flashIdx < 0 && ( -
-

{lineText}

+ {/* A replaced fear line decays away letter by letter. */} + {!anthem && ghost && ( +
+

+ {[...ghost.text].map((ch, i) => ( + + {ch === ' ' ? ' ' : ch} + + ))} +

)} - {/* The ending: the big words + the cascade through every TREK language — and it stays. */} + {/* The lines. Hope-act lines reveal sentence by sentence, like speech. */} + {!anthem && lineText && flashIdx < 0 && ( +
+

+ {cueIdx >= 6 && cueIdx <= 10 && !hard + ? lineText.split(/(?<=[.!?…])\s+/u).map((seg, i) => ( + {seg}{' '} + )) + : lineText} +

+
+ )} + + {/* The ending: the big words condense out of rising light, then the + cascade through every TREK language joins — and it all stays. */} {anthem && (
-

{copy.anthem}

+

+ {copy.anthem} +

{ANTHEM_CASCADE.filter(c => c.lang !== language).map((c, i) => ( - + {c.text} ))} diff --git a/client/src/components/FourZero/fourzero.css b/client/src/components/FourZero/fourzero.css index 97eea64df..3f5b862cd 100644 --- a/client/src/components/FourZero/fourzero.css +++ b/client/src/components/FourZero/fourzero.css @@ -76,6 +76,49 @@ to { opacity: 1; transform: translateY(0); filter: blur(0); } } +/* A replaced fear line falls apart: every letter drifts off on its own path. */ +.fz-line-ghost { + position: absolute; + animation: none; +} + +.fz-char-decay { + display: inline-block; + white-space: pre; + animation: fzCharDecay 1.05s cubic-bezier(0.4, 0, 0.9, 0.4) both; +} + +@keyframes fzCharDecay { + 0% { opacity: 1; transform: translate(0, 0) rotate(0); filter: blur(0); } + 100% { opacity: 0; transform: translate(var(--dx, 0), var(--dy, -20px)) rotate(var(--rot, 0)); filter: blur(7px); } +} + +/* Hope-act lines arrive sentence by sentence, in speech rhythm. */ +.fz-seg { + opacity: 0; + animation: fzSegIn 1.5s cubic-bezier(0.19, 1, 0.22, 1) both; +} + +@keyframes fzSegIn { + from { opacity: 0; filter: blur(5px); } + to { opacity: 1; filter: blur(0); } +} + +/* The climax boom rocks the whole frame — short, decaying, gone. */ +.fz-shake { + animation: fzShake 0.55s cubic-bezier(0.36, 0.07, 0.19, 0.97) both; +} + +@keyframes fzShake { + 0% { transform: translate(0, 0); } + 12% { transform: translate(-6px, 3px) rotate(-0.25deg); } + 28% { transform: translate(5px, -4px) rotate(0.2deg); } + 46% { transform: translate(-4px, 2px); } + 64% { transform: translate(3px, -1px); } + 82% { transform: translate(-1px, 1px); } + 100% { transform: translate(0, 0); } +} + /* Staccato words strobing to the heartbeat in the late fear act. */ .fz-flash-wrap { position: absolute; @@ -149,6 +192,24 @@ to { opacity: 1; transform: scale(1); letter-spacing: 0.06em; filter: blur(0); } } +/* Assembly mode: the DOM word skips the zoom-in (the particles ARE the + entrance, and the zoom would skew the measured letterform targets); once the + particles settle it fades in crisp and keeps breathing quietly forever. */ +.fz-anthem-word.fz-word-assembled { + animation: fzAnthemBreathe 5.5s ease-in-out 5.5s infinite; + transition: opacity 1.5s ease; +} + +.fz-anthem-word.fz-word-hidden { + opacity: 0; + transition: none; +} + +@keyframes fzAnthemBreathe { + 0%, 100% { transform: scale(1); text-shadow: 0 0 60px rgba(255, 200, 120, 0.35), 0 2px 30px rgba(0, 0, 0, 0.9); } + 50% { transform: scale(1.012); text-shadow: 0 0 84px rgba(255, 205, 130, 0.5), 0 2px 30px rgba(0, 0, 0, 0.9); } +} + .fz-cascade { display: flex; flex-wrap: wrap; @@ -214,7 +275,7 @@ display: none; width: 100%; min-height: 196px; - border-radius: 16px; + border-radius: var(--r-xl, 16px); /* same corner radius as the dashboard tool widgets */ overflow: hidden; isolation: isolate; box-shadow: 0 12px 34px -14px rgba(0, 0, 0, 0.55), inset 0 0 0 1px rgba(255, 190, 110, 0.14); diff --git a/client/src/components/FourZero/noFearAssembly.ts b/client/src/components/FourZero/noFearAssembly.ts new file mode 100644 index 000000000..d8a19af14 --- /dev/null +++ b/client/src/components/FourZero/noFearAssembly.ts @@ -0,0 +1,92 @@ +// The finale's signature move: "KEINE ANGST" is not faded in — it CONDENSES. +// Hundreds of light particles rise out of the world and gather into the +// letterforms; once they settle, the real DOM title takes over for crispness. +// The targets are sampled from an offscreen raster of the exact same text in +// the exact same font, so the swap is seamless. + +interface AssemblyParticle { + sx: number + sy: number + tx: number + ty: number + delay: number + size: number + seed: number +} + +const easeOutCubic = (q: number): number => 1 - (1 - q) ** 3 + +export class TextAssembly { + private particles: AssemblyParticle[] = [] + private ready = false + + /** + * Samples particle targets from the given text. `font` is a full CSS font + * shorthand measured off the real DOM title; the box is the title's screen + * rect so canvas particles land exactly where the DOM text will appear. + */ + init(text: string, font: string, box: { left: number; top: number; width: number; height: number }, screenW: number, screenH: number): void { + const off = document.createElement('canvas') + // Raster at a capped size — targets only need ~2px precision. + const scale = Math.min(1, 700 / Math.max(box.width, 1)) + off.width = Math.max(Math.round(box.width * scale), 1) + off.height = Math.max(Math.round(box.height * scale), 1) + const c = off.getContext('2d') + if (!c) return + c.font = font.replace(/(\d+(?:\.\d+)?)px/, (_, n) => `${Number(n) * scale}px`) + c.textAlign = 'center' + c.textBaseline = 'middle' + c.fillStyle = '#fff' + c.fillText(text, off.width / 2, off.height / 2) + const img = c.getImageData(0, 0, off.width, off.height).data + const step = 3 // sample grid in raster px — ~800-1400 particles for a title + this.particles = [] + for (let y = 0; y < off.height; y += step) { + for (let x = 0; x < off.width; x += step) { + if (img[(y * off.width + x) * 4 + 3] > 128) { + const tx = box.left + x / scale + const ty = box.top + y / scale + // Particles rise from below and drift in from the sides — they come + // OUT of the world, not from nowhere. + const fromSide = Math.random() < 0.3 + this.particles.push({ + sx: fromSide ? (Math.random() < 0.5 ? -30 : screenW + 30) : Math.random() * screenW, + sy: fromSide ? Math.random() * screenH : screenH + 20 + Math.random() * 80, + tx, + ty, + delay: Math.random() * 0.45, + size: 0.9 + Math.random() * 1.2, + seed: Math.random() * 7, + }) + } + } + } + this.ready = true + } + + isReady(): boolean { + return this.ready + } + + /** progress 0→1 gathers, `fade` 0→1 dissolves the particles into the DOM text. */ + draw(c: CanvasRenderingContext2D, progress: number, fade: number, t: number): void { + if (!this.ready || fade >= 1) return + c.save() + c.globalCompositeOperation = 'lighter' + for (const p of this.particles) { + const local = easeOutCubic(Math.min(Math.max((progress - p.delay) / (1 - p.delay), 0), 1)) + if (local <= 0) continue + const x = p.sx + (p.tx - p.sx) * local + const y = p.sy + (p.ty - p.sy) * local + // In flight the particle glows warm; settled it cools toward the ivory of the text. + const settled = local > 0.98 + const flicker = 0.65 + 0.35 * Math.sin(t * 5 + p.seed) + c.globalAlpha = (1 - fade) * (settled ? 0.9 : 0.75 * flicker) + c.fillStyle = settled ? 'rgb(247, 240, 226)' : 'rgb(255, 205, 130)' + c.beginPath() + c.arc(x, y, settled ? p.size * 0.85 : p.size, 0, Math.PI * 2) + c.fill() + } + c.restore() + } +}