6860 lines
199 KiB
TypeScript
6860 lines
199 KiB
TypeScript
import {
|
|
AdditiveBlending,
|
|
AmbientLight,
|
|
AnimationClip,
|
|
AnimationMixer,
|
|
BufferGeometry,
|
|
BoxGeometry,
|
|
CapsuleGeometry,
|
|
Color,
|
|
ConeGeometry,
|
|
DirectionalLight,
|
|
DoubleSide,
|
|
Euler,
|
|
Group,
|
|
FogExp2,
|
|
LoopOnce,
|
|
LoopRepeat,
|
|
Matrix4,
|
|
Material,
|
|
Mesh,
|
|
MeshBasicMaterial,
|
|
MeshPhysicalMaterial,
|
|
MeshStandardMaterial,
|
|
OrthographicCamera,
|
|
PerspectiveCamera,
|
|
PlaneGeometry,
|
|
PointLight,
|
|
Quaternion,
|
|
Scene,
|
|
ShaderMaterial,
|
|
Vector3,
|
|
SpotLight,
|
|
TextureLoader,
|
|
Texture,
|
|
WebGLRenderTarget,
|
|
WebGLRenderer
|
|
} from "three";
|
|
import { EffectComposer } from "postprocessing";
|
|
|
|
import {
|
|
createModelInstanceRenderGroup,
|
|
disposeModelInstance
|
|
} from "../assets/model-instance-rendering";
|
|
import type { LoadedModelAsset } from "../assets/gltf-model-import";
|
|
import type { LoadedImageAsset } from "../assets/image-assets";
|
|
import type { LoadedAudioAsset } from "../assets/audio-assets";
|
|
import type { ProjectAssetRecord } from "../assets/project-assets";
|
|
import {
|
|
cloneFaceUvState,
|
|
type Brush,
|
|
type WhiteboxFaceId
|
|
} from "../document/brushes";
|
|
import {
|
|
mapWorldPointToScenePathProgressBetweenPoints,
|
|
resolveNearestPointOnResolvedScenePath,
|
|
sampleResolvedScenePathPosition
|
|
} from "../document/paths";
|
|
import {
|
|
applyControlEffectToResolvedState,
|
|
createDefaultResolvedControlSource,
|
|
createInteractionLinkResolvedControlSource,
|
|
type ActorControlTargetRef,
|
|
type ControlEffect,
|
|
type InteractionControlTargetRef,
|
|
type LightControlTargetRef,
|
|
type ModelInstanceControlTargetRef,
|
|
type RuntimeResolvedControlChannelValue,
|
|
type RuntimeResolvedDiscreteControlState,
|
|
type SceneControlTargetRef,
|
|
type SoundEmitterControlTargetRef
|
|
} from "../controls/control-surface";
|
|
import { buildBoxBrushDerivedMeshData } from "../geometry/box-brush-mesh";
|
|
import { buildTerrainDerivedMeshData } from "../geometry/terrain-mesh";
|
|
import {
|
|
createStarterMaterialSignature,
|
|
createStarterMaterialTextureSet,
|
|
disposeStarterMaterialTextureSet,
|
|
type StarterMaterialTextureSet
|
|
} from "../materials/starter-material-textures";
|
|
import {
|
|
applyAdvancedRenderingRenderableShadowFlags,
|
|
configureAdvancedRenderingShadowLight,
|
|
configureAdvancedRenderingRenderer,
|
|
createAdvancedRenderingComposer,
|
|
resolveBoxVolumeRenderPaths,
|
|
type ResolvedBoxVolumeRenderPaths
|
|
} from "../rendering/advanced-rendering";
|
|
import {
|
|
fitCelestialDirectionalShadow,
|
|
resolveDominantCelestialShadowCaster
|
|
} from "../rendering/celestial-shadows";
|
|
import {
|
|
resolveWorldShaderSkyEnvironmentPhaseStates,
|
|
resolveWorldShaderSkyRenderState
|
|
} from "../rendering/world-shader-sky";
|
|
import {
|
|
resolveWorldCelestialBodiesState,
|
|
resolveWorldEnvironmentState,
|
|
WorldBackgroundRenderer
|
|
} from "../rendering/world-background-renderer";
|
|
import {
|
|
createRendererPrecomputedShaderSkyEnvironmentCache,
|
|
type PrecomputedShaderSkyEnvironmentCache
|
|
} from "../rendering/precomputed-shader-sky-environment-cache";
|
|
import {
|
|
createRendererQuantizedEnvironmentBlendCache,
|
|
createRendererQuantizedPmremBlendCache,
|
|
type QuantizedEnvironmentBlendCache
|
|
} from "../rendering/quantized-environment-blend-cache";
|
|
import {
|
|
collectWaterContactPatches,
|
|
createWaterContactPatchAxisUniformValue,
|
|
createWaterContactPatchShapeUniformValue,
|
|
createWaterContactPatchUniformValue,
|
|
createWaterMaterial
|
|
} from "../rendering/water-material";
|
|
import { createFogQualityMaterial } from "../rendering/fog-material";
|
|
import { updatePlanarReflectionCamera } from "../rendering/planar-reflection";
|
|
import {
|
|
createTerrainLayerBlendMaterial,
|
|
getTerrainLayerTexture
|
|
} from "../rendering/terrain-layer-material";
|
|
import {
|
|
applyWhiteboxBevelToMaterial,
|
|
shouldApplyWhiteboxBevel
|
|
} from "../rendering/whitebox-bevel-material";
|
|
import {
|
|
applyRendererRenderCategory,
|
|
applyRendererRenderCategoryFromMaterial,
|
|
enableCameraRendererRenderCategories,
|
|
enableObjectForAllRendererRenderCategories
|
|
} from "../rendering/render-layers";
|
|
import {
|
|
areAdvancedRenderingSettingsEqual,
|
|
cloneAdvancedRenderingSettings,
|
|
type AdvancedRenderingSettings
|
|
} from "../document/world-settings";
|
|
import {
|
|
createPlayerStartInputBindings,
|
|
DEFAULT_PLAYER_START_ALLOW_LOOK_INPUT_TARGET_SWITCH,
|
|
DEFAULT_PLAYER_START_INTERACTION_ANGLE_DEGREES,
|
|
DEFAULT_PLAYER_START_INTERACTION_REACH_METERS,
|
|
DEFAULT_PLAYER_START_TARGET_BUTTON_CYCLES_ACTIVE_TARGET,
|
|
getNpcColliderHeight,
|
|
getPlayerStartMouseBindingCodeForButton,
|
|
isPlayerStartMouseBindingCode
|
|
} from "../entities/entity-instances";
|
|
import type { InteractionLink } from "../interactions/interaction-links";
|
|
import type {
|
|
ImpulseSequenceStep,
|
|
SequenceVisibilityMode,
|
|
SequenceVisibilityTarget
|
|
} from "../sequencer/project-sequence-steps";
|
|
|
|
import { FirstPersonNavigationController } from "./first-person-navigation-controller";
|
|
import type {
|
|
NavigationController,
|
|
PlayerControllerTelemetry,
|
|
RuntimeControllerContext,
|
|
RuntimePlayerAudioHookState,
|
|
RuntimePlayerVolumeState,
|
|
RuntimeTargetLookInput,
|
|
RuntimeTargetLookInputResult
|
|
} from "./navigation-controller";
|
|
import { RapierCollisionWorld } from "./rapier-collision-world";
|
|
import {
|
|
RuntimeInteractionSystem,
|
|
resolveRuntimeTargetCandidates,
|
|
resolveRuntimeTargetReference,
|
|
type RuntimeDialogueStartSource,
|
|
type RuntimeInteractionDispatcher,
|
|
type RuntimeInteractionPrompt,
|
|
type RuntimeTargetCandidate,
|
|
type RuntimeResolvedTarget,
|
|
type RuntimeTargetReference
|
|
} from "./runtime-interaction-system";
|
|
import { RuntimeAudioSystem } from "./runtime-audio-system";
|
|
import {
|
|
advanceRuntimeClockState,
|
|
areRuntimeClockStatesEqual,
|
|
cloneRuntimeClockState,
|
|
createRuntimeClockState,
|
|
reconfigureRuntimeClockState,
|
|
resolveRuntimeDayNightWorldState,
|
|
resolveRuntimeTimeState,
|
|
type RuntimeClockState
|
|
} from "./runtime-project-time";
|
|
import { resolveRuntimePlayerMovementHooks } from "./player-controller-telemetry";
|
|
import {
|
|
resolveDialogueAttentionCameraSolution,
|
|
type DialogueAttentionSideSign
|
|
} from "./dialogue-attention-camera";
|
|
import {
|
|
commitRuntimeScheduleSyncResult,
|
|
createRuntimeScheduleSyncContext,
|
|
syncRuntimeSceneScheduleToClock,
|
|
type RuntimeScheduleSyncContext
|
|
} from "./runtime-schedule-sync";
|
|
import {
|
|
THIRD_PERSON_CAMERA_COLLISION_RADIUS,
|
|
ThirdPersonNavigationController
|
|
} from "./third-person-navigation-controller";
|
|
import { resolveUnderwaterFogState } from "./underwater-fog";
|
|
import { resolveWaterContact } from "./water-volume-utils";
|
|
import type {
|
|
RuntimeBrushFace,
|
|
RuntimeCameraRig,
|
|
RuntimeNpc,
|
|
RuntimeNpcDefinition,
|
|
RuntimeBoxBrushInstance,
|
|
RuntimeLocalLightCollection,
|
|
RuntimeNavigationMode,
|
|
RuntimeSceneDefinition,
|
|
RuntimeTerrain,
|
|
RuntimeTeleportTarget
|
|
} from "./runtime-scene-build";
|
|
import {
|
|
resolveDefaultTargetCycleInput,
|
|
resolvePlayerStartClearTargetInput,
|
|
resolvePlayerStartInteractInput,
|
|
resolvePlayerStartLookInput,
|
|
resolvePlayerStartPauseInput
|
|
} from "./player-input-bindings";
|
|
|
|
interface CachedMaterialTexture {
|
|
signature: string;
|
|
textureSet: StarterMaterialTextureSet;
|
|
}
|
|
|
|
function createRuntimeGeometryBrush(brush: RuntimeBoxBrushInstance): Brush {
|
|
const faces = Object.fromEntries(
|
|
Object.entries(brush.faces).map(([faceId, face]) => [
|
|
faceId,
|
|
{
|
|
materialId: face.materialId,
|
|
uv: cloneFaceUvState(face.uv)
|
|
}
|
|
])
|
|
);
|
|
const base = {
|
|
id: brush.id,
|
|
name: undefined,
|
|
visible: brush.visible,
|
|
enabled: true,
|
|
center: brush.center,
|
|
rotationDegrees: brush.rotationDegrees,
|
|
size: brush.size,
|
|
volume: brush.volume
|
|
};
|
|
|
|
switch (brush.kind) {
|
|
case "box":
|
|
return {
|
|
...base,
|
|
kind: "box",
|
|
geometry: brush.geometry as Brush["geometry"],
|
|
faces: faces as unknown as Brush["faces"]
|
|
} as Brush;
|
|
case "wedge":
|
|
return {
|
|
...base,
|
|
kind: "wedge",
|
|
geometry: brush.geometry as Brush["geometry"],
|
|
faces: faces as unknown as Brush["faces"]
|
|
} as Brush;
|
|
case "radialPrism":
|
|
return {
|
|
...base,
|
|
kind: "radialPrism",
|
|
sideCount: brush.sideCount ?? 12,
|
|
geometry: brush.geometry as Brush["geometry"],
|
|
faces: faces as unknown as Brush["faces"]
|
|
} as Brush;
|
|
case "cone":
|
|
return {
|
|
...base,
|
|
kind: "cone",
|
|
sideCount: brush.sideCount ?? 12,
|
|
geometry: brush.geometry as Brush["geometry"],
|
|
faces: faces as unknown as Brush["faces"]
|
|
} as Brush;
|
|
case "torus":
|
|
return {
|
|
...base,
|
|
kind: "torus",
|
|
majorSegmentCount: brush.majorSegmentCount ?? 16,
|
|
tubeSegmentCount: brush.tubeSegmentCount ?? 8,
|
|
geometry: brush.geometry as Brush["geometry"],
|
|
faces: faces as unknown as Brush["faces"]
|
|
} as Brush;
|
|
}
|
|
}
|
|
|
|
function isEditableEventTarget(target: EventTarget | null): boolean {
|
|
if (!(target instanceof HTMLElement)) {
|
|
return false;
|
|
}
|
|
|
|
const tagName = target.tagName.toLowerCase();
|
|
return (
|
|
target.isContentEditable ||
|
|
tagName === "input" ||
|
|
tagName === "textarea" ||
|
|
tagName === "select" ||
|
|
tagName === "button"
|
|
);
|
|
}
|
|
|
|
interface LocalLightRenderObjects {
|
|
group: Group;
|
|
light: PointLight | SpotLight;
|
|
}
|
|
|
|
interface LightVolumeRenderObjects {
|
|
group: Group;
|
|
lights: PointLight[];
|
|
}
|
|
|
|
interface RuntimeWaterContactUniformBinding {
|
|
brush: RuntimeBoxBrushInstance;
|
|
uniform: { value: import("three").Vector4[] };
|
|
axisUniform: { value: import("three").Vector2[] };
|
|
shapeUniform: { value: number[] };
|
|
staticContactPatches: ReturnType<typeof collectWaterContactPatches>;
|
|
reflectionTextureUniform: { value: import("three").Texture | null } | null;
|
|
reflectionMatrixUniform: { value: Matrix4 } | null;
|
|
reflectionEnabledUniform: { value: number } | null;
|
|
reflectionRenderTarget: WebGLRenderTarget | null;
|
|
lastReflectionUpdateTime: number;
|
|
}
|
|
|
|
const FALLBACK_FACE_COLOR = 0xf2ece2;
|
|
const RUNTIME_CLOCK_PUBLISH_INTERVAL_SECONDS = 1 / 30;
|
|
const WATER_REFLECTION_UPDATE_INTERVAL_MS = 96;
|
|
const CAMERA_RIG_POINTER_LOOK_SENSITIVITY = 0.004;
|
|
const CAMERA_RIG_GAMEPAD_LOOK_SPEED = 2.2;
|
|
const DIALOGUE_ATTENTION_CAMERA_TRANSITION_DURATION_SECONDS = 0.35;
|
|
const DIALOGUE_ATTENTION_PLAYER_FOCUS_HEIGHT_FACTOR = 0.82;
|
|
const DIALOGUE_ATTENTION_NPC_FOCUS_HEIGHT_FACTOR = 0.88;
|
|
const DIALOGUE_PARTICIPANT_MIN_SURFACE_DISTANCE = 0.5;
|
|
const DIALOGUE_PARTICIPANT_PUSHBACK_DURATION_SECONDS = 0.3;
|
|
const DIALOGUE_PARTICIPANT_YAW_BLEND_RATE = 8;
|
|
const DIALOGUE_PARTICIPANT_RESTORE_EPSILON_DEGREES = 0.5;
|
|
const CAMERA_COLLISION_RECOVERY_SPEED = 6.5;
|
|
const CAMERA_COLLISION_DISTANCE_EPSILON = 1e-4;
|
|
const TARGETING_LUX_FOLLOW_RATE = 8;
|
|
const TARGETING_LUX_FLIGHT_RATE = 7.5;
|
|
const TARGETING_LUX_RETURN_RATE = 8.5;
|
|
const TARGETING_LUX_HOME_HEIGHT_FACTOR = 0.52;
|
|
const TARGETING_LUX_HIDE_DISTANCE = 0.06;
|
|
const TARGETING_LUX_EXTRA_TARGET_LIFT = 0.3;
|
|
const TARGETING_LUX_SWAY_RATE = 2.2;
|
|
const TARGETING_LUX_SWAY_DISTANCE = 0.22;
|
|
const TARGETING_LUX_BOB_RATE = 4.2;
|
|
const TARGETING_LUX_PULSE_RATE = 6.5;
|
|
const TARGETING_ACTIVE_ARROW_COUNT = 3;
|
|
const TARGETING_ACTIVE_ARROW_ORBIT_RATE = 1.6;
|
|
const TARGETING_DIRECTION_SWITCH_INPUT_THRESHOLD = 0.28;
|
|
const TARGETING_SCREEN_SWITCH_MIN_DISTANCE = 0.04;
|
|
const TARGETING_SCREEN_SWITCH_MIN_ALIGNMENT = 0.68;
|
|
const TARGETING_SCREEN_SWITCH_MAX_ABS_X = 1.35;
|
|
const TARGETING_SCREEN_SWITCH_MAX_ABS_Y = 1.25;
|
|
const TARGETING_SCREEN_PROPOSAL_MAX_ABS_X = 1;
|
|
const TARGETING_SCREEN_PROPOSAL_MAX_ABS_Y = 1;
|
|
const TARGETING_SCREEN_PROPOSAL_FOCUS_Y = 0.2;
|
|
const TARGETING_MAX_ACTIVE_TARGET_DISTANCE = 15;
|
|
const TARGETING_ACTIVE_TARGET_RELEASE_DISTANCE =
|
|
TARGETING_MAX_ACTIVE_TARGET_DISTANCE + 0.75;
|
|
const TARGETING_AUTO_RETARGET_SAFE_DISTANCE =
|
|
TARGETING_MAX_ACTIVE_TARGET_DISTANCE - 0.75;
|
|
const TARGETING_VISIBILITY_TARGET_CLEARANCE = 0.45;
|
|
const TARGETING_VISIBILITY_TARGET_CLEARANCE_PADDING = 0.08;
|
|
const TARGETING_ACTIVE_OCCLUSION_GRACE_SECONDS = 0.35;
|
|
// Proposed-target camera nudging is intentionally disabled for now. Lux alone
|
|
// should communicate proposal without moving the gameplay camera.
|
|
// const PROPOSED_TARGET_CAMERA_ASSIST_STRENGTH = 0.28;
|
|
const ACTIVE_TARGET_CAMERA_ASSIST_STRENGTH = 0.55;
|
|
const TARGETING_LUX_VERTEX_SHADER = `
|
|
varying vec2 vUv;
|
|
|
|
void main() {
|
|
vUv = uv;
|
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
}
|
|
`;
|
|
const TARGETING_LUX_CORE_FRAGMENT_SHADER = `
|
|
varying vec2 vUv;
|
|
|
|
void main() {
|
|
vec2 centeredUv = vUv * 2.0 - 1.0;
|
|
float distanceFromCenter = length(centeredUv);
|
|
float body = smoothstep(0.72, 0.2, distanceFromCenter);
|
|
float rim = smoothstep(0.56, 0.76, distanceFromCenter) *
|
|
smoothstep(1.0, 0.7, distanceFromCenter);
|
|
float edgeFade = smoothstep(1.0, 0.78, distanceFromCenter);
|
|
|
|
vec3 whiteCore = vec3(1.0, 0.98, 0.9);
|
|
vec3 tealRim = vec3(0.35, 1.0, 1.0);
|
|
vec3 color = mix(whiteCore, tealRim, rim * 0.7);
|
|
color += tealRim * rim * 0.35;
|
|
|
|
float alpha = clamp(body * 0.95 + rim * 0.45, 0.0, 1.0) * edgeFade;
|
|
if (alpha <= 0.002) {
|
|
discard;
|
|
}
|
|
|
|
gl_FragColor = vec4(color, alpha);
|
|
}
|
|
`;
|
|
const TARGETING_LUX_GLOW_FRAGMENT_SHADER = `
|
|
varying vec2 vUv;
|
|
|
|
void main() {
|
|
vec2 centeredUv = vUv * 2.0 - 1.0;
|
|
float distanceFromCenter = length(centeredUv);
|
|
float outerFade = smoothstep(1.0, 0.04, distanceFromCenter);
|
|
float ringWeight = smoothstep(0.28, 0.64, distanceFromCenter) *
|
|
smoothstep(1.0, 0.58, distanceFromCenter);
|
|
vec3 teal = vec3(0.36, 0.98, 1.0);
|
|
vec3 deepTeal = vec3(0.02, 0.46, 0.54);
|
|
vec3 color = mix(deepTeal, teal, ringWeight);
|
|
float alpha = outerFade * (0.12 + ringWeight * 0.42);
|
|
|
|
if (alpha <= 0.002) {
|
|
discard;
|
|
}
|
|
|
|
gl_FragColor = vec4(color, alpha);
|
|
}
|
|
`;
|
|
|
|
export function resolveRuntimeTargetVisualPlacement(target: {
|
|
center: { x: number; y: number; z: number };
|
|
range: number;
|
|
}) {
|
|
const luxLift =
|
|
clampScalar(target.range * 0.42, 0.78, 1.35) +
|
|
TARGETING_LUX_EXTRA_TARGET_LIFT;
|
|
const activeMarkerRadius = clampScalar(target.range * 0.48, 0.62, 1.15);
|
|
const activeMarkerScale = clampScalar(target.range * 0.55, 0.8, 1.35);
|
|
|
|
return {
|
|
luxPosition: {
|
|
x: target.center.x,
|
|
y: target.center.y + luxLift,
|
|
z: target.center.z
|
|
},
|
|
activeMarkerPosition: {
|
|
x: target.center.x,
|
|
y: target.center.y,
|
|
z: target.center.z
|
|
},
|
|
activeMarkerRadius,
|
|
activeMarkerScale
|
|
};
|
|
}
|
|
|
|
function dampScalar(current: number, target: number, rate: number, dt: number) {
|
|
return current + (target - current) * Math.min(1, dt * rate);
|
|
}
|
|
|
|
function clampScalar(value: number, min: number, max: number) {
|
|
return Math.max(min, Math.min(max, value));
|
|
}
|
|
|
|
function lerpScalar(start: number, end: number, t: number) {
|
|
return start + (end - start) * t;
|
|
}
|
|
|
|
function createTargetingLuxCoreMaterial() {
|
|
return new ShaderMaterial({
|
|
vertexShader: TARGETING_LUX_VERTEX_SHADER,
|
|
fragmentShader: TARGETING_LUX_CORE_FRAGMENT_SHADER,
|
|
depthTest: false,
|
|
depthWrite: false,
|
|
fog: false,
|
|
transparent: true,
|
|
side: DoubleSide
|
|
});
|
|
}
|
|
|
|
function createTargetingLuxGlowMaterial() {
|
|
return new ShaderMaterial({
|
|
vertexShader: TARGETING_LUX_VERTEX_SHADER,
|
|
fragmentShader: TARGETING_LUX_GLOW_FRAGMENT_SHADER,
|
|
blending: AdditiveBlending,
|
|
depthTest: false,
|
|
depthWrite: false,
|
|
fog: false,
|
|
transparent: true,
|
|
side: DoubleSide
|
|
});
|
|
}
|
|
|
|
function distanceBetweenPoints(
|
|
left: { x: number; y: number; z: number },
|
|
right: { x: number; y: number; z: number }
|
|
) {
|
|
return Math.hypot(left.x - right.x, left.y - right.y, left.z - right.z);
|
|
}
|
|
|
|
function smoothStep01(value: number) {
|
|
const t = clampScalar(value, 0, 1);
|
|
|
|
return t * t * (3 - 2 * t);
|
|
}
|
|
|
|
function normalizeDegrees(value: number) {
|
|
const wrapped = ((((value + 180) % 360) + 360) % 360) - 180;
|
|
|
|
return wrapped === -180 ? 180 : wrapped;
|
|
}
|
|
|
|
function resolveShortestAngleDeltaDegrees(
|
|
fromDegrees: number,
|
|
toDegrees: number
|
|
) {
|
|
return normalizeDegrees(toDegrees - fromDegrees);
|
|
}
|
|
|
|
function dampAngleDegrees(
|
|
currentDegrees: number,
|
|
targetDegrees: number,
|
|
rate: number,
|
|
dt: number
|
|
) {
|
|
return normalizeDegrees(
|
|
currentDegrees +
|
|
resolveShortestAngleDeltaDegrees(currentDegrees, targetDegrees) *
|
|
Math.min(1, dt * rate)
|
|
);
|
|
}
|
|
|
|
export interface RuntimeSceneLoadState {
|
|
status: "loading" | "ready" | "error";
|
|
message: string | null;
|
|
}
|
|
|
|
export interface RuntimeSceneTransitionRequest {
|
|
sourceEntityId: string | null;
|
|
targetSceneId: string;
|
|
targetEntryEntityId: string;
|
|
}
|
|
|
|
export interface RuntimeDialogueState {
|
|
npcEntityId: string;
|
|
dialogueId: string;
|
|
title: string;
|
|
lineId: string;
|
|
lineIndex: number;
|
|
lineCount: number;
|
|
speakerName: string;
|
|
text: string;
|
|
source: RuntimeDialogueStartSource;
|
|
}
|
|
|
|
export interface RuntimePauseState {
|
|
paused: boolean;
|
|
source: "manual" | "control" | "dialogue" | "mixed" | null;
|
|
}
|
|
|
|
type RuntimeCameraSourceKey =
|
|
| "gameplay"
|
|
| `rig:${string}`
|
|
| `dialogue:${string}`;
|
|
|
|
interface RuntimeCameraPose {
|
|
position: Vector3;
|
|
lookTarget: Vector3;
|
|
collisionPivot?: Vector3 | null;
|
|
collisionRadius?: number | null;
|
|
}
|
|
|
|
interface RuntimeCameraTransitionState {
|
|
durationSeconds: number;
|
|
elapsedSeconds: number;
|
|
fromPose: RuntimeCameraPose;
|
|
toPose: RuntimeCameraPose;
|
|
destinationSourceKey: RuntimeCameraSourceKey;
|
|
}
|
|
|
|
interface RuntimeDialogueAttentionState {
|
|
npcEntityId: string;
|
|
sideSign: DialogueAttentionSideSign;
|
|
}
|
|
|
|
interface RuntimeDialogueParticipantState {
|
|
npcEntityId: string;
|
|
npcCurrentYawDegrees: number;
|
|
npcTargetYawDegrees: number;
|
|
npcRestoreYawDegrees: number;
|
|
playerStartFeetPosition: RuntimeTeleportTarget["position"];
|
|
playerTargetFeetPosition: RuntimeTeleportTarget["position"];
|
|
playerPositionBlendElapsedSeconds: number;
|
|
playerPositionBlendDurationSeconds: number;
|
|
playerCurrentYawDegrees: number;
|
|
playerTargetYawDegrees: number;
|
|
}
|
|
|
|
type RuntimeResolvedCameraSource =
|
|
| {
|
|
kind: "gameplay";
|
|
}
|
|
| {
|
|
kind: "rig";
|
|
rig: RuntimeCameraRig;
|
|
}
|
|
| {
|
|
kind: "dialogue";
|
|
state: RuntimeDialogueAttentionState;
|
|
};
|
|
|
|
type TargetingLuxFlightState =
|
|
| "hidden"
|
|
| "outbound"
|
|
| "following"
|
|
| "returning";
|
|
|
|
export class RuntimeHost {
|
|
private readonly scene = new Scene();
|
|
private readonly worldBackgroundRenderer = new WorldBackgroundRenderer();
|
|
private readonly camera = new PerspectiveCamera(70, 1, 0.05, 1000);
|
|
private readonly cameraForward = new Vector3();
|
|
private readonly cameraRigLookTarget = new Vector3();
|
|
private readonly cameraRigDirection = new Vector3();
|
|
private readonly cameraRigForward = new Vector3();
|
|
private readonly cameraCollisionDirection = new Vector3();
|
|
private readonly volumeOffset = new Vector3();
|
|
private readonly volumeInverseRotation = new Quaternion();
|
|
private readonly fogLocalCameraPosition = new Vector3();
|
|
private readonly domElement: HTMLCanvasElement;
|
|
private readonly ambientLight = new AmbientLight();
|
|
private readonly sunLight = new DirectionalLight();
|
|
private readonly moonLight = new DirectionalLight();
|
|
private readonly localLightGroup = new Group();
|
|
private readonly lightVolumeGroup = new Group();
|
|
private readonly brushGroup = new Group();
|
|
private readonly terrainGroup = new Group();
|
|
private readonly modelGroup = new Group();
|
|
private readonly targetingVisualGroup = new Group();
|
|
private readonly targetingLuxGroup = new Group();
|
|
private readonly targetingActiveGroup = new Group();
|
|
private readonly targetingLuxTargetPosition = new Vector3();
|
|
private readonly targetingLuxHomePosition = new Vector3();
|
|
private readonly targetingLuxSwayDirection = new Vector3();
|
|
private readonly targetingActiveCameraRight = new Vector3();
|
|
private readonly targetingActiveCameraUp = new Vector3();
|
|
private readonly targetingActiveArrowDirection = new Vector3();
|
|
private readonly targetingActiveArrowLocalTipAxis = new Vector3(0, 1, 0);
|
|
private readonly targetingActiveArrowGeometry = new ConeGeometry(
|
|
0.16,
|
|
0.38,
|
|
16
|
|
);
|
|
private readonly targetingActiveArrowMaterial = new MeshBasicMaterial({
|
|
color: 0xfff2a2,
|
|
depthTest: false,
|
|
depthWrite: false,
|
|
transparent: true,
|
|
opacity: 0.95
|
|
});
|
|
private readonly targetingActiveArrows: Mesh<
|
|
ConeGeometry,
|
|
MeshBasicMaterial
|
|
>[] = Array.from(
|
|
{ length: TARGETING_ACTIVE_ARROW_COUNT },
|
|
() =>
|
|
new Mesh(
|
|
this.targetingActiveArrowGeometry,
|
|
this.targetingActiveArrowMaterial
|
|
)
|
|
);
|
|
private readonly targetingLuxMesh = new Mesh(
|
|
new PlaneGeometry(0.32, 0.32),
|
|
createTargetingLuxCoreMaterial()
|
|
);
|
|
private readonly targetingLuxGlowMesh = new Mesh(
|
|
new PlaneGeometry(0.76, 0.76),
|
|
createTargetingLuxGlowMaterial()
|
|
);
|
|
private readonly targetingLuxLight = new PointLight(0x8df7ff, 1.25, 3.2, 2);
|
|
private targetingLuxInitialized = false;
|
|
private targetingLuxFlightState: TargetingLuxFlightState = "hidden";
|
|
private targetingVisualTime = 0;
|
|
private readonly firstPersonController =
|
|
new FirstPersonNavigationController();
|
|
private readonly thirdPersonController =
|
|
new ThirdPersonNavigationController();
|
|
private readonly interactionSystem = new RuntimeInteractionSystem();
|
|
private readonly audioSystem = new RuntimeAudioSystem(
|
|
this.scene,
|
|
this.camera,
|
|
null
|
|
);
|
|
private readonly underwaterSceneFog = new FogExp2("#2c6f8d", 0.03);
|
|
private readonly waterReflectionCamera = new PerspectiveCamera();
|
|
private readonly brushMeshes = new Map<
|
|
string,
|
|
Mesh<BufferGeometry, Material[]>
|
|
>();
|
|
private readonly terrainMeshes = new Map<
|
|
string,
|
|
Mesh<BufferGeometry, Material>
|
|
>();
|
|
private volumeTime = 0;
|
|
private readonly volumeAnimatedUniforms: Array<{ value: number }> = [];
|
|
private readonly runtimeWaterContactUniforms: RuntimeWaterContactUniformBinding[] =
|
|
[];
|
|
private readonly localLightObjects = new Map<
|
|
string,
|
|
LocalLightRenderObjects
|
|
>();
|
|
private readonly lightVolumeObjects = new Map<
|
|
string,
|
|
LightVolumeRenderObjects
|
|
>();
|
|
private readonly modelRenderObjects = new Map<string, Group>();
|
|
private readonly materialTextureCache = new Map<
|
|
string,
|
|
CachedMaterialTexture
|
|
>();
|
|
private readonly materialTextureLoader = new TextureLoader();
|
|
private readonly animationMixers = new Map<string, AnimationMixer>();
|
|
private readonly instanceAnimationClips = new Map<string, AnimationClip[]>();
|
|
private readonly controllerContext: RuntimeControllerContext;
|
|
private readonly renderer: WebGLRenderer | null;
|
|
private readonly environmentBlendCache: QuantizedEnvironmentBlendCache | null;
|
|
private readonly shaderSkyEnvironmentBlendCache: QuantizedEnvironmentBlendCache | null;
|
|
private readonly shaderSkyEnvironmentCache: PrecomputedShaderSkyEnvironmentCache | null;
|
|
private runtimeScene: RuntimeSceneDefinition | null = null;
|
|
private collisionWorld: RapierCollisionWorld | null = null;
|
|
private collisionWorldRequestId = 0;
|
|
private desiredNavigationMode: RuntimeNavigationMode = "thirdPerson";
|
|
private sceneReady = false;
|
|
private currentWorld: RuntimeSceneDefinition["world"] | null = null;
|
|
private runtimeScheduleSyncContext: RuntimeScheduleSyncContext | null = null;
|
|
private currentAdvancedRenderingSettings: AdvancedRenderingSettings | null =
|
|
null;
|
|
private advancedRenderingComposer: EffectComposer | null = null;
|
|
private projectAssets: Record<string, ProjectAssetRecord> = {};
|
|
private loadedModelAssets: Record<string, LoadedModelAsset> = {};
|
|
private loadedImageAssets: Record<string, LoadedImageAsset> = {};
|
|
private resizeObserver: ResizeObserver | null = null;
|
|
private animationFrame = 0;
|
|
private previousFrameTime = 0;
|
|
private container: HTMLElement | null = null;
|
|
private activeController: NavigationController | null = null;
|
|
private runtimeMessageHandler: ((message: string | null) => void) | null =
|
|
null;
|
|
private playerControllerTelemetryHandler:
|
|
| ((telemetry: PlayerControllerTelemetry | null) => void)
|
|
| null = null;
|
|
private interactionPromptHandler:
|
|
| ((prompt: RuntimeInteractionPrompt | null) => void)
|
|
| null = null;
|
|
private runtimeDialogueHandler:
|
|
| ((dialogue: RuntimeDialogueState | null) => void)
|
|
| null = null;
|
|
private runtimePauseStateHandler:
|
|
| ((state: RuntimePauseState) => void)
|
|
| null = null;
|
|
private sceneLoadStateHandler:
|
|
| ((state: RuntimeSceneLoadState) => void)
|
|
| null = null;
|
|
private sceneTransitionHandler:
|
|
| ((request: RuntimeSceneTransitionRequest) => void)
|
|
| null = null;
|
|
private currentRuntimeMessage: string | null = null;
|
|
private currentPlayerControllerTelemetry: PlayerControllerTelemetry | null =
|
|
null;
|
|
private currentCelestialShadowCaster: "sun" | "moon" | null = null;
|
|
private currentInteractionPrompt: RuntimeInteractionPrompt | null = null;
|
|
private currentDialogue: RuntimeDialogueState | null = null;
|
|
private currentPauseState: RuntimePauseState = {
|
|
paused: false,
|
|
source: null
|
|
};
|
|
private currentSceneLoadState: RuntimeSceneLoadState | null = null;
|
|
private currentClockState: RuntimeClockState | null = null;
|
|
private lastPublishedClockState: RuntimeClockState | null = null;
|
|
private currentPlayerAudioHooks: RuntimePlayerAudioHookState | null = null;
|
|
private runtimeTargetCandidates: RuntimeTargetCandidate[] = [];
|
|
private proposedRuntimeTarget: RuntimeTargetCandidate | null = null;
|
|
private activeRuntimeTargetReference: RuntimeTargetReference | null = null;
|
|
private activeRuntimeTargetOcclusionSeconds = 0;
|
|
private runtimeTargetSwitchInputHeld = false;
|
|
private previousTargetCycleInputActive = false;
|
|
private previousClearTargetInputActive = false;
|
|
private activeCameraRigOverrideEntityId: string | null = null;
|
|
private activeCameraSourceKey: RuntimeCameraSourceKey | null = null;
|
|
private activeRuntimeCameraRig: RuntimeCameraRig | null = null;
|
|
private activeDialogueAttentionState: RuntimeDialogueAttentionState | null =
|
|
null;
|
|
private dialogueParticipantState: RuntimeDialogueParticipantState | null =
|
|
null;
|
|
private cameraTransitionState: RuntimeCameraTransitionState | null = null;
|
|
private suppressNextCameraSourceTransition = false;
|
|
private cameraRigLookYawRadians = 0;
|
|
private cameraRigLookPitchRadians = 0;
|
|
private cameraRigLookDragging = false;
|
|
private smoothedRuntimeCameraCollisionDistance: number | null = null;
|
|
private lastCameraRigPointerClientX = 0;
|
|
private lastCameraRigPointerClientY = 0;
|
|
private runtimeClockStateHandler:
|
|
| ((state: RuntimeClockState) => void)
|
|
| null = null;
|
|
private clockPublishAccumulator = 0;
|
|
private cameraEffectVerticalOffset = 0;
|
|
private cameraEffectVerticalVelocity = 0;
|
|
private cameraEffectPitchOffset = 0;
|
|
private cameraEffectPitchVelocity = 0;
|
|
private cameraEffectRollOffset = 0;
|
|
private baseCameraFov = 70;
|
|
private manualPauseActive = false;
|
|
private controlPauseActive = false;
|
|
private dialoguePauseActive = false;
|
|
private previousInteractInputActive = false;
|
|
private previousPauseInputActive = false;
|
|
private readonly pressedKeys = new Set<string>();
|
|
private activeScheduledImpulseRoutineIds = new Set<string>();
|
|
private completedScheduledImpulseRoutineIds = new Set<string>();
|
|
|
|
constructor(options: { enableRendering?: boolean } = {}) {
|
|
const enableRendering = options.enableRendering ?? true;
|
|
|
|
enableCameraRendererRenderCategories(this.camera);
|
|
enableCameraRendererRenderCategories(this.waterReflectionCamera);
|
|
enableObjectForAllRendererRenderCategories(this.ambientLight);
|
|
enableObjectForAllRendererRenderCategories(this.sunLight);
|
|
enableObjectForAllRendererRenderCategories(this.moonLight);
|
|
this.scene.add(this.ambientLight);
|
|
this.scene.add(this.sunLight);
|
|
this.scene.add(this.sunLight.target);
|
|
this.scene.add(this.moonLight);
|
|
this.scene.add(this.moonLight.target);
|
|
this.scene.add(this.localLightGroup);
|
|
this.scene.add(this.lightVolumeGroup);
|
|
this.scene.add(this.brushGroup);
|
|
this.scene.add(this.terrainGroup);
|
|
this.scene.add(this.modelGroup);
|
|
this.targetingLuxMesh.renderOrder = 10000;
|
|
this.targetingLuxGlowMesh.renderOrder = 9999;
|
|
this.targetingLuxLight.castShadow = false;
|
|
this.targetingLuxGroup.add(this.targetingLuxGlowMesh);
|
|
this.targetingLuxGroup.add(this.targetingLuxMesh);
|
|
this.targetingLuxGroup.add(this.targetingLuxLight);
|
|
applyRendererRenderCategory(this.targetingLuxGroup, "overlay");
|
|
enableObjectForAllRendererRenderCategories(this.targetingLuxLight);
|
|
this.targetingActiveArrows.forEach((arrow, index) => {
|
|
arrow.renderOrder = 10001 + index;
|
|
this.targetingActiveGroup.add(arrow);
|
|
});
|
|
applyRendererRenderCategory(this.targetingActiveGroup, "overlay");
|
|
this.targetingVisualGroup.add(this.targetingLuxGroup);
|
|
this.targetingVisualGroup.add(this.targetingActiveGroup);
|
|
this.targetingVisualGroup.visible = false;
|
|
this.targetingLuxGroup.visible = false;
|
|
this.targetingActiveGroup.visible = false;
|
|
this.scene.add(this.targetingVisualGroup);
|
|
this.underwaterSceneFog.density = 0;
|
|
this.scene.fog = this.underwaterSceneFog;
|
|
this.renderer = enableRendering
|
|
? new WebGLRenderer({ antialias: false, alpha: true })
|
|
: null;
|
|
this.domElement =
|
|
this.renderer?.domElement ?? document.createElement("canvas");
|
|
|
|
if (this.renderer !== null) {
|
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
this.renderer.setClearAlpha(0);
|
|
} else {
|
|
this.domElement.className = "runner-canvas__surface";
|
|
}
|
|
|
|
this.moonLight.intensity = 0;
|
|
this.moonLight.visible = false;
|
|
this.environmentBlendCache =
|
|
this.renderer === null
|
|
? null
|
|
: createRendererQuantizedEnvironmentBlendCache(this.renderer, {
|
|
onTextureReady: () => {
|
|
this.applyDayNightLighting();
|
|
}
|
|
});
|
|
this.shaderSkyEnvironmentBlendCache =
|
|
this.renderer === null
|
|
? null
|
|
: createRendererQuantizedPmremBlendCache(this.renderer, {
|
|
onTextureReady: () => {
|
|
this.applyDayNightLighting();
|
|
}
|
|
});
|
|
this.shaderSkyEnvironmentCache =
|
|
this.renderer === null
|
|
? null
|
|
: createRendererPrecomputedShaderSkyEnvironmentCache(
|
|
this.renderer,
|
|
this.worldBackgroundRenderer,
|
|
{
|
|
phaseBlendTextureResolver: this.shaderSkyEnvironmentBlendCache,
|
|
captureSize: 32
|
|
}
|
|
);
|
|
|
|
this.controllerContext = {
|
|
camera: this.camera,
|
|
domElement: this.domElement,
|
|
getRuntimeScene: () => {
|
|
if (this.runtimeScene === null) {
|
|
throw new Error("Runtime scene has not been loaded.");
|
|
}
|
|
|
|
return this.runtimeScene;
|
|
},
|
|
resolveFirstPersonMotion: (feetPosition, motion, shape) =>
|
|
this.collisionWorld?.resolveFirstPersonMotion(
|
|
feetPosition,
|
|
motion,
|
|
shape
|
|
) ?? null,
|
|
probePlayerGround: (feetPosition, shape, maxDistance) =>
|
|
this.collisionWorld?.probePlayerGround(
|
|
feetPosition,
|
|
shape,
|
|
maxDistance
|
|
) ?? {
|
|
grounded: false,
|
|
distance: null,
|
|
normal: null,
|
|
slopeDegrees: null
|
|
},
|
|
canOccupyPlayerShape: (feetPosition, shape) =>
|
|
this.collisionWorld?.canOccupyPlayerShape(feetPosition, shape) ?? true,
|
|
resolvePlayerVolumeState: (feetPosition) =>
|
|
this.resolvePlayerVolumeState(feetPosition),
|
|
resolveThirdPersonCameraCollision: (
|
|
pivot,
|
|
desiredCameraPosition,
|
|
radius
|
|
) =>
|
|
this.collisionWorld?.resolveThirdPersonCameraCollision(
|
|
pivot,
|
|
desiredCameraPosition,
|
|
radius
|
|
) ?? { ...desiredCameraPosition },
|
|
resolveThirdPersonTargetAssist: () =>
|
|
this.resolveThirdPersonTargetAssist(),
|
|
handleRuntimeTargetLookInput: (input) =>
|
|
this.handleRuntimeTargetLookInput(input),
|
|
handleRuntimeTargetLookBoundaryReached: () => {
|
|
this.clearActiveRuntimeTarget();
|
|
return false;
|
|
},
|
|
isCameraDrivenExternally: () =>
|
|
this.resolveActiveRuntimeCameraRig() !== null ||
|
|
this.resolveDialogueAttentionNpc() !== null,
|
|
getCameraYawRadians: () => {
|
|
this.camera.getWorldDirection(this.cameraForward);
|
|
return Math.atan2(this.cameraForward.x, this.cameraForward.z);
|
|
},
|
|
isInputSuspended: () => this.isRuntimePaused(),
|
|
setRuntimeMessage: (message) => {
|
|
if (message === this.currentRuntimeMessage) {
|
|
return;
|
|
}
|
|
|
|
this.currentRuntimeMessage = message;
|
|
this.runtimeMessageHandler?.(message);
|
|
},
|
|
setPlayerControllerTelemetry: (telemetry) => {
|
|
this.currentPlayerControllerTelemetry = telemetry;
|
|
this.currentPlayerAudioHooks = telemetry?.hooks.audio ?? null;
|
|
this.playerControllerTelemetryHandler?.(telemetry);
|
|
}
|
|
};
|
|
}
|
|
|
|
private resolvePlayerVolumeState(feetPosition: {
|
|
x: number;
|
|
y: number;
|
|
z: number;
|
|
}): RuntimePlayerVolumeState {
|
|
if (this.runtimeScene === null) {
|
|
return {
|
|
inWater: false,
|
|
inFog: false,
|
|
waterSurfaceHeight: null
|
|
};
|
|
}
|
|
|
|
const waterContact = resolveWaterContact(
|
|
feetPosition,
|
|
this.runtimeScene.volumes.water
|
|
);
|
|
const inFog = this.runtimeScene.volumes.fog.some((volume) =>
|
|
this.isPointInsideOrientedVolume(feetPosition, volume)
|
|
);
|
|
|
|
return {
|
|
inWater: waterContact !== null,
|
|
inFog,
|
|
waterSurfaceHeight: waterContact?.surfaceHeight ?? null
|
|
};
|
|
}
|
|
|
|
private isPointInsideOrientedVolume(
|
|
point: { x: number; y: number; z: number },
|
|
volume: {
|
|
center: { x: number; y: number; z: number };
|
|
rotationDegrees: { x: number; y: number; z: number };
|
|
size: { x: number; y: number; z: number };
|
|
}
|
|
): boolean {
|
|
this.volumeOffset.set(
|
|
point.x - volume.center.x,
|
|
point.y - volume.center.y,
|
|
point.z - volume.center.z
|
|
);
|
|
|
|
this.volumeInverseRotation
|
|
.setFromEuler(
|
|
new Euler(
|
|
(volume.rotationDegrees.x * Math.PI) / 180,
|
|
(volume.rotationDegrees.y * Math.PI) / 180,
|
|
(volume.rotationDegrees.z * Math.PI) / 180,
|
|
"XYZ"
|
|
)
|
|
)
|
|
.invert();
|
|
|
|
this.volumeOffset.applyQuaternion(this.volumeInverseRotation);
|
|
|
|
const halfX = volume.size.x * 0.5;
|
|
const halfY = volume.size.y * 0.5;
|
|
const halfZ = volume.size.z * 0.5;
|
|
|
|
return (
|
|
Math.abs(this.volumeOffset.x) <= halfX &&
|
|
Math.abs(this.volumeOffset.y) <= halfY &&
|
|
Math.abs(this.volumeOffset.z) <= halfZ
|
|
);
|
|
}
|
|
|
|
mount(container: HTMLElement) {
|
|
this.container = container;
|
|
container.appendChild(this.domElement);
|
|
this.domElement.addEventListener(
|
|
"pointerdown",
|
|
this.handleRuntimePointerDown
|
|
);
|
|
this.domElement.addEventListener("wheel", this.handleRuntimeWheel, {
|
|
passive: false
|
|
});
|
|
window.addEventListener("keydown", this.handleRuntimeKeyDown);
|
|
window.addEventListener("keyup", this.handleRuntimeKeyUp);
|
|
window.addEventListener("pointermove", this.handleRuntimePointerMove);
|
|
window.addEventListener("pointerup", this.handleRuntimePointerUp);
|
|
window.addEventListener("blur", this.handleRuntimeBlur);
|
|
this.resize();
|
|
|
|
this.resizeObserver = new ResizeObserver(() => {
|
|
this.resize();
|
|
});
|
|
this.resizeObserver.observe(container);
|
|
|
|
this.previousFrameTime = performance.now();
|
|
this.render();
|
|
}
|
|
|
|
loadScene(runtimeScene: RuntimeSceneDefinition) {
|
|
const requestId = ++this.collisionWorldRequestId;
|
|
const preservePointerLockDuringLoad =
|
|
this.activeController === this.firstPersonController &&
|
|
this.desiredNavigationMode === "firstPerson" &&
|
|
document.pointerLockElement === this.domElement;
|
|
|
|
this.sceneReady = false;
|
|
this.runtimeScene = runtimeScene;
|
|
this.runtimeScheduleSyncContext =
|
|
createRuntimeScheduleSyncContext(runtimeScene);
|
|
this.currentWorld = runtimeScene.world;
|
|
this.activeScheduledImpulseRoutineIds.clear();
|
|
this.syncRuntimeClockState(runtimeScene.time);
|
|
this.syncRuntimeScheduleToCurrentClock();
|
|
this.activeController?.deactivate(this.controllerContext, {
|
|
releasePointerLock: !preservePointerLockDuringLoad
|
|
});
|
|
this.activeController = null;
|
|
this.firstPersonController.resetSceneState();
|
|
this.thirdPersonController.resetSceneState();
|
|
this.interactionSystem.reset();
|
|
this.setInteractionPrompt(null);
|
|
this.clearRuntimeTargetingState();
|
|
this.setRuntimeDialogue(null);
|
|
this.manualPauseActive = false;
|
|
this.controlPauseActive = false;
|
|
this.dialoguePauseActive = false;
|
|
this.previousInteractInputActive = false;
|
|
this.previousClearTargetInputActive = false;
|
|
this.previousPauseInputActive = false;
|
|
this.cameraRigLookDragging = false;
|
|
this.cameraRigLookYawRadians = 0;
|
|
this.cameraRigLookPitchRadians = 0;
|
|
this.activeCameraSourceKey = null;
|
|
this.activeRuntimeCameraRig = null;
|
|
this.activeDialogueAttentionState = null;
|
|
this.dialogueParticipantState = null;
|
|
this.cameraTransitionState = null;
|
|
this.resetRuntimeCameraCollisionSmoothing();
|
|
this.suppressNextCameraSourceTransition = true;
|
|
this.pressedKeys.clear();
|
|
this.publishRuntimePauseState(true);
|
|
this.currentPlayerControllerTelemetry = null;
|
|
this.currentPlayerAudioHooks = null;
|
|
this.playerControllerTelemetryHandler?.(null);
|
|
this.currentRuntimeMessage = null;
|
|
this.runtimeMessageHandler?.(null);
|
|
this.resetPlayerCameraEffects();
|
|
this.clearCollisionWorld();
|
|
this.publishSceneLoadState({
|
|
status: "loading",
|
|
message: null
|
|
});
|
|
this.syncResolvedControlStateToRuntime(runtimeScene.control.resolved);
|
|
this.applyWorld();
|
|
this.rebuildLocalLights(runtimeScene.localLights);
|
|
this.rebuildLightVolumes(runtimeScene.volumes.light);
|
|
this.rebuildBrushMeshes(runtimeScene.brushes);
|
|
this.rebuildTerrainMeshes(runtimeScene.terrains);
|
|
this.rebuildModelRenderObjects(
|
|
runtimeScene.modelInstances,
|
|
runtimeScene.npcDefinitions
|
|
);
|
|
this.audioSystem.loadScene(runtimeScene);
|
|
void this.finalizeSceneLoad(
|
|
requestId,
|
|
runtimeScene.colliders,
|
|
runtimeScene.playerCollider,
|
|
runtimeScene.playerMovement
|
|
);
|
|
}
|
|
|
|
updateAssets(
|
|
projectAssets: Record<string, ProjectAssetRecord>,
|
|
loadedModelAssets: Record<string, LoadedModelAsset>,
|
|
loadedImageAssets: Record<string, LoadedImageAsset>,
|
|
loadedAudioAssets: Record<string, LoadedAudioAsset>
|
|
) {
|
|
this.projectAssets = projectAssets;
|
|
this.loadedModelAssets = loadedModelAssets;
|
|
this.loadedImageAssets = loadedImageAssets;
|
|
this.environmentBlendCache?.clear();
|
|
|
|
if (this.currentWorld !== null) {
|
|
this.applyWorld();
|
|
}
|
|
|
|
if (this.runtimeScene !== null) {
|
|
this.rebuildModelRenderObjects(
|
|
this.runtimeScene.modelInstances,
|
|
this.runtimeScene.npcDefinitions
|
|
);
|
|
}
|
|
|
|
this.audioSystem.updateAssets(projectAssets, loadedAudioAssets);
|
|
}
|
|
|
|
setNavigationMode(mode: RuntimeNavigationMode) {
|
|
this.desiredNavigationMode = mode;
|
|
|
|
if (mode === "firstPerson") {
|
|
this.clearRuntimeTargetingState();
|
|
}
|
|
|
|
if (this.runtimeScene === null || !this.sceneReady) {
|
|
return;
|
|
}
|
|
|
|
this.activateDesiredNavigationController();
|
|
}
|
|
|
|
setActiveCameraRigOverride(entityId: string | null) {
|
|
const nextEntityId = entityId === null ? null : entityId.trim() || null;
|
|
|
|
if (this.activeCameraRigOverrideEntityId === nextEntityId) {
|
|
return;
|
|
}
|
|
|
|
this.activeCameraRigOverrideEntityId = nextEntityId;
|
|
}
|
|
|
|
setRuntimeMessageHandler(handler: ((message: string | null) => void) | null) {
|
|
this.runtimeMessageHandler = handler;
|
|
this.audioSystem.setRuntimeMessageHandler(handler);
|
|
}
|
|
|
|
setPlayerControllerTelemetryHandler(
|
|
handler: ((telemetry: PlayerControllerTelemetry | null) => void) | null
|
|
) {
|
|
this.playerControllerTelemetryHandler = handler;
|
|
}
|
|
|
|
setFirstPersonTelemetryHandler(
|
|
handler: ((telemetry: PlayerControllerTelemetry | null) => void) | null
|
|
) {
|
|
this.setPlayerControllerTelemetryHandler(handler);
|
|
}
|
|
|
|
setInteractionPromptHandler(
|
|
handler: ((prompt: RuntimeInteractionPrompt | null) => void) | null
|
|
) {
|
|
this.interactionPromptHandler = handler;
|
|
}
|
|
|
|
setRuntimeDialogueHandler(
|
|
handler: ((dialogue: RuntimeDialogueState | null) => void) | null
|
|
) {
|
|
this.runtimeDialogueHandler = handler;
|
|
|
|
if (handler !== null && this.currentDialogue !== null) {
|
|
handler(this.currentDialogue);
|
|
}
|
|
}
|
|
|
|
setRuntimePauseStateHandler(
|
|
handler: ((state: RuntimePauseState) => void) | null
|
|
) {
|
|
this.runtimePauseStateHandler = handler;
|
|
|
|
if (handler !== null) {
|
|
handler({ ...this.currentPauseState });
|
|
}
|
|
}
|
|
|
|
setManualPause(paused: boolean) {
|
|
this.setManualPauseActive(paused);
|
|
}
|
|
|
|
toggleManualPause() {
|
|
this.setManualPauseActive(!this.manualPauseActive);
|
|
}
|
|
|
|
advanceRuntimeDialogue() {
|
|
if (this.runtimeScene === null || this.currentDialogue === null) {
|
|
return;
|
|
}
|
|
|
|
const npc =
|
|
this.runtimeScene.entities.npcs.find(
|
|
(candidate) => candidate.entityId === this.currentDialogue?.npcEntityId
|
|
) ?? null;
|
|
const dialogue =
|
|
npc?.dialogues.find(
|
|
(candidate) => candidate.id === this.currentDialogue?.dialogueId
|
|
) ?? null;
|
|
|
|
if (dialogue === null) {
|
|
this.setRuntimeDialogue(null);
|
|
return;
|
|
}
|
|
|
|
const nextLineIndex = this.currentDialogue.lineIndex + 1;
|
|
|
|
if (nextLineIndex >= dialogue.lines.length) {
|
|
this.setRuntimeDialogue(null);
|
|
return;
|
|
}
|
|
|
|
this.setRuntimeDialogue(
|
|
this.createRuntimeNpcDialogueState(
|
|
this.currentDialogue.npcEntityId,
|
|
dialogue.id,
|
|
nextLineIndex,
|
|
this.currentDialogue.source
|
|
)
|
|
);
|
|
}
|
|
|
|
closeRuntimeDialogue() {
|
|
this.setRuntimeDialogue(null);
|
|
}
|
|
|
|
setSceneLoadStateHandler(
|
|
handler: ((state: RuntimeSceneLoadState) => void) | null
|
|
) {
|
|
this.sceneLoadStateHandler = handler;
|
|
|
|
if (handler !== null && this.currentSceneLoadState !== null) {
|
|
handler(this.currentSceneLoadState);
|
|
}
|
|
}
|
|
|
|
setRuntimeClockStateHandler(
|
|
handler: ((state: RuntimeClockState) => void) | null
|
|
) {
|
|
this.runtimeClockStateHandler = handler;
|
|
|
|
if (handler !== null && this.currentClockState !== null) {
|
|
handler(cloneRuntimeClockState(this.currentClockState));
|
|
}
|
|
}
|
|
|
|
setSceneTransitionHandler(
|
|
handler: ((request: RuntimeSceneTransitionRequest) => void) | null
|
|
) {
|
|
this.sceneTransitionHandler = handler;
|
|
}
|
|
|
|
dispose() {
|
|
if (this.animationFrame !== 0) {
|
|
cancelAnimationFrame(this.animationFrame);
|
|
this.animationFrame = 0;
|
|
}
|
|
|
|
this.activeController?.deactivate(this.controllerContext);
|
|
this.activeController = null;
|
|
this.resetPlayerCameraEffects();
|
|
this.setInteractionPrompt(null);
|
|
this.clearRuntimeTargetingState();
|
|
this.resizeObserver?.disconnect();
|
|
this.resizeObserver = null;
|
|
this.clearLocalLights();
|
|
this.clearLightVolumes();
|
|
this.clearBrushMeshes();
|
|
this.clearTerrainMeshes();
|
|
this.clearModelRenderObjects();
|
|
this.collisionWorldRequestId += 1;
|
|
this.clearCollisionWorld();
|
|
this.audioSystem.dispose();
|
|
this.advancedRenderingComposer?.dispose();
|
|
this.advancedRenderingComposer = null;
|
|
this.currentAdvancedRenderingSettings = null;
|
|
this.scene.fog = null;
|
|
this.runtimeScheduleSyncContext = null;
|
|
this.currentClockState = null;
|
|
this.lastPublishedClockState = null;
|
|
this.activeScheduledImpulseRoutineIds.clear();
|
|
this.completedScheduledImpulseRoutineIds.clear();
|
|
this.manualPauseActive = false;
|
|
this.controlPauseActive = false;
|
|
this.dialoguePauseActive = false;
|
|
this.previousInteractInputActive = false;
|
|
this.previousClearTargetInputActive = false;
|
|
this.previousPauseInputActive = false;
|
|
this.cameraRigLookDragging = false;
|
|
this.cameraRigLookYawRadians = 0;
|
|
this.cameraRigLookPitchRadians = 0;
|
|
this.activeCameraSourceKey = null;
|
|
this.activeRuntimeCameraRig = null;
|
|
this.activeDialogueAttentionState = null;
|
|
this.dialogueParticipantState = null;
|
|
this.cameraTransitionState = null;
|
|
this.resetRuntimeCameraCollisionSmoothing();
|
|
this.suppressNextCameraSourceTransition = false;
|
|
this.pressedKeys.clear();
|
|
this.publishRuntimePauseState(true);
|
|
if (this.renderer !== null) {
|
|
this.renderer.autoClear = true;
|
|
}
|
|
|
|
for (const cachedTexture of this.materialTextureCache.values()) {
|
|
disposeStarterMaterialTextureSet(cachedTexture.textureSet);
|
|
}
|
|
|
|
this.materialTextureCache.clear();
|
|
this.environmentBlendCache?.dispose();
|
|
this.shaderSkyEnvironmentBlendCache?.dispose();
|
|
this.shaderSkyEnvironmentCache?.dispose();
|
|
this.targetingLuxMesh.geometry.dispose();
|
|
this.targetingLuxMesh.material.dispose();
|
|
this.targetingLuxGlowMesh.geometry.dispose();
|
|
this.targetingLuxGlowMesh.material.dispose();
|
|
this.targetingActiveArrowGeometry.dispose();
|
|
this.targetingActiveArrowMaterial.dispose();
|
|
this.worldBackgroundRenderer.dispose();
|
|
this.renderer?.forceContextLoss();
|
|
this.renderer?.dispose();
|
|
this.domElement.removeEventListener(
|
|
"pointerdown",
|
|
this.handleRuntimePointerDown
|
|
);
|
|
this.domElement.removeEventListener("wheel", this.handleRuntimeWheel);
|
|
window.removeEventListener("keydown", this.handleRuntimeKeyDown);
|
|
window.removeEventListener("keyup", this.handleRuntimeKeyUp);
|
|
window.removeEventListener("pointermove", this.handleRuntimePointerMove);
|
|
window.removeEventListener("pointerup", this.handleRuntimePointerUp);
|
|
window.removeEventListener("blur", this.handleRuntimeBlur);
|
|
this.pressedKeys.clear();
|
|
|
|
if (this.container !== null && this.container.contains(this.domElement)) {
|
|
this.container.removeChild(this.domElement);
|
|
}
|
|
|
|
this.container = null;
|
|
}
|
|
|
|
private publishSceneLoadState(state: RuntimeSceneLoadState) {
|
|
if (
|
|
this.currentSceneLoadState?.status === state.status &&
|
|
this.currentSceneLoadState.message === state.message
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.currentSceneLoadState = state;
|
|
this.sceneLoadStateHandler?.(state);
|
|
}
|
|
|
|
private syncRuntimeClockState(timeSettings: RuntimeSceneDefinition["time"]) {
|
|
this.currentClockState =
|
|
this.currentClockState === null
|
|
? createRuntimeClockState(timeSettings)
|
|
: reconfigureRuntimeClockState(this.currentClockState, timeSettings);
|
|
this.clockPublishAccumulator = 0;
|
|
this.publishRuntimeClockState(true);
|
|
}
|
|
|
|
private publishRuntimeClockState(force = false) {
|
|
if (this.currentClockState === null) {
|
|
return;
|
|
}
|
|
|
|
const nextState = cloneRuntimeClockState(this.currentClockState);
|
|
|
|
if (
|
|
!force &&
|
|
this.lastPublishedClockState !== null &&
|
|
areRuntimeClockStatesEqual(this.lastPublishedClockState, nextState)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.lastPublishedClockState = nextState;
|
|
this.runtimeClockStateHandler?.(cloneRuntimeClockState(nextState));
|
|
}
|
|
|
|
private isRuntimePaused(): boolean {
|
|
return (
|
|
this.manualPauseActive ||
|
|
this.controlPauseActive ||
|
|
this.dialoguePauseActive
|
|
);
|
|
}
|
|
|
|
private publishRuntimePauseState(force = false) {
|
|
const pauseSources: RuntimePauseState["source"][] = [];
|
|
|
|
if (this.manualPauseActive) {
|
|
pauseSources.push("manual");
|
|
}
|
|
|
|
if (this.controlPauseActive) {
|
|
pauseSources.push("control");
|
|
}
|
|
|
|
if (this.dialoguePauseActive) {
|
|
pauseSources.push("dialogue");
|
|
}
|
|
|
|
const nextState: RuntimePauseState = {
|
|
paused: this.isRuntimePaused(),
|
|
source:
|
|
pauseSources.length === 0
|
|
? null
|
|
: pauseSources.length === 1
|
|
? pauseSources[0]
|
|
: "mixed"
|
|
};
|
|
|
|
if (
|
|
!force &&
|
|
this.currentPauseState.paused === nextState.paused &&
|
|
this.currentPauseState.source === nextState.source
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.currentPauseState = nextState;
|
|
|
|
if (nextState.paused) {
|
|
this.setInteractionPrompt(null);
|
|
}
|
|
|
|
this.runtimePauseStateHandler?.({ ...nextState });
|
|
}
|
|
|
|
private setManualPauseActive(paused: boolean) {
|
|
if (this.manualPauseActive === paused) {
|
|
return;
|
|
}
|
|
|
|
this.manualPauseActive = paused;
|
|
this.publishRuntimePauseState();
|
|
}
|
|
|
|
private setControlPauseActive(paused: boolean) {
|
|
if (this.controlPauseActive === paused) {
|
|
return;
|
|
}
|
|
|
|
this.controlPauseActive = paused;
|
|
this.publishRuntimePauseState();
|
|
}
|
|
|
|
private setDialoguePauseActive(paused: boolean) {
|
|
if (this.dialoguePauseActive === paused) {
|
|
return;
|
|
}
|
|
|
|
this.dialoguePauseActive = paused;
|
|
this.publishRuntimePauseState();
|
|
}
|
|
|
|
private activateDesiredNavigationController() {
|
|
if (this.runtimeScene === null || !this.sceneReady) {
|
|
return;
|
|
}
|
|
|
|
const nextController =
|
|
this.desiredNavigationMode === "firstPerson"
|
|
? this.firstPersonController
|
|
: this.thirdPersonController;
|
|
|
|
if (this.activeController?.id === nextController.id) {
|
|
return;
|
|
}
|
|
|
|
this.activeController?.deactivate(this.controllerContext);
|
|
this.interactionSystem.reset();
|
|
this.setInteractionPrompt(null);
|
|
if (nextController === this.firstPersonController) {
|
|
this.clearRuntimeTargetingState();
|
|
}
|
|
this.activeController = nextController;
|
|
this.activeController.activate(this.controllerContext);
|
|
}
|
|
|
|
private resolveRuntimeEntityPositionById(entityId: string) {
|
|
if (this.runtimeScene === null) {
|
|
return null;
|
|
}
|
|
|
|
const playerStart =
|
|
this.runtimeScene.entities.playerStarts.find(
|
|
(candidate) => candidate.entityId === entityId
|
|
) ?? null;
|
|
|
|
if (playerStart !== null) {
|
|
return playerStart.position;
|
|
}
|
|
|
|
const sceneEntry =
|
|
this.runtimeScene.entities.sceneEntries.find(
|
|
(candidate) => candidate.entityId === entityId
|
|
) ?? null;
|
|
|
|
if (sceneEntry !== null) {
|
|
return sceneEntry.position;
|
|
}
|
|
|
|
const npc =
|
|
this.runtimeScene.npcDefinitions.find(
|
|
(candidate) => candidate.entityId === entityId
|
|
) ?? null;
|
|
|
|
if (npc !== null) {
|
|
return npc.position;
|
|
}
|
|
|
|
const soundEmitter =
|
|
this.runtimeScene.entities.soundEmitters.find(
|
|
(candidate) => candidate.entityId === entityId
|
|
) ?? null;
|
|
|
|
if (soundEmitter !== null) {
|
|
return soundEmitter.position;
|
|
}
|
|
|
|
const triggerVolume =
|
|
this.runtimeScene.entities.triggerVolumes.find(
|
|
(candidate) => candidate.entityId === entityId
|
|
) ?? null;
|
|
|
|
if (triggerVolume !== null) {
|
|
return triggerVolume.position;
|
|
}
|
|
|
|
const teleportTarget =
|
|
this.runtimeScene.entities.teleportTargets.find(
|
|
(candidate) => candidate.entityId === entityId
|
|
) ?? null;
|
|
|
|
if (teleportTarget !== null) {
|
|
return teleportTarget.position;
|
|
}
|
|
|
|
const interactable =
|
|
this.runtimeScene.entities.interactables.find(
|
|
(candidate) => candidate.entityId === entityId
|
|
) ?? null;
|
|
|
|
if (interactable !== null) {
|
|
return interactable.position;
|
|
}
|
|
|
|
const pointLight =
|
|
this.runtimeScene.localLights.pointLights.find(
|
|
(candidate) => candidate.entityId === entityId
|
|
) ?? null;
|
|
|
|
if (pointLight !== null) {
|
|
return pointLight.position;
|
|
}
|
|
|
|
const spotLight =
|
|
this.runtimeScene.localLights.spotLights.find(
|
|
(candidate) => candidate.entityId === entityId
|
|
) ?? null;
|
|
|
|
if (spotLight !== null) {
|
|
return spotLight.position;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private resolveDialogueAttentionNpc() {
|
|
if (this.runtimeScene === null || this.currentDialogue === null) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
this.runtimeScene.entities.npcs.find(
|
|
(candidate) => candidate.entityId === this.currentDialogue?.npcEntityId
|
|
) ?? null
|
|
);
|
|
}
|
|
|
|
private resolveDialogueAttentionPlayerFocusPoint() {
|
|
if (this.runtimeScene === null) {
|
|
return null;
|
|
}
|
|
|
|
const eyePosition = this.currentPlayerControllerTelemetry?.eyePosition ?? {
|
|
x: this.runtimeScene.spawn.position.x,
|
|
y:
|
|
this.runtimeScene.spawn.position.y +
|
|
this.runtimeScene.playerCollider.eyeHeight,
|
|
z: this.runtimeScene.spawn.position.z
|
|
};
|
|
const feetPosition =
|
|
this.currentPlayerControllerTelemetry?.feetPosition ??
|
|
this.runtimeScene.spawn.position;
|
|
|
|
return {
|
|
x: feetPosition.x + (eyePosition.x - feetPosition.x) * 0.5,
|
|
y:
|
|
feetPosition.y +
|
|
(eyePosition.y - feetPosition.y) *
|
|
DIALOGUE_ATTENTION_PLAYER_FOCUS_HEIGHT_FACTOR,
|
|
z: feetPosition.z + (eyePosition.z - feetPosition.z) * 0.5
|
|
};
|
|
}
|
|
|
|
private resolveDialogueAttentionNpcFocusPoint(npc: RuntimeNpc) {
|
|
return {
|
|
x: npc.position.x,
|
|
y:
|
|
npc.position.y +
|
|
npc.collider.eyeHeight * DIALOGUE_ATTENTION_NPC_FOCUS_HEIGHT_FACTOR,
|
|
z: npc.position.z
|
|
};
|
|
}
|
|
|
|
private resolveDialoguePlayerFeetPosition() {
|
|
if (this.runtimeScene === null) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
this.currentPlayerControllerTelemetry?.feetPosition ??
|
|
this.runtimeScene.playerStart?.position ??
|
|
this.runtimeScene.spawn.position
|
|
);
|
|
}
|
|
|
|
private resolveDialoguePlayerYawDegrees() {
|
|
if (this.currentPlayerControllerTelemetry !== null) {
|
|
return this.currentPlayerControllerTelemetry.yawDegrees;
|
|
}
|
|
|
|
this.camera.getWorldDirection(this.cameraForward);
|
|
return (
|
|
(Math.atan2(this.cameraForward.x, this.cameraForward.z) * 180) / Math.PI
|
|
);
|
|
}
|
|
|
|
private resolvePlayerShapeHorizontalRadius() {
|
|
if (this.runtimeScene === null) {
|
|
return 0;
|
|
}
|
|
|
|
const playerShape = this.runtimeScene.playerCollider;
|
|
|
|
switch (playerShape.mode) {
|
|
case "capsule":
|
|
return playerShape.radius;
|
|
case "box":
|
|
return Math.max(playerShape.size.x, playerShape.size.z) * 0.5;
|
|
case "none":
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
private resolveNpcShapeHorizontalRadius(npc: RuntimeNpc) {
|
|
switch (npc.collider.mode) {
|
|
case "capsule":
|
|
return npc.collider.radius;
|
|
case "box":
|
|
return Math.max(npc.collider.size.x, npc.collider.size.z) * 0.5;
|
|
case "none":
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
private resolveYawDegreesTowards(
|
|
from: { x: number; z: number },
|
|
to: { x: number; z: number }
|
|
) {
|
|
return (Math.atan2(to.x - from.x, to.z - from.z) * 180) / Math.PI;
|
|
}
|
|
|
|
private resolveDialogueParticipantPlayerFeetPosition(
|
|
state: RuntimeDialogueParticipantState
|
|
) {
|
|
if (state.playerPositionBlendDurationSeconds <= 0) {
|
|
return state.playerTargetFeetPosition;
|
|
}
|
|
|
|
const blendT = smoothStep01(
|
|
state.playerPositionBlendElapsedSeconds /
|
|
state.playerPositionBlendDurationSeconds
|
|
);
|
|
|
|
return {
|
|
x: lerpScalar(
|
|
state.playerStartFeetPosition.x,
|
|
state.playerTargetFeetPosition.x,
|
|
blendT
|
|
),
|
|
y: lerpScalar(
|
|
state.playerStartFeetPosition.y,
|
|
state.playerTargetFeetPosition.y,
|
|
blendT
|
|
),
|
|
z: lerpScalar(
|
|
state.playerStartFeetPosition.z,
|
|
state.playerTargetFeetPosition.z,
|
|
blendT
|
|
)
|
|
};
|
|
}
|
|
|
|
private isDialogueAttentionCameraReady(npcEntityId: string) {
|
|
const state = this.dialogueParticipantState;
|
|
|
|
if (state === null || state.npcEntityId !== npcEntityId) {
|
|
return true;
|
|
}
|
|
|
|
return (
|
|
state.playerPositionBlendDurationSeconds <= 0 ||
|
|
state.playerPositionBlendElapsedSeconds >=
|
|
state.playerPositionBlendDurationSeconds - 1e-4
|
|
);
|
|
}
|
|
|
|
private resolveDialogueParticipantState(
|
|
npc: RuntimeNpc
|
|
): RuntimeDialogueParticipantState | null {
|
|
if (this.runtimeScene === null) {
|
|
return null;
|
|
}
|
|
|
|
const playerFeetPosition = this.resolveDialoguePlayerFeetPosition();
|
|
|
|
if (playerFeetPosition === null) {
|
|
return null;
|
|
}
|
|
|
|
const currentPlayerYawDegrees = this.resolveDialoguePlayerYawDegrees();
|
|
const minimumCenterDistance =
|
|
this.resolvePlayerShapeHorizontalRadius() +
|
|
this.resolveNpcShapeHorizontalRadius(npc) +
|
|
DIALOGUE_PARTICIPANT_MIN_SURFACE_DISTANCE;
|
|
const offsetX = playerFeetPosition.x - npc.position.x;
|
|
const offsetZ = playerFeetPosition.z - npc.position.z;
|
|
const currentHorizontalDistance = Math.hypot(offsetX, offsetZ);
|
|
let directionX = offsetX;
|
|
let directionZ = offsetZ;
|
|
|
|
if (currentHorizontalDistance <= 1e-4) {
|
|
const fallbackYawRadians = (npc.yawDegrees * Math.PI) / 180;
|
|
directionX = -Math.sin(fallbackYawRadians);
|
|
directionZ = -Math.cos(fallbackYawRadians);
|
|
}
|
|
|
|
const directionLength = Math.hypot(directionX, directionZ);
|
|
const normalizedDirectionX =
|
|
directionLength <= 1e-4 ? 0 : directionX / directionLength;
|
|
const normalizedDirectionZ =
|
|
directionLength <= 1e-4 ? -1 : directionZ / directionLength;
|
|
const desiredHorizontalDistance = Math.max(
|
|
currentHorizontalDistance,
|
|
minimumCenterDistance
|
|
);
|
|
const desiredFeetPosition = {
|
|
x: npc.position.x + normalizedDirectionX * desiredHorizontalDistance,
|
|
y: playerFeetPosition.y,
|
|
z: npc.position.z + normalizedDirectionZ * desiredHorizontalDistance
|
|
};
|
|
let targetFeetPosition = desiredFeetPosition;
|
|
|
|
if (
|
|
currentHorizontalDistance < desiredHorizontalDistance - 1e-4 &&
|
|
this.collisionWorld !== null &&
|
|
this.runtimeScene.playerCollider.mode !== "none"
|
|
) {
|
|
for (let step = 1; step <= 8; step += 1) {
|
|
const t = step / 8;
|
|
const candidate = {
|
|
x:
|
|
playerFeetPosition.x +
|
|
(desiredFeetPosition.x - playerFeetPosition.x) * t,
|
|
y: playerFeetPosition.y,
|
|
z:
|
|
playerFeetPosition.z +
|
|
(desiredFeetPosition.z - playerFeetPosition.z) * t
|
|
};
|
|
|
|
if (
|
|
this.collisionWorld.canOccupyPlayerShape(
|
|
candidate,
|
|
this.runtimeScene.playerCollider
|
|
)
|
|
) {
|
|
targetFeetPosition = candidate;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
const playerTargetYawDegrees = this.resolveYawDegreesTowards(
|
|
{
|
|
x: targetFeetPosition.x,
|
|
z: targetFeetPosition.z
|
|
},
|
|
{
|
|
x: npc.position.x,
|
|
z: npc.position.z
|
|
}
|
|
);
|
|
const npcTargetYawDegrees = this.resolveYawDegreesTowards(
|
|
{
|
|
x: npc.position.x,
|
|
z: npc.position.z
|
|
},
|
|
{
|
|
x: targetFeetPosition.x,
|
|
z: targetFeetPosition.z
|
|
}
|
|
);
|
|
|
|
return {
|
|
npcEntityId: npc.entityId,
|
|
npcCurrentYawDegrees: npc.yawDegrees,
|
|
npcTargetYawDegrees,
|
|
npcRestoreYawDegrees: npc.yawDegrees,
|
|
playerStartFeetPosition: {
|
|
...playerFeetPosition
|
|
},
|
|
playerTargetFeetPosition: targetFeetPosition,
|
|
playerPositionBlendElapsedSeconds: 0,
|
|
playerPositionBlendDurationSeconds:
|
|
currentHorizontalDistance < desiredHorizontalDistance - 1e-4
|
|
? DIALOGUE_PARTICIPANT_PUSHBACK_DURATION_SECONDS
|
|
: 0,
|
|
playerCurrentYawDegrees: currentPlayerYawDegrees,
|
|
playerTargetYawDegrees
|
|
};
|
|
}
|
|
|
|
private syncNpcRenderGroupTransform(renderGroup: Group, npc: RuntimeNpc) {
|
|
renderGroup.position.set(npc.position.x, npc.position.y, npc.position.z);
|
|
const facingGroup = renderGroup.getObjectByName("npcFacingGroup");
|
|
|
|
if (facingGroup !== undefined) {
|
|
renderGroup.rotation.set(0, 0, 0);
|
|
facingGroup.rotation.set(0, (npc.yawDegrees * Math.PI) / 180, 0);
|
|
return;
|
|
}
|
|
|
|
renderGroup.rotation.set(0, (npc.yawDegrees * Math.PI) / 180, 0);
|
|
}
|
|
|
|
private setRuntimeNpcYawDegrees(entityId: string, yawDegrees: number) {
|
|
if (this.runtimeScene === null) {
|
|
return;
|
|
}
|
|
|
|
const npc =
|
|
this.runtimeScene.entities.npcs.find(
|
|
(candidate) => candidate.entityId === entityId
|
|
) ?? null;
|
|
|
|
if (npc === null) {
|
|
return;
|
|
}
|
|
|
|
npc.yawDegrees = normalizeDegrees(yawDegrees);
|
|
const renderGroup = this.modelRenderObjects.get(entityId);
|
|
|
|
if (renderGroup !== undefined) {
|
|
this.syncNpcRenderGroupTransform(renderGroup, npc);
|
|
}
|
|
}
|
|
|
|
private updateRuntimeDialogueParticipants(dt: number) {
|
|
if (this.runtimeScene === null || this.dialogueParticipantState === null) {
|
|
return;
|
|
}
|
|
|
|
const state = this.dialogueParticipantState;
|
|
const dialogueActive =
|
|
this.currentDialogue !== null &&
|
|
this.currentDialogue.npcEntityId === state.npcEntityId;
|
|
const npc =
|
|
this.runtimeScene.entities.npcs.find(
|
|
(candidate) => candidate.entityId === state.npcEntityId
|
|
) ?? null;
|
|
|
|
if (npc === null) {
|
|
this.dialogueParticipantState = null;
|
|
return;
|
|
}
|
|
|
|
if (dialogueActive) {
|
|
state.npcTargetYawDegrees = this.resolveYawDegreesTowards(
|
|
{
|
|
x: npc.position.x,
|
|
z: npc.position.z
|
|
},
|
|
{
|
|
x: state.playerTargetFeetPosition.x,
|
|
z: state.playerTargetFeetPosition.z
|
|
}
|
|
);
|
|
state.playerPositionBlendElapsedSeconds = Math.min(
|
|
state.playerPositionBlendDurationSeconds,
|
|
state.playerPositionBlendElapsedSeconds + dt
|
|
);
|
|
const playerFeetPosition =
|
|
this.resolveDialogueParticipantPlayerFeetPosition(state);
|
|
state.playerTargetYawDegrees = this.resolveYawDegreesTowards(
|
|
{
|
|
x: playerFeetPosition.x,
|
|
z: playerFeetPosition.z
|
|
},
|
|
{
|
|
x: npc.position.x,
|
|
z: npc.position.z
|
|
}
|
|
);
|
|
state.playerCurrentYawDegrees = dampAngleDegrees(
|
|
state.playerCurrentYawDegrees,
|
|
state.playerTargetYawDegrees,
|
|
DIALOGUE_PARTICIPANT_YAW_BLEND_RATE,
|
|
dt
|
|
);
|
|
state.npcCurrentYawDegrees = dampAngleDegrees(
|
|
state.npcCurrentYawDegrees,
|
|
state.npcTargetYawDegrees,
|
|
DIALOGUE_PARTICIPANT_YAW_BLEND_RATE,
|
|
dt
|
|
);
|
|
this.applyTeleportPlayerAction({
|
|
position: playerFeetPosition,
|
|
yawDegrees: state.playerCurrentYawDegrees
|
|
});
|
|
this.setRuntimeNpcYawDegrees(
|
|
state.npcEntityId,
|
|
state.npcCurrentYawDegrees
|
|
);
|
|
return;
|
|
}
|
|
|
|
state.npcCurrentYawDegrees = dampAngleDegrees(
|
|
state.npcCurrentYawDegrees,
|
|
state.npcRestoreYawDegrees,
|
|
DIALOGUE_PARTICIPANT_YAW_BLEND_RATE,
|
|
dt
|
|
);
|
|
this.setRuntimeNpcYawDegrees(state.npcEntityId, state.npcCurrentYawDegrees);
|
|
|
|
if (
|
|
Math.abs(
|
|
resolveShortestAngleDeltaDegrees(
|
|
state.npcCurrentYawDegrees,
|
|
state.npcRestoreYawDegrees
|
|
)
|
|
) <= DIALOGUE_PARTICIPANT_RESTORE_EPSILON_DEGREES
|
|
) {
|
|
this.setRuntimeNpcYawDegrees(
|
|
state.npcEntityId,
|
|
state.npcRestoreYawDegrees
|
|
);
|
|
this.dialogueParticipantState = null;
|
|
}
|
|
}
|
|
|
|
private resolveRuntimeCameraRigTargetPosition(rig: RuntimeCameraRig) {
|
|
if (this.runtimeScene === null) {
|
|
return null;
|
|
}
|
|
|
|
switch (rig.target.kind) {
|
|
case "player":
|
|
return (
|
|
this.currentPlayerControllerTelemetry?.feetPosition ??
|
|
this.runtimeScene.playerStart?.position ??
|
|
this.runtimeScene.spawn.position
|
|
);
|
|
case "actor": {
|
|
const target = rig.target;
|
|
const activeNpc =
|
|
this.runtimeScene.npcDefinitions.find(
|
|
(candidate) =>
|
|
candidate.actorId === target.actorId && candidate.active
|
|
) ??
|
|
this.runtimeScene.npcDefinitions.find(
|
|
(candidate) => candidate.actorId === target.actorId
|
|
) ??
|
|
null;
|
|
|
|
return activeNpc?.position ?? null;
|
|
}
|
|
case "entity":
|
|
return this.resolveRuntimeEntityPositionById(rig.target.entityId);
|
|
case "worldPoint":
|
|
return rig.target.point;
|
|
}
|
|
}
|
|
|
|
private resolveRuntimeCameraRigPosition(rig: RuntimeCameraRig) {
|
|
if (this.runtimeScene === null) {
|
|
return null;
|
|
}
|
|
|
|
switch (rig.rigType) {
|
|
case "fixed":
|
|
return rig.position;
|
|
case "rail": {
|
|
const path =
|
|
this.runtimeScene.paths.find(
|
|
(candidate) => candidate.id === rig.pathId
|
|
) ?? null;
|
|
|
|
if (path === null) {
|
|
return null;
|
|
}
|
|
|
|
const targetPosition = this.resolveRuntimeCameraRigTargetPosition(rig);
|
|
|
|
if (targetPosition === null) {
|
|
return null;
|
|
}
|
|
|
|
if (rig.railPlacementMode === "mapTargetBetweenPoints") {
|
|
const mappedProgress = mapWorldPointToScenePathProgressBetweenPoints({
|
|
point: targetPosition,
|
|
trackStartPoint: rig.trackStartPoint,
|
|
trackEndPoint: rig.trackEndPoint,
|
|
railStartProgress: rig.railStartProgress,
|
|
railEndProgress: rig.railEndProgress
|
|
});
|
|
|
|
return sampleResolvedScenePathPosition(
|
|
path,
|
|
mappedProgress.railProgress
|
|
);
|
|
}
|
|
|
|
return resolveNearestPointOnResolvedScenePath(path, targetPosition)
|
|
.position;
|
|
}
|
|
}
|
|
}
|
|
|
|
private resolveRuntimeCameraRigLookTarget(rig: RuntimeCameraRig) {
|
|
const targetPosition = this.resolveRuntimeCameraRigTargetPosition(rig);
|
|
|
|
if (targetPosition === null) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
x: targetPosition.x + rig.targetOffset.x,
|
|
y: targetPosition.y + rig.targetOffset.y,
|
|
z: targetPosition.z + rig.targetOffset.z
|
|
};
|
|
}
|
|
|
|
private resolveActiveRuntimeCameraRig() {
|
|
if (this.runtimeScene === null) {
|
|
return null;
|
|
}
|
|
|
|
const cameraRigs = this.runtimeScene.entities.cameraRigs;
|
|
|
|
if (cameraRigs.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
if (this.activeCameraRigOverrideEntityId !== null) {
|
|
return (
|
|
cameraRigs.find(
|
|
(candidate) =>
|
|
candidate.entityId === this.activeCameraRigOverrideEntityId
|
|
) ?? null
|
|
);
|
|
}
|
|
|
|
const eligibleCameraRigs = cameraRigs.filter(
|
|
(candidate) => candidate.defaultActive
|
|
);
|
|
|
|
if (eligibleCameraRigs.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return [...eligibleCameraRigs].sort(
|
|
(left, right) =>
|
|
right.priority - left.priority ||
|
|
left.entityId.localeCompare(right.entityId)
|
|
)[0]!;
|
|
}
|
|
|
|
private updateRuntimeCameraRigLookState(rig: RuntimeCameraRig, dt: number) {
|
|
if (this.runtimeScene === null) {
|
|
return;
|
|
}
|
|
|
|
if (rig.lookAround.enabled) {
|
|
const lookInput = resolvePlayerStartLookInput(
|
|
this.runtimeScene.playerInputBindings
|
|
);
|
|
|
|
if (lookInput.horizontal !== 0 || lookInput.vertical !== 0) {
|
|
this.cameraRigLookYawRadians -=
|
|
lookInput.horizontal * CAMERA_RIG_GAMEPAD_LOOK_SPEED * dt;
|
|
this.cameraRigLookPitchRadians = clampScalar(
|
|
this.cameraRigLookPitchRadians -
|
|
lookInput.vertical * CAMERA_RIG_GAMEPAD_LOOK_SPEED * dt,
|
|
(-rig.lookAround.pitchLimitDegrees * Math.PI) / 180,
|
|
(rig.lookAround.pitchLimitDegrees * Math.PI) / 180
|
|
);
|
|
}
|
|
|
|
this.cameraRigLookYawRadians = clampScalar(
|
|
this.cameraRigLookYawRadians,
|
|
(-rig.lookAround.yawLimitDegrees * Math.PI) / 180,
|
|
(rig.lookAround.yawLimitDegrees * Math.PI) / 180
|
|
);
|
|
}
|
|
|
|
const recenterRate =
|
|
rig.lookAround.enabled && !this.cameraRigLookDragging
|
|
? rig.lookAround.recenterSpeed
|
|
: rig.lookAround.enabled
|
|
? 0
|
|
: Math.max(8, rig.lookAround.recenterSpeed);
|
|
|
|
this.cameraRigLookYawRadians = dampScalar(
|
|
this.cameraRigLookYawRadians,
|
|
0,
|
|
recenterRate,
|
|
dt
|
|
);
|
|
this.cameraRigLookPitchRadians = dampScalar(
|
|
this.cameraRigLookPitchRadians,
|
|
0,
|
|
recenterRate,
|
|
dt
|
|
);
|
|
}
|
|
|
|
private syncCameraRigTelemetryHooks() {
|
|
const telemetry = this.currentPlayerControllerTelemetry;
|
|
|
|
if (telemetry === null) {
|
|
this.currentPlayerAudioHooks = null;
|
|
return;
|
|
}
|
|
|
|
const cameraVolumeState = this.resolvePlayerVolumeState({
|
|
x: this.camera.position.x,
|
|
y: this.camera.position.y,
|
|
z: this.camera.position.z
|
|
});
|
|
const cameraSubmerged =
|
|
cameraVolumeState.inWater &&
|
|
cameraVolumeState.waterSurfaceHeight !== null &&
|
|
this.camera.position.y < cameraVolumeState.waterSurfaceHeight;
|
|
const hooks = resolveRuntimePlayerMovementHooks({
|
|
locomotionState: telemetry.locomotionState,
|
|
inWaterVolume: telemetry.inWaterVolume,
|
|
cameraSubmerged,
|
|
signals: telemetry.signals
|
|
});
|
|
const nextTelemetry: PlayerControllerTelemetry = {
|
|
...telemetry,
|
|
cameraSubmerged,
|
|
hooks
|
|
};
|
|
|
|
this.currentPlayerControllerTelemetry = nextTelemetry;
|
|
this.currentPlayerAudioHooks = hooks.audio;
|
|
this.playerControllerTelemetryHandler?.(nextTelemetry);
|
|
}
|
|
|
|
private captureCurrentCameraPose(): RuntimeCameraPose {
|
|
const position = this.camera.position.clone();
|
|
const lookTarget = position
|
|
.clone()
|
|
.add(this.camera.getWorldDirection(this.cameraForward));
|
|
|
|
return {
|
|
position,
|
|
lookTarget
|
|
};
|
|
}
|
|
|
|
private resetRuntimeCameraCollisionSmoothing() {
|
|
this.smoothedRuntimeCameraCollisionDistance = null;
|
|
}
|
|
|
|
private smoothRuntimeCameraCollisionPosition(
|
|
pivot: { x: number; y: number; z: number },
|
|
desiredPosition: Vector3,
|
|
resolvedPosition: { x: number; y: number; z: number },
|
|
dt: number
|
|
): Vector3 {
|
|
this.cameraCollisionDirection.set(
|
|
desiredPosition.x - pivot.x,
|
|
desiredPosition.y - pivot.y,
|
|
desiredPosition.z - pivot.z
|
|
);
|
|
|
|
const desiredDistance = this.cameraCollisionDirection.length();
|
|
|
|
if (desiredDistance <= CAMERA_COLLISION_DISTANCE_EPSILON) {
|
|
this.resetRuntimeCameraCollisionSmoothing();
|
|
return new Vector3(
|
|
resolvedPosition.x,
|
|
resolvedPosition.y,
|
|
resolvedPosition.z
|
|
);
|
|
}
|
|
|
|
const resolvedDistance = Math.hypot(
|
|
resolvedPosition.x - pivot.x,
|
|
resolvedPosition.y - pivot.y,
|
|
resolvedPosition.z - pivot.z
|
|
);
|
|
const previousDistance = this.smoothedRuntimeCameraCollisionDistance;
|
|
const nextDistance =
|
|
previousDistance === null ||
|
|
dt <= 0 ||
|
|
resolvedDistance < previousDistance
|
|
? resolvedDistance
|
|
: dampScalar(
|
|
previousDistance,
|
|
resolvedDistance,
|
|
CAMERA_COLLISION_RECOVERY_SPEED,
|
|
dt
|
|
);
|
|
const clampedDistance = Math.min(
|
|
Math.max(0, nextDistance),
|
|
Math.min(resolvedDistance, desiredDistance)
|
|
);
|
|
|
|
this.smoothedRuntimeCameraCollisionDistance = clampedDistance;
|
|
this.cameraCollisionDirection.normalize().multiplyScalar(clampedDistance);
|
|
|
|
return new Vector3(
|
|
pivot.x + this.cameraCollisionDirection.x,
|
|
pivot.y + this.cameraCollisionDirection.y,
|
|
pivot.z + this.cameraCollisionDirection.z
|
|
);
|
|
}
|
|
|
|
private resolveCollisionAdjustedCameraPose(
|
|
pose: RuntimeCameraPose,
|
|
dt = 0
|
|
): RuntimeCameraPose {
|
|
if (
|
|
pose.collisionPivot === undefined ||
|
|
pose.collisionPivot === null ||
|
|
pose.collisionRadius === undefined ||
|
|
pose.collisionRadius === null
|
|
) {
|
|
this.resetRuntimeCameraCollisionSmoothing();
|
|
return pose;
|
|
}
|
|
|
|
const resolvedPosition =
|
|
this.collisionWorld?.resolveThirdPersonCameraCollision(
|
|
{
|
|
x: pose.collisionPivot.x,
|
|
y: pose.collisionPivot.y,
|
|
z: pose.collisionPivot.z
|
|
},
|
|
{
|
|
x: pose.position.x,
|
|
y: pose.position.y,
|
|
z: pose.position.z
|
|
},
|
|
pose.collisionRadius
|
|
);
|
|
|
|
if (resolvedPosition === undefined) {
|
|
this.resetRuntimeCameraCollisionSmoothing();
|
|
return pose;
|
|
}
|
|
|
|
return {
|
|
...pose,
|
|
position: this.smoothRuntimeCameraCollisionPosition(
|
|
pose.collisionPivot,
|
|
pose.position,
|
|
resolvedPosition,
|
|
dt
|
|
)
|
|
};
|
|
}
|
|
|
|
private applyCameraPose(pose: RuntimeCameraPose, dt = 0) {
|
|
const resolvedPose = this.resolveCollisionAdjustedCameraPose(pose, dt);
|
|
|
|
this.camera.position.copy(resolvedPose.position);
|
|
this.camera.lookAt(resolvedPose.lookTarget);
|
|
}
|
|
|
|
private isActiveExternalCameraSource() {
|
|
return (
|
|
this.activeCameraSourceKey !== null &&
|
|
this.activeCameraSourceKey !== "gameplay"
|
|
);
|
|
}
|
|
|
|
private resolveRuntimeCameraRigPose(
|
|
rig: RuntimeCameraRig,
|
|
dt: number
|
|
): RuntimeCameraPose | null {
|
|
const nextLookTarget = this.resolveRuntimeCameraRigLookTarget(rig);
|
|
const nextPosition = this.resolveRuntimeCameraRigPosition(rig);
|
|
|
|
if (nextLookTarget === null || nextPosition === null) {
|
|
return null;
|
|
}
|
|
|
|
this.updateRuntimeCameraRigLookState(rig, dt);
|
|
|
|
const authoredPosition = new Vector3(
|
|
nextPosition.x,
|
|
nextPosition.y,
|
|
nextPosition.z
|
|
);
|
|
this.cameraRigLookTarget.set(
|
|
nextLookTarget.x,
|
|
nextLookTarget.y,
|
|
nextLookTarget.z
|
|
);
|
|
this.cameraRigDirection
|
|
.subVectors(this.cameraRigLookTarget, authoredPosition)
|
|
.normalize();
|
|
|
|
if (this.cameraRigDirection.lengthSq() <= 1e-8) {
|
|
this.cameraRigDirection.set(0, 0, 1);
|
|
}
|
|
|
|
const baseYawRadians = Math.atan2(
|
|
this.cameraRigDirection.x,
|
|
this.cameraRigDirection.z
|
|
);
|
|
const basePitchRadians = Math.asin(
|
|
clampScalar(this.cameraRigDirection.y, -1, 1)
|
|
);
|
|
const lookYawRadians = baseYawRadians + this.cameraRigLookYawRadians;
|
|
const lookPitchRadians = clampScalar(
|
|
basePitchRadians + this.cameraRigLookPitchRadians,
|
|
-Math.PI * 0.49,
|
|
Math.PI * 0.49
|
|
);
|
|
const lookDirection = new Vector3(
|
|
Math.sin(lookYawRadians) * Math.cos(lookPitchRadians),
|
|
Math.sin(lookPitchRadians),
|
|
Math.cos(lookYawRadians) * Math.cos(lookPitchRadians)
|
|
);
|
|
|
|
return {
|
|
position: authoredPosition,
|
|
lookTarget: authoredPosition.clone().add(lookDirection)
|
|
};
|
|
}
|
|
|
|
private createRuntimeCameraSourceKey(
|
|
source: RuntimeResolvedCameraSource
|
|
): RuntimeCameraSourceKey {
|
|
switch (source.kind) {
|
|
case "gameplay":
|
|
return "gameplay";
|
|
case "rig":
|
|
return `rig:${source.rig.entityId}`;
|
|
case "dialogue":
|
|
return `dialogue:${source.state.npcEntityId}`;
|
|
}
|
|
}
|
|
|
|
private resolveDialogueAttentionCameraPose(
|
|
referenceCameraPose: RuntimeCameraPose
|
|
): RuntimeCameraPose | null {
|
|
const npc = this.resolveDialogueAttentionNpc();
|
|
const playerFocusPoint = this.resolveDialogueAttentionPlayerFocusPoint();
|
|
|
|
if (npc === null || playerFocusPoint === null) {
|
|
return null;
|
|
}
|
|
|
|
const solution = resolveDialogueAttentionCameraSolution({
|
|
playerFocusPoint,
|
|
npcFocusPoint: this.resolveDialogueAttentionNpcFocusPoint(npc),
|
|
referenceCameraPosition: {
|
|
x: referenceCameraPose.position.x,
|
|
y: referenceCameraPose.position.y,
|
|
z: referenceCameraPose.position.z
|
|
},
|
|
referenceLookTarget: {
|
|
x: referenceCameraPose.lookTarget.x,
|
|
y: referenceCameraPose.lookTarget.y,
|
|
z: referenceCameraPose.lookTarget.z
|
|
},
|
|
previousSideSign:
|
|
this.activeDialogueAttentionState?.npcEntityId === npc.entityId
|
|
? this.activeDialogueAttentionState.sideSign
|
|
: null,
|
|
cameraVerticalFovRadians: (this.camera.fov * Math.PI) / 180,
|
|
cameraAspect: this.camera.aspect
|
|
});
|
|
|
|
this.activeDialogueAttentionState = {
|
|
npcEntityId: npc.entityId,
|
|
sideSign: solution.sideSign
|
|
};
|
|
|
|
return {
|
|
position: new Vector3(
|
|
solution.position.x,
|
|
solution.position.y,
|
|
solution.position.z
|
|
),
|
|
lookTarget: new Vector3(
|
|
solution.lookTarget.x,
|
|
solution.lookTarget.y,
|
|
solution.lookTarget.z
|
|
),
|
|
collisionPivot: new Vector3(
|
|
solution.pivot.x,
|
|
solution.pivot.y,
|
|
solution.pivot.z
|
|
),
|
|
collisionRadius: THIRD_PERSON_CAMERA_COLLISION_RADIUS
|
|
};
|
|
}
|
|
|
|
private resolveActiveRuntimeCameraSource(): RuntimeResolvedCameraSource {
|
|
const nextRig = this.resolveActiveRuntimeCameraRig();
|
|
|
|
if (nextRig !== null) {
|
|
return {
|
|
kind: "rig",
|
|
rig: nextRig
|
|
};
|
|
}
|
|
|
|
const dialogueNpc = this.resolveDialogueAttentionNpc();
|
|
|
|
if (dialogueNpc !== null) {
|
|
if (!this.isDialogueAttentionCameraReady(dialogueNpc.entityId)) {
|
|
return {
|
|
kind: "gameplay"
|
|
};
|
|
}
|
|
|
|
return {
|
|
kind: "dialogue",
|
|
state:
|
|
this.activeDialogueAttentionState?.npcEntityId ===
|
|
dialogueNpc.entityId
|
|
? this.activeDialogueAttentionState
|
|
: {
|
|
npcEntityId: dialogueNpc.entityId,
|
|
sideSign: 1
|
|
}
|
|
};
|
|
}
|
|
|
|
return {
|
|
kind: "gameplay"
|
|
};
|
|
}
|
|
|
|
private resolveRuntimeCameraSourcePose(
|
|
source: RuntimeResolvedCameraSource,
|
|
dt: number,
|
|
referenceCameraPose: RuntimeCameraPose
|
|
): RuntimeCameraPose | null {
|
|
switch (source.kind) {
|
|
case "gameplay":
|
|
return this.captureCurrentCameraPose();
|
|
case "rig":
|
|
return this.resolveRuntimeCameraRigPose(source.rig, dt);
|
|
case "dialogue":
|
|
return this.resolveDialogueAttentionCameraPose(referenceCameraPose);
|
|
}
|
|
}
|
|
|
|
private resolveRuntimeCameraTransitionSettings(
|
|
previousSource: RuntimeResolvedCameraSource,
|
|
nextSource: RuntimeResolvedCameraSource
|
|
) {
|
|
if (this.suppressNextCameraSourceTransition) {
|
|
return {
|
|
mode: "cut" as const,
|
|
durationSeconds: 0
|
|
};
|
|
}
|
|
|
|
const transitionRig =
|
|
nextSource.kind === "rig"
|
|
? nextSource.rig
|
|
: previousSource.kind === "rig"
|
|
? previousSource.rig
|
|
: null;
|
|
|
|
if (transitionRig !== null) {
|
|
return {
|
|
mode: transitionRig.transitionMode,
|
|
durationSeconds: transitionRig.transitionDurationSeconds
|
|
};
|
|
}
|
|
|
|
if (previousSource.kind === "dialogue" || nextSource.kind === "dialogue") {
|
|
return {
|
|
mode: "blend" as const,
|
|
durationSeconds: DIALOGUE_ATTENTION_CAMERA_TRANSITION_DURATION_SECONDS
|
|
};
|
|
}
|
|
|
|
return {
|
|
mode: "cut" as const,
|
|
durationSeconds: 0
|
|
};
|
|
}
|
|
|
|
private applyActiveCameraRig(
|
|
dt: number,
|
|
previousCameraPose: RuntimeCameraPose = this.captureCurrentCameraPose()
|
|
) {
|
|
const previousSource: RuntimeResolvedCameraSource =
|
|
this.activeRuntimeCameraRig !== null
|
|
? {
|
|
kind: "rig",
|
|
rig: this.activeRuntimeCameraRig
|
|
}
|
|
: this.activeCameraSourceKey !== null &&
|
|
this.activeCameraSourceKey.startsWith("dialogue:") &&
|
|
this.activeDialogueAttentionState !== null
|
|
? {
|
|
kind: "dialogue",
|
|
state: this.activeDialogueAttentionState
|
|
}
|
|
: {
|
|
kind: "gameplay"
|
|
};
|
|
let nextSource = this.resolveActiveRuntimeCameraSource();
|
|
let nextSourceKey = this.createRuntimeCameraSourceKey(nextSource);
|
|
let sourceChanged = this.activeCameraSourceKey !== nextSourceKey;
|
|
|
|
if (sourceChanged) {
|
|
this.cameraRigLookDragging = false;
|
|
this.cameraRigLookYawRadians = 0;
|
|
this.cameraRigLookPitchRadians = 0;
|
|
this.resetRuntimeCameraCollisionSmoothing();
|
|
}
|
|
|
|
let targetPose = this.resolveRuntimeCameraSourcePose(
|
|
nextSource,
|
|
dt,
|
|
previousCameraPose
|
|
);
|
|
|
|
if (targetPose === null) {
|
|
nextSource = {
|
|
kind: "gameplay"
|
|
};
|
|
nextSourceKey = "gameplay";
|
|
sourceChanged = this.activeCameraSourceKey !== nextSourceKey;
|
|
this.cameraRigLookDragging = false;
|
|
this.cameraRigLookYawRadians = dampScalar(
|
|
this.cameraRigLookYawRadians,
|
|
0,
|
|
8,
|
|
dt
|
|
);
|
|
this.cameraRigLookPitchRadians = dampScalar(
|
|
this.cameraRigLookPitchRadians,
|
|
0,
|
|
8,
|
|
dt
|
|
);
|
|
targetPose = this.captureCurrentCameraPose();
|
|
}
|
|
|
|
if (sourceChanged) {
|
|
const transitionSettings = this.resolveRuntimeCameraTransitionSettings(
|
|
previousSource,
|
|
nextSource
|
|
);
|
|
|
|
if (
|
|
transitionSettings.mode === "blend" &&
|
|
transitionSettings.durationSeconds > 0
|
|
) {
|
|
this.cameraTransitionState = {
|
|
durationSeconds: transitionSettings.durationSeconds,
|
|
elapsedSeconds: 0,
|
|
fromPose: {
|
|
position: previousCameraPose.position.clone(),
|
|
lookTarget: previousCameraPose.lookTarget.clone(),
|
|
collisionPivot: previousCameraPose.collisionPivot?.clone() ?? null,
|
|
collisionRadius: previousCameraPose.collisionRadius ?? null
|
|
},
|
|
toPose: {
|
|
position: targetPose.position.clone(),
|
|
lookTarget: targetPose.lookTarget.clone(),
|
|
collisionPivot: targetPose.collisionPivot?.clone() ?? null,
|
|
collisionRadius: targetPose.collisionRadius ?? null
|
|
},
|
|
destinationSourceKey: nextSourceKey
|
|
};
|
|
} else {
|
|
this.cameraTransitionState = null;
|
|
}
|
|
|
|
this.activeCameraSourceKey = nextSourceKey;
|
|
this.suppressNextCameraSourceTransition = false;
|
|
}
|
|
|
|
this.activeRuntimeCameraRig =
|
|
nextSource.kind === "rig" ? nextSource.rig : null;
|
|
|
|
if (nextSource.kind === "gameplay" && this.currentDialogue === null) {
|
|
this.activeDialogueAttentionState = null;
|
|
}
|
|
|
|
if (
|
|
this.cameraTransitionState !== null &&
|
|
this.cameraTransitionState.destinationSourceKey === nextSourceKey
|
|
) {
|
|
this.cameraTransitionState.elapsedSeconds = Math.min(
|
|
this.cameraTransitionState.durationSeconds,
|
|
this.cameraTransitionState.elapsedSeconds + dt
|
|
);
|
|
this.cameraTransitionState.toPose.position.copy(targetPose.position);
|
|
this.cameraTransitionState.toPose.lookTarget.copy(targetPose.lookTarget);
|
|
this.cameraTransitionState.toPose.collisionPivot =
|
|
targetPose.collisionPivot?.clone() ?? null;
|
|
this.cameraTransitionState.toPose.collisionRadius =
|
|
targetPose.collisionRadius ?? null;
|
|
const blendT =
|
|
this.cameraTransitionState.durationSeconds <= 0
|
|
? 1
|
|
: this.cameraTransitionState.elapsedSeconds /
|
|
this.cameraTransitionState.durationSeconds;
|
|
const blendedPosition = this.cameraRigForward.lerpVectors(
|
|
this.cameraTransitionState.fromPose.position,
|
|
this.cameraTransitionState.toPose.position,
|
|
blendT
|
|
);
|
|
const blendedLookTarget = this.cameraRigLookTarget.lerpVectors(
|
|
this.cameraTransitionState.fromPose.lookTarget,
|
|
this.cameraTransitionState.toPose.lookTarget,
|
|
blendT
|
|
);
|
|
this.applyCameraPose(
|
|
{
|
|
position: blendedPosition.clone(),
|
|
lookTarget: blendedLookTarget.clone(),
|
|
collisionPivot:
|
|
this.cameraTransitionState.toPose.collisionPivot?.clone() ?? null,
|
|
collisionRadius:
|
|
this.cameraTransitionState.toPose.collisionRadius ?? null
|
|
},
|
|
dt
|
|
);
|
|
|
|
if (blendT >= 1) {
|
|
this.cameraTransitionState = null;
|
|
}
|
|
} else {
|
|
this.cameraTransitionState = null;
|
|
this.applyCameraPose(targetPose, dt);
|
|
}
|
|
|
|
if (nextSource.kind !== "gameplay") {
|
|
this.syncCameraRigTelemetryHooks();
|
|
}
|
|
|
|
return this.activeRuntimeCameraRig;
|
|
}
|
|
|
|
private async finalizeSceneLoad(
|
|
requestId: number,
|
|
colliders: RuntimeSceneDefinition["colliders"],
|
|
playerShape: RuntimeSceneDefinition["playerCollider"],
|
|
playerMovement: RuntimeSceneDefinition["playerMovement"]
|
|
) {
|
|
try {
|
|
const nextCollisionWorld = await this.buildCollisionWorld(
|
|
requestId,
|
|
colliders,
|
|
playerShape,
|
|
playerMovement
|
|
);
|
|
|
|
if (requestId !== this.collisionWorldRequestId) {
|
|
nextCollisionWorld.dispose();
|
|
return;
|
|
}
|
|
|
|
this.collisionWorld = nextCollisionWorld;
|
|
this.sceneReady = true;
|
|
this.publishSceneLoadState({
|
|
status: "ready",
|
|
message: null
|
|
});
|
|
this.activateDesiredNavigationController();
|
|
} catch (error) {
|
|
if (requestId !== this.collisionWorldRequestId) {
|
|
return;
|
|
}
|
|
|
|
const detail =
|
|
error instanceof Error && error.message.trim().length > 0
|
|
? error.message.trim()
|
|
: "Unknown error.";
|
|
const message = `Runner scene failed to load: ${detail}`;
|
|
this.sceneReady = false;
|
|
this.currentRuntimeMessage = message;
|
|
this.runtimeMessageHandler?.(message);
|
|
this.publishSceneLoadState({
|
|
status: "error",
|
|
message
|
|
});
|
|
}
|
|
}
|
|
|
|
private applyWorld() {
|
|
if (this.currentWorld === null) {
|
|
return;
|
|
}
|
|
|
|
const world = this.currentWorld;
|
|
|
|
this.scene.background = null;
|
|
this.scene.environment = null;
|
|
this.scene.environmentIntensity = 1;
|
|
|
|
this.applyDayNightLighting();
|
|
|
|
if (this.renderer !== null) {
|
|
configureAdvancedRenderingRenderer(
|
|
this.renderer,
|
|
world.advancedRendering
|
|
);
|
|
this.syncAdvancedRenderingComposer(world.advancedRendering);
|
|
}
|
|
|
|
this.applyShadowState();
|
|
}
|
|
|
|
private applyDayNightLighting() {
|
|
if (this.currentWorld === null || this.runtimeScene === null) {
|
|
return;
|
|
}
|
|
|
|
const resolvedTime =
|
|
this.currentClockState === null
|
|
? null
|
|
: resolveRuntimeTimeState(
|
|
this.runtimeScene.time,
|
|
this.currentClockState
|
|
);
|
|
|
|
const resolvedWorld = resolveRuntimeDayNightWorldState(
|
|
this.currentWorld,
|
|
this.runtimeScene.time,
|
|
this.currentClockState,
|
|
resolvedTime
|
|
);
|
|
const backgroundTexture =
|
|
resolvedWorld.background.mode === "image"
|
|
? (this.loadedImageAssets[resolvedWorld.background.assetId]?.texture ??
|
|
null)
|
|
: null;
|
|
const nightBackgroundOverlay = resolvedWorld.nightBackgroundOverlay;
|
|
const backgroundOverlayState =
|
|
nightBackgroundOverlay === null
|
|
? null
|
|
: {
|
|
texture:
|
|
this.loadedImageAssets[nightBackgroundOverlay.assetId]?.texture ??
|
|
null,
|
|
opacity: nightBackgroundOverlay.opacity,
|
|
environmentIntensity: nightBackgroundOverlay.environmentIntensity
|
|
};
|
|
const celestialBodiesState = resolveWorldCelestialBodiesState(
|
|
this.currentWorld.showCelestialBodies,
|
|
resolvedWorld.sunLight,
|
|
resolvedWorld.moonLight
|
|
);
|
|
const shaderSkyState = resolveWorldShaderSkyRenderState(
|
|
this.currentWorld,
|
|
resolvedWorld,
|
|
resolvedTime,
|
|
this.runtimeScene.time
|
|
);
|
|
if (this.currentWorld.background.mode === "shader") {
|
|
this.shaderSkyEnvironmentCache?.syncPhaseTextures(
|
|
resolveWorldShaderSkyEnvironmentPhaseStates(
|
|
this.currentWorld,
|
|
this.runtimeScene.time
|
|
)
|
|
);
|
|
}
|
|
|
|
this.worldBackgroundRenderer.update(
|
|
resolvedWorld.background,
|
|
backgroundTexture,
|
|
backgroundOverlayState,
|
|
celestialBodiesState,
|
|
shaderSkyState
|
|
);
|
|
const environmentState = resolveWorldEnvironmentState(
|
|
resolvedWorld.background,
|
|
backgroundTexture,
|
|
backgroundOverlayState,
|
|
this.environmentBlendCache,
|
|
shaderSkyState,
|
|
this.shaderSkyEnvironmentCache
|
|
);
|
|
this.scene.background = null;
|
|
this.scene.environment = environmentState.texture;
|
|
this.scene.environmentIntensity = environmentState.intensity;
|
|
|
|
this.ambientLight.color.set(resolvedWorld.ambientLight.colorHex);
|
|
this.ambientLight.intensity = resolvedWorld.ambientLight.intensity;
|
|
this.currentCelestialShadowCaster =
|
|
resolveDominantCelestialShadowCaster(
|
|
resolvedWorld.sunLight,
|
|
resolvedWorld.moonLight
|
|
)?.key ?? null;
|
|
this.sunLight.color.set(resolvedWorld.sunLight.colorHex);
|
|
this.sunLight.intensity = resolvedWorld.sunLight.intensity;
|
|
this.sunLight.visible = resolvedWorld.sunLight.intensity > 1e-4;
|
|
this.sunLight.position
|
|
.set(
|
|
resolvedWorld.sunLight.direction.x,
|
|
resolvedWorld.sunLight.direction.y,
|
|
resolvedWorld.sunLight.direction.z
|
|
)
|
|
.normalize()
|
|
.multiplyScalar(18);
|
|
this.sunLight.target.position.set(0, 0, 0);
|
|
|
|
if (resolvedWorld.moonLight === null) {
|
|
this.moonLight.visible = false;
|
|
this.moonLight.intensity = 0;
|
|
this.moonLight.target.position.set(0, 0, 0);
|
|
return;
|
|
}
|
|
|
|
this.moonLight.visible = resolvedWorld.moonLight.intensity > 1e-4;
|
|
this.moonLight.color.set(resolvedWorld.moonLight.colorHex);
|
|
this.moonLight.intensity = resolvedWorld.moonLight.intensity;
|
|
this.moonLight.position
|
|
.set(
|
|
resolvedWorld.moonLight.direction.x,
|
|
resolvedWorld.moonLight.direction.y,
|
|
resolvedWorld.moonLight.direction.z
|
|
)
|
|
.normalize()
|
|
.multiplyScalar(16);
|
|
this.moonLight.target.position.set(0, 0, 0);
|
|
}
|
|
|
|
private async buildCollisionWorld(
|
|
requestId: number,
|
|
colliders: RuntimeSceneDefinition["colliders"],
|
|
playerShape: RuntimeSceneDefinition["playerCollider"],
|
|
playerMovement: RuntimeSceneDefinition["playerMovement"]
|
|
) {
|
|
const nextCollisionWorld = await RapierCollisionWorld.create(
|
|
colliders,
|
|
playerShape,
|
|
{
|
|
maxStepHeight: playerMovement.maxStepHeight
|
|
}
|
|
);
|
|
|
|
if (requestId !== this.collisionWorldRequestId) {
|
|
nextCollisionWorld.dispose();
|
|
throw new Error("Scene load was superseded by a newer request.");
|
|
}
|
|
|
|
return nextCollisionWorld;
|
|
}
|
|
|
|
private clearCollisionWorld() {
|
|
this.collisionWorld?.dispose();
|
|
this.collisionWorld = null;
|
|
}
|
|
|
|
private syncAdvancedRenderingComposer(settings: AdvancedRenderingSettings) {
|
|
if (this.renderer === null) {
|
|
return;
|
|
}
|
|
|
|
const shouldUseComposer = settings.enabled;
|
|
const settingsChanged =
|
|
this.currentAdvancedRenderingSettings === null ||
|
|
!areAdvancedRenderingSettingsEqual(
|
|
this.currentAdvancedRenderingSettings,
|
|
settings
|
|
);
|
|
|
|
if (!shouldUseComposer) {
|
|
if (this.advancedRenderingComposer !== null) {
|
|
this.advancedRenderingComposer.dispose();
|
|
this.advancedRenderingComposer = null;
|
|
}
|
|
|
|
this.currentAdvancedRenderingSettings = null;
|
|
this.renderer.autoClear = true;
|
|
return;
|
|
}
|
|
|
|
if (this.advancedRenderingComposer !== null && !settingsChanged) {
|
|
return;
|
|
}
|
|
|
|
if (this.advancedRenderingComposer !== null) {
|
|
this.advancedRenderingComposer.dispose();
|
|
}
|
|
|
|
this.advancedRenderingComposer = createAdvancedRenderingComposer(
|
|
this.renderer,
|
|
this.scene,
|
|
this.camera,
|
|
settings,
|
|
this.worldBackgroundRenderer.scene
|
|
);
|
|
this.currentAdvancedRenderingSettings =
|
|
cloneAdvancedRenderingSettings(settings);
|
|
this.renderer.autoClear = false;
|
|
}
|
|
|
|
private applyShadowState() {
|
|
if (this.currentWorld === null) {
|
|
return;
|
|
}
|
|
|
|
const advancedRendering = this.currentWorld.advancedRendering;
|
|
const shadowsEnabled =
|
|
advancedRendering.enabled && advancedRendering.shadows.enabled;
|
|
|
|
for (const mesh of this.brushMeshes.values()) {
|
|
applyAdvancedRenderingRenderableShadowFlags(mesh, shadowsEnabled);
|
|
}
|
|
|
|
for (const mesh of this.terrainMeshes.values()) {
|
|
applyAdvancedRenderingRenderableShadowFlags(mesh, shadowsEnabled);
|
|
}
|
|
|
|
for (const renderGroup of this.modelRenderObjects.values()) {
|
|
applyAdvancedRenderingRenderableShadowFlags(renderGroup, shadowsEnabled);
|
|
}
|
|
|
|
this.syncCelestialShadowState();
|
|
}
|
|
|
|
private resolveRuntimeShadowFocusTarget() {
|
|
const telemetry = this.currentPlayerControllerTelemetry;
|
|
|
|
if (telemetry !== null) {
|
|
return {
|
|
center: {
|
|
x: telemetry.feetPosition.x,
|
|
y: (telemetry.feetPosition.y + telemetry.eyePosition.y) * 0.5,
|
|
z: telemetry.feetPosition.z
|
|
},
|
|
radius: 8
|
|
};
|
|
}
|
|
|
|
const sceneBounds = this.runtimeScene?.sceneBounds ?? null;
|
|
|
|
if (sceneBounds !== null) {
|
|
return {
|
|
center: {
|
|
x: sceneBounds.center.x,
|
|
y: sceneBounds.center.y,
|
|
z: sceneBounds.center.z
|
|
},
|
|
radius: Math.max(
|
|
6,
|
|
Math.hypot(
|
|
sceneBounds.size.x,
|
|
sceneBounds.size.y,
|
|
sceneBounds.size.z
|
|
) * 0.2
|
|
)
|
|
};
|
|
}
|
|
|
|
return {
|
|
center: {
|
|
x: this.camera.position.x,
|
|
y: this.camera.position.y,
|
|
z: this.camera.position.z
|
|
},
|
|
radius: 8
|
|
};
|
|
}
|
|
|
|
private syncCelestialShadowState() {
|
|
if (this.currentWorld === null) {
|
|
return;
|
|
}
|
|
|
|
const advancedRendering = this.currentWorld.advancedRendering;
|
|
const shadowsEnabled =
|
|
advancedRendering.enabled && advancedRendering.shadows.enabled;
|
|
|
|
for (const renderObjects of this.localLightObjects.values()) {
|
|
configureAdvancedRenderingShadowLight(
|
|
renderObjects.light,
|
|
advancedRendering,
|
|
false
|
|
);
|
|
}
|
|
|
|
for (const renderObjects of this.lightVolumeObjects.values()) {
|
|
for (const light of renderObjects.lights) {
|
|
configureAdvancedRenderingShadowLight(light, advancedRendering, false);
|
|
}
|
|
}
|
|
|
|
if (!shadowsEnabled || this.currentCelestialShadowCaster === null) {
|
|
configureAdvancedRenderingShadowLight(
|
|
this.sunLight,
|
|
advancedRendering,
|
|
false
|
|
);
|
|
configureAdvancedRenderingShadowLight(
|
|
this.moonLight,
|
|
advancedRendering,
|
|
false
|
|
);
|
|
return;
|
|
}
|
|
|
|
const activeLight =
|
|
this.currentCelestialShadowCaster === "moon"
|
|
? this.moonLight
|
|
: this.sunLight;
|
|
const lightDirection = activeLight.position
|
|
.clone()
|
|
.sub(activeLight.target.position)
|
|
.normalize();
|
|
const fit = fitCelestialDirectionalShadow({
|
|
activeCamera: this.camera,
|
|
focusTarget: this.resolveRuntimeShadowFocusTarget(),
|
|
lightDirection: {
|
|
x: lightDirection.x,
|
|
y: lightDirection.y,
|
|
z: lightDirection.z
|
|
},
|
|
mapSize: advancedRendering.shadows.mapSize,
|
|
sceneBounds: this.runtimeScene?.sceneBounds ?? null
|
|
});
|
|
|
|
if (fit === null) {
|
|
configureAdvancedRenderingShadowLight(
|
|
this.sunLight,
|
|
advancedRendering,
|
|
false
|
|
);
|
|
configureAdvancedRenderingShadowLight(
|
|
this.moonLight,
|
|
advancedRendering,
|
|
false
|
|
);
|
|
return;
|
|
}
|
|
|
|
configureAdvancedRenderingShadowLight(
|
|
this.sunLight,
|
|
advancedRendering,
|
|
this.currentCelestialShadowCaster === "sun",
|
|
this.currentCelestialShadowCaster === "sun" ? fit.normalBias : 0
|
|
);
|
|
configureAdvancedRenderingShadowLight(
|
|
this.moonLight,
|
|
advancedRendering,
|
|
this.currentCelestialShadowCaster === "moon",
|
|
this.currentCelestialShadowCaster === "moon" ? fit.normalBias : 0
|
|
);
|
|
|
|
activeLight.position.set(
|
|
fit.lightPosition.x,
|
|
fit.lightPosition.y,
|
|
fit.lightPosition.z
|
|
);
|
|
activeLight.target.position.set(
|
|
fit.targetPosition.x,
|
|
fit.targetPosition.y,
|
|
fit.targetPosition.z
|
|
);
|
|
activeLight.updateMatrixWorld();
|
|
activeLight.target.updateMatrixWorld();
|
|
const shadowCamera = activeLight.shadow.camera as OrthographicCamera;
|
|
shadowCamera.left = fit.cameraBounds.left;
|
|
shadowCamera.right = fit.cameraBounds.right;
|
|
shadowCamera.top = fit.cameraBounds.top;
|
|
shadowCamera.bottom = fit.cameraBounds.bottom;
|
|
shadowCamera.near = fit.cameraBounds.near;
|
|
shadowCamera.far = fit.cameraBounds.far;
|
|
shadowCamera.updateProjectionMatrix();
|
|
activeLight.shadow.needsUpdate = true;
|
|
}
|
|
|
|
private rebuildLocalLights(localLights: RuntimeLocalLightCollection) {
|
|
this.clearLocalLights();
|
|
|
|
for (const pointLight of localLights.pointLights) {
|
|
const renderObjects = this.createPointLightRuntimeObjects(pointLight);
|
|
this.localLightGroup.add(renderObjects.group);
|
|
this.localLightObjects.set(pointLight.entityId, renderObjects);
|
|
}
|
|
|
|
for (const spotLight of localLights.spotLights) {
|
|
const renderObjects = this.createSpotLightRuntimeObjects(spotLight);
|
|
this.localLightGroup.add(renderObjects.group);
|
|
this.localLightObjects.set(spotLight.entityId, renderObjects);
|
|
}
|
|
|
|
this.applyShadowState();
|
|
}
|
|
|
|
private rebuildLightVolumes(
|
|
lightVolumes: RuntimeSceneDefinition["volumes"]["light"]
|
|
) {
|
|
this.clearLightVolumes();
|
|
|
|
for (const lightVolume of lightVolumes) {
|
|
const renderObjects = this.createLightVolumeRuntimeObjects(lightVolume);
|
|
this.lightVolumeGroup.add(renderObjects.group);
|
|
this.lightVolumeObjects.set(lightVolume.brushId, renderObjects);
|
|
}
|
|
}
|
|
|
|
private createPointLightRuntimeObjects(
|
|
pointLight: RuntimeLocalLightCollection["pointLights"][number]
|
|
): LocalLightRenderObjects {
|
|
const group = new Group();
|
|
const light = new PointLight(
|
|
pointLight.colorHex,
|
|
pointLight.intensity,
|
|
pointLight.distance
|
|
);
|
|
|
|
group.position.set(
|
|
pointLight.position.x,
|
|
pointLight.position.y,
|
|
pointLight.position.z
|
|
);
|
|
group.visible = pointLight.enabled;
|
|
light.position.set(0, 0, 0);
|
|
group.add(light);
|
|
enableObjectForAllRendererRenderCategories(group);
|
|
|
|
return {
|
|
group,
|
|
light
|
|
};
|
|
}
|
|
|
|
private createLightVolumeRuntimeObjects(
|
|
lightVolume: RuntimeSceneDefinition["volumes"]["light"][number]
|
|
): LightVolumeRenderObjects {
|
|
const group = new Group();
|
|
const lights: PointLight[] = [];
|
|
|
|
group.position.set(
|
|
lightVolume.center.x,
|
|
lightVolume.center.y,
|
|
lightVolume.center.z
|
|
);
|
|
group.rotation.set(
|
|
(lightVolume.rotationDegrees.x * Math.PI) / 180,
|
|
(lightVolume.rotationDegrees.y * Math.PI) / 180,
|
|
(lightVolume.rotationDegrees.z * Math.PI) / 180
|
|
);
|
|
group.visible = lightVolume.enabled;
|
|
|
|
for (const derivedLight of lightVolume.lights) {
|
|
const light = new PointLight(
|
|
lightVolume.colorHex,
|
|
derivedLight.intensity,
|
|
derivedLight.distance,
|
|
derivedLight.decay
|
|
);
|
|
light.castShadow = false;
|
|
light.shadow.autoUpdate = false;
|
|
light.position.set(
|
|
derivedLight.localPosition.x,
|
|
derivedLight.localPosition.y,
|
|
derivedLight.localPosition.z
|
|
);
|
|
group.add(light);
|
|
lights.push(light);
|
|
}
|
|
enableObjectForAllRendererRenderCategories(group);
|
|
|
|
return {
|
|
group,
|
|
lights
|
|
};
|
|
}
|
|
|
|
private createSpotLightRuntimeObjects(
|
|
spotLight: RuntimeLocalLightCollection["spotLights"][number]
|
|
): LocalLightRenderObjects {
|
|
const group = new Group();
|
|
const light = new SpotLight(
|
|
spotLight.colorHex,
|
|
spotLight.intensity,
|
|
spotLight.distance,
|
|
(spotLight.angleDegrees * Math.PI) / 180,
|
|
0.18,
|
|
1
|
|
);
|
|
const direction = new Vector3(
|
|
spotLight.direction.x,
|
|
spotLight.direction.y,
|
|
spotLight.direction.z
|
|
).normalize();
|
|
const orientation = new Quaternion().setFromUnitVectors(
|
|
new Vector3(0, 1, 0),
|
|
direction
|
|
);
|
|
|
|
group.position.set(
|
|
spotLight.position.x,
|
|
spotLight.position.y,
|
|
spotLight.position.z
|
|
);
|
|
group.quaternion.copy(orientation);
|
|
group.visible = spotLight.enabled;
|
|
light.position.set(0, 0, 0);
|
|
light.target.position.set(0, 1, 0);
|
|
group.add(light);
|
|
group.add(light.target);
|
|
enableObjectForAllRendererRenderCategories(group);
|
|
|
|
return {
|
|
group,
|
|
light
|
|
};
|
|
}
|
|
|
|
private syncResolvedControlStateToRuntime(
|
|
resolved: RuntimeSceneDefinition["control"]["resolved"]
|
|
) {
|
|
for (const state of resolved.discrete) {
|
|
this.applyResolvedDiscreteControlState(state);
|
|
}
|
|
|
|
for (const channelValue of resolved.channels) {
|
|
this.applyResolvedControlChannelValue(channelValue);
|
|
}
|
|
}
|
|
|
|
private applyResolvedDiscreteControlState(
|
|
state: RuntimeResolvedDiscreteControlState
|
|
) {
|
|
switch (state.type) {
|
|
case "projectTimePaused":
|
|
this.applyProjectTimePausedControl(state.value);
|
|
return;
|
|
case "cameraRigOverride":
|
|
this.applyCameraRigOverrideControl(state.entityId);
|
|
return;
|
|
case "actorPresence":
|
|
this.applyActorPresenceControl(state.target.actorId, state.value);
|
|
return;
|
|
case "actorAnimationPlayback":
|
|
this.applyActorAnimationPlaybackControl(
|
|
state.target,
|
|
state.clipName,
|
|
state.loop
|
|
);
|
|
return;
|
|
case "actorPathAssignment":
|
|
return;
|
|
case "modelVisibility":
|
|
this.applyModelInstanceVisibilityControl(state.target, state.value);
|
|
return;
|
|
case "soundPlayback":
|
|
this.applySoundPlaybackControl(state.target, state.value);
|
|
return;
|
|
case "modelAnimationPlayback":
|
|
this.applyModelAnimationPlaybackControl(
|
|
state.target,
|
|
state.clipName,
|
|
state.loop
|
|
);
|
|
return;
|
|
case "lightEnabled":
|
|
this.applyLightEnabledControl(state.target, state.value);
|
|
return;
|
|
case "lightColor":
|
|
this.applyLightColorControl(state.target, state.value);
|
|
return;
|
|
case "interactionEnabled":
|
|
this.applyInteractionEnabledControl(state.target, state.value);
|
|
return;
|
|
case "ambientLightColor":
|
|
this.applyAmbientLightColorControl(state.target, state.value);
|
|
return;
|
|
case "sunLightColor":
|
|
this.applySunLightColorControl(state.target, state.value);
|
|
return;
|
|
}
|
|
}
|
|
|
|
private applyResolvedControlChannelValue(
|
|
channelValue: RuntimeResolvedControlChannelValue
|
|
) {
|
|
switch (channelValue.type) {
|
|
case "lightIntensity":
|
|
this.applyLightIntensityControl(
|
|
channelValue.descriptor.target,
|
|
channelValue.value
|
|
);
|
|
return;
|
|
case "soundVolume":
|
|
this.applySoundVolumeControl(
|
|
channelValue.descriptor.target,
|
|
channelValue.value
|
|
);
|
|
return;
|
|
case "ambientLightIntensity":
|
|
this.applyAmbientLightIntensityControl(
|
|
channelValue.descriptor.target,
|
|
channelValue.value
|
|
);
|
|
return;
|
|
case "sunLightIntensity":
|
|
this.applySunLightIntensityControl(
|
|
channelValue.descriptor.target,
|
|
channelValue.value
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
private mutateRuntimeLightState(
|
|
target: LightControlTargetRef,
|
|
mutate: (
|
|
light:
|
|
| RuntimeSceneDefinition["localLights"]["pointLights"][number]
|
|
| RuntimeSceneDefinition["localLights"]["spotLights"][number]
|
|
) => void
|
|
) {
|
|
if (this.runtimeScene === null) {
|
|
return;
|
|
}
|
|
|
|
const lights =
|
|
target.entityKind === "pointLight"
|
|
? this.runtimeScene.localLights.pointLights
|
|
: this.runtimeScene.localLights.spotLights;
|
|
const light = lights.find(
|
|
(candidate) => candidate.entityId === target.entityId
|
|
);
|
|
|
|
if (light !== undefined) {
|
|
mutate(light);
|
|
}
|
|
}
|
|
|
|
private applyLightEnabledControl(
|
|
target: LightControlTargetRef,
|
|
enabled: boolean
|
|
) {
|
|
this.mutateRuntimeLightState(target, (light) => {
|
|
light.enabled = enabled;
|
|
});
|
|
|
|
const renderObjects = this.localLightObjects.get(target.entityId);
|
|
|
|
if (renderObjects === undefined) {
|
|
return;
|
|
}
|
|
|
|
renderObjects.group.visible = enabled;
|
|
}
|
|
|
|
private applyLightIntensityControl(
|
|
target: LightControlTargetRef,
|
|
intensity: number
|
|
) {
|
|
this.mutateRuntimeLightState(target, (light) => {
|
|
light.intensity = intensity;
|
|
});
|
|
|
|
const renderObjects = this.localLightObjects.get(target.entityId);
|
|
|
|
if (renderObjects === undefined) {
|
|
return;
|
|
}
|
|
|
|
renderObjects.light.intensity = intensity;
|
|
}
|
|
|
|
private applyLightColorControl(
|
|
target: LightControlTargetRef,
|
|
colorHex: string
|
|
) {
|
|
this.mutateRuntimeLightState(target, (light) => {
|
|
light.colorHex = colorHex;
|
|
});
|
|
|
|
const renderObjects = this.localLightObjects.get(target.entityId);
|
|
|
|
if (renderObjects === undefined) {
|
|
return;
|
|
}
|
|
|
|
renderObjects.light.color.set(colorHex);
|
|
}
|
|
|
|
private applyAmbientLightIntensityControl(
|
|
_target: SceneControlTargetRef,
|
|
intensity: number
|
|
) {
|
|
if (this.runtimeScene === null || this.currentWorld === null) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
this.runtimeScene.world.ambientLight.intensity === intensity &&
|
|
this.currentWorld.ambientLight.intensity === intensity
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.runtimeScene.world.ambientLight.intensity = intensity;
|
|
this.currentWorld.ambientLight.intensity = intensity;
|
|
this.applyDayNightLighting();
|
|
}
|
|
|
|
private applyAmbientLightColorControl(
|
|
_target: SceneControlTargetRef,
|
|
colorHex: string
|
|
) {
|
|
if (this.runtimeScene === null || this.currentWorld === null) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
this.runtimeScene.world.ambientLight.colorHex === colorHex &&
|
|
this.currentWorld.ambientLight.colorHex === colorHex
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.runtimeScene.world.ambientLight.colorHex = colorHex;
|
|
this.currentWorld.ambientLight.colorHex = colorHex;
|
|
this.applyDayNightLighting();
|
|
}
|
|
|
|
private applySunLightIntensityControl(
|
|
_target: SceneControlTargetRef,
|
|
intensity: number
|
|
) {
|
|
if (this.runtimeScene === null || this.currentWorld === null) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
this.runtimeScene.world.sunLight.intensity === intensity &&
|
|
this.currentWorld.sunLight.intensity === intensity
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.runtimeScene.world.sunLight.intensity = intensity;
|
|
this.currentWorld.sunLight.intensity = intensity;
|
|
this.applyDayNightLighting();
|
|
}
|
|
|
|
private applySunLightColorControl(
|
|
_target: SceneControlTargetRef,
|
|
colorHex: string
|
|
) {
|
|
if (this.runtimeScene === null || this.currentWorld === null) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
this.runtimeScene.world.sunLight.colorHex === colorHex &&
|
|
this.currentWorld.sunLight.colorHex === colorHex
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.runtimeScene.world.sunLight.colorHex = colorHex;
|
|
this.currentWorld.sunLight.colorHex = colorHex;
|
|
this.applyDayNightLighting();
|
|
}
|
|
|
|
private applyModelInstanceVisibilityControl(
|
|
target: ModelInstanceControlTargetRef,
|
|
visible: boolean
|
|
) {
|
|
if (this.runtimeScene !== null) {
|
|
const modelInstance =
|
|
this.runtimeScene.modelInstances.find(
|
|
(candidate) => candidate.instanceId === target.modelInstanceId
|
|
) ?? null;
|
|
|
|
if (modelInstance !== null) {
|
|
modelInstance.visible = visible;
|
|
}
|
|
}
|
|
|
|
const renderGroup = this.modelRenderObjects.get(target.modelInstanceId);
|
|
|
|
if (renderGroup !== undefined) {
|
|
renderGroup.visible = visible;
|
|
}
|
|
}
|
|
|
|
private applyModelAnimationPlaybackControl(
|
|
target: ModelInstanceControlTargetRef,
|
|
clipName: string | null,
|
|
loop: boolean | undefined
|
|
) {
|
|
let stateChanged = true;
|
|
|
|
if (this.runtimeScene !== null) {
|
|
const modelInstance =
|
|
this.runtimeScene.modelInstances.find(
|
|
(candidate) => candidate.instanceId === target.modelInstanceId
|
|
) ?? null;
|
|
|
|
if (modelInstance !== null) {
|
|
const nextClipName = clipName ?? undefined;
|
|
const nextAutoplay = clipName !== null;
|
|
const nextLoop = clipName === null ? undefined : loop;
|
|
|
|
stateChanged =
|
|
modelInstance.animationClipName !== nextClipName ||
|
|
modelInstance.animationAutoplay !== nextAutoplay ||
|
|
modelInstance.animationLoop !== nextLoop;
|
|
modelInstance.animationClipName = nextClipName;
|
|
modelInstance.animationAutoplay = nextAutoplay;
|
|
modelInstance.animationLoop = nextLoop;
|
|
}
|
|
}
|
|
|
|
if (!stateChanged) {
|
|
return;
|
|
}
|
|
|
|
if (!this.animationMixers.has(target.modelInstanceId)) {
|
|
return;
|
|
}
|
|
|
|
if (clipName === null) {
|
|
this.applyStopAnimationAction(target.modelInstanceId);
|
|
return;
|
|
}
|
|
|
|
this.applyPlayAnimationAction(target.modelInstanceId, clipName, loop);
|
|
}
|
|
|
|
private applySoundPlaybackControl(
|
|
target: SoundEmitterControlTargetRef,
|
|
playing: boolean,
|
|
link: InteractionLink | null = null
|
|
) {
|
|
let stateChanged = true;
|
|
|
|
if (this.runtimeScene !== null) {
|
|
const soundEmitter =
|
|
this.runtimeScene.entities.soundEmitters.find(
|
|
(candidate) => candidate.entityId === target.entityId
|
|
) ?? null;
|
|
|
|
if (soundEmitter !== null) {
|
|
stateChanged = soundEmitter.autoplay !== playing;
|
|
soundEmitter.autoplay = playing;
|
|
}
|
|
}
|
|
|
|
if (!stateChanged) {
|
|
return;
|
|
}
|
|
|
|
if (!this.audioSystem.hasSoundEmitter(target.entityId)) {
|
|
return;
|
|
}
|
|
|
|
if (playing) {
|
|
this.audioSystem.playSound(target.entityId, link);
|
|
} else {
|
|
this.audioSystem.stopSound(target.entityId);
|
|
}
|
|
}
|
|
|
|
private applyActorAnimationPlaybackControl(
|
|
target: ActorControlTargetRef,
|
|
clipName: string | null,
|
|
loop: boolean | undefined
|
|
) {
|
|
if (this.runtimeScene !== null) {
|
|
for (const npc of this.runtimeScene.npcDefinitions) {
|
|
if (npc.actorId !== target.actorId) {
|
|
continue;
|
|
}
|
|
|
|
const nextClipName = clipName;
|
|
|
|
if (
|
|
npc.animationClipName === nextClipName &&
|
|
npc.animationLoop === loop
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
npc.animationClipName = nextClipName;
|
|
npc.animationLoop = loop;
|
|
}
|
|
}
|
|
|
|
const npcIds =
|
|
this.runtimeScene?.npcDefinitions
|
|
.filter((npc) => npc.actorId === target.actorId)
|
|
.map((npc) => npc.entityId) ?? [];
|
|
|
|
for (const npcId of npcIds) {
|
|
if (!this.animationMixers.has(npcId)) {
|
|
continue;
|
|
}
|
|
|
|
if (clipName === null) {
|
|
this.applyStopAnimationAction(npcId);
|
|
} else {
|
|
this.applyPlayAnimationAction(npcId, clipName, loop);
|
|
}
|
|
}
|
|
}
|
|
|
|
private applySoundVolumeControl(
|
|
target: SoundEmitterControlTargetRef,
|
|
volume: number
|
|
) {
|
|
let stateChanged = true;
|
|
|
|
if (this.runtimeScene !== null) {
|
|
const soundEmitter =
|
|
this.runtimeScene.entities.soundEmitters.find(
|
|
(candidate) => candidate.entityId === target.entityId
|
|
) ?? null;
|
|
|
|
if (soundEmitter !== null) {
|
|
stateChanged = soundEmitter.volume !== volume;
|
|
soundEmitter.volume = volume;
|
|
}
|
|
}
|
|
|
|
if (!stateChanged) {
|
|
return;
|
|
}
|
|
|
|
this.audioSystem.setSoundEmitterVolume(target.entityId, volume);
|
|
}
|
|
|
|
private applyInteractionEnabledControl(
|
|
target: InteractionControlTargetRef,
|
|
enabled: boolean
|
|
) {
|
|
if (this.runtimeScene === null) {
|
|
return;
|
|
}
|
|
|
|
const interactable =
|
|
this.runtimeScene.entities.interactables.find(
|
|
(candidate) => candidate.entityId === target.entityId
|
|
) ?? null;
|
|
|
|
if (interactable !== null) {
|
|
interactable.interactionEnabled = enabled;
|
|
}
|
|
}
|
|
|
|
private applyActorPresenceControl(actorId: string, active: boolean) {
|
|
if (this.runtimeScene === null) {
|
|
return;
|
|
}
|
|
|
|
let changed = false;
|
|
|
|
for (const npc of this.runtimeScene.npcDefinitions) {
|
|
if (npc.actorId !== actorId || npc.active === active) {
|
|
continue;
|
|
}
|
|
|
|
npc.active = active;
|
|
npc.activeRoutineId = null;
|
|
npc.activeRoutineTitle = null;
|
|
changed = true;
|
|
const renderGroup = this.modelRenderObjects.get(npc.entityId);
|
|
|
|
if (renderGroup !== undefined) {
|
|
renderGroup.visible = npc.visible && npc.active;
|
|
}
|
|
}
|
|
|
|
if (!changed) {
|
|
return;
|
|
}
|
|
|
|
this.refreshRuntimeNpcCollections();
|
|
this.refreshCollisionWorldForNpcSchedule();
|
|
}
|
|
|
|
private applyProjectTimePausedControl(paused: boolean) {
|
|
this.setControlPauseActive(paused);
|
|
}
|
|
|
|
private applyCameraRigOverrideControl(entityId: string | null) {
|
|
this.setActiveCameraRigOverride(entityId);
|
|
}
|
|
|
|
private applyControlEffect(
|
|
effect: ControlEffect,
|
|
link: InteractionLink | null = null
|
|
) {
|
|
switch (effect.type) {
|
|
case "setProjectTimePaused":
|
|
this.applyProjectTimePausedControl(effect.paused);
|
|
break;
|
|
case "activateCameraRigOverride":
|
|
this.applyCameraRigOverrideControl(effect.target.entityId);
|
|
break;
|
|
case "clearCameraRigOverride":
|
|
this.applyCameraRigOverrideControl(null);
|
|
break;
|
|
case "setActorPresence":
|
|
this.applyActorPresenceControl(effect.target.actorId, effect.active);
|
|
break;
|
|
case "playActorAnimation":
|
|
this.applyActorAnimationPlaybackControl(
|
|
effect.target,
|
|
effect.clipName,
|
|
effect.loop
|
|
);
|
|
break;
|
|
case "followActorPath":
|
|
console.warn(
|
|
"followActorPath is scheduler-owned in this slice and is ignored when dispatched directly."
|
|
);
|
|
break;
|
|
case "playModelAnimation":
|
|
this.applyModelAnimationPlaybackControl(
|
|
effect.target,
|
|
effect.clipName,
|
|
effect.loop
|
|
);
|
|
break;
|
|
case "stopModelAnimation":
|
|
this.applyModelAnimationPlaybackControl(effect.target, null, undefined);
|
|
break;
|
|
case "setModelInstanceVisible":
|
|
this.applyModelInstanceVisibilityControl(effect.target, effect.visible);
|
|
break;
|
|
case "playSound":
|
|
this.applySoundPlaybackControl(effect.target, true, link);
|
|
break;
|
|
case "stopSound":
|
|
this.applySoundPlaybackControl(effect.target, false);
|
|
break;
|
|
case "setSoundVolume":
|
|
this.applySoundVolumeControl(effect.target, effect.volume);
|
|
break;
|
|
case "setInteractionEnabled":
|
|
this.applyInteractionEnabledControl(effect.target, effect.enabled);
|
|
break;
|
|
case "setLightEnabled":
|
|
this.applyLightEnabledControl(effect.target, effect.enabled);
|
|
break;
|
|
case "setLightIntensity":
|
|
this.applyLightIntensityControl(effect.target, effect.intensity);
|
|
break;
|
|
case "setLightColor":
|
|
this.applyLightColorControl(effect.target, effect.colorHex);
|
|
break;
|
|
case "setAmbientLightIntensity":
|
|
this.applyAmbientLightIntensityControl(effect.target, effect.intensity);
|
|
break;
|
|
case "setAmbientLightColor":
|
|
this.applyAmbientLightColorControl(effect.target, effect.colorHex);
|
|
break;
|
|
case "setSunLightIntensity":
|
|
this.applySunLightIntensityControl(effect.target, effect.intensity);
|
|
break;
|
|
case "setSunLightColor":
|
|
this.applySunLightColorControl(effect.target, effect.colorHex);
|
|
break;
|
|
}
|
|
|
|
if (this.runtimeScene === null) {
|
|
return;
|
|
}
|
|
|
|
this.runtimeScene.control.resolved = applyControlEffectToResolvedState(
|
|
this.runtimeScene.control.resolved,
|
|
effect,
|
|
link === null
|
|
? createDefaultResolvedControlSource()
|
|
: createInteractionLinkResolvedControlSource(link.id)
|
|
);
|
|
}
|
|
|
|
private rebuildBrushMeshes(brushes: RuntimeBoxBrushInstance[]) {
|
|
this.clearBrushMeshes();
|
|
const volumeRenderPaths: ResolvedBoxVolumeRenderPaths =
|
|
this.currentWorld === null
|
|
? { fog: "performance", water: "performance" }
|
|
: resolveBoxVolumeRenderPaths(this.currentWorld.advancedRendering);
|
|
|
|
for (const brush of brushes) {
|
|
const geometryBrush = createRuntimeGeometryBrush(brush);
|
|
const derivedMesh = buildBoxBrushDerivedMeshData(geometryBrush);
|
|
const geometry = derivedMesh.geometry;
|
|
const staticContactPatches =
|
|
brush.volume.mode === "water"
|
|
? this.collectRuntimeStaticWaterContactPatches(brush)
|
|
: [];
|
|
const contactPatches =
|
|
brush.volume.mode === "water"
|
|
? this.mergeRuntimeWaterContactPatches(
|
|
brush,
|
|
staticContactPatches,
|
|
this.collectRuntimePlayerWaterContactPatches(brush)
|
|
)
|
|
: [];
|
|
|
|
const materials =
|
|
this.createFogMaterialSet(
|
|
brush,
|
|
volumeRenderPaths,
|
|
derivedMesh.faceIdsInOrder
|
|
) ??
|
|
derivedMesh.faceIdsInOrder.map((faceId) =>
|
|
this.createFaceMaterial(
|
|
brush,
|
|
faceId,
|
|
brush.faces[faceId]?.material ?? null,
|
|
volumeRenderPaths,
|
|
contactPatches,
|
|
staticContactPatches
|
|
)
|
|
);
|
|
|
|
const mesh = new Mesh(geometry, materials);
|
|
mesh.position.set(brush.center.x, brush.center.y, brush.center.z);
|
|
mesh.rotation.set(
|
|
(brush.rotationDegrees.x * Math.PI) / 180,
|
|
(brush.rotationDegrees.y * Math.PI) / 180,
|
|
(brush.rotationDegrees.z * Math.PI) / 180
|
|
);
|
|
mesh.visible = brush.visible;
|
|
this.configureFogVolumeMesh(mesh, materials);
|
|
applyRendererRenderCategoryFromMaterial(mesh);
|
|
this.brushGroup.add(mesh);
|
|
this.brushMeshes.set(brush.id, mesh);
|
|
}
|
|
|
|
this.applyShadowState();
|
|
}
|
|
|
|
private rebuildTerrainMeshes(terrains: RuntimeTerrain[]) {
|
|
this.clearTerrainMeshes();
|
|
|
|
for (const terrain of terrains) {
|
|
const geometry = buildTerrainDerivedMeshData({
|
|
...terrain,
|
|
kind: "terrain",
|
|
enabled: true
|
|
}).geometry;
|
|
const mesh = new Mesh(
|
|
geometry,
|
|
this.createRuntimeTerrainMaterial(terrain)
|
|
);
|
|
|
|
mesh.position.set(
|
|
terrain.position.x,
|
|
terrain.position.y,
|
|
terrain.position.z
|
|
);
|
|
mesh.visible = terrain.visible;
|
|
mesh.castShadow = false;
|
|
mesh.receiveShadow = true;
|
|
applyRendererRenderCategory(mesh, "ao-world");
|
|
this.terrainGroup.add(mesh);
|
|
this.terrainMeshes.set(terrain.id, mesh);
|
|
}
|
|
|
|
this.applyShadowState();
|
|
}
|
|
|
|
private createRuntimeTerrainMaterial(terrain: RuntimeTerrain): Material {
|
|
const layerTextures = terrain.layers.map((layer) =>
|
|
getTerrainLayerTexture(
|
|
layer.material,
|
|
(material) => this.getOrCreateTextureSet(material).baseColor
|
|
)
|
|
) as [Texture, Texture, Texture, Texture];
|
|
|
|
return createTerrainLayerBlendMaterial({
|
|
layerTextures
|
|
});
|
|
}
|
|
|
|
private createFogMaterialSet(
|
|
brush: RuntimeBoxBrushInstance,
|
|
volumeRenderPaths: {
|
|
fog: "performance" | "quality";
|
|
water: "performance" | "quality";
|
|
},
|
|
faceIds: WhiteboxFaceId[]
|
|
): Material[] | null {
|
|
if (brush.volume.mode !== "fog") {
|
|
return null;
|
|
}
|
|
|
|
if (volumeRenderPaths.fog === "quality") {
|
|
const fogMaterial = createFogQualityMaterial({
|
|
colorHex: brush.volume.fog.colorHex,
|
|
density: brush.volume.fog.density,
|
|
padding: brush.volume.fog.padding,
|
|
time: this.volumeTime,
|
|
halfSize: {
|
|
x: brush.size.x * 0.5,
|
|
y: brush.size.y * 0.5,
|
|
z: brush.size.z * 0.5
|
|
}
|
|
});
|
|
|
|
this.volumeAnimatedUniforms.push(fogMaterial.animationUniform);
|
|
return faceIds.map(() => fogMaterial.material);
|
|
}
|
|
|
|
const densityOpacity = Math.max(
|
|
0.06,
|
|
Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08)
|
|
);
|
|
const fogMaterial = new MeshBasicMaterial({
|
|
color: brush.volume.fog.colorHex,
|
|
transparent: true,
|
|
opacity: densityOpacity,
|
|
depthWrite: false
|
|
});
|
|
|
|
return faceIds.map(() => fogMaterial);
|
|
}
|
|
|
|
private configureFogVolumeMesh(
|
|
mesh: Mesh<BufferGeometry, Material[]>,
|
|
materials: Material[]
|
|
) {
|
|
const fogMaterials = materials.filter(
|
|
(material): material is ShaderMaterial =>
|
|
material instanceof ShaderMaterial &&
|
|
material.uniforms["localCameraPosition"] !== undefined
|
|
);
|
|
|
|
if (fogMaterials.length === 0) {
|
|
return;
|
|
}
|
|
|
|
mesh.onBeforeRender = (_renderer, _scene, camera) => {
|
|
const localCameraPosition = mesh.worldToLocal(
|
|
this.fogLocalCameraPosition.copy(camera.position)
|
|
);
|
|
|
|
for (const material of fogMaterials) {
|
|
(
|
|
material.uniforms["localCameraPosition"] as { value: Vector3 }
|
|
).value.copy(localCameraPosition);
|
|
}
|
|
};
|
|
}
|
|
|
|
private createNpcColliderFallbackRenderGroup(npc: RuntimeNpc): Group {
|
|
const group = new Group();
|
|
const colliderMaterial = new MeshStandardMaterial({
|
|
color: 0xa0df7a,
|
|
emissive: 0xa0df7a,
|
|
emissiveIntensity: 0.05,
|
|
roughness: 0.52,
|
|
metalness: 0.02,
|
|
transparent: true,
|
|
opacity: 0.3
|
|
});
|
|
const facingMaterial = new MeshStandardMaterial({
|
|
color: 0xa0df7a,
|
|
emissive: 0xa0df7a,
|
|
emissiveIntensity: 0.08,
|
|
roughness: 0.42,
|
|
metalness: 0.03
|
|
});
|
|
|
|
group.position.set(npc.position.x, npc.position.y, npc.position.z);
|
|
|
|
switch (npc.collider.mode) {
|
|
case "capsule": {
|
|
const collisionMesh = new Mesh(
|
|
new CapsuleGeometry(
|
|
npc.collider.radius,
|
|
Math.max(0, npc.collider.height - npc.collider.radius * 2),
|
|
8,
|
|
14
|
|
),
|
|
colliderMaterial
|
|
);
|
|
collisionMesh.position.y = npc.collider.height * 0.5;
|
|
group.add(collisionMesh);
|
|
break;
|
|
}
|
|
case "box": {
|
|
const collisionMesh = new Mesh(
|
|
new BoxGeometry(
|
|
npc.collider.size.x,
|
|
npc.collider.size.y,
|
|
npc.collider.size.z
|
|
),
|
|
colliderMaterial
|
|
);
|
|
collisionMesh.position.y = npc.collider.size.y * 0.5;
|
|
group.add(collisionMesh);
|
|
break;
|
|
}
|
|
case "none":
|
|
break;
|
|
}
|
|
|
|
const facingGroup = new Group();
|
|
facingGroup.name = "npcFacingGroup";
|
|
facingGroup.rotation.y = (npc.yawDegrees * Math.PI) / 180;
|
|
group.add(facingGroup);
|
|
const colliderTop =
|
|
getNpcColliderHeight({
|
|
mode: npc.collider.mode,
|
|
eyeHeight: npc.collider.eyeHeight,
|
|
capsuleRadius:
|
|
npc.collider.mode === "capsule" ? npc.collider.radius : 0.35,
|
|
capsuleHeight:
|
|
npc.collider.mode === "capsule" ? npc.collider.height : 1.8,
|
|
boxSize:
|
|
npc.collider.mode === "box"
|
|
? npc.collider.size
|
|
: {
|
|
x: 0.7,
|
|
y: 1.8,
|
|
z: 0.7
|
|
}
|
|
}) ?? 0.18;
|
|
|
|
const body = new Mesh(new BoxGeometry(0.08, 0.08, 0.34), facingMaterial);
|
|
body.position.set(0, colliderTop + 0.12, 0.06);
|
|
|
|
const arrowHead = new Mesh(new ConeGeometry(0.1, 0.22, 14), facingMaterial);
|
|
arrowHead.rotation.x = Math.PI * 0.5;
|
|
arrowHead.position.set(0, colliderTop + 0.12, 0.28);
|
|
|
|
facingGroup.add(body);
|
|
facingGroup.add(arrowHead);
|
|
|
|
return group;
|
|
}
|
|
|
|
private rebuildModelRenderObjects(
|
|
modelInstances: RuntimeSceneDefinition["modelInstances"],
|
|
npcs: RuntimeNpcDefinition[]
|
|
) {
|
|
this.clearModelRenderObjects();
|
|
|
|
for (const modelInstance of modelInstances) {
|
|
const asset = this.projectAssets[modelInstance.assetId];
|
|
const loadedAsset = this.loadedModelAssets[modelInstance.assetId];
|
|
const renderGroup = createModelInstanceRenderGroup(
|
|
{
|
|
id: modelInstance.instanceId,
|
|
kind: "modelInstance",
|
|
assetId: modelInstance.assetId,
|
|
name: modelInstance.name,
|
|
visible: modelInstance.visible,
|
|
enabled: true,
|
|
position: modelInstance.position,
|
|
rotationDegrees: modelInstance.rotationDegrees,
|
|
scale: modelInstance.scale,
|
|
collision: {
|
|
mode: "none",
|
|
visible: false
|
|
}
|
|
},
|
|
asset,
|
|
loadedAsset,
|
|
false
|
|
);
|
|
renderGroup.visible = modelInstance.visible;
|
|
applyRendererRenderCategoryFromMaterial(renderGroup);
|
|
this.modelGroup.add(renderGroup);
|
|
this.modelRenderObjects.set(modelInstance.instanceId, renderGroup);
|
|
|
|
if (loadedAsset?.animations && loadedAsset.animations.length > 0) {
|
|
const mixer = new AnimationMixer(renderGroup);
|
|
this.animationMixers.set(modelInstance.instanceId, mixer);
|
|
this.instanceAnimationClips.set(
|
|
modelInstance.instanceId,
|
|
loadedAsset.animations
|
|
);
|
|
|
|
if (
|
|
modelInstance.animationAutoplay === true &&
|
|
modelInstance.animationClipName
|
|
) {
|
|
const clip = AnimationClip.findByName(
|
|
loadedAsset.animations,
|
|
modelInstance.animationClipName
|
|
);
|
|
if (clip) {
|
|
const action = mixer.clipAction(clip);
|
|
action.loop =
|
|
modelInstance.animationLoop === false ? LoopOnce : LoopRepeat;
|
|
action.clampWhenFinished = modelInstance.animationLoop === false;
|
|
action.reset().play();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const npc of npcs) {
|
|
const asset =
|
|
npc.modelAssetId === null
|
|
? null
|
|
: (this.projectAssets[npc.modelAssetId] ?? null);
|
|
const loadedAsset =
|
|
npc.modelAssetId === null
|
|
? undefined
|
|
: this.loadedModelAssets[npc.modelAssetId];
|
|
const renderGroup =
|
|
npc.modelAssetId === null || asset?.kind !== "model"
|
|
? this.createNpcColliderFallbackRenderGroup(npc)
|
|
: createModelInstanceRenderGroup(
|
|
{
|
|
id: npc.entityId,
|
|
kind: "modelInstance",
|
|
assetId: npc.modelAssetId,
|
|
name: npc.name,
|
|
visible: npc.visible,
|
|
enabled: true,
|
|
position: npc.position,
|
|
rotationDegrees: {
|
|
x: 0,
|
|
y: npc.yawDegrees,
|
|
z: 0
|
|
},
|
|
scale: {
|
|
x: 1,
|
|
y: 1,
|
|
z: 1
|
|
},
|
|
collision: {
|
|
mode: "none",
|
|
visible: false
|
|
}
|
|
},
|
|
asset,
|
|
loadedAsset,
|
|
false
|
|
);
|
|
renderGroup.visible = npc.visible && npc.active;
|
|
applyRendererRenderCategoryFromMaterial(renderGroup);
|
|
this.modelGroup.add(renderGroup);
|
|
this.modelRenderObjects.set(npc.entityId, renderGroup);
|
|
|
|
if (loadedAsset?.animations && loadedAsset.animations.length > 0) {
|
|
const mixer = new AnimationMixer(renderGroup);
|
|
this.animationMixers.set(npc.entityId, mixer);
|
|
this.instanceAnimationClips.set(npc.entityId, loadedAsset.animations);
|
|
|
|
if (npc.animationClipName !== null) {
|
|
const clip = AnimationClip.findByName(
|
|
loadedAsset.animations,
|
|
npc.animationClipName
|
|
);
|
|
|
|
if (clip) {
|
|
const action = mixer.clipAction(clip);
|
|
action.loop = npc.animationLoop === false ? LoopOnce : LoopRepeat;
|
|
action.clampWhenFinished = npc.animationLoop === false;
|
|
action.reset().play();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
this.applyShadowState();
|
|
}
|
|
|
|
private createFaceMaterial(
|
|
brush: RuntimeBoxBrushInstance,
|
|
faceId: WhiteboxFaceId,
|
|
material: RuntimeBrushFace["material"],
|
|
volumeRenderPaths: {
|
|
fog: "performance" | "quality";
|
|
water: "performance" | "quality";
|
|
},
|
|
contactPatches: ReturnType<typeof collectWaterContactPatches>,
|
|
staticContactPatches: ReturnType<typeof collectWaterContactPatches>
|
|
): Material {
|
|
if (brush.volume.mode === "water") {
|
|
const baseOpacity = Math.max(
|
|
0.05,
|
|
Math.min(1, brush.volume.water.surfaceOpacity)
|
|
);
|
|
const isTopFace = brush.kind === "box" && faceId === "posY";
|
|
const waterMaterial = createWaterMaterial({
|
|
colorHex: brush.volume.water.colorHex,
|
|
surfaceOpacity: brush.volume.water.surfaceOpacity,
|
|
waveStrength: brush.volume.water.waveStrength,
|
|
surfaceDisplacementEnabled:
|
|
brush.volume.water.surfaceDisplacementEnabled,
|
|
opacity: isTopFace
|
|
? Math.min(1, baseOpacity + 0.18)
|
|
: baseOpacity * 0.5,
|
|
quality: volumeRenderPaths.water === "quality",
|
|
wireframe: false,
|
|
isTopFace,
|
|
time: this.volumeTime,
|
|
halfSize: {
|
|
x: brush.size.x * 0.5,
|
|
z: brush.size.z * 0.5
|
|
},
|
|
contactPatches,
|
|
reflection: {
|
|
texture: null,
|
|
enabled: isTopFace
|
|
}
|
|
});
|
|
|
|
if (waterMaterial.animationUniform !== null) {
|
|
this.volumeAnimatedUniforms.push(waterMaterial.animationUniform);
|
|
}
|
|
|
|
if (
|
|
isTopFace &&
|
|
waterMaterial.contactPatchesUniform !== null &&
|
|
waterMaterial.contactPatchAxesUniform !== null
|
|
) {
|
|
this.runtimeWaterContactUniforms.push({
|
|
brush,
|
|
uniform: waterMaterial.contactPatchesUniform,
|
|
axisUniform: waterMaterial.contactPatchAxesUniform,
|
|
shapeUniform: waterMaterial.contactPatchShapesUniform ?? {
|
|
value: []
|
|
},
|
|
staticContactPatches,
|
|
reflectionTextureUniform: waterMaterial.reflectionTextureUniform,
|
|
reflectionMatrixUniform: waterMaterial.reflectionMatrixUniform,
|
|
reflectionEnabledUniform: waterMaterial.reflectionEnabledUniform,
|
|
reflectionRenderTarget:
|
|
this.getWaterReflectionMode() !== "none"
|
|
? this.createWaterReflectionRenderTarget()
|
|
: null,
|
|
lastReflectionUpdateTime: Number.NEGATIVE_INFINITY
|
|
});
|
|
}
|
|
|
|
return waterMaterial.material;
|
|
}
|
|
|
|
if (brush.volume.mode === "fog") {
|
|
if (volumeRenderPaths.fog === "quality") {
|
|
const fogMaterial = createFogQualityMaterial({
|
|
colorHex: brush.volume.fog.colorHex,
|
|
density: brush.volume.fog.density,
|
|
padding: brush.volume.fog.padding,
|
|
time: this.volumeTime,
|
|
halfSize: {
|
|
x: brush.size.x * 0.5,
|
|
y: brush.size.y * 0.5,
|
|
z: brush.size.z * 0.5
|
|
}
|
|
});
|
|
|
|
this.volumeAnimatedUniforms.push(fogMaterial.animationUniform);
|
|
return fogMaterial.material;
|
|
}
|
|
// Performance fallback: simple transparent material
|
|
const densityOpacity = Math.max(
|
|
0.06,
|
|
Math.min(0.72, brush.volume.fog.density * 0.8 + 0.08)
|
|
);
|
|
return new MeshBasicMaterial({
|
|
color: brush.volume.fog.colorHex,
|
|
transparent: true,
|
|
opacity: densityOpacity,
|
|
depthWrite: false
|
|
});
|
|
}
|
|
|
|
if (brush.volume.mode === "light") {
|
|
const lightMaterial = new MeshBasicMaterial({
|
|
color: brush.volume.light.colorHex,
|
|
transparent: true,
|
|
opacity: 0,
|
|
depthWrite: false
|
|
});
|
|
lightMaterial.colorWrite = false;
|
|
return lightMaterial;
|
|
}
|
|
|
|
if (material === null) {
|
|
const faceMaterial = new MeshStandardMaterial({
|
|
color: FALLBACK_FACE_COLOR,
|
|
roughness: 0.9,
|
|
metalness: 0.05
|
|
});
|
|
|
|
if (
|
|
this.currentWorld !== null &&
|
|
shouldApplyWhiteboxBevel(this.currentWorld.advancedRendering)
|
|
) {
|
|
applyWhiteboxBevelToMaterial(
|
|
faceMaterial,
|
|
this.currentWorld.advancedRendering.whiteboxBevel
|
|
);
|
|
}
|
|
|
|
return faceMaterial;
|
|
}
|
|
|
|
const textureSet = this.getOrCreateTextureSet(material);
|
|
const faceMaterial = new MeshPhysicalMaterial({
|
|
color: 0xffffff,
|
|
map: textureSet.baseColor,
|
|
normalMap: textureSet.normal,
|
|
roughnessMap: textureSet.roughness,
|
|
roughness: 1,
|
|
metalnessMap: textureSet.metallic,
|
|
metalness: textureSet.metallic === null ? 0.03 : 1,
|
|
specularColorMap: textureSet.specular,
|
|
specularColor: new Color(0xffffff),
|
|
specularIntensity: textureSet.specular === null ? 0.2 : 1
|
|
});
|
|
|
|
if (
|
|
this.currentWorld !== null &&
|
|
shouldApplyWhiteboxBevel(this.currentWorld.advancedRendering)
|
|
) {
|
|
applyWhiteboxBevelToMaterial(
|
|
faceMaterial,
|
|
this.currentWorld.advancedRendering.whiteboxBevel
|
|
);
|
|
}
|
|
|
|
return faceMaterial;
|
|
}
|
|
|
|
private updateUnderwaterSceneFog() {
|
|
const cameraVolumeState = this.resolvePlayerVolumeState({
|
|
x: this.camera.position.x,
|
|
y: this.camera.position.y,
|
|
z: this.camera.position.z
|
|
});
|
|
const fogTelemetry = this.isActiveExternalCameraSource()
|
|
? {
|
|
cameraSubmerged:
|
|
cameraVolumeState.inWater &&
|
|
cameraVolumeState.waterSurfaceHeight !== null &&
|
|
this.camera.position.y < cameraVolumeState.waterSurfaceHeight,
|
|
eyePosition: {
|
|
x: this.camera.position.x,
|
|
y: this.camera.position.y,
|
|
z: this.camera.position.z
|
|
}
|
|
}
|
|
: this.activeController === this.firstPersonController
|
|
? this.currentPlayerControllerTelemetry
|
|
: null;
|
|
const fogState = resolveUnderwaterFogState(this.runtimeScene, fogTelemetry);
|
|
|
|
if (fogState === null) {
|
|
this.underwaterSceneFog.density = 0;
|
|
return;
|
|
}
|
|
|
|
this.underwaterSceneFog.color.set(fogState.colorHex);
|
|
this.underwaterSceneFog.density = fogState.density;
|
|
}
|
|
|
|
private resetPlayerCameraEffects() {
|
|
this.cameraEffectVerticalOffset = 0;
|
|
this.cameraEffectVerticalVelocity = 0;
|
|
this.cameraEffectPitchOffset = 0;
|
|
this.cameraEffectPitchVelocity = 0;
|
|
this.cameraEffectRollOffset = 0;
|
|
|
|
if (Math.abs(this.camera.fov - this.baseCameraFov) > 1e-4) {
|
|
this.camera.fov = this.baseCameraFov;
|
|
this.camera.updateProjectionMatrix();
|
|
}
|
|
}
|
|
|
|
private applyPlayerCameraEffects(dt: number) {
|
|
const telemetry = this.currentPlayerControllerTelemetry;
|
|
const cameraHooks = telemetry?.hooks.camera ?? null;
|
|
const signals = telemetry?.signals ?? null;
|
|
|
|
if (signals?.jumpStarted) {
|
|
this.cameraEffectVerticalVelocity += 0.42;
|
|
this.cameraEffectPitchVelocity += 0.045;
|
|
}
|
|
|
|
if (signals?.startedFalling) {
|
|
this.cameraEffectVerticalVelocity -= 0.1;
|
|
this.cameraEffectPitchVelocity -= 0.035;
|
|
}
|
|
|
|
if (signals?.landed) {
|
|
this.cameraEffectVerticalVelocity -= 0.68;
|
|
this.cameraEffectPitchVelocity -= 0.08;
|
|
}
|
|
|
|
if (signals?.headBump) {
|
|
this.cameraEffectVerticalVelocity -= 0.28;
|
|
this.cameraEffectPitchVelocity -= 0.05;
|
|
}
|
|
|
|
this.cameraEffectVerticalOffset += this.cameraEffectVerticalVelocity * dt;
|
|
this.cameraEffectPitchOffset += this.cameraEffectPitchVelocity * dt;
|
|
this.cameraEffectVerticalVelocity = dampScalar(
|
|
this.cameraEffectVerticalVelocity,
|
|
0,
|
|
10,
|
|
dt
|
|
);
|
|
this.cameraEffectPitchVelocity = dampScalar(
|
|
this.cameraEffectPitchVelocity,
|
|
0,
|
|
12,
|
|
dt
|
|
);
|
|
this.cameraEffectVerticalOffset = dampScalar(
|
|
this.cameraEffectVerticalOffset,
|
|
0,
|
|
9,
|
|
dt
|
|
);
|
|
this.cameraEffectPitchOffset = dampScalar(
|
|
this.cameraEffectPitchOffset,
|
|
0,
|
|
10,
|
|
dt
|
|
);
|
|
|
|
const swimmingOffset =
|
|
cameraHooks?.swimming === true
|
|
? Math.sin(this.volumeTime * 2.8) * 0.025
|
|
: 0;
|
|
const targetRollOffset =
|
|
cameraHooks?.swimming === true
|
|
? Math.sin(this.volumeTime * 1.8) * 0.012
|
|
: 0;
|
|
const targetFov =
|
|
this.baseCameraFov -
|
|
(cameraHooks?.underwaterAmount ?? 0) * 1.8 -
|
|
(cameraHooks?.swimming === true ? 0.6 : 0);
|
|
|
|
this.cameraEffectRollOffset = dampScalar(
|
|
this.cameraEffectRollOffset,
|
|
targetRollOffset,
|
|
6,
|
|
dt
|
|
);
|
|
|
|
this.camera.position.y += this.cameraEffectVerticalOffset + swimmingOffset;
|
|
this.camera.rotation.x += this.cameraEffectPitchOffset;
|
|
this.camera.rotation.z += this.cameraEffectRollOffset;
|
|
|
|
const nextFov = dampScalar(this.camera.fov, targetFov, 6, dt);
|
|
|
|
if (Math.abs(nextFov - this.camera.fov) > 1e-4) {
|
|
this.camera.fov = nextFov;
|
|
this.camera.updateProjectionMatrix();
|
|
}
|
|
}
|
|
|
|
private getWaterReflectionMode() {
|
|
if (
|
|
this.currentWorld === null ||
|
|
!this.currentWorld.advancedRendering.enabled ||
|
|
this.currentWorld.advancedRendering.waterPath !== "quality"
|
|
) {
|
|
return "none" as const;
|
|
}
|
|
|
|
return this.currentWorld.advancedRendering.waterReflectionMode;
|
|
}
|
|
|
|
private createWaterReflectionRenderTarget() {
|
|
const canvasWidth = this.container?.clientWidth ?? this.domElement.width;
|
|
const canvasHeight = this.container?.clientHeight ?? this.domElement.height;
|
|
const width = Math.max(128, Math.round(Math.max(canvasWidth, 512) * 0.5));
|
|
const height = Math.max(128, Math.round(Math.max(canvasHeight, 512) * 0.5));
|
|
return new WebGLRenderTarget(width, height);
|
|
}
|
|
|
|
private resizeWaterReflectionTargets() {
|
|
const canvasWidth = this.container?.clientWidth ?? this.domElement.width;
|
|
const canvasHeight = this.container?.clientHeight ?? this.domElement.height;
|
|
const width = Math.max(128, Math.round(Math.max(canvasWidth, 512) * 0.5));
|
|
const height = Math.max(128, Math.round(Math.max(canvasHeight, 512) * 0.5));
|
|
|
|
for (const binding of this.runtimeWaterContactUniforms) {
|
|
binding.reflectionRenderTarget?.setSize(width, height);
|
|
binding.lastReflectionUpdateTime = Number.NEGATIVE_INFINITY;
|
|
}
|
|
}
|
|
|
|
private updateRuntimeWaterReflections() {
|
|
if (this.renderer === null || this.runtimeScene === null) {
|
|
return;
|
|
}
|
|
|
|
const reflectionMode = this.getWaterReflectionMode();
|
|
const now = performance.now();
|
|
|
|
for (const binding of this.runtimeWaterContactUniforms) {
|
|
if (
|
|
reflectionMode === "none" ||
|
|
binding.reflectionTextureUniform === null ||
|
|
binding.reflectionMatrixUniform === null ||
|
|
binding.reflectionEnabledUniform === null
|
|
) {
|
|
if (binding.reflectionEnabledUniform !== null) {
|
|
binding.reflectionEnabledUniform.value = 0;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (binding.reflectionRenderTarget === null) {
|
|
binding.reflectionRenderTarget =
|
|
this.createWaterReflectionRenderTarget();
|
|
}
|
|
|
|
const canRenderReflection = updatePlanarReflectionCamera(
|
|
binding.brush,
|
|
this.camera,
|
|
this.waterReflectionCamera,
|
|
binding.reflectionMatrixUniform.value
|
|
);
|
|
|
|
if (!canRenderReflection || binding.reflectionRenderTarget === null) {
|
|
binding.reflectionEnabledUniform.value = 0;
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
binding.reflectionTextureUniform.value !== null &&
|
|
now - binding.lastReflectionUpdateTime <
|
|
WATER_REFLECTION_UPDATE_INTERVAL_MS
|
|
) {
|
|
binding.reflectionEnabledUniform.value = 0.36;
|
|
continue;
|
|
}
|
|
|
|
const hiddenWaterMeshes: Array<{
|
|
mesh: Mesh<BufferGeometry, Material[]>;
|
|
visible: boolean;
|
|
}> = [];
|
|
for (const runtimeBrush of this.runtimeScene.brushes) {
|
|
if (runtimeBrush.volume.mode !== "water") {
|
|
continue;
|
|
}
|
|
|
|
const mesh = this.brushMeshes.get(runtimeBrush.id);
|
|
if (mesh === undefined) {
|
|
continue;
|
|
}
|
|
|
|
hiddenWaterMeshes.push({ mesh, visible: mesh.visible });
|
|
mesh.visible = false;
|
|
}
|
|
|
|
const previousModelGroupVisibility = this.modelGroup.visible;
|
|
if (reflectionMode === "world") {
|
|
this.modelGroup.visible = false;
|
|
}
|
|
|
|
const previousAutoClear = this.renderer.autoClear;
|
|
const previousRenderTarget = this.renderer.getRenderTarget();
|
|
const previousFogDensity = this.underwaterSceneFog.density;
|
|
const previousReflectionStates = this.runtimeWaterContactUniforms.map(
|
|
(waterBinding) => ({
|
|
binding: waterBinding,
|
|
enabled: waterBinding.reflectionEnabledUniform?.value ?? 0,
|
|
texture: waterBinding.reflectionTextureUniform?.value ?? null
|
|
})
|
|
);
|
|
|
|
try {
|
|
this.underwaterSceneFog.density = 0;
|
|
for (const state of previousReflectionStates) {
|
|
if (state.binding.reflectionEnabledUniform !== null) {
|
|
state.binding.reflectionEnabledUniform.value = 0;
|
|
}
|
|
}
|
|
binding.reflectionTextureUniform.value = null;
|
|
this.renderer.setRenderTarget(binding.reflectionRenderTarget);
|
|
this.renderer.autoClear = true;
|
|
this.renderer.clear();
|
|
this.renderer.render(
|
|
this.worldBackgroundRenderer.scene,
|
|
this.waterReflectionCamera
|
|
);
|
|
this.renderer.autoClear = false;
|
|
this.renderer.render(this.scene, this.waterReflectionCamera);
|
|
} finally {
|
|
this.renderer.setRenderTarget(previousRenderTarget);
|
|
this.renderer.autoClear = previousAutoClear;
|
|
this.modelGroup.visible = previousModelGroupVisibility;
|
|
this.underwaterSceneFog.density = previousFogDensity;
|
|
for (const state of previousReflectionStates) {
|
|
if (state.binding.reflectionEnabledUniform !== null) {
|
|
state.binding.reflectionEnabledUniform.value = state.enabled;
|
|
}
|
|
if (state.binding.reflectionTextureUniform !== null) {
|
|
state.binding.reflectionTextureUniform.value = state.texture;
|
|
}
|
|
}
|
|
|
|
for (const hiddenWaterMesh of hiddenWaterMeshes) {
|
|
hiddenWaterMesh.mesh.visible = hiddenWaterMesh.visible;
|
|
}
|
|
}
|
|
|
|
binding.reflectionTextureUniform.value =
|
|
binding.reflectionRenderTarget.texture;
|
|
binding.reflectionEnabledUniform.value = 0.36;
|
|
binding.lastReflectionUpdateTime = now;
|
|
}
|
|
}
|
|
|
|
private getOrCreateTextureSet(
|
|
material: NonNullable<RuntimeBrushFace["material"]>
|
|
) {
|
|
const signature = createStarterMaterialSignature(material);
|
|
const cachedTexture = this.materialTextureCache.get(material.id);
|
|
|
|
if (cachedTexture !== undefined && cachedTexture.signature === signature) {
|
|
return cachedTexture.textureSet;
|
|
}
|
|
|
|
if (cachedTexture !== undefined) {
|
|
disposeStarterMaterialTextureSet(cachedTexture.textureSet);
|
|
}
|
|
|
|
const textureSet = createStarterMaterialTextureSet(
|
|
material,
|
|
this.materialTextureLoader
|
|
);
|
|
this.materialTextureCache.set(material.id, {
|
|
signature,
|
|
textureSet
|
|
});
|
|
|
|
return textureSet;
|
|
}
|
|
|
|
private clearLocalLights() {
|
|
for (const renderObjects of this.localLightObjects.values()) {
|
|
this.localLightGroup.remove(renderObjects.group);
|
|
}
|
|
|
|
this.localLightObjects.clear();
|
|
}
|
|
|
|
private clearLightVolumes() {
|
|
for (const renderObjects of this.lightVolumeObjects.values()) {
|
|
this.lightVolumeGroup.remove(renderObjects.group);
|
|
}
|
|
|
|
this.lightVolumeObjects.clear();
|
|
}
|
|
|
|
private clearBrushMeshes() {
|
|
for (const mesh of this.brushMeshes.values()) {
|
|
this.brushGroup.remove(mesh);
|
|
mesh.geometry.dispose();
|
|
this.disposeUniqueMaterials(mesh.material);
|
|
}
|
|
|
|
this.brushMeshes.clear();
|
|
this.volumeAnimatedUniforms.length = 0;
|
|
for (const binding of this.runtimeWaterContactUniforms) {
|
|
binding.reflectionRenderTarget?.dispose();
|
|
}
|
|
this.runtimeWaterContactUniforms.length = 0;
|
|
}
|
|
|
|
private clearTerrainMeshes() {
|
|
for (const mesh of this.terrainMeshes.values()) {
|
|
this.terrainGroup.remove(mesh);
|
|
mesh.geometry.dispose();
|
|
mesh.material.dispose();
|
|
}
|
|
|
|
this.terrainMeshes.clear();
|
|
}
|
|
|
|
private disposeUniqueMaterials(materials: Material[]) {
|
|
for (const material of new Set(materials)) {
|
|
material.dispose();
|
|
}
|
|
}
|
|
|
|
private createPlayerWaterContactBounds() {
|
|
if (
|
|
this.runtimeScene === null ||
|
|
this.currentPlayerControllerTelemetry === null
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const feetPosition = this.currentPlayerControllerTelemetry.feetPosition;
|
|
const playerShape = this.runtimeScene.playerCollider;
|
|
|
|
switch (playerShape.mode) {
|
|
case "capsule":
|
|
return {
|
|
min: {
|
|
x: feetPosition.x - playerShape.radius,
|
|
y: feetPosition.y,
|
|
z: feetPosition.z - playerShape.radius
|
|
},
|
|
max: {
|
|
x: feetPosition.x + playerShape.radius,
|
|
y: feetPosition.y + playerShape.height,
|
|
z: feetPosition.z + playerShape.radius
|
|
}
|
|
};
|
|
case "box":
|
|
return {
|
|
min: {
|
|
x: feetPosition.x - playerShape.size.x * 0.5,
|
|
y: feetPosition.y,
|
|
z: feetPosition.z - playerShape.size.z * 0.5
|
|
},
|
|
max: {
|
|
x: feetPosition.x + playerShape.size.x * 0.5,
|
|
y: feetPosition.y + playerShape.size.y,
|
|
z: feetPosition.z + playerShape.size.z * 0.5
|
|
}
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private collectRuntimeStaticWaterContactPatches(
|
|
brush: RuntimeBoxBrushInstance
|
|
): ReturnType<typeof collectWaterContactPatches> {
|
|
const contactBounds: Parameters<typeof collectWaterContactPatches>[1] = [];
|
|
|
|
if (this.runtimeScene === null) {
|
|
return [];
|
|
}
|
|
|
|
for (const terrain of this.runtimeScene.terrains) {
|
|
if (!terrain.visible) {
|
|
continue;
|
|
}
|
|
|
|
const derivedMesh = buildTerrainDerivedMeshData({
|
|
...terrain,
|
|
kind: "terrain",
|
|
enabled: true
|
|
});
|
|
|
|
contactBounds.push({
|
|
kind: "triangleMesh",
|
|
vertices: derivedMesh.positions,
|
|
indices: derivedMesh.indices,
|
|
mergeProfile: "aggressive",
|
|
transform: {
|
|
position: terrain.position,
|
|
rotationDegrees: {
|
|
x: 0,
|
|
y: 0,
|
|
z: 0
|
|
},
|
|
scale: {
|
|
x: 1,
|
|
y: 1,
|
|
z: 1
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
for (const collider of this.runtimeScene.colliders) {
|
|
if (collider.source === "terrain") {
|
|
continue;
|
|
}
|
|
|
|
if (collider.kind === "trimesh" && collider.source === "brush") {
|
|
if (collider.brushId === brush.id) {
|
|
continue;
|
|
}
|
|
|
|
contactBounds.push({
|
|
kind: "triangleMesh",
|
|
vertices: collider.vertices,
|
|
indices: collider.indices,
|
|
transform: {
|
|
position: collider.center,
|
|
rotationDegrees: collider.rotationDegrees,
|
|
scale: {
|
|
x: 1,
|
|
y: 1,
|
|
z: 1
|
|
}
|
|
}
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (collider.kind === "trimesh") {
|
|
contactBounds.push({
|
|
kind: "triangleMesh",
|
|
vertices: collider.vertices,
|
|
indices: collider.indices,
|
|
mergeProfile: "aggressive",
|
|
transform: collider.transform
|
|
});
|
|
continue;
|
|
}
|
|
|
|
contactBounds.push({
|
|
min: collider.worldBounds.min,
|
|
max: collider.worldBounds.max
|
|
});
|
|
}
|
|
|
|
return collectWaterContactPatches(
|
|
{
|
|
center: brush.center,
|
|
rotationDegrees: brush.rotationDegrees,
|
|
size: brush.size
|
|
},
|
|
contactBounds,
|
|
this.getRuntimeWaterFoamContactLimit(brush)
|
|
);
|
|
}
|
|
|
|
private collectRuntimePlayerWaterContactPatches(
|
|
brush: RuntimeBoxBrushInstance
|
|
) {
|
|
const playerBounds = this.createPlayerWaterContactBounds();
|
|
|
|
if (playerBounds === null) {
|
|
return [];
|
|
}
|
|
|
|
return collectWaterContactPatches(
|
|
{
|
|
center: brush.center,
|
|
rotationDegrees: brush.rotationDegrees,
|
|
size: brush.size
|
|
},
|
|
[playerBounds],
|
|
this.getRuntimeWaterFoamContactLimit(brush)
|
|
);
|
|
}
|
|
|
|
private getRuntimeWaterFoamContactLimit(brush: RuntimeBoxBrushInstance) {
|
|
return brush.volume.mode === "water"
|
|
? brush.volume.water.foamContactLimit
|
|
: 0;
|
|
}
|
|
|
|
private mergeRuntimeWaterContactPatches(
|
|
brush: RuntimeBoxBrushInstance,
|
|
staticContactPatches: ReturnType<typeof collectWaterContactPatches>,
|
|
dynamicContactPatches: ReturnType<typeof collectWaterContactPatches>
|
|
) {
|
|
return [...dynamicContactPatches, ...staticContactPatches].slice(
|
|
0,
|
|
this.getRuntimeWaterFoamContactLimit(brush)
|
|
);
|
|
}
|
|
|
|
private updateRuntimeWaterContactUniforms() {
|
|
for (const binding of this.runtimeWaterContactUniforms) {
|
|
const mergedPatches = this.mergeRuntimeWaterContactPatches(
|
|
binding.brush,
|
|
binding.staticContactPatches,
|
|
this.collectRuntimePlayerWaterContactPatches(binding.brush)
|
|
);
|
|
binding.uniform.value =
|
|
createWaterContactPatchUniformValue(mergedPatches);
|
|
binding.axisUniform.value =
|
|
createWaterContactPatchAxisUniformValue(mergedPatches);
|
|
binding.shapeUniform.value =
|
|
createWaterContactPatchShapeUniformValue(mergedPatches);
|
|
}
|
|
}
|
|
|
|
private clearModelRenderObjects() {
|
|
for (const mixer of this.animationMixers.values()) {
|
|
mixer.stopAllAction();
|
|
}
|
|
this.animationMixers.clear();
|
|
this.instanceAnimationClips.clear();
|
|
|
|
for (const renderGroup of this.modelRenderObjects.values()) {
|
|
this.modelGroup.remove(renderGroup);
|
|
disposeModelInstance(renderGroup);
|
|
}
|
|
|
|
this.modelRenderObjects.clear();
|
|
}
|
|
|
|
private resize() {
|
|
if (this.container === null) {
|
|
return;
|
|
}
|
|
|
|
const width = this.container.clientWidth;
|
|
const height = this.container.clientHeight;
|
|
|
|
if (width === 0 || height === 0) {
|
|
return;
|
|
}
|
|
|
|
this.camera.aspect = width / height;
|
|
this.camera.updateProjectionMatrix();
|
|
this.domElement.width = width;
|
|
this.domElement.height = height;
|
|
this.renderer?.setSize(width, height, false);
|
|
this.advancedRenderingComposer?.setSize(width, height);
|
|
this.resizeWaterReflectionTargets();
|
|
}
|
|
|
|
private render = () => {
|
|
this.animationFrame = window.requestAnimationFrame(this.render);
|
|
|
|
const now = performance.now();
|
|
const dt = Math.min((now - this.previousFrameTime) / 1000, 1 / 20);
|
|
this.previousFrameTime = now;
|
|
this.updateInteractInputState();
|
|
this.updatePauseInputState();
|
|
this.updateRuntimeTargetingInputState();
|
|
this.updateClearTargetInputState();
|
|
const simulationDt = this.isRuntimePaused() ? 0 : dt;
|
|
const cameraDt = dt;
|
|
const previousCameraPose = this.captureCurrentCameraPose();
|
|
|
|
this.updateRuntimeDialogueParticipants(cameraDt);
|
|
this.refreshRuntimeTargetingState();
|
|
this.activeController?.update(simulationDt);
|
|
this.refreshRuntimeTargetingState();
|
|
this.updateActiveRuntimeTargetLockState(cameraDt);
|
|
const activeCameraRig = this.applyActiveCameraRig(
|
|
cameraDt,
|
|
previousCameraPose
|
|
);
|
|
this.updateRuntimeTargetingVisuals(cameraDt);
|
|
|
|
if (!this.isActiveExternalCameraSource() && activeCameraRig === null) {
|
|
this.applyPlayerCameraEffects(simulationDt);
|
|
} else {
|
|
this.resetPlayerCameraEffects();
|
|
}
|
|
|
|
this.audioSystem.setPlayerControllerAudioHooks(
|
|
this.currentPlayerAudioHooks
|
|
);
|
|
this.audioSystem.updateListenerTransform();
|
|
|
|
this.volumeTime += simulationDt;
|
|
for (const uniform of this.volumeAnimatedUniforms) {
|
|
uniform.value = this.volumeTime;
|
|
}
|
|
|
|
if (this.currentClockState !== null && simulationDt > 0) {
|
|
this.currentClockState = advanceRuntimeClockState(
|
|
this.currentClockState,
|
|
simulationDt
|
|
);
|
|
if (this.sceneReady) {
|
|
this.syncRuntimeScheduleToCurrentClock();
|
|
}
|
|
this.applyDayNightLighting();
|
|
this.clockPublishAccumulator += simulationDt;
|
|
|
|
if (
|
|
this.clockPublishAccumulator >= RUNTIME_CLOCK_PUBLISH_INTERVAL_SECONDS
|
|
) {
|
|
this.clockPublishAccumulator = 0;
|
|
this.publishRuntimeClockState();
|
|
}
|
|
}
|
|
|
|
for (const mixer of this.animationMixers.values()) {
|
|
mixer.update(simulationDt);
|
|
}
|
|
|
|
if (
|
|
this.sceneReady &&
|
|
this.runtimeScene !== null &&
|
|
this.currentPlayerControllerTelemetry !== null &&
|
|
!this.isRuntimePaused()
|
|
) {
|
|
this.interactionSystem.updatePlayerPosition(
|
|
{
|
|
feetPosition: this.currentPlayerControllerTelemetry.feetPosition,
|
|
eyePosition: this.currentPlayerControllerTelemetry.eyePosition
|
|
},
|
|
this.runtimeScene,
|
|
this.createInteractionDispatcher()
|
|
);
|
|
|
|
this.setInteractionPrompt(
|
|
this.currentDialogue === null ? this.resolveInteractionPrompt() : null
|
|
);
|
|
} else {
|
|
this.setInteractionPrompt(null);
|
|
}
|
|
|
|
if (this.runtimeWaterContactUniforms.length > 0) {
|
|
this.updateRuntimeWaterContactUniforms();
|
|
this.updateRuntimeWaterReflections();
|
|
}
|
|
|
|
this.updateUnderwaterSceneFog();
|
|
this.syncCelestialShadowState();
|
|
|
|
if (this.advancedRenderingComposer !== null) {
|
|
this.worldBackgroundRenderer.syncToCamera(this.camera);
|
|
this.advancedRenderingComposer.render(dt);
|
|
return;
|
|
}
|
|
|
|
if (this.renderer === null) {
|
|
return;
|
|
}
|
|
|
|
this.worldBackgroundRenderer.syncToCamera(this.camera);
|
|
const previousAutoClear = this.renderer.autoClear;
|
|
this.renderer.autoClear = true;
|
|
this.renderer.clear();
|
|
this.renderer.render(this.worldBackgroundRenderer.scene, this.camera);
|
|
this.renderer.autoClear = false;
|
|
this.renderer.render(this.scene, this.camera);
|
|
this.renderer.autoClear = previousAutoClear;
|
|
};
|
|
|
|
private applyTeleportPlayerAction(target: {
|
|
position: RuntimeTeleportTarget["position"];
|
|
yawDegrees: number;
|
|
}) {
|
|
if (this.activeController === this.thirdPersonController) {
|
|
this.thirdPersonController.teleportTo(target.position, target.yawDegrees);
|
|
return;
|
|
}
|
|
|
|
this.firstPersonController.teleportTo(target.position, target.yawDegrees);
|
|
}
|
|
|
|
private applySceneTransitionEffect(options: {
|
|
sourceEntityId: string | null;
|
|
targetSceneId: string;
|
|
targetEntryEntityId: string;
|
|
}) {
|
|
this.sceneTransitionHandler?.({
|
|
sourceEntityId: options.sourceEntityId,
|
|
targetSceneId: options.targetSceneId,
|
|
targetEntryEntityId: options.targetEntryEntityId
|
|
});
|
|
}
|
|
|
|
private dispatchImpulseSequenceEffect(
|
|
effect: ImpulseSequenceStep,
|
|
sourceEntityId: string | null
|
|
) {
|
|
if (this.runtimeScene === null) {
|
|
return;
|
|
}
|
|
|
|
switch (effect.type) {
|
|
case "controlEffect":
|
|
this.applyControlEffect(effect.effect, null);
|
|
return;
|
|
case "makeNpcTalk":
|
|
this.openRuntimeNpcDialogue(effect.npcEntityId, effect.dialogueId, {
|
|
kind: "direct",
|
|
sourceEntityId,
|
|
linkId: null,
|
|
trigger: null
|
|
});
|
|
return;
|
|
case "teleportPlayer": {
|
|
const teleportTarget =
|
|
this.runtimeScene.entities.teleportTargets.find(
|
|
(candidate) => candidate.entityId === effect.targetEntityId
|
|
) ?? null;
|
|
|
|
if (teleportTarget !== null) {
|
|
this.applyTeleportPlayerAction(teleportTarget);
|
|
}
|
|
return;
|
|
}
|
|
case "startSceneTransition":
|
|
this.applySceneTransitionEffect({
|
|
sourceEntityId,
|
|
targetSceneId: effect.targetSceneId,
|
|
targetEntryEntityId: effect.targetEntryEntityId
|
|
});
|
|
return;
|
|
case "setVisibility":
|
|
this.applyVisibilitySequenceEffect(effect.target, effect.mode);
|
|
return;
|
|
}
|
|
}
|
|
|
|
private syncRuntimeScheduleToCurrentClock() {
|
|
if (this.runtimeScene === null || this.currentClockState === null) {
|
|
return;
|
|
}
|
|
|
|
this.runtimeScheduleSyncContext ??= createRuntimeScheduleSyncContext(
|
|
this.runtimeScene
|
|
);
|
|
|
|
const syncResult = syncRuntimeSceneScheduleToClock({
|
|
runtimeScene: this.runtimeScene,
|
|
clock: this.currentClockState,
|
|
context: this.runtimeScheduleSyncContext
|
|
});
|
|
|
|
for (const change of syncResult.npcChanges) {
|
|
const { npc } = change;
|
|
const renderGroup = this.modelRenderObjects.get(npc.entityId);
|
|
|
|
if (renderGroup !== undefined) {
|
|
renderGroup.visible = npc.visible && npc.active;
|
|
this.syncNpcRenderGroupTransform(renderGroup, npc);
|
|
}
|
|
|
|
if (this.animationMixers.has(npc.entityId) && change.animationChanged) {
|
|
if (npc.animationClipName === null) {
|
|
this.applyStopAnimationAction(npc.entityId);
|
|
} else {
|
|
this.applyPlayAnimationAction(
|
|
npc.entityId,
|
|
npc.animationClipName,
|
|
npc.animationLoop
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
this.syncResolvedControlStateToRuntime(syncResult.resolvedControl);
|
|
|
|
for (const impulseRoutine of syncResult.resolvedScheduler.impulses) {
|
|
if (
|
|
this.activeScheduledImpulseRoutineIds.has(impulseRoutine.routineId) ||
|
|
this.completedScheduledImpulseRoutineIds.has(impulseRoutine.routineId)
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
for (const effect of impulseRoutine.effects) {
|
|
this.dispatchImpulseSequenceEffect(effect, null);
|
|
}
|
|
|
|
this.completedScheduledImpulseRoutineIds.add(impulseRoutine.routineId);
|
|
}
|
|
|
|
commitRuntimeScheduleSyncResult(this.runtimeScene, syncResult);
|
|
this.activeScheduledImpulseRoutineIds =
|
|
syncResult.nextActiveImpulseRoutineIds;
|
|
|
|
if (syncResult.npcColliderCollectionChanged) {
|
|
this.refreshCollisionWorldForNpcSchedule();
|
|
}
|
|
}
|
|
|
|
private refreshCollisionWorldForNpcSchedule() {
|
|
if (this.runtimeScene === null) {
|
|
return;
|
|
}
|
|
|
|
const requestId = ++this.collisionWorldRequestId;
|
|
const previousCollisionWorld = this.collisionWorld;
|
|
|
|
void this.buildCollisionWorld(
|
|
requestId,
|
|
this.runtimeScene.colliders,
|
|
this.runtimeScene.playerCollider,
|
|
this.runtimeScene.playerMovement
|
|
)
|
|
.then((nextCollisionWorld) => {
|
|
if (requestId !== this.collisionWorldRequestId) {
|
|
nextCollisionWorld.dispose();
|
|
return;
|
|
}
|
|
|
|
this.collisionWorld = nextCollisionWorld;
|
|
previousCollisionWorld?.dispose();
|
|
})
|
|
.catch((error) => {
|
|
if (requestId !== this.collisionWorldRequestId) {
|
|
return;
|
|
}
|
|
|
|
const detail =
|
|
error instanceof Error && error.message.trim().length > 0
|
|
? error.message.trim()
|
|
: "Unknown error.";
|
|
const message = `Runner collision refresh failed: ${detail}`;
|
|
this.currentRuntimeMessage = message;
|
|
this.runtimeMessageHandler?.(message);
|
|
});
|
|
}
|
|
|
|
private applyToggleBrushVisibilityAction(
|
|
brushId: string,
|
|
visible: boolean | undefined
|
|
) {
|
|
const mesh = this.brushMeshes.get(brushId);
|
|
|
|
if (mesh === undefined) {
|
|
return;
|
|
}
|
|
|
|
if (this.runtimeScene !== null) {
|
|
const brush =
|
|
this.runtimeScene.brushes.find(
|
|
(candidate) => candidate.id === brushId
|
|
) ?? null;
|
|
|
|
if (brush !== null) {
|
|
brush.visible = visible ?? !brush.visible;
|
|
}
|
|
}
|
|
|
|
mesh.visible = visible ?? !mesh.visible;
|
|
}
|
|
|
|
private applyVisibilitySequenceEffect(
|
|
target: SequenceVisibilityTarget,
|
|
mode: SequenceVisibilityMode
|
|
) {
|
|
const explicitVisible = mode === "toggle" ? undefined : mode === "show";
|
|
|
|
if (target.kind === "brush") {
|
|
this.applyToggleBrushVisibilityAction(target.brushId, explicitVisible);
|
|
return;
|
|
}
|
|
|
|
const runtimeModelInstance =
|
|
this.runtimeScene?.modelInstances.find(
|
|
(candidate) => candidate.instanceId === target.modelInstanceId
|
|
) ?? null;
|
|
const currentVisible =
|
|
runtimeModelInstance?.visible ??
|
|
this.modelRenderObjects.get(target.modelInstanceId)?.visible ??
|
|
true;
|
|
|
|
this.applyModelInstanceVisibilityControl(
|
|
{
|
|
kind: "modelInstance",
|
|
modelInstanceId: target.modelInstanceId
|
|
},
|
|
explicitVisible ?? !currentVisible
|
|
);
|
|
}
|
|
|
|
private applyPlayAnimationAction(
|
|
instanceId: string,
|
|
clipName: string,
|
|
loop: boolean | undefined
|
|
) {
|
|
const mixer = this.animationMixers.get(instanceId);
|
|
const clips = this.instanceAnimationClips.get(instanceId);
|
|
|
|
if (!mixer || !clips) {
|
|
console.warn(`playAnimation: no mixer for instance ${instanceId}`);
|
|
return;
|
|
}
|
|
|
|
const clip = AnimationClip.findByName(clips, clipName);
|
|
|
|
if (!clip) {
|
|
console.warn(
|
|
`playAnimation: clip "${clipName}" not found on instance ${instanceId}`
|
|
);
|
|
return;
|
|
}
|
|
|
|
// LoopRepeat is the three.js default; LoopOnce plays the clip a single time then stops.
|
|
const action = mixer.clipAction(clip);
|
|
action.loop = loop === false ? LoopOnce : LoopRepeat;
|
|
action.clampWhenFinished = loop === false;
|
|
mixer.stopAllAction();
|
|
action.reset().play();
|
|
}
|
|
|
|
private applyStopAnimationAction(instanceId: string) {
|
|
const mixer = this.animationMixers.get(instanceId);
|
|
|
|
if (!mixer) {
|
|
console.warn(`stopAnimation: no mixer for instance ${instanceId}`);
|
|
return;
|
|
}
|
|
|
|
mixer.stopAllAction();
|
|
}
|
|
|
|
private createInteractionDispatcher(): RuntimeInteractionDispatcher {
|
|
return {
|
|
teleportPlayer: (target) => {
|
|
this.applyTeleportPlayerAction(target);
|
|
},
|
|
startSceneTransition: (request) => {
|
|
this.applySceneTransitionEffect(request);
|
|
},
|
|
toggleBrushVisibility: (brushId, visible) => {
|
|
this.applyToggleBrushVisibilityAction(brushId, visible);
|
|
},
|
|
setVisibility: (target, mode) => {
|
|
this.applyVisibilitySequenceEffect(target, mode);
|
|
},
|
|
playAnimation: (instanceId, clipName, loop) => {
|
|
this.applyPlayAnimationAction(instanceId, clipName, loop);
|
|
},
|
|
stopAnimation: (instanceId) => {
|
|
this.applyStopAnimationAction(instanceId);
|
|
},
|
|
playSound: (soundEmitterId, link) => {
|
|
this.audioSystem.playSound(soundEmitterId, link);
|
|
},
|
|
stopSound: (soundEmitterId) => {
|
|
this.audioSystem.stopSound(soundEmitterId);
|
|
},
|
|
startNpcDialogue: (npcEntityId, dialogueId, source) => {
|
|
this.openRuntimeNpcDialogue(npcEntityId, dialogueId, source);
|
|
},
|
|
dispatchControlEffect: (effect, link) => {
|
|
this.applyControlEffect(effect, link);
|
|
}
|
|
};
|
|
}
|
|
|
|
private setInteractionPrompt(prompt: RuntimeInteractionPrompt | null) {
|
|
if (
|
|
this.currentInteractionPrompt?.sourceEntityId ===
|
|
prompt?.sourceEntityId &&
|
|
this.currentInteractionPrompt?.prompt === prompt?.prompt &&
|
|
this.currentInteractionPrompt?.distance === prompt?.distance &&
|
|
this.currentInteractionPrompt?.range === prompt?.range
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.currentInteractionPrompt = prompt;
|
|
this.interactionPromptHandler?.(prompt);
|
|
}
|
|
|
|
private createRuntimeNpcDialogueState(
|
|
npcEntityId: string,
|
|
dialogueId: string,
|
|
lineIndex: number,
|
|
source: RuntimeDialogueStartSource
|
|
): RuntimeDialogueState | null {
|
|
if (this.runtimeScene === null) {
|
|
return null;
|
|
}
|
|
|
|
const npc =
|
|
this.runtimeScene.entities.npcs.find(
|
|
(candidate) => candidate.entityId === npcEntityId
|
|
) ?? null;
|
|
|
|
if (npc === null) {
|
|
return null;
|
|
}
|
|
|
|
const dialogue = npc.dialogues.find(
|
|
(candidate) => candidate.id === dialogueId
|
|
);
|
|
|
|
if (dialogue === undefined) {
|
|
return null;
|
|
}
|
|
|
|
const line = dialogue.lines[lineIndex];
|
|
|
|
if (line === undefined) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
npcEntityId,
|
|
dialogueId,
|
|
title: dialogue.title,
|
|
lineId: line.id,
|
|
lineIndex,
|
|
lineCount: dialogue.lines.length,
|
|
speakerName: npc.actorId,
|
|
text: line.text,
|
|
source
|
|
};
|
|
}
|
|
|
|
private setRuntimeDialogue(dialogue: RuntimeDialogueState | null) {
|
|
if (
|
|
this.currentDialogue?.npcEntityId === dialogue?.npcEntityId &&
|
|
this.currentDialogue?.dialogueId === dialogue?.dialogueId &&
|
|
this.currentDialogue?.lineId === dialogue?.lineId &&
|
|
this.currentDialogue?.lineIndex === dialogue?.lineIndex &&
|
|
this.currentDialogue?.lineCount === dialogue?.lineCount &&
|
|
this.currentDialogue?.speakerName === dialogue?.speakerName &&
|
|
this.currentDialogue?.text === dialogue?.text &&
|
|
this.currentDialogue?.title === dialogue?.title &&
|
|
this.currentDialogue?.source.kind === dialogue?.source.kind &&
|
|
this.currentDialogue?.source.sourceEntityId ===
|
|
dialogue?.source.sourceEntityId &&
|
|
this.currentDialogue?.source.linkId === dialogue?.source.linkId &&
|
|
this.currentDialogue?.source.trigger === dialogue?.source.trigger
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
dialogue !== null &&
|
|
this.activeDialogueAttentionState?.npcEntityId !== dialogue.npcEntityId
|
|
) {
|
|
this.activeDialogueAttentionState = null;
|
|
}
|
|
|
|
if (
|
|
dialogue !== null &&
|
|
this.dialogueParticipantState?.npcEntityId !== dialogue.npcEntityId
|
|
) {
|
|
this.dialogueParticipantState = null;
|
|
}
|
|
|
|
this.currentDialogue = dialogue;
|
|
this.setDialoguePauseActive(dialogue !== null);
|
|
this.runtimeDialogueHandler?.(dialogue);
|
|
}
|
|
|
|
private openRuntimeNpcDialogue(
|
|
npcEntityId: string,
|
|
dialogueId: string | null,
|
|
source: RuntimeDialogueStartSource = {
|
|
kind: "direct",
|
|
sourceEntityId: null,
|
|
linkId: null,
|
|
trigger: null
|
|
}
|
|
) {
|
|
if (this.runtimeScene === null) {
|
|
return;
|
|
}
|
|
|
|
const npc =
|
|
this.runtimeScene.entities.npcs.find(
|
|
(candidate) => candidate.entityId === npcEntityId
|
|
) ?? null;
|
|
|
|
if (npc === null) {
|
|
console.warn(`dialogue: missing npc ${npcEntityId}`);
|
|
return;
|
|
}
|
|
|
|
const resolvedDialogueId =
|
|
dialogueId ?? npc.defaultDialogueId ?? npc.dialogues[0]?.id ?? null;
|
|
|
|
if (resolvedDialogueId === null) {
|
|
console.warn(`dialogue: npc ${npcEntityId} has no dialogue to speak`);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
this.currentDialogue?.npcEntityId === npcEntityId &&
|
|
this.currentDialogue?.dialogueId === resolvedDialogueId
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const dialogue = this.createRuntimeNpcDialogueState(
|
|
npcEntityId,
|
|
resolvedDialogueId,
|
|
0,
|
|
source
|
|
);
|
|
|
|
if (dialogue === null) {
|
|
console.warn(
|
|
`dialogue: npc ${npcEntityId} is missing dialogue ${resolvedDialogueId}`
|
|
);
|
|
return;
|
|
}
|
|
|
|
this.dialogueParticipantState = this.resolveDialogueParticipantState(npc);
|
|
this.setRuntimeDialogue(dialogue);
|
|
}
|
|
|
|
private resolveInteractionPrompt(): RuntimeInteractionPrompt | null {
|
|
if (
|
|
this.runtimeScene === null ||
|
|
this.currentPlayerControllerTelemetry === null ||
|
|
(this.activeController !== this.firstPersonController &&
|
|
this.activeController !== this.thirdPersonController)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
this.camera.getWorldDirection(this.cameraForward);
|
|
|
|
const interactionOrigin = this.currentPlayerControllerTelemetry.eyePosition;
|
|
const interactionReachMeters =
|
|
this.runtimeScene.playerStart?.interactionReachMeters ??
|
|
DEFAULT_PLAYER_START_INTERACTION_REACH_METERS;
|
|
const interactionAngleDegrees =
|
|
this.runtimeScene.playerStart?.interactionAngleDegrees ??
|
|
DEFAULT_PLAYER_START_INTERACTION_ANGLE_DEGREES;
|
|
const horizontalViewLengthSquared =
|
|
this.cameraForward.x * this.cameraForward.x +
|
|
this.cameraForward.z * this.cameraForward.z;
|
|
const interactionViewDirection =
|
|
horizontalViewLengthSquared > Number.EPSILON
|
|
? {
|
|
x: this.cameraForward.x,
|
|
y: 0,
|
|
z: this.cameraForward.z
|
|
}
|
|
: {
|
|
x: Math.sin(
|
|
(this.currentPlayerControllerTelemetry.yawDegrees * Math.PI) / 180
|
|
),
|
|
y: 0,
|
|
z: Math.cos(
|
|
(this.currentPlayerControllerTelemetry.yawDegrees * Math.PI) / 180
|
|
)
|
|
};
|
|
|
|
return this.interactionSystem.resolveClickInteractionPrompt(
|
|
interactionOrigin,
|
|
interactionViewDirection,
|
|
interactionReachMeters,
|
|
interactionAngleDegrees,
|
|
this.runtimeScene
|
|
);
|
|
}
|
|
|
|
private clearRuntimeTargetingState() {
|
|
this.runtimeTargetCandidates = [];
|
|
this.proposedRuntimeTarget = null;
|
|
this.activeRuntimeTargetReference = null;
|
|
this.activeRuntimeTargetOcclusionSeconds = 0;
|
|
this.runtimeTargetSwitchInputHeld = false;
|
|
this.previousTargetCycleInputActive = false;
|
|
this.previousClearTargetInputActive = false;
|
|
this.targetingLuxInitialized = false;
|
|
this.targetingLuxFlightState = "hidden";
|
|
this.targetingVisualTime = 0;
|
|
this.targetingVisualGroup.visible = false;
|
|
this.targetingLuxGroup.visible = false;
|
|
this.targetingActiveGroup.visible = false;
|
|
}
|
|
|
|
private resolveActiveRuntimeTarget(): RuntimeResolvedTarget | null {
|
|
if (
|
|
this.runtimeScene === null ||
|
|
this.activeRuntimeTargetReference === null
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return resolveRuntimeTargetReference(
|
|
this.runtimeScene,
|
|
this.activeRuntimeTargetReference
|
|
);
|
|
}
|
|
|
|
private setActiveRuntimeTargetReference(
|
|
reference: RuntimeTargetReference | null
|
|
) {
|
|
this.activeRuntimeTargetReference = reference;
|
|
this.activeRuntimeTargetOcclusionSeconds = 0;
|
|
this.runtimeTargetSwitchInputHeld = false;
|
|
}
|
|
|
|
private resolveRuntimePlayerInputBindings() {
|
|
return createPlayerStartInputBindings(this.runtimeScene?.playerInputBindings);
|
|
}
|
|
|
|
private resolveRuntimeTargetVisibilityClearance(target: {
|
|
kind?: string;
|
|
entityId?: string;
|
|
range: number;
|
|
}): number {
|
|
if (this.runtimeScene !== null && target.kind === "npc") {
|
|
const npc =
|
|
this.runtimeScene.entities.npcs.find(
|
|
(candidate) => candidate.entityId === target.entityId
|
|
) ?? null;
|
|
|
|
if (npc !== null) {
|
|
switch (npc.collider.mode) {
|
|
case "capsule":
|
|
return (
|
|
Math.max(
|
|
npc.collider.radius,
|
|
TARGETING_VISIBILITY_TARGET_CLEARANCE
|
|
) + TARGETING_VISIBILITY_TARGET_CLEARANCE_PADDING
|
|
);
|
|
case "box":
|
|
return (
|
|
clampScalar(
|
|
Math.max(
|
|
npc.collider.size.x,
|
|
npc.collider.size.y,
|
|
npc.collider.size.z
|
|
) * 0.25,
|
|
0.35,
|
|
0.75
|
|
) + TARGETING_VISIBILITY_TARGET_CLEARANCE_PADDING
|
|
);
|
|
case "none":
|
|
return 0.9 + TARGETING_VISIBILITY_TARGET_CLEARANCE_PADDING;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.runtimeScene !== null && target.kind === "interactable") {
|
|
const interactable =
|
|
this.runtimeScene.entities.interactables.find(
|
|
(candidate) => candidate.entityId === target.entityId
|
|
) ?? null;
|
|
|
|
if (interactable !== null) {
|
|
return clampScalar(interactable.radius * 0.5, 0.25, 0.9);
|
|
}
|
|
}
|
|
|
|
return Math.max(
|
|
TARGETING_VISIBILITY_TARGET_CLEARANCE,
|
|
clampScalar(target.range * 0.5, 0.25, 0.9)
|
|
);
|
|
}
|
|
|
|
private resolveRuntimeTargetVisibilitySamples(target: {
|
|
kind?: string;
|
|
entityId?: string;
|
|
center: { x: number; y: number; z: number };
|
|
range: number;
|
|
}): Array<{
|
|
point: { x: number; y: number; z: number };
|
|
targetClearance: number;
|
|
}> {
|
|
const targetClearance =
|
|
this.resolveRuntimeTargetVisibilityClearance(target);
|
|
|
|
if (this.runtimeScene !== null && target.kind === "npc") {
|
|
const npc =
|
|
this.runtimeScene.entities.npcs.find(
|
|
(candidate) => candidate.entityId === target.entityId
|
|
) ?? null;
|
|
|
|
if (npc !== null) {
|
|
switch (npc.collider.mode) {
|
|
case "capsule": {
|
|
const collider = npc.collider;
|
|
const sampleClearance =
|
|
Math.max(collider.radius, TARGETING_VISIBILITY_TARGET_CLEARANCE) +
|
|
TARGETING_VISIBILITY_TARGET_CLEARANCE_PADDING;
|
|
const yAt = (factor: number) =>
|
|
npc.position.y + collider.height * factor;
|
|
|
|
return [
|
|
{
|
|
point: { x: npc.position.x, y: yAt(0.82), z: npc.position.z },
|
|
targetClearance: sampleClearance
|
|
},
|
|
{
|
|
point: { x: npc.position.x, y: yAt(0.62), z: npc.position.z },
|
|
targetClearance: sampleClearance
|
|
},
|
|
{ point: target.center, targetClearance: sampleClearance },
|
|
{
|
|
point: { x: npc.position.x, y: yAt(0.38), z: npc.position.z },
|
|
targetClearance: sampleClearance
|
|
}
|
|
];
|
|
}
|
|
case "box": {
|
|
const collider = npc.collider;
|
|
const sampleClearance =
|
|
clampScalar(
|
|
Math.max(collider.size.x, collider.size.y, collider.size.z) *
|
|
0.25,
|
|
0.35,
|
|
0.75
|
|
) + TARGETING_VISIBILITY_TARGET_CLEARANCE_PADDING;
|
|
const yAt = (factor: number) =>
|
|
npc.position.y + collider.size.y * factor;
|
|
|
|
return [
|
|
{
|
|
point: { x: npc.position.x, y: yAt(0.82), z: npc.position.z },
|
|
targetClearance: sampleClearance
|
|
},
|
|
{
|
|
point: { x: npc.position.x, y: yAt(0.62), z: npc.position.z },
|
|
targetClearance: sampleClearance
|
|
},
|
|
{ point: target.center, targetClearance: sampleClearance },
|
|
{
|
|
point: { x: npc.position.x, y: yAt(0.38), z: npc.position.z },
|
|
targetClearance: sampleClearance
|
|
}
|
|
];
|
|
}
|
|
case "none":
|
|
return [
|
|
{
|
|
point: {
|
|
x: npc.position.x,
|
|
y: npc.position.y + 1.45,
|
|
z: npc.position.z
|
|
},
|
|
targetClearance
|
|
},
|
|
{ point: target.center, targetClearance },
|
|
{
|
|
point: {
|
|
x: npc.position.x,
|
|
y: npc.position.y + 0.65,
|
|
z: npc.position.z
|
|
},
|
|
targetClearance
|
|
}
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
return [{ point: target.center, targetClearance }];
|
|
}
|
|
|
|
private isRuntimeTargetVisibleFrom(
|
|
origin: { x: number; y: number; z: number },
|
|
target: {
|
|
kind?: string;
|
|
entityId?: string;
|
|
center: { x: number; y: number; z: number };
|
|
range: number;
|
|
}
|
|
): boolean {
|
|
if (this.collisionWorld === null) {
|
|
return true;
|
|
}
|
|
|
|
const collisionWorld = this.collisionWorld;
|
|
|
|
return this.resolveRuntimeTargetVisibilitySamples(target).some((sample) =>
|
|
collisionWorld.isLineSegmentClear(origin, sample.point, {
|
|
targetClearance: sample.targetClearance
|
|
})
|
|
);
|
|
}
|
|
|
|
private isRuntimeTargetCameraVisible(target: {
|
|
kind?: string;
|
|
entityId?: string;
|
|
center: { x: number; y: number; z: number };
|
|
range: number;
|
|
}): boolean {
|
|
return this.isRuntimeTargetVisibleFrom(
|
|
{
|
|
x: this.camera.position.x,
|
|
y: this.camera.position.y,
|
|
z: this.camera.position.z
|
|
},
|
|
target
|
|
);
|
|
}
|
|
|
|
private isRuntimeTargetPlayerVisible(target: {
|
|
kind?: string;
|
|
entityId?: string;
|
|
center: { x: number; y: number; z: number };
|
|
range: number;
|
|
}): boolean {
|
|
const playerEyePosition =
|
|
this.currentPlayerControllerTelemetry?.eyePosition;
|
|
|
|
if (playerEyePosition === undefined) {
|
|
return false;
|
|
}
|
|
|
|
return this.isRuntimeTargetVisibleFrom(playerEyePosition, target);
|
|
}
|
|
|
|
private refreshRuntimeTargetingState() {
|
|
if (
|
|
this.runtimeScene === null ||
|
|
this.currentPlayerControllerTelemetry === null ||
|
|
!this.sceneReady ||
|
|
this.activeController !== this.thirdPersonController
|
|
) {
|
|
if (this.activeController === this.firstPersonController) {
|
|
this.clearRuntimeTargetingState();
|
|
} else {
|
|
this.runtimeTargetCandidates = [];
|
|
this.proposedRuntimeTarget = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (this.currentDialogue !== null) {
|
|
this.runtimeTargetCandidates = [];
|
|
this.proposedRuntimeTarget = null;
|
|
return;
|
|
}
|
|
|
|
this.camera.getWorldDirection(this.cameraForward);
|
|
|
|
const previousProposedId = this.proposedRuntimeTarget?.entityId ?? null;
|
|
this.runtimeTargetCandidates = resolveRuntimeTargetCandidates({
|
|
interactionOrigin: this.currentPlayerControllerTelemetry.eyePosition,
|
|
cameraPosition: {
|
|
x: this.camera.position.x,
|
|
y: this.camera.position.y,
|
|
z: this.camera.position.z
|
|
},
|
|
cameraForward: {
|
|
x: this.cameraForward.x,
|
|
y: this.cameraForward.y,
|
|
z: this.cameraForward.z
|
|
},
|
|
runtimeScene: this.runtimeScene,
|
|
previousProposedTargetEntityId: previousProposedId
|
|
}).filter((candidate) => this.isRuntimeTargetCameraVisible(candidate));
|
|
|
|
if (
|
|
this.activeRuntimeTargetReference !== null &&
|
|
this.resolveActiveRuntimeTarget() === null
|
|
) {
|
|
this.setActiveRuntimeTargetReference(null);
|
|
}
|
|
|
|
this.proposedRuntimeTarget =
|
|
this.resolveRuntimeTargetCandidateNearestScreenCenter({
|
|
requirePlayerVisibility: true
|
|
}) ??
|
|
this.runtimeTargetCandidates.find((candidate) =>
|
|
this.isRuntimeTargetPlayerVisible(candidate)
|
|
) ??
|
|
null;
|
|
}
|
|
|
|
private activateOrCycleRuntimeTarget() {
|
|
if (
|
|
this.runtimeScene === null ||
|
|
!this.sceneReady ||
|
|
this.activeController !== this.thirdPersonController ||
|
|
this.currentDialogue !== null
|
|
) {
|
|
if (this.activeController === this.firstPersonController) {
|
|
this.clearRuntimeTargetingState();
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (this.activeRuntimeTargetReference !== null) {
|
|
if (
|
|
this.runtimeScene.playerStart?.targetButtonCyclesActiveTarget ??
|
|
DEFAULT_PLAYER_START_TARGET_BUTTON_CYCLES_ACTIVE_TARGET
|
|
) {
|
|
this.cycleActiveRuntimeTargetFromButton();
|
|
} else {
|
|
this.clearActiveRuntimeTarget();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const nextTarget =
|
|
this.proposedRuntimeTarget ??
|
|
this.resolveRuntimeTargetCandidateNearestScreenCenter({
|
|
requirePlayerVisibility: true
|
|
});
|
|
|
|
if (nextTarget !== null) {
|
|
this.setActiveRuntimeTargetReference({
|
|
kind: nextTarget.kind,
|
|
entityId: nextTarget.entityId
|
|
});
|
|
}
|
|
}
|
|
|
|
private cycleActiveRuntimeTargetFromButton() {
|
|
if (this.activeRuntimeTargetReference === null) {
|
|
return;
|
|
}
|
|
|
|
const cycleCandidates = this.runtimeTargetCandidates.filter((candidate) => {
|
|
if (!this.isRuntimeTargetPlayerVisible(candidate)) {
|
|
return false;
|
|
}
|
|
|
|
const screenPoint = this.resolveRuntimeTargetScreenPoint(candidate.center);
|
|
|
|
return (
|
|
screenPoint !== null &&
|
|
Math.abs(screenPoint.x) <= TARGETING_SCREEN_PROPOSAL_MAX_ABS_X &&
|
|
Math.abs(screenPoint.y) <= TARGETING_SCREEN_PROPOSAL_MAX_ABS_Y
|
|
);
|
|
});
|
|
|
|
if (cycleCandidates.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const activeIndex = cycleCandidates.findIndex(
|
|
(candidate) =>
|
|
candidate.kind === this.activeRuntimeTargetReference?.kind &&
|
|
candidate.entityId === this.activeRuntimeTargetReference?.entityId
|
|
);
|
|
const nextCandidate =
|
|
activeIndex < 0
|
|
? (cycleCandidates[0] ?? null)
|
|
: cycleCandidates.length > 1
|
|
? (cycleCandidates[(activeIndex + 1) % cycleCandidates.length] ??
|
|
null)
|
|
: null;
|
|
|
|
if (nextCandidate === null) {
|
|
return;
|
|
}
|
|
|
|
this.setActiveRuntimeTargetReference({
|
|
kind: nextCandidate.kind,
|
|
entityId: nextCandidate.entityId
|
|
});
|
|
this.proposedRuntimeTarget = nextCandidate;
|
|
}
|
|
|
|
private clearActiveRuntimeTarget() {
|
|
this.setActiveRuntimeTargetReference(null);
|
|
}
|
|
|
|
private createRuntimeTargetLookInputResult(
|
|
result: Partial<RuntimeTargetLookInputResult> = {}
|
|
): RuntimeTargetLookInputResult {
|
|
return {
|
|
activeTargetLocked: result.activeTargetLocked ?? false,
|
|
switchedTarget: result.switchedTarget ?? false,
|
|
switchInputHeld: result.switchInputHeld ?? false
|
|
};
|
|
}
|
|
|
|
private handleRuntimeTargetLookInput(
|
|
input: RuntimeTargetLookInput
|
|
): RuntimeTargetLookInputResult {
|
|
const activeTarget = this.resolveActiveRuntimeTarget();
|
|
|
|
if (activeTarget === null) {
|
|
if (this.activeRuntimeTargetReference !== null) {
|
|
this.setActiveRuntimeTargetReference(null);
|
|
}
|
|
this.runtimeTargetSwitchInputHeld = false;
|
|
return this.createRuntimeTargetLookInputResult();
|
|
}
|
|
|
|
const inputMagnitude = Math.hypot(input.horizontal, input.vertical);
|
|
|
|
if (inputMagnitude <= Number.EPSILON) {
|
|
this.runtimeTargetSwitchInputHeld = false;
|
|
return this.createRuntimeTargetLookInputResult({
|
|
activeTargetLocked: true
|
|
});
|
|
}
|
|
|
|
if (
|
|
!(
|
|
this.runtimeScene?.playerStart?.allowLookInputTargetSwitch ??
|
|
DEFAULT_PLAYER_START_ALLOW_LOOK_INPUT_TARGET_SWITCH
|
|
)
|
|
) {
|
|
this.runtimeTargetSwitchInputHeld = false;
|
|
return this.createRuntimeTargetLookInputResult({
|
|
activeTargetLocked: true
|
|
});
|
|
}
|
|
|
|
if (
|
|
this.runtimeTargetSwitchInputHeld ||
|
|
inputMagnitude < TARGETING_DIRECTION_SWITCH_INPUT_THRESHOLD
|
|
) {
|
|
if (inputMagnitude < TARGETING_DIRECTION_SWITCH_INPUT_THRESHOLD) {
|
|
this.runtimeTargetSwitchInputHeld = false;
|
|
}
|
|
|
|
return this.createRuntimeTargetLookInputResult({
|
|
activeTargetLocked: true,
|
|
switchInputHeld: this.runtimeTargetSwitchInputHeld
|
|
});
|
|
}
|
|
|
|
const directionalTarget = this.resolveRuntimeTargetCandidateInLookDirection(
|
|
activeTarget,
|
|
input
|
|
);
|
|
|
|
if (directionalTarget !== null) {
|
|
this.setActiveRuntimeTargetReference({
|
|
kind: directionalTarget.kind,
|
|
entityId: directionalTarget.entityId
|
|
});
|
|
this.runtimeTargetSwitchInputHeld = true;
|
|
this.proposedRuntimeTarget = directionalTarget;
|
|
|
|
return this.createRuntimeTargetLookInputResult({
|
|
activeTargetLocked: true,
|
|
switchedTarget: true,
|
|
switchInputHeld: true
|
|
});
|
|
}
|
|
|
|
return this.createRuntimeTargetLookInputResult({
|
|
activeTargetLocked: true
|
|
});
|
|
}
|
|
|
|
private resolveRuntimeTargetScreenPoint(point: {
|
|
x: number;
|
|
y: number;
|
|
z: number;
|
|
}) {
|
|
const projected = new Vector3(point.x, point.y, point.z).project(
|
|
this.camera
|
|
);
|
|
|
|
if (
|
|
!Number.isFinite(projected.x) ||
|
|
!Number.isFinite(projected.y) ||
|
|
!Number.isFinite(projected.z) ||
|
|
projected.z < -1 ||
|
|
projected.z > 1
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
x: projected.x,
|
|
y: projected.y
|
|
};
|
|
}
|
|
|
|
private resolveRuntimeTargetCandidateInLookDirection(
|
|
activeTarget: RuntimeResolvedTarget,
|
|
input: RuntimeTargetLookInput
|
|
): RuntimeTargetCandidate | null {
|
|
const inputLength = Math.hypot(input.horizontal, input.vertical);
|
|
|
|
if (inputLength <= Number.EPSILON) {
|
|
return null;
|
|
}
|
|
|
|
const activeScreenPoint = this.resolveRuntimeTargetScreenPoint(
|
|
activeTarget.center
|
|
);
|
|
|
|
if (activeScreenPoint === null) {
|
|
return null;
|
|
}
|
|
|
|
let bestCandidate: RuntimeTargetCandidate | null = null;
|
|
let bestAlignment = TARGETING_SCREEN_SWITCH_MIN_ALIGNMENT;
|
|
let bestScreenDistance = 0;
|
|
const inputX = input.horizontal / inputLength;
|
|
const inputY = input.vertical / inputLength;
|
|
|
|
for (const candidate of this.runtimeTargetCandidates) {
|
|
if (candidate.entityId === activeTarget.entityId) {
|
|
continue;
|
|
}
|
|
|
|
const candidateScreenPoint = this.resolveRuntimeTargetScreenPoint(
|
|
candidate.center
|
|
);
|
|
|
|
if (
|
|
candidateScreenPoint === null ||
|
|
Math.abs(candidateScreenPoint.x) > TARGETING_SCREEN_SWITCH_MAX_ABS_X ||
|
|
Math.abs(candidateScreenPoint.y) > TARGETING_SCREEN_SWITCH_MAX_ABS_Y
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const screenDeltaX = candidateScreenPoint.x - activeScreenPoint.x;
|
|
const screenDeltaY = candidateScreenPoint.y - activeScreenPoint.y;
|
|
const screenDistance = Math.hypot(screenDeltaX, screenDeltaY);
|
|
|
|
if (screenDistance < TARGETING_SCREEN_SWITCH_MIN_DISTANCE) {
|
|
continue;
|
|
}
|
|
|
|
const alignment =
|
|
(screenDeltaX / screenDistance) * inputX +
|
|
(screenDeltaY / screenDistance) * inputY;
|
|
|
|
if (alignment < TARGETING_SCREEN_SWITCH_MIN_ALIGNMENT) {
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
bestCandidate === null ||
|
|
alignment > bestAlignment ||
|
|
(alignment === bestAlignment && screenDistance > bestScreenDistance) ||
|
|
(alignment === bestAlignment &&
|
|
screenDistance === bestScreenDistance &&
|
|
candidate.score > bestCandidate.score)
|
|
) {
|
|
bestCandidate = candidate;
|
|
bestAlignment = alignment;
|
|
bestScreenDistance = screenDistance;
|
|
}
|
|
}
|
|
|
|
return bestCandidate;
|
|
}
|
|
|
|
private resolveRuntimeTargetCandidateNearestScreenCenter(
|
|
options: {
|
|
exclude?: RuntimeTargetReference | null;
|
|
maxDistanceFromPlayer?: number;
|
|
requirePlayerVisibility?: boolean;
|
|
} = {}
|
|
): RuntimeTargetCandidate | null {
|
|
const exclude = options.exclude ?? null;
|
|
const maxDistanceFromPlayer = options.maxDistanceFromPlayer ?? null;
|
|
const requirePlayerVisibility = options.requirePlayerVisibility ?? false;
|
|
const playerEyePosition =
|
|
maxDistanceFromPlayer === null && !requirePlayerVisibility
|
|
? null
|
|
: (this.currentPlayerControllerTelemetry?.eyePosition ?? null);
|
|
let bestCandidate: RuntimeTargetCandidate | null = null;
|
|
let bestScreenDistanceSquared = Number.POSITIVE_INFINITY;
|
|
|
|
for (const candidate of this.runtimeTargetCandidates) {
|
|
if (
|
|
exclude !== null &&
|
|
candidate.kind === exclude.kind &&
|
|
candidate.entityId === exclude.entityId
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
maxDistanceFromPlayer !== null &&
|
|
playerEyePosition !== null &&
|
|
distanceBetweenPoints(playerEyePosition, candidate.center) >
|
|
maxDistanceFromPlayer
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
requirePlayerVisibility &&
|
|
(playerEyePosition === null ||
|
|
!this.isRuntimeTargetVisibleFrom(playerEyePosition, candidate))
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const screenPoint = this.resolveRuntimeTargetScreenPoint(
|
|
candidate.center
|
|
);
|
|
|
|
if (
|
|
screenPoint === null ||
|
|
Math.abs(screenPoint.x) > TARGETING_SCREEN_PROPOSAL_MAX_ABS_X ||
|
|
Math.abs(screenPoint.y) > TARGETING_SCREEN_PROPOSAL_MAX_ABS_Y
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const screenDistanceSquared =
|
|
screenPoint.x * screenPoint.x +
|
|
(screenPoint.y - TARGETING_SCREEN_PROPOSAL_FOCUS_Y) *
|
|
(screenPoint.y - TARGETING_SCREEN_PROPOSAL_FOCUS_Y);
|
|
|
|
if (
|
|
bestCandidate === null ||
|
|
screenDistanceSquared < bestScreenDistanceSquared ||
|
|
(screenDistanceSquared === bestScreenDistanceSquared &&
|
|
candidate.score > bestCandidate.score)
|
|
) {
|
|
bestCandidate = candidate;
|
|
bestScreenDistanceSquared = screenDistanceSquared;
|
|
}
|
|
}
|
|
|
|
return bestCandidate;
|
|
}
|
|
|
|
private retargetOrClearActiveRuntimeTarget(): boolean {
|
|
if (this.activeRuntimeTargetReference === null) {
|
|
return false;
|
|
}
|
|
|
|
const replacementTarget =
|
|
this.resolveRuntimeTargetCandidateNearestScreenCenter({
|
|
exclude: this.activeRuntimeTargetReference,
|
|
maxDistanceFromPlayer: TARGETING_AUTO_RETARGET_SAFE_DISTANCE,
|
|
requirePlayerVisibility: true
|
|
});
|
|
|
|
if (replacementTarget !== null) {
|
|
this.setActiveRuntimeTargetReference({
|
|
kind: replacementTarget.kind,
|
|
entityId: replacementTarget.entityId
|
|
});
|
|
this.proposedRuntimeTarget = replacementTarget;
|
|
return true;
|
|
}
|
|
|
|
this.setActiveRuntimeTargetReference(null);
|
|
return false;
|
|
}
|
|
|
|
private updateActiveRuntimeTargetLockState(dt = 0) {
|
|
if (
|
|
this.activeRuntimeTargetReference === null ||
|
|
this.currentPlayerControllerTelemetry === null ||
|
|
this.activeController !== this.thirdPersonController
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const activeTarget = this.resolveActiveRuntimeTarget();
|
|
|
|
if (activeTarget === null) {
|
|
this.setActiveRuntimeTargetReference(null);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
distanceBetweenPoints(
|
|
this.currentPlayerControllerTelemetry.eyePosition,
|
|
activeTarget.center
|
|
) > TARGETING_ACTIVE_TARGET_RELEASE_DISTANCE
|
|
) {
|
|
this.retargetOrClearActiveRuntimeTarget();
|
|
return;
|
|
}
|
|
|
|
if (this.isRuntimeTargetCameraVisible(activeTarget)) {
|
|
this.activeRuntimeTargetOcclusionSeconds = 0;
|
|
} else {
|
|
this.activeRuntimeTargetOcclusionSeconds += Math.max(0, dt);
|
|
|
|
if (
|
|
this.activeRuntimeTargetOcclusionSeconds >=
|
|
TARGETING_ACTIVE_OCCLUSION_GRACE_SECONDS
|
|
) {
|
|
this.setActiveRuntimeTargetReference(null);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
private updateRuntimeTargetingInputState() {
|
|
if (this.runtimeScene === null || !this.sceneReady) {
|
|
this.previousTargetCycleInputActive = false;
|
|
return;
|
|
}
|
|
|
|
const targetInputActive = resolveDefaultTargetCycleInput() >= 0.5;
|
|
|
|
if (targetInputActive && !this.previousTargetCycleInputActive) {
|
|
this.activateOrCycleRuntimeTarget();
|
|
}
|
|
|
|
this.previousTargetCycleInputActive = targetInputActive;
|
|
}
|
|
|
|
private updateClearTargetInputState() {
|
|
if (this.runtimeScene === null || !this.sceneReady) {
|
|
this.previousClearTargetInputActive = false;
|
|
return;
|
|
}
|
|
|
|
const clearTargetInputActive =
|
|
resolvePlayerStartClearTargetInput(
|
|
this.pressedKeys,
|
|
this.resolveRuntimePlayerInputBindings()
|
|
) >= 0.5;
|
|
|
|
if (
|
|
this.activeRuntimeTargetReference !== null &&
|
|
clearTargetInputActive &&
|
|
!this.previousClearTargetInputActive
|
|
) {
|
|
this.clearActiveRuntimeTarget();
|
|
}
|
|
|
|
this.previousClearTargetInputActive = clearTargetInputActive;
|
|
}
|
|
|
|
private resolveThirdPersonTargetAssist() {
|
|
if (
|
|
this.runtimeScene === null ||
|
|
this.activeController !== this.thirdPersonController ||
|
|
this.currentDialogue !== null ||
|
|
this.resolveActiveRuntimeCameraRig() !== null ||
|
|
this.resolveDialogueAttentionNpc() !== null
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const activeTarget = this.resolveActiveRuntimeTarget();
|
|
|
|
if (activeTarget !== null) {
|
|
return {
|
|
targetPosition: activeTarget.center,
|
|
strength: ACTIVE_TARGET_CAMERA_ASSIST_STRENGTH
|
|
};
|
|
}
|
|
|
|
// Keep this branch commented instead of deleted; proposed-target nudging may
|
|
// come back later after the Lux readability pass has settled.
|
|
// if (this.proposedRuntimeTarget !== null) {
|
|
// return {
|
|
// targetPosition: this.proposedRuntimeTarget.center,
|
|
// strength: PROPOSED_TARGET_CAMERA_ASSIST_STRENGTH
|
|
// };
|
|
// }
|
|
|
|
return null;
|
|
}
|
|
|
|
private resolveTargetingLuxHomePosition(): Vector3 | null {
|
|
const telemetry = this.currentPlayerControllerTelemetry;
|
|
|
|
if (telemetry === null) {
|
|
return null;
|
|
}
|
|
|
|
this.targetingLuxHomePosition.set(
|
|
lerpScalar(
|
|
telemetry.feetPosition.x,
|
|
telemetry.eyePosition.x,
|
|
TARGETING_LUX_HOME_HEIGHT_FACTOR
|
|
),
|
|
lerpScalar(
|
|
telemetry.feetPosition.y,
|
|
telemetry.eyePosition.y,
|
|
TARGETING_LUX_HOME_HEIGHT_FACTOR
|
|
),
|
|
lerpScalar(
|
|
telemetry.feetPosition.z,
|
|
telemetry.eyePosition.z,
|
|
TARGETING_LUX_HOME_HEIGHT_FACTOR
|
|
)
|
|
);
|
|
|
|
return this.targetingLuxHomePosition;
|
|
}
|
|
|
|
private hideRuntimeTargetingVisuals() {
|
|
this.targetingVisualGroup.visible = false;
|
|
this.targetingLuxGroup.visible = false;
|
|
this.targetingActiveGroup.visible = false;
|
|
this.targetingLuxInitialized = false;
|
|
this.targetingLuxFlightState = "hidden";
|
|
}
|
|
|
|
private updateRuntimeActiveTargetIndicator(
|
|
visualPlacement: ReturnType<typeof resolveRuntimeTargetVisualPlacement>
|
|
) {
|
|
this.targetingActiveGroup.position.set(
|
|
visualPlacement.activeMarkerPosition.x,
|
|
visualPlacement.activeMarkerPosition.y,
|
|
visualPlacement.activeMarkerPosition.z
|
|
);
|
|
this.targetingActiveGroup.quaternion.identity();
|
|
this.targetingActiveGroup.scale.setScalar(
|
|
visualPlacement.activeMarkerScale
|
|
);
|
|
this.targetingActiveCameraRight
|
|
.setFromMatrixColumn(this.camera.matrixWorld, 0)
|
|
.normalize();
|
|
this.targetingActiveCameraUp
|
|
.setFromMatrixColumn(this.camera.matrixWorld, 1)
|
|
.normalize();
|
|
|
|
const orbitAngle =
|
|
this.targetingVisualTime * TARGETING_ACTIVE_ARROW_ORBIT_RATE;
|
|
const localRadius =
|
|
visualPlacement.activeMarkerRadius / visualPlacement.activeMarkerScale;
|
|
|
|
this.targetingActiveArrows.forEach((arrow, index) => {
|
|
const angle =
|
|
orbitAngle + (index / TARGETING_ACTIVE_ARROW_COUNT) * Math.PI * 2;
|
|
arrow.position
|
|
.copy(this.targetingActiveCameraRight)
|
|
.multiplyScalar(Math.cos(angle) * localRadius)
|
|
.addScaledVector(
|
|
this.targetingActiveCameraUp,
|
|
Math.sin(angle) * localRadius
|
|
);
|
|
this.targetingActiveArrowDirection
|
|
.copy(arrow.position)
|
|
.multiplyScalar(-1);
|
|
|
|
if (this.targetingActiveArrowDirection.lengthSq() > Number.EPSILON) {
|
|
this.targetingActiveArrowDirection.normalize();
|
|
arrow.quaternion.setFromUnitVectors(
|
|
this.targetingActiveArrowLocalTipAxis,
|
|
this.targetingActiveArrowDirection
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
private updateRuntimeTargetingVisuals(dt: number) {
|
|
const activeTarget = this.resolveActiveRuntimeTarget();
|
|
const visualTarget = activeTarget ?? this.proposedRuntimeTarget;
|
|
const luxHomePosition = this.resolveTargetingLuxHomePosition();
|
|
const shouldShow =
|
|
visualTarget !== null &&
|
|
this.runtimeScene !== null &&
|
|
this.sceneReady &&
|
|
this.activeController === this.thirdPersonController &&
|
|
this.currentDialogue === null &&
|
|
!this.isActiveExternalCameraSource() &&
|
|
this.resolveActiveRuntimeCameraRig() === null &&
|
|
this.resolveDialogueAttentionNpc() === null;
|
|
const dtSeconds = Math.max(0, dt);
|
|
|
|
if (!shouldShow || visualTarget === null) {
|
|
if (
|
|
this.targetingLuxFlightState !== "hidden" &&
|
|
this.targetingLuxInitialized &&
|
|
luxHomePosition !== null
|
|
) {
|
|
this.targetingLuxFlightState = "returning";
|
|
this.targetingVisualTime += dtSeconds;
|
|
|
|
const returnAlpha =
|
|
1 - Math.exp(-TARGETING_LUX_RETURN_RATE * dtSeconds);
|
|
this.targetingLuxGroup.position.lerp(luxHomePosition, returnAlpha);
|
|
|
|
if (
|
|
this.targetingLuxGroup.position.distanceTo(luxHomePosition) <=
|
|
TARGETING_LUX_HIDE_DISTANCE
|
|
) {
|
|
this.hideRuntimeTargetingVisuals();
|
|
return;
|
|
}
|
|
|
|
const returnPulse =
|
|
0.9 +
|
|
Math.sin(this.targetingVisualTime * TARGETING_LUX_PULSE_RATE) * 0.08;
|
|
this.targetingLuxMesh.scale.setScalar(returnPulse);
|
|
this.targetingLuxGlowMesh.scale.setScalar(
|
|
0.9 + (returnPulse - 0.9) * 1.6
|
|
);
|
|
this.targetingLuxLight.intensity = 0.9;
|
|
this.targetingLuxLight.distance = 2.6;
|
|
this.targetingLuxGroup.lookAt(this.camera.position);
|
|
this.targetingVisualGroup.visible = true;
|
|
this.targetingLuxGroup.visible = true;
|
|
this.targetingActiveGroup.visible = false;
|
|
return;
|
|
}
|
|
|
|
this.hideRuntimeTargetingVisuals();
|
|
return;
|
|
}
|
|
|
|
this.targetingVisualTime += dtSeconds;
|
|
const visualPlacement = resolveRuntimeTargetVisualPlacement(visualTarget);
|
|
const bob =
|
|
Math.sin(this.targetingVisualTime * TARGETING_LUX_BOB_RATE) * 0.08;
|
|
const sway =
|
|
Math.sin(this.targetingVisualTime * TARGETING_LUX_SWAY_RATE) *
|
|
TARGETING_LUX_SWAY_DISTANCE;
|
|
const pulse =
|
|
1 + Math.sin(this.targetingVisualTime * TARGETING_LUX_PULSE_RATE) * 0.12;
|
|
this.targetingLuxSwayDirection
|
|
.setFromMatrixColumn(this.camera.matrixWorld, 0)
|
|
.normalize();
|
|
this.targetingLuxTargetPosition.set(
|
|
visualPlacement.luxPosition.x,
|
|
visualPlacement.luxPosition.y + bob,
|
|
visualPlacement.luxPosition.z
|
|
);
|
|
this.targetingLuxTargetPosition.addScaledVector(
|
|
this.targetingLuxSwayDirection,
|
|
sway
|
|
);
|
|
|
|
if (
|
|
!this.targetingLuxInitialized ||
|
|
this.targetingLuxFlightState === "hidden"
|
|
) {
|
|
this.targetingLuxGroup.position.copy(
|
|
luxHomePosition ?? this.targetingLuxTargetPosition
|
|
);
|
|
this.targetingLuxInitialized = true;
|
|
this.targetingLuxFlightState = "outbound";
|
|
} else if (this.targetingLuxFlightState === "returning") {
|
|
this.targetingLuxFlightState = "outbound";
|
|
}
|
|
|
|
const alpha =
|
|
1 -
|
|
Math.exp(
|
|
-(this.targetingLuxFlightState === "outbound"
|
|
? TARGETING_LUX_FLIGHT_RATE
|
|
: TARGETING_LUX_FOLLOW_RATE) * dtSeconds
|
|
);
|
|
this.targetingLuxGroup.position.lerp(
|
|
this.targetingLuxTargetPosition,
|
|
alpha
|
|
);
|
|
|
|
if (
|
|
this.targetingLuxFlightState === "outbound" &&
|
|
this.targetingLuxGroup.position.distanceTo(
|
|
this.targetingLuxTargetPosition
|
|
) <= TARGETING_LUX_HIDE_DISTANCE
|
|
) {
|
|
this.targetingLuxFlightState = "following";
|
|
}
|
|
|
|
this.targetingLuxMesh.scale.setScalar(pulse);
|
|
this.targetingLuxGlowMesh.scale.setScalar(1.05 + (pulse - 1) * 1.8);
|
|
this.targetingLuxLight.intensity = activeTarget === null ? 1.15 : 1.45;
|
|
this.targetingLuxLight.distance = activeTarget === null ? 3 : 3.6;
|
|
this.targetingLuxGroup.lookAt(this.camera.position);
|
|
this.targetingVisualGroup.visible = true;
|
|
this.targetingLuxGroup.visible = true;
|
|
this.targetingActiveGroup.visible = activeTarget !== null;
|
|
|
|
if (activeTarget !== null) {
|
|
this.updateRuntimeActiveTargetIndicator(visualPlacement);
|
|
}
|
|
}
|
|
|
|
private dispatchRuntimeInteract() {
|
|
if (
|
|
!this.sceneReady ||
|
|
this.runtimeScene === null ||
|
|
(this.activeController !== this.firstPersonController &&
|
|
this.activeController !== this.thirdPersonController)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.audioSystem.handleUserGesture();
|
|
|
|
if (this.currentDialogue !== null) {
|
|
this.advanceRuntimeDialogue();
|
|
return;
|
|
}
|
|
|
|
if (this.isRuntimePaused()) {
|
|
return;
|
|
}
|
|
|
|
if (this.currentInteractionPrompt === null) {
|
|
return;
|
|
}
|
|
|
|
this.interactionSystem.dispatchClickInteraction(
|
|
this.currentInteractionPrompt.sourceEntityId,
|
|
this.runtimeScene,
|
|
this.createInteractionDispatcher()
|
|
);
|
|
}
|
|
|
|
private handleRuntimePointerDown = (event: PointerEvent) => {
|
|
if (!this.sceneReady) {
|
|
return;
|
|
}
|
|
|
|
this.audioSystem.handleUserGesture();
|
|
|
|
if (this.runtimeScene !== null) {
|
|
const pointerBinding = getPlayerStartMouseBindingCodeForButton(
|
|
event.button
|
|
);
|
|
const playerInputBindings = this.resolveRuntimePlayerInputBindings();
|
|
|
|
if (
|
|
pointerBinding !== null &&
|
|
this.activeRuntimeTargetReference !== null &&
|
|
playerInputBindings.keyboard.clearTarget === pointerBinding
|
|
) {
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
this.clearActiveRuntimeTarget();
|
|
return;
|
|
}
|
|
|
|
if (
|
|
pointerBinding !== null &&
|
|
playerInputBindings.keyboard.interact === pointerBinding
|
|
) {
|
|
this.dispatchRuntimeInteract();
|
|
}
|
|
}
|
|
|
|
if (
|
|
this.activeRuntimeCameraRig === null ||
|
|
!this.activeRuntimeCameraRig.lookAround.enabled ||
|
|
this.isRuntimePaused() ||
|
|
event.button !== 0
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this.cameraRigLookDragging = true;
|
|
this.lastCameraRigPointerClientX = event.clientX;
|
|
this.lastCameraRigPointerClientY = event.clientY;
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
};
|
|
|
|
private handleRuntimeKeyDown = (event: KeyboardEvent) => {
|
|
if (this.runtimeScene === null || !this.sceneReady) {
|
|
return;
|
|
}
|
|
|
|
this.pressedKeys.add(event.code);
|
|
|
|
if (
|
|
event.defaultPrevented ||
|
|
event.repeat ||
|
|
event.altKey ||
|
|
event.ctrlKey ||
|
|
event.metaKey ||
|
|
isEditableEventTarget(event.target)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const playerInputBindings = this.resolveRuntimePlayerInputBindings();
|
|
const interactKeyboardBinding = playerInputBindings.keyboard.interact;
|
|
if (
|
|
!isPlayerStartMouseBindingCode(interactKeyboardBinding) &&
|
|
event.code === interactKeyboardBinding
|
|
) {
|
|
event.preventDefault();
|
|
this.dispatchRuntimeInteract();
|
|
this.previousInteractInputActive = true;
|
|
return;
|
|
}
|
|
|
|
if (event.code === "Tab") {
|
|
event.preventDefault();
|
|
this.activateOrCycleRuntimeTarget();
|
|
this.previousTargetCycleInputActive = true;
|
|
return;
|
|
}
|
|
|
|
const clearTargetKeyboardBinding = playerInputBindings.keyboard.clearTarget;
|
|
if (
|
|
this.activeRuntimeTargetReference !== null &&
|
|
!isPlayerStartMouseBindingCode(clearTargetKeyboardBinding) &&
|
|
event.code === clearTargetKeyboardBinding
|
|
) {
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
this.clearActiveRuntimeTarget();
|
|
this.previousClearTargetInputActive = true;
|
|
return;
|
|
}
|
|
|
|
if (event.code === playerInputBindings.keyboard.pauseTime) {
|
|
event.preventDefault();
|
|
this.toggleManualPause();
|
|
this.previousPauseInputActive = true;
|
|
}
|
|
};
|
|
|
|
private handleRuntimeKeyUp = (event: KeyboardEvent) => {
|
|
this.pressedKeys.delete(event.code);
|
|
};
|
|
|
|
private handleRuntimePointerMove = (event: PointerEvent) => {
|
|
if (
|
|
!this.cameraRigLookDragging ||
|
|
this.activeRuntimeCameraRig === null ||
|
|
!this.activeRuntimeCameraRig.lookAround.enabled ||
|
|
this.isRuntimePaused()
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const deltaX = event.clientX - this.lastCameraRigPointerClientX;
|
|
const deltaY = event.clientY - this.lastCameraRigPointerClientY;
|
|
this.lastCameraRigPointerClientX = event.clientX;
|
|
this.lastCameraRigPointerClientY = event.clientY;
|
|
this.cameraRigLookYawRadians = clampScalar(
|
|
this.cameraRigLookYawRadians -
|
|
deltaX * CAMERA_RIG_POINTER_LOOK_SENSITIVITY,
|
|
(-this.activeRuntimeCameraRig.lookAround.yawLimitDegrees * Math.PI) / 180,
|
|
(this.activeRuntimeCameraRig.lookAround.yawLimitDegrees * Math.PI) / 180
|
|
);
|
|
this.cameraRigLookPitchRadians = clampScalar(
|
|
this.cameraRigLookPitchRadians -
|
|
deltaY * CAMERA_RIG_POINTER_LOOK_SENSITIVITY,
|
|
(-this.activeRuntimeCameraRig.lookAround.pitchLimitDegrees * Math.PI) /
|
|
180,
|
|
(this.activeRuntimeCameraRig.lookAround.pitchLimitDegrees * Math.PI) / 180
|
|
);
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
};
|
|
|
|
private handleRuntimePointerUp = (event: PointerEvent) => {
|
|
if (!this.cameraRigLookDragging) {
|
|
return;
|
|
}
|
|
|
|
this.cameraRigLookDragging = false;
|
|
event.stopImmediatePropagation();
|
|
};
|
|
|
|
private handleRuntimeWheel = (event: WheelEvent) => {
|
|
if (this.activeRuntimeCameraRig === null) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
};
|
|
|
|
private handleRuntimeBlur = () => {
|
|
this.pressedKeys.clear();
|
|
this.previousInteractInputActive = false;
|
|
this.previousClearTargetInputActive = false;
|
|
this.previousPauseInputActive = false;
|
|
this.previousTargetCycleInputActive = false;
|
|
this.cameraRigLookDragging = false;
|
|
};
|
|
|
|
private updateInteractInputState() {
|
|
if (this.runtimeScene === null || !this.sceneReady) {
|
|
this.previousInteractInputActive = false;
|
|
return;
|
|
}
|
|
|
|
const interactInputActive =
|
|
resolvePlayerStartInteractInput(
|
|
this.pressedKeys,
|
|
this.resolveRuntimePlayerInputBindings()
|
|
) >= 0.5;
|
|
|
|
if (interactInputActive && !this.previousInteractInputActive) {
|
|
this.dispatchRuntimeInteract();
|
|
}
|
|
|
|
this.previousInteractInputActive = interactInputActive;
|
|
}
|
|
|
|
private updatePauseInputState() {
|
|
if (this.runtimeScene === null || !this.sceneReady) {
|
|
this.previousPauseInputActive = false;
|
|
return;
|
|
}
|
|
|
|
const pauseInputActive =
|
|
resolvePlayerStartPauseInput(
|
|
this.pressedKeys,
|
|
this.resolveRuntimePlayerInputBindings()
|
|
) >= 0.5;
|
|
|
|
if (pauseInputActive && !this.previousPauseInputActive) {
|
|
this.toggleManualPause();
|
|
}
|
|
|
|
this.previousPauseInputActive = pauseInputActive;
|
|
}
|
|
}
|