feat: integrate accessible journey world and controls
This commit is contained in:
141
src/composables/useSceneController.ts
Normal file
141
src/composables/useSceneController.ts
Normal 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) };
|
||||
}
|
||||
Reference in New Issue
Block a user