feat: scroll-driven maintenance window set-piece in the infrastructure chapter
This commit is contained in:
@@ -14,6 +14,7 @@ import './styles/wayfinding.css';
|
||||
import './styles/route.css';
|
||||
import './styles/gantry.css';
|
||||
import './styles/cards.css';
|
||||
import './styles/maintenance.css';
|
||||
import './styles/print.css';
|
||||
|
||||
defineProps<{ inputs: Readonly<ProductionInputs> }>();
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useAudioIntent } from '@/composables/useAudioIntent';
|
||||
import { useCardEntrances } from '@/composables/useCardEntrances';
|
||||
import { useJourneySections } from '@/composables/useJourneySections';
|
||||
import { useKeyboardNavigation } from '@/composables/useKeyboardNavigation';
|
||||
import { useMaintenanceWindow } from '@/composables/useMaintenanceWindow';
|
||||
import { useMotionPreferences } from '@/composables/useMotionPreferences';
|
||||
import { useRouteMemory } from '@/composables/useRouteMemory';
|
||||
import { assetUrl } from '@/content/asset-url';
|
||||
@@ -81,6 +82,8 @@ const journeyBinding = useJourneySections(
|
||||
computed(() => motion.policy.value.allowSceneRequest),
|
||||
);
|
||||
const journey = journeyBinding.snapshot;
|
||||
/* The 03:12 window (Infrastructure): the plate's sub-line and the diagram steps. */
|
||||
const maintenanceSubline = useMaintenanceWindow(journey);
|
||||
useCardEntrances();
|
||||
const route = useRouteMemory(canonicalSectionIds, () => journey.value.activeSectionId);
|
||||
const resume = computed(() => {
|
||||
@@ -258,6 +261,7 @@ onUnmounted(() => removePageListeners());
|
||||
:exit="exitFor(journey.activeSectionId)"
|
||||
:next-label="nextLabelFor(journey.activeSectionId)"
|
||||
:next-exit="nextExitFor(journey.activeSectionId)"
|
||||
:subline="maintenanceSubline"
|
||||
/>
|
||||
<header class="site-header">
|
||||
<SiteNavigation
|
||||
|
||||
@@ -9,6 +9,8 @@ const props = defineProps<{
|
||||
exit?: string | undefined;
|
||||
nextLabel?: string | undefined;
|
||||
nextExit?: string | undefined;
|
||||
/** Set client-side only (the 03:12 window); empty on the server. */
|
||||
subline?: string | undefined;
|
||||
}>();
|
||||
|
||||
/** Decorative next-exit line (CSS generated content keeps SSR text stable). */
|
||||
@@ -40,5 +42,6 @@ const routePercent = computed(() => `${Math.round(route.value * 100)}%`);
|
||||
<span class="journey-hud__route" :data-next="next"><span>ROUTE</span> <strong>{{ routePercent }}</strong></span>
|
||||
<span class="journey-hud__section">{{ sectionLabel }}</span>
|
||||
<strong v-if="journey.arrived" class="journey-hud__arrival">ARRIVED</strong>
|
||||
<span class="journey-hud__subline">{{ subline }}</span>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
41
src/composables/useMaintenanceWindow.ts
Normal file
41
src/composables/useMaintenanceWindow.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { computed, onMounted, onUnmounted, shallowRef, watch, type Ref } from 'vue';
|
||||
import type { JourneySnapshot } from '@/journey/types';
|
||||
import {
|
||||
createMaintenanceDirection,
|
||||
maintenanceProgress,
|
||||
maintenanceStep,
|
||||
maintenanceSubline,
|
||||
} from '@/journey/maintenance-progress';
|
||||
import { bucharestClock } from '@/lib/operations';
|
||||
|
||||
/**
|
||||
* The DOM side of the 03:12 window: the gantry plate's sub-line and
|
||||
* html[data-maintenance-step] for the diagram. Both are client-only and only
|
||||
* start after mount, so the server HTML (no attribute, an empty sub-line)
|
||||
* hydrates unchanged. Works without the scene (reduced motion, static).
|
||||
*/
|
||||
export function useMaintenanceWindow(journey: Readonly<Ref<JourneySnapshot>>): Readonly<Ref<string>> {
|
||||
const mounted = shallowRef(false);
|
||||
const reversing = shallowRef(false);
|
||||
const track = createMaintenanceDirection();
|
||||
const progress = computed(() => (mounted.value ? maintenanceProgress(journey.value) : null));
|
||||
const step = computed(() => (progress.value === null ? null : maintenanceStep(progress.value)));
|
||||
|
||||
watch(progress, (p) => { reversing.value = track(p); }, { flush: 'sync' });
|
||||
watch(step, (value) => {
|
||||
const root = document.documentElement;
|
||||
if (value === null) delete root.dataset.maintenanceStep;
|
||||
else root.dataset.maintenanceStep = value;
|
||||
});
|
||||
|
||||
onMounted(() => { mounted.value = true; });
|
||||
onUnmounted(() => {
|
||||
mounted.value = false;
|
||||
delete document.documentElement.dataset.maintenanceStep;
|
||||
});
|
||||
|
||||
return computed(() => {
|
||||
const p = progress.value;
|
||||
return p === null ? '' : maintenanceSubline(p, reversing.value, () => bucharestClock(new Date()));
|
||||
});
|
||||
}
|
||||
88
src/journey/maintenance-progress.ts
Normal file
88
src/journey/maintenance-progress.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { JourneySnapshot } from './types';
|
||||
|
||||
/**
|
||||
* The 03:12 window: the Infrastructure chapter's set-piece is driven by how
|
||||
* far the visitor has read through the chapter, never by time, so scrolling
|
||||
* back up replays it in reverse (a rollback). Shared by the DOM (HUD sub-line,
|
||||
* diagram steps) and the lazy scene chunk, so it stays tiny and pure.
|
||||
*
|
||||
* The beats follow the featured operation in the content: back up, convert
|
||||
* and transfer, verify, then cut over.
|
||||
*/
|
||||
export type MaintenanceStep = 'open' | 'backup' | 'transfer' | 'verify' | 'done';
|
||||
|
||||
/** Upper bounds of each step, in section-local progress. */
|
||||
export const MAINTENANCE_BOUNDS = { open: 0.1, backup: 0.35, transfer: 0.65, verify: 0.9 } as const;
|
||||
|
||||
/**
|
||||
* The clock reads live Bucharest time again from here. The window keeps its
|
||||
* own clock right up to the next chapter: "CUTOVER OK" always reads 03:51.
|
||||
*/
|
||||
export const MAINTENANCE_LIVE_FROM = 1;
|
||||
|
||||
type ProgressInput = Pick<JourneySnapshot, 'activeSectionId' | 'sectionActivation'>;
|
||||
|
||||
/**
|
||||
* Section-local progress: 0 with the Infrastructure heading on the activation
|
||||
* line, 1 when the next chapter's heading reaches it. Null outside the chapter.
|
||||
*/
|
||||
export function maintenanceProgress(snapshot: ProgressInput): number | null {
|
||||
if (snapshot.activeSectionId !== 'infrastructure') return null;
|
||||
const { activeHeadingOffsetVh: active, nextHeadingOffsetVh: next } = snapshot.sectionActivation;
|
||||
if (next === null || !Number.isFinite(active) || !Number.isFinite(next)) return null;
|
||||
const span = next - active;
|
||||
if (!(span > 0)) return null;
|
||||
return Math.min(1, Math.max(0, -active / span));
|
||||
}
|
||||
|
||||
export function maintenanceStep(p: number): MaintenanceStep {
|
||||
if (p < MAINTENANCE_BOUNDS.open) return 'open';
|
||||
if (p < MAINTENANCE_BOUNDS.backup) return 'backup';
|
||||
if (p < MAINTENANCE_BOUNDS.transfer) return 'transfer';
|
||||
if (p < MAINTENANCE_BOUNDS.verify) return 'verify';
|
||||
return 'done';
|
||||
}
|
||||
|
||||
/** The window opens at 03:12 and cuts over at 03:51; the clock runs with the scroll. */
|
||||
export function maintenanceClock(p: number, live: () => string): string {
|
||||
if (p >= MAINTENANCE_LIVE_FROM) return live();
|
||||
if (p >= MAINTENANCE_BOUNDS.verify) return '03:51';
|
||||
const run = Math.min(1, Math.max(0, (p - MAINTENANCE_BOUNDS.open) / (MAINTENANCE_BOUNDS.verify - MAINTENANCE_BOUNDS.open)));
|
||||
return `03:${String(12 + Math.round(run * 39)).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const LABELS: Readonly<Record<MaintenanceStep, string>> = {
|
||||
open: 'MAINTENANCE WINDOW',
|
||||
backup: 'BACKUP',
|
||||
transfer: 'CONVERT · TRANSFER',
|
||||
verify: 'VERIFY',
|
||||
done: 'CUTOVER OK',
|
||||
};
|
||||
|
||||
/** The gantry plate's sub-line, e.g. "MAINTENANCE WINDOW · 03:12". */
|
||||
export function maintenanceSubline(p: number, reversing: boolean, live: () => string): string {
|
||||
const label = reversing ? 'ROLLBACK CHECKPOINT' : LABELS[maintenanceStep(p)];
|
||||
return `${label} · ${maintenanceClock(p, live)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Latches the scroll direction inside the window: progress falling means a
|
||||
* rollback until it rises again. Small jitter is ignored.
|
||||
*/
|
||||
export function createMaintenanceDirection(): (p: number | null) => boolean {
|
||||
let last: number | null = null;
|
||||
let reversing = false;
|
||||
return (p) => {
|
||||
if (p === null) {
|
||||
last = null;
|
||||
reversing = false;
|
||||
return false;
|
||||
}
|
||||
if (last !== null) {
|
||||
if (p < last - 0.001) reversing = true;
|
||||
else if (p > last + 0.001) reversing = false;
|
||||
}
|
||||
last = p;
|
||||
return reversing && p > 0 && p < 1;
|
||||
};
|
||||
}
|
||||
@@ -50,6 +50,10 @@ export interface LensState {
|
||||
letterbox: number;
|
||||
/** Drive energy: widens aberration and streaks a little. */
|
||||
energy: number;
|
||||
/** Windshield rain (the 03:12 window), 0..1. */
|
||||
rain?: number;
|
||||
/** Seconds, for the drops' slow slide. */
|
||||
rainTime?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,6 +72,8 @@ export class LensFinishPass extends Pass {
|
||||
uAberration: { value: 0.0035 },
|
||||
uGrain: { value: 0.03 },
|
||||
uVignette: { value: 0.42 },
|
||||
uRain: { value: 0 },
|
||||
uRainTime: { value: 0 },
|
||||
toneMappingExposure: { value: 1 },
|
||||
};
|
||||
|
||||
@@ -105,6 +111,8 @@ export class LensFinishPass extends Pass {
|
||||
uniform float uAberration;
|
||||
uniform float uGrain;
|
||||
uniform float uVignette;
|
||||
uniform float uRain;
|
||||
uniform float uRainTime;
|
||||
varying vec2 vUv;
|
||||
#include <tonemapping_pars_fragment>
|
||||
#include <colorspace_pars_fragment>
|
||||
@@ -115,15 +123,58 @@ export class LensFinishPass extends Pass {
|
||||
return fract( ( p3.x + p3.y ) * p3.z );
|
||||
}
|
||||
|
||||
vec3 rainHash( vec2 p ) {
|
||||
vec3 q = fract( vec3( p.xyx ) * vec3( 0.1031, 0.1030, 0.0973 ) );
|
||||
q += dot( q, q.yxz + 33.33 );
|
||||
return fract( ( q.xxy + q.yzz ) * q.zyx );
|
||||
}
|
||||
|
||||
// Rain on the windshield: one layer of drops on a grid, each with its
|
||||
// own slow life (fade in, maybe slide, fade out over about 8 s), so
|
||||
// nothing pops. A drop refracts the frame through itself; a sliding
|
||||
// drop leaves a smeared trail above it. xy: sample offset, z: trail.
|
||||
vec3 rainLayer( vec2 uv, float scale, float t, float density ) {
|
||||
vec2 grid = vec2( uv.x * uAspect, uv.y ) * scale;
|
||||
vec2 id = floor( grid );
|
||||
vec2 f = fract( grid ) - 0.5;
|
||||
vec3 h = rainHash( id + scale );
|
||||
float present = smoothstep( h.x, h.x + 0.08, density );
|
||||
if ( present <= 0.0 ) return vec3( 0.0 );
|
||||
float life = fract( t * ( 0.09 + h.z * 0.05 ) + h.y );
|
||||
float seen = smoothstep( 0.0, 0.18, life ) * ( 1.0 - smoothstep( 0.72, 1.0, life ) ) * present;
|
||||
float slides = step( 0.55, h.z );
|
||||
vec2 center = vec2( ( h.y - 0.5 ) * 0.5, 0.32 - slides * life * 0.62 );
|
||||
float radius = 0.07 + h.z * 0.09;
|
||||
vec2 d = ( f - center ) * vec2( 1.0, 0.85 );
|
||||
float drop = ( 1.0 - smoothstep( radius * 0.55, radius, length( d ) ) ) * seen;
|
||||
float trail = slides * seen
|
||||
* ( 1.0 - smoothstep( 0.0, radius * 0.45, abs( f.x - center.x ) ) )
|
||||
* step( center.y, f.y ) * ( 1.0 - smoothstep( center.y, 0.5, f.y ) );
|
||||
return vec3( -d * drop * 2.4 / scale, trail * 0.6 );
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 sampleUv = vUv;
|
||||
float smear = 0.0;
|
||||
if ( uRain > 0.001 ) {
|
||||
vec3 near = rainLayer( vUv, 7.0, uRainTime, uRain * 0.75 );
|
||||
vec3 far = rainLayer( vUv + 0.37, 13.0, uRainTime * 1.3, uRain * 0.6 );
|
||||
sampleUv += ( near.xy + far.xy * 0.6 ) * vec2( 1.0 / uAspect, 1.0 );
|
||||
smear = clamp( near.z + far.z * 0.5, 0.0, 1.0 ) * uRain;
|
||||
}
|
||||
vec2 centered = vUv - 0.5;
|
||||
float radius2 = dot( centered * vec2( uAspect, 1.0 ), centered * vec2( uAspect, 1.0 ) );
|
||||
vec2 shift = centered * radius2 * uAberration;
|
||||
vec3 color = vec3(
|
||||
texture2D( tDiffuse, vUv - shift ).r,
|
||||
texture2D( tDiffuse, vUv ).g,
|
||||
texture2D( tDiffuse, vUv + shift ).b
|
||||
texture2D( tDiffuse, sampleUv - shift ).r,
|
||||
texture2D( tDiffuse, sampleUv ).g,
|
||||
texture2D( tDiffuse, sampleUv + shift ).b
|
||||
);
|
||||
if ( smear > 0.002 ) {
|
||||
vec3 streaked = ( texture2D( tDiffuse, sampleUv + vec2( 0.0, 0.008 ) ).rgb
|
||||
+ texture2D( tDiffuse, sampleUv + vec2( 0.0, 0.018 ) ).rgb ) * 0.5;
|
||||
color = mix( color, streaked, smear * 0.8 );
|
||||
}
|
||||
if ( uStreak > 0.0 ) {
|
||||
vec3 streak = vec3( 0.0 );
|
||||
for ( int i = 1; i <= 7; i++ ) {
|
||||
@@ -161,6 +212,8 @@ export class LensFinishPass extends Pass {
|
||||
this.uniforms.uTime.value = state.time;
|
||||
this.uniforms.uLetterbox.value = THREE.MathUtils.clamp(state.letterbox, 0, 0.2);
|
||||
this.uniforms.uAberration.value = 0.0035 + energy * 0.006;
|
||||
this.uniforms.uRain.value = THREE.MathUtils.clamp(state.rain ?? 0, 0, 1);
|
||||
this.uniforms.uRainTime.value = state.rainTime ?? state.time;
|
||||
if (this.uniforms.tStreak.value) {
|
||||
this.uniforms.uStreak.value = this.streakStrength * (1 + energy * 0.6);
|
||||
}
|
||||
|
||||
@@ -105,9 +105,11 @@ const VERTEX = `
|
||||
varying float vDepth;
|
||||
varying float vRise;
|
||||
varying float vHeight;
|
||||
varying float vStagger;
|
||||
void main() {
|
||||
vec4 origin = instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
|
||||
float stagger = fract(sin(dot(origin.xz, vec2(12.9898, 78.233))) * 43758.5453);
|
||||
vStagger = stagger;
|
||||
float rise = clamp(uRise * 1.45 - stagger * 0.45, 0.0, 1.0);
|
||||
rise = rise * rise * (3.0 - 2.0 * rise);
|
||||
vRise = rise;
|
||||
@@ -128,6 +130,32 @@ const FADES = `
|
||||
if (fade < 0.02) discard;
|
||||
`;
|
||||
|
||||
/** Pieces the 03:12 window powers down and re-lights (the Infrastructure district). */
|
||||
const SERVICED: ReadonlySet<DistrictPiece> = new Set(['rack-row', 'mast']);
|
||||
/** The infrastructure accent the service re-lights in. */
|
||||
export const SERVICE_ACCENT = 0x8be9ed;
|
||||
|
||||
/* The 03:12 window. Each piece (a rack row, a mast) powers down to about 10%
|
||||
on its own staggered beat, then re-lights lamp row by lamp row, bottom up,
|
||||
in the infrastructure accent. Scroll-driven, so it never flickers. */
|
||||
const SERVICE_PARS = `
|
||||
uniform float uServiceDim;
|
||||
uniform float uServiceRelight;
|
||||
uniform float uPieceHeight;
|
||||
uniform vec3 uServiceColor;
|
||||
varying float vStagger;
|
||||
float serviceRowDim() {
|
||||
return clamp(uServiceDim * 1.6 - vStagger * 0.6, 0.0, 1.0);
|
||||
}
|
||||
float serviceRelit() {
|
||||
float rows = 7.0;
|
||||
float row = floor(clamp(vHeight / max(0.001, uPieceHeight), 0.0, 0.999) * rows);
|
||||
float front = uServiceRelight * 1.3 - vStagger * 0.3;
|
||||
float at = (row + 0.5) / rows;
|
||||
return smoothstep(at - 0.05, at + 0.05, front);
|
||||
}
|
||||
`;
|
||||
|
||||
export interface DistrictsWorld {
|
||||
group: { name: string; add(...objects: unknown[]): void };
|
||||
update(
|
||||
@@ -139,6 +167,8 @@ export interface DistrictsWorld {
|
||||
): void;
|
||||
/** Pieces currently raised (for tests and diagnostics). */
|
||||
raised(): DistrictPiece[];
|
||||
/** The 03:12 window: power-down (0..1) and re-light (0..1). Uniform writes only. */
|
||||
setServiceState(dim: number, relight: number): void;
|
||||
}
|
||||
|
||||
export function createDistricts(
|
||||
@@ -154,6 +184,11 @@ export function createDistricts(
|
||||
uTime: { value: 0 },
|
||||
uAccent: { value: new THREE.Color(signAccentFor('start')) },
|
||||
};
|
||||
const service = {
|
||||
uServiceDim: { value: 0 },
|
||||
uServiceRelight: { value: 0 },
|
||||
uServiceColor: { value: new THREE.Color(SERVICE_ACCENT) },
|
||||
};
|
||||
const targetAccent = new THREE.Color(signAccentFor('start'));
|
||||
const state = new Map<
|
||||
DistrictPiece,
|
||||
@@ -162,11 +197,11 @@ export function createDistricts(
|
||||
const pieces = Object.keys(DISTRICT_PRESENCE) as DistrictPiece[];
|
||||
for (const piece of pieces) state.set(piece, { rise: { value: 0 }, meshes: [] });
|
||||
|
||||
const bodyMaterial = (rise: { value: number }) =>
|
||||
const bodyMaterial = (rise: { value: number }, serviced: boolean, height: { value: number }) =>
|
||||
resources.own(
|
||||
'material',
|
||||
new THREE.ShaderMaterial({
|
||||
uniforms: { ...shared, uRise: rise },
|
||||
uniforms: { ...shared, ...service, uRise: rise, uPieceHeight: height },
|
||||
vertexShader: VERTEX,
|
||||
fragmentShader: `
|
||||
uniform vec3 uAccent;
|
||||
@@ -174,6 +209,7 @@ export function createDistricts(
|
||||
varying float vDepth;
|
||||
varying float vRise;
|
||||
varying float vHeight;
|
||||
${serviced ? SERVICE_PARS : ''}
|
||||
void main() {
|
||||
${FADES}
|
||||
// Opaque dark steel, like the sign truss: fades dissolve through a
|
||||
@@ -191,7 +227,12 @@ export function createDistricts(
|
||||
// the tops of the pieces carry a thin lit band.
|
||||
float rim = pow(1.0 - abs(dot(n, normalize(cameraPosition - vWorld))), 4.0);
|
||||
float crown = smoothstep(0.92, 1.0, n.y) * smoothstep(1.0, 6.0, vHeight);
|
||||
color += uAccent * (rim * 0.22 + crown * 0.05);
|
||||
${serviced ? `
|
||||
float dimmed = serviceRowDim();
|
||||
float relit = serviceRelit();
|
||||
float power = mix(1.0, mix(0.1, 1.0, relit), dimmed);
|
||||
color += mix(uAccent, uServiceColor, dimmed * relit) * (rim * 0.22 + crown * 0.05) * power;` : `
|
||||
color += uAccent * (rim * 0.22 + crown * 0.05);`}
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
@@ -202,11 +243,11 @@ export function createDistricts(
|
||||
side: THREE.DoubleSide,
|
||||
}),
|
||||
);
|
||||
const lightMaterial = (rise: { value: number }, accentMix: number) =>
|
||||
const lightMaterial = (rise: { value: number }, accentMix: number, serviced: boolean, height: { value: number }) =>
|
||||
resources.own(
|
||||
'material',
|
||||
new THREE.ShaderMaterial({
|
||||
uniforms: { ...shared, uRise: rise },
|
||||
uniforms: { ...shared, ...service, uRise: rise, uPieceHeight: height },
|
||||
vertexShader: VERTEX,
|
||||
fragmentShader: `
|
||||
uniform vec3 uAccent;
|
||||
@@ -215,6 +256,8 @@ export function createDistricts(
|
||||
varying vec3 vWorld;
|
||||
varying float vDepth;
|
||||
varying float vRise;
|
||||
varying float vHeight;
|
||||
${serviced ? SERVICE_PARS : ''}
|
||||
void main() {
|
||||
${FADES}
|
||||
// Neon tubes warm up as the district finishes rising. A smooth
|
||||
@@ -226,6 +269,11 @@ export function createDistricts(
|
||||
// Close passes dim, so overhead neon never floods the bloom pass.
|
||||
float near = mix(0.3, 1.0, smoothstep(5.0, 32.0, vDepth));
|
||||
vec3 color = tone * (1.1 + uEnergy * 0.4) * near;
|
||||
${serviced ? `
|
||||
float dimmed = serviceRowDim();
|
||||
float relit = serviceRelit();
|
||||
color = mix(color, uServiceColor * (1.1 + uEnergy * 0.4) * near, dimmed * relit * 0.85);
|
||||
color *= mix(1.0, mix(0.1, 1.0, relit), dimmed);` : ''}
|
||||
gl_FragColor = vec4(color * on * fade, 1.0);
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
@@ -247,9 +295,16 @@ export function createDistricts(
|
||||
if (!entry) return;
|
||||
const placements = placementsFor(piece, quality.postEffects);
|
||||
const accentMix = piece === 'arch' ? 1 : 0.35;
|
||||
const serviced = SERVICED.has(piece);
|
||||
const height = { value: 1 };
|
||||
if (serviced) {
|
||||
const bounds = (body as Geometry & { computeBoundingBox?(): void; boundingBox?: { max: { y: number } } | null });
|
||||
bounds.computeBoundingBox?.();
|
||||
height.value = Math.max(0.5, bounds.boundingBox?.max.y ?? 1);
|
||||
}
|
||||
for (const [geometry, material] of [
|
||||
[body, bodyMaterial(entry.rise)],
|
||||
[light, lightMaterial(entry.rise, accentMix)],
|
||||
[body, bodyMaterial(entry.rise, serviced, height)],
|
||||
[light, lightMaterial(entry.rise, accentMix, serviced, height)],
|
||||
] as const) {
|
||||
resources.own('geometry', geometry);
|
||||
const mesh = resources.own(
|
||||
@@ -318,5 +373,9 @@ export function createDistricts(
|
||||
},
|
||||
raised: () =>
|
||||
pieces.filter((piece) => (DISTRICT_PRESENCE[piece][current] ?? 0) > 0),
|
||||
setServiceState(dim, relight) {
|
||||
service.uServiceDim.value = Number.isFinite(dim) ? Math.min(1, Math.max(0, dim)) : 0;
|
||||
service.uServiceRelight.value = Number.isFinite(relight) ? Math.min(1, Math.max(0, relight)) : 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,9 +26,13 @@ import { createCoachworkEnvironment } from '@/scene/world/coachwork-light';
|
||||
import { createTravelDistance } from '@/scene/world/travel-distance';
|
||||
import {
|
||||
createMutableChapterPalette,
|
||||
STORM_STAR_SCALE,
|
||||
STORM_TINT,
|
||||
writeChapterPalette,
|
||||
type MutableChapterPalette,
|
||||
} from '@/scene/world/palette';
|
||||
import { createMaintenanceWindow } from '@/scene/world/maintenance-window';
|
||||
import { maintenanceProgress } from '@/journey/maintenance-progress';
|
||||
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
|
||||
|
||||
declare const __PORTFOLIO_TEST_BUILD__: boolean;
|
||||
@@ -141,7 +145,16 @@ export async function createProceduralSceneGeneration(
|
||||
maximumTextureSize: renderer.capabilities.maxTextureSize,
|
||||
});
|
||||
const atmosphere = createAtmosphere(context.resources, quality);
|
||||
// The 03:12 window. Phones, coarse pointers and the low tier keep only the
|
||||
// power-down, the re-light and the palette nudge: no rain, packets or lightning.
|
||||
const maintenance = createMaintenanceWindow(context.resources);
|
||||
const maintenanceTier =
|
||||
context.quality === 'low'
|
||||
|| (typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches)
|
||||
? 'lite'
|
||||
: 'full';
|
||||
scene.add(
|
||||
maintenance.group,
|
||||
atmosphere.group,
|
||||
road.group,
|
||||
expressway.group,
|
||||
@@ -307,6 +320,19 @@ export async function createProceduralSceneGeneration(
|
||||
terrainSpectrumPhase =
|
||||
(terrainSpectrumPhase + deltaSeconds * overdrive * 0.38) % 1;
|
||||
writeChapterPalette(targetPalette, snapshot.chapter, snapshot.chapter, 0);
|
||||
// The 03:12 window: section-local scroll progress drives every beat.
|
||||
const service = maintenance.update(
|
||||
maintenanceProgress(snapshot),
|
||||
snapshot.direction,
|
||||
deltaSeconds,
|
||||
maintenanceTier,
|
||||
);
|
||||
if (service.storm > 0) {
|
||||
targetPalette.sky.lerp(STORM_SKY, 0.55 * service.storm);
|
||||
targetPalette.fog.lerp(STORM_SKY, 0.4 * service.storm);
|
||||
targetPalette.starVisibility *= 1 - (1 - STORM_STAR_SCALE) * service.storm;
|
||||
}
|
||||
districts.setServiceState(service.dim, service.relight);
|
||||
// Engine off: parked at the destination the sky drops about 10%, more
|
||||
// stars come out, and the drive winds down to a stop.
|
||||
const engineOff = THREE.MathUtils.smoothstep(outroProgress, 0.12, 0.7);
|
||||
@@ -388,6 +414,13 @@ export async function createProceduralSceneGeneration(
|
||||
);
|
||||
sign.setResting(snapshot.activeSectionId);
|
||||
sign.update(snapshot.sign, now, snapshot.driveSpeed);
|
||||
// Distant lightning lifts the sky a little, toward a still-dark lilac.
|
||||
if (service.flash > 0) {
|
||||
flashRestore.sky.copy(currentPalette.sky);
|
||||
flashRestore.fog.copy(currentPalette.fog);
|
||||
currentPalette.sky.lerp(LIGHTNING_SKY, service.flash * 0.7);
|
||||
currentPalette.fog.lerp(LIGHTNING_SKY, service.flash * 0.3);
|
||||
}
|
||||
atmosphere.update(
|
||||
driveDistance,
|
||||
visualSpeed,
|
||||
@@ -402,6 +435,10 @@ export async function createProceduralSceneGeneration(
|
||||
.copy(currentPalette.haze)
|
||||
.lerp(currentPalette.terrainHigh, 0.26);
|
||||
ambient.groundColor.copy(currentPalette.fog);
|
||||
if (service.flash > 0) {
|
||||
currentPalette.sky.copy(flashRestore.sky);
|
||||
currentPalette.fog.copy(flashRestore.fog);
|
||||
}
|
||||
ambient.intensity = 1.05 + currentPalette.warmth * 0.18;
|
||||
carKeyLight.color.setHex(0xb2d5ff);
|
||||
carKeyLight.intensity = 32 + response.bloomEnergy * 3.6 + overdrive * 8;
|
||||
@@ -620,6 +657,8 @@ export async function createProceduralSceneGeneration(
|
||||
time: elapsedSeconds,
|
||||
letterbox: 0.115 * (1 - THREE.MathUtils.smoothstep(introProgress, 0.5, 1)),
|
||||
energy: overdrive,
|
||||
rain: service.rain,
|
||||
rainTime: elapsedSeconds,
|
||||
});
|
||||
postProcessor.render(scene, camera);
|
||||
} else {
|
||||
@@ -648,6 +687,9 @@ const OUTRO_SECONDS = 5.5;
|
||||
const OUTRO_RETURN_SECONDS = 1.6;
|
||||
const OUTRO_REACH = 0.62;
|
||||
const ENGINE_OFF_NIGHT = new THREE.Color(0x000000);
|
||||
const STORM_SKY = new THREE.Color(STORM_TINT);
|
||||
const LIGHTNING_SKY = new THREE.Color(0x5a5ea8);
|
||||
const flashRestore = { sky: new THREE.Color(), fog: new THREE.Color() };
|
||||
/** Plates rise back over 720 ms with up to three 90 ms staggers. */
|
||||
const INTERLUDE_RISE_MS = 800;
|
||||
|
||||
|
||||
351
src/scene/world/maintenance-window.ts
Normal file
351
src/scene/world/maintenance-window.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import * as THREE from 'three';
|
||||
import type { SceneResourceBag } from '@/scene/resources';
|
||||
import { maintenanceStep, type MaintenanceStep } from '@/journey/maintenance-progress';
|
||||
|
||||
/**
|
||||
* THE 03:12 WINDOW, the Infrastructure chapter's set-piece. A maintenance
|
||||
* window opens over the city: one district powers down lamp by lamp, amber
|
||||
* backup packets stream to an offsite silhouette on the far right, rain
|
||||
* starts on the windshield, then the service re-lights in the infrastructure
|
||||
* accent. Everything is a pure function of section-local scroll progress, so
|
||||
* scrolling back up replays it in reverse. Only the distant lightning keeps
|
||||
* its own (bounded) clock.
|
||||
*
|
||||
* Photosensitivity (round 5): lightning is at most one soft pulse per 3.2 s
|
||||
* (0.31 Hz, never two within 2.8 s), its peak is capped and it lights only a
|
||||
* distant patch of sky. Nothing here blinks.
|
||||
*/
|
||||
|
||||
export interface MaintenanceFrame {
|
||||
step: MaintenanceStep;
|
||||
/** District emissive power-down, 0..1 (rows stagger in the shader). */
|
||||
dim: number;
|
||||
/** Bottom-up re-light in the infrastructure accent, 0..1. */
|
||||
relight: number;
|
||||
/** Windshield rain, 0..0.7. */
|
||||
rain: number;
|
||||
/** 0..1 outbound backup packets, 1..2 the verified return trip. */
|
||||
packetPhase: number;
|
||||
/** How lit the offsite silhouette is, 0..1. */
|
||||
offsite: number;
|
||||
/** Storm palette nudge (cold navy, fewer stars), 0..1. */
|
||||
storm: number;
|
||||
/** Whether distant lightning may strike, 0..1. */
|
||||
lightning: number;
|
||||
}
|
||||
|
||||
export const MAINTENANCE_RAIN_PEAK = 0.7;
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
/** The window as a pure function of section-local progress p (0..1). */
|
||||
export function maintenanceTimeline(p: number): MaintenanceFrame {
|
||||
const x = Number.isFinite(p) ? clamp01(p) : 0;
|
||||
return {
|
||||
step: maintenanceStep(x),
|
||||
dim: smooth(0.1, 0.35, x),
|
||||
relight: smooth(0.35, 0.65, x),
|
||||
rain: MAINTENANCE_RAIN_PEAK * smooth(0.1, 0.3, x) * (1 - smooth(0.7, 0.9, x)),
|
||||
packetPhase: clamp01((x - 0.12) / 0.21) + clamp01((x - 0.67) / 0.21),
|
||||
offsite: smooth(0.14, 0.36, x),
|
||||
storm: smooth(0, 0.06, x) * (1 - smooth(0.68, 0.92, x)),
|
||||
lightning: smooth(0, 0.03, x) * (1 - smooth(0.62, 0.7, x)),
|
||||
};
|
||||
}
|
||||
|
||||
export const LIGHTNING = Object.freeze({
|
||||
/** Seconds between strikes. 1 / 3.2 s = 0.31 Hz. */
|
||||
minGap: 3.2,
|
||||
maxGap: 7.6,
|
||||
/** A strike never lands this soon after the window opens. */
|
||||
settle: 1.4,
|
||||
rise: 0.07,
|
||||
decay: 0.5,
|
||||
/** Peak flash, as a fraction of the (already dim) flash colour. */
|
||||
peak: 0.6,
|
||||
});
|
||||
|
||||
/** Distant lightning: one soft pulse at a time, seeded so tests are exact. */
|
||||
export function createLightning(seed = 0x0312) {
|
||||
let state = seed >>> 0 || 1;
|
||||
const random = (): number => {
|
||||
state = (state + 0x6d2b79f5) >>> 0;
|
||||
let t = state;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
let clock = 0;
|
||||
let lastStrike = -Infinity;
|
||||
let next: number = LIGHTNING.settle;
|
||||
return {
|
||||
sample(dt: number, enable: number): number {
|
||||
clock += Math.max(0, Number.isFinite(dt) ? dt : 0);
|
||||
if (enable < 0.5) next = Math.max(next, clock + LIGHTNING.settle);
|
||||
else if (clock >= next && clock - lastStrike >= LIGHTNING.minGap) {
|
||||
lastStrike = clock;
|
||||
next = clock + LIGHTNING.minGap + random() * (LIGHTNING.maxGap - LIGHTNING.minGap);
|
||||
}
|
||||
const age = clock - lastStrike;
|
||||
const envelope = age < LIGHTNING.rise
|
||||
? age / LIGHTNING.rise
|
||||
: 1 - smooth(LIGHTNING.rise, LIGHTNING.rise + LIGHTNING.decay, age);
|
||||
return LIGHTNING.peak * envelope * clamp01(enable);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type MaintenanceTier = 'full' | 'lite';
|
||||
|
||||
export interface MaintenanceState extends MaintenanceFrame {
|
||||
/** 1 while the Infrastructure chapter is current, easing to 0 after. */
|
||||
presence: number;
|
||||
/** Current lightning flash, 0..LIGHTNING.peak. */
|
||||
flash: number;
|
||||
reversing: boolean;
|
||||
}
|
||||
|
||||
/** Far right of the infrastructure framing, on the horizon. */
|
||||
export const OFFSITE = Object.freeze({ x: 250, y: 0, z: -540 });
|
||||
const PACKET_COUNT = 24;
|
||||
|
||||
export function createMaintenanceWindow(resources: SceneResourceBag) {
|
||||
const group = new THREE.Group();
|
||||
group.name = 'maintenance-window';
|
||||
|
||||
// ---- Offsite: a low campus silhouette whose windows light as copies land.
|
||||
const offsiteUniforms = { uLit: { value: 0 }, uPresence: { value: 0 }, uFlash: { value: 0 } };
|
||||
const boxGeometry = resources.own('geometry', new THREE.BoxGeometry(1, 1, 1));
|
||||
boxGeometry.translate(0, 0.5, 0);
|
||||
const offsiteMaterial = resources.own('material', new THREE.ShaderMaterial({
|
||||
uniforms: offsiteUniforms,
|
||||
vertexShader: /* glsl */ `
|
||||
varying vec3 vWorld;
|
||||
varying vec3 vNormal2;
|
||||
void main() {
|
||||
vec4 world = modelMatrix * instanceMatrix * vec4(position, 1.0);
|
||||
vWorld = world.xyz;
|
||||
vNormal2 = normal;
|
||||
gl_Position = projectionMatrix * viewMatrix * world;
|
||||
}`,
|
||||
fragmentShader: /* glsl */ `
|
||||
uniform float uLit;
|
||||
uniform float uPresence;
|
||||
uniform float uFlash;
|
||||
varying vec3 vWorld;
|
||||
varying vec3 vNormal2;
|
||||
float hash(vec2 p) { return fract(sin(dot(p, vec2(41.3, 289.1))) * 15731.743); }
|
||||
void main() {
|
||||
vec3 color = vec3(0.016, 0.014, 0.032) + vec3(0.02, 0.022, 0.05) * max(0.0, vNormal2.y);
|
||||
// Window rows on the faces that look back at the road.
|
||||
vec2 grid = vec2(vWorld.x * 0.42 + vWorld.z * 0.42, vWorld.y * 0.5);
|
||||
vec2 cell = floor(grid);
|
||||
vec2 f = fract(grid);
|
||||
float pane = step(0.25, f.x) * step(f.x, 0.75) * step(0.3, f.y) * step(f.y, 0.72)
|
||||
* (1.0 - step(0.5, abs(vNormal2.y)));
|
||||
float order = hash(cell);
|
||||
float lit = smoothstep(order - 0.05, order + 0.05, uLit * 1.05) * uPresence;
|
||||
vec3 amber = vec3(1.0, 0.62, 0.26);
|
||||
vec3 accent = vec3(0.545, 0.914, 0.929);
|
||||
color += mix(amber, accent, step(0.7, hash(cell * 1.7))) * pane * lit * 1.3;
|
||||
// Lightning behind it only rims the roofs.
|
||||
color += vec3(0.32, 0.36, 0.62) * uFlash * smoothstep(0.6, 1.0, vNormal2.y);
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
#include <tonemapping_fragment>
|
||||
#include <colorspace_fragment>
|
||||
}`,
|
||||
fog: false,
|
||||
}));
|
||||
const campus = [
|
||||
[0, 0, 44, 20, 24],
|
||||
[-15, 5, 10, 46, 10],
|
||||
[11, -6, 12, 34, 12],
|
||||
[28, 8, 16, 14, 16],
|
||||
[-32, -3, 14, 11, 14],
|
||||
] as const;
|
||||
const offsite = resources.own('other', new THREE.InstancedMesh(boxGeometry, offsiteMaterial, campus.length));
|
||||
offsite.name = 'maintenance-offsite';
|
||||
const dummy = new THREE.Object3D();
|
||||
campus.forEach(([dx, dz, w, h, d], index) => {
|
||||
dummy.position.set(OFFSITE.x + dx, OFFSITE.y, OFFSITE.z + dz);
|
||||
dummy.rotation.set(0, -0.35, 0);
|
||||
dummy.scale.set(w, h, d);
|
||||
dummy.updateMatrix();
|
||||
offsite.setMatrixAt(index, dummy.matrix);
|
||||
});
|
||||
offsite.instanceMatrix.needsUpdate = true;
|
||||
offsite.frustumCulled = false;
|
||||
offsite.visible = false;
|
||||
|
||||
// ---- Distant lightning: a soft patch of sky behind the offsite.
|
||||
const glowUniforms = { uFlash: { value: 0 } };
|
||||
const glow = new THREE.Mesh(
|
||||
resources.own('geometry', new THREE.PlaneGeometry(420, 200)),
|
||||
resources.own('material', new THREE.ShaderMaterial({
|
||||
uniforms: glowUniforms,
|
||||
vertexShader: /* glsl */ `
|
||||
varying vec2 vUv;
|
||||
void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
|
||||
fragmentShader: /* glsl */ `
|
||||
uniform float uFlash;
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vec2 c = (vUv - vec2(0.55, 0.42)) * vec2(1.0, 1.9);
|
||||
float cloud = exp(-dot(c, c) * 7.0);
|
||||
gl_FragColor = vec4(vec3(0.42, 0.46, 0.9) * cloud * uFlash, 1.0);
|
||||
#include <colorspace_fragment>
|
||||
}`,
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
fog: false,
|
||||
})),
|
||||
);
|
||||
glow.name = 'maintenance-lightning';
|
||||
glow.position.set(OFFSITE.x - 20, 70, OFFSITE.z - 80);
|
||||
glow.frustumCulled = false;
|
||||
glow.visible = false;
|
||||
|
||||
// ---- Backup packets: 24 amber streaks, the flight lives in the shader.
|
||||
const packetUniforms = {
|
||||
uOut: { value: 0 },
|
||||
uBack: { value: 0 },
|
||||
uPresence: { value: 0 },
|
||||
uTarget: { value: new THREE.Vector3(OFFSITE.x, OFFSITE.y + 30, OFFSITE.z) },
|
||||
};
|
||||
const packetGeometry = resources.own('geometry', new THREE.InstancedBufferGeometry());
|
||||
packetGeometry.setAttribute('position', new THREE.Float32BufferAttribute([0, -1, 0, 1, -1, 0, 1, 1, 0, 0, 1, 0], 3));
|
||||
packetGeometry.setIndex([0, 1, 2, 0, 2, 3]);
|
||||
const sources: number[] = [];
|
||||
const seeds: number[] = [];
|
||||
for (let i = 0; i < PACKET_COUNT; i += 1) {
|
||||
const side = i % 2 ? 1 : -1;
|
||||
const row = Math.floor(i / 2);
|
||||
sources.push(side * (14.6 + (row % 3) * 2.3), 3 + ((i * 7) % 5), -48 - row * 11);
|
||||
seeds.push(((i * 0.618) % 1), 34 + ((i * 13) % 7) * 6, 0.32 + ((i * 5) % 3) * 0.08);
|
||||
}
|
||||
packetGeometry.setAttribute('aSource', new THREE.InstancedBufferAttribute(new Float32Array(sources), 3));
|
||||
packetGeometry.setAttribute('aSeed', new THREE.InstancedBufferAttribute(new Float32Array(seeds), 3));
|
||||
packetGeometry.instanceCount = PACKET_COUNT;
|
||||
const packets = new THREE.Mesh(packetGeometry, resources.own('material', new THREE.ShaderMaterial({
|
||||
uniforms: packetUniforms,
|
||||
vertexShader: /* glsl */ `
|
||||
uniform float uOut;
|
||||
uniform float uBack;
|
||||
uniform float uPresence;
|
||||
uniform vec3 uTarget;
|
||||
attribute vec3 aSource;
|
||||
attribute vec3 aSeed;
|
||||
varying vec2 vUv;
|
||||
varying float vAlpha;
|
||||
varying float vReturn;
|
||||
float travel(float phase) { return clamp(phase * 1.6 - aSeed.x * 0.6, 0.0, 1.0); }
|
||||
vec3 along(float e) {
|
||||
float s = e * e * (3.0 - 2.0 * e);
|
||||
vec3 p = mix(aSource, uTarget, s);
|
||||
p.y += sin(3.14159 * s) * aSeed.y;
|
||||
return p;
|
||||
}
|
||||
void main() {
|
||||
float returning = step(0.0001, uBack);
|
||||
float o = travel(uOut);
|
||||
float b = travel(uBack);
|
||||
float local = mix(o, b, returning);
|
||||
float e = mix(o, 1.0 - b, returning);
|
||||
vAlpha = smoothstep(0.0, 0.05, local) * (1.0 - smoothstep(0.88, 1.0, local)) * uPresence;
|
||||
vReturn = returning;
|
||||
vUv = position.xy;
|
||||
vec4 head = modelViewMatrix * vec4(along(e), 1.0);
|
||||
vec4 tail = modelViewMatrix * vec4(along(clamp(e - mix(0.04, -0.04, returning), 0.0, 1.0)), 1.0);
|
||||
vec2 dir = head.xy - tail.xy;
|
||||
float len = length(dir);
|
||||
dir = len > 0.0001 ? dir / len : vec2(1.0, 0.0);
|
||||
float width = aSeed.z * max(1.0, -head.z * 0.0045);
|
||||
vec4 v = mix(tail, head, position.x);
|
||||
v.xy += vec2(-dir.y, dir.x) * position.y * width + dir * (position.x - 0.5) * width * 2.0;
|
||||
gl_Position = vAlpha < 0.001 ? vec4(2.0, 2.0, 2.0, 1.0) : projectionMatrix * v;
|
||||
}`,
|
||||
fragmentShader: /* glsl */ `
|
||||
varying vec2 vUv;
|
||||
varying float vAlpha;
|
||||
varying float vReturn;
|
||||
void main() {
|
||||
float across = abs(vUv.y);
|
||||
float body = (1.0 - smoothstep(0.0, 1.0, across)) * pow(vUv.x, 1.6);
|
||||
float head = 1.0 - smoothstep(0.0, 1.0, length(vec2((vUv.x - 0.9) * 5.0, across)));
|
||||
float alpha = max(body * 0.7, head) * vAlpha;
|
||||
if (alpha < 0.004) discard;
|
||||
vec3 tone = mix(vec3(1.0, 0.56, 0.16), vec3(0.545, 0.914, 0.929), vReturn);
|
||||
gl_FragColor = vec4(tone * 1.9 * alpha, alpha);
|
||||
#include <colorspace_fragment>
|
||||
}`,
|
||||
transparent: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
toneMapped: false,
|
||||
})));
|
||||
packets.name = 'maintenance-packets';
|
||||
packets.frustumCulled = false;
|
||||
packets.visible = false;
|
||||
|
||||
group.add(glow, offsite, packets);
|
||||
|
||||
const lightning = createLightning();
|
||||
let smoothed = 0;
|
||||
let presence = 0;
|
||||
let reversing = false;
|
||||
let previous: number | null = null;
|
||||
// One hidden-but-drawn frame compiles the programs before the chapter.
|
||||
let warm = true;
|
||||
|
||||
return {
|
||||
group,
|
||||
update(p: number | null, direction: -1 | 0 | 1, dt: number, tier: MaintenanceTier): MaintenanceState {
|
||||
const step = Math.max(0, Math.min(0.1, Number.isFinite(dt) ? dt : 0));
|
||||
const inWindow = p !== null && Number.isFinite(p);
|
||||
if (inWindow) {
|
||||
if (presence < 0.02) smoothed = p;
|
||||
smoothed += (p - smoothed) * (1 - Math.exp(-step / 0.16));
|
||||
if (previous !== null && Math.abs(p - previous) > 0.0001) reversing = p < previous;
|
||||
else if (direction !== 0) reversing = direction < 0;
|
||||
previous = p;
|
||||
} else {
|
||||
previous = null;
|
||||
reversing = false;
|
||||
}
|
||||
presence += ((inWindow ? 1 : 0) - presence) * (1 - Math.exp(-step / 0.5));
|
||||
if (presence < 0.002 && !inWindow) presence = 0;
|
||||
const frame = maintenanceTimeline(smoothed);
|
||||
const full = tier === 'full';
|
||||
const flash = full ? lightning.sample(step, frame.lightning * presence) : 0;
|
||||
|
||||
const live = presence > 0;
|
||||
offsite.visible = full && (warm || live);
|
||||
glow.visible = full && (warm || flash > 0.0005);
|
||||
packets.visible = full && (warm || (live && frame.packetPhase > 0 && frame.packetPhase < 2));
|
||||
warm = false;
|
||||
offsiteUniforms.uLit.value = frame.offsite;
|
||||
offsiteUniforms.uPresence.value = presence;
|
||||
offsiteUniforms.uFlash.value = flash;
|
||||
glowUniforms.uFlash.value = flash;
|
||||
packetUniforms.uOut.value = Math.min(1, frame.packetPhase);
|
||||
packetUniforms.uBack.value = Math.max(0, frame.packetPhase - 1);
|
||||
packetUniforms.uPresence.value = presence;
|
||||
|
||||
return {
|
||||
...frame,
|
||||
dim: frame.dim * presence,
|
||||
rain: full ? frame.rain * presence : 0,
|
||||
storm: frame.storm * presence,
|
||||
lightning: full ? frame.lightning * presence : 0,
|
||||
presence,
|
||||
flash,
|
||||
reversing,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -111,6 +111,14 @@ export const CHAPTER_PALETTES: Readonly<Record<EnvironmentChapter, ChapterPalett
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The 03:12 window's storm: a colder navy the Infrastructure sky leans toward
|
||||
* (about 55%) while the maintenance window is open. A tint, not a chapter.
|
||||
*/
|
||||
export const STORM_TINT = 0x060c26;
|
||||
/** Star visibility while the storm is in. */
|
||||
export const STORM_STAR_SCALE = 0.4;
|
||||
|
||||
const paletteColors = Object.fromEntries(
|
||||
Object.entries(CHAPTER_PALETTES).map(([chapter, palette]) => [chapter, {
|
||||
sky: new THREE.Color(palette.sky),
|
||||
|
||||
63
src/styles/maintenance.css
Normal file
63
src/styles/maintenance.css
Normal file
@@ -0,0 +1,63 @@
|
||||
/* ---------- The 03:12 window (Infrastructure set-piece) ----------
|
||||
html[data-maintenance-step] is set client-side while the Infrastructure
|
||||
chapter is current, from section-local scroll progress (never time), so
|
||||
the steps replay in reverse on the way back up. The server HTML carries
|
||||
no attribute and an empty sub-line. */
|
||||
|
||||
/* The gantry plate's sub-line hangs under the window like a variable
|
||||
message strip: "MAINTENANCE WINDOW · 03:12" ... "CUTOVER OK · 03:51". */
|
||||
html .journey-hud__subline {
|
||||
position: absolute;
|
||||
inset: calc(100% + 5px) 0 auto;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
padding: 3px 8px 2px;
|
||||
background: #0b0918;
|
||||
box-shadow: inset 0 1px rgb(139 233 237 / 32%);
|
||||
color: #8be9ed;
|
||||
font: 650 0.56rem/1.3 var(--mono);
|
||||
letter-spacing: 0.14em;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
html .journey-hud__subline:empty { display: none; }
|
||||
html[data-maintenance-step='open'] .journey-hud__subline,
|
||||
html[data-maintenance-step='backup'] .journey-hud__subline { color: #ffc27a; box-shadow: inset 0 1px rgb(255 194 122 / 34%); }
|
||||
@media (max-width: 767px) {
|
||||
/* The phone plate is about 155px wide: "MAINTENANCE WINDOW · 03:12" (26
|
||||
characters) only fits with tighter tracking and a hair of overhang. */
|
||||
html .journey-hud__subline { inset: calc(100% + 3px) -6px auto; padding-inline: 2px; font-size: 0.5rem; letter-spacing: 0.04em; }
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
html .journey-hud__subline { background: Canvas; color: CanvasText; border: 1px solid CanvasText; box-shadow: none; }
|
||||
}
|
||||
|
||||
/* The diagram follows the window: stages 01-04 go dark when it opens and
|
||||
light again one by one, in the infrastructure accent, as it runs. */
|
||||
@media screen {
|
||||
html[data-maintenance-step] .infrastructure-node:not(.infrastructure-node--visitor) {
|
||||
--node-accent: color-mix(in srgb, #8b86a6 55%, #201a35);
|
||||
}
|
||||
html[data-maintenance-step='backup'] .infrastructure-node--ingress,
|
||||
html[data-maintenance-step='transfer'] :is(.infrastructure-node--ingress, .infrastructure-node--access),
|
||||
html[data-maintenance-step='verify'] :is(.infrastructure-node--ingress, .infrastructure-node--access, .infrastructure-node--services),
|
||||
html[data-maintenance-step='done'] :is(.infrastructure-node--ingress, .infrastructure-node--access, .infrastructure-node--services, .infrastructure-node--operations) {
|
||||
--node-accent: #8be9ed;
|
||||
box-shadow:
|
||||
inset 0 2px var(--node-accent),
|
||||
0 0 0 1px rgb(139 233 237 / 30%),
|
||||
0 0 26px rgb(139 233 237 / 14%),
|
||||
0 12px 28px rgb(11 4 30 / 22%);
|
||||
}
|
||||
}
|
||||
@media screen and (prefers-reduced-motion: no-preference) {
|
||||
html .infrastructure-node,
|
||||
html .infrastructure-node :is(strong, .infrastructure-node__sequence, .infrastructure-boundary, .infrastructure-node__guard, .infrastructure-node__connector) {
|
||||
transition:
|
||||
background-color 320ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
border-color 320ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
box-shadow 320ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||
color 320ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user