auto-git:

[add] playwright.config.js
 [add] src/app/App.js
 [add] src/app/editor-store.js
 [add] src/app/use-editor-store.js
 [add] src/assets/audio-assets.js
 [add] src/assets/gltf-model-import.js
 [add] src/assets/image-assets.js
 [add] src/assets/model-instance-labels.js
 [add] src/assets/model-instance-rendering.js
 [add] src/assets/model-instances.js
 [add] src/assets/project-asset-storage.js
 [add] src/assets/project-assets.js
 [add] src/commands/brush-command-helpers.js
 [add] src/commands/command-history.js
 [add] src/commands/command.js
 [add] src/commands/commit-transform-session-command.js
 [add] src/commands/create-box-brush-command.js
 [add] src/commands/delete-box-brush-command.js
 [add] src/commands/delete-entity-command.js
 [add] src/commands/delete-interaction-link-command.js
 [add] src/commands/delete-model-instance-command.js
 [add] src/commands/import-audio-asset-command.js
 [add] src/commands/import-background-image-asset-command.js
 [add] src/commands/import-model-asset-command.js
 [add] src/commands/move-box-brush-command.js
 [add] src/commands/resize-box-brush-command.js
 [add] src/commands/rotate-box-brush-command.js
 [add] src/commands/set-box-brush-face-material-command.js
 [add] src/commands/set-box-brush-face-uv-state-command.js
 [add] src/commands/set-box-brush-name-command.js
 [add] src/commands/set-box-brush-transform-command.js
 [add] src/commands/set-entity-name-command.js
 [add] src/commands/set-model-instance-name-command.js
 [add] src/commands/set-player-start-command.js
 [add] src/commands/set-scene-name-command.js
 [add] src/commands/set-world-settings-command.js
 [add] src/commands/upsert-entity-command.js
 [add] src/commands/upsert-interaction-link-command.js
 [add] src/commands/upsert-model-instance-command.js
 [add] src/core/ids.js
 [add] src/core/selection.js
 [add] src/core/tool-mode.js
 [add] src/core/transform-session.js
 [add] src/core/vector.js
 [add] src/core/whitebox-selection-feedback.js
 [add] src/core/whitebox-selection-mode.js
 [add] src/document/brushes.js
 [add] src/document/migrate-scene-document.js
 [add] src/document/scene-document-validation.js
 [add] src/document/scene-document.js
 [add] src/document/world-settings.js
 [add] src/entities/entity-instances.js
 [add] src/entities/entity-labels.js
 [add] src/geometry/box-brush-components.js
 [add] src/geometry/box-brush-mesh.js
 [add] src/geometry/box-brush.js
 [add] src/geometry/box-face-uvs.js
 [add] src/geometry/grid-snapping.js
 [add] src/geometry/model-instance-collider-debug-mesh.js
 [add] src/geometry/model-instance-collider-generation.js
 [add] src/interactions/interaction-links.js
 [add] src/main.js
 [add] src/materials/starter-material-library.js
 [add] src/materials/starter-material-textures.js
 [add] src/rendering/advanced-rendering.js
 [add] src/runner-web/RunnerCanvas.js
 [add] src/runtime-three/first-person-navigation-controller.js
 [add] src/runtime-three/navigation-controller.js
 [add] src/runtime-three/orbit-visitor-navigation-controller.js
 [add] src/runtime-three/player-collision.js
 [add] src/runtime-three/rapier-collision-world.js
 [add] src/runtime-three/runtime-audio-system.js
 [add] src/runtime-three/runtime-host.js
 [add] src/runtime-three/runtime-interaction-system.js
 [add] src/runtime-three/runtime-scene-build.js
 [add] src/runtime-three/runtime-scene-validation.js
 [add] src/serialization/local-draft-storage.js
 [add] src/serialization/scene-document-json.js
 [add] src/shared-ui/HierarchicalMenu.js
 [add] src/shared-ui/Panel.js
 [add] src/shared-ui/world-background-style.js
 [add] src/viewport-three/ViewportCanvas.js
 [add] src/viewport-three/ViewportPanel.js
 [add] src/viewport-three/viewport-entity-markers.js
 [add] src/viewport-three/viewport-focus.js
 [add] src/viewport-three/viewport-host.js
 [add] src/viewport-three/viewport-layout.js
 [add] src/viewport-three/viewport-transient-state.js
 [add] src/viewport-three/viewport-view-modes.js
 [add] tests/domain/box-brush-face-editing.command.test.js
 [add] tests/domain/build-runtime-scene.test.js
 [add] tests/domain/create-box-brush.command.test.js
 [add] tests/domain/create-empty-scene-document.test.js
 [add] tests/domain/editor-store.test.js
 [add] tests/domain/entity.command.test.js
 [add] tests/domain/interaction-links.validation.test.js
 [add] tests/domain/model-import.test.js
 [add] tests/domain/model-instance.command.test.js
 [add] tests/domain/player-start.command.test.js
 [add] tests/domain/rapier-collision-world.test.js
 [add] tests/domain/runtime-audio-system.test.js
 [add] tests/domain/runtime-interaction-system.test.js
 [add] tests/domain/runtime-scene-validation.test.js
 [add] tests/domain/scene-document-validation.test.js
 [add] tests/domain/transform-session.command.test.js
 [add] tests/domain/world-settings.command.test.js
 [add] tests/domain/world-settings.test.js
 [add] tests/e2e/app-smoke.e2e.js
 [add] tests/e2e/box-brush-authoring.e2e.js
 [add] tests/e2e/entities-foundation.e2e.js
 [add] tests/e2e/face-material-authoring.e2e.js
 [add] tests/e2e/first-room-workflow.e2e.js
 [add] tests/e2e/import-draco-model-asset.e2e.js
 [add] tests/e2e/import-external-model-asset.e2e.js
 [add] tests/e2e/import-model-asset.e2e.js
 [add] tests/e2e/local-lights-and-background.e2e.js
 [add] tests/e2e/orthographic-views.e2e.js
 [add] tests/e2e/runner-v1.e2e.js
 [add] tests/e2e/runtime-click-interaction.e2e.js
 [add] tests/e2e/runtime-trigger-teleport.e2e.js
 [add] tests/e2e/viewport-quad-layout.e2e.js
 [add] tests/e2e/viewport-test-helpers.js
 [add] tests/e2e/whitebox-component-selection.e2e.js
 [add] tests/e2e/world-environment.e2e.js
 [add] tests/geometry/box-brush-geometry.test.js
 [add] tests/geometry/box-face-uvs.test.js
 [add] tests/geometry/model-instance-collider-generation.test.js
 [add] tests/helpers/model-collider-fixtures.js
 [add] tests/serialization/local-draft-storage.test.js
 [add] tests/serialization/project-asset-storage.test.js
 [add] tests/serialization/scene-document-json.test.js
 [add] tests/setup/vitest.setup.js
 [add] tests/unit/audio-assets.test.js
 [add] tests/unit/entity-instances.test.js
 [add] tests/unit/package-scripts.test.js
 [add] tests/unit/transform-foundation.integration.test.js
 [add] tests/unit/viewport-canvas.test.js
 [add] tests/unit/viewport-entity-markers.test.js
 [add] tests/unit/viewport-focus.test.js
 [add] tests/unit/viewport-layout.test.js
 [add] tests/unit/viewport-view-modes.test.js
 [add] vite.config.js
 [add] vitest.config.js
This commit is contained in:
2026-04-05 02:25:08 +02:00
parent b23cb0dd32
commit 0b54fa14c9
143 changed files with 26849 additions and 0 deletions

View File

@@ -0,0 +1,203 @@
import { Euler, Vector3 } from "three";
import { getFirstPersonPlayerEyeHeight } from "./player-collision";
const LOOK_SENSITIVITY = 0.0022;
const MOVE_SPEED = 4.5;
const GRAVITY = 22;
const MAX_PITCH_RADIANS = Math.PI * 0.48;
function clampPitch(pitchRadians) {
return Math.max(-MAX_PITCH_RADIANS, Math.min(MAX_PITCH_RADIANS, pitchRadians));
}
function toEyePosition(feetPosition, eyeHeight) {
return {
x: feetPosition.x,
y: feetPosition.y + eyeHeight,
z: feetPosition.z
};
}
export class FirstPersonNavigationController {
id = "firstPerson";
context = null;
pressedKeys = new Set();
cameraRotation = new Euler(0, 0, 0, "YXZ");
forwardVector = new Vector3();
rightVector = new Vector3();
feetPosition = {
x: 0,
y: 0,
z: 0
};
yawRadians = 0;
pitchRadians = 0;
verticalVelocity = 0;
grounded = false;
pointerLocked = false;
initializedFromSpawn = false;
activate(ctx) {
this.context = ctx;
if (!this.initializedFromSpawn) {
const spawn = ctx.getRuntimeScene().spawn;
this.feetPosition = {
...spawn.position
};
this.yawRadians = (spawn.yawDegrees * Math.PI) / 180;
this.pitchRadians = 0;
this.verticalVelocity = 0;
this.grounded = false;
this.initializedFromSpawn = true;
}
window.addEventListener("keydown", this.handleKeyDown);
window.addEventListener("keyup", this.handleKeyUp);
window.addEventListener("blur", this.handleBlur);
document.addEventListener("mousemove", this.handleMouseMove);
document.addEventListener("pointerlockchange", this.handlePointerLockChange);
document.addEventListener("pointerlockerror", this.handlePointerLockError);
ctx.domElement.addEventListener("pointerdown", this.handlePointerDown);
this.syncPointerLockState();
this.updateCameraTransform();
this.publishTelemetry();
}
deactivate(ctx) {
window.removeEventListener("keydown", this.handleKeyDown);
window.removeEventListener("keyup", this.handleKeyUp);
window.removeEventListener("blur", this.handleBlur);
document.removeEventListener("mousemove", this.handleMouseMove);
document.removeEventListener("pointerlockchange", this.handlePointerLockChange);
document.removeEventListener("pointerlockerror", this.handlePointerLockError);
ctx.domElement.removeEventListener("pointerdown", this.handlePointerDown);
this.pressedKeys.clear();
if (document.pointerLockElement === ctx.domElement) {
document.exitPointerLock();
}
this.pointerLocked = false;
ctx.setRuntimeMessage(null);
ctx.setFirstPersonTelemetry(null);
this.context = null;
}
update(dt) {
if (this.context === null) {
return;
}
const playerShape = this.context.getRuntimeScene().playerCollider;
const inputX = (this.pressedKeys.has("KeyD") ? 1 : 0) - (this.pressedKeys.has("KeyA") ? 1 : 0);
const inputZ = (this.pressedKeys.has("KeyW") ? 1 : 0) - (this.pressedKeys.has("KeyS") ? 1 : 0);
const inputLength = Math.hypot(inputX, inputZ);
let horizontalX = 0;
let horizontalZ = 0;
if (inputLength > 0) {
const normalizedInputX = inputX / inputLength;
const normalizedInputZ = inputZ / inputLength;
const moveDistance = MOVE_SPEED * dt;
this.forwardVector.set(Math.sin(this.yawRadians), 0, Math.cos(this.yawRadians));
this.rightVector.set(-Math.cos(this.yawRadians), 0, Math.sin(this.yawRadians));
horizontalX = (this.forwardVector.x * normalizedInputZ + this.rightVector.x * normalizedInputX) * moveDistance;
horizontalZ = (this.forwardVector.z * normalizedInputZ + this.rightVector.z * normalizedInputX) * moveDistance;
}
if (playerShape.mode === "none") {
this.verticalVelocity = 0;
}
else {
this.verticalVelocity -= GRAVITY * dt;
}
const resolvedMotion = this.context.resolveFirstPersonMotion(this.feetPosition, {
x: horizontalX,
y: playerShape.mode === "none" ? 0 : this.verticalVelocity * dt,
z: horizontalZ
}, playerShape);
if (resolvedMotion === null) {
this.updateCameraTransform();
this.publishTelemetry();
return;
}
this.feetPosition = resolvedMotion.feetPosition;
this.grounded = resolvedMotion.grounded;
if (this.grounded && this.verticalVelocity < 0) {
this.verticalVelocity = 0;
}
this.updateCameraTransform();
this.publishTelemetry();
}
teleportTo(feetPosition, yawDegrees) {
this.feetPosition = {
...feetPosition
};
this.yawRadians = (yawDegrees * Math.PI) / 180;
this.pitchRadians = 0;
this.verticalVelocity = 0;
this.grounded = false;
this.updateCameraTransform();
this.publishTelemetry();
}
updateCameraTransform() {
if (this.context === null) {
return;
}
const eyePosition = toEyePosition(this.feetPosition, getFirstPersonPlayerEyeHeight(this.context.getRuntimeScene().playerCollider));
this.cameraRotation.x = this.pitchRadians;
// Authoring yaw treats 0 degrees as facing +Z, while a three.js camera
// looks down -Z by default. Offset by 180 degrees so runtime view matches
// the authored PlayerStart marker and movement basis.
this.cameraRotation.y = this.yawRadians + Math.PI;
this.cameraRotation.z = 0;
this.context.camera.position.set(eyePosition.x, eyePosition.y, eyePosition.z);
this.context.camera.rotation.copy(this.cameraRotation);
}
publishTelemetry() {
if (this.context === null) {
return;
}
this.context.setFirstPersonTelemetry({
feetPosition: {
...this.feetPosition
},
eyePosition: toEyePosition(this.feetPosition, getFirstPersonPlayerEyeHeight(this.context.getRuntimeScene().playerCollider)),
grounded: this.grounded,
pointerLocked: this.pointerLocked,
spawn: this.context.getRuntimeScene().spawn
});
}
syncPointerLockState() {
if (this.context === null) {
return;
}
const pointerLocked = document.pointerLockElement === this.context.domElement;
this.pointerLocked = pointerLocked;
this.context.setRuntimeMessage(pointerLocked
? "Mouse look active. Press Escape to release the cursor or switch to Orbit Visitor."
: "Click inside the runner viewport to capture mouse look. If pointer lock fails, switch to Orbit Visitor.");
this.publishTelemetry();
}
handleKeyDown = (event) => {
this.pressedKeys.add(event.code);
};
handleKeyUp = (event) => {
this.pressedKeys.delete(event.code);
};
handleBlur = () => {
this.pressedKeys.clear();
};
handleMouseMove = (event) => {
if (!this.pointerLocked) {
return;
}
this.yawRadians -= event.movementX * LOOK_SENSITIVITY;
this.pitchRadians = clampPitch(this.pitchRadians - event.movementY * LOOK_SENSITIVITY);
};
handlePointerLockChange = () => {
this.syncPointerLockState();
};
handlePointerLockError = () => {
this.context?.setRuntimeMessage("Pointer lock was unavailable in this browser context. Orbit Visitor remains available as the non-FPS fallback.");
};
handlePointerDown = () => {
if (this.context === null || document.pointerLockElement === this.context.domElement) {
return;
}
const pointerLockCapableElement = this.context.domElement;
const pointerLockResult = pointerLockCapableElement.requestPointerLock();
if (pointerLockResult instanceof Promise) {
pointerLockResult.catch(() => {
this.context?.setRuntimeMessage("Pointer lock request was denied. Click again or use Orbit Visitor for non-locked navigation.");
});
}
};
}

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,117 @@
import { Vector3 } from "three";
const MIN_DISTANCE = 2;
const MAX_DISTANCE = 48;
const MIN_PITCH = 0.15;
const MAX_PITCH = Math.PI * 0.48;
function clampDistance(distance) {
return Math.max(MIN_DISTANCE, Math.min(MAX_DISTANCE, distance));
}
function clampPitch(pitchRadians) {
return Math.max(MIN_PITCH, Math.min(MAX_PITCH, pitchRadians));
}
function cloneVec3(vector) {
return {
x: vector.x,
y: vector.y,
z: vector.z
};
}
export class OrbitVisitorNavigationController {
id = "orbitVisitor";
context = null;
lookAtVector = new Vector3();
target = {
x: 0,
y: 0,
z: 0
};
distance = 8;
yawRadians = Math.PI * 0.25;
pitchRadians = Math.PI * 0.35;
dragging = false;
lastPointerClientX = 0;
lastPointerClientY = 0;
initializedFromScene = false;
activate(ctx) {
this.context = ctx;
if (!this.initializedFromScene) {
const runtimeScene = ctx.getRuntimeScene();
const focusPoint = runtimeScene.playerStart?.position ?? runtimeScene.sceneBounds?.center ?? this.target;
const focusDistance = runtimeScene.sceneBounds
? Math.max(runtimeScene.sceneBounds.size.x, runtimeScene.sceneBounds.size.y, runtimeScene.sceneBounds.size.z) * 1.1
: 8;
this.target = cloneVec3(focusPoint);
this.distance = clampDistance(focusDistance);
this.initializedFromScene = true;
}
ctx.domElement.addEventListener("pointerdown", this.handlePointerDown);
ctx.domElement.addEventListener("wheel", this.handleWheel, { passive: false });
ctx.domElement.addEventListener("contextmenu", this.handleContextMenu);
window.addEventListener("pointermove", this.handlePointerMove);
window.addEventListener("pointerup", this.handlePointerUp);
ctx.setRuntimeMessage("Orbit Visitor active. Drag to orbit around the scene and use the mouse wheel to zoom.");
ctx.setFirstPersonTelemetry(null);
this.updateCameraTransform();
}
deactivate(ctx) {
ctx.domElement.removeEventListener("pointerdown", this.handlePointerDown);
ctx.domElement.removeEventListener("wheel", this.handleWheel);
ctx.domElement.removeEventListener("contextmenu", this.handleContextMenu);
window.removeEventListener("pointermove", this.handlePointerMove);
window.removeEventListener("pointerup", this.handlePointerUp);
ctx.setRuntimeMessage(null);
this.dragging = false;
this.context = null;
}
update(_dt) {
void _dt;
this.updateCameraTransform();
}
setFocusPoint(target) {
this.target = cloneVec3(target);
this.updateCameraTransform();
}
updateCameraTransform() {
if (this.context === null) {
return;
}
const horizontalDistance = Math.cos(this.pitchRadians) * this.distance;
const cameraPosition = {
x: this.target.x + Math.sin(this.yawRadians) * horizontalDistance,
y: this.target.y + Math.sin(this.pitchRadians) * this.distance,
z: this.target.z + Math.cos(this.yawRadians) * horizontalDistance
};
this.context.camera.position.set(cameraPosition.x, cameraPosition.y, cameraPosition.z);
this.lookAtVector.set(this.target.x, this.target.y, this.target.z);
this.context.camera.lookAt(this.lookAtVector);
}
handlePointerDown = (event) => {
if (event.button !== 0) {
return;
}
this.dragging = true;
this.lastPointerClientX = event.clientX;
this.lastPointerClientY = event.clientY;
};
handlePointerMove = (event) => {
if (!this.dragging) {
return;
}
const deltaX = event.clientX - this.lastPointerClientX;
const deltaY = event.clientY - this.lastPointerClientY;
this.lastPointerClientX = event.clientX;
this.lastPointerClientY = event.clientY;
this.yawRadians -= deltaX * 0.008;
this.pitchRadians = clampPitch(this.pitchRadians + deltaY * 0.008);
};
handlePointerUp = () => {
this.dragging = false;
};
handleWheel = (event) => {
event.preventDefault();
this.distance = clampDistance(this.distance + event.deltaY * 0.01);
};
handleContextMenu = (event) => {
event.preventDefault();
};
}

View File

@@ -0,0 +1,19 @@
export const FIRST_PERSON_PLAYER_SHAPE = {
mode: "capsule",
radius: 0.3,
height: 1.8,
eyeHeight: 1.6
};
export function getFirstPersonPlayerEyeHeight(shape) {
return shape.eyeHeight;
}
export function getFirstPersonPlayerHeight(shape) {
switch (shape.mode) {
case "capsule":
return shape.height;
case "box":
return shape.size.y;
case "none":
return null;
}
}

View File

@@ -0,0 +1,272 @@
import RAPIER from "@dimforge/rapier3d-compat";
import { Euler, MathUtils, Quaternion } from "three";
const CHARACTER_CONTROLLER_OFFSET = 0.01;
const COLLISION_EPSILON = 1e-5;
let rapierInitPromise = null;
function componentScale(vector, scale) {
return {
x: vector.x * scale.x,
y: vector.y * scale.y,
z: vector.z * scale.z
};
}
function createRapierQuaternion(rotationDegrees) {
const quaternion = new Quaternion().setFromEuler(new Euler(MathUtils.degToRad(rotationDegrees.x), MathUtils.degToRad(rotationDegrees.y), MathUtils.degToRad(rotationDegrees.z), "XYZ"));
return {
x: quaternion.x,
y: quaternion.y,
z: quaternion.z,
w: quaternion.w
};
}
function scaleVertices(vertices, scale) {
const scaledVertices = new Float32Array(vertices.length);
for (let index = 0; index < vertices.length; index += 3) {
scaledVertices[index] = vertices[index] * scale.x;
scaledVertices[index + 1] = vertices[index + 1] * scale.y;
scaledVertices[index + 2] = vertices[index + 2] * scale.z;
}
return scaledVertices;
}
function scaleBoundsCenter(bounds, scale) {
return {
x: ((bounds.min.x + bounds.max.x) * 0.5) * scale.x,
y: ((bounds.min.y + bounds.max.y) * 0.5) * scale.y,
z: ((bounds.min.z + bounds.max.z) * 0.5) * scale.z
};
}
function createRapierHeightfieldHeights(collider) {
const heights = new Float32Array(collider.heights.length);
// Rapier's heightfield samples are column-major, with the Z axis varying
// fastest inside each X column. Our generated collider stores X-major rows
// for easier editor/debug mesh reconstruction, so transpose here.
for (let zIndex = 0; zIndex < collider.cols; zIndex += 1) {
for (let xIndex = 0; xIndex < collider.rows; xIndex += 1) {
heights[zIndex + xIndex * collider.cols] = collider.heights[xIndex + zIndex * collider.rows];
}
}
return heights;
}
function createFixedBodyForModelCollider(world, collider) {
return world.createRigidBody(RAPIER.RigidBodyDesc.fixed()
.setTranslation(collider.transform.position.x, collider.transform.position.y, collider.transform.position.z)
.setRotation(createRapierQuaternion(collider.transform.rotationDegrees)));
}
function attachBrushCollider(world, collider) {
const body = world.createRigidBody(RAPIER.RigidBodyDesc.fixed()
.setTranslation(collider.center.x, collider.center.y, collider.center.z)
.setRotation(createRapierQuaternion(collider.rotationDegrees)));
const halfExtents = {
x: collider.size.x * 0.5,
y: collider.size.y * 0.5,
z: collider.size.z * 0.5
};
world.createCollider(RAPIER.ColliderDesc.cuboid(halfExtents.x, halfExtents.y, halfExtents.z), body);
}
function attachSimpleModelCollider(world, collider) {
const body = createFixedBodyForModelCollider(world, collider);
const scaledCenter = componentScale(collider.center, collider.transform.scale);
const scaledHalfExtents = componentScale({
x: collider.size.x * 0.5,
y: collider.size.y * 0.5,
z: collider.size.z * 0.5
}, collider.transform.scale);
world.createCollider(RAPIER.ColliderDesc.cuboid(scaledHalfExtents.x, scaledHalfExtents.y, scaledHalfExtents.z).setTranslation(scaledCenter.x, scaledCenter.y, scaledCenter.z), body);
}
function attachStaticModelCollider(world, collider) {
const body = createFixedBodyForModelCollider(world, collider);
world.createCollider(RAPIER.ColliderDesc.trimesh(scaleVertices(collider.vertices, collider.transform.scale), collider.indices), body);
}
function attachTerrainModelCollider(world, collider) {
if (collider.rows < 2 || collider.cols < 2) {
throw new Error(`Terrain collider ${collider.instanceId} must have at least a 2x2 height sample grid.`);
}
const body = createFixedBodyForModelCollider(world, collider);
const center = scaleBoundsCenter({
min: {
x: collider.minX,
y: 0,
z: collider.minZ
},
max: {
x: collider.maxX,
y: 0,
z: collider.maxZ
}
}, collider.transform.scale);
const rowSubdivisions = collider.rows - 1;
const colSubdivisions = collider.cols - 1;
world.createCollider(
// Rapier expects the number of grid subdivisions here, while our generated
// collider stores the sampled height grid dimensions.
RAPIER.ColliderDesc.heightfield(rowSubdivisions, colSubdivisions, createRapierHeightfieldHeights(collider), {
x: (collider.maxX - collider.minX) * collider.transform.scale.x,
y: collider.transform.scale.y,
z: (collider.maxZ - collider.minZ) * collider.transform.scale.z
}).setTranslation(center.x, center.y, center.z), body);
}
function attachDynamicModelCollider(world, collider) {
const body = createFixedBodyForModelCollider(world, collider);
for (const piece of collider.pieces) {
const scaledPoints = scaleVertices(piece.points, collider.transform.scale);
const descriptor = RAPIER.ColliderDesc.convexHull(scaledPoints);
if (descriptor === null) {
throw new Error(`Dynamic collider piece ${piece.id} could not form a valid convex hull.`);
}
world.createCollider(descriptor, body);
}
}
function attachModelCollider(world, collider) {
switch (collider.kind) {
case "box":
attachSimpleModelCollider(world, collider);
break;
case "trimesh":
attachStaticModelCollider(world, collider);
break;
case "heightfield":
attachTerrainModelCollider(world, collider);
break;
case "compound":
attachDynamicModelCollider(world, collider);
break;
}
}
function feetPositionToColliderCenter(feetPosition, shape) {
switch (shape.mode) {
case "capsule": {
const cylindricalHalfHeight = Math.max(0, (shape.height - shape.radius * 2) * 0.5);
return {
x: feetPosition.x,
y: feetPosition.y + shape.radius + cylindricalHalfHeight,
z: feetPosition.z
};
}
case "box":
return {
x: feetPosition.x,
y: feetPosition.y + shape.size.y * 0.5,
z: feetPosition.z
};
case "none":
return {
...feetPosition
};
}
}
function colliderCenterToFeetPosition(center, shape) {
switch (shape.mode) {
case "capsule": {
const cylindricalHalfHeight = Math.max(0, (shape.height - shape.radius * 2) * 0.5);
return {
x: center.x,
y: center.y - (shape.radius + cylindricalHalfHeight),
z: center.z
};
}
case "box":
return {
x: center.x,
y: center.y - shape.size.y * 0.5,
z: center.z
};
case "none":
return {
...center
};
}
}
function createPlayerCollider(world, rapier, playerShape) {
switch (playerShape.mode) {
case "capsule":
return world.createCollider(rapier.ColliderDesc.capsule(Math.max(0, (playerShape.height - playerShape.radius * 2) * 0.5), playerShape.radius));
case "box":
return world.createCollider(rapier.ColliderDesc.cuboid(playerShape.size.x * 0.5, playerShape.size.y * 0.5, playerShape.size.z * 0.5));
case "none":
return null;
}
}
export async function initializeRapierCollisionWorld() {
rapierInitPromise ??= RAPIER.init().then(() => RAPIER);
return rapierInitPromise;
}
export class RapierCollisionWorld {
world;
characterController;
playerCollider;
static async create(colliders, playerShape) {
const rapier = await initializeRapierCollisionWorld();
const world = new rapier.World({
x: 0,
y: 0,
z: 0
});
for (const collider of colliders) {
if (collider.source === "brush") {
attachBrushCollider(world, collider);
continue;
}
attachModelCollider(world, collider);
}
const playerCollider = createPlayerCollider(world, rapier, playerShape);
const characterController = playerCollider === null ? null : world.createCharacterController(CHARACTER_CONTROLLER_OFFSET);
if (characterController !== null) {
characterController.setUp({ x: 0, y: 1, z: 0 });
characterController.setSlideEnabled(true);
characterController.enableSnapToGround(0.2);
characterController.enableAutostep(0.35, 0.15, false);
characterController.setMaxSlopeClimbAngle(Math.PI * 0.45);
characterController.setMinSlopeSlideAngle(Math.PI * 0.5);
}
world.step();
return new RapierCollisionWorld(world, characterController, playerCollider);
}
constructor(world, characterController, playerCollider) {
this.world = world;
this.characterController = characterController;
this.playerCollider = playerCollider;
}
resolveFirstPersonMotion(feetPosition, motion, shape) {
if (this.playerCollider === null || this.characterController === null || shape.mode === "none") {
return {
feetPosition: {
x: feetPosition.x + motion.x,
y: feetPosition.y + motion.y,
z: feetPosition.z + motion.z
},
grounded: false,
collidedAxes: {
x: false,
y: false,
z: false
}
};
}
const currentCenter = feetPositionToColliderCenter(feetPosition, shape);
this.playerCollider.setTranslation(currentCenter);
this.characterController.computeColliderMovement(this.playerCollider, motion);
const correctedMovement = this.characterController.computedMovement();
const collidedAxes = {
x: Math.abs(correctedMovement.x - motion.x) > COLLISION_EPSILON,
y: Math.abs(correctedMovement.y - motion.y) > COLLISION_EPSILON,
z: Math.abs(correctedMovement.z - motion.z) > COLLISION_EPSILON
};
const nextCenter = {
x: currentCenter.x + correctedMovement.x,
y: currentCenter.y + correctedMovement.y,
z: currentCenter.z + correctedMovement.z
};
this.playerCollider.setTranslation(nextCenter);
return {
feetPosition: colliderCenterToFeetPosition(nextCenter, shape),
grounded: this.characterController.computedGrounded() || (motion.y < 0 && collidedAxes.y),
collidedAxes
};
}
dispose() {
if (this.characterController !== null) {
this.world.removeCharacterController(this.characterController);
}
this.world.free();
}
}

View File

@@ -0,0 +1,289 @@
import { AudioListener, Group, PositionalAudio, Scene, Vector3 } from "three";
const _listenerPosition = /*@__PURE__*/ new Vector3();
const _emitterPosition = /*@__PURE__*/ new Vector3();
function getErrorDetail(error) {
if (error instanceof Error && error.message.trim().length > 0) {
return error.message.trim();
}
return "Unknown error.";
}
function formatSoundEmitterLabel(entityId, link) {
return link === null ? entityId : `${entityId} (${link.id})`;
}
export function computeSoundEmitterDistanceGain(distance, refDistance, maxDistance) {
if (!Number.isFinite(distance) || !Number.isFinite(refDistance) || !Number.isFinite(maxDistance)) {
return 0;
}
if (distance <= refDistance) {
return 1;
}
if (maxDistance <= refDistance) {
return 0;
}
if (distance >= maxDistance) {
return 0;
}
const normalizedDistance = (distance - refDistance) / (maxDistance - refDistance);
const clampedDistance = Math.min(1, Math.max(0, normalizedDistance));
const proximity = 1 - clampedDistance;
const easedProximity = proximity * proximity * proximity * proximity;
return easedProximity;
}
export class RuntimeAudioSystem {
camera;
scene;
soundGroup = new Group();
soundEmitters = new Map();
pendingPlayEmitterIds = new Set();
listener;
runtimeScene = null;
projectAssets = {};
loadedAudioAssets = {};
runtimeMessageHandler;
currentRuntimeMessage = null;
unlockRequested = false;
constructor(scene, camera, runtimeMessageHandler) {
this.scene = scene;
this.camera = camera;
this.runtimeMessageHandler = runtimeMessageHandler;
this.scene.add(this.soundGroup);
let listener = null;
try {
listener = new AudioListener();
this.camera.add(listener);
}
catch (error) {
console.warn(`Audio is unavailable in this browser environment: ${getErrorDetail(error)}`);
}
this.listener = listener;
}
setRuntimeMessageHandler(handler) {
this.runtimeMessageHandler = handler;
}
loadScene(runtimeScene) {
this.runtimeScene = runtimeScene;
this.rebuildSoundEmitters();
this.queueAutoplayEmitters();
}
updateAssets(projectAssets, loadedAudioAssets) {
this.projectAssets = projectAssets;
this.loadedAudioAssets = loadedAudioAssets;
this.rebuildSoundEmitters();
this.queueAutoplayEmitters();
}
updateListenerTransform() {
this.listener?.updateMatrixWorld(true);
this.updateSoundEmitterVolumes();
}
handleUserGesture() {
if (this.listener === null) {
return;
}
const context = this.listener.context;
if (context.state === "running") {
if (this.unlockRequested) {
this.unlockRequested = false;
this.setRuntimeMessage(null);
}
return;
}
this.unlockRequested = true;
void context
.resume()
.then(() => {
this.unlockRequested = false;
this.flushPendingPlays();
this.setRuntimeMessage(null);
})
.catch((error) => {
this.setRuntimeMessage(`Audio unlock failed: ${getErrorDetail(error)}`);
});
}
playSound(soundEmitterId, link) {
const soundEmitter = this.soundEmitters.get(soundEmitterId);
if (soundEmitter === undefined) {
this.setRuntimeMessage(`Sound emitter ${formatSoundEmitterLabel(soundEmitterId, link)} could not be found.`);
return;
}
if (this.listener === null) {
this.setRuntimeMessage("Audio is unavailable in this browser environment.");
return;
}
if (soundEmitter.buffer === null) {
const assetLabel = this.describeAudioAssetAvailability(soundEmitter.entity.audioAssetId);
this.setRuntimeMessage(`Sound emitter ${formatSoundEmitterLabel(soundEmitterId, link)} cannot play because ${assetLabel}.`);
console.warn(`playSound: ${soundEmitterId} has no playable audio buffer.`);
return;
}
if (this.listener.context.state !== "running") {
this.pendingPlayEmitterIds.add(soundEmitterId);
this.setRuntimeMessage("Audio is locked. Click the runner to enable sound.");
return;
}
this.playBufferedSound(soundEmitterId);
}
stopSound(soundEmitterId) {
this.pendingPlayEmitterIds.delete(soundEmitterId);
const soundEmitter = this.soundEmitters.get(soundEmitterId);
if (soundEmitter === undefined || soundEmitter.audio === null) {
return;
}
try {
soundEmitter.audio.stop();
}
catch (error) {
console.warn(`stopSound: ${soundEmitterId} could not be stopped: ${getErrorDetail(error)}`);
}
}
dispose() {
for (const soundEmitterId of this.soundEmitters.keys()) {
this.stopSound(soundEmitterId);
}
this.pendingPlayEmitterIds.clear();
for (const soundEmitter of this.soundEmitters.values()) {
this.soundGroup.remove(soundEmitter.group);
if (soundEmitter.audio !== null) {
soundEmitter.group.remove(soundEmitter.audio);
}
}
this.soundEmitters.clear();
this.scene.remove(this.soundGroup);
if (this.listener !== null) {
this.camera.remove(this.listener);
}
}
setRuntimeMessage(message) {
if (this.currentRuntimeMessage === message) {
return;
}
this.currentRuntimeMessage = message;
this.runtimeMessageHandler?.(message);
}
rebuildSoundEmitters() {
if (this.runtimeScene === null) {
return;
}
for (const soundEmitter of this.soundEmitters.values()) {
this.stopSound(soundEmitter.entity.entityId);
this.soundGroup.remove(soundEmitter.group);
if (soundEmitter.audio !== null) {
soundEmitter.group.remove(soundEmitter.audio);
}
}
this.soundEmitters.clear();
for (const entity of this.runtimeScene.entities.soundEmitters) {
const group = new Group();
group.position.set(entity.position.x, entity.position.y, entity.position.z);
let audio = null;
if (this.listener !== null) {
audio = new PositionalAudio(this.listener);
this.configurePositionalAudio(audio, entity);
audio.position.set(0, 0, 0);
group.add(audio);
}
const buffer = this.resolveAudioBuffer(entity.audioAssetId);
if (audio !== null && buffer !== null) {
audio.setBuffer(buffer);
}
this.soundGroup.add(group);
this.soundEmitters.set(entity.entityId, {
entity,
group,
audio,
buffer
});
}
}
resolveAudioBuffer(audioAssetId) {
if (audioAssetId === null) {
return null;
}
const loadedAsset = this.loadedAudioAssets[audioAssetId];
if (loadedAsset !== undefined) {
return loadedAsset.buffer;
}
const asset = this.projectAssets[audioAssetId];
if (asset === undefined) {
return null;
}
if (asset.kind !== "audio") {
return null;
}
return null;
}
describeAudioAssetAvailability(audioAssetId) {
if (audioAssetId === null) {
return "no assigned audio asset";
}
const asset = this.projectAssets[audioAssetId];
if (asset === undefined) {
return `missing audio asset ${audioAssetId}`;
}
if (asset.kind !== "audio") {
return `asset ${audioAssetId} is not an audio asset`;
}
return `audio asset ${audioAssetId} is unavailable`;
}
queueAutoplayEmitters() {
if (this.runtimeScene === null) {
return;
}
for (const entity of this.runtimeScene.entities.soundEmitters) {
if (entity.autoplay) {
this.pendingPlayEmitterIds.add(entity.entityId);
}
}
this.flushPendingPlays();
}
flushPendingPlays() {
if (this.listener === null || this.listener.context.state !== "running") {
return;
}
const pendingEmitterIds = [...this.pendingPlayEmitterIds];
this.pendingPlayEmitterIds.clear();
for (const soundEmitterId of pendingEmitterIds) {
this.playBufferedSound(soundEmitterId);
}
}
playBufferedSound(soundEmitterId) {
const soundEmitter = this.soundEmitters.get(soundEmitterId);
if (soundEmitter === undefined || soundEmitter.audio === null || soundEmitter.buffer === null) {
return;
}
try {
soundEmitter.audio.stop();
}
catch {
// three.js audio.stop() can throw when the underlying source is not active yet.
}
this.configurePositionalAudio(soundEmitter.audio, soundEmitter.entity);
this.updateSoundEmitterVolume(soundEmitter);
soundEmitter.audio.setBuffer(soundEmitter.buffer);
soundEmitter.audio.play();
}
configurePositionalAudio(audio, entity) {
audio.setLoop(entity.loop);
audio.setRefDistance(entity.refDistance);
audio.setMaxDistance(entity.maxDistance);
audio.setDistanceModel("inverse");
audio.setRolloffFactor(0);
}
updateSoundEmitterVolumes() {
if (this.listener === null) {
return;
}
for (const soundEmitter of this.soundEmitters.values()) {
this.updateSoundEmitterVolume(soundEmitter);
}
}
updateSoundEmitterVolume(soundEmitter) {
if (soundEmitter.audio === null) {
return;
}
this.camera.getWorldPosition(_listenerPosition);
soundEmitter.group.getWorldPosition(_emitterPosition);
const distance = _listenerPosition.distanceTo(_emitterPosition);
const attenuation = computeSoundEmitterDistanceGain(distance, soundEmitter.entity.refDistance, soundEmitter.entity.maxDistance);
soundEmitter.audio.setVolume(soundEmitter.entity.volume * attenuation);
}
}

View File

@@ -0,0 +1,564 @@
import { AmbientLight, AnimationClip, AnimationMixer, BoxGeometry, DirectionalLight, Group, LoopOnce, LoopRepeat, Mesh, MeshStandardMaterial, PerspectiveCamera, PointLight, Quaternion, Scene, Vector3, SpotLight, WebGLRenderer } from "three";
import { EffectComposer } from "postprocessing";
import { createModelInstanceRenderGroup, disposeModelInstance } from "../assets/model-instance-rendering";
import { applyBoxBrushFaceUvsToGeometry } from "../geometry/box-face-uvs";
import { createStarterMaterialSignature, createStarterMaterialTexture } from "../materials/starter-material-textures";
import { applyAdvancedRenderingLightShadowFlags, applyAdvancedRenderingRenderableShadowFlags, configureAdvancedRenderingRenderer, createAdvancedRenderingComposer } from "../rendering/advanced-rendering";
import { areAdvancedRenderingSettingsEqual, cloneAdvancedRenderingSettings } from "../document/world-settings";
import { FirstPersonNavigationController } from "./first-person-navigation-controller";
import { RapierCollisionWorld } from "./rapier-collision-world";
import { RuntimeInteractionSystem } from "./runtime-interaction-system";
import { RuntimeAudioSystem } from "./runtime-audio-system";
import { OrbitVisitorNavigationController } from "./orbit-visitor-navigation-controller";
const FALLBACK_FACE_COLOR = 0x747d89;
export class RuntimeHost {
scene = new Scene();
camera = new PerspectiveCamera(70, 1, 0.05, 1000);
cameraForward = new Vector3();
domElement;
ambientLight = new AmbientLight();
sunLight = new DirectionalLight();
localLightGroup = new Group();
brushGroup = new Group();
modelGroup = new Group();
firstPersonController = new FirstPersonNavigationController();
orbitVisitorController = new OrbitVisitorNavigationController();
interactionSystem = new RuntimeInteractionSystem();
audioSystem = new RuntimeAudioSystem(this.scene, this.camera, null);
brushMeshes = new Map();
localLightObjects = new Map();
modelRenderObjects = new Map();
materialTextureCache = new Map();
animationMixers = new Map();
instanceAnimationClips = new Map();
controllerContext;
renderer;
runtimeScene = null;
collisionWorld = null;
collisionWorldRequestId = 0;
currentWorld = null;
currentAdvancedRenderingSettings = null;
advancedRenderingComposer = null;
projectAssets = {};
loadedModelAssets = {};
loadedImageAssets = {};
resizeObserver = null;
animationFrame = 0;
previousFrameTime = 0;
container = null;
activeController = null;
runtimeMessageHandler = null;
firstPersonTelemetryHandler = null;
interactionPromptHandler = null;
currentRuntimeMessage = null;
currentFirstPersonTelemetry = null;
currentInteractionPrompt = null;
constructor(options = {}) {
const enableRendering = options.enableRendering ?? true;
this.scene.add(this.ambientLight);
this.scene.add(this.sunLight);
this.scene.add(this.localLightGroup);
this.scene.add(this.brushGroup);
this.scene.add(this.modelGroup);
this.renderer = enableRendering ? new WebGLRenderer({ antialias: true, alpha: true }) : null;
this.domElement = this.renderer?.domElement ?? document.createElement("canvas");
if (this.renderer !== null) {
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.setClearAlpha(0);
}
else {
this.domElement.className = "runner-canvas__surface";
}
this.controllerContext = {
camera: this.camera,
domElement: this.domElement,
getRuntimeScene: () => {
if (this.runtimeScene === null) {
throw new Error("Runtime scene has not been loaded.");
}
return this.runtimeScene;
},
resolveFirstPersonMotion: (feetPosition, motion, shape) => this.collisionWorld?.resolveFirstPersonMotion(feetPosition, motion, shape) ?? null,
setRuntimeMessage: (message) => {
if (message === this.currentRuntimeMessage) {
return;
}
this.currentRuntimeMessage = message;
this.runtimeMessageHandler?.(message);
},
setFirstPersonTelemetry: (telemetry) => {
this.currentFirstPersonTelemetry = telemetry;
this.firstPersonTelemetryHandler?.(telemetry);
}
};
}
mount(container) {
this.container = container;
container.appendChild(this.domElement);
this.domElement.addEventListener("click", this.handleRuntimeClick);
this.domElement.addEventListener("pointerdown", this.handleRuntimePointerDown);
this.resize();
this.resizeObserver = new ResizeObserver(() => {
this.resize();
});
this.resizeObserver.observe(container);
this.previousFrameTime = performance.now();
this.render();
}
loadScene(runtimeScene) {
this.runtimeScene = runtimeScene;
this.currentWorld = runtimeScene.world;
this.interactionSystem.reset();
this.setInteractionPrompt(null);
this.applyWorld();
this.rebuildLocalLights(runtimeScene.localLights);
this.rebuildBrushMeshes(runtimeScene.brushes);
this.rebuildModelInstances(runtimeScene.modelInstances);
void this.rebuildCollisionWorld(runtimeScene.colliders, runtimeScene.playerCollider);
this.audioSystem.loadScene(runtimeScene);
}
updateAssets(projectAssets, loadedModelAssets, loadedImageAssets, loadedAudioAssets) {
this.projectAssets = projectAssets;
this.loadedModelAssets = loadedModelAssets;
this.loadedImageAssets = loadedImageAssets;
if (this.currentWorld !== null) {
this.applyWorld();
}
if (this.runtimeScene !== null) {
this.rebuildModelInstances(this.runtimeScene.modelInstances);
}
this.audioSystem.updateAssets(projectAssets, loadedAudioAssets);
}
setNavigationMode(mode) {
if (this.runtimeScene === null) {
return;
}
const nextController = mode === "firstPerson" ? this.firstPersonController : this.orbitVisitorController;
if (this.activeController?.id === nextController.id) {
return;
}
if (this.activeController === this.firstPersonController && this.currentFirstPersonTelemetry !== null && nextController === this.orbitVisitorController) {
this.orbitVisitorController.setFocusPoint(this.currentFirstPersonTelemetry.feetPosition);
}
this.activeController?.deactivate(this.controllerContext);
this.interactionSystem.reset();
this.setInteractionPrompt(null);
this.activeController = nextController;
this.activeController.activate(this.controllerContext);
}
setRuntimeMessageHandler(handler) {
this.runtimeMessageHandler = handler;
this.audioSystem.setRuntimeMessageHandler(handler);
}
setFirstPersonTelemetryHandler(handler) {
this.firstPersonTelemetryHandler = handler;
}
setInteractionPromptHandler(handler) {
this.interactionPromptHandler = handler;
}
dispose() {
if (this.animationFrame !== 0) {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = 0;
}
this.activeController?.deactivate(this.controllerContext);
this.activeController = null;
this.setInteractionPrompt(null);
this.resizeObserver?.disconnect();
this.resizeObserver = null;
this.clearLocalLights();
this.clearBrushMeshes();
this.clearModelInstances();
this.collisionWorldRequestId += 1;
this.clearCollisionWorld();
this.audioSystem.dispose();
this.advancedRenderingComposer?.dispose();
this.advancedRenderingComposer = null;
this.currentAdvancedRenderingSettings = null;
if (this.renderer !== null) {
this.renderer.autoClear = true;
}
for (const cachedTexture of this.materialTextureCache.values()) {
cachedTexture.texture.dispose();
}
this.materialTextureCache.clear();
this.renderer?.dispose();
this.domElement.removeEventListener("click", this.handleRuntimeClick);
this.domElement.removeEventListener("pointerdown", this.handleRuntimePointerDown);
if (this.container !== null && this.container.contains(this.domElement)) {
this.container.removeChild(this.domElement);
}
this.container = null;
}
applyWorld() {
if (this.currentWorld === null) {
return;
}
const world = this.currentWorld;
this.ambientLight.color.set(world.ambientLight.colorHex);
this.ambientLight.intensity = world.ambientLight.intensity;
this.sunLight.color.set(world.sunLight.colorHex);
this.sunLight.intensity = world.sunLight.intensity;
this.sunLight.position
.set(world.sunLight.direction.x, world.sunLight.direction.y, world.sunLight.direction.z)
.normalize()
.multiplyScalar(18);
if (world.background.mode === "image") {
const texture = this.loadedImageAssets[world.background.assetId]?.texture ?? null;
this.scene.background = texture;
this.scene.environment = texture;
this.scene.environmentIntensity = world.background.environmentIntensity;
}
else {
this.scene.background = null;
this.scene.environment = null;
this.scene.environmentIntensity = 1;
}
if (this.renderer !== null) {
configureAdvancedRenderingRenderer(this.renderer, world.advancedRendering);
this.syncAdvancedRenderingComposer(world.advancedRendering);
}
this.applyShadowState();
}
async rebuildCollisionWorld(colliders, playerShape) {
const requestId = ++this.collisionWorldRequestId;
this.clearCollisionWorld();
try {
const nextCollisionWorld = await RapierCollisionWorld.create(colliders, playerShape);
if (requestId !== this.collisionWorldRequestId) {
nextCollisionWorld.dispose();
return;
}
this.collisionWorld = nextCollisionWorld;
}
catch (error) {
if (requestId !== this.collisionWorldRequestId) {
return;
}
const message = error instanceof Error ? error.message : "Runner collision initialization failed.";
this.currentRuntimeMessage = `Runner collision initialization failed: ${message}`;
this.runtimeMessageHandler?.(this.currentRuntimeMessage);
}
}
clearCollisionWorld() {
this.collisionWorld?.dispose();
this.collisionWorld = null;
}
syncAdvancedRenderingComposer(settings) {
if (this.renderer === null) {
return;
}
const shouldUseComposer = settings.enabled;
const settingsChanged = this.currentAdvancedRenderingSettings === null ||
!areAdvancedRenderingSettingsEqual(this.currentAdvancedRenderingSettings, settings);
if (!shouldUseComposer) {
if (this.advancedRenderingComposer !== null) {
this.advancedRenderingComposer.dispose();
this.advancedRenderingComposer = null;
}
this.currentAdvancedRenderingSettings = null;
this.renderer.autoClear = true;
return;
}
if (this.advancedRenderingComposer !== null && !settingsChanged) {
return;
}
if (this.advancedRenderingComposer !== null) {
this.advancedRenderingComposer.dispose();
}
this.advancedRenderingComposer = createAdvancedRenderingComposer(this.renderer, this.scene, this.camera, settings);
this.currentAdvancedRenderingSettings = cloneAdvancedRenderingSettings(settings);
this.renderer.autoClear = false;
}
applyShadowState() {
if (this.currentWorld === null) {
return;
}
const advancedRendering = this.currentWorld.advancedRendering;
const shadowsEnabled = advancedRendering.enabled && advancedRendering.shadows.enabled;
applyAdvancedRenderingLightShadowFlags(this.sunLight, advancedRendering);
for (const renderGroup of this.localLightObjects.values()) {
applyAdvancedRenderingLightShadowFlags(renderGroup, advancedRendering);
}
for (const mesh of this.brushMeshes.values()) {
applyAdvancedRenderingRenderableShadowFlags(mesh, shadowsEnabled);
}
for (const renderGroup of this.modelRenderObjects.values()) {
applyAdvancedRenderingRenderableShadowFlags(renderGroup, shadowsEnabled);
}
}
rebuildLocalLights(localLights) {
this.clearLocalLights();
for (const pointLight of localLights.pointLights) {
const renderObjects = this.createPointLightRuntimeObjects(pointLight);
this.localLightGroup.add(renderObjects.group);
this.localLightObjects.set(pointLight.entityId, renderObjects.group);
}
for (const spotLight of localLights.spotLights) {
const renderObjects = this.createSpotLightRuntimeObjects(spotLight);
this.localLightGroup.add(renderObjects.group);
this.localLightObjects.set(spotLight.entityId, renderObjects.group);
}
this.applyShadowState();
}
createPointLightRuntimeObjects(pointLight) {
const group = new Group();
const light = new PointLight(pointLight.colorHex, pointLight.intensity, pointLight.distance);
group.position.set(pointLight.position.x, pointLight.position.y, pointLight.position.z);
light.position.set(0, 0, 0);
group.add(light);
return {
group
};
}
createSpotLightRuntimeObjects(spotLight) {
const group = new Group();
const light = new SpotLight(spotLight.colorHex, spotLight.intensity, spotLight.distance, (spotLight.angleDegrees * Math.PI) / 180, 0.18, 1);
const direction = new Vector3(spotLight.direction.x, spotLight.direction.y, spotLight.direction.z).normalize();
const orientation = new Quaternion().setFromUnitVectors(new Vector3(0, 1, 0), direction);
group.position.set(spotLight.position.x, spotLight.position.y, spotLight.position.z);
group.quaternion.copy(orientation);
light.position.set(0, 0, 0);
light.target.position.set(0, 1, 0);
group.add(light);
group.add(light.target);
return {
group
};
}
rebuildBrushMeshes(brushes) {
this.clearBrushMeshes();
for (const brush of brushes) {
const geometry = new BoxGeometry(brush.size.x, brush.size.y, brush.size.z);
applyBoxBrushFaceUvsToGeometry(geometry, brush);
const materials = [
this.createFaceMaterial(brush.faces.posX.material),
this.createFaceMaterial(brush.faces.negX.material),
this.createFaceMaterial(brush.faces.posY.material),
this.createFaceMaterial(brush.faces.negY.material),
this.createFaceMaterial(brush.faces.posZ.material),
this.createFaceMaterial(brush.faces.negZ.material)
];
const mesh = new Mesh(geometry, materials);
mesh.position.set(brush.center.x, brush.center.y, brush.center.z);
mesh.rotation.set((brush.rotationDegrees.x * Math.PI) / 180, (brush.rotationDegrees.y * Math.PI) / 180, (brush.rotationDegrees.z * Math.PI) / 180);
this.brushGroup.add(mesh);
this.brushMeshes.set(brush.id, mesh);
}
this.applyShadowState();
}
rebuildModelInstances(modelInstances) {
this.clearModelInstances();
for (const modelInstance of modelInstances) {
const asset = this.projectAssets[modelInstance.assetId];
const loadedAsset = this.loadedModelAssets[modelInstance.assetId];
const renderGroup = createModelInstanceRenderGroup({
id: modelInstance.instanceId,
kind: "modelInstance",
assetId: modelInstance.assetId,
name: modelInstance.name,
position: modelInstance.position,
rotationDegrees: modelInstance.rotationDegrees,
scale: modelInstance.scale,
collision: {
mode: "none",
visible: false
}
}, asset, loadedAsset, false);
this.modelGroup.add(renderGroup);
this.modelRenderObjects.set(modelInstance.instanceId, renderGroup);
if (loadedAsset?.animations && loadedAsset.animations.length > 0) {
const mixer = new AnimationMixer(renderGroup);
this.animationMixers.set(modelInstance.instanceId, mixer);
this.instanceAnimationClips.set(modelInstance.instanceId, loadedAsset.animations);
if (modelInstance.animationAutoplay === true && modelInstance.animationClipName) {
const clip = AnimationClip.findByName(loadedAsset.animations, modelInstance.animationClipName);
if (clip) {
mixer.clipAction(clip).play();
}
}
}
}
this.applyShadowState();
}
createFaceMaterial(material) {
if (material === null) {
return new MeshStandardMaterial({
color: FALLBACK_FACE_COLOR,
roughness: 0.9,
metalness: 0.05
});
}
return new MeshStandardMaterial({
color: 0xffffff,
map: this.getOrCreateTexture(material),
roughness: 0.92,
metalness: 0.02
});
}
getOrCreateTexture(material) {
const signature = createStarterMaterialSignature(material);
const cachedTexture = this.materialTextureCache.get(material.id);
if (cachedTexture !== undefined && cachedTexture.signature === signature) {
return cachedTexture.texture;
}
cachedTexture?.texture.dispose();
const texture = createStarterMaterialTexture(material);
this.materialTextureCache.set(material.id, {
signature,
texture
});
return texture;
}
clearLocalLights() {
for (const renderGroup of this.localLightObjects.values()) {
this.localLightGroup.remove(renderGroup);
}
this.localLightObjects.clear();
}
clearBrushMeshes() {
for (const mesh of this.brushMeshes.values()) {
this.brushGroup.remove(mesh);
mesh.geometry.dispose();
for (const material of mesh.material) {
material.dispose();
}
}
this.brushMeshes.clear();
}
clearModelInstances() {
for (const mixer of this.animationMixers.values()) {
mixer.stopAllAction();
}
this.animationMixers.clear();
this.instanceAnimationClips.clear();
for (const renderGroup of this.modelRenderObjects.values()) {
this.modelGroup.remove(renderGroup);
disposeModelInstance(renderGroup);
}
this.modelRenderObjects.clear();
}
resize() {
if (this.container === null) {
return;
}
const width = this.container.clientWidth;
const height = this.container.clientHeight;
if (width === 0 || height === 0) {
return;
}
this.camera.aspect = width / height;
this.camera.updateProjectionMatrix();
this.domElement.width = width;
this.domElement.height = height;
this.renderer?.setSize(width, height, false);
this.advancedRenderingComposer?.setSize(width, height);
}
render = () => {
this.animationFrame = window.requestAnimationFrame(this.render);
const now = performance.now();
const dt = Math.min((now - this.previousFrameTime) / 1000, 1 / 20);
this.previousFrameTime = now;
this.activeController?.update(dt);
this.audioSystem.updateListenerTransform();
for (const mixer of this.animationMixers.values()) {
mixer.update(dt);
}
if (this.runtimeScene !== null && this.activeController === this.firstPersonController && this.currentFirstPersonTelemetry !== null) {
this.interactionSystem.updatePlayerPosition(this.currentFirstPersonTelemetry.feetPosition, this.runtimeScene, this.createInteractionDispatcher());
this.camera.getWorldDirection(this.cameraForward);
this.setInteractionPrompt(this.interactionSystem.resolveClickInteractionPrompt(this.currentFirstPersonTelemetry.eyePosition, {
x: this.cameraForward.x,
y: this.cameraForward.y,
z: this.cameraForward.z
}, this.runtimeScene));
}
else {
this.setInteractionPrompt(null);
}
if (this.advancedRenderingComposer !== null) {
this.advancedRenderingComposer.render(dt);
return;
}
this.renderer?.render(this.scene, this.camera);
};
applyTeleportPlayerAction(target) {
this.firstPersonController.teleportTo(target.position, target.yawDegrees);
}
applyToggleBrushVisibilityAction(brushId, visible) {
const mesh = this.brushMeshes.get(brushId);
if (mesh === undefined) {
return;
}
mesh.visible = visible ?? !mesh.visible;
}
applyPlayAnimationAction(instanceId, clipName, loop) {
const mixer = this.animationMixers.get(instanceId);
const clips = this.instanceAnimationClips.get(instanceId);
if (!mixer || !clips) {
console.warn(`playAnimation: no mixer for instance ${instanceId}`);
return;
}
const clip = AnimationClip.findByName(clips, clipName);
if (!clip) {
console.warn(`playAnimation: clip "${clipName}" not found on instance ${instanceId}`);
return;
}
// LoopRepeat is the three.js default; LoopOnce plays the clip a single time then stops.
const action = mixer.clipAction(clip);
action.loop = loop === false ? LoopOnce : LoopRepeat;
action.clampWhenFinished = loop === false;
mixer.stopAllAction();
action.reset().play();
}
applyStopAnimationAction(instanceId) {
const mixer = this.animationMixers.get(instanceId);
if (!mixer) {
console.warn(`stopAnimation: no mixer for instance ${instanceId}`);
return;
}
mixer.stopAllAction();
}
createInteractionDispatcher() {
return {
teleportPlayer: (target) => {
this.applyTeleportPlayerAction(target);
},
toggleBrushVisibility: (brushId, visible) => {
this.applyToggleBrushVisibilityAction(brushId, visible);
},
playAnimation: (instanceId, clipName, loop) => {
this.applyPlayAnimationAction(instanceId, clipName, loop);
},
stopAnimation: (instanceId) => {
this.applyStopAnimationAction(instanceId);
},
playSound: (soundEmitterId, link) => {
this.audioSystem.playSound(soundEmitterId, link);
},
stopSound: (soundEmitterId) => {
this.audioSystem.stopSound(soundEmitterId);
}
};
}
setInteractionPrompt(prompt) {
if (this.currentInteractionPrompt?.sourceEntityId === prompt?.sourceEntityId &&
this.currentInteractionPrompt?.prompt === prompt?.prompt &&
this.currentInteractionPrompt?.distance === prompt?.distance &&
this.currentInteractionPrompt?.range === prompt?.range) {
return;
}
this.currentInteractionPrompt = prompt;
this.interactionPromptHandler?.(prompt);
}
handleRuntimeClick = () => {
this.audioSystem.handleUserGesture();
if (this.runtimeScene === null || this.activeController !== this.firstPersonController || this.currentInteractionPrompt === null) {
return;
}
this.interactionSystem.dispatchClickInteraction(this.currentInteractionPrompt.sourceEntityId, this.runtimeScene, this.createInteractionDispatcher());
};
handleRuntimePointerDown = () => {
this.audioSystem.handleUserGesture();
};
}

View File

@@ -0,0 +1,163 @@
const DEFAULT_INTERACTABLE_TARGET_RADIUS = 0.75;
function subtractVec3(left, right) {
return {
x: left.x - right.x,
y: left.y - right.y,
z: left.z - right.z
};
}
function scaleVec3(vector, scalar) {
return {
x: vector.x * scalar,
y: vector.y * scalar,
z: vector.z * scalar
};
}
function dotVec3(left, right) {
return left.x * right.x + left.y * right.y + left.z * right.z;
}
function lengthSquaredVec3(vector) {
return dotVec3(vector, vector);
}
function distanceBetweenVec3(left, right) {
return Math.sqrt(lengthSquaredVec3(subtractVec3(left, right)));
}
function normalizeVec3(vector) {
const lengthSquared = lengthSquaredVec3(vector);
if (lengthSquared <= Number.EPSILON) {
return null;
}
return scaleVec3(vector, 1 / Math.sqrt(lengthSquared));
}
function isPointInsideTriggerVolume(position, triggerVolume) {
const halfSize = {
x: triggerVolume.size.x * 0.5,
y: triggerVolume.size.y * 0.5,
z: triggerVolume.size.z * 0.5
};
return (position.x >= triggerVolume.position.x - halfSize.x &&
position.x <= triggerVolume.position.x + halfSize.x &&
position.y >= triggerVolume.position.y - halfSize.y &&
position.y <= triggerVolume.position.y + halfSize.y &&
position.z >= triggerVolume.position.z - halfSize.z &&
position.z <= triggerVolume.position.z + halfSize.z);
}
function raySphereHitDistance(origin, direction, center, radius) {
const offset = subtractVec3(origin, center);
const halfB = dotVec3(offset, direction);
const c = dotVec3(offset, offset) - radius * radius;
const discriminant = halfB * halfB - c;
if (discriminant < 0) {
return null;
}
const discriminantRoot = Math.sqrt(discriminant);
const nearestHit = -halfB - discriminantRoot;
if (nearestHit >= 0) {
return nearestHit;
}
const farHit = -halfB + discriminantRoot;
return farHit >= 0 ? 0 : null;
}
function resolveTeleportTarget(runtimeScene, entityId) {
return runtimeScene.entities.teleportTargets.find((teleportTarget) => teleportTarget.entityId === entityId) ?? null;
}
function hasTriggerLinks(runtimeScene, sourceEntityId, trigger) {
return runtimeScene.interactionLinks.some((link) => link.sourceEntityId === sourceEntityId && link.trigger === trigger);
}
function getInteractableTargetRadius(interactable) {
return Math.min(DEFAULT_INTERACTABLE_TARGET_RADIUS, interactable.radius);
}
export class RuntimeInteractionSystem {
occupiedTriggerVolumes = new Set();
reset() {
this.occupiedTriggerVolumes.clear();
}
updatePlayerPosition(feetPosition, runtimeScene, dispatcher) {
for (const triggerVolume of runtimeScene.entities.triggerVolumes) {
const containsPlayer = isPointInsideTriggerVolume(feetPosition, triggerVolume);
const wasOccupied = this.occupiedTriggerVolumes.has(triggerVolume.entityId);
if (!wasOccupied && containsPlayer && hasTriggerLinks(runtimeScene, triggerVolume.entityId, "enter")) {
this.dispatchLinks(triggerVolume.entityId, "enter", runtimeScene, dispatcher);
}
else if (wasOccupied && !containsPlayer && hasTriggerLinks(runtimeScene, triggerVolume.entityId, "exit")) {
this.dispatchLinks(triggerVolume.entityId, "exit", runtimeScene, dispatcher);
}
if (containsPlayer) {
this.occupiedTriggerVolumes.add(triggerVolume.entityId);
}
else {
this.occupiedTriggerVolumes.delete(triggerVolume.entityId);
}
}
}
resolveClickInteractionPrompt(viewOrigin, viewDirection, runtimeScene) {
const normalizedViewDirection = normalizeVec3(viewDirection);
if (normalizedViewDirection === null) {
return null;
}
let bestPrompt = null;
let bestHitDistance = Number.POSITIVE_INFINITY;
for (const interactable of runtimeScene.entities.interactables) {
if (!interactable.enabled || !hasTriggerLinks(runtimeScene, interactable.entityId, "click")) {
continue;
}
const distance = distanceBetweenVec3(viewOrigin, interactable.position);
if (distance > interactable.radius) {
continue;
}
const hitDistance = raySphereHitDistance(viewOrigin, normalizedViewDirection, interactable.position, getInteractableTargetRadius(interactable));
if (hitDistance === null) {
continue;
}
const nextPrompt = {
sourceEntityId: interactable.entityId,
prompt: interactable.prompt,
distance,
range: interactable.radius
};
if (hitDistance < bestHitDistance ||
(hitDistance === bestHitDistance &&
(bestPrompt === null ||
distance < bestPrompt.distance ||
(distance === bestPrompt.distance && interactable.entityId.localeCompare(bestPrompt.sourceEntityId) < 0)))) {
bestHitDistance = hitDistance;
bestPrompt = nextPrompt;
}
}
return bestPrompt;
}
dispatchClickInteraction(sourceEntityId, runtimeScene, dispatcher) {
this.dispatchLinks(sourceEntityId, "click", runtimeScene, dispatcher);
}
dispatchLinks(sourceEntityId, trigger, runtimeScene, dispatcher) {
for (const link of runtimeScene.interactionLinks) {
if (link.sourceEntityId !== sourceEntityId || link.trigger !== trigger) {
continue;
}
switch (link.action.type) {
case "teleportPlayer": {
const teleportTarget = resolveTeleportTarget(runtimeScene, link.action.targetEntityId);
if (teleportTarget !== null) {
dispatcher.teleportPlayer(teleportTarget, link);
}
break;
}
case "toggleVisibility":
dispatcher.toggleBrushVisibility(link.action.targetBrushId, link.action.visible, link);
break;
case "playAnimation":
dispatcher.playAnimation(link.action.targetModelInstanceId, link.action.clipName, link.action.loop, link);
break;
case "stopAnimation":
dispatcher.stopAnimation(link.action.targetModelInstanceId, link);
break;
case "playSound":
dispatcher.playSound(link.action.targetSoundEmitterId, link);
break;
case "stopSound":
dispatcher.stopSound(link.action.targetSoundEmitterId, link);
break;
}
}
}
}

View File

@@ -0,0 +1,331 @@
import { getModelInstances } from "../assets/model-instances";
import { cloneWorldSettings } from "../document/world-settings";
import { getEntityInstances, getPrimaryPlayerStartEntity } from "../entities/entity-instances";
import { getBoxBrushBounds } from "../geometry/box-brush";
import { buildGeneratedModelCollider } from "../geometry/model-instance-collider-generation";
import { cloneInteractionLink, getInteractionLinks } from "../interactions/interaction-links";
import { cloneMaterialDef } from "../materials/starter-material-library";
import { cloneFaceUvState } from "../document/brushes";
import { assertRuntimeSceneBuildable } from "./runtime-scene-validation";
import { FIRST_PERSON_PLAYER_SHAPE } from "./player-collision";
function cloneVec3(vector) {
return {
x: vector.x,
y: vector.y,
z: vector.z
};
}
function resolveRuntimeMaterial(document, materialId) {
if (materialId === null) {
return null;
}
const material = document.materials[materialId];
if (material === undefined) {
throw new Error(`Runtime build could not resolve material ${materialId}.`);
}
return cloneMaterialDef(material);
}
function buildRuntimeBrush(brush, document) {
return {
id: brush.id,
kind: "box",
center: cloneVec3(brush.center),
rotationDegrees: cloneVec3(brush.rotationDegrees),
size: cloneVec3(brush.size),
faces: {
posX: {
materialId: brush.faces.posX.materialId,
material: resolveRuntimeMaterial(document, brush.faces.posX.materialId),
uv: cloneFaceUvState(brush.faces.posX.uv)
},
negX: {
materialId: brush.faces.negX.materialId,
material: resolveRuntimeMaterial(document, brush.faces.negX.materialId),
uv: cloneFaceUvState(brush.faces.negX.uv)
},
posY: {
materialId: brush.faces.posY.materialId,
material: resolveRuntimeMaterial(document, brush.faces.posY.materialId),
uv: cloneFaceUvState(brush.faces.posY.uv)
},
negY: {
materialId: brush.faces.negY.materialId,
material: resolveRuntimeMaterial(document, brush.faces.negY.materialId),
uv: cloneFaceUvState(brush.faces.negY.uv)
},
posZ: {
materialId: brush.faces.posZ.materialId,
material: resolveRuntimeMaterial(document, brush.faces.posZ.materialId),
uv: cloneFaceUvState(brush.faces.posZ.uv)
},
negZ: {
materialId: brush.faces.negZ.materialId,
material: resolveRuntimeMaterial(document, brush.faces.negZ.materialId),
uv: cloneFaceUvState(brush.faces.negZ.uv)
}
}
};
}
function buildRuntimeCollider(brush) {
const bounds = getBoxBrushBounds(brush);
return {
kind: "box",
source: "brush",
brushId: brush.id,
center: cloneVec3(brush.center),
rotationDegrees: cloneVec3(brush.rotationDegrees),
size: cloneVec3(brush.size),
worldBounds: {
min: cloneVec3(bounds.min),
max: cloneVec3(bounds.max)
}
};
}
function buildRuntimeModelInstance(modelInstance) {
return {
instanceId: modelInstance.id,
assetId: modelInstance.assetId,
name: modelInstance.name,
position: cloneVec3(modelInstance.position),
rotationDegrees: cloneVec3(modelInstance.rotationDegrees),
scale: cloneVec3(modelInstance.scale),
animationClipName: modelInstance.animationClipName,
animationAutoplay: modelInstance.animationAutoplay
};
}
function getColliderBounds(collider) {
if (collider.source === "brush") {
return {
min: cloneVec3(collider.worldBounds.min),
max: cloneVec3(collider.worldBounds.max)
};
}
return {
min: cloneVec3(collider.worldBounds.min),
max: cloneVec3(collider.worldBounds.max)
};
}
function combineColliderBounds(colliders) {
if (colliders.length === 0) {
return null;
}
const firstBounds = getColliderBounds(colliders[0]);
const min = cloneVec3(firstBounds.min);
const max = cloneVec3(firstBounds.max);
for (const collider of colliders.slice(1)) {
const bounds = getColliderBounds(collider);
min.x = Math.min(min.x, bounds.min.x);
min.y = Math.min(min.y, bounds.min.y);
min.z = Math.min(min.z, bounds.min.z);
max.x = Math.max(max.x, bounds.max.x);
max.y = Math.max(max.y, bounds.max.y);
max.z = Math.max(max.z, bounds.max.z);
}
return {
min,
max,
center: {
x: (min.x + max.x) * 0.5,
y: (min.y + max.y) * 0.5,
z: (min.z + max.z) * 0.5
},
size: {
x: max.x - min.x,
y: max.y - min.y,
z: max.z - min.z
}
};
}
function buildFallbackSpawn(sceneBounds) {
if (sceneBounds === null) {
return {
source: "fallback",
entityId: null,
position: {
x: 0,
y: 0,
z: -4
},
yawDegrees: 0
};
}
return {
source: "fallback",
entityId: null,
position: {
x: sceneBounds.center.x,
y: sceneBounds.max.y + 0.1,
z: sceneBounds.max.z + 3
},
yawDegrees: 180
};
}
function buildRuntimeSceneCollections(document) {
const runtimeEntities = {
playerStarts: [],
soundEmitters: [],
triggerVolumes: [],
teleportTargets: [],
interactables: []
};
const localLights = {
pointLights: [],
spotLights: []
};
for (const entity of getEntityInstances(document.entities)) {
switch (entity.kind) {
case "pointLight":
localLights.pointLights.push({
entityId: entity.id,
position: cloneVec3(entity.position),
colorHex: entity.colorHex,
intensity: entity.intensity,
distance: entity.distance
});
break;
case "spotLight":
localLights.spotLights.push({
entityId: entity.id,
position: cloneVec3(entity.position),
direction: cloneVec3(entity.direction),
colorHex: entity.colorHex,
intensity: entity.intensity,
distance: entity.distance,
angleDegrees: entity.angleDegrees
});
break;
case "playerStart":
runtimeEntities.playerStarts.push({
entityId: entity.id,
position: cloneVec3(entity.position),
yawDegrees: entity.yawDegrees,
collider: buildRuntimePlayerShape(entity)
});
break;
case "soundEmitter":
runtimeEntities.soundEmitters.push({
entityId: entity.id,
position: cloneVec3(entity.position),
audioAssetId: entity.audioAssetId,
volume: entity.volume,
refDistance: entity.refDistance,
maxDistance: entity.maxDistance,
autoplay: entity.autoplay,
loop: entity.loop
});
break;
case "triggerVolume":
runtimeEntities.triggerVolumes.push({
entityId: entity.id,
position: cloneVec3(entity.position),
size: cloneVec3(entity.size),
// Derive from links so flags are always correct regardless of stored entity state
triggerOnEnter: Object.values(document.interactionLinks).some((l) => l.sourceEntityId === entity.id && l.trigger === "enter"),
triggerOnExit: Object.values(document.interactionLinks).some((l) => l.sourceEntityId === entity.id && l.trigger === "exit")
});
break;
case "teleportTarget":
runtimeEntities.teleportTargets.push({
entityId: entity.id,
position: cloneVec3(entity.position),
yawDegrees: entity.yawDegrees
});
break;
case "interactable":
runtimeEntities.interactables.push({
entityId: entity.id,
position: cloneVec3(entity.position),
radius: entity.radius,
prompt: entity.prompt,
enabled: entity.enabled
});
break;
default:
assertNever(entity);
}
}
return {
entities: runtimeEntities,
localLights
};
}
function assertNever(value) {
throw new Error(`Unsupported runtime entity: ${String(value.kind)}`);
}
function buildRuntimePlayerShape(playerStartEntity) {
if (playerStartEntity === null) {
return FIRST_PERSON_PLAYER_SHAPE;
}
switch (playerStartEntity.collider.mode) {
case "capsule":
return {
mode: "capsule",
radius: playerStartEntity.collider.capsuleRadius,
height: playerStartEntity.collider.capsuleHeight,
eyeHeight: playerStartEntity.collider.eyeHeight
};
case "box":
return {
mode: "box",
size: cloneVec3(playerStartEntity.collider.boxSize),
eyeHeight: playerStartEntity.collider.eyeHeight
};
case "none":
return {
mode: "none",
eyeHeight: playerStartEntity.collider.eyeHeight
};
}
}
export function buildRuntimeSceneFromDocument(document, options = {}) {
assertRuntimeSceneBuildable(document, {
navigationMode: options.navigationMode ?? "orbitVisitor",
loadedModelAssets: options.loadedModelAssets
});
const brushes = Object.values(document.brushes).map((brush) => buildRuntimeBrush(brush, document));
const colliders = Object.values(document.brushes).map((brush) => buildRuntimeCollider(brush));
const modelInstances = getModelInstances(document.modelInstances).map(buildRuntimeModelInstance);
const collections = buildRuntimeSceneCollections(document);
const interactionLinks = getInteractionLinks(document.interactionLinks).map((link) => cloneInteractionLink(link));
const playerStartEntity = getPrimaryPlayerStartEntity(document.entities);
const playerCollider = buildRuntimePlayerShape(playerStartEntity);
for (const modelInstance of getModelInstances(document.modelInstances)) {
const asset = document.assets[modelInstance.assetId];
if (asset === undefined || asset.kind !== "model") {
continue;
}
const generatedCollider = buildGeneratedModelCollider(modelInstance, asset, options.loadedModelAssets?.[modelInstance.assetId]);
if (generatedCollider !== null) {
colliders.push(generatedCollider);
}
}
const combinedSceneBounds = combineColliderBounds(colliders);
const playerStart = playerStartEntity === null
? null
: {
entityId: playerStartEntity.id,
position: cloneVec3(playerStartEntity.position),
yawDegrees: playerStartEntity.yawDegrees,
collider: playerCollider
};
return {
world: cloneWorldSettings(document.world),
localLights: collections.localLights,
brushes,
colliders,
sceneBounds: combinedSceneBounds,
modelInstances,
entities: collections.entities,
interactionLinks,
playerStart,
playerCollider,
spawn: playerStart === null
? buildFallbackSpawn(combinedSceneBounds)
: {
source: "playerStart",
entityId: playerStart.entityId,
position: cloneVec3(playerStart.position),
yawDegrees: playerStart.yawDegrees
}
};
}

View File

@@ -0,0 +1,42 @@
import { getModelInstances } from "../assets/model-instances";
import { assertSceneDocumentIsValid, createDiagnostic, formatSceneDiagnosticSummary } from "../document/scene-document-validation";
import { getPrimaryPlayerStartEntity } from "../entities/entity-instances";
import { buildGeneratedModelCollider, ModelColliderGenerationError } from "../geometry/model-instance-collider-generation";
export function validateRuntimeSceneBuild(document, options) {
const diagnostics = [];
if (options.navigationMode === "firstPerson" && getPrimaryPlayerStartEntity(document.entities) === null) {
diagnostics.push(createDiagnostic("error", "missing-player-start", "First-person run requires an authored Player Start. Place one or switch to Orbit Visitor.", "entities", "build"));
}
for (const modelInstance of getModelInstances(document.modelInstances)) {
const path = `modelInstances.${modelInstance.id}.collision.mode`;
const asset = document.assets[modelInstance.assetId];
if (modelInstance.collision.mode === "none" || asset === undefined || asset.kind !== "model") {
continue;
}
try {
const generatedCollider = buildGeneratedModelCollider(modelInstance, asset, options.loadedModelAssets?.[modelInstance.assetId]);
if (generatedCollider?.mode === "dynamic") {
diagnostics.push(createDiagnostic("warning", "dynamic-model-collider-fixed-query-only", "Dynamic model collision currently generates convex compound pieces for Rapier queries, but the runner still uses them as fixed world collision rather than fully simulated rigid bodies.", path, "build"));
}
}
catch (error) {
const message = error instanceof Error ? error.message : "Imported model collision generation failed.";
const code = error instanceof ModelColliderGenerationError
? error.code
: "invalid-model-instance-collision-mode";
diagnostics.push(createDiagnostic("error", code, message, path, "build"));
}
}
return {
diagnostics,
errors: diagnostics.filter((diagnostic) => diagnostic.severity === "error"),
warnings: diagnostics.filter((diagnostic) => diagnostic.severity === "warning")
};
}
export function assertRuntimeSceneBuildable(document, options) {
assertSceneDocumentIsValid(document);
const validation = validateRuntimeSceneBuild(document, options);
if (validation.errors.length > 0) {
throw new Error(`Runtime build is blocked: ${formatSceneDiagnosticSummary(validation.errors)}`);
}
}