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;
|
min-height: 100vh;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top, rgba(80, 96, 120, 0.35) 0%, rgba(23, 28, 37, 0) 42%),
|
radial-gradient(
|
||||||
|
circle at top,
|
||||||
|
rgba(80, 96, 120, 0.35) 0%,
|
||||||
|
rgba(23, 28, 37, 0) 42%
|
||||||
|
),
|
||||||
linear-gradient(180deg, #1b2029 0%, #101318 100%);
|
linear-gradient(180deg, #1b2029 0%, #101318 100%);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
@@ -203,7 +207,10 @@ button:disabled {
|
|||||||
|
|
||||||
.workspace {
|
.workspace {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(240px, 280px) minmax(0, 1fr) minmax(280px, 320px);
|
grid-template-columns: minmax(240px, 280px) minmax(0, 1fr) minmax(
|
||||||
|
280px,
|
||||||
|
320px
|
||||||
|
);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -296,7 +303,11 @@ button:disabled {
|
|||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.04) 0%, rgba(255, 255, 255, 0.02) 100%);
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(255, 255, 255, 0.04) 0%,
|
||||||
|
rgba(255, 255, 255, 0.02) 100%
|
||||||
|
);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
}
|
}
|
||||||
@@ -988,7 +999,11 @@ button:disabled {
|
|||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top, rgba(130, 154, 188, 0.28) 0%, rgba(130, 154, 188, 0) 38%),
|
radial-gradient(
|
||||||
|
circle at top,
|
||||||
|
rgba(130, 154, 188, 0.28) 0%,
|
||||||
|
rgba(130, 154, 188, 0) 38%
|
||||||
|
),
|
||||||
linear-gradient(180deg, #55657c 0%, #2c3440 34%, #151920 100%);
|
linear-gradient(180deg, #55657c 0%, #2c3440 34%, #151920 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1145,7 +1160,11 @@ button:disabled {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
color: #f3e8da;
|
color: #f3e8da;
|
||||||
background: linear-gradient(180deg, rgba(8, 10, 14, 0.12) 0%, rgba(8, 10, 14, 0.58) 100%);
|
background: linear-gradient(
|
||||||
|
180deg,
|
||||||
|
rgba(8, 10, 14, 0.12) 0%,
|
||||||
|
rgba(8, 10, 14, 0.58) 100%
|
||||||
|
);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
@@ -1263,7 +1282,11 @@ button:disabled {
|
|||||||
z-index: 2;
|
z-index: 2;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 50% 18%, rgba(146, 223, 255, 0.2), transparent 42%),
|
radial-gradient(
|
||||||
|
circle at 50% 18%,
|
||||||
|
rgba(146, 223, 255, 0.2),
|
||||||
|
transparent 42%
|
||||||
|
),
|
||||||
linear-gradient(180deg, rgba(38, 113, 153, 0.16), rgba(8, 40, 63, 0.42));
|
linear-gradient(180deg, rgba(38, 113, 153, 0.16), rgba(8, 40, 63, 0.42));
|
||||||
backdrop-filter: blur(1.5px) saturate(1.08);
|
backdrop-filter: blur(1.5px) saturate(1.08);
|
||||||
mix-blend-mode: screen;
|
mix-blend-mode: screen;
|
||||||
|
|||||||
@@ -70,7 +70,9 @@ export function createSetSceneLoadingScreenCommand(
|
|||||||
...currentProjectDocument.scenes,
|
...currentProjectDocument.scenes,
|
||||||
[options.sceneId]: {
|
[options.sceneId]: {
|
||||||
...currentScene,
|
...currentScene,
|
||||||
loadingScreen: cloneSceneLoadingScreenSettings(previousLoadingScreen)
|
loadingScreen: cloneSceneLoadingScreenSettings(
|
||||||
|
previousLoadingScreen
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -4,8 +4,15 @@ import type { ModelInstance } from "../assets/model-instances";
|
|||||||
import type { ProjectAssetRecord } from "../assets/project-assets";
|
import type { ProjectAssetRecord } from "../assets/project-assets";
|
||||||
import type { EntityInstance } from "../entities/entity-instances";
|
import type { EntityInstance } from "../entities/entity-instances";
|
||||||
import type { InteractionLink } from "../interactions/interaction-links";
|
import type { InteractionLink } from "../interactions/interaction-links";
|
||||||
import { cloneMaterialRegistry, createStarterMaterialRegistry, type MaterialDef } from "../materials/starter-material-library";
|
import {
|
||||||
import { createDefaultWorldSettings, type WorldSettings } from "./world-settings";
|
cloneMaterialRegistry,
|
||||||
|
createStarterMaterialRegistry,
|
||||||
|
type MaterialDef
|
||||||
|
} from "../materials/starter-material-library";
|
||||||
|
import {
|
||||||
|
createDefaultWorldSettings,
|
||||||
|
type WorldSettings
|
||||||
|
} from "./world-settings";
|
||||||
|
|
||||||
export const SCENE_DOCUMENT_VERSION = 23 as const;
|
export const SCENE_DOCUMENT_VERSION = 23 as const;
|
||||||
export const MULTI_SCENE_FOUNDATION_SCENE_DOCUMENT_VERSION = 22 as const;
|
export const MULTI_SCENE_FOUNDATION_SCENE_DOCUMENT_VERSION = 22 as const;
|
||||||
@@ -13,7 +20,8 @@ export const WATER_SURFACE_DISPLACEMENT_SCENE_DOCUMENT_VERSION = 21 as const;
|
|||||||
export const WHITEBOX_BOX_VOLUME_SCENE_DOCUMENT_VERSION = 20 as const;
|
export const WHITEBOX_BOX_VOLUME_SCENE_DOCUMENT_VERSION = 20 as const;
|
||||||
export const WHITEBOX_GEOMETRY_SCENE_DOCUMENT_VERSION = 19 as const;
|
export const WHITEBOX_GEOMETRY_SCENE_DOCUMENT_VERSION = 19 as const;
|
||||||
export const WHITEBOX_FLOAT_TRANSFORM_SCENE_DOCUMENT_VERSION = 18 as const;
|
export const WHITEBOX_FLOAT_TRANSFORM_SCENE_DOCUMENT_VERSION = 18 as const;
|
||||||
export const PLAYER_START_COLLIDER_SETTINGS_SCENE_DOCUMENT_VERSION = 17 as const;
|
export const PLAYER_START_COLLIDER_SETTINGS_SCENE_DOCUMENT_VERSION =
|
||||||
|
17 as const;
|
||||||
export const IMPORTED_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION = 16 as const;
|
export const IMPORTED_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION = 16 as const;
|
||||||
export const ENTITY_NAMES_SCENE_DOCUMENT_VERSION = 15 as const;
|
export const ENTITY_NAMES_SCENE_DOCUMENT_VERSION = 15 as const;
|
||||||
export const SPATIAL_AUDIO_SCENE_DOCUMENT_VERSION = 13 as const;
|
export const SPATIAL_AUDIO_SCENE_DOCUMENT_VERSION = 13 as const;
|
||||||
@@ -27,7 +35,8 @@ export const RUNNER_V1_SCENE_DOCUMENT_VERSION = 4 as const;
|
|||||||
export const FIRST_ROOM_POLISH_SCENE_DOCUMENT_VERSION = 5 as const;
|
export const FIRST_ROOM_POLISH_SCENE_DOCUMENT_VERSION = 5 as const;
|
||||||
export const WORLD_ENVIRONMENT_SCENE_DOCUMENT_VERSION = 6 as const;
|
export const WORLD_ENVIRONMENT_SCENE_DOCUMENT_VERSION = 6 as const;
|
||||||
export const ENTITY_SYSTEM_FOUNDATION_SCENE_DOCUMENT_VERSION = 7 as const;
|
export const ENTITY_SYSTEM_FOUNDATION_SCENE_DOCUMENT_VERSION = 7 as const;
|
||||||
export const TRIGGER_ACTION_TARGET_FOUNDATION_SCENE_DOCUMENT_VERSION = 8 as const;
|
export const TRIGGER_ACTION_TARGET_FOUNDATION_SCENE_DOCUMENT_VERSION =
|
||||||
|
8 as const;
|
||||||
export const RUNNER_LOADING_SCREEN_SCENE_DOCUMENT_VERSION = 23 as const;
|
export const RUNNER_LOADING_SCREEN_SCENE_DOCUMENT_VERSION = 23 as const;
|
||||||
|
|
||||||
export const DEFAULT_PROJECT_SCENE_ID = "scene-main" as const;
|
export const DEFAULT_PROJECT_SCENE_ID = "scene-main" as const;
|
||||||
@@ -71,12 +80,16 @@ export interface SceneDocument {
|
|||||||
interactionLinks: Record<string, InteractionLink>;
|
interactionLinks: Record<string, InteractionLink>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createEmptySceneDocument(overrides: Partial<Pick<SceneDocument, "name" | "world" | "materials">> = {}): SceneDocument {
|
export function createEmptySceneDocument(
|
||||||
|
overrides: Partial<Pick<SceneDocument, "name" | "world" | "materials">> = {}
|
||||||
|
): SceneDocument {
|
||||||
return {
|
return {
|
||||||
version: SCENE_DOCUMENT_VERSION,
|
version: SCENE_DOCUMENT_VERSION,
|
||||||
name: overrides.name ?? "Untitled Scene",
|
name: overrides.name ?? "Untitled Scene",
|
||||||
world: overrides.world ?? createDefaultWorldSettings(),
|
world: overrides.world ?? createDefaultWorldSettings(),
|
||||||
materials: cloneMaterialRegistry(overrides.materials ?? createStarterMaterialRegistry()),
|
materials: cloneMaterialRegistry(
|
||||||
|
overrides.materials ?? createStarterMaterialRegistry()
|
||||||
|
),
|
||||||
textures: {},
|
textures: {},
|
||||||
assets: {},
|
assets: {},
|
||||||
brushes: {},
|
brushes: {},
|
||||||
@@ -87,7 +100,9 @@ export function createEmptySceneDocument(overrides: Partial<Pick<SceneDocument,
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createEmptyProjectScene(
|
export function createEmptyProjectScene(
|
||||||
overrides: Partial<Pick<ProjectScene, "id" | "name" | "loadingScreen" | "world">> = {}
|
overrides: Partial<
|
||||||
|
Pick<ProjectScene, "id" | "name" | "loadingScreen" | "world">
|
||||||
|
> = {}
|
||||||
): ProjectScene {
|
): ProjectScene {
|
||||||
return {
|
return {
|
||||||
id: overrides.id ?? createOpaqueId("scene"),
|
id: overrides.id ?? createOpaqueId("scene"),
|
||||||
@@ -113,7 +128,8 @@ export function createEmptyProjectDocument(
|
|||||||
} = {}
|
} = {}
|
||||||
): ProjectDocument {
|
): ProjectDocument {
|
||||||
const initialScene = createEmptyProjectScene({
|
const initialScene = createEmptyProjectScene({
|
||||||
id: overrides.sceneId ?? overrides.activeSceneId ?? DEFAULT_PROJECT_SCENE_ID,
|
id:
|
||||||
|
overrides.sceneId ?? overrides.activeSceneId ?? DEFAULT_PROJECT_SCENE_ID,
|
||||||
name: overrides.sceneName,
|
name: overrides.sceneName,
|
||||||
world: overrides.world
|
world: overrides.world
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,9 +6,15 @@ import type { LoadedImageAsset } from "../assets/image-assets";
|
|||||||
import type { ProjectAssetRecord } from "../assets/project-assets";
|
import type { ProjectAssetRecord } from "../assets/project-assets";
|
||||||
import type { SceneLoadingScreenSettings } from "../document/scene-document";
|
import type { SceneLoadingScreenSettings } from "../document/scene-document";
|
||||||
import type { FirstPersonTelemetry } from "../runtime-three/navigation-controller";
|
import type { FirstPersonTelemetry } from "../runtime-three/navigation-controller";
|
||||||
import { RuntimeHost, type RuntimeSceneLoadState } from "../runtime-three/runtime-host";
|
import {
|
||||||
|
RuntimeHost,
|
||||||
|
type RuntimeSceneLoadState
|
||||||
|
} from "../runtime-three/runtime-host";
|
||||||
import type { RuntimeInteractionPrompt } from "../runtime-three/runtime-interaction-system";
|
import type { RuntimeInteractionPrompt } from "../runtime-three/runtime-interaction-system";
|
||||||
import type { RuntimeNavigationMode, RuntimeSceneDefinition } from "../runtime-three/runtime-scene-build";
|
import type {
|
||||||
|
RuntimeNavigationMode,
|
||||||
|
RuntimeSceneDefinition
|
||||||
|
} from "../runtime-three/runtime-scene-build";
|
||||||
import { createWorldBackgroundStyle } from "../shared-ui/world-background-style";
|
import { createWorldBackgroundStyle } from "../shared-ui/world-background-style";
|
||||||
|
|
||||||
interface RunnerCanvasProps {
|
interface RunnerCanvasProps {
|
||||||
@@ -45,10 +51,13 @@ export function RunnerCanvas({
|
|||||||
status: "loading",
|
status: "loading",
|
||||||
message: null
|
message: null
|
||||||
});
|
});
|
||||||
const [interactionPrompt, setInteractionPrompt] = useState<RuntimeInteractionPrompt | null>(null);
|
const [interactionPrompt, setInteractionPrompt] =
|
||||||
const [firstPersonTelemetry, setFirstPersonTelemetry] = useState<FirstPersonTelemetry | null>(null);
|
useState<RuntimeInteractionPrompt | null>(null);
|
||||||
|
const [firstPersonTelemetry, setFirstPersonTelemetry] =
|
||||||
|
useState<FirstPersonTelemetry | null>(null);
|
||||||
const overlayMessage = runnerMessage ?? sceneLoadState.message;
|
const overlayMessage = runnerMessage ?? sceneLoadState.message;
|
||||||
const overlayStatus = overlayMessage !== null ? "error" : sceneLoadState.status;
|
const overlayStatus =
|
||||||
|
overlayMessage !== null ? "error" : sceneLoadState.status;
|
||||||
const runnerReady = overlayStatus === "ready";
|
const runnerReady = overlayStatus === "ready";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -86,7 +95,9 @@ export function RunnerCanvas({
|
|||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : "Runner initialization failed.";
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Runner initialization failed.";
|
||||||
const failureMessage = `Runner initialization failed: ${message}`;
|
const failureMessage = `Runner initialization failed: ${message}`;
|
||||||
setRunnerMessage(failureMessage);
|
setRunnerMessage(failureMessage);
|
||||||
setSceneLoadState({
|
setSceneLoadState({
|
||||||
@@ -98,10 +109,19 @@ export function RunnerCanvas({
|
|||||||
onFirstPersonTelemetryChange(null);
|
onFirstPersonTelemetryChange(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}, [onFirstPersonTelemetryChange, onInteractionPromptChange, onRuntimeMessageChange]);
|
}, [
|
||||||
|
onFirstPersonTelemetryChange,
|
||||||
|
onInteractionPromptChange,
|
||||||
|
onRuntimeMessageChange
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
hostRef.current?.updateAssets(projectAssets, loadedModelAssets, loadedImageAssets, loadedAudioAssets);
|
hostRef.current?.updateAssets(
|
||||||
|
projectAssets,
|
||||||
|
loadedModelAssets,
|
||||||
|
loadedImageAssets,
|
||||||
|
loadedAudioAssets
|
||||||
|
);
|
||||||
}, [projectAssets, loadedModelAssets, loadedImageAssets, loadedAudioAssets]);
|
}, [projectAssets, loadedModelAssets, loadedImageAssets, loadedAudioAssets]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -116,7 +136,12 @@ export function RunnerCanvas({
|
|||||||
onFirstPersonTelemetryChange(null);
|
onFirstPersonTelemetryChange(null);
|
||||||
onRuntimeMessageChange(null);
|
onRuntimeMessageChange(null);
|
||||||
hostRef.current?.loadScene(runtimeScene);
|
hostRef.current?.loadScene(runtimeScene);
|
||||||
}, [onFirstPersonTelemetryChange, onInteractionPromptChange, onRuntimeMessageChange, runtimeScene]);
|
}, [
|
||||||
|
onFirstPersonTelemetryChange,
|
||||||
|
onInteractionPromptChange,
|
||||||
|
onRuntimeMessageChange,
|
||||||
|
runtimeScene
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
hostRef.current?.setNavigationMode(navigationMode);
|
hostRef.current?.setNavigationMode(navigationMode);
|
||||||
@@ -131,7 +156,10 @@ export function RunnerCanvas({
|
|||||||
aria-busy={!runnerReady}
|
aria-busy={!runnerReady}
|
||||||
style={createWorldBackgroundStyle(
|
style={createWorldBackgroundStyle(
|
||||||
runtimeScene.world.background,
|
runtimeScene.world.background,
|
||||||
runtimeScene.world.background.mode === "image" ? loadedImageAssets[runtimeScene.world.background.assetId]?.sourceUrl ?? null : null
|
runtimeScene.world.background.mode === "image"
|
||||||
|
? (loadedImageAssets[runtimeScene.world.background.assetId]
|
||||||
|
?.sourceUrl ?? null)
|
||||||
|
: null
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -191,19 +219,33 @@ export function RunnerCanvas({
|
|||||||
{runnerReady &&
|
{runnerReady &&
|
||||||
navigationMode === "firstPerson" &&
|
navigationMode === "firstPerson" &&
|
||||||
interactionPrompt !== null ? (
|
interactionPrompt !== null ? (
|
||||||
<div className="runner-canvas__prompt" data-testid="runner-interaction-prompt" role="status" aria-live="polite">
|
<div
|
||||||
|
className="runner-canvas__prompt"
|
||||||
|
data-testid="runner-interaction-prompt"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
<div className="runner-canvas__prompt-badge">Click</div>
|
<div className="runner-canvas__prompt-badge">Click</div>
|
||||||
<div className="runner-canvas__prompt-text" data-testid="runner-interaction-prompt-text">
|
<div
|
||||||
|
className="runner-canvas__prompt-text"
|
||||||
|
data-testid="runner-interaction-prompt-text"
|
||||||
|
>
|
||||||
{interactionPrompt.prompt}
|
{interactionPrompt.prompt}
|
||||||
</div>
|
</div>
|
||||||
<div className="runner-canvas__prompt-meta" data-testid="runner-interaction-prompt-meta">
|
<div
|
||||||
{interactionPrompt.distance.toFixed(1)}m away · {interactionPrompt.range.toFixed(1)}m range
|
className="runner-canvas__prompt-meta"
|
||||||
|
data-testid="runner-interaction-prompt-meta"
|
||||||
|
>
|
||||||
|
{interactionPrompt.distance.toFixed(1)}m away ·{" "}
|
||||||
|
{interactionPrompt.range.toFixed(1)}m range
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{runnerMessage === null ? null : (
|
{runnerMessage === null ? null : (
|
||||||
<div className="runner-canvas__fallback" role="status">
|
<div className="runner-canvas__fallback" role="status">
|
||||||
<div className="runner-canvas__fallback-title">Runner Unavailable</div>
|
<div className="runner-canvas__fallback-title">
|
||||||
|
Runner Unavailable
|
||||||
|
</div>
|
||||||
<div>{runnerMessage}</div>
|
<div>{runnerMessage}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import { Euler, Vector3 } from "three";
|
|||||||
import type { Vec3 } from "../core/vector";
|
import type { Vec3 } from "../core/vector";
|
||||||
|
|
||||||
import { getFirstPersonPlayerEyeHeight } from "./player-collision";
|
import { getFirstPersonPlayerEyeHeight } from "./player-collision";
|
||||||
import type { NavigationController, RuntimeControllerContext, RuntimeLocomotionState } from "./navigation-controller";
|
import type {
|
||||||
|
NavigationController,
|
||||||
|
RuntimeControllerContext,
|
||||||
|
RuntimeLocomotionState
|
||||||
|
} from "./navigation-controller";
|
||||||
|
|
||||||
const LOOK_SENSITIVITY = 0.0022;
|
const LOOK_SENSITIVITY = 0.0022;
|
||||||
const MOVE_SPEED = 4.5;
|
const MOVE_SPEED = 4.5;
|
||||||
@@ -11,7 +15,10 @@ const GRAVITY = 22;
|
|||||||
const MAX_PITCH_RADIANS = Math.PI * 0.48;
|
const MAX_PITCH_RADIANS = Math.PI * 0.48;
|
||||||
|
|
||||||
function clampPitch(pitchRadians: number): number {
|
function clampPitch(pitchRadians: number): number {
|
||||||
return Math.max(-MAX_PITCH_RADIANS, Math.min(MAX_PITCH_RADIANS, pitchRadians));
|
return Math.max(
|
||||||
|
-MAX_PITCH_RADIANS,
|
||||||
|
Math.min(MAX_PITCH_RADIANS, pitchRadians)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toEyePosition(feetPosition: Vec3, eyeHeight: number): Vec3 {
|
function toEyePosition(feetPosition: Vec3, eyeHeight: number): Vec3 {
|
||||||
@@ -67,7 +74,10 @@ export class FirstPersonNavigationController implements NavigationController {
|
|||||||
window.addEventListener("keyup", this.handleKeyUp);
|
window.addEventListener("keyup", this.handleKeyUp);
|
||||||
window.addEventListener("blur", this.handleBlur);
|
window.addEventListener("blur", this.handleBlur);
|
||||||
document.addEventListener("mousemove", this.handleMouseMove);
|
document.addEventListener("mousemove", this.handleMouseMove);
|
||||||
document.addEventListener("pointerlockchange", this.handlePointerLockChange);
|
document.addEventListener(
|
||||||
|
"pointerlockchange",
|
||||||
|
this.handlePointerLockChange
|
||||||
|
);
|
||||||
document.addEventListener("pointerlockerror", this.handlePointerLockError);
|
document.addEventListener("pointerlockerror", this.handlePointerLockError);
|
||||||
ctx.domElement.addEventListener("pointerdown", this.handlePointerDown);
|
ctx.domElement.addEventListener("pointerdown", this.handlePointerDown);
|
||||||
|
|
||||||
@@ -81,8 +91,14 @@ export class FirstPersonNavigationController implements NavigationController {
|
|||||||
window.removeEventListener("keyup", this.handleKeyUp);
|
window.removeEventListener("keyup", this.handleKeyUp);
|
||||||
window.removeEventListener("blur", this.handleBlur);
|
window.removeEventListener("blur", this.handleBlur);
|
||||||
document.removeEventListener("mousemove", this.handleMouseMove);
|
document.removeEventListener("mousemove", this.handleMouseMove);
|
||||||
document.removeEventListener("pointerlockchange", this.handlePointerLockChange);
|
document.removeEventListener(
|
||||||
document.removeEventListener("pointerlockerror", this.handlePointerLockError);
|
"pointerlockchange",
|
||||||
|
this.handlePointerLockChange
|
||||||
|
);
|
||||||
|
document.removeEventListener(
|
||||||
|
"pointerlockerror",
|
||||||
|
this.handlePointerLockError
|
||||||
|
);
|
||||||
ctx.domElement.removeEventListener("pointerdown", this.handlePointerDown);
|
ctx.domElement.removeEventListener("pointerdown", this.handlePointerDown);
|
||||||
this.pressedKeys.clear();
|
this.pressedKeys.clear();
|
||||||
|
|
||||||
@@ -120,9 +136,15 @@ export class FirstPersonNavigationController implements NavigationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const playerShape = this.context.getRuntimeScene().playerCollider;
|
const playerShape = this.context.getRuntimeScene().playerCollider;
|
||||||
const currentVolumeState = this.context.resolvePlayerVolumeState(this.feetPosition);
|
const currentVolumeState = this.context.resolvePlayerVolumeState(
|
||||||
const inputX = (this.pressedKeys.has("KeyD") ? 1 : 0) - (this.pressedKeys.has("KeyA") ? 1 : 0);
|
this.feetPosition
|
||||||
const inputZ = (this.pressedKeys.has("KeyW") ? 1 : 0) - (this.pressedKeys.has("KeyS") ? 1 : 0);
|
);
|
||||||
|
const inputX =
|
||||||
|
(this.pressedKeys.has("KeyD") ? 1 : 0) -
|
||||||
|
(this.pressedKeys.has("KeyA") ? 1 : 0);
|
||||||
|
const inputZ =
|
||||||
|
(this.pressedKeys.has("KeyW") ? 1 : 0) -
|
||||||
|
(this.pressedKeys.has("KeyS") ? 1 : 0);
|
||||||
const inputLength = Math.hypot(inputX, inputZ);
|
const inputLength = Math.hypot(inputX, inputZ);
|
||||||
|
|
||||||
let horizontalX = 0;
|
let horizontalX = 0;
|
||||||
@@ -133,11 +155,25 @@ export class FirstPersonNavigationController implements NavigationController {
|
|||||||
const normalizedInputZ = inputZ / inputLength;
|
const normalizedInputZ = inputZ / inputLength;
|
||||||
const moveDistance = MOVE_SPEED * dt;
|
const moveDistance = MOVE_SPEED * dt;
|
||||||
|
|
||||||
this.forwardVector.set(Math.sin(this.yawRadians), 0, Math.cos(this.yawRadians));
|
this.forwardVector.set(
|
||||||
this.rightVector.set(-Math.cos(this.yawRadians), 0, Math.sin(this.yawRadians));
|
Math.sin(this.yawRadians),
|
||||||
|
0,
|
||||||
|
Math.cos(this.yawRadians)
|
||||||
|
);
|
||||||
|
this.rightVector.set(
|
||||||
|
-Math.cos(this.yawRadians),
|
||||||
|
0,
|
||||||
|
Math.sin(this.yawRadians)
|
||||||
|
);
|
||||||
|
|
||||||
horizontalX = (this.forwardVector.x * normalizedInputZ + this.rightVector.x * normalizedInputX) * moveDistance;
|
horizontalX =
|
||||||
horizontalZ = (this.forwardVector.z * normalizedInputZ + this.rightVector.z * normalizedInputX) * moveDistance;
|
(this.forwardVector.x * normalizedInputZ +
|
||||||
|
this.rightVector.x * normalizedInputX) *
|
||||||
|
moveDistance;
|
||||||
|
horizontalZ =
|
||||||
|
(this.forwardVector.z * normalizedInputZ +
|
||||||
|
this.rightVector.z * normalizedInputX) *
|
||||||
|
moveDistance;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playerShape.mode === "none") {
|
if (playerShape.mode === "none") {
|
||||||
@@ -152,7 +188,10 @@ export class FirstPersonNavigationController implements NavigationController {
|
|||||||
this.feetPosition,
|
this.feetPosition,
|
||||||
{
|
{
|
||||||
x: horizontalX,
|
x: horizontalX,
|
||||||
y: playerShape.mode === "none" || currentVolumeState.inWater ? 0 : this.verticalVelocity * dt,
|
y:
|
||||||
|
playerShape.mode === "none" || currentVolumeState.inWater
|
||||||
|
? 0
|
||||||
|
: this.verticalVelocity * dt,
|
||||||
z: horizontalZ
|
z: horizontalZ
|
||||||
},
|
},
|
||||||
playerShape
|
playerShape
|
||||||
@@ -165,7 +204,9 @@ export class FirstPersonNavigationController implements NavigationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.feetPosition = resolvedMotion.feetPosition;
|
this.feetPosition = resolvedMotion.feetPosition;
|
||||||
const nextVolumeState = this.context.resolvePlayerVolumeState(this.feetPosition);
|
const nextVolumeState = this.context.resolvePlayerVolumeState(
|
||||||
|
this.feetPosition
|
||||||
|
);
|
||||||
this.inWaterVolume = nextVolumeState.inWater;
|
this.inWaterVolume = nextVolumeState.inWater;
|
||||||
this.inFogVolume = nextVolumeState.inFog;
|
this.inFogVolume = nextVolumeState.inFog;
|
||||||
this.grounded = nextVolumeState.inWater ? false : resolvedMotion.grounded;
|
this.grounded = nextVolumeState.inWater ? false : resolvedMotion.grounded;
|
||||||
@@ -210,7 +251,12 @@ export class FirstPersonNavigationController implements NavigationController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eyePosition = toEyePosition(this.feetPosition, getFirstPersonPlayerEyeHeight(this.context.getRuntimeScene().playerCollider));
|
const eyePosition = toEyePosition(
|
||||||
|
this.feetPosition,
|
||||||
|
getFirstPersonPlayerEyeHeight(
|
||||||
|
this.context.getRuntimeScene().playerCollider
|
||||||
|
)
|
||||||
|
);
|
||||||
this.cameraRotation.x = this.pitchRadians;
|
this.cameraRotation.x = this.pitchRadians;
|
||||||
// Authoring yaw treats 0 degrees as facing +Z, while a three.js camera
|
// Authoring yaw treats 0 degrees as facing +Z, while a three.js camera
|
||||||
// looks down -Z by default. Offset by 180 degrees so runtime view matches
|
// looks down -Z by default. Offset by 180 degrees so runtime view matches
|
||||||
@@ -218,7 +264,11 @@ export class FirstPersonNavigationController implements NavigationController {
|
|||||||
this.cameraRotation.y = this.yawRadians + Math.PI;
|
this.cameraRotation.y = this.yawRadians + Math.PI;
|
||||||
this.cameraRotation.z = 0;
|
this.cameraRotation.z = 0;
|
||||||
|
|
||||||
this.context.camera.position.set(eyePosition.x, eyePosition.y, eyePosition.z);
|
this.context.camera.position.set(
|
||||||
|
eyePosition.x,
|
||||||
|
eyePosition.y,
|
||||||
|
eyePosition.z
|
||||||
|
);
|
||||||
this.context.camera.rotation.copy(this.cameraRotation);
|
this.context.camera.rotation.copy(this.cameraRotation);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,8 +277,14 @@ export class FirstPersonNavigationController implements NavigationController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eyePosition = toEyePosition(this.feetPosition, getFirstPersonPlayerEyeHeight(this.context.getRuntimeScene().playerCollider));
|
const eyePosition = toEyePosition(
|
||||||
const cameraVolumeState = this.context.resolvePlayerVolumeState(eyePosition);
|
this.feetPosition,
|
||||||
|
getFirstPersonPlayerEyeHeight(
|
||||||
|
this.context.getRuntimeScene().playerCollider
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const cameraVolumeState =
|
||||||
|
this.context.resolvePlayerVolumeState(eyePosition);
|
||||||
|
|
||||||
this.context.setFirstPersonTelemetry({
|
this.context.setFirstPersonTelemetry({
|
||||||
feetPosition: {
|
feetPosition: {
|
||||||
@@ -250,7 +306,8 @@ export class FirstPersonNavigationController implements NavigationController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pointerLocked = document.pointerLockElement === this.context.domElement;
|
const pointerLocked =
|
||||||
|
document.pointerLockElement === this.context.domElement;
|
||||||
this.pointerLocked = pointerLocked;
|
this.pointerLocked = pointerLocked;
|
||||||
this.context.setRuntimeMessage(
|
this.context.setRuntimeMessage(
|
||||||
pointerLocked
|
pointerLocked
|
||||||
@@ -278,7 +335,9 @@ export class FirstPersonNavigationController implements NavigationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.yawRadians -= event.movementX * LOOK_SENSITIVITY;
|
this.yawRadians -= event.movementX * LOOK_SENSITIVITY;
|
||||||
this.pitchRadians = clampPitch(this.pitchRadians - event.movementY * LOOK_SENSITIVITY);
|
this.pitchRadians = clampPitch(
|
||||||
|
this.pitchRadians - event.movementY * LOOK_SENSITIVITY
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
private handlePointerLockChange = () => {
|
private handlePointerLockChange = () => {
|
||||||
@@ -292,11 +351,15 @@ export class FirstPersonNavigationController implements NavigationController {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private handlePointerDown = () => {
|
private handlePointerDown = () => {
|
||||||
if (this.context === null || document.pointerLockElement === this.context.domElement) {
|
if (
|
||||||
|
this.context === null ||
|
||||||
|
document.pointerLockElement === this.context.domElement
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pointerLockCapableElement = this.context.domElement as HTMLCanvasElement & {
|
const pointerLockCapableElement = this.context
|
||||||
|
.domElement as HTMLCanvasElement & {
|
||||||
requestPointerLock(): void | Promise<void>;
|
requestPointerLock(): void | Promise<void>;
|
||||||
};
|
};
|
||||||
const pointerLockResult = pointerLockCapableElement.requestPointerLock();
|
const pointerLockResult = pointerLockCapableElement.requestPointerLock();
|
||||||
|
|||||||
@@ -2,8 +2,15 @@ import type { PerspectiveCamera } from "three";
|
|||||||
|
|
||||||
import type { Vec3 } from "../core/vector";
|
import type { Vec3 } from "../core/vector";
|
||||||
|
|
||||||
import type { FirstPersonPlayerShape, ResolvedPlayerMotion } from "./player-collision";
|
import type {
|
||||||
import type { RuntimeNavigationMode, RuntimeSceneDefinition, RuntimeSpawnPoint } from "./runtime-scene-build";
|
FirstPersonPlayerShape,
|
||||||
|
ResolvedPlayerMotion
|
||||||
|
} from "./player-collision";
|
||||||
|
import type {
|
||||||
|
RuntimeNavigationMode,
|
||||||
|
RuntimeSceneDefinition,
|
||||||
|
RuntimeSpawnPoint
|
||||||
|
} from "./runtime-scene-build";
|
||||||
|
|
||||||
export interface FirstPersonTelemetry {
|
export interface FirstPersonTelemetry {
|
||||||
feetPosition: Vec3;
|
feetPosition: Vec3;
|
||||||
@@ -28,7 +35,11 @@ export interface RuntimeControllerContext {
|
|||||||
camera: PerspectiveCamera;
|
camera: PerspectiveCamera;
|
||||||
domElement: HTMLCanvasElement;
|
domElement: HTMLCanvasElement;
|
||||||
getRuntimeScene(): RuntimeSceneDefinition;
|
getRuntimeScene(): RuntimeSceneDefinition;
|
||||||
resolveFirstPersonMotion(feetPosition: Vec3, motion: Vec3, shape: FirstPersonPlayerShape): ResolvedPlayerMotion | null;
|
resolveFirstPersonMotion(
|
||||||
|
feetPosition: Vec3,
|
||||||
|
motion: Vec3,
|
||||||
|
shape: FirstPersonPlayerShape
|
||||||
|
): ResolvedPlayerMotion | null;
|
||||||
resolvePlayerVolumeState(feetPosition: Vec3): RuntimePlayerVolumeState;
|
resolvePlayerVolumeState(feetPosition: Vec3): RuntimePlayerVolumeState;
|
||||||
setRuntimeMessage(message: string | null): void;
|
setRuntimeMessage(message: string | null): void;
|
||||||
setFirstPersonTelemetry(telemetry: FirstPersonTelemetry | null): void;
|
setFirstPersonTelemetry(telemetry: FirstPersonTelemetry | null): void;
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import { Vector3 } from "three";
|
|||||||
|
|
||||||
import type { Vec3 } from "../core/vector";
|
import type { Vec3 } from "../core/vector";
|
||||||
|
|
||||||
import type { NavigationController, RuntimeControllerContext } from "./navigation-controller";
|
import type {
|
||||||
|
NavigationController,
|
||||||
|
RuntimeControllerContext
|
||||||
|
} from "./navigation-controller";
|
||||||
|
|
||||||
const MIN_DISTANCE = 2;
|
const MIN_DISTANCE = 2;
|
||||||
const MAX_DISTANCE = 48;
|
const MAX_DISTANCE = 48;
|
||||||
@@ -48,9 +51,16 @@ export class OrbitVisitorNavigationController implements NavigationController {
|
|||||||
|
|
||||||
if (!this.initializedFromScene) {
|
if (!this.initializedFromScene) {
|
||||||
const runtimeScene = ctx.getRuntimeScene();
|
const runtimeScene = ctx.getRuntimeScene();
|
||||||
const focusPoint = runtimeScene.playerStart?.position ?? runtimeScene.sceneBounds?.center ?? this.target;
|
const focusPoint =
|
||||||
|
runtimeScene.playerStart?.position ??
|
||||||
|
runtimeScene.sceneBounds?.center ??
|
||||||
|
this.target;
|
||||||
const focusDistance = runtimeScene.sceneBounds
|
const focusDistance = runtimeScene.sceneBounds
|
||||||
? Math.max(runtimeScene.sceneBounds.size.x, runtimeScene.sceneBounds.size.y, runtimeScene.sceneBounds.size.z) * 1.1
|
? Math.max(
|
||||||
|
runtimeScene.sceneBounds.size.x,
|
||||||
|
runtimeScene.sceneBounds.size.y,
|
||||||
|
runtimeScene.sceneBounds.size.z
|
||||||
|
) * 1.1
|
||||||
: 8;
|
: 8;
|
||||||
|
|
||||||
this.target = cloneVec3(focusPoint);
|
this.target = cloneVec3(focusPoint);
|
||||||
@@ -59,12 +69,16 @@ export class OrbitVisitorNavigationController implements NavigationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx.domElement.addEventListener("pointerdown", this.handlePointerDown);
|
ctx.domElement.addEventListener("pointerdown", this.handlePointerDown);
|
||||||
ctx.domElement.addEventListener("wheel", this.handleWheel, { passive: false });
|
ctx.domElement.addEventListener("wheel", this.handleWheel, {
|
||||||
|
passive: false
|
||||||
|
});
|
||||||
ctx.domElement.addEventListener("contextmenu", this.handleContextMenu);
|
ctx.domElement.addEventListener("contextmenu", this.handleContextMenu);
|
||||||
window.addEventListener("pointermove", this.handlePointerMove);
|
window.addEventListener("pointermove", this.handlePointerMove);
|
||||||
window.addEventListener("pointerup", this.handlePointerUp);
|
window.addEventListener("pointerup", this.handlePointerUp);
|
||||||
|
|
||||||
ctx.setRuntimeMessage("Orbit Visitor active. Drag to orbit around the scene and use the mouse wheel to zoom.");
|
ctx.setRuntimeMessage(
|
||||||
|
"Orbit Visitor active. Drag to orbit around the scene and use the mouse wheel to zoom."
|
||||||
|
);
|
||||||
ctx.setFirstPersonTelemetry(null);
|
ctx.setFirstPersonTelemetry(null);
|
||||||
this.updateCameraTransform();
|
this.updateCameraTransform();
|
||||||
}
|
}
|
||||||
@@ -117,7 +131,11 @@ export class OrbitVisitorNavigationController implements NavigationController {
|
|||||||
z: this.target.z + Math.cos(this.yawRadians) * horizontalDistance
|
z: this.target.z + Math.cos(this.yawRadians) * horizontalDistance
|
||||||
};
|
};
|
||||||
|
|
||||||
this.context.camera.position.set(cameraPosition.x, cameraPosition.y, cameraPosition.z);
|
this.context.camera.position.set(
|
||||||
|
cameraPosition.x,
|
||||||
|
cameraPosition.y,
|
||||||
|
cameraPosition.z
|
||||||
|
);
|
||||||
this.lookAtVector.set(this.target.x, this.target.y, this.target.z);
|
this.lookAtVector.set(this.target.x, this.target.y, this.target.z);
|
||||||
this.context.camera.lookAt(this.lookAtVector);
|
this.context.camera.lookAt(this.lookAtVector);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,13 +26,19 @@ import {
|
|||||||
} from "three";
|
} from "three";
|
||||||
import { EffectComposer } from "postprocessing";
|
import { EffectComposer } from "postprocessing";
|
||||||
|
|
||||||
import { createModelInstanceRenderGroup, disposeModelInstance } from "../assets/model-instance-rendering";
|
import {
|
||||||
|
createModelInstanceRenderGroup,
|
||||||
|
disposeModelInstance
|
||||||
|
} from "../assets/model-instance-rendering";
|
||||||
import type { LoadedModelAsset } from "../assets/gltf-model-import";
|
import type { LoadedModelAsset } from "../assets/gltf-model-import";
|
||||||
import type { LoadedImageAsset } from "../assets/image-assets";
|
import type { LoadedImageAsset } from "../assets/image-assets";
|
||||||
import type { LoadedAudioAsset } from "../assets/audio-assets";
|
import type { LoadedAudioAsset } from "../assets/audio-assets";
|
||||||
import type { ProjectAssetRecord } from "../assets/project-assets";
|
import type { ProjectAssetRecord } from "../assets/project-assets";
|
||||||
import { buildBoxBrushDerivedMeshData } from "../geometry/box-brush-mesh";
|
import { buildBoxBrushDerivedMeshData } from "../geometry/box-brush-mesh";
|
||||||
import { createStarterMaterialSignature, createStarterMaterialTexture } from "../materials/starter-material-textures";
|
import {
|
||||||
|
createStarterMaterialSignature,
|
||||||
|
createStarterMaterialTexture
|
||||||
|
} from "../materials/starter-material-textures";
|
||||||
import {
|
import {
|
||||||
applyAdvancedRenderingLightShadowFlags,
|
applyAdvancedRenderingLightShadowFlags,
|
||||||
applyAdvancedRenderingRenderableShadowFlags,
|
applyAdvancedRenderingRenderableShadowFlags,
|
||||||
@@ -57,9 +63,18 @@ import {
|
|||||||
} from "../document/world-settings";
|
} from "../document/world-settings";
|
||||||
|
|
||||||
import { FirstPersonNavigationController } from "./first-person-navigation-controller";
|
import { FirstPersonNavigationController } from "./first-person-navigation-controller";
|
||||||
import type { FirstPersonTelemetry, NavigationController, RuntimeControllerContext, RuntimePlayerVolumeState } from "./navigation-controller";
|
import type {
|
||||||
|
FirstPersonTelemetry,
|
||||||
|
NavigationController,
|
||||||
|
RuntimeControllerContext,
|
||||||
|
RuntimePlayerVolumeState
|
||||||
|
} from "./navigation-controller";
|
||||||
import { RapierCollisionWorld } from "./rapier-collision-world";
|
import { RapierCollisionWorld } from "./rapier-collision-world";
|
||||||
import { RuntimeInteractionSystem, type RuntimeInteractionDispatcher, type RuntimeInteractionPrompt } from "./runtime-interaction-system";
|
import {
|
||||||
|
RuntimeInteractionSystem,
|
||||||
|
type RuntimeInteractionDispatcher,
|
||||||
|
type RuntimeInteractionPrompt
|
||||||
|
} from "./runtime-interaction-system";
|
||||||
import { RuntimeAudioSystem } from "./runtime-audio-system";
|
import { RuntimeAudioSystem } from "./runtime-audio-system";
|
||||||
import { OrbitVisitorNavigationController } from "./orbit-visitor-navigation-controller";
|
import { OrbitVisitorNavigationController } from "./orbit-visitor-navigation-controller";
|
||||||
import { resolveUnderwaterFogState } from "./underwater-fog";
|
import { resolveUnderwaterFogState } from "./underwater-fog";
|
||||||
@@ -115,19 +130,32 @@ export class RuntimeHost {
|
|||||||
private readonly localLightGroup = new Group();
|
private readonly localLightGroup = new Group();
|
||||||
private readonly brushGroup = new Group();
|
private readonly brushGroup = new Group();
|
||||||
private readonly modelGroup = new Group();
|
private readonly modelGroup = new Group();
|
||||||
private readonly firstPersonController = new FirstPersonNavigationController();
|
private readonly firstPersonController =
|
||||||
private readonly orbitVisitorController = new OrbitVisitorNavigationController();
|
new FirstPersonNavigationController();
|
||||||
|
private readonly orbitVisitorController =
|
||||||
|
new OrbitVisitorNavigationController();
|
||||||
private readonly interactionSystem = new RuntimeInteractionSystem();
|
private readonly interactionSystem = new RuntimeInteractionSystem();
|
||||||
private readonly audioSystem = new RuntimeAudioSystem(this.scene, this.camera, null);
|
private readonly audioSystem = new RuntimeAudioSystem(
|
||||||
|
this.scene,
|
||||||
|
this.camera,
|
||||||
|
null
|
||||||
|
);
|
||||||
private readonly underwaterSceneFog = new FogExp2("#2c6f8d", 0.03);
|
private readonly underwaterSceneFog = new FogExp2("#2c6f8d", 0.03);
|
||||||
private readonly waterReflectionCamera = new PerspectiveCamera();
|
private readonly waterReflectionCamera = new PerspectiveCamera();
|
||||||
private readonly brushMeshes = new Map<string, Mesh<BufferGeometry, Material[]>>();
|
private readonly brushMeshes = new Map<
|
||||||
|
string,
|
||||||
|
Mesh<BufferGeometry, Material[]>
|
||||||
|
>();
|
||||||
private volumeTime = 0;
|
private volumeTime = 0;
|
||||||
private readonly volumeAnimatedUniforms: Array<{ value: number }> = [];
|
private readonly volumeAnimatedUniforms: Array<{ value: number }> = [];
|
||||||
private readonly runtimeWaterContactUniforms: RuntimeWaterContactUniformBinding[] = [];
|
private readonly runtimeWaterContactUniforms: RuntimeWaterContactUniformBinding[] =
|
||||||
|
[];
|
||||||
private readonly localLightObjects = new Map<string, Group>();
|
private readonly localLightObjects = new Map<string, Group>();
|
||||||
private readonly modelRenderObjects = new Map<string, Group>();
|
private readonly modelRenderObjects = new Map<string, Group>();
|
||||||
private readonly materialTextureCache = new Map<string, CachedMaterialTexture>();
|
private readonly materialTextureCache = new Map<
|
||||||
|
string,
|
||||||
|
CachedMaterialTexture
|
||||||
|
>();
|
||||||
private readonly animationMixers = new Map<string, AnimationMixer>();
|
private readonly animationMixers = new Map<string, AnimationMixer>();
|
||||||
private readonly instanceAnimationClips = new Map<string, AnimationClip[]>();
|
private readonly instanceAnimationClips = new Map<string, AnimationClip[]>();
|
||||||
private readonly controllerContext: RuntimeControllerContext;
|
private readonly controllerContext: RuntimeControllerContext;
|
||||||
@@ -138,7 +166,8 @@ export class RuntimeHost {
|
|||||||
private desiredNavigationMode: RuntimeNavigationMode = "firstPerson";
|
private desiredNavigationMode: RuntimeNavigationMode = "firstPerson";
|
||||||
private sceneReady = false;
|
private sceneReady = false;
|
||||||
private currentWorld: RuntimeSceneDefinition["world"] | null = null;
|
private currentWorld: RuntimeSceneDefinition["world"] | null = null;
|
||||||
private currentAdvancedRenderingSettings: AdvancedRenderingSettings | null = null;
|
private currentAdvancedRenderingSettings: AdvancedRenderingSettings | null =
|
||||||
|
null;
|
||||||
private advancedRenderingComposer: EffectComposer | null = null;
|
private advancedRenderingComposer: EffectComposer | null = null;
|
||||||
private projectAssets: Record<string, ProjectAssetRecord> = {};
|
private projectAssets: Record<string, ProjectAssetRecord> = {};
|
||||||
private loadedModelAssets: Record<string, LoadedModelAsset> = {};
|
private loadedModelAssets: Record<string, LoadedModelAsset> = {};
|
||||||
@@ -148,9 +177,14 @@ export class RuntimeHost {
|
|||||||
private previousFrameTime = 0;
|
private previousFrameTime = 0;
|
||||||
private container: HTMLElement | null = null;
|
private container: HTMLElement | null = null;
|
||||||
private activeController: NavigationController | null = null;
|
private activeController: NavigationController | null = null;
|
||||||
private runtimeMessageHandler: ((message: string | null) => void) | null = null;
|
private runtimeMessageHandler: ((message: string | null) => void) | null =
|
||||||
private firstPersonTelemetryHandler: ((telemetry: FirstPersonTelemetry | null) => void) | null = null;
|
null;
|
||||||
private interactionPromptHandler: ((prompt: RuntimeInteractionPrompt | null) => void) | null = null;
|
private firstPersonTelemetryHandler:
|
||||||
|
| ((telemetry: FirstPersonTelemetry | null) => void)
|
||||||
|
| null = null;
|
||||||
|
private interactionPromptHandler:
|
||||||
|
| ((prompt: RuntimeInteractionPrompt | null) => void)
|
||||||
|
| null = null;
|
||||||
private sceneLoadStateHandler:
|
private sceneLoadStateHandler:
|
||||||
| ((state: RuntimeSceneLoadState) => void)
|
| ((state: RuntimeSceneLoadState) => void)
|
||||||
| null = null;
|
| null = null;
|
||||||
@@ -169,8 +203,11 @@ export class RuntimeHost {
|
|||||||
this.scene.add(this.modelGroup);
|
this.scene.add(this.modelGroup);
|
||||||
this.underwaterSceneFog.density = 0;
|
this.underwaterSceneFog.density = 0;
|
||||||
this.scene.fog = this.underwaterSceneFog;
|
this.scene.fog = this.underwaterSceneFog;
|
||||||
this.renderer = enableRendering ? new WebGLRenderer({ antialias: false, alpha: true }) : null;
|
this.renderer = enableRendering
|
||||||
this.domElement = this.renderer?.domElement ?? document.createElement("canvas");
|
? new WebGLRenderer({ antialias: false, alpha: true })
|
||||||
|
: null;
|
||||||
|
this.domElement =
|
||||||
|
this.renderer?.domElement ?? document.createElement("canvas");
|
||||||
|
|
||||||
if (this.renderer !== null) {
|
if (this.renderer !== null) {
|
||||||
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
@@ -189,8 +226,14 @@ export class RuntimeHost {
|
|||||||
|
|
||||||
return this.runtimeScene;
|
return this.runtimeScene;
|
||||||
},
|
},
|
||||||
resolveFirstPersonMotion: (feetPosition, motion, shape) => this.collisionWorld?.resolveFirstPersonMotion(feetPosition, motion, shape) ?? null,
|
resolveFirstPersonMotion: (feetPosition, motion, shape) =>
|
||||||
resolvePlayerVolumeState: (feetPosition) => this.resolvePlayerVolumeState(feetPosition),
|
this.collisionWorld?.resolveFirstPersonMotion(
|
||||||
|
feetPosition,
|
||||||
|
motion,
|
||||||
|
shape
|
||||||
|
) ?? null,
|
||||||
|
resolvePlayerVolumeState: (feetPosition) =>
|
||||||
|
this.resolvePlayerVolumeState(feetPosition),
|
||||||
setRuntimeMessage: (message) => {
|
setRuntimeMessage: (message) => {
|
||||||
if (message === this.currentRuntimeMessage) {
|
if (message === this.currentRuntimeMessage) {
|
||||||
return;
|
return;
|
||||||
@@ -206,7 +249,11 @@ export class RuntimeHost {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolvePlayerVolumeState(feetPosition: { x: number; y: number; z: number }): RuntimePlayerVolumeState {
|
private resolvePlayerVolumeState(feetPosition: {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
}): RuntimePlayerVolumeState {
|
||||||
if (this.runtimeScene === null) {
|
if (this.runtimeScene === null) {
|
||||||
return {
|
return {
|
||||||
inWater: false,
|
inWater: false,
|
||||||
@@ -214,8 +261,12 @@ export class RuntimeHost {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const inWater = this.runtimeScene.volumes.water.some((volume) => this.isPointInsideOrientedVolume(feetPosition, volume));
|
const inWater = this.runtimeScene.volumes.water.some((volume) =>
|
||||||
const inFog = this.runtimeScene.volumes.fog.some((volume) => this.isPointInsideOrientedVolume(feetPosition, volume));
|
this.isPointInsideOrientedVolume(feetPosition, volume)
|
||||||
|
);
|
||||||
|
const inFog = this.runtimeScene.volumes.fog.some((volume) =>
|
||||||
|
this.isPointInsideOrientedVolume(feetPosition, volume)
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
inWater,
|
inWater,
|
||||||
@@ -225,9 +276,17 @@ export class RuntimeHost {
|
|||||||
|
|
||||||
private isPointInsideOrientedVolume(
|
private isPointInsideOrientedVolume(
|
||||||
point: { x: number; y: number; z: number },
|
point: { x: number; y: number; z: number },
|
||||||
volume: { center: { x: number; y: number; z: number }; rotationDegrees: { x: number; y: number; z: number }; size: { x: number; y: number; z: number } }
|
volume: {
|
||||||
|
center: { x: number; y: number; z: number };
|
||||||
|
rotationDegrees: { x: number; y: number; z: number };
|
||||||
|
size: { x: number; y: number; z: number };
|
||||||
|
}
|
||||||
): boolean {
|
): boolean {
|
||||||
this.volumeOffset.set(point.x - volume.center.x, point.y - volume.center.y, point.z - volume.center.z);
|
this.volumeOffset.set(
|
||||||
|
point.x - volume.center.x,
|
||||||
|
point.y - volume.center.y,
|
||||||
|
point.z - volume.center.z
|
||||||
|
);
|
||||||
|
|
||||||
this.volumeInverseRotation
|
this.volumeInverseRotation
|
||||||
.setFromEuler(
|
.setFromEuler(
|
||||||
@@ -257,7 +316,10 @@ export class RuntimeHost {
|
|||||||
this.container = container;
|
this.container = container;
|
||||||
container.appendChild(this.domElement);
|
container.appendChild(this.domElement);
|
||||||
this.domElement.addEventListener("click", this.handleRuntimeClick);
|
this.domElement.addEventListener("click", this.handleRuntimeClick);
|
||||||
this.domElement.addEventListener("pointerdown", this.handleRuntimePointerDown);
|
this.domElement.addEventListener(
|
||||||
|
"pointerdown",
|
||||||
|
this.handleRuntimePointerDown
|
||||||
|
);
|
||||||
this.resize();
|
this.resize();
|
||||||
|
|
||||||
this.resizeObserver = new ResizeObserver(() => {
|
this.resizeObserver = new ResizeObserver(() => {
|
||||||
@@ -338,11 +400,15 @@ export class RuntimeHost {
|
|||||||
this.audioSystem.setRuntimeMessageHandler(handler);
|
this.audioSystem.setRuntimeMessageHandler(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
setFirstPersonTelemetryHandler(handler: ((telemetry: FirstPersonTelemetry | null) => void) | null) {
|
setFirstPersonTelemetryHandler(
|
||||||
|
handler: ((telemetry: FirstPersonTelemetry | null) => void) | null
|
||||||
|
) {
|
||||||
this.firstPersonTelemetryHandler = handler;
|
this.firstPersonTelemetryHandler = handler;
|
||||||
}
|
}
|
||||||
|
|
||||||
setInteractionPromptHandler(handler: ((prompt: RuntimeInteractionPrompt | null) => void) | null) {
|
setInteractionPromptHandler(
|
||||||
|
handler: ((prompt: RuntimeInteractionPrompt | null) => void) | null
|
||||||
|
) {
|
||||||
this.interactionPromptHandler = handler;
|
this.interactionPromptHandler = handler;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,7 +455,10 @@ export class RuntimeHost {
|
|||||||
this.renderer?.forceContextLoss();
|
this.renderer?.forceContextLoss();
|
||||||
this.renderer?.dispose();
|
this.renderer?.dispose();
|
||||||
this.domElement.removeEventListener("click", this.handleRuntimeClick);
|
this.domElement.removeEventListener("click", this.handleRuntimeClick);
|
||||||
this.domElement.removeEventListener("pointerdown", this.handleRuntimePointerDown);
|
this.domElement.removeEventListener(
|
||||||
|
"pointerdown",
|
||||||
|
this.handleRuntimePointerDown
|
||||||
|
);
|
||||||
|
|
||||||
if (this.container !== null && this.container.contains(this.domElement)) {
|
if (this.container !== null && this.container.contains(this.domElement)) {
|
||||||
this.container.removeChild(this.domElement);
|
this.container.removeChild(this.domElement);
|
||||||
@@ -505,7 +574,8 @@ export class RuntimeHost {
|
|||||||
.multiplyScalar(18);
|
.multiplyScalar(18);
|
||||||
|
|
||||||
if (world.background.mode === "image") {
|
if (world.background.mode === "image") {
|
||||||
const texture = this.loadedImageAssets[world.background.assetId]?.texture ?? null;
|
const texture =
|
||||||
|
this.loadedImageAssets[world.background.assetId]?.texture ?? null;
|
||||||
this.scene.background = texture;
|
this.scene.background = texture;
|
||||||
this.scene.environment = texture;
|
this.scene.environment = texture;
|
||||||
this.scene.environmentIntensity = world.background.environmentIntensity;
|
this.scene.environmentIntensity = world.background.environmentIntensity;
|
||||||
@@ -516,7 +586,10 @@ export class RuntimeHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.renderer !== null) {
|
if (this.renderer !== null) {
|
||||||
configureAdvancedRenderingRenderer(this.renderer, world.advancedRendering);
|
configureAdvancedRenderingRenderer(
|
||||||
|
this.renderer,
|
||||||
|
world.advancedRendering
|
||||||
|
);
|
||||||
this.syncAdvancedRenderingComposer(world.advancedRendering);
|
this.syncAdvancedRenderingComposer(world.advancedRendering);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -554,7 +627,10 @@ export class RuntimeHost {
|
|||||||
const shouldUseComposer = settings.enabled;
|
const shouldUseComposer = settings.enabled;
|
||||||
const settingsChanged =
|
const settingsChanged =
|
||||||
this.currentAdvancedRenderingSettings === null ||
|
this.currentAdvancedRenderingSettings === null ||
|
||||||
!areAdvancedRenderingSettingsEqual(this.currentAdvancedRenderingSettings, settings);
|
!areAdvancedRenderingSettingsEqual(
|
||||||
|
this.currentAdvancedRenderingSettings,
|
||||||
|
settings
|
||||||
|
);
|
||||||
|
|
||||||
if (!shouldUseComposer) {
|
if (!shouldUseComposer) {
|
||||||
if (this.advancedRenderingComposer !== null) {
|
if (this.advancedRenderingComposer !== null) {
|
||||||
@@ -575,8 +651,14 @@ export class RuntimeHost {
|
|||||||
this.advancedRenderingComposer.dispose();
|
this.advancedRenderingComposer.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.advancedRenderingComposer = createAdvancedRenderingComposer(this.renderer, this.scene, this.camera, settings);
|
this.advancedRenderingComposer = createAdvancedRenderingComposer(
|
||||||
this.currentAdvancedRenderingSettings = cloneAdvancedRenderingSettings(settings);
|
this.renderer,
|
||||||
|
this.scene,
|
||||||
|
this.camera,
|
||||||
|
settings
|
||||||
|
);
|
||||||
|
this.currentAdvancedRenderingSettings =
|
||||||
|
cloneAdvancedRenderingSettings(settings);
|
||||||
this.renderer.autoClear = false;
|
this.renderer.autoClear = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,7 +668,8 @@ export class RuntimeHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const advancedRendering = this.currentWorld.advancedRendering;
|
const advancedRendering = this.currentWorld.advancedRendering;
|
||||||
const shadowsEnabled = advancedRendering.enabled && advancedRendering.shadows.enabled;
|
const shadowsEnabled =
|
||||||
|
advancedRendering.enabled && advancedRendering.shadows.enabled;
|
||||||
|
|
||||||
applyAdvancedRenderingLightShadowFlags(this.sunLight, advancedRendering);
|
applyAdvancedRenderingLightShadowFlags(this.sunLight, advancedRendering);
|
||||||
|
|
||||||
@@ -621,11 +704,21 @@ export class RuntimeHost {
|
|||||||
this.applyShadowState();
|
this.applyShadowState();
|
||||||
}
|
}
|
||||||
|
|
||||||
private createPointLightRuntimeObjects(pointLight: RuntimeLocalLightCollection["pointLights"][number]): LocalLightRenderObjects {
|
private createPointLightRuntimeObjects(
|
||||||
|
pointLight: RuntimeLocalLightCollection["pointLights"][number]
|
||||||
|
): LocalLightRenderObjects {
|
||||||
const group = new Group();
|
const group = new Group();
|
||||||
const light = new PointLight(pointLight.colorHex, pointLight.intensity, pointLight.distance);
|
const light = new PointLight(
|
||||||
|
pointLight.colorHex,
|
||||||
|
pointLight.intensity,
|
||||||
|
pointLight.distance
|
||||||
|
);
|
||||||
|
|
||||||
group.position.set(pointLight.position.x, pointLight.position.y, pointLight.position.z);
|
group.position.set(
|
||||||
|
pointLight.position.x,
|
||||||
|
pointLight.position.y,
|
||||||
|
pointLight.position.z
|
||||||
|
);
|
||||||
light.position.set(0, 0, 0);
|
light.position.set(0, 0, 0);
|
||||||
group.add(light);
|
group.add(light);
|
||||||
|
|
||||||
@@ -634,7 +727,9 @@ export class RuntimeHost {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private createSpotLightRuntimeObjects(spotLight: RuntimeLocalLightCollection["spotLights"][number]): LocalLightRenderObjects {
|
private createSpotLightRuntimeObjects(
|
||||||
|
spotLight: RuntimeLocalLightCollection["spotLights"][number]
|
||||||
|
): LocalLightRenderObjects {
|
||||||
const group = new Group();
|
const group = new Group();
|
||||||
const light = new SpotLight(
|
const light = new SpotLight(
|
||||||
spotLight.colorHex,
|
spotLight.colorHex,
|
||||||
@@ -644,10 +739,21 @@ export class RuntimeHost {
|
|||||||
0.18,
|
0.18,
|
||||||
1
|
1
|
||||||
);
|
);
|
||||||
const direction = new Vector3(spotLight.direction.x, spotLight.direction.y, spotLight.direction.z).normalize();
|
const direction = new Vector3(
|
||||||
const orientation = new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), direction);
|
spotLight.direction.x,
|
||||||
|
spotLight.direction.y,
|
||||||
|
spotLight.direction.z
|
||||||
|
).normalize();
|
||||||
|
const orientation = new Quaternion().setFromUnitVectors(
|
||||||
|
new Vector3(0, 1, 0),
|
||||||
|
direction
|
||||||
|
);
|
||||||
|
|
||||||
group.position.set(spotLight.position.x, spotLight.position.y, spotLight.position.z);
|
group.position.set(
|
||||||
|
spotLight.position.x,
|
||||||
|
spotLight.position.y,
|
||||||
|
spotLight.position.z
|
||||||
|
);
|
||||||
group.quaternion.copy(orientation);
|
group.quaternion.copy(orientation);
|
||||||
light.position.set(0, 0, 0);
|
light.position.set(0, 0, 0);
|
||||||
light.target.position.set(0, 1, 0);
|
light.target.position.set(0, 1, 0);
|
||||||
@@ -662,26 +768,75 @@ export class RuntimeHost {
|
|||||||
private rebuildBrushMeshes(brushes: RuntimeBoxBrushInstance[]) {
|
private rebuildBrushMeshes(brushes: RuntimeBoxBrushInstance[]) {
|
||||||
this.clearBrushMeshes();
|
this.clearBrushMeshes();
|
||||||
const volumeRenderPaths: ResolvedBoxVolumeRenderPaths =
|
const volumeRenderPaths: ResolvedBoxVolumeRenderPaths =
|
||||||
this.currentWorld === null ? { fog: "performance", water: "performance" } : resolveBoxVolumeRenderPaths(this.currentWorld.advancedRendering);
|
this.currentWorld === null
|
||||||
|
? { fog: "performance", water: "performance" }
|
||||||
|
: resolveBoxVolumeRenderPaths(this.currentWorld.advancedRendering);
|
||||||
|
|
||||||
for (const brush of brushes) {
|
for (const brush of brushes) {
|
||||||
const geometry = buildBoxBrushDerivedMeshData(brush).geometry;
|
const geometry = buildBoxBrushDerivedMeshData(brush).geometry;
|
||||||
const staticContactPatches = brush.volume.mode === "water" ? this.collectRuntimeStaticWaterContactPatches(brush) : [];
|
const staticContactPatches =
|
||||||
|
brush.volume.mode === "water"
|
||||||
|
? this.collectRuntimeStaticWaterContactPatches(brush)
|
||||||
|
: [];
|
||||||
const contactPatches =
|
const contactPatches =
|
||||||
brush.volume.mode === "water"
|
brush.volume.mode === "water"
|
||||||
? this.mergeRuntimeWaterContactPatches(brush, staticContactPatches, this.collectRuntimePlayerWaterContactPatches(brush))
|
? this.mergeRuntimeWaterContactPatches(
|
||||||
|
brush,
|
||||||
|
staticContactPatches,
|
||||||
|
this.collectRuntimePlayerWaterContactPatches(brush)
|
||||||
|
)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const materials =
|
const materials = this.createFogMaterialSet(brush, volumeRenderPaths) ?? [
|
||||||
this.createFogMaterialSet(brush, volumeRenderPaths) ??
|
this.createFaceMaterial(
|
||||||
[
|
brush,
|
||||||
this.createFaceMaterial(brush, "posX", brush.faces.posX.material, volumeRenderPaths, contactPatches, staticContactPatches),
|
"posX",
|
||||||
this.createFaceMaterial(brush, "negX", brush.faces.negX.material, volumeRenderPaths, contactPatches, staticContactPatches),
|
brush.faces.posX.material,
|
||||||
this.createFaceMaterial(brush, "posY", brush.faces.posY.material, volumeRenderPaths, contactPatches, staticContactPatches),
|
volumeRenderPaths,
|
||||||
this.createFaceMaterial(brush, "negY", brush.faces.negY.material, volumeRenderPaths, contactPatches, staticContactPatches),
|
contactPatches,
|
||||||
this.createFaceMaterial(brush, "posZ", brush.faces.posZ.material, volumeRenderPaths, contactPatches, staticContactPatches),
|
staticContactPatches
|
||||||
this.createFaceMaterial(brush, "negZ", brush.faces.negZ.material, volumeRenderPaths, contactPatches, staticContactPatches)
|
),
|
||||||
];
|
this.createFaceMaterial(
|
||||||
|
brush,
|
||||||
|
"negX",
|
||||||
|
brush.faces.negX.material,
|
||||||
|
volumeRenderPaths,
|
||||||
|
contactPatches,
|
||||||
|
staticContactPatches
|
||||||
|
),
|
||||||
|
this.createFaceMaterial(
|
||||||
|
brush,
|
||||||
|
"posY",
|
||||||
|
brush.faces.posY.material,
|
||||||
|
volumeRenderPaths,
|
||||||
|
contactPatches,
|
||||||
|
staticContactPatches
|
||||||
|
),
|
||||||
|
this.createFaceMaterial(
|
||||||
|
brush,
|
||||||
|
"negY",
|
||||||
|
brush.faces.negY.material,
|
||||||
|
volumeRenderPaths,
|
||||||
|
contactPatches,
|
||||||
|
staticContactPatches
|
||||||
|
),
|
||||||
|
this.createFaceMaterial(
|
||||||
|
brush,
|
||||||
|
"posZ",
|
||||||
|
brush.faces.posZ.material,
|
||||||
|
volumeRenderPaths,
|
||||||
|
contactPatches,
|
||||||
|
staticContactPatches
|
||||||
|
),
|
||||||
|
this.createFaceMaterial(
|
||||||
|
brush,
|
||||||
|
"negZ",
|
||||||
|
brush.faces.negZ.material,
|
||||||
|
volumeRenderPaths,
|
||||||
|
contactPatches,
|
||||||
|
staticContactPatches
|
||||||
|
)
|
||||||
|
];
|
||||||
|
|
||||||
const mesh = new Mesh(geometry, materials);
|
const mesh = new Mesh(geometry, materials);
|
||||||
mesh.position.set(brush.center.x, brush.center.y, brush.center.z);
|
mesh.position.set(brush.center.x, brush.center.y, brush.center.z);
|
||||||
@@ -700,7 +855,10 @@ export class RuntimeHost {
|
|||||||
|
|
||||||
private createFogMaterialSet(
|
private createFogMaterialSet(
|
||||||
brush: RuntimeBoxBrushInstance,
|
brush: RuntimeBoxBrushInstance,
|
||||||
volumeRenderPaths: { fog: "performance" | "quality"; water: "performance" | "quality" }
|
volumeRenderPaths: {
|
||||||
|
fog: "performance" | "quality";
|
||||||
|
water: "performance" | "quality";
|
||||||
|
}
|
||||||
): Material[] | null {
|
): Material[] | null {
|
||||||
if (brush.volume.mode !== "fog") {
|
if (brush.volume.mode !== "fog") {
|
||||||
return null;
|
return null;
|
||||||
@@ -720,10 +878,16 @@ export class RuntimeHost {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.volumeAnimatedUniforms.push(fogMaterial.animationUniform);
|
this.volumeAnimatedUniforms.push(fogMaterial.animationUniform);
|
||||||
return Array.from({ length: BOX_FACE_MATERIAL_COUNT }, () => fogMaterial.material);
|
return Array.from(
|
||||||
|
{ length: BOX_FACE_MATERIAL_COUNT },
|
||||||
|
() => fogMaterial.material
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const densityOpacity = Math.max(0.06, Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08));
|
const densityOpacity = Math.max(
|
||||||
|
0.06,
|
||||||
|
Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08)
|
||||||
|
);
|
||||||
const fogMaterial = new MeshBasicMaterial({
|
const fogMaterial = new MeshBasicMaterial({
|
||||||
color: brush.volume.fog.colorHex,
|
color: brush.volume.fog.colorHex,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
@@ -734,9 +898,14 @@ export class RuntimeHost {
|
|||||||
return Array.from({ length: BOX_FACE_MATERIAL_COUNT }, () => fogMaterial);
|
return Array.from({ length: BOX_FACE_MATERIAL_COUNT }, () => fogMaterial);
|
||||||
}
|
}
|
||||||
|
|
||||||
private configureFogVolumeMesh(mesh: Mesh<BufferGeometry, Material[]>, materials: Material[]) {
|
private configureFogVolumeMesh(
|
||||||
|
mesh: Mesh<BufferGeometry, Material[]>,
|
||||||
|
materials: Material[]
|
||||||
|
) {
|
||||||
const fogMaterials = materials.filter(
|
const fogMaterials = materials.filter(
|
||||||
(material): material is ShaderMaterial => material instanceof ShaderMaterial && material.uniforms["localCameraPosition"] !== undefined
|
(material): material is ShaderMaterial =>
|
||||||
|
material instanceof ShaderMaterial &&
|
||||||
|
material.uniforms["localCameraPosition"] !== undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
if (fogMaterials.length === 0) {
|
if (fogMaterials.length === 0) {
|
||||||
@@ -744,15 +913,21 @@ export class RuntimeHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
mesh.onBeforeRender = (_renderer, _scene, camera) => {
|
mesh.onBeforeRender = (_renderer, _scene, camera) => {
|
||||||
const localCameraPosition = mesh.worldToLocal(this.fogLocalCameraPosition.copy(camera.position));
|
const localCameraPosition = mesh.worldToLocal(
|
||||||
|
this.fogLocalCameraPosition.copy(camera.position)
|
||||||
|
);
|
||||||
|
|
||||||
for (const material of fogMaterials) {
|
for (const material of fogMaterials) {
|
||||||
(material.uniforms["localCameraPosition"] as { value: Vector3 }).value.copy(localCameraPosition);
|
(
|
||||||
|
material.uniforms["localCameraPosition"] as { value: Vector3 }
|
||||||
|
).value.copy(localCameraPosition);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private rebuildModelInstances(modelInstances: RuntimeSceneDefinition["modelInstances"]) {
|
private rebuildModelInstances(
|
||||||
|
modelInstances: RuntimeSceneDefinition["modelInstances"]
|
||||||
|
) {
|
||||||
this.clearModelInstances();
|
this.clearModelInstances();
|
||||||
|
|
||||||
for (const modelInstance of modelInstances) {
|
for (const modelInstance of modelInstances) {
|
||||||
@@ -782,10 +957,19 @@ export class RuntimeHost {
|
|||||||
if (loadedAsset?.animations && loadedAsset.animations.length > 0) {
|
if (loadedAsset?.animations && loadedAsset.animations.length > 0) {
|
||||||
const mixer = new AnimationMixer(renderGroup);
|
const mixer = new AnimationMixer(renderGroup);
|
||||||
this.animationMixers.set(modelInstance.instanceId, mixer);
|
this.animationMixers.set(modelInstance.instanceId, mixer);
|
||||||
this.instanceAnimationClips.set(modelInstance.instanceId, loadedAsset.animations);
|
this.instanceAnimationClips.set(
|
||||||
|
modelInstance.instanceId,
|
||||||
|
loadedAsset.animations
|
||||||
|
);
|
||||||
|
|
||||||
if (modelInstance.animationAutoplay === true && modelInstance.animationClipName) {
|
if (
|
||||||
const clip = AnimationClip.findByName(loadedAsset.animations, modelInstance.animationClipName);
|
modelInstance.animationAutoplay === true &&
|
||||||
|
modelInstance.animationClipName
|
||||||
|
) {
|
||||||
|
const clip = AnimationClip.findByName(
|
||||||
|
loadedAsset.animations,
|
||||||
|
modelInstance.animationClipName
|
||||||
|
);
|
||||||
if (clip) {
|
if (clip) {
|
||||||
mixer.clipAction(clip).play();
|
mixer.clipAction(clip).play();
|
||||||
}
|
}
|
||||||
@@ -800,18 +984,28 @@ export class RuntimeHost {
|
|||||||
brush: RuntimeBoxBrushInstance,
|
brush: RuntimeBoxBrushInstance,
|
||||||
faceId: "posX" | "negX" | "posY" | "negY" | "posZ" | "negZ",
|
faceId: "posX" | "negX" | "posY" | "negY" | "posZ" | "negZ",
|
||||||
material: RuntimeBoxBrushInstance["faces"]["posX"]["material"],
|
material: RuntimeBoxBrushInstance["faces"]["posX"]["material"],
|
||||||
volumeRenderPaths: { fog: "performance" | "quality"; water: "performance" | "quality" },
|
volumeRenderPaths: {
|
||||||
|
fog: "performance" | "quality";
|
||||||
|
water: "performance" | "quality";
|
||||||
|
},
|
||||||
contactPatches: ReturnType<typeof collectWaterContactPatches>,
|
contactPatches: ReturnType<typeof collectWaterContactPatches>,
|
||||||
staticContactPatches: ReturnType<typeof collectWaterContactPatches>
|
staticContactPatches: ReturnType<typeof collectWaterContactPatches>
|
||||||
): Material {
|
): Material {
|
||||||
if (brush.volume.mode === "water") {
|
if (brush.volume.mode === "water") {
|
||||||
const baseOpacity = Math.max(0.05, Math.min(1, brush.volume.water.surfaceOpacity));
|
const baseOpacity = Math.max(
|
||||||
|
0.05,
|
||||||
|
Math.min(1, brush.volume.water.surfaceOpacity)
|
||||||
|
);
|
||||||
const waterMaterial = createWaterMaterial({
|
const waterMaterial = createWaterMaterial({
|
||||||
colorHex: brush.volume.water.colorHex,
|
colorHex: brush.volume.water.colorHex,
|
||||||
surfaceOpacity: brush.volume.water.surfaceOpacity,
|
surfaceOpacity: brush.volume.water.surfaceOpacity,
|
||||||
waveStrength: brush.volume.water.waveStrength,
|
waveStrength: brush.volume.water.waveStrength,
|
||||||
surfaceDisplacementEnabled: brush.volume.water.surfaceDisplacementEnabled,
|
surfaceDisplacementEnabled:
|
||||||
opacity: faceId === "posY" ? Math.min(1, baseOpacity + 0.18) : baseOpacity * 0.5,
|
brush.volume.water.surfaceDisplacementEnabled,
|
||||||
|
opacity:
|
||||||
|
faceId === "posY"
|
||||||
|
? Math.min(1, baseOpacity + 0.18)
|
||||||
|
: baseOpacity * 0.5,
|
||||||
quality: volumeRenderPaths.water === "quality",
|
quality: volumeRenderPaths.water === "quality",
|
||||||
wireframe: false,
|
wireframe: false,
|
||||||
isTopFace: faceId === "posY",
|
isTopFace: faceId === "posY",
|
||||||
@@ -831,17 +1025,26 @@ export class RuntimeHost {
|
|||||||
this.volumeAnimatedUniforms.push(waterMaterial.animationUniform);
|
this.volumeAnimatedUniforms.push(waterMaterial.animationUniform);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (faceId === "posY" && waterMaterial.contactPatchesUniform !== null && waterMaterial.contactPatchAxesUniform !== null) {
|
if (
|
||||||
|
faceId === "posY" &&
|
||||||
|
waterMaterial.contactPatchesUniform !== null &&
|
||||||
|
waterMaterial.contactPatchAxesUniform !== null
|
||||||
|
) {
|
||||||
this.runtimeWaterContactUniforms.push({
|
this.runtimeWaterContactUniforms.push({
|
||||||
brush,
|
brush,
|
||||||
uniform: waterMaterial.contactPatchesUniform,
|
uniform: waterMaterial.contactPatchesUniform,
|
||||||
axisUniform: waterMaterial.contactPatchAxesUniform,
|
axisUniform: waterMaterial.contactPatchAxesUniform,
|
||||||
shapeUniform: waterMaterial.contactPatchShapesUniform ?? { value: [] },
|
shapeUniform: waterMaterial.contactPatchShapesUniform ?? {
|
||||||
|
value: []
|
||||||
|
},
|
||||||
staticContactPatches,
|
staticContactPatches,
|
||||||
reflectionTextureUniform: waterMaterial.reflectionTextureUniform,
|
reflectionTextureUniform: waterMaterial.reflectionTextureUniform,
|
||||||
reflectionMatrixUniform: waterMaterial.reflectionMatrixUniform,
|
reflectionMatrixUniform: waterMaterial.reflectionMatrixUniform,
|
||||||
reflectionEnabledUniform: waterMaterial.reflectionEnabledUniform,
|
reflectionEnabledUniform: waterMaterial.reflectionEnabledUniform,
|
||||||
reflectionRenderTarget: this.getWaterReflectionMode() !== "none" ? this.createWaterReflectionRenderTarget() : null,
|
reflectionRenderTarget:
|
||||||
|
this.getWaterReflectionMode() !== "none"
|
||||||
|
? this.createWaterReflectionRenderTarget()
|
||||||
|
: null,
|
||||||
lastReflectionUpdateTime: Number.NEGATIVE_INFINITY
|
lastReflectionUpdateTime: Number.NEGATIVE_INFINITY
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -867,7 +1070,10 @@ export class RuntimeHost {
|
|||||||
return fogMaterial.material;
|
return fogMaterial.material;
|
||||||
}
|
}
|
||||||
// Performance fallback: simple transparent material
|
// Performance fallback: simple transparent material
|
||||||
const densityOpacity = Math.max(0.06, Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08));
|
const densityOpacity = Math.max(
|
||||||
|
0.06,
|
||||||
|
Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08)
|
||||||
|
);
|
||||||
return new MeshBasicMaterial({
|
return new MeshBasicMaterial({
|
||||||
color: brush.volume.fog.colorHex,
|
color: brush.volume.fog.colorHex,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
@@ -895,7 +1101,10 @@ export class RuntimeHost {
|
|||||||
private updateUnderwaterSceneFog() {
|
private updateUnderwaterSceneFog() {
|
||||||
const fogState =
|
const fogState =
|
||||||
this.activeController === this.firstPersonController
|
this.activeController === this.firstPersonController
|
||||||
? resolveUnderwaterFogState(this.runtimeScene, this.currentFirstPersonTelemetry)
|
? resolveUnderwaterFogState(
|
||||||
|
this.runtimeScene,
|
||||||
|
this.currentFirstPersonTelemetry
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (fogState === null) {
|
if (fogState === null) {
|
||||||
@@ -908,7 +1117,11 @@ export class RuntimeHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getWaterReflectionMode() {
|
private getWaterReflectionMode() {
|
||||||
if (this.currentWorld === null || !this.currentWorld.advancedRendering.enabled || this.currentWorld.advancedRendering.waterPath !== "quality") {
|
if (
|
||||||
|
this.currentWorld === null ||
|
||||||
|
!this.currentWorld.advancedRendering.enabled ||
|
||||||
|
this.currentWorld.advancedRendering.waterPath !== "quality"
|
||||||
|
) {
|
||||||
return "none" as const;
|
return "none" as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -957,7 +1170,8 @@ export class RuntimeHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (binding.reflectionRenderTarget === null) {
|
if (binding.reflectionRenderTarget === null) {
|
||||||
binding.reflectionRenderTarget = this.createWaterReflectionRenderTarget();
|
binding.reflectionRenderTarget =
|
||||||
|
this.createWaterReflectionRenderTarget();
|
||||||
}
|
}
|
||||||
|
|
||||||
const canRenderReflection = updatePlanarReflectionCamera(
|
const canRenderReflection = updatePlanarReflectionCamera(
|
||||||
@@ -972,12 +1186,19 @@ export class RuntimeHost {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (binding.reflectionTextureUniform.value !== null && now - binding.lastReflectionUpdateTime < WATER_REFLECTION_UPDATE_INTERVAL_MS) {
|
if (
|
||||||
|
binding.reflectionTextureUniform.value !== null &&
|
||||||
|
now - binding.lastReflectionUpdateTime <
|
||||||
|
WATER_REFLECTION_UPDATE_INTERVAL_MS
|
||||||
|
) {
|
||||||
binding.reflectionEnabledUniform.value = 0.36;
|
binding.reflectionEnabledUniform.value = 0.36;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hiddenWaterMeshes: Array<{ mesh: Mesh<BufferGeometry, Material[]>; visible: boolean }> = [];
|
const hiddenWaterMeshes: Array<{
|
||||||
|
mesh: Mesh<BufferGeometry, Material[]>;
|
||||||
|
visible: boolean;
|
||||||
|
}> = [];
|
||||||
for (const runtimeBrush of this.runtimeScene.brushes) {
|
for (const runtimeBrush of this.runtimeScene.brushes) {
|
||||||
if (runtimeBrush.volume.mode !== "water") {
|
if (runtimeBrush.volume.mode !== "water") {
|
||||||
continue;
|
continue;
|
||||||
@@ -1000,11 +1221,13 @@ export class RuntimeHost {
|
|||||||
const previousAutoClear = this.renderer.autoClear;
|
const previousAutoClear = this.renderer.autoClear;
|
||||||
const previousRenderTarget = this.renderer.getRenderTarget();
|
const previousRenderTarget = this.renderer.getRenderTarget();
|
||||||
const previousFogDensity = this.underwaterSceneFog.density;
|
const previousFogDensity = this.underwaterSceneFog.density;
|
||||||
const previousReflectionStates = this.runtimeWaterContactUniforms.map((waterBinding) => ({
|
const previousReflectionStates = this.runtimeWaterContactUniforms.map(
|
||||||
binding: waterBinding,
|
(waterBinding) => ({
|
||||||
enabled: waterBinding.reflectionEnabledUniform?.value ?? 0,
|
binding: waterBinding,
|
||||||
texture: waterBinding.reflectionTextureUniform?.value ?? null
|
enabled: waterBinding.reflectionEnabledUniform?.value ?? 0,
|
||||||
}));
|
texture: waterBinding.reflectionTextureUniform?.value ?? null
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.underwaterSceneFog.density = 0;
|
this.underwaterSceneFog.density = 0;
|
||||||
@@ -1037,13 +1260,16 @@ export class RuntimeHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.reflectionTextureUniform.value = binding.reflectionRenderTarget.texture;
|
binding.reflectionTextureUniform.value =
|
||||||
|
binding.reflectionRenderTarget.texture;
|
||||||
binding.reflectionEnabledUniform.value = 0.36;
|
binding.reflectionEnabledUniform.value = 0.36;
|
||||||
binding.lastReflectionUpdateTime = now;
|
binding.lastReflectionUpdateTime = now;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private getOrCreateTexture(material: NonNullable<RuntimeBoxBrushInstance["faces"]["posX"]["material"]>) {
|
private getOrCreateTexture(
|
||||||
|
material: NonNullable<RuntimeBoxBrushInstance["faces"]["posX"]["material"]>
|
||||||
|
) {
|
||||||
const signature = createStarterMaterialSignature(material);
|
const signature = createStarterMaterialSignature(material);
|
||||||
const cachedTexture = this.materialTextureCache.get(material.id);
|
const cachedTexture = this.materialTextureCache.get(material.id);
|
||||||
|
|
||||||
@@ -1092,7 +1318,10 @@ export class RuntimeHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private createPlayerWaterContactBounds() {
|
private createPlayerWaterContactBounds() {
|
||||||
if (this.runtimeScene === null || this.currentFirstPersonTelemetry === null) {
|
if (
|
||||||
|
this.runtimeScene === null ||
|
||||||
|
this.currentFirstPersonTelemetry === null
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1131,16 +1360,27 @@ export class RuntimeHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private collectRuntimeStaticWaterContactPatches(brush: RuntimeBoxBrushInstance) {
|
private collectRuntimeStaticWaterContactPatches(
|
||||||
|
brush: RuntimeBoxBrushInstance
|
||||||
|
) {
|
||||||
const contactBounds: Parameters<typeof collectWaterContactPatches>[1] = [];
|
const contactBounds: Parameters<typeof collectWaterContactPatches>[1] = [];
|
||||||
|
|
||||||
const runtimeBrushesById = new Map((this.runtimeScene?.brushes ?? []).map((runtimeBrush) => [runtimeBrush.id, runtimeBrush]));
|
const runtimeBrushesById = new Map(
|
||||||
|
(this.runtimeScene?.brushes ?? []).map((runtimeBrush) => [
|
||||||
|
runtimeBrush.id,
|
||||||
|
runtimeBrush
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
for (const collider of this.runtimeScene?.colliders ?? []) {
|
for (const collider of this.runtimeScene?.colliders ?? []) {
|
||||||
if (collider.source === "brush") {
|
if (collider.source === "brush") {
|
||||||
const otherBrush = runtimeBrushesById.get(collider.brushId);
|
const otherBrush = runtimeBrushesById.get(collider.brushId);
|
||||||
|
|
||||||
if (otherBrush === undefined || otherBrush.id === brush.id || otherBrush.volume.mode !== "none") {
|
if (
|
||||||
|
otherBrush === undefined ||
|
||||||
|
otherBrush.id === brush.id ||
|
||||||
|
otherBrush.volume.mode !== "none"
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1189,7 +1429,9 @@ export class RuntimeHost {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private collectRuntimePlayerWaterContactPatches(brush: RuntimeBoxBrushInstance) {
|
private collectRuntimePlayerWaterContactPatches(
|
||||||
|
brush: RuntimeBoxBrushInstance
|
||||||
|
) {
|
||||||
const playerBounds = this.createPlayerWaterContactBounds();
|
const playerBounds = this.createPlayerWaterContactBounds();
|
||||||
|
|
||||||
if (playerBounds === null) {
|
if (playerBounds === null) {
|
||||||
@@ -1208,7 +1450,9 @@ export class RuntimeHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getRuntimeWaterFoamContactLimit(brush: RuntimeBoxBrushInstance) {
|
private getRuntimeWaterFoamContactLimit(brush: RuntimeBoxBrushInstance) {
|
||||||
return brush.volume.mode === "water" ? brush.volume.water.foamContactLimit : 0;
|
return brush.volume.mode === "water"
|
||||||
|
? brush.volume.water.foamContactLimit
|
||||||
|
: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private mergeRuntimeWaterContactPatches(
|
private mergeRuntimeWaterContactPatches(
|
||||||
@@ -1216,7 +1460,10 @@ export class RuntimeHost {
|
|||||||
staticContactPatches: ReturnType<typeof collectWaterContactPatches>,
|
staticContactPatches: ReturnType<typeof collectWaterContactPatches>,
|
||||||
dynamicContactPatches: ReturnType<typeof collectWaterContactPatches>
|
dynamicContactPatches: ReturnType<typeof collectWaterContactPatches>
|
||||||
) {
|
) {
|
||||||
return [...dynamicContactPatches, ...staticContactPatches].slice(0, this.getRuntimeWaterFoamContactLimit(brush));
|
return [...dynamicContactPatches, ...staticContactPatches].slice(
|
||||||
|
0,
|
||||||
|
this.getRuntimeWaterFoamContactLimit(brush)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateRuntimeWaterContactUniforms() {
|
private updateRuntimeWaterContactUniforms() {
|
||||||
@@ -1226,9 +1473,12 @@ export class RuntimeHost {
|
|||||||
binding.staticContactPatches,
|
binding.staticContactPatches,
|
||||||
this.collectRuntimePlayerWaterContactPatches(binding.brush)
|
this.collectRuntimePlayerWaterContactPatches(binding.brush)
|
||||||
);
|
);
|
||||||
binding.uniform.value = createWaterContactPatchUniformValue(mergedPatches);
|
binding.uniform.value =
|
||||||
binding.axisUniform.value = createWaterContactPatchAxisUniformValue(mergedPatches);
|
createWaterContactPatchUniformValue(mergedPatches);
|
||||||
binding.shapeUniform.value = createWaterContactPatchShapeUniformValue(mergedPatches);
|
binding.axisUniform.value =
|
||||||
|
createWaterContactPatchAxisUniformValue(mergedPatches);
|
||||||
|
binding.shapeUniform.value =
|
||||||
|
createWaterContactPatchShapeUniformValue(mergedPatches);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1293,7 +1543,11 @@ export class RuntimeHost {
|
|||||||
this.activeController === this.firstPersonController &&
|
this.activeController === this.firstPersonController &&
|
||||||
this.currentFirstPersonTelemetry !== null
|
this.currentFirstPersonTelemetry !== null
|
||||||
) {
|
) {
|
||||||
this.interactionSystem.updatePlayerPosition(this.currentFirstPersonTelemetry.feetPosition, this.runtimeScene, this.createInteractionDispatcher());
|
this.interactionSystem.updatePlayerPosition(
|
||||||
|
this.currentFirstPersonTelemetry.feetPosition,
|
||||||
|
this.runtimeScene,
|
||||||
|
this.createInteractionDispatcher()
|
||||||
|
);
|
||||||
this.camera.getWorldDirection(this.cameraForward);
|
this.camera.getWorldDirection(this.cameraForward);
|
||||||
this.setInteractionPrompt(
|
this.setInteractionPrompt(
|
||||||
this.interactionSystem.resolveClickInteractionPrompt(
|
this.interactionSystem.resolveClickInteractionPrompt(
|
||||||
@@ -1329,7 +1583,10 @@ export class RuntimeHost {
|
|||||||
this.firstPersonController.teleportTo(target.position, target.yawDegrees);
|
this.firstPersonController.teleportTo(target.position, target.yawDegrees);
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyToggleBrushVisibilityAction(brushId: string, visible: boolean | undefined) {
|
private applyToggleBrushVisibilityAction(
|
||||||
|
brushId: string,
|
||||||
|
visible: boolean | undefined
|
||||||
|
) {
|
||||||
const mesh = this.brushMeshes.get(brushId);
|
const mesh = this.brushMeshes.get(brushId);
|
||||||
|
|
||||||
if (mesh === undefined) {
|
if (mesh === undefined) {
|
||||||
@@ -1339,7 +1596,11 @@ export class RuntimeHost {
|
|||||||
mesh.visible = visible ?? !mesh.visible;
|
mesh.visible = visible ?? !mesh.visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyPlayAnimationAction(instanceId: string, clipName: string, loop: boolean | undefined) {
|
private applyPlayAnimationAction(
|
||||||
|
instanceId: string,
|
||||||
|
clipName: string,
|
||||||
|
loop: boolean | undefined
|
||||||
|
) {
|
||||||
const mixer = this.animationMixers.get(instanceId);
|
const mixer = this.animationMixers.get(instanceId);
|
||||||
const clips = this.instanceAnimationClips.get(instanceId);
|
const clips = this.instanceAnimationClips.get(instanceId);
|
||||||
|
|
||||||
@@ -1351,7 +1612,9 @@ export class RuntimeHost {
|
|||||||
const clip = AnimationClip.findByName(clips, clipName);
|
const clip = AnimationClip.findByName(clips, clipName);
|
||||||
|
|
||||||
if (!clip) {
|
if (!clip) {
|
||||||
console.warn(`playAnimation: clip "${clipName}" not found on instance ${instanceId}`);
|
console.warn(
|
||||||
|
`playAnimation: clip "${clipName}" not found on instance ${instanceId}`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1399,7 +1662,8 @@ export class RuntimeHost {
|
|||||||
|
|
||||||
private setInteractionPrompt(prompt: RuntimeInteractionPrompt | null) {
|
private setInteractionPrompt(prompt: RuntimeInteractionPrompt | null) {
|
||||||
if (
|
if (
|
||||||
this.currentInteractionPrompt?.sourceEntityId === prompt?.sourceEntityId &&
|
this.currentInteractionPrompt?.sourceEntityId ===
|
||||||
|
prompt?.sourceEntityId &&
|
||||||
this.currentInteractionPrompt?.prompt === prompt?.prompt &&
|
this.currentInteractionPrompt?.prompt === prompt?.prompt &&
|
||||||
this.currentInteractionPrompt?.distance === prompt?.distance &&
|
this.currentInteractionPrompt?.distance === prompt?.distance &&
|
||||||
this.currentInteractionPrompt?.range === prompt?.range
|
this.currentInteractionPrompt?.range === prompt?.range
|
||||||
@@ -1422,7 +1686,11 @@ export class RuntimeHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.audioSystem.handleUserGesture();
|
this.audioSystem.handleUserGesture();
|
||||||
this.interactionSystem.dispatchClickInteraction(this.currentInteractionPrompt.sourceEntityId, this.runtimeScene, this.createInteractionDispatcher());
|
this.interactionSystem.dispatchClickInteraction(
|
||||||
|
this.currentInteractionPrompt.sourceEntityId,
|
||||||
|
this.runtimeScene,
|
||||||
|
this.createInteractionDispatcher()
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
private handleRuntimePointerDown = () => {
|
private handleRuntimePointerDown = () => {
|
||||||
|
|||||||
@@ -84,7 +84,9 @@ describe("EditorStore", () => {
|
|||||||
const secondSceneId = store.getState().activeSceneId;
|
const secondSceneId = store.getState().activeSceneId;
|
||||||
|
|
||||||
expect(secondSceneId).not.toBe(firstSceneId);
|
expect(secondSceneId).not.toBe(firstSceneId);
|
||||||
expect(Object.keys(store.getState().projectDocument.scenes)).toHaveLength(2);
|
expect(Object.keys(store.getState().projectDocument.scenes)).toHaveLength(
|
||||||
|
2
|
||||||
|
);
|
||||||
expect(store.getState().document.name).toBe("Scene 2");
|
expect(store.getState().document.name).toBe("Scene 2");
|
||||||
|
|
||||||
store.executeCommand(createCreateBoxBrushCommand());
|
store.executeCommand(createCreateBoxBrushCommand());
|
||||||
@@ -269,9 +271,13 @@ describe("EditorStore", () => {
|
|||||||
expect(store.getState().whiteboxSelectionMode).toBe("object");
|
expect(store.getState().whiteboxSelectionMode).toBe("object");
|
||||||
expect(store.getState().viewportLayoutMode).toBe("single");
|
expect(store.getState().viewportLayoutMode).toBe("single");
|
||||||
expect(store.getState().activeViewportPanelId).toBe("topLeft");
|
expect(store.getState().activeViewportPanelId).toBe("topLeft");
|
||||||
expect(store.getState().viewportPanels.topLeft.viewMode).toBe("perspective");
|
expect(store.getState().viewportPanels.topLeft.viewMode).toBe(
|
||||||
|
"perspective"
|
||||||
|
);
|
||||||
expect(store.getState().viewportPanels.topRight.viewMode).toBe("top");
|
expect(store.getState().viewportPanels.topRight.viewMode).toBe("top");
|
||||||
expect(store.getState().viewportPanels.topRight.displayMode).toBe("authoring");
|
expect(store.getState().viewportPanels.topRight.displayMode).toBe(
|
||||||
|
"authoring"
|
||||||
|
);
|
||||||
expect(store.getState().viewportQuadSplit).toEqual({
|
expect(store.getState().viewportQuadSplit).toEqual({
|
||||||
x: 0.5,
|
x: 0.5,
|
||||||
y: 0.5
|
y: 0.5
|
||||||
@@ -289,7 +295,9 @@ describe("EditorStore", () => {
|
|||||||
expect(store.getState().viewportLayoutMode).toBe("quad");
|
expect(store.getState().viewportLayoutMode).toBe("quad");
|
||||||
expect(store.getState().activeViewportPanelId).toBe("bottomRight");
|
expect(store.getState().activeViewportPanelId).toBe("bottomRight");
|
||||||
expect(store.getState().viewportPanels.bottomRight.viewMode).toBe("front");
|
expect(store.getState().viewportPanels.bottomRight.viewMode).toBe("front");
|
||||||
expect(store.getState().viewportPanels.bottomRight.displayMode).toBe("normal");
|
expect(store.getState().viewportPanels.bottomRight.displayMode).toBe(
|
||||||
|
"normal"
|
||||||
|
);
|
||||||
expect(store.getState().viewportQuadSplit).toEqual({
|
expect(store.getState().viewportQuadSplit).toEqual({
|
||||||
x: 0.38,
|
x: 0.38,
|
||||||
y: 0.62
|
y: 0.62
|
||||||
@@ -482,7 +490,9 @@ describe("EditorStore", () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(store.getState().viewportTransientState.transformSession).toMatchObject({
|
expect(
|
||||||
|
store.getState().viewportTransientState.transformSession
|
||||||
|
).toMatchObject({
|
||||||
kind: "active",
|
kind: "active",
|
||||||
source: "keyboard",
|
source: "keyboard",
|
||||||
sourcePanelId: "bottomRight",
|
sourcePanelId: "bottomRight",
|
||||||
|
|||||||
@@ -75,11 +75,16 @@ describe("local draft storage", () => {
|
|||||||
expect(result.document.version).toBe(SCENE_DOCUMENT_VERSION);
|
expect(result.document.version).toBe(SCENE_DOCUMENT_VERSION);
|
||||||
expect(result.document).toEqual(createEmptyProjectDocument());
|
expect(result.document).toEqual(createEmptyProjectDocument());
|
||||||
expect(result.diagnostic).toContain("Stored autosave could not be loaded.");
|
expect(result.diagnostic).toContain("Stored autosave could not be loaded.");
|
||||||
expect(result.diagnostic).toContain("Starting with a fresh empty document.");
|
expect(result.diagnostic).toContain(
|
||||||
|
"Starting with a fresh empty document."
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports browser storage access failures without throwing", () => {
|
it("reports browser storage access failures without throwing", () => {
|
||||||
const originalDescriptor = Object.getOwnPropertyDescriptor(window, "localStorage");
|
const originalDescriptor = Object.getOwnPropertyDescriptor(
|
||||||
|
window,
|
||||||
|
"localStorage"
|
||||||
|
);
|
||||||
|
|
||||||
Object.defineProperty(window, "localStorage", {
|
Object.defineProperty(window, "localStorage", {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -92,7 +97,9 @@ describe("local draft storage", () => {
|
|||||||
const result = getBrowserStorageAccess();
|
const result = getBrowserStorageAccess();
|
||||||
|
|
||||||
expect(result.storage).toBeNull();
|
expect(result.storage).toBeNull();
|
||||||
expect(result.diagnostic).toContain("Browser local storage is unavailable.");
|
expect(result.diagnostic).toContain(
|
||||||
|
"Browser local storage is unavailable."
|
||||||
|
);
|
||||||
expect(result.diagnostic).toContain("access denied");
|
expect(result.diagnostic).toContain("access denied");
|
||||||
} finally {
|
} finally {
|
||||||
if (originalDescriptor !== undefined) {
|
if (originalDescriptor !== undefined) {
|
||||||
@@ -161,9 +168,9 @@ describe("local draft storage", () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(
|
expect(result.document.scenes[result.document.activeSceneId]?.name).toBe(
|
||||||
result.document.scenes[result.document.activeSceneId]?.name
|
"Viewport Draft"
|
||||||
).toBe("Viewport Draft");
|
);
|
||||||
expect(result.viewportLayoutState).toMatchObject({
|
expect(result.viewportLayoutState).toMatchObject({
|
||||||
layoutMode: "quad",
|
layoutMode: "quad",
|
||||||
activePanelId: "bottomRight",
|
activePanelId: "bottomRight",
|
||||||
@@ -235,7 +242,10 @@ describe("local draft storage", () => {
|
|||||||
|
|
||||||
it("loads older raw scene-document drafts without requiring viewport layout state", () => {
|
it("loads older raw scene-document drafts without requiring viewport layout state", () => {
|
||||||
const storage = new MemoryStorage();
|
const storage = new MemoryStorage();
|
||||||
storage.setItem(DEFAULT_SCENE_DRAFT_STORAGE_KEY, serializeSceneDocument(createEmptySceneDocument({ name: "Legacy Draft" })));
|
storage.setItem(
|
||||||
|
DEFAULT_SCENE_DRAFT_STORAGE_KEY,
|
||||||
|
serializeSceneDocument(createEmptySceneDocument({ name: "Legacy Draft" }))
|
||||||
|
);
|
||||||
|
|
||||||
const result = loadSceneDocumentDraft(storage);
|
const result = loadSceneDocumentDraft(storage);
|
||||||
|
|
||||||
@@ -245,9 +255,9 @@ describe("local draft storage", () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(
|
expect(result.document.scenes[result.document.activeSceneId]?.name).toBe(
|
||||||
result.document.scenes[result.document.activeSceneId]?.name
|
"Legacy Draft"
|
||||||
).toBe("Legacy Draft");
|
);
|
||||||
expect(result.viewportLayoutState).toBeNull();
|
expect(result.viewportLayoutState).toBeNull();
|
||||||
expect(result.message).toBe("Recovered latest autosave.");
|
expect(result.message).toBe("Recovered latest autosave.");
|
||||||
});
|
});
|
||||||
@@ -263,9 +273,9 @@ describe("local draft storage", () => {
|
|||||||
|
|
||||||
const result = loadOrCreateSceneDocument(storage);
|
const result = loadOrCreateSceneDocument(storage);
|
||||||
|
|
||||||
expect(
|
expect(result.document.scenes[result.document.activeSceneId]?.name).toBe(
|
||||||
result.document.scenes[result.document.activeSceneId]?.name
|
"Recovered Scene"
|
||||||
).toBe("Recovered Scene");
|
);
|
||||||
expect(result.diagnostic).toBe("Recovered latest autosave.");
|
expect(result.diagnostic).toBe("Recovered latest autosave.");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ describe("project document JSON", () => {
|
|||||||
id: "scene-main",
|
id: "scene-main",
|
||||||
name: "Legacy Entry"
|
name: "Legacy Entry"
|
||||||
});
|
});
|
||||||
const { loadingScreen: _loadingScreen, ...legacySceneWithoutLoadingScreen } =
|
const {
|
||||||
legacyScene;
|
loadingScreen: _loadingScreen,
|
||||||
|
...legacySceneWithoutLoadingScreen
|
||||||
|
} = legacyScene;
|
||||||
void _loadingScreen;
|
void _loadingScreen;
|
||||||
|
|
||||||
const migratedDocument = parseProjectDocumentJson(
|
const migratedDocument = parseProjectDocumentJson(
|
||||||
|
|||||||
@@ -5,11 +5,21 @@ import { strToU8, unzipSync, Zip, ZipDeflate } from "fflate";
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { loadAudioAssetFromStorage } from "../../src/assets/audio-assets";
|
import { loadAudioAssetFromStorage } from "../../src/assets/audio-assets";
|
||||||
import { loadModelAssetFromStorage, importModelAssetFromFiles } from "../../src/assets/gltf-model-import";
|
import {
|
||||||
|
loadModelAssetFromStorage,
|
||||||
|
importModelAssetFromFiles
|
||||||
|
} from "../../src/assets/gltf-model-import";
|
||||||
import { loadImageAssetFromStorage } from "../../src/assets/image-assets";
|
import { loadImageAssetFromStorage } from "../../src/assets/image-assets";
|
||||||
import { createModelInstance } from "../../src/assets/model-instances";
|
import { createModelInstance } from "../../src/assets/model-instances";
|
||||||
import { createInMemoryProjectAssetStorage, type ProjectAssetStorage } from "../../src/assets/project-asset-storage";
|
import {
|
||||||
import { createProjectAssetStorageKey, type AudioAssetRecord, type ImageAssetRecord } from "../../src/assets/project-assets";
|
createInMemoryProjectAssetStorage,
|
||||||
|
type ProjectAssetStorage
|
||||||
|
} from "../../src/assets/project-asset-storage";
|
||||||
|
import {
|
||||||
|
createProjectAssetStorageKey,
|
||||||
|
type AudioAssetRecord,
|
||||||
|
type ImageAssetRecord
|
||||||
|
} from "../../src/assets/project-assets";
|
||||||
import {
|
import {
|
||||||
createEmptyProjectScene,
|
createEmptyProjectScene,
|
||||||
createEmptySceneDocument,
|
createEmptySceneDocument,
|
||||||
@@ -22,11 +32,24 @@ import {
|
|||||||
} from "../../src/serialization/project-package";
|
} from "../../src/serialization/project-package";
|
||||||
import { serializeProjectDocument } from "../../src/serialization/scene-document-json";
|
import { serializeProjectDocument } from "../../src/serialization/scene-document-json";
|
||||||
|
|
||||||
const tinyGlbFixturePath = path.resolve(process.cwd(), "fixtures/assets/tiny-triangle.glb");
|
const tinyGlbFixturePath = path.resolve(
|
||||||
const externalTriangleGltfPath = path.resolve(process.cwd(), "fixtures/assets/external-triangle/scene.gltf");
|
process.cwd(),
|
||||||
const externalTriangleBinPath = path.resolve(process.cwd(), "fixtures/assets/external-triangle/triangle.bin");
|
"fixtures/assets/tiny-triangle.glb"
|
||||||
|
);
|
||||||
|
const externalTriangleGltfPath = path.resolve(
|
||||||
|
process.cwd(),
|
||||||
|
"fixtures/assets/external-triangle/scene.gltf"
|
||||||
|
);
|
||||||
|
const externalTriangleBinPath = path.resolve(
|
||||||
|
process.cwd(),
|
||||||
|
"fixtures/assets/external-triangle/triangle.bin"
|
||||||
|
);
|
||||||
|
|
||||||
function createTestFile(bytes: Uint8Array | Buffer, name: string, type: string): File {
|
function createTestFile(
|
||||||
|
bytes: Uint8Array | Buffer,
|
||||||
|
name: string,
|
||||||
|
type: string
|
||||||
|
): File {
|
||||||
const arrayBuffer = new ArrayBuffer(bytes.byteLength);
|
const arrayBuffer = new ArrayBuffer(bytes.byteLength);
|
||||||
new Uint8Array(arrayBuffer).set(bytes);
|
new Uint8Array(arrayBuffer).set(bytes);
|
||||||
|
|
||||||
@@ -72,7 +95,9 @@ function buildZipArchive(entries: Record<string, Uint8Array>): Uint8Array {
|
|||||||
|
|
||||||
zip.end();
|
zip.end();
|
||||||
|
|
||||||
const archiveBytes = new Uint8Array(chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0));
|
const archiveBytes = new Uint8Array(
|
||||||
|
chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0)
|
||||||
|
);
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
@@ -177,7 +202,11 @@ describe("project package serialization", () => {
|
|||||||
await storage.putAsset(imageAsset.storageKey, {
|
await storage.putAsset(imageAsset.storageKey, {
|
||||||
files: {
|
files: {
|
||||||
[imageAsset.sourceName]: {
|
[imageAsset.sourceName]: {
|
||||||
bytes: cloneArrayBuffer(strToU8("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"1024\" height=\"512\"></svg>")),
|
bytes: cloneArrayBuffer(
|
||||||
|
strToU8(
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="512"></svg>'
|
||||||
|
)
|
||||||
|
),
|
||||||
mimeType: imageAsset.mimeType
|
mimeType: imageAsset.mimeType
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,7 +220,10 @@ describe("project package serialization", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const imageLoadListeners = new WeakMap<object, { load?: () => void; error?: () => void }>();
|
const imageLoadListeners = new WeakMap<
|
||||||
|
object,
|
||||||
|
{ load?: () => void; error?: () => void }
|
||||||
|
>();
|
||||||
const mockImageWidth = 1024;
|
const mockImageWidth = 1024;
|
||||||
const mockImageHeight = 512;
|
const mockImageHeight = 512;
|
||||||
|
|
||||||
@@ -247,18 +279,32 @@ describe("project package serialization", () => {
|
|||||||
|
|
||||||
const packageBytes = await saveProjectPackage(document, storage);
|
const packageBytes = await saveProjectPackage(document, storage);
|
||||||
const restoredStorage = createInMemoryProjectAssetStorage();
|
const restoredStorage = createInMemoryProjectAssetStorage();
|
||||||
const restoredDocument = await loadProjectPackage(packageBytes, restoredStorage);
|
const restoredDocument = await loadProjectPackage(
|
||||||
|
packageBytes,
|
||||||
|
restoredStorage
|
||||||
|
);
|
||||||
|
|
||||||
expect(restoredDocument).toEqual(document);
|
expect(restoredDocument).toEqual(document);
|
||||||
|
|
||||||
const restoredModel = await loadModelAssetFromStorage(restoredStorage, importedModel.asset);
|
const restoredModel = await loadModelAssetFromStorage(
|
||||||
const restoredImage = await loadImageAssetFromStorage(restoredStorage, imageAsset);
|
restoredStorage,
|
||||||
const restoredAudio = await loadAudioAssetFromStorage(restoredStorage, audioAsset);
|
importedModel.asset
|
||||||
|
);
|
||||||
|
const restoredImage = await loadImageAssetFromStorage(
|
||||||
|
restoredStorage,
|
||||||
|
imageAsset
|
||||||
|
);
|
||||||
|
const restoredAudio = await loadAudioAssetFromStorage(
|
||||||
|
restoredStorage,
|
||||||
|
audioAsset
|
||||||
|
);
|
||||||
|
|
||||||
expect(restoredModel.metadata.format).toBe("glb");
|
expect(restoredModel.metadata.format).toBe("glb");
|
||||||
expect(restoredModel.template.children.length).toBeGreaterThan(0);
|
expect(restoredModel.template.children.length).toBeGreaterThan(0);
|
||||||
expect(restoredImage.metadata.width).toBe(imageAsset.metadata.width);
|
expect(restoredImage.metadata.width).toBe(imageAsset.metadata.width);
|
||||||
expect(restoredAudio.metadata.durationSeconds).toBe(audioAsset.metadata.durationSeconds);
|
expect(restoredAudio.metadata.durationSeconds).toBe(
|
||||||
|
audioAsset.metadata.durationSeconds
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves multi-file gltf asset bundles inside the packaged assets directory", async () => {
|
it("preserves multi-file gltf asset bundles inside the packaged assets directory", async () => {
|
||||||
@@ -290,7 +336,10 @@ describe("project package serialization", () => {
|
|||||||
|
|
||||||
await loadProjectPackage(packageBytes, restoredStorage);
|
await loadProjectPackage(packageBytes, restoredStorage);
|
||||||
|
|
||||||
const restoredModel = await loadModelAssetFromStorage(restoredStorage, importedModel.asset);
|
const restoredModel = await loadModelAssetFromStorage(
|
||||||
|
restoredStorage,
|
||||||
|
importedModel.asset
|
||||||
|
);
|
||||||
|
|
||||||
expect(restoredModel.metadata.format).toBe("gltf");
|
expect(restoredModel.metadata.format).toBe("gltf");
|
||||||
expect(restoredModel.template.children.length).toBeGreaterThan(0);
|
expect(restoredModel.template.children.length).toBeGreaterThan(0);
|
||||||
@@ -320,7 +369,9 @@ describe("project package serialization", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(saveProjectPackage(document, storage)).rejects.toThrow("Missing stored binary data for image asset missing.png.");
|
await expect(saveProjectPackage(document, storage)).rejects.toThrow(
|
||||||
|
"Missing stored binary data for image asset missing.png."
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails project load when scene.json is missing", async () => {
|
it("fails project load when scene.json is missing", async () => {
|
||||||
@@ -328,7 +379,9 @@ describe("project package serialization", () => {
|
|||||||
"assets/readme.txt": strToU8("not a project")
|
"assets/readme.txt": strToU8("not a project")
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(loadProjectPackage(packageBytes, null)).rejects.toThrow("project package is missing scene.json");
|
await expect(loadProjectPackage(packageBytes, null)).rejects.toThrow(
|
||||||
|
"project package is missing scene.json"
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails project load when a declared asset has no packaged files", async () => {
|
it("fails project load when a declared asset has no packaged files", async () => {
|
||||||
@@ -357,7 +410,9 @@ describe("project package serialization", () => {
|
|||||||
[PROJECT_PACKAGE_SCENE_PATH]: strToU8(serializeProjectDocument(document))
|
[PROJECT_PACKAGE_SCENE_PATH]: strToU8(serializeProjectDocument(document))
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(loadProjectPackage(packageBytes, createInMemoryProjectAssetStorage())).rejects.toThrow(
|
await expect(
|
||||||
|
loadProjectPackage(packageBytes, createInMemoryProjectAssetStorage())
|
||||||
|
).rejects.toThrow(
|
||||||
"project package is missing bundled files for image asset missing.svg"
|
"project package is missing bundled files for image asset missing.svg"
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -366,8 +421,13 @@ describe("project package serialization", () => {
|
|||||||
const document = createProjectDocument(
|
const document = createProjectDocument(
|
||||||
createEmptySceneDocument({ name: "Portable Scene Without Storage" })
|
createEmptySceneDocument({ name: "Portable Scene Without Storage" })
|
||||||
);
|
);
|
||||||
const packageBytes = await saveProjectPackage(document, createInMemoryProjectAssetStorage());
|
const packageBytes = await saveProjectPackage(
|
||||||
|
document,
|
||||||
|
createInMemoryProjectAssetStorage()
|
||||||
|
);
|
||||||
|
|
||||||
await expect(loadProjectPackage(packageBytes, null as ProjectAssetStorage | null)).resolves.toEqual(document);
|
await expect(
|
||||||
|
loadProjectPackage(packageBytes, null as ProjectAssetStorage | null)
|
||||||
|
).resolves.toEqual(document);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ describe("RunnerCanvas", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("only shows the underwater overlay when the camera is submerged", async () => {
|
it("only shows the underwater overlay when the camera is submerged", async () => {
|
||||||
const runtimeScene = buildRuntimeSceneFromDocument(createEmptySceneDocument());
|
const runtimeScene = buildRuntimeSceneFromDocument(
|
||||||
|
createEmptySceneDocument()
|
||||||
|
);
|
||||||
const onTelemetryChange = vi.fn();
|
const onTelemetryChange = vi.fn();
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@@ -81,12 +83,20 @@ describe("RunnerCanvas", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(runtimeHostInstances).toHaveLength(1);
|
expect(runtimeHostInstances).toHaveLength(1);
|
||||||
expect(runtimeHostInstances[0]?.setFirstPersonTelemetryHandler).toHaveBeenCalledTimes(1);
|
expect(
|
||||||
expect(runtimeHostInstances[0]?.setSceneLoadStateHandler).toHaveBeenCalledTimes(1);
|
runtimeHostInstances[0]?.setFirstPersonTelemetryHandler
|
||||||
|
).toHaveBeenCalledTimes(1);
|
||||||
|
expect(
|
||||||
|
runtimeHostInstances[0]?.setSceneLoadStateHandler
|
||||||
|
).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
const publishTelemetry = runtimeHostInstances[0]?.setFirstPersonTelemetryHandler.mock.calls[0]?.[0] as ((telemetry: FirstPersonTelemetry | null) => void) | undefined;
|
const publishTelemetry = runtimeHostInstances[0]
|
||||||
const publishSceneLoadState = runtimeHostInstances[0]?.setSceneLoadStateHandler.mock.calls[0]?.[0] as
|
?.setFirstPersonTelemetryHandler.mock.calls[0]?.[0] as
|
||||||
|
| ((telemetry: FirstPersonTelemetry | null) => void)
|
||||||
|
| undefined;
|
||||||
|
const publishSceneLoadState = runtimeHostInstances[0]
|
||||||
|
?.setSceneLoadStateHandler.mock.calls[0]?.[0] as
|
||||||
| ((state: RuntimeSceneLoadState) => void)
|
| ((state: RuntimeSceneLoadState) => void)
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
@@ -114,7 +124,9 @@ describe("RunnerCanvas", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.queryByLabelText("Built-in scene runner")?.className).not.toContain("runner-canvas--underwater");
|
expect(
|
||||||
|
screen.queryByLabelText("Built-in scene runner")?.className
|
||||||
|
).not.toContain("runner-canvas--underwater");
|
||||||
expect(document.querySelector(".runner-canvas__underwater")).toBeNull();
|
expect(document.querySelector(".runner-canvas__underwater")).toBeNull();
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -131,12 +143,16 @@ describe("RunnerCanvas", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByLabelText("Built-in scene runner").className).toContain("runner-canvas--underwater");
|
expect(screen.getByLabelText("Built-in scene runner").className).toContain(
|
||||||
|
"runner-canvas--underwater"
|
||||||
|
);
|
||||||
expect(document.querySelector(".runner-canvas__underwater")).not.toBeNull();
|
expect(document.querySelector(".runner-canvas__underwater")).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the loading overlay until the runtime host reports readiness", async () => {
|
it("shows the loading overlay until the runtime host reports readiness", async () => {
|
||||||
const runtimeScene = buildRuntimeSceneFromDocument(createEmptySceneDocument());
|
const runtimeScene = buildRuntimeSceneFromDocument(
|
||||||
|
createEmptySceneDocument()
|
||||||
|
);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<RunnerCanvas
|
<RunnerCanvas
|
||||||
@@ -160,28 +176,31 @@ describe("RunnerCanvas", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(runtimeHostInstances).toHaveLength(1);
|
expect(runtimeHostInstances).toHaveLength(1);
|
||||||
expect(runtimeHostInstances[0]?.setSceneLoadStateHandler).toHaveBeenCalledTimes(1);
|
expect(
|
||||||
|
runtimeHostInstances[0]?.setSceneLoadStateHandler
|
||||||
|
).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByTestId("runner-loading-overlay").className).not.toContain(
|
expect(
|
||||||
"runner-canvas__loading-overlay--hidden"
|
screen.getByTestId("runner-loading-overlay").className
|
||||||
);
|
).not.toContain("runner-canvas__loading-overlay--hidden");
|
||||||
expect(screen.getByTestId("runner-loading-scene-name")).toHaveTextContent(
|
expect(screen.getByTestId("runner-loading-scene-name")).toHaveTextContent(
|
||||||
"Dungeon Entry"
|
"Dungeon Entry"
|
||||||
);
|
);
|
||||||
expect(screen.getByTestId("runner-loading-headline")).toHaveTextContent(
|
expect(screen.getByTestId("runner-loading-headline")).toHaveTextContent(
|
||||||
"Preparing encounter"
|
"Preparing encounter"
|
||||||
);
|
);
|
||||||
expect(
|
expect(screen.getByTestId("runner-loading-description")).toHaveTextContent(
|
||||||
screen.getByTestId("runner-loading-description")
|
"Enemies and triggers are being wired up."
|
||||||
).toHaveTextContent("Enemies and triggers are being wired up.");
|
);
|
||||||
expect(document.querySelector(".runner-canvas__crosshair")).toBeNull();
|
expect(document.querySelector(".runner-canvas__crosshair")).toBeNull();
|
||||||
expect(screen.getByTestId("runner-shell")).toHaveAttribute(
|
expect(screen.getByTestId("runner-shell")).toHaveAttribute(
|
||||||
"aria-busy",
|
"aria-busy",
|
||||||
"true"
|
"true"
|
||||||
);
|
);
|
||||||
|
|
||||||
const publishSceneLoadState = runtimeHostInstances[0]?.setSceneLoadStateHandler.mock.calls[0]?.[0] as
|
const publishSceneLoadState = runtimeHostInstances[0]
|
||||||
|
?.setSceneLoadStateHandler.mock.calls[0]?.[0] as
|
||||||
| ((state: RuntimeSceneLoadState) => void)
|
| ((state: RuntimeSceneLoadState) => void)
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
@@ -205,7 +224,9 @@ describe("RunnerCanvas", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the overlay visible and shows load errors from the runtime host", async () => {
|
it("keeps the overlay visible and shows load errors from the runtime host", async () => {
|
||||||
const runtimeScene = buildRuntimeSceneFromDocument(createEmptySceneDocument());
|
const runtimeScene = buildRuntimeSceneFromDocument(
|
||||||
|
createEmptySceneDocument()
|
||||||
|
);
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<RunnerCanvas
|
<RunnerCanvas
|
||||||
@@ -224,10 +245,13 @@ describe("RunnerCanvas", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(runtimeHostInstances[0]?.setSceneLoadStateHandler).toHaveBeenCalledTimes(1);
|
expect(
|
||||||
|
runtimeHostInstances[0]?.setSceneLoadStateHandler
|
||||||
|
).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
const publishSceneLoadState = runtimeHostInstances[0]?.setSceneLoadStateHandler.mock.calls[0]?.[0] as
|
const publishSceneLoadState = runtimeHostInstances[0]
|
||||||
|
?.setSceneLoadStateHandler.mock.calls[0]?.[0] as
|
||||||
| ((state: RuntimeSceneLoadState) => void)
|
| ((state: RuntimeSceneLoadState) => void)
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
@@ -238,9 +262,9 @@ describe("RunnerCanvas", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.getByTestId("runner-loading-overlay").className).not.toContain(
|
expect(
|
||||||
"runner-canvas__loading-overlay--hidden"
|
screen.getByTestId("runner-loading-overlay").className
|
||||||
);
|
).not.toContain("runner-canvas__loading-overlay--hidden");
|
||||||
expect(screen.getByTestId("runner-loading-error")).toHaveTextContent(
|
expect(screen.getByTestId("runner-loading-error")).toHaveTextContent(
|
||||||
"Runner scene failed to load: collision bootstrap exploded."
|
"Runner scene failed to load: collision bootstrap exploded."
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ describe("RuntimeHost", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("delays controller activation until collision setup reports the scene as ready", async () => {
|
it("delays controller activation until collision setup reports the scene as ready", async () => {
|
||||||
const runtimeScene = buildRuntimeSceneFromDocument(createEmptySceneDocument());
|
const runtimeScene = buildRuntimeSceneFromDocument(
|
||||||
|
createEmptySceneDocument()
|
||||||
|
);
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||||
const collisionWorld = {
|
const collisionWorld = {
|
||||||
dispose: vi.fn()
|
dispose: vi.fn()
|
||||||
|
|||||||
Reference in New Issue
Block a user