fix: close phase 2 journey blockers

This commit is contained in:
2026-09-02 06:04:20 +03:00
parent a86767cdc6
commit 77895c01ff
19 changed files with 378 additions and 26 deletions

View File

@@ -78,20 +78,28 @@ watch(
watch(
() => motion.policy.value.maximumTier,
(tier, previous) => {
if (tier !== previous) adaptive.setMaximum(tier, 'manual');
if (tier !== previous) {
adaptive.setMaximum(tier, controlsReady.value ? 'resize' : 'manual');
}
},
{ flush: 'sync' },
);
const setQuality = (tier: import('@/journey/motion-policy').QualityTier): void => {
motion.setMaximumQuality(tier);
adaptive.setMaximum(tier, 'manual');
adaptive.setMaximum(motion.policy.value.maximumTier, 'manual');
};
const enableMotion = (): void => {
const tier = motion.maximumQuality.value === 'static' ? 'high' : motion.maximumQuality.value;
motion.setMaximumQuality(tier);
motion.setPaused(false);
adaptive.setMaximum(tier, 'manual');
adaptive.setMaximum(motion.policy.value.maximumTier, 'manual');
};
const resumeMotion = (): void => {
motion.setPaused(false);
adaptive.setMaximum(motion.policy.value.maximumTier, 'manual');
};
onMounted(() => {
@@ -118,6 +126,7 @@ onUnmounted(() => removePageListeners());
:policy="motion.policy.value"
:quality="actualQuality"
:scene-description="content.sceneDescription"
:sample-frame="adaptive.sampleFrame"
@state-change="sceneState = $event"
/>
<JourneyHud
@@ -185,7 +194,7 @@ onUnmounted(() => removePageListeners());
:audio-muted="audio.muted.value"
:audio-volume="controlsReady ? motion.volume.value : 0.4"
@pause="motion.setPaused(true)"
@resume="motion.setPaused(false)"
@resume="resumeMotion"
@enable="enableMotion"
@quality-change="setQuality"
@audio-start="audio.startFromGesture"

View File

@@ -9,6 +9,7 @@ const props = defineProps<{
policy: MotionPolicy;
quality: QualityTier;
sceneDescription: string;
sampleFrame?: (start: number, end: number) => unknown;
}>();
const emit = defineEmits<{
@@ -20,13 +21,16 @@ const clientReady = ref(false);
const journey = toRef(props, 'journey');
const policy = toRef(props, 'policy');
const quality = toRef(props, 'quality');
const scene = useSceneController({ canvas, journey, policy, quality });
const scene = useSceneController({ canvas, journey, policy, quality, sampleFrame: props.sampleFrame });
const sceneState = computed(() => scene.state.value);
const sceneGeneration = computed(() => scene.snapshot.value?.currentGeneration ?? null);
const showCanvas = computed(() => (
clientReady.value
&& policy.value.allowSceneRequest
&& quality.value !== 'static'
&& !scene.error.value
&& (
(policy.value.allowSceneRequest && quality.value !== 'static')
|| (policy.value.reason === 'visitor-pause' && sceneGeneration.value !== null)
)
));
watch(scene.state, (state) => emit('stateChange', state), { immediate: true });
@@ -35,7 +39,12 @@ onMounted(() => { clientReady.value = true; });
<template>
<p id="journey-scene-description" class="visually-hidden">{{ sceneDescription }}</p>
<div class="journey-world" aria-hidden="true" :data-scene-state="sceneState">
<div
class="journey-world"
aria-hidden="true"
:data-scene-state="sceneState"
:data-scene-generation="sceneGeneration"
>
<img class="journey-poster" alt="" width="1600" height="900">
<canvas v-if="showCanvas" ref="canvas" class="journey-canvas" aria-hidden="true" />
</div>

View File

@@ -13,6 +13,7 @@ import {
type QualityDecision,
} from '@/journey/adaptive-quality';
import type { QualityTier } from '@/journey/motion-policy';
import { lowerTier } from '@/journey/motion-policy';
const PASSIVE_LISTENER: AddEventListenerOptions = { passive: true };
@@ -70,7 +71,7 @@ export function useAdaptiveQuality(sources: AdaptiveQualitySources): AdaptiveQua
tier: QualityTier,
reason: 'resize' | 'manual',
): QualityDecision | null => {
const decision = controller.setMaximum(tier, reason);
const decision = controller.setMaximum(lowerTier(tier, sources.maximum.value), reason);
reconcileEpoch();
return decision;
};

View File

@@ -1,4 +1,5 @@
import {
computed,
onMounted,
onUnmounted,
readonly,
@@ -41,7 +42,12 @@ export function useJourneySections(
throw new Error("At least one canonical journey section is required.");
}
const drive = useScrollDrive();
const snapshot = shallowRef<JourneySnapshot>(initialJourney(firstId));
const sectionSnapshot = shallowRef<JourneySnapshot>(initialJourney(firstId));
const snapshot = computed<JourneySnapshot>(() => ({
...sectionSnapshot.value,
...drive.value,
routeProgress: sectionSnapshot.value.arrived ? 1 : drive.value.routeProgress,
}));
let scheduleMeasurement = (): void => {};
let teardown = (): void => {};
@@ -100,7 +106,7 @@ export function useJourneySections(
const activeIndex = canonicalIds.indexOf(activeSectionId);
const crossedBoundaries = Math.abs(activeIndex - previousActiveIndex);
const deltaY = scrollY - previousScrollY;
const currentSign = snapshot.value.sign;
const currentSign = sectionSnapshot.value.sign;
const signTargetId =
currentSign?.targetId ?? canonicalIds[activeIndex + 1] ?? null;
const signTarget = headings.find(
@@ -119,7 +125,7 @@ export function useJourneySections(
motionAllowed: motionAllowed?.value ?? true,
});
const sectionState = deriveSectionState(activeSectionId);
snapshot.value = {
sectionSnapshot.value = {
...drive.value,
routeProgress: atDocumentEnd ? 1 : drive.value.routeProgress,
...sectionState,

View File

@@ -25,6 +25,7 @@ export interface SceneControllerSources {
journey: Readonly<Ref<JourneySnapshot>>;
policy: Readonly<Ref<MotionPolicy>>;
quality: Readonly<Ref<QualityTier>>;
sampleFrame?: ((start: number, end: number) => unknown) | undefined;
}
export function useSceneController(sources: SceneControllerSources): SceneControllerBinding {
@@ -61,14 +62,26 @@ export function useSceneController(sources: SceneControllerSources): SceneContro
if (!mounted) return;
const allowed = sources.policy.value.allowSceneRequest && sources.quality.value !== 'static';
const canvas = sources.canvas.value;
if (!allowed || canvas === null) {
disposeActive(allowed ? (error.value ? 'failed' : 'idle') : 'static');
if (!allowed) {
if (sources.policy.value.reason === 'visitor-pause' && controller !== null) {
controller.pause();
publish();
} else {
disposeActive('static');
}
return;
}
if (canvas === null) {
disposeActive(error.value ? 'failed' : 'idle');
return;
}
if (controller !== null) {
controller.update(sources.journey.value);
await controller.setQuality(sources.quality.value);
if (controller.snapshot().state.kind === 'paused') {
await controller.resume();
}
publish();
return;
}
@@ -94,6 +107,7 @@ export function useSceneController(sources: SceneControllerSources): SceneContro
quality: sources.quality.value,
build: createProceduralSceneGeneration,
motionAllowed: () => sources.policy.value.allowSceneRequest,
onFrameRendered: sources.sampleFrame,
signal: abort.signal,
});
controller = next;

View File

@@ -39,6 +39,7 @@ export function useScrollDrive(): Readonly<Ref<ScrollDriveSnapshot>> {
? controller.reset(input)
: controller.sample(input);
resetOnNextFrame = false;
if (snapshot.value.acceleration > 0) scheduleFrame();
};
const scheduleFrame = (): void => {

View File

@@ -10,6 +10,7 @@ const DECAY_TIME_CONSTANT_MS = 420;
const CRUISE_SPEED = 0.18;
const ACCELERATION_SPEED_RANGE = 0.82;
const BACKGROUND_GAP_MS = 250;
const DECAY_SETTLE_EPSILON = 0.001;
const clamp = (value: number, minimum: number, maximum: number): number =>
Math.min(maximum, Math.max(minimum, value));
@@ -87,6 +88,9 @@ export function createScrollDriveController(): ScrollDriveController {
: DECAY_TIME_CONSTANT_MS;
const alpha = 1 - Math.exp(-elapsedMs / timeConstantMs);
smoothedAcceleration += (impulse - smoothedAcceleration) * alpha;
if (impulse === 0 && smoothedAcceleration < DECAY_SETTLE_EPSILON) {
smoothedAcceleration = 0;
}
previousSample = { ...input };
const direction: -1 | 0 | 1 = deltaY > 0 ? 1 : deltaY < 0 ? -1 : 0;

View File

@@ -121,7 +121,10 @@ export function createSceneController(options: SceneControllerOptions): SceneCon
rootResources.releaseAnimationFrame(frameId);
if (overlay !== 'active' || machine.snapshot().kind !== 'running' || !current) return;
try {
const frameStartedAt = scheduler.now();
current.runtime.render(latestJourney, now);
const frameEndedAt = scheduler.now();
options.onFrameRendered?.(frameStartedAt, frameEndedAt);
} catch {
enterStatic('render-failure');
return;
@@ -332,7 +335,7 @@ export function createSceneController(options: SceneControllerOptions): SceneCon
pauseReason = 'freeze';
break;
case 'paused':
pauseReason = 'freeze';
if (pauseReason !== 'visitor') pauseReason = 'freeze';
break;
case 'lost':
clearRestoreWatchdog();
@@ -351,11 +354,17 @@ export function createSceneController(options: SceneControllerOptions): SceneCon
const persisted = Boolean((event as Event & { persisted?: boolean }).persisted);
if (!persisted || overlay !== 'frozen') return;
overlay = 'active';
const lifecycle = machine.snapshot();
if (!(options.motionAllowed?.() ?? true)) {
if (
lifecycle.kind === 'paused'
&& current !== null
&& !lifecycle.resumeNeedsLoad
&& pauseReason === 'visitor'
) return;
enterStatic('motion-disallowed');
return;
}
const lifecycle = machine.snapshot();
if (page.visibilityState !== 'visible') return;
if (lifecycle.kind === 'paused' && pauseReason === 'freeze') {
pauseReason = null;

View File

@@ -52,6 +52,7 @@ export interface SceneControllerOptions {
scheduler?: SceneScheduler;
page?: ScenePageEnvironment;
motionAllowed?: () => boolean;
onFrameRendered?: ((start: number, end: number) => unknown) | undefined;
signal?: AbortSignal;
}

View File

@@ -3,10 +3,11 @@ import type { SceneResourceBag } from '@/scene/resources';
export function createCar(resources: SceneResourceBag) {
const group = new THREE.Group();
group.position.set(0, 0.72, 4.4);
group.position.set(0, 0.69, 0.5);
group.scale.set(0.9, 0.65, 0.78);
const bodyMaterial = resources.own('material', new THREE.MeshStandardMaterial({
color: 0x111722,
color: 0x202938,
roughness: 0.28,
metalness: 0.78,
}));
@@ -16,6 +17,12 @@ export function createCar(resources: SceneResourceBag) {
metalness: 0.35,
}));
const tailMaterial = resources.own('material', new THREE.MeshBasicMaterial({ color: 0xff315f }));
const silhouetteMaterial = resources.own('material', new THREE.MeshBasicMaterial({ color: 0x405875 }));
const fasciaMaterial = resources.own('material', new THREE.MeshStandardMaterial({
color: 0x151c28,
roughness: 0.42,
metalness: 0.62,
}));
const underglowMaterial = resources.own('material', new THREE.MeshBasicMaterial({
color: 0x28d8ef,
transparent: true,
@@ -39,6 +46,38 @@ export function createCar(resources: SceneResourceBag) {
rearLight.position.set(0, 0.04, 3.08);
group.add(rearLight);
const rearProfile = new THREE.Shape();
rearProfile.moveTo(-2.5, -0.32);
rearProfile.lineTo(-2.32, 0.28);
rearProfile.lineTo(-1.42, 0.72);
rearProfile.lineTo(1.42, 0.72);
rearProfile.lineTo(2.32, 0.28);
rearProfile.lineTo(2.5, -0.32);
rearProfile.closePath();
const silhouetteGeometry = resources.own('geometry', new THREE.ShapeGeometry(rearProfile));
const silhouette = new THREE.Mesh(silhouetteGeometry, silhouetteMaterial);
silhouette.name = 'rear-silhouette';
silhouette.position.z = 2.98;
group.add(silhouette);
const fasciaGeometry = resources.own('geometry', new THREE.BoxGeometry(4.3, 0.38, 0.22));
const fascia = new THREE.Mesh(fasciaGeometry, fasciaMaterial);
fascia.position.set(0, -0.12, 2.94);
group.add(fascia);
const shoulderGeometry = resources.own('geometry', new THREE.BoxGeometry(5.05, 0.18, 1.1));
const shoulders = new THREE.Mesh(shoulderGeometry, bodyMaterial);
shoulders.position.set(0, 0.18, 2.48);
group.add(shoulders);
const accentMaterial = resources.own('material', new THREE.MeshBasicMaterial({ color: 0x28d8ef }));
const accentGeometry = resources.own('geometry', new THREE.BoxGeometry(0.36, 0.1, 0.08));
for (const x of [-2.02, 2.02]) {
const accent = new THREE.Mesh(accentGeometry, accentMaterial);
accent.position.set(x, -0.15, 3.11);
group.add(accent);
}
const glowGeometry = resources.own('geometry', new THREE.PlaneGeometry(4.2, 6.2));
const underglow = new THREE.Mesh(glowGeometry, underglowMaterial);
underglow.rotation.x = -Math.PI / 2;
@@ -60,7 +99,7 @@ export function createCar(resources: SceneResourceBag) {
group,
update(speed: number, acceleration: number, elapsedSeconds: number) {
const response = Math.min(1, Math.max(0, acceleration));
group.position.y = 0.72 + Math.sin(elapsedSeconds * (2.2 + speed)) * (0.012 + response * 0.018);
group.position.y = 0.69 + Math.sin(elapsedSeconds * (2.2 + speed)) * (0.012 + response * 0.018);
group.rotation.z = Math.sin(elapsedSeconds * 1.4) * 0.003 * response;
tailMaterial.color.setRGB(1, 0.12 + response * 0.08, 0.28 + response * 0.08);
underglowMaterial.opacity = 0.32 + Math.min(0.3, speed * 0.2);

View File

@@ -19,6 +19,13 @@ const skyColors: Readonly<Record<EnvironmentChapter, number>> = {
destination: 0x2b2035,
};
export function createChaseCamera(aspect = 1) {
const camera = new THREE.PerspectiveCamera(52, aspect, 0.1, 320);
camera.position.set(0, 4.8, 12.5);
camera.lookAt(0, 1.6, -22);
return camera;
}
export async function createProceduralSceneGeneration(
context: SceneBuildContext,
): Promise<SceneGeneration> {
@@ -42,9 +49,7 @@ export async function createProceduralSceneGeneration(
const background = new THREE.Color(skyColors['deep-night']);
scene.background = background;
scene.fog = new THREE.FogExp2(0x080c18, 0.012);
const camera = new THREE.PerspectiveCamera(52, 1, 0.1, 320);
camera.position.set(0, 4.8, 12.5);
camera.lookAt(0, 1.6, -22);
const camera = createChaseCamera();
const ambient = new THREE.HemisphereLight(0x6aa9c5, 0x100812, 1.25);
scene.add(ambient);

View File

@@ -44,6 +44,8 @@
}
.site-header {
position: relative;
z-index: 2;
background: rgb(7 13 23 / 96%);
}