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;
overflow: hidden;
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%);
color: var(--color-text);
}
@@ -203,7 +207,10 @@ button:disabled {
.workspace {
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;
min-height: 0;
overflow: hidden;
@@ -296,7 +303,11 @@ button:disabled {
.stat-card {
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-radius: 14px;
}
@@ -988,7 +999,11 @@ button:disabled {
flex: 1 1 auto;
min-height: 0;
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%);
}
@@ -1145,7 +1160,11 @@ button:disabled {
gap: 8px;
padding: 18px;
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-radius: 18px;
pointer-events: auto;
@@ -1263,7 +1282,11 @@ button:disabled {
z-index: 2;
pointer-events: none;
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));
backdrop-filter: blur(1.5px) saturate(1.08);
mix-blend-mode: screen;

View File

@@ -70,7 +70,9 @@ export function createSetSceneLoadingScreenCommand(
...currentProjectDocument.scenes,
[options.sceneId]: {
...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 { EntityInstance } from "../entities/entity-instances";
import type { InteractionLink } from "../interactions/interaction-links";
import { cloneMaterialRegistry, createStarterMaterialRegistry, type MaterialDef } from "../materials/starter-material-library";
import { createDefaultWorldSettings, type WorldSettings } from "./world-settings";
import {
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 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_GEOMETRY_SCENE_DOCUMENT_VERSION = 19 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 ENTITY_NAMES_SCENE_DOCUMENT_VERSION = 15 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 WORLD_ENVIRONMENT_SCENE_DOCUMENT_VERSION = 6 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 DEFAULT_PROJECT_SCENE_ID = "scene-main" as const;
@@ -71,12 +80,16 @@ export interface SceneDocument {
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 {
version: SCENE_DOCUMENT_VERSION,
name: overrides.name ?? "Untitled Scene",
world: overrides.world ?? createDefaultWorldSettings(),
materials: cloneMaterialRegistry(overrides.materials ?? createStarterMaterialRegistry()),
materials: cloneMaterialRegistry(
overrides.materials ?? createStarterMaterialRegistry()
),
textures: {},
assets: {},
brushes: {},
@@ -87,7 +100,9 @@ export function createEmptySceneDocument(overrides: Partial<Pick<SceneDocument,
}
export function createEmptyProjectScene(
overrides: Partial<Pick<ProjectScene, "id" | "name" | "loadingScreen" | "world">> = {}
overrides: Partial<
Pick<ProjectScene, "id" | "name" | "loadingScreen" | "world">
> = {}
): ProjectScene {
return {
id: overrides.id ?? createOpaqueId("scene"),
@@ -113,7 +128,8 @@ export function createEmptyProjectDocument(
} = {}
): ProjectDocument {
const initialScene = createEmptyProjectScene({
id: overrides.sceneId ?? overrides.activeSceneId ?? DEFAULT_PROJECT_SCENE_ID,
id:
overrides.sceneId ?? overrides.activeSceneId ?? DEFAULT_PROJECT_SCENE_ID,
name: overrides.sceneName,
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 { SceneLoadingScreenSettings } from "../document/scene-document";
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 { 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";
interface RunnerCanvasProps {
@@ -45,10 +51,13 @@ export function RunnerCanvas({
status: "loading",
message: null
});
const [interactionPrompt, setInteractionPrompt] = useState<RuntimeInteractionPrompt | null>(null);
const [firstPersonTelemetry, setFirstPersonTelemetry] = useState<FirstPersonTelemetry | null>(null);
const [interactionPrompt, setInteractionPrompt] =
useState<RuntimeInteractionPrompt | null>(null);
const [firstPersonTelemetry, setFirstPersonTelemetry] =
useState<FirstPersonTelemetry | null>(null);
const overlayMessage = runnerMessage ?? sceneLoadState.message;
const overlayStatus = overlayMessage !== null ? "error" : sceneLoadState.status;
const overlayStatus =
overlayMessage !== null ? "error" : sceneLoadState.status;
const runnerReady = overlayStatus === "ready";
useEffect(() => {
@@ -86,7 +95,9 @@ export function RunnerCanvas({
};
} catch (error) {
const message =
error instanceof Error ? error.message : "Runner initialization failed.";
error instanceof Error
? error.message
: "Runner initialization failed.";
const failureMessage = `Runner initialization failed: ${message}`;
setRunnerMessage(failureMessage);
setSceneLoadState({
@@ -98,10 +109,19 @@ export function RunnerCanvas({
onFirstPersonTelemetryChange(null);
return;
}
}, [onFirstPersonTelemetryChange, onInteractionPromptChange, onRuntimeMessageChange]);
}, [
onFirstPersonTelemetryChange,
onInteractionPromptChange,
onRuntimeMessageChange
]);
useEffect(() => {
hostRef.current?.updateAssets(projectAssets, loadedModelAssets, loadedImageAssets, loadedAudioAssets);
hostRef.current?.updateAssets(
projectAssets,
loadedModelAssets,
loadedImageAssets,
loadedAudioAssets
);
}, [projectAssets, loadedModelAssets, loadedImageAssets, loadedAudioAssets]);
useEffect(() => {
@@ -116,7 +136,12 @@ export function RunnerCanvas({
onFirstPersonTelemetryChange(null);
onRuntimeMessageChange(null);
hostRef.current?.loadScene(runtimeScene);
}, [onFirstPersonTelemetryChange, onInteractionPromptChange, onRuntimeMessageChange, runtimeScene]);
}, [
onFirstPersonTelemetryChange,
onInteractionPromptChange,
onRuntimeMessageChange,
runtimeScene
]);
useEffect(() => {
hostRef.current?.setNavigationMode(navigationMode);
@@ -131,7 +156,10 @@ export function RunnerCanvas({
aria-busy={!runnerReady}
style={createWorldBackgroundStyle(
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
@@ -191,19 +219,33 @@ export function RunnerCanvas({
{runnerReady &&
navigationMode === "firstPerson" &&
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-text" data-testid="runner-interaction-prompt-text">
<div
className="runner-canvas__prompt-text"
data-testid="runner-interaction-prompt-text"
>
{interactionPrompt.prompt}
</div>
<div className="runner-canvas__prompt-meta" data-testid="runner-interaction-prompt-meta">
{interactionPrompt.distance.toFixed(1)}m away · {interactionPrompt.range.toFixed(1)}m range
<div
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>
) : null}
{runnerMessage === null ? null : (
<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>
)}

View File

@@ -3,7 +3,11 @@ import { Euler, Vector3 } from "three";
import type { Vec3 } from "../core/vector";
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 MOVE_SPEED = 4.5;
@@ -11,7 +15,10 @@ const GRAVITY = 22;
const MAX_PITCH_RADIANS = Math.PI * 0.48;
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 {
@@ -67,7 +74,10 @@ export class FirstPersonNavigationController implements NavigationController {
window.addEventListener("keyup", this.handleKeyUp);
window.addEventListener("blur", this.handleBlur);
document.addEventListener("mousemove", this.handleMouseMove);
document.addEventListener("pointerlockchange", this.handlePointerLockChange);
document.addEventListener(
"pointerlockchange",
this.handlePointerLockChange
);
document.addEventListener("pointerlockerror", this.handlePointerLockError);
ctx.domElement.addEventListener("pointerdown", this.handlePointerDown);
@@ -81,8 +91,14 @@ export class FirstPersonNavigationController implements NavigationController {
window.removeEventListener("keyup", this.handleKeyUp);
window.removeEventListener("blur", this.handleBlur);
document.removeEventListener("mousemove", this.handleMouseMove);
document.removeEventListener("pointerlockchange", this.handlePointerLockChange);
document.removeEventListener("pointerlockerror", this.handlePointerLockError);
document.removeEventListener(
"pointerlockchange",
this.handlePointerLockChange
);
document.removeEventListener(
"pointerlockerror",
this.handlePointerLockError
);
ctx.domElement.removeEventListener("pointerdown", this.handlePointerDown);
this.pressedKeys.clear();
@@ -120,9 +136,15 @@ export class FirstPersonNavigationController implements NavigationController {
}
const playerShape = this.context.getRuntimeScene().playerCollider;
const currentVolumeState = this.context.resolvePlayerVolumeState(this.feetPosition);
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 currentVolumeState = this.context.resolvePlayerVolumeState(
this.feetPosition
);
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);
let horizontalX = 0;
@@ -133,11 +155,25 @@ export class FirstPersonNavigationController implements NavigationController {
const normalizedInputZ = inputZ / inputLength;
const moveDistance = MOVE_SPEED * dt;
this.forwardVector.set(Math.sin(this.yawRadians), 0, Math.cos(this.yawRadians));
this.rightVector.set(-Math.cos(this.yawRadians), 0, Math.sin(this.yawRadians));
this.forwardVector.set(
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;
horizontalZ = (this.forwardVector.z * normalizedInputZ + this.rightVector.z * normalizedInputX) * moveDistance;
horizontalX =
(this.forwardVector.x * normalizedInputZ +
this.rightVector.x * normalizedInputX) *
moveDistance;
horizontalZ =
(this.forwardVector.z * normalizedInputZ +
this.rightVector.z * normalizedInputX) *
moveDistance;
}
if (playerShape.mode === "none") {
@@ -152,7 +188,10 @@ export class FirstPersonNavigationController implements NavigationController {
this.feetPosition,
{
x: horizontalX,
y: playerShape.mode === "none" || currentVolumeState.inWater ? 0 : this.verticalVelocity * dt,
y:
playerShape.mode === "none" || currentVolumeState.inWater
? 0
: this.verticalVelocity * dt,
z: horizontalZ
},
playerShape
@@ -165,7 +204,9 @@ export class FirstPersonNavigationController implements NavigationController {
}
this.feetPosition = resolvedMotion.feetPosition;
const nextVolumeState = this.context.resolvePlayerVolumeState(this.feetPosition);
const nextVolumeState = this.context.resolvePlayerVolumeState(
this.feetPosition
);
this.inWaterVolume = nextVolumeState.inWater;
this.inFogVolume = nextVolumeState.inFog;
this.grounded = nextVolumeState.inWater ? false : resolvedMotion.grounded;
@@ -210,7 +251,12 @@ export class FirstPersonNavigationController implements NavigationController {
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;
// 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
@@ -218,7 +264,11 @@ export class FirstPersonNavigationController implements NavigationController {
this.cameraRotation.y = this.yawRadians + Math.PI;
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);
}
@@ -227,8 +277,14 @@ export class FirstPersonNavigationController implements NavigationController {
return;
}
const eyePosition = toEyePosition(this.feetPosition, getFirstPersonPlayerEyeHeight(this.context.getRuntimeScene().playerCollider));
const cameraVolumeState = this.context.resolvePlayerVolumeState(eyePosition);
const eyePosition = toEyePosition(
this.feetPosition,
getFirstPersonPlayerEyeHeight(
this.context.getRuntimeScene().playerCollider
)
);
const cameraVolumeState =
this.context.resolvePlayerVolumeState(eyePosition);
this.context.setFirstPersonTelemetry({
feetPosition: {
@@ -250,7 +306,8 @@ export class FirstPersonNavigationController implements NavigationController {
return;
}
const pointerLocked = document.pointerLockElement === this.context.domElement;
const pointerLocked =
document.pointerLockElement === this.context.domElement;
this.pointerLocked = pointerLocked;
this.context.setRuntimeMessage(
pointerLocked
@@ -278,7 +335,9 @@ export class FirstPersonNavigationController implements NavigationController {
}
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 = () => {
@@ -292,11 +351,15 @@ export class FirstPersonNavigationController implements NavigationController {
};
private handlePointerDown = () => {
if (this.context === null || document.pointerLockElement === this.context.domElement) {
if (
this.context === null ||
document.pointerLockElement === this.context.domElement
) {
return;
}
const pointerLockCapableElement = this.context.domElement as HTMLCanvasElement & {
const pointerLockCapableElement = this.context
.domElement as HTMLCanvasElement & {
requestPointerLock(): void | Promise<void>;
};
const pointerLockResult = pointerLockCapableElement.requestPointerLock();

View File

@@ -2,8 +2,15 @@ import type { PerspectiveCamera } from "three";
import type { Vec3 } from "../core/vector";
import type { FirstPersonPlayerShape, ResolvedPlayerMotion } from "./player-collision";
import type { RuntimeNavigationMode, RuntimeSceneDefinition, RuntimeSpawnPoint } from "./runtime-scene-build";
import type {
FirstPersonPlayerShape,
ResolvedPlayerMotion
} from "./player-collision";
import type {
RuntimeNavigationMode,
RuntimeSceneDefinition,
RuntimeSpawnPoint
} from "./runtime-scene-build";
export interface FirstPersonTelemetry {
feetPosition: Vec3;
@@ -28,7 +35,11 @@ export interface RuntimeControllerContext {
camera: PerspectiveCamera;
domElement: HTMLCanvasElement;
getRuntimeScene(): RuntimeSceneDefinition;
resolveFirstPersonMotion(feetPosition: Vec3, motion: Vec3, shape: FirstPersonPlayerShape): ResolvedPlayerMotion | null;
resolveFirstPersonMotion(
feetPosition: Vec3,
motion: Vec3,
shape: FirstPersonPlayerShape
): ResolvedPlayerMotion | null;
resolvePlayerVolumeState(feetPosition: Vec3): RuntimePlayerVolumeState;
setRuntimeMessage(message: string | 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 { NavigationController, RuntimeControllerContext } from "./navigation-controller";
import type {
NavigationController,
RuntimeControllerContext
} from "./navigation-controller";
const MIN_DISTANCE = 2;
const MAX_DISTANCE = 48;
@@ -48,9 +51,16 @@ export class OrbitVisitorNavigationController implements NavigationController {
if (!this.initializedFromScene) {
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
? 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;
this.target = cloneVec3(focusPoint);
@@ -59,12 +69,16 @@ export class OrbitVisitorNavigationController implements NavigationController {
}
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);
window.addEventListener("pointermove", this.handlePointerMove);
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);
this.updateCameraTransform();
}
@@ -117,7 +131,11 @@ export class OrbitVisitorNavigationController implements NavigationController {
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.context.camera.lookAt(this.lookAtVector);
}

View File

@@ -26,13 +26,19 @@ import {
} from "three";
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 { LoadedImageAsset } from "../assets/image-assets";
import type { LoadedAudioAsset } from "../assets/audio-assets";
import type { ProjectAssetRecord } from "../assets/project-assets";
import { buildBoxBrushDerivedMeshData } from "../geometry/box-brush-mesh";
import { createStarterMaterialSignature, createStarterMaterialTexture } from "../materials/starter-material-textures";
import {
createStarterMaterialSignature,
createStarterMaterialTexture
} from "../materials/starter-material-textures";
import {
applyAdvancedRenderingLightShadowFlags,
applyAdvancedRenderingRenderableShadowFlags,
@@ -57,9 +63,18 @@ import {
} from "../document/world-settings";
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 { 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 { OrbitVisitorNavigationController } from "./orbit-visitor-navigation-controller";
import { resolveUnderwaterFogState } from "./underwater-fog";
@@ -115,19 +130,32 @@ export class RuntimeHost {
private readonly localLightGroup = new Group();
private readonly brushGroup = new Group();
private readonly modelGroup = new Group();
private readonly firstPersonController = new FirstPersonNavigationController();
private readonly orbitVisitorController = new OrbitVisitorNavigationController();
private readonly firstPersonController =
new FirstPersonNavigationController();
private readonly orbitVisitorController =
new OrbitVisitorNavigationController();
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 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 readonly volumeAnimatedUniforms: Array<{ value: number }> = [];
private readonly runtimeWaterContactUniforms: RuntimeWaterContactUniformBinding[] = [];
private readonly runtimeWaterContactUniforms: RuntimeWaterContactUniformBinding[] =
[];
private readonly localLightObjects = 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 instanceAnimationClips = new Map<string, AnimationClip[]>();
private readonly controllerContext: RuntimeControllerContext;
@@ -138,7 +166,8 @@ export class RuntimeHost {
private desiredNavigationMode: RuntimeNavigationMode = "firstPerson";
private sceneReady = false;
private currentWorld: RuntimeSceneDefinition["world"] | null = null;
private currentAdvancedRenderingSettings: AdvancedRenderingSettings | null = null;
private currentAdvancedRenderingSettings: AdvancedRenderingSettings | null =
null;
private advancedRenderingComposer: EffectComposer | null = null;
private projectAssets: Record<string, ProjectAssetRecord> = {};
private loadedModelAssets: Record<string, LoadedModelAsset> = {};
@@ -148,9 +177,14 @@ export class RuntimeHost {
private previousFrameTime = 0;
private container: HTMLElement | null = null;
private activeController: NavigationController | null = null;
private runtimeMessageHandler: ((message: string | null) => void) | null = null;
private firstPersonTelemetryHandler: ((telemetry: FirstPersonTelemetry | null) => void) | null = null;
private interactionPromptHandler: ((prompt: RuntimeInteractionPrompt | null) => void) | null = null;
private runtimeMessageHandler: ((message: string | null) => void) | null =
null;
private firstPersonTelemetryHandler:
| ((telemetry: FirstPersonTelemetry | null) => void)
| null = null;
private interactionPromptHandler:
| ((prompt: RuntimeInteractionPrompt | null) => void)
| null = null;
private sceneLoadStateHandler:
| ((state: RuntimeSceneLoadState) => void)
| null = null;
@@ -169,8 +203,11 @@ export class RuntimeHost {
this.scene.add(this.modelGroup);
this.underwaterSceneFog.density = 0;
this.scene.fog = this.underwaterSceneFog;
this.renderer = enableRendering ? new WebGLRenderer({ antialias: false, alpha: true }) : null;
this.domElement = this.renderer?.domElement ?? document.createElement("canvas");
this.renderer = enableRendering
? new WebGLRenderer({ antialias: false, alpha: true })
: null;
this.domElement =
this.renderer?.domElement ?? document.createElement("canvas");
if (this.renderer !== null) {
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
@@ -189,8 +226,14 @@ export class RuntimeHost {
return this.runtimeScene;
},
resolveFirstPersonMotion: (feetPosition, motion, shape) => this.collisionWorld?.resolveFirstPersonMotion(feetPosition, motion, shape) ?? null,
resolvePlayerVolumeState: (feetPosition) => this.resolvePlayerVolumeState(feetPosition),
resolveFirstPersonMotion: (feetPosition, motion, shape) =>
this.collisionWorld?.resolveFirstPersonMotion(
feetPosition,
motion,
shape
) ?? null,
resolvePlayerVolumeState: (feetPosition) =>
this.resolvePlayerVolumeState(feetPosition),
setRuntimeMessage: (message) => {
if (message === this.currentRuntimeMessage) {
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) {
return {
inWater: false,
@@ -214,8 +261,12 @@ export class RuntimeHost {
};
}
const inWater = this.runtimeScene.volumes.water.some((volume) => this.isPointInsideOrientedVolume(feetPosition, volume));
const inFog = this.runtimeScene.volumes.fog.some((volume) => this.isPointInsideOrientedVolume(feetPosition, volume));
const inWater = this.runtimeScene.volumes.water.some((volume) =>
this.isPointInsideOrientedVolume(feetPosition, volume)
);
const inFog = this.runtimeScene.volumes.fog.some((volume) =>
this.isPointInsideOrientedVolume(feetPosition, volume)
);
return {
inWater,
@@ -225,9 +276,17 @@ export class RuntimeHost {
private isPointInsideOrientedVolume(
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 {
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
.setFromEuler(
@@ -257,7 +316,10 @@ export class RuntimeHost {
this.container = container;
container.appendChild(this.domElement);
this.domElement.addEventListener("click", this.handleRuntimeClick);
this.domElement.addEventListener("pointerdown", this.handleRuntimePointerDown);
this.domElement.addEventListener(
"pointerdown",
this.handleRuntimePointerDown
);
this.resize();
this.resizeObserver = new ResizeObserver(() => {
@@ -338,11 +400,15 @@ export class RuntimeHost {
this.audioSystem.setRuntimeMessageHandler(handler);
}
setFirstPersonTelemetryHandler(handler: ((telemetry: FirstPersonTelemetry | null) => void) | null) {
setFirstPersonTelemetryHandler(
handler: ((telemetry: FirstPersonTelemetry | null) => void) | null
) {
this.firstPersonTelemetryHandler = handler;
}
setInteractionPromptHandler(handler: ((prompt: RuntimeInteractionPrompt | null) => void) | null) {
setInteractionPromptHandler(
handler: ((prompt: RuntimeInteractionPrompt | null) => void) | null
) {
this.interactionPromptHandler = handler;
}
@@ -389,7 +455,10 @@ export class RuntimeHost {
this.renderer?.forceContextLoss();
this.renderer?.dispose();
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)) {
this.container.removeChild(this.domElement);
@@ -505,7 +574,8 @@ export class RuntimeHost {
.multiplyScalar(18);
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.environment = texture;
this.scene.environmentIntensity = world.background.environmentIntensity;
@@ -516,7 +586,10 @@ export class RuntimeHost {
}
if (this.renderer !== null) {
configureAdvancedRenderingRenderer(this.renderer, world.advancedRendering);
configureAdvancedRenderingRenderer(
this.renderer,
world.advancedRendering
);
this.syncAdvancedRenderingComposer(world.advancedRendering);
}
@@ -554,7 +627,10 @@ export class RuntimeHost {
const shouldUseComposer = settings.enabled;
const settingsChanged =
this.currentAdvancedRenderingSettings === null ||
!areAdvancedRenderingSettingsEqual(this.currentAdvancedRenderingSettings, settings);
!areAdvancedRenderingSettingsEqual(
this.currentAdvancedRenderingSettings,
settings
);
if (!shouldUseComposer) {
if (this.advancedRenderingComposer !== null) {
@@ -575,8 +651,14 @@ export class RuntimeHost {
this.advancedRenderingComposer.dispose();
}
this.advancedRenderingComposer = createAdvancedRenderingComposer(this.renderer, this.scene, this.camera, settings);
this.currentAdvancedRenderingSettings = cloneAdvancedRenderingSettings(settings);
this.advancedRenderingComposer = createAdvancedRenderingComposer(
this.renderer,
this.scene,
this.camera,
settings
);
this.currentAdvancedRenderingSettings =
cloneAdvancedRenderingSettings(settings);
this.renderer.autoClear = false;
}
@@ -586,7 +668,8 @@ export class RuntimeHost {
}
const advancedRendering = this.currentWorld.advancedRendering;
const shadowsEnabled = advancedRendering.enabled && advancedRendering.shadows.enabled;
const shadowsEnabled =
advancedRendering.enabled && advancedRendering.shadows.enabled;
applyAdvancedRenderingLightShadowFlags(this.sunLight, advancedRendering);
@@ -621,11 +704,21 @@ export class RuntimeHost {
this.applyShadowState();
}
private createPointLightRuntimeObjects(pointLight: RuntimeLocalLightCollection["pointLights"][number]): LocalLightRenderObjects {
private createPointLightRuntimeObjects(
pointLight: RuntimeLocalLightCollection["pointLights"][number]
): LocalLightRenderObjects {
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);
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 light = new SpotLight(
spotLight.colorHex,
@@ -644,10 +739,21 @@ export class RuntimeHost {
0.18,
1
);
const direction = new Vector3(spotLight.direction.x, spotLight.direction.y, spotLight.direction.z).normalize();
const orientation = new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), direction);
const direction = new Vector3(
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);
light.position.set(0, 0, 0);
light.target.position.set(0, 1, 0);
@@ -662,26 +768,75 @@ export class RuntimeHost {
private rebuildBrushMeshes(brushes: RuntimeBoxBrushInstance[]) {
this.clearBrushMeshes();
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) {
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 =
brush.volume.mode === "water"
? this.mergeRuntimeWaterContactPatches(brush, staticContactPatches, this.collectRuntimePlayerWaterContactPatches(brush))
? this.mergeRuntimeWaterContactPatches(
brush,
staticContactPatches,
this.collectRuntimePlayerWaterContactPatches(brush)
)
: [];
const materials =
this.createFogMaterialSet(brush, volumeRenderPaths) ??
[
this.createFaceMaterial(brush, "posX", brush.faces.posX.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 materials = this.createFogMaterialSet(brush, volumeRenderPaths) ?? [
this.createFaceMaterial(
brush,
"posX",
brush.faces.posX.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);
mesh.position.set(brush.center.x, brush.center.y, brush.center.z);
@@ -700,7 +855,10 @@ export class RuntimeHost {
private createFogMaterialSet(
brush: RuntimeBoxBrushInstance,
volumeRenderPaths: { fog: "performance" | "quality"; water: "performance" | "quality" }
volumeRenderPaths: {
fog: "performance" | "quality";
water: "performance" | "quality";
}
): Material[] | null {
if (brush.volume.mode !== "fog") {
return null;
@@ -720,10 +878,16 @@ export class RuntimeHost {
});
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({
color: brush.volume.fog.colorHex,
transparent: true,
@@ -734,9 +898,14 @@ export class RuntimeHost {
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(
(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) {
@@ -744,15 +913,21 @@ export class RuntimeHost {
}
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) {
(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();
for (const modelInstance of modelInstances) {
@@ -782,10 +957,19 @@ export class RuntimeHost {
if (loadedAsset?.animations && loadedAsset.animations.length > 0) {
const mixer = new AnimationMixer(renderGroup);
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) {
const clip = AnimationClip.findByName(loadedAsset.animations, modelInstance.animationClipName);
if (
modelInstance.animationAutoplay === true &&
modelInstance.animationClipName
) {
const clip = AnimationClip.findByName(
loadedAsset.animations,
modelInstance.animationClipName
);
if (clip) {
mixer.clipAction(clip).play();
}
@@ -800,18 +984,28 @@ export class RuntimeHost {
brush: RuntimeBoxBrushInstance,
faceId: "posX" | "negX" | "posY" | "negY" | "posZ" | "negZ",
material: RuntimeBoxBrushInstance["faces"]["posX"]["material"],
volumeRenderPaths: { fog: "performance" | "quality"; water: "performance" | "quality" },
volumeRenderPaths: {
fog: "performance" | "quality";
water: "performance" | "quality";
},
contactPatches: ReturnType<typeof collectWaterContactPatches>,
staticContactPatches: ReturnType<typeof collectWaterContactPatches>
): Material {
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({
colorHex: brush.volume.water.colorHex,
surfaceOpacity: brush.volume.water.surfaceOpacity,
waveStrength: brush.volume.water.waveStrength,
surfaceDisplacementEnabled: brush.volume.water.surfaceDisplacementEnabled,
opacity: faceId === "posY" ? Math.min(1, baseOpacity + 0.18) : baseOpacity * 0.5,
surfaceDisplacementEnabled:
brush.volume.water.surfaceDisplacementEnabled,
opacity:
faceId === "posY"
? Math.min(1, baseOpacity + 0.18)
: baseOpacity * 0.5,
quality: volumeRenderPaths.water === "quality",
wireframe: false,
isTopFace: faceId === "posY",
@@ -831,17 +1025,26 @@ export class RuntimeHost {
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({
brush,
uniform: waterMaterial.contactPatchesUniform,
axisUniform: waterMaterial.contactPatchAxesUniform,
shapeUniform: waterMaterial.contactPatchShapesUniform ?? { value: [] },
shapeUniform: waterMaterial.contactPatchShapesUniform ?? {
value: []
},
staticContactPatches,
reflectionTextureUniform: waterMaterial.reflectionTextureUniform,
reflectionMatrixUniform: waterMaterial.reflectionMatrixUniform,
reflectionEnabledUniform: waterMaterial.reflectionEnabledUniform,
reflectionRenderTarget: this.getWaterReflectionMode() !== "none" ? this.createWaterReflectionRenderTarget() : null,
reflectionRenderTarget:
this.getWaterReflectionMode() !== "none"
? this.createWaterReflectionRenderTarget()
: null,
lastReflectionUpdateTime: Number.NEGATIVE_INFINITY
});
}
@@ -867,7 +1070,10 @@ export class RuntimeHost {
return fogMaterial.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({
color: brush.volume.fog.colorHex,
transparent: true,
@@ -895,7 +1101,10 @@ export class RuntimeHost {
private updateUnderwaterSceneFog() {
const fogState =
this.activeController === this.firstPersonController
? resolveUnderwaterFogState(this.runtimeScene, this.currentFirstPersonTelemetry)
? resolveUnderwaterFogState(
this.runtimeScene,
this.currentFirstPersonTelemetry
)
: null;
if (fogState === null) {
@@ -908,7 +1117,11 @@ export class RuntimeHost {
}
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;
}
@@ -957,7 +1170,8 @@ export class RuntimeHost {
}
if (binding.reflectionRenderTarget === null) {
binding.reflectionRenderTarget = this.createWaterReflectionRenderTarget();
binding.reflectionRenderTarget =
this.createWaterReflectionRenderTarget();
}
const canRenderReflection = updatePlanarReflectionCamera(
@@ -972,12 +1186,19 @@ export class RuntimeHost {
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;
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) {
if (runtimeBrush.volume.mode !== "water") {
continue;
@@ -1000,11 +1221,13 @@ export class RuntimeHost {
const previousAutoClear = this.renderer.autoClear;
const previousRenderTarget = this.renderer.getRenderTarget();
const previousFogDensity = this.underwaterSceneFog.density;
const previousReflectionStates = this.runtimeWaterContactUniforms.map((waterBinding) => ({
binding: waterBinding,
enabled: waterBinding.reflectionEnabledUniform?.value ?? 0,
texture: waterBinding.reflectionTextureUniform?.value ?? null
}));
const previousReflectionStates = this.runtimeWaterContactUniforms.map(
(waterBinding) => ({
binding: waterBinding,
enabled: waterBinding.reflectionEnabledUniform?.value ?? 0,
texture: waterBinding.reflectionTextureUniform?.value ?? null
})
);
try {
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.lastReflectionUpdateTime = now;
}
}
private getOrCreateTexture(material: NonNullable<RuntimeBoxBrushInstance["faces"]["posX"]["material"]>) {
private getOrCreateTexture(
material: NonNullable<RuntimeBoxBrushInstance["faces"]["posX"]["material"]>
) {
const signature = createStarterMaterialSignature(material);
const cachedTexture = this.materialTextureCache.get(material.id);
@@ -1092,7 +1318,10 @@ export class RuntimeHost {
}
private createPlayerWaterContactBounds() {
if (this.runtimeScene === null || this.currentFirstPersonTelemetry === null) {
if (
this.runtimeScene === null ||
this.currentFirstPersonTelemetry === 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 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 ?? []) {
if (collider.source === "brush") {
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;
}
@@ -1189,7 +1429,9 @@ export class RuntimeHost {
);
}
private collectRuntimePlayerWaterContactPatches(brush: RuntimeBoxBrushInstance) {
private collectRuntimePlayerWaterContactPatches(
brush: RuntimeBoxBrushInstance
) {
const playerBounds = this.createPlayerWaterContactBounds();
if (playerBounds === null) {
@@ -1208,7 +1450,9 @@ export class RuntimeHost {
}
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(
@@ -1216,7 +1460,10 @@ export class RuntimeHost {
staticContactPatches: 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() {
@@ -1226,9 +1473,12 @@ export class RuntimeHost {
binding.staticContactPatches,
this.collectRuntimePlayerWaterContactPatches(binding.brush)
);
binding.uniform.value = createWaterContactPatchUniformValue(mergedPatches);
binding.axisUniform.value = createWaterContactPatchAxisUniformValue(mergedPatches);
binding.shapeUniform.value = createWaterContactPatchShapeUniformValue(mergedPatches);
binding.uniform.value =
createWaterContactPatchUniformValue(mergedPatches);
binding.axisUniform.value =
createWaterContactPatchAxisUniformValue(mergedPatches);
binding.shapeUniform.value =
createWaterContactPatchShapeUniformValue(mergedPatches);
}
}
@@ -1293,7 +1543,11 @@ export class RuntimeHost {
this.activeController === this.firstPersonController &&
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.setInteractionPrompt(
this.interactionSystem.resolveClickInteractionPrompt(
@@ -1329,7 +1583,10 @@ export class RuntimeHost {
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);
if (mesh === undefined) {
@@ -1339,7 +1596,11 @@ export class RuntimeHost {
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 clips = this.instanceAnimationClips.get(instanceId);
@@ -1351,7 +1612,9 @@ export class RuntimeHost {
const clip = AnimationClip.findByName(clips, clipName);
if (!clip) {
console.warn(`playAnimation: clip "${clipName}" not found on instance ${instanceId}`);
console.warn(
`playAnimation: clip "${clipName}" not found on instance ${instanceId}`
);
return;
}
@@ -1399,7 +1662,8 @@ export class RuntimeHost {
private setInteractionPrompt(prompt: RuntimeInteractionPrompt | null) {
if (
this.currentInteractionPrompt?.sourceEntityId === prompt?.sourceEntityId &&
this.currentInteractionPrompt?.sourceEntityId ===
prompt?.sourceEntityId &&
this.currentInteractionPrompt?.prompt === prompt?.prompt &&
this.currentInteractionPrompt?.distance === prompt?.distance &&
this.currentInteractionPrompt?.range === prompt?.range
@@ -1422,7 +1686,11 @@ export class RuntimeHost {
}
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 = () => {

View File

@@ -84,7 +84,9 @@ describe("EditorStore", () => {
const secondSceneId = store.getState().activeSceneId;
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");
store.executeCommand(createCreateBoxBrushCommand());
@@ -269,9 +271,13 @@ describe("EditorStore", () => {
expect(store.getState().whiteboxSelectionMode).toBe("object");
expect(store.getState().viewportLayoutMode).toBe("single");
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.displayMode).toBe("authoring");
expect(store.getState().viewportPanels.topRight.displayMode).toBe(
"authoring"
);
expect(store.getState().viewportQuadSplit).toEqual({
x: 0.5,
y: 0.5
@@ -289,7 +295,9 @@ describe("EditorStore", () => {
expect(store.getState().viewportLayoutMode).toBe("quad");
expect(store.getState().activeViewportPanelId).toBe("bottomRight");
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({
x: 0.38,
y: 0.62
@@ -482,7 +490,9 @@ describe("EditorStore", () => {
})
);
expect(store.getState().viewportTransientState.transformSession).toMatchObject({
expect(
store.getState().viewportTransientState.transformSession
).toMatchObject({
kind: "active",
source: "keyboard",
sourcePanelId: "bottomRight",

View File

@@ -75,11 +75,16 @@ describe("local draft storage", () => {
expect(result.document.version).toBe(SCENE_DOCUMENT_VERSION);
expect(result.document).toEqual(createEmptyProjectDocument());
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", () => {
const originalDescriptor = Object.getOwnPropertyDescriptor(window, "localStorage");
const originalDescriptor = Object.getOwnPropertyDescriptor(
window,
"localStorage"
);
Object.defineProperty(window, "localStorage", {
configurable: true,
@@ -92,7 +97,9 @@ describe("local draft storage", () => {
const result = getBrowserStorageAccess();
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");
} finally {
if (originalDescriptor !== undefined) {
@@ -161,9 +168,9 @@ describe("local draft storage", () => {
return;
}
expect(
result.document.scenes[result.document.activeSceneId]?.name
).toBe("Viewport Draft");
expect(result.document.scenes[result.document.activeSceneId]?.name).toBe(
"Viewport Draft"
);
expect(result.viewportLayoutState).toMatchObject({
layoutMode: "quad",
activePanelId: "bottomRight",
@@ -235,7 +242,10 @@ describe("local draft storage", () => {
it("loads older raw scene-document drafts without requiring viewport layout state", () => {
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);
@@ -245,9 +255,9 @@ describe("local draft storage", () => {
return;
}
expect(
result.document.scenes[result.document.activeSceneId]?.name
).toBe("Legacy Draft");
expect(result.document.scenes[result.document.activeSceneId]?.name).toBe(
"Legacy Draft"
);
expect(result.viewportLayoutState).toBeNull();
expect(result.message).toBe("Recovered latest autosave.");
});
@@ -263,9 +273,9 @@ describe("local draft storage", () => {
const result = loadOrCreateSceneDocument(storage);
expect(
result.document.scenes[result.document.activeSceneId]?.name
).toBe("Recovered Scene");
expect(result.document.scenes[result.document.activeSceneId]?.name).toBe(
"Recovered Scene"
);
expect(result.diagnostic).toBe("Recovered latest autosave.");
});

View File

@@ -44,8 +44,10 @@ describe("project document JSON", () => {
id: "scene-main",
name: "Legacy Entry"
});
const { loadingScreen: _loadingScreen, ...legacySceneWithoutLoadingScreen } =
legacyScene;
const {
loadingScreen: _loadingScreen,
...legacySceneWithoutLoadingScreen
} = legacyScene;
void _loadingScreen;
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 { 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 { createModelInstance } from "../../src/assets/model-instances";
import { createInMemoryProjectAssetStorage, type ProjectAssetStorage } from "../../src/assets/project-asset-storage";
import { createProjectAssetStorageKey, type AudioAssetRecord, type ImageAssetRecord } from "../../src/assets/project-assets";
import {
createInMemoryProjectAssetStorage,
type ProjectAssetStorage
} from "../../src/assets/project-asset-storage";
import {
createProjectAssetStorageKey,
type AudioAssetRecord,
type ImageAssetRecord
} from "../../src/assets/project-assets";
import {
createEmptyProjectScene,
createEmptySceneDocument,
@@ -22,11 +32,24 @@ import {
} from "../../src/serialization/project-package";
import { serializeProjectDocument } from "../../src/serialization/scene-document-json";
const tinyGlbFixturePath = path.resolve(process.cwd(), "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");
const tinyGlbFixturePath = path.resolve(
process.cwd(),
"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);
new Uint8Array(arrayBuffer).set(bytes);
@@ -72,7 +95,9 @@ function buildZipArchive(entries: Record<string, Uint8Array>): Uint8Array {
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;
for (const chunk of chunks) {
@@ -177,7 +202,11 @@ describe("project package serialization", () => {
await storage.putAsset(imageAsset.storageKey, {
files: {
[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
}
}
@@ -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 mockImageHeight = 512;
@@ -247,18 +279,32 @@ describe("project package serialization", () => {
const packageBytes = await saveProjectPackage(document, storage);
const restoredStorage = createInMemoryProjectAssetStorage();
const restoredDocument = await loadProjectPackage(packageBytes, restoredStorage);
const restoredDocument = await loadProjectPackage(
packageBytes,
restoredStorage
);
expect(restoredDocument).toEqual(document);
const restoredModel = await loadModelAssetFromStorage(restoredStorage, importedModel.asset);
const restoredImage = await loadImageAssetFromStorage(restoredStorage, imageAsset);
const restoredAudio = await loadAudioAssetFromStorage(restoredStorage, audioAsset);
const restoredModel = await loadModelAssetFromStorage(
restoredStorage,
importedModel.asset
);
const restoredImage = await loadImageAssetFromStorage(
restoredStorage,
imageAsset
);
const restoredAudio = await loadAudioAssetFromStorage(
restoredStorage,
audioAsset
);
expect(restoredModel.metadata.format).toBe("glb");
expect(restoredModel.template.children.length).toBeGreaterThan(0);
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 () => {
@@ -290,7 +336,10 @@ describe("project package serialization", () => {
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.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 () => {
@@ -328,7 +379,9 @@ describe("project package serialization", () => {
"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 () => {
@@ -357,7 +410,9 @@ describe("project package serialization", () => {
[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"
);
});
@@ -366,8 +421,13 @@ describe("project package serialization", () => {
const document = createProjectDocument(
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 () => {
const runtimeScene = buildRuntimeSceneFromDocument(createEmptySceneDocument());
const runtimeScene = buildRuntimeSceneFromDocument(
createEmptySceneDocument()
);
const onTelemetryChange = vi.fn();
render(
@@ -81,12 +83,20 @@ describe("RunnerCanvas", () => {
await waitFor(() => {
expect(runtimeHostInstances).toHaveLength(1);
expect(runtimeHostInstances[0]?.setFirstPersonTelemetryHandler).toHaveBeenCalledTimes(1);
expect(runtimeHostInstances[0]?.setSceneLoadStateHandler).toHaveBeenCalledTimes(1);
expect(
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 publishSceneLoadState = runtimeHostInstances[0]?.setSceneLoadStateHandler.mock.calls[0]?.[0] as
const publishTelemetry = runtimeHostInstances[0]
?.setFirstPersonTelemetryHandler.mock.calls[0]?.[0] as
| ((telemetry: FirstPersonTelemetry | null) => void)
| undefined;
const publishSceneLoadState = runtimeHostInstances[0]
?.setSceneLoadStateHandler.mock.calls[0]?.[0] as
| ((state: RuntimeSceneLoadState) => void)
| 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();
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();
});
it("shows the loading overlay until the runtime host reports readiness", async () => {
const runtimeScene = buildRuntimeSceneFromDocument(createEmptySceneDocument());
const runtimeScene = buildRuntimeSceneFromDocument(
createEmptySceneDocument()
);
render(
<RunnerCanvas
@@ -160,28 +176,31 @@ describe("RunnerCanvas", () => {
await waitFor(() => {
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(
"runner-canvas__loading-overlay--hidden"
);
expect(
screen.getByTestId("runner-loading-overlay").className
).not.toContain("runner-canvas__loading-overlay--hidden");
expect(screen.getByTestId("runner-loading-scene-name")).toHaveTextContent(
"Dungeon Entry"
);
expect(screen.getByTestId("runner-loading-headline")).toHaveTextContent(
"Preparing encounter"
);
expect(
screen.getByTestId("runner-loading-description")
).toHaveTextContent("Enemies and triggers are being wired up.");
expect(screen.getByTestId("runner-loading-description")).toHaveTextContent(
"Enemies and triggers are being wired up."
);
expect(document.querySelector(".runner-canvas__crosshair")).toBeNull();
expect(screen.getByTestId("runner-shell")).toHaveAttribute(
"aria-busy",
"true"
);
const publishSceneLoadState = runtimeHostInstances[0]?.setSceneLoadStateHandler.mock.calls[0]?.[0] as
const publishSceneLoadState = runtimeHostInstances[0]
?.setSceneLoadStateHandler.mock.calls[0]?.[0] as
| ((state: RuntimeSceneLoadState) => void)
| undefined;
@@ -205,7 +224,9 @@ describe("RunnerCanvas", () => {
});
it("keeps the overlay visible and shows load errors from the runtime host", async () => {
const runtimeScene = buildRuntimeSceneFromDocument(createEmptySceneDocument());
const runtimeScene = buildRuntimeSceneFromDocument(
createEmptySceneDocument()
);
render(
<RunnerCanvas
@@ -224,10 +245,13 @@ describe("RunnerCanvas", () => {
);
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)
| undefined;
@@ -238,9 +262,9 @@ describe("RunnerCanvas", () => {
});
});
expect(screen.getByTestId("runner-loading-overlay").className).not.toContain(
"runner-canvas__loading-overlay--hidden"
);
expect(
screen.getByTestId("runner-loading-overlay").className
).not.toContain("runner-canvas__loading-overlay--hidden");
expect(screen.getByTestId("runner-loading-error")).toHaveTextContent(
"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 () => {
const runtimeScene = buildRuntimeSceneFromDocument(createEmptySceneDocument());
const runtimeScene = buildRuntimeSceneFromDocument(
createEmptySceneDocument()
);
vi.spyOn(console, "warn").mockImplementation(() => undefined);
const collisionWorld = {
dispose: vi.fn()