feat: integrate accessible journey world and controls

This commit is contained in:
2026-09-02 05:07:24 +03:00
parent fd8347fbdc
commit 190ac1ce62
10 changed files with 755 additions and 15 deletions

View File

@@ -0,0 +1,141 @@
import {
onMounted,
onUnmounted,
readonly,
shallowRef,
watch,
type Ref,
type WatchStopHandle,
} from 'vue';
import { createSceneController } from '@/scene/create-scene-controller';
import type { JourneySnapshot } from '@/journey/types';
import type { MotionPolicy, QualityTier } from '@/journey/motion-policy';
import type { SceneController, SceneControllerSnapshot } from '@/scene/types';
export type SceneHostState = 'idle' | 'loading' | 'running' | 'paused' | 'static' | 'failed' | 'disposed';
export interface SceneControllerBinding {
state: Readonly<Ref<SceneHostState>>;
snapshot: Readonly<Ref<SceneControllerSnapshot | null>>;
error: Readonly<Ref<boolean>>;
}
export interface SceneControllerSources {
canvas: Readonly<Ref<HTMLCanvasElement | null>>;
journey: Readonly<Ref<JourneySnapshot>>;
policy: Readonly<Ref<MotionPolicy>>;
quality: Readonly<Ref<QualityTier>>;
}
export function useSceneController(sources: SceneControllerSources): SceneControllerBinding {
const state = shallowRef<SceneHostState>('idle');
const snapshot = shallowRef<SceneControllerSnapshot | null>(null);
const error = shallowRef(false);
let controller: SceneController | null = null;
let importAbort: AbortController | null = null;
let generation = 0;
let mounted = false;
let stopWatch: WatchStopHandle = () => {};
let removePageHide = (): void => {};
const publish = (): void => {
snapshot.value = controller?.snapshot() ?? null;
const next = snapshot.value?.state.kind;
state.value = next === undefined
? (error.value ? 'failed' : 'static')
: next === 'lost' || next === 'restoring' ? 'loading' : next;
};
const disposeActive = (nextState: SceneHostState): void => {
generation += 1;
importAbort?.abort();
importAbort = null;
const active = controller;
controller = null;
active?.dispose();
snapshot.value = active?.snapshot() ?? null;
state.value = nextState;
};
const reconcile = async (): Promise<void> => {
if (!mounted) return;
const allowed = sources.policy.value.allowSceneRequest && sources.quality.value !== 'static';
const canvas = sources.canvas.value;
if (!allowed || canvas === null) {
disposeActive(allowed ? 'idle' : 'static');
return;
}
if (controller !== null) {
controller.update(sources.journey.value);
await controller.setQuality(sources.quality.value);
publish();
return;
}
const currentGeneration = ++generation;
const abort = new AbortController();
importAbort = abort;
error.value = false;
state.value = 'loading';
try {
const { createProceduralSceneGeneration } = await import('@/scene/world/environment');
if (
abort.signal.aborted
|| !mounted
|| currentGeneration !== generation
|| !sources.policy.value.allowSceneRequest
|| sources.quality.value === 'static'
|| sources.canvas.value !== canvas
) return;
const next = createSceneController({
canvas,
quality: sources.quality.value,
build: createProceduralSceneGeneration,
motionAllowed: () => sources.policy.value.allowSceneRequest,
signal: abort.signal,
});
controller = next;
next.update(sources.journey.value);
await next.start();
if (controller !== next || currentGeneration !== generation) {
next.dispose();
return;
}
const kind = next.snapshot().state.kind;
error.value = kind === 'static';
publish();
} catch {
if (!abort.signal.aborted && currentGeneration === generation) {
error.value = true;
disposeActive('failed');
}
} finally {
if (importAbort === abort) importAbort = null;
}
};
onMounted(() => {
mounted = true;
const onPageHide = (event: PageTransitionEvent): void => {
if (!event.persisted) disposeActive('disposed');
};
window.addEventListener('pagehide', onPageHide);
removePageHide = () => window.removeEventListener('pagehide', onPageHide);
stopWatch = watch(
[sources.canvas, sources.policy, sources.quality, sources.journey],
() => { void reconcile(); },
{ immediate: true, flush: 'post' },
);
});
onUnmounted(() => {
mounted = false;
stopWatch();
removePageHide();
disposeActive('disposed');
});
return { state: readonly(state), snapshot: readonly(snapshot), error: readonly(error) };
}