auto-git:

[change] src/app/app.css
 [change] src/commands/set-scene-loading-screen-command.ts
 [change] src/document/migrate-scene-document.ts
 [change] src/document/scene-document-validation.ts
 [change] src/document/scene-document.ts
 [change] src/runner-web/RunnerCanvas.tsx
 [change] src/runtime-three/first-person-navigation-controller.ts
 [change] src/runtime-three/navigation-controller.ts
 [change] src/runtime-three/orbit-visitor-navigation-controller.ts
 [change] src/runtime-three/runtime-host.ts
 [change] tests/domain/editor-store.test.ts
 [change] tests/serialization/local-draft-storage.test.ts
 [change] tests/serialization/project-document-json.test.ts
 [change] tests/serialization/project-package.test.ts
 [change] tests/unit/runner-canvas.test.tsx
 [change] tests/unit/runtime-host.test.ts
This commit is contained in:
2026-04-11 04:19:50 +02:00
parent 75986da19d
commit e205cea50c
16 changed files with 2232 additions and 563 deletions

View File

@@ -32,7 +32,11 @@ body {
min-height: 100vh; min-height: 100vh;
overflow: hidden; overflow: hidden;
background: background:
radial-gradient(circle at top, rgba(80, 96, 120, 0.35) 0%, rgba(23, 28, 37, 0) 42%), radial-gradient(
circle at top,
rgba(80, 96, 120, 0.35) 0%,
rgba(23, 28, 37, 0) 42%
),
linear-gradient(180deg, #1b2029 0%, #101318 100%); linear-gradient(180deg, #1b2029 0%, #101318 100%);
color: var(--color-text); color: var(--color-text);
} }
@@ -203,7 +207,10 @@ button:disabled {
.workspace { .workspace {
display: grid; display: grid;
grid-template-columns: minmax(240px, 280px) minmax(0, 1fr) minmax(280px, 320px); grid-template-columns: minmax(240px, 280px) minmax(0, 1fr) minmax(
280px,
320px
);
gap: 12px; gap: 12px;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
@@ -296,7 +303,11 @@ button:disabled {
.stat-card { .stat-card {
padding: 12px; padding: 12px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.02) 100%); background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.04) 0%,
rgba(255, 255, 255, 0.02) 100%
);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 14px; border-radius: 14px;
} }
@@ -988,7 +999,11 @@ button:disabled {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
background: background:
radial-gradient(circle at top, rgba(130, 154, 188, 0.28) 0%, rgba(130, 154, 188, 0) 38%), radial-gradient(
circle at top,
rgba(130, 154, 188, 0.28) 0%,
rgba(130, 154, 188, 0) 38%
),
linear-gradient(180deg, #55657c 0%, #2c3440 34%, #151920 100%); linear-gradient(180deg, #55657c 0%, #2c3440 34%, #151920 100%);
} }
@@ -1145,7 +1160,11 @@ button:disabled {
gap: 8px; gap: 8px;
padding: 18px; padding: 18px;
color: #f3e8da; color: #f3e8da;
background: linear-gradient(180deg, rgba(8, 10, 14, 0.12) 0%, rgba(8, 10, 14, 0.58) 100%); background: linear-gradient(
180deg,
rgba(8, 10, 14, 0.12) 0%,
rgba(8, 10, 14, 0.58) 100%
);
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 18px; border-radius: 18px;
pointer-events: auto; pointer-events: auto;
@@ -1263,7 +1282,11 @@ button:disabled {
z-index: 2; z-index: 2;
pointer-events: none; pointer-events: none;
background: background:
radial-gradient(circle at 50% 18%, rgba(146, 223, 255, 0.2), transparent 42%), radial-gradient(
circle at 50% 18%,
rgba(146, 223, 255, 0.2),
transparent 42%
),
linear-gradient(180deg, rgba(38, 113, 153, 0.16), rgba(8, 40, 63, 0.42)); linear-gradient(180deg, rgba(38, 113, 153, 0.16), rgba(8, 40, 63, 0.42));
backdrop-filter: blur(1.5px) saturate(1.08); backdrop-filter: blur(1.5px) saturate(1.08);
mix-blend-mode: screen; mix-blend-mode: screen;

View File

@@ -70,7 +70,9 @@ export function createSetSceneLoadingScreenCommand(
...currentProjectDocument.scenes, ...currentProjectDocument.scenes,
[options.sceneId]: { [options.sceneId]: {
...currentScene, ...currentScene,
loadingScreen: cloneSceneLoadingScreenSettings(previousLoadingScreen) loadingScreen: cloneSceneLoadingScreenSettings(
previousLoadingScreen
)
} }
} }
}); });

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -4,8 +4,15 @@ import type { ModelInstance } from "../assets/model-instances";
import type { ProjectAssetRecord } from "../assets/project-assets"; import type { ProjectAssetRecord } from "../assets/project-assets";
import type { EntityInstance } from "../entities/entity-instances"; import type { EntityInstance } from "../entities/entity-instances";
import type { InteractionLink } from "../interactions/interaction-links"; import type { InteractionLink } from "../interactions/interaction-links";
import { cloneMaterialRegistry, createStarterMaterialRegistry, type MaterialDef } from "../materials/starter-material-library"; import {
import { createDefaultWorldSettings, type WorldSettings } from "./world-settings"; cloneMaterialRegistry,
createStarterMaterialRegistry,
type MaterialDef
} from "../materials/starter-material-library";
import {
createDefaultWorldSettings,
type WorldSettings
} from "./world-settings";
export const SCENE_DOCUMENT_VERSION = 23 as const; export const SCENE_DOCUMENT_VERSION = 23 as const;
export const MULTI_SCENE_FOUNDATION_SCENE_DOCUMENT_VERSION = 22 as const; export const MULTI_SCENE_FOUNDATION_SCENE_DOCUMENT_VERSION = 22 as const;
@@ -13,7 +20,8 @@ export const WATER_SURFACE_DISPLACEMENT_SCENE_DOCUMENT_VERSION = 21 as const;
export const WHITEBOX_BOX_VOLUME_SCENE_DOCUMENT_VERSION = 20 as const; export const WHITEBOX_BOX_VOLUME_SCENE_DOCUMENT_VERSION = 20 as const;
export const WHITEBOX_GEOMETRY_SCENE_DOCUMENT_VERSION = 19 as const; export const WHITEBOX_GEOMETRY_SCENE_DOCUMENT_VERSION = 19 as const;
export const WHITEBOX_FLOAT_TRANSFORM_SCENE_DOCUMENT_VERSION = 18 as const; export const WHITEBOX_FLOAT_TRANSFORM_SCENE_DOCUMENT_VERSION = 18 as const;
export const PLAYER_START_COLLIDER_SETTINGS_SCENE_DOCUMENT_VERSION = 17 as const; export const PLAYER_START_COLLIDER_SETTINGS_SCENE_DOCUMENT_VERSION =
17 as const;
export const IMPORTED_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION = 16 as const; export const IMPORTED_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION = 16 as const;
export const ENTITY_NAMES_SCENE_DOCUMENT_VERSION = 15 as const; export const ENTITY_NAMES_SCENE_DOCUMENT_VERSION = 15 as const;
export const SPATIAL_AUDIO_SCENE_DOCUMENT_VERSION = 13 as const; export const SPATIAL_AUDIO_SCENE_DOCUMENT_VERSION = 13 as const;
@@ -27,7 +35,8 @@ export const RUNNER_V1_SCENE_DOCUMENT_VERSION = 4 as const;
export const FIRST_ROOM_POLISH_SCENE_DOCUMENT_VERSION = 5 as const; export const FIRST_ROOM_POLISH_SCENE_DOCUMENT_VERSION = 5 as const;
export const WORLD_ENVIRONMENT_SCENE_DOCUMENT_VERSION = 6 as const; export const WORLD_ENVIRONMENT_SCENE_DOCUMENT_VERSION = 6 as const;
export const ENTITY_SYSTEM_FOUNDATION_SCENE_DOCUMENT_VERSION = 7 as const; export const ENTITY_SYSTEM_FOUNDATION_SCENE_DOCUMENT_VERSION = 7 as const;
export const TRIGGER_ACTION_TARGET_FOUNDATION_SCENE_DOCUMENT_VERSION = 8 as const; export const TRIGGER_ACTION_TARGET_FOUNDATION_SCENE_DOCUMENT_VERSION =
8 as const;
export const RUNNER_LOADING_SCREEN_SCENE_DOCUMENT_VERSION = 23 as const; export const RUNNER_LOADING_SCREEN_SCENE_DOCUMENT_VERSION = 23 as const;
export const DEFAULT_PROJECT_SCENE_ID = "scene-main" as const; export const DEFAULT_PROJECT_SCENE_ID = "scene-main" as const;
@@ -71,12 +80,16 @@ export interface SceneDocument {
interactionLinks: Record<string, InteractionLink>; interactionLinks: Record<string, InteractionLink>;
} }
export function createEmptySceneDocument(overrides: Partial<Pick<SceneDocument, "name" | "world" | "materials">> = {}): SceneDocument { export function createEmptySceneDocument(
overrides: Partial<Pick<SceneDocument, "name" | "world" | "materials">> = {}
): SceneDocument {
return { return {
version: SCENE_DOCUMENT_VERSION, version: SCENE_DOCUMENT_VERSION,
name: overrides.name ?? "Untitled Scene", name: overrides.name ?? "Untitled Scene",
world: overrides.world ?? createDefaultWorldSettings(), world: overrides.world ?? createDefaultWorldSettings(),
materials: cloneMaterialRegistry(overrides.materials ?? createStarterMaterialRegistry()), materials: cloneMaterialRegistry(
overrides.materials ?? createStarterMaterialRegistry()
),
textures: {}, textures: {},
assets: {}, assets: {},
brushes: {}, brushes: {},
@@ -87,7 +100,9 @@ export function createEmptySceneDocument(overrides: Partial<Pick<SceneDocument,
} }
export function createEmptyProjectScene( export function createEmptyProjectScene(
overrides: Partial<Pick<ProjectScene, "id" | "name" | "loadingScreen" | "world">> = {} overrides: Partial<
Pick<ProjectScene, "id" | "name" | "loadingScreen" | "world">
> = {}
): ProjectScene { ): ProjectScene {
return { return {
id: overrides.id ?? createOpaqueId("scene"), id: overrides.id ?? createOpaqueId("scene"),
@@ -113,7 +128,8 @@ export function createEmptyProjectDocument(
} = {} } = {}
): ProjectDocument { ): ProjectDocument {
const initialScene = createEmptyProjectScene({ const initialScene = createEmptyProjectScene({
id: overrides.sceneId ?? overrides.activeSceneId ?? DEFAULT_PROJECT_SCENE_ID, id:
overrides.sceneId ?? overrides.activeSceneId ?? DEFAULT_PROJECT_SCENE_ID,
name: overrides.sceneName, name: overrides.sceneName,
world: overrides.world world: overrides.world
}); });

View File

@@ -6,9 +6,15 @@ import type { LoadedImageAsset } from "../assets/image-assets";
import type { ProjectAssetRecord } from "../assets/project-assets"; import type { ProjectAssetRecord } from "../assets/project-assets";
import type { SceneLoadingScreenSettings } from "../document/scene-document"; import type { SceneLoadingScreenSettings } from "../document/scene-document";
import type { FirstPersonTelemetry } from "../runtime-three/navigation-controller"; import type { FirstPersonTelemetry } from "../runtime-three/navigation-controller";
import { RuntimeHost, type RuntimeSceneLoadState } from "../runtime-three/runtime-host"; import {
RuntimeHost,
type RuntimeSceneLoadState
} from "../runtime-three/runtime-host";
import type { RuntimeInteractionPrompt } from "../runtime-three/runtime-interaction-system"; import type { RuntimeInteractionPrompt } from "../runtime-three/runtime-interaction-system";
import type { RuntimeNavigationMode, RuntimeSceneDefinition } from "../runtime-three/runtime-scene-build"; import type {
RuntimeNavigationMode,
RuntimeSceneDefinition
} from "../runtime-three/runtime-scene-build";
import { createWorldBackgroundStyle } from "../shared-ui/world-background-style"; import { createWorldBackgroundStyle } from "../shared-ui/world-background-style";
interface RunnerCanvasProps { interface RunnerCanvasProps {
@@ -45,10 +51,13 @@ export function RunnerCanvas({
status: "loading", status: "loading",
message: null message: null
}); });
const [interactionPrompt, setInteractionPrompt] = useState<RuntimeInteractionPrompt | null>(null); const [interactionPrompt, setInteractionPrompt] =
const [firstPersonTelemetry, setFirstPersonTelemetry] = useState<FirstPersonTelemetry | null>(null); useState<RuntimeInteractionPrompt | null>(null);
const [firstPersonTelemetry, setFirstPersonTelemetry] =
useState<FirstPersonTelemetry | null>(null);
const overlayMessage = runnerMessage ?? sceneLoadState.message; const overlayMessage = runnerMessage ?? sceneLoadState.message;
const overlayStatus = overlayMessage !== null ? "error" : sceneLoadState.status; const overlayStatus =
overlayMessage !== null ? "error" : sceneLoadState.status;
const runnerReady = overlayStatus === "ready"; const runnerReady = overlayStatus === "ready";
useEffect(() => { useEffect(() => {
@@ -86,7 +95,9 @@ export function RunnerCanvas({
}; };
} catch (error) { } catch (error) {
const message = const message =
error instanceof Error ? error.message : "Runner initialization failed."; error instanceof Error
? error.message
: "Runner initialization failed.";
const failureMessage = `Runner initialization failed: ${message}`; const failureMessage = `Runner initialization failed: ${message}`;
setRunnerMessage(failureMessage); setRunnerMessage(failureMessage);
setSceneLoadState({ setSceneLoadState({
@@ -98,10 +109,19 @@ export function RunnerCanvas({
onFirstPersonTelemetryChange(null); onFirstPersonTelemetryChange(null);
return; return;
} }
}, [onFirstPersonTelemetryChange, onInteractionPromptChange, onRuntimeMessageChange]); }, [
onFirstPersonTelemetryChange,
onInteractionPromptChange,
onRuntimeMessageChange
]);
useEffect(() => { useEffect(() => {
hostRef.current?.updateAssets(projectAssets, loadedModelAssets, loadedImageAssets, loadedAudioAssets); hostRef.current?.updateAssets(
projectAssets,
loadedModelAssets,
loadedImageAssets,
loadedAudioAssets
);
}, [projectAssets, loadedModelAssets, loadedImageAssets, loadedAudioAssets]); }, [projectAssets, loadedModelAssets, loadedImageAssets, loadedAudioAssets]);
useEffect(() => { useEffect(() => {
@@ -116,7 +136,12 @@ export function RunnerCanvas({
onFirstPersonTelemetryChange(null); onFirstPersonTelemetryChange(null);
onRuntimeMessageChange(null); onRuntimeMessageChange(null);
hostRef.current?.loadScene(runtimeScene); hostRef.current?.loadScene(runtimeScene);
}, [onFirstPersonTelemetryChange, onInteractionPromptChange, onRuntimeMessageChange, runtimeScene]); }, [
onFirstPersonTelemetryChange,
onInteractionPromptChange,
onRuntimeMessageChange,
runtimeScene
]);
useEffect(() => { useEffect(() => {
hostRef.current?.setNavigationMode(navigationMode); hostRef.current?.setNavigationMode(navigationMode);
@@ -131,7 +156,10 @@ export function RunnerCanvas({
aria-busy={!runnerReady} aria-busy={!runnerReady}
style={createWorldBackgroundStyle( style={createWorldBackgroundStyle(
runtimeScene.world.background, runtimeScene.world.background,
runtimeScene.world.background.mode === "image" ? loadedImageAssets[runtimeScene.world.background.assetId]?.sourceUrl ?? null : null runtimeScene.world.background.mode === "image"
? (loadedImageAssets[runtimeScene.world.background.assetId]
?.sourceUrl ?? null)
: null
)} )}
> >
<div <div
@@ -191,19 +219,33 @@ export function RunnerCanvas({
{runnerReady && {runnerReady &&
navigationMode === "firstPerson" && navigationMode === "firstPerson" &&
interactionPrompt !== null ? ( interactionPrompt !== null ? (
<div className="runner-canvas__prompt" data-testid="runner-interaction-prompt" role="status" aria-live="polite"> <div
className="runner-canvas__prompt"
data-testid="runner-interaction-prompt"
role="status"
aria-live="polite"
>
<div className="runner-canvas__prompt-badge">Click</div> <div className="runner-canvas__prompt-badge">Click</div>
<div className="runner-canvas__prompt-text" data-testid="runner-interaction-prompt-text"> <div
className="runner-canvas__prompt-text"
data-testid="runner-interaction-prompt-text"
>
{interactionPrompt.prompt} {interactionPrompt.prompt}
</div> </div>
<div className="runner-canvas__prompt-meta" data-testid="runner-interaction-prompt-meta"> <div
{interactionPrompt.distance.toFixed(1)}m away · {interactionPrompt.range.toFixed(1)}m range className="runner-canvas__prompt-meta"
data-testid="runner-interaction-prompt-meta"
>
{interactionPrompt.distance.toFixed(1)}m away ·{" "}
{interactionPrompt.range.toFixed(1)}m range
</div> </div>
</div> </div>
) : null} ) : null}
{runnerMessage === null ? null : ( {runnerMessage === null ? null : (
<div className="runner-canvas__fallback" role="status"> <div className="runner-canvas__fallback" role="status">
<div className="runner-canvas__fallback-title">Runner Unavailable</div> <div className="runner-canvas__fallback-title">
Runner Unavailable
</div>
<div>{runnerMessage}</div> <div>{runnerMessage}</div>
</div> </div>
)} )}

View File

@@ -3,7 +3,11 @@ import { Euler, Vector3 } from "three";
import type { Vec3 } from "../core/vector"; import type { Vec3 } from "../core/vector";
import { getFirstPersonPlayerEyeHeight } from "./player-collision"; import { getFirstPersonPlayerEyeHeight } from "./player-collision";
import type { NavigationController, RuntimeControllerContext, RuntimeLocomotionState } from "./navigation-controller"; import type {
NavigationController,
RuntimeControllerContext,
RuntimeLocomotionState
} from "./navigation-controller";
const LOOK_SENSITIVITY = 0.0022; const LOOK_SENSITIVITY = 0.0022;
const MOVE_SPEED = 4.5; const MOVE_SPEED = 4.5;
@@ -11,7 +15,10 @@ const GRAVITY = 22;
const MAX_PITCH_RADIANS = Math.PI * 0.48; const MAX_PITCH_RADIANS = Math.PI * 0.48;
function clampPitch(pitchRadians: number): number { function clampPitch(pitchRadians: number): number {
return Math.max(-MAX_PITCH_RADIANS, Math.min(MAX_PITCH_RADIANS, pitchRadians)); return Math.max(
-MAX_PITCH_RADIANS,
Math.min(MAX_PITCH_RADIANS, pitchRadians)
);
} }
function toEyePosition(feetPosition: Vec3, eyeHeight: number): Vec3 { function toEyePosition(feetPosition: Vec3, eyeHeight: number): Vec3 {
@@ -67,7 +74,10 @@ export class FirstPersonNavigationController implements NavigationController {
window.addEventListener("keyup", this.handleKeyUp); window.addEventListener("keyup", this.handleKeyUp);
window.addEventListener("blur", this.handleBlur); window.addEventListener("blur", this.handleBlur);
document.addEventListener("mousemove", this.handleMouseMove); document.addEventListener("mousemove", this.handleMouseMove);
document.addEventListener("pointerlockchange", this.handlePointerLockChange); document.addEventListener(
"pointerlockchange",
this.handlePointerLockChange
);
document.addEventListener("pointerlockerror", this.handlePointerLockError); document.addEventListener("pointerlockerror", this.handlePointerLockError);
ctx.domElement.addEventListener("pointerdown", this.handlePointerDown); ctx.domElement.addEventListener("pointerdown", this.handlePointerDown);
@@ -81,8 +91,14 @@ export class FirstPersonNavigationController implements NavigationController {
window.removeEventListener("keyup", this.handleKeyUp); window.removeEventListener("keyup", this.handleKeyUp);
window.removeEventListener("blur", this.handleBlur); window.removeEventListener("blur", this.handleBlur);
document.removeEventListener("mousemove", this.handleMouseMove); document.removeEventListener("mousemove", this.handleMouseMove);
document.removeEventListener("pointerlockchange", this.handlePointerLockChange); document.removeEventListener(
document.removeEventListener("pointerlockerror", this.handlePointerLockError); "pointerlockchange",
this.handlePointerLockChange
);
document.removeEventListener(
"pointerlockerror",
this.handlePointerLockError
);
ctx.domElement.removeEventListener("pointerdown", this.handlePointerDown); ctx.domElement.removeEventListener("pointerdown", this.handlePointerDown);
this.pressedKeys.clear(); this.pressedKeys.clear();
@@ -120,9 +136,15 @@ export class FirstPersonNavigationController implements NavigationController {
} }
const playerShape = this.context.getRuntimeScene().playerCollider; const playerShape = this.context.getRuntimeScene().playerCollider;
const currentVolumeState = this.context.resolvePlayerVolumeState(this.feetPosition); const currentVolumeState = this.context.resolvePlayerVolumeState(
const inputX = (this.pressedKeys.has("KeyD") ? 1 : 0) - (this.pressedKeys.has("KeyA") ? 1 : 0); this.feetPosition
const inputZ = (this.pressedKeys.has("KeyW") ? 1 : 0) - (this.pressedKeys.has("KeyS") ? 1 : 0); );
const inputX =
(this.pressedKeys.has("KeyD") ? 1 : 0) -
(this.pressedKeys.has("KeyA") ? 1 : 0);
const inputZ =
(this.pressedKeys.has("KeyW") ? 1 : 0) -
(this.pressedKeys.has("KeyS") ? 1 : 0);
const inputLength = Math.hypot(inputX, inputZ); const inputLength = Math.hypot(inputX, inputZ);
let horizontalX = 0; let horizontalX = 0;
@@ -133,11 +155,25 @@ export class FirstPersonNavigationController implements NavigationController {
const normalizedInputZ = inputZ / inputLength; const normalizedInputZ = inputZ / inputLength;
const moveDistance = MOVE_SPEED * dt; const moveDistance = MOVE_SPEED * dt;
this.forwardVector.set(Math.sin(this.yawRadians), 0, Math.cos(this.yawRadians)); this.forwardVector.set(
this.rightVector.set(-Math.cos(this.yawRadians), 0, Math.sin(this.yawRadians)); Math.sin(this.yawRadians),
0,
Math.cos(this.yawRadians)
);
this.rightVector.set(
-Math.cos(this.yawRadians),
0,
Math.sin(this.yawRadians)
);
horizontalX = (this.forwardVector.x * normalizedInputZ + this.rightVector.x * normalizedInputX) * moveDistance; horizontalX =
horizontalZ = (this.forwardVector.z * normalizedInputZ + this.rightVector.z * normalizedInputX) * moveDistance; (this.forwardVector.x * normalizedInputZ +
this.rightVector.x * normalizedInputX) *
moveDistance;
horizontalZ =
(this.forwardVector.z * normalizedInputZ +
this.rightVector.z * normalizedInputX) *
moveDistance;
} }
if (playerShape.mode === "none") { if (playerShape.mode === "none") {
@@ -152,7 +188,10 @@ export class FirstPersonNavigationController implements NavigationController {
this.feetPosition, this.feetPosition,
{ {
x: horizontalX, x: horizontalX,
y: playerShape.mode === "none" || currentVolumeState.inWater ? 0 : this.verticalVelocity * dt, y:
playerShape.mode === "none" || currentVolumeState.inWater
? 0
: this.verticalVelocity * dt,
z: horizontalZ z: horizontalZ
}, },
playerShape playerShape
@@ -165,7 +204,9 @@ export class FirstPersonNavigationController implements NavigationController {
} }
this.feetPosition = resolvedMotion.feetPosition; this.feetPosition = resolvedMotion.feetPosition;
const nextVolumeState = this.context.resolvePlayerVolumeState(this.feetPosition); const nextVolumeState = this.context.resolvePlayerVolumeState(
this.feetPosition
);
this.inWaterVolume = nextVolumeState.inWater; this.inWaterVolume = nextVolumeState.inWater;
this.inFogVolume = nextVolumeState.inFog; this.inFogVolume = nextVolumeState.inFog;
this.grounded = nextVolumeState.inWater ? false : resolvedMotion.grounded; this.grounded = nextVolumeState.inWater ? false : resolvedMotion.grounded;
@@ -210,7 +251,12 @@ export class FirstPersonNavigationController implements NavigationController {
return; return;
} }
const eyePosition = toEyePosition(this.feetPosition, getFirstPersonPlayerEyeHeight(this.context.getRuntimeScene().playerCollider)); const eyePosition = toEyePosition(
this.feetPosition,
getFirstPersonPlayerEyeHeight(
this.context.getRuntimeScene().playerCollider
)
);
this.cameraRotation.x = this.pitchRadians; this.cameraRotation.x = this.pitchRadians;
// Authoring yaw treats 0 degrees as facing +Z, while a three.js camera // Authoring yaw treats 0 degrees as facing +Z, while a three.js camera
// looks down -Z by default. Offset by 180 degrees so runtime view matches // looks down -Z by default. Offset by 180 degrees so runtime view matches
@@ -218,7 +264,11 @@ export class FirstPersonNavigationController implements NavigationController {
this.cameraRotation.y = this.yawRadians + Math.PI; this.cameraRotation.y = this.yawRadians + Math.PI;
this.cameraRotation.z = 0; this.cameraRotation.z = 0;
this.context.camera.position.set(eyePosition.x, eyePosition.y, eyePosition.z); this.context.camera.position.set(
eyePosition.x,
eyePosition.y,
eyePosition.z
);
this.context.camera.rotation.copy(this.cameraRotation); this.context.camera.rotation.copy(this.cameraRotation);
} }
@@ -227,8 +277,14 @@ export class FirstPersonNavigationController implements NavigationController {
return; return;
} }
const eyePosition = toEyePosition(this.feetPosition, getFirstPersonPlayerEyeHeight(this.context.getRuntimeScene().playerCollider)); const eyePosition = toEyePosition(
const cameraVolumeState = this.context.resolvePlayerVolumeState(eyePosition); this.feetPosition,
getFirstPersonPlayerEyeHeight(
this.context.getRuntimeScene().playerCollider
)
);
const cameraVolumeState =
this.context.resolvePlayerVolumeState(eyePosition);
this.context.setFirstPersonTelemetry({ this.context.setFirstPersonTelemetry({
feetPosition: { feetPosition: {
@@ -250,7 +306,8 @@ export class FirstPersonNavigationController implements NavigationController {
return; return;
} }
const pointerLocked = document.pointerLockElement === this.context.domElement; const pointerLocked =
document.pointerLockElement === this.context.domElement;
this.pointerLocked = pointerLocked; this.pointerLocked = pointerLocked;
this.context.setRuntimeMessage( this.context.setRuntimeMessage(
pointerLocked pointerLocked
@@ -278,7 +335,9 @@ export class FirstPersonNavigationController implements NavigationController {
} }
this.yawRadians -= event.movementX * LOOK_SENSITIVITY; this.yawRadians -= event.movementX * LOOK_SENSITIVITY;
this.pitchRadians = clampPitch(this.pitchRadians - event.movementY * LOOK_SENSITIVITY); this.pitchRadians = clampPitch(
this.pitchRadians - event.movementY * LOOK_SENSITIVITY
);
}; };
private handlePointerLockChange = () => { private handlePointerLockChange = () => {
@@ -292,11 +351,15 @@ export class FirstPersonNavigationController implements NavigationController {
}; };
private handlePointerDown = () => { private handlePointerDown = () => {
if (this.context === null || document.pointerLockElement === this.context.domElement) { if (
this.context === null ||
document.pointerLockElement === this.context.domElement
) {
return; return;
} }
const pointerLockCapableElement = this.context.domElement as HTMLCanvasElement & { const pointerLockCapableElement = this.context
.domElement as HTMLCanvasElement & {
requestPointerLock(): void | Promise<void>; requestPointerLock(): void | Promise<void>;
}; };
const pointerLockResult = pointerLockCapableElement.requestPointerLock(); const pointerLockResult = pointerLockCapableElement.requestPointerLock();

View File

@@ -2,8 +2,15 @@ import type { PerspectiveCamera } from "three";
import type { Vec3 } from "../core/vector"; import type { Vec3 } from "../core/vector";
import type { FirstPersonPlayerShape, ResolvedPlayerMotion } from "./player-collision"; import type {
import type { RuntimeNavigationMode, RuntimeSceneDefinition, RuntimeSpawnPoint } from "./runtime-scene-build"; FirstPersonPlayerShape,
ResolvedPlayerMotion
} from "./player-collision";
import type {
RuntimeNavigationMode,
RuntimeSceneDefinition,
RuntimeSpawnPoint
} from "./runtime-scene-build";
export interface FirstPersonTelemetry { export interface FirstPersonTelemetry {
feetPosition: Vec3; feetPosition: Vec3;
@@ -28,7 +35,11 @@ export interface RuntimeControllerContext {
camera: PerspectiveCamera; camera: PerspectiveCamera;
domElement: HTMLCanvasElement; domElement: HTMLCanvasElement;
getRuntimeScene(): RuntimeSceneDefinition; getRuntimeScene(): RuntimeSceneDefinition;
resolveFirstPersonMotion(feetPosition: Vec3, motion: Vec3, shape: FirstPersonPlayerShape): ResolvedPlayerMotion | null; resolveFirstPersonMotion(
feetPosition: Vec3,
motion: Vec3,
shape: FirstPersonPlayerShape
): ResolvedPlayerMotion | null;
resolvePlayerVolumeState(feetPosition: Vec3): RuntimePlayerVolumeState; resolvePlayerVolumeState(feetPosition: Vec3): RuntimePlayerVolumeState;
setRuntimeMessage(message: string | null): void; setRuntimeMessage(message: string | null): void;
setFirstPersonTelemetry(telemetry: FirstPersonTelemetry | null): void; setFirstPersonTelemetry(telemetry: FirstPersonTelemetry | null): void;

View File

@@ -2,7 +2,10 @@ import { Vector3 } from "three";
import type { Vec3 } from "../core/vector"; import type { Vec3 } from "../core/vector";
import type { NavigationController, RuntimeControllerContext } from "./navigation-controller"; import type {
NavigationController,
RuntimeControllerContext
} from "./navigation-controller";
const MIN_DISTANCE = 2; const MIN_DISTANCE = 2;
const MAX_DISTANCE = 48; const MAX_DISTANCE = 48;
@@ -48,9 +51,16 @@ export class OrbitVisitorNavigationController implements NavigationController {
if (!this.initializedFromScene) { if (!this.initializedFromScene) {
const runtimeScene = ctx.getRuntimeScene(); const runtimeScene = ctx.getRuntimeScene();
const focusPoint = runtimeScene.playerStart?.position ?? runtimeScene.sceneBounds?.center ?? this.target; const focusPoint =
runtimeScene.playerStart?.position ??
runtimeScene.sceneBounds?.center ??
this.target;
const focusDistance = runtimeScene.sceneBounds const focusDistance = runtimeScene.sceneBounds
? Math.max(runtimeScene.sceneBounds.size.x, runtimeScene.sceneBounds.size.y, runtimeScene.sceneBounds.size.z) * 1.1 ? Math.max(
runtimeScene.sceneBounds.size.x,
runtimeScene.sceneBounds.size.y,
runtimeScene.sceneBounds.size.z
) * 1.1
: 8; : 8;
this.target = cloneVec3(focusPoint); this.target = cloneVec3(focusPoint);
@@ -59,12 +69,16 @@ export class OrbitVisitorNavigationController implements NavigationController {
} }
ctx.domElement.addEventListener("pointerdown", this.handlePointerDown); ctx.domElement.addEventListener("pointerdown", this.handlePointerDown);
ctx.domElement.addEventListener("wheel", this.handleWheel, { passive: false }); ctx.domElement.addEventListener("wheel", this.handleWheel, {
passive: false
});
ctx.domElement.addEventListener("contextmenu", this.handleContextMenu); ctx.domElement.addEventListener("contextmenu", this.handleContextMenu);
window.addEventListener("pointermove", this.handlePointerMove); window.addEventListener("pointermove", this.handlePointerMove);
window.addEventListener("pointerup", this.handlePointerUp); window.addEventListener("pointerup", this.handlePointerUp);
ctx.setRuntimeMessage("Orbit Visitor active. Drag to orbit around the scene and use the mouse wheel to zoom."); ctx.setRuntimeMessage(
"Orbit Visitor active. Drag to orbit around the scene and use the mouse wheel to zoom."
);
ctx.setFirstPersonTelemetry(null); ctx.setFirstPersonTelemetry(null);
this.updateCameraTransform(); this.updateCameraTransform();
} }
@@ -117,7 +131,11 @@ export class OrbitVisitorNavigationController implements NavigationController {
z: this.target.z + Math.cos(this.yawRadians) * horizontalDistance z: this.target.z + Math.cos(this.yawRadians) * horizontalDistance
}; };
this.context.camera.position.set(cameraPosition.x, cameraPosition.y, cameraPosition.z); this.context.camera.position.set(
cameraPosition.x,
cameraPosition.y,
cameraPosition.z
);
this.lookAtVector.set(this.target.x, this.target.y, this.target.z); this.lookAtVector.set(this.target.x, this.target.y, this.target.z);
this.context.camera.lookAt(this.lookAtVector); this.context.camera.lookAt(this.lookAtVector);
} }

View File

@@ -26,13 +26,19 @@ import {
} from "three"; } from "three";
import { EffectComposer } from "postprocessing"; import { EffectComposer } from "postprocessing";
import { createModelInstanceRenderGroup, disposeModelInstance } from "../assets/model-instance-rendering"; import {
createModelInstanceRenderGroup,
disposeModelInstance
} from "../assets/model-instance-rendering";
import type { LoadedModelAsset } from "../assets/gltf-model-import"; import type { LoadedModelAsset } from "../assets/gltf-model-import";
import type { LoadedImageAsset } from "../assets/image-assets"; import type { LoadedImageAsset } from "../assets/image-assets";
import type { LoadedAudioAsset } from "../assets/audio-assets"; import type { LoadedAudioAsset } from "../assets/audio-assets";
import type { ProjectAssetRecord } from "../assets/project-assets"; import type { ProjectAssetRecord } from "../assets/project-assets";
import { buildBoxBrushDerivedMeshData } from "../geometry/box-brush-mesh"; import { buildBoxBrushDerivedMeshData } from "../geometry/box-brush-mesh";
import { createStarterMaterialSignature, createStarterMaterialTexture } from "../materials/starter-material-textures"; import {
createStarterMaterialSignature,
createStarterMaterialTexture
} from "../materials/starter-material-textures";
import { import {
applyAdvancedRenderingLightShadowFlags, applyAdvancedRenderingLightShadowFlags,
applyAdvancedRenderingRenderableShadowFlags, applyAdvancedRenderingRenderableShadowFlags,
@@ -57,9 +63,18 @@ import {
} from "../document/world-settings"; } from "../document/world-settings";
import { FirstPersonNavigationController } from "./first-person-navigation-controller"; import { FirstPersonNavigationController } from "./first-person-navigation-controller";
import type { FirstPersonTelemetry, NavigationController, RuntimeControllerContext, RuntimePlayerVolumeState } from "./navigation-controller"; import type {
FirstPersonTelemetry,
NavigationController,
RuntimeControllerContext,
RuntimePlayerVolumeState
} from "./navigation-controller";
import { RapierCollisionWorld } from "./rapier-collision-world"; import { RapierCollisionWorld } from "./rapier-collision-world";
import { RuntimeInteractionSystem, type RuntimeInteractionDispatcher, type RuntimeInteractionPrompt } from "./runtime-interaction-system"; import {
RuntimeInteractionSystem,
type RuntimeInteractionDispatcher,
type RuntimeInteractionPrompt
} from "./runtime-interaction-system";
import { RuntimeAudioSystem } from "./runtime-audio-system"; import { RuntimeAudioSystem } from "./runtime-audio-system";
import { OrbitVisitorNavigationController } from "./orbit-visitor-navigation-controller"; import { OrbitVisitorNavigationController } from "./orbit-visitor-navigation-controller";
import { resolveUnderwaterFogState } from "./underwater-fog"; import { resolveUnderwaterFogState } from "./underwater-fog";
@@ -115,19 +130,32 @@ export class RuntimeHost {
private readonly localLightGroup = new Group(); private readonly localLightGroup = new Group();
private readonly brushGroup = new Group(); private readonly brushGroup = new Group();
private readonly modelGroup = new Group(); private readonly modelGroup = new Group();
private readonly firstPersonController = new FirstPersonNavigationController(); private readonly firstPersonController =
private readonly orbitVisitorController = new OrbitVisitorNavigationController(); new FirstPersonNavigationController();
private readonly orbitVisitorController =
new OrbitVisitorNavigationController();
private readonly interactionSystem = new RuntimeInteractionSystem(); private readonly interactionSystem = new RuntimeInteractionSystem();
private readonly audioSystem = new RuntimeAudioSystem(this.scene, this.camera, null); private readonly audioSystem = new RuntimeAudioSystem(
this.scene,
this.camera,
null
);
private readonly underwaterSceneFog = new FogExp2("#2c6f8d", 0.03); private readonly underwaterSceneFog = new FogExp2("#2c6f8d", 0.03);
private readonly waterReflectionCamera = new PerspectiveCamera(); private readonly waterReflectionCamera = new PerspectiveCamera();
private readonly brushMeshes = new Map<string, Mesh<BufferGeometry, Material[]>>(); private readonly brushMeshes = new Map<
string,
Mesh<BufferGeometry, Material[]>
>();
private volumeTime = 0; private volumeTime = 0;
private readonly volumeAnimatedUniforms: Array<{ value: number }> = []; private readonly volumeAnimatedUniforms: Array<{ value: number }> = [];
private readonly runtimeWaterContactUniforms: RuntimeWaterContactUniformBinding[] = []; private readonly runtimeWaterContactUniforms: RuntimeWaterContactUniformBinding[] =
[];
private readonly localLightObjects = new Map<string, Group>(); private readonly localLightObjects = new Map<string, Group>();
private readonly modelRenderObjects = new Map<string, Group>(); private readonly modelRenderObjects = new Map<string, Group>();
private readonly materialTextureCache = new Map<string, CachedMaterialTexture>(); private readonly materialTextureCache = new Map<
string,
CachedMaterialTexture
>();
private readonly animationMixers = new Map<string, AnimationMixer>(); private readonly animationMixers = new Map<string, AnimationMixer>();
private readonly instanceAnimationClips = new Map<string, AnimationClip[]>(); private readonly instanceAnimationClips = new Map<string, AnimationClip[]>();
private readonly controllerContext: RuntimeControllerContext; private readonly controllerContext: RuntimeControllerContext;
@@ -138,7 +166,8 @@ export class RuntimeHost {
private desiredNavigationMode: RuntimeNavigationMode = "firstPerson"; private desiredNavigationMode: RuntimeNavigationMode = "firstPerson";
private sceneReady = false; private sceneReady = false;
private currentWorld: RuntimeSceneDefinition["world"] | null = null; private currentWorld: RuntimeSceneDefinition["world"] | null = null;
private currentAdvancedRenderingSettings: AdvancedRenderingSettings | null = null; private currentAdvancedRenderingSettings: AdvancedRenderingSettings | null =
null;
private advancedRenderingComposer: EffectComposer | null = null; private advancedRenderingComposer: EffectComposer | null = null;
private projectAssets: Record<string, ProjectAssetRecord> = {}; private projectAssets: Record<string, ProjectAssetRecord> = {};
private loadedModelAssets: Record<string, LoadedModelAsset> = {}; private loadedModelAssets: Record<string, LoadedModelAsset> = {};
@@ -148,9 +177,14 @@ export class RuntimeHost {
private previousFrameTime = 0; private previousFrameTime = 0;
private container: HTMLElement | null = null; private container: HTMLElement | null = null;
private activeController: NavigationController | null = null; private activeController: NavigationController | null = null;
private runtimeMessageHandler: ((message: string | null) => void) | null = null; private runtimeMessageHandler: ((message: string | null) => void) | null =
private firstPersonTelemetryHandler: ((telemetry: FirstPersonTelemetry | null) => void) | null = null; null;
private interactionPromptHandler: ((prompt: RuntimeInteractionPrompt | null) => void) | null = null; private firstPersonTelemetryHandler:
| ((telemetry: FirstPersonTelemetry | null) => void)
| null = null;
private interactionPromptHandler:
| ((prompt: RuntimeInteractionPrompt | null) => void)
| null = null;
private sceneLoadStateHandler: private sceneLoadStateHandler:
| ((state: RuntimeSceneLoadState) => void) | ((state: RuntimeSceneLoadState) => void)
| null = null; | null = null;
@@ -169,8 +203,11 @@ export class RuntimeHost {
this.scene.add(this.modelGroup); this.scene.add(this.modelGroup);
this.underwaterSceneFog.density = 0; this.underwaterSceneFog.density = 0;
this.scene.fog = this.underwaterSceneFog; this.scene.fog = this.underwaterSceneFog;
this.renderer = enableRendering ? new WebGLRenderer({ antialias: false, alpha: true }) : null; this.renderer = enableRendering
this.domElement = this.renderer?.domElement ?? document.createElement("canvas"); ? new WebGLRenderer({ antialias: false, alpha: true })
: null;
this.domElement =
this.renderer?.domElement ?? document.createElement("canvas");
if (this.renderer !== null) { if (this.renderer !== null) {
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
@@ -189,8 +226,14 @@ export class RuntimeHost {
return this.runtimeScene; return this.runtimeScene;
}, },
resolveFirstPersonMotion: (feetPosition, motion, shape) => this.collisionWorld?.resolveFirstPersonMotion(feetPosition, motion, shape) ?? null, resolveFirstPersonMotion: (feetPosition, motion, shape) =>
resolvePlayerVolumeState: (feetPosition) => this.resolvePlayerVolumeState(feetPosition), this.collisionWorld?.resolveFirstPersonMotion(
feetPosition,
motion,
shape
) ?? null,
resolvePlayerVolumeState: (feetPosition) =>
this.resolvePlayerVolumeState(feetPosition),
setRuntimeMessage: (message) => { setRuntimeMessage: (message) => {
if (message === this.currentRuntimeMessage) { if (message === this.currentRuntimeMessage) {
return; return;
@@ -206,7 +249,11 @@ export class RuntimeHost {
}; };
} }
private resolvePlayerVolumeState(feetPosition: { x: number; y: number; z: number }): RuntimePlayerVolumeState { private resolvePlayerVolumeState(feetPosition: {
x: number;
y: number;
z: number;
}): RuntimePlayerVolumeState {
if (this.runtimeScene === null) { if (this.runtimeScene === null) {
return { return {
inWater: false, inWater: false,
@@ -214,8 +261,12 @@ export class RuntimeHost {
}; };
} }
const inWater = this.runtimeScene.volumes.water.some((volume) => this.isPointInsideOrientedVolume(feetPosition, volume)); const inWater = this.runtimeScene.volumes.water.some((volume) =>
const inFog = this.runtimeScene.volumes.fog.some((volume) => this.isPointInsideOrientedVolume(feetPosition, volume)); this.isPointInsideOrientedVolume(feetPosition, volume)
);
const inFog = this.runtimeScene.volumes.fog.some((volume) =>
this.isPointInsideOrientedVolume(feetPosition, volume)
);
return { return {
inWater, inWater,
@@ -225,9 +276,17 @@ export class RuntimeHost {
private isPointInsideOrientedVolume( private isPointInsideOrientedVolume(
point: { x: number; y: number; z: number }, point: { x: number; y: number; z: number },
volume: { center: { x: number; y: number; z: number }; rotationDegrees: { x: number; y: number; z: number }; size: { x: number; y: number; z: number } } volume: {
center: { x: number; y: number; z: number };
rotationDegrees: { x: number; y: number; z: number };
size: { x: number; y: number; z: number };
}
): boolean { ): boolean {
this.volumeOffset.set(point.x - volume.center.x, point.y - volume.center.y, point.z - volume.center.z); this.volumeOffset.set(
point.x - volume.center.x,
point.y - volume.center.y,
point.z - volume.center.z
);
this.volumeInverseRotation this.volumeInverseRotation
.setFromEuler( .setFromEuler(
@@ -257,7 +316,10 @@ export class RuntimeHost {
this.container = container; this.container = container;
container.appendChild(this.domElement); container.appendChild(this.domElement);
this.domElement.addEventListener("click", this.handleRuntimeClick); this.domElement.addEventListener("click", this.handleRuntimeClick);
this.domElement.addEventListener("pointerdown", this.handleRuntimePointerDown); this.domElement.addEventListener(
"pointerdown",
this.handleRuntimePointerDown
);
this.resize(); this.resize();
this.resizeObserver = new ResizeObserver(() => { this.resizeObserver = new ResizeObserver(() => {
@@ -338,11 +400,15 @@ export class RuntimeHost {
this.audioSystem.setRuntimeMessageHandler(handler); this.audioSystem.setRuntimeMessageHandler(handler);
} }
setFirstPersonTelemetryHandler(handler: ((telemetry: FirstPersonTelemetry | null) => void) | null) { setFirstPersonTelemetryHandler(
handler: ((telemetry: FirstPersonTelemetry | null) => void) | null
) {
this.firstPersonTelemetryHandler = handler; this.firstPersonTelemetryHandler = handler;
} }
setInteractionPromptHandler(handler: ((prompt: RuntimeInteractionPrompt | null) => void) | null) { setInteractionPromptHandler(
handler: ((prompt: RuntimeInteractionPrompt | null) => void) | null
) {
this.interactionPromptHandler = handler; this.interactionPromptHandler = handler;
} }
@@ -389,7 +455,10 @@ export class RuntimeHost {
this.renderer?.forceContextLoss(); this.renderer?.forceContextLoss();
this.renderer?.dispose(); this.renderer?.dispose();
this.domElement.removeEventListener("click", this.handleRuntimeClick); this.domElement.removeEventListener("click", this.handleRuntimeClick);
this.domElement.removeEventListener("pointerdown", this.handleRuntimePointerDown); this.domElement.removeEventListener(
"pointerdown",
this.handleRuntimePointerDown
);
if (this.container !== null && this.container.contains(this.domElement)) { if (this.container !== null && this.container.contains(this.domElement)) {
this.container.removeChild(this.domElement); this.container.removeChild(this.domElement);
@@ -505,7 +574,8 @@ export class RuntimeHost {
.multiplyScalar(18); .multiplyScalar(18);
if (world.background.mode === "image") { if (world.background.mode === "image") {
const texture = this.loadedImageAssets[world.background.assetId]?.texture ?? null; const texture =
this.loadedImageAssets[world.background.assetId]?.texture ?? null;
this.scene.background = texture; this.scene.background = texture;
this.scene.environment = texture; this.scene.environment = texture;
this.scene.environmentIntensity = world.background.environmentIntensity; this.scene.environmentIntensity = world.background.environmentIntensity;
@@ -516,7 +586,10 @@ export class RuntimeHost {
} }
if (this.renderer !== null) { if (this.renderer !== null) {
configureAdvancedRenderingRenderer(this.renderer, world.advancedRendering); configureAdvancedRenderingRenderer(
this.renderer,
world.advancedRendering
);
this.syncAdvancedRenderingComposer(world.advancedRendering); this.syncAdvancedRenderingComposer(world.advancedRendering);
} }
@@ -554,7 +627,10 @@ export class RuntimeHost {
const shouldUseComposer = settings.enabled; const shouldUseComposer = settings.enabled;
const settingsChanged = const settingsChanged =
this.currentAdvancedRenderingSettings === null || this.currentAdvancedRenderingSettings === null ||
!areAdvancedRenderingSettingsEqual(this.currentAdvancedRenderingSettings, settings); !areAdvancedRenderingSettingsEqual(
this.currentAdvancedRenderingSettings,
settings
);
if (!shouldUseComposer) { if (!shouldUseComposer) {
if (this.advancedRenderingComposer !== null) { if (this.advancedRenderingComposer !== null) {
@@ -575,8 +651,14 @@ export class RuntimeHost {
this.advancedRenderingComposer.dispose(); this.advancedRenderingComposer.dispose();
} }
this.advancedRenderingComposer = createAdvancedRenderingComposer(this.renderer, this.scene, this.camera, settings); this.advancedRenderingComposer = createAdvancedRenderingComposer(
this.currentAdvancedRenderingSettings = cloneAdvancedRenderingSettings(settings); this.renderer,
this.scene,
this.camera,
settings
);
this.currentAdvancedRenderingSettings =
cloneAdvancedRenderingSettings(settings);
this.renderer.autoClear = false; this.renderer.autoClear = false;
} }
@@ -586,7 +668,8 @@ export class RuntimeHost {
} }
const advancedRendering = this.currentWorld.advancedRendering; const advancedRendering = this.currentWorld.advancedRendering;
const shadowsEnabled = advancedRendering.enabled && advancedRendering.shadows.enabled; const shadowsEnabled =
advancedRendering.enabled && advancedRendering.shadows.enabled;
applyAdvancedRenderingLightShadowFlags(this.sunLight, advancedRendering); applyAdvancedRenderingLightShadowFlags(this.sunLight, advancedRendering);
@@ -621,11 +704,21 @@ export class RuntimeHost {
this.applyShadowState(); this.applyShadowState();
} }
private createPointLightRuntimeObjects(pointLight: RuntimeLocalLightCollection["pointLights"][number]): LocalLightRenderObjects { private createPointLightRuntimeObjects(
pointLight: RuntimeLocalLightCollection["pointLights"][number]
): LocalLightRenderObjects {
const group = new Group(); const group = new Group();
const light = new PointLight(pointLight.colorHex, pointLight.intensity, pointLight.distance); const light = new PointLight(
pointLight.colorHex,
pointLight.intensity,
pointLight.distance
);
group.position.set(pointLight.position.x, pointLight.position.y, pointLight.position.z); group.position.set(
pointLight.position.x,
pointLight.position.y,
pointLight.position.z
);
light.position.set(0, 0, 0); light.position.set(0, 0, 0);
group.add(light); group.add(light);
@@ -634,7 +727,9 @@ export class RuntimeHost {
}; };
} }
private createSpotLightRuntimeObjects(spotLight: RuntimeLocalLightCollection["spotLights"][number]): LocalLightRenderObjects { private createSpotLightRuntimeObjects(
spotLight: RuntimeLocalLightCollection["spotLights"][number]
): LocalLightRenderObjects {
const group = new Group(); const group = new Group();
const light = new SpotLight( const light = new SpotLight(
spotLight.colorHex, spotLight.colorHex,
@@ -644,10 +739,21 @@ export class RuntimeHost {
0.18, 0.18,
1 1
); );
const direction = new Vector3(spotLight.direction.x, spotLight.direction.y, spotLight.direction.z).normalize(); const direction = new Vector3(
const orientation = new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), direction); spotLight.direction.x,
spotLight.direction.y,
spotLight.direction.z
).normalize();
const orientation = new Quaternion().setFromUnitVectors(
new Vector3(0, 1, 0),
direction
);
group.position.set(spotLight.position.x, spotLight.position.y, spotLight.position.z); group.position.set(
spotLight.position.x,
spotLight.position.y,
spotLight.position.z
);
group.quaternion.copy(orientation); group.quaternion.copy(orientation);
light.position.set(0, 0, 0); light.position.set(0, 0, 0);
light.target.position.set(0, 1, 0); light.target.position.set(0, 1, 0);
@@ -662,26 +768,75 @@ export class RuntimeHost {
private rebuildBrushMeshes(brushes: RuntimeBoxBrushInstance[]) { private rebuildBrushMeshes(brushes: RuntimeBoxBrushInstance[]) {
this.clearBrushMeshes(); this.clearBrushMeshes();
const volumeRenderPaths: ResolvedBoxVolumeRenderPaths = const volumeRenderPaths: ResolvedBoxVolumeRenderPaths =
this.currentWorld === null ? { fog: "performance", water: "performance" } : resolveBoxVolumeRenderPaths(this.currentWorld.advancedRendering); this.currentWorld === null
? { fog: "performance", water: "performance" }
: resolveBoxVolumeRenderPaths(this.currentWorld.advancedRendering);
for (const brush of brushes) { for (const brush of brushes) {
const geometry = buildBoxBrushDerivedMeshData(brush).geometry; const geometry = buildBoxBrushDerivedMeshData(brush).geometry;
const staticContactPatches = brush.volume.mode === "water" ? this.collectRuntimeStaticWaterContactPatches(brush) : []; const staticContactPatches =
brush.volume.mode === "water"
? this.collectRuntimeStaticWaterContactPatches(brush)
: [];
const contactPatches = const contactPatches =
brush.volume.mode === "water" brush.volume.mode === "water"
? this.mergeRuntimeWaterContactPatches(brush, staticContactPatches, this.collectRuntimePlayerWaterContactPatches(brush)) ? this.mergeRuntimeWaterContactPatches(
brush,
staticContactPatches,
this.collectRuntimePlayerWaterContactPatches(brush)
)
: []; : [];
const materials = const materials = this.createFogMaterialSet(brush, volumeRenderPaths) ?? [
this.createFogMaterialSet(brush, volumeRenderPaths) ?? this.createFaceMaterial(
[ brush,
this.createFaceMaterial(brush, "posX", brush.faces.posX.material, volumeRenderPaths, contactPatches, staticContactPatches), "posX",
this.createFaceMaterial(brush, "negX", brush.faces.negX.material, volumeRenderPaths, contactPatches, staticContactPatches), brush.faces.posX.material,
this.createFaceMaterial(brush, "posY", brush.faces.posY.material, volumeRenderPaths, contactPatches, staticContactPatches), volumeRenderPaths,
this.createFaceMaterial(brush, "negY", brush.faces.negY.material, volumeRenderPaths, contactPatches, staticContactPatches), contactPatches,
this.createFaceMaterial(brush, "posZ", brush.faces.posZ.material, volumeRenderPaths, contactPatches, staticContactPatches), staticContactPatches
this.createFaceMaterial(brush, "negZ", brush.faces.negZ.material, volumeRenderPaths, contactPatches, staticContactPatches) ),
]; this.createFaceMaterial(
brush,
"negX",
brush.faces.negX.material,
volumeRenderPaths,
contactPatches,
staticContactPatches
),
this.createFaceMaterial(
brush,
"posY",
brush.faces.posY.material,
volumeRenderPaths,
contactPatches,
staticContactPatches
),
this.createFaceMaterial(
brush,
"negY",
brush.faces.negY.material,
volumeRenderPaths,
contactPatches,
staticContactPatches
),
this.createFaceMaterial(
brush,
"posZ",
brush.faces.posZ.material,
volumeRenderPaths,
contactPatches,
staticContactPatches
),
this.createFaceMaterial(
brush,
"negZ",
brush.faces.negZ.material,
volumeRenderPaths,
contactPatches,
staticContactPatches
)
];
const mesh = new Mesh(geometry, materials); const mesh = new Mesh(geometry, materials);
mesh.position.set(brush.center.x, brush.center.y, brush.center.z); mesh.position.set(brush.center.x, brush.center.y, brush.center.z);
@@ -700,7 +855,10 @@ export class RuntimeHost {
private createFogMaterialSet( private createFogMaterialSet(
brush: RuntimeBoxBrushInstance, brush: RuntimeBoxBrushInstance,
volumeRenderPaths: { fog: "performance" | "quality"; water: "performance" | "quality" } volumeRenderPaths: {
fog: "performance" | "quality";
water: "performance" | "quality";
}
): Material[] | null { ): Material[] | null {
if (brush.volume.mode !== "fog") { if (brush.volume.mode !== "fog") {
return null; return null;
@@ -720,10 +878,16 @@ export class RuntimeHost {
}); });
this.volumeAnimatedUniforms.push(fogMaterial.animationUniform); this.volumeAnimatedUniforms.push(fogMaterial.animationUniform);
return Array.from({ length: BOX_FACE_MATERIAL_COUNT }, () => fogMaterial.material); return Array.from(
{ length: BOX_FACE_MATERIAL_COUNT },
() => fogMaterial.material
);
} }
const densityOpacity = Math.max(0.06, Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08)); const densityOpacity = Math.max(
0.06,
Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08)
);
const fogMaterial = new MeshBasicMaterial({ const fogMaterial = new MeshBasicMaterial({
color: brush.volume.fog.colorHex, color: brush.volume.fog.colorHex,
transparent: true, transparent: true,
@@ -734,9 +898,14 @@ export class RuntimeHost {
return Array.from({ length: BOX_FACE_MATERIAL_COUNT }, () => fogMaterial); return Array.from({ length: BOX_FACE_MATERIAL_COUNT }, () => fogMaterial);
} }
private configureFogVolumeMesh(mesh: Mesh<BufferGeometry, Material[]>, materials: Material[]) { private configureFogVolumeMesh(
mesh: Mesh<BufferGeometry, Material[]>,
materials: Material[]
) {
const fogMaterials = materials.filter( const fogMaterials = materials.filter(
(material): material is ShaderMaterial => material instanceof ShaderMaterial && material.uniforms["localCameraPosition"] !== undefined (material): material is ShaderMaterial =>
material instanceof ShaderMaterial &&
material.uniforms["localCameraPosition"] !== undefined
); );
if (fogMaterials.length === 0) { if (fogMaterials.length === 0) {
@@ -744,15 +913,21 @@ export class RuntimeHost {
} }
mesh.onBeforeRender = (_renderer, _scene, camera) => { mesh.onBeforeRender = (_renderer, _scene, camera) => {
const localCameraPosition = mesh.worldToLocal(this.fogLocalCameraPosition.copy(camera.position)); const localCameraPosition = mesh.worldToLocal(
this.fogLocalCameraPosition.copy(camera.position)
);
for (const material of fogMaterials) { for (const material of fogMaterials) {
(material.uniforms["localCameraPosition"] as { value: Vector3 }).value.copy(localCameraPosition); (
material.uniforms["localCameraPosition"] as { value: Vector3 }
).value.copy(localCameraPosition);
} }
}; };
} }
private rebuildModelInstances(modelInstances: RuntimeSceneDefinition["modelInstances"]) { private rebuildModelInstances(
modelInstances: RuntimeSceneDefinition["modelInstances"]
) {
this.clearModelInstances(); this.clearModelInstances();
for (const modelInstance of modelInstances) { for (const modelInstance of modelInstances) {
@@ -782,10 +957,19 @@ export class RuntimeHost {
if (loadedAsset?.animations && loadedAsset.animations.length > 0) { if (loadedAsset?.animations && loadedAsset.animations.length > 0) {
const mixer = new AnimationMixer(renderGroup); const mixer = new AnimationMixer(renderGroup);
this.animationMixers.set(modelInstance.instanceId, mixer); this.animationMixers.set(modelInstance.instanceId, mixer);
this.instanceAnimationClips.set(modelInstance.instanceId, loadedAsset.animations); this.instanceAnimationClips.set(
modelInstance.instanceId,
loadedAsset.animations
);
if (modelInstance.animationAutoplay === true && modelInstance.animationClipName) { if (
const clip = AnimationClip.findByName(loadedAsset.animations, modelInstance.animationClipName); modelInstance.animationAutoplay === true &&
modelInstance.animationClipName
) {
const clip = AnimationClip.findByName(
loadedAsset.animations,
modelInstance.animationClipName
);
if (clip) { if (clip) {
mixer.clipAction(clip).play(); mixer.clipAction(clip).play();
} }
@@ -800,18 +984,28 @@ export class RuntimeHost {
brush: RuntimeBoxBrushInstance, brush: RuntimeBoxBrushInstance,
faceId: "posX" | "negX" | "posY" | "negY" | "posZ" | "negZ", faceId: "posX" | "negX" | "posY" | "negY" | "posZ" | "negZ",
material: RuntimeBoxBrushInstance["faces"]["posX"]["material"], material: RuntimeBoxBrushInstance["faces"]["posX"]["material"],
volumeRenderPaths: { fog: "performance" | "quality"; water: "performance" | "quality" }, volumeRenderPaths: {
fog: "performance" | "quality";
water: "performance" | "quality";
},
contactPatches: ReturnType<typeof collectWaterContactPatches>, contactPatches: ReturnType<typeof collectWaterContactPatches>,
staticContactPatches: ReturnType<typeof collectWaterContactPatches> staticContactPatches: ReturnType<typeof collectWaterContactPatches>
): Material { ): Material {
if (brush.volume.mode === "water") { if (brush.volume.mode === "water") {
const baseOpacity = Math.max(0.05, Math.min(1, brush.volume.water.surfaceOpacity)); const baseOpacity = Math.max(
0.05,
Math.min(1, brush.volume.water.surfaceOpacity)
);
const waterMaterial = createWaterMaterial({ const waterMaterial = createWaterMaterial({
colorHex: brush.volume.water.colorHex, colorHex: brush.volume.water.colorHex,
surfaceOpacity: brush.volume.water.surfaceOpacity, surfaceOpacity: brush.volume.water.surfaceOpacity,
waveStrength: brush.volume.water.waveStrength, waveStrength: brush.volume.water.waveStrength,
surfaceDisplacementEnabled: brush.volume.water.surfaceDisplacementEnabled, surfaceDisplacementEnabled:
opacity: faceId === "posY" ? Math.min(1, baseOpacity + 0.18) : baseOpacity * 0.5, brush.volume.water.surfaceDisplacementEnabled,
opacity:
faceId === "posY"
? Math.min(1, baseOpacity + 0.18)
: baseOpacity * 0.5,
quality: volumeRenderPaths.water === "quality", quality: volumeRenderPaths.water === "quality",
wireframe: false, wireframe: false,
isTopFace: faceId === "posY", isTopFace: faceId === "posY",
@@ -831,17 +1025,26 @@ export class RuntimeHost {
this.volumeAnimatedUniforms.push(waterMaterial.animationUniform); this.volumeAnimatedUniforms.push(waterMaterial.animationUniform);
} }
if (faceId === "posY" && waterMaterial.contactPatchesUniform !== null && waterMaterial.contactPatchAxesUniform !== null) { if (
faceId === "posY" &&
waterMaterial.contactPatchesUniform !== null &&
waterMaterial.contactPatchAxesUniform !== null
) {
this.runtimeWaterContactUniforms.push({ this.runtimeWaterContactUniforms.push({
brush, brush,
uniform: waterMaterial.contactPatchesUniform, uniform: waterMaterial.contactPatchesUniform,
axisUniform: waterMaterial.contactPatchAxesUniform, axisUniform: waterMaterial.contactPatchAxesUniform,
shapeUniform: waterMaterial.contactPatchShapesUniform ?? { value: [] }, shapeUniform: waterMaterial.contactPatchShapesUniform ?? {
value: []
},
staticContactPatches, staticContactPatches,
reflectionTextureUniform: waterMaterial.reflectionTextureUniform, reflectionTextureUniform: waterMaterial.reflectionTextureUniform,
reflectionMatrixUniform: waterMaterial.reflectionMatrixUniform, reflectionMatrixUniform: waterMaterial.reflectionMatrixUniform,
reflectionEnabledUniform: waterMaterial.reflectionEnabledUniform, reflectionEnabledUniform: waterMaterial.reflectionEnabledUniform,
reflectionRenderTarget: this.getWaterReflectionMode() !== "none" ? this.createWaterReflectionRenderTarget() : null, reflectionRenderTarget:
this.getWaterReflectionMode() !== "none"
? this.createWaterReflectionRenderTarget()
: null,
lastReflectionUpdateTime: Number.NEGATIVE_INFINITY lastReflectionUpdateTime: Number.NEGATIVE_INFINITY
}); });
} }
@@ -867,7 +1070,10 @@ export class RuntimeHost {
return fogMaterial.material; return fogMaterial.material;
} }
// Performance fallback: simple transparent material // Performance fallback: simple transparent material
const densityOpacity = Math.max(0.06, Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08)); const densityOpacity = Math.max(
0.06,
Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08)
);
return new MeshBasicMaterial({ return new MeshBasicMaterial({
color: brush.volume.fog.colorHex, color: brush.volume.fog.colorHex,
transparent: true, transparent: true,
@@ -895,7 +1101,10 @@ export class RuntimeHost {
private updateUnderwaterSceneFog() { private updateUnderwaterSceneFog() {
const fogState = const fogState =
this.activeController === this.firstPersonController this.activeController === this.firstPersonController
? resolveUnderwaterFogState(this.runtimeScene, this.currentFirstPersonTelemetry) ? resolveUnderwaterFogState(
this.runtimeScene,
this.currentFirstPersonTelemetry
)
: null; : null;
if (fogState === null) { if (fogState === null) {
@@ -908,7 +1117,11 @@ export class RuntimeHost {
} }
private getWaterReflectionMode() { private getWaterReflectionMode() {
if (this.currentWorld === null || !this.currentWorld.advancedRendering.enabled || this.currentWorld.advancedRendering.waterPath !== "quality") { if (
this.currentWorld === null ||
!this.currentWorld.advancedRendering.enabled ||
this.currentWorld.advancedRendering.waterPath !== "quality"
) {
return "none" as const; return "none" as const;
} }
@@ -957,7 +1170,8 @@ export class RuntimeHost {
} }
if (binding.reflectionRenderTarget === null) { if (binding.reflectionRenderTarget === null) {
binding.reflectionRenderTarget = this.createWaterReflectionRenderTarget(); binding.reflectionRenderTarget =
this.createWaterReflectionRenderTarget();
} }
const canRenderReflection = updatePlanarReflectionCamera( const canRenderReflection = updatePlanarReflectionCamera(
@@ -972,12 +1186,19 @@ export class RuntimeHost {
continue; continue;
} }
if (binding.reflectionTextureUniform.value !== null && now - binding.lastReflectionUpdateTime < WATER_REFLECTION_UPDATE_INTERVAL_MS) { if (
binding.reflectionTextureUniform.value !== null &&
now - binding.lastReflectionUpdateTime <
WATER_REFLECTION_UPDATE_INTERVAL_MS
) {
binding.reflectionEnabledUniform.value = 0.36; binding.reflectionEnabledUniform.value = 0.36;
continue; continue;
} }
const hiddenWaterMeshes: Array<{ mesh: Mesh<BufferGeometry, Material[]>; visible: boolean }> = []; const hiddenWaterMeshes: Array<{
mesh: Mesh<BufferGeometry, Material[]>;
visible: boolean;
}> = [];
for (const runtimeBrush of this.runtimeScene.brushes) { for (const runtimeBrush of this.runtimeScene.brushes) {
if (runtimeBrush.volume.mode !== "water") { if (runtimeBrush.volume.mode !== "water") {
continue; continue;
@@ -1000,11 +1221,13 @@ export class RuntimeHost {
const previousAutoClear = this.renderer.autoClear; const previousAutoClear = this.renderer.autoClear;
const previousRenderTarget = this.renderer.getRenderTarget(); const previousRenderTarget = this.renderer.getRenderTarget();
const previousFogDensity = this.underwaterSceneFog.density; const previousFogDensity = this.underwaterSceneFog.density;
const previousReflectionStates = this.runtimeWaterContactUniforms.map((waterBinding) => ({ const previousReflectionStates = this.runtimeWaterContactUniforms.map(
binding: waterBinding, (waterBinding) => ({
enabled: waterBinding.reflectionEnabledUniform?.value ?? 0, binding: waterBinding,
texture: waterBinding.reflectionTextureUniform?.value ?? null enabled: waterBinding.reflectionEnabledUniform?.value ?? 0,
})); texture: waterBinding.reflectionTextureUniform?.value ?? null
})
);
try { try {
this.underwaterSceneFog.density = 0; this.underwaterSceneFog.density = 0;
@@ -1037,13 +1260,16 @@ export class RuntimeHost {
} }
} }
binding.reflectionTextureUniform.value = binding.reflectionRenderTarget.texture; binding.reflectionTextureUniform.value =
binding.reflectionRenderTarget.texture;
binding.reflectionEnabledUniform.value = 0.36; binding.reflectionEnabledUniform.value = 0.36;
binding.lastReflectionUpdateTime = now; binding.lastReflectionUpdateTime = now;
} }
} }
private getOrCreateTexture(material: NonNullable<RuntimeBoxBrushInstance["faces"]["posX"]["material"]>) { private getOrCreateTexture(
material: NonNullable<RuntimeBoxBrushInstance["faces"]["posX"]["material"]>
) {
const signature = createStarterMaterialSignature(material); const signature = createStarterMaterialSignature(material);
const cachedTexture = this.materialTextureCache.get(material.id); const cachedTexture = this.materialTextureCache.get(material.id);
@@ -1092,7 +1318,10 @@ export class RuntimeHost {
} }
private createPlayerWaterContactBounds() { private createPlayerWaterContactBounds() {
if (this.runtimeScene === null || this.currentFirstPersonTelemetry === null) { if (
this.runtimeScene === null ||
this.currentFirstPersonTelemetry === null
) {
return null; return null;
} }
@@ -1131,16 +1360,27 @@ export class RuntimeHost {
} }
} }
private collectRuntimeStaticWaterContactPatches(brush: RuntimeBoxBrushInstance) { private collectRuntimeStaticWaterContactPatches(
brush: RuntimeBoxBrushInstance
) {
const contactBounds: Parameters<typeof collectWaterContactPatches>[1] = []; const contactBounds: Parameters<typeof collectWaterContactPatches>[1] = [];
const runtimeBrushesById = new Map((this.runtimeScene?.brushes ?? []).map((runtimeBrush) => [runtimeBrush.id, runtimeBrush])); const runtimeBrushesById = new Map(
(this.runtimeScene?.brushes ?? []).map((runtimeBrush) => [
runtimeBrush.id,
runtimeBrush
])
);
for (const collider of this.runtimeScene?.colliders ?? []) { for (const collider of this.runtimeScene?.colliders ?? []) {
if (collider.source === "brush") { if (collider.source === "brush") {
const otherBrush = runtimeBrushesById.get(collider.brushId); const otherBrush = runtimeBrushesById.get(collider.brushId);
if (otherBrush === undefined || otherBrush.id === brush.id || otherBrush.volume.mode !== "none") { if (
otherBrush === undefined ||
otherBrush.id === brush.id ||
otherBrush.volume.mode !== "none"
) {
continue; continue;
} }
@@ -1189,7 +1429,9 @@ export class RuntimeHost {
); );
} }
private collectRuntimePlayerWaterContactPatches(brush: RuntimeBoxBrushInstance) { private collectRuntimePlayerWaterContactPatches(
brush: RuntimeBoxBrushInstance
) {
const playerBounds = this.createPlayerWaterContactBounds(); const playerBounds = this.createPlayerWaterContactBounds();
if (playerBounds === null) { if (playerBounds === null) {
@@ -1208,7 +1450,9 @@ export class RuntimeHost {
} }
private getRuntimeWaterFoamContactLimit(brush: RuntimeBoxBrushInstance) { private getRuntimeWaterFoamContactLimit(brush: RuntimeBoxBrushInstance) {
return brush.volume.mode === "water" ? brush.volume.water.foamContactLimit : 0; return brush.volume.mode === "water"
? brush.volume.water.foamContactLimit
: 0;
} }
private mergeRuntimeWaterContactPatches( private mergeRuntimeWaterContactPatches(
@@ -1216,7 +1460,10 @@ export class RuntimeHost {
staticContactPatches: ReturnType<typeof collectWaterContactPatches>, staticContactPatches: ReturnType<typeof collectWaterContactPatches>,
dynamicContactPatches: ReturnType<typeof collectWaterContactPatches> dynamicContactPatches: ReturnType<typeof collectWaterContactPatches>
) { ) {
return [...dynamicContactPatches, ...staticContactPatches].slice(0, this.getRuntimeWaterFoamContactLimit(brush)); return [...dynamicContactPatches, ...staticContactPatches].slice(
0,
this.getRuntimeWaterFoamContactLimit(brush)
);
} }
private updateRuntimeWaterContactUniforms() { private updateRuntimeWaterContactUniforms() {
@@ -1226,9 +1473,12 @@ export class RuntimeHost {
binding.staticContactPatches, binding.staticContactPatches,
this.collectRuntimePlayerWaterContactPatches(binding.brush) this.collectRuntimePlayerWaterContactPatches(binding.brush)
); );
binding.uniform.value = createWaterContactPatchUniformValue(mergedPatches); binding.uniform.value =
binding.axisUniform.value = createWaterContactPatchAxisUniformValue(mergedPatches); createWaterContactPatchUniformValue(mergedPatches);
binding.shapeUniform.value = createWaterContactPatchShapeUniformValue(mergedPatches); binding.axisUniform.value =
createWaterContactPatchAxisUniformValue(mergedPatches);
binding.shapeUniform.value =
createWaterContactPatchShapeUniformValue(mergedPatches);
} }
} }
@@ -1293,7 +1543,11 @@ export class RuntimeHost {
this.activeController === this.firstPersonController && this.activeController === this.firstPersonController &&
this.currentFirstPersonTelemetry !== null this.currentFirstPersonTelemetry !== null
) { ) {
this.interactionSystem.updatePlayerPosition(this.currentFirstPersonTelemetry.feetPosition, this.runtimeScene, this.createInteractionDispatcher()); this.interactionSystem.updatePlayerPosition(
this.currentFirstPersonTelemetry.feetPosition,
this.runtimeScene,
this.createInteractionDispatcher()
);
this.camera.getWorldDirection(this.cameraForward); this.camera.getWorldDirection(this.cameraForward);
this.setInteractionPrompt( this.setInteractionPrompt(
this.interactionSystem.resolveClickInteractionPrompt( this.interactionSystem.resolveClickInteractionPrompt(
@@ -1329,7 +1583,10 @@ export class RuntimeHost {
this.firstPersonController.teleportTo(target.position, target.yawDegrees); this.firstPersonController.teleportTo(target.position, target.yawDegrees);
} }
private applyToggleBrushVisibilityAction(brushId: string, visible: boolean | undefined) { private applyToggleBrushVisibilityAction(
brushId: string,
visible: boolean | undefined
) {
const mesh = this.brushMeshes.get(brushId); const mesh = this.brushMeshes.get(brushId);
if (mesh === undefined) { if (mesh === undefined) {
@@ -1339,7 +1596,11 @@ export class RuntimeHost {
mesh.visible = visible ?? !mesh.visible; mesh.visible = visible ?? !mesh.visible;
} }
private applyPlayAnimationAction(instanceId: string, clipName: string, loop: boolean | undefined) { private applyPlayAnimationAction(
instanceId: string,
clipName: string,
loop: boolean | undefined
) {
const mixer = this.animationMixers.get(instanceId); const mixer = this.animationMixers.get(instanceId);
const clips = this.instanceAnimationClips.get(instanceId); const clips = this.instanceAnimationClips.get(instanceId);
@@ -1351,7 +1612,9 @@ export class RuntimeHost {
const clip = AnimationClip.findByName(clips, clipName); const clip = AnimationClip.findByName(clips, clipName);
if (!clip) { if (!clip) {
console.warn(`playAnimation: clip "${clipName}" not found on instance ${instanceId}`); console.warn(
`playAnimation: clip "${clipName}" not found on instance ${instanceId}`
);
return; return;
} }
@@ -1399,7 +1662,8 @@ export class RuntimeHost {
private setInteractionPrompt(prompt: RuntimeInteractionPrompt | null) { private setInteractionPrompt(prompt: RuntimeInteractionPrompt | null) {
if ( if (
this.currentInteractionPrompt?.sourceEntityId === prompt?.sourceEntityId && this.currentInteractionPrompt?.sourceEntityId ===
prompt?.sourceEntityId &&
this.currentInteractionPrompt?.prompt === prompt?.prompt && this.currentInteractionPrompt?.prompt === prompt?.prompt &&
this.currentInteractionPrompt?.distance === prompt?.distance && this.currentInteractionPrompt?.distance === prompt?.distance &&
this.currentInteractionPrompt?.range === prompt?.range this.currentInteractionPrompt?.range === prompt?.range
@@ -1422,7 +1686,11 @@ export class RuntimeHost {
} }
this.audioSystem.handleUserGesture(); this.audioSystem.handleUserGesture();
this.interactionSystem.dispatchClickInteraction(this.currentInteractionPrompt.sourceEntityId, this.runtimeScene, this.createInteractionDispatcher()); this.interactionSystem.dispatchClickInteraction(
this.currentInteractionPrompt.sourceEntityId,
this.runtimeScene,
this.createInteractionDispatcher()
);
}; };
private handleRuntimePointerDown = () => { private handleRuntimePointerDown = () => {

View File

@@ -84,7 +84,9 @@ describe("EditorStore", () => {
const secondSceneId = store.getState().activeSceneId; const secondSceneId = store.getState().activeSceneId;
expect(secondSceneId).not.toBe(firstSceneId); expect(secondSceneId).not.toBe(firstSceneId);
expect(Object.keys(store.getState().projectDocument.scenes)).toHaveLength(2); expect(Object.keys(store.getState().projectDocument.scenes)).toHaveLength(
2
);
expect(store.getState().document.name).toBe("Scene 2"); expect(store.getState().document.name).toBe("Scene 2");
store.executeCommand(createCreateBoxBrushCommand()); store.executeCommand(createCreateBoxBrushCommand());
@@ -269,9 +271,13 @@ describe("EditorStore", () => {
expect(store.getState().whiteboxSelectionMode).toBe("object"); expect(store.getState().whiteboxSelectionMode).toBe("object");
expect(store.getState().viewportLayoutMode).toBe("single"); expect(store.getState().viewportLayoutMode).toBe("single");
expect(store.getState().activeViewportPanelId).toBe("topLeft"); expect(store.getState().activeViewportPanelId).toBe("topLeft");
expect(store.getState().viewportPanels.topLeft.viewMode).toBe("perspective"); expect(store.getState().viewportPanels.topLeft.viewMode).toBe(
"perspective"
);
expect(store.getState().viewportPanels.topRight.viewMode).toBe("top"); expect(store.getState().viewportPanels.topRight.viewMode).toBe("top");
expect(store.getState().viewportPanels.topRight.displayMode).toBe("authoring"); expect(store.getState().viewportPanels.topRight.displayMode).toBe(
"authoring"
);
expect(store.getState().viewportQuadSplit).toEqual({ expect(store.getState().viewportQuadSplit).toEqual({
x: 0.5, x: 0.5,
y: 0.5 y: 0.5
@@ -289,7 +295,9 @@ describe("EditorStore", () => {
expect(store.getState().viewportLayoutMode).toBe("quad"); expect(store.getState().viewportLayoutMode).toBe("quad");
expect(store.getState().activeViewportPanelId).toBe("bottomRight"); expect(store.getState().activeViewportPanelId).toBe("bottomRight");
expect(store.getState().viewportPanels.bottomRight.viewMode).toBe("front"); expect(store.getState().viewportPanels.bottomRight.viewMode).toBe("front");
expect(store.getState().viewportPanels.bottomRight.displayMode).toBe("normal"); expect(store.getState().viewportPanels.bottomRight.displayMode).toBe(
"normal"
);
expect(store.getState().viewportQuadSplit).toEqual({ expect(store.getState().viewportQuadSplit).toEqual({
x: 0.38, x: 0.38,
y: 0.62 y: 0.62
@@ -482,7 +490,9 @@ describe("EditorStore", () => {
}) })
); );
expect(store.getState().viewportTransientState.transformSession).toMatchObject({ expect(
store.getState().viewportTransientState.transformSession
).toMatchObject({
kind: "active", kind: "active",
source: "keyboard", source: "keyboard",
sourcePanelId: "bottomRight", sourcePanelId: "bottomRight",

View File

@@ -75,11 +75,16 @@ describe("local draft storage", () => {
expect(result.document.version).toBe(SCENE_DOCUMENT_VERSION); expect(result.document.version).toBe(SCENE_DOCUMENT_VERSION);
expect(result.document).toEqual(createEmptyProjectDocument()); expect(result.document).toEqual(createEmptyProjectDocument());
expect(result.diagnostic).toContain("Stored autosave could not be loaded."); expect(result.diagnostic).toContain("Stored autosave could not be loaded.");
expect(result.diagnostic).toContain("Starting with a fresh empty document."); expect(result.diagnostic).toContain(
"Starting with a fresh empty document."
);
}); });
it("reports browser storage access failures without throwing", () => { it("reports browser storage access failures without throwing", () => {
const originalDescriptor = Object.getOwnPropertyDescriptor(window, "localStorage"); const originalDescriptor = Object.getOwnPropertyDescriptor(
window,
"localStorage"
);
Object.defineProperty(window, "localStorage", { Object.defineProperty(window, "localStorage", {
configurable: true, configurable: true,
@@ -92,7 +97,9 @@ describe("local draft storage", () => {
const result = getBrowserStorageAccess(); const result = getBrowserStorageAccess();
expect(result.storage).toBeNull(); expect(result.storage).toBeNull();
expect(result.diagnostic).toContain("Browser local storage is unavailable."); expect(result.diagnostic).toContain(
"Browser local storage is unavailable."
);
expect(result.diagnostic).toContain("access denied"); expect(result.diagnostic).toContain("access denied");
} finally { } finally {
if (originalDescriptor !== undefined) { if (originalDescriptor !== undefined) {
@@ -161,9 +168,9 @@ describe("local draft storage", () => {
return; return;
} }
expect( expect(result.document.scenes[result.document.activeSceneId]?.name).toBe(
result.document.scenes[result.document.activeSceneId]?.name "Viewport Draft"
).toBe("Viewport Draft"); );
expect(result.viewportLayoutState).toMatchObject({ expect(result.viewportLayoutState).toMatchObject({
layoutMode: "quad", layoutMode: "quad",
activePanelId: "bottomRight", activePanelId: "bottomRight",
@@ -235,7 +242,10 @@ describe("local draft storage", () => {
it("loads older raw scene-document drafts without requiring viewport layout state", () => { it("loads older raw scene-document drafts without requiring viewport layout state", () => {
const storage = new MemoryStorage(); const storage = new MemoryStorage();
storage.setItem(DEFAULT_SCENE_DRAFT_STORAGE_KEY, serializeSceneDocument(createEmptySceneDocument({ name: "Legacy Draft" }))); storage.setItem(
DEFAULT_SCENE_DRAFT_STORAGE_KEY,
serializeSceneDocument(createEmptySceneDocument({ name: "Legacy Draft" }))
);
const result = loadSceneDocumentDraft(storage); const result = loadSceneDocumentDraft(storage);
@@ -245,9 +255,9 @@ describe("local draft storage", () => {
return; return;
} }
expect( expect(result.document.scenes[result.document.activeSceneId]?.name).toBe(
result.document.scenes[result.document.activeSceneId]?.name "Legacy Draft"
).toBe("Legacy Draft"); );
expect(result.viewportLayoutState).toBeNull(); expect(result.viewportLayoutState).toBeNull();
expect(result.message).toBe("Recovered latest autosave."); expect(result.message).toBe("Recovered latest autosave.");
}); });
@@ -263,9 +273,9 @@ describe("local draft storage", () => {
const result = loadOrCreateSceneDocument(storage); const result = loadOrCreateSceneDocument(storage);
expect( expect(result.document.scenes[result.document.activeSceneId]?.name).toBe(
result.document.scenes[result.document.activeSceneId]?.name "Recovered Scene"
).toBe("Recovered Scene"); );
expect(result.diagnostic).toBe("Recovered latest autosave."); expect(result.diagnostic).toBe("Recovered latest autosave.");
}); });

View File

@@ -44,8 +44,10 @@ describe("project document JSON", () => {
id: "scene-main", id: "scene-main",
name: "Legacy Entry" name: "Legacy Entry"
}); });
const { loadingScreen: _loadingScreen, ...legacySceneWithoutLoadingScreen } = const {
legacyScene; loadingScreen: _loadingScreen,
...legacySceneWithoutLoadingScreen
} = legacyScene;
void _loadingScreen; void _loadingScreen;
const migratedDocument = parseProjectDocumentJson( const migratedDocument = parseProjectDocumentJson(

View File

@@ -5,11 +5,21 @@ import { strToU8, unzipSync, Zip, ZipDeflate } from "fflate";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { loadAudioAssetFromStorage } from "../../src/assets/audio-assets"; import { loadAudioAssetFromStorage } from "../../src/assets/audio-assets";
import { loadModelAssetFromStorage, importModelAssetFromFiles } from "../../src/assets/gltf-model-import"; import {
loadModelAssetFromStorage,
importModelAssetFromFiles
} from "../../src/assets/gltf-model-import";
import { loadImageAssetFromStorage } from "../../src/assets/image-assets"; import { loadImageAssetFromStorage } from "../../src/assets/image-assets";
import { createModelInstance } from "../../src/assets/model-instances"; import { createModelInstance } from "../../src/assets/model-instances";
import { createInMemoryProjectAssetStorage, type ProjectAssetStorage } from "../../src/assets/project-asset-storage"; import {
import { createProjectAssetStorageKey, type AudioAssetRecord, type ImageAssetRecord } from "../../src/assets/project-assets"; createInMemoryProjectAssetStorage,
type ProjectAssetStorage
} from "../../src/assets/project-asset-storage";
import {
createProjectAssetStorageKey,
type AudioAssetRecord,
type ImageAssetRecord
} from "../../src/assets/project-assets";
import { import {
createEmptyProjectScene, createEmptyProjectScene,
createEmptySceneDocument, createEmptySceneDocument,
@@ -22,11 +32,24 @@ import {
} from "../../src/serialization/project-package"; } from "../../src/serialization/project-package";
import { serializeProjectDocument } from "../../src/serialization/scene-document-json"; import { serializeProjectDocument } from "../../src/serialization/scene-document-json";
const tinyGlbFixturePath = path.resolve(process.cwd(), "fixtures/assets/tiny-triangle.glb"); const tinyGlbFixturePath = path.resolve(
const externalTriangleGltfPath = path.resolve(process.cwd(), "fixtures/assets/external-triangle/scene.gltf"); process.cwd(),
const externalTriangleBinPath = path.resolve(process.cwd(), "fixtures/assets/external-triangle/triangle.bin"); "fixtures/assets/tiny-triangle.glb"
);
const externalTriangleGltfPath = path.resolve(
process.cwd(),
"fixtures/assets/external-triangle/scene.gltf"
);
const externalTriangleBinPath = path.resolve(
process.cwd(),
"fixtures/assets/external-triangle/triangle.bin"
);
function createTestFile(bytes: Uint8Array | Buffer, name: string, type: string): File { function createTestFile(
bytes: Uint8Array | Buffer,
name: string,
type: string
): File {
const arrayBuffer = new ArrayBuffer(bytes.byteLength); const arrayBuffer = new ArrayBuffer(bytes.byteLength);
new Uint8Array(arrayBuffer).set(bytes); new Uint8Array(arrayBuffer).set(bytes);
@@ -72,7 +95,9 @@ function buildZipArchive(entries: Record<string, Uint8Array>): Uint8Array {
zip.end(); zip.end();
const archiveBytes = new Uint8Array(chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0)); const archiveBytes = new Uint8Array(
chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0)
);
let offset = 0; let offset = 0;
for (const chunk of chunks) { for (const chunk of chunks) {
@@ -177,7 +202,11 @@ describe("project package serialization", () => {
await storage.putAsset(imageAsset.storageKey, { await storage.putAsset(imageAsset.storageKey, {
files: { files: {
[imageAsset.sourceName]: { [imageAsset.sourceName]: {
bytes: cloneArrayBuffer(strToU8("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1024\" height=\"512\"></svg>")), bytes: cloneArrayBuffer(
strToU8(
'<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="512"></svg>'
)
),
mimeType: imageAsset.mimeType mimeType: imageAsset.mimeType
} }
} }
@@ -191,7 +220,10 @@ describe("project package serialization", () => {
} }
}); });
const imageLoadListeners = new WeakMap<object, { load?: () => void; error?: () => void }>(); const imageLoadListeners = new WeakMap<
object,
{ load?: () => void; error?: () => void }
>();
const mockImageWidth = 1024; const mockImageWidth = 1024;
const mockImageHeight = 512; const mockImageHeight = 512;
@@ -247,18 +279,32 @@ describe("project package serialization", () => {
const packageBytes = await saveProjectPackage(document, storage); const packageBytes = await saveProjectPackage(document, storage);
const restoredStorage = createInMemoryProjectAssetStorage(); const restoredStorage = createInMemoryProjectAssetStorage();
const restoredDocument = await loadProjectPackage(packageBytes, restoredStorage); const restoredDocument = await loadProjectPackage(
packageBytes,
restoredStorage
);
expect(restoredDocument).toEqual(document); expect(restoredDocument).toEqual(document);
const restoredModel = await loadModelAssetFromStorage(restoredStorage, importedModel.asset); const restoredModel = await loadModelAssetFromStorage(
const restoredImage = await loadImageAssetFromStorage(restoredStorage, imageAsset); restoredStorage,
const restoredAudio = await loadAudioAssetFromStorage(restoredStorage, audioAsset); importedModel.asset
);
const restoredImage = await loadImageAssetFromStorage(
restoredStorage,
imageAsset
);
const restoredAudio = await loadAudioAssetFromStorage(
restoredStorage,
audioAsset
);
expect(restoredModel.metadata.format).toBe("glb"); expect(restoredModel.metadata.format).toBe("glb");
expect(restoredModel.template.children.length).toBeGreaterThan(0); expect(restoredModel.template.children.length).toBeGreaterThan(0);
expect(restoredImage.metadata.width).toBe(imageAsset.metadata.width); expect(restoredImage.metadata.width).toBe(imageAsset.metadata.width);
expect(restoredAudio.metadata.durationSeconds).toBe(audioAsset.metadata.durationSeconds); expect(restoredAudio.metadata.durationSeconds).toBe(
audioAsset.metadata.durationSeconds
);
}); });
it("preserves multi-file gltf asset bundles inside the packaged assets directory", async () => { it("preserves multi-file gltf asset bundles inside the packaged assets directory", async () => {
@@ -290,7 +336,10 @@ describe("project package serialization", () => {
await loadProjectPackage(packageBytes, restoredStorage); await loadProjectPackage(packageBytes, restoredStorage);
const restoredModel = await loadModelAssetFromStorage(restoredStorage, importedModel.asset); const restoredModel = await loadModelAssetFromStorage(
restoredStorage,
importedModel.asset
);
expect(restoredModel.metadata.format).toBe("gltf"); expect(restoredModel.metadata.format).toBe("gltf");
expect(restoredModel.template.children.length).toBeGreaterThan(0); expect(restoredModel.template.children.length).toBeGreaterThan(0);
@@ -320,7 +369,9 @@ describe("project package serialization", () => {
} }
}); });
await expect(saveProjectPackage(document, storage)).rejects.toThrow("Missing stored binary data for image asset missing.png."); await expect(saveProjectPackage(document, storage)).rejects.toThrow(
"Missing stored binary data for image asset missing.png."
);
}); });
it("fails project load when scene.json is missing", async () => { it("fails project load when scene.json is missing", async () => {
@@ -328,7 +379,9 @@ describe("project package serialization", () => {
"assets/readme.txt": strToU8("not a project") "assets/readme.txt": strToU8("not a project")
}); });
await expect(loadProjectPackage(packageBytes, null)).rejects.toThrow("project package is missing scene.json"); await expect(loadProjectPackage(packageBytes, null)).rejects.toThrow(
"project package is missing scene.json"
);
}); });
it("fails project load when a declared asset has no packaged files", async () => { it("fails project load when a declared asset has no packaged files", async () => {
@@ -357,7 +410,9 @@ describe("project package serialization", () => {
[PROJECT_PACKAGE_SCENE_PATH]: strToU8(serializeProjectDocument(document)) [PROJECT_PACKAGE_SCENE_PATH]: strToU8(serializeProjectDocument(document))
}); });
await expect(loadProjectPackage(packageBytes, createInMemoryProjectAssetStorage())).rejects.toThrow( await expect(
loadProjectPackage(packageBytes, createInMemoryProjectAssetStorage())
).rejects.toThrow(
"project package is missing bundled files for image asset missing.svg" "project package is missing bundled files for image asset missing.svg"
); );
}); });
@@ -366,8 +421,13 @@ describe("project package serialization", () => {
const document = createProjectDocument( const document = createProjectDocument(
createEmptySceneDocument({ name: "Portable Scene Without Storage" }) createEmptySceneDocument({ name: "Portable Scene Without Storage" })
); );
const packageBytes = await saveProjectPackage(document, createInMemoryProjectAssetStorage()); const packageBytes = await saveProjectPackage(
document,
createInMemoryProjectAssetStorage()
);
await expect(loadProjectPackage(packageBytes, null as ProjectAssetStorage | null)).resolves.toEqual(document); await expect(
loadProjectPackage(packageBytes, null as ProjectAssetStorage | null)
).resolves.toEqual(document);
}); });
}); });

View File

@@ -60,7 +60,9 @@ describe("RunnerCanvas", () => {
}); });
it("only shows the underwater overlay when the camera is submerged", async () => { it("only shows the underwater overlay when the camera is submerged", async () => {
const runtimeScene = buildRuntimeSceneFromDocument(createEmptySceneDocument()); const runtimeScene = buildRuntimeSceneFromDocument(
createEmptySceneDocument()
);
const onTelemetryChange = vi.fn(); const onTelemetryChange = vi.fn();
render( render(
@@ -81,12 +83,20 @@ describe("RunnerCanvas", () => {
await waitFor(() => { await waitFor(() => {
expect(runtimeHostInstances).toHaveLength(1); expect(runtimeHostInstances).toHaveLength(1);
expect(runtimeHostInstances[0]?.setFirstPersonTelemetryHandler).toHaveBeenCalledTimes(1); expect(
expect(runtimeHostInstances[0]?.setSceneLoadStateHandler).toHaveBeenCalledTimes(1); runtimeHostInstances[0]?.setFirstPersonTelemetryHandler
).toHaveBeenCalledTimes(1);
expect(
runtimeHostInstances[0]?.setSceneLoadStateHandler
).toHaveBeenCalledTimes(1);
}); });
const publishTelemetry = runtimeHostInstances[0]?.setFirstPersonTelemetryHandler.mock.calls[0]?.[0] as ((telemetry: FirstPersonTelemetry | null) => void) | undefined; const publishTelemetry = runtimeHostInstances[0]
const publishSceneLoadState = runtimeHostInstances[0]?.setSceneLoadStateHandler.mock.calls[0]?.[0] as ?.setFirstPersonTelemetryHandler.mock.calls[0]?.[0] as
| ((telemetry: FirstPersonTelemetry | null) => void)
| undefined;
const publishSceneLoadState = runtimeHostInstances[0]
?.setSceneLoadStateHandler.mock.calls[0]?.[0] as
| ((state: RuntimeSceneLoadState) => void) | ((state: RuntimeSceneLoadState) => void)
| undefined; | undefined;
@@ -114,7 +124,9 @@ describe("RunnerCanvas", () => {
}); });
}); });
expect(screen.queryByLabelText("Built-in scene runner")?.className).not.toContain("runner-canvas--underwater"); expect(
screen.queryByLabelText("Built-in scene runner")?.className
).not.toContain("runner-canvas--underwater");
expect(document.querySelector(".runner-canvas__underwater")).toBeNull(); expect(document.querySelector(".runner-canvas__underwater")).toBeNull();
act(() => { act(() => {
@@ -131,12 +143,16 @@ describe("RunnerCanvas", () => {
}); });
}); });
expect(screen.getByLabelText("Built-in scene runner").className).toContain("runner-canvas--underwater"); expect(screen.getByLabelText("Built-in scene runner").className).toContain(
"runner-canvas--underwater"
);
expect(document.querySelector(".runner-canvas__underwater")).not.toBeNull(); expect(document.querySelector(".runner-canvas__underwater")).not.toBeNull();
}); });
it("shows the loading overlay until the runtime host reports readiness", async () => { it("shows the loading overlay until the runtime host reports readiness", async () => {
const runtimeScene = buildRuntimeSceneFromDocument(createEmptySceneDocument()); const runtimeScene = buildRuntimeSceneFromDocument(
createEmptySceneDocument()
);
render( render(
<RunnerCanvas <RunnerCanvas
@@ -160,28 +176,31 @@ describe("RunnerCanvas", () => {
await waitFor(() => { await waitFor(() => {
expect(runtimeHostInstances).toHaveLength(1); expect(runtimeHostInstances).toHaveLength(1);
expect(runtimeHostInstances[0]?.setSceneLoadStateHandler).toHaveBeenCalledTimes(1); expect(
runtimeHostInstances[0]?.setSceneLoadStateHandler
).toHaveBeenCalledTimes(1);
}); });
expect(screen.getByTestId("runner-loading-overlay").className).not.toContain( expect(
"runner-canvas__loading-overlay--hidden" screen.getByTestId("runner-loading-overlay").className
); ).not.toContain("runner-canvas__loading-overlay--hidden");
expect(screen.getByTestId("runner-loading-scene-name")).toHaveTextContent( expect(screen.getByTestId("runner-loading-scene-name")).toHaveTextContent(
"Dungeon Entry" "Dungeon Entry"
); );
expect(screen.getByTestId("runner-loading-headline")).toHaveTextContent( expect(screen.getByTestId("runner-loading-headline")).toHaveTextContent(
"Preparing encounter" "Preparing encounter"
); );
expect( expect(screen.getByTestId("runner-loading-description")).toHaveTextContent(
screen.getByTestId("runner-loading-description") "Enemies and triggers are being wired up."
).toHaveTextContent("Enemies and triggers are being wired up."); );
expect(document.querySelector(".runner-canvas__crosshair")).toBeNull(); expect(document.querySelector(".runner-canvas__crosshair")).toBeNull();
expect(screen.getByTestId("runner-shell")).toHaveAttribute( expect(screen.getByTestId("runner-shell")).toHaveAttribute(
"aria-busy", "aria-busy",
"true" "true"
); );
const publishSceneLoadState = runtimeHostInstances[0]?.setSceneLoadStateHandler.mock.calls[0]?.[0] as const publishSceneLoadState = runtimeHostInstances[0]
?.setSceneLoadStateHandler.mock.calls[0]?.[0] as
| ((state: RuntimeSceneLoadState) => void) | ((state: RuntimeSceneLoadState) => void)
| undefined; | undefined;
@@ -205,7 +224,9 @@ describe("RunnerCanvas", () => {
}); });
it("keeps the overlay visible and shows load errors from the runtime host", async () => { it("keeps the overlay visible and shows load errors from the runtime host", async () => {
const runtimeScene = buildRuntimeSceneFromDocument(createEmptySceneDocument()); const runtimeScene = buildRuntimeSceneFromDocument(
createEmptySceneDocument()
);
render( render(
<RunnerCanvas <RunnerCanvas
@@ -224,10 +245,13 @@ describe("RunnerCanvas", () => {
); );
await waitFor(() => { await waitFor(() => {
expect(runtimeHostInstances[0]?.setSceneLoadStateHandler).toHaveBeenCalledTimes(1); expect(
runtimeHostInstances[0]?.setSceneLoadStateHandler
).toHaveBeenCalledTimes(1);
}); });
const publishSceneLoadState = runtimeHostInstances[0]?.setSceneLoadStateHandler.mock.calls[0]?.[0] as const publishSceneLoadState = runtimeHostInstances[0]
?.setSceneLoadStateHandler.mock.calls[0]?.[0] as
| ((state: RuntimeSceneLoadState) => void) | ((state: RuntimeSceneLoadState) => void)
| undefined; | undefined;
@@ -238,9 +262,9 @@ describe("RunnerCanvas", () => {
}); });
}); });
expect(screen.getByTestId("runner-loading-overlay").className).not.toContain( expect(
"runner-canvas__loading-overlay--hidden" screen.getByTestId("runner-loading-overlay").className
); ).not.toContain("runner-canvas__loading-overlay--hidden");
expect(screen.getByTestId("runner-loading-error")).toHaveTextContent( expect(screen.getByTestId("runner-loading-error")).toHaveTextContent(
"Runner scene failed to load: collision bootstrap exploded." "Runner scene failed to load: collision bootstrap exploded."
); );

View File

@@ -34,7 +34,9 @@ describe("RuntimeHost", () => {
}); });
it("delays controller activation until collision setup reports the scene as ready", async () => { it("delays controller activation until collision setup reports the scene as ready", async () => {
const runtimeScene = buildRuntimeSceneFromDocument(createEmptySceneDocument()); const runtimeScene = buildRuntimeSceneFromDocument(
createEmptySceneDocument()
);
vi.spyOn(console, "warn").mockImplementation(() => undefined); vi.spyOn(console, "warn").mockImplementation(() => undefined);
const collisionWorld = { const collisionWorld = {
dispose: vi.fn() dispose: vi.fn()