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:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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."
|
||||
);
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user