2026-03-31 03:04:15 +02:00
|
|
|
import {
|
|
|
|
|
AmbientLight,
|
|
|
|
|
BoxGeometry,
|
|
|
|
|
DirectionalLight,
|
|
|
|
|
Group,
|
|
|
|
|
Mesh,
|
|
|
|
|
MeshStandardMaterial,
|
|
|
|
|
PerspectiveCamera,
|
2026-03-31 20:06:54 +02:00
|
|
|
PointLight,
|
|
|
|
|
Quaternion,
|
2026-03-31 03:04:15 +02:00
|
|
|
Scene,
|
2026-03-31 06:46:27 +02:00
|
|
|
Vector3,
|
2026-03-31 20:06:54 +02:00
|
|
|
SpotLight,
|
2026-03-31 03:04:15 +02:00
|
|
|
WebGLRenderer
|
|
|
|
|
} from "three";
|
|
|
|
|
|
2026-03-31 17:40:12 +02:00
|
|
|
import { createModelInstanceRenderGroup, disposeModelInstance } from "../assets/model-instance-rendering";
|
|
|
|
|
import type { LoadedModelAsset } from "../assets/gltf-model-import";
|
2026-03-31 20:06:54 +02:00
|
|
|
import type { LoadedImageAsset } from "../assets/image-assets";
|
2026-03-31 17:40:12 +02:00
|
|
|
import type { ProjectAssetRecord } from "../assets/project-assets";
|
2026-03-31 03:04:15 +02:00
|
|
|
import { applyBoxBrushFaceUvsToGeometry } from "../geometry/box-face-uvs";
|
|
|
|
|
import { createStarterMaterialSignature, createStarterMaterialTexture } from "../materials/starter-material-textures";
|
|
|
|
|
|
|
|
|
|
import { FirstPersonNavigationController } from "./first-person-navigation-controller";
|
|
|
|
|
import type { FirstPersonTelemetry, NavigationController, RuntimeControllerContext } from "./navigation-controller";
|
2026-03-31 06:46:27 +02:00
|
|
|
import { RuntimeInteractionSystem, type RuntimeInteractionDispatcher, type RuntimeInteractionPrompt } from "./runtime-interaction-system";
|
2026-03-31 03:04:15 +02:00
|
|
|
import { OrbitVisitorNavigationController } from "./orbit-visitor-navigation-controller";
|
2026-03-31 20:06:54 +02:00
|
|
|
import type {
|
|
|
|
|
RuntimeBoxBrushInstance,
|
|
|
|
|
RuntimeLocalLightCollection,
|
|
|
|
|
RuntimeNavigationMode,
|
|
|
|
|
RuntimeSceneDefinition,
|
|
|
|
|
RuntimeTeleportTarget
|
|
|
|
|
} from "./runtime-scene-build";
|
2026-03-31 03:04:15 +02:00
|
|
|
|
|
|
|
|
interface CachedMaterialTexture {
|
|
|
|
|
signature: string;
|
|
|
|
|
texture: ReturnType<typeof createStarterMaterialTexture>;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 20:07:11 +02:00
|
|
|
interface LocalLightRenderObjects {
|
|
|
|
|
group: Group;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 03:04:15 +02:00
|
|
|
const FALLBACK_FACE_COLOR = 0x747d89;
|
|
|
|
|
|
|
|
|
|
export class RuntimeHost {
|
|
|
|
|
private readonly scene = new Scene();
|
|
|
|
|
private readonly camera = new PerspectiveCamera(70, 1, 0.05, 1000);
|
2026-03-31 06:46:27 +02:00
|
|
|
private readonly cameraForward = new Vector3();
|
2026-03-31 06:25:22 +02:00
|
|
|
private readonly domElement: HTMLCanvasElement;
|
2026-03-31 03:04:15 +02:00
|
|
|
private readonly ambientLight = new AmbientLight();
|
|
|
|
|
private readonly sunLight = new DirectionalLight();
|
2026-03-31 20:06:54 +02:00
|
|
|
private readonly localLightGroup = new Group();
|
2026-03-31 03:04:15 +02:00
|
|
|
private readonly brushGroup = new Group();
|
2026-03-31 17:40:12 +02:00
|
|
|
private readonly modelGroup = new Group();
|
2026-03-31 03:04:15 +02:00
|
|
|
private readonly firstPersonController = new FirstPersonNavigationController();
|
|
|
|
|
private readonly orbitVisitorController = new OrbitVisitorNavigationController();
|
2026-03-31 06:17:09 +02:00
|
|
|
private readonly interactionSystem = new RuntimeInteractionSystem();
|
2026-03-31 03:04:15 +02:00
|
|
|
private readonly brushMeshes = new Map<string, Mesh<BoxGeometry, MeshStandardMaterial[]>>();
|
2026-03-31 20:06:54 +02:00
|
|
|
private readonly localLightObjects = new Map<string, Group>();
|
2026-03-31 17:40:12 +02:00
|
|
|
private readonly modelRenderObjects = new Map<string, Group>();
|
2026-03-31 03:04:15 +02:00
|
|
|
private readonly materialTextureCache = new Map<string, CachedMaterialTexture>();
|
|
|
|
|
private readonly controllerContext: RuntimeControllerContext;
|
2026-03-31 06:25:22 +02:00
|
|
|
private readonly renderer: WebGLRenderer | null;
|
2026-03-31 17:40:12 +02:00
|
|
|
private runtimeScene: RuntimeSceneDefinition | null = null;
|
2026-03-31 20:06:54 +02:00
|
|
|
private currentWorld: RuntimeSceneDefinition["world"] | null = null;
|
2026-03-31 17:40:12 +02:00
|
|
|
private projectAssets: Record<string, ProjectAssetRecord> = {};
|
|
|
|
|
private loadedModelAssets: Record<string, LoadedModelAsset> = {};
|
2026-03-31 20:06:54 +02:00
|
|
|
private loadedImageAssets: Record<string, LoadedImageAsset> = {};
|
2026-03-31 03:04:15 +02:00
|
|
|
private resizeObserver: ResizeObserver | null = null;
|
|
|
|
|
private animationFrame = 0;
|
|
|
|
|
private previousFrameTime = 0;
|
|
|
|
|
private container: HTMLElement | null = null;
|
|
|
|
|
private activeController: NavigationController | null = null;
|
|
|
|
|
private runtimeMessageHandler: ((message: string | null) => void) | null = null;
|
|
|
|
|
private firstPersonTelemetryHandler: ((telemetry: FirstPersonTelemetry | null) => void) | null = null;
|
2026-03-31 06:46:27 +02:00
|
|
|
private interactionPromptHandler: ((prompt: RuntimeInteractionPrompt | null) => void) | null = null;
|
2026-03-31 03:04:15 +02:00
|
|
|
private currentRuntimeMessage: string | null = null;
|
|
|
|
|
private currentFirstPersonTelemetry: FirstPersonTelemetry | null = null;
|
2026-03-31 06:46:27 +02:00
|
|
|
private currentInteractionPrompt: RuntimeInteractionPrompt | null = null;
|
2026-03-31 03:04:15 +02:00
|
|
|
|
2026-03-31 06:25:22 +02:00
|
|
|
constructor(options: { enableRendering?: boolean } = {}) {
|
|
|
|
|
const enableRendering = options.enableRendering ?? true;
|
|
|
|
|
|
2026-03-31 03:04:15 +02:00
|
|
|
this.scene.add(this.ambientLight);
|
|
|
|
|
this.scene.add(this.sunLight);
|
2026-03-31 20:06:54 +02:00
|
|
|
this.scene.add(this.localLightGroup);
|
2026-03-31 03:04:15 +02:00
|
|
|
this.scene.add(this.brushGroup);
|
2026-03-31 17:40:12 +02:00
|
|
|
this.scene.add(this.modelGroup);
|
2026-03-31 06:25:22 +02:00
|
|
|
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";
|
|
|
|
|
}
|
2026-03-31 03:04:15 +02:00
|
|
|
|
|
|
|
|
this.controllerContext = {
|
|
|
|
|
camera: this.camera,
|
2026-03-31 06:25:22 +02:00
|
|
|
domElement: this.domElement,
|
2026-03-31 03:04:15 +02:00
|
|
|
getRuntimeScene: () => {
|
|
|
|
|
if (this.runtimeScene === null) {
|
|
|
|
|
throw new Error("Runtime scene has not been loaded.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this.runtimeScene;
|
|
|
|
|
},
|
|
|
|
|
setRuntimeMessage: (message) => {
|
|
|
|
|
if (message === this.currentRuntimeMessage) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.currentRuntimeMessage = message;
|
|
|
|
|
this.runtimeMessageHandler?.(message);
|
|
|
|
|
},
|
|
|
|
|
setFirstPersonTelemetry: (telemetry) => {
|
|
|
|
|
this.currentFirstPersonTelemetry = telemetry;
|
|
|
|
|
this.firstPersonTelemetryHandler?.(telemetry);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mount(container: HTMLElement) {
|
|
|
|
|
this.container = container;
|
2026-03-31 06:25:22 +02:00
|
|
|
container.appendChild(this.domElement);
|
2026-03-31 06:46:27 +02:00
|
|
|
this.domElement.addEventListener("click", this.handleRuntimeClick);
|
2026-03-31 03:04:15 +02:00
|
|
|
this.resize();
|
|
|
|
|
|
|
|
|
|
this.resizeObserver = new ResizeObserver(() => {
|
|
|
|
|
this.resize();
|
|
|
|
|
});
|
|
|
|
|
this.resizeObserver.observe(container);
|
|
|
|
|
|
|
|
|
|
this.previousFrameTime = performance.now();
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loadScene(runtimeScene: RuntimeSceneDefinition) {
|
|
|
|
|
this.runtimeScene = runtimeScene;
|
2026-03-31 20:07:11 +02:00
|
|
|
this.currentWorld = runtimeScene.world;
|
2026-03-31 06:17:09 +02:00
|
|
|
this.interactionSystem.reset();
|
2026-03-31 06:46:27 +02:00
|
|
|
this.setInteractionPrompt(null);
|
2026-03-31 20:07:11 +02:00
|
|
|
this.applyWorld();
|
|
|
|
|
this.rebuildLocalLights(runtimeScene.localLights);
|
2026-03-31 03:04:15 +02:00
|
|
|
this.rebuildBrushMeshes(runtimeScene.brushes);
|
2026-03-31 17:40:12 +02:00
|
|
|
this.rebuildModelInstances(runtimeScene.modelInstances);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 20:07:11 +02:00
|
|
|
updateAssets(
|
|
|
|
|
projectAssets: Record<string, ProjectAssetRecord>,
|
|
|
|
|
loadedModelAssets: Record<string, LoadedModelAsset>,
|
|
|
|
|
loadedImageAssets: Record<string, LoadedImageAsset>
|
|
|
|
|
) {
|
2026-03-31 17:40:12 +02:00
|
|
|
this.projectAssets = projectAssets;
|
|
|
|
|
this.loadedModelAssets = loadedModelAssets;
|
2026-03-31 20:07:11 +02:00
|
|
|
this.loadedImageAssets = loadedImageAssets;
|
|
|
|
|
|
|
|
|
|
if (this.currentWorld !== null) {
|
|
|
|
|
this.applyWorld();
|
|
|
|
|
}
|
2026-03-31 17:40:12 +02:00
|
|
|
|
|
|
|
|
if (this.runtimeScene !== null) {
|
|
|
|
|
this.rebuildModelInstances(this.runtimeScene.modelInstances);
|
|
|
|
|
}
|
2026-03-31 03:04:15 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setNavigationMode(mode: RuntimeNavigationMode) {
|
|
|
|
|
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);
|
2026-03-31 06:17:31 +02:00
|
|
|
this.interactionSystem.reset();
|
2026-03-31 06:46:27 +02:00
|
|
|
this.setInteractionPrompt(null);
|
2026-03-31 03:04:15 +02:00
|
|
|
this.activeController = nextController;
|
|
|
|
|
this.activeController.activate(this.controllerContext);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setRuntimeMessageHandler(handler: ((message: string | null) => void) | null) {
|
|
|
|
|
this.runtimeMessageHandler = handler;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setFirstPersonTelemetryHandler(handler: ((telemetry: FirstPersonTelemetry | null) => void) | null) {
|
|
|
|
|
this.firstPersonTelemetryHandler = handler;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 06:46:27 +02:00
|
|
|
setInteractionPromptHandler(handler: ((prompt: RuntimeInteractionPrompt | null) => void) | null) {
|
|
|
|
|
this.interactionPromptHandler = handler;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 03:04:15 +02:00
|
|
|
dispose() {
|
|
|
|
|
if (this.animationFrame !== 0) {
|
|
|
|
|
cancelAnimationFrame(this.animationFrame);
|
|
|
|
|
this.animationFrame = 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.activeController?.deactivate(this.controllerContext);
|
|
|
|
|
this.activeController = null;
|
2026-03-31 06:46:27 +02:00
|
|
|
this.setInteractionPrompt(null);
|
2026-03-31 03:04:15 +02:00
|
|
|
this.resizeObserver?.disconnect();
|
|
|
|
|
this.resizeObserver = null;
|
2026-03-31 20:07:11 +02:00
|
|
|
this.clearLocalLights();
|
2026-03-31 03:04:15 +02:00
|
|
|
this.clearBrushMeshes();
|
2026-03-31 17:40:12 +02:00
|
|
|
this.clearModelInstances();
|
2026-03-31 03:04:15 +02:00
|
|
|
|
|
|
|
|
for (const cachedTexture of this.materialTextureCache.values()) {
|
|
|
|
|
cachedTexture.texture.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.materialTextureCache.clear();
|
2026-03-31 06:25:22 +02:00
|
|
|
this.renderer?.dispose();
|
2026-03-31 06:46:27 +02:00
|
|
|
this.domElement.removeEventListener("click", this.handleRuntimeClick);
|
2026-03-31 03:04:15 +02:00
|
|
|
|
2026-03-31 06:25:22 +02:00
|
|
|
if (this.container !== null && this.container.contains(this.domElement)) {
|
|
|
|
|
this.container.removeChild(this.domElement);
|
2026-03-31 03:04:15 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.container = null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 20:07:11 +02:00
|
|
|
private 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;
|
2026-03-31 03:04:15 +02:00
|
|
|
this.sunLight.position
|
|
|
|
|
.set(
|
2026-03-31 20:07:11 +02:00
|
|
|
world.sunLight.direction.x,
|
|
|
|
|
world.sunLight.direction.y,
|
|
|
|
|
world.sunLight.direction.z
|
2026-03-31 03:04:15 +02:00
|
|
|
)
|
|
|
|
|
.normalize()
|
|
|
|
|
.multiplyScalar(18);
|
2026-03-31 20:07:11 +02:00
|
|
|
|
|
|
|
|
if (world.background.mode === "image") {
|
|
|
|
|
this.scene.background = this.loadedImageAssets[world.background.assetId]?.texture ?? null;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.scene.background = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private rebuildLocalLights(localLights: RuntimeLocalLightCollection) {
|
|
|
|
|
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);
|
|
|
|
|
}
|
2026-03-31 03:04:15 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private rebuildBrushMeshes(brushes: RuntimeBoxBrushInstance[]) {
|
|
|
|
|
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);
|
|
|
|
|
this.brushGroup.add(mesh);
|
|
|
|
|
this.brushMeshes.set(brush.id, mesh);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 17:40:12 +02:00
|
|
|
private rebuildModelInstances(modelInstances: RuntimeSceneDefinition["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
|
|
|
|
|
},
|
|
|
|
|
asset,
|
|
|
|
|
loadedAsset,
|
|
|
|
|
false
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
this.modelGroup.add(renderGroup);
|
|
|
|
|
this.modelRenderObjects.set(modelInstance.instanceId, renderGroup);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 03:04:15 +02:00
|
|
|
private createFaceMaterial(material: RuntimeBoxBrushInstance["faces"]["posX"]["material"]): MeshStandardMaterial {
|
|
|
|
|
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
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getOrCreateTexture(material: NonNullable<RuntimeBoxBrushInstance["faces"]["posX"]["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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private 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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 17:40:12 +02:00
|
|
|
private clearModelInstances() {
|
|
|
|
|
for (const renderGroup of this.modelRenderObjects.values()) {
|
|
|
|
|
this.modelGroup.remove(renderGroup);
|
|
|
|
|
disposeModelInstance(renderGroup);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.modelRenderObjects.clear();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 03:04:15 +02:00
|
|
|
private 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();
|
2026-03-31 06:25:22 +02:00
|
|
|
this.domElement.width = width;
|
|
|
|
|
this.domElement.height = height;
|
|
|
|
|
this.renderer?.setSize(width, height, false);
|
2026-03-31 03:04:15 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private 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);
|
2026-03-31 06:17:09 +02:00
|
|
|
|
|
|
|
|
if (this.runtimeScene !== null && this.activeController === this.firstPersonController && this.currentFirstPersonTelemetry !== null) {
|
2026-03-31 06:46:27 +02:00
|
|
|
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);
|
2026-03-31 06:17:09 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-31 06:25:22 +02:00
|
|
|
this.renderer?.render(this.scene, this.camera);
|
2026-03-31 03:04:15 +02:00
|
|
|
};
|
2026-03-31 06:17:09 +02:00
|
|
|
|
|
|
|
|
private applyTeleportPlayerAction(target: RuntimeTeleportTarget) {
|
|
|
|
|
this.firstPersonController.teleportTo(target.position, target.yawDegrees);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private applyToggleBrushVisibilityAction(brushId: string, visible: boolean | undefined) {
|
|
|
|
|
const mesh = this.brushMeshes.get(brushId);
|
|
|
|
|
|
|
|
|
|
if (mesh === undefined) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mesh.visible = visible ?? !mesh.visible;
|
|
|
|
|
}
|
2026-03-31 06:46:27 +02:00
|
|
|
|
|
|
|
|
private createInteractionDispatcher(): RuntimeInteractionDispatcher {
|
|
|
|
|
return {
|
|
|
|
|
teleportPlayer: (target) => {
|
|
|
|
|
this.applyTeleportPlayerAction(target);
|
|
|
|
|
},
|
|
|
|
|
toggleBrushVisibility: (brushId, visible) => {
|
|
|
|
|
this.applyToggleBrushVisibilityAction(brushId, visible);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private setInteractionPrompt(prompt: RuntimeInteractionPrompt | null) {
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private handleRuntimeClick = () => {
|
|
|
|
|
if (this.runtimeScene === null || this.activeController !== this.firstPersonController || this.currentInteractionPrompt === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.interactionSystem.dispatchClickInteraction(this.currentInteractionPrompt.sourceEntityId, this.runtimeScene, this.createInteractionDispatcher());
|
|
|
|
|
};
|
2026-03-31 03:04:15 +02:00
|
|
|
}
|