2026-03-31 03:04:15 +02:00
|
|
|
import {
|
|
|
|
|
AmbientLight,
|
2026-04-01 00:05:29 +02:00
|
|
|
AnimationClip,
|
|
|
|
|
AnimationMixer,
|
2026-04-05 02:30:08 +02:00
|
|
|
BufferGeometry,
|
2026-04-13 17:24:23 +02:00
|
|
|
BoxGeometry,
|
|
|
|
|
CapsuleGeometry,
|
|
|
|
|
ConeGeometry,
|
2026-03-31 03:04:15 +02:00
|
|
|
DirectionalLight,
|
2026-04-06 08:26:53 +02:00
|
|
|
Euler,
|
2026-03-31 03:04:15 +02:00
|
|
|
Group,
|
2026-04-07 06:15:02 +02:00
|
|
|
FogExp2,
|
2026-04-01 04:05:19 +02:00
|
|
|
LoopOnce,
|
|
|
|
|
LoopRepeat,
|
2026-04-07 06:34:54 +02:00
|
|
|
Matrix4,
|
2026-04-06 09:16:30 +02:00
|
|
|
Material,
|
2026-03-31 03:04:15 +02:00
|
|
|
Mesh,
|
2026-04-06 09:16:30 +02:00
|
|
|
MeshBasicMaterial,
|
2026-03-31 03:04:15 +02:00
|
|
|
MeshStandardMaterial,
|
|
|
|
|
PerspectiveCamera,
|
2026-03-31 20:06:54 +02:00
|
|
|
PointLight,
|
|
|
|
|
Quaternion,
|
2026-03-31 03:04:15 +02:00
|
|
|
Scene,
|
2026-04-07 11:18:51 +02:00
|
|
|
ShaderMaterial,
|
2026-03-31 06:46:27 +02:00
|
|
|
Vector3,
|
2026-03-31 20:06:54 +02:00
|
|
|
SpotLight,
|
2026-04-07 06:34:54 +02:00
|
|
|
WebGLRenderTarget,
|
2026-03-31 03:04:15 +02:00
|
|
|
WebGLRenderer
|
|
|
|
|
} from "three";
|
2026-04-02 20:51:55 +02:00
|
|
|
import { EffectComposer } from "postprocessing";
|
2026-03-31 03:04:15 +02:00
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
import {
|
|
|
|
|
createModelInstanceRenderGroup,
|
|
|
|
|
disposeModelInstance
|
|
|
|
|
} from "../assets/model-instance-rendering";
|
2026-03-31 17:40:12 +02:00
|
|
|
import type { LoadedModelAsset } from "../assets/gltf-model-import";
|
2026-03-31 20:06:54 +02:00
|
|
|
import type { LoadedImageAsset } from "../assets/image-assets";
|
2026-04-02 19:39:55 +02:00
|
|
|
import type { LoadedAudioAsset } from "../assets/audio-assets";
|
2026-03-31 17:40:12 +02:00
|
|
|
import type { ProjectAssetRecord } from "../assets/project-assets";
|
2026-04-12 03:56:31 +02:00
|
|
|
import type { BoxBrush } from "../document/brushes";
|
2026-04-14 01:35:27 +02:00
|
|
|
import {
|
|
|
|
|
applyControlEffectToResolvedState,
|
|
|
|
|
createInteractionLinkResolvedControlSource,
|
2026-04-14 13:46:26 +02:00
|
|
|
type ActorControlTargetRef,
|
2026-04-14 01:35:27 +02:00
|
|
|
type ControlEffect,
|
|
|
|
|
type InteractionControlTargetRef,
|
|
|
|
|
type LightControlTargetRef,
|
2026-04-14 02:38:06 +02:00
|
|
|
type ModelInstanceControlTargetRef,
|
2026-04-14 01:35:27 +02:00
|
|
|
type RuntimeResolvedControlChannelValue,
|
2026-04-14 02:38:06 +02:00
|
|
|
type RuntimeResolvedDiscreteControlState,
|
|
|
|
|
type SceneControlTargetRef,
|
|
|
|
|
type SoundEmitterControlTargetRef
|
2026-04-14 01:35:27 +02:00
|
|
|
} from "../controls/control-surface";
|
2026-04-05 02:30:08 +02:00
|
|
|
import { buildBoxBrushDerivedMeshData } from "../geometry/box-brush-mesh";
|
2026-04-11 04:19:50 +02:00
|
|
|
import {
|
|
|
|
|
createStarterMaterialSignature,
|
|
|
|
|
createStarterMaterialTexture
|
|
|
|
|
} from "../materials/starter-material-textures";
|
2026-04-02 20:51:55 +02:00
|
|
|
import {
|
|
|
|
|
applyAdvancedRenderingLightShadowFlags,
|
|
|
|
|
applyAdvancedRenderingRenderableShadowFlags,
|
|
|
|
|
configureAdvancedRenderingRenderer,
|
2026-04-06 08:26:27 +02:00
|
|
|
createAdvancedRenderingComposer,
|
2026-04-06 08:44:52 +02:00
|
|
|
resolveBoxVolumeRenderPaths,
|
|
|
|
|
type ResolvedBoxVolumeRenderPaths
|
2026-04-02 20:51:55 +02:00
|
|
|
} from "../rendering/advanced-rendering";
|
2026-04-13 14:10:52 +02:00
|
|
|
import {
|
|
|
|
|
resolveWorldEnvironmentState,
|
|
|
|
|
WorldBackgroundRenderer
|
|
|
|
|
} from "../rendering/world-background-renderer";
|
2026-04-07 06:15:02 +02:00
|
|
|
import {
|
|
|
|
|
collectWaterContactPatches,
|
|
|
|
|
createWaterContactPatchAxisUniformValue,
|
|
|
|
|
createWaterContactPatchShapeUniformValue,
|
|
|
|
|
createWaterContactPatchUniformValue,
|
|
|
|
|
createWaterMaterial
|
|
|
|
|
} from "../rendering/water-material";
|
2026-04-07 11:07:19 +02:00
|
|
|
import { createFogQualityMaterial } from "../rendering/fog-material";
|
2026-04-07 06:34:54 +02:00
|
|
|
import { updatePlanarReflectionCamera } from "../rendering/planar-reflection";
|
2026-04-12 01:04:39 +02:00
|
|
|
import {
|
|
|
|
|
applyWhiteboxBevelToMaterial,
|
|
|
|
|
shouldApplyWhiteboxBevel
|
|
|
|
|
} from "../rendering/whitebox-bevel-material";
|
2026-04-02 20:51:55 +02:00
|
|
|
import {
|
|
|
|
|
areAdvancedRenderingSettingsEqual,
|
|
|
|
|
cloneAdvancedRenderingSettings,
|
|
|
|
|
type AdvancedRenderingSettings
|
2026-04-02 20:58:35 +02:00
|
|
|
} from "../document/world-settings";
|
2026-04-13 17:24:35 +02:00
|
|
|
import { getNpcColliderHeight } from "../entities/entity-instances";
|
2026-04-14 01:35:46 +02:00
|
|
|
import type { InteractionLink } from "../interactions/interaction-links";
|
2026-03-31 03:04:15 +02:00
|
|
|
|
|
|
|
|
import { FirstPersonNavigationController } from "./first-person-navigation-controller";
|
2026-04-11 04:19:50 +02:00
|
|
|
import type {
|
|
|
|
|
NavigationController,
|
2026-04-11 19:06:34 +02:00
|
|
|
PlayerControllerTelemetry,
|
2026-04-11 04:19:50 +02:00
|
|
|
RuntimeControllerContext,
|
2026-04-11 19:06:34 +02:00
|
|
|
RuntimePlayerAudioHookState,
|
2026-04-11 04:19:50 +02:00
|
|
|
RuntimePlayerVolumeState
|
|
|
|
|
} from "./navigation-controller";
|
2026-04-04 07:51:38 +02:00
|
|
|
import { RapierCollisionWorld } from "./rapier-collision-world";
|
2026-04-11 04:19:50 +02:00
|
|
|
import {
|
|
|
|
|
RuntimeInteractionSystem,
|
|
|
|
|
type RuntimeInteractionDispatcher,
|
|
|
|
|
type RuntimeInteractionPrompt
|
|
|
|
|
} from "./runtime-interaction-system";
|
2026-04-02 19:39:55 +02:00
|
|
|
import { RuntimeAudioSystem } from "./runtime-audio-system";
|
2026-04-12 04:32:46 +02:00
|
|
|
import {
|
|
|
|
|
advanceRuntimeClockState,
|
|
|
|
|
areRuntimeClockStatesEqual,
|
|
|
|
|
cloneRuntimeClockState,
|
|
|
|
|
createRuntimeClockState,
|
|
|
|
|
reconfigureRuntimeClockState,
|
|
|
|
|
resolveRuntimeDayNightWorldState,
|
2026-04-13 15:54:16 +02:00
|
|
|
resolveRuntimeTimeState,
|
2026-04-12 04:32:46 +02:00
|
|
|
type RuntimeClockState
|
|
|
|
|
} from "./runtime-project-time";
|
2026-04-14 01:57:28 +02:00
|
|
|
import {
|
|
|
|
|
applyRuntimeProjectScheduleToControlState,
|
|
|
|
|
resolveRuntimeProjectScheduleState
|
|
|
|
|
} from "./runtime-project-scheduler";
|
2026-04-11 11:16:01 +02:00
|
|
|
import { ThirdPersonNavigationController } from "./third-person-navigation-controller";
|
2026-04-07 06:15:02 +02:00
|
|
|
import { resolveUnderwaterFogState } from "./underwater-fog";
|
2026-04-11 22:09:45 +02:00
|
|
|
import { resolveWaterContact } from "./water-volume-utils";
|
2026-03-31 20:06:54 +02:00
|
|
|
import type {
|
2026-04-13 23:50:32 +02:00
|
|
|
RuntimeNpcDefinition,
|
2026-03-31 20:06:54 +02:00
|
|
|
RuntimeBoxBrushInstance,
|
|
|
|
|
RuntimeLocalLightCollection,
|
|
|
|
|
RuntimeNavigationMode,
|
2026-04-13 16:30:12 +02:00
|
|
|
RuntimeNpc,
|
2026-03-31 20:06:54 +02:00
|
|
|
RuntimeSceneDefinition,
|
|
|
|
|
RuntimeTeleportTarget
|
|
|
|
|
} from "./runtime-scene-build";
|
2026-04-13 23:57:07 +02:00
|
|
|
import {
|
2026-04-14 13:46:26 +02:00
|
|
|
applyActorScheduleStateToNpcDefinition,
|
2026-04-13 23:57:07 +02:00
|
|
|
buildRuntimeNpcCollider,
|
|
|
|
|
createRuntimeNpcFromDefinition
|
|
|
|
|
} from "./runtime-scene-build";
|
2026-03-31 03:04:15 +02:00
|
|
|
|
|
|
|
|
interface CachedMaterialTexture {
|
|
|
|
|
signature: string;
|
|
|
|
|
texture: ReturnType<typeof createStarterMaterialTexture>;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 20:07:11 +02:00
|
|
|
interface LocalLightRenderObjects {
|
|
|
|
|
group: Group;
|
2026-04-14 01:35:27 +02:00
|
|
|
light: PointLight | SpotLight;
|
2026-03-31 20:07:11 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-06 18:04:29 +02:00
|
|
|
interface RuntimeWaterContactUniformBinding {
|
|
|
|
|
brush: RuntimeBoxBrushInstance;
|
|
|
|
|
uniform: { value: import("three").Vector4[] };
|
2026-04-07 04:58:48 +02:00
|
|
|
axisUniform: { value: import("three").Vector2[] };
|
2026-04-07 06:15:02 +02:00
|
|
|
shapeUniform: { value: number[] };
|
2026-04-06 20:52:05 +02:00
|
|
|
staticContactPatches: ReturnType<typeof collectWaterContactPatches>;
|
2026-04-07 06:34:54 +02:00
|
|
|
reflectionTextureUniform: { value: import("three").Texture | null } | null;
|
|
|
|
|
reflectionMatrixUniform: { value: Matrix4 } | null;
|
|
|
|
|
reflectionEnabledUniform: { value: number } | null;
|
|
|
|
|
reflectionRenderTarget: WebGLRenderTarget | null;
|
2026-04-07 07:12:47 +02:00
|
|
|
lastReflectionUpdateTime: number;
|
2026-04-06 18:04:29 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 14:53:34 +02:00
|
|
|
const FALLBACK_FACE_COLOR = 0xf2ece2;
|
2026-04-07 11:33:12 +02:00
|
|
|
const BOX_FACE_MATERIAL_COUNT = 6;
|
2026-04-12 14:57:02 +02:00
|
|
|
const RUNTIME_CLOCK_PUBLISH_INTERVAL_SECONDS = 1 / 30;
|
2026-04-07 07:12:47 +02:00
|
|
|
const WATER_REFLECTION_UPDATE_INTERVAL_MS = 96;
|
2026-03-31 03:04:15 +02:00
|
|
|
|
2026-04-11 19:07:37 +02:00
|
|
|
function dampScalar(current: number, target: number, rate: number, dt: number) {
|
|
|
|
|
return current + (target - current) * Math.min(1, dt * rate);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 23:50:32 +02:00
|
|
|
function isNonNull<T>(value: T | null): value is T {
|
|
|
|
|
return value !== null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:15:01 +02:00
|
|
|
export interface RuntimeSceneLoadState {
|
|
|
|
|
status: "loading" | "ready" | "error";
|
|
|
|
|
message: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:34:26 +02:00
|
|
|
export interface RuntimeSceneExitTransitionRequest {
|
|
|
|
|
sourceExitEntityId: string;
|
|
|
|
|
targetSceneId: string;
|
|
|
|
|
targetEntryEntityId: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 03:04:15 +02:00
|
|
|
export class RuntimeHost {
|
|
|
|
|
private readonly scene = new Scene();
|
2026-04-13 14:10:52 +02:00
|
|
|
private readonly worldBackgroundRenderer = new WorldBackgroundRenderer();
|
2026-03-31 03:04:15 +02:00
|
|
|
private readonly camera = new PerspectiveCamera(70, 1, 0.05, 1000);
|
2026-03-31 06:46:27 +02:00
|
|
|
private readonly cameraForward = new Vector3();
|
2026-04-06 08:25:50 +02:00
|
|
|
private readonly volumeOffset = new Vector3();
|
|
|
|
|
private readonly volumeInverseRotation = new Quaternion();
|
2026-04-07 11:18:51 +02:00
|
|
|
private readonly fogLocalCameraPosition = new Vector3();
|
2026-03-31 06:25:22 +02:00
|
|
|
private readonly domElement: HTMLCanvasElement;
|
2026-03-31 03:04:15 +02:00
|
|
|
private readonly ambientLight = new AmbientLight();
|
|
|
|
|
private readonly sunLight = new DirectionalLight();
|
2026-04-12 14:10:11 +02:00
|
|
|
private readonly moonLight = new DirectionalLight();
|
2026-03-31 20:06:54 +02:00
|
|
|
private readonly localLightGroup = new Group();
|
2026-03-31 03:04:15 +02:00
|
|
|
private readonly brushGroup = new Group();
|
2026-03-31 17:40:12 +02:00
|
|
|
private readonly modelGroup = new Group();
|
2026-04-11 04:19:50 +02:00
|
|
|
private readonly firstPersonController =
|
|
|
|
|
new FirstPersonNavigationController();
|
2026-04-11 11:16:01 +02:00
|
|
|
private readonly thirdPersonController =
|
|
|
|
|
new ThirdPersonNavigationController();
|
2026-03-31 06:17:09 +02:00
|
|
|
private readonly interactionSystem = new RuntimeInteractionSystem();
|
2026-04-11 04:19:50 +02:00
|
|
|
private readonly audioSystem = new RuntimeAudioSystem(
|
|
|
|
|
this.scene,
|
|
|
|
|
this.camera,
|
|
|
|
|
null
|
|
|
|
|
);
|
2026-04-07 06:15:02 +02:00
|
|
|
private readonly underwaterSceneFog = new FogExp2("#2c6f8d", 0.03);
|
2026-04-07 06:34:54 +02:00
|
|
|
private readonly waterReflectionCamera = new PerspectiveCamera();
|
2026-04-11 04:19:50 +02:00
|
|
|
private readonly brushMeshes = new Map<
|
|
|
|
|
string,
|
|
|
|
|
Mesh<BufferGeometry, Material[]>
|
|
|
|
|
>();
|
2026-04-06 09:16:30 +02:00
|
|
|
private volumeTime = 0;
|
2026-04-06 17:27:03 +02:00
|
|
|
private readonly volumeAnimatedUniforms: Array<{ value: number }> = [];
|
2026-04-11 04:19:50 +02:00
|
|
|
private readonly runtimeWaterContactUniforms: RuntimeWaterContactUniformBinding[] =
|
|
|
|
|
[];
|
2026-04-14 01:40:15 +02:00
|
|
|
private readonly localLightObjects = new Map<
|
|
|
|
|
string,
|
|
|
|
|
LocalLightRenderObjects
|
|
|
|
|
>();
|
2026-03-31 17:40:12 +02:00
|
|
|
private readonly modelRenderObjects = new Map<string, Group>();
|
2026-04-11 04:19:50 +02:00
|
|
|
private readonly materialTextureCache = new Map<
|
|
|
|
|
string,
|
|
|
|
|
CachedMaterialTexture
|
|
|
|
|
>();
|
2026-04-01 00:05:33 +02:00
|
|
|
private readonly animationMixers = new Map<string, AnimationMixer>();
|
|
|
|
|
private readonly instanceAnimationClips = new Map<string, AnimationClip[]>();
|
2026-03-31 03:04:15 +02:00
|
|
|
private readonly controllerContext: RuntimeControllerContext;
|
2026-03-31 06:25:22 +02:00
|
|
|
private readonly renderer: WebGLRenderer | null;
|
2026-03-31 17:40:12 +02:00
|
|
|
private runtimeScene: RuntimeSceneDefinition | null = null;
|
2026-04-04 07:51:38 +02:00
|
|
|
private collisionWorld: RapierCollisionWorld | null = null;
|
|
|
|
|
private collisionWorldRequestId = 0;
|
2026-04-11 11:16:01 +02:00
|
|
|
private desiredNavigationMode: RuntimeNavigationMode = "thirdPerson";
|
2026-04-11 04:15:01 +02:00
|
|
|
private sceneReady = false;
|
2026-03-31 20:06:54 +02:00
|
|
|
private currentWorld: RuntimeSceneDefinition["world"] | null = null;
|
2026-04-11 04:19:50 +02:00
|
|
|
private currentAdvancedRenderingSettings: AdvancedRenderingSettings | null =
|
|
|
|
|
null;
|
2026-04-02 20:52:00 +02:00
|
|
|
private advancedRenderingComposer: EffectComposer | null = null;
|
2026-03-31 17:40:12 +02:00
|
|
|
private projectAssets: Record<string, ProjectAssetRecord> = {};
|
|
|
|
|
private loadedModelAssets: Record<string, LoadedModelAsset> = {};
|
2026-03-31 20:06:54 +02:00
|
|
|
private loadedImageAssets: Record<string, LoadedImageAsset> = {};
|
2026-03-31 03:04:15 +02:00
|
|
|
private resizeObserver: ResizeObserver | null = null;
|
|
|
|
|
private animationFrame = 0;
|
|
|
|
|
private previousFrameTime = 0;
|
|
|
|
|
private container: HTMLElement | null = null;
|
|
|
|
|
private activeController: NavigationController | null = null;
|
2026-04-11 04:19:50 +02:00
|
|
|
private runtimeMessageHandler: ((message: string | null) => void) | null =
|
|
|
|
|
null;
|
2026-04-11 19:06:34 +02:00
|
|
|
private playerControllerTelemetryHandler:
|
|
|
|
|
| ((telemetry: PlayerControllerTelemetry | null) => void)
|
2026-04-11 04:19:50 +02:00
|
|
|
| null = null;
|
|
|
|
|
private interactionPromptHandler:
|
|
|
|
|
| ((prompt: RuntimeInteractionPrompt | null) => void)
|
|
|
|
|
| null = null;
|
2026-04-11 04:15:01 +02:00
|
|
|
private sceneLoadStateHandler:
|
|
|
|
|
| ((state: RuntimeSceneLoadState) => void)
|
|
|
|
|
| null = null;
|
2026-04-11 04:34:26 +02:00
|
|
|
private sceneExitHandler:
|
|
|
|
|
| ((request: RuntimeSceneExitTransitionRequest) => void)
|
|
|
|
|
| null = null;
|
2026-03-31 03:04:15 +02:00
|
|
|
private currentRuntimeMessage: string | null = null;
|
2026-04-11 19:06:34 +02:00
|
|
|
private currentPlayerControllerTelemetry: PlayerControllerTelemetry | null =
|
|
|
|
|
null;
|
2026-03-31 06:46:27 +02:00
|
|
|
private currentInteractionPrompt: RuntimeInteractionPrompt | null = null;
|
2026-04-11 04:15:01 +02:00
|
|
|
private currentSceneLoadState: RuntimeSceneLoadState | null = null;
|
2026-04-12 04:32:46 +02:00
|
|
|
private currentClockState: RuntimeClockState | null = null;
|
|
|
|
|
private lastPublishedClockState: RuntimeClockState | null = null;
|
2026-04-11 19:06:34 +02:00
|
|
|
private currentPlayerAudioHooks: RuntimePlayerAudioHookState | null = null;
|
2026-04-12 04:32:46 +02:00
|
|
|
private runtimeClockStateHandler:
|
|
|
|
|
| ((state: RuntimeClockState) => void)
|
|
|
|
|
| null = null;
|
|
|
|
|
private clockPublishAccumulator = 0;
|
2026-04-11 19:06:34 +02:00
|
|
|
private cameraEffectVerticalOffset = 0;
|
|
|
|
|
private cameraEffectVerticalVelocity = 0;
|
|
|
|
|
private cameraEffectPitchOffset = 0;
|
|
|
|
|
private cameraEffectPitchVelocity = 0;
|
|
|
|
|
private cameraEffectRollOffset = 0;
|
|
|
|
|
private baseCameraFov = 70;
|
2026-03-31 03:04:15 +02:00
|
|
|
|
2026-03-31 06:25:22 +02:00
|
|
|
constructor(options: { enableRendering?: boolean } = {}) {
|
|
|
|
|
const enableRendering = options.enableRendering ?? true;
|
|
|
|
|
|
2026-03-31 03:04:15 +02:00
|
|
|
this.scene.add(this.ambientLight);
|
|
|
|
|
this.scene.add(this.sunLight);
|
2026-04-12 14:10:11 +02:00
|
|
|
this.scene.add(this.moonLight);
|
2026-03-31 20:06:54 +02:00
|
|
|
this.scene.add(this.localLightGroup);
|
2026-03-31 03:04:15 +02:00
|
|
|
this.scene.add(this.brushGroup);
|
2026-03-31 17:40:12 +02:00
|
|
|
this.scene.add(this.modelGroup);
|
2026-04-07 07:12:52 +02:00
|
|
|
this.underwaterSceneFog.density = 0;
|
|
|
|
|
this.scene.fog = this.underwaterSceneFog;
|
2026-04-11 04:19:50 +02:00
|
|
|
this.renderer = enableRendering
|
|
|
|
|
? new WebGLRenderer({ antialias: false, alpha: true })
|
|
|
|
|
: null;
|
|
|
|
|
this.domElement =
|
|
|
|
|
this.renderer?.domElement ?? document.createElement("canvas");
|
2026-03-31 06:25:22 +02:00
|
|
|
|
|
|
|
|
if (this.renderer !== null) {
|
|
|
|
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
|
|
|
this.renderer.setClearAlpha(0);
|
|
|
|
|
} else {
|
|
|
|
|
this.domElement.className = "runner-canvas__surface";
|
|
|
|
|
}
|
2026-03-31 03:04:15 +02:00
|
|
|
|
2026-04-12 14:10:11 +02:00
|
|
|
this.moonLight.intensity = 0;
|
|
|
|
|
this.moonLight.visible = false;
|
|
|
|
|
|
2026-03-31 03:04:15 +02:00
|
|
|
this.controllerContext = {
|
|
|
|
|
camera: this.camera,
|
2026-03-31 06:25:22 +02:00
|
|
|
domElement: this.domElement,
|
2026-03-31 03:04:15 +02:00
|
|
|
getRuntimeScene: () => {
|
|
|
|
|
if (this.runtimeScene === null) {
|
|
|
|
|
throw new Error("Runtime scene has not been loaded.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this.runtimeScene;
|
|
|
|
|
},
|
2026-04-11 04:19:50 +02:00
|
|
|
resolveFirstPersonMotion: (feetPosition, motion, shape) =>
|
|
|
|
|
this.collisionWorld?.resolveFirstPersonMotion(
|
|
|
|
|
feetPosition,
|
|
|
|
|
motion,
|
|
|
|
|
shape
|
|
|
|
|
) ?? null,
|
2026-04-11 18:36:07 +02:00
|
|
|
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,
|
2026-04-11 04:19:50 +02:00
|
|
|
resolvePlayerVolumeState: (feetPosition) =>
|
|
|
|
|
this.resolvePlayerVolumeState(feetPosition),
|
2026-04-11 11:16:30 +02:00
|
|
|
resolveThirdPersonCameraCollision: (
|
|
|
|
|
pivot,
|
|
|
|
|
desiredCameraPosition,
|
|
|
|
|
radius
|
|
|
|
|
) =>
|
|
|
|
|
this.collisionWorld?.resolveThirdPersonCameraCollision(
|
|
|
|
|
pivot,
|
|
|
|
|
desiredCameraPosition,
|
|
|
|
|
radius
|
|
|
|
|
) ?? { ...desiredCameraPosition },
|
2026-03-31 03:04:15 +02:00
|
|
|
setRuntimeMessage: (message) => {
|
|
|
|
|
if (message === this.currentRuntimeMessage) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.currentRuntimeMessage = message;
|
|
|
|
|
this.runtimeMessageHandler?.(message);
|
|
|
|
|
},
|
2026-04-11 19:06:34 +02:00
|
|
|
setPlayerControllerTelemetry: (telemetry) => {
|
|
|
|
|
this.currentPlayerControllerTelemetry = telemetry;
|
|
|
|
|
this.currentPlayerAudioHooks = telemetry?.hooks.audio ?? null;
|
|
|
|
|
this.playerControllerTelemetryHandler?.(telemetry);
|
2026-03-31 03:04:15 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
private resolvePlayerVolumeState(feetPosition: {
|
|
|
|
|
x: number;
|
|
|
|
|
y: number;
|
|
|
|
|
z: number;
|
|
|
|
|
}): RuntimePlayerVolumeState {
|
2026-04-06 08:25:50 +02:00
|
|
|
if (this.runtimeScene === null) {
|
|
|
|
|
return {
|
|
|
|
|
inWater: false,
|
2026-04-11 22:09:45 +02:00
|
|
|
inFog: false,
|
|
|
|
|
waterSurfaceHeight: null
|
2026-04-06 08:25:50 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 22:09:45 +02:00
|
|
|
const waterContact = resolveWaterContact(
|
|
|
|
|
feetPosition,
|
|
|
|
|
this.runtimeScene.volumes.water
|
2026-04-11 04:19:50 +02:00
|
|
|
);
|
|
|
|
|
const inFog = this.runtimeScene.volumes.fog.some((volume) =>
|
|
|
|
|
this.isPointInsideOrientedVolume(feetPosition, volume)
|
|
|
|
|
);
|
2026-04-06 08:25:50 +02:00
|
|
|
|
|
|
|
|
return {
|
2026-04-11 22:09:45 +02:00
|
|
|
inWater: waterContact !== null,
|
|
|
|
|
inFog,
|
|
|
|
|
waterSurfaceHeight: waterContact?.surfaceHeight ?? null
|
2026-04-06 08:25:50 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private isPointInsideOrientedVolume(
|
|
|
|
|
point: { x: number; y: number; z: number },
|
2026-04-11 04:19:50 +02:00
|
|
|
volume: {
|
|
|
|
|
center: { x: number; y: number; z: number };
|
|
|
|
|
rotationDegrees: { x: number; y: number; z: number };
|
|
|
|
|
size: { x: number; y: number; z: number };
|
|
|
|
|
}
|
2026-04-06 08:25:50 +02:00
|
|
|
): boolean {
|
2026-04-11 04:19:50 +02:00
|
|
|
this.volumeOffset.set(
|
|
|
|
|
point.x - volume.center.x,
|
|
|
|
|
point.y - volume.center.y,
|
|
|
|
|
point.z - volume.center.z
|
|
|
|
|
);
|
2026-04-06 08:25:50 +02:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 03:04:15 +02:00
|
|
|
mount(container: HTMLElement) {
|
|
|
|
|
this.container = container;
|
2026-03-31 06:25:22 +02:00
|
|
|
container.appendChild(this.domElement);
|
2026-03-31 06:46:27 +02:00
|
|
|
this.domElement.addEventListener("click", this.handleRuntimeClick);
|
2026-04-11 04:19:50 +02:00
|
|
|
this.domElement.addEventListener(
|
|
|
|
|
"pointerdown",
|
|
|
|
|
this.handleRuntimePointerDown
|
|
|
|
|
);
|
2026-03-31 03:04:15 +02:00
|
|
|
this.resize();
|
|
|
|
|
|
|
|
|
|
this.resizeObserver = new ResizeObserver(() => {
|
|
|
|
|
this.resize();
|
|
|
|
|
});
|
|
|
|
|
this.resizeObserver.observe(container);
|
|
|
|
|
|
|
|
|
|
this.previousFrameTime = performance.now();
|
|
|
|
|
this.render();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
loadScene(runtimeScene: RuntimeSceneDefinition) {
|
2026-04-11 04:15:11 +02:00
|
|
|
const requestId = ++this.collisionWorldRequestId;
|
2026-04-11 05:16:53 +02:00
|
|
|
const preservePointerLockDuringLoad =
|
|
|
|
|
this.activeController === this.firstPersonController &&
|
|
|
|
|
this.desiredNavigationMode === "firstPerson" &&
|
|
|
|
|
document.pointerLockElement === this.domElement;
|
2026-04-11 04:15:11 +02:00
|
|
|
|
|
|
|
|
this.sceneReady = false;
|
2026-03-31 03:04:15 +02:00
|
|
|
this.runtimeScene = runtimeScene;
|
2026-03-31 20:07:11 +02:00
|
|
|
this.currentWorld = runtimeScene.world;
|
2026-04-12 04:32:46 +02:00
|
|
|
this.syncRuntimeClockState(runtimeScene.time);
|
2026-04-14 03:02:17 +02:00
|
|
|
this.syncRuntimeScheduleToCurrentClock();
|
2026-04-11 05:16:53 +02:00
|
|
|
this.activeController?.deactivate(this.controllerContext, {
|
|
|
|
|
releasePointerLock: !preservePointerLockDuringLoad
|
|
|
|
|
});
|
2026-04-11 04:15:11 +02:00
|
|
|
this.activeController = null;
|
|
|
|
|
this.firstPersonController.resetSceneState();
|
2026-04-11 11:16:30 +02:00
|
|
|
this.thirdPersonController.resetSceneState();
|
2026-03-31 06:17:09 +02:00
|
|
|
this.interactionSystem.reset();
|
2026-03-31 06:46:27 +02:00
|
|
|
this.setInteractionPrompt(null);
|
2026-04-11 19:06:44 +02:00
|
|
|
this.currentPlayerControllerTelemetry = null;
|
|
|
|
|
this.currentPlayerAudioHooks = null;
|
|
|
|
|
this.playerControllerTelemetryHandler?.(null);
|
2026-04-11 04:15:11 +02:00
|
|
|
this.currentRuntimeMessage = null;
|
|
|
|
|
this.runtimeMessageHandler?.(null);
|
2026-04-11 19:08:08 +02:00
|
|
|
this.resetPlayerCameraEffects();
|
2026-04-11 04:15:11 +02:00
|
|
|
this.clearCollisionWorld();
|
|
|
|
|
this.publishSceneLoadState({
|
|
|
|
|
status: "loading",
|
|
|
|
|
message: null
|
|
|
|
|
});
|
2026-04-14 02:38:06 +02:00
|
|
|
this.syncResolvedControlStateToRuntime(runtimeScene.control.resolved);
|
2026-03-31 20:07:11 +02:00
|
|
|
this.applyWorld();
|
|
|
|
|
this.rebuildLocalLights(runtimeScene.localLights);
|
2026-03-31 03:04:15 +02:00
|
|
|
this.rebuildBrushMeshes(runtimeScene.brushes);
|
2026-04-13 16:30:12 +02:00
|
|
|
this.rebuildModelRenderObjects(
|
|
|
|
|
runtimeScene.modelInstances,
|
2026-04-13 23:50:32 +02:00
|
|
|
runtimeScene.npcDefinitions
|
2026-04-13 16:30:12 +02:00
|
|
|
);
|
2026-04-02 19:39:55 +02:00
|
|
|
this.audioSystem.loadScene(runtimeScene);
|
2026-04-11 04:15:11 +02:00
|
|
|
void this.finalizeSceneLoad(
|
|
|
|
|
requestId,
|
|
|
|
|
runtimeScene.colliders,
|
2026-04-11 21:00:04 +02:00
|
|
|
runtimeScene.playerCollider,
|
|
|
|
|
runtimeScene.playerMovement
|
2026-04-11 04:15:11 +02:00
|
|
|
);
|
2026-03-31 17:40:12 +02:00
|
|
|
}
|
|
|
|
|
|
2026-03-31 20:07:11 +02:00
|
|
|
updateAssets(
|
|
|
|
|
projectAssets: Record<string, ProjectAssetRecord>,
|
|
|
|
|
loadedModelAssets: Record<string, LoadedModelAsset>,
|
2026-04-02 19:39:55 +02:00
|
|
|
loadedImageAssets: Record<string, LoadedImageAsset>,
|
|
|
|
|
loadedAudioAssets: Record<string, LoadedAudioAsset>
|
2026-03-31 20:07:11 +02:00
|
|
|
) {
|
2026-03-31 17:40:12 +02:00
|
|
|
this.projectAssets = projectAssets;
|
|
|
|
|
this.loadedModelAssets = loadedModelAssets;
|
2026-03-31 20:07:11 +02:00
|
|
|
this.loadedImageAssets = loadedImageAssets;
|
|
|
|
|
|
|
|
|
|
if (this.currentWorld !== null) {
|
|
|
|
|
this.applyWorld();
|
|
|
|
|
}
|
2026-03-31 17:40:12 +02:00
|
|
|
|
|
|
|
|
if (this.runtimeScene !== null) {
|
2026-04-13 16:30:12 +02:00
|
|
|
this.rebuildModelRenderObjects(
|
|
|
|
|
this.runtimeScene.modelInstances,
|
2026-04-13 23:50:32 +02:00
|
|
|
this.runtimeScene.npcDefinitions
|
2026-04-13 16:30:12 +02:00
|
|
|
);
|
2026-03-31 17:40:12 +02:00
|
|
|
}
|
2026-04-02 19:39:55 +02:00
|
|
|
|
|
|
|
|
this.audioSystem.updateAssets(projectAssets, loadedAudioAssets);
|
2026-03-31 03:04:15 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setNavigationMode(mode: RuntimeNavigationMode) {
|
2026-04-11 04:15:11 +02:00
|
|
|
this.desiredNavigationMode = mode;
|
2026-03-31 03:04:15 +02:00
|
|
|
|
2026-04-11 04:15:11 +02:00
|
|
|
if (this.runtimeScene === null || !this.sceneReady) {
|
2026-03-31 03:04:15 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:15:11 +02:00
|
|
|
this.activateDesiredNavigationController();
|
2026-03-31 03:04:15 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setRuntimeMessageHandler(handler: ((message: string | null) => void) | null) {
|
|
|
|
|
this.runtimeMessageHandler = handler;
|
2026-04-02 19:39:55 +02:00
|
|
|
this.audioSystem.setRuntimeMessageHandler(handler);
|
2026-03-31 03:04:15 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 19:06:47 +02:00
|
|
|
setPlayerControllerTelemetryHandler(
|
|
|
|
|
handler: ((telemetry: PlayerControllerTelemetry | null) => void) | null
|
|
|
|
|
) {
|
|
|
|
|
this.playerControllerTelemetryHandler = handler;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
setFirstPersonTelemetryHandler(
|
2026-04-11 19:06:47 +02:00
|
|
|
handler: ((telemetry: PlayerControllerTelemetry | null) => void) | null
|
2026-04-11 04:19:50 +02:00
|
|
|
) {
|
2026-04-11 19:06:47 +02:00
|
|
|
this.setPlayerControllerTelemetryHandler(handler);
|
2026-03-31 03:04:15 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
setInteractionPromptHandler(
|
|
|
|
|
handler: ((prompt: RuntimeInteractionPrompt | null) => void) | null
|
|
|
|
|
) {
|
2026-03-31 06:46:27 +02:00
|
|
|
this.interactionPromptHandler = handler;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:15:11 +02:00
|
|
|
setSceneLoadStateHandler(
|
|
|
|
|
handler: ((state: RuntimeSceneLoadState) => void) | null
|
|
|
|
|
) {
|
|
|
|
|
this.sceneLoadStateHandler = handler;
|
|
|
|
|
|
|
|
|
|
if (handler !== null && this.currentSceneLoadState !== null) {
|
|
|
|
|
handler(this.currentSceneLoadState);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 04:32:46 +02:00
|
|
|
setRuntimeClockStateHandler(
|
|
|
|
|
handler: ((state: RuntimeClockState) => void) | null
|
|
|
|
|
) {
|
|
|
|
|
this.runtimeClockStateHandler = handler;
|
|
|
|
|
|
|
|
|
|
if (handler !== null && this.currentClockState !== null) {
|
|
|
|
|
handler(cloneRuntimeClockState(this.currentClockState));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:34:26 +02:00
|
|
|
setSceneExitHandler(
|
2026-04-14 01:40:15 +02:00
|
|
|
handler: ((request: RuntimeSceneExitTransitionRequest) => void) | null
|
2026-04-11 04:34:26 +02:00
|
|
|
) {
|
|
|
|
|
this.sceneExitHandler = handler;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 03:04:15 +02:00
|
|
|
dispose() {
|
|
|
|
|
if (this.animationFrame !== 0) {
|
|
|
|
|
cancelAnimationFrame(this.animationFrame);
|
|
|
|
|
this.animationFrame = 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.activeController?.deactivate(this.controllerContext);
|
|
|
|
|
this.activeController = null;
|
2026-04-11 19:08:08 +02:00
|
|
|
this.resetPlayerCameraEffects();
|
2026-03-31 06:46:27 +02:00
|
|
|
this.setInteractionPrompt(null);
|
2026-03-31 03:04:15 +02:00
|
|
|
this.resizeObserver?.disconnect();
|
|
|
|
|
this.resizeObserver = null;
|
2026-03-31 20:07:11 +02:00
|
|
|
this.clearLocalLights();
|
2026-03-31 03:04:15 +02:00
|
|
|
this.clearBrushMeshes();
|
2026-04-13 16:30:12 +02:00
|
|
|
this.clearModelRenderObjects();
|
2026-04-04 07:51:38 +02:00
|
|
|
this.collisionWorldRequestId += 1;
|
|
|
|
|
this.clearCollisionWorld();
|
2026-04-02 19:39:55 +02:00
|
|
|
this.audioSystem.dispose();
|
2026-04-02 20:52:22 +02:00
|
|
|
this.advancedRenderingComposer?.dispose();
|
|
|
|
|
this.advancedRenderingComposer = null;
|
|
|
|
|
this.currentAdvancedRenderingSettings = null;
|
2026-04-07 06:15:02 +02:00
|
|
|
this.scene.fog = null;
|
2026-04-12 04:32:46 +02:00
|
|
|
this.currentClockState = null;
|
|
|
|
|
this.lastPublishedClockState = null;
|
2026-04-02 20:52:22 +02:00
|
|
|
if (this.renderer !== null) {
|
|
|
|
|
this.renderer.autoClear = true;
|
|
|
|
|
}
|
2026-03-31 03:04:15 +02:00
|
|
|
|
|
|
|
|
for (const cachedTexture of this.materialTextureCache.values()) {
|
|
|
|
|
cachedTexture.texture.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.materialTextureCache.clear();
|
2026-04-13 14:10:52 +02:00
|
|
|
this.worldBackgroundRenderer.dispose();
|
2026-04-06 18:04:29 +02:00
|
|
|
this.renderer?.forceContextLoss();
|
2026-03-31 06:25:22 +02:00
|
|
|
this.renderer?.dispose();
|
2026-03-31 06:46:27 +02:00
|
|
|
this.domElement.removeEventListener("click", this.handleRuntimeClick);
|
2026-04-11 04:19:50 +02:00
|
|
|
this.domElement.removeEventListener(
|
|
|
|
|
"pointerdown",
|
|
|
|
|
this.handleRuntimePointerDown
|
|
|
|
|
);
|
2026-03-31 03:04:15 +02:00
|
|
|
|
2026-03-31 06:25:22 +02:00
|
|
|
if (this.container !== null && this.container.contains(this.domElement)) {
|
|
|
|
|
this.container.removeChild(this.domElement);
|
2026-03-31 03:04:15 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.container = null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:15:21 +02:00
|
|
|
private publishSceneLoadState(state: RuntimeSceneLoadState) {
|
|
|
|
|
if (
|
|
|
|
|
this.currentSceneLoadState?.status === state.status &&
|
|
|
|
|
this.currentSceneLoadState.message === state.message
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.currentSceneLoadState = state;
|
|
|
|
|
this.sceneLoadStateHandler?.(state);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 04:32:46 +02:00
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:15:21 +02:00
|
|
|
private activateDesiredNavigationController() {
|
|
|
|
|
if (this.runtimeScene === null || !this.sceneReady) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nextController =
|
|
|
|
|
this.desiredNavigationMode === "firstPerson"
|
|
|
|
|
? this.firstPersonController
|
2026-04-11 11:16:30 +02:00
|
|
|
: this.thirdPersonController;
|
2026-04-11 04:15:21 +02:00
|
|
|
|
|
|
|
|
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 async finalizeSceneLoad(
|
|
|
|
|
requestId: number,
|
|
|
|
|
colliders: RuntimeSceneDefinition["colliders"],
|
2026-04-11 20:59:52 +02:00
|
|
|
playerShape: RuntimeSceneDefinition["playerCollider"],
|
|
|
|
|
playerMovement: RuntimeSceneDefinition["playerMovement"]
|
2026-04-11 04:15:21 +02:00
|
|
|
) {
|
|
|
|
|
try {
|
|
|
|
|
const nextCollisionWorld = await this.buildCollisionWorld(
|
|
|
|
|
requestId,
|
|
|
|
|
colliders,
|
2026-04-11 20:59:52 +02:00
|
|
|
playerShape,
|
|
|
|
|
playerMovement
|
2026-04-11 04:15:21 +02:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 20:07:11 +02:00
|
|
|
private applyWorld() {
|
|
|
|
|
if (this.currentWorld === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const world = this.currentWorld;
|
|
|
|
|
|
2026-04-13 14:10:52 +02:00
|
|
|
this.scene.background = null;
|
|
|
|
|
this.scene.environment = null;
|
|
|
|
|
this.scene.environmentIntensity = 1;
|
2026-04-02 20:52:12 +02:00
|
|
|
|
2026-04-12 04:32:46 +02:00
|
|
|
this.applyDayNightLighting();
|
|
|
|
|
|
2026-04-02 20:52:12 +02:00
|
|
|
if (this.renderer !== null) {
|
2026-04-11 04:19:50 +02:00
|
|
|
configureAdvancedRenderingRenderer(
|
|
|
|
|
this.renderer,
|
|
|
|
|
world.advancedRendering
|
|
|
|
|
);
|
2026-04-02 20:52:12 +02:00
|
|
|
this.syncAdvancedRenderingComposer(world.advancedRendering);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.applyShadowState();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-12 04:32:46 +02:00
|
|
|
private applyDayNightLighting() {
|
2026-04-12 14:10:11 +02:00
|
|
|
if (this.currentWorld === null || this.runtimeScene === null) {
|
2026-04-12 04:32:46 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 15:54:16 +02:00
|
|
|
const resolvedTime =
|
|
|
|
|
this.currentClockState === null
|
|
|
|
|
? null
|
2026-04-14 01:40:15 +02:00
|
|
|
: resolveRuntimeTimeState(
|
|
|
|
|
this.runtimeScene.time,
|
|
|
|
|
this.currentClockState
|
|
|
|
|
);
|
2026-04-13 15:54:16 +02:00
|
|
|
|
2026-04-12 04:32:46 +02:00
|
|
|
const resolvedWorld = resolveRuntimeDayNightWorldState(
|
|
|
|
|
this.currentWorld,
|
2026-04-12 14:10:11 +02:00
|
|
|
this.runtimeScene.time,
|
2026-04-13 15:54:16 +02:00
|
|
|
this.currentClockState,
|
|
|
|
|
resolvedTime
|
2026-04-12 04:32:46 +02:00
|
|
|
);
|
2026-04-13 14:10:52 +02:00
|
|
|
const backgroundTexture =
|
|
|
|
|
resolvedWorld.background.mode === "image"
|
2026-04-14 01:40:15 +02:00
|
|
|
? (this.loadedImageAssets[resolvedWorld.background.assetId]?.texture ??
|
|
|
|
|
null)
|
2026-04-13 14:10:52 +02:00
|
|
|
: null;
|
|
|
|
|
const nightBackgroundOverlay = resolvedWorld.nightBackgroundOverlay;
|
|
|
|
|
const backgroundOverlayState =
|
|
|
|
|
nightBackgroundOverlay === null
|
|
|
|
|
? null
|
|
|
|
|
: {
|
|
|
|
|
texture:
|
|
|
|
|
this.loadedImageAssets[nightBackgroundOverlay.assetId]?.texture ??
|
|
|
|
|
null,
|
|
|
|
|
opacity: nightBackgroundOverlay.opacity,
|
2026-04-14 01:40:15 +02:00
|
|
|
environmentIntensity: nightBackgroundOverlay.environmentIntensity
|
2026-04-13 14:10:52 +02:00
|
|
|
};
|
|
|
|
|
const environmentState = resolveWorldEnvironmentState(
|
|
|
|
|
resolvedWorld.background,
|
|
|
|
|
backgroundTexture,
|
|
|
|
|
backgroundOverlayState
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
this.worldBackgroundRenderer.update(
|
|
|
|
|
resolvedWorld.background,
|
|
|
|
|
backgroundTexture,
|
|
|
|
|
backgroundOverlayState
|
|
|
|
|
);
|
|
|
|
|
this.scene.background = null;
|
|
|
|
|
this.scene.environment = environmentState.texture;
|
|
|
|
|
this.scene.environmentIntensity = environmentState.intensity;
|
2026-04-12 04:32:46 +02:00
|
|
|
|
|
|
|
|
this.ambientLight.color.set(resolvedWorld.ambientLight.colorHex);
|
|
|
|
|
this.ambientLight.intensity = resolvedWorld.ambientLight.intensity;
|
|
|
|
|
this.sunLight.color.set(resolvedWorld.sunLight.colorHex);
|
|
|
|
|
this.sunLight.intensity = resolvedWorld.sunLight.intensity;
|
|
|
|
|
this.sunLight.position
|
|
|
|
|
.set(
|
|
|
|
|
resolvedWorld.sunLight.direction.x,
|
|
|
|
|
resolvedWorld.sunLight.direction.y,
|
|
|
|
|
resolvedWorld.sunLight.direction.z
|
|
|
|
|
)
|
|
|
|
|
.normalize()
|
|
|
|
|
.multiplyScalar(18);
|
2026-04-12 14:10:11 +02:00
|
|
|
|
|
|
|
|
if (resolvedWorld.moonLight === null) {
|
|
|
|
|
this.moonLight.visible = false;
|
|
|
|
|
this.moonLight.intensity = 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);
|
2026-04-12 04:32:46 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:15:28 +02:00
|
|
|
private async buildCollisionWorld(
|
|
|
|
|
requestId: number,
|
|
|
|
|
colliders: RuntimeSceneDefinition["colliders"],
|
2026-04-11 20:59:44 +02:00
|
|
|
playerShape: RuntimeSceneDefinition["playerCollider"],
|
|
|
|
|
playerMovement: RuntimeSceneDefinition["playerMovement"]
|
2026-04-11 04:15:28 +02:00
|
|
|
) {
|
|
|
|
|
const nextCollisionWorld = await RapierCollisionWorld.create(
|
|
|
|
|
colliders,
|
2026-04-11 20:59:44 +02:00
|
|
|
playerShape,
|
|
|
|
|
{
|
|
|
|
|
maxStepHeight: playerMovement.maxStepHeight
|
|
|
|
|
}
|
2026-04-11 04:15:28 +02:00
|
|
|
);
|
2026-04-04 07:51:38 +02:00
|
|
|
|
2026-04-11 04:15:28 +02:00
|
|
|
if (requestId !== this.collisionWorldRequestId) {
|
|
|
|
|
nextCollisionWorld.dispose();
|
|
|
|
|
throw new Error("Scene load was superseded by a newer request.");
|
2026-04-04 07:51:38 +02:00
|
|
|
}
|
2026-04-11 04:15:28 +02:00
|
|
|
|
|
|
|
|
return nextCollisionWorld;
|
2026-04-04 07:51:38 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private clearCollisionWorld() {
|
|
|
|
|
this.collisionWorld?.dispose();
|
|
|
|
|
this.collisionWorld = null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 20:52:12 +02:00
|
|
|
private syncAdvancedRenderingComposer(settings: AdvancedRenderingSettings) {
|
|
|
|
|
if (this.renderer === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const shouldUseComposer = settings.enabled;
|
|
|
|
|
const settingsChanged =
|
|
|
|
|
this.currentAdvancedRenderingSettings === null ||
|
2026-04-11 04:19:50 +02:00
|
|
|
!areAdvancedRenderingSettingsEqual(
|
|
|
|
|
this.currentAdvancedRenderingSettings,
|
|
|
|
|
settings
|
|
|
|
|
);
|
2026-04-02 20:52:12 +02:00
|
|
|
|
|
|
|
|
if (!shouldUseComposer) {
|
|
|
|
|
if (this.advancedRenderingComposer !== null) {
|
|
|
|
|
this.advancedRenderingComposer.dispose();
|
|
|
|
|
this.advancedRenderingComposer = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.currentAdvancedRenderingSettings = null;
|
|
|
|
|
this.renderer.autoClear = true;
|
2026-03-31 20:07:11 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 20:52:12 +02:00
|
|
|
if (this.advancedRenderingComposer !== null && !settingsChanged) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.advancedRenderingComposer !== null) {
|
|
|
|
|
this.advancedRenderingComposer.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
this.advancedRenderingComposer = createAdvancedRenderingComposer(
|
|
|
|
|
this.renderer,
|
|
|
|
|
this.scene,
|
|
|
|
|
this.camera,
|
2026-04-13 14:10:52 +02:00
|
|
|
settings,
|
|
|
|
|
this.worldBackgroundRenderer.scene
|
2026-04-11 04:19:50 +02:00
|
|
|
);
|
|
|
|
|
this.currentAdvancedRenderingSettings =
|
|
|
|
|
cloneAdvancedRenderingSettings(settings);
|
2026-04-02 20:52:12 +02:00
|
|
|
this.renderer.autoClear = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private applyShadowState() {
|
|
|
|
|
if (this.currentWorld === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const advancedRendering = this.currentWorld.advancedRendering;
|
2026-04-11 04:19:50 +02:00
|
|
|
const shadowsEnabled =
|
|
|
|
|
advancedRendering.enabled && advancedRendering.shadows.enabled;
|
2026-04-02 20:52:12 +02:00
|
|
|
|
|
|
|
|
applyAdvancedRenderingLightShadowFlags(this.sunLight, advancedRendering);
|
2026-04-12 14:10:11 +02:00
|
|
|
this.moonLight.castShadow = false;
|
|
|
|
|
this.moonLight.shadow.autoUpdate = false;
|
2026-04-02 20:52:12 +02:00
|
|
|
|
2026-04-14 01:35:41 +02:00
|
|
|
for (const renderObjects of this.localLightObjects.values()) {
|
|
|
|
|
applyAdvancedRenderingLightShadowFlags(
|
|
|
|
|
renderObjects.group,
|
|
|
|
|
advancedRendering
|
|
|
|
|
);
|
2026-04-02 20:52:12 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const mesh of this.brushMeshes.values()) {
|
|
|
|
|
applyAdvancedRenderingRenderableShadowFlags(mesh, shadowsEnabled);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const renderGroup of this.modelRenderObjects.values()) {
|
|
|
|
|
applyAdvancedRenderingRenderableShadowFlags(renderGroup, shadowsEnabled);
|
|
|
|
|
}
|
2026-03-31 20:07:11 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private rebuildLocalLights(localLights: RuntimeLocalLightCollection) {
|
|
|
|
|
this.clearLocalLights();
|
|
|
|
|
|
|
|
|
|
for (const pointLight of localLights.pointLights) {
|
|
|
|
|
const renderObjects = this.createPointLightRuntimeObjects(pointLight);
|
|
|
|
|
this.localLightGroup.add(renderObjects.group);
|
2026-04-14 01:35:27 +02:00
|
|
|
this.localLightObjects.set(pointLight.entityId, renderObjects);
|
2026-03-31 20:07:11 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const spotLight of localLights.spotLights) {
|
|
|
|
|
const renderObjects = this.createSpotLightRuntimeObjects(spotLight);
|
|
|
|
|
this.localLightGroup.add(renderObjects.group);
|
2026-04-14 01:35:27 +02:00
|
|
|
this.localLightObjects.set(spotLight.entityId, renderObjects);
|
2026-03-31 20:07:11 +02:00
|
|
|
}
|
2026-04-02 20:52:12 +02:00
|
|
|
|
|
|
|
|
this.applyShadowState();
|
2026-03-31 03:04:15 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
private createPointLightRuntimeObjects(
|
|
|
|
|
pointLight: RuntimeLocalLightCollection["pointLights"][number]
|
|
|
|
|
): LocalLightRenderObjects {
|
2026-03-31 20:07:32 +02:00
|
|
|
const group = new Group();
|
2026-04-11 04:19:50 +02:00
|
|
|
const light = new PointLight(
|
|
|
|
|
pointLight.colorHex,
|
|
|
|
|
pointLight.intensity,
|
|
|
|
|
pointLight.distance
|
|
|
|
|
);
|
2026-03-31 20:07:32 +02:00
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
group.position.set(
|
|
|
|
|
pointLight.position.x,
|
|
|
|
|
pointLight.position.y,
|
|
|
|
|
pointLight.position.z
|
|
|
|
|
);
|
2026-04-14 02:38:06 +02:00
|
|
|
group.visible = pointLight.enabled;
|
2026-03-31 20:07:32 +02:00
|
|
|
light.position.set(0, 0, 0);
|
|
|
|
|
group.add(light);
|
|
|
|
|
|
|
|
|
|
return {
|
2026-04-14 01:35:27 +02:00
|
|
|
group,
|
|
|
|
|
light
|
2026-03-31 20:07:32 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
private createSpotLightRuntimeObjects(
|
|
|
|
|
spotLight: RuntimeLocalLightCollection["spotLights"][number]
|
|
|
|
|
): LocalLightRenderObjects {
|
2026-03-31 20:07:32 +02:00
|
|
|
const group = new Group();
|
|
|
|
|
const light = new SpotLight(
|
|
|
|
|
spotLight.colorHex,
|
|
|
|
|
spotLight.intensity,
|
|
|
|
|
spotLight.distance,
|
|
|
|
|
(spotLight.angleDegrees * Math.PI) / 180,
|
|
|
|
|
0.18,
|
|
|
|
|
1
|
|
|
|
|
);
|
2026-04-11 04:19:50 +02:00
|
|
|
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
|
|
|
|
|
);
|
2026-03-31 20:07:32 +02:00
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
group.position.set(
|
|
|
|
|
spotLight.position.x,
|
|
|
|
|
spotLight.position.y,
|
|
|
|
|
spotLight.position.z
|
|
|
|
|
);
|
2026-03-31 20:07:32 +02:00
|
|
|
group.quaternion.copy(orientation);
|
2026-04-14 02:38:06 +02:00
|
|
|
group.visible = spotLight.enabled;
|
2026-03-31 20:07:32 +02:00
|
|
|
light.position.set(0, 0, 0);
|
|
|
|
|
light.target.position.set(0, 1, 0);
|
|
|
|
|
group.add(light);
|
|
|
|
|
group.add(light.target);
|
|
|
|
|
|
|
|
|
|
return {
|
2026-04-14 01:35:27 +02:00
|
|
|
group,
|
|
|
|
|
light
|
2026-03-31 20:07:32 +02:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 01:35:27 +02:00
|
|
|
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) {
|
2026-04-14 02:38:06 +02:00
|
|
|
case "actorPresence":
|
|
|
|
|
this.applyActorPresenceControl(state.target.actorId, state.value);
|
|
|
|
|
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;
|
2026-04-14 01:35:27 +02:00
|
|
|
case "lightEnabled":
|
|
|
|
|
this.applyLightEnabledControl(state.target, state.value);
|
|
|
|
|
return;
|
2026-04-14 02:38:06 +02:00
|
|
|
case "lightColor":
|
|
|
|
|
this.applyLightColorControl(state.target, state.value);
|
|
|
|
|
return;
|
2026-04-14 01:35:27 +02:00
|
|
|
case "interactionEnabled":
|
|
|
|
|
this.applyInteractionEnabledControl(state.target, state.value);
|
|
|
|
|
return;
|
2026-04-14 02:38:06 +02:00
|
|
|
case "ambientLightColor":
|
|
|
|
|
this.applyAmbientLightColorControl(state.target, state.value);
|
|
|
|
|
return;
|
|
|
|
|
case "sunLightColor":
|
|
|
|
|
this.applySunLightColorControl(state.target, state.value);
|
|
|
|
|
return;
|
2026-04-14 01:35:27 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private applyResolvedControlChannelValue(
|
|
|
|
|
channelValue: RuntimeResolvedControlChannelValue
|
|
|
|
|
) {
|
|
|
|
|
switch (channelValue.type) {
|
|
|
|
|
case "lightIntensity":
|
|
|
|
|
this.applyLightIntensityControl(
|
|
|
|
|
channelValue.descriptor.target,
|
|
|
|
|
channelValue.value
|
|
|
|
|
);
|
|
|
|
|
return;
|
2026-04-14 02:38:06 +02:00
|
|
|
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);
|
2026-04-14 01:35:27 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private applyLightEnabledControl(
|
|
|
|
|
target: LightControlTargetRef,
|
|
|
|
|
enabled: boolean
|
|
|
|
|
) {
|
2026-04-14 02:38:06 +02:00
|
|
|
this.mutateRuntimeLightState(target, (light) => {
|
|
|
|
|
light.enabled = enabled;
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-14 01:35:27 +02:00
|
|
|
const renderObjects = this.localLightObjects.get(target.entityId);
|
|
|
|
|
|
|
|
|
|
if (renderObjects === undefined) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
renderObjects.group.visible = enabled;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private applyLightIntensityControl(
|
|
|
|
|
target: LightControlTargetRef,
|
|
|
|
|
intensity: number
|
|
|
|
|
) {
|
2026-04-14 02:38:06 +02:00
|
|
|
this.mutateRuntimeLightState(target, (light) => {
|
|
|
|
|
light.intensity = intensity;
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-14 01:35:27 +02:00
|
|
|
const renderObjects = this.localLightObjects.get(target.entityId);
|
|
|
|
|
|
|
|
|
|
if (renderObjects === undefined) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
renderObjects.light.intensity = intensity;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 02:38:06 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 03:02:44 +02:00
|
|
|
if (
|
|
|
|
|
this.runtimeScene.world.ambientLight.intensity === intensity &&
|
|
|
|
|
this.currentWorld.ambientLight.intensity === intensity
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 02:38:06 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 03:02:44 +02:00
|
|
|
if (
|
|
|
|
|
this.runtimeScene.world.ambientLight.colorHex === colorHex &&
|
|
|
|
|
this.currentWorld.ambientLight.colorHex === colorHex
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 02:38:06 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 03:02:44 +02:00
|
|
|
if (
|
|
|
|
|
this.runtimeScene.world.sunLight.intensity === intensity &&
|
|
|
|
|
this.currentWorld.sunLight.intensity === intensity
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 02:38:06 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 03:02:44 +02:00
|
|
|
if (
|
|
|
|
|
this.runtimeScene.world.sunLight.colorHex === colorHex &&
|
|
|
|
|
this.currentWorld.sunLight.colorHex === colorHex
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 02:38:06 +02:00
|
|
|
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
|
|
|
|
|
) {
|
2026-04-14 03:02:44 +02:00
|
|
|
let stateChanged = true;
|
|
|
|
|
|
2026-04-14 02:38:06 +02:00
|
|
|
if (this.runtimeScene !== null) {
|
|
|
|
|
const modelInstance =
|
|
|
|
|
this.runtimeScene.modelInstances.find(
|
|
|
|
|
(candidate) => candidate.instanceId === target.modelInstanceId
|
|
|
|
|
) ?? null;
|
|
|
|
|
|
|
|
|
|
if (modelInstance !== null) {
|
2026-04-14 03:02:44 +02:00
|
|
|
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;
|
2026-04-14 02:38:06 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 03:02:44 +02:00
|
|
|
if (!stateChanged) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 02:38:47 +02:00
|
|
|
if (!this.animationMixers.has(target.modelInstanceId)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 02:38:06 +02:00
|
|
|
if (clipName === null) {
|
|
|
|
|
this.applyStopAnimationAction(target.modelInstanceId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.applyPlayAnimationAction(target.modelInstanceId, clipName, loop);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private applySoundPlaybackControl(
|
|
|
|
|
target: SoundEmitterControlTargetRef,
|
|
|
|
|
playing: boolean,
|
|
|
|
|
link: InteractionLink | null = null
|
|
|
|
|
) {
|
2026-04-14 03:02:44 +02:00
|
|
|
let stateChanged = true;
|
|
|
|
|
|
2026-04-14 02:38:06 +02:00
|
|
|
if (this.runtimeScene !== null) {
|
|
|
|
|
const soundEmitter =
|
|
|
|
|
this.runtimeScene.entities.soundEmitters.find(
|
|
|
|
|
(candidate) => candidate.entityId === target.entityId
|
|
|
|
|
) ?? null;
|
|
|
|
|
|
|
|
|
|
if (soundEmitter !== null) {
|
2026-04-14 03:02:44 +02:00
|
|
|
stateChanged = soundEmitter.autoplay !== playing;
|
2026-04-14 02:38:06 +02:00
|
|
|
soundEmitter.autoplay = playing;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 03:02:44 +02:00
|
|
|
if (!stateChanged) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 02:38:06 +02:00
|
|
|
if (!this.audioSystem.hasSoundEmitter(target.entityId)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (playing) {
|
|
|
|
|
this.audioSystem.playSound(target.entityId, link);
|
|
|
|
|
} else {
|
|
|
|
|
this.audioSystem.stopSound(target.entityId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private applySoundVolumeControl(
|
|
|
|
|
target: SoundEmitterControlTargetRef,
|
|
|
|
|
volume: number
|
|
|
|
|
) {
|
2026-04-14 03:02:44 +02:00
|
|
|
let stateChanged = true;
|
|
|
|
|
|
2026-04-14 02:38:06 +02:00
|
|
|
if (this.runtimeScene !== null) {
|
|
|
|
|
const soundEmitter =
|
|
|
|
|
this.runtimeScene.entities.soundEmitters.find(
|
|
|
|
|
(candidate) => candidate.entityId === target.entityId
|
|
|
|
|
) ?? null;
|
|
|
|
|
|
|
|
|
|
if (soundEmitter !== null) {
|
2026-04-14 03:02:44 +02:00
|
|
|
stateChanged = soundEmitter.volume !== volume;
|
2026-04-14 02:38:06 +02:00
|
|
|
soundEmitter.volume = volume;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 03:02:44 +02:00
|
|
|
if (!stateChanged) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 02:38:06 +02:00
|
|
|
this.audioSystem.setSoundEmitterVolume(target.entityId, volume);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 01:35:27 +02:00
|
|
|
private applyInteractionEnabledControl(
|
|
|
|
|
target: InteractionControlTargetRef,
|
|
|
|
|
enabled: boolean
|
|
|
|
|
) {
|
|
|
|
|
if (this.runtimeScene === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (target.interactionKind === "interactable") {
|
|
|
|
|
const interactable =
|
|
|
|
|
this.runtimeScene.entities.interactables.find(
|
|
|
|
|
(candidate) => candidate.entityId === target.entityId
|
|
|
|
|
) ?? null;
|
|
|
|
|
|
|
|
|
|
if (interactable !== null) {
|
|
|
|
|
interactable.interactionEnabled = enabled;
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const sceneExit =
|
|
|
|
|
this.runtimeScene.entities.sceneExits.find(
|
|
|
|
|
(candidate) => candidate.entityId === target.entityId
|
|
|
|
|
) ?? null;
|
|
|
|
|
|
|
|
|
|
if (sceneExit !== null) {
|
|
|
|
|
sceneExit.interactionEnabled = enabled;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 01:58:33 +02:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 01:35:27 +02:00
|
|
|
private applyControlEffect(effect: ControlEffect, link: InteractionLink) {
|
|
|
|
|
switch (effect.type) {
|
2026-04-14 01:58:33 +02:00
|
|
|
case "setActorPresence":
|
|
|
|
|
this.applyActorPresenceControl(effect.target.actorId, effect.active);
|
|
|
|
|
break;
|
2026-04-14 01:35:27 +02:00
|
|
|
case "playModelAnimation":
|
2026-04-14 02:38:06 +02:00
|
|
|
this.applyModelAnimationPlaybackControl(
|
|
|
|
|
effect.target,
|
2026-04-14 01:35:27 +02:00
|
|
|
effect.clipName,
|
|
|
|
|
effect.loop
|
|
|
|
|
);
|
|
|
|
|
break;
|
|
|
|
|
case "stopModelAnimation":
|
2026-04-14 02:38:06 +02:00
|
|
|
this.applyModelAnimationPlaybackControl(effect.target, null, undefined);
|
|
|
|
|
break;
|
|
|
|
|
case "setModelInstanceVisible":
|
|
|
|
|
this.applyModelInstanceVisibilityControl(effect.target, effect.visible);
|
2026-04-14 01:35:27 +02:00
|
|
|
break;
|
|
|
|
|
case "playSound":
|
2026-04-14 02:38:06 +02:00
|
|
|
this.applySoundPlaybackControl(effect.target, true, link);
|
2026-04-14 01:35:27 +02:00
|
|
|
break;
|
|
|
|
|
case "stopSound":
|
2026-04-14 02:38:06 +02:00
|
|
|
this.applySoundPlaybackControl(effect.target, false);
|
|
|
|
|
break;
|
|
|
|
|
case "setSoundVolume":
|
|
|
|
|
this.applySoundVolumeControl(effect.target, effect.volume);
|
2026-04-14 01:35:27 +02:00
|
|
|
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;
|
2026-04-14 02:38:06 +02:00
|
|
|
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;
|
2026-04-14 01:35:27 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (this.runtimeScene === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.runtimeScene.control.resolved = applyControlEffectToResolvedState(
|
|
|
|
|
this.runtimeScene.control.resolved,
|
|
|
|
|
effect,
|
|
|
|
|
createInteractionLinkResolvedControlSource(link.id)
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 03:04:15 +02:00
|
|
|
private rebuildBrushMeshes(brushes: RuntimeBoxBrushInstance[]) {
|
|
|
|
|
this.clearBrushMeshes();
|
2026-04-06 08:44:52 +02:00
|
|
|
const volumeRenderPaths: ResolvedBoxVolumeRenderPaths =
|
2026-04-11 04:19:50 +02:00
|
|
|
this.currentWorld === null
|
|
|
|
|
? { fog: "performance", water: "performance" }
|
|
|
|
|
: resolveBoxVolumeRenderPaths(this.currentWorld.advancedRendering);
|
2026-03-31 03:04:15 +02:00
|
|
|
|
|
|
|
|
for (const brush of brushes) {
|
2026-04-12 03:56:31 +02:00
|
|
|
const geometryBrush: BoxBrush = {
|
|
|
|
|
id: brush.id,
|
2026-04-12 03:57:38 +02:00
|
|
|
kind: "box",
|
2026-04-12 03:56:31 +02:00
|
|
|
name: undefined,
|
|
|
|
|
visible: brush.visible,
|
|
|
|
|
enabled: true,
|
|
|
|
|
center: brush.center,
|
|
|
|
|
rotationDegrees: brush.rotationDegrees,
|
|
|
|
|
size: brush.size,
|
|
|
|
|
geometry: brush.geometry,
|
|
|
|
|
faces: brush.faces,
|
|
|
|
|
volume: brush.volume
|
|
|
|
|
};
|
|
|
|
|
const geometry = buildBoxBrushDerivedMeshData(geometryBrush).geometry;
|
2026-04-11 04:19:50 +02:00
|
|
|
const staticContactPatches =
|
|
|
|
|
brush.volume.mode === "water"
|
|
|
|
|
? this.collectRuntimeStaticWaterContactPatches(brush)
|
|
|
|
|
: [];
|
2026-04-07 06:36:05 +02:00
|
|
|
const contactPatches =
|
|
|
|
|
brush.volume.mode === "water"
|
2026-04-11 04:19:50 +02:00
|
|
|
? this.mergeRuntimeWaterContactPatches(
|
|
|
|
|
brush,
|
|
|
|
|
staticContactPatches,
|
|
|
|
|
this.collectRuntimePlayerWaterContactPatches(brush)
|
|
|
|
|
)
|
2026-04-07 06:36:05 +02:00
|
|
|
: [];
|
2026-03-31 03:04:15 +02:00
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
const materials = this.createFogMaterialSet(brush, volumeRenderPaths) ?? [
|
|
|
|
|
this.createFaceMaterial(
|
|
|
|
|
brush,
|
|
|
|
|
"posX",
|
|
|
|
|
brush.faces.posX.material,
|
|
|
|
|
volumeRenderPaths,
|
|
|
|
|
contactPatches,
|
|
|
|
|
staticContactPatches
|
|
|
|
|
),
|
|
|
|
|
this.createFaceMaterial(
|
|
|
|
|
brush,
|
|
|
|
|
"negX",
|
|
|
|
|
brush.faces.negX.material,
|
|
|
|
|
volumeRenderPaths,
|
|
|
|
|
contactPatches,
|
|
|
|
|
staticContactPatches
|
|
|
|
|
),
|
|
|
|
|
this.createFaceMaterial(
|
|
|
|
|
brush,
|
|
|
|
|
"posY",
|
|
|
|
|
brush.faces.posY.material,
|
|
|
|
|
volumeRenderPaths,
|
|
|
|
|
contactPatches,
|
|
|
|
|
staticContactPatches
|
|
|
|
|
),
|
|
|
|
|
this.createFaceMaterial(
|
|
|
|
|
brush,
|
|
|
|
|
"negY",
|
|
|
|
|
brush.faces.negY.material,
|
|
|
|
|
volumeRenderPaths,
|
|
|
|
|
contactPatches,
|
|
|
|
|
staticContactPatches
|
|
|
|
|
),
|
|
|
|
|
this.createFaceMaterial(
|
|
|
|
|
brush,
|
|
|
|
|
"posZ",
|
|
|
|
|
brush.faces.posZ.material,
|
|
|
|
|
volumeRenderPaths,
|
|
|
|
|
contactPatches,
|
|
|
|
|
staticContactPatches
|
|
|
|
|
),
|
|
|
|
|
this.createFaceMaterial(
|
|
|
|
|
brush,
|
|
|
|
|
"negZ",
|
|
|
|
|
brush.faces.negZ.material,
|
|
|
|
|
volumeRenderPaths,
|
|
|
|
|
contactPatches,
|
|
|
|
|
staticContactPatches
|
|
|
|
|
)
|
|
|
|
|
];
|
2026-03-31 03:04:15 +02:00
|
|
|
|
|
|
|
|
const mesh = new Mesh(geometry, materials);
|
|
|
|
|
mesh.position.set(brush.center.x, brush.center.y, brush.center.z);
|
2026-04-04 19:27:06 +02:00
|
|
|
mesh.rotation.set(
|
|
|
|
|
(brush.rotationDegrees.x * Math.PI) / 180,
|
|
|
|
|
(brush.rotationDegrees.y * Math.PI) / 180,
|
|
|
|
|
(brush.rotationDegrees.z * Math.PI) / 180
|
|
|
|
|
);
|
2026-04-12 03:36:05 +02:00
|
|
|
mesh.visible = brush.visible;
|
2026-04-07 11:18:51 +02:00
|
|
|
this.configureFogVolumeMesh(mesh, materials);
|
2026-03-31 03:04:15 +02:00
|
|
|
this.brushGroup.add(mesh);
|
|
|
|
|
this.brushMeshes.set(brush.id, mesh);
|
|
|
|
|
}
|
2026-04-02 20:52:12 +02:00
|
|
|
|
|
|
|
|
this.applyShadowState();
|
2026-03-31 03:04:15 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-07 11:33:12 +02:00
|
|
|
private createFogMaterialSet(
|
|
|
|
|
brush: RuntimeBoxBrushInstance,
|
2026-04-11 04:19:50 +02:00
|
|
|
volumeRenderPaths: {
|
|
|
|
|
fog: "performance" | "quality";
|
|
|
|
|
water: "performance" | "quality";
|
|
|
|
|
}
|
2026-04-07 11:33:12 +02:00
|
|
|
): 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);
|
2026-04-11 04:19:50 +02:00
|
|
|
return Array.from(
|
|
|
|
|
{ length: BOX_FACE_MATERIAL_COUNT },
|
|
|
|
|
() => fogMaterial.material
|
|
|
|
|
);
|
2026-04-07 11:33:12 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
const densityOpacity = Math.max(
|
|
|
|
|
0.06,
|
|
|
|
|
Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08)
|
|
|
|
|
);
|
2026-04-07 11:33:12 +02:00
|
|
|
const fogMaterial = new MeshBasicMaterial({
|
|
|
|
|
color: brush.volume.fog.colorHex,
|
|
|
|
|
transparent: true,
|
|
|
|
|
opacity: densityOpacity,
|
|
|
|
|
depthWrite: false
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return Array.from({ length: BOX_FACE_MATERIAL_COUNT }, () => fogMaterial);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
private configureFogVolumeMesh(
|
|
|
|
|
mesh: Mesh<BufferGeometry, Material[]>,
|
|
|
|
|
materials: Material[]
|
|
|
|
|
) {
|
2026-04-07 11:18:51 +02:00
|
|
|
const fogMaterials = materials.filter(
|
2026-04-11 04:19:50 +02:00
|
|
|
(material): material is ShaderMaterial =>
|
|
|
|
|
material instanceof ShaderMaterial &&
|
|
|
|
|
material.uniforms["localCameraPosition"] !== undefined
|
2026-04-07 11:18:51 +02:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (fogMaterials.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mesh.onBeforeRender = (_renderer, _scene, camera) => {
|
2026-04-11 04:19:50 +02:00
|
|
|
const localCameraPosition = mesh.worldToLocal(
|
|
|
|
|
this.fogLocalCameraPosition.copy(camera.position)
|
|
|
|
|
);
|
2026-04-07 11:18:51 +02:00
|
|
|
|
|
|
|
|
for (const material of fogMaterials) {
|
2026-04-11 04:19:50 +02:00
|
|
|
(
|
|
|
|
|
material.uniforms["localCameraPosition"] as { value: Vector3 }
|
|
|
|
|
).value.copy(localCameraPosition);
|
2026-04-07 11:18:51 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 17:25:07 +02:00
|
|
|
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);
|
2026-04-14 01:40:15 +02:00
|
|
|
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;
|
2026-04-13 17:25:07 +02:00
|
|
|
|
|
|
|
|
const body = new Mesh(new BoxGeometry(0.08, 0.08, 0.34), facingMaterial);
|
|
|
|
|
body.position.set(0, colliderTop + 0.12, 0.06);
|
|
|
|
|
|
2026-04-14 01:40:15 +02:00
|
|
|
const arrowHead = new Mesh(new ConeGeometry(0.1, 0.22, 14), facingMaterial);
|
2026-04-13 17:25:07 +02:00
|
|
|
arrowHead.rotation.x = Math.PI * 0.5;
|
|
|
|
|
arrowHead.position.set(0, colliderTop + 0.12, 0.28);
|
|
|
|
|
|
|
|
|
|
facingGroup.add(body);
|
|
|
|
|
facingGroup.add(arrowHead);
|
|
|
|
|
|
|
|
|
|
return group;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 16:30:12 +02:00
|
|
|
private rebuildModelRenderObjects(
|
|
|
|
|
modelInstances: RuntimeSceneDefinition["modelInstances"],
|
2026-04-13 23:50:32 +02:00
|
|
|
npcs: RuntimeNpcDefinition[]
|
2026-04-11 04:19:50 +02:00
|
|
|
) {
|
2026-04-13 16:30:12 +02:00
|
|
|
this.clearModelRenderObjects();
|
2026-03-31 17:40:12 +02:00
|
|
|
|
|
|
|
|
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,
|
2026-04-12 03:36:05 +02:00
|
|
|
visible: modelInstance.visible,
|
|
|
|
|
enabled: true,
|
2026-03-31 17:40:12 +02:00
|
|
|
position: modelInstance.position,
|
|
|
|
|
rotationDegrees: modelInstance.rotationDegrees,
|
2026-04-04 07:57:32 +02:00
|
|
|
scale: modelInstance.scale,
|
|
|
|
|
collision: {
|
|
|
|
|
mode: "none",
|
|
|
|
|
visible: false
|
|
|
|
|
}
|
2026-03-31 17:40:12 +02:00
|
|
|
},
|
|
|
|
|
asset,
|
|
|
|
|
loadedAsset,
|
|
|
|
|
false
|
|
|
|
|
);
|
2026-04-12 03:36:05 +02:00
|
|
|
renderGroup.visible = modelInstance.visible;
|
2026-03-31 17:40:12 +02:00
|
|
|
this.modelGroup.add(renderGroup);
|
|
|
|
|
this.modelRenderObjects.set(modelInstance.instanceId, renderGroup);
|
2026-04-01 00:07:25 +02:00
|
|
|
|
|
|
|
|
if (loadedAsset?.animations && loadedAsset.animations.length > 0) {
|
|
|
|
|
const mixer = new AnimationMixer(renderGroup);
|
|
|
|
|
this.animationMixers.set(modelInstance.instanceId, mixer);
|
2026-04-11 04:19:50 +02:00
|
|
|
this.instanceAnimationClips.set(
|
|
|
|
|
modelInstance.instanceId,
|
|
|
|
|
loadedAsset.animations
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
modelInstance.animationAutoplay === true &&
|
|
|
|
|
modelInstance.animationClipName
|
|
|
|
|
) {
|
|
|
|
|
const clip = AnimationClip.findByName(
|
|
|
|
|
loadedAsset.animations,
|
|
|
|
|
modelInstance.animationClipName
|
|
|
|
|
);
|
2026-04-01 00:07:25 +02:00
|
|
|
if (clip) {
|
2026-04-14 02:38:06 +02:00
|
|
|
const action = mixer.clipAction(clip);
|
|
|
|
|
action.loop =
|
|
|
|
|
modelInstance.animationLoop === false ? LoopOnce : LoopRepeat;
|
|
|
|
|
action.clampWhenFinished = modelInstance.animationLoop === false;
|
|
|
|
|
action.reset().play();
|
2026-04-01 00:07:25 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-31 17:40:12 +02:00
|
|
|
}
|
2026-04-02 20:52:12 +02:00
|
|
|
|
2026-04-13 16:30:12 +02:00
|
|
|
for (const npc of npcs) {
|
2026-04-13 21:19:30 +02:00
|
|
|
const asset =
|
2026-04-13 17:25:07 +02:00
|
|
|
npc.modelAssetId === null
|
2026-04-13 21:19:30 +02:00
|
|
|
? null
|
2026-04-14 01:40:15 +02:00
|
|
|
: (this.projectAssets[npc.modelAssetId] ?? null);
|
2026-04-13 21:19:30 +02:00
|
|
|
const renderGroup =
|
|
|
|
|
npc.modelAssetId === null || asset?.kind !== "model"
|
2026-04-13 17:25:07 +02:00
|
|
|
? 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
|
|
|
|
|
}
|
|
|
|
|
},
|
2026-04-13 21:19:30 +02:00
|
|
|
asset,
|
2026-04-13 17:25:07 +02:00
|
|
|
this.loadedModelAssets[npc.modelAssetId],
|
|
|
|
|
false
|
|
|
|
|
);
|
2026-04-13 23:50:32 +02:00
|
|
|
renderGroup.visible = npc.visible && npc.active;
|
2026-04-13 16:30:12 +02:00
|
|
|
this.modelGroup.add(renderGroup);
|
|
|
|
|
this.modelRenderObjects.set(npc.entityId, renderGroup);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 20:52:12 +02:00
|
|
|
this.applyShadowState();
|
2026-03-31 17:40:12 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-06 08:26:27 +02:00
|
|
|
private createFaceMaterial(
|
|
|
|
|
brush: RuntimeBoxBrushInstance,
|
|
|
|
|
faceId: "posX" | "negX" | "posY" | "negY" | "posZ" | "negZ",
|
|
|
|
|
material: RuntimeBoxBrushInstance["faces"]["posX"]["material"],
|
2026-04-11 04:19:50 +02:00
|
|
|
volumeRenderPaths: {
|
|
|
|
|
fog: "performance" | "quality";
|
|
|
|
|
water: "performance" | "quality";
|
|
|
|
|
},
|
2026-04-06 20:55:10 +02:00
|
|
|
contactPatches: ReturnType<typeof collectWaterContactPatches>,
|
|
|
|
|
staticContactPatches: ReturnType<typeof collectWaterContactPatches>
|
2026-04-06 09:17:19 +02:00
|
|
|
): Material {
|
2026-04-06 08:26:27 +02:00
|
|
|
if (brush.volume.mode === "water") {
|
2026-04-11 04:19:50 +02:00
|
|
|
const baseOpacity = Math.max(
|
|
|
|
|
0.05,
|
|
|
|
|
Math.min(1, brush.volume.water.surfaceOpacity)
|
|
|
|
|
);
|
2026-04-06 17:27:03 +02:00
|
|
|
const waterMaterial = createWaterMaterial({
|
|
|
|
|
colorHex: brush.volume.water.colorHex,
|
|
|
|
|
surfaceOpacity: brush.volume.water.surfaceOpacity,
|
|
|
|
|
waveStrength: brush.volume.water.waveStrength,
|
2026-04-11 04:19:50 +02:00
|
|
|
surfaceDisplacementEnabled:
|
|
|
|
|
brush.volume.water.surfaceDisplacementEnabled,
|
|
|
|
|
opacity:
|
|
|
|
|
faceId === "posY"
|
|
|
|
|
? Math.min(1, baseOpacity + 0.18)
|
|
|
|
|
: baseOpacity * 0.5,
|
2026-04-06 17:27:03 +02:00
|
|
|
quality: volumeRenderPaths.water === "quality",
|
|
|
|
|
wireframe: false,
|
|
|
|
|
isTopFace: faceId === "posY",
|
|
|
|
|
time: this.volumeTime,
|
|
|
|
|
halfSize: {
|
|
|
|
|
x: brush.size.x * 0.5,
|
|
|
|
|
z: brush.size.z * 0.5
|
|
|
|
|
},
|
2026-04-07 06:35:08 +02:00
|
|
|
contactPatches,
|
|
|
|
|
reflection: {
|
|
|
|
|
texture: null,
|
2026-04-07 08:31:54 +02:00
|
|
|
enabled: faceId === "posY"
|
2026-04-07 06:35:08 +02:00
|
|
|
}
|
2026-04-06 08:26:27 +02:00
|
|
|
});
|
2026-04-06 17:27:03 +02:00
|
|
|
|
|
|
|
|
if (waterMaterial.animationUniform !== null) {
|
|
|
|
|
this.volumeAnimatedUniforms.push(waterMaterial.animationUniform);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
if (
|
|
|
|
|
faceId === "posY" &&
|
|
|
|
|
waterMaterial.contactPatchesUniform !== null &&
|
|
|
|
|
waterMaterial.contactPatchAxesUniform !== null
|
|
|
|
|
) {
|
2026-04-06 18:04:29 +02:00
|
|
|
this.runtimeWaterContactUniforms.push({
|
|
|
|
|
brush,
|
2026-04-06 20:52:05 +02:00
|
|
|
uniform: waterMaterial.contactPatchesUniform,
|
2026-04-07 04:58:48 +02:00
|
|
|
axisUniform: waterMaterial.contactPatchAxesUniform,
|
2026-04-11 04:19:50 +02:00
|
|
|
shapeUniform: waterMaterial.contactPatchShapesUniform ?? {
|
|
|
|
|
value: []
|
|
|
|
|
},
|
2026-04-07 06:35:08 +02:00
|
|
|
staticContactPatches,
|
|
|
|
|
reflectionTextureUniform: waterMaterial.reflectionTextureUniform,
|
|
|
|
|
reflectionMatrixUniform: waterMaterial.reflectionMatrixUniform,
|
|
|
|
|
reflectionEnabledUniform: waterMaterial.reflectionEnabledUniform,
|
2026-04-11 04:19:50 +02:00
|
|
|
reflectionRenderTarget:
|
|
|
|
|
this.getWaterReflectionMode() !== "none"
|
|
|
|
|
? this.createWaterReflectionRenderTarget()
|
|
|
|
|
: null,
|
2026-04-07 07:13:02 +02:00
|
|
|
lastReflectionUpdateTime: Number.NEGATIVE_INFINITY
|
2026-04-06 18:04:29 +02:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-06 17:27:03 +02:00
|
|
|
return waterMaterial.material;
|
2026-04-06 08:26:27 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (brush.volume.mode === "fog") {
|
2026-04-06 09:17:19 +02:00
|
|
|
if (volumeRenderPaths.fog === "quality") {
|
2026-04-07 11:07:19 +02:00
|
|
|
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;
|
2026-04-06 09:17:19 +02:00
|
|
|
}
|
|
|
|
|
// Performance fallback: simple transparent material
|
2026-04-11 04:19:50 +02:00
|
|
|
const densityOpacity = Math.max(
|
|
|
|
|
0.06,
|
|
|
|
|
Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08)
|
|
|
|
|
);
|
2026-04-06 09:17:19 +02:00
|
|
|
return new MeshBasicMaterial({
|
2026-04-06 08:26:27 +02:00
|
|
|
color: brush.volume.fog.colorHex,
|
|
|
|
|
transparent: true,
|
|
|
|
|
opacity: densityOpacity,
|
|
|
|
|
depthWrite: false
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 03:04:15 +02:00
|
|
|
if (material === null) {
|
2026-04-12 01:04:39 +02:00
|
|
|
const faceMaterial = new MeshStandardMaterial({
|
2026-03-31 03:04:15 +02:00
|
|
|
color: FALLBACK_FACE_COLOR,
|
|
|
|
|
roughness: 0.9,
|
|
|
|
|
metalness: 0.05
|
|
|
|
|
});
|
2026-04-12 01:04:39 +02:00
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
this.currentWorld !== null &&
|
|
|
|
|
shouldApplyWhiteboxBevel(this.currentWorld.advancedRendering)
|
|
|
|
|
) {
|
|
|
|
|
applyWhiteboxBevelToMaterial(
|
|
|
|
|
faceMaterial,
|
|
|
|
|
this.currentWorld.advancedRendering.whiteboxBevel
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return faceMaterial;
|
2026-03-31 03:04:15 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-12 01:04:39 +02:00
|
|
|
const faceMaterial = new MeshStandardMaterial({
|
2026-03-31 03:04:15 +02:00
|
|
|
color: 0xffffff,
|
|
|
|
|
map: this.getOrCreateTexture(material),
|
|
|
|
|
roughness: 0.92,
|
|
|
|
|
metalness: 0.02
|
|
|
|
|
});
|
2026-04-12 01:04:39 +02:00
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
this.currentWorld !== null &&
|
|
|
|
|
shouldApplyWhiteboxBevel(this.currentWorld.advancedRendering)
|
|
|
|
|
) {
|
|
|
|
|
applyWhiteboxBevelToMaterial(
|
|
|
|
|
faceMaterial,
|
|
|
|
|
this.currentWorld.advancedRendering.whiteboxBevel
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return faceMaterial;
|
2026-03-31 03:04:15 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-07 06:15:02 +02:00
|
|
|
private updateUnderwaterSceneFog() {
|
|
|
|
|
const fogState =
|
|
|
|
|
this.activeController === this.firstPersonController
|
2026-04-11 04:19:50 +02:00
|
|
|
? resolveUnderwaterFogState(
|
|
|
|
|
this.runtimeScene,
|
2026-04-11 19:07:00 +02:00
|
|
|
this.currentPlayerControllerTelemetry
|
2026-04-11 04:19:50 +02:00
|
|
|
)
|
2026-04-07 06:15:02 +02:00
|
|
|
: null;
|
|
|
|
|
|
|
|
|
|
if (fogState === null) {
|
2026-04-07 07:13:33 +02:00
|
|
|
this.underwaterSceneFog.density = 0;
|
2026-04-07 06:15:02 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.underwaterSceneFog.color.set(fogState.colorHex);
|
|
|
|
|
this.underwaterSceneFog.density = fogState.density;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 19:07:53 +02:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 06:35:21 +02:00
|
|
|
private getWaterReflectionMode() {
|
2026-04-11 04:19:50 +02:00
|
|
|
if (
|
|
|
|
|
this.currentWorld === null ||
|
|
|
|
|
!this.currentWorld.advancedRendering.enabled ||
|
|
|
|
|
this.currentWorld.advancedRendering.waterPath !== "quality"
|
|
|
|
|
) {
|
2026-04-07 06:35:21 +02:00
|
|
|
return "none" as const;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this.currentWorld.advancedRendering.waterReflectionMode;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private createWaterReflectionRenderTarget() {
|
2026-04-07 06:42:24 +02:00
|
|
|
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));
|
2026-04-07 06:35:21 +02:00
|
|
|
return new WebGLRenderTarget(width, height);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private resizeWaterReflectionTargets() {
|
2026-04-07 06:42:24 +02:00
|
|
|
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));
|
2026-04-07 06:35:21 +02:00
|
|
|
|
|
|
|
|
for (const binding of this.runtimeWaterContactUniforms) {
|
|
|
|
|
binding.reflectionRenderTarget?.setSize(width, height);
|
2026-04-07 07:13:08 +02:00
|
|
|
binding.lastReflectionUpdateTime = Number.NEGATIVE_INFINITY;
|
2026-04-07 06:35:21 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private updateRuntimeWaterReflections() {
|
|
|
|
|
if (this.renderer === null || this.runtimeScene === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const reflectionMode = this.getWaterReflectionMode();
|
2026-04-07 07:13:18 +02:00
|
|
|
const now = performance.now();
|
2026-04-07 06:35:21 +02:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 08:31:54 +02:00
|
|
|
if (binding.reflectionRenderTarget === null) {
|
2026-04-11 04:19:50 +02:00
|
|
|
binding.reflectionRenderTarget =
|
|
|
|
|
this.createWaterReflectionRenderTarget();
|
2026-04-07 08:31:54 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-07 06:35:21 +02:00
|
|
|
const canRenderReflection = updatePlanarReflectionCamera(
|
|
|
|
|
binding.brush,
|
|
|
|
|
this.camera,
|
|
|
|
|
this.waterReflectionCamera,
|
|
|
|
|
binding.reflectionMatrixUniform.value
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-07 08:31:54 +02:00
|
|
|
if (!canRenderReflection || binding.reflectionRenderTarget === null) {
|
2026-04-07 06:35:21 +02:00
|
|
|
binding.reflectionEnabledUniform.value = 0;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
if (
|
|
|
|
|
binding.reflectionTextureUniform.value !== null &&
|
|
|
|
|
now - binding.lastReflectionUpdateTime <
|
|
|
|
|
WATER_REFLECTION_UPDATE_INTERVAL_MS
|
|
|
|
|
) {
|
2026-04-07 07:13:18 +02:00
|
|
|
binding.reflectionEnabledUniform.value = 0.36;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
const hiddenWaterMeshes: Array<{
|
|
|
|
|
mesh: Mesh<BufferGeometry, Material[]>;
|
|
|
|
|
visible: boolean;
|
|
|
|
|
}> = [];
|
2026-04-07 06:35:21 +02:00
|
|
|
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();
|
2026-04-07 07:13:18 +02:00
|
|
|
const previousFogDensity = this.underwaterSceneFog.density;
|
2026-04-11 04:19:50 +02:00
|
|
|
const previousReflectionStates = this.runtimeWaterContactUniforms.map(
|
|
|
|
|
(waterBinding) => ({
|
|
|
|
|
binding: waterBinding,
|
|
|
|
|
enabled: waterBinding.reflectionEnabledUniform?.value ?? 0,
|
|
|
|
|
texture: waterBinding.reflectionTextureUniform?.value ?? null
|
|
|
|
|
})
|
|
|
|
|
);
|
2026-04-07 07:13:18 +02:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
this.underwaterSceneFog.density = 0;
|
2026-04-07 07:45:15 +02:00
|
|
|
for (const state of previousReflectionStates) {
|
|
|
|
|
if (state.binding.reflectionEnabledUniform !== null) {
|
|
|
|
|
state.binding.reflectionEnabledUniform.value = 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
binding.reflectionTextureUniform.value = null;
|
2026-04-07 07:13:18 +02:00
|
|
|
this.renderer.setRenderTarget(binding.reflectionRenderTarget);
|
2026-04-13 15:23:15 +02:00
|
|
|
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);
|
2026-04-07 07:13:18 +02:00
|
|
|
} finally {
|
|
|
|
|
this.renderer.setRenderTarget(previousRenderTarget);
|
|
|
|
|
this.renderer.autoClear = previousAutoClear;
|
|
|
|
|
this.modelGroup.visible = previousModelGroupVisibility;
|
|
|
|
|
this.underwaterSceneFog.density = previousFogDensity;
|
2026-04-07 07:45:15 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-07 07:13:18 +02:00
|
|
|
|
|
|
|
|
for (const hiddenWaterMesh of hiddenWaterMeshes) {
|
|
|
|
|
hiddenWaterMesh.mesh.visible = hiddenWaterMesh.visible;
|
|
|
|
|
}
|
2026-04-07 06:35:21 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
binding.reflectionTextureUniform.value =
|
|
|
|
|
binding.reflectionRenderTarget.texture;
|
2026-04-07 06:35:21 +02:00
|
|
|
binding.reflectionEnabledUniform.value = 0.36;
|
2026-04-07 07:13:18 +02:00
|
|
|
binding.lastReflectionUpdateTime = now;
|
2026-04-07 06:35:21 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
private getOrCreateTexture(
|
|
|
|
|
material: NonNullable<RuntimeBoxBrushInstance["faces"]["posX"]["material"]>
|
|
|
|
|
) {
|
2026-03-31 03:04:15 +02:00
|
|
|
const signature = createStarterMaterialSignature(material);
|
|
|
|
|
const cachedTexture = this.materialTextureCache.get(material.id);
|
|
|
|
|
|
|
|
|
|
if (cachedTexture !== undefined && cachedTexture.signature === signature) {
|
|
|
|
|
return cachedTexture.texture;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cachedTexture?.texture.dispose();
|
|
|
|
|
|
|
|
|
|
const texture = createStarterMaterialTexture(material);
|
|
|
|
|
this.materialTextureCache.set(material.id, {
|
|
|
|
|
signature,
|
|
|
|
|
texture
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return texture;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 20:07:37 +02:00
|
|
|
private clearLocalLights() {
|
2026-04-14 01:35:41 +02:00
|
|
|
for (const renderObjects of this.localLightObjects.values()) {
|
|
|
|
|
this.localLightGroup.remove(renderObjects.group);
|
2026-03-31 20:07:37 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.localLightObjects.clear();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 03:04:15 +02:00
|
|
|
private clearBrushMeshes() {
|
|
|
|
|
for (const mesh of this.brushMeshes.values()) {
|
|
|
|
|
this.brushGroup.remove(mesh);
|
|
|
|
|
mesh.geometry.dispose();
|
2026-04-07 11:33:12 +02:00
|
|
|
this.disposeUniqueMaterials(mesh.material);
|
2026-03-31 03:04:15 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.brushMeshes.clear();
|
2026-04-06 17:27:49 +02:00
|
|
|
this.volumeAnimatedUniforms.length = 0;
|
2026-04-07 06:36:13 +02:00
|
|
|
for (const binding of this.runtimeWaterContactUniforms) {
|
|
|
|
|
binding.reflectionRenderTarget?.dispose();
|
|
|
|
|
}
|
2026-04-06 18:04:29 +02:00
|
|
|
this.runtimeWaterContactUniforms.length = 0;
|
|
|
|
|
}
|
2026-04-07 11:33:12 +02:00
|
|
|
|
|
|
|
|
private disposeUniqueMaterials(materials: Material[]) {
|
|
|
|
|
for (const material of new Set(materials)) {
|
|
|
|
|
material.dispose();
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-06 18:04:29 +02:00
|
|
|
|
|
|
|
|
private createPlayerWaterContactBounds() {
|
2026-04-11 04:19:50 +02:00
|
|
|
if (
|
|
|
|
|
this.runtimeScene === null ||
|
2026-04-11 19:07:17 +02:00
|
|
|
this.currentPlayerControllerTelemetry === null
|
2026-04-11 04:19:50 +02:00
|
|
|
) {
|
2026-04-06 18:04:29 +02:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 19:07:17 +02:00
|
|
|
const feetPosition = this.currentPlayerControllerTelemetry.feetPosition;
|
2026-04-06 18:04:29 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
case "none":
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
private collectRuntimeStaticWaterContactPatches(
|
|
|
|
|
brush: RuntimeBoxBrushInstance
|
|
|
|
|
) {
|
2026-04-07 04:58:48 +02:00
|
|
|
const contactBounds: Parameters<typeof collectWaterContactPatches>[1] = [];
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
const runtimeBrushesById = new Map(
|
|
|
|
|
(this.runtimeScene?.brushes ?? []).map((runtimeBrush) => [
|
|
|
|
|
runtimeBrush.id,
|
|
|
|
|
runtimeBrush
|
|
|
|
|
])
|
|
|
|
|
);
|
2026-04-07 05:44:04 +02:00
|
|
|
|
|
|
|
|
for (const collider of this.runtimeScene?.colliders ?? []) {
|
|
|
|
|
if (collider.source === "brush") {
|
|
|
|
|
const otherBrush = runtimeBrushesById.get(collider.brushId);
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
if (
|
|
|
|
|
otherBrush === undefined ||
|
|
|
|
|
otherBrush.id === brush.id ||
|
|
|
|
|
otherBrush.volume.mode !== "none"
|
|
|
|
|
) {
|
2026-04-07 05:44:04 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-04-07 04:58:48 +02:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 05:44:04 +02:00
|
|
|
if (collider.kind === "trimesh") {
|
|
|
|
|
contactBounds.push({
|
|
|
|
|
kind: "triangleMesh",
|
|
|
|
|
vertices: collider.vertices,
|
|
|
|
|
indices: collider.indices,
|
2026-04-07 05:50:55 +02:00
|
|
|
mergeProfile: "aggressive",
|
2026-04-07 05:44:04 +02:00
|
|
|
transform: collider.transform
|
|
|
|
|
});
|
2026-04-07 04:58:48 +02:00
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
contactBounds.push({
|
|
|
|
|
min: collider.worldBounds.min,
|
|
|
|
|
max: collider.worldBounds.max
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-06 20:52:05 +02:00
|
|
|
|
|
|
|
|
return collectWaterContactPatches(
|
|
|
|
|
{
|
|
|
|
|
center: brush.center,
|
|
|
|
|
rotationDegrees: brush.rotationDegrees,
|
|
|
|
|
size: brush.size
|
|
|
|
|
},
|
2026-04-07 06:36:05 +02:00
|
|
|
contactBounds,
|
2026-04-07 06:55:15 +02:00
|
|
|
this.getRuntimeWaterFoamContactLimit(brush)
|
2026-04-06 20:52:05 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
private collectRuntimePlayerWaterContactPatches(
|
|
|
|
|
brush: RuntimeBoxBrushInstance
|
|
|
|
|
) {
|
2026-04-06 18:04:29 +02:00
|
|
|
const playerBounds = this.createPlayerWaterContactBounds();
|
|
|
|
|
|
2026-04-06 20:52:05 +02:00
|
|
|
if (playerBounds === null) {
|
|
|
|
|
return [];
|
2026-04-06 18:04:29 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return collectWaterContactPatches(
|
|
|
|
|
{
|
|
|
|
|
center: brush.center,
|
|
|
|
|
rotationDegrees: brush.rotationDegrees,
|
|
|
|
|
size: brush.size
|
|
|
|
|
},
|
2026-04-07 06:36:05 +02:00
|
|
|
[playerBounds],
|
2026-04-07 06:55:15 +02:00
|
|
|
this.getRuntimeWaterFoamContactLimit(brush)
|
2026-04-06 18:04:29 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 06:55:15 +02:00
|
|
|
private getRuntimeWaterFoamContactLimit(brush: RuntimeBoxBrushInstance) {
|
2026-04-11 04:19:50 +02:00
|
|
|
return brush.volume.mode === "water"
|
|
|
|
|
? brush.volume.water.foamContactLimit
|
|
|
|
|
: 0;
|
2026-04-07 06:55:15 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-06 20:52:05 +02:00
|
|
|
private mergeRuntimeWaterContactPatches(
|
2026-04-07 06:36:05 +02:00
|
|
|
brush: RuntimeBoxBrushInstance,
|
2026-04-06 20:52:05 +02:00
|
|
|
staticContactPatches: ReturnType<typeof collectWaterContactPatches>,
|
|
|
|
|
dynamicContactPatches: ReturnType<typeof collectWaterContactPatches>
|
|
|
|
|
) {
|
2026-04-11 04:19:50 +02:00
|
|
|
return [...dynamicContactPatches, ...staticContactPatches].slice(
|
|
|
|
|
0,
|
|
|
|
|
this.getRuntimeWaterFoamContactLimit(brush)
|
|
|
|
|
);
|
2026-04-06 20:52:05 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-06 18:04:29 +02:00
|
|
|
private updateRuntimeWaterContactUniforms() {
|
|
|
|
|
for (const binding of this.runtimeWaterContactUniforms) {
|
2026-04-07 06:36:05 +02:00
|
|
|
const mergedPatches = this.mergeRuntimeWaterContactPatches(
|
|
|
|
|
binding.brush,
|
|
|
|
|
binding.staticContactPatches,
|
|
|
|
|
this.collectRuntimePlayerWaterContactPatches(binding.brush)
|
|
|
|
|
);
|
2026-04-11 04:19:50 +02:00
|
|
|
binding.uniform.value =
|
|
|
|
|
createWaterContactPatchUniformValue(mergedPatches);
|
|
|
|
|
binding.axisUniform.value =
|
|
|
|
|
createWaterContactPatchAxisUniformValue(mergedPatches);
|
|
|
|
|
binding.shapeUniform.value =
|
|
|
|
|
createWaterContactPatchShapeUniformValue(mergedPatches);
|
2026-04-06 18:04:29 +02:00
|
|
|
}
|
2026-03-31 03:04:15 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-13 16:30:12 +02:00
|
|
|
private clearModelRenderObjects() {
|
2026-04-01 00:07:30 +02:00
|
|
|
for (const mixer of this.animationMixers.values()) {
|
|
|
|
|
mixer.stopAllAction();
|
|
|
|
|
}
|
|
|
|
|
this.animationMixers.clear();
|
|
|
|
|
this.instanceAnimationClips.clear();
|
|
|
|
|
|
2026-03-31 17:40:12 +02:00
|
|
|
for (const renderGroup of this.modelRenderObjects.values()) {
|
|
|
|
|
this.modelGroup.remove(renderGroup);
|
|
|
|
|
disposeModelInstance(renderGroup);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.modelRenderObjects.clear();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 03:04:15 +02:00
|
|
|
private resize() {
|
|
|
|
|
if (this.container === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const width = this.container.clientWidth;
|
|
|
|
|
const height = this.container.clientHeight;
|
|
|
|
|
|
|
|
|
|
if (width === 0 || height === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.camera.aspect = width / height;
|
|
|
|
|
this.camera.updateProjectionMatrix();
|
2026-03-31 06:25:22 +02:00
|
|
|
this.domElement.width = width;
|
|
|
|
|
this.domElement.height = height;
|
|
|
|
|
this.renderer?.setSize(width, height, false);
|
2026-04-02 20:52:22 +02:00
|
|
|
this.advancedRenderingComposer?.setSize(width, height);
|
2026-04-07 06:36:13 +02:00
|
|
|
this.resizeWaterReflectionTargets();
|
2026-03-31 03:04:15 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private render = () => {
|
|
|
|
|
this.animationFrame = window.requestAnimationFrame(this.render);
|
|
|
|
|
|
|
|
|
|
const now = performance.now();
|
|
|
|
|
const dt = Math.min((now - this.previousFrameTime) / 1000, 1 / 20);
|
|
|
|
|
this.previousFrameTime = now;
|
|
|
|
|
|
|
|
|
|
this.activeController?.update(dt);
|
2026-04-11 19:08:10 +02:00
|
|
|
this.applyPlayerCameraEffects(dt);
|
|
|
|
|
this.audioSystem.setPlayerControllerAudioHooks(
|
|
|
|
|
this.currentPlayerAudioHooks
|
|
|
|
|
);
|
2026-04-02 19:39:55 +02:00
|
|
|
this.audioSystem.updateListenerTransform();
|
2026-03-31 06:17:09 +02:00
|
|
|
|
2026-04-06 09:16:39 +02:00
|
|
|
this.volumeTime += dt;
|
2026-04-06 17:27:44 +02:00
|
|
|
for (const uniform of this.volumeAnimatedUniforms) {
|
|
|
|
|
uniform.value = this.volumeTime;
|
|
|
|
|
}
|
2026-04-06 09:16:39 +02:00
|
|
|
|
2026-04-12 04:32:46 +02:00
|
|
|
if (this.currentClockState !== null) {
|
|
|
|
|
this.currentClockState = advanceRuntimeClockState(
|
|
|
|
|
this.currentClockState,
|
|
|
|
|
dt
|
|
|
|
|
);
|
2026-04-13 23:50:55 +02:00
|
|
|
if (this.sceneReady) {
|
2026-04-14 03:02:17 +02:00
|
|
|
this.syncRuntimeScheduleToCurrentClock();
|
2026-04-13 23:50:55 +02:00
|
|
|
}
|
2026-04-12 04:32:46 +02:00
|
|
|
this.applyDayNightLighting();
|
|
|
|
|
this.clockPublishAccumulator += dt;
|
|
|
|
|
|
2026-04-14 01:40:15 +02:00
|
|
|
if (
|
|
|
|
|
this.clockPublishAccumulator >= RUNTIME_CLOCK_PUBLISH_INTERVAL_SECONDS
|
|
|
|
|
) {
|
2026-04-12 04:32:46 +02:00
|
|
|
this.clockPublishAccumulator = 0;
|
|
|
|
|
this.publishRuntimeClockState();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 00:07:34 +02:00
|
|
|
for (const mixer of this.animationMixers.values()) {
|
|
|
|
|
mixer.update(dt);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:15:33 +02:00
|
|
|
if (
|
|
|
|
|
this.sceneReady &&
|
|
|
|
|
this.runtimeScene !== null &&
|
2026-04-11 19:07:00 +02:00
|
|
|
this.currentPlayerControllerTelemetry !== null
|
2026-04-11 04:15:33 +02:00
|
|
|
) {
|
2026-04-11 04:19:50 +02:00
|
|
|
this.interactionSystem.updatePlayerPosition(
|
2026-04-11 19:07:00 +02:00
|
|
|
this.currentPlayerControllerTelemetry.feetPosition,
|
2026-04-11 04:19:50 +02:00
|
|
|
this.runtimeScene,
|
|
|
|
|
this.createInteractionDispatcher()
|
|
|
|
|
);
|
2026-04-11 11:16:36 +02:00
|
|
|
|
2026-04-11 11:39:33 +02:00
|
|
|
this.setInteractionPrompt(this.resolveInteractionPrompt());
|
2026-03-31 06:46:27 +02:00
|
|
|
} else {
|
|
|
|
|
this.setInteractionPrompt(null);
|
2026-03-31 06:17:09 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-06 18:04:29 +02:00
|
|
|
if (this.runtimeWaterContactUniforms.length > 0) {
|
|
|
|
|
this.updateRuntimeWaterContactUniforms();
|
2026-04-07 06:36:13 +02:00
|
|
|
this.updateRuntimeWaterReflections();
|
2026-04-06 18:04:29 +02:00
|
|
|
}
|
|
|
|
|
|
2026-04-07 06:15:02 +02:00
|
|
|
this.updateUnderwaterSceneFog();
|
|
|
|
|
|
2026-04-02 20:52:22 +02:00
|
|
|
if (this.advancedRenderingComposer !== null) {
|
2026-04-13 14:17:06 +02:00
|
|
|
this.worldBackgroundRenderer.syncToCamera(this.camera);
|
2026-04-02 20:52:22 +02:00
|
|
|
this.advancedRenderingComposer.render(dt);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 15:23:15 +02:00
|
|
|
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;
|
2026-03-31 03:04:15 +02:00
|
|
|
};
|
2026-03-31 06:17:09 +02:00
|
|
|
|
|
|
|
|
private applyTeleportPlayerAction(target: RuntimeTeleportTarget) {
|
2026-04-11 11:16:36 +02:00
|
|
|
if (this.activeController === this.thirdPersonController) {
|
|
|
|
|
this.thirdPersonController.teleportTo(target.position, target.yawDegrees);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 06:17:09 +02:00
|
|
|
this.firstPersonController.teleportTo(target.position, target.yawDegrees);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 03:02:17 +02:00
|
|
|
private syncRuntimeScheduleToCurrentClock() {
|
2026-04-13 23:50:32 +02:00
|
|
|
if (this.runtimeScene === null || this.currentClockState === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 01:57:44 +02:00
|
|
|
const nextResolvedScheduler = resolveRuntimeProjectScheduleState({
|
|
|
|
|
scheduler: this.runtimeScene.scheduler.document,
|
|
|
|
|
actorIds: this.runtimeScene.npcDefinitions.map((npc) => npc.actorId),
|
|
|
|
|
dayNumber: this.currentClockState.dayCount + 1,
|
|
|
|
|
timeOfDayHours: this.currentClockState.timeOfDayHours
|
|
|
|
|
});
|
|
|
|
|
const actorStates = new Map(
|
|
|
|
|
nextResolvedScheduler.actors.map((state) => [state.actorId, state])
|
|
|
|
|
);
|
2026-04-13 23:50:32 +02:00
|
|
|
let changed = false;
|
|
|
|
|
|
|
|
|
|
for (const npc of this.runtimeScene.npcDefinitions) {
|
2026-04-14 01:57:44 +02:00
|
|
|
const actorState = actorStates.get(npc.actorId);
|
|
|
|
|
const nextActive = actorState?.active ?? true;
|
|
|
|
|
const nextRoutineId = actorState?.activeRoutineId ?? null;
|
|
|
|
|
const nextRoutineTitle = actorState?.activeRoutineTitle ?? null;
|
2026-04-13 23:50:32 +02:00
|
|
|
|
|
|
|
|
if (
|
2026-04-14 01:57:44 +02:00
|
|
|
npc.active === nextActive &&
|
|
|
|
|
npc.activeRoutineId === nextRoutineId &&
|
|
|
|
|
npc.activeRoutineTitle === nextRoutineTitle
|
2026-04-13 23:50:32 +02:00
|
|
|
) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
npc.active = nextActive;
|
2026-04-14 01:57:44 +02:00
|
|
|
npc.activeRoutineId = nextRoutineId;
|
|
|
|
|
npc.activeRoutineTitle = nextRoutineTitle;
|
2026-04-13 23:50:32 +02:00
|
|
|
changed = true;
|
|
|
|
|
const renderGroup = this.modelRenderObjects.get(npc.entityId);
|
|
|
|
|
|
|
|
|
|
if (renderGroup !== undefined) {
|
|
|
|
|
renderGroup.visible = npc.visible && npc.active;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 03:02:25 +02:00
|
|
|
const nextResolvedControl = applyRuntimeProjectScheduleToControlState(
|
2026-04-14 01:57:44 +02:00
|
|
|
this.runtimeScene.control.resolved,
|
2026-04-14 03:02:25 +02:00
|
|
|
nextResolvedScheduler,
|
|
|
|
|
this.runtimeScene.control.baselineResolved
|
2026-04-14 01:57:44 +02:00
|
|
|
);
|
2026-04-14 03:02:25 +02:00
|
|
|
this.syncResolvedControlStateToRuntime(nextResolvedControl);
|
|
|
|
|
this.runtimeScene.scheduler.resolved = nextResolvedScheduler;
|
|
|
|
|
this.runtimeScene.control.resolved = nextResolvedControl;
|
2026-04-13 23:50:32 +02:00
|
|
|
|
2026-04-14 01:57:44 +02:00
|
|
|
if (changed) {
|
|
|
|
|
this.refreshRuntimeNpcCollections();
|
|
|
|
|
this.refreshCollisionWorldForNpcSchedule();
|
|
|
|
|
}
|
2026-04-13 23:50:32 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private refreshRuntimeNpcCollections() {
|
|
|
|
|
if (this.runtimeScene === null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 01:40:15 +02:00
|
|
|
this.runtimeScene.entities.npcs = this.runtimeScene.npcDefinitions
|
|
|
|
|
.filter((npc) => npc.active)
|
|
|
|
|
.map((npc) => createRuntimeNpcFromDefinition(npc));
|
2026-04-13 23:50:32 +02:00
|
|
|
this.runtimeScene.colliders = [
|
|
|
|
|
...this.runtimeScene.staticColliders,
|
|
|
|
|
...this.runtimeScene.entities.npcs
|
|
|
|
|
.map((npc) => buildRuntimeNpcCollider(npc))
|
|
|
|
|
.filter(isNonNull)
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-14 01:57:44 +02:00
|
|
|
private refreshCollisionWorldForNpcSchedule() {
|
2026-04-13 23:50:32 +02:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
private applyToggleBrushVisibilityAction(
|
|
|
|
|
brushId: string,
|
|
|
|
|
visible: boolean | undefined
|
|
|
|
|
) {
|
2026-03-31 06:17:09 +02:00
|
|
|
const mesh = this.brushMeshes.get(brushId);
|
|
|
|
|
|
|
|
|
|
if (mesh === undefined) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mesh.visible = visible ?? !mesh.visible;
|
|
|
|
|
}
|
2026-03-31 06:46:27 +02:00
|
|
|
|
2026-04-11 04:19:50 +02:00
|
|
|
private applyPlayAnimationAction(
|
|
|
|
|
instanceId: string,
|
|
|
|
|
clipName: string,
|
|
|
|
|
loop: boolean | undefined
|
|
|
|
|
) {
|
2026-04-01 00:05:01 +02:00
|
|
|
const mixer = this.animationMixers.get(instanceId);
|
|
|
|
|
const clips = this.instanceAnimationClips.get(instanceId);
|
|
|
|
|
|
|
|
|
|
if (!mixer || !clips) {
|
|
|
|
|
console.warn(`playAnimation: no mixer for instance ${instanceId}`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 00:05:37 +02:00
|
|
|
const clip = AnimationClip.findByName(clips, clipName);
|
2026-04-01 00:05:01 +02:00
|
|
|
|
|
|
|
|
if (!clip) {
|
2026-04-11 04:19:50 +02:00
|
|
|
console.warn(
|
|
|
|
|
`playAnimation: clip "${clipName}" not found on instance ${instanceId}`
|
|
|
|
|
);
|
2026-04-01 00:05:01 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 04:05:09 +02:00
|
|
|
// LoopRepeat is the three.js default; LoopOnce plays the clip a single time then stops.
|
|
|
|
|
const action = mixer.clipAction(clip);
|
2026-04-01 04:05:25 +02:00
|
|
|
action.loop = loop === false ? LoopOnce : LoopRepeat;
|
2026-04-01 04:05:09 +02:00
|
|
|
action.clampWhenFinished = loop === false;
|
2026-04-01 00:05:01 +02:00
|
|
|
mixer.stopAllAction();
|
2026-04-01 04:05:09 +02:00
|
|
|
action.reset().play();
|
2026-04-01 00:05:01 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private applyStopAnimationAction(instanceId: string) {
|
|
|
|
|
const mixer = this.animationMixers.get(instanceId);
|
|
|
|
|
|
|
|
|
|
if (!mixer) {
|
|
|
|
|
console.warn(`stopAnimation: no mixer for instance ${instanceId}`);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
mixer.stopAllAction();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 06:46:27 +02:00
|
|
|
private createInteractionDispatcher(): RuntimeInteractionDispatcher {
|
|
|
|
|
return {
|
|
|
|
|
teleportPlayer: (target) => {
|
|
|
|
|
this.applyTeleportPlayerAction(target);
|
|
|
|
|
},
|
2026-04-11 04:34:26 +02:00
|
|
|
activateSceneExit: (sceneExit) => {
|
|
|
|
|
this.sceneExitHandler?.({
|
|
|
|
|
sourceExitEntityId: sceneExit.entityId,
|
|
|
|
|
targetSceneId: sceneExit.targetSceneId,
|
|
|
|
|
targetEntryEntityId: sceneExit.targetEntryEntityId
|
|
|
|
|
});
|
|
|
|
|
},
|
2026-03-31 06:46:27 +02:00
|
|
|
toggleBrushVisibility: (brushId, visible) => {
|
|
|
|
|
this.applyToggleBrushVisibilityAction(brushId, visible);
|
2026-04-01 00:04:28 +02:00
|
|
|
},
|
2026-04-01 04:05:13 +02:00
|
|
|
playAnimation: (instanceId, clipName, loop) => {
|
|
|
|
|
this.applyPlayAnimationAction(instanceId, clipName, loop);
|
2026-04-01 00:04:28 +02:00
|
|
|
},
|
|
|
|
|
stopAnimation: (instanceId) => {
|
|
|
|
|
this.applyStopAnimationAction(instanceId);
|
2026-04-02 19:39:55 +02:00
|
|
|
},
|
|
|
|
|
playSound: (soundEmitterId, link) => {
|
|
|
|
|
this.audioSystem.playSound(soundEmitterId, link);
|
|
|
|
|
},
|
|
|
|
|
stopSound: (soundEmitterId) => {
|
|
|
|
|
this.audioSystem.stopSound(soundEmitterId);
|
2026-04-14 01:35:27 +02:00
|
|
|
},
|
|
|
|
|
dispatchControlEffect: (effect, link) => {
|
|
|
|
|
this.applyControlEffect(effect, link);
|
2026-03-31 06:46:27 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private setInteractionPrompt(prompt: RuntimeInteractionPrompt | null) {
|
|
|
|
|
if (
|
2026-04-11 04:19:50 +02:00
|
|
|
this.currentInteractionPrompt?.sourceEntityId ===
|
|
|
|
|
prompt?.sourceEntityId &&
|
2026-03-31 06:46:27 +02:00
|
|
|
this.currentInteractionPrompt?.prompt === prompt?.prompt &&
|
|
|
|
|
this.currentInteractionPrompt?.distance === prompt?.distance &&
|
|
|
|
|
this.currentInteractionPrompt?.range === prompt?.range
|
|
|
|
|
) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.currentInteractionPrompt = prompt;
|
|
|
|
|
this.interactionPromptHandler?.(prompt);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 11:39:33 +02:00
|
|
|
private resolveInteractionPrompt(): RuntimeInteractionPrompt | null {
|
|
|
|
|
if (
|
|
|
|
|
this.runtimeScene === null ||
|
2026-04-11 19:07:00 +02:00
|
|
|
this.currentPlayerControllerTelemetry === null ||
|
2026-04-11 11:39:33 +02:00
|
|
|
(this.activeController !== this.firstPersonController &&
|
|
|
|
|
this.activeController !== this.thirdPersonController)
|
|
|
|
|
) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.camera.getWorldDirection(this.cameraForward);
|
|
|
|
|
|
2026-04-11 19:07:00 +02:00
|
|
|
const interactionOrigin = this.currentPlayerControllerTelemetry.eyePosition;
|
2026-04-11 11:39:33 +02:00
|
|
|
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
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-31 06:46:27 +02:00
|
|
|
private handleRuntimeClick = () => {
|
2026-04-11 04:15:33 +02:00
|
|
|
if (
|
|
|
|
|
!this.sceneReady ||
|
|
|
|
|
this.runtimeScene === null ||
|
2026-04-11 11:39:33 +02:00
|
|
|
(this.activeController !== this.firstPersonController &&
|
|
|
|
|
this.activeController !== this.thirdPersonController) ||
|
2026-04-11 04:15:33 +02:00
|
|
|
this.currentInteractionPrompt === null
|
|
|
|
|
) {
|
2026-03-31 06:46:27 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 04:15:33 +02:00
|
|
|
this.audioSystem.handleUserGesture();
|
2026-04-11 04:19:50 +02:00
|
|
|
this.interactionSystem.dispatchClickInteraction(
|
|
|
|
|
this.currentInteractionPrompt.sourceEntityId,
|
|
|
|
|
this.runtimeScene,
|
|
|
|
|
this.createInteractionDispatcher()
|
|
|
|
|
);
|
2026-03-31 06:46:27 +02:00
|
|
|
};
|
2026-04-02 19:39:55 +02:00
|
|
|
|
|
|
|
|
private handleRuntimePointerDown = () => {
|
2026-04-11 04:15:33 +02:00
|
|
|
if (!this.sceneReady) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 19:39:55 +02:00
|
|
|
this.audioSystem.handleUserGesture();
|
|
|
|
|
};
|
2026-03-31 03:04:15 +02:00
|
|
|
}
|