From 743aee7bd4daa1c8371b3ecdb716d1e5824575b0 Mon Sep 17 00:00:00 2001 From: DavidK4leido5 Date: Thu, 24 Sep 2026 06:55:34 +0800 Subject: [PATCH 1/4] swarm attack --- src/lib/swarmEvents.ts | 6 +++ src/scene/BrainTouch.tsx | 91 ++++++++++++++++++++++++++++++++++- src/scene/shaders.ts | 49 ++++++++++++++++++- src/scene/shared.ts | 4 ++ src/styles/global.css | 101 +++++++++++++++++++++++++++++++++++++++ src/ui/Cursor.tsx | 99 +++++++++++++++++++++++++++++++++++++- 6 files changed, 345 insertions(+), 5 deletions(-) diff --git a/src/lib/swarmEvents.ts b/src/lib/swarmEvents.ts index 0bcd4b4..0dbcb47 100644 --- a/src/lib/swarmEvents.ts +++ b/src/lib/swarmEvents.ts @@ -8,3 +8,9 @@ export const SWARM_ENTER = 'swarm:enter' export const SWARM_LEAVE = 'swarm:leave' /** A click landed on the cloud and startled the swarm. */ export const SWARM_TOUCH = 'swarm:touch' +/** A raid has reached the cursor. */ +export const SWARM_ATTACK = 'swarm:attack' +/** One attacker has bitten the cursor. */ +export const SWARM_BITE = 'swarm:bite' +/** The raid is falling back. */ +export const SWARM_RETREAT = 'swarm:retreat' diff --git a/src/scene/BrainTouch.tsx b/src/scene/BrainTouch.tsx index a6c3bb3..463b573 100644 --- a/src/scene/BrainTouch.tsx +++ b/src/scene/BrainTouch.tsx @@ -10,7 +10,14 @@ import { RIPPLE_SLOTS, } from './shared' import { useSceneStore } from '../store/sceneStore' -import { SWARM_ENTER, SWARM_LEAVE, SWARM_TOUCH } from '../lib/swarmEvents' +import { + SWARM_ATTACK, + SWARM_BITE, + SWARM_ENTER, + SWARM_LEAVE, + SWARM_RETREAT, + SWARM_TOUCH, +} from '../lib/swarmEvents' const raycaster = new Raycaster() const ndc = new Vector2() @@ -18,6 +25,8 @@ const localHit = new Vector3() const lastHit = new Vector3() const view = new Vector3() const groupRot = new Quaternion() +const chase = new Vector3() +const center = new Vector3() /** Seconds between the light pulses a moving pointer sends out, min and spread. */ const PULSE_GAP = 0.8 @@ -25,6 +34,21 @@ const PULSE_JITTER = 0.9 /** Distance the pointer has to travel on the cloud before it sends another. */ const PULSE_TRAVEL = 0.5 +/* + * Raids. Time on the cloud before the swarm first goes for the cursor, and + * between raids after that (min, spread). A click provokes one sooner. + */ +const RAID_FIRST = 3.5 +const RAID_FIRST_JITTER = 2.5 +const RAID_EVERY = 7 +const RAID_EVERY_JITTER = 5 +/** The raid's beats, seconds from its start. Mirrors attackPos in shaders.ts. */ +const RAID_ARRIVE = 0.9 +const RAID_RETREAT = 2.35 +const RAID_END = 4.0 +/** How fast the attackers' target follows the cursor; lower lags more. */ +const CHASE = 5 + /** Invisible proxy — ray hit ≈ cursor over the node cloud (local cluster space). */ export function BrainTouchProbe() { const meshRef = useRef(null) @@ -38,6 +62,16 @@ export function BrainTouchProbe() { rippleSlot: 0, clicked: false, reduced: typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches, + // Raids need a cursor to go after, so touch screens never get them + canRaid: + typeof matchMedia !== 'undefined' && + matchMedia('(hover: hover) and (pointer: fine)').matches && + !matchMedia('(prefers-reduced-motion: reduce)').matches, + onTime: 0, + nextRaid: RAID_FIRST + Math.random() * RAID_FIRST_JITTER, + raidAt: -1, + raidStage: 0, + nextBite: 0, }) /* @@ -56,12 +90,15 @@ export function BrainTouchProbe() { addEventListener('click', onClick, { passive: true }) return () => { removeEventListener('click', onClick) - // Unmounted with the pointer still on the cloud: let the UI know it left + // Unmounted with the pointer still on the cloud, or mid-raid: tell the UI if (feelRef.current.on) dispatchEvent(new Event(SWARM_LEAVE)) + if (feelRef.current.raidAt >= 0) dispatchEvent(new Event(SWARM_RETREAT)) } }, []) useFrame((_, delta) => { + // Capped only against a tab-switch jump, so the timing holds on slow devices + const dt = Math.min(delta, 0.25) const { loadPhase, scrollZone } = useSceneStore.getState() const ready = loadPhase === 'ready' && pointerOnScene // The fabric dent is the hero's alone; the light answers on every shape @@ -120,6 +157,7 @@ export function BrainTouchProbe() { lastHit.copy(localHit) if (now - f.lastPulse > f.gap && f.travel > PULSE_TRAVEL) pulse() } + f.onTime += dt if (f.clicked) { // Reduced motion keeps the light and skips the swarm's movement if (!f.reduced) { @@ -127,6 +165,8 @@ export function BrainTouchProbe() { f.rippleSlot = (f.rippleSlot + 1) % RIPPLE_SLOTS } pulse() + // Poked, it hits back: a raid just after the startle + if (f.raidAt < 0) f.onTime = Math.max(f.onTime, f.nextRaid - 0.35) // Tells the UI the cloud was touched (the cursor and the hint answer it) dispatchEvent(new Event(SWARM_TOUCH)) } @@ -135,6 +175,53 @@ export function BrainTouchProbe() { dispatchEvent(new Event(SWARM_LEAVE)) } f.clicked = false + + /* + * The raid. It starts from wherever the pointer is on the cloud; the + * attackers' target then follows the cursor, at the cloud's depth along + * the pointer's ray, so it holds even when the cursor runs off the cloud. + * The UI hears the beats: arrival, each bite, the retreat. + */ + if (!f.canRaid) return + const attack = uniforms.uAttack.value + if (f.raidAt < 0 && hit && f.onTime >= f.nextRaid) { + f.raidAt = now + f.raidStage = 0 + attack.set(localHit.x, localHit.y, localHit.z, now) + uniforms.uAttackTarget.value.copy(localHit) + } + if (f.raidAt < 0) return + + ndc.set(mouse.x, mouse.y) + raycaster.setFromCamera(ndc, camera) + if (clusterState.group) clusterState.group.getWorldPosition(center) + else center.set(0, 0, 0) + raycaster.ray.at(camera.position.distanceTo(center), chase) + if (clusterState.group) clusterState.group.worldToLocal(chase) + uniforms.uAttackTarget.value.lerp(chase, Math.min(1, dt * CHASE)) + + const t = now - f.raidAt + if (f.raidStage === 0 && t >= RAID_ARRIVE) { + f.raidStage = 1 + f.nextBite = t + dispatchEvent(new Event(SWARM_ATTACK)) + } + if (f.raidStage === 1) { + if (t >= f.nextBite) { + dispatchEvent(new Event(SWARM_BITE)) + f.nextBite = t + 0.18 + Math.random() * 0.2 + } + if (t >= RAID_RETREAT) { + f.raidStage = 2 + dispatchEvent(new Event(SWARM_RETREAT)) + } + } + if (t >= RAID_END) { + attack.w = -1 + f.raidAt = -1 + f.onTime = 0 + f.nextRaid = RAID_EVERY + Math.random() * RAID_EVERY_JITTER + } }) return ( diff --git a/src/scene/shaders.ts b/src/scene/shaders.ts index fd742c6..21ddf25 100644 --- a/src/scene/shaders.ts +++ b/src/scene/shaders.ts @@ -54,6 +54,8 @@ uniform float uTouch; uniform float uFabricGain; uniform vec4 uPulse[4]; uniform vec4 uRipple[3]; +uniform vec4 uAttack; +uniform vec3 uAttackTarget; uniform vec3 uViewDir; uniform float uDim; uniform float uTravel; @@ -213,6 +215,38 @@ vec3 swarmOffset(vec3 pos,float seed){ return off; } +// A raid on the cursor. A few nodes, mostly from near where the pointer was, +// break off, arc out to it, swarm it for about a second and fall back home. +// The target is the cursor now, so they chase it if it runs. gAttack is how +// far out the node is, for its colour and for dropping its connections. +float gAttack=0.0; +vec3 attackPos(vec3 p,float seed){ + gAttack=0.0; + if(uAttack.w<0.0) return p; + float t=uTime-uAttack.w; + if(t<0.0||t>4.0) return p; + vec3 fromTouch=p-uAttack.xyz; + float near=exp(-dot(fromTouch,fromTouch)*0.25); + if(fract(seed*91.7)>0.035+0.09*near) return p; + // Each attacker leaves on its own beat, then a 3.4s raid + float lt=(t-fract(seed*37.3)*0.5)/3.4; + if(lt<=0.0||lt>=1.0) return p; + // Out on an accelerating strike, hold on the target, drift back home + float k=lt<0.28?pow(lt/0.28,2.2):(lt<0.62?1.0:1.0-smoothstep(0.62,1.0,lt)); + float s=seed*6.2831853; + // At the target they do not sit still: a tight, twitching cloud round it + vec3 bite=vec3(sin(uTime*7.0+s)*0.22,cos(uTime*6.3+s*1.7)*0.2,sin(uTime*5.1+s*2.3)*0.18); + bite*=0.6+0.4*snoise(vec3(seed*9.0,uTime*2.0,0.0)); + vec3 target=uAttackTarget+bite; + vec3 dir=target-p; + float len=length(dir); + // Curved flight: each one swings out to its own side on the way + vec3 side=len>1e-4?cross(dir/len,uViewDir):vec3(0.0); + vec3 bend=side*sin(k*3.14159265)*len*0.28*(fract(seed*5.7)<0.5?-1.0:1.0); + gAttack=k; + return mix(p,target,k)+bend; +} + vec3 displace(vec3 pos, float seed, float morph){ float k=morph*morph; float t=uTime*0.05; @@ -235,6 +269,13 @@ vec3 displace(vec3 pos, float seed, float morph){ ); if(uTouch>0.001) p+=fabricTouch(pos,seed,k); p+=swarmOffset(pos,seed)*k; + // Only a settled node can be sent: mid-spawn ones would fly off half-formed. + // The web stays put (NO_ATTACK in the line shader): the attackers tear away + // from it, instead of dragging streaks across the screen to the cursor. + gAttack=0.0; +#ifndef NO_ATTACK + if(k>0.9) p=attackPos(p,seed); +#endif return p; } @@ -360,8 +401,11 @@ void main(){ vec3 feel=touchLight(anchor)*morph+mix(uAccent,vec3(1.0),0.45)*gSwarm*0.75*morph; vColor+=feel; vGlow+=dot(feel,vec3(0.4)); - float px=uSize*(0.82+aSeed*0.38)*(28.0/-mv.z)*(1.0+uShapeAlt*0.3); - gl_PointSize=clamp(px,1.8,6.5); + // Attackers run hot + vColor=mix(vColor,vec3(1.0,0.26,0.42),gAttack*0.75); + vGlow*=1.0+gAttack*1.6; + float px=uSize*(0.82+aSeed*0.38)*(28.0/-mv.z)*(1.0+uShapeAlt*0.3)*(1.0+gAttack*0.45); + gl_PointSize=clamp(px,1.8,8.0); gl_Position=projectionMatrix*mv; } ` @@ -386,6 +430,7 @@ void main(){ ` export const connectionVert = /* glsl */ ` +#define NO_ATTACK varying float vAlpha; varying vec3 vColor; ${NOISE} diff --git a/src/scene/shared.ts b/src/scene/shared.ts index d275278..dbc68eb 100644 --- a/src/scene/shared.ts +++ b/src/scene/shared.ts @@ -47,6 +47,10 @@ export const uniforms = { uPulse: { value: Array.from({ length: PULSE_SLOTS }, () => new Vector4(0, 0, 0, -1)) }, // Swarm reactions to a click on the cloud uRipple: { value: Array.from({ length: RIPPLE_SLOTS }, () => new Vector4(0, 0, 0, -1)) }, + // A raid on the cursor: xyz = where the pointer was when it began, w = uTime + // it began (-1 = none); the target is the cursor now, chased each frame + uAttack: { value: new Vector4(0, 0, 0, -1) }, + uAttackTarget: { value: new Vector3() }, // Camera forward in cluster space, so the swarm reacts across the screen uViewDir: { value: new Vector3(0, 0, -1) }, uDim: { value: 0 }, diff --git a/src/styles/global.css b/src/styles/global.css index 1b1b59a..8f7141c 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -3854,3 +3854,104 @@ html.has-cursor :is(input:not([type="range"]):not([type="checkbox"]):not([type=" .cursor[data-felt] .cursor__hint-line { color: var(--cursor-accent); } + +/* ==== The cursor under attack ==== + Chunks bitten out of the ring (a conic mask with gaps, set from Cursor.tsx), + a red/cyan split on every bite, a stepped jitter, and the cursor shouting. */ +.cursor__shape { + -webkit-mask: var(--bites, none); + mask: var(--bites, none); +} + +.cursor[data-attacked] .cursor__shape { + border-color: #ff4d6d; + background: color-mix(in srgb, #ff4d6d 10%, transparent); +} + +/* Two offset copies of the ring, cut into slices that shift on each bite */ +.cursor__shape::before, +.cursor__shape::after { + content: ''; + position: absolute; + inset: -1px; + border-radius: 50%; + border: 1px solid transparent; + opacity: 0; + pointer-events: none; +} + +.cursor__shape { + position: relative; +} + +.cursor[data-hurt] .cursor__shape::before { + border-color: rgba(255, 40, 90, 0.95); + opacity: 1; + animation: cursor-slice-a 0.3s steps(3) both; +} + +.cursor[data-hurt] .cursor__shape::after { + border-color: rgba(40, 230, 255, 0.9); + opacity: 1; + animation: cursor-slice-b 0.3s steps(3) both; +} + +.cursor[data-hurt] .cursor__shape { + animation: cursor-jolt 0.3s steps(4) both; +} + +.cursor[data-hurt] .cursor__dot { + box-shadow: 3px 0 0 rgba(255, 40, 90, 0.95), -3px 0 0 rgba(40, 230, 255, 0.9); + animation: cursor-jolt 0.3s steps(4) both; +} + +@keyframes cursor-jolt { + 0% { translate: 3px -2px; } + 25% { translate: -4px 1px; } + 50% { translate: 2px 3px; } + 75% { translate: -2px -3px; } + 100% { translate: 0 0; } +} + +@keyframes cursor-slice-a { + 0% { translate: 4px 0; clip-path: inset(0 0 62% 0); } + 33% { translate: -3px 0; clip-path: inset(40% 0 22% 0); } + 66% { translate: 5px 0; clip-path: inset(70% 0 0 0); } + 100% { translate: 0 0; clip-path: inset(0 0 100% 0); } +} + +@keyframes cursor-slice-b { + 0% { translate: -4px 0; clip-path: inset(55% 0 10% 0); } + 33% { translate: 3px 0; clip-path: inset(10% 0 60% 0); } + 66% { translate: -5px 0; clip-path: inset(30% 0 45% 0); } + 100% { translate: 0 0; clip-path: inset(100% 0 0 0); } +} + +/* While it is being bitten the words show wherever the cursor has run to */ +.cursor[data-attacked] .cursor__hint { + opacity: 1; + translate: 0 -50%; +} + +.cursor[data-attacked] .cursor__hint-line { + font-size: 11px; + color: #ff4d6d; + text-shadow: 1.5px 0 rgba(40, 230, 255, 0.85), -1.5px 0 rgba(255, 40, 90, 0.85); + animation: cursor-shout 0.34s steps(3) infinite; +} + +@keyframes cursor-shout { + 0% { translate: 0 0; } + 33% { translate: 1px -1px; } + 66% { translate: -1px 1px; } +} + +@media (prefers-reduced-motion: reduce) { + .cursor[data-hurt] .cursor__shape, + .cursor[data-hurt] .cursor__dot, + .cursor[data-hurt] .cursor__shape::before, + .cursor[data-hurt] .cursor__shape::after, + .cursor[data-attacked] .cursor__hint-line { + animation: none; + } +} diff --git a/src/ui/Cursor.tsx b/src/ui/Cursor.tsx index c6e388f..fa6c83d 100644 --- a/src/ui/Cursor.tsx +++ b/src/ui/Cursor.tsx @@ -1,5 +1,12 @@ import { useEffect, useRef } from 'react' -import { SWARM_ENTER, SWARM_LEAVE, SWARM_TOUCH } from '../lib/swarmEvents' +import { + SWARM_ATTACK, + SWARM_BITE, + SWARM_ENTER, + SWARM_LEAVE, + SWARM_RETREAT, + SWARM_TOUCH, +} from '../lib/swarmEvents' /** What the cursor says while it is over the particle cloud. */ const DARE = ["Don't touch.", 'The swarm is alive.'] as const @@ -7,6 +14,13 @@ const FELT = 'It felt that.' /** How long the answer to a touch stays before the dare comes back. */ const FELT_MS = 2800 +/** What the cursor shouts while the swarm is biting it. */ +const HURT = ['Ouch!', 'Hey!', 'Ahhh!', 'Run.', 'It bites!', 'Stop that.', 'Not the cursor!'] +/** Most chunks the ring can lose before it stops losing more. */ +const MAX_BITES = 7 +/** Gap between chunks growing back once the swarm falls back, ms. */ +const HEAL_MS = 260 + /** Anything the ring should open up for. */ const INTERACTIVE = 'a, button, [role="button"], label, summary, input[type="range"], [data-cursor]' /** Fields keep the native caret; a custom cursor over a text box hides where you type. */ @@ -69,6 +83,7 @@ export function Cursor() { const dot = root.querySelector('.cursor__dot')! const ring = root.querySelector('.cursor__ring')! const label = root.querySelector('.cursor__label')! + const shape = root.querySelector('.cursor__shape')! const hintLine = root.querySelector('.cursor__hint-line')! const hintSub = root.querySelector('.cursor__hint-sub')! document.documentElement.classList.add('has-cursor') @@ -147,9 +162,82 @@ export function Cursor() { hintSub.textContent = DARE[1] root.removeAttribute('data-felt') } + /* + * Being eaten. Each bite takes a chunk out of the ring (a gap in a conic + * mask), flashes a red/cyan split and a jitter, and the cursor says so. + * The chunks grow back one at a time after the swarm falls back. Every + * state also times itself out, in case the scene stops mid-raid. + */ + const bites: { at: number; size: number }[] = [] + let hurtTo: ReturnType + let attackTo: ReturnType + let healTo: ReturnType + let lastWord = '' + const paintBites = () => { + if (!bites.length) { + shape.style.removeProperty('--bites') + return + } + const gaps = [...bites].sort((a, b) => a.at - b.at) + let at = 0 + const stops: string[] = [] + for (const g of gaps) { + const from = Math.max(at, g.at) + stops.push(`#000 ${at}deg ${from}deg`, `transparent ${from}deg ${from + g.size}deg`) + at = from + g.size + } + stops.push(`#000 ${at}deg 360deg`) + shape.style.setProperty('--bites', `conic-gradient(${stops.join(', ')})`) + } + const shout = () => { + let word = lastWord + while (word === lastWord) word = HURT[Math.floor(Math.random() * HURT.length)] + lastWord = word + hintLine.textContent = word + hintSub.textContent = '' + } + const heal = () => { + clearTimeout(healTo) + if (!bites.length) return + bites.shift() + paintBites() + healTo = setTimeout(heal, HEAL_MS) + } + const endAttack = () => { + root.removeAttribute('data-attacked') + root.removeAttribute('data-hurt') + dare() + heal() + } + const onAttack = () => { + clearTimeout(healTo) + root.toggleAttribute('data-attacked', true) + shout() + clearTimeout(attackTo) + attackTo = setTimeout(endAttack, 3500) + } + const onBite = () => { + if (!root.hasAttribute('data-attacked')) onAttack() + if (bites.length < MAX_BITES) { + bites.push({ at: Math.random() * 340, size: 14 + Math.random() * 26 }) + paintBites() + } + // Restart the flash: drop the attribute and set it again next frame + root.removeAttribute('data-hurt') + requestAnimationFrame(() => root.toggleAttribute('data-hurt', true)) + clearTimeout(hurtTo) + hurtTo = setTimeout(() => root.removeAttribute('data-hurt'), 320) + if (Math.random() < 0.45) shout() + } + const onRetreat = () => { + clearTimeout(attackTo) + attackTo = setTimeout(endAttack, 450) + } + const onSwarmEnter = () => root.toggleAttribute('data-swarm', true) const onSwarmLeave = () => root.toggleAttribute('data-swarm', false) const onSwarmTouch = () => { + if (root.hasAttribute('data-attacked')) return clearTimeout(feltTo) hintLine.textContent = FELT hintSub.textContent = '' @@ -183,6 +271,9 @@ export function Cursor() { addEventListener(SWARM_ENTER, onSwarmEnter) addEventListener(SWARM_LEAVE, onSwarmLeave) addEventListener(SWARM_TOUCH, onSwarmTouch) + addEventListener(SWARM_ATTACK, onAttack) + addEventListener(SWARM_BITE, onBite) + addEventListener(SWARM_RETREAT, onRetreat) return () => { cancelAnimationFrame(raf) @@ -198,7 +289,13 @@ export function Cursor() { removeEventListener(SWARM_ENTER, onSwarmEnter) removeEventListener(SWARM_LEAVE, onSwarmLeave) removeEventListener(SWARM_TOUCH, onSwarmTouch) + removeEventListener(SWARM_ATTACK, onAttack) + removeEventListener(SWARM_BITE, onBite) + removeEventListener(SWARM_RETREAT, onRetreat) clearTimeout(feltTo) + clearTimeout(hurtTo) + clearTimeout(attackTo) + clearTimeout(healTo) } }, []) From d5d037d8b4e610a72f55f29c60b4028c2a6fee12 Mon Sep 17 00:00:00 2001 From: DavidK4leido5 Date: Thu, 24 Sep 2026 07:04:56 +0800 Subject: [PATCH 2/4] final phase done comet fix --- src/styles/global.css | 79 +++++++++++++++++++++++++++++++++++++-- src/ui/ProjectJourney.tsx | 57 ++++++++++++++++++++++++++-- 2 files changed, 130 insertions(+), 6 deletions(-) diff --git a/src/styles/global.css b/src/styles/global.css index 8f7141c..464186b 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1645,19 +1645,36 @@ canvas { display: block; } position: absolute; inset: 0; overflow: hidden; - will-change: transform; + will-change: transform, translate; } .journey-line__clip-inner { position: absolute; inset: 0; - will-change: transform; + will-change: transform, translate; +} + +/* The comet's track: first beat to last. Sized from the engine's measure */ +.journey-line__track { + position: absolute; + left: 0; + right: 0; + top: var(--tl-y0, 0px); + height: var(--tl-span, 0px); +} + +/* Held at the viewport centre by the browser across the whole track */ +.journey-line__pin { + position: sticky; + top: 50vh; + top: 50dvh; + height: 0; } .journey-line__comet { position: absolute; top: 0; - left: 0; + left: 50%; width: 14px; height: 14px; margin: -7px 0 0 -7px; @@ -3955,3 +3972,59 @@ html.has-cursor :is(input:not([type="range"]):not([type="checkbox"]):not([type=" animation: none; } } + +/* ==== Timeline on the scroll itself ==== + Where the browser has scroll timelines, the lit half of the line and the + comet's swing are animations bound to the scroll position, so they run on + the compositor in lockstep with it. ProjectJourney.tsx leaves them alone + in that case and only drives them itself as a fallback. */ +@supports (animation-timeline: view()) { + .project-journey { + view-timeline-name: --journey; + } + + /* Across the journey's cover range the viewport centre runs from half a + viewport above the line to half a viewport past its end. The wrapper + rides up by (centre - height) and the inner copy back down by as much, + so the lit part always ends at the centre */ + .journey-line__clip { + animation: journey-lit linear both; + animation-timeline: --journey; + } + + .journey-line__clip-inner { + animation: journey-lit-inner linear both; + animation-timeline: --journey; + } + + .journey-line__track { + view-timeline-name: --journey-track; + } + + /* 0 when the track's top reaches the centre, 1 when its bottom does */ + .journey-line__comet { + animation: journey-comet-swing linear both; + animation-timeline: --journey-track; + animation-range: cover 50vh cover calc(100% - 50vh); + animation-range: cover 50dvh cover calc(100% - 50dvh); + } +} + +@keyframes journey-lit { + from { translate: 0 calc(-100% - 50dvh); } + to { translate: 0 50dvh; } +} + +@keyframes journey-lit-inner { + from { translate: 0 calc(100% + 50dvh); } + to { translate: 0 -50dvh; } +} + +@media (prefers-reduced-motion: reduce) { + .journey-line__clip, + .journey-line__clip-inner, + .journey-line__comet { + animation: none; + translate: none; + } +} diff --git a/src/ui/ProjectJourney.tsx b/src/ui/ProjectJourney.tsx index 6bf5594..f5de222 100644 --- a/src/ui/ProjectJourney.tsx +++ b/src/ui/ProjectJourney.tsx @@ -49,6 +49,36 @@ const NEAR_RANGE = 1.4 const LINE_SWING = 0.3 const LINE_SWING_MAX = 56 +/** + * Whether the browser can drive the lit line and the comet's swing from the + * scroll position itself. Where it can, those run on the compositor in step + * with the scroll and the scroll handler never touches them; a handler a + * frame behind a natively scrolling line is what made the comet stagger. + */ +const scrollDriven = () => + typeof CSS !== 'undefined' && CSS.supports('animation-timeline: view()') + +/** + * Keyframes for the comet's swing: the path's own sine, one extreme per beat, + * sampled at every quarter period. Easing out into each extreme and back in + * to each crossing is close enough to a sine that the comet stays on the line. + */ +function swingKeyframes(beats: number): string { + const quarters = Math.max(1, (beats - 1) * 2) + const out: string[] = [] + for (let i = 0; i <= quarters; i++) { + const phase = i % 4 + const x = phase === 1 ? 1 : phase === 3 ? -1 : 0 + // Leaving a crossing it decelerates into the extreme; leaving an extreme + // it accelerates back through the middle + const ease = x === 0 ? 'cubic-bezier(0.61, 1, 0.88, 1)' : 'cubic-bezier(0.12, 0, 0.39, 0)' + out.push( + `${((i / quarters) * 100).toFixed(4)}% { translate: calc(var(--tl-swing, 0px) * ${x}) 0; animation-timing-function: ${ease}; }`, + ) + } + return `@keyframes journey-comet-swing { ${out.join(' ')} }` +} + type BeatEl = { el: HTMLElement index: number @@ -104,6 +134,7 @@ export function ProjectJourney() { const clip = line.querySelector('.journey-line__clip') const clipInner = line.querySelector('.journey-line__clip-inner') const comet = line.querySelector('.journey-line__comet') + const cssDriven = scrollDriven() const nodes = [...line.querySelectorAll('[data-node]')] const lit = nodes.map(() => false) @@ -183,6 +214,11 @@ export function ProjectJourney() { nodes.forEach((node, i) => { node.style.transform = `translate3d(0, ${(y0 + i * BEATS_PER_PROJECT * pitch).toFixed(1)}px, 0)` }) + // The comet's track spans the first beat to the last; its sticky pin + // holds it at the viewport centre across exactly that stretch + root.style.setProperty('--tl-y0', `${y0.toFixed(1)}px`) + root.style.setProperty('--tl-span', `${(y1 - y0).toFixed(1)}px`) + root.style.setProperty('--tl-swing', `${swing.toFixed(1)}px`) } const apply = () => { @@ -212,12 +248,19 @@ export function ProjectJourney() { * offset would drift off the comet (arc length is not linear in y on a * curve) and a clip-path would repaint the column every frame. */ + /* + * Fallback only. Where scroll timelines exist the CSS drives the lit + * line and the swing, and the comet's height is a sticky pin, so none + * of the three waits on this handler. + */ const tip = firstCentre - rootTop + (reduced ? total - 1 : pos) * pitch - if (clip && clipInner) { + if (!cssDriven && clip && clipInner) { clip.style.transform = `translate3d(0, ${(tip - lineH).toFixed(1)}px, 0)` clipInner.style.transform = `translate3d(0, ${(lineH - tip).toFixed(1)}px, 0)` } - if (comet) comet.style.transform = `translate3d(${xAt(tip).toFixed(1)}px, ${tip.toFixed(1)}px, 0)` + if (!cssDriven && comet && swing) { + comet.style.transform = `translate3d(${(xAt(tip) - lineW / 2).toFixed(1)}px, 0, 0)` + } nodes.forEach((node, i) => { const on = reduced || pos >= i * BEATS_PER_PROJECT - 0.02 if (on !== lit[i]) { @@ -597,6 +640,7 @@ export function ProjectJourney() { The line spans the whole journey and scrolls with the page, so moving it costs nothing; only the lit copy and the comet are written per frame. */} +
@@ -619,7 +663,14 @@ export function ProjectJourney() { {s.period.split(' - ')[0]} ))} - + {/* Track from the first beat to the last; the pin inside it is + sticky at the viewport centre, so the browser holds the comet on + the scroll itself rather than a handler chasing it */} +
+
+ +
+
From 4ae17bec490ebc98cf48e7d94ce9662e903f7fa4 Mon Sep 17 00:00:00 2001 From: DavidK4leido5 Date: Thu, 24 Sep 2026 08:10:57 +0800 Subject: [PATCH 3/4] added sound effects and bump version --- src/App.tsx | 11 +- src/audio/SoundToggle.tsx | 59 +++ src/audio/Soundscape.tsx | 177 +++++++ src/audio/engine.ts | 956 ++++++++++++++++++++++++++++++++++++++ src/lib/swarmEvents.ts | 2 + src/lib/uiEvents.ts | 11 + src/scene/BrainTouch.tsx | 2 + src/styles/global.css | 180 +++++++ src/ui/LoadingScreen.tsx | 43 +- src/ui/PortfolioUI.tsx | 2 +- src/ui/ProjectJourney.tsx | 2 + src/ui/RequestTrace.tsx | 7 +- src/ui/textReveal.ts | 4 + 13 files changed, 1450 insertions(+), 6 deletions(-) create mode 100644 src/audio/SoundToggle.tsx create mode 100644 src/audio/Soundscape.tsx create mode 100644 src/audio/engine.ts create mode 100644 src/lib/uiEvents.ts diff --git a/src/App.tsx b/src/App.tsx index af32612..f62bee5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,8 @@ import { detectTier } from './lib/quality' import { Cursor } from './ui/Cursor' import { ScrollProgress } from './ui/ScrollProgress' import { SwarmHint } from './ui/SwarmHint' +import { Soundscape } from './audio/Soundscape' +import { SoundToggle } from './audio/SoundToggle' export default function App() { const sceneReady = useSceneStore((s) => s.sceneReady) @@ -41,8 +43,13 @@ export default function App() { return () => { clearTimeout(to); removeEventListener('resize', onResize) } }, []) + /* + * The intro starts from the loading screen's button, which is also the + * gesture the browser needs before it allows sound. Automated browsers + * have no one to press it, so under test the intro starts on its own. + */ useEffect(() => { - if (!sceneReady || loadPhase !== 'loading') return + if (!sceneReady || loadPhase !== 'loading' || !navigator.webdriver) return const t = setTimeout(() => useSceneStore.getState().startIntro(), 1500) return () => clearTimeout(t) }, [sceneReady, loadPhase]) @@ -77,6 +84,8 @@ export default function App() { + +
) diff --git a/src/audio/SoundToggle.tsx b/src/audio/SoundToggle.tsx new file mode 100644 index 0000000..3beb78c --- /dev/null +++ b/src/audio/SoundToggle.tsx @@ -0,0 +1,59 @@ +import { useRef, useSyncExternalStore } from 'react' +import { audio } from './engine' + +/** + * 'on': sound is playing. 'waiting': sound is on, but the browser is holding + * it until the first click, tap or key press. 'muted': the visitor turned it off. + */ +type Status = 'on' | 'waiting' | 'muted' + +const status = (): Status => (audio.muted ? 'muted' : audio.running ? 'on' : 'waiting') + +/** + * The mute toggle, always on screen. Sound is on by default; this turns it + * off and back on, and the choice is remembered. + */ +export function SoundToggle() { + const state = useSyncExternalStore( + (fn) => audio.subscribe(fn), + status, + () => 'waiting' as Status, + ) + // The page-wide gesture listener resumes audio on this same press, before + // the click lands. A press that was only waking the audio should not then + // mute it, so remember whether sound was actually playing when it began. + const wasPlaying = useRef(false) + const remember = () => { wasPlaying.current = audio.playing } + + const onClick = () => { + if (!audio.muted && !wasPlaying.current) { + audio.unlock() + return + } + const mute = !audio.muted + audio.setMuted(mute) + if (!mute) audio.click() + } + + return ( + + ) +} diff --git a/src/audio/Soundscape.tsx b/src/audio/Soundscape.tsx new file mode 100644 index 0000000..8912c8f --- /dev/null +++ b/src/audio/Soundscape.tsx @@ -0,0 +1,177 @@ +import { useEffect } from 'react' +import { useSceneStore } from '../store/sceneStore' +import { + SWARM_ATTACK, + SWARM_BITE, + SWARM_PULSE, + SWARM_RETREAT, + SWARM_TOUCH, +} from '../lib/swarmEvents' +import { UI_REVEAL, type RevealKind } from '../lib/uiEvents' +import { audio } from './engine' +import { uniforms } from '../scene/shared' + +/** Anything the interface ticks for. */ +const INTERACTIVE = 'a, button, [role="button"], input[type="range"], summary' +/** + * Share of the intro spawn at which the particles read as a finished + * structure: nearly every node home and the connection web starting to draw. + */ +const LAND_AT = 0.93 +/** Exit progress at which the veil opens: the reveal lands here. */ +const REVEAL_AT = 0.84 + +/** + * Wires the page to the sound engine. Renders nothing. + * + * - Sound starts as the page loads where the browser allows it, and on the + * first click, tap or key press everywhere else. + * - The intro gets a low, heavy build while the swarm assembles. + * - Scrolling into FULL STACK spools up an engine that follows the scroll, + * and the moment the frame breaks through the letters lands a reveal. + * - The drone takes the mood of wherever the page is; titles, stages and + * details announce themselves as they arrive; the swarm drives the rest. + */ +export function Soundscape() { + useEffect(() => { + audio.init() + + const onGesture = () => audio.unlock() + addEventListener('pointerdown', onGesture, { passive: true }) + addEventListener('keydown', onGesture) + addEventListener('touchend', onGesture, { passive: true }) + + /* + * Intro. The build starts with the intro (or as soon as audio runs, if + * that comes a moment later), and it lands when the particles actually + * close into the structure: the spawn uniform read every frame, not a + * guess at the timeline. + */ + let introAt = -1 + let assembled = false + let watch = 0 + const tryAssemble = () => { + if (assembled || introAt < 0 || !audio.playing) return + if (matchMedia('(prefers-reduced-motion: reduce)').matches) return + assembled = true + audio.assembleStart() + const check = () => { + if (uniforms.uSpawn.value >= LAND_AT) { + audio.land() + return + } + watch = requestAnimationFrame(check) + } + watch = requestAnimationFrame(check) + } + const bringDroneIn = () => { + if (useSceneStore.getState().loadPhase === 'ready' && audio.playing) audio.fadeInDrone() + } + const unsubAudio = audio.subscribe(() => { + tryAssemble() + bringDroneIn() + }) + + // Mood: hero, trace stage, or the section in the middle of the screen + let section: string | null = null + const moodNow = () => { + const { scrollZone, traceStage } = useSceneStore.getState() + if (scrollZone === 'hero') return 'hero' + if (scrollZone === 'trace') return traceStage == null ? 'hero' : `trace-${traceStage}` + return section ?? 'about' + } + const syncMood = () => audio.setMood(moodNow()) + const io = new IntersectionObserver( + (entries) => { + for (const e of entries) { + if (e.isIntersecting) section = (e.target as HTMLElement).dataset.section ?? section + } + syncMood() + }, + { rootMargin: '-45% 0px -45% 0px' }, + ) + document.querySelectorAll('.spine-section[data-section]').forEach((el) => io.observe(el)) + + // Flying into the words, scrubbed by the hero exit + let revealArmed = true + const onExit = (p: number) => { + audio.setWarp(p) + if (revealArmed && p >= REVEAL_AT) { + revealArmed = false + audio.reveal() + } else if (p < 0.5) revealArmed = true + } + + const unsubStore = useSceneStore.subscribe((s, prev) => { + if (s.loadPhase !== prev.loadPhase && s.loadPhase === 'intro') { + introAt = performance.now() + tryAssemble() + } + // A moment after the words are up, if the landing did not already do it + if (s.loadPhase !== prev.loadPhase && s.loadPhase === 'ready') setTimeout(bringDroneIn, 1500) + if (s.heroExitProgress !== prev.heroExitProgress) onExit(s.heroExitProgress) + if (s.scrollZone !== prev.scrollZone || s.traceStage !== prev.traceStage) syncMood() + }) + if (useSceneStore.getState().loadPhase === 'intro') introAt = performance.now() + + // Arrivals + const onReveal = (e: Event) => { + const kind = (e as CustomEvent<{ kind: RevealKind }>).detail?.kind + if (kind === 'title') audio.revealTitle() + else if (kind === 'stage') audio.revealStage() + else audio.revealItem() + } + addEventListener(UI_REVEAL, onReveal) + + // Swarm + const onPulse = () => audio.chime() + const onTouch = () => audio.startle() + const onAttack = () => audio.setAttack(true) + const onBite = () => audio.bite() + const onRetreat = () => { + audio.retreat() + audio.setAttack(false) + } + addEventListener(SWARM_PULSE, onPulse) + addEventListener(SWARM_TOUCH, onTouch) + addEventListener(SWARM_ATTACK, onAttack) + addEventListener(SWARM_BITE, onBite) + addEventListener(SWARM_RETREAT, onRetreat) + + // Interface: a tick entering anything clickable, a blip on clicking it + let hovered: Element | null = null + const fine = matchMedia('(hover: hover) and (pointer: fine)').matches + const onOver = (e: PointerEvent) => { + const hit = (e.target as Element).closest?.(INTERACTIVE) ?? null + if (hit && hit !== hovered && fine) audio.hover() + hovered = hit + } + const onClick = (e: MouseEvent) => { + const hit = (e.target as Element).closest?.(INTERACTIVE) + // The gate's buttons stay silent: the page's first sound is the landing + if (hit && !hit.closest('[data-sound-toggle], .loader-gate')) audio.click() + } + addEventListener('pointerover', onOver, { passive: true }) + addEventListener('click', onClick, { passive: true }) + + return () => { + removeEventListener('pointerdown', onGesture) + removeEventListener('keydown', onGesture) + removeEventListener('touchend', onGesture) + cancelAnimationFrame(watch) + unsubAudio() + unsubStore() + io.disconnect() + removeEventListener(UI_REVEAL, onReveal) + removeEventListener(SWARM_PULSE, onPulse) + removeEventListener(SWARM_TOUCH, onTouch) + removeEventListener(SWARM_ATTACK, onAttack) + removeEventListener(SWARM_BITE, onBite) + removeEventListener(SWARM_RETREAT, onRetreat) + removeEventListener('pointerover', onOver) + removeEventListener('click', onClick) + } + }, []) + + return null +} diff --git a/src/audio/engine.ts b/src/audio/engine.ts new file mode 100644 index 0000000..aff9959 --- /dev/null +++ b/src/audio/engine.ts @@ -0,0 +1,956 @@ +/** + * The soundtrack, synthesised live with the Web Audio API. + * + * Nothing is downloaded: every sound is built from oscillators, noise and a + * generated reverb, so it costs no bytes and the music can morph between + * places on the page instead of crossfading between recordings. + * + * Sound is on by default. The context is created as the page loads and + * plays straight away where the browser allows it; where it does not (most + * browsers, until the visitor has clicked, tapped or pressed a key) it waits + * suspended and `unlock()` resumes it on the first gesture. One-shots are + * skipped while it is suspended, or they would all fire at once on resume. + * + * Signal flow: music, attack and effects buses into a compressor, with a + * shared reverb send, then a master gain the mute toggle ramps. + */ + +type Mood = { + /** MIDI note of the root */ + root: number + /** Four chord tones in semitones above the root */ + chord: [number, number, number, number] + /** Lowpass on the pad, Hz: lower is darker and further away */ + cutoff: number +} + +/** One mood per place on the page. Keys come from Soundscape. */ +const MOODS: Record = { + hero: { root: 45, chord: [7, 12, 14, 19], cutoff: 680 }, + 'trace-0': { root: 50, chord: [7, 12, 16, 19], cutoff: 760 }, + 'trace-1': { root: 52, chord: [7, 10, 14, 19], cutoff: 720 }, + 'trace-2': { root: 47, chord: [7, 12, 15, 19], cutoff: 700 }, + 'trace-3': { root: 43, chord: [7, 11, 14, 19], cutoff: 640 }, + 'trace-4': { root: 41, chord: [7, 12, 14, 19], cutoff: 580 }, + about: { root: 48, chord: [7, 11, 14, 19], cutoff: 700 }, + projects: { root: 53, chord: [7, 12, 16, 19], cutoff: 760 }, + experience: { root: 50, chord: [7, 10, 15, 19], cutoff: 690 }, + skills: { root: 52, chord: [7, 12, 14, 21], cutoff: 780 }, + contact: { root: 55, chord: [7, 11, 16, 19], cutoff: 720 }, +} + +/** Level of the drone bus. Kept low: it should sit under everything. */ +const MUSIC_LEVEL = 0.42 +const MUTE_KEY = 'sound-muted' +const hz = (midi: number) => 440 * 2 ** ((midi - 69) / 12) +const rand = (a: number, b: number) => a + Math.random() * (b - a) +const pick = (xs: readonly T[]) => xs[Math.floor(Math.random() * xs.length)] +const clamp01 = (x: number) => Math.min(1, Math.max(0, x)) +const smooth = (a: number, b: number, x: number) => { + const t = clamp01((x - a) / (b - a)) + return t * t * (3 - 2 * t) +} + +function readMuted(): boolean { + try { + return localStorage.getItem(MUTE_KEY) === '1' + } catch { + return false + } +} + +function writeMuted(muted: boolean) { + try { + localStorage.setItem(MUTE_KEY, muted ? '1' : '0') + } catch { + /* private mode: the choice is not remembered */ + } +} + +type Voice = { osc: OscillatorNode; interval: number } + +type Warp = { + noise: AudioBufferSourceNode + band: BiquadFilterNode + saws: OscillatorNode[] + sawFilter: BiquadFilterNode + sub: OscillatorNode + trem: OscillatorNode + out: GainNode +} + +class Engine { + private ctx: AudioContext | null = null + private master!: GainNode + private music!: GainNode + private attackBus!: GainNode + private sfx!: GainNode + private reverb!: ConvolverNode + private noise!: AudioBuffer + private padFilter!: BiquadFilterNode + private voices: Voice[] = [] + private sub!: OscillatorNode + private mood: Mood = MOODS.hero + private moodKey = 'hero' + private attackTimer = 0 + private attackOn = false + private attackOff: ReturnType | undefined + private tremoloStop: (() => void) | null = null + private chimeTimer: ReturnType | undefined + private warp: Warp | null = null + private warpStop: ReturnType | undefined + private lastHover = 0 + private lastItem = 0 + private listeners = new Set<() => void>() + + muted = readMuted() + + /** Audio is actually running (the browser has let it start). */ + get running() { + return this.ctx?.state === 'running' + } + /** Sound is coming out of the speakers. */ + get playing() { + return this.running && !this.muted + } + + subscribe(fn: () => void) { + this.listeners.add(fn) + return () => this.listeners.delete(fn) + } + private emit() { + for (const fn of this.listeners) fn() + } + + /** A one-shot may play only on a running, audible context. */ + private live(): AudioContext | null { + return this.ctx && this.ctx.state === 'running' && !this.muted ? this.ctx : null + } + + /** Build the graph and start the drone. Called once, as the page loads. */ + init() { + if (this.ctx) return + const Ctx = + window.AudioContext ?? + (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext + if (!Ctx) return + const ctx = new Ctx() + this.ctx = ctx + + this.noise = this.makeNoise(2) + this.reverb = ctx.createConvolver() + this.reverb.buffer = this.makeImpulse(5, 3) + + const comp = ctx.createDynamicsCompressor() + comp.threshold.value = -18 + comp.ratio.value = 3 + comp.attack.value = 0.02 + comp.release.value = 0.5 + + this.master = ctx.createGain() + this.master.gain.value = this.muted ? 0 : 1 + comp.connect(this.master).connect(ctx.destination) + + const wet = ctx.createGain() + wet.gain.value = 0.6 + this.reverb.connect(wet).connect(comp) + + this.music = ctx.createGain() + this.music.gain.value = 0 + this.music.connect(comp) + this.music.connect(this.reverb) + + this.attackBus = ctx.createGain() + this.attackBus.gain.value = 0 + this.attackBus.connect(comp) + this.attackBus.connect(this.reverb) + + this.sfx = ctx.createGain() + this.sfx.gain.value = 0.9 + this.sfx.connect(comp) + + this.startDrone() + ctx.addEventListener('statechange', () => this.emit()) + document.addEventListener('visibilitychange', () => { + if (!this.ctx) return + if (document.hidden) void this.ctx.suspend() + else if (!this.muted) void this.ctx.resume() + }) + // Plays now where autoplay is allowed; otherwise stays suspended + if (!this.muted) void ctx.resume().catch(() => {}) + this.emit() + } + + /** Resume on a user gesture. Safe to call on every one. */ + unlock() { + if (!this.ctx) this.init() + if (this.ctx && this.ctx.state === 'suspended' && !this.muted && !document.hidden) { + void this.ctx.resume().catch(() => {}) + } + } + + setMuted(muted: boolean) { + this.muted = muted + writeMuted(muted) + if (this.ctx) { + const t = this.ctx.currentTime + this.master.gain.cancelScheduledValues(t) + this.master.gain.setTargetAtTime(muted ? 0 : 1, t, 0.15) + if (!muted) void this.ctx.resume().catch(() => {}) + } + this.emit() + } + + // ---- building blocks ------------------------------------------------- + + private makeNoise(seconds: number): AudioBuffer { + const ctx = this.ctx! + const buf = ctx.createBuffer(1, Math.floor(ctx.sampleRate * seconds), ctx.sampleRate) + const d = buf.getChannelData(0) + for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1 + return buf + } + + /** A reverb tail: stereo noise under a decay, softened at the top. */ + private makeImpulse(seconds: number, decay: number): AudioBuffer { + const ctx = this.ctx! + const len = Math.floor(ctx.sampleRate * seconds) + const buf = ctx.createBuffer(2, len, ctx.sampleRate) + for (let c = 0; c < 2; c++) { + const d = buf.getChannelData(c) + let last = 0 + for (let i = 0; i < len; i++) { + // A one-pole lowpass on the noise: a darker, gentler tail + last = last * 0.6 + (Math.random() * 2 - 1) * 0.4 + d[i] = last * (1 - i / len) ** decay + } + } + return buf + } + + private noiseSource(loop = false): AudioBufferSourceNode { + const src = this.ctx!.createBufferSource() + src.buffer = this.noise + src.loop = loop + return src + } + + /** Gain with an attack and an exponential release, for one-shots. */ + private env(peak: number, at: number, attack: number, release: number): GainNode { + const g = this.ctx!.createGain() + g.gain.setValueAtTime(0.0001, at) + g.gain.exponentialRampToValueAtTime(Math.max(peak, 0.0002), at + attack) + g.gain.exponentialRampToValueAtTime(0.0001, at + attack + release) + return g + } + + private softClip(amount: number): WaveShaperNode { + const ws = this.ctx!.createWaveShaper() + const n = 1024 + const curve = new Float32Array(n) + for (let i = 0; i < n; i++) { + const x = (i / (n - 1)) * 2 - 1 + curve[i] = Math.tanh(x * amount) / Math.tanh(amount) + } + ws.curve = curve + return ws + } + + private panned(pan: number, dest: AudioNode): StereoPannerNode { + const p = this.ctx!.createStereoPanner() + p.pan.value = pan + p.connect(dest) + return p + } + + /** A glassy note: a sine and a quiet inharmonic partial, long in the reverb. */ + private glass(midi: number, peak: number, at: number, release: number, dest: AudioNode) { + const ctx = this.ctx! + const out = this.env(peak, at, 0.015, release) + out.connect(dest) + out.connect(this.reverb) + for (const [ratio, level] of [[1, 1], [2.76, 0.18], [5.4, 0.05]] as const) { + const o = ctx.createOscillator() + o.type = 'sine' + o.frequency.value = hz(midi) * ratio + const g = ctx.createGain() + g.gain.value = level + o.connect(g).connect(out) + o.start(at) + o.stop(at + release + 0.2) + } + } + + // ---- the drone: calm, far away, never quite still -------------------- + + private startDrone() { + const ctx = this.ctx! + const t = ctx.currentTime + + this.padFilter = ctx.createBiquadFilter() + this.padFilter.type = 'lowpass' + this.padFilter.frequency.value = this.mood.cutoff + this.padFilter.Q.value = 0.3 + this.padFilter.connect(this.music) + + const lfo = ctx.createOscillator() + lfo.frequency.value = 0.03 + const lfoAmt = ctx.createGain() + lfoAmt.gain.value = 180 + lfo.connect(lfoAmt).connect(this.padFilter.frequency) + lfo.start(t) + + /* + * Five soft voices: root, then the chord tones above it. Sines and one + * triangle, no saws, so nothing buzzes. Each one swells and fades on its + * own slow cycle and sits somewhere different in the stereo field, so the + * chord drifts rather than holds. + */ + const intervals = [0, ...this.mood.chord] + const levels = [0.09, 0.05, 0.04, 0.03, 0.02] + const pans = [0, -0.55, 0.5, -0.3, 0.7] + this.voices = intervals.map((interval, i) => { + const osc = ctx.createOscillator() + osc.type = i === 1 ? 'triangle' : 'sine' + osc.frequency.value = hz(this.mood.root + interval) + osc.detune.value = rand(-4, 4) + const g = ctx.createGain() + g.gain.value = levels[i] * 0.6 + const swell = ctx.createOscillator() + swell.frequency.value = rand(0.02, 0.07) + const swellAmt = ctx.createGain() + swellAmt.gain.value = levels[i] * 0.4 + swell.connect(swellAmt).connect(g.gain) + osc.connect(g).connect(this.panned(pans[i], this.padFilter)) + osc.start(t) + swell.start(t + rand(0, 3)) + return { osc, interval } + }) + + // A sub so low it is felt more than heard + this.sub = ctx.createOscillator() + this.sub.type = 'sine' + this.sub.frequency.value = hz(this.mood.root - 12) + const subGain = ctx.createGain() + subGain.gain.value = 0.07 + this.sub.connect(subGain).connect(this.music) + this.sub.start(t) + + // Air: a faint breath of low noise + const air = this.noiseSource(true) + const airLow = ctx.createBiquadFilter() + airLow.type = 'lowpass' + airLow.frequency.value = 900 + const airGain = ctx.createGain() + airGain.gain.value = 0.008 + const airLfo = ctx.createOscillator() + airLfo.frequency.value = 0.035 + const airLfoAmt = ctx.createGain() + airLfoAmt.gain.value = 0.006 + airLfo.connect(airLfoAmt).connect(airGain.gain) + air.connect(airLow).connect(airGain).connect(this.music) + air.start(t) + airLfo.start(t) + + // Silent until fadeInDrone(): it rises out of the intro's landing + this.music.gain.setValueAtTime(0, t) + + this.scheduleChime() + } + + /** + * Bring the ambience in, slowly, once. Called under the intro's landing, or + * when the page is ready without one (entered muted, reduced motion). + */ + fadeInDrone(delay = 0) { + const ctx = this.live() + if (!ctx || this.droneIn) return + this.droneIn = true + const t = ctx.currentTime + delay + this.music.gain.cancelScheduledValues(ctx.currentTime) + this.music.gain.setValueAtTime(0, t) + this.music.gain.linearRampToValueAtTime(MUSIC_LEVEL, t + 6) + } + + /** Now and then, a distant glass note from the chord. The mystery. */ + private scheduleChime() { + this.chimeTimer = setTimeout(() => { + const ctx = this.live() + if (ctx && this.droneIn && !this.attackOn) { + const note = this.mood.root + 24 + pick(this.mood.chord) + this.glass(note, 0.022, ctx.currentTime, 4.5, this.panned(rand(-0.8, 0.8), this.music)) + } + this.scheduleChime() + }, rand(7000, 15000)) + } + + /** Drift to another place's chord and colour, slowly. */ + setMood(key: string) { + const mood = MOODS[key] + if (!mood || key === this.moodKey) return + this.moodKey = key + this.mood = mood + if (!this.ctx) return + const t = this.ctx.currentTime + const intervals = [0, ...mood.chord] + this.voices.forEach((v, i) => v.osc.frequency.setTargetAtTime(hz(mood.root + intervals[i]), t, 3)) + this.sub.frequency.setTargetAtTime(hz(mood.root - 12), t, 3.5) + this.padFilter.frequency.setTargetAtTime(mood.cutoff, t, 3) + } + + // ---- the intro: the swarm assembling --------------------------------- + + private build: { nodes: AudioScheduledSourceNode[]; gains: GainNode[] } | null = null + private landFallback: ReturnType | undefined + private droneIn = false + + /** + * The swarm pulling together: a low rumble, a throbbing sub and a rush, + * rising from the first movement and holding at the top, kept well under + * the landing so the build is felt and the hit is what lands. It does not + * land on its own: `land()` is called when the particles actually close. + * + * Every gain is pinned to 0 at the start. A GainNode defaults to 1, and a + * layer whose first scheduled value came later (the rush, 0.6s in) played + * at full level until then: the loud burst right after the start click. + */ + assembleStart() { + const ctx = this.live() + if (!ctx || this.build) return + const t = ctx.currentTime + 0.02 + /** When the build reaches its top; roughly when the structure closes */ + const top = t + 3.4 + const nodes: AudioScheduledSourceNode[] = [] + const gains: GainNode[] = [] + + const rum = this.noiseSource(true) + const rf = ctx.createBiquadFilter() + rf.type = 'lowpass' + rf.frequency.setValueAtTime(70, t) + rf.frequency.exponentialRampToValueAtTime(380, top) + const rg = ctx.createGain() + rg.gain.value = 0 + rg.gain.setValueAtTime(0, t) + rg.gain.linearRampToValueAtTime(0.06, t + 0.8) + rg.gain.linearRampToValueAtTime(0.18, top) + rum.connect(rf).connect(rg) + rg.connect(this.sfx) + rg.connect(this.reverb) + nodes.push(rum) + gains.push(rg) + + const sub = ctx.createOscillator() + sub.frequency.setValueAtTime(30, t) + sub.frequency.linearRampToValueAtTime(48, top) + const throbbed = ctx.createGain() + throbbed.gain.value = 0.55 + const throb = ctx.createOscillator() + throb.frequency.setValueAtTime(1.4, t) + throb.frequency.linearRampToValueAtTime(7, top) + const throbAmt = ctx.createGain() + throbAmt.gain.value = 0.45 + throb.connect(throbAmt).connect(throbbed.gain) + const sg = ctx.createGain() + sg.gain.value = 0 + sg.gain.setValueAtTime(0, t) + sg.gain.linearRampToValueAtTime(0.14, t + 1.1) + sg.gain.linearRampToValueAtTime(0.3, top) + sub.connect(this.softClip(1.8)).connect(throbbed).connect(sg).connect(this.sfx) + nodes.push(sub, throb) + gains.push(sg) + + const rush = this.noiseSource(true) + const bf = ctx.createBiquadFilter() + bf.type = 'bandpass' + bf.Q.value = 1.6 + bf.frequency.setValueAtTime(160, t) + bf.frequency.setValueAtTime(160, t + 0.6) + bf.frequency.exponentialRampToValueAtTime(700, t + 2.2) + bf.frequency.exponentialRampToValueAtTime(320, top) + const bg = ctx.createGain() + bg.gain.value = 0 + bg.gain.setValueAtTime(0, t) + bg.gain.setValueAtTime(0, t + 0.6) + bg.gain.linearRampToValueAtTime(0.08, t + 2.2) + bg.gain.linearRampToValueAtTime(0.035, top) + rush.connect(bf).connect(bg) + bg.connect(this.sfx) + bg.connect(this.reverb) + nodes.push(rush) + gains.push(bg) + + for (const n of nodes) n.start(t) + this.build = { nodes, gains } + // If the cue never comes (the intro was skipped), land anyway + clearTimeout(this.landFallback) + this.landFallback = setTimeout(() => this.land(), 7000) + } + + /** + * The structure has closed: the build stops dead, the hit lands, and the + * ambience rises out of it. + */ + land() { + clearTimeout(this.landFallback) + const build = this.build + this.build = null + if (!this.ctx || !build) return + const now = this.ctx.currentTime + for (const g of build.gains) { + g.gain.cancelScheduledValues(now) + g.gain.setTargetAtTime(0, now, 0.06) + } + for (const n of build.nodes) n.stop(now + 0.6) + const ctx = this.live() + if (!ctx) return + const t = ctx.currentTime + this.fadeInDrone(1.2) + + const thud = ctx.createOscillator() + thud.frequency.setValueAtTime(92, t) + thud.frequency.exponentialRampToValueAtTime(30, t + 1.2) + const tg = this.env(0.9, t, 0.005, 1.8) + thud.connect(this.softClip(2)).connect(tg).connect(this.sfx) + thud.start(t) + thud.stop(t + 2.2) + const bloom = this.env(0.06, t, 0.3, 4) + bloom.connect(this.sfx) + bloom.connect(this.reverb) + for (const note of [33, 40, 45, 52]) { + const o = ctx.createOscillator() + o.type = 'sine' + o.frequency.value = hz(note) + o.connect(bloom) + o.start(t) + o.stop(t + 4.6) + } + } + + // ---- flying into the words ------------------------------------------- + + /** + * The hero exit, scrubbed by scroll: 0 at rest, 1 once the page is inside + * the letters. A ship's engine spooling up into a tunnel: pitch climbs, the + * filter opens, the throb speeds up. It dies away as the veil opens, where + * `reveal()` takes over. + */ + setWarp(p: number) { + const on = p > 0.015 && p < 0.97 + if (!on) { + if (this.warp) this.releaseWarp() + return + } + const ctx = this.live() + if (!ctx) return + if (!this.warp) this.warp = this.buildWarp(ctx) + clearTimeout(this.warpStop) + const w = this.warp + const t = ctx.currentTime + const level = smooth(0.015, 0.2, p) * (1 - smooth(0.8, 0.92, p)) * 0.3 + w.out.gain.setTargetAtTime(level, t, 0.08) + w.band.frequency.setTargetAtTime(180 * 2 ** (p * 4.2), t, 0.08) + w.sawFilter.frequency.setTargetAtTime(220 + 2600 * p * p, t, 0.08) + const semis = -12 + p * 24 + for (const [i, o] of w.saws.entries()) o.frequency.setTargetAtTime(hz(this.mood.root + semis + i * 7), t, 0.08) + w.sub.frequency.setTargetAtTime(hz(this.mood.root - 24 + p * 12), t, 0.1) + w.trem.frequency.setTargetAtTime(2 + p * 14, t, 0.1) + } + + private buildWarp(ctx: AudioContext): Warp { + const t = ctx.currentTime + const out = ctx.createGain() + out.gain.value = 0 + out.connect(this.sfx) + out.connect(this.reverb) + + // The throb: amplitude pulsing faster the deeper in it goes + const throbbed = ctx.createGain() + throbbed.gain.value = 0.6 + const trem = ctx.createOscillator() + trem.frequency.value = 2 + const tremAmt = ctx.createGain() + tremAmt.gain.value = 0.4 + trem.connect(tremAmt).connect(throbbed.gain) + throbbed.connect(out) + + const noise = this.noiseSource(true) + const band = ctx.createBiquadFilter() + band.type = 'bandpass' + band.Q.value = 6 + band.frequency.value = 180 + const ng = ctx.createGain() + ng.gain.value = 0.9 + noise.connect(band).connect(ng).connect(throbbed) + + const sawFilter = ctx.createBiquadFilter() + sawFilter.type = 'lowpass' + sawFilter.frequency.value = 220 + sawFilter.Q.value = 4 + const sg = ctx.createGain() + sg.gain.value = 0.18 + sawFilter.connect(sg).connect(throbbed) + const saws = [0, 1].map((i) => { + const o = ctx.createOscillator() + o.type = 'sawtooth' + o.frequency.value = hz(this.mood.root - 12 + i * 7) + o.detune.value = i ? 8 : -8 + o.connect(sawFilter) + o.start(t) + return o + }) + + const sub = ctx.createOscillator() + sub.frequency.value = hz(this.mood.root - 24) + const subG = ctx.createGain() + subG.gain.value = 0.5 + sub.connect(subG).connect(out) + + noise.start(t) + sub.start(t) + trem.start(t) + return { noise, band, saws, sawFilter, sub, trem, out } + } + + private releaseWarp() { + const w = this.warp + if (!w || !this.ctx) return + const t = this.ctx.currentTime + w.out.gain.setTargetAtTime(0, t, 0.12) + clearTimeout(this.warpStop) + this.warpStop = setTimeout(() => { + for (const n of [w.noise, w.sub, w.trem, ...w.saws]) n.stop() + if (this.warp === w) this.warp = null + }, 900) + } + + /** + * Through the letters and out the other side. Kept quiet on purpose: the + * engine has done the drama, so this is the view opening, a soft chord + * swelling in and away, and one glass note on top. + */ + reveal() { + const ctx = this.live() + if (!ctx) return + const t = ctx.currentTime + 0.01 + const view = ctx.createBiquadFilter() + view.type = 'lowpass' + view.frequency.setValueAtTime(380, t) + view.frequency.exponentialRampToValueAtTime(1400, t + 1.2) + view.frequency.exponentialRampToValueAtTime(600, t + 4.5) + const vg = ctx.createGain() + vg.gain.setValueAtTime(0.0001, t) + vg.gain.exponentialRampToValueAtTime(0.045, t + 0.9) + vg.gain.exponentialRampToValueAtTime(0.0001, t + 4.8) + view.connect(vg) + vg.connect(this.sfx) + vg.connect(this.reverb) + const root = this.mood.root + for (const semi of [0, 7, 12, 16]) { + const o = ctx.createOscillator() + o.type = 'sine' + o.frequency.value = hz(root + semi) + o.connect(view) + o.start(t) + o.stop(t + 5) + } + this.glass(root + 31, 0.028, t + 0.4, 3.2, this.sfx) + } + + // ---- reveals --------------------------------------------------------- + + /** A section title rising in: a soft low swell with a glassy top. */ + revealTitle() { + const ctx = this.live() + if (!ctx) return + const t = ctx.currentTime + const o = ctx.createOscillator() + o.type = 'sine' + o.frequency.setValueAtTime(hz(this.mood.root - 12), t) + o.frequency.exponentialRampToValueAtTime(hz(this.mood.root), t + 0.5) + const g = this.env(0.18, t, 0.12, 1.4) + o.connect(g) + g.connect(this.sfx) + g.connect(this.reverb) + o.start(t) + o.stop(t + 1.8) + const n = this.noiseSource() + const f = ctx.createBiquadFilter() + f.type = 'bandpass' + f.Q.value = 1.2 + f.frequency.setValueAtTime(300, t) + f.frequency.exponentialRampToValueAtTime(2200, t + 0.7) + const ng = this.env(0.06, t, 0.35, 0.6) + n.connect(f).connect(ng) + ng.connect(this.sfx) + ng.connect(this.reverb) + n.start(t, rand(0, 1)) + n.stop(t + 1.1) + this.glass(this.mood.root + 24 + this.mood.chord[1], 0.035, t + 0.25, 3, this.sfx) + } + + /** A stage or chapter arriving: one glass note, a little brighter. */ + revealStage() { + const ctx = this.live() + if (!ctx) return + const t = ctx.currentTime + this.glass(this.mood.root + 24 + pick(this.mood.chord), 0.04, t, 3, this.panned(rand(-0.4, 0.4), this.sfx)) + const o = ctx.createOscillator() + o.frequency.setValueAtTime(hz(this.mood.root - 12), t) + const g = this.env(0.1, t, 0.03, 0.9) + o.connect(g).connect(this.sfx) + o.start(t) + o.stop(t + 1.1) + } + + /** Small items arriving: a faint shimmer, rate-limited so a list sparkles. */ + revealItem() { + const ctx = this.live() + if (!ctx) return + const now = performance.now() + if (now - this.lastItem < 90) return + this.lastItem = now + this.glass(this.mood.root + 36 + pick(this.mood.chord), 0.012, ctx.currentTime, 1.2, this.panned(rand(-0.7, 0.7), this.sfx)) + } + + // ---- attack music ---------------------------------------------------- + + /** Suspense while the swarm is on the cursor; lingers after it lets go. */ + setAttack(on: boolean) { + if (!this.ctx) return + clearTimeout(this.attackOff) + if (on) { + if (this.attackOn || !this.live()) return + this.attackOn = true + const t = this.ctx.currentTime + this.attackBus.gain.cancelScheduledValues(t) + this.attackBus.gain.setTargetAtTime(0.8, t, 0.25) + this.music.gain.setTargetAtTime(MUSIC_LEVEL * 0.35, t, 0.3) + this.startOstinato() + this.startTremolo() + this.riser() + } else if (this.attackOn) { + // Suspense hangs on, so even a short raid reads as a scene + this.attackOff = setTimeout(() => { + if (!this.ctx) return + this.attackOn = false + const t = this.ctx.currentTime + this.attackBus.gain.setTargetAtTime(0, t, 0.8) + this.music.gain.setTargetAtTime(MUSIC_LEVEL, t, 1.5) + window.clearInterval(this.attackTimer) + this.attackTimer = 0 + this.tremoloStop?.() + }, 4000) + } + } + + /** Pulsing bass in eighths with a heartbeat on the beat, scheduled ahead. */ + private startOstinato() { + const ctx = this.ctx! + const step = 60 / 148 / 2 + const pattern = [0, 0, 12, 0, 1, 0, 12, 1] + let next = ctx.currentTime + 0.05 + let i = 0 + const schedule = () => { + while (next < ctx.currentTime + 0.12) { + const o = ctx.createOscillator() + o.type = 'sawtooth' + o.frequency.value = hz(this.mood.root - 12 + pattern[i % pattern.length]) + const f = ctx.createBiquadFilter() + f.type = 'lowpass' + f.frequency.setValueAtTime(800, next) + f.frequency.exponentialRampToValueAtTime(160, next + step * 0.9) + const g = this.env(0.2, next, 0.005, step * 0.9) + o.connect(f).connect(g).connect(this.attackBus) + o.start(next) + o.stop(next + step) + if (i % 2 === 0) this.kick(next) + next += step + i++ + } + } + schedule() + window.clearInterval(this.attackTimer) + this.attackTimer = window.setInterval(schedule, 25) + } + + private kick(at: number) { + const ctx = this.ctx! + const o = ctx.createOscillator() + o.frequency.setValueAtTime(130, at) + o.frequency.exponentialRampToValueAtTime(38, at + 0.22) + const g = this.env(0.45, at, 0.004, 0.3) + o.connect(g).connect(this.attackBus) + o.start(at) + o.stop(at + 0.4) + } + + /** Two high tones a semitone apart, shivering: the sound of being watched. */ + private startTremolo() { + const ctx = this.ctx! + this.tremoloStop?.() + const t = ctx.currentTime + const out = ctx.createGain() + out.gain.setValueAtTime(0, t) + out.gain.linearRampToValueAtTime(0.04, t + 1.2) + const trem = ctx.createOscillator() + trem.frequency.value = 9 + const tremAmt = ctx.createGain() + tremAmt.gain.value = 0.03 + trem.connect(tremAmt).connect(out.gain) + const tones = [0, 1].map((semi) => { + const o = ctx.createOscillator() + o.type = 'sine' + o.frequency.value = hz(this.mood.root + 36 + semi) + o.connect(out) + o.start(t) + return o + }) + out.connect(this.attackBus) + trem.start(t) + this.tremoloStop = () => { + const now = ctx.currentTime + out.gain.setTargetAtTime(0, now, 0.4) + for (const o of [...tones, trem]) o.stop(now + 2) + this.tremoloStop = null + } + } + + /** The raid arriving: noise tightening upward under a dissonant stab. */ + private riser() { + const ctx = this.ctx! + const t = ctx.currentTime + const n = this.noiseSource() + const f = ctx.createBiquadFilter() + f.type = 'bandpass' + f.Q.value = 3 + f.frequency.setValueAtTime(300, t) + f.frequency.exponentialRampToValueAtTime(4000, t + 0.6) + const g = this.env(0.22, t, 0.5, 0.25) + n.connect(f).connect(g).connect(this.attackBus) + n.start(t) + n.stop(t + 0.9) + for (const semi of [0, 1, 6]) { + const o = ctx.createOscillator() + o.type = 'sawtooth' + o.frequency.value = hz(this.mood.root + 12 + semi) + const og = this.env(0.04, t + 0.55, 0.01, 0.9) + o.connect(og).connect(this.attackBus) + o.start(t + 0.55) + o.stop(t + 1.6) + } + } + + // ---- one-shots ------------------------------------------------------- + + /** A bite: a crunch of filtered noise, clipped, with a falling chirp. */ + bite() { + const ctx = this.live() + if (!ctx) return + const t = ctx.currentTime + const pan = this.panned(rand(-0.5, 0.5), this.sfx) + const n = this.noiseSource() + const bp = ctx.createBiquadFilter() + bp.type = 'bandpass' + bp.frequency.value = rand(900, 2600) + bp.Q.value = 5 + const ng = this.env(0.45, t, 0.002, 0.08) + n.connect(bp).connect(this.softClip(6)).connect(ng).connect(pan) + n.start(t, rand(0, 1.5)) + n.stop(t + 0.12) + const o = ctx.createOscillator() + o.type = 'square' + o.frequency.setValueAtTime(rand(500, 800), t) + o.frequency.exponentialRampToValueAtTime(90, t + 0.1) + const og = this.env(0.1, t, 0.002, 0.1) + o.connect(og).connect(pan) + o.start(t) + o.stop(t + 0.14) + } + + /** The raid falling back: a whoosh that sinks away. */ + retreat() { + const ctx = this.live() + if (!ctx) return + const t = ctx.currentTime + const n = this.noiseSource() + const f = ctx.createBiquadFilter() + f.type = 'bandpass' + f.Q.value = 2 + f.frequency.setValueAtTime(3000, t) + f.frequency.exponentialRampToValueAtTime(250, t + 0.9) + const g = this.env(0.16, t, 0.05, 0.9) + n.connect(f).connect(g) + g.connect(this.sfx) + g.connect(this.reverb) + n.start(t) + n.stop(t + 1.1) + } + + /** A light pulse from the pointer: a quiet glass note from the chord. */ + chime() { + const ctx = this.live() + if (!ctx) return + this.glass(this.mood.root + 24 + pick(this.mood.chord), 0.025, ctx.currentTime, 2.2, this.panned(rand(-0.5, 0.5), this.sfx)) + } + + /** The swarm startled by a click: an airy sweep over a low thump. */ + startle() { + const ctx = this.live() + if (!ctx) return + const t = ctx.currentTime + const n = this.noiseSource() + const f = ctx.createBiquadFilter() + f.type = 'bandpass' + f.Q.value = 1.4 + f.frequency.setValueAtTime(420, t) + f.frequency.exponentialRampToValueAtTime(3200, t + 0.6) + const g = this.env(0.12, t, 0.3, 0.5) + n.connect(f).connect(g) + g.connect(this.sfx) + g.connect(this.reverb) + n.start(t, rand(0, 1)) + n.stop(t + 0.9) + const o = ctx.createOscillator() + o.frequency.setValueAtTime(90, t) + o.frequency.exponentialRampToValueAtTime(40, t + 0.35) + const og = this.env(0.3, t, 0.005, 0.4) + o.connect(og).connect(this.sfx) + o.start(t) + o.stop(t + 0.5) + } + + /** Hovering something clickable: a tiny high tick. Rate-limited. */ + hover() { + const ctx = this.live() + if (!ctx) return + const now = performance.now() + if (now - this.lastHover < 70) return + this.lastHover = now + const t = ctx.currentTime + const o = ctx.createOscillator() + o.type = 'sine' + o.frequency.value = rand(2300, 2600) + const g = this.env(0.02, t, 0.002, 0.03) + o.connect(g).connect(this.sfx) + o.start(t) + o.stop(t + 0.05) + } + + /** A click on the interface: two quick rising blips. */ + click() { + const ctx = this.live() + if (!ctx) return + const t = ctx.currentTime + ;[880, 1320].forEach((f, i) => { + const o = ctx.createOscillator() + o.type = 'triangle' + o.frequency.value = f + const g = this.env(0.04, t + i * 0.055, 0.003, 0.06) + o.connect(g).connect(this.sfx) + o.start(t + i * 0.055) + o.stop(t + i * 0.055 + 0.08) + }) + } +} + +export const audio = new Engine() diff --git a/src/lib/swarmEvents.ts b/src/lib/swarmEvents.ts index 0dbcb47..6c7d426 100644 --- a/src/lib/swarmEvents.ts +++ b/src/lib/swarmEvents.ts @@ -6,6 +6,8 @@ export const SWARM_ENTER = 'swarm:enter' /** The pointer has left the cloud. */ export const SWARM_LEAVE = 'swarm:leave' +/** The pointer sent a shockwave of light through the cloud. */ +export const SWARM_PULSE = 'swarm:pulse' /** A click landed on the cloud and startled the swarm. */ export const SWARM_TOUCH = 'swarm:touch' /** A raid has reached the cursor. */ diff --git a/src/lib/uiEvents.ts b/src/lib/uiEvents.ts new file mode 100644 index 0000000..9c91ef1 --- /dev/null +++ b/src/lib/uiEvents.ts @@ -0,0 +1,11 @@ +/** + * Window event for something arriving on screen, so the soundtrack can + * answer it. `detail.kind` says how big a moment it is. + */ +export const UI_REVEAL = 'ui:reveal' + +export type RevealKind = 'title' | 'stage' | 'item' + +export function announceReveal(kind: RevealKind) { + dispatchEvent(new CustomEvent(UI_REVEAL, { detail: { kind } })) +} diff --git a/src/scene/BrainTouch.tsx b/src/scene/BrainTouch.tsx index 463b573..15c2d8a 100644 --- a/src/scene/BrainTouch.tsx +++ b/src/scene/BrainTouch.tsx @@ -15,6 +15,7 @@ import { SWARM_BITE, SWARM_ENTER, SWARM_LEAVE, + SWARM_PULSE, SWARM_RETREAT, SWARM_TOUCH, } from '../lib/swarmEvents' @@ -144,6 +145,7 @@ export function BrainTouchProbe() { f.lastPulse = now f.travel = 0 f.gap = PULSE_GAP + Math.random() * PULSE_JITTER + dispatchEvent(new Event(SWARM_PULSE)) } if (hit) { diff --git a/src/styles/global.css b/src/styles/global.css index 464186b..17e3167 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -4028,3 +4028,183 @@ html.has-cursor :is(input:not([type="range"]):not([type="checkbox"]):not([type=" translate: none; } } + +/* ==== Sound toggle ==== + Always on screen, above the loader too, so sound can be turned on before + the intro lands. Bars move while sound is playing. */ +.sound-toggle { + position: fixed; + right: clamp(16px, 2vw, 26px); + bottom: 22px; + z-index: 120; + display: inline-flex; + align-items: center; + gap: 10px; + padding: 8px 12px 8px 10px; + font: 9.5px ui-monospace, Consolas, monospace; + letter-spacing: 0.24em; + text-transform: uppercase; + color: rgba(235, 235, 255, 0.55); + background: rgba(5, 5, 10, 0.55); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 999px; + backdrop-filter: none; + cursor: pointer; + transition: color 0.25s, border-color 0.25s; +} + +.sound-toggle:hover, +.sound-toggle:focus-visible { + color: #fff; + border-color: color-mix(in srgb, var(--accent) 60%, transparent); + outline: none; +} + +.sound-toggle__bars { + display: inline-flex; + align-items: flex-end; + gap: 2px; + height: 12px; +} + +.sound-toggle__bars i { + width: 2px; + height: 100%; + background: currentColor; + transform-origin: 50% 100%; + scale: 1 0.25; + transition: scale 0.4s var(--ease-soft); +} + +.sound-toggle[data-state="on"] { + color: rgba(245, 245, 255, 0.85); +} + +.sound-toggle[data-state="on"] .sound-toggle__bars i { + background: var(--accent); + animation: sound-bar 1.1s ease-in-out infinite; +} + +.sound-toggle[data-state="on"] .sound-toggle__bars i:nth-child(2) { animation-delay: -0.35s; } +.sound-toggle[data-state="on"] .sound-toggle__bars i:nth-child(3) { animation-delay: -0.7s; } +.sound-toggle[data-state="on"] .sound-toggle__bars i:nth-child(4) { animation-delay: -0.15s; } + +@keyframes sound-bar { + 0%, 100% { scale: 1 0.3; } + 50% { scale: 1 1; } +} + +/* On, but held by the browser until a first gesture: a slow pulse invites it */ +.sound-toggle[data-state="waiting"] { + animation: sound-invite 2.4s ease-in-out infinite; +} + +@keyframes sound-invite { + 0%, 100% { border-color: rgba(255, 255, 255, 0.1); } + 50% { border-color: color-mix(in srgb, var(--accent) 65%, transparent); } +} + +.sound-toggle[data-state="muted"] .sound-toggle__bars i { + scale: 1 0.18; +} + +/* Phones: top right, clear of the brand and the section nav bar */ +@media (max-width: 768px) { + .sound-toggle { + top: calc(env(safe-area-inset-top, 0px) + 14px); + bottom: auto; + right: 14px; + padding: 7px 10px 7px 9px; + } +} + +@media (prefers-reduced-motion: reduce) { + .sound-toggle[data-state="on"] .sound-toggle__bars i, + .sound-toggle[data-state="waiting"] { + animation: none; + } +} + +/* ==== Loader gate ==== + The way in once the surface is mapped. The press doubles as the gesture + browsers need before they allow sound, so the intro plays with it. */ +.loader-gate { + display: grid; + justify-items: center; + gap: 12px; + margin-top: 34px; + opacity: 0; + transform: translate3d(0, 10px, 0); + transition: + opacity 0.7s var(--ease-soft), + transform 0.9s var(--ease-osmo); +} + +.loader-gate[data-shown="true"] { + opacity: 1; + transform: none; +} + +.loader-wake { + padding: 14px 26px; + font: 11px ui-monospace, Consolas, monospace; + letter-spacing: 0.3em; + text-transform: uppercase; + color: #fff; + background: color-mix(in srgb, var(--accent) 16%, rgba(5, 5, 10, 0.6)); + border: 1px solid color-mix(in srgb, var(--accent) 70%, transparent); + border-radius: 999px; + box-shadow: 0 0 32px color-mix(in srgb, var(--accent) 30%, transparent); + cursor: pointer; + animation: loader-wake 2.6s ease-in-out infinite; + transition: background-color 0.3s, box-shadow 0.3s; +} + +.loader-wake:hover, +.loader-wake:focus-visible { + background: color-mix(in srgb, var(--accent) 32%, rgba(5, 5, 10, 0.6)); + box-shadow: 0 0 48px color-mix(in srgb, var(--accent) 50%, transparent); + outline: none; +} + +@keyframes loader-wake { + 0%, 100% { box-shadow: 0 0 24px color-mix(in srgb, var(--accent) 22%, transparent); } + 50% { box-shadow: 0 0 44px color-mix(in srgb, var(--accent) 48%, transparent); } +} + +.loader-note { + font: 9px ui-monospace, Consolas, monospace; + letter-spacing: 0.26em; + text-transform: uppercase; + color: rgba(235, 235, 255, 0.42); +} + +.loader-silent { + padding: 6px 10px; + font: 9px ui-monospace, Consolas, monospace; + letter-spacing: 0.24em; + text-transform: uppercase; + color: rgba(235, 235, 255, 0.35); + background: none; + border: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.12); + cursor: pointer; + transition: color 0.25s, border-color 0.25s; +} + +.loader-silent:hover, +.loader-silent:focus-visible { + color: rgba(245, 245, 255, 0.8); + border-color: rgba(255, 255, 255, 0.4); + outline: none; +} + +@media (prefers-reduced-motion: reduce) { + .loader-wake { animation: none; } +} + +/* The gate carries the sound choice while loading; the toggle waits */ +.app-root[data-load-phase="loading"] .sound-toggle { + opacity: 0; + pointer-events: none; +} diff --git a/src/ui/LoadingScreen.tsx b/src/ui/LoadingScreen.tsx index d9c595a..67731e1 100644 --- a/src/ui/LoadingScreen.tsx +++ b/src/ui/LoadingScreen.tsx @@ -1,7 +1,8 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import gsap from 'gsap' import { useSceneStore } from '../store/sceneStore' import { profile } from '../content/portfolio' +import { audio } from '../audio/engine' export function LoadingScreen() { const loadPhase = useSceneStore((s) => s.loadPhase) @@ -9,6 +10,26 @@ export function LoadingScreen() { const ref = useRef(null) const barRef = useRef(null) const pctRef = useRef(null) + const wakeRef = useRef(null) + const [gate, setGate] = useState(false) + + // Once the surface is mapped, offer the way in instead of starting alone + useEffect(() => { + if (!sceneReady || navigator.webdriver) return + const t = setTimeout(() => setGate(true), 700) + return () => clearTimeout(t) + }, [sceneReady]) + + useEffect(() => { + if (gate) wakeRef.current?.focus({ preventScroll: true }) + }, [gate]) + + /** This click is the gesture that lets sound start, so it plays from here. */ + const enter = (withSound: boolean) => { + audio.setMuted(!withSound) + audio.unlock() + useSceneStore.getState().startIntro() + } useEffect(() => { const bar = barRef.current @@ -47,7 +68,25 @@ export function LoadingScreen() { {profile.hero.beats[0]?.text ?? profile.title}

-

MAPPING SURFACE NODES 0%

+

+ {gate ? 'Surface mapped' : 'Mapping surface nodes'} 0% +

+ +
+ +

Sound on. Best with headphones.

+ +
) diff --git a/src/ui/PortfolioUI.tsx b/src/ui/PortfolioUI.tsx index d18da4b..de63e95 100644 --- a/src/ui/PortfolioUI.tsx +++ b/src/ui/PortfolioUI.tsx @@ -98,7 +98,7 @@ export function PortfolioUI() { )}
- THE ADAPTIVE MIND v1.0
{profile.tagline} + THE ADAPTIVE MIND v3.1
{profile.tagline}
{/* diff --git a/src/ui/ProjectJourney.tsx b/src/ui/ProjectJourney.tsx index f5de222..be626d5 100644 --- a/src/ui/ProjectJourney.tsx +++ b/src/ui/ProjectJourney.tsx @@ -1,6 +1,7 @@ import { Fragment, useEffect, useMemo, useRef, useState } from 'react' import { projectStories } from '../content/projects' import { toWords } from './textReveal' +import { announceReveal } from '../lib/uiEvents' const prefersReduced = () => typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches @@ -239,6 +240,7 @@ export function ProjectJourney() { if (storyIndex !== activeRef.current) { activeRef.current = storyIndex setActive(storyIndex) + announceReveal('stage') } /* diff --git a/src/ui/RequestTrace.tsx b/src/ui/RequestTrace.tsx index 86ef11f..28ed1a5 100644 --- a/src/ui/RequestTrace.tsx +++ b/src/ui/RequestTrace.tsx @@ -5,6 +5,7 @@ import { traceStages, traceOpening, TRACE_COUNT } from '../content/requestTrace' import { SECTION_IDS, sections } from '../data/sections' import { triggerSectorWave } from '../scene/nodeAnimator' import { scrollToBeat } from '../scroll/traceNav' +import { announceReveal } from '../lib/uiEvents' const prefersReduced = () => typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches @@ -61,8 +62,10 @@ export function RequestTrace() { // An invisible link is a tab trap el.toggleAttribute('inert', alpha < 0.5) el.setAttribute('aria-hidden', alpha < 0.5 ? 'true' : 'false') - if (alpha > 0.5) el.classList.add('is-in') - else if (alpha < 0.05) el.classList.remove('is-in') + if (alpha > 0.5 && !el.classList.contains('is-in')) { + el.classList.add('is-in') + announceReveal('stage') + } else if (alpha < 0.05) el.classList.remove('is-in') }) // The ghost arrives from the edge it is placed against, so each stage's diff --git a/src/ui/textReveal.ts b/src/ui/textReveal.ts index 84e95d1..181ae91 100644 --- a/src/ui/textReveal.ts +++ b/src/ui/textReveal.ts @@ -15,6 +15,8 @@ */ /** Everything the one-shot reveal drives. `data-wipe` is the image variant. */ +import { announceReveal } from '../lib/uiEvents' + export const REVEAL_SELECTOR = '[data-reveal],[data-wipe]' /** @@ -103,6 +105,8 @@ export function observeReveal(root: ParentNode, selector = '[data-reveal]'): () for (const entry of entries) { if (!entry.isIntersecting) continue entry.target.classList.add('is-in') + // Section titles are a moment; everything else is a detail arriving + announceReveal(entry.target.matches('.spine-title__mask') ? 'title' : 'item') for (const wipe of entry.target.querySelectorAll(WIPE)) { wipe.classList.add('is-in') } From c19ff43e674c1507749558dc1834ce06d4d150a4 Mon Sep 17 00:00:00 2001 From: DavidK4leido5 Date: Thu, 24 Sep 2026 08:20:12 +0800 Subject: [PATCH 4/4] Update cinematic skill: cursor, interactive particles, sound design, scroll sync, scenario map --- .../cinematic-scroll-portfolio/SKILL.md | 235 ++++++++++------- .../references/01-foundations.md | 24 +- .../references/04-scroll-timeline.md | 101 +++++++- .../references/05-seo-prerender.md | 25 ++ .../references/06-verify-and-perf.md | 42 +++ .../references/07-cursor-and-chrome.md | 242 ++++++++++++++++++ .../references/08-interactive-particles.md | 216 ++++++++++++++++ .../references/09-sound-design.md | 220 ++++++++++++++++ 8 files changed, 994 insertions(+), 111 deletions(-) create mode 100644 .claude/skills/cinematic-scroll-portfolio/references/07-cursor-and-chrome.md create mode 100644 .claude/skills/cinematic-scroll-portfolio/references/08-interactive-particles.md create mode 100644 .claude/skills/cinematic-scroll-portfolio/references/09-sound-design.md diff --git a/.claude/skills/cinematic-scroll-portfolio/SKILL.md b/.claude/skills/cinematic-scroll-portfolio/SKILL.md index def7789..fcfeca3 100644 --- a/.claude/skills/cinematic-scroll-portfolio/SKILL.md +++ b/.claude/skills/cinematic-scroll-portfolio/SKILL.md @@ -1,113 +1,156 @@ --- name: cinematic-scroll-portfolio -description: Build a cinematic, scroll-driven portfolio or landing page where giant typography is the layout, images live inside letterforms, and a pinned scroll timeline tells a story project by project, while staying fast (60fps on phones) and fully indexable by search engines. Use this for any request mentioning a cinematic, editorial, awwwards-style, big-type or "not a template" site, a hero word that zooms open as you scroll, text masked over an image or 3D scene, a project timeline or journey that animates as you scroll, alternating left/right project chapters with a moving graphic in the center, giant section numbers or outlined ghost words, or making such a page SEO-friendly. Also use it to fix an existing scroll page that feels laggy, bouncy, noisy, or leaves pieces of a zoom on screen. +description: Build a cinematic, scroll-driven site (portfolio, product launch, agency, event page) where giant typography is the layout, images live inside letterforms, a hero word zooms open as you scroll, and a pinned timeline tells a story chapter by chapter, while staying fast on phones and fully indexable by search engines. Also covers a custom cursor with hover labels and damage effects, a WebGL particle cloud that reacts to the visitor (light pulses, swarm reactions, particles that attack the cursor), a generative Web Audio soundtrack (intro hit, section drones, scroll-scrubbed effects, attack music, a start gate and a mute toggle), a scroll progress bar, and build-time prerendering. Use it whenever a request mentions a cinematic, editorial, awwwards-style, immersive or "not a template" site, giant or outlined type, text masked over an image or 3D scene, a scroll timeline or journey, alternating left/right chapters with a moving graphic in the middle, a custom or reactive cursor, interactive particles, sound design or background music on a web page, or making such a page SEO friendly. Also use it to fix an existing scroll page that feels laggy, bouncy, noisy, staggers on mobile, leaves pieces of a zoom on screen, or has sound that is too loud or annoying. --- -# Cinematic scroll portfolio - -This skill is a complete recipe. Follow it in order and copy the code in `references/` -nearly as written. Every rule below exists because breaking it produced a visible bug -in a real build. The failure is named next to each rule so you can recognise it. - -Read this file fully first. Then open only the reference file for the step you are on. - -## What you are building - -A single long page on a near-black ground: - -1. **Hero.** One or two giant words (for example FULL over STACK) cut out of a dark - veil laid over a background (a WebGL scene, a video, or an image). Scrolling zooms - the camera *into* one letter until that letter's solid stem fills the screen, and - only then does the veil dissolve. See `references/02-hero-knockout.md`. -2. **Sections.** Each section opens with a small index (`02 / 05`), one display word - fitted to the full column width, a short lede, and a giant outlined section - number bleeding off the right edge. Content inside uses typographic rows, not - cards. See `references/03-section-patterns.md`. -3. **Scroll timeline.** A pinned stage with a line running down the center that draws - itself as you scroll, a glowing comet on its tip, one node per project, a giant - outlined project number behind everything, and copy plus a 3D screenshot deck - that alternate left and right per project. See `references/04-scroll-timeline.md`. -4. **SEO layer.** All copy is in the served HTML through a build-time prerender, - with real headings, JSON-LD, and a share image. See `references/05-seo-prerender.md`. - -Foundations (tokens, fitted type, reveal system) are in `references/01-foundations.md`. -Performance rules and the checks that prove the page works are in -`references/06-verify-and-perf.md`. - -## Build order - -Do these in order. Each step is usable on its own, so stop and verify after each. - -1. Tokens, fonts, ground, grain (`01-foundations.md`, "Tokens"). -2. Reveal system and fitted type (`01-foundations.md`). Nothing else works without these. -3. Section shell with fitted title and ghost numeral (`03-section-patterns.md`, "Section shell"). -4. Section contents: index rows, marquee, numbers, rows, contact, footer (`03`). -5. Scroll timeline (`04-scroll-timeline.md`). Build desktop first, then the mobile override. -6. Hero knockout (`02-hero-knockout.md`). Hardest piece, needs the rest of the page to scroll into. -7. Prerender, head tags, JSON-LD, share image (`05-seo-prerender.md`). -8. Verification scripts (`06-verify-and-perf.md`). Run them, fix what fails, run again. - -## The ten rules that matter most - -1. **Fit display type, do not clamp it.** Measure at a reference size and scale: - `size = ref * targetWidth / measuredWidth`, then step down until it fits. A clamp - overflows long words and leaves gutters beside short ones. -2. **Hide content only under a class the script adds** (`html.reveal-armed`). If the - hidden state is plain CSS, the prerendered page and any page whose script failed - show blank sections. Symptom: sections are empty with JS disabled. -3. **Put the IntersectionObserver on the static clipping parent**, never on an element - that hides itself by translating out of its own clip. Symptom: a title that never appears. -4. **Every per-frame write is a transform or opacity.** Never animate width, height, - top, left, clip-path, filter or box-shadow per frame. Blur only on desktop, never - on phones. -5. **One rAF-throttled scroll handler per effect, writing straight to the DOM.** - React state changes only at discrete moments (a project handover), never per frame. -6. **Cache layout, read it only on resize.** Measure document offsets once, re-measure - from a `ResizeObserver` on `document.body` (debounced ~80ms). Reading - `getBoundingClientRect` inside the scroll handler forces layout every frame. -7. **Scroll-driven motion must never reverse while readable.** Drive entrances from - the distance to a beat's center on the way in, and only fade on the way out. - Ease the entrance (`1 - (1 - t)^3`) so it settles. Symptom: text that "bounces" - or kicks backwards as it passes the center. -8. **No counter-scroll parallax on reading copy.** Holding copy against the page - makes it lag, then catch up. It reads as bouncing. Parallax only decorative giants. -9. **Image-filled text must be softened and outlined.** Downscale-blur the image, - tone it into a fixed brightness band, tint it toward an accent, and add an - `em`-sized hairline stroke. Raw screenshots inside letters look like noise, dark - ones vanish into the ground, and light ones turn flat grey. -10. **A zoom through a word must end inside a solid part of a letter.** Pick a stem, - measure it from pixels, pan it to the frame center while scaling, and stop drawing - glyphs as text past ~6000px (swap in a rectangle). Chrome's SVG mask breaks on - glyphs around 10,000px tall and the veil comes back. Symptom: pieces of letters - left on screen, then a sudden cut, or the frame dimming near the end. +# Cinematic scroll site + +This skill is a complete recipe, written so that a small model can follow it. Read +this file fully first. Then open only the reference files your scenario needs (see +the map below), one at a time, and copy their code nearly as written. Every rule +here exists because breaking it produced a bug a real person saw or heard. The +symptom is named next to each rule so you can recognise it. + +## Reference files + +| File | What it covers | +|---|---| +| `references/01-foundations.md` | Tokens, film grain, fitted type, the reveal system, masked title reveals | +| `references/02-hero-knockout.md` | Words cut out of a veil over a scene; the zoom into a letter's stem | +| `references/03-section-patterns.md` | Section shell, ghost numerals, image-filled type, index rows, marquee, numbers, experience rows, contact, footer, far jumps | +| `references/04-scroll-timeline.md` | The pinned chapter timeline: line, comet, nodes, numeral, 3D decks, and keeping it synced to the scroll | +| `references/05-seo-prerender.md` | Build-time prerender, head tags, JSON-LD, share image, semantics | +| `references/06-verify-and-perf.md` | Playwright checks, frame-time A/B, headless caveats | +| `references/07-cursor-and-chrome.md` | Custom cursor, cursor labels and damage, start gate, scroll progress bar, mute toggle | +| `references/08-interactive-particles.md` | A WebGL point cloud that feels the visitor: light pulses, swarm startle, raids on the cursor | +| `references/09-sound-design.md` | The Web Audio soundtrack: graph, calm drone, intro hit, scroll-scrubbed warp, reveal sounds, attack music, testing | + +## Scenario map: what to read for what you are building + +| Scenario | Read | Skip | +|---|---|---| +| Full cinematic portfolio like the original | All, in the build order below | Nothing | +| Product or launch page (no 3D) | 01, 03, 04 (as a feature timeline), 05, 06, 07 (progress bar, cursor) | 02 needs a background: use a video or image in place of the WebGL scene; skip 08 | +| Agency or studio site | 01, 03 (index rows are the case studies), 04, 05, 06, 07 | 08, 09 unless asked | +| Adding "make it feel alive" to an existing site | 07 (cursor, progress bar), 03 (image-filled type), 09 (UI sounds only) | Rebuilding layout | +| Only the scroll timeline | 01 (reveal + fitted type), 04, 06 | The rest | +| Only sound | 09, 07 (start gate and mute toggle) | The rest | +| Only interactive particles | 08, 06; 09 if the particles should be heard | The rest | +| Fixing a laggy, bouncy or staggering scroll | 04 ("Performance contract", "Comet and lit line", "Common failures"), 06 | New features | +| Fixing SEO on a client-rendered cinematic site | 05, 06 (progressive enhancement check) | Visuals | +| Low-end phones must be smooth | 04 performance contract, 06 performance rules; drop blur and grain on phones; no raids on touch | Heavy effects on mobile | + +## Build order (full build) + +Each step is usable on its own, so verify after each. + +1. Tokens, fonts, ground, grain (01). +2. Reveal system and fitted type (01). Everything else depends on these. +3. Section shell with fitted title and ghost numeral (03). +4. Section contents: index rows, marquee, numbers, rows, contact, footer (03). +5. Scroll timeline, desktop first, then the mobile override, then the scroll sync (04). +6. Hero knockout (02). It needs the rest of the page to scroll into. +7. Prerender, head tags, JSON-LD, share image (05). +8. Cursor, scroll progress bar (07). +9. Interactive particles, if there is a WebGL cloud (08). +10. Sound: start gate, then the engine, then the triggers (07, 09). +11. Verification (06). Run it, fix what fails, run again. + +## The rules that matter most + +Layout and type +1. **Fit display type, do not clamp it.** Measure at a reference size and scale, then + step down by the overshoot and climb back while it fits (01). *Symptom: long words + overflow, short words leave gutters, an email address overhangs on phones.* +2. **Hide content only under a class the script adds** (`html.reveal-armed`). + *Symptom: blank sections with JS off or in the prerendered page.* +3. **Put the IntersectionObserver on the static clipping parent**, never on the + element that slides out of its clip. *Symptom: a title that never appears.* +4. **Image-filled text is softened, toned, tinted and outlined** (03). *Symptom: + letters full of screenshot text read as noise; dark images vanish, light ones go flat grey.* + +Scroll and motion +5. **Per-frame writes are `transform` and `opacity` only.** No width, top, clip-path, + filter or box-shadow per frame. Blur only on desktop. +6. **One rAF-throttled handler per effect, writing straight to the DOM.** Framework + state changes only at discrete moments (a chapter handover). +7. **Cache layout; re-measure only on resize** and on a body `ResizeObserver`. +8. **Anything locked to the scroll is not moved by JS.** Use `position: sticky` and CSS + scroll timelines, with JS only as a fallback (04). *Symptom: the comet or the tip of + a line staggers while scrolling, much worse on phones.* +9. **Scroll motion never reverses while readable, and reading copy gets no parallax.** + Animate in on approach with an ease-out, and only fade on the way out. *Symptom: + text "bounces" as it passes the centre.* +10. **A zoom through a word must end inside a solid stem**, measured from pixels, panned + to centre, with text swapped for a rectangle past ~6000px (02). *Symptom: pieces of + letters left on screen, then a cut, or the frame dimming near the end.* + +Interaction +11. **Movement answers intent; light answers presence.** Hover gets light, clicks get + movement. Pushing particles on hover looked cheap (08). +12. **Custom cursors are fine-pointer only** and never hide the text caret (07). +13. **Test automation skips gates** via `navigator.webdriver` (07). + +Sound +14. **No autoplay. Start from a gate click** (07, 09). +15. **Every gain starts at 0 at time 0.** `GainNode` defaults to 1. *Symptom: a loud + burst right after the start click.* +16. **Big hits fire on the visual state, not on a guessed timeline** (09). *Symptom: the + boom lands late or early.* +17. **The background is calm:** sines, slow swells, dark reverb, no whoosh on every + change. *Symptom: "the background sound is annoying".* +18. **Skip one-shots while suspended or muted**, or they all fire at once on resume. ## Visual system - Ground `#05050a` (sections) and `#020204` (hero). Type `#ece9f2` ("cream"). Muted copy `rgba(235,235,255,.55-.7)`. Hairlines `rgba(255,255,255,.08)`. -- One display face at one heavy weight (Inter Tight 800 works; a condensed face like - Anton works too). Only load weights you use; a missing weight is faked by the browser. +- One display face at one heavy weight (Inter Tight 800, or a condensed face like + Anton). Load only the weights you use. - Supporting labels: monospace, 9.5 to 12px, uppercase, `letter-spacing: .2em-.32em`. - The contrast between 10px labels and 300px words is the design. Do not enlarge labels. + The contrast between 10px labels and 300px words is the design. - Giants: `line-height: .84-.9`, `letter-spacing: -.03em to -.05em`, uppercase. -- One accent per section (or per project) used for the index number, a trailing dot - on the title, hairlines, glows. Never more than one accent on screen at a time. -- Atmosphere: a static radial glow of the accent at the top of each section at 6-11% - mix, plus film grain animated with `steps()`. -- Outlined giants: `color: transparent; -webkit-text-stroke: .005em-.012em `. - Size strokes in `em` so they track the font. +- One accent per section or per chapter, used for index numbers, a trailing dot on the + title, hairlines and glows, and crossfaded through a registered `@property`. +- Atmosphere: a static radial glow of the accent at 6 to 11% on each section (and on + the timeline's sticky stage, so every chapter gets it), plus film grain in `steps()`. +- Outlined giants: `color: transparent; -webkit-text-stroke: .005em-.012em `, + sized in `em`. +- Attack or damage states use a hot red `#ff4d6d` with red and cyan chromatic + offsets, never the page accent. -## Content rules +## Content and copy rules - Headings are statements or single words, never "Our Services". -- Every heading, lede, row and chapter is real text in the DOM. Decorative giants - (ghost numbers, marquee words, the timeline numeral) get `aria-hidden`. -- Say the same thing once. Duplicated copy (an `sr-only` copy next to split words, a - marquee repeating names) goes `aria-hidden` or is removed. +- Every heading, lede, row and chapter is real text in the served HTML. Decorative + giants, marquees and cursor text are `aria-hidden`. +- Say each thing once: no `sr-only` duplicate beside split words. +- Interactive copy is short and in the site's voice: a dare ("Don't touch. The swarm + is alive."), an answer ("It felt that."), a shout ("Ouch!", "Run."). Straight + apostrophes, no em dashes. +- Buttons say what happens in context ("Wake the swarm", "Enter without sound"), not + "Start" or "OK". + +## Working with the person + +- Visual and audio taste is theirs. Show screenshots, describe sounds with numbers you + can change, and ask what is off. Do not report "looks great" or "sounds right" from + your own read, and never claim to have heard audio. +- When they say something "looks stupid" or is "annoying", remove or replace it; do + not tune it and argue for it. +- When they say "it's too loud" or "not timed right" and a first fix does not land, + look for a bug (a default value, a wrong trigger) before adjusting levels again. +- If they stop a dev server, do not restart it; test the production preview instead. ## Stop conditions -Do not report the page as done until the checks in `06-verify-and-perf.md` pass: -no horizontal overflow at 390px and 1440px, comet within 2px of the viewport center, -veil coverage at 0% before the veil starts fading at 390px, 1440px and 2560px, -prerendered HTML containing every heading, and no console errors. +Do not report the page as done until these pass (06): +- no horizontal overflow at 390px and 1440px +- every fitted title fills its column without overhanging +- sections are readable with JS off, and every heading is in the prerendered HTML +- the timeline comet is within 2px of the viewport centre and the lit edge within 1px + of it, with scroll timelines and with the JS fallback +- the hero veil covers 0% of the frame before it starts fading at 390, 1440 and 2560px +- if there is sound: nothing plays before the gate, the context runs right after it, + the intro hit fires on the visual cue, mute survives a reload +- no console errors diff --git a/.claude/skills/cinematic-scroll-portfolio/references/01-foundations.md b/.claude/skills/cinematic-scroll-portfolio/references/01-foundations.md index 1017b03..b235e8a 100644 --- a/.claude/skills/cinematic-scroll-portfolio/references/01-foundations.md +++ b/.claude/skills/cinematic-scroll-portfolio/references/01-foundations.md @@ -101,7 +101,9 @@ because rendered width snaps to the pixel grid and a correction loop oscillates. ```ts const REF_PX = 200 const STEP = 0.004 -const MAX_STEPS = 4 +// Twelve, not four: at phone sizes the browser rounds every glyph advance, and a +// long line (an email address, 30 characters) can land 5px over at the solved size +const MAX_STEPS = 12 export function fitToWidth(el: HTMLElement, targetPx: number, trackingEm: number) { if (targetPx <= 0) return @@ -114,11 +116,22 @@ export function fitToWidth(el: HTMLElement, targetPx: number, trackingEm: number if (measured < 1) return let size = (REF_PX * targetPx) / measured setSize(size) - // Overflow is the failure that shows; undershooting 0.4% is invisible + // Overflow is the failure that shows; undershooting a fraction is invisible. + // Back off by the actual overshoot, never by less than one step. + let shrank = false for (let step = 0; step < MAX_STEPS; step++) { - if (el.getBoundingClientRect().width <= targetPx) break - size *= 1 - STEP + const width = el.getBoundingClientRect().width + if (width <= targetPx) break + size *= Math.min(1 - STEP, targetPx / width) setSize(size) + shrank = true + } + // The back-off can land a whole rounding step low; climb back while it still fits + for (let step = 0; shrank && step < MAX_STEPS; step++) { + const up = size / (1 - STEP) + setSize(up) + if (el.getBoundingClientRect().width > targetPx) { setSize(size); break } + size = up } } @@ -164,6 +177,9 @@ Markup and CSS for a fitted line: ``` Rules: +- Some sizes cannot be hit exactly: at 20.4px vs 20.5px a long line's width can jump + 5% because of glyph rounding. The loop above never overflows and gets as close as + the renderer allows. Accept a line that sits a few percent short on one phone width. - The fitted element must be one unbroken text run with `white-space: nowrap`. An inline child span (a coloured trailing dot) is fine. - Run the fit after the reveal system arms (see below). If a hidden state adds a diff --git a/.claude/skills/cinematic-scroll-portfolio/references/04-scroll-timeline.md b/.claude/skills/cinematic-scroll-portfolio/references/04-scroll-timeline.md index 43616fb..20d09fc 100644 --- a/.claude/skills/cinematic-scroll-portfolio/references/04-scroll-timeline.md +++ b/.claude/skills/cinematic-scroll-portfolio/references/04-scroll-timeline.md @@ -18,9 +18,16 @@ lane, puts the line in the left gutter, and pins the deck in a 44dvh band at the - One rAF-throttled scroll listener. It returns immediately while an IntersectionObserver says the timeline is off screen. -- Per frame it writes only: `transform` on the comet and on two line wrappers, - `opacity` and `transform` on rows and words of beats within 1.4 beats of the - center, and `transform`/`opacity` on the current project's cards. +- Per frame it writes only `opacity` and `transform` on rows and words of beats + within 1.4 beats of the center, and `transform`/`opacity` on the current + project's cards. +- **The comet and the lit line are never written per frame** where the browser has + scroll timelines. The comet's height is a sticky pin, and the lit line and the + comet's swing are CSS animations bound to the scroll (see "Comet and lit line" + below). A scroll handler always runs a frame behind a natively scrolling page, so + anything that must stay locked to the scroll (a comet on the viewport centre, the + tip of a line) visibly staggers if JS moves it. This is worst on phones, where + scrolling runs on another thread and scroll events arrive less often. - Layout is read in `measure()` only, on resize and on body `ResizeObserver`. - The line scrolls with the page natively (it spans the full timeline height), so moving it costs nothing. Only its lit half is written per frame. @@ -57,7 +64,11 @@ split into spans for the per-word resolve, **without** a duplicate `sr-only` cop Aug 2026 - + +
+
+
@@ -123,9 +134,11 @@ Card image `src` is set by the engine only for the active story and its neighbou .tl-line path { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } .tl-line__base path { stroke: rgba(255,255,255,.12); stroke-width: 1; stroke-dasharray: 2 7; } .tl-line path.tl-line__glow { stroke-width: 12; opacity: .16; } /* glow without a filter */ -.tl-line__clip, .tl-line__clip-inner { position: absolute; inset: 0; will-change: transform; } +.tl-line__clip, .tl-line__clip-inner { position: absolute; inset: 0; will-change: transform, translate; } .tl-line__clip { overflow: hidden; } -.tl-line__comet { position: absolute; top: 0; left: 0; width: 14px; height: 14px; margin: -7px 0 0 -7px; +.tl-line__track { position: absolute; left: 0; right: 0; top: var(--tl-y0, 0px); height: var(--tl-span, 0px); } +.tl-line__pin { position: sticky; top: 50vh; top: 50dvh; height: 0; } +.tl-line__comet { position: absolute; top: 0; left: 50%; width: 14px; height: 14px; margin: -7px 0 0 -7px; border-radius: 50%; background: #fff; will-change: transform; box-shadow: 0 0 0 4px color-mix(in srgb, currentColor 28%, transparent), 0 0 28px 8px color-mix(in srgb, currentColor 55%, transparent); } @@ -249,6 +262,8 @@ export function createTimeline(root: HTMLElement, stories: { accent: string; tit const caption = root.querySelector('.tl-caption__name')! const cards = [...root.querySelectorAll('[data-shot]')] const total = stories.length * BEATS + // Where scroll timelines exist, CSS drives the lit line and the swing (below) + const cssDriven = typeof CSS !== 'undefined' && CSS.supports('animation-timeline: view()') const lit = nodes.map(() => false) let vh = innerHeight, first = 0, pitch = vh, top = 0, lw = 0, lh = 0, swing = 0 @@ -286,6 +301,10 @@ export function createTimeline(root: HTMLElement, stories: { accent: string; tit for (const s of svgs) s.setAttribute('viewBox', `0 0 ${lw} ${lh}`) for (const p of paths) p.setAttribute('d', d) nodes.forEach((n, i) => { n.style.transform = `translate3d(0, ${(y0 + i * BEATS * pitch).toFixed(1)}px, 0)` }) + // The comet's track and swing, for the sticky pin and the CSS swing keyframes + root.style.setProperty('--tl-y0', `${y0.toFixed(1)}px`) + root.style.setProperty('--tl-span', `${(y1 - y0).toFixed(1)}px`) + root.style.setProperty('--tl-swing', `${swing.toFixed(1)}px`) } function handover(si: number) { @@ -306,12 +325,16 @@ export function createTimeline(root: HTMLElement, stories: { accent: string; tit const si = Math.min(stories.length - 1, Math.floor(Math.round(pos) / BEATS)) if (si !== active) handover(si) - // Lit line: wrapper up, inner back down, so the lit copy ends exactly at the tip. - // A dash offset drifts off the comet on a curve; a clip-path repaints every frame. + // Fallback only (no scroll timelines, e.g. Firefox). Lit line: wrapper up, inner + // back down, so the lit copy ends exactly at the tip. A dash offset drifts off the + // comet on a curve; a clip-path repaints every frame. The comet's height is the + // sticky pin in every browser; here JS only adds its sideways swing. const tip = first - top + (reduced ? total - 1 : pos) * pitch - clip.style.transform = `translate3d(0, ${(tip - lh).toFixed(1)}px, 0)` - clipInner.style.transform = `translate3d(0, ${(lh - tip).toFixed(1)}px, 0)` - comet.style.transform = `translate3d(${xAt(tip).toFixed(1)}px, ${tip.toFixed(1)}px, 0)` + if (!cssDriven) { + clip.style.transform = `translate3d(0, ${(tip - lh).toFixed(1)}px, 0)` + clipInner.style.transform = `translate3d(0, ${(lh - tip).toFixed(1)}px, 0)` + if (swing) comet.style.transform = `translate3d(${(xAt(tip) - lw / 2).toFixed(1)}px, 0, 0)` + } nodes.forEach((n, i) => { const on = reduced || pos >= i * BEATS - 0.02 if (on !== lit[i]) { lit[i] = on; n.classList.toggle('is-lit', on) } @@ -406,6 +429,60 @@ export function createTimeline(root: HTMLElement, stories: { accent: string; tit } ``` +## Comet and lit line on the scroll itself + +Add this CSS. It binds the lit line to the timeline's own scroll range and the +comet's swing to the track's, so both run on the compositor in lockstep with the +scroll. Derivation for the lit line: across the root's `cover` range, the viewport +centre in the line's own coordinates runs from `-50vh` to `H + 50vh`, where H is +the line's height (`100%` in `translate` terms), and the wrapper sits at +`centre - H`. + +```css +@supports (animation-timeline: view()) { + .tl { view-timeline-name: --tl; } + .tl-line__clip { animation: tl-lit linear both; animation-timeline: --tl; } + .tl-line__clip-inner { animation: tl-lit-inner linear both; animation-timeline: --tl; } + .tl-line__track { view-timeline-name: --tl-track; } + /* 0 when the track's top reaches the centre, 1 when its bottom does */ + .tl-line__comet { + animation: tl-swing linear both; + animation-timeline: --tl-track; + animation-range: cover 50vh cover calc(100% - 50vh); + animation-range: cover 50dvh cover calc(100% - 50dvh); + } +} +@keyframes tl-lit { from { translate: 0 calc(-100% - 50dvh); } to { translate: 0 50dvh; } } +@keyframes tl-lit-inner { from { translate: 0 calc(100% + 50dvh); } to { translate: 0 -50dvh; } } +@media (prefers-reduced-motion: reduce) { + .tl-line__clip, .tl-line__clip-inner, .tl-line__comet { animation: none; translate: none; } +} +``` + +The swing keyframes follow the path's own sine, one extreme per beat, sampled every +quarter period. Easing out into each extreme and in to each crossing is close +enough to a sine that the comet stays on the line. Generate them once from the beat +count and render them in a `