fix: close phase 2 journey blockers
This commit is contained in:
@@ -78,20 +78,28 @@ watch(
|
|||||||
watch(
|
watch(
|
||||||
() => motion.policy.value.maximumTier,
|
() => motion.policy.value.maximumTier,
|
||||||
(tier, previous) => {
|
(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 => {
|
const setQuality = (tier: import('@/journey/motion-policy').QualityTier): void => {
|
||||||
motion.setMaximumQuality(tier);
|
motion.setMaximumQuality(tier);
|
||||||
adaptive.setMaximum(tier, 'manual');
|
adaptive.setMaximum(motion.policy.value.maximumTier, 'manual');
|
||||||
};
|
};
|
||||||
|
|
||||||
const enableMotion = (): void => {
|
const enableMotion = (): void => {
|
||||||
const tier = motion.maximumQuality.value === 'static' ? 'high' : motion.maximumQuality.value;
|
const tier = motion.maximumQuality.value === 'static' ? 'high' : motion.maximumQuality.value;
|
||||||
motion.setMaximumQuality(tier);
|
motion.setMaximumQuality(tier);
|
||||||
motion.setPaused(false);
|
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(() => {
|
onMounted(() => {
|
||||||
@@ -118,6 +126,7 @@ onUnmounted(() => removePageListeners());
|
|||||||
:policy="motion.policy.value"
|
:policy="motion.policy.value"
|
||||||
:quality="actualQuality"
|
:quality="actualQuality"
|
||||||
:scene-description="content.sceneDescription"
|
:scene-description="content.sceneDescription"
|
||||||
|
:sample-frame="adaptive.sampleFrame"
|
||||||
@state-change="sceneState = $event"
|
@state-change="sceneState = $event"
|
||||||
/>
|
/>
|
||||||
<JourneyHud
|
<JourneyHud
|
||||||
@@ -185,7 +194,7 @@ onUnmounted(() => removePageListeners());
|
|||||||
:audio-muted="audio.muted.value"
|
:audio-muted="audio.muted.value"
|
||||||
:audio-volume="controlsReady ? motion.volume.value : 0.4"
|
:audio-volume="controlsReady ? motion.volume.value : 0.4"
|
||||||
@pause="motion.setPaused(true)"
|
@pause="motion.setPaused(true)"
|
||||||
@resume="motion.setPaused(false)"
|
@resume="resumeMotion"
|
||||||
@enable="enableMotion"
|
@enable="enableMotion"
|
||||||
@quality-change="setQuality"
|
@quality-change="setQuality"
|
||||||
@audio-start="audio.startFromGesture"
|
@audio-start="audio.startFromGesture"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const props = defineProps<{
|
|||||||
policy: MotionPolicy;
|
policy: MotionPolicy;
|
||||||
quality: QualityTier;
|
quality: QualityTier;
|
||||||
sceneDescription: string;
|
sceneDescription: string;
|
||||||
|
sampleFrame?: (start: number, end: number) => unknown;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -20,13 +21,16 @@ const clientReady = ref(false);
|
|||||||
const journey = toRef(props, 'journey');
|
const journey = toRef(props, 'journey');
|
||||||
const policy = toRef(props, 'policy');
|
const policy = toRef(props, 'policy');
|
||||||
const quality = toRef(props, 'quality');
|
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 sceneState = computed(() => scene.state.value);
|
||||||
|
const sceneGeneration = computed(() => scene.snapshot.value?.currentGeneration ?? null);
|
||||||
const showCanvas = computed(() => (
|
const showCanvas = computed(() => (
|
||||||
clientReady.value
|
clientReady.value
|
||||||
&& policy.value.allowSceneRequest
|
|
||||||
&& quality.value !== 'static'
|
|
||||||
&& !scene.error.value
|
&& !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 });
|
watch(scene.state, (state) => emit('stateChange', state), { immediate: true });
|
||||||
@@ -35,7 +39,12 @@ onMounted(() => { clientReady.value = true; });
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<p id="journey-scene-description" class="visually-hidden">{{ sceneDescription }}</p>
|
<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">
|
<img class="journey-poster" alt="" width="1600" height="900">
|
||||||
<canvas v-if="showCanvas" ref="canvas" class="journey-canvas" aria-hidden="true" />
|
<canvas v-if="showCanvas" ref="canvas" class="journey-canvas" aria-hidden="true" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
type QualityDecision,
|
type QualityDecision,
|
||||||
} from '@/journey/adaptive-quality';
|
} from '@/journey/adaptive-quality';
|
||||||
import type { QualityTier } from '@/journey/motion-policy';
|
import type { QualityTier } from '@/journey/motion-policy';
|
||||||
|
import { lowerTier } from '@/journey/motion-policy';
|
||||||
|
|
||||||
const PASSIVE_LISTENER: AddEventListenerOptions = { passive: true };
|
const PASSIVE_LISTENER: AddEventListenerOptions = { passive: true };
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ export function useAdaptiveQuality(sources: AdaptiveQualitySources): AdaptiveQua
|
|||||||
tier: QualityTier,
|
tier: QualityTier,
|
||||||
reason: 'resize' | 'manual',
|
reason: 'resize' | 'manual',
|
||||||
): QualityDecision | null => {
|
): QualityDecision | null => {
|
||||||
const decision = controller.setMaximum(tier, reason);
|
const decision = controller.setMaximum(lowerTier(tier, sources.maximum.value), reason);
|
||||||
reconcileEpoch();
|
reconcileEpoch();
|
||||||
return decision;
|
return decision;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
computed,
|
||||||
onMounted,
|
onMounted,
|
||||||
onUnmounted,
|
onUnmounted,
|
||||||
readonly,
|
readonly,
|
||||||
@@ -41,7 +42,12 @@ export function useJourneySections(
|
|||||||
throw new Error("At least one canonical journey section is required.");
|
throw new Error("At least one canonical journey section is required.");
|
||||||
}
|
}
|
||||||
const drive = useScrollDrive();
|
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 scheduleMeasurement = (): void => {};
|
||||||
let teardown = (): void => {};
|
let teardown = (): void => {};
|
||||||
|
|
||||||
@@ -100,7 +106,7 @@ export function useJourneySections(
|
|||||||
const activeIndex = canonicalIds.indexOf(activeSectionId);
|
const activeIndex = canonicalIds.indexOf(activeSectionId);
|
||||||
const crossedBoundaries = Math.abs(activeIndex - previousActiveIndex);
|
const crossedBoundaries = Math.abs(activeIndex - previousActiveIndex);
|
||||||
const deltaY = scrollY - previousScrollY;
|
const deltaY = scrollY - previousScrollY;
|
||||||
const currentSign = snapshot.value.sign;
|
const currentSign = sectionSnapshot.value.sign;
|
||||||
const signTargetId =
|
const signTargetId =
|
||||||
currentSign?.targetId ?? canonicalIds[activeIndex + 1] ?? null;
|
currentSign?.targetId ?? canonicalIds[activeIndex + 1] ?? null;
|
||||||
const signTarget = headings.find(
|
const signTarget = headings.find(
|
||||||
@@ -119,7 +125,7 @@ export function useJourneySections(
|
|||||||
motionAllowed: motionAllowed?.value ?? true,
|
motionAllowed: motionAllowed?.value ?? true,
|
||||||
});
|
});
|
||||||
const sectionState = deriveSectionState(activeSectionId);
|
const sectionState = deriveSectionState(activeSectionId);
|
||||||
snapshot.value = {
|
sectionSnapshot.value = {
|
||||||
...drive.value,
|
...drive.value,
|
||||||
routeProgress: atDocumentEnd ? 1 : drive.value.routeProgress,
|
routeProgress: atDocumentEnd ? 1 : drive.value.routeProgress,
|
||||||
...sectionState,
|
...sectionState,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export interface SceneControllerSources {
|
|||||||
journey: Readonly<Ref<JourneySnapshot>>;
|
journey: Readonly<Ref<JourneySnapshot>>;
|
||||||
policy: Readonly<Ref<MotionPolicy>>;
|
policy: Readonly<Ref<MotionPolicy>>;
|
||||||
quality: Readonly<Ref<QualityTier>>;
|
quality: Readonly<Ref<QualityTier>>;
|
||||||
|
sampleFrame?: ((start: number, end: number) => unknown) | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSceneController(sources: SceneControllerSources): SceneControllerBinding {
|
export function useSceneController(sources: SceneControllerSources): SceneControllerBinding {
|
||||||
@@ -61,14 +62,26 @@ export function useSceneController(sources: SceneControllerSources): SceneContro
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
const allowed = sources.policy.value.allowSceneRequest && sources.quality.value !== 'static';
|
const allowed = sources.policy.value.allowSceneRequest && sources.quality.value !== 'static';
|
||||||
const canvas = sources.canvas.value;
|
const canvas = sources.canvas.value;
|
||||||
if (!allowed || canvas === null) {
|
if (!allowed) {
|
||||||
disposeActive(allowed ? (error.value ? 'failed' : 'idle') : 'static');
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (controller !== null) {
|
if (controller !== null) {
|
||||||
controller.update(sources.journey.value);
|
controller.update(sources.journey.value);
|
||||||
await controller.setQuality(sources.quality.value);
|
await controller.setQuality(sources.quality.value);
|
||||||
|
if (controller.snapshot().state.kind === 'paused') {
|
||||||
|
await controller.resume();
|
||||||
|
}
|
||||||
publish();
|
publish();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -94,6 +107,7 @@ export function useSceneController(sources: SceneControllerSources): SceneContro
|
|||||||
quality: sources.quality.value,
|
quality: sources.quality.value,
|
||||||
build: createProceduralSceneGeneration,
|
build: createProceduralSceneGeneration,
|
||||||
motionAllowed: () => sources.policy.value.allowSceneRequest,
|
motionAllowed: () => sources.policy.value.allowSceneRequest,
|
||||||
|
onFrameRendered: sources.sampleFrame,
|
||||||
signal: abort.signal,
|
signal: abort.signal,
|
||||||
});
|
});
|
||||||
controller = next;
|
controller = next;
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export function useScrollDrive(): Readonly<Ref<ScrollDriveSnapshot>> {
|
|||||||
? controller.reset(input)
|
? controller.reset(input)
|
||||||
: controller.sample(input);
|
: controller.sample(input);
|
||||||
resetOnNextFrame = false;
|
resetOnNextFrame = false;
|
||||||
|
if (snapshot.value.acceleration > 0) scheduleFrame();
|
||||||
};
|
};
|
||||||
|
|
||||||
const scheduleFrame = (): void => {
|
const scheduleFrame = (): void => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const DECAY_TIME_CONSTANT_MS = 420;
|
|||||||
const CRUISE_SPEED = 0.18;
|
const CRUISE_SPEED = 0.18;
|
||||||
const ACCELERATION_SPEED_RANGE = 0.82;
|
const ACCELERATION_SPEED_RANGE = 0.82;
|
||||||
const BACKGROUND_GAP_MS = 250;
|
const BACKGROUND_GAP_MS = 250;
|
||||||
|
const DECAY_SETTLE_EPSILON = 0.001;
|
||||||
|
|
||||||
const clamp = (value: number, minimum: number, maximum: number): number =>
|
const clamp = (value: number, minimum: number, maximum: number): number =>
|
||||||
Math.min(maximum, Math.max(minimum, value));
|
Math.min(maximum, Math.max(minimum, value));
|
||||||
@@ -87,6 +88,9 @@ export function createScrollDriveController(): ScrollDriveController {
|
|||||||
: DECAY_TIME_CONSTANT_MS;
|
: DECAY_TIME_CONSTANT_MS;
|
||||||
const alpha = 1 - Math.exp(-elapsedMs / timeConstantMs);
|
const alpha = 1 - Math.exp(-elapsedMs / timeConstantMs);
|
||||||
smoothedAcceleration += (impulse - smoothedAcceleration) * alpha;
|
smoothedAcceleration += (impulse - smoothedAcceleration) * alpha;
|
||||||
|
if (impulse === 0 && smoothedAcceleration < DECAY_SETTLE_EPSILON) {
|
||||||
|
smoothedAcceleration = 0;
|
||||||
|
}
|
||||||
previousSample = { ...input };
|
previousSample = { ...input };
|
||||||
|
|
||||||
const direction: -1 | 0 | 1 = deltaY > 0 ? 1 : deltaY < 0 ? -1 : 0;
|
const direction: -1 | 0 | 1 = deltaY > 0 ? 1 : deltaY < 0 ? -1 : 0;
|
||||||
|
|||||||
@@ -121,7 +121,10 @@ export function createSceneController(options: SceneControllerOptions): SceneCon
|
|||||||
rootResources.releaseAnimationFrame(frameId);
|
rootResources.releaseAnimationFrame(frameId);
|
||||||
if (overlay !== 'active' || machine.snapshot().kind !== 'running' || !current) return;
|
if (overlay !== 'active' || machine.snapshot().kind !== 'running' || !current) return;
|
||||||
try {
|
try {
|
||||||
|
const frameStartedAt = scheduler.now();
|
||||||
current.runtime.render(latestJourney, now);
|
current.runtime.render(latestJourney, now);
|
||||||
|
const frameEndedAt = scheduler.now();
|
||||||
|
options.onFrameRendered?.(frameStartedAt, frameEndedAt);
|
||||||
} catch {
|
} catch {
|
||||||
enterStatic('render-failure');
|
enterStatic('render-failure');
|
||||||
return;
|
return;
|
||||||
@@ -332,7 +335,7 @@ export function createSceneController(options: SceneControllerOptions): SceneCon
|
|||||||
pauseReason = 'freeze';
|
pauseReason = 'freeze';
|
||||||
break;
|
break;
|
||||||
case 'paused':
|
case 'paused':
|
||||||
pauseReason = 'freeze';
|
if (pauseReason !== 'visitor') pauseReason = 'freeze';
|
||||||
break;
|
break;
|
||||||
case 'lost':
|
case 'lost':
|
||||||
clearRestoreWatchdog();
|
clearRestoreWatchdog();
|
||||||
@@ -351,11 +354,17 @@ export function createSceneController(options: SceneControllerOptions): SceneCon
|
|||||||
const persisted = Boolean((event as Event & { persisted?: boolean }).persisted);
|
const persisted = Boolean((event as Event & { persisted?: boolean }).persisted);
|
||||||
if (!persisted || overlay !== 'frozen') return;
|
if (!persisted || overlay !== 'frozen') return;
|
||||||
overlay = 'active';
|
overlay = 'active';
|
||||||
|
const lifecycle = machine.snapshot();
|
||||||
if (!(options.motionAllowed?.() ?? true)) {
|
if (!(options.motionAllowed?.() ?? true)) {
|
||||||
|
if (
|
||||||
|
lifecycle.kind === 'paused'
|
||||||
|
&& current !== null
|
||||||
|
&& !lifecycle.resumeNeedsLoad
|
||||||
|
&& pauseReason === 'visitor'
|
||||||
|
) return;
|
||||||
enterStatic('motion-disallowed');
|
enterStatic('motion-disallowed');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const lifecycle = machine.snapshot();
|
|
||||||
if (page.visibilityState !== 'visible') return;
|
if (page.visibilityState !== 'visible') return;
|
||||||
if (lifecycle.kind === 'paused' && pauseReason === 'freeze') {
|
if (lifecycle.kind === 'paused' && pauseReason === 'freeze') {
|
||||||
pauseReason = null;
|
pauseReason = null;
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export interface SceneControllerOptions {
|
|||||||
scheduler?: SceneScheduler;
|
scheduler?: SceneScheduler;
|
||||||
page?: ScenePageEnvironment;
|
page?: ScenePageEnvironment;
|
||||||
motionAllowed?: () => boolean;
|
motionAllowed?: () => boolean;
|
||||||
|
onFrameRendered?: ((start: number, end: number) => unknown) | undefined;
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import type { SceneResourceBag } from '@/scene/resources';
|
|||||||
|
|
||||||
export function createCar(resources: SceneResourceBag) {
|
export function createCar(resources: SceneResourceBag) {
|
||||||
const group = new THREE.Group();
|
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({
|
const bodyMaterial = resources.own('material', new THREE.MeshStandardMaterial({
|
||||||
color: 0x111722,
|
color: 0x202938,
|
||||||
roughness: 0.28,
|
roughness: 0.28,
|
||||||
metalness: 0.78,
|
metalness: 0.78,
|
||||||
}));
|
}));
|
||||||
@@ -16,6 +17,12 @@ export function createCar(resources: SceneResourceBag) {
|
|||||||
metalness: 0.35,
|
metalness: 0.35,
|
||||||
}));
|
}));
|
||||||
const tailMaterial = resources.own('material', new THREE.MeshBasicMaterial({ color: 0xff315f }));
|
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({
|
const underglowMaterial = resources.own('material', new THREE.MeshBasicMaterial({
|
||||||
color: 0x28d8ef,
|
color: 0x28d8ef,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
@@ -39,6 +46,38 @@ export function createCar(resources: SceneResourceBag) {
|
|||||||
rearLight.position.set(0, 0.04, 3.08);
|
rearLight.position.set(0, 0.04, 3.08);
|
||||||
group.add(rearLight);
|
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 glowGeometry = resources.own('geometry', new THREE.PlaneGeometry(4.2, 6.2));
|
||||||
const underglow = new THREE.Mesh(glowGeometry, underglowMaterial);
|
const underglow = new THREE.Mesh(glowGeometry, underglowMaterial);
|
||||||
underglow.rotation.x = -Math.PI / 2;
|
underglow.rotation.x = -Math.PI / 2;
|
||||||
@@ -60,7 +99,7 @@ export function createCar(resources: SceneResourceBag) {
|
|||||||
group,
|
group,
|
||||||
update(speed: number, acceleration: number, elapsedSeconds: number) {
|
update(speed: number, acceleration: number, elapsedSeconds: number) {
|
||||||
const response = Math.min(1, Math.max(0, acceleration));
|
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;
|
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);
|
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);
|
underglowMaterial.opacity = 0.32 + Math.min(0.3, speed * 0.2);
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ const skyColors: Readonly<Record<EnvironmentChapter, number>> = {
|
|||||||
destination: 0x2b2035,
|
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(
|
export async function createProceduralSceneGeneration(
|
||||||
context: SceneBuildContext,
|
context: SceneBuildContext,
|
||||||
): Promise<SceneGeneration> {
|
): Promise<SceneGeneration> {
|
||||||
@@ -42,9 +49,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
const background = new THREE.Color(skyColors['deep-night']);
|
const background = new THREE.Color(skyColors['deep-night']);
|
||||||
scene.background = background;
|
scene.background = background;
|
||||||
scene.fog = new THREE.FogExp2(0x080c18, 0.012);
|
scene.fog = new THREE.FogExp2(0x080c18, 0.012);
|
||||||
const camera = new THREE.PerspectiveCamera(52, 1, 0.1, 320);
|
const camera = createChaseCamera();
|
||||||
camera.position.set(0, 4.8, 12.5);
|
|
||||||
camera.lookAt(0, 1.6, -22);
|
|
||||||
|
|
||||||
const ambient = new THREE.HemisphereLight(0x6aa9c5, 0x100812, 1.25);
|
const ambient = new THREE.HemisphereLight(0x6aa9c5, 0x100812, 1.25);
|
||||||
scene.add(ambient);
|
scene.add(ambient);
|
||||||
|
|||||||
@@ -44,6 +44,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.site-header {
|
.site-header {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
background: rgb(7 13 23 / 96%);
|
background: rgb(7 13 23 / 96%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ test('persisted lifecycle events preserve paused and Static states without dupli
|
|||||||
await page.getByRole('button', { name: 'Pause motion' }).click();
|
await page.getByRole('button', { name: 'Pause motion' }).click();
|
||||||
await dispatchPersistedPageTransition(page, 'pagehide');
|
await dispatchPersistedPageTransition(page, 'pagehide');
|
||||||
await dispatchPersistedPageTransition(page, 'pageshow');
|
await dispatchPersistedPageTransition(page, 'pageshow');
|
||||||
await expect(page.locator('.journey-canvas')).toHaveCount(0);
|
await expect(page.locator('.journey-canvas')).toHaveCount(1);
|
||||||
await expect(page.getByRole('button', { name: 'Resume motion' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Resume motion' })).toBeVisible();
|
||||||
|
|
||||||
await page.getByLabel('Quality control').selectOption('static');
|
await page.getByLabel('Quality control').selectOption('static');
|
||||||
|
|||||||
@@ -10,6 +10,37 @@ test('keeps the decorative world separate from the semantic reading path', async
|
|||||||
await expect(page.locator('main#portfolio-content')).toBeVisible();
|
await expect(page.locator('main#portfolio-content')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('keeps the desktop identity and navigation in a paint layer above the world', async ({ page, distUrl }) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||||
|
|
||||||
|
const layers = await page.evaluate(() => {
|
||||||
|
const world = document.querySelector<HTMLElement>('.journey-world');
|
||||||
|
const header = document.querySelector<HTMLElement>('.site-header');
|
||||||
|
const heading = document.querySelector<HTMLElement>('.site-header h1');
|
||||||
|
const navigation = document.querySelector<HTMLElement>('.site-navigation');
|
||||||
|
if (!world || !header || !heading || !navigation) throw new Error('Missing top-screen structure');
|
||||||
|
const worldStyle = getComputedStyle(world);
|
||||||
|
const headerStyle = getComputedStyle(header);
|
||||||
|
return {
|
||||||
|
worldZ: Number(worldStyle.zIndex),
|
||||||
|
headerZ: Number(headerStyle.zIndex),
|
||||||
|
headerPosition: headerStyle.position,
|
||||||
|
headingVisible: heading.getBoundingClientRect().width > 0 && heading.getBoundingClientRect().height > 0,
|
||||||
|
navigationVisible: navigation.getBoundingClientRect().width > 0 && navigation.getBoundingClientRect().height > 0,
|
||||||
|
headerOpacity: Number.parseFloat(headerStyle.backgroundColor.match(/[\d.]+(?=\))/gu)?.at(-1) ?? '1'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(layers).toMatchObject({
|
||||||
|
headerPosition: 'relative',
|
||||||
|
headingVisible: true,
|
||||||
|
navigationVisible: true,
|
||||||
|
});
|
||||||
|
expect(layers.headerZ).toBeGreaterThan(layers.worldZ);
|
||||||
|
expect(layers.headerOpacity).toBeGreaterThanOrEqual(0.94);
|
||||||
|
});
|
||||||
|
|
||||||
test('provides native motion and quality controls with visible state', async ({ page, distUrl }) => {
|
test('provides native motion and quality controls with visible state', async ({ page, distUrl }) => {
|
||||||
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||||
|
|
||||||
@@ -17,13 +48,21 @@ test('provides native motion and quality controls with visible state', async ({
|
|||||||
await expect(quality).toBeVisible();
|
await expect(quality).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'Pause motion' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Pause motion' })).toBeVisible();
|
||||||
await expect(page.getByRole('status')).toContainText('Motion:');
|
await expect(page.getByRole('status')).toContainText('Motion:');
|
||||||
|
const canvas = page.locator('.journey-canvas');
|
||||||
|
const originalCanvas = await canvas.elementHandle();
|
||||||
|
const originalGeneration = await page.locator('.journey-world').getAttribute('data-scene-generation');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Pause motion' }).click();
|
await page.getByRole('button', { name: 'Pause motion' }).click();
|
||||||
await expect(page.getByRole('button', { name: 'Resume motion' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Resume motion' })).toBeVisible();
|
||||||
await expect(page.locator('.journey-canvas')).toHaveCount(0);
|
await expect(canvas).toHaveCount(1);
|
||||||
|
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-generation', originalGeneration ?? '');
|
||||||
|
expect(await originalCanvas?.evaluate((element) => element.isConnected)).toBe(true);
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Resume motion' }).click();
|
await page.getByRole('button', { name: 'Resume motion' }).click();
|
||||||
await expect(page.getByRole('button', { name: 'Pause motion' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Pause motion' })).toBeVisible();
|
||||||
|
await expect(canvas).toHaveCount(1);
|
||||||
|
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-generation', originalGeneration ?? '');
|
||||||
|
expect(await originalCanvas?.evaluate((element) => element.isConnected)).toBe(true);
|
||||||
|
|
||||||
await quality.selectOption('static');
|
await quality.selectOption('static');
|
||||||
await expect(page.getByRole('button', { name: 'Enable motion' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Enable motion' })).toBeVisible();
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ const policy = (overrides: Partial<MotionPolicy> = {}): MotionPolicy => ({
|
|||||||
|
|
||||||
describe('SceneCanvas', () => {
|
describe('SceneCanvas', () => {
|
||||||
beforeEach(() => sceneMocks.build.mockClear());
|
beforeEach(() => sceneMocks.build.mockClear());
|
||||||
afterEach(() => vi.restoreAllMocks());
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['reduced motion', policy({ allowSceneRequest: false, maximumTier: 'static', reason: 'system-reduce' })],
|
['reduced motion', policy({ allowSceneRequest: false, maximumTier: 'static', reason: 'system-reduce' })],
|
||||||
@@ -81,6 +84,36 @@ describe('SceneCanvas', () => {
|
|||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('forwards each complete scene frame interval to adaptive quality', async () => {
|
||||||
|
const callbacks = new Map<number, FrameRequestCallback>();
|
||||||
|
let nextFrameId = 1;
|
||||||
|
vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => {
|
||||||
|
const id = nextFrameId++;
|
||||||
|
callbacks.set(id, callback);
|
||||||
|
return id;
|
||||||
|
}));
|
||||||
|
vi.stubGlobal('cancelAnimationFrame', vi.fn((id: number) => callbacks.delete(id)));
|
||||||
|
const sampleFrame = vi.fn();
|
||||||
|
const wrapper = mount(SceneCanvas, {
|
||||||
|
props: {
|
||||||
|
journey: journey(),
|
||||||
|
policy: policy(),
|
||||||
|
quality: 'high',
|
||||||
|
sceneDescription: 'A decorative night road behind the portfolio content.',
|
||||||
|
sampleFrame,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
const frame = [...callbacks.entries()][0]!;
|
||||||
|
callbacks.delete(frame[0]);
|
||||||
|
vi.spyOn(performance, 'now').mockReturnValueOnce(2_000).mockReturnValueOnce(2_301);
|
||||||
|
|
||||||
|
frame[1](2_000);
|
||||||
|
|
||||||
|
expect(sampleFrame).toHaveBeenCalledWith(2_000, 2_301);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
it('falls back to the poster without obscuring semantic content after scene failure', async () => {
|
it('falls back to the poster without obscuring semantic content after scene failure', async () => {
|
||||||
sceneMocks.build.mockRejectedValueOnce(new Error('WebGL unavailable'));
|
sceneMocks.build.mockRejectedValueOnce(new Error('WebGL unavailable'));
|
||||||
const wrapper = mount(SceneCanvas, {
|
const wrapper = mount(SceneCanvas, {
|
||||||
@@ -123,6 +156,38 @@ describe('SceneCanvas', () => {
|
|||||||
expect(wrapper.get('img').isVisible()).toBe(true);
|
expect(wrapper.get('img').isVisible()).toBe(true);
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('retains the same canvas and scene generation for a visitor pause and resume', async () => {
|
||||||
|
const wrapper = mount(SceneCanvas, {
|
||||||
|
props: {
|
||||||
|
journey: journey(),
|
||||||
|
policy: policy(),
|
||||||
|
quality: 'high',
|
||||||
|
sceneDescription: 'A decorative night road behind the portfolio content.',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
const canvas = wrapper.get('canvas').element;
|
||||||
|
const runtime = await sceneMocks.build.mock.results[0]!.value;
|
||||||
|
|
||||||
|
await wrapper.setProps({
|
||||||
|
policy: policy({ allowSceneRequest: false, maximumTier: 'static', reason: 'visitor-pause' }),
|
||||||
|
quality: 'high',
|
||||||
|
});
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(wrapper.get('canvas').element).toBe(canvas);
|
||||||
|
expect(runtime.dispose).not.toHaveBeenCalled();
|
||||||
|
expect(sceneMocks.build).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await wrapper.setProps({ policy: policy() });
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(wrapper.get('canvas').element).toBe(canvas);
|
||||||
|
expect(runtime.dispose).not.toHaveBeenCalled();
|
||||||
|
expect(sceneMocks.build).toHaveBeenCalledTimes(1);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('DriveControls', () => {
|
describe('DriveControls', () => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type AdaptiveQualityController,
|
type AdaptiveQualityController,
|
||||||
type QualityDecision,
|
type QualityDecision,
|
||||||
} from '@/journey/adaptive-quality';
|
} from '@/journey/adaptive-quality';
|
||||||
|
import type { QualityTier } from '@/journey/motion-policy';
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
@@ -265,6 +266,26 @@ describe('useAdaptiveQuality', () => {
|
|||||||
expect(wrapper.vm.snapshot.epoch).toBeNull();
|
expect(wrapper.vm.snapshot.epoch).toBeNull();
|
||||||
expect(documentRemove).toHaveBeenCalledWith('visibilitychange', expect.any(Function), { passive: true });
|
expect(documentRemove).toHaveBeenCalledWith('visibilitychange', expect.any(Function), { passive: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('never lets a visitor request bypass the device-resolved maximum', async () => {
|
||||||
|
const { useAdaptiveQuality } = await import('@/composables/useAdaptiveQuality');
|
||||||
|
Object.defineProperty(document, 'visibilityState', {
|
||||||
|
configurable: true,
|
||||||
|
value: 'visible',
|
||||||
|
});
|
||||||
|
const maximum = ref<QualityTier>('low');
|
||||||
|
const sceneState = ref('running');
|
||||||
|
const pageLifecycle = ref<'active'>('active');
|
||||||
|
const wrapper = mount(defineComponent({
|
||||||
|
setup: () => useAdaptiveQuality({ maximum, sceneState, pageLifecycle, now: () => 0 }),
|
||||||
|
template: '<output />',
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(wrapper.vm.setMaximum('high', 'manual')).toBeNull();
|
||||||
|
expect(wrapper.vm.snapshot).toMatchObject({ maximum: 'low', actual: 'low' });
|
||||||
|
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function feedWindow(
|
function feedWindow(
|
||||||
|
|||||||
@@ -89,6 +89,51 @@ describe('scene controller lifecycle', () => {
|
|||||||
expect(harness.generations[0]?.render).toHaveBeenCalledOnce();
|
expect(harness.generations[0]?.render).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('samples the complete scene update and render interval', async () => {
|
||||||
|
const harness = createHarness();
|
||||||
|
const renderedFrames: Array<{ start: number; end: number }> = [];
|
||||||
|
const controller = createSceneController({
|
||||||
|
...harness.options,
|
||||||
|
onFrameRendered: (start, end) => renderedFrames.push({ start, end }),
|
||||||
|
});
|
||||||
|
await controller.start();
|
||||||
|
harness.generations[0]?.render.mockImplementation(() => {
|
||||||
|
harness.clock.now += 300;
|
||||||
|
});
|
||||||
|
|
||||||
|
harness.scheduler.flushFrame(2_000);
|
||||||
|
|
||||||
|
expect(renderedFrames).toEqual([{ start: 2_000, end: 2_300 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains the same complete generation and frame across a visitor pause', async () => {
|
||||||
|
const harness = createHarness();
|
||||||
|
const controller = createSceneController(harness.options);
|
||||||
|
await controller.start();
|
||||||
|
const running = controller.snapshot();
|
||||||
|
|
||||||
|
controller.pause();
|
||||||
|
|
||||||
|
expect(controller.snapshot()).toMatchObject({
|
||||||
|
state: { kind: 'paused', generation: running.currentGeneration, resumeNeedsLoad: false },
|
||||||
|
currentGeneration: running.currentGeneration,
|
||||||
|
canvasCount: 1,
|
||||||
|
rafCount: 0,
|
||||||
|
});
|
||||||
|
expect(harness.generations).toHaveLength(1);
|
||||||
|
expect(harness.generations[0]?.dispose).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await controller.resume();
|
||||||
|
|
||||||
|
expect(controller.snapshot()).toMatchObject({
|
||||||
|
state: { kind: 'running', generation: running.currentGeneration },
|
||||||
|
currentGeneration: running.currentGeneration,
|
||||||
|
canvasCount: 1,
|
||||||
|
rafCount: 1,
|
||||||
|
});
|
||||||
|
expect(harness.generations).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('copies serializable journey snapshots instead of retaining caller objects', async () => {
|
it('copies serializable journey snapshots instead of retaining caller objects', async () => {
|
||||||
const harness = createHarness();
|
const harness = createHarness();
|
||||||
const controller = createSceneController(harness.options);
|
const controller = createSceneController(harness.options);
|
||||||
@@ -266,7 +311,15 @@ describe('scene controller lifecycle', () => {
|
|||||||
await controller.whenSettled();
|
await controller.whenSettled();
|
||||||
|
|
||||||
expect(controller.snapshot().overlay).toBe('active');
|
expect(controller.snapshot().overlay).toBe('active');
|
||||||
if (source === 'running' || source === 'paused') expect(controller.snapshot().state.kind).toBe('running');
|
if (source === 'running') expect(controller.snapshot().state.kind).toBe('running');
|
||||||
|
if (source === 'paused') {
|
||||||
|
expect(controller.snapshot()).toMatchObject({
|
||||||
|
state: { kind: 'paused', generation: 1, resumeNeedsLoad: false },
|
||||||
|
currentGeneration: 1,
|
||||||
|
rafCount: 0,
|
||||||
|
});
|
||||||
|
expect(harness.generations[0]?.dispose).not.toHaveBeenCalled();
|
||||||
|
}
|
||||||
if (source === 'loading') expect(harness.buildContexts.length).toBe(beforeShow + 1);
|
if (source === 'loading') expect(harness.buildContexts.length).toBe(beforeShow + 1);
|
||||||
if (source === 'static') expect(controller.snapshot().state.kind).toBe('static');
|
if (source === 'static') expect(controller.snapshot().state.kind).toBe('static');
|
||||||
if (start && source === 'loading') {
|
if (start && source === 'loading') {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import * as THREE from 'three';
|
||||||
import { SceneResourceBag } from '@/scene/resources';
|
import { SceneResourceBag } from '@/scene/resources';
|
||||||
import { renderQualityFor } from '@/scene/render-quality';
|
import { renderQualityFor } from '@/scene/render-quality';
|
||||||
|
import { createChaseCamera } from '@/scene/world/environment';
|
||||||
|
import { createCar } from '@/scene/world/car';
|
||||||
import { routeLabelFor } from '@/scene/world/sign';
|
import { routeLabelFor } from '@/scene/world/sign';
|
||||||
|
|
||||||
describe('SceneResourceBag', () => {
|
describe('SceneResourceBag', () => {
|
||||||
@@ -109,4 +112,48 @@ describe('scene render quality', () => {
|
|||||||
expect(routeLabelFor('infrastructure')).toBe('03 / INFRASTRUCTURE');
|
expect(routeLabelFor('infrastructure')).toBe('03 / INFRASTRUCTURE');
|
||||||
expect(routeLabelFor('contact')).toBe('07 / DESTINATION');
|
expect(routeLabelFor('contact')).toBe('07 / DESTINATION');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the chase car recognizably wide and fully inside the desktop view', () => {
|
||||||
|
const resources = new SceneResourceBag();
|
||||||
|
const camera = createChaseCamera(16 / 9);
|
||||||
|
const car = createCar(resources);
|
||||||
|
car.group.updateMatrixWorld(true);
|
||||||
|
camera.updateMatrixWorld(true);
|
||||||
|
const bounds = new THREE.Box3().setFromObject(car.group);
|
||||||
|
const projected = boxCorners(bounds).map((point) => point.project(camera));
|
||||||
|
const minX = Math.min(...projected.map((point) => point.x));
|
||||||
|
const maxX = Math.max(...projected.map((point) => point.x));
|
||||||
|
const minY = Math.min(...projected.map((point) => point.y));
|
||||||
|
const maxY = Math.max(...projected.map((point) => point.y));
|
||||||
|
|
||||||
|
expect(maxX - minX).toBeGreaterThan(0.22);
|
||||||
|
expect(minY).toBeGreaterThan(-0.98);
|
||||||
|
expect(maxY).toBeLessThan(-0.15);
|
||||||
|
const silhouette = car.group.getObjectByName('rear-silhouette');
|
||||||
|
expect(silhouette).toBeDefined();
|
||||||
|
const silhouetteBounds = new THREE.Box3().setFromObject(silhouette);
|
||||||
|
expect(silhouetteBounds.max.x - silhouetteBounds.min.x).toBeGreaterThan(4);
|
||||||
|
expect(Math.max(
|
||||||
|
silhouette.material.color.r,
|
||||||
|
silhouette.material.color.g,
|
||||||
|
silhouette.material.color.b,
|
||||||
|
)).toBeGreaterThan(0.12);
|
||||||
|
resources.dispose();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function boxCorners(bounds: {
|
||||||
|
min: { x: number; y: number; z: number };
|
||||||
|
max: { x: number; y: number; z: number };
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
|
new THREE.Vector3(bounds.min.x, bounds.min.y, bounds.min.z),
|
||||||
|
new THREE.Vector3(bounds.min.x, bounds.min.y, bounds.max.z),
|
||||||
|
new THREE.Vector3(bounds.min.x, bounds.max.y, bounds.min.z),
|
||||||
|
new THREE.Vector3(bounds.min.x, bounds.max.y, bounds.max.z),
|
||||||
|
new THREE.Vector3(bounds.max.x, bounds.min.y, bounds.min.z),
|
||||||
|
new THREE.Vector3(bounds.max.x, bounds.min.y, bounds.max.z),
|
||||||
|
new THREE.Vector3(bounds.max.x, bounds.max.y, bounds.min.z),
|
||||||
|
new THREE.Vector3(bounds.max.x, bounds.max.y, bounds.max.z),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|||||||
@@ -247,6 +247,33 @@ describe('useScrollDrive', () => {
|
|||||||
acceleration: expect.closeTo(0.6708070121920944, 14),
|
acceleration: expect.closeTo(0.6708070121920944, 14),
|
||||||
driveSpeed: expect.closeTo(0.7300617499975173, 14),
|
driveSpeed: expect.closeTo(0.7300617499975173, 14),
|
||||||
});
|
});
|
||||||
|
expect(callbacks).toHaveLength(1);
|
||||||
|
|
||||||
|
const decayingAccelerations: number[] = [];
|
||||||
|
let frameNow = 216;
|
||||||
|
while (callbacks.size > 0 && frameNow < 10_000) {
|
||||||
|
expect(callbacks).toHaveLength(1);
|
||||||
|
const decayFrame = [...callbacks.entries()][0]!;
|
||||||
|
callbacks.delete(decayFrame[0]);
|
||||||
|
decayFrame[1](frameNow);
|
||||||
|
await nextTick();
|
||||||
|
decayingAccelerations.push(drive().acceleration);
|
||||||
|
frameNow += 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(decayingAccelerations.length).toBeGreaterThan(1);
|
||||||
|
expect(decayingAccelerations.every((value, index) => (
|
||||||
|
index === 0 || value <= decayingAccelerations[index - 1]!
|
||||||
|
))).toBe(true);
|
||||||
|
expect(decayingAccelerations.at(-1)).toBe(0);
|
||||||
|
expect(drive()).toMatchObject({
|
||||||
|
scrollY: 750,
|
||||||
|
routeProgress: 0.75,
|
||||||
|
direction: 0,
|
||||||
|
acceleration: 0,
|
||||||
|
driveSpeed: 0.18,
|
||||||
|
});
|
||||||
|
expect(callbacks).toHaveLength(0);
|
||||||
|
|
||||||
window.dispatchEvent(new Event('scroll'));
|
window.dispatchEvent(new Event('scroll'));
|
||||||
expect(callbacks).toHaveLength(1);
|
expect(callbacks).toHaveLength(1);
|
||||||
|
|||||||
Reference in New Issue
Block a user