feat: relay station masts come online through the toolkit chapter
This commit is contained in:
@@ -20,6 +20,7 @@ import './styles/headlight.css';
|
||||
import './styles/route-log.css';
|
||||
import './styles/billboard.css';
|
||||
import './styles/dashcam.css';
|
||||
import './styles/relay.css';
|
||||
|
||||
defineProps<{ inputs: Readonly<ProductionInputs> }>();
|
||||
</script>
|
||||
|
||||
45
src/audio/relay-tone.ts
Normal file
45
src/audio/relay-tone.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { readSwitchboard } from '@/audio/music-pulse';
|
||||
|
||||
/** Tone length, seconds. */
|
||||
export const RELAY_TONE_SECONDS = 0.12;
|
||||
export const RELAY_TONE_GAIN = 0.12;
|
||||
/** At most one tone every 0.6 s, however fast the scroll. */
|
||||
export const RELAY_TONE_GAP_SECONDS = 0.6;
|
||||
|
||||
let lastStart = Number.NEGATIVE_INFINITY;
|
||||
|
||||
/**
|
||||
* RELAY STATION: a mast coming online chirps once, a 120 ms sine rising
|
||||
* 880 -> 1320 Hz, through the switchboard (mix bus) so mute and volume
|
||||
* apply. Lazy-loaded by the scene; plays only while the soundtrack is
|
||||
* running and audible; never creates an AudioContext and never throws.
|
||||
*/
|
||||
export function playRelayTone(): boolean {
|
||||
try {
|
||||
const input = readSwitchboard();
|
||||
if (input === null || input.context.state !== 'running') return false;
|
||||
const context = input.context;
|
||||
const start = context.currentTime + 0.005;
|
||||
if (start - lastStart < RELAY_TONE_GAP_SECONDS) return false;
|
||||
lastStart = start;
|
||||
const osc = context.createOscillator();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(880, start);
|
||||
osc.frequency.exponentialRampToValueAtTime(1320, start + RELAY_TONE_SECONDS);
|
||||
const envelope = context.createGain();
|
||||
envelope.gain.setValueAtTime(0, start);
|
||||
envelope.gain.linearRampToValueAtTime(RELAY_TONE_GAIN, start + 0.01);
|
||||
envelope.gain.exponentialRampToValueAtTime(0.0002, start + RELAY_TONE_SECONDS);
|
||||
osc.connect(envelope);
|
||||
envelope.connect(input);
|
||||
osc.onended = () => {
|
||||
osc.disconnect();
|
||||
envelope.disconnect();
|
||||
};
|
||||
osc.start(start);
|
||||
osc.stop(start + RELAY_TONE_SECONDS + 0.01);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { useRouteMemory } from '@/composables/useRouteMemory';
|
||||
import { useRouteLog } from '@/composables/useRouteLog';
|
||||
import { useBillboard } from '@/composables/useBillboard';
|
||||
import { useDashcam } from '@/composables/useDashcam';
|
||||
import { useRelay } from '@/composables/useRelay';
|
||||
import { assetUrl } from '@/content/asset-url';
|
||||
import { signRouteFor } from '@/journey/sign-routes';
|
||||
import { END_OF_SHIFT_SUBLINE } from '@/journey/dawn';
|
||||
@@ -93,6 +94,7 @@ const maintenanceSubline = useMaintenanceWindow(journey);
|
||||
const routeLogSubline = useRouteLog(journey, () => props.content);
|
||||
/* DRIVE-IN (Selected builds): the floodlit billboard follows the case studies. */
|
||||
useBillboard(journey, () => props.content.projects.selected, props.manifest, exitFor('selected-builds'));
|
||||
useRelay(journey, () => props.content.toolkit);
|
||||
useCardEntrances();
|
||||
const route = useRouteMemory(canonicalSectionIds, () => journey.value.activeSectionId);
|
||||
const resume = computed(() => {
|
||||
|
||||
35
src/composables/useRelay.ts
Normal file
35
src/composables/useRelay.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { computed, onMounted, onUnmounted, shallowRef, watch, type Ref } from 'vue';
|
||||
import type { ToolkitGroup } from '@/content/types';
|
||||
import type { JourneySnapshot } from '@/journey/types';
|
||||
import { sectionLocalProgress } from '@/journey/route-log';
|
||||
import { publishRelay, relayOnline } from '@/journey/relay-progress';
|
||||
|
||||
/**
|
||||
* RELAY STATION, DOM side: html[data-relay-online] ("1".."4") through
|
||||
* Toolkit and (fine pointer) html[data-relay-focus] ("group:item") while a
|
||||
* technology is hovered. After mount only, so the server HTML is unchanged;
|
||||
* works without the scene (CSS keys the ONLINE plates off the attribute).
|
||||
*/
|
||||
export function useRelay(journey: Readonly<Ref<JourneySnapshot>>, groups: () => readonly ToolkitGroup[]): void {
|
||||
const mounted = shallowRef(false);
|
||||
const html = (): DOMStringMap => document.documentElement.dataset;
|
||||
const online = computed(() => (mounted.value ? relayOnline(sectionLocalProgress(journey.value, 'toolkit')) : 0));
|
||||
watch(online, (n) => { if (n === 0) delete html().relayOnline; else html().relayOnline = String(n); });
|
||||
let idle = 0;
|
||||
const hover = (event: Event): void => {
|
||||
const item = (event.target as Element).closest?.('.toolkit-item');
|
||||
const group = item?.closest('.toolkit-group');
|
||||
if (!item || !group?.parentElement) return;
|
||||
clearTimeout(idle);
|
||||
if (event.type === 'pointerover') html().relayFocus = `${[...group.parentElement.children].indexOf(group)}:${[...item.parentElement!.children].indexOf(item)}`;
|
||||
else idle = window.setTimeout(() => delete html().relayFocus, 1200);
|
||||
};
|
||||
const listen = (on: boolean): void => ['pointerover', 'pointerout'].forEach((type) =>
|
||||
document.getElementById('toolkit')?.[on ? 'addEventListener' : 'removeEventListener'](type, hover));
|
||||
onMounted(() => {
|
||||
publishRelay(groups().map((group) => group.technologies.length));
|
||||
mounted.value = true;
|
||||
if (window.matchMedia?.('(hover: hover) and (pointer: fine)').matches) listen(true);
|
||||
});
|
||||
onUnmounted(() => { mounted.value = false; listen(false); clearTimeout(idle); delete html().relayOnline; delete html().relayFocus; });
|
||||
}
|
||||
35
src/journey/relay-progress.ts
Normal file
35
src/journey/relay-progress.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* RELAY STATION: the Toolkit chapter is a pre-dawn relay station. One mast
|
||||
* per toolkit group comes online in turn as the chapter scrolls, its lamp
|
||||
* rungs igniting bottom-up, one per technology. Shared by the DOM
|
||||
* (html[data-relay-online]) and the lazy scene chunk, so it stays tiny and
|
||||
* pure. Scroll-driven, so scrolling back takes the masts offline again.
|
||||
*/
|
||||
|
||||
/** Chapter-local progress at which each mast comes online. */
|
||||
export const RELAY_STOPS = [0.04, 0.22, 0.4, 0.58] as const;
|
||||
/** A mast's rungs light bottom-up over this much chapter progress after its stop. */
|
||||
export const RELAY_RAMP = 0.12;
|
||||
/** Beacon blink rate (photosensitivity: well under 3 Hz). */
|
||||
export const BEACON_HZ = 0.5;
|
||||
|
||||
/** How many masts are online at chapter progress p (0 outside the chapter). */
|
||||
export const relayOnline = (p: number | null): number =>
|
||||
p === null ? 0 : RELAY_STOPS.filter((stop) => p >= stop).length;
|
||||
|
||||
/** 0..1: rung `rung` of `count` on mast `mast` at progress p. Each rung takes its own slice of the ramp. */
|
||||
export function rungLit(p: number | null, mast: number, rung: number, count: number): number {
|
||||
const stop = RELAY_STOPS[mast];
|
||||
if (p === null || stop === undefined || count <= 0) return 0;
|
||||
const t = Math.min(1, Math.max(0, (p - stop - (RELAY_RAMP * rung) / count) / (RELAY_RAMP / count)));
|
||||
return t * t * (3 - 2 * t);
|
||||
}
|
||||
|
||||
/** The DOM publishes rung counts (technologies per group) here; the scene reads them each frame. */
|
||||
let counts: readonly number[] = [];
|
||||
|
||||
export function publishRelay(next: readonly number[]): void {
|
||||
counts = next.slice(0, RELAY_STOPS.length);
|
||||
}
|
||||
|
||||
export const readRelay = (): { counts: readonly number[] } => ({ counts });
|
||||
@@ -94,3 +94,9 @@ export const CHAPTER_COMPOSITIONS: Readonly<
|
||||
* out when the board goes dark, so it is fully reversible.
|
||||
*/
|
||||
export const BILLBOARD_NUDGE = { targetZ: -6, shift: 0.03, seconds: 0.9 } as const;
|
||||
|
||||
/**
|
||||
* RELAY STATION: while a mast is online the lens lifts toward the mast tops
|
||||
* and the sky beams. Eased in over 0.9 s, back out when the station goes dark.
|
||||
*/
|
||||
export const RELAY_NUDGE = { targetY: 3, seconds: 0.9 } as const;
|
||||
|
||||
@@ -38,7 +38,8 @@ import { createHeadlights } from '@/scene/world/headlights';
|
||||
import { maintenanceProgress } from '@/journey/maintenance-progress';
|
||||
import { createMilestones } from '@/scene/world/milestones';
|
||||
import { billboardNear, createBillboard, textureSize } from '@/scene/world/billboard';
|
||||
import { BILLBOARD_NUDGE } from '@/scene/world/composition';
|
||||
import { BILLBOARD_NUDGE, RELAY_NUDGE } from '@/scene/world/composition';
|
||||
import { createRelayMasts } from '@/scene/world/relay-masts';
|
||||
import { isMilestoneSection, sectionLocalProgress } from '@/journey/route-log';
|
||||
import { dawnStep, type DawnStep } from '@/journey/dawn';
|
||||
import { dawnLightUniforms } from '@/scene/world/dawn-light';
|
||||
@@ -185,7 +186,15 @@ export async function createProceduralSceneGeneration(
|
||||
textureSize: textureSize(context.quality),
|
||||
});
|
||||
let billboardKey = '';
|
||||
// RELAY STATION: transmitter masts come online through Toolkit. Low tier,
|
||||
// touch and data saver: all rungs lit, steady beacons, no beams, hover or tone.
|
||||
const relay = createRelayMasts(
|
||||
context.resources,
|
||||
maintenanceTier === 'full'
|
||||
&& (globalThis.navigator as { connection?: { saveData?: boolean } } | undefined)?.connection?.saveData !== true,
|
||||
);
|
||||
scene.add(
|
||||
relay.group,
|
||||
billboard.mesh,
|
||||
maintenance.group,
|
||||
milestones.mesh,
|
||||
@@ -380,6 +389,15 @@ export async function createProceduralSceneGeneration(
|
||||
musicPulse,
|
||||
deltaSeconds,
|
||||
);
|
||||
const relayNudge = relay.update(
|
||||
snapshot.activeSectionId,
|
||||
snapshot.activeSectionId === 'toolkit' ? sectionLocalProgress(snapshot, 'toolkit') : null,
|
||||
musicPulse,
|
||||
document.documentElement.dataset.relayFocus,
|
||||
deltaSeconds,
|
||||
now,
|
||||
camera.aspect,
|
||||
);
|
||||
const billboardNudge = billboard.update({
|
||||
section: snapshot.activeSectionId,
|
||||
p: snapshot.activeSectionId === 'selected-builds'
|
||||
@@ -533,7 +551,7 @@ export async function createProceduralSceneGeneration(
|
||||
baseComposition.targetY,
|
||||
upcoming.targetY,
|
||||
anticipation,
|
||||
),
|
||||
) + RELAY_NUDGE.targetY * relayNudge,
|
||||
targetZ: THREE.MathUtils.lerp(
|
||||
baseComposition.targetZ,
|
||||
upcoming.targetZ,
|
||||
|
||||
469
src/scene/world/relay-masts.ts
Normal file
469
src/scene/world/relay-masts.ts
Normal file
@@ -0,0 +1,469 @@
|
||||
import * as THREE from 'three';
|
||||
import type { SectionId } from '@/content/types';
|
||||
import { SIGN_ACCENTS } from '@/journey/sign-routes';
|
||||
import { BEACON_HZ, readRelay, RELAY_STOPS, rungLit } from '@/journey/relay-progress';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
import { CHAPTER_COMPOSITIONS, RELAY_NUDGE } from './composition';
|
||||
|
||||
type ThreeVector3 = InstanceType<typeof THREE.Vector3>;
|
||||
type ThreeBufferGeometry = InstanceType<typeof THREE.BufferGeometry>;
|
||||
type ThreeColor = InstanceType<typeof THREE.Color>;
|
||||
type ThreeShaderMaterial = InstanceType<typeof THREE.ShaderMaterial>;
|
||||
type ThreeGroup = InstanceType<typeof THREE.Group>;
|
||||
type ThreeMaterial = InstanceType<typeof THREE.Material>;
|
||||
type ThreeInstancedBufferAttribute = InstanceType<typeof THREE.InstancedBufferAttribute>;
|
||||
type ThreeInstancedMesh = InstanceType<typeof THREE.InstancedMesh>;
|
||||
|
||||
/**
|
||||
* RELAY STATION, scene side. Four lattice transmitter masts, one per toolkit
|
||||
* group, stand on the far shoulders, alternating sides (first on the right).
|
||||
* As the chapter scrolls each comes online in turn: its lamp rungs ignite
|
||||
* bottom-up (one per technology), a red beacon blinks at 0.5 Hz and a thin
|
||||
* additive beam sweeps the sky every 6 s. Every beat is a pure function of
|
||||
* section-local progress, so scrolling back takes the masts offline again.
|
||||
* One InstancedMesh per part (frames, rungs, beacons, beams). Hovering a
|
||||
* technology flashes its rung. Lite (low tier, touch, data saver): all rungs
|
||||
* lit from chapter entry, steady beacons, no beams, hover or tone.
|
||||
*/
|
||||
/** Lattice mast at scale 1 (the world is ~2.3x life size). */
|
||||
export const MAST_HEIGHT = 44;
|
||||
const BASE_HALF = 2.4;
|
||||
const TOP_HALF = 0.7;
|
||||
const ANTENNA = 6;
|
||||
const LEG = 0.36;
|
||||
const BRACE = 0.16;
|
||||
const SECTIONS = 9;
|
||||
/** Rungs fill this band of the mast's height, bottom-up. */
|
||||
const RUNG_FROM = 0.08;
|
||||
const RUNG_SPAN = 0.8;
|
||||
const MAX_RUNGS = 64;
|
||||
const FLASH_SECONDS = 0.45;
|
||||
/** A flashing rung swells (width, height factors): the lit lamps are already hot, so colour alone would not read. */
|
||||
const FLASH_SWELL = { width: 0.5, height: 2.2 } as const;
|
||||
/** One hover flash per rung at most every 0.35 s (photosensitivity). */
|
||||
export const RUNG_FLASH_GAP_SECONDS = 0.35;
|
||||
const BEAM_LENGTH = 170;
|
||||
const BEAM_WIDTH = 7;
|
||||
/** Beam brightness cap (additive, so bloom does not blow out). */
|
||||
export const BEAM_INTENSITY = 0.65;
|
||||
const SWEEP_SECONDS = 6;
|
||||
const PULSE_GAIN = 0.08;
|
||||
|
||||
/**
|
||||
* Where the masts stand, placed by where they land in the frame (toolkit
|
||||
* lens: camera y 8.6, z 25, fov 62). `fx` is the fraction of the half-width
|
||||
* from the centre: the 84%-wide reading panel covers up to 0.84, so every
|
||||
* mast sits in the side margin. The near mast of each pair stands at the
|
||||
* frame edge, the far one just inside it, closer to the centre (perspective:
|
||||
* a row receding toward the vanishing point), so the pair reads as two
|
||||
* towers along the shoulder and never overlaps. `fy` is where the beacon
|
||||
* lands as a fraction of the half-height above the centre: in the upper
|
||||
* third, clear of the nav bar. x and the scale follow from those and the
|
||||
* viewport aspect, so the masts keep their place on narrow lenses too.
|
||||
* Portrait (phones): the margins are slivers, so the masts tuck under the
|
||||
* panel edge instead of leaving the frame.
|
||||
*/
|
||||
const LENS = CHAPTER_COMPOSITIONS.toolkit;
|
||||
export const DESIGN_ASPECT = 1.6;
|
||||
const TAN_HALF_HEIGHT = Math.tan(THREE.MathUtils.degToRad(LENS.fov / 2));
|
||||
const SLOT_FRAMES = [
|
||||
{ z: -55, fx: 0.94, fy: 0.72 },
|
||||
{ z: -55, fx: 0.94, fy: 0.72 },
|
||||
{ z: -130, fx: 0.875, fy: 0.56 },
|
||||
{ z: -130, fx: 0.875, fy: 0.56 },
|
||||
] as const;
|
||||
|
||||
interface RelaySlot {
|
||||
z: number;
|
||||
side: number;
|
||||
x: number;
|
||||
scale: number;
|
||||
}
|
||||
|
||||
let slotAspect = Number.NaN;
|
||||
let slots: RelaySlot[] = [];
|
||||
|
||||
/** The four stands for a viewport aspect (memoised: the aspect changes only on resize). */
|
||||
export function relaySlots(aspect: number = DESIGN_ASPECT): RelaySlot[] {
|
||||
if (aspect === slotAspect) return slots;
|
||||
slotAspect = aspect;
|
||||
const portrait = aspect < 1.18;
|
||||
slots = SLOT_FRAMES.map((slot, i) => {
|
||||
const side = i % 2 === 0 ? 1 : -1;
|
||||
const distance = LENS.z - slot.z;
|
||||
const fx = portrait ? slot.fx * 0.9 : slot.fx;
|
||||
return {
|
||||
z: slot.z,
|
||||
side,
|
||||
x: side * fx * TAN_HALF_HEIGHT * aspect * distance,
|
||||
scale: (LENS.y + slot.fy * TAN_HALF_HEIGHT * distance) / (MAST_HEIGHT + ANTENNA),
|
||||
};
|
||||
});
|
||||
return slots;
|
||||
}
|
||||
|
||||
const clamp01 = (value: number): number => Math.min(1, Math.max(0, value));
|
||||
const smooth = (from: number, to: number, value: number): number => {
|
||||
const t = clamp01((value - from) / (to - from));
|
||||
return t * t * (3 - 2 * t);
|
||||
};
|
||||
const halfWidthAt = (y: number): number => THREE.MathUtils.lerp(BASE_HALF, TOP_HALF, y / MAST_HEIGHT);
|
||||
|
||||
/** The chapter fades the station in and out at its edges. */
|
||||
export const relayPresence = (p: number): number => smooth(0, 0.05, p) * (1 - smooth(0.95, 1, p));
|
||||
|
||||
export interface RelayRung {
|
||||
/** World height of the rung's centre. */
|
||||
y: number;
|
||||
halfWidth: number;
|
||||
lit: number;
|
||||
}
|
||||
|
||||
export interface RelayMast {
|
||||
x: number;
|
||||
z: number;
|
||||
scale: number;
|
||||
side: number;
|
||||
height: number;
|
||||
presence: number;
|
||||
/** 0..1: the mast is online (beacon and beam). */
|
||||
level: number;
|
||||
rungs: RelayRung[];
|
||||
}
|
||||
|
||||
/** Every mast's frame for chapter progress p (null outside Toolkit: all absent). */
|
||||
export function relayLayout(
|
||||
p: number | null,
|
||||
counts: readonly number[] = readRelay().counts,
|
||||
full = true,
|
||||
aspect: number = DESIGN_ASPECT,
|
||||
): RelayMast[] {
|
||||
return relaySlots(aspect).slice(0, counts.length).map((slot, mast) => {
|
||||
const count = counts[mast] ?? 0;
|
||||
const presence = p === null ? 0 : relayPresence(p);
|
||||
const live = presence > 0;
|
||||
return {
|
||||
x: slot.x,
|
||||
z: slot.z,
|
||||
scale: slot.scale,
|
||||
side: slot.side,
|
||||
height: (MAST_HEIGHT + ANTENNA) * slot.scale,
|
||||
presence,
|
||||
level: !live ? 0 : full ? smooth(RELAY_STOPS[mast]!, RELAY_STOPS[mast]! + 0.03, p!) : 1,
|
||||
rungs: Array.from({ length: count }, (_, rung) => {
|
||||
const y = MAST_HEIGHT * (RUNG_FROM + (RUNG_SPAN * (rung + 0.5)) / count);
|
||||
return { y: y * slot.scale, halfWidth: halfWidthAt(y) * slot.scale, lit: !live ? 0 : full ? rungLit(p, mast, rung, count) : 1 };
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** One strut from a to b, square section t. */
|
||||
function strut(a: ThreeVector3, b: ThreeVector3, t: number): ThreeBufferGeometry {
|
||||
const dir = b.clone().sub(a);
|
||||
const geometry = new THREE.BoxGeometry(t, dir.length(), t).translate(0, dir.length() / 2, 0);
|
||||
geometry.applyQuaternion(new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.normalize()));
|
||||
return geometry.translate(a.x, a.y, a.z);
|
||||
}
|
||||
|
||||
function merge(pieces: ThreeBufferGeometry[]): ThreeBufferGeometry {
|
||||
const positions: number[] = [];
|
||||
const normals: number[] = [];
|
||||
const indices: number[] = [];
|
||||
for (const piece of pieces) {
|
||||
const offset = positions.length / 3;
|
||||
positions.push(...piece.getAttribute('position').array);
|
||||
normals.push(...piece.getAttribute('normal').array);
|
||||
indices.push(...Array.from(piece.getIndex()!.array as ArrayLike<number>, (i) => i + offset));
|
||||
piece.dispose();
|
||||
}
|
||||
const merged = new THREE.BufferGeometry();
|
||||
merged.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
||||
merged.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
|
||||
merged.setIndex(indices);
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Four tapered legs, X braces and rings on every face, and an antenna. */
|
||||
function latticeGeometry(): ThreeBufferGeometry {
|
||||
const corner = (y: number, sx: number, sz: number) => new THREE.Vector3(sx * halfWidthAt(y), y, sz * halfWidthAt(y));
|
||||
const corners = [[1, 1], [-1, 1], [-1, -1], [1, -1]] as const;
|
||||
const pieces: ThreeBufferGeometry[] = [];
|
||||
corners.forEach(([sx, sz]) => pieces.push(strut(corner(0, sx, sz), corner(MAST_HEIGHT, sx, sz), LEG)));
|
||||
for (let k = 0; k <= SECTIONS; k += 1) {
|
||||
const y0 = (MAST_HEIGHT * k) / SECTIONS;
|
||||
const y1 = (MAST_HEIGHT * (k + 1)) / SECTIONS;
|
||||
corners.forEach(([sx, sz], c) => {
|
||||
const [nx, nz] = corners[(c + 1) % 4]!;
|
||||
pieces.push(strut(corner(y0, sx, sz), corner(y0, nx, nz), BRACE));
|
||||
if (k < SECTIONS) {
|
||||
pieces.push(strut(corner(y0, sx, sz), corner(y1, nx, nz), BRACE));
|
||||
pieces.push(strut(corner(y0, nx, nz), corner(y1, sx, sz), BRACE));
|
||||
}
|
||||
});
|
||||
}
|
||||
pieces.push(strut(new THREE.Vector3(0, MAST_HEIGHT - 1, 0), new THREE.Vector3(0, MAST_HEIGHT + ANTENNA, 0), 0.3));
|
||||
return merge(pieces);
|
||||
}
|
||||
|
||||
const SCREEN_DOOR = /* glsl */ `
|
||||
vec2 cell = mod(floor(gl_FragCoord.xy), 4.0);
|
||||
float threshold = (mod(cell.x + cell.y * 2.0, 4.0) * 4.0 + mod(cell.y, 2.0) * 2.0 + mod(cell.x, 2.0) + 0.5) / 16.0;
|
||||
if (vState.z < threshold) discard;
|
||||
`;
|
||||
|
||||
/** x: lit/level, y: flash, z: presence. */
|
||||
function solidMaterial(accent: ThreeColor, pulse: { value: number }, body: string): ThreeShaderMaterial {
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms: { uAccent: { value: accent }, uPulse: pulse },
|
||||
vertexShader: /* glsl */ `
|
||||
attribute vec3 aState;
|
||||
varying vec3 vState;
|
||||
varying vec3 vNormal;
|
||||
void main() {
|
||||
vState = aState;
|
||||
vNormal = normalize(mat3(modelMatrix * instanceMatrix) * normal);
|
||||
gl_Position = projectionMatrix * viewMatrix * modelMatrix * instanceMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: /* glsl */ `
|
||||
uniform vec3 uAccent;
|
||||
uniform float uPulse;
|
||||
varying vec3 vState;
|
||||
varying vec3 vNormal;
|
||||
void main() {
|
||||
${SCREEN_DOOR}
|
||||
float key = max(0.0, dot(normalize(vNormal), normalize(vec3(-0.45, 0.85, 0.35))));
|
||||
vec3 color;
|
||||
${body}
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`,
|
||||
fog: false,
|
||||
});
|
||||
}
|
||||
|
||||
export interface RelayRungState {
|
||||
lit: number;
|
||||
flash: number;
|
||||
}
|
||||
|
||||
export interface RelayMastsWorld {
|
||||
readonly group: ThreeGroup;
|
||||
/**
|
||||
* Section-local progress (null outside Toolkit), the smoothed bass pulse,
|
||||
* html[data-relay-focus] ("group:item"), frame delta and the frame clock
|
||||
* (ms) and the viewport aspect. Returns the camera nudge, 0..1 (eased).
|
||||
*/
|
||||
update(section: SectionId, p: number | null, pulse: number, focus: string | undefined, deltaSeconds: number, now: number, aspect?: number): number;
|
||||
/** Test hook: every rung's lit and flash level, in group order. */
|
||||
rungs(): RelayRungState[];
|
||||
/** Test hook: each beacon's current level. */
|
||||
beacons(): number[];
|
||||
/** Test hook: masts online, and tones requested. */
|
||||
online(): { count: number; tones: number };
|
||||
}
|
||||
|
||||
const instanced = (geometry: ThreeBufferGeometry, material: ThreeMaterial, count: number, name: string) => {
|
||||
const mesh = new THREE.InstancedMesh(geometry, material, count);
|
||||
mesh.name = name;
|
||||
mesh.frustumCulled = false;
|
||||
mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
||||
return mesh;
|
||||
};
|
||||
|
||||
const stateAttribute = (geometry: ThreeBufferGeometry, count: number): ThreeInstancedBufferAttribute => {
|
||||
const state = new THREE.InstancedBufferAttribute(new Float32Array(count * 3), 3);
|
||||
state.setUsage(THREE.DynamicDrawUsage);
|
||||
geometry.setAttribute('aState', state);
|
||||
return state;
|
||||
};
|
||||
|
||||
const defaultTone = (): void => {
|
||||
void import('@/audio/relay-tone').then((tone) => tone.playRelayTone(), () => undefined);
|
||||
};
|
||||
|
||||
export function createRelayMasts(resources: SceneResourceBag, full: boolean, tone: () => void = defaultTone): RelayMastsWorld {
|
||||
const accent = new THREE.Color(SIGN_ACCENTS.toolkit);
|
||||
const pulseUniform = { value: 0 };
|
||||
const group = new THREE.Group();
|
||||
group.name = 'relay-station';
|
||||
group.visible = false;
|
||||
|
||||
const frameGeometry = resources.own('geometry', latticeGeometry());
|
||||
const frameState = stateAttribute(frameGeometry, SLOT_FRAMES.length);
|
||||
const frames = instanced(frameGeometry, resources.own('material', solidMaterial(accent, pulseUniform, /* glsl */ `
|
||||
color = vec3(0.07, 0.06, 0.1) + vec3(0.1, 0.09, 0.15) * key + uAccent * (0.08 + 0.22 * vState.x);
|
||||
`)), SLOT_FRAMES.length, 'relay-masts');
|
||||
|
||||
const rungGeometry = resources.own('geometry', new THREE.BoxGeometry(1, 1, 1));
|
||||
const rungState = stateAttribute(rungGeometry, MAX_RUNGS);
|
||||
const rungMesh = instanced(rungGeometry, resources.own('material', solidMaterial(accent, pulseUniform, /* glsl */ `
|
||||
// Lit lamps are a hot, saturated violet (hotter than the bloom threshold, so they glow violet, not white).
|
||||
vec3 lamp = vec3(0.62, 0.3, 1.0) * 2.1 * (1.0 + ${PULSE_GAIN.toFixed(2)} * uPulse);
|
||||
vec3 dark = uAccent * 0.12 + vec3(0.02) * key;
|
||||
// Hover flash: the rung turns ice-white and swells (see FLASH_SWELL).
|
||||
color = mix(mix(dark, lamp, vState.x), vec3(0.78, 1.0, 1.0) * 2.4, vState.y);
|
||||
`)), MAX_RUNGS, 'relay-rungs');
|
||||
|
||||
const beaconGeometry = resources.own('geometry', new THREE.OctahedronGeometry(1.6));
|
||||
const beaconState = stateAttribute(beaconGeometry, SLOT_FRAMES.length);
|
||||
const beaconMesh = instanced(beaconGeometry, resources.own('material', solidMaterial(accent, pulseUniform, /* glsl */ `
|
||||
color = vec3(1.0, 0.12, 0.08) * (0.12 + 2.2 * vState.x);
|
||||
`)), SLOT_FRAMES.length, 'relay-beacons');
|
||||
group.add(frames, rungMesh, beaconMesh);
|
||||
|
||||
// The sky beam: a thin additive quad from the beacon, leaning in over the road and sweeping every 6 s.
|
||||
let beamMesh: ThreeInstancedMesh | null = null;
|
||||
let beamState: ThreeInstancedBufferAttribute | null = null;
|
||||
if (full) {
|
||||
const beamGeometry = resources.own('geometry', new THREE.PlaneGeometry(1, 1).translate(0, 0.5, 0));
|
||||
beamState = stateAttribute(beamGeometry, SLOT_FRAMES.length);
|
||||
beamMesh = instanced(beamGeometry, resources.own('material', new THREE.ShaderMaterial({
|
||||
uniforms: { uAccent: { value: accent } },
|
||||
vertexShader: /* glsl */ `
|
||||
attribute vec3 aState;
|
||||
varying vec2 vUv;
|
||||
varying float vLevel;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
vLevel = aState.x * aState.z;
|
||||
gl_Position = projectionMatrix * viewMatrix * modelMatrix * instanceMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: /* glsl */ `
|
||||
uniform vec3 uAccent;
|
||||
varying vec2 vUv;
|
||||
varying float vLevel;
|
||||
void main() {
|
||||
float across = pow(max(0.0, 1.0 - abs(vUv.x - 0.5) * 2.0), 2.0);
|
||||
// The base eases in over the first 12% so the source does not read as a blown blob at the beacon.
|
||||
float along = pow(1.0 - vUv.y, 1.6) * smoothstep(0.0, 0.12, vUv.y);
|
||||
vec3 color = mix(vec3(0.6, 0.36, 1.0), vec3(0.9, 0.92, 1.0), 0.3) * across * along * ${BEAM_INTENSITY.toFixed(2)} * vLevel;
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`,
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
fog: false,
|
||||
})), SLOT_FRAMES.length, 'relay-beams');
|
||||
group.add(beamMesh);
|
||||
}
|
||||
|
||||
const flash = new Float32Array(MAX_RUNGS);
|
||||
const sinceFlash = new Float32Array(MAX_RUNGS).fill(Number.POSITIVE_INFINITY);
|
||||
const lit = new Float32Array(MAX_RUNGS);
|
||||
const beaconLevels = [0, 0, 0, 0];
|
||||
let total = 0;
|
||||
let lastFocus: string | undefined;
|
||||
let lastSection: SectionId | null = null;
|
||||
let onlineCount = 0;
|
||||
let tones = 0;
|
||||
let nudgeRun = 0;
|
||||
const matrix = new THREE.Matrix4();
|
||||
const position = new THREE.Vector3();
|
||||
const rotation = new THREE.Quaternion();
|
||||
const scale = new THREE.Vector3();
|
||||
const forward = new THREE.Vector3(0, 0, 1);
|
||||
|
||||
return {
|
||||
group,
|
||||
rungs: () => Array.from({ length: total }, (_, i) => ({ lit: lit[i]!, flash: flash[i]! })),
|
||||
beacons: () => [...beaconLevels],
|
||||
online: () => ({ count: onlineCount, tones }),
|
||||
update(section, p, pulse, focus, deltaSeconds, now, aspect = DESIGN_ASPECT) {
|
||||
const dt = Math.max(0, deltaSeconds);
|
||||
const live = section === 'toolkit' ? p : null;
|
||||
const masts = relayLayout(live, readRelay().counts, full, aspect);
|
||||
const changed = section !== lastSection;
|
||||
lastSection = section;
|
||||
|
||||
// A mast coming online (scrolling forward, not a jump into the chapter) chirps once.
|
||||
const count = live === null ? 0 : RELAY_STOPS.filter((stop, i) => i < masts.length && live >= stop).length;
|
||||
if (full && !changed && count > onlineCount) {
|
||||
tones += 1;
|
||||
tone();
|
||||
}
|
||||
onlineCount = count;
|
||||
nudgeRun = clamp01(nudgeRun + (count > 0 ? 1 : -1) * (dt / RELAY_NUDGE.seconds));
|
||||
|
||||
// Hover: the focused technology's rung flashes, at most every 0.35 s.
|
||||
let focusIndex = -1;
|
||||
if (full && focus !== undefined && focus !== lastFocus) {
|
||||
const [g, item] = focus.split(':').map(Number);
|
||||
if (masts[g!] !== undefined && item! >= 0 && item! < masts[g!]!.rungs.length) {
|
||||
focusIndex = masts.slice(0, g).reduce((sum, mast) => sum + mast.rungs.length, 0) + item!;
|
||||
}
|
||||
}
|
||||
lastFocus = focus;
|
||||
|
||||
const time = now / 1000;
|
||||
let index = 0;
|
||||
let any = false;
|
||||
masts.forEach((mast, m) => {
|
||||
const visible = mast.presence > 0;
|
||||
any ||= visible;
|
||||
scale.setScalar(visible ? mast.scale : 0);
|
||||
rotation.identity();
|
||||
position.set(mast.x, 0, mast.z);
|
||||
frames.setMatrixAt(m, matrix.compose(position, rotation, scale));
|
||||
frameState.setXYZ(m, mast.level, 0, mast.presence);
|
||||
|
||||
// A soft 0.5 Hz blink while online (lite: steady).
|
||||
const blink = full ? Math.pow(0.5 + 0.5 * Math.cos(2 * Math.PI * BEACON_HZ * time + m * 1.3), 3) : 0.6;
|
||||
beaconLevels[m] = visible ? mast.level * (0.2 + 0.8 * blink) : 0;
|
||||
position.set(mast.x, (MAST_HEIGHT + ANTENNA) * mast.scale, mast.z);
|
||||
beaconMesh.setMatrixAt(m, matrix.compose(position, rotation, scale));
|
||||
beaconState.setXYZ(m, beaconLevels[m]!, 0, mast.presence);
|
||||
|
||||
if (beamMesh !== null && beamState !== null) {
|
||||
const sweep = mast.side * 0.5 + 0.4 * Math.sin((2 * Math.PI * time) / SWEEP_SECONDS + m * 1.7);
|
||||
rotation.setFromAxisAngle(forward, sweep);
|
||||
const shown = visible && mast.level > 0 ? mast.scale : 0;
|
||||
beamMesh.setMatrixAt(m, matrix.compose(position, rotation, scale.set(BEAM_WIDTH * shown, BEAM_LENGTH * shown, 1)));
|
||||
beamState.setXYZ(m, mast.level, 0, mast.presence);
|
||||
rotation.identity();
|
||||
}
|
||||
|
||||
for (const rung of mast.rungs) {
|
||||
if (index >= MAX_RUNGS) break;
|
||||
sinceFlash[index]! += dt;
|
||||
if (index === focusIndex && visible && sinceFlash[index]! >= RUNG_FLASH_GAP_SECONDS) {
|
||||
flash[index] = 1;
|
||||
sinceFlash[index] = 0;
|
||||
} else {
|
||||
flash[index] = Math.max(0, flash[index]! - dt / FLASH_SECONDS);
|
||||
}
|
||||
lit[index] = rung.lit;
|
||||
const s = visible ? mast.scale : 0;
|
||||
const f = flash[index]!;
|
||||
position.set(mast.x, rung.y, mast.z + rung.halfWidth + 0.35 * s);
|
||||
scale.set(visible ? rung.halfWidth * 2.3 * (1 + FLASH_SWELL.width * f) : 0, 0.9 * s * (1 + FLASH_SWELL.height * f), 0.6 * s);
|
||||
rungMesh.setMatrixAt(index, matrix.compose(position, rotation, scale));
|
||||
rungState.setXYZ(index, rung.lit, flash[index]!, mast.presence);
|
||||
index += 1;
|
||||
}
|
||||
});
|
||||
total = index;
|
||||
rungMesh.count = total;
|
||||
frames.count = masts.length;
|
||||
beaconMesh.count = masts.length;
|
||||
if (beamMesh !== null) beamMesh.count = masts.length;
|
||||
group.visible = any;
|
||||
if (any) {
|
||||
pulseUniform.value = full ? clamp01(pulse) : 0;
|
||||
for (const mesh of [frames, rungMesh, beaconMesh, beamMesh]) {
|
||||
if (mesh === null) continue;
|
||||
mesh.instanceMatrix.needsUpdate = true;
|
||||
(mesh.geometry.getAttribute('aState') as ThreeInstancedBufferAttribute).needsUpdate = true;
|
||||
}
|
||||
}
|
||||
return nudgeRun * nudgeRun * (3 - 2 * nudgeRun);
|
||||
},
|
||||
};
|
||||
}
|
||||
25
src/styles/relay.css
Normal file
25
src/styles/relay.css
Normal file
@@ -0,0 +1,25 @@
|
||||
/* RELAY STATION: toolkit groups come ONLINE in order, one per mast in the
|
||||
scene. Also the whole effect without a scene (reduced motion, static).
|
||||
html[data-relay-online] exists only after mount. */
|
||||
html[data-relay-online] #toolkit .toolkit-group {
|
||||
transition: border-color 600ms ease, box-shadow 600ms ease;
|
||||
}
|
||||
html[data-relay-online='1'] #toolkit .toolkit-group:nth-child(-n + 1),
|
||||
html[data-relay-online='2'] #toolkit .toolkit-group:nth-child(-n + 2),
|
||||
html[data-relay-online='3'] #toolkit .toolkit-group:nth-child(-n + 3),
|
||||
html[data-relay-online='4'] #toolkit .toolkit-group:nth-child(-n + 4) {
|
||||
border-block-start-color: var(--chapter-accent, var(--accent));
|
||||
box-shadow: inset 0 2px 0 var(--chapter-accent, var(--accent));
|
||||
}
|
||||
html[data-relay-online='1'] #toolkit .toolkit-group:nth-child(-n + 1) h3::after,
|
||||
html[data-relay-online='2'] #toolkit .toolkit-group:nth-child(-n + 2) h3::after,
|
||||
html[data-relay-online='3'] #toolkit .toolkit-group:nth-child(-n + 3) h3::after,
|
||||
html[data-relay-online='4'] #toolkit .toolkit-group:nth-child(-n + 4) h3::after {
|
||||
content: ' · ONLINE' / '';
|
||||
font: 500 0.62em/1 var(--mono);
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--chapter-accent, var(--accent));
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html[data-relay-online] #toolkit .toolkit-group { transition: none; }
|
||||
}
|
||||
Reference in New Issue
Block a user