mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(dashboard): make the KEINE ANGST show hit harder
Visual: city lights now come alive in the opening and die one by one while the
borders burn; three staccato words strobe with the heartbeat before the cut;
at the climax the borders no longer fade — they shatter into drifting sparks.
The traveler's own places ignite gold in the release act, with their real
numbers in the line ('{p} places. {c} countries. And not once did the world
hurt you.'). All heavy geometry is decimated to a vertex budget and pre-baked
into offscreen layers, so the frame path is a handful of drawImage calls — the
per-frame border stroke that dropped the show to ~1 fps is gone.
Sound: heartbeat with a click transient, wind textures, a doubled noise riser,
cue-driven sub-bass impacts and a gliding D–A–f#m–E progression with a sub
root under the anthem instead of the static pad.
Also: dashboard FAB hidden behind the show, suspended-AudioContext recovery
(Safari), tab-hidden pause for clock and audio, aborted geo fetch on close,
valid beacon markup (dismiss as sibling button), RTL-aware beacon, real
reduced-motion static frame, and native-speaker fixes for nl/sv/br/fr/it/es/
tr/gr copy.
This commit is contained in:
@@ -25,23 +25,23 @@ export default function NoFearBeacon() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" className="fz-beacon" onClick={() => setOpen(true)}>
|
||||
<div className="fz-beacon">
|
||||
<button type="button" className="fz-beacon-play" onClick={() => setOpen(true)}>
|
||||
<p className="fz-beacon-eyebrow">TREK 4.0.0</p>
|
||||
<p className="fz-beacon-title">{copy.beaconTitle}</p>
|
||||
<p className="fz-beacon-sub">{copy.beaconSub}</p>
|
||||
<span className="fz-beacon-cta"><Play size={12} fill="currentColor" /> {copy.beaconCta}</span>
|
||||
</button>
|
||||
<span className="fz-beacon-pulse" aria-hidden />
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
<button
|
||||
type="button"
|
||||
className="fz-beacon-dismiss"
|
||||
aria-label="×"
|
||||
onClick={e => { e.stopPropagation(); localStorage.setItem(DISMISS_KEY, '1'); setDismissed(true) }}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); localStorage.setItem(DISMISS_KEY, '1'); setDismissed(true) } }}
|
||||
aria-label={copy.skip}
|
||||
onClick={() => { localStorage.setItem(DISMISS_KEY, '1'); setDismissed(true) }}
|
||||
>
|
||||
<X size={12} />
|
||||
</span>
|
||||
<p className="fz-beacon-eyebrow">TREK 4.0.0</p>
|
||||
<p className="fz-beacon-title">{copy.beaconTitle}</p>
|
||||
<p className="fz-beacon-sub">{copy.beaconSub}</p>
|
||||
<span className="fz-beacon-cta"><Play size={12} fill="currentColor" /> {copy.beaconCta}</span>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<Suspense fallback={null}>
|
||||
<NoFearShow onClose={() => setOpen(false)} />
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Volume2, VolumeX, X } from 'lucide-react'
|
||||
import { placesApi, tripsApi } from '../../api/client'
|
||||
import apiClient from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { Place, Trip } from '../../types'
|
||||
import { NoFearAudio, type NoFearAct } from './noFearAudio'
|
||||
import { NoFearScene, type SceneState } from './noFearScene'
|
||||
import { ANTHEM_CASCADE, INSPIRED_URL, noFearCopy } from './noFearLines'
|
||||
@@ -15,51 +18,68 @@ interface Cue {
|
||||
act?: NoFearAct
|
||||
line?: LineKey | null
|
||||
hard?: boolean
|
||||
impact?: number
|
||||
}
|
||||
|
||||
// The script. Times in seconds; the scene ramps below are keyed to the same clock.
|
||||
// The script. Times in seconds; the scene ramps below share the same clock.
|
||||
const CUES: Cue[] = [
|
||||
{ at: 0.8, act: 'fear', line: 'afraid' },
|
||||
{ at: 6.0, line: 'ofTheStranger' },
|
||||
{ at: 12.0, act: 'dread', line: 'fearTool' },
|
||||
{ at: 18.5, line: 'hateTrade' },
|
||||
{ at: 25.0, act: 'silence', line: null },
|
||||
{ at: 27.5, line: 'butYouTraveled' },
|
||||
{ at: 33.0, act: 'hope', line: 'tables' },
|
||||
{ at: 41.0, line: 'face' },
|
||||
{ at: 48.0, line: 'fences' },
|
||||
{ at: 54.5, line: 'notAnOpinion', hard: true },
|
||||
{ at: 62.0, act: 'anthem', line: null },
|
||||
{ at: 74.0, act: 'end', line: null },
|
||||
{ at: 26.0, act: 'silence', line: null },
|
||||
{ at: 28.5, line: 'butYouTraveled', impact: 0.5 },
|
||||
{ at: 34.0, act: 'hope', line: 'tables' },
|
||||
{ at: 42.0, line: 'face' },
|
||||
{ at: 49.0, line: 'fences' },
|
||||
{ at: 55.5, line: 'yourPlaces', impact: 0.35 },
|
||||
{ at: 58.8, line: 'yourStatsCountries' },
|
||||
{ at: 64.0, line: 'notAnOpinion', hard: true, impact: 1 },
|
||||
{ at: 71.0, act: 'anthem', line: null, impact: 0.8 },
|
||||
{ at: 84.0, act: 'end', line: null },
|
||||
]
|
||||
const ANTHEM_AT = 62
|
||||
const CREDITS_AT = 74
|
||||
const ANTHEM_AT = 71
|
||||
const CREDITS_AT = 84
|
||||
// The three staccato words strobe with the late fear act, just before the cut.
|
||||
const FLASH_WINDOWS: [number, number][] = [[22.2, 22.85], [23.6, 24.25], [25.0, 25.65]]
|
||||
|
||||
const ramp = (t: number, from: number, to: number): number =>
|
||||
Math.min(Math.max((t - from) / (to - from), 0), 1)
|
||||
|
||||
function sceneAt(t: number): SceneState {
|
||||
// The blackout (25→27.4s) is the show's held breath; everything else is ramps.
|
||||
const blackout = t >= 25 && t < 31 ? 1 - ramp(t, 30, 31) : 0
|
||||
// Hard cut to black at 26s; the pivot line stands alone in the dark, then the
|
||||
// world returns WITH the hope act (28→31.5) — that overlap is intentional.
|
||||
const blackout = t >= 26 && t < 31.5 ? 1 - ramp(t, 28, 31.5) : 0
|
||||
return {
|
||||
land: ramp(t, 5.5, 9.5),
|
||||
borderHeat: t < 33 ? ramp(t, 12, 15.5) : 0.45,
|
||||
borderGone: ramp(t, 54.5, 61.5),
|
||||
web: ramp(t, 33, 58),
|
||||
warmth: ramp(t, 33, 45),
|
||||
land: ramp(t, 6, 10),
|
||||
cityLife: ramp(t, 6.5, 11),
|
||||
cityDeath: ramp(t, 13, 22),
|
||||
borderHeat: t < 34 ? ramp(t, 12, 15.5) : 1 - 0.55 * ramp(t, 34, 38),
|
||||
borderBurst: ramp(t, 64, 70),
|
||||
web: ramp(t, 34, 62),
|
||||
warmth: ramp(t, 34, 46),
|
||||
personalGlow: ramp(t, 55.5, 60.5),
|
||||
particles: ramp(t, ANTHEM_AT, ANTHEM_AT + 4),
|
||||
opacity: (1 - blackout) * (t >= CREDITS_AT ? 0.35 : 1),
|
||||
}
|
||||
}
|
||||
|
||||
/** Formats "{p} places. {c} countries. …" with the traveler's real numbers. */
|
||||
function fillStats(template: string, p: number, cOrT: number): string {
|
||||
return template.replace('{p}', String(p)).replace('{c}', String(cOrT)).replace('{t}', String(cOrT))
|
||||
}
|
||||
|
||||
export default function NoFearShow({ onClose }: { onClose: () => void }) {
|
||||
const { language } = useTranslation()
|
||||
const copy = useMemo(() => noFearCopy(language), [language])
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const audioRef = useRef<NoFearAudio | null>(null)
|
||||
const sceneRef = useRef<NoFearScene | null>(null)
|
||||
const startRef = useRef<number>(performance.now())
|
||||
const statsRef = useRef<{ line: string } | null>(null)
|
||||
const [cueIdx, setCueIdx] = useState(-1)
|
||||
const [flashIdx, setFlashIdx] = useState(-1)
|
||||
const [muted, setMuted] = useState(false)
|
||||
const [credits, setCredits] = useState(false)
|
||||
const [anthem, setAnthem] = useState(false)
|
||||
@@ -72,41 +92,93 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
|
||||
audioRef.current?.setAct('end')
|
||||
}, [])
|
||||
|
||||
// Boot: audio (user gesture already happened — the beacon click), scene, clock.
|
||||
// Boot: audio, scene, personal places, chrome side effects.
|
||||
useEffect(() => {
|
||||
document.body.classList.add('fz-show-open') // hides the dashboard FAB + locks scroll
|
||||
const audio = new NoFearAudio()
|
||||
audioRef.current = audio
|
||||
if (!reducedMotion) audio.start()
|
||||
const scene = new NoFearScene()
|
||||
sceneRef.current = scene
|
||||
const aborter = new AbortController()
|
||||
const cv = canvasRef.current
|
||||
if (cv) {
|
||||
const fit = () => {
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2)
|
||||
cv.width = Math.round(cv.clientWidth * dpr)
|
||||
cv.height = Math.round(cv.clientHeight * dpr)
|
||||
cv.getContext('2d')?.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
scene.layout(cv.clientWidth, cv.clientHeight)
|
||||
}
|
||||
fit()
|
||||
void scene.load(cv.clientWidth, cv.clientHeight)
|
||||
window.addEventListener('resize', fit)
|
||||
if (reducedMotion) skipToEnd()
|
||||
return () => {
|
||||
window.removeEventListener('resize', fit)
|
||||
audio.dispose()
|
||||
const fit = () => {
|
||||
if (!cv) return
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 1.75)
|
||||
cv.width = Math.round(cv.clientWidth * dpr)
|
||||
cv.height = Math.round(cv.clientHeight * dpr)
|
||||
cv.getContext('2d')?.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
scene.layout(cv.clientWidth, cv.clientHeight)
|
||||
}
|
||||
fit()
|
||||
if (cv) void scene.load(cv.clientWidth, cv.clientHeight, aborter.signal)
|
||||
window.addEventListener('resize', fit)
|
||||
// The traveler's own places, gathered quietly during the fear act. Fail-soft:
|
||||
// without them the show falls back to its generic lines.
|
||||
void (async () => {
|
||||
try {
|
||||
const trips = await tripsApi.list() as Trip[]
|
||||
if (aborter.signal.aborted || !Array.isArray(trips)) return
|
||||
const lists = await Promise.all(trips.slice(0, 12).map(t => placesApi.list(t.id).catch(() => [] as Place[])))
|
||||
if (aborter.signal.aborted) return
|
||||
const pts = lists.flat().filter((p: Place) => p.lat != null && p.lng != null).map((p: Place) => ({ lat: p.lat as number, lng: p.lng as number }))
|
||||
if (pts.length >= 3) {
|
||||
sceneRef.current?.setPersonalPlaces(pts)
|
||||
// Prefer real country counts from Atlas; fall back to trip counts.
|
||||
try {
|
||||
const { data } = await apiClient.get('/addons/atlas/stats', { signal: aborter.signal })
|
||||
const countries = data?.stats?.totalCountries
|
||||
statsRef.current = countries > 0
|
||||
? { line: fillStats(copy.lines.yourStatsCountries, pts.length, countries) }
|
||||
: { line: fillStats(copy.lines.yourStatsTrips, pts.length, trips.length) }
|
||||
} catch {
|
||||
statsRef.current = { line: fillStats(copy.lines.yourStatsTrips, pts.length, trips.length) }
|
||||
}
|
||||
}
|
||||
} catch { /* not signed-in edge / API down — generic show */ }
|
||||
})()
|
||||
// Pause the whole show (clock + audio) while the tab is hidden.
|
||||
let hiddenAt: number | null = null
|
||||
const onVisibility = () => {
|
||||
if (document.hidden) {
|
||||
hiddenAt = performance.now()
|
||||
audio.setSuspended(true)
|
||||
} else {
|
||||
if (hiddenAt !== null) startRef.current += performance.now() - hiddenAt
|
||||
hiddenAt = null
|
||||
audio.setSuspended(false)
|
||||
}
|
||||
}
|
||||
return () => audio.dispose()
|
||||
}, [reducedMotion, skipToEnd])
|
||||
document.addEventListener('visibilitychange', onVisibility)
|
||||
// Any real gesture inside the overlay doubles as an audio-resume recovery
|
||||
// for browsers that refused the post-lazy-load AudioContext start.
|
||||
const onPointer = () => audio.resume()
|
||||
window.addEventListener('pointerdown', onPointer)
|
||||
overlayRef.current?.focus()
|
||||
if (reducedMotion) skipToEnd()
|
||||
return () => {
|
||||
document.body.classList.remove('fz-show-open')
|
||||
window.removeEventListener('resize', fit)
|
||||
document.removeEventListener('visibilitychange', onVisibility)
|
||||
window.removeEventListener('pointerdown', onPointer)
|
||||
aborter.abort()
|
||||
audio.dispose()
|
||||
}
|
||||
}, [reducedMotion, skipToEnd, copy])
|
||||
|
||||
// The clock: one rAF loop drives the canvas directly and only touches React
|
||||
// state on discrete cue changes.
|
||||
// state on discrete cue/flash changes. Reduced motion renders one static frame.
|
||||
useEffect(() => {
|
||||
let raf = 0
|
||||
let lastCue = -1
|
||||
const cv = canvasRef.current
|
||||
const ctx = cv?.getContext('2d')
|
||||
if (reducedMotion) {
|
||||
setCueIdx(CUES.length - 1)
|
||||
if (ctx) sceneRef.current?.draw(ctx, { ...sceneAt(CREDITS_AT), particles: 0 }, CREDITS_AT)
|
||||
return
|
||||
}
|
||||
let raf = 0
|
||||
let lastCue = -1
|
||||
let lastFlash = -1
|
||||
const tick = () => {
|
||||
const t = (performance.now() - startRef.current) / 1000
|
||||
let idx = -1
|
||||
@@ -115,18 +187,27 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
|
||||
lastCue = idx
|
||||
const cue = CUES[idx]
|
||||
if (cue.act) audioRef.current?.setAct(cue.act)
|
||||
if (cue.impact) audioRef.current?.impact(cue.impact)
|
||||
setCueIdx(idx)
|
||||
if (t >= ANTHEM_AT) setAnthem(true)
|
||||
if (t >= CREDITS_AT) setCredits(true)
|
||||
}
|
||||
if (ctx && cv) sceneRef.current?.draw(ctx, sceneAt(t), t)
|
||||
let flash = -1
|
||||
for (let i = 0; i < FLASH_WINDOWS.length; i++) {
|
||||
if (t >= FLASH_WINDOWS[i][0] && t < FLASH_WINDOWS[i][1]) flash = i
|
||||
}
|
||||
if (flash !== lastFlash) {
|
||||
lastFlash = flash
|
||||
setFlashIdx(flash)
|
||||
}
|
||||
if (ctx) sceneRef.current?.draw(ctx, sceneAt(t), t)
|
||||
raf = requestAnimationFrame(tick)
|
||||
}
|
||||
raf = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [])
|
||||
}, [reducedMotion])
|
||||
|
||||
// ESC closes, any key after the credits too.
|
||||
// ESC closes the show at any point.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
|
||||
window.addEventListener('keydown', onKey)
|
||||
@@ -135,19 +216,31 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
|
||||
|
||||
const cue = cueIdx >= 0 ? CUES[cueIdx] : null
|
||||
const lineKey = cue?.line ?? null
|
||||
const lineText = lineKey ? copy.lines[lineKey] : null
|
||||
// The personal-stats line only exists when the traveler's data arrived; the
|
||||
// yourPlaces line falls back to the generic "every light" line without it.
|
||||
let lineText: string | null = null
|
||||
if (lineKey === 'yourStatsCountries') lineText = statsRef.current?.line ?? null
|
||||
else if (lineKey === 'yourPlaces') lineText = statsRef.current ? copy.lines.yourPlaces : copy.lines.everyDot
|
||||
else if (lineKey) lineText = copy.lines[lineKey]
|
||||
const hard = !!cue?.hard
|
||||
|
||||
return (
|
||||
<div className="fz-overlay" role="dialog" aria-label={copy.beaconTitle}>
|
||||
<div ref={overlayRef} className="fz-overlay" role="dialog" aria-modal="true" aria-label={copy.beaconTitle} tabIndex={-1}>
|
||||
<canvas ref={canvasRef} className="fz-canvas" />
|
||||
|
||||
{/* Red vignette while the borders burn. */}
|
||||
{!anthem && cueIdx >= 2 && cueIdx <= 3 && <div className="fz-vignette" aria-hidden />}
|
||||
|
||||
{/* Staccato words strobing to the heartbeat, right before the cut. */}
|
||||
{flashIdx >= 0 && !anthem && (
|
||||
<div className="fz-flash-wrap" aria-hidden>
|
||||
<span key={flashIdx} className="fz-flash">{copy.flashes[flashIdx]}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The lines. */}
|
||||
{!anthem && lineText && (
|
||||
<div className="fz-line-wrap">
|
||||
{!anthem && lineText && flashIdx < 0 && (
|
||||
<div className="fz-line-wrap" aria-live="polite">
|
||||
<p key={lineKey} className={`fz-line ${hard ? 'fz-line-hard' : ''}`}>{lineText}</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -170,6 +263,7 @@ export default function NoFearShow({ onClose }: { onClose: () => void }) {
|
||||
{credits && (
|
||||
<div className="fz-credits">
|
||||
<h1 className="fz-anthem-word fz-anthem-word-small">{copy.anthem}</h1>
|
||||
<p className="fz-credit-lead">{copy.tooBeautiful}</p>
|
||||
<p className="fz-credit-title">{copy.creditTitle}</p>
|
||||
<p className="fz-credit-body">{copy.creditBody}</p>
|
||||
<a className="fz-credit-link" href={INSPIRED_URL} target="_blank" rel="noopener noreferrer">
|
||||
|
||||
@@ -65,6 +65,32 @@
|
||||
to { opacity: 1; transform: translateY(0); filter: blur(0); }
|
||||
}
|
||||
|
||||
/* Staccato words strobing to the heartbeat in the late fear act. */
|
||||
.fz-flash-wrap {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fz-flash {
|
||||
font-size: clamp(52px, 9vw, 130px);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.08em;
|
||||
color: #ff4b3e;
|
||||
text-shadow: 0 0 42px rgba(235, 50, 45, 0.65), 0 2px 18px rgba(0, 0, 0, 0.9);
|
||||
animation: fzFlashIn 0.65s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@keyframes fzFlashIn {
|
||||
0% { opacity: 0; transform: scale(1.28); filter: blur(8px); }
|
||||
18% { opacity: 1; transform: scale(1); filter: blur(0); }
|
||||
80% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* ── anthem ── */
|
||||
|
||||
.fz-anthem {
|
||||
@@ -134,6 +160,15 @@
|
||||
animation: fzLineIn 1.8s ease-out both;
|
||||
}
|
||||
|
||||
.fz-credit-lead {
|
||||
font-size: clamp(16px, 1.7vw, 23px);
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
opacity: 0.95;
|
||||
margin: 6px 0 4px;
|
||||
max-width: 680px;
|
||||
}
|
||||
|
||||
.fz-credit-title {
|
||||
font-size: clamp(17px, 1.8vw, 24px);
|
||||
font-weight: 700;
|
||||
@@ -216,36 +251,46 @@
|
||||
|
||||
/* ── beacon (dashboard trigger) ── */
|
||||
|
||||
/* Wrapper is a div so the dismiss can be a real sibling <button> — a button
|
||||
may not contain another interactive element. */
|
||||
.fz-beacon {
|
||||
position: relative;
|
||||
display: none;
|
||||
width: 100%;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: 16px;
|
||||
padding: 18px 16px 16px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(120% 90% at 20% 0%, rgba(235, 60, 55, 0.16), transparent 55%),
|
||||
radial-gradient(130% 100% at 85% 100%, rgba(255, 180, 90, 0.14), transparent 60%),
|
||||
#0a0c12;
|
||||
color: #f5f2ea;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.fz-beacon { display: block; }
|
||||
}
|
||||
|
||||
.fz-beacon-play {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: start;
|
||||
cursor: pointer;
|
||||
padding: 18px 16px 16px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.fz-beacon-pulse {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
inset-inline-end: 16px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: #ff5a4e;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fz-beacon-pulse::before,
|
||||
@@ -302,7 +347,7 @@
|
||||
.fz-beacon-dismiss {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
inset-inline-start: 8px;
|
||||
display: inline-flex;
|
||||
padding: 5px;
|
||||
border: none;
|
||||
@@ -314,11 +359,18 @@
|
||||
|
||||
.fz-beacon-dismiss:hover { color: rgba(245, 240, 225, 0.85); }
|
||||
|
||||
/* Accessibility: the show honours reduced motion by jumping to its final,
|
||||
static frame (handled in the component); kill the looping accents too. */
|
||||
/* While the show runs: no floating dashboard chrome above it, no page scroll. */
|
||||
body.fz-show-open { overflow: hidden; }
|
||||
body.fz-show-open .fab-new-trip { display: none; }
|
||||
|
||||
/* Accessibility: with reduced motion the component renders a single static
|
||||
frame and skips straight to the credits; flatten every looping accent too. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fz-overlay *,
|
||||
.fz-beacon-pulse::before,
|
||||
.fz-beacon-pulse::after,
|
||||
.fz-vignette { animation: none; }
|
||||
.fz-cascade-item { animation-duration: 0.01s; }
|
||||
.fz-vignette {
|
||||
animation-duration: 0.01s !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Procedural soundtrack for the 4.0.0 "KEINE ANGST" show. Everything is
|
||||
// synthesized with the Web Audio API at runtime — a heartbeat, a low drone, a
|
||||
// rising dissonance for the fear act and a warm A-major pad for the release —
|
||||
// synthesized with the Web Audio API at runtime — heartbeat with a real click
|
||||
// transient, wind-noise textures, a trailer-style noise riser, cue-driven
|
||||
// sub-bass impacts and a moving chord progression with glided voice-leading —
|
||||
// so the show ships zero audio assets and touches no copyrighted material.
|
||||
|
||||
export type NoFearAct = 'fear' | 'dread' | 'silence' | 'hope' | 'anthem' | 'end'
|
||||
@@ -9,16 +10,32 @@ interface Voice {
|
||||
stop: (at: number) => void
|
||||
}
|
||||
|
||||
// The hope/anthem harmony: D — A — f#m — E in open voicings, five gliding
|
||||
// voices each. Movement is what makes the release feel alive.
|
||||
const PROGRESSION: number[][] = [
|
||||
[73.42, 110.0, 146.83, 185.0, 293.66], // D2 A2 D3 F#3 D4
|
||||
[82.41, 110.0, 164.81, 220.0, 277.18], // E2 A2 E3 A3 C#4
|
||||
[92.5, 110.0, 138.59, 185.0, 277.18], // F#2 A2 C#3 F#3 C#4
|
||||
[82.41, 123.47, 164.81, 207.65, 246.94], // E2 B2 E3 G#3 B3
|
||||
]
|
||||
const CHORD_SECONDS = 4.4
|
||||
const BASS_ROOTS = [36.71, 41.2, 46.25, 41.2] // D1 E1 F#1 E1
|
||||
|
||||
export class NoFearAudio {
|
||||
private ctx: AudioContext | null = null
|
||||
private master: GainNode | null = null
|
||||
private noiseBuf: AudioBuffer | null = null
|
||||
private muted = false
|
||||
private act: NoFearAct | null = null
|
||||
private heartbeatTimer: number | null = null
|
||||
private nextBeat = 0
|
||||
private bpm = 76
|
||||
private beatGain = 0.5
|
||||
private beatGain = 0.55
|
||||
private voices: Voice[] = []
|
||||
private padVoices: OscillatorNode[] = []
|
||||
private bassOsc: OscillatorNode | null = null
|
||||
private chordTimer: number | null = null
|
||||
private chordIdx = 0
|
||||
|
||||
/** Must be called from a user gesture (the beacon click) so autoplay rules allow it. */
|
||||
start(): void {
|
||||
@@ -26,16 +43,22 @@ export class NoFearAudio {
|
||||
const Ctor = window.AudioContext ?? (window as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
|
||||
if (!Ctor) return
|
||||
this.ctx = new Ctor()
|
||||
// A lazily-loaded chunk can construct us a beat after the click gesture —
|
||||
// resume() re-arms the context if the browser started it suspended.
|
||||
if (this.ctx.state === 'suspended') void this.ctx.resume().catch(() => {})
|
||||
this.master = this.ctx.createGain()
|
||||
this.master.gain.value = this.muted ? 0 : 0.8
|
||||
// A soft knee compressor keeps the stacked voices from ever clipping.
|
||||
this.master.gain.value = this.muted ? 0 : 0.9
|
||||
const limiter = this.ctx.createDynamicsCompressor()
|
||||
limiter.threshold.value = -14
|
||||
limiter.knee.value = 24
|
||||
limiter.threshold.value = -12
|
||||
limiter.knee.value = 22
|
||||
limiter.ratio.value = 12
|
||||
this.master.connect(limiter)
|
||||
limiter.connect(this.ctx.destination)
|
||||
// Heartbeat scheduler: 100ms tick, 300ms lookahead.
|
||||
// 2s of looped white noise feeds every texture (wind, risers, ticks).
|
||||
const len = this.ctx.sampleRate * 2
|
||||
this.noiseBuf = this.ctx.createBuffer(1, len, this.ctx.sampleRate)
|
||||
const data = this.noiseBuf.getChannelData(0)
|
||||
for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1
|
||||
this.nextBeat = this.ctx.currentTime + 0.2
|
||||
this.heartbeatTimer = window.setInterval(() => this.scheduleBeats(), 100)
|
||||
}
|
||||
@@ -43,7 +66,52 @@ export class NoFearAudio {
|
||||
setMuted(muted: boolean): void {
|
||||
this.muted = muted
|
||||
if (this.master && this.ctx) {
|
||||
this.master.gain.setTargetAtTime(muted ? 0 : 0.8, this.ctx.currentTime, 0.05)
|
||||
// The mute button is a real gesture — use it as a recovery path for
|
||||
// browsers (Safari) that refused to start the context earlier.
|
||||
if (!muted && this.ctx.state === 'suspended') void this.ctx.resume().catch(() => {})
|
||||
this.master.gain.setTargetAtTime(muted ? 0 : 0.9, this.ctx.currentTime, 0.05)
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort resume from any fresh user gesture (overlay pointerdown). */
|
||||
resume(): void {
|
||||
if (this.ctx?.state === 'suspended') void this.ctx.resume().catch(() => {})
|
||||
}
|
||||
|
||||
/** Freeze/unfreeze with the show clock when the tab goes hidden. */
|
||||
setSuspended(suspended: boolean): void {
|
||||
if (!this.ctx) return
|
||||
if (suspended) void this.ctx.suspend().catch(() => {})
|
||||
else void this.ctx.resume().catch(() => {})
|
||||
}
|
||||
|
||||
/** A cue-driven cinema hit: sub boom + filtered noise burst. strength 0..1. */
|
||||
impact(strength = 1): void {
|
||||
if (!this.ctx || !this.master) return
|
||||
const t = this.ctx.currentTime + 0.02
|
||||
const o = this.ctx.createOscillator()
|
||||
o.type = 'sine'
|
||||
o.frequency.setValueAtTime(82, t)
|
||||
o.frequency.exponentialRampToValueAtTime(28, t + 0.9)
|
||||
const g = this.ctx.createGain()
|
||||
g.gain.setValueAtTime(0.0001, t)
|
||||
g.gain.exponentialRampToValueAtTime(0.9 * strength, t + 0.018)
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t + 1.6)
|
||||
o.connect(g); g.connect(this.master)
|
||||
o.start(t); o.stop(t + 1.8)
|
||||
if (this.noiseBuf) {
|
||||
const n = this.ctx.createBufferSource()
|
||||
n.buffer = this.noiseBuf
|
||||
const lp = this.ctx.createBiquadFilter()
|
||||
lp.type = 'lowpass'
|
||||
lp.frequency.setValueAtTime(900, t)
|
||||
lp.frequency.exponentialRampToValueAtTime(120, t + 0.5)
|
||||
const ng = this.ctx.createGain()
|
||||
ng.gain.setValueAtTime(0.0001, t)
|
||||
ng.gain.exponentialRampToValueAtTime(0.32 * strength, t + 0.012)
|
||||
ng.gain.exponentialRampToValueAtTime(0.0001, t + 0.6)
|
||||
n.connect(lp); lp.connect(ng); ng.connect(this.master)
|
||||
n.start(t); n.stop(t + 0.8)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,31 +119,33 @@ export class NoFearAudio {
|
||||
if (!this.ctx || !this.master || this.act === act) return
|
||||
this.act = act
|
||||
const t = this.ctx.currentTime
|
||||
// Every act replaces the sustained voices; the heartbeat just changes pace.
|
||||
this.releaseVoices(act === 'silence' ? 0.12 : 1.6)
|
||||
this.releaseVoices(act === 'silence' ? 0.1 : 1.6)
|
||||
this.stopChords()
|
||||
switch (act) {
|
||||
case 'fear':
|
||||
this.bpm = 76
|
||||
this.beatGain = 0.5
|
||||
this.drone(55, 82.4, 0.16, 260, 2.5)
|
||||
this.bpm = 74
|
||||
this.beatGain = 0.55
|
||||
this.drone(55, 82.4, 0.15, 230, 3)
|
||||
this.wind(0.05, 420)
|
||||
break
|
||||
case 'dread':
|
||||
this.bpm = 112
|
||||
this.beatGain = 0.62
|
||||
this.drone(55, 78, 0.2, 320, 1.2) // detuned fifth — it beats uneasily
|
||||
this.bpm = 116
|
||||
this.beatGain = 0.7
|
||||
this.drone(55, 77.8, 0.19, 300, 1) // flattened fifth — it beats uneasily
|
||||
this.wind(0.085, 700)
|
||||
this.riser(t)
|
||||
break
|
||||
case 'silence':
|
||||
this.bpm = 0 // the cut itself is the effect
|
||||
break
|
||||
case 'hope':
|
||||
this.bpm = 56
|
||||
this.beatGain = 0.28
|
||||
this.pad(t, [110, 164.81, 220, 277.18], 900, 0.075) // A2 E3 A3 C#4
|
||||
this.bpm = 54
|
||||
this.beatGain = 0.3
|
||||
this.startChords(700, 0.085, false)
|
||||
break
|
||||
case 'anthem':
|
||||
this.bpm = 0
|
||||
this.pad(t, [110, 164.81, 220, 277.18, 329.63, 440], 2600, 0.062) // + E4 A4, filter open
|
||||
this.startChords(2400, 0.075, true)
|
||||
this.shimmer(t)
|
||||
break
|
||||
case 'end':
|
||||
@@ -88,6 +158,7 @@ export class NoFearAudio {
|
||||
dispose(): void {
|
||||
if (this.heartbeatTimer !== null) window.clearInterval(this.heartbeatTimer)
|
||||
this.heartbeatTimer = null
|
||||
this.stopChords()
|
||||
this.releaseVoices(0.1)
|
||||
const ctx = this.ctx
|
||||
this.ctx = null
|
||||
@@ -95,7 +166,7 @@ export class NoFearAudio {
|
||||
if (ctx) window.setTimeout(() => { void ctx.close().catch(() => {}) }, 300)
|
||||
}
|
||||
|
||||
// ── voices ────────────────────────────────────────────────────────────────
|
||||
// ── sustained voices ──────────────────────────────────────────────────────
|
||||
|
||||
private releaseVoices(release: number): void {
|
||||
if (!this.ctx) return
|
||||
@@ -104,7 +175,7 @@ export class NoFearAudio {
|
||||
this.voices = []
|
||||
}
|
||||
|
||||
private hold(gain: GainNode, oscs: OscillatorNode[]): void {
|
||||
private hold(gain: GainNode, oscs: (OscillatorNode | AudioBufferSourceNode)[]): void {
|
||||
this.voices.push({
|
||||
stop: (at) => {
|
||||
gain.gain.cancelScheduledValues(0)
|
||||
@@ -114,7 +185,7 @@ export class NoFearAudio {
|
||||
})
|
||||
}
|
||||
|
||||
/** Two slightly detuned low oscillators through a lowpass — the room tone of the fear act. */
|
||||
/** Two slightly detuned low saws through a lowpass — the fear act's room tone. */
|
||||
private drone(f1: number, f2: number, level: number, cutoff: number, attack: number): void {
|
||||
if (!this.ctx || !this.master) return
|
||||
const t = this.ctx.currentTime
|
||||
@@ -124,7 +195,14 @@ export class NoFearAudio {
|
||||
const lp = this.ctx.createBiquadFilter()
|
||||
lp.type = 'lowpass'
|
||||
lp.frequency.value = cutoff
|
||||
const oscs = [f1, f2].map(f => {
|
||||
// A slow LFO breathes the filter so the drone never sits still.
|
||||
const lfo = this.ctx.createOscillator()
|
||||
lfo.frequency.value = 0.09
|
||||
const lfoGain = this.ctx.createGain()
|
||||
lfoGain.gain.value = cutoff * 0.35
|
||||
lfo.connect(lfoGain); lfoGain.connect(lp.frequency)
|
||||
lfo.start(t)
|
||||
const oscs: (OscillatorNode | AudioBufferSourceNode)[] = [f1, f2].map(f => {
|
||||
const o = this.ctx!.createOscillator()
|
||||
o.type = 'sawtooth'
|
||||
o.frequency.value = f
|
||||
@@ -133,78 +211,162 @@ export class NoFearAudio {
|
||||
o.start(t)
|
||||
return o
|
||||
})
|
||||
oscs.push(lfo)
|
||||
lp.connect(g)
|
||||
g.connect(this.master)
|
||||
this.hold(g, oscs)
|
||||
}
|
||||
|
||||
/** A slow 14s upward sweep, quiet but relentless — the dread act's tension. */
|
||||
/** Bandpassed noise that slowly swells and recedes — wind through the scene. */
|
||||
private wind(level: number, freq: number): void {
|
||||
if (!this.ctx || !this.master || !this.noiseBuf) return
|
||||
const t = this.ctx.currentTime
|
||||
const src = this.ctx.createBufferSource()
|
||||
src.buffer = this.noiseBuf
|
||||
src.loop = true
|
||||
const bp = this.ctx.createBiquadFilter()
|
||||
bp.type = 'bandpass'
|
||||
bp.frequency.value = freq
|
||||
bp.Q.value = 0.6
|
||||
const g = this.ctx.createGain()
|
||||
g.gain.setValueAtTime(0.0001, t)
|
||||
g.gain.exponentialRampToValueAtTime(level, t + 4)
|
||||
const lfo = this.ctx.createOscillator()
|
||||
lfo.frequency.value = 0.07
|
||||
const lfoGain = this.ctx.createGain()
|
||||
lfoGain.gain.value = level * 0.55
|
||||
lfo.connect(lfoGain); lfoGain.connect(g.gain)
|
||||
lfo.start(t)
|
||||
src.connect(bp); bp.connect(g); g.connect(this.master)
|
||||
src.start(t)
|
||||
this.hold(g, [src, lfo])
|
||||
}
|
||||
|
||||
/** Trailer riser: a saw sweep doubled by a noise sweep, 14s of climb. */
|
||||
private riser(t: number): void {
|
||||
if (!this.ctx || !this.master) return
|
||||
const o = this.ctx.createOscillator()
|
||||
o.type = 'sawtooth'
|
||||
o.frequency.setValueAtTime(180, t)
|
||||
o.frequency.exponentialRampToValueAtTime(760, t + 14)
|
||||
o.frequency.exponentialRampToValueAtTime(820, t + 14)
|
||||
const bp = this.ctx.createBiquadFilter()
|
||||
bp.type = 'bandpass'
|
||||
bp.frequency.setValueAtTime(300, t)
|
||||
bp.frequency.exponentialRampToValueAtTime(1200, t + 14)
|
||||
bp.Q.value = 9
|
||||
bp.frequency.exponentialRampToValueAtTime(1400, t + 14)
|
||||
bp.Q.value = 8
|
||||
const g = this.ctx.createGain()
|
||||
g.gain.setValueAtTime(0.0001, t)
|
||||
g.gain.exponentialRampToValueAtTime(0.05, t + 6)
|
||||
g.gain.exponentialRampToValueAtTime(0.055, t + 7)
|
||||
o.connect(bp); bp.connect(g); g.connect(this.master)
|
||||
o.start(t)
|
||||
this.hold(g, [o])
|
||||
const nodes: (OscillatorNode | AudioBufferSourceNode)[] = [o]
|
||||
if (this.noiseBuf) {
|
||||
const n = this.ctx.createBufferSource()
|
||||
n.buffer = this.noiseBuf
|
||||
n.loop = true
|
||||
const nbp = this.ctx.createBiquadFilter()
|
||||
nbp.type = 'bandpass'
|
||||
nbp.frequency.setValueAtTime(240, t)
|
||||
nbp.frequency.exponentialRampToValueAtTime(3200, t + 14)
|
||||
nbp.Q.value = 1.4
|
||||
const ng = this.ctx.createGain()
|
||||
ng.gain.setValueAtTime(0.0001, t)
|
||||
ng.gain.exponentialRampToValueAtTime(0.09, t + 12)
|
||||
n.connect(nbp); nbp.connect(ng); ng.connect(this.master)
|
||||
n.start(t)
|
||||
nodes.push(n)
|
||||
this.hold(ng, [n])
|
||||
}
|
||||
this.hold(g, nodes)
|
||||
}
|
||||
|
||||
/** Warm triangle-wave chord with chorus detune — the release. */
|
||||
private pad(t: number, freqs: number[], cutoff: number, level: number): void {
|
||||
// ── the progression ───────────────────────────────────────────────────────
|
||||
|
||||
/** Five gliding triangle voices + a sub root, stepping through D–A–f#m–E. */
|
||||
private startChords(cutoff: number, level: number, withSub: boolean): void {
|
||||
if (!this.ctx || !this.master) return
|
||||
const t = this.ctx.currentTime
|
||||
const lp = this.ctx.createBiquadFilter()
|
||||
lp.type = 'lowpass'
|
||||
lp.frequency.setValueAtTime(cutoff * 0.4, t)
|
||||
lp.frequency.exponentialRampToValueAtTime(cutoff, t + 6)
|
||||
lp.frequency.setValueAtTime(cutoff * 0.35, t)
|
||||
lp.frequency.exponentialRampToValueAtTime(cutoff, t + 7)
|
||||
const g = this.ctx.createGain()
|
||||
g.gain.setValueAtTime(0.0001, t)
|
||||
g.gain.exponentialRampToValueAtTime(level, t + 3.2)
|
||||
this.padVoices = []
|
||||
const first = PROGRESSION[0]
|
||||
const oscs: OscillatorNode[] = []
|
||||
for (const f of freqs) {
|
||||
for (const det of [-4, 4]) {
|
||||
for (let v = 0; v < first.length; v++) {
|
||||
for (const det of [-5, 5]) {
|
||||
const o = this.ctx.createOscillator()
|
||||
o.type = 'triangle'
|
||||
o.frequency.value = f
|
||||
o.frequency.value = first[v]
|
||||
o.detune.value = det
|
||||
o.connect(lp)
|
||||
o.start(t)
|
||||
oscs.push(o)
|
||||
this.padVoices.push(o)
|
||||
}
|
||||
}
|
||||
lp.connect(g)
|
||||
g.connect(this.master)
|
||||
this.hold(g, oscs)
|
||||
if (withSub) {
|
||||
const sub = this.ctx.createOscillator()
|
||||
sub.type = 'sine'
|
||||
sub.frequency.value = BASS_ROOTS[0]
|
||||
const sg = this.ctx.createGain()
|
||||
sg.gain.setValueAtTime(0.0001, t)
|
||||
sg.gain.exponentialRampToValueAtTime(0.16, t + 2.5)
|
||||
sub.connect(sg); sg.connect(this.master)
|
||||
sub.start(t)
|
||||
this.bassOsc = sub
|
||||
this.hold(sg, [sub])
|
||||
}
|
||||
this.chordIdx = 0
|
||||
this.chordTimer = window.setInterval(() => this.nextChord(), CHORD_SECONDS * 1000)
|
||||
}
|
||||
|
||||
private nextChord(): void {
|
||||
if (!this.ctx || this.padVoices.length === 0) return
|
||||
this.chordIdx = (this.chordIdx + 1) % PROGRESSION.length
|
||||
const chord = PROGRESSION[this.chordIdx]
|
||||
const t = this.ctx.currentTime
|
||||
// Each pair of detuned oscillators shares a chord voice; glide legato.
|
||||
for (let i = 0; i < this.padVoices.length; i++) {
|
||||
const target = chord[Math.floor(i / 2) % chord.length]
|
||||
this.padVoices[i].frequency.setTargetAtTime(target, t, 0.55)
|
||||
}
|
||||
this.bassOsc?.frequency.setTargetAtTime(BASS_ROOTS[this.chordIdx], t, 0.5)
|
||||
}
|
||||
|
||||
private stopChords(): void {
|
||||
if (this.chordTimer !== null) window.clearInterval(this.chordTimer)
|
||||
this.chordTimer = null
|
||||
this.padVoices = []
|
||||
this.bassOsc = null
|
||||
}
|
||||
|
||||
/** Sparse high pings on the A-major pentatonic — tiny lights going on. */
|
||||
private shimmer(t: number): void {
|
||||
if (!this.ctx || !this.master) return
|
||||
const notes = [880, 1108.7, 1318.5, 1760]
|
||||
const notes = [880, 1108.7, 1318.5, 1479.98, 1760]
|
||||
const g = this.ctx.createGain()
|
||||
g.gain.value = 0.05
|
||||
g.gain.value = 0.055
|
||||
g.connect(this.master)
|
||||
const oscs: OscillatorNode[] = []
|
||||
for (let i = 0; i < 10; i++) {
|
||||
for (let i = 0; i < 14; i++) {
|
||||
const o = this.ctx.createOscillator()
|
||||
o.type = 'sine'
|
||||
o.frequency.value = notes[Math.floor(Math.random() * notes.length)]
|
||||
const eg = this.ctx.createGain()
|
||||
const at = t + 0.8 + i * 1.05 + Math.random() * 0.5
|
||||
const at = t + 0.8 + i * 0.8 + Math.random() * 0.4
|
||||
eg.gain.setValueAtTime(0.0001, at)
|
||||
eg.gain.exponentialRampToValueAtTime(1, at + 0.02)
|
||||
eg.gain.exponentialRampToValueAtTime(0.0001, at + 1.6)
|
||||
eg.gain.exponentialRampToValueAtTime(0.0001, at + 1.9)
|
||||
o.connect(eg); eg.connect(g)
|
||||
o.start(at)
|
||||
o.stop(at + 1.8)
|
||||
o.stop(at + 2.1)
|
||||
oscs.push(o)
|
||||
}
|
||||
this.voices.push({ stop: (stopAt) => { g.gain.setTargetAtTime(0, stopAt, 0.2); for (const o of oscs) { try { o.stop(stopAt + 0.5) } catch { /* ok */ } } } })
|
||||
@@ -214,28 +376,45 @@ export class NoFearAudio {
|
||||
|
||||
private scheduleBeats(): void {
|
||||
if (!this.ctx || !this.master || this.bpm <= 0) return
|
||||
// Clamp BEFORE scheduling: after a bpm=0 act (the silence) nextBeat lags far
|
||||
// behind and would otherwise back-fill a burst of past-due thumps at once.
|
||||
if (this.nextBeat < this.ctx.currentTime) this.nextBeat = this.ctx.currentTime + 0.1
|
||||
const ahead = this.ctx.currentTime + 0.3
|
||||
while (this.nextBeat < ahead) {
|
||||
this.thump(this.nextBeat, this.beatGain)
|
||||
this.thump(this.nextBeat + 0.19, this.beatGain * 0.55) // the "dub"
|
||||
this.thump(this.nextBeat + 0.19, this.beatGain * 0.55)
|
||||
this.nextBeat += 60 / this.bpm
|
||||
}
|
||||
if (this.nextBeat < this.ctx.currentTime) this.nextBeat = this.ctx.currentTime + 0.1
|
||||
}
|
||||
|
||||
/** A heartbeat with body AND transient: low sine thump + tiny filtered click. */
|
||||
private thump(at: number, level: number): void {
|
||||
if (!this.ctx || !this.master) return
|
||||
const o = this.ctx.createOscillator()
|
||||
o.type = 'sine'
|
||||
o.frequency.setValueAtTime(62, at)
|
||||
o.frequency.exponentialRampToValueAtTime(38, at + 0.1)
|
||||
o.frequency.setValueAtTime(58, at)
|
||||
o.frequency.exponentialRampToValueAtTime(34, at + 0.12)
|
||||
const g = this.ctx.createGain()
|
||||
g.gain.setValueAtTime(0.0001, at)
|
||||
g.gain.exponentialRampToValueAtTime(level, at + 0.012)
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, at + 0.22)
|
||||
g.gain.exponentialRampToValueAtTime(level, at + 0.01)
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, at + 0.24)
|
||||
o.connect(g)
|
||||
g.connect(this.master)
|
||||
o.start(at)
|
||||
o.stop(at + 0.3)
|
||||
o.stop(at + 0.32)
|
||||
if (this.noiseBuf) {
|
||||
const n = this.ctx.createBufferSource()
|
||||
n.buffer = this.noiseBuf
|
||||
const hp = this.ctx.createBiquadFilter()
|
||||
hp.type = 'lowpass'
|
||||
hp.frequency.value = 340
|
||||
const ng = this.ctx.createGain()
|
||||
ng.gain.setValueAtTime(0.0001, at)
|
||||
ng.gain.exponentialRampToValueAtTime(level * 0.5, at + 0.006)
|
||||
ng.gain.exponentialRampToValueAtTime(0.0001, at + 0.05)
|
||||
n.connect(hp); hp.connect(ng); ng.connect(this.master)
|
||||
n.start(at)
|
||||
n.stop(at + 0.08)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,14 @@ export interface NoFearCopy {
|
||||
face: string
|
||||
fences: string
|
||||
notAnOpinion: string
|
||||
everyDot: string
|
||||
yourPlaces: string
|
||||
yourStatsCountries: string
|
||||
yourStatsTrips: string
|
||||
}
|
||||
// Three staccato words flashed to the heartbeat in the fear act.
|
||||
flashes: [string, string, string]
|
||||
tooBeautiful: string
|
||||
anthem: string
|
||||
creditTitle: string
|
||||
creditBody: string
|
||||
@@ -43,7 +50,13 @@ const en: NoFearCopy = {
|
||||
face: 'You know: the stranger has a face. And it looks like yours.',
|
||||
fences: 'No one who has seen the world wants it locked behind fences again.',
|
||||
notAnOpinion: 'Racism is not an opinion. Fascism is not an alternative.',
|
||||
everyDot: 'Every one of these lights is a table where someone was welcome.',
|
||||
yourPlaces: 'This — this was you.',
|
||||
yourStatsCountries: '{p} places. {c} countries. And not once did the world hurt you.',
|
||||
yourStatsTrips: '{p} places. {t} journeys. And not once did the world hurt you.',
|
||||
},
|
||||
flashes: ['FEAR.', 'HATRED.', 'WALLS.'],
|
||||
tooBeautiful: 'The world is too beautiful for fear. You have seen it yourself.',
|
||||
anthem: 'NO FEAR',
|
||||
creditTitle: 'TREK 4.0.0 — for an open world.',
|
||||
creditBody: 'Against racism, hatred and fascism. Travel turns strangers into neighbors.',
|
||||
@@ -68,7 +81,13 @@ const de: NoFearCopy = {
|
||||
face: 'Du weißt: Das Fremde hat ein Gesicht. Und es sieht aus wie deins.',
|
||||
fences: 'Niemand, der die Welt gesehen hat, will sie wieder hinter Zäune sperren.',
|
||||
notAnOpinion: 'Rassismus ist keine Meinung. Faschismus ist keine Alternative.',
|
||||
everyDot: 'Jedes dieser Lichter ist ein Tisch, an dem jemand willkommen war.',
|
||||
yourPlaces: 'Das hier — das warst du.',
|
||||
yourStatsCountries: '{p} Orte. {c} Länder. Und nicht ein einziges Mal hat die Welt dir wehgetan.',
|
||||
yourStatsTrips: '{p} Orte. {t} Reisen. Und nicht ein einziges Mal hat die Welt dir wehgetan.',
|
||||
},
|
||||
flashes: ['ANGST.', 'HASS.', 'MAUERN.'],
|
||||
tooBeautiful: 'Die Welt ist zu schön für Angst. Du hast sie selbst gesehen.',
|
||||
anthem: 'KEINE ANGST',
|
||||
creditTitle: 'TREK 4.0.0 — für eine offene Welt.',
|
||||
creditBody: 'Gegen Rassismus, Hass und Faschismus. Reisen macht aus Fremden Nachbarn.',
|
||||
@@ -90,18 +109,24 @@ const es: NoFearCopy = {
|
||||
hateTrade: 'El miedo es su herramienta. El odio, su negocio.',
|
||||
butYouTraveled: 'Pero tú has viajado.',
|
||||
tables: 'Has comido en mesas ajenas. Dormido bajo techos ajenos. Reído con desconocidos.',
|
||||
face: 'Lo sabes: lo extranjero tiene un rostro. Y se parece al tuyo.',
|
||||
face: 'Lo sabes: el extranjero tiene un rostro. Y se parece al tuyo.',
|
||||
fences: 'Nadie que haya visto el mundo quiere volver a encerrarlo tras vallas.',
|
||||
notAnOpinion: 'El racismo no es una opinión. El fascismo no es una alternativa.',
|
||||
everyDot: 'Cada una de estas luces es una mesa donde alguien fue bienvenido.',
|
||||
yourPlaces: 'Esto — esto fuiste tú.',
|
||||
yourStatsCountries: '{p} lugares. {c} países. Y ni una sola vez el mundo te hizo daño.',
|
||||
yourStatsTrips: '{p} lugares. {t} viajes. Y ni una sola vez el mundo te hizo daño.',
|
||||
},
|
||||
flashes: ['MIEDO.', 'ODIO.', 'MUROS.'],
|
||||
tooBeautiful: 'El mundo es demasiado hermoso para el miedo. Tú mismo lo has visto.',
|
||||
anthem: 'SIN MIEDO',
|
||||
creditTitle: 'TREK 4.0.0 — por un mundo abierto.',
|
||||
creditBody: 'Contra el racismo, el odio y el fascismo. Viajar convierte a extraños en vecinos.',
|
||||
inspiredBy: 'Inspirado en Danger Dan — “Keine Angst”',
|
||||
close: 'Llévalo contigo',
|
||||
close: 'Pásalo',
|
||||
skip: 'Saltar',
|
||||
soundOn: 'Sonido sí',
|
||||
soundOff: 'Sonido no',
|
||||
soundOn: 'Con sonido',
|
||||
soundOff: 'Sin sonido',
|
||||
}
|
||||
|
||||
const fr: NoFearCopy = {
|
||||
@@ -116,12 +141,18 @@ const fr: NoFearCopy = {
|
||||
butYouTraveled: 'Mais toi, tu as voyagé.',
|
||||
tables: 'Tu as mangé à des tables étrangères. Dormi sous des toits étrangers. Ri avec des inconnus.',
|
||||
face: 'Tu le sais : l’étranger a un visage. Et il ressemble au tien.',
|
||||
fences: 'Personne qui a vu le monde ne veut le remettre derrière des clôtures.',
|
||||
fences: 'Personne qui ait vu le monde ne veut l’enfermer à nouveau derrière des clôtures.',
|
||||
notAnOpinion: 'Le racisme n’est pas une opinion. Le fascisme n’est pas une alternative.',
|
||||
everyDot: 'Chacune de ces lumières est une table où quelqu’un a été bienvenu.',
|
||||
yourPlaces: 'Ça — c’était toi.',
|
||||
yourStatsCountries: '{p} lieux. {c} pays. Et pas une seule fois le monde ne t’a fait de mal.',
|
||||
yourStatsTrips: '{p} lieux. {t} voyages. Et pas une seule fois le monde ne t’a fait de mal.',
|
||||
},
|
||||
flashes: ['PEUR.', 'HAINE.', 'MURS.'],
|
||||
tooBeautiful: 'Le monde est trop beau pour la peur. Tu l’as vu de tes yeux.',
|
||||
anthem: 'SANS PEUR',
|
||||
creditTitle: 'TREK 4.0.0 — pour un monde ouvert.',
|
||||
creditBody: 'Contre le racisme, la haine et le fascisme. Voyager fait des étrangers des voisins.',
|
||||
creditBody: 'Contre le racisme, la haine et le fascisme. Voyager transforme les étrangers en voisins.',
|
||||
inspiredBy: 'Inspiré par Danger Dan — « Keine Angst »',
|
||||
close: 'Fais-le vivre',
|
||||
skip: 'Passer',
|
||||
@@ -141,17 +172,23 @@ const it: NoFearCopy = {
|
||||
butYouTraveled: 'Ma tu hai viaggiato.',
|
||||
tables: 'Hai mangiato a tavole straniere. Dormito sotto tetti stranieri. Riso con sconosciuti.',
|
||||
face: 'Lo sai: lo straniero ha un volto. E assomiglia al tuo.',
|
||||
fences: 'Nessuno che ha visto il mondo vuole richiuderlo dietro recinzioni.',
|
||||
fences: 'Nessuno che abbia visto il mondo vuole richiuderlo dietro recinzioni.',
|
||||
notAnOpinion: 'Il razzismo non è un’opinione. Il fascismo non è un’alternativa.',
|
||||
everyDot: 'Ognuna di queste luci è una tavola dove qualcuno è stato benvenuto.',
|
||||
yourPlaces: 'Questo — questo eri tu.',
|
||||
yourStatsCountries: '{p} luoghi. {c} paesi. E il mondo non ti ha fatto male neanche una volta.',
|
||||
yourStatsTrips: '{p} luoghi. {t} viaggi. E il mondo non ti ha fatto male neanche una volta.',
|
||||
},
|
||||
flashes: ['PAURA.', 'ODIO.', 'MURI.'],
|
||||
tooBeautiful: 'Il mondo è troppo bello per la paura. L’hai visto con i tuoi occhi.',
|
||||
anthem: 'SENZA PAURA',
|
||||
creditTitle: 'TREK 4.0.0 — per un mondo aperto.',
|
||||
creditBody: 'Contro il razzismo, l’odio e il fascismo. Viaggiare trasforma gli estranei in vicini.',
|
||||
inspiredBy: 'Ispirato a Danger Dan — “Keine Angst”',
|
||||
close: 'Portalo avanti',
|
||||
skip: 'Salta',
|
||||
soundOn: 'Audio sì',
|
||||
soundOff: 'Audio no',
|
||||
soundOn: 'Audio attivo',
|
||||
soundOff: 'Audio disattivato',
|
||||
}
|
||||
|
||||
const nl: NoFearCopy = {
|
||||
@@ -166,14 +203,20 @@ const nl: NoFearCopy = {
|
||||
butYouTraveled: 'Maar jij hebt gereisd.',
|
||||
tables: 'Je hebt aan vreemde tafels gegeten. Onder vreemde daken geslapen. Met vreemden gelachen.',
|
||||
face: 'Je weet het: het vreemde heeft een gezicht. En het lijkt op het jouwe.',
|
||||
fences: 'Niemand die de wereld heeft gezien, wil haar weer achter hekken zetten.',
|
||||
fences: 'Niemand die de wereld heeft gezien, wil hem weer achter hekken opsluiten.',
|
||||
notAnOpinion: 'Racisme is geen mening. Fascisme is geen alternatief.',
|
||||
everyDot: 'Elk van deze lichtjes is een tafel waar iemand welkom was.',
|
||||
yourPlaces: 'Dit — dit was jij.',
|
||||
yourStatsCountries: '{p} plekken. {c} landen. En niet één keer heeft de wereld je pijn gedaan.',
|
||||
yourStatsTrips: '{p} plekken. {t} reizen. En niet één keer heeft de wereld je pijn gedaan.',
|
||||
},
|
||||
flashes: ['ANGST.', 'HAAT.', 'MUREN.'],
|
||||
tooBeautiful: 'De wereld is te mooi voor angst. Je hebt het zelf gezien.',
|
||||
anthem: 'GEEN ANGST',
|
||||
creditTitle: 'TREK 4.0.0 — voor een open wereld.',
|
||||
creditBody: 'Tegen racisme, haat en fascisme. Reizen maakt van vreemden buren.',
|
||||
inspiredBy: 'Geïnspireerd door Danger Dan — “Keine Angst”',
|
||||
close: 'Draag het verder',
|
||||
close: 'Geef het door',
|
||||
skip: 'Overslaan',
|
||||
soundOn: 'Geluid aan',
|
||||
soundOff: 'Geluid uit',
|
||||
@@ -193,7 +236,13 @@ const pl: NoFearCopy = {
|
||||
face: 'Wiesz: obcy ma twarz. I wygląda jak twoja.',
|
||||
fences: 'Nikt, kto widział świat, nie chce go znów zamykać za płotami.',
|
||||
notAnOpinion: 'Rasizm to nie opinia. Faszyzm to nie alternatywa.',
|
||||
everyDot: 'Każde z tych świateł to stół, przy którym ktoś był mile widziany.',
|
||||
yourPlaces: 'To — to byłeś ty.',
|
||||
yourStatsCountries: '{p} miejsc. {c} krajów. I ani razu świat nie zrobił ci krzywdy.',
|
||||
yourStatsTrips: '{p} miejsc. {t} podróży. I ani razu świat nie zrobił ci krzywdy.',
|
||||
},
|
||||
flashes: ['STRACH.', 'NIENAWIŚĆ.', 'MURY.'],
|
||||
tooBeautiful: 'Świat jest zbyt piękny na strach. Widziałeś to na własne oczy.',
|
||||
anthem: 'BEZ STRACHU',
|
||||
creditTitle: 'TREK 4.0.0 — dla otwartego świata.',
|
||||
creditBody: 'Przeciw rasizmowi, nienawiści i faszyzmowi. Podróże zmieniają obcych w sąsiadów.',
|
||||
@@ -218,7 +267,13 @@ const ru: NoFearCopy = {
|
||||
face: 'Ты знаешь: у чужого есть лицо. И оно похоже на твоё.',
|
||||
fences: 'Никто, кто видел мир, не хочет снова запирать его за заборами.',
|
||||
notAnOpinion: 'Расизм — не мнение. Фашизм — не альтернатива.',
|
||||
everyDot: 'Каждый из этих огней — стол, за которым кому-то были рады.',
|
||||
yourPlaces: 'Это — это был ты.',
|
||||
yourStatsCountries: '{p} мест. {c} стран. И ни разу мир не причинил тебе зла.',
|
||||
yourStatsTrips: '{p} мест. {t} путешествий. И ни разу мир не причинил тебе зла.',
|
||||
},
|
||||
flashes: ['СТРАХ.', 'НЕНАВИСТЬ.', 'СТЕНЫ.'],
|
||||
tooBeautiful: 'Мир слишком красив для страха. Ты видел это своими глазами.',
|
||||
anthem: 'БЕЗ СТРАХА',
|
||||
creditTitle: 'TREK 4.0.0 — за открытый мир.',
|
||||
creditBody: 'Против расизма, ненависти и фашизма. Путешествия превращают чужих в соседей.',
|
||||
@@ -243,7 +298,13 @@ const uk: NoFearCopy = {
|
||||
face: 'Ти знаєш: чуже має обличчя. І воно схоже на твоє.',
|
||||
fences: 'Ніхто, хто бачив світ, не хоче знову замикати його за парканами.',
|
||||
notAnOpinion: 'Расизм — не думка. Фашизм — не альтернатива.',
|
||||
everyDot: 'Кожен із цих вогників — стіл, за яким комусь були раді.',
|
||||
yourPlaces: 'Це — це був ти.',
|
||||
yourStatsCountries: '{p} місць. {c} країн. І жодного разу світ не заподіяв тобі зла.',
|
||||
yourStatsTrips: '{p} місць. {t} подорожей. І жодного разу світ не заподіяв тобі зла.',
|
||||
},
|
||||
flashes: ['СТРАХ.', 'НЕНАВИСТЬ.', 'МУРИ.'],
|
||||
tooBeautiful: 'Світ надто гарний для страху. Ти бачив це на власні очі.',
|
||||
anthem: 'БЕЗ СТРАХУ',
|
||||
creditTitle: 'TREK 4.0.0 — за відкритий світ.',
|
||||
creditBody: 'Проти расизму, ненависті й фашизму. Подорожі перетворюють чужих на сусідів.',
|
||||
@@ -268,7 +329,13 @@ const cs: NoFearCopy = {
|
||||
face: 'Víš: cizí má tvář. A vypadá jako ta tvoje.',
|
||||
fences: 'Nikdo, kdo viděl svět, ho nechce znovu zavírat za ploty.',
|
||||
notAnOpinion: 'Rasismus není názor. Fašismus není alternativa.',
|
||||
everyDot: 'Každé z těchto světel je stůl, u kterého byl někdo vítán.',
|
||||
yourPlaces: 'Tohle — tohle jsi byl ty.',
|
||||
yourStatsCountries: '{p} míst. {c} zemí. A svět ti ani jednou neublížil.',
|
||||
yourStatsTrips: '{p} míst. {t} cest. A svět ti ani jednou neublížil.',
|
||||
},
|
||||
flashes: ['STRACH.', 'NENÁVIST.', 'ZDI.'],
|
||||
tooBeautiful: 'Svět je na strach příliš krásný. Viděl jsi to na vlastní oči.',
|
||||
anthem: 'BEZE STRACHU',
|
||||
creditTitle: 'TREK 4.0.0 — pro otevřený svět.',
|
||||
creditBody: 'Proti rasismu, nenávisti a fašismu. Cestování dělá z cizinců sousedy.',
|
||||
@@ -285,15 +352,21 @@ const sv: NoFearCopy = {
|
||||
beaconCta: 'Tryck på play.',
|
||||
lines: {
|
||||
afraid: 'De vill att du ska vara rädd.',
|
||||
ofTheStranger: 'Rädd för främlingen. Rädd för allt du inte känner.',
|
||||
ofTheStranger: 'Rädd för främlingen. Rädd för allt du inte känner till.',
|
||||
fearTool: 'För rädsla stänger gränser — först på kartor, sedan i huvuden.',
|
||||
hateTrade: 'Rädsla är deras verktyg. Hat är deras handel.',
|
||||
hateTrade: 'Rädsla är deras verktyg. Hat är deras handelsvara.',
|
||||
butYouTraveled: 'Men du har rest.',
|
||||
tables: 'Du har ätit vid främmande bord. Sovit under främmande tak. Skrattat med främlingar.',
|
||||
face: 'Du vet: det främmande har ett ansikte. Och det liknar ditt.',
|
||||
fences: 'Ingen som har sett världen vill låsa in den bakom stängsel igen.',
|
||||
notAnOpinion: 'Rasism är ingen åsikt. Fascism är inget alternativ.',
|
||||
everyDot: 'Varje ljus här är ett bord där någon var välkommen.',
|
||||
yourPlaces: 'Det här — det var du.',
|
||||
yourStatsCountries: '{p} platser. {c} länder. Och inte en enda gång gjorde världen dig illa.',
|
||||
yourStatsTrips: '{p} platser. {t} resor. Och inte en enda gång gjorde världen dig illa.',
|
||||
},
|
||||
flashes: ['RÄDSLA.', 'HAT.', 'MURAR.'],
|
||||
tooBeautiful: 'Världen är för vacker för rädsla. Du har sett det själv.',
|
||||
anthem: 'INGEN RÄDSLA',
|
||||
creditTitle: 'TREK 4.0.0 — för en öppen värld.',
|
||||
creditBody: 'Mot rasism, hat och fascism. Att resa gör främlingar till grannar.',
|
||||
@@ -317,13 +390,19 @@ const tr: NoFearCopy = {
|
||||
tables: 'Yabancı sofralarda yemek yedin. Yabancı çatılar altında uyudun. Yabancılarla güldün.',
|
||||
face: 'Biliyorsun: yabancının bir yüzü var. Ve seninkine benziyor.',
|
||||
fences: 'Dünyayı görmüş hiç kimse onu yeniden çitlerin arkasına kapatmak istemez.',
|
||||
notAnOpinion: 'Irkçılık bir görüş değildir. Faşizm bir alternatif değildir.',
|
||||
notAnOpinion: 'Irkçılık bir fikir değil. Faşizm bir alternatif değil.',
|
||||
everyDot: 'Bu ışıkların her biri, birinin hoş karşılandığı bir sofra.',
|
||||
yourPlaces: 'Bu — buydun sen.',
|
||||
yourStatsCountries: '{p} yer. {c} ülke. Ve dünya sana bir kez bile zarar vermedi.',
|
||||
yourStatsTrips: '{p} yer. {t} seyahat. Ve dünya sana bir kez bile zarar vermedi.',
|
||||
},
|
||||
flashes: ['KORKU.', 'NEFRET.', 'DUVARLAR.'],
|
||||
tooBeautiful: 'Dünya korku için fazla güzel. Kendi gözlerinle gördün.',
|
||||
anthem: 'KORKMA',
|
||||
creditTitle: 'TREK 4.0.0 — açık bir dünya için.',
|
||||
creditBody: 'Irkçılığa, nefrete ve faşizme karşı. Seyahat, yabancıları komşulara dönüştürür.',
|
||||
inspiredBy: 'Danger Dan’den ilhamla — “Keine Angst”',
|
||||
close: 'Taşımaya devam et',
|
||||
close: 'İleri taşı',
|
||||
skip: 'Atla',
|
||||
soundOn: 'Ses açık',
|
||||
soundOff: 'Ses kapalı',
|
||||
@@ -343,15 +422,21 @@ const gr: NoFearCopy = {
|
||||
face: 'Το ξέρεις: το ξένο έχει πρόσωπο. Και μοιάζει με το δικό σου.',
|
||||
fences: 'Κανείς που είδε τον κόσμο δεν θέλει να τον ξανακλείσει πίσω από φράχτες.',
|
||||
notAnOpinion: 'Ο ρατσισμός δεν είναι άποψη. Ο φασισμός δεν είναι εναλλακτική.',
|
||||
everyDot: 'Καθένα από αυτά τα φώτα είναι ένα τραπέζι όπου κάποιος ήταν ευπρόσδεκτος.',
|
||||
yourPlaces: 'Αυτό — αυτό ήσουν εσύ.',
|
||||
yourStatsCountries: '{p} μέρη. {c} χώρες. Και ούτε μία φορά ο κόσμος δεν σε πλήγωσε.',
|
||||
yourStatsTrips: '{p} μέρη. {t} ταξίδια. Και ούτε μία φορά ο κόσμος δεν σε πλήγωσε.',
|
||||
},
|
||||
flashes: ['ΦΟΒΟΣ.', 'ΜΙΣΟΣ.', 'ΤΕΙΧΗ.'],
|
||||
tooBeautiful: 'Ο κόσμος είναι πολύ όμορφος για φόβο. Το είδες με τα μάτια σου.',
|
||||
anthem: 'ΧΩΡΙΣ ΦΟΒΟ',
|
||||
creditTitle: 'TREK 4.0.0 — για έναν ανοιχτό κόσμο.',
|
||||
creditBody: 'Ενάντια στον ρατσισμό, το μίσος και τον φασισμό. Το ταξίδι κάνει τους ξένους γείτονες.',
|
||||
inspiredBy: 'Εμπνευσμένο από τον Danger Dan — «Keine Angst»',
|
||||
close: 'Δώσ’ το παρακάτω',
|
||||
skip: 'Παράλειψη',
|
||||
soundOn: 'Ήχος ναι',
|
||||
soundOff: 'Ήχος όχι',
|
||||
soundOn: 'Με ήχο',
|
||||
soundOff: 'Χωρίς ήχο',
|
||||
}
|
||||
|
||||
const hu: NoFearCopy = {
|
||||
@@ -368,7 +453,13 @@ const hu: NoFearCopy = {
|
||||
face: 'Tudod: az idegennek arca van. És a tiédre hasonlít.',
|
||||
fences: 'Senki, aki látta a világot, nem akarja újra kerítések mögé zárni.',
|
||||
notAnOpinion: 'A rasszizmus nem vélemény. A fasizmus nem alternatíva.',
|
||||
everyDot: 'Ezek a fények mind egy-egy asztal, ahol valakit szívesen láttak.',
|
||||
yourPlaces: 'Ez — ez te voltál.',
|
||||
yourStatsCountries: '{p} hely. {c} ország. És a világ egyetlenegyszer sem bántott.',
|
||||
yourStatsTrips: '{p} hely. {t} utazás. És a világ egyetlenegyszer sem bántott.',
|
||||
},
|
||||
flashes: ['FÉLELEM.', 'GYŰLÖLET.', 'FALAK.'],
|
||||
tooBeautiful: 'A világ túl szép a félelemhez. A saját szemeddel láttad.',
|
||||
anthem: 'NE FÉLJ',
|
||||
creditTitle: 'TREK 4.0.0 — egy nyitott világért.',
|
||||
creditBody: 'A rasszizmus, a gyűlölet és a fasizmus ellen. Az utazás szomszédokká teszi az idegeneket.',
|
||||
@@ -382,18 +473,24 @@ const hu: NoFearCopy = {
|
||||
const br: NoFearCopy = {
|
||||
beaconTitle: 'SEM MEDO',
|
||||
beaconSub: 'Um sinal por um mundo aberto.',
|
||||
beaconCta: 'Aperte play.',
|
||||
beaconCta: 'Aperte o play.',
|
||||
lines: {
|
||||
afraid: 'Eles querem que você tenha medo.',
|
||||
ofTheStranger: 'Medo do estrangeiro. Medo de tudo o que você não conhece.',
|
||||
fearTool: 'Porque o medo fecha fronteiras — primeiro nos mapas, depois nas mentes.',
|
||||
hateTrade: 'O medo é a ferramenta deles. O ódio, o seu negócio.',
|
||||
hateTrade: 'O medo é a ferramenta deles. O ódio, o negócio deles.',
|
||||
butYouTraveled: 'Mas você viajou.',
|
||||
tables: 'Você comeu em mesas estrangeiras. Dormiu sob tetos estrangeiros. Riu com desconhecidos.',
|
||||
face: 'Você sabe: o estrangeiro tem um rosto. E ele se parece com o seu.',
|
||||
fences: 'Ninguém que viu o mundo quer trancá-lo atrás de cercas de novo.',
|
||||
notAnOpinion: 'Racismo não é opinião. Fascismo não é alternativa.',
|
||||
everyDot: 'Cada uma destas luzes é uma mesa onde alguém foi bem-vindo.',
|
||||
yourPlaces: 'Isto — isto foi você.',
|
||||
yourStatsCountries: '{p} lugares. {c} países. E nem uma única vez o mundo te machucou.',
|
||||
yourStatsTrips: '{p} lugares. {t} viagens. E nem uma única vez o mundo te machucou.',
|
||||
},
|
||||
flashes: ['MEDO.', 'ÓDIO.', 'MUROS.'],
|
||||
tooBeautiful: 'O mundo é bonito demais para o medo. Você viu com os próprios olhos.',
|
||||
anthem: 'SEM MEDO',
|
||||
creditTitle: 'TREK 4.0.0 — por um mundo aberto.',
|
||||
creditBody: 'Contra o racismo, o ódio e o fascismo. Viajar transforma estranhos em vizinhos.',
|
||||
@@ -418,7 +515,13 @@ const ca: NoFearCopy = {
|
||||
face: 'Ho saps: l’estranger té un rostre. I s’assembla al teu.',
|
||||
fences: 'Ningú que hagi vist el món vol tornar-lo a tancar rere tanques.',
|
||||
notAnOpinion: 'El racisme no és una opinió. El feixisme no és una alternativa.',
|
||||
everyDot: 'Cadascuna d’aquestes llums és una taula on algú va ser benvingut.',
|
||||
yourPlaces: 'Això — això eres tu.',
|
||||
yourStatsCountries: '{p} llocs. {c} països. I ni una sola vegada el món et va fer mal.',
|
||||
yourStatsTrips: '{p} llocs. {t} viatges. I ni una sola vegada el món et va fer mal.',
|
||||
},
|
||||
flashes: ['POR.', 'ODI.', 'MURS.'],
|
||||
tooBeautiful: 'El món és massa bonic per a la por. Ho has vist amb els teus ulls.',
|
||||
anthem: 'SENSE POR',
|
||||
creditTitle: 'TREK 4.0.0 — per un món obert.',
|
||||
creditBody: 'Contra el racisme, l’odi i el feixisme. Viatjar converteix els estranys en veïns.',
|
||||
@@ -443,7 +546,13 @@ const id: NoFearCopy = {
|
||||
face: 'Kamu tahu: orang asing punya wajah. Dan wajah itu mirip wajahmu.',
|
||||
fences: 'Tak seorang pun yang pernah melihat dunia ingin menguncinya lagi di balik pagar.',
|
||||
notAnOpinion: 'Rasisme bukan pendapat. Fasisme bukan alternatif.',
|
||||
everyDot: 'Setiap cahaya ini adalah meja tempat seseorang disambut.',
|
||||
yourPlaces: 'Ini — ini adalah kamu.',
|
||||
yourStatsCountries: '{p} tempat. {c} negara. Dan dunia tak pernah sekali pun menyakitimu.',
|
||||
yourStatsTrips: '{p} tempat. {t} perjalanan. Dan dunia tak pernah sekali pun menyakitimu.',
|
||||
},
|
||||
flashes: ['TAKUT.', 'BENCI.', 'TEMBOK.'],
|
||||
tooBeautiful: 'Dunia terlalu indah untuk rasa takut. Kamu sudah melihatnya sendiri.',
|
||||
anthem: 'JANGAN TAKUT',
|
||||
creditTitle: 'TREK 4.0.0 — untuk dunia yang terbuka.',
|
||||
creditBody: 'Melawan rasisme, kebencian, dan fasisme. Bepergian mengubah orang asing menjadi tetangga.',
|
||||
@@ -468,7 +577,13 @@ const vi: NoFearCopy = {
|
||||
face: 'Bạn biết: người lạ có một gương mặt. Và nó giống gương mặt bạn.',
|
||||
fences: 'Không ai từng thấy thế giới lại muốn nhốt nó sau hàng rào lần nữa.',
|
||||
notAnOpinion: 'Phân biệt chủng tộc không phải là quan điểm. Chủ nghĩa phát xít không phải là lựa chọn thay thế.',
|
||||
everyDot: 'Mỗi ánh đèn này là một bàn ăn nơi ai đó từng được chào đón.',
|
||||
yourPlaces: 'Đây — đây chính là bạn.',
|
||||
yourStatsCountries: '{p} địa điểm. {c} quốc gia. Và chưa một lần nào thế giới làm bạn tổn thương.',
|
||||
yourStatsTrips: '{p} địa điểm. {t} chuyến đi. Và chưa một lần nào thế giới làm bạn tổn thương.',
|
||||
},
|
||||
flashes: ['SỢ HÃI.', 'HẬN THÙ.', 'BỨC TƯỜNG.'],
|
||||
tooBeautiful: 'Thế giới quá đẹp để phải sợ hãi. Chính bạn đã thấy điều đó.',
|
||||
anthem: 'ĐỪNG SỢ',
|
||||
creditTitle: 'TREK 4.0.0 — vì một thế giới rộng mở.',
|
||||
creditBody: 'Chống phân biệt chủng tộc, hận thù và chủ nghĩa phát xít. Du lịch biến người lạ thành láng giềng.',
|
||||
@@ -493,7 +608,13 @@ const ja: NoFearCopy = {
|
||||
face: 'あなたは知っている。「よそ者」には顔があり、それはあなたの顔に似ていることを。',
|
||||
fences: '世界を見た人は誰も、それを再び柵の中に閉じ込めたいとは思わない。',
|
||||
notAnOpinion: 'レイシズムは意見ではない。ファシズムは選択肢ではない。',
|
||||
everyDot: 'この光のひとつひとつが、誰かが歓迎された食卓です。',
|
||||
yourPlaces: 'これは — あなたでした。',
|
||||
yourStatsCountries: '{p}の場所。{c}の国。世界があなたを傷つけたことは、一度もなかった。',
|
||||
yourStatsTrips: '{p}の場所。{t}の旅。世界があなたを傷つけたことは、一度もなかった。',
|
||||
},
|
||||
flashes: ['恐怖。', '憎悪。', '壁。'],
|
||||
tooBeautiful: '世界は恐れるにはあまりに美しい。あなた自身が見てきたのだから。',
|
||||
anthem: '恐れないで',
|
||||
creditTitle: 'TREK 4.0.0 — 開かれた世界のために。',
|
||||
creditBody: 'レイシズム、憎しみ、ファシズムに抗して。旅は見知らぬ人を隣人に変える。',
|
||||
@@ -518,7 +639,13 @@ const ko: NoFearCopy = {
|
||||
face: '당신은 압니다. 낯선 이에게도 얼굴이 있고, 그 얼굴이 당신을 닮았다는 것을.',
|
||||
fences: '세상을 본 사람이라면 누구도 그것을 다시 울타리 뒤에 가두고 싶어 하지 않습니다.',
|
||||
notAnOpinion: '인종차별은 의견이 아닙니다. 파시즘은 대안이 아닙니다.',
|
||||
everyDot: '이 불빛 하나하나가 누군가 환영받았던 식탁입니다.',
|
||||
yourPlaces: '이것이 — 바로 당신이었습니다.',
|
||||
yourStatsCountries: '{p}곳의 장소. {c}개의 나라. 그리고 세상은 단 한 번도 당신을 해치지 않았습니다.',
|
||||
yourStatsTrips: '{p}곳의 장소. {t}번의 여행. 그리고 세상은 단 한 번도 당신을 해치지 않았습니다.',
|
||||
},
|
||||
flashes: ['두려움.', '증오.', '장벽.'],
|
||||
tooBeautiful: '세상은 두려워하기엔 너무 아름답습니다. 당신이 직접 보았습니다.',
|
||||
anthem: '두려워하지 마',
|
||||
creditTitle: 'TREK 4.0.0 — 열린 세상을 위하여.',
|
||||
creditBody: '인종차별, 증오, 파시즘에 반대하며. 여행은 낯선 사람을 이웃으로 만듭니다.',
|
||||
@@ -543,7 +670,13 @@ const zh: NoFearCopy = {
|
||||
face: '你知道:陌生人也有一张脸,而那张脸和你的很像。',
|
||||
fences: '见过世界的人,没有谁愿意再把它锁回栅栏之后。',
|
||||
notAnOpinion: '种族主义不是一种观点。法西斯主义不是一种选择。',
|
||||
everyDot: '这里的每一盏灯,都是一张有人被欢迎过的餐桌。',
|
||||
yourPlaces: '这——就是你。',
|
||||
yourStatsCountries: '{p} 个地方。{c} 个国家。而世界从未伤害过你一次。',
|
||||
yourStatsTrips: '{p} 个地方。{t} 次旅行。而世界从未伤害过你一次。',
|
||||
},
|
||||
flashes: ['恐惧。', '仇恨。', '高墙。'],
|
||||
tooBeautiful: '这个世界美得容不下恐惧。你亲眼见过。',
|
||||
anthem: '别害怕',
|
||||
creditTitle: 'TREK 4.0.0 — 为了一个开放的世界。',
|
||||
creditBody: '反对种族主义、仇恨与法西斯主义。旅行让陌生人成为邻居。',
|
||||
@@ -568,7 +701,13 @@ const zhTW: NoFearCopy = {
|
||||
face: '你知道:陌生人也有一張臉,而那張臉和你的很像。',
|
||||
fences: '見過世界的人,沒有誰願意再把它鎖回柵欄之後。',
|
||||
notAnOpinion: '種族主義不是一種觀點。法西斯主義不是一種選項。',
|
||||
everyDot: '這裡的每一盞燈,都是一張有人被歡迎過的餐桌。',
|
||||
yourPlaces: '這——就是你。',
|
||||
yourStatsCountries: '{p} 個地方。{c} 個國家。而世界從未傷害過你一次。',
|
||||
yourStatsTrips: '{p} 個地方。{t} 次旅行。而世界從未傷害過你一次。',
|
||||
},
|
||||
flashes: ['恐懼。', '仇恨。', '高牆。'],
|
||||
tooBeautiful: '這個世界美得容不下恐懼。你親眼見過。',
|
||||
anthem: '別害怕',
|
||||
creditTitle: 'TREK 4.0.0 — 為了一個開放的世界。',
|
||||
creditBody: '反對種族主義、仇恨與法西斯主義。旅行讓陌生人成為鄰居。',
|
||||
@@ -593,7 +732,13 @@ const ar: NoFearCopy = {
|
||||
face: 'أنت تعرف: للغريب وجه. وهو يشبه وجهك.',
|
||||
fences: 'لا أحد رأى العالم يريد أن يحبسه خلف الأسوار من جديد.',
|
||||
notAnOpinion: 'العنصرية ليست رأيًا. الفاشية ليست بديلًا.',
|
||||
everyDot: 'كل ضوء من هذه الأضواء مائدة رُحّب فيها بأحدهم.',
|
||||
yourPlaces: 'هذا — كان أنت.',
|
||||
yourStatsCountries: '{p} أماكن. {c} دولة. ولم يؤذِك العالم ولا مرة واحدة.',
|
||||
yourStatsTrips: '{p} أماكن. {t} رحلة. ولم يؤذِك العالم ولا مرة واحدة.',
|
||||
},
|
||||
flashes: ['الخوف.', 'الكراهية.', 'الجدران.'],
|
||||
tooBeautiful: 'العالم أجمل من أن نخاف. لقد رأيته بعينيك.',
|
||||
anthem: 'لا تخف',
|
||||
creditTitle: 'TREK 4.0.0 — من أجل عالم مفتوح.',
|
||||
creditBody: 'ضد العنصرية والكراهية والفاشية. السفر يحوّل الغرباء إلى جيران.',
|
||||
|
||||
@@ -1,25 +1,36 @@
|
||||
// Canvas scene for the 4.0.0 "KEINE ANGST" show: a world of dots whose borders
|
||||
// first burn red, then dissolve under a growing web of travel connections.
|
||||
// Canvas scene for the 4.0.0 "KEINE ANGST" show: a world of dots whose city
|
||||
// lights die while its borders burn red — until the travel web returns, the
|
||||
// user's own places ignite, and the borders shatter into drifting sparks.
|
||||
//
|
||||
// The landmass and border geometry come from TREK's own Atlas bundle
|
||||
// (/addons/atlas/countries/geo). The polygons are rasterized once onto a small
|
||||
// offscreen canvas (the browser does the point-in-polygon work natively), then
|
||||
// sampled into a dot grid. If the Atlas addon is disabled the show degrades
|
||||
// gracefully: no map, but the city web and typography still carry it.
|
||||
// Perf model: nothing expensive happens per frame. The Atlas rings are
|
||||
// decimated to a global vertex budget first; land dots, borders and the
|
||||
// finished part of the travel web are pre-rendered into offscreen layers
|
||||
// (rebuilt on resize) and blitted with drawImage. No shadowBlur anywhere in
|
||||
// the frame path — every glow is baked into a layer or a sprite.
|
||||
//
|
||||
// The geometry comes from TREK's own Atlas bundle (/addons/atlas/countries/geo).
|
||||
// If the Atlas addon is disabled the show degrades gracefully: no map, but the
|
||||
// city web and typography still carry it.
|
||||
|
||||
type Ring = [number, number][]
|
||||
|
||||
export interface SceneState {
|
||||
/** 0→1: how far the land dots have faded in. */
|
||||
land: number
|
||||
/** 0→1: the calm city lights of the opening. */
|
||||
cityLife: number
|
||||
/** 0→1: how far those lights have died during the fear act. */
|
||||
cityDeath: number
|
||||
/** 0→1: how hot the borders burn (red act). */
|
||||
borderHeat: number
|
||||
/** 0→1: how far the borders have dissolved (0 = solid, 1 = gone). */
|
||||
borderGone: number
|
||||
/** 0→1: the borders shattering into sparks at the climax. */
|
||||
borderBurst: number
|
||||
/** 0→1: how much of the city web has grown. */
|
||||
web: number
|
||||
/** 0→1: warm ambient glow of the release act. */
|
||||
warmth: number
|
||||
/** 0→1: the user's own places igniting gold. */
|
||||
personalGlow: number
|
||||
/** 0→1: rising particles of the anthem. */
|
||||
particles: number
|
||||
/** Master opacity for the whole scene. */
|
||||
@@ -39,20 +50,38 @@ const CITIES: [number, number][] = [
|
||||
[-12.05, -77.04], [-23.55, -46.63], [-34.6, -58.38], [-33.45, -70.67], [-22.91, -43.17],
|
||||
]
|
||||
|
||||
interface Arc { a: number; b: number; delay: number }
|
||||
interface Dot { x: number; y: number; r: number; tw: number }
|
||||
const LAT_TOP = 78
|
||||
const LAT_BOT = -58
|
||||
const TWINKLE_GROUPS = 3
|
||||
const BORDER_VERTEX_BUDGET = 14000
|
||||
const BURST_SPARKS = 2200
|
||||
|
||||
interface Arc { a: number; b: number; delay: number; baked: boolean }
|
||||
interface Particle { x: number; y: number; v: number; drift: number; size: number; seed: number }
|
||||
interface Spark { lon: number; lat: number; vx: number; vy: number; seed: number }
|
||||
|
||||
const nextFrame = () => new Promise<void>(r => requestAnimationFrame(() => r()))
|
||||
|
||||
export class NoFearScene {
|
||||
private dots: Dot[] = []
|
||||
private borders: Path2D | null = null
|
||||
private rings: Ring[] = []
|
||||
private dotLonLat: { x: number; y: number; r: number; g: number }[] = []
|
||||
private arcs: Arc[] = []
|
||||
private particlesPool: Particle[] = []
|
||||
private sparks: Spark[] = []
|
||||
private personal: { lat: number; lng: number }[] = []
|
||||
private w = 0
|
||||
private h = 0
|
||||
private ready = false
|
||||
|
||||
/** Maps lon/lat to scene pixels. Set up in layout(). */
|
||||
// Prebaked layers (screen-sized). Rebuilt on resize / when geometry arrives.
|
||||
private coldLayers: HTMLCanvasElement[] = []
|
||||
private warmLayers: HTMLCanvasElement[] = []
|
||||
private borderLayer: HTMLCanvasElement | null = null
|
||||
private webLayer: HTMLCanvasElement | null = null
|
||||
private webCtx: CanvasRenderingContext2D | null = null
|
||||
private citySprite: HTMLCanvasElement | null = null
|
||||
private goldSprite: HTMLCanvasElement | null = null
|
||||
private lastWeb = 0
|
||||
|
||||
private px = (lon: number): number => lon
|
||||
private py = (lat: number): number => lat
|
||||
|
||||
@@ -66,15 +95,13 @@ export class NoFearScene {
|
||||
}
|
||||
const seen = new Set<string>()
|
||||
CITIES.forEach((_, i) => {
|
||||
const peers = CITIES
|
||||
.map((_, j) => j)
|
||||
.filter(j => j !== i && d2(i, j) > 900)
|
||||
const peers = CITIES.map((_, j) => j).filter(j => j !== i && d2(i, j) > 900)
|
||||
for (let k = 0; k < 2 && peers.length > 0; k++) {
|
||||
const j = peers.splice(Math.floor(Math.random() * peers.length), 1)[0]
|
||||
const key = i < j ? `${i}-${j}` : `${j}-${i}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
this.arcs.push({ a: i, b: j, delay: Math.random() })
|
||||
this.arcs.push({ a: i, b: j, delay: Math.random(), baked: false })
|
||||
}
|
||||
})
|
||||
this.arcs.sort((x, y) => x.delay - y.delay)
|
||||
@@ -86,48 +113,68 @@ export class NoFearScene {
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetches the Atlas country bundle and builds dots + border paths. Safe to fail. */
|
||||
async load(width: number, height: number): Promise<void> {
|
||||
/** The user's own visited places — they ignite gold in the release act. */
|
||||
setPersonalPlaces(points: { lat: number; lng: number }[]): void {
|
||||
this.personal = points.slice(0, 400)
|
||||
}
|
||||
|
||||
/** Fetches the Atlas country bundle and builds all static layers. Safe to fail. */
|
||||
async load(width: number, height: number, signal?: AbortSignal): Promise<void> {
|
||||
this.layout(width, height)
|
||||
try {
|
||||
const res = await fetch('/api/addons/atlas/countries/geo', { credentials: 'include' })
|
||||
const res = await fetch('/api/addons/atlas/countries/geo', { credentials: 'include', signal })
|
||||
if (!res.ok) return
|
||||
const geo = await res.json() as { features?: { geometry?: { type: string; coordinates: unknown } }[] }
|
||||
const rings: Ring[] = []
|
||||
if (signal?.aborted) return
|
||||
await nextFrame() // let the parse frame paint before the geometry pass
|
||||
const raw: Ring[] = []
|
||||
for (const f of geo.features ?? []) {
|
||||
const g = f.geometry
|
||||
if (!g) continue
|
||||
if (g.type === 'Polygon') for (const r of g.coordinates as Ring[]) rings.push(r)
|
||||
else if (g.type === 'MultiPolygon') for (const p of g.coordinates as Ring[][]) for (const r of p) rings.push(r)
|
||||
if (g.type === 'Polygon') for (const r of g.coordinates as Ring[]) raw.push(r)
|
||||
else if (g.type === 'MultiPolygon') for (const p of g.coordinates as Ring[][]) for (const r of p) raw.push(r)
|
||||
}
|
||||
if (rings.length === 0) return
|
||||
this.buildDots(rings)
|
||||
this.buildBorders(rings)
|
||||
this.ready = true
|
||||
if (raw.length === 0) return
|
||||
// Decimate against a global vertex budget BEFORE any rasterizing — the
|
||||
// Atlas bundle carries ~2M points, the show needs a fraction of that.
|
||||
const total = raw.reduce((n, r) => n + r.length, 0)
|
||||
const step = Math.max(1, Math.ceil(total / BORDER_VERTEX_BUDGET))
|
||||
const rings: Ring[] = []
|
||||
for (const r of raw) {
|
||||
if (r.length < step * 4) continue // rings too small to survive decimation are visual noise
|
||||
const thin: Ring = []
|
||||
for (let i = 0; i < r.length; i += step) thin.push(r[i])
|
||||
if (thin.length >= 4) rings.push(thin)
|
||||
}
|
||||
this.rings = rings
|
||||
if (signal?.aborted) return
|
||||
await nextFrame()
|
||||
this.sampleDots(rings)
|
||||
if (signal?.aborted) return
|
||||
await nextFrame()
|
||||
this.bake()
|
||||
} catch {
|
||||
// Atlas addon off / offline — the show runs without the map.
|
||||
// Atlas addon off / offline / aborted — the show runs without the map.
|
||||
}
|
||||
}
|
||||
|
||||
layout(width: number, height: number): void {
|
||||
const changed = width !== this.w || height !== this.h
|
||||
this.w = width
|
||||
this.h = height
|
||||
// Equirectangular, latitude clamped to [-58, 78] (no empty Antarctica band),
|
||||
// fit as "cover" so the map always fills the screen width.
|
||||
const latTop = 78
|
||||
const latBot = -58
|
||||
const scale = Math.max(width / 360, height / (latTop - latBot))
|
||||
// Equirectangular, latitude clamped (no empty Antarctica band), "cover" fit.
|
||||
const scale = Math.max(width / 360, height / (LAT_TOP - LAT_BOT))
|
||||
const mapW = 360 * scale
|
||||
const mapH = (latTop - latBot) * scale
|
||||
const mapH = (LAT_TOP - LAT_BOT) * scale
|
||||
const ox = (width - mapW) / 2
|
||||
const oy = (height - mapH) / 2
|
||||
this.px = (lon: number) => ox + (lon + 180) * scale
|
||||
this.py = (lat: number) => oy + (latTop - lat) * scale
|
||||
this.py = (lat: number) => oy + (LAT_TOP - lat) * scale
|
||||
// Layers bake in pixel space — a resize invalidates them all.
|
||||
if (changed && this.rings.length > 0) this.bake()
|
||||
}
|
||||
|
||||
private buildDots(rings: Ring[]): void {
|
||||
// Rasterize the filled polygons at low resolution; the alpha channel then
|
||||
// tells us where land is. ~160 columns keeps this a one-off few-ms job.
|
||||
private sampleDots(rings: Ring[]): void {
|
||||
const cols = 168
|
||||
const rows = 84
|
||||
const off = document.createElement('canvas')
|
||||
@@ -136,51 +183,148 @@ export class NoFearScene {
|
||||
const c = off.getContext('2d')
|
||||
if (!c) return
|
||||
const sx = (lon: number) => ((lon + 180) / 360) * cols
|
||||
const sy = (lat: number) => ((78 - lat) / (78 - -58)) * rows
|
||||
const sy = (lat: number) => ((LAT_TOP - lat) / (LAT_TOP - LAT_BOT)) * rows
|
||||
c.fillStyle = '#fff'
|
||||
c.beginPath()
|
||||
for (const ring of rings) {
|
||||
if (ring.length < 3) continue
|
||||
c.moveTo(sx(ring[0][0]), sy(ring[0][1]))
|
||||
for (let i = 1; i < ring.length; i++) c.lineTo(sx(ring[i][0]), sy(ring[i][1]))
|
||||
c.closePath()
|
||||
}
|
||||
c.fill('evenodd')
|
||||
const img = c.getImageData(0, 0, cols, rows).data
|
||||
this.dots = []
|
||||
this.dotLonLat = []
|
||||
for (let y = 0; y < rows; y++) {
|
||||
for (let x = 0; x < cols; x++) {
|
||||
if (img[(y * cols + x) * 4 + 3] > 96) {
|
||||
const lon = (x / cols) * 360 - 180
|
||||
const lat = 78 - (y / rows) * (78 - -58)
|
||||
this.dots.push({
|
||||
x: lon, y: lat,
|
||||
this.dotLonLat.push({
|
||||
x: (x / cols) * 360 - 180,
|
||||
y: LAT_TOP - (y / rows) * (LAT_TOP - LAT_BOT),
|
||||
r: 0.8 + Math.random() * 0.9,
|
||||
tw: Math.random() * Math.PI * 2,
|
||||
g: Math.floor(Math.random() * TWINKLE_GROUPS),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private buildBorders(rings: Ring[]): void {
|
||||
const path = new Path2D()
|
||||
for (const ring of rings) {
|
||||
if (ring.length < 8) continue // skip islets — visual noise at this scale
|
||||
// Decimate: the show projection is far coarser than the Atlas zoom levels.
|
||||
const step = Math.max(1, Math.floor(ring.length / 240))
|
||||
path.moveTo(this.px(ring[0][0]), this.py(ring[0][1]))
|
||||
for (let i = step; i < ring.length; i += step) {
|
||||
path.lineTo(this.px(ring[i][0]), this.py(ring[i][1]))
|
||||
}
|
||||
path.closePath()
|
||||
/** (Re)builds every static layer in current screen coordinates. */
|
||||
private bake(): void {
|
||||
const mk = () => {
|
||||
const cv = document.createElement('canvas')
|
||||
cv.width = Math.max(this.w, 1)
|
||||
cv.height = Math.max(this.h, 1)
|
||||
return cv
|
||||
}
|
||||
this.borders = path
|
||||
// Dot layers: one cold + one warm canvas per twinkle group.
|
||||
this.coldLayers = []
|
||||
this.warmLayers = []
|
||||
for (let g = 0; g < TWINKLE_GROUPS; g++) {
|
||||
const cold = mk(); const warm = mk()
|
||||
const cc = cold.getContext('2d'); const wc = warm.getContext('2d')
|
||||
if (!cc || !wc) continue
|
||||
cc.fillStyle = 'rgb(112, 132, 168)'
|
||||
wc.fillStyle = 'rgb(214, 189, 141)'
|
||||
for (const d of this.dotLonLat) {
|
||||
if (d.g !== g) continue
|
||||
const x = this.px(d.x); const y = this.py(d.y)
|
||||
cc.beginPath(); cc.arc(x, y, d.r, 0, Math.PI * 2); cc.fill()
|
||||
wc.beginPath(); wc.arc(x, y, d.r, 0, Math.PI * 2); wc.fill()
|
||||
}
|
||||
this.coldLayers.push(cold)
|
||||
this.warmLayers.push(warm)
|
||||
}
|
||||
// Border layer: glow baked once via a wide soft stroke under a hot core.
|
||||
const borders = mk()
|
||||
const bc = borders.getContext('2d')
|
||||
if (bc) {
|
||||
const path = new Path2D()
|
||||
for (const ring of this.rings) {
|
||||
path.moveTo(this.px(ring[0][0]), this.py(ring[0][1]))
|
||||
for (let i = 1; i < ring.length; i++) path.lineTo(this.px(ring[i][0]), this.py(ring[i][1]))
|
||||
path.closePath()
|
||||
}
|
||||
bc.strokeStyle = 'rgba(210, 40, 35, 0.16)'
|
||||
bc.lineWidth = 6
|
||||
bc.stroke(path)
|
||||
bc.strokeStyle = 'rgba(235, 62, 52, 0.55)'
|
||||
bc.lineWidth = 2.6
|
||||
bc.stroke(path)
|
||||
bc.strokeStyle = 'rgba(255, 120, 95, 0.9)'
|
||||
bc.lineWidth = 1.1
|
||||
bc.stroke(path)
|
||||
this.borderLayer = borders
|
||||
}
|
||||
// Sparks for the shatter: sampled along the borders, each with a drift vector.
|
||||
this.sparks = []
|
||||
const every = Math.max(1, Math.floor(this.rings.reduce((n, r) => n + r.length, 0) / BURST_SPARKS))
|
||||
let idx = 0
|
||||
for (const ring of this.rings) {
|
||||
for (const [lon, lat] of ring) {
|
||||
if (idx++ % every !== 0) continue
|
||||
const ang = Math.random() * Math.PI * 2
|
||||
const sp = 0.4 + Math.random() * 0.6
|
||||
this.sparks.push({ lon, lat, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp - 0.55, seed: Math.random() })
|
||||
}
|
||||
}
|
||||
// Web layer: finished arcs + lit cities accumulate here.
|
||||
this.webLayer = mk()
|
||||
this.webCtx = this.webLayer.getContext('2d')
|
||||
for (const a of this.arcs) a.baked = false
|
||||
this.lastWeb = 0
|
||||
// Glow sprites: one warm (cities), one gold (the user's own places).
|
||||
this.citySprite = this.makeSprite('rgba(255, 216, 140, 0.95)', 'rgba(255, 190, 110, 0.45)', 'rgba(255, 180, 100, 0)')
|
||||
this.goldSprite = this.makeSprite('rgba(255, 232, 170, 1)', 'rgba(255, 205, 120, 0.55)', 'rgba(255, 195, 105, 0)')
|
||||
}
|
||||
|
||||
/** Border paths bake in pixel coordinates, so a resize needs a rebuild — cheap enough to redo on demand. */
|
||||
hasMap(): boolean {
|
||||
return this.ready
|
||||
private makeSprite(inner: string, mid: string, outer: string): HTMLCanvasElement {
|
||||
const sprite = document.createElement('canvas')
|
||||
sprite.width = 64
|
||||
sprite.height = 64
|
||||
const sc = sprite.getContext('2d')
|
||||
if (sc) {
|
||||
const grad = sc.createRadialGradient(32, 32, 0, 32, 32, 32)
|
||||
grad.addColorStop(0, inner)
|
||||
grad.addColorStop(0.25, mid)
|
||||
grad.addColorStop(1, outer)
|
||||
sc.fillStyle = grad
|
||||
sc.fillRect(0, 0, 64, 64)
|
||||
}
|
||||
return sprite
|
||||
}
|
||||
|
||||
private arcPoint(arc: Arc, q: number): [number, number] {
|
||||
const [alat, alon] = CITIES[arc.a]
|
||||
const [blat, blon] = CITIES[arc.b]
|
||||
const x1 = this.px(alon); const y1 = this.py(alat)
|
||||
const x2 = this.px(blon); const y2 = this.py(blat)
|
||||
const mx = (x1 + x2) / 2
|
||||
const my = (y1 + y2) / 2 - Math.min(Math.hypot(x2 - x1, y2 - y1) * 0.22, this.h * 0.2)
|
||||
return [
|
||||
(1 - q) * (1 - q) * x1 + 2 * (1 - q) * q * mx + q * q * x2,
|
||||
(1 - q) * (1 - q) * y1 + 2 * (1 - q) * q * my + q * q * y2,
|
||||
]
|
||||
}
|
||||
|
||||
private strokeArc(c: CanvasRenderingContext2D, arc: Arc, upTo: number, alpha: number): void {
|
||||
const steps = 26
|
||||
const n = Math.max(2, Math.round(steps * upTo))
|
||||
c.beginPath()
|
||||
for (let i = 0; i <= n; i++) {
|
||||
const [ix, iy] = this.arcPoint(arc, (i / steps))
|
||||
if (i === 0) c.moveTo(ix, iy)
|
||||
else c.lineTo(ix, iy)
|
||||
}
|
||||
c.strokeStyle = `rgba(255, 176, 90, ${0.10 * alpha})`
|
||||
c.lineWidth = 4.2
|
||||
c.stroke()
|
||||
c.strokeStyle = `rgba(255, 202, 122, ${0.4 * alpha})`
|
||||
c.lineWidth = 1.2
|
||||
c.stroke()
|
||||
}
|
||||
|
||||
private arcLocal(arc: Arc, web: number): number {
|
||||
return Math.min(Math.max((web - arc.delay * 0.72) / 0.28, 0), 1)
|
||||
}
|
||||
|
||||
draw(c: CanvasRenderingContext2D, s: SceneState, t: number): void {
|
||||
@@ -188,103 +332,141 @@ export class NoFearScene {
|
||||
c.clearRect(0, 0, w, h)
|
||||
if (s.opacity <= 0) return
|
||||
c.save()
|
||||
c.globalAlpha = s.opacity
|
||||
|
||||
// Land dots — cold blue-grey in the fear act, warming with s.warmth.
|
||||
if (s.land > 0 && this.dots.length > 0) {
|
||||
for (const d of this.dots) {
|
||||
const twinkle = 0.75 + 0.25 * Math.sin(t * 0.7 + d.tw)
|
||||
const a = s.land * 0.55 * twinkle
|
||||
const warm = s.warmth
|
||||
c.fillStyle = `rgba(${Math.round(110 + warm * 105)}, ${Math.round(130 + warm * 60)}, ${Math.round(165 - warm * 25)}, ${a})`
|
||||
c.beginPath()
|
||||
c.arc(this.px(d.x), this.py(d.y), d.r, 0, Math.PI * 2)
|
||||
c.fill()
|
||||
}
|
||||
}
|
||||
|
||||
// Borders — a hot red web that later dissolves to nothing.
|
||||
if (this.borders && s.borderHeat > 0 && s.borderGone < 1) {
|
||||
const alive = 1 - s.borderGone
|
||||
const pulse = 0.72 + 0.28 * Math.sin(t * 2.4)
|
||||
c.save()
|
||||
c.globalAlpha = s.opacity * alive * s.borderHeat * pulse
|
||||
c.strokeStyle = `rgba(235, 60, 55, 0.9)`
|
||||
c.lineWidth = 1 + s.borderHeat * 0.6
|
||||
c.shadowColor = 'rgba(235, 50, 45, 0.85)'
|
||||
c.shadowBlur = 6 + s.borderHeat * 10 + s.borderGone * 22
|
||||
c.stroke(this.borders)
|
||||
c.restore()
|
||||
}
|
||||
|
||||
// The web: city lights and great arcs across everything that just burned.
|
||||
if (s.web > 0) {
|
||||
c.save()
|
||||
c.globalCompositeOperation = 'lighter'
|
||||
const grow = s.web
|
||||
for (const arc of this.arcs) {
|
||||
// Each arc gets its own window inside the act so the web cascades.
|
||||
const local = Math.min(Math.max((grow - arc.delay * 0.72) / 0.28, 0), 1)
|
||||
if (local <= 0) continue
|
||||
const [alat, alon] = CITIES[arc.a]
|
||||
const [blat, blon] = CITIES[arc.b]
|
||||
const x1 = this.px(alon); const y1 = this.py(alat)
|
||||
const x2 = this.px(blon); const y2 = this.py(blat)
|
||||
const mx = (x1 + x2) / 2
|
||||
const my = (y1 + y2) / 2 - Math.min(Math.hypot(x2 - x1, y2 - y1) * 0.22, h * 0.2)
|
||||
c.strokeStyle = `rgba(255, 196, 110, ${0.34 * local})`
|
||||
c.lineWidth = 1.1
|
||||
c.shadowColor = 'rgba(255, 180, 90, 0.9)'
|
||||
c.shadowBlur = 7
|
||||
c.beginPath()
|
||||
// Draw the quadratic curve only up to `local` by subdividing.
|
||||
const steps = 26
|
||||
const upto = Math.max(2, Math.floor(steps * local))
|
||||
for (let i = 0; i <= upto; i++) {
|
||||
const q = (i / steps)
|
||||
const ix = (1 - q) * (1 - q) * x1 + 2 * (1 - q) * q * mx + q * q * x2
|
||||
const iy = (1 - q) * (1 - q) * y1 + 2 * (1 - q) * q * my + q * q * y2
|
||||
if (i === 0) c.moveTo(ix, iy)
|
||||
else c.lineTo(ix, iy)
|
||||
// Land: TWINKLE_GROUPS×2 drawImages, alpha does the twinkle and the warmth mix.
|
||||
if (s.land > 0 && this.coldLayers.length > 0) {
|
||||
for (let g = 0; g < this.coldLayers.length; g++) {
|
||||
const twinkle = 0.72 + 0.28 * Math.sin(t * 0.8 + g * 2.1)
|
||||
const a = s.opacity * s.land * 0.6 * twinkle
|
||||
if (s.warmth < 1) {
|
||||
c.globalAlpha = a * (1 - s.warmth)
|
||||
c.drawImage(this.coldLayers[g], 0, 0)
|
||||
}
|
||||
if (s.warmth > 0) {
|
||||
c.globalAlpha = a * s.warmth
|
||||
c.drawImage(this.warmLayers[g], 0, 0)
|
||||
}
|
||||
c.stroke()
|
||||
}
|
||||
// City lights on top of the arcs.
|
||||
}
|
||||
|
||||
// The opening's calm city lights — and their death in the fear act. Each city
|
||||
// dies at its own pseudo-random moment while the borders heat up.
|
||||
if (this.citySprite && s.cityLife > 0 && s.cityDeath < 1 && s.web <= 0) {
|
||||
c.globalCompositeOperation = 'lighter'
|
||||
for (let i = 0; i < CITIES.length; i++) {
|
||||
const local = Math.min(Math.max((grow - (i / CITIES.length) * 0.5) / 0.2, 0), 1)
|
||||
if (local <= 0) continue
|
||||
const on = Math.min(Math.max(s.cityLife * 1.5 - ((i * 29) % 100) / 100 * 0.5, 0), 1)
|
||||
const deathAt = ((i * 37) % 100) / 100 * 0.8
|
||||
const dead = Math.min(Math.max((s.cityDeath * 1.25 - deathAt) / 0.2, 0), 1)
|
||||
const a = on * (1 - dead)
|
||||
if (a <= 0) continue
|
||||
// A light flickers just before it dies.
|
||||
const flicker = dead > 0 && dead < 1 ? 0.5 + 0.5 * Math.sin(t * 26 + i * 3) : 1
|
||||
const [lat, lon] = CITIES[i]
|
||||
const px = this.px(lon); const py = this.py(lat)
|
||||
const halo = 2.6 + Math.sin(t * 1.8 + i) * 0.7
|
||||
c.fillStyle = `rgba(255, 214, 140, ${0.85 * local})`
|
||||
c.shadowColor = 'rgba(255, 200, 120, 1)'
|
||||
c.shadowBlur = 10
|
||||
c.beginPath()
|
||||
c.arc(px, py, 1.6 + local * 1.1, 0, Math.PI * 2)
|
||||
c.fill()
|
||||
c.fillStyle = `rgba(255, 190, 100, ${0.12 * local})`
|
||||
c.beginPath()
|
||||
c.arc(px, py, halo * 2.4, 0, Math.PI * 2)
|
||||
c.fill()
|
||||
const x = this.px(lon); const y = this.py(lat)
|
||||
c.globalAlpha = s.opacity * 0.5 * a * flicker
|
||||
c.drawImage(this.citySprite, x - 8, y - 8, 16, 16)
|
||||
}
|
||||
c.restore()
|
||||
c.globalCompositeOperation = 'source-over'
|
||||
}
|
||||
|
||||
// Borders: one blit; pulse via alpha. The climax shatter replaces the layer
|
||||
// with sparks that drift off and burn out.
|
||||
if (this.borderLayer && s.borderHeat > 0 && s.borderBurst < 1) {
|
||||
const alive = 1 - s.borderBurst
|
||||
const pulse = 0.72 + 0.28 * Math.sin(t * 2.4)
|
||||
c.globalAlpha = s.opacity * alive * s.borderHeat * pulse
|
||||
c.drawImage(this.borderLayer, 0, 0)
|
||||
}
|
||||
if (s.borderBurst > 0 && s.borderBurst < 1 && this.sparks.length > 0) {
|
||||
c.globalCompositeOperation = 'lighter'
|
||||
const prog = s.borderBurst
|
||||
const ease = 1 - (1 - prog) * (1 - prog)
|
||||
for (const sp of this.sparks) {
|
||||
const a = (1 - prog) * (0.5 + 0.5 * Math.sin(sp.seed * 9 + t * 7))
|
||||
if (a <= 0.02) continue
|
||||
const x = this.px(sp.lon) + sp.vx * ease * 46
|
||||
const y = this.py(sp.lat) + sp.vy * ease * 46
|
||||
c.globalAlpha = s.opacity * a
|
||||
c.fillStyle = sp.seed > 0.5 ? 'rgb(255, 150, 95)' : 'rgb(255, 205, 130)'
|
||||
c.fillRect(x, y, 1.6, 1.6)
|
||||
}
|
||||
c.globalCompositeOperation = 'source-over'
|
||||
}
|
||||
|
||||
// Web: finished arcs live in the baked layer; only growing arcs are stroked.
|
||||
if (s.web > 0) {
|
||||
c.globalCompositeOperation = 'lighter'
|
||||
if (this.webCtx && s.web > this.lastWeb) {
|
||||
for (const arc of this.arcs) {
|
||||
if (!arc.baked && this.arcLocal(arc, s.web) >= 1) {
|
||||
this.strokeArc(this.webCtx, arc, 1, 1)
|
||||
arc.baked = true
|
||||
}
|
||||
}
|
||||
this.lastWeb = s.web
|
||||
}
|
||||
if (this.webLayer) {
|
||||
c.globalAlpha = s.opacity
|
||||
c.drawImage(this.webLayer, 0, 0)
|
||||
}
|
||||
c.globalAlpha = s.opacity
|
||||
for (const arc of this.arcs) {
|
||||
if (arc.baked) continue
|
||||
const local = this.arcLocal(arc, s.web)
|
||||
if (local <= 0) continue
|
||||
this.strokeArc(c, arc, local, local)
|
||||
}
|
||||
if (this.citySprite) {
|
||||
for (let i = 0; i < CITIES.length; i++) {
|
||||
const local = Math.min(Math.max((s.web - (i / CITIES.length) * 0.5) / 0.2, 0), 1)
|
||||
if (local <= 0) continue
|
||||
const [lat, lon] = CITIES[i]
|
||||
const x = this.px(lon); const y = this.py(lat)
|
||||
const halo = (11 + Math.sin(t * 1.8 + i) * 3) * (0.6 + 0.4 * local)
|
||||
c.globalAlpha = s.opacity * 0.55 * local
|
||||
c.drawImage(this.citySprite, x - halo, y - halo, halo * 2, halo * 2)
|
||||
c.globalAlpha = s.opacity * local
|
||||
c.drawImage(this.citySprite, x - 3.2, y - 3.2, 6.4, 6.4)
|
||||
}
|
||||
}
|
||||
c.globalCompositeOperation = 'source-over'
|
||||
}
|
||||
|
||||
// The user's own places igniting gold — each with a small overshoot pulse.
|
||||
if (this.goldSprite && s.personalGlow > 0 && this.personal.length > 0) {
|
||||
c.globalCompositeOperation = 'lighter'
|
||||
const n = this.personal.length
|
||||
for (let i = 0; i < n; i++) {
|
||||
const local = Math.min(Math.max((s.personalGlow - (i / n) * 0.6) / 0.25, 0), 1)
|
||||
if (local <= 0) continue
|
||||
const p = this.personal[i]
|
||||
const x = this.px(p.lng); const y = this.py(p.lat)
|
||||
const overshoot = 1 + (1 - local) * 2.2
|
||||
const halo = 7 * overshoot
|
||||
c.globalAlpha = s.opacity * 0.85 * local
|
||||
c.drawImage(this.goldSprite, x - halo, y - halo, halo * 2, halo * 2)
|
||||
c.globalAlpha = s.opacity * local
|
||||
c.drawImage(this.goldSprite, x - 2.6, y - 2.6, 5.2, 5.2)
|
||||
}
|
||||
c.globalCompositeOperation = 'source-over'
|
||||
}
|
||||
|
||||
// Rising particles for the anthem.
|
||||
if (s.particles > 0) {
|
||||
c.save()
|
||||
c.globalCompositeOperation = 'lighter'
|
||||
for (const p of this.particlesPool) {
|
||||
const y = ((p.y - t * p.v * 0.13) % 1.15)
|
||||
const y = ((((p.y - t * p.v * 0.13) % 1.15) + 1.15) % 1.15)
|
||||
const x = p.x + Math.sin(t * 0.4 + p.seed) * p.drift
|
||||
c.fillStyle = `rgba(255, 210, 150, ${0.28 * s.particles * (0.4 + 0.6 * Math.sin(t + p.seed))})`
|
||||
c.globalAlpha = s.opacity * 0.28 * s.particles * (0.4 + 0.6 * Math.abs(Math.sin(t + p.seed)))
|
||||
c.fillStyle = 'rgb(255, 210, 150)'
|
||||
c.beginPath()
|
||||
c.arc(x * w, y * h, p.size, 0, Math.PI * 2)
|
||||
c.fill()
|
||||
}
|
||||
c.restore()
|
||||
c.globalCompositeOperation = 'source-over'
|
||||
}
|
||||
|
||||
c.globalAlpha = 1
|
||||
c.restore()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user