import { AmbientLight, AnimationClip, AnimationMixer, BufferGeometry, BoxGeometry, CapsuleGeometry, Color, ConeGeometry, DirectionalLight, Euler, Group, FogExp2, LoopOnce, LoopRepeat, Matrix4, Material, Mesh, MeshBasicMaterial, MeshPhysicalMaterial, MeshStandardMaterial, OrthographicCamera, PerspectiveCamera, PointLight, Quaternion, Scene, ShaderMaterial, Vector3, SpotLight, TextureLoader, Texture, WebGLRenderTarget, WebGLRenderer } from "three"; import { EffectComposer } from "postprocessing"; import { createModelInstanceRenderGroup, disposeModelInstance } from "../assets/model-instance-rendering"; import type { LoadedModelAsset } from "../assets/gltf-model-import"; import type { LoadedImageAsset } from "../assets/image-assets"; import type { LoadedAudioAsset } from "../assets/audio-assets"; import type { ProjectAssetRecord } from "../assets/project-assets"; import { cloneFaceUvState, type Brush, type WhiteboxFaceId } from "../document/brushes"; import { mapWorldPointToScenePathProgressBetweenPoints, resolveNearestPointOnResolvedScenePath, sampleResolvedScenePathPosition } from "../document/paths"; import { applyControlEffectToResolvedState, createDefaultResolvedControlSource, createInteractionLinkResolvedControlSource, type ActorControlTargetRef, type ControlEffect, type InteractionControlTargetRef, type LightControlTargetRef, type ModelInstanceControlTargetRef, type RuntimeResolvedControlChannelValue, type RuntimeResolvedDiscreteControlState, type SceneControlTargetRef, type SoundEmitterControlTargetRef } from "../controls/control-surface"; import { buildBoxBrushDerivedMeshData } from "../geometry/box-brush-mesh"; import { buildTerrainDerivedMeshData } from "../geometry/terrain-mesh"; import { createStarterMaterialSignature, createStarterMaterialTextureSet, disposeStarterMaterialTextureSet, type StarterMaterialTextureSet } from "../materials/starter-material-textures"; import { applyAdvancedRenderingRenderableShadowFlags, configureAdvancedRenderingShadowLight, configureAdvancedRenderingRenderer, createAdvancedRenderingComposer, resolveBoxVolumeRenderPaths, type ResolvedBoxVolumeRenderPaths } from "../rendering/advanced-rendering"; import { fitCelestialDirectionalShadow, resolveDominantCelestialShadowCaster } from "../rendering/celestial-shadows"; import { resolveWorldShaderSkyEnvironmentPhaseStates, resolveWorldShaderSkyRenderState } from "../rendering/world-shader-sky"; import { resolveWorldCelestialBodiesState, resolveWorldEnvironmentState, WorldBackgroundRenderer } from "../rendering/world-background-renderer"; import { createRendererPrecomputedShaderSkyEnvironmentCache, type PrecomputedShaderSkyEnvironmentCache } from "../rendering/precomputed-shader-sky-environment-cache"; import { createRendererQuantizedEnvironmentBlendCache, createRendererQuantizedPmremBlendCache, type QuantizedEnvironmentBlendCache } from "../rendering/quantized-environment-blend-cache"; import { collectWaterContactPatches, createWaterContactPatchAxisUniformValue, createWaterContactPatchShapeUniformValue, createWaterContactPatchUniformValue, createWaterMaterial } from "../rendering/water-material"; import { createFogQualityMaterial } from "../rendering/fog-material"; import { updatePlanarReflectionCamera } from "../rendering/planar-reflection"; import { createTerrainLayerBlendMaterial, getTerrainLayerTexture } from "../rendering/terrain-layer-material"; import { applyWhiteboxBevelToMaterial, shouldApplyWhiteboxBevel } from "../rendering/whitebox-bevel-material"; import { areAdvancedRenderingSettingsEqual, cloneAdvancedRenderingSettings, type AdvancedRenderingSettings } from "../document/world-settings"; import { getNpcColliderHeight } from "../entities/entity-instances"; import type { InteractionLink } from "../interactions/interaction-links"; import type { ImpulseSequenceStep, SequenceVisibilityMode, SequenceVisibilityTarget } from "../sequencer/project-sequence-steps"; import { FirstPersonNavigationController } from "./first-person-navigation-controller"; import type { NavigationController, PlayerControllerTelemetry, RuntimeControllerContext, RuntimePlayerAudioHookState, RuntimePlayerVolumeState } from "./navigation-controller"; import { RapierCollisionWorld } from "./rapier-collision-world"; import { RuntimeInteractionSystem, type RuntimeDialogueStartSource, type RuntimeInteractionDispatcher, type RuntimeInteractionPrompt } from "./runtime-interaction-system"; import { RuntimeAudioSystem } from "./runtime-audio-system"; import { advanceRuntimeClockState, areRuntimeClockStatesEqual, cloneRuntimeClockState, createRuntimeClockState, reconfigureRuntimeClockState, resolveRuntimeDayNightWorldState, resolveRuntimeTimeState, type RuntimeClockState } from "./runtime-project-time"; import { resolveRuntimePlayerMovementHooks } from "./player-controller-telemetry"; import { resolveDialogueAttentionCameraSolution, type DialogueAttentionSideSign } from "./dialogue-attention-camera"; import { applyRuntimeProjectScheduleToControlState, resolveRuntimeProjectScheduleState } from "./runtime-project-scheduler"; import { ThirdPersonNavigationController } from "./third-person-navigation-controller"; import { resolveUnderwaterFogState } from "./underwater-fog"; import { resolveWaterContact } from "./water-volume-utils"; import type { RuntimeBrushFace, RuntimeCameraRig, RuntimeNpc, RuntimeNpcDefinition, RuntimeBoxBrushInstance, RuntimeLocalLightCollection, RuntimeNavigationMode, RuntimeNpc, RuntimeSceneDefinition, RuntimeTerrain, RuntimeTeleportTarget } from "./runtime-scene-build"; import { applyActorScheduleStateToNpcDefinition, buildRuntimeNpcCollider, createRuntimeNpcFromDefinition } from "./runtime-scene-build"; import { resolvePlayerStartLookInput, resolvePlayerStartPauseInput } from "./player-input-bindings"; interface CachedMaterialTexture { signature: string; textureSet: StarterMaterialTextureSet; } function createRuntimeGeometryBrush(brush: RuntimeBoxBrushInstance): Brush { const faces = Object.fromEntries( Object.entries(brush.faces).map(([faceId, face]) => [ faceId, { materialId: face.materialId, uv: cloneFaceUvState(face.uv) } ]) ); const base = { id: brush.id, name: undefined, visible: brush.visible, enabled: true, center: brush.center, rotationDegrees: brush.rotationDegrees, size: brush.size, volume: brush.volume }; switch (brush.kind) { case "box": return { ...base, kind: "box", geometry: brush.geometry as Brush["geometry"], faces: faces as unknown as Brush["faces"] } as Brush; case "wedge": return { ...base, kind: "wedge", geometry: brush.geometry as Brush["geometry"], faces: faces as unknown as Brush["faces"] } as Brush; case "radialPrism": return { ...base, kind: "radialPrism", sideCount: brush.sideCount ?? 12, geometry: brush.geometry as Brush["geometry"], faces: faces as unknown as Brush["faces"] } as Brush; case "cone": return { ...base, kind: "cone", sideCount: brush.sideCount ?? 12, geometry: brush.geometry as Brush["geometry"], faces: faces as unknown as Brush["faces"] } as Brush; case "torus": return { ...base, kind: "torus", majorSegmentCount: brush.majorSegmentCount ?? 16, tubeSegmentCount: brush.tubeSegmentCount ?? 8, geometry: brush.geometry as Brush["geometry"], faces: faces as unknown as Brush["faces"] } as Brush; } } function isEditableEventTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) { return false; } const tagName = target.tagName.toLowerCase(); return ( target.isContentEditable || tagName === "input" || tagName === "textarea" || tagName === "select" || tagName === "button" ); } interface LocalLightRenderObjects { group: Group; light: PointLight | SpotLight; } interface LightVolumeRenderObjects { group: Group; lights: PointLight[]; } interface RuntimeWaterContactUniformBinding { brush: RuntimeBoxBrushInstance; uniform: { value: import("three").Vector4[] }; axisUniform: { value: import("three").Vector2[] }; shapeUniform: { value: number[] }; staticContactPatches: ReturnType; reflectionTextureUniform: { value: import("three").Texture | null } | null; reflectionMatrixUniform: { value: Matrix4 } | null; reflectionEnabledUniform: { value: number } | null; reflectionRenderTarget: WebGLRenderTarget | null; lastReflectionUpdateTime: number; } const FALLBACK_FACE_COLOR = 0xf2ece2; const RUNTIME_CLOCK_PUBLISH_INTERVAL_SECONDS = 1 / 30; const WATER_REFLECTION_UPDATE_INTERVAL_MS = 96; const CAMERA_RIG_POINTER_LOOK_SENSITIVITY = 0.004; const CAMERA_RIG_GAMEPAD_LOOK_SPEED = 2.2; const DIALOGUE_ATTENTION_CAMERA_TRANSITION_DURATION_SECONDS = 0.35; const DIALOGUE_ATTENTION_PLAYER_FOCUS_HEIGHT_FACTOR = 0.82; const DIALOGUE_ATTENTION_NPC_FOCUS_HEIGHT_FACTOR = 0.88; function dampScalar(current: number, target: number, rate: number, dt: number) { return current + (target - current) * Math.min(1, dt * rate); } function clampScalar(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)); } function isNonNull(value: T | null): value is T { return value !== null; } export interface RuntimeSceneLoadState { status: "loading" | "ready" | "error"; message: string | null; } export interface RuntimeSceneTransitionRequest { sourceEntityId: string | null; targetSceneId: string; targetEntryEntityId: string; } export interface RuntimeDialogueState { npcEntityId: string; dialogueId: string; title: string; lineId: string; lineIndex: number; lineCount: number; speakerName: string; text: string; source: RuntimeDialogueStartSource; } export interface RuntimePauseState { paused: boolean; source: "manual" | "control" | "dialogue" | "mixed" | null; } type RuntimeCameraSourceKey = | "gameplay" | `rig:${string}` | `dialogue:${string}`; interface RuntimeCameraPose { position: Vector3; lookTarget: Vector3; } interface RuntimeCameraTransitionState { durationSeconds: number; elapsedSeconds: number; fromPose: RuntimeCameraPose; toPose: RuntimeCameraPose; destinationSourceKey: RuntimeCameraSourceKey; } interface RuntimeDialogueAttentionState { npcEntityId: string; sideSign: DialogueAttentionSideSign; } type RuntimeResolvedCameraSource = | { kind: "gameplay"; } | { kind: "rig"; rig: RuntimeCameraRig; } | { kind: "dialogue"; state: RuntimeDialogueAttentionState; }; export class RuntimeHost { private readonly scene = new Scene(); private readonly worldBackgroundRenderer = new WorldBackgroundRenderer(); private readonly camera = new PerspectiveCamera(70, 1, 0.05, 1000); private readonly cameraForward = new Vector3(); private readonly cameraRigLookTarget = new Vector3(); private readonly cameraRigDirection = new Vector3(); private readonly cameraRigForward = new Vector3(); private readonly volumeOffset = new Vector3(); private readonly volumeInverseRotation = new Quaternion(); private readonly fogLocalCameraPosition = new Vector3(); private readonly domElement: HTMLCanvasElement; private readonly ambientLight = new AmbientLight(); private readonly sunLight = new DirectionalLight(); private readonly moonLight = new DirectionalLight(); private readonly localLightGroup = new Group(); private readonly lightVolumeGroup = new Group(); private readonly brushGroup = new Group(); private readonly terrainGroup = new Group(); private readonly modelGroup = new Group(); private readonly firstPersonController = new FirstPersonNavigationController(); private readonly thirdPersonController = new ThirdPersonNavigationController(); private readonly interactionSystem = new RuntimeInteractionSystem(); private readonly audioSystem = new RuntimeAudioSystem( this.scene, this.camera, null ); private readonly underwaterSceneFog = new FogExp2("#2c6f8d", 0.03); private readonly waterReflectionCamera = new PerspectiveCamera(); private readonly brushMeshes = new Map< string, Mesh >(); private readonly terrainMeshes = new Map< string, Mesh >(); private volumeTime = 0; private readonly volumeAnimatedUniforms: Array<{ value: number }> = []; private readonly runtimeWaterContactUniforms: RuntimeWaterContactUniformBinding[] = []; private readonly localLightObjects = new Map< string, LocalLightRenderObjects >(); private readonly lightVolumeObjects = new Map< string, LightVolumeRenderObjects >(); private readonly modelRenderObjects = new Map(); private readonly materialTextureCache = new Map< string, CachedMaterialTexture >(); private readonly materialTextureLoader = new TextureLoader(); private readonly animationMixers = new Map(); private readonly instanceAnimationClips = new Map(); private readonly controllerContext: RuntimeControllerContext; private readonly renderer: WebGLRenderer | null; private readonly environmentBlendCache: QuantizedEnvironmentBlendCache | null; private readonly shaderSkyEnvironmentBlendCache: QuantizedEnvironmentBlendCache | null; private readonly shaderSkyEnvironmentCache: PrecomputedShaderSkyEnvironmentCache | null; private runtimeScene: RuntimeSceneDefinition | null = null; private collisionWorld: RapierCollisionWorld | null = null; private collisionWorldRequestId = 0; private desiredNavigationMode: RuntimeNavigationMode = "thirdPerson"; private sceneReady = false; private currentWorld: RuntimeSceneDefinition["world"] | null = null; private currentAdvancedRenderingSettings: AdvancedRenderingSettings | null = null; private advancedRenderingComposer: EffectComposer | null = null; private projectAssets: Record = {}; private loadedModelAssets: Record = {}; private loadedImageAssets: Record = {}; 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 playerControllerTelemetryHandler: | ((telemetry: PlayerControllerTelemetry | null) => void) | null = null; private interactionPromptHandler: | ((prompt: RuntimeInteractionPrompt | null) => void) | null = null; private runtimeDialogueHandler: | ((dialogue: RuntimeDialogueState | null) => void) | null = null; private runtimePauseStateHandler: | ((state: RuntimePauseState) => void) | null = null; private sceneLoadStateHandler: | ((state: RuntimeSceneLoadState) => void) | null = null; private sceneTransitionHandler: | ((request: RuntimeSceneTransitionRequest) => void) | null = null; private currentRuntimeMessage: string | null = null; private currentPlayerControllerTelemetry: PlayerControllerTelemetry | null = null; private currentCelestialShadowCaster: "sun" | "moon" | null = null; private currentInteractionPrompt: RuntimeInteractionPrompt | null = null; private currentDialogue: RuntimeDialogueState | null = null; private currentPauseState: RuntimePauseState = { paused: false, source: null }; private currentSceneLoadState: RuntimeSceneLoadState | null = null; private currentClockState: RuntimeClockState | null = null; private lastPublishedClockState: RuntimeClockState | null = null; private currentPlayerAudioHooks: RuntimePlayerAudioHookState | null = null; private activeCameraRigOverrideEntityId: string | null = null; private activeCameraSourceKey: RuntimeCameraSourceKey | null = null; private activeRuntimeCameraRig: RuntimeCameraRig | null = null; private activeDialogueAttentionState: RuntimeDialogueAttentionState | null = null; private cameraTransitionState: RuntimeCameraTransitionState | null = null; private suppressNextCameraSourceTransition = false; private cameraRigLookYawRadians = 0; private cameraRigLookPitchRadians = 0; private cameraRigLookDragging = false; private lastCameraRigPointerClientX = 0; private lastCameraRigPointerClientY = 0; private runtimeClockStateHandler: | ((state: RuntimeClockState) => void) | null = null; private clockPublishAccumulator = 0; private cameraEffectVerticalOffset = 0; private cameraEffectVerticalVelocity = 0; private cameraEffectPitchOffset = 0; private cameraEffectPitchVelocity = 0; private cameraEffectRollOffset = 0; private baseCameraFov = 70; private manualPauseActive = false; private controlPauseActive = false; private dialoguePauseActive = false; private previousPauseInputActive = false; private readonly pressedKeys = new Set(); private activeScheduledImpulseRoutineIds = new Set(); private completedScheduledImpulseRoutineIds = new Set(); constructor(options: { enableRendering?: boolean } = {}) { const enableRendering = options.enableRendering ?? true; this.scene.add(this.ambientLight); this.scene.add(this.sunLight); this.scene.add(this.sunLight.target); this.scene.add(this.moonLight); this.scene.add(this.moonLight.target); this.scene.add(this.localLightGroup); this.scene.add(this.lightVolumeGroup); this.scene.add(this.brushGroup); this.scene.add(this.terrainGroup); this.scene.add(this.modelGroup); this.underwaterSceneFog.density = 0; this.scene.fog = this.underwaterSceneFog; this.renderer = enableRendering ? new WebGLRenderer({ antialias: false, alpha: true }) : null; this.domElement = this.renderer?.domElement ?? document.createElement("canvas"); if (this.renderer !== null) { this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.renderer.setClearAlpha(0); } else { this.domElement.className = "runner-canvas__surface"; } this.moonLight.intensity = 0; this.moonLight.visible = false; this.environmentBlendCache = this.renderer === null ? null : createRendererQuantizedEnvironmentBlendCache(this.renderer, { onTextureReady: () => { this.applyDayNightLighting(); } }); this.shaderSkyEnvironmentBlendCache = this.renderer === null ? null : createRendererQuantizedPmremBlendCache(this.renderer, { onTextureReady: () => { this.applyDayNightLighting(); } }); this.shaderSkyEnvironmentCache = this.renderer === null ? null : createRendererPrecomputedShaderSkyEnvironmentCache( this.renderer, this.worldBackgroundRenderer, { phaseBlendTextureResolver: this.shaderSkyEnvironmentBlendCache, captureSize: 32 } ); 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, probePlayerGround: (feetPosition, shape, maxDistance) => this.collisionWorld?.probePlayerGround( feetPosition, shape, maxDistance ) ?? { grounded: false, distance: null, normal: null, slopeDegrees: null }, canOccupyPlayerShape: (feetPosition, shape) => this.collisionWorld?.canOccupyPlayerShape(feetPosition, shape) ?? true, resolvePlayerVolumeState: (feetPosition) => this.resolvePlayerVolumeState(feetPosition), resolveThirdPersonCameraCollision: ( pivot, desiredCameraPosition, radius ) => this.collisionWorld?.resolveThirdPersonCameraCollision( pivot, desiredCameraPosition, radius ) ?? { ...desiredCameraPosition }, isCameraDrivenExternally: () => this.resolveActiveRuntimeCameraRig() !== null || this.resolveDialogueAttentionNpc() !== null, getCameraYawRadians: () => { this.camera.getWorldDirection(this.cameraForward); return Math.atan2(this.cameraForward.x, this.cameraForward.z); }, isInputSuspended: () => this.isRuntimePaused(), setRuntimeMessage: (message) => { if (message === this.currentRuntimeMessage) { return; } this.currentRuntimeMessage = message; this.runtimeMessageHandler?.(message); }, setPlayerControllerTelemetry: (telemetry) => { this.currentPlayerControllerTelemetry = telemetry; this.currentPlayerAudioHooks = telemetry?.hooks.audio ?? null; this.playerControllerTelemetryHandler?.(telemetry); } }; } private resolvePlayerVolumeState(feetPosition: { x: number; y: number; z: number; }): RuntimePlayerVolumeState { if (this.runtimeScene === null) { return { inWater: false, inFog: false, waterSurfaceHeight: null }; } const waterContact = resolveWaterContact( feetPosition, this.runtimeScene.volumes.water ); const inFog = this.runtimeScene.volumes.fog.some((volume) => this.isPointInsideOrientedVolume(feetPosition, volume) ); return { inWater: waterContact !== null, inFog, waterSurfaceHeight: waterContact?.surfaceHeight ?? null }; } private isPointInsideOrientedVolume( point: { x: number; y: number; z: number }, volume: { center: { x: number; y: number; z: number }; rotationDegrees: { x: number; y: number; z: number }; size: { x: number; y: number; z: number }; } ): boolean { this.volumeOffset.set( point.x - volume.center.x, point.y - volume.center.y, point.z - volume.center.z ); this.volumeInverseRotation .setFromEuler( new Euler( (volume.rotationDegrees.x * Math.PI) / 180, (volume.rotationDegrees.y * Math.PI) / 180, (volume.rotationDegrees.z * Math.PI) / 180, "XYZ" ) ) .invert(); this.volumeOffset.applyQuaternion(this.volumeInverseRotation); const halfX = volume.size.x * 0.5; const halfY = volume.size.y * 0.5; const halfZ = volume.size.z * 0.5; return ( Math.abs(this.volumeOffset.x) <= halfX && Math.abs(this.volumeOffset.y) <= halfY && Math.abs(this.volumeOffset.z) <= halfZ ); } mount(container: HTMLElement) { this.container = container; container.appendChild(this.domElement); this.domElement.addEventListener("click", this.handleRuntimeClick); this.domElement.addEventListener( "pointerdown", this.handleRuntimePointerDown ); this.domElement.addEventListener("wheel", this.handleRuntimeWheel, { passive: false }); window.addEventListener("keydown", this.handleRuntimeKeyDown); window.addEventListener("keyup", this.handleRuntimeKeyUp); window.addEventListener("pointermove", this.handleRuntimePointerMove); window.addEventListener("pointerup", this.handleRuntimePointerUp); window.addEventListener("blur", this.handleRuntimeBlur); this.resize(); this.resizeObserver = new ResizeObserver(() => { this.resize(); }); this.resizeObserver.observe(container); this.previousFrameTime = performance.now(); this.render(); } loadScene(runtimeScene: RuntimeSceneDefinition) { const requestId = ++this.collisionWorldRequestId; const preservePointerLockDuringLoad = this.activeController === this.firstPersonController && this.desiredNavigationMode === "firstPerson" && document.pointerLockElement === this.domElement; this.sceneReady = false; this.runtimeScene = runtimeScene; this.currentWorld = runtimeScene.world; this.activeScheduledImpulseRoutineIds.clear(); this.syncRuntimeClockState(runtimeScene.time); this.syncRuntimeScheduleToCurrentClock(); this.activeController?.deactivate(this.controllerContext, { releasePointerLock: !preservePointerLockDuringLoad }); this.activeController = null; this.firstPersonController.resetSceneState(); this.thirdPersonController.resetSceneState(); this.interactionSystem.reset(); this.setInteractionPrompt(null); this.setRuntimeDialogue(null); this.manualPauseActive = false; this.controlPauseActive = false; this.dialoguePauseActive = false; this.previousPauseInputActive = false; this.cameraRigLookDragging = false; this.cameraRigLookYawRadians = 0; this.cameraRigLookPitchRadians = 0; this.activeCameraSourceKey = null; this.activeRuntimeCameraRig = null; this.activeDialogueAttentionState = null; this.cameraTransitionState = null; this.suppressNextCameraSourceTransition = true; this.pressedKeys.clear(); this.publishRuntimePauseState(true); this.currentPlayerControllerTelemetry = null; this.currentPlayerAudioHooks = null; this.playerControllerTelemetryHandler?.(null); this.currentRuntimeMessage = null; this.runtimeMessageHandler?.(null); this.resetPlayerCameraEffects(); this.clearCollisionWorld(); this.publishSceneLoadState({ status: "loading", message: null }); this.syncResolvedControlStateToRuntime(runtimeScene.control.resolved); this.applyWorld(); this.rebuildLocalLights(runtimeScene.localLights); this.rebuildLightVolumes(runtimeScene.volumes.light); this.rebuildBrushMeshes(runtimeScene.brushes); this.rebuildTerrainMeshes(runtimeScene.terrains); this.rebuildModelRenderObjects( runtimeScene.modelInstances, runtimeScene.npcDefinitions ); this.audioSystem.loadScene(runtimeScene); void this.finalizeSceneLoad( requestId, runtimeScene.colliders, runtimeScene.playerCollider, runtimeScene.playerMovement ); } updateAssets( projectAssets: Record, loadedModelAssets: Record, loadedImageAssets: Record, loadedAudioAssets: Record ) { this.projectAssets = projectAssets; this.loadedModelAssets = loadedModelAssets; this.loadedImageAssets = loadedImageAssets; this.environmentBlendCache?.clear(); if (this.currentWorld !== null) { this.applyWorld(); } if (this.runtimeScene !== null) { this.rebuildModelRenderObjects( this.runtimeScene.modelInstances, this.runtimeScene.npcDefinitions ); } this.audioSystem.updateAssets(projectAssets, loadedAudioAssets); } setNavigationMode(mode: RuntimeNavigationMode) { this.desiredNavigationMode = mode; if (this.runtimeScene === null || !this.sceneReady) { return; } this.activateDesiredNavigationController(); } setActiveCameraRigOverride(entityId: string | null) { const nextEntityId = entityId === null ? null : entityId.trim() || null; if (this.activeCameraRigOverrideEntityId === nextEntityId) { return; } this.activeCameraRigOverrideEntityId = nextEntityId; } setRuntimeMessageHandler(handler: ((message: string | null) => void) | null) { this.runtimeMessageHandler = handler; this.audioSystem.setRuntimeMessageHandler(handler); } setPlayerControllerTelemetryHandler( handler: ((telemetry: PlayerControllerTelemetry | null) => void) | null ) { this.playerControllerTelemetryHandler = handler; } setFirstPersonTelemetryHandler( handler: ((telemetry: PlayerControllerTelemetry | null) => void) | null ) { this.setPlayerControllerTelemetryHandler(handler); } setInteractionPromptHandler( handler: ((prompt: RuntimeInteractionPrompt | null) => void) | null ) { this.interactionPromptHandler = handler; } setRuntimeDialogueHandler( handler: ((dialogue: RuntimeDialogueState | null) => void) | null ) { this.runtimeDialogueHandler = handler; if (handler !== null && this.currentDialogue !== null) { handler(this.currentDialogue); } } setRuntimePauseStateHandler( handler: ((state: RuntimePauseState) => void) | null ) { this.runtimePauseStateHandler = handler; if (handler !== null) { handler({ ...this.currentPauseState }); } } setManualPause(paused: boolean) { this.setManualPauseActive(paused); } toggleManualPause() { this.setManualPauseActive(!this.manualPauseActive); } advanceRuntimeDialogue() { if (this.runtimeScene === null || this.currentDialogue === null) { return; } const npc = this.runtimeScene.entities.npcs.find( (candidate) => candidate.entityId === this.currentDialogue?.npcEntityId ) ?? null; const dialogue = npc?.dialogues.find( (candidate) => candidate.id === this.currentDialogue?.dialogueId ) ?? null; if (dialogue === null) { this.setRuntimeDialogue(null); return; } const nextLineIndex = this.currentDialogue.lineIndex + 1; if (nextLineIndex >= dialogue.lines.length) { this.setRuntimeDialogue(null); return; } this.setRuntimeDialogue( this.createRuntimeNpcDialogueState( this.currentDialogue.npcEntityId, dialogue.id, nextLineIndex, this.currentDialogue.source ) ); } closeRuntimeDialogue() { this.setRuntimeDialogue(null); } setSceneLoadStateHandler( handler: ((state: RuntimeSceneLoadState) => void) | null ) { this.sceneLoadStateHandler = handler; if (handler !== null && this.currentSceneLoadState !== null) { handler(this.currentSceneLoadState); } } setRuntimeClockStateHandler( handler: ((state: RuntimeClockState) => void) | null ) { this.runtimeClockStateHandler = handler; if (handler !== null && this.currentClockState !== null) { handler(cloneRuntimeClockState(this.currentClockState)); } } setSceneTransitionHandler( handler: ((request: RuntimeSceneTransitionRequest) => void) | null ) { this.sceneTransitionHandler = handler; } dispose() { if (this.animationFrame !== 0) { cancelAnimationFrame(this.animationFrame); this.animationFrame = 0; } this.activeController?.deactivate(this.controllerContext); this.activeController = null; this.resetPlayerCameraEffects(); this.setInteractionPrompt(null); this.resizeObserver?.disconnect(); this.resizeObserver = null; this.clearLocalLights(); this.clearLightVolumes(); this.clearBrushMeshes(); this.clearTerrainMeshes(); this.clearModelRenderObjects(); this.collisionWorldRequestId += 1; this.clearCollisionWorld(); this.audioSystem.dispose(); this.advancedRenderingComposer?.dispose(); this.advancedRenderingComposer = null; this.currentAdvancedRenderingSettings = null; this.scene.fog = null; this.currentClockState = null; this.lastPublishedClockState = null; this.activeScheduledImpulseRoutineIds.clear(); this.completedScheduledImpulseRoutineIds.clear(); this.manualPauseActive = false; this.controlPauseActive = false; this.dialoguePauseActive = false; this.previousPauseInputActive = false; this.cameraRigLookDragging = false; this.cameraRigLookYawRadians = 0; this.cameraRigLookPitchRadians = 0; this.activeCameraSourceKey = null; this.activeRuntimeCameraRig = null; this.activeDialogueAttentionState = null; this.cameraTransitionState = null; this.suppressNextCameraSourceTransition = false; this.pressedKeys.clear(); this.publishRuntimePauseState(true); if (this.renderer !== null) { this.renderer.autoClear = true; } for (const cachedTexture of this.materialTextureCache.values()) { disposeStarterMaterialTextureSet(cachedTexture.textureSet); } this.materialTextureCache.clear(); this.environmentBlendCache?.dispose(); this.shaderSkyEnvironmentBlendCache?.dispose(); this.shaderSkyEnvironmentCache?.dispose(); this.worldBackgroundRenderer.dispose(); this.renderer?.forceContextLoss(); this.renderer?.dispose(); this.domElement.removeEventListener("click", this.handleRuntimeClick); this.domElement.removeEventListener( "pointerdown", this.handleRuntimePointerDown ); this.domElement.removeEventListener("wheel", this.handleRuntimeWheel); window.removeEventListener("keydown", this.handleRuntimeKeyDown); window.removeEventListener("keyup", this.handleRuntimeKeyUp); window.removeEventListener("pointermove", this.handleRuntimePointerMove); window.removeEventListener("pointerup", this.handleRuntimePointerUp); window.removeEventListener("blur", this.handleRuntimeBlur); this.pressedKeys.clear(); if (this.container !== null && this.container.contains(this.domElement)) { this.container.removeChild(this.domElement); } this.container = null; } private publishSceneLoadState(state: RuntimeSceneLoadState) { if ( this.currentSceneLoadState?.status === state.status && this.currentSceneLoadState.message === state.message ) { return; } this.currentSceneLoadState = state; this.sceneLoadStateHandler?.(state); } private syncRuntimeClockState(timeSettings: RuntimeSceneDefinition["time"]) { this.currentClockState = this.currentClockState === null ? createRuntimeClockState(timeSettings) : reconfigureRuntimeClockState(this.currentClockState, timeSettings); this.clockPublishAccumulator = 0; this.publishRuntimeClockState(true); } private publishRuntimeClockState(force = false) { if (this.currentClockState === null) { return; } const nextState = cloneRuntimeClockState(this.currentClockState); if ( !force && this.lastPublishedClockState !== null && areRuntimeClockStatesEqual(this.lastPublishedClockState, nextState) ) { return; } this.lastPublishedClockState = nextState; this.runtimeClockStateHandler?.(cloneRuntimeClockState(nextState)); } private isRuntimePaused(): boolean { return ( this.manualPauseActive || this.controlPauseActive || this.dialoguePauseActive ); } private publishRuntimePauseState(force = false) { const pauseSources: RuntimePauseState["source"][] = []; if (this.manualPauseActive) { pauseSources.push("manual"); } if (this.controlPauseActive) { pauseSources.push("control"); } if (this.dialoguePauseActive) { pauseSources.push("dialogue"); } const nextState: RuntimePauseState = { paused: this.isRuntimePaused(), source: pauseSources.length === 0 ? null : pauseSources.length === 1 ? pauseSources[0] : "mixed" }; if ( !force && this.currentPauseState.paused === nextState.paused && this.currentPauseState.source === nextState.source ) { return; } this.currentPauseState = nextState; if (nextState.paused) { this.setInteractionPrompt(null); } this.runtimePauseStateHandler?.({ ...nextState }); } private setManualPauseActive(paused: boolean) { if (this.manualPauseActive === paused) { return; } this.manualPauseActive = paused; this.publishRuntimePauseState(); } private setControlPauseActive(paused: boolean) { if (this.controlPauseActive === paused) { return; } this.controlPauseActive = paused; this.publishRuntimePauseState(); } private setDialoguePauseActive(paused: boolean) { if (this.dialoguePauseActive === paused) { return; } this.dialoguePauseActive = paused; this.publishRuntimePauseState(); } private activateDesiredNavigationController() { if (this.runtimeScene === null || !this.sceneReady) { return; } const nextController = this.desiredNavigationMode === "firstPerson" ? this.firstPersonController : this.thirdPersonController; if (this.activeController?.id === nextController.id) { return; } this.activeController?.deactivate(this.controllerContext); this.interactionSystem.reset(); this.setInteractionPrompt(null); this.activeController = nextController; this.activeController.activate(this.controllerContext); } private resolveRuntimeEntityPositionById(entityId: string) { if (this.runtimeScene === null) { return null; } const playerStart = this.runtimeScene.entities.playerStarts.find( (candidate) => candidate.entityId === entityId ) ?? null; if (playerStart !== null) { return playerStart.position; } const sceneEntry = this.runtimeScene.entities.sceneEntries.find( (candidate) => candidate.entityId === entityId ) ?? null; if (sceneEntry !== null) { return sceneEntry.position; } const npc = this.runtimeScene.npcDefinitions.find( (candidate) => candidate.entityId === entityId ) ?? null; if (npc !== null) { return npc.position; } const soundEmitter = this.runtimeScene.entities.soundEmitters.find( (candidate) => candidate.entityId === entityId ) ?? null; if (soundEmitter !== null) { return soundEmitter.position; } const triggerVolume = this.runtimeScene.entities.triggerVolumes.find( (candidate) => candidate.entityId === entityId ) ?? null; if (triggerVolume !== null) { return triggerVolume.position; } const teleportTarget = this.runtimeScene.entities.teleportTargets.find( (candidate) => candidate.entityId === entityId ) ?? null; if (teleportTarget !== null) { return teleportTarget.position; } const interactable = this.runtimeScene.entities.interactables.find( (candidate) => candidate.entityId === entityId ) ?? null; if (interactable !== null) { return interactable.position; } const pointLight = this.runtimeScene.localLights.pointLights.find( (candidate) => candidate.entityId === entityId ) ?? null; if (pointLight !== null) { return pointLight.position; } const spotLight = this.runtimeScene.localLights.spotLights.find( (candidate) => candidate.entityId === entityId ) ?? null; if (spotLight !== null) { return spotLight.position; } return null; } private resolveDialogueAttentionNpc() { if (this.runtimeScene === null || this.currentDialogue === null) { return null; } return ( this.runtimeScene.entities.npcs.find( (candidate) => candidate.entityId === this.currentDialogue?.npcEntityId ) ?? null ); } private resolveDialogueAttentionPlayerFocusPoint() { if (this.runtimeScene === null) { return null; } const eyePosition = this.currentPlayerControllerTelemetry?.eyePosition ?? { x: this.runtimeScene.spawn.position.x, y: this.runtimeScene.spawn.position.y + this.runtimeScene.playerCollider.eyeHeight, z: this.runtimeScene.spawn.position.z }; const feetPosition = this.currentPlayerControllerTelemetry?.feetPosition ?? this.runtimeScene.spawn.position; return { x: feetPosition.x + (eyePosition.x - feetPosition.x) * 0.5, y: feetPosition.y + (eyePosition.y - feetPosition.y) * DIALOGUE_ATTENTION_PLAYER_FOCUS_HEIGHT_FACTOR, z: feetPosition.z + (eyePosition.z - feetPosition.z) * 0.5 }; } private resolveDialogueAttentionNpcFocusPoint(npc: RuntimeNpc) { return { x: npc.position.x, y: npc.position.y + npc.collider.eyeHeight * DIALOGUE_ATTENTION_NPC_FOCUS_HEIGHT_FACTOR, z: npc.position.z }; } private resolveRuntimeCameraRigTargetPosition(rig: RuntimeCameraRig) { if (this.runtimeScene === null) { return null; } switch (rig.target.kind) { case "player": return ( this.currentPlayerControllerTelemetry?.feetPosition ?? this.runtimeScene.playerStart?.position ?? this.runtimeScene.spawn.position ); case "actor": { const target = rig.target; const activeNpc = this.runtimeScene.npcDefinitions.find( (candidate) => candidate.actorId === target.actorId && candidate.active ) ?? this.runtimeScene.npcDefinitions.find( (candidate) => candidate.actorId === target.actorId ) ?? null; return activeNpc?.position ?? null; } case "entity": return this.resolveRuntimeEntityPositionById(rig.target.entityId); case "worldPoint": return rig.target.point; } } private resolveRuntimeCameraRigPosition(rig: RuntimeCameraRig) { if (this.runtimeScene === null) { return null; } switch (rig.rigType) { case "fixed": return rig.position; case "rail": { const path = this.runtimeScene.paths.find( (candidate) => candidate.id === rig.pathId ) ?? null; if (path === null) { return null; } const targetPosition = this.resolveRuntimeCameraRigTargetPosition(rig); if (targetPosition === null) { return null; } if (rig.railPlacementMode === "mapTargetBetweenPoints") { const mappedProgress = mapWorldPointToScenePathProgressBetweenPoints({ point: targetPosition, trackStartPoint: rig.trackStartPoint, trackEndPoint: rig.trackEndPoint, railStartProgress: rig.railStartProgress, railEndProgress: rig.railEndProgress }); return sampleResolvedScenePathPosition(path, mappedProgress.railProgress); } return resolveNearestPointOnResolvedScenePath(path, targetPosition).position; } } } private resolveRuntimeCameraRigLookTarget(rig: RuntimeCameraRig) { const targetPosition = this.resolveRuntimeCameraRigTargetPosition(rig); if (targetPosition === null) { return null; } return { x: targetPosition.x + rig.targetOffset.x, y: targetPosition.y + rig.targetOffset.y, z: targetPosition.z + rig.targetOffset.z }; } private resolveActiveRuntimeCameraRig() { if (this.runtimeScene === null) { return null; } const cameraRigs = this.runtimeScene.entities.cameraRigs; if (cameraRigs.length === 0) { return null; } if (this.activeCameraRigOverrideEntityId !== null) { return ( cameraRigs.find( (candidate) => candidate.entityId === this.activeCameraRigOverrideEntityId ) ?? null ); } const eligibleCameraRigs = cameraRigs.filter( (candidate) => candidate.defaultActive ); if (eligibleCameraRigs.length === 0) { return null; } return [...eligibleCameraRigs].sort( (left, right) => right.priority - left.priority || left.entityId.localeCompare(right.entityId) )[0]!; } private updateRuntimeCameraRigLookState(rig: RuntimeCameraRig, dt: number) { if (this.runtimeScene === null) { return; } if (rig.lookAround.enabled) { const lookInput = resolvePlayerStartLookInput( this.runtimeScene.playerInputBindings ); if (lookInput.horizontal !== 0 || lookInput.vertical !== 0) { this.cameraRigLookYawRadians -= lookInput.horizontal * CAMERA_RIG_GAMEPAD_LOOK_SPEED * dt; this.cameraRigLookPitchRadians = clampScalar( this.cameraRigLookPitchRadians - lookInput.vertical * CAMERA_RIG_GAMEPAD_LOOK_SPEED * dt, (-rig.lookAround.pitchLimitDegrees * Math.PI) / 180, (rig.lookAround.pitchLimitDegrees * Math.PI) / 180 ); } this.cameraRigLookYawRadians = clampScalar( this.cameraRigLookYawRadians, (-rig.lookAround.yawLimitDegrees * Math.PI) / 180, (rig.lookAround.yawLimitDegrees * Math.PI) / 180 ); } const recenterRate = rig.lookAround.enabled && !this.cameraRigLookDragging ? rig.lookAround.recenterSpeed : rig.lookAround.enabled ? 0 : Math.max(8, rig.lookAround.recenterSpeed); this.cameraRigLookYawRadians = dampScalar( this.cameraRigLookYawRadians, 0, recenterRate, dt ); this.cameraRigLookPitchRadians = dampScalar( this.cameraRigLookPitchRadians, 0, recenterRate, dt ); } private syncCameraRigTelemetryHooks() { const telemetry = this.currentPlayerControllerTelemetry; if (telemetry === null) { this.currentPlayerAudioHooks = null; return; } const cameraVolumeState = this.resolvePlayerVolumeState({ x: this.camera.position.x, y: this.camera.position.y, z: this.camera.position.z }); const cameraSubmerged = cameraVolumeState.inWater && cameraVolumeState.waterSurfaceHeight !== null && this.camera.position.y < cameraVolumeState.waterSurfaceHeight; const hooks = resolveRuntimePlayerMovementHooks({ locomotionState: telemetry.locomotionState, inWaterVolume: telemetry.inWaterVolume, cameraSubmerged, signals: telemetry.signals }); const nextTelemetry: PlayerControllerTelemetry = { ...telemetry, cameraSubmerged, hooks }; this.currentPlayerControllerTelemetry = nextTelemetry; this.currentPlayerAudioHooks = hooks.audio; this.playerControllerTelemetryHandler?.(nextTelemetry); } private captureCurrentCameraPose(): RuntimeCameraPose { const position = this.camera.position.clone(); const lookTarget = position .clone() .add(this.camera.getWorldDirection(this.cameraForward)); return { position, lookTarget }; } private applyCameraPose(pose: RuntimeCameraPose) { this.camera.position.copy(pose.position); this.camera.lookAt(pose.lookTarget); } private resolveRuntimeCameraRigPose( rig: RuntimeCameraRig, dt: number ): RuntimeCameraPose | null { const nextLookTarget = this.resolveRuntimeCameraRigLookTarget(rig); const nextPosition = this.resolveRuntimeCameraRigPosition(rig); if (nextLookTarget === null || nextPosition === null) { return null; } this.updateRuntimeCameraRigLookState(rig, dt); const authoredPosition = new Vector3( nextPosition.x, nextPosition.y, nextPosition.z ); this.cameraRigLookTarget.set( nextLookTarget.x, nextLookTarget.y, nextLookTarget.z ); this.cameraRigDirection .subVectors(this.cameraRigLookTarget, authoredPosition) .normalize(); if (this.cameraRigDirection.lengthSq() <= 1e-8) { this.cameraRigDirection.set(0, 0, 1); } const baseYawRadians = Math.atan2( this.cameraRigDirection.x, this.cameraRigDirection.z ); const basePitchRadians = Math.asin( clampScalar(this.cameraRigDirection.y, -1, 1) ); const lookYawRadians = baseYawRadians + this.cameraRigLookYawRadians; const lookPitchRadians = clampScalar( basePitchRadians + this.cameraRigLookPitchRadians, -Math.PI * 0.49, Math.PI * 0.49 ); const lookDirection = new Vector3( Math.sin(lookYawRadians) * Math.cos(lookPitchRadians), Math.sin(lookPitchRadians), Math.cos(lookYawRadians) * Math.cos(lookPitchRadians) ); return { position: authoredPosition, lookTarget: authoredPosition.clone().add(lookDirection) }; } private createRuntimeCameraSourceKey( source: RuntimeResolvedCameraSource ): RuntimeCameraSourceKey { switch (source.kind) { case "gameplay": return "gameplay"; case "rig": return `rig:${source.rig.entityId}`; case "dialogue": return `dialogue:${source.state.npcEntityId}`; } } private resolveDialogueAttentionCameraPose( referenceCameraPose: RuntimeCameraPose ): RuntimeCameraPose | null { const npc = this.resolveDialogueAttentionNpc(); const playerFocusPoint = this.resolveDialogueAttentionPlayerFocusPoint(); if (npc === null || playerFocusPoint === null) { return null; } const solution = resolveDialogueAttentionCameraSolution({ playerFocusPoint, npcFocusPoint: this.resolveDialogueAttentionNpcFocusPoint(npc), referenceCameraPosition: { x: referenceCameraPose.position.x, y: referenceCameraPose.position.y, z: referenceCameraPose.position.z }, referenceLookTarget: { x: referenceCameraPose.lookTarget.x, y: referenceCameraPose.lookTarget.y, z: referenceCameraPose.lookTarget.z }, previousSideSign: this.activeDialogueAttentionState?.npcEntityId === npc.entityId ? this.activeDialogueAttentionState.sideSign : null }); this.activeDialogueAttentionState = { npcEntityId: npc.entityId, sideSign: solution.sideSign }; return { position: new Vector3( solution.position.x, solution.position.y, solution.position.z ), lookTarget: new Vector3( solution.lookTarget.x, solution.lookTarget.y, solution.lookTarget.z ) }; } private resolveActiveRuntimeCameraSource(): RuntimeResolvedCameraSource { const nextRig = this.resolveActiveRuntimeCameraRig(); if (nextRig !== null) { return { kind: "rig", rig: nextRig }; } const dialogueNpc = this.resolveDialogueAttentionNpc(); if (dialogueNpc !== null) { return { kind: "dialogue", state: this.activeDialogueAttentionState?.npcEntityId === dialogueNpc.entityId ? this.activeDialogueAttentionState : { npcEntityId: dialogueNpc.entityId, sideSign: 1 } }; } return { kind: "gameplay" }; } private resolveRuntimeCameraSourcePose( source: RuntimeResolvedCameraSource, dt: number, referenceCameraPose: RuntimeCameraPose ): RuntimeCameraPose | null { switch (source.kind) { case "gameplay": return this.captureCurrentCameraPose(); case "rig": return this.resolveRuntimeCameraRigPose(source.rig, dt); case "dialogue": return this.resolveDialogueAttentionCameraPose(referenceCameraPose); } } private resolveRuntimeCameraTransitionSettings( previousSource: RuntimeResolvedCameraSource, nextSource: RuntimeResolvedCameraSource ) { if (this.suppressNextCameraSourceTransition) { return { mode: "cut" as const, durationSeconds: 0 }; } const transitionRig = nextSource.kind === "rig" ? nextSource.rig : previousSource.kind === "rig" ? previousSource.rig : null; if (transitionRig !== null) { return { mode: transitionRig.transitionMode, durationSeconds: transitionRig.transitionDurationSeconds }; } if (previousSource.kind === "dialogue" || nextSource.kind === "dialogue") { return { mode: "blend" as const, durationSeconds: DIALOGUE_ATTENTION_CAMERA_TRANSITION_DURATION_SECONDS }; } return { mode: "cut" as const, durationSeconds: 0 }; } private applyActiveCameraRig( dt: number, previousCameraPose: RuntimeCameraPose = this.captureCurrentCameraPose() ) { const previousSource: RuntimeResolvedCameraSource = this.activeRuntimeCameraRig !== null ? { kind: "rig", rig: this.activeRuntimeCameraRig } : this.activeCameraSourceKey !== null && this.activeCameraSourceKey.startsWith("dialogue:") && this.activeDialogueAttentionState !== null ? { kind: "dialogue", state: this.activeDialogueAttentionState } : { kind: "gameplay" }; let nextSource = this.resolveActiveRuntimeCameraSource(); let nextSourceKey = this.createRuntimeCameraSourceKey(nextSource); let sourceChanged = this.activeCameraSourceKey !== nextSourceKey; if (sourceChanged) { this.cameraRigLookDragging = false; this.cameraRigLookYawRadians = 0; this.cameraRigLookPitchRadians = 0; } let targetPose = this.resolveRuntimeCameraSourcePose( nextSource, dt, previousCameraPose ); if (targetPose === null) { nextSource = { kind: "gameplay" }; nextSourceKey = "gameplay"; sourceChanged = this.activeCameraSourceKey !== nextSourceKey; this.cameraRigLookDragging = false; this.cameraRigLookYawRadians = dampScalar( this.cameraRigLookYawRadians, 0, 8, dt ); this.cameraRigLookPitchRadians = dampScalar( this.cameraRigLookPitchRadians, 0, 8, dt ); targetPose = this.captureCurrentCameraPose(); } if (sourceChanged) { const transitionSettings = this.resolveRuntimeCameraTransitionSettings( previousSource, nextSource ); if ( transitionSettings.mode === "blend" && transitionSettings.durationSeconds > 0 ) { this.cameraTransitionState = { durationSeconds: transitionSettings.durationSeconds, elapsedSeconds: 0, fromPose: { position: previousCameraPose.position.clone(), lookTarget: previousCameraPose.lookTarget.clone() }, toPose: { position: targetPose.position.clone(), lookTarget: targetPose.lookTarget.clone() }, destinationSourceKey: nextSourceKey }; } else { this.cameraTransitionState = null; } this.activeCameraSourceKey = nextSourceKey; this.suppressNextCameraSourceTransition = false; } this.activeRuntimeCameraRig = nextSource.kind === "rig" ? nextSource.rig : null; if (nextSource.kind === "gameplay" && this.currentDialogue === null) { this.activeDialogueAttentionState = null; } if ( this.cameraTransitionState !== null && this.cameraTransitionState.destinationSourceKey === nextSourceKey ) { this.cameraTransitionState.elapsedSeconds = Math.min( this.cameraTransitionState.durationSeconds, this.cameraTransitionState.elapsedSeconds + dt ); this.cameraTransitionState.toPose.position.copy(targetPose.position); this.cameraTransitionState.toPose.lookTarget.copy(targetPose.lookTarget); const blendT = this.cameraTransitionState.durationSeconds <= 0 ? 1 : this.cameraTransitionState.elapsedSeconds / this.cameraTransitionState.durationSeconds; const blendedPosition = this.cameraRigForward.lerpVectors( this.cameraTransitionState.fromPose.position, this.cameraTransitionState.toPose.position, blendT ); const blendedLookTarget = this.cameraRigLookTarget.lerpVectors( this.cameraTransitionState.fromPose.lookTarget, this.cameraTransitionState.toPose.lookTarget, blendT ); this.camera.position.copy(blendedPosition); this.camera.lookAt(blendedLookTarget); if (blendT >= 1) { this.cameraTransitionState = null; } } else { this.cameraTransitionState = null; this.applyCameraPose(targetPose); } if (nextSource.kind !== "gameplay") { this.syncCameraRigTelemetryHooks(); } return this.activeRuntimeCameraRig; } private async finalizeSceneLoad( requestId: number, colliders: RuntimeSceneDefinition["colliders"], playerShape: RuntimeSceneDefinition["playerCollider"], playerMovement: RuntimeSceneDefinition["playerMovement"] ) { try { const nextCollisionWorld = await this.buildCollisionWorld( requestId, colliders, playerShape, playerMovement ); if (requestId !== this.collisionWorldRequestId) { nextCollisionWorld.dispose(); return; } this.collisionWorld = nextCollisionWorld; this.sceneReady = true; this.publishSceneLoadState({ status: "ready", message: null }); this.activateDesiredNavigationController(); } catch (error) { if (requestId !== this.collisionWorldRequestId) { return; } const detail = error instanceof Error && error.message.trim().length > 0 ? error.message.trim() : "Unknown error."; const message = `Runner scene failed to load: ${detail}`; this.sceneReady = false; this.currentRuntimeMessage = message; this.runtimeMessageHandler?.(message); this.publishSceneLoadState({ status: "error", message }); } } private applyWorld() { if (this.currentWorld === null) { return; } const world = this.currentWorld; this.scene.background = null; this.scene.environment = null; this.scene.environmentIntensity = 1; this.applyDayNightLighting(); if (this.renderer !== null) { configureAdvancedRenderingRenderer( this.renderer, world.advancedRendering ); this.syncAdvancedRenderingComposer(world.advancedRendering); } this.applyShadowState(); } private applyDayNightLighting() { if (this.currentWorld === null || this.runtimeScene === null) { return; } const resolvedTime = this.currentClockState === null ? null : resolveRuntimeTimeState( this.runtimeScene.time, this.currentClockState ); const resolvedWorld = resolveRuntimeDayNightWorldState( this.currentWorld, this.runtimeScene.time, this.currentClockState, resolvedTime ); const backgroundTexture = resolvedWorld.background.mode === "image" ? (this.loadedImageAssets[resolvedWorld.background.assetId]?.texture ?? null) : null; const nightBackgroundOverlay = resolvedWorld.nightBackgroundOverlay; const backgroundOverlayState = nightBackgroundOverlay === null ? null : { texture: this.loadedImageAssets[nightBackgroundOverlay.assetId]?.texture ?? null, opacity: nightBackgroundOverlay.opacity, environmentIntensity: nightBackgroundOverlay.environmentIntensity }; const celestialBodiesState = resolveWorldCelestialBodiesState( this.currentWorld.showCelestialBodies, resolvedWorld.sunLight, resolvedWorld.moonLight ); const shaderSkyState = resolveWorldShaderSkyRenderState( this.currentWorld, resolvedWorld, resolvedTime, this.runtimeScene.time ); if (this.currentWorld.background.mode === "shader") { this.shaderSkyEnvironmentCache?.syncPhaseTextures( resolveWorldShaderSkyEnvironmentPhaseStates( this.currentWorld, this.runtimeScene.time ) ); } this.worldBackgroundRenderer.update( resolvedWorld.background, backgroundTexture, backgroundOverlayState, celestialBodiesState, shaderSkyState ); const environmentState = resolveWorldEnvironmentState( resolvedWorld.background, backgroundTexture, backgroundOverlayState, this.environmentBlendCache, shaderSkyState, this.shaderSkyEnvironmentCache ); this.scene.background = null; this.scene.environment = environmentState.texture; this.scene.environmentIntensity = environmentState.intensity; this.ambientLight.color.set(resolvedWorld.ambientLight.colorHex); this.ambientLight.intensity = resolvedWorld.ambientLight.intensity; this.currentCelestialShadowCaster = resolveDominantCelestialShadowCaster( resolvedWorld.sunLight, resolvedWorld.moonLight )?.key ?? null; this.sunLight.color.set(resolvedWorld.sunLight.colorHex); this.sunLight.intensity = resolvedWorld.sunLight.intensity; this.sunLight.visible = resolvedWorld.sunLight.intensity > 1e-4; this.sunLight.position .set( resolvedWorld.sunLight.direction.x, resolvedWorld.sunLight.direction.y, resolvedWorld.sunLight.direction.z ) .normalize() .multiplyScalar(18); this.sunLight.target.position.set(0, 0, 0); if (resolvedWorld.moonLight === null) { this.moonLight.visible = false; this.moonLight.intensity = 0; this.moonLight.target.position.set(0, 0, 0); return; } this.moonLight.visible = resolvedWorld.moonLight.intensity > 1e-4; this.moonLight.color.set(resolvedWorld.moonLight.colorHex); this.moonLight.intensity = resolvedWorld.moonLight.intensity; this.moonLight.position .set( resolvedWorld.moonLight.direction.x, resolvedWorld.moonLight.direction.y, resolvedWorld.moonLight.direction.z ) .normalize() .multiplyScalar(16); this.moonLight.target.position.set(0, 0, 0); } private async buildCollisionWorld( requestId: number, colliders: RuntimeSceneDefinition["colliders"], playerShape: RuntimeSceneDefinition["playerCollider"], playerMovement: RuntimeSceneDefinition["playerMovement"] ) { const nextCollisionWorld = await RapierCollisionWorld.create( colliders, playerShape, { maxStepHeight: playerMovement.maxStepHeight } ); if (requestId !== this.collisionWorldRequestId) { nextCollisionWorld.dispose(); throw new Error("Scene load was superseded by a newer request."); } return nextCollisionWorld; } private clearCollisionWorld() { this.collisionWorld?.dispose(); this.collisionWorld = null; } private syncAdvancedRenderingComposer(settings: AdvancedRenderingSettings) { 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.worldBackgroundRenderer.scene ); this.currentAdvancedRenderingSettings = cloneAdvancedRenderingSettings(settings); this.renderer.autoClear = false; } private applyShadowState() { if (this.currentWorld === null) { return; } const advancedRendering = this.currentWorld.advancedRendering; const shadowsEnabled = advancedRendering.enabled && advancedRendering.shadows.enabled; for (const mesh of this.brushMeshes.values()) { applyAdvancedRenderingRenderableShadowFlags(mesh, shadowsEnabled); } for (const mesh of this.terrainMeshes.values()) { applyAdvancedRenderingRenderableShadowFlags(mesh, shadowsEnabled); } for (const renderGroup of this.modelRenderObjects.values()) { applyAdvancedRenderingRenderableShadowFlags(renderGroup, shadowsEnabled); } this.syncCelestialShadowState(); } private resolveRuntimeShadowFocusTarget() { const telemetry = this.currentPlayerControllerTelemetry; if (telemetry !== null) { return { center: { x: telemetry.feetPosition.x, y: (telemetry.feetPosition.y + telemetry.eyePosition.y) * 0.5, z: telemetry.feetPosition.z }, radius: 8 }; } const sceneBounds = this.runtimeScene?.sceneBounds ?? null; if (sceneBounds !== null) { return { center: { x: sceneBounds.center.x, y: sceneBounds.center.y, z: sceneBounds.center.z }, radius: Math.max( 6, Math.hypot( sceneBounds.size.x, sceneBounds.size.y, sceneBounds.size.z ) * 0.2 ) }; } return { center: { x: this.camera.position.x, y: this.camera.position.y, z: this.camera.position.z }, radius: 8 }; } private syncCelestialShadowState() { if (this.currentWorld === null) { return; } const advancedRendering = this.currentWorld.advancedRendering; const shadowsEnabled = advancedRendering.enabled && advancedRendering.shadows.enabled; for (const renderObjects of this.localLightObjects.values()) { configureAdvancedRenderingShadowLight( renderObjects.light, advancedRendering, false ); } for (const renderObjects of this.lightVolumeObjects.values()) { for (const light of renderObjects.lights) { configureAdvancedRenderingShadowLight(light, advancedRendering, false); } } if (!shadowsEnabled || this.currentCelestialShadowCaster === null) { configureAdvancedRenderingShadowLight( this.sunLight, advancedRendering, false ); configureAdvancedRenderingShadowLight( this.moonLight, advancedRendering, false ); return; } const activeLight = this.currentCelestialShadowCaster === "moon" ? this.moonLight : this.sunLight; const lightDirection = activeLight.position .clone() .sub(activeLight.target.position) .normalize(); const fit = fitCelestialDirectionalShadow({ activeCamera: this.camera, focusTarget: this.resolveRuntimeShadowFocusTarget(), lightDirection: { x: lightDirection.x, y: lightDirection.y, z: lightDirection.z }, mapSize: advancedRendering.shadows.mapSize, sceneBounds: this.runtimeScene?.sceneBounds ?? null }); if (fit === null) { configureAdvancedRenderingShadowLight( this.sunLight, advancedRendering, false ); configureAdvancedRenderingShadowLight( this.moonLight, advancedRendering, false ); return; } configureAdvancedRenderingShadowLight( this.sunLight, advancedRendering, this.currentCelestialShadowCaster === "sun", this.currentCelestialShadowCaster === "sun" ? fit.normalBias : 0 ); configureAdvancedRenderingShadowLight( this.moonLight, advancedRendering, this.currentCelestialShadowCaster === "moon", this.currentCelestialShadowCaster === "moon" ? fit.normalBias : 0 ); activeLight.position.set( fit.lightPosition.x, fit.lightPosition.y, fit.lightPosition.z ); activeLight.target.position.set( fit.targetPosition.x, fit.targetPosition.y, fit.targetPosition.z ); activeLight.updateMatrixWorld(); activeLight.target.updateMatrixWorld(); const shadowCamera = activeLight.shadow.camera as OrthographicCamera; shadowCamera.left = fit.cameraBounds.left; shadowCamera.right = fit.cameraBounds.right; shadowCamera.top = fit.cameraBounds.top; shadowCamera.bottom = fit.cameraBounds.bottom; shadowCamera.near = fit.cameraBounds.near; shadowCamera.far = fit.cameraBounds.far; shadowCamera.updateProjectionMatrix(); activeLight.shadow.needsUpdate = true; } 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); } for (const spotLight of localLights.spotLights) { const renderObjects = this.createSpotLightRuntimeObjects(spotLight); this.localLightGroup.add(renderObjects.group); this.localLightObjects.set(spotLight.entityId, renderObjects); } this.applyShadowState(); } private rebuildLightVolumes( lightVolumes: RuntimeSceneDefinition["volumes"]["light"] ) { this.clearLightVolumes(); for (const lightVolume of lightVolumes) { const renderObjects = this.createLightVolumeRuntimeObjects(lightVolume); this.lightVolumeGroup.add(renderObjects.group); this.lightVolumeObjects.set(lightVolume.brushId, renderObjects); } } private createPointLightRuntimeObjects( pointLight: RuntimeLocalLightCollection["pointLights"][number] ): LocalLightRenderObjects { 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 ); group.visible = pointLight.enabled; light.position.set(0, 0, 0); group.add(light); return { group, light }; } private createLightVolumeRuntimeObjects( lightVolume: RuntimeSceneDefinition["volumes"]["light"][number] ): LightVolumeRenderObjects { const group = new Group(); const lights: PointLight[] = []; group.position.set( lightVolume.center.x, lightVolume.center.y, lightVolume.center.z ); group.rotation.set( (lightVolume.rotationDegrees.x * Math.PI) / 180, (lightVolume.rotationDegrees.y * Math.PI) / 180, (lightVolume.rotationDegrees.z * Math.PI) / 180 ); group.visible = lightVolume.enabled; for (const derivedLight of lightVolume.lights) { const light = new PointLight( lightVolume.colorHex, derivedLight.intensity, derivedLight.distance, derivedLight.decay ); light.castShadow = false; light.shadow.autoUpdate = false; light.position.set( derivedLight.localPosition.x, derivedLight.localPosition.y, derivedLight.localPosition.z ); group.add(light); lights.push(light); } return { group, lights }; } private createSpotLightRuntimeObjects( spotLight: RuntimeLocalLightCollection["spotLights"][number] ): LocalLightRenderObjects { 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); group.visible = spotLight.enabled; light.position.set(0, 0, 0); light.target.position.set(0, 1, 0); group.add(light); group.add(light.target); return { group, light }; } private syncResolvedControlStateToRuntime( resolved: RuntimeSceneDefinition["control"]["resolved"] ) { for (const state of resolved.discrete) { this.applyResolvedDiscreteControlState(state); } for (const channelValue of resolved.channels) { this.applyResolvedControlChannelValue(channelValue); } } private applyResolvedDiscreteControlState( state: RuntimeResolvedDiscreteControlState ) { switch (state.type) { case "projectTimePaused": this.applyProjectTimePausedControl(state.value); return; case "cameraRigOverride": this.applyCameraRigOverrideControl(state.entityId); return; case "actorPresence": this.applyActorPresenceControl(state.target.actorId, state.value); return; case "actorAnimationPlayback": this.applyActorAnimationPlaybackControl( state.target, state.clipName, state.loop ); return; case "actorPathAssignment": return; case "modelVisibility": this.applyModelInstanceVisibilityControl(state.target, state.value); return; case "soundPlayback": this.applySoundPlaybackControl(state.target, state.value); return; case "modelAnimationPlayback": this.applyModelAnimationPlaybackControl( state.target, state.clipName, state.loop ); return; case "lightEnabled": this.applyLightEnabledControl(state.target, state.value); return; case "lightColor": this.applyLightColorControl(state.target, state.value); return; case "interactionEnabled": this.applyInteractionEnabledControl(state.target, state.value); return; case "ambientLightColor": this.applyAmbientLightColorControl(state.target, state.value); return; case "sunLightColor": this.applySunLightColorControl(state.target, state.value); return; } } private applyResolvedControlChannelValue( channelValue: RuntimeResolvedControlChannelValue ) { switch (channelValue.type) { case "lightIntensity": this.applyLightIntensityControl( channelValue.descriptor.target, channelValue.value ); return; case "soundVolume": this.applySoundVolumeControl( channelValue.descriptor.target, channelValue.value ); return; case "ambientLightIntensity": this.applyAmbientLightIntensityControl( channelValue.descriptor.target, channelValue.value ); return; case "sunLightIntensity": this.applySunLightIntensityControl( channelValue.descriptor.target, channelValue.value ); return; } } private mutateRuntimeLightState( target: LightControlTargetRef, mutate: ( light: | RuntimeSceneDefinition["localLights"]["pointLights"][number] | RuntimeSceneDefinition["localLights"]["spotLights"][number] ) => void ) { if (this.runtimeScene === null) { return; } const lights = target.entityKind === "pointLight" ? this.runtimeScene.localLights.pointLights : this.runtimeScene.localLights.spotLights; const light = lights.find( (candidate) => candidate.entityId === target.entityId ); if (light !== undefined) { mutate(light); } } private applyLightEnabledControl( target: LightControlTargetRef, enabled: boolean ) { this.mutateRuntimeLightState(target, (light) => { light.enabled = enabled; }); const renderObjects = this.localLightObjects.get(target.entityId); if (renderObjects === undefined) { return; } renderObjects.group.visible = enabled; } private applyLightIntensityControl( target: LightControlTargetRef, intensity: number ) { this.mutateRuntimeLightState(target, (light) => { light.intensity = intensity; }); const renderObjects = this.localLightObjects.get(target.entityId); if (renderObjects === undefined) { return; } renderObjects.light.intensity = intensity; } private applyLightColorControl( target: LightControlTargetRef, colorHex: string ) { this.mutateRuntimeLightState(target, (light) => { light.colorHex = colorHex; }); const renderObjects = this.localLightObjects.get(target.entityId); if (renderObjects === undefined) { return; } renderObjects.light.color.set(colorHex); } private applyAmbientLightIntensityControl( _target: SceneControlTargetRef, intensity: number ) { if (this.runtimeScene === null || this.currentWorld === null) { return; } if ( this.runtimeScene.world.ambientLight.intensity === intensity && this.currentWorld.ambientLight.intensity === intensity ) { return; } this.runtimeScene.world.ambientLight.intensity = intensity; this.currentWorld.ambientLight.intensity = intensity; this.applyDayNightLighting(); } private applyAmbientLightColorControl( _target: SceneControlTargetRef, colorHex: string ) { if (this.runtimeScene === null || this.currentWorld === null) { return; } if ( this.runtimeScene.world.ambientLight.colorHex === colorHex && this.currentWorld.ambientLight.colorHex === colorHex ) { return; } this.runtimeScene.world.ambientLight.colorHex = colorHex; this.currentWorld.ambientLight.colorHex = colorHex; this.applyDayNightLighting(); } private applySunLightIntensityControl( _target: SceneControlTargetRef, intensity: number ) { if (this.runtimeScene === null || this.currentWorld === null) { return; } if ( this.runtimeScene.world.sunLight.intensity === intensity && this.currentWorld.sunLight.intensity === intensity ) { return; } this.runtimeScene.world.sunLight.intensity = intensity; this.currentWorld.sunLight.intensity = intensity; this.applyDayNightLighting(); } private applySunLightColorControl( _target: SceneControlTargetRef, colorHex: string ) { if (this.runtimeScene === null || this.currentWorld === null) { return; } if ( this.runtimeScene.world.sunLight.colorHex === colorHex && this.currentWorld.sunLight.colorHex === colorHex ) { return; } this.runtimeScene.world.sunLight.colorHex = colorHex; this.currentWorld.sunLight.colorHex = colorHex; this.applyDayNightLighting(); } private applyModelInstanceVisibilityControl( target: ModelInstanceControlTargetRef, visible: boolean ) { if (this.runtimeScene !== null) { const modelInstance = this.runtimeScene.modelInstances.find( (candidate) => candidate.instanceId === target.modelInstanceId ) ?? null; if (modelInstance !== null) { modelInstance.visible = visible; } } const renderGroup = this.modelRenderObjects.get(target.modelInstanceId); if (renderGroup !== undefined) { renderGroup.visible = visible; } } private applyModelAnimationPlaybackControl( target: ModelInstanceControlTargetRef, clipName: string | null, loop: boolean | undefined ) { let stateChanged = true; if (this.runtimeScene !== null) { const modelInstance = this.runtimeScene.modelInstances.find( (candidate) => candidate.instanceId === target.modelInstanceId ) ?? null; if (modelInstance !== null) { const nextClipName = clipName ?? undefined; const nextAutoplay = clipName !== null; const nextLoop = clipName === null ? undefined : loop; stateChanged = modelInstance.animationClipName !== nextClipName || modelInstance.animationAutoplay !== nextAutoplay || modelInstance.animationLoop !== nextLoop; modelInstance.animationClipName = nextClipName; modelInstance.animationAutoplay = nextAutoplay; modelInstance.animationLoop = nextLoop; } } if (!stateChanged) { return; } if (!this.animationMixers.has(target.modelInstanceId)) { return; } if (clipName === null) { this.applyStopAnimationAction(target.modelInstanceId); return; } this.applyPlayAnimationAction(target.modelInstanceId, clipName, loop); } private applySoundPlaybackControl( target: SoundEmitterControlTargetRef, playing: boolean, link: InteractionLink | null = null ) { let stateChanged = true; if (this.runtimeScene !== null) { const soundEmitter = this.runtimeScene.entities.soundEmitters.find( (candidate) => candidate.entityId === target.entityId ) ?? null; if (soundEmitter !== null) { stateChanged = soundEmitter.autoplay !== playing; soundEmitter.autoplay = playing; } } if (!stateChanged) { return; } if (!this.audioSystem.hasSoundEmitter(target.entityId)) { return; } if (playing) { this.audioSystem.playSound(target.entityId, link); } else { this.audioSystem.stopSound(target.entityId); } } private applyActorAnimationPlaybackControl( target: ActorControlTargetRef, clipName: string | null, loop: boolean | undefined ) { if (this.runtimeScene !== null) { for (const npc of this.runtimeScene.npcDefinitions) { if (npc.actorId !== target.actorId) { continue; } const nextClipName = clipName; if ( npc.animationClipName === nextClipName && npc.animationLoop === loop ) { continue; } npc.animationClipName = nextClipName; npc.animationLoop = loop; } } const npcIds = this.runtimeScene?.npcDefinitions .filter((npc) => npc.actorId === target.actorId) .map((npc) => npc.entityId) ?? []; for (const npcId of npcIds) { if (!this.animationMixers.has(npcId)) { continue; } if (clipName === null) { this.applyStopAnimationAction(npcId); } else { this.applyPlayAnimationAction(npcId, clipName, loop); } } } private applySoundVolumeControl( target: SoundEmitterControlTargetRef, volume: number ) { let stateChanged = true; if (this.runtimeScene !== null) { const soundEmitter = this.runtimeScene.entities.soundEmitters.find( (candidate) => candidate.entityId === target.entityId ) ?? null; if (soundEmitter !== null) { stateChanged = soundEmitter.volume !== volume; soundEmitter.volume = volume; } } if (!stateChanged) { return; } this.audioSystem.setSoundEmitterVolume(target.entityId, volume); } private applyInteractionEnabledControl( target: InteractionControlTargetRef, enabled: boolean ) { if (this.runtimeScene === null) { return; } const interactable = this.runtimeScene.entities.interactables.find( (candidate) => candidate.entityId === target.entityId ) ?? null; if (interactable !== null) { interactable.interactionEnabled = enabled; } } private applyActorPresenceControl(actorId: string, active: boolean) { if (this.runtimeScene === null) { return; } let changed = false; for (const npc of this.runtimeScene.npcDefinitions) { if (npc.actorId !== actorId || npc.active === active) { continue; } npc.active = active; npc.activeRoutineId = null; npc.activeRoutineTitle = null; changed = true; const renderGroup = this.modelRenderObjects.get(npc.entityId); if (renderGroup !== undefined) { renderGroup.visible = npc.visible && npc.active; } } if (!changed) { return; } this.refreshRuntimeNpcCollections(); this.refreshCollisionWorldForNpcSchedule(); } private applyProjectTimePausedControl(paused: boolean) { this.setControlPauseActive(paused); } private applyCameraRigOverrideControl(entityId: string | null) { this.setActiveCameraRigOverride(entityId); } private applyControlEffect( effect: ControlEffect, link: InteractionLink | null = null ) { switch (effect.type) { case "setProjectTimePaused": this.applyProjectTimePausedControl(effect.paused); break; case "activateCameraRigOverride": this.applyCameraRigOverrideControl(effect.target.entityId); break; case "clearCameraRigOverride": this.applyCameraRigOverrideControl(null); break; case "setActorPresence": this.applyActorPresenceControl(effect.target.actorId, effect.active); break; case "playActorAnimation": this.applyActorAnimationPlaybackControl( effect.target, effect.clipName, effect.loop ); break; case "followActorPath": console.warn( "followActorPath is scheduler-owned in this slice and is ignored when dispatched directly." ); break; case "playModelAnimation": this.applyModelAnimationPlaybackControl( effect.target, effect.clipName, effect.loop ); break; case "stopModelAnimation": this.applyModelAnimationPlaybackControl(effect.target, null, undefined); break; case "setModelInstanceVisible": this.applyModelInstanceVisibilityControl(effect.target, effect.visible); break; case "playSound": this.applySoundPlaybackControl(effect.target, true, link); break; case "stopSound": this.applySoundPlaybackControl(effect.target, false); break; case "setSoundVolume": this.applySoundVolumeControl(effect.target, effect.volume); break; case "setInteractionEnabled": this.applyInteractionEnabledControl(effect.target, effect.enabled); break; case "setLightEnabled": this.applyLightEnabledControl(effect.target, effect.enabled); break; case "setLightIntensity": this.applyLightIntensityControl(effect.target, effect.intensity); break; case "setLightColor": this.applyLightColorControl(effect.target, effect.colorHex); break; case "setAmbientLightIntensity": this.applyAmbientLightIntensityControl(effect.target, effect.intensity); break; case "setAmbientLightColor": this.applyAmbientLightColorControl(effect.target, effect.colorHex); break; case "setSunLightIntensity": this.applySunLightIntensityControl(effect.target, effect.intensity); break; case "setSunLightColor": this.applySunLightColorControl(effect.target, effect.colorHex); break; } if (this.runtimeScene === null) { return; } this.runtimeScene.control.resolved = applyControlEffectToResolvedState( this.runtimeScene.control.resolved, effect, link === null ? createDefaultResolvedControlSource() : createInteractionLinkResolvedControlSource(link.id) ); } private rebuildBrushMeshes(brushes: RuntimeBoxBrushInstance[]) { this.clearBrushMeshes(); const volumeRenderPaths: ResolvedBoxVolumeRenderPaths = this.currentWorld === null ? { fog: "performance", water: "performance" } : resolveBoxVolumeRenderPaths(this.currentWorld.advancedRendering); for (const brush of brushes) { const geometryBrush = createRuntimeGeometryBrush(brush); const derivedMesh = buildBoxBrushDerivedMeshData(geometryBrush); const geometry = derivedMesh.geometry; const staticContactPatches = brush.volume.mode === "water" ? this.collectRuntimeStaticWaterContactPatches(brush) : []; const contactPatches = brush.volume.mode === "water" ? this.mergeRuntimeWaterContactPatches( brush, staticContactPatches, this.collectRuntimePlayerWaterContactPatches(brush) ) : []; const materials = this.createFogMaterialSet( brush, volumeRenderPaths, derivedMesh.faceIdsInOrder ) ?? derivedMesh.faceIdsInOrder.map((faceId) => this.createFaceMaterial( brush, faceId, brush.faces[faceId]?.material ?? null, volumeRenderPaths, contactPatches, staticContactPatches ) ); 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 ); mesh.visible = brush.visible; this.configureFogVolumeMesh(mesh, materials); this.brushGroup.add(mesh); this.brushMeshes.set(brush.id, mesh); } this.applyShadowState(); } private rebuildTerrainMeshes(terrains: RuntimeTerrain[]) { this.clearTerrainMeshes(); for (const terrain of terrains) { const geometry = buildTerrainDerivedMeshData({ ...terrain, kind: "terrain", enabled: true }).geometry; const mesh = new Mesh( geometry, this.createRuntimeTerrainMaterial(terrain) ); mesh.position.set( terrain.position.x, terrain.position.y, terrain.position.z ); mesh.visible = terrain.visible; mesh.castShadow = false; mesh.receiveShadow = true; this.terrainGroup.add(mesh); this.terrainMeshes.set(terrain.id, mesh); } this.applyShadowState(); } private createRuntimeTerrainMaterial(terrain: RuntimeTerrain): Material { const layerTextures = terrain.layers.map((layer) => getTerrainLayerTexture( layer.material, (material) => this.getOrCreateTextureSet(material).baseColor ) ) as [Texture, Texture, Texture, Texture]; return createTerrainLayerBlendMaterial({ layerTextures }); } private createFogMaterialSet( brush: RuntimeBoxBrushInstance, volumeRenderPaths: { fog: "performance" | "quality"; water: "performance" | "quality"; }, faceIds: WhiteboxFaceId[] ): Material[] | null { if (brush.volume.mode !== "fog") { return null; } if (volumeRenderPaths.fog === "quality") { const fogMaterial = createFogQualityMaterial({ colorHex: brush.volume.fog.colorHex, density: brush.volume.fog.density, padding: brush.volume.fog.padding, time: this.volumeTime, halfSize: { x: brush.size.x * 0.5, y: brush.size.y * 0.5, z: brush.size.z * 0.5 } }); this.volumeAnimatedUniforms.push(fogMaterial.animationUniform); return faceIds.map(() => fogMaterial.material); } const densityOpacity = Math.max( 0.06, Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08) ); const fogMaterial = new MeshBasicMaterial({ color: brush.volume.fog.colorHex, transparent: true, opacity: densityOpacity, depthWrite: false }); return faceIds.map(() => fogMaterial); } private configureFogVolumeMesh( mesh: Mesh, materials: Material[] ) { const fogMaterials = materials.filter( (material): material is ShaderMaterial => material instanceof ShaderMaterial && material.uniforms["localCameraPosition"] !== undefined ); if (fogMaterials.length === 0) { return; } mesh.onBeforeRender = (_renderer, _scene, camera) => { const localCameraPosition = mesh.worldToLocal( this.fogLocalCameraPosition.copy(camera.position) ); for (const material of fogMaterials) { ( material.uniforms["localCameraPosition"] as { value: Vector3 } ).value.copy(localCameraPosition); } }; } private createNpcColliderFallbackRenderGroup(npc: RuntimeNpc): Group { const group = new Group(); const colliderMaterial = new MeshStandardMaterial({ color: 0xa0df7a, emissive: 0xa0df7a, emissiveIntensity: 0.05, roughness: 0.52, metalness: 0.02, transparent: true, opacity: 0.3 }); const facingMaterial = new MeshStandardMaterial({ color: 0xa0df7a, emissive: 0xa0df7a, emissiveIntensity: 0.08, roughness: 0.42, metalness: 0.03 }); group.position.set(npc.position.x, npc.position.y, npc.position.z); switch (npc.collider.mode) { case "capsule": { const collisionMesh = new Mesh( new CapsuleGeometry( npc.collider.radius, Math.max(0, npc.collider.height - npc.collider.radius * 2), 8, 14 ), colliderMaterial ); collisionMesh.position.y = npc.collider.height * 0.5; group.add(collisionMesh); break; } case "box": { const collisionMesh = new Mesh( new BoxGeometry( npc.collider.size.x, npc.collider.size.y, npc.collider.size.z ), colliderMaterial ); collisionMesh.position.y = npc.collider.size.y * 0.5; group.add(collisionMesh); break; } case "none": break; } const facingGroup = new Group(); facingGroup.rotation.y = (npc.yawDegrees * Math.PI) / 180; group.add(facingGroup); const colliderTop = getNpcColliderHeight({ mode: npc.collider.mode, eyeHeight: npc.collider.eyeHeight, capsuleRadius: npc.collider.mode === "capsule" ? npc.collider.radius : 0.35, capsuleHeight: npc.collider.mode === "capsule" ? npc.collider.height : 1.8, boxSize: npc.collider.mode === "box" ? npc.collider.size : { x: 0.7, y: 1.8, z: 0.7 } }) ?? 0.18; const body = new Mesh(new BoxGeometry(0.08, 0.08, 0.34), facingMaterial); body.position.set(0, colliderTop + 0.12, 0.06); const arrowHead = new Mesh(new ConeGeometry(0.1, 0.22, 14), facingMaterial); arrowHead.rotation.x = Math.PI * 0.5; arrowHead.position.set(0, colliderTop + 0.12, 0.28); facingGroup.add(body); facingGroup.add(arrowHead); return group; } private rebuildModelRenderObjects( modelInstances: RuntimeSceneDefinition["modelInstances"], npcs: RuntimeNpcDefinition[] ) { this.clearModelRenderObjects(); 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, visible: modelInstance.visible, enabled: true, position: modelInstance.position, rotationDegrees: modelInstance.rotationDegrees, scale: modelInstance.scale, collision: { mode: "none", visible: false } }, asset, loadedAsset, false ); renderGroup.visible = modelInstance.visible; 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) { const action = mixer.clipAction(clip); action.loop = modelInstance.animationLoop === false ? LoopOnce : LoopRepeat; action.clampWhenFinished = modelInstance.animationLoop === false; action.reset().play(); } } } } for (const npc of npcs) { const asset = npc.modelAssetId === null ? null : (this.projectAssets[npc.modelAssetId] ?? null); const loadedAsset = npc.modelAssetId === null ? undefined : this.loadedModelAssets[npc.modelAssetId]; const renderGroup = npc.modelAssetId === null || asset?.kind !== "model" ? this.createNpcColliderFallbackRenderGroup(npc) : createModelInstanceRenderGroup( { id: npc.entityId, kind: "modelInstance", assetId: npc.modelAssetId, name: npc.name, visible: npc.visible, enabled: true, position: npc.position, rotationDegrees: { x: 0, y: npc.yawDegrees, z: 0 }, scale: { x: 1, y: 1, z: 1 }, collision: { mode: "none", visible: false } }, asset, loadedAsset, false ); renderGroup.visible = npc.visible && npc.active; this.modelGroup.add(renderGroup); this.modelRenderObjects.set(npc.entityId, renderGroup); if (loadedAsset?.animations && loadedAsset.animations.length > 0) { const mixer = new AnimationMixer(renderGroup); this.animationMixers.set(npc.entityId, mixer); this.instanceAnimationClips.set(npc.entityId, loadedAsset.animations); if (npc.animationClipName !== null) { const clip = AnimationClip.findByName( loadedAsset.animations, npc.animationClipName ); if (clip) { const action = mixer.clipAction(clip); action.loop = npc.animationLoop === false ? LoopOnce : LoopRepeat; action.clampWhenFinished = npc.animationLoop === false; action.reset().play(); } } } } this.applyShadowState(); } private createFaceMaterial( brush: RuntimeBoxBrushInstance, faceId: WhiteboxFaceId, material: RuntimeBrushFace["material"], volumeRenderPaths: { fog: "performance" | "quality"; water: "performance" | "quality"; }, contactPatches: ReturnType, staticContactPatches: ReturnType ): Material { if (brush.volume.mode === "water") { const baseOpacity = Math.max( 0.05, Math.min(1, brush.volume.water.surfaceOpacity) ); const isTopFace = brush.kind === "box" && faceId === "posY"; const waterMaterial = createWaterMaterial({ colorHex: brush.volume.water.colorHex, surfaceOpacity: brush.volume.water.surfaceOpacity, waveStrength: brush.volume.water.waveStrength, surfaceDisplacementEnabled: brush.volume.water.surfaceDisplacementEnabled, opacity: isTopFace ? Math.min(1, baseOpacity + 0.18) : baseOpacity * 0.5, quality: volumeRenderPaths.water === "quality", wireframe: false, isTopFace, time: this.volumeTime, halfSize: { x: brush.size.x * 0.5, z: brush.size.z * 0.5 }, contactPatches, reflection: { texture: null, enabled: isTopFace } }); if (waterMaterial.animationUniform !== null) { this.volumeAnimatedUniforms.push(waterMaterial.animationUniform); } if ( isTopFace && waterMaterial.contactPatchesUniform !== null && waterMaterial.contactPatchAxesUniform !== null ) { this.runtimeWaterContactUniforms.push({ brush, uniform: waterMaterial.contactPatchesUniform, axisUniform: waterMaterial.contactPatchAxesUniform, shapeUniform: waterMaterial.contactPatchShapesUniform ?? { value: [] }, staticContactPatches, reflectionTextureUniform: waterMaterial.reflectionTextureUniform, reflectionMatrixUniform: waterMaterial.reflectionMatrixUniform, reflectionEnabledUniform: waterMaterial.reflectionEnabledUniform, reflectionRenderTarget: this.getWaterReflectionMode() !== "none" ? this.createWaterReflectionRenderTarget() : null, lastReflectionUpdateTime: Number.NEGATIVE_INFINITY }); } return waterMaterial.material; } if (brush.volume.mode === "fog") { if (volumeRenderPaths.fog === "quality") { const fogMaterial = createFogQualityMaterial({ colorHex: brush.volume.fog.colorHex, density: brush.volume.fog.density, padding: brush.volume.fog.padding, time: this.volumeTime, halfSize: { x: brush.size.x * 0.5, y: brush.size.y * 0.5, z: brush.size.z * 0.5 } }); this.volumeAnimatedUniforms.push(fogMaterial.animationUniform); return fogMaterial.material; } // Performance fallback: simple transparent material const densityOpacity = Math.max( 0.06, Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08) ); return new MeshBasicMaterial({ color: brush.volume.fog.colorHex, transparent: true, opacity: densityOpacity, depthWrite: false }); } if (brush.volume.mode === "light") { const lightMaterial = new MeshBasicMaterial({ color: brush.volume.light.colorHex, transparent: true, opacity: 0, depthWrite: false }); lightMaterial.colorWrite = false; return lightMaterial; } if (material === null) { const faceMaterial = new MeshStandardMaterial({ color: FALLBACK_FACE_COLOR, roughness: 0.9, metalness: 0.05 }); if ( this.currentWorld !== null && shouldApplyWhiteboxBevel(this.currentWorld.advancedRendering) ) { applyWhiteboxBevelToMaterial( faceMaterial, this.currentWorld.advancedRendering.whiteboxBevel ); } return faceMaterial; } const textureSet = this.getOrCreateTextureSet(material); const faceMaterial = new MeshPhysicalMaterial({ color: 0xffffff, map: textureSet.baseColor, normalMap: textureSet.normal, roughnessMap: textureSet.roughness, roughness: 1, metalnessMap: textureSet.metallic, metalness: textureSet.metallic === null ? 0.03 : 1, specularColorMap: textureSet.specular, specularColor: new Color(0xffffff), specularIntensity: textureSet.specular === null ? 0.2 : 1 }); if ( this.currentWorld !== null && shouldApplyWhiteboxBevel(this.currentWorld.advancedRendering) ) { applyWhiteboxBevelToMaterial( faceMaterial, this.currentWorld.advancedRendering.whiteboxBevel ); } return faceMaterial; } private updateUnderwaterSceneFog() { const cameraVolumeState = this.resolvePlayerVolumeState({ x: this.camera.position.x, y: this.camera.position.y, z: this.camera.position.z }); const fogTelemetry = this.activeRuntimeCameraRig !== null ? { cameraSubmerged: cameraVolumeState.inWater && cameraVolumeState.waterSurfaceHeight !== null && this.camera.position.y < cameraVolumeState.waterSurfaceHeight, eyePosition: { x: this.camera.position.x, y: this.camera.position.y, z: this.camera.position.z } } : this.activeController === this.firstPersonController ? this.currentPlayerControllerTelemetry : null; const fogState = resolveUnderwaterFogState(this.runtimeScene, fogTelemetry); if (fogState === null) { this.underwaterSceneFog.density = 0; return; } this.underwaterSceneFog.color.set(fogState.colorHex); this.underwaterSceneFog.density = fogState.density; } private resetPlayerCameraEffects() { this.cameraEffectVerticalOffset = 0; this.cameraEffectVerticalVelocity = 0; this.cameraEffectPitchOffset = 0; this.cameraEffectPitchVelocity = 0; this.cameraEffectRollOffset = 0; if (Math.abs(this.camera.fov - this.baseCameraFov) > 1e-4) { this.camera.fov = this.baseCameraFov; this.camera.updateProjectionMatrix(); } } private applyPlayerCameraEffects(dt: number) { const telemetry = this.currentPlayerControllerTelemetry; const cameraHooks = telemetry?.hooks.camera ?? null; const signals = telemetry?.signals ?? null; if (signals?.jumpStarted) { this.cameraEffectVerticalVelocity += 0.42; this.cameraEffectPitchVelocity += 0.045; } if (signals?.startedFalling) { this.cameraEffectVerticalVelocity -= 0.1; this.cameraEffectPitchVelocity -= 0.035; } if (signals?.landed) { this.cameraEffectVerticalVelocity -= 0.68; this.cameraEffectPitchVelocity -= 0.08; } if (signals?.headBump) { this.cameraEffectVerticalVelocity -= 0.28; this.cameraEffectPitchVelocity -= 0.05; } this.cameraEffectVerticalOffset += this.cameraEffectVerticalVelocity * dt; this.cameraEffectPitchOffset += this.cameraEffectPitchVelocity * dt; this.cameraEffectVerticalVelocity = dampScalar( this.cameraEffectVerticalVelocity, 0, 10, dt ); this.cameraEffectPitchVelocity = dampScalar( this.cameraEffectPitchVelocity, 0, 12, dt ); this.cameraEffectVerticalOffset = dampScalar( this.cameraEffectVerticalOffset, 0, 9, dt ); this.cameraEffectPitchOffset = dampScalar( this.cameraEffectPitchOffset, 0, 10, dt ); const swimmingOffset = cameraHooks?.swimming === true ? Math.sin(this.volumeTime * 2.8) * 0.025 : 0; const targetRollOffset = cameraHooks?.swimming === true ? Math.sin(this.volumeTime * 1.8) * 0.012 : 0; const targetFov = this.baseCameraFov - (cameraHooks?.underwaterAmount ?? 0) * 1.8 - (cameraHooks?.swimming === true ? 0.6 : 0); this.cameraEffectRollOffset = dampScalar( this.cameraEffectRollOffset, targetRollOffset, 6, dt ); this.camera.position.y += this.cameraEffectVerticalOffset + swimmingOffset; this.camera.rotation.x += this.cameraEffectPitchOffset; this.camera.rotation.z += this.cameraEffectRollOffset; const nextFov = dampScalar(this.camera.fov, targetFov, 6, dt); if (Math.abs(nextFov - this.camera.fov) > 1e-4) { this.camera.fov = nextFov; this.camera.updateProjectionMatrix(); } } private getWaterReflectionMode() { if ( this.currentWorld === null || !this.currentWorld.advancedRendering.enabled || this.currentWorld.advancedRendering.waterPath !== "quality" ) { return "none" as const; } return this.currentWorld.advancedRendering.waterReflectionMode; } private createWaterReflectionRenderTarget() { const canvasWidth = this.container?.clientWidth ?? this.domElement.width; const canvasHeight = this.container?.clientHeight ?? this.domElement.height; const width = Math.max(128, Math.round(Math.max(canvasWidth, 512) * 0.5)); const height = Math.max(128, Math.round(Math.max(canvasHeight, 512) * 0.5)); return new WebGLRenderTarget(width, height); } private resizeWaterReflectionTargets() { const canvasWidth = this.container?.clientWidth ?? this.domElement.width; const canvasHeight = this.container?.clientHeight ?? this.domElement.height; const width = Math.max(128, Math.round(Math.max(canvasWidth, 512) * 0.5)); const height = Math.max(128, Math.round(Math.max(canvasHeight, 512) * 0.5)); for (const binding of this.runtimeWaterContactUniforms) { binding.reflectionRenderTarget?.setSize(width, height); binding.lastReflectionUpdateTime = Number.NEGATIVE_INFINITY; } } private updateRuntimeWaterReflections() { if (this.renderer === null || this.runtimeScene === null) { return; } const reflectionMode = this.getWaterReflectionMode(); const now = performance.now(); for (const binding of this.runtimeWaterContactUniforms) { if ( reflectionMode === "none" || binding.reflectionTextureUniform === null || binding.reflectionMatrixUniform === null || binding.reflectionEnabledUniform === null ) { if (binding.reflectionEnabledUniform !== null) { binding.reflectionEnabledUniform.value = 0; } continue; } if (binding.reflectionRenderTarget === null) { binding.reflectionRenderTarget = this.createWaterReflectionRenderTarget(); } const canRenderReflection = updatePlanarReflectionCamera( binding.brush, this.camera, this.waterReflectionCamera, binding.reflectionMatrixUniform.value ); if (!canRenderReflection || binding.reflectionRenderTarget === null) { binding.reflectionEnabledUniform.value = 0; continue; } if ( binding.reflectionTextureUniform.value !== null && now - binding.lastReflectionUpdateTime < WATER_REFLECTION_UPDATE_INTERVAL_MS ) { binding.reflectionEnabledUniform.value = 0.36; continue; } const hiddenWaterMeshes: Array<{ mesh: Mesh; visible: boolean; }> = []; for (const runtimeBrush of this.runtimeScene.brushes) { if (runtimeBrush.volume.mode !== "water") { continue; } const mesh = this.brushMeshes.get(runtimeBrush.id); if (mesh === undefined) { continue; } hiddenWaterMeshes.push({ mesh, visible: mesh.visible }); mesh.visible = false; } const previousModelGroupVisibility = this.modelGroup.visible; if (reflectionMode === "world") { this.modelGroup.visible = false; } const previousAutoClear = this.renderer.autoClear; const previousRenderTarget = this.renderer.getRenderTarget(); const previousFogDensity = this.underwaterSceneFog.density; const previousReflectionStates = this.runtimeWaterContactUniforms.map( (waterBinding) => ({ binding: waterBinding, enabled: waterBinding.reflectionEnabledUniform?.value ?? 0, texture: waterBinding.reflectionTextureUniform?.value ?? null }) ); try { this.underwaterSceneFog.density = 0; for (const state of previousReflectionStates) { if (state.binding.reflectionEnabledUniform !== null) { state.binding.reflectionEnabledUniform.value = 0; } } binding.reflectionTextureUniform.value = null; this.renderer.setRenderTarget(binding.reflectionRenderTarget); this.renderer.autoClear = true; this.renderer.clear(); this.renderer.render( this.worldBackgroundRenderer.scene, this.waterReflectionCamera ); this.renderer.autoClear = false; this.renderer.render(this.scene, this.waterReflectionCamera); } finally { this.renderer.setRenderTarget(previousRenderTarget); this.renderer.autoClear = previousAutoClear; this.modelGroup.visible = previousModelGroupVisibility; this.underwaterSceneFog.density = previousFogDensity; for (const state of previousReflectionStates) { if (state.binding.reflectionEnabledUniform !== null) { state.binding.reflectionEnabledUniform.value = state.enabled; } if (state.binding.reflectionTextureUniform !== null) { state.binding.reflectionTextureUniform.value = state.texture; } } for (const hiddenWaterMesh of hiddenWaterMeshes) { hiddenWaterMesh.mesh.visible = hiddenWaterMesh.visible; } } binding.reflectionTextureUniform.value = binding.reflectionRenderTarget.texture; binding.reflectionEnabledUniform.value = 0.36; binding.lastReflectionUpdateTime = now; } } private getOrCreateTextureSet( material: NonNullable ) { const signature = createStarterMaterialSignature(material); const cachedTexture = this.materialTextureCache.get(material.id); if (cachedTexture !== undefined && cachedTexture.signature === signature) { return cachedTexture.textureSet; } if (cachedTexture !== undefined) { disposeStarterMaterialTextureSet(cachedTexture.textureSet); } const textureSet = createStarterMaterialTextureSet( material, this.materialTextureLoader ); this.materialTextureCache.set(material.id, { signature, textureSet }); return textureSet; } private clearLocalLights() { for (const renderObjects of this.localLightObjects.values()) { this.localLightGroup.remove(renderObjects.group); } this.localLightObjects.clear(); } private clearLightVolumes() { for (const renderObjects of this.lightVolumeObjects.values()) { this.lightVolumeGroup.remove(renderObjects.group); } this.lightVolumeObjects.clear(); } private clearBrushMeshes() { for (const mesh of this.brushMeshes.values()) { this.brushGroup.remove(mesh); mesh.geometry.dispose(); this.disposeUniqueMaterials(mesh.material); } this.brushMeshes.clear(); this.volumeAnimatedUniforms.length = 0; for (const binding of this.runtimeWaterContactUniforms) { binding.reflectionRenderTarget?.dispose(); } this.runtimeWaterContactUniforms.length = 0; } private clearTerrainMeshes() { for (const mesh of this.terrainMeshes.values()) { this.terrainGroup.remove(mesh); mesh.geometry.dispose(); mesh.material.dispose(); } this.terrainMeshes.clear(); } private disposeUniqueMaterials(materials: Material[]) { for (const material of new Set(materials)) { material.dispose(); } } private createPlayerWaterContactBounds() { if ( this.runtimeScene === null || this.currentPlayerControllerTelemetry === null ) { return null; } const feetPosition = this.currentPlayerControllerTelemetry.feetPosition; const playerShape = this.runtimeScene.playerCollider; switch (playerShape.mode) { case "capsule": return { min: { x: feetPosition.x - playerShape.radius, y: feetPosition.y, z: feetPosition.z - playerShape.radius }, max: { x: feetPosition.x + playerShape.radius, y: feetPosition.y + playerShape.height, z: feetPosition.z + playerShape.radius } }; case "box": return { min: { x: feetPosition.x - playerShape.size.x * 0.5, y: feetPosition.y, z: feetPosition.z - playerShape.size.z * 0.5 }, max: { x: feetPosition.x + playerShape.size.x * 0.5, y: feetPosition.y + playerShape.size.y, z: feetPosition.z + playerShape.size.z * 0.5 } }; } return null; } private collectRuntimeStaticWaterContactPatches( brush: RuntimeBoxBrushInstance ): ReturnType { const contactBounds: Parameters[1] = []; if (this.runtimeScene === null) { return []; } for (const terrain of this.runtimeScene.terrains) { if (!terrain.visible) { continue; } const derivedMesh = buildTerrainDerivedMeshData({ ...terrain, kind: "terrain", enabled: true }); contactBounds.push({ kind: "triangleMesh", vertices: derivedMesh.positions, indices: derivedMesh.indices, mergeProfile: "aggressive", transform: { position: terrain.position, rotationDegrees: { x: 0, y: 0, z: 0 }, scale: { x: 1, y: 1, z: 1 } } }); } for (const collider of this.runtimeScene.colliders) { if (collider.source === "terrain") { continue; } if (collider.kind === "trimesh" && collider.source === "brush") { if (collider.brushId === brush.id) { continue; } contactBounds.push({ kind: "triangleMesh", vertices: collider.vertices, indices: collider.indices, transform: { position: collider.center, rotationDegrees: collider.rotationDegrees, scale: { x: 1, y: 1, z: 1 } } }); continue; } if (collider.kind === "trimesh") { contactBounds.push({ kind: "triangleMesh", vertices: collider.vertices, indices: collider.indices, mergeProfile: "aggressive", transform: collider.transform }); continue; } contactBounds.push({ min: collider.worldBounds.min, max: collider.worldBounds.max }); } return collectWaterContactPatches( { center: brush.center, rotationDegrees: brush.rotationDegrees, size: brush.size }, contactBounds, this.getRuntimeWaterFoamContactLimit(brush) ); } private collectRuntimePlayerWaterContactPatches( brush: RuntimeBoxBrushInstance ) { const playerBounds = this.createPlayerWaterContactBounds(); if (playerBounds === null) { return []; } return collectWaterContactPatches( { center: brush.center, rotationDegrees: brush.rotationDegrees, size: brush.size }, [playerBounds], this.getRuntimeWaterFoamContactLimit(brush) ); } private getRuntimeWaterFoamContactLimit(brush: RuntimeBoxBrushInstance) { return brush.volume.mode === "water" ? brush.volume.water.foamContactLimit : 0; } private mergeRuntimeWaterContactPatches( brush: RuntimeBoxBrushInstance, staticContactPatches: ReturnType, dynamicContactPatches: ReturnType ) { return [...dynamicContactPatches, ...staticContactPatches].slice( 0, this.getRuntimeWaterFoamContactLimit(brush) ); } private updateRuntimeWaterContactUniforms() { for (const binding of this.runtimeWaterContactUniforms) { const mergedPatches = this.mergeRuntimeWaterContactPatches( binding.brush, binding.staticContactPatches, this.collectRuntimePlayerWaterContactPatches(binding.brush) ); binding.uniform.value = createWaterContactPatchUniformValue(mergedPatches); binding.axisUniform.value = createWaterContactPatchAxisUniformValue(mergedPatches); binding.shapeUniform.value = createWaterContactPatchShapeUniformValue(mergedPatches); } } private clearModelRenderObjects() { 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(); } 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(); this.domElement.width = width; this.domElement.height = height; this.renderer?.setSize(width, height, false); this.advancedRenderingComposer?.setSize(width, height); this.resizeWaterReflectionTargets(); } 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.updatePauseInputState(); const simulationDt = this.isRuntimePaused() ? 0 : dt; const previousCameraPose = this.captureCurrentCameraPose(); this.activeController?.update(simulationDt); const activeCameraRig = this.applyActiveCameraRig( simulationDt, previousCameraPose ); if (activeCameraRig === null) { this.applyPlayerCameraEffects(simulationDt); } else { this.resetPlayerCameraEffects(); } this.audioSystem.setPlayerControllerAudioHooks( this.currentPlayerAudioHooks ); this.audioSystem.updateListenerTransform(); this.volumeTime += simulationDt; for (const uniform of this.volumeAnimatedUniforms) { uniform.value = this.volumeTime; } if (this.currentClockState !== null && simulationDt > 0) { this.currentClockState = advanceRuntimeClockState( this.currentClockState, simulationDt ); if (this.sceneReady) { this.syncRuntimeScheduleToCurrentClock(); } this.applyDayNightLighting(); this.clockPublishAccumulator += simulationDt; if ( this.clockPublishAccumulator >= RUNTIME_CLOCK_PUBLISH_INTERVAL_SECONDS ) { this.clockPublishAccumulator = 0; this.publishRuntimeClockState(); } } for (const mixer of this.animationMixers.values()) { mixer.update(simulationDt); } if ( this.sceneReady && this.runtimeScene !== null && this.currentPlayerControllerTelemetry !== null && !this.isRuntimePaused() ) { this.interactionSystem.updatePlayerPosition( { feetPosition: this.currentPlayerControllerTelemetry.feetPosition, eyePosition: this.currentPlayerControllerTelemetry.eyePosition }, this.runtimeScene, this.createInteractionDispatcher() ); this.setInteractionPrompt( this.currentDialogue === null ? this.resolveInteractionPrompt() : null ); } else { this.setInteractionPrompt(null); } if (this.runtimeWaterContactUniforms.length > 0) { this.updateRuntimeWaterContactUniforms(); this.updateRuntimeWaterReflections(); } this.updateUnderwaterSceneFog(); this.syncCelestialShadowState(); if (this.advancedRenderingComposer !== null) { this.worldBackgroundRenderer.syncToCamera(this.camera); this.advancedRenderingComposer.render(dt); return; } if (this.renderer === null) { return; } this.worldBackgroundRenderer.syncToCamera(this.camera); const previousAutoClear = this.renderer.autoClear; this.renderer.autoClear = true; this.renderer.clear(); this.renderer.render(this.worldBackgroundRenderer.scene, this.camera); this.renderer.autoClear = false; this.renderer.render(this.scene, this.camera); this.renderer.autoClear = previousAutoClear; }; private applyTeleportPlayerAction(target: RuntimeTeleportTarget) { if (this.activeController === this.thirdPersonController) { this.thirdPersonController.teleportTo(target.position, target.yawDegrees); return; } this.firstPersonController.teleportTo(target.position, target.yawDegrees); } private applySceneTransitionEffect(options: { sourceEntityId: string | null; targetSceneId: string; targetEntryEntityId: string; }) { this.sceneTransitionHandler?.({ sourceEntityId: options.sourceEntityId, targetSceneId: options.targetSceneId, targetEntryEntityId: options.targetEntryEntityId }); } private dispatchImpulseSequenceEffect( effect: ImpulseSequenceStep, sourceEntityId: string | null ) { if (this.runtimeScene === null) { return; } switch (effect.type) { case "controlEffect": this.applyControlEffect(effect.effect, null); return; case "makeNpcTalk": this.openRuntimeNpcDialogue(effect.npcEntityId, effect.dialogueId, { kind: "direct", sourceEntityId, linkId: null, trigger: null }); return; case "teleportPlayer": { const teleportTarget = this.runtimeScene.entities.teleportTargets.find( (candidate) => candidate.entityId === effect.targetEntityId ) ?? null; if (teleportTarget !== null) { this.applyTeleportPlayerAction(teleportTarget); } return; } case "startSceneTransition": this.applySceneTransitionEffect({ sourceEntityId, targetSceneId: effect.targetSceneId, targetEntryEntityId: effect.targetEntryEntityId }); return; case "setVisibility": this.applyVisibilitySequenceEffect(effect.target, effect.mode); return; } } private syncRuntimeScheduleToCurrentClock() { if (this.runtimeScene === null || this.currentClockState === null) { return; } const nextResolvedScheduler = resolveRuntimeProjectScheduleState({ scheduler: this.runtimeScene.scheduler.document, sequences: this.runtimeScene.sequences, actorIds: this.runtimeScene.npcDefinitions.map((npc) => npc.actorId), dayNumber: this.currentClockState.dayCount + 1, timeOfDayHours: this.currentClockState.timeOfDayHours, pathsById: new Map(this.runtimeScene.paths.map((path) => [path.id, path])) }); const actorStates = new Map( nextResolvedScheduler.actors.map((state) => [state.actorId, state]) ); const nextActiveImpulseRoutineIds = new Set( nextResolvedScheduler.impulses.map((routine) => routine.routineId) ); let changed = false; for (const npc of this.runtimeScene.npcDefinitions) { const actorState = actorStates.get(npc.actorId); const previousActive = npc.active; const previousRoutineId = npc.activeRoutineId; const previousRoutineTitle = npc.activeRoutineTitle; const previousAnimationClipName = npc.animationClipName; const previousAnimationLoop = npc.animationLoop; const previousYawDegrees = npc.yawDegrees; const previousPosition = { x: npc.position.x, y: npc.position.y, z: npc.position.z }; const previousPathId = npc.resolvedPath?.pathId ?? null; const previousPathProgress = npc.resolvedPath?.progress ?? null; applyActorScheduleStateToNpcDefinition(npc, actorState ?? null); if ( npc.active === previousActive && npc.activeRoutineId === previousRoutineId && npc.activeRoutineTitle === previousRoutineTitle && npc.animationClipName === previousAnimationClipName && npc.animationLoop === previousAnimationLoop && npc.yawDegrees === previousYawDegrees && npc.position.x === previousPosition.x && npc.position.y === previousPosition.y && npc.position.z === previousPosition.z && (npc.resolvedPath?.pathId ?? null) === previousPathId && (npc.resolvedPath?.progress ?? null) === previousPathProgress ) { continue; } changed = true; const renderGroup = this.modelRenderObjects.get(npc.entityId); if (renderGroup !== undefined) { renderGroup.visible = npc.visible && npc.active; renderGroup.position.set( npc.position.x, npc.position.y, npc.position.z ); renderGroup.rotation.set(0, (npc.yawDegrees * Math.PI) / 180, 0); } if ( this.animationMixers.has(npc.entityId) && (npc.animationClipName !== previousAnimationClipName || npc.animationLoop !== previousAnimationLoop) ) { if (npc.animationClipName === null) { this.applyStopAnimationAction(npc.entityId); } else { this.applyPlayAnimationAction( npc.entityId, npc.animationClipName, npc.animationLoop ); } } } const nextResolvedControl = applyRuntimeProjectScheduleToControlState( this.runtimeScene.control.resolved, nextResolvedScheduler, this.runtimeScene.control.baselineResolved ); this.syncResolvedControlStateToRuntime(nextResolvedControl); for (const impulseRoutine of nextResolvedScheduler.impulses) { if ( this.activeScheduledImpulseRoutineIds.has(impulseRoutine.routineId) || this.completedScheduledImpulseRoutineIds.has(impulseRoutine.routineId) ) { continue; } for (const effect of impulseRoutine.effects) { this.dispatchImpulseSequenceEffect(effect, null); } this.completedScheduledImpulseRoutineIds.add(impulseRoutine.routineId); } this.runtimeScene.scheduler.resolved = nextResolvedScheduler; this.runtimeScene.control.resolved = nextResolvedControl; this.activeScheduledImpulseRoutineIds = nextActiveImpulseRoutineIds; if (changed) { this.refreshRuntimeNpcCollections(); this.refreshCollisionWorldForNpcSchedule(); } } private refreshRuntimeNpcCollections() { if (this.runtimeScene === null) { return; } this.runtimeScene.entities.npcs = this.runtimeScene.npcDefinitions .filter((npc) => npc.active) .map((npc) => createRuntimeNpcFromDefinition(npc)); this.runtimeScene.colliders = [ ...this.runtimeScene.staticColliders, ...this.runtimeScene.entities.npcs .map((npc) => buildRuntimeNpcCollider(npc)) .filter(isNonNull) ]; } private refreshCollisionWorldForNpcSchedule() { if (this.runtimeScene === null) { return; } const requestId = ++this.collisionWorldRequestId; const previousCollisionWorld = this.collisionWorld; void this.buildCollisionWorld( requestId, this.runtimeScene.colliders, this.runtimeScene.playerCollider, this.runtimeScene.playerMovement ) .then((nextCollisionWorld) => { if (requestId !== this.collisionWorldRequestId) { nextCollisionWorld.dispose(); return; } this.collisionWorld = nextCollisionWorld; previousCollisionWorld?.dispose(); }) .catch((error) => { if (requestId !== this.collisionWorldRequestId) { return; } const detail = error instanceof Error && error.message.trim().length > 0 ? error.message.trim() : "Unknown error."; const message = `Runner collision refresh failed: ${detail}`; this.currentRuntimeMessage = message; this.runtimeMessageHandler?.(message); }); } private applyToggleBrushVisibilityAction( brushId: string, visible: boolean | undefined ) { const mesh = this.brushMeshes.get(brushId); if (mesh === undefined) { return; } if (this.runtimeScene !== null) { const brush = this.runtimeScene.brushes.find( (candidate) => candidate.id === brushId ) ?? null; if (brush !== null) { brush.visible = visible ?? !brush.visible; } } mesh.visible = visible ?? !mesh.visible; } private applyVisibilitySequenceEffect( target: SequenceVisibilityTarget, mode: SequenceVisibilityMode ) { const explicitVisible = mode === "toggle" ? undefined : mode === "show"; if (target.kind === "brush") { this.applyToggleBrushVisibilityAction(target.brushId, explicitVisible); return; } const runtimeModelInstance = this.runtimeScene?.modelInstances.find( (candidate) => candidate.instanceId === target.modelInstanceId ) ?? null; const currentVisible = runtimeModelInstance?.visible ?? this.modelRenderObjects.get(target.modelInstanceId)?.visible ?? true; this.applyModelInstanceVisibilityControl( { kind: "modelInstance", modelInstanceId: target.modelInstanceId }, explicitVisible ?? !currentVisible ); } private applyPlayAnimationAction( instanceId: string, clipName: string, loop: boolean | undefined ) { 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(); } private applyStopAnimationAction(instanceId: string) { const mixer = this.animationMixers.get(instanceId); if (!mixer) { console.warn(`stopAnimation: no mixer for instance ${instanceId}`); return; } mixer.stopAllAction(); } private createInteractionDispatcher(): RuntimeInteractionDispatcher { return { teleportPlayer: (target) => { this.applyTeleportPlayerAction(target); }, startSceneTransition: (request) => { this.applySceneTransitionEffect(request); }, toggleBrushVisibility: (brushId, visible) => { this.applyToggleBrushVisibilityAction(brushId, visible); }, setVisibility: (target, mode) => { this.applyVisibilitySequenceEffect(target, mode); }, 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); }, startNpcDialogue: (npcEntityId, dialogueId, source) => { this.openRuntimeNpcDialogue(npcEntityId, dialogueId, source); }, dispatchControlEffect: (effect, link) => { this.applyControlEffect(effect, link); } }; } 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 createRuntimeNpcDialogueState( npcEntityId: string, dialogueId: string, lineIndex: number, source: RuntimeDialogueStartSource ): RuntimeDialogueState | null { if (this.runtimeScene === null) { return null; } const npc = this.runtimeScene.entities.npcs.find( (candidate) => candidate.entityId === npcEntityId ) ?? null; if (npc === null) { return null; } const dialogue = npc.dialogues.find( (candidate) => candidate.id === dialogueId ); if (dialogue === undefined) { return null; } const line = dialogue.lines[lineIndex]; if (line === undefined) { return null; } return { npcEntityId, dialogueId, title: dialogue.title, lineId: line.id, lineIndex, lineCount: dialogue.lines.length, speakerName: npc.actorId, text: line.text, source }; } private setRuntimeDialogue(dialogue: RuntimeDialogueState | null) { if ( this.currentDialogue?.npcEntityId === dialogue?.npcEntityId && this.currentDialogue?.dialogueId === dialogue?.dialogueId && this.currentDialogue?.lineId === dialogue?.lineId && this.currentDialogue?.lineIndex === dialogue?.lineIndex && this.currentDialogue?.lineCount === dialogue?.lineCount && this.currentDialogue?.speakerName === dialogue?.speakerName && this.currentDialogue?.text === dialogue?.text && this.currentDialogue?.title === dialogue?.title && this.currentDialogue?.source.kind === dialogue?.source.kind && this.currentDialogue?.source.sourceEntityId === dialogue?.source.sourceEntityId && this.currentDialogue?.source.linkId === dialogue?.source.linkId && this.currentDialogue?.source.trigger === dialogue?.source.trigger ) { return; } if ( dialogue !== null && this.activeDialogueAttentionState?.npcEntityId !== dialogue.npcEntityId ) { this.activeDialogueAttentionState = null; } this.currentDialogue = dialogue; this.setDialoguePauseActive(dialogue !== null); this.runtimeDialogueHandler?.(dialogue); } private openRuntimeNpcDialogue( npcEntityId: string, dialogueId: string | null, source: RuntimeDialogueStartSource = { kind: "direct", sourceEntityId: null, linkId: null, trigger: null } ) { if (this.runtimeScene === null) { return; } const npc = this.runtimeScene.entities.npcs.find( (candidate) => candidate.entityId === npcEntityId ) ?? null; if (npc === null) { console.warn(`dialogue: missing npc ${npcEntityId}`); return; } const resolvedDialogueId = dialogueId ?? npc.defaultDialogueId ?? npc.dialogues[0]?.id ?? null; if (resolvedDialogueId === null) { console.warn(`dialogue: npc ${npcEntityId} has no dialogue to speak`); return; } if ( this.currentDialogue?.npcEntityId === npcEntityId && this.currentDialogue?.dialogueId === resolvedDialogueId ) { return; } const dialogue = this.createRuntimeNpcDialogueState( npcEntityId, resolvedDialogueId, 0, source ); if (dialogue === null) { console.warn( `dialogue: npc ${npcEntityId} is missing dialogue ${resolvedDialogueId}` ); return; } this.setRuntimeDialogue(dialogue); } private resolveInteractionPrompt(): RuntimeInteractionPrompt | null { if ( this.runtimeScene === null || this.currentPlayerControllerTelemetry === null || (this.activeController !== this.firstPersonController && this.activeController !== this.thirdPersonController) ) { return null; } this.camera.getWorldDirection(this.cameraForward); const interactionOrigin = this.currentPlayerControllerTelemetry.eyePosition; const rayOrigin = this.activeController === this.thirdPersonController ? { x: this.camera.position.x, y: this.camera.position.y, z: this.camera.position.z } : interactionOrigin; return this.interactionSystem.resolveClickInteractionPrompt( interactionOrigin, rayOrigin, { x: this.cameraForward.x, y: this.cameraForward.y, z: this.cameraForward.z }, this.runtimeScene ); } private handleRuntimeClick = () => { if ( !this.sceneReady || this.runtimeScene === null || (this.activeController !== this.firstPersonController && this.activeController !== this.thirdPersonController) ) { return; } this.audioSystem.handleUserGesture(); if (this.currentDialogue !== null) { this.advanceRuntimeDialogue(); return; } if (this.isRuntimePaused()) { return; } if (this.currentInteractionPrompt === null) { return; } this.interactionSystem.dispatchClickInteraction( this.currentInteractionPrompt.sourceEntityId, this.runtimeScene, this.createInteractionDispatcher() ); }; private handleRuntimePointerDown = (event: PointerEvent) => { if (!this.sceneReady) { return; } this.audioSystem.handleUserGesture(); if ( this.activeRuntimeCameraRig === null || !this.activeRuntimeCameraRig.lookAround.enabled || this.isRuntimePaused() || event.button !== 0 ) { return; } this.cameraRigLookDragging = true; this.lastCameraRigPointerClientX = event.clientX; this.lastCameraRigPointerClientY = event.clientY; event.preventDefault(); event.stopImmediatePropagation(); }; private handleRuntimeKeyDown = (event: KeyboardEvent) => { if ( this.runtimeScene === null || !this.sceneReady || event.code !== this.runtimeScene.playerInputBindings.keyboard.pauseTime ) { return; } this.pressedKeys.add(event.code); if ( event.defaultPrevented || event.repeat || event.altKey || event.ctrlKey || event.metaKey || isEditableEventTarget(event.target) ) { return; } this.pressedKeys.add(event.code); event.preventDefault(); this.toggleManualPause(); this.previousPauseInputActive = true; }; private handleRuntimeKeyUp = (event: KeyboardEvent) => { this.pressedKeys.delete(event.code); }; private handleRuntimePointerMove = (event: PointerEvent) => { if ( !this.cameraRigLookDragging || this.activeRuntimeCameraRig === null || !this.activeRuntimeCameraRig.lookAround.enabled || this.isRuntimePaused() ) { return; } const deltaX = event.clientX - this.lastCameraRigPointerClientX; const deltaY = event.clientY - this.lastCameraRigPointerClientY; this.lastCameraRigPointerClientX = event.clientX; this.lastCameraRigPointerClientY = event.clientY; this.cameraRigLookYawRadians = clampScalar( this.cameraRigLookYawRadians - deltaX * CAMERA_RIG_POINTER_LOOK_SENSITIVITY, (-this.activeRuntimeCameraRig.lookAround.yawLimitDegrees * Math.PI) / 180, (this.activeRuntimeCameraRig.lookAround.yawLimitDegrees * Math.PI) / 180 ); this.cameraRigLookPitchRadians = clampScalar( this.cameraRigLookPitchRadians - deltaY * CAMERA_RIG_POINTER_LOOK_SENSITIVITY, (-this.activeRuntimeCameraRig.lookAround.pitchLimitDegrees * Math.PI) / 180, (this.activeRuntimeCameraRig.lookAround.pitchLimitDegrees * Math.PI) / 180 ); event.preventDefault(); event.stopImmediatePropagation(); }; private handleRuntimePointerUp = (event: PointerEvent) => { if (!this.cameraRigLookDragging) { return; } this.cameraRigLookDragging = false; event.stopImmediatePropagation(); }; private handleRuntimeWheel = (event: WheelEvent) => { if (this.activeRuntimeCameraRig === null) { return; } event.preventDefault(); event.stopImmediatePropagation(); }; private handleRuntimeBlur = () => { this.pressedKeys.clear(); this.previousPauseInputActive = false; this.cameraRigLookDragging = false; }; private updatePauseInputState() { if (this.runtimeScene === null || !this.sceneReady) { this.previousPauseInputActive = false; return; } const pauseInputActive = resolvePlayerStartPauseInput( this.pressedKeys, this.runtimeScene.playerInputBindings ) >= 0.5; if (pauseInputActive && !this.previousPauseInputActive) { this.toggleManualPause(); } this.previousPauseInputActive = pauseInputActive; } }