import { createOpaqueId } from "../core/ids"; import type { Vec3 } from "../core/vector"; import { areProjectDialoguesEqual, cloneProjectDialogue, type ProjectDialogue } from "../dialogues/project-dialogues"; import { normalizeTimeOfDayHours } from "../document/project-time-settings"; import { isHexColorString } from "../document/world-settings"; import { mapWorldPointToScenePathProgressBetweenPoints, resolveNearestPointOnResolvedScenePath, resolveScenePath, sampleResolvedScenePathPosition, type ScenePath } from "../document/paths"; interface AuthoredEntityState { id: string; name?: string; visible: boolean; enabled: boolean; } interface PositionedEntity extends AuthoredEntityState { position: Vec3; } export interface PointLightEntity extends PositionedEntity { kind: "pointLight"; colorHex: string; intensity: number; distance: number; } export interface SpotLightEntity extends PositionedEntity { kind: "spotLight"; direction: Vec3; colorHex: string; intensity: number; distance: number; angleDegrees: number; } export interface PlayerStartEntity extends PositionedEntity { kind: "playerStart"; yawDegrees: number; navigationMode: PlayerStartNavigationMode; interactionReachMeters: number; interactionAngleDegrees: number; allowLookInputTargetSwitch: boolean; targetButtonCyclesActiveTarget: boolean; invertMouseCameraHorizontal: boolean; movementTemplate: PlayerStartMovementTemplate; inputBindings: PlayerStartInputBindings; collider: PlayerStartColliderSettings; } export interface SceneEntryEntity extends PositionedEntity { kind: "sceneEntry"; yawDegrees: number; } export const CAMERA_RIG_TYPES = ["fixed", "rail"] as const; export type CameraRigType = (typeof CAMERA_RIG_TYPES)[number]; export const CAMERA_RIG_TARGET_KINDS = [ "player", "actor", "entity", "worldPoint" ] as const; export type CameraRigTargetKind = (typeof CAMERA_RIG_TARGET_KINDS)[number]; export const CAMERA_RIG_TRANSITION_MODES = ["cut", "blend"] as const; export type CameraRigTransitionMode = (typeof CAMERA_RIG_TRANSITION_MODES)[number]; export const CAMERA_RIG_RAIL_PLACEMENT_MODES = [ "nearestToTarget", "mapTargetBetweenPoints" ] as const; export type CameraRigRailPlacementMode = (typeof CAMERA_RIG_RAIL_PLACEMENT_MODES)[number]; export interface CameraRigPlayerTargetRef { kind: "player"; } export interface CameraRigActorTargetRef { kind: "actor"; actorId: string; } export interface CameraRigEntityTargetRef { kind: "entity"; entityId: string; } export interface CameraRigWorldPointTargetRef { kind: "worldPoint"; point: Vec3; } export type CameraRigTargetRef = | CameraRigPlayerTargetRef | CameraRigActorTargetRef | CameraRigEntityTargetRef | CameraRigWorldPointTargetRef; export interface CameraRigLookAroundSettings { enabled: boolean; yawLimitDegrees: number; pitchLimitDegrees: number; recenterSpeed: number; } export interface CameraRigBaseEntity extends AuthoredEntityState { kind: "cameraRig"; priority: number; defaultActive: boolean; target: CameraRigTargetRef; targetOffset: Vec3; transitionMode: CameraRigTransitionMode; transitionDurationSeconds: number; lookAround: CameraRigLookAroundSettings; } export interface FixedCameraRigEntity extends PositionedEntity, CameraRigBaseEntity { rigType: "fixed"; } interface RailCameraRigBaseEntity extends CameraRigBaseEntity { rigType: "rail"; pathId: string; railPlacementMode: CameraRigRailPlacementMode; } export interface NearestRailCameraRigEntity extends RailCameraRigBaseEntity { railPlacementMode: "nearestToTarget"; } export interface MappedRailCameraRigEntity extends RailCameraRigBaseEntity { railPlacementMode: "mapTargetBetweenPoints"; trackStartPoint: Vec3; trackEndPoint: Vec3; railStartProgress: number; railEndProgress: number; } export type RailCameraRigEntity = | NearestRailCameraRigEntity | MappedRailCameraRigEntity; export type CameraRigEntity = FixedCameraRigEntity | RailCameraRigEntity; export interface CharacterColliderSettings { mode: PlayerStartColliderMode; eyeHeight: number; capsuleRadius: number; capsuleHeight: number; boxSize: Vec3; } export interface PlayerStartColliderSettings extends CharacterColliderSettings {} export interface NpcColliderSettings extends CharacterColliderSettings {} export const NPC_PRESENCE_MODES = ["always", "timeWindow"] as const; export type NpcPresenceMode = (typeof NPC_PRESENCE_MODES)[number]; export interface NpcAlwaysPresence { mode: "always"; } export interface NpcTimeWindowPresence { mode: "timeWindow"; startHour: number; endHour: number; } export type NpcPresence = NpcAlwaysPresence | NpcTimeWindowPresence; export interface NpcEntity extends PositionedEntity { kind: "npc"; actorId: string; presence: NpcPresence; yawDegrees: number; scale: Vec3; modelAssetId: string | null; dialogues: ProjectDialogue[]; defaultDialogueId: string | null; collider: NpcColliderSettings; } export const PLAYER_START_COLLIDER_MODES = ["capsule", "box", "none"] as const; export type PlayerStartColliderMode = (typeof PLAYER_START_COLLIDER_MODES)[number]; export const PLAYER_START_NAVIGATION_MODES = [ "firstPerson", "thirdPerson" ] as const; export type PlayerStartNavigationMode = (typeof PLAYER_START_NAVIGATION_MODES)[number]; export const PLAYER_START_MOVEMENT_TEMPLATE_KINDS = [ "default", "responsive", "custom" ] as const; export type PlayerStartMovementTemplateKind = (typeof PLAYER_START_MOVEMENT_TEMPLATE_KINDS)[number]; export const PLAYER_START_MOVEMENT_ACTIONS = [ "moveForward", "moveBackward", "moveLeft", "moveRight" ] as const; export type PlayerStartMovementAction = (typeof PLAYER_START_MOVEMENT_ACTIONS)[number]; export const PLAYER_START_LOCOMOTION_ACTIONS = [ "jump", "sprint", "crouch", "climb" ] as const; export type PlayerStartLocomotionAction = (typeof PLAYER_START_LOCOMOTION_ACTIONS)[number]; export const PLAYER_START_SYSTEM_ACTIONS = [ "interact", "clearTarget", "pauseTime" ] as const; export type PlayerStartSystemAction = (typeof PLAYER_START_SYSTEM_ACTIONS)[number]; export type PlayerStartInputAction = | PlayerStartMovementAction | PlayerStartLocomotionAction | PlayerStartSystemAction; export type PlayerStartKeyboardBindingCode = string; export const PLAYER_START_MOUSE_BINDING_CODES = [ "MouseLeft", "MouseMiddle", "MouseRight", "MouseBack", "MouseForward" ] as const; export type PlayerStartMouseBindingCode = (typeof PLAYER_START_MOUSE_BINDING_CODES)[number]; export const PLAYER_START_GAMEPAD_BINDINGS = [ "leftStickUp", "leftStickDown", "leftStickLeft", "leftStickRight", "dpadUp", "dpadDown", "dpadLeft", "dpadRight" ] as const; export type PlayerStartGamepadBinding = (typeof PLAYER_START_GAMEPAD_BINDINGS)[number]; export const PLAYER_START_GAMEPAD_ACTION_BINDINGS = [ "buttonSouth", "buttonEast", "buttonWest", "buttonNorth", "buttonMenu", "leftShoulder", "rightShoulder", "leftTrigger", "rightTrigger", "leftStickPress", "rightStickPress" ] as const; export type PlayerStartGamepadActionBinding = (typeof PLAYER_START_GAMEPAD_ACTION_BINDINGS)[number]; export const PLAYER_START_GAMEPAD_CAMERA_LOOK_BINDINGS = [ "rightStick" ] as const; export type PlayerStartGamepadCameraLookBinding = (typeof PLAYER_START_GAMEPAD_CAMERA_LOOK_BINDINGS)[number]; export interface PlayerStartKeyboardBindings { moveForward: PlayerStartKeyboardBindingCode; moveBackward: PlayerStartKeyboardBindingCode; moveLeft: PlayerStartKeyboardBindingCode; moveRight: PlayerStartKeyboardBindingCode; jump: PlayerStartKeyboardBindingCode; sprint: PlayerStartKeyboardBindingCode; crouch: PlayerStartKeyboardBindingCode; climb: PlayerStartKeyboardBindingCode; interact: PlayerStartKeyboardBindingCode; clearTarget: PlayerStartKeyboardBindingCode; pauseTime: PlayerStartKeyboardBindingCode; } export interface PlayerStartGamepadBindings { moveForward: PlayerStartGamepadBinding; moveBackward: PlayerStartGamepadBinding; moveLeft: PlayerStartGamepadBinding; moveRight: PlayerStartGamepadBinding; jump: PlayerStartGamepadActionBinding; sprint: PlayerStartGamepadActionBinding; crouch: PlayerStartGamepadActionBinding; climb: PlayerStartGamepadActionBinding; interact: PlayerStartGamepadActionBinding; clearTarget: PlayerStartGamepadActionBinding; pauseTime: PlayerStartGamepadActionBinding; cameraLook: PlayerStartGamepadCameraLookBinding; } export interface PlayerStartInputBindings { keyboard: PlayerStartKeyboardBindings; gamepad: PlayerStartGamepadBindings; } export interface PlayerStartInputBindingOverrides { keyboard?: Partial; gamepad?: Partial; } export interface PlayerStartMovementCapabilities { jump: boolean; sprint: boolean; crouch: boolean; } export interface PlayerStartJumpSettings { speed: number; bufferMs: number; coyoteTimeMs: number; variableHeight: boolean; maxHoldMs: number; moveWhileJumping: boolean; moveWhileFalling: boolean; directionOnly: boolean; bunnyHop: boolean; bunnyHopBoost: number; } export interface PlayerStartSprintSettings { speedMultiplier: number; } export interface PlayerStartCrouchSettings { speedMultiplier: number; } export interface PlayerStartEdgeAssistSettings { enabled: boolean; pushToTopHeight: number; } export interface PlayerStartMovementTemplate { kind: PlayerStartMovementTemplateKind; moveSpeed: number; maxSpeed: number; maxStepHeight: number; capabilities: PlayerStartMovementCapabilities; jump: PlayerStartJumpSettings; sprint: PlayerStartSprintSettings; crouch: PlayerStartCrouchSettings; edgeAssist: PlayerStartEdgeAssistSettings; } export interface PlayerStartMovementTemplateOverrides { kind?: PlayerStartMovementTemplateKind; moveSpeed?: number; maxSpeed?: number; maxStepHeight?: number; capabilities?: Partial; jump?: Partial; sprint?: Partial; crouch?: Partial; edgeAssist?: Partial; } export interface SoundEmitterEntity extends PositionedEntity { kind: "soundEmitter"; audioAssetId: string | null; volume: number; refDistance: number; maxDistance: number; autoplay: boolean; loop: boolean; } export interface TriggerVolumeEntity extends PositionedEntity { kind: "triggerVolume"; rotationDegrees: Vec3; size: Vec3; triggerOnEnter: boolean; triggerOnExit: boolean; } export interface TeleportTargetEntity extends PositionedEntity { kind: "teleportTarget"; yawDegrees: number; } export interface InteractableEntity extends PositionedEntity { kind: "interactable"; radius: number; prompt: string; interactionEnabled: boolean; } export type EntityInstance = | PointLightEntity | SpotLightEntity | PlayerStartEntity | CameraRigEntity | SceneEntryEntity | NpcEntity | SoundEmitterEntity | TriggerVolumeEntity | TeleportTargetEntity | InteractableEntity; export type EntityKind = EntityInstance["kind"]; export interface EntityRegistryEntry< T extends EntityInstance = EntityInstance > { kind: T["kind"]; label: string; description: string; createDefaultEntity(overrides?: Partial): T; } export const ENTITY_KIND_ORDER = [ "pointLight", "spotLight", "playerStart", "cameraRig", "sceneEntry", "npc", "soundEmitter", "triggerVolume", "teleportTarget", "interactable" ] as const; export const DEFAULT_POINT_LIGHT_POSITION: Vec3 = { x: 0, y: 0, z: 0 }; export const DEFAULT_POINT_LIGHT_COLOR_HEX = "#ffffff"; export const DEFAULT_POINT_LIGHT_INTENSITY = 1.25; export const DEFAULT_POINT_LIGHT_DISTANCE = 8; export const DEFAULT_SPOT_LIGHT_POSITION: Vec3 = { x: 0, y: 0, z: 0 }; export const DEFAULT_SPOT_LIGHT_DIRECTION: Vec3 = { x: 0, y: -1, z: 0 }; export const DEFAULT_SPOT_LIGHT_COLOR_HEX = "#ffffff"; export const DEFAULT_SPOT_LIGHT_INTENSITY = 1.5; export const DEFAULT_SPOT_LIGHT_DISTANCE = 12; export const DEFAULT_SPOT_LIGHT_ANGLE_DEGREES = 35; export const DEFAULT_ENTITY_POSITION: Vec3 = { x: 0, y: 0, z: 0 }; export const DEFAULT_ENTITY_VISIBLE = true; export const DEFAULT_ENTITY_ENABLED = true; export const DEFAULT_PLAYER_START_POSITION = DEFAULT_ENTITY_POSITION; export const DEFAULT_PLAYER_START_YAW_DEGREES = 0; export const DEFAULT_CAMERA_RIG_PRIORITY = 0; export const DEFAULT_CAMERA_RIG_DEFAULT_ACTIVE = true; export const DEFAULT_CAMERA_RIG_TARGET_OFFSET: Vec3 = { x: 0, y: 1.4, z: 0 }; export const DEFAULT_CAMERA_RIG_TRANSITION_MODE: CameraRigTransitionMode = "blend"; export const DEFAULT_CAMERA_RIG_TRANSITION_DURATION_SECONDS = 0.35; export const DEFAULT_CAMERA_RIG_RAIL_PLACEMENT_MODE: CameraRigRailPlacementMode = "nearestToTarget"; export const DEFAULT_CAMERA_RIG_TRACK_START_POINT: Vec3 = DEFAULT_ENTITY_POSITION; export const DEFAULT_CAMERA_RIG_TRACK_END_POINT: Vec3 = { x: 1, y: 0, z: 0 }; export const DEFAULT_CAMERA_RIG_RAIL_START_PROGRESS = 0; export const DEFAULT_CAMERA_RIG_RAIL_END_PROGRESS = 1; export const DEFAULT_CAMERA_RIG_LOOK_AROUND_ENABLED = true; export const DEFAULT_CAMERA_RIG_LOOK_AROUND_YAW_LIMIT_DEGREES = 12; export const DEFAULT_CAMERA_RIG_LOOK_AROUND_PITCH_LIMIT_DEGREES = 8; export const DEFAULT_CAMERA_RIG_LOOK_AROUND_RECENTER_SPEED = 3.5; export const DEFAULT_PLAYER_START_NAVIGATION_MODE: PlayerStartNavigationMode = "firstPerson"; export const DEFAULT_PLAYER_START_MOVEMENT_TEMPLATE_KIND: PlayerStartMovementTemplateKind = "default"; export const DEFAULT_PLAYER_START_MOVE_SPEED = 4.5; export const DEFAULT_PLAYER_START_MAX_SPEED = 0; export const DEFAULT_PLAYER_START_MAX_STEP_HEIGHT = 0.35; export const DEFAULT_PLAYER_START_JUMP_SPEED = 7.2; export const DEFAULT_PLAYER_START_JUMP_BUFFER_MS = 0; export const DEFAULT_PLAYER_START_COYOTE_TIME_MS = 0; export const DEFAULT_PLAYER_START_VARIABLE_JUMP_HEIGHT = false; export const DEFAULT_PLAYER_START_VARIABLE_JUMP_MAX_HOLD_MS = 160; export const DEFAULT_PLAYER_START_MOVE_WHILE_JUMPING = true; export const DEFAULT_PLAYER_START_MOVE_WHILE_FALLING = true; export const DEFAULT_PLAYER_START_AIR_DIRECTION_ONLY = false; export const DEFAULT_PLAYER_START_BUNNY_HOP = false; export const DEFAULT_PLAYER_START_BUNNY_HOP_BOOST = 0.05; export const DEFAULT_PLAYER_START_SPRINT_SPEED_MULTIPLIER = 1.65; export const DEFAULT_PLAYER_START_CROUCH_SPEED_MULTIPLIER = 0.45; export const DEFAULT_PLAYER_START_EDGE_ASSIST_ENABLED = true; export const DEFAULT_PLAYER_START_PUSH_TO_TOP_HEIGHT = 0.55; export const DEFAULT_PLAYER_START_MOVEMENT_CAPABILITIES: PlayerStartMovementCapabilities = { jump: true, sprint: true, crouch: true }; export const DEFAULT_PLAYER_START_JUMP_SETTINGS: PlayerStartJumpSettings = { speed: DEFAULT_PLAYER_START_JUMP_SPEED, bufferMs: DEFAULT_PLAYER_START_JUMP_BUFFER_MS, coyoteTimeMs: DEFAULT_PLAYER_START_COYOTE_TIME_MS, variableHeight: DEFAULT_PLAYER_START_VARIABLE_JUMP_HEIGHT, maxHoldMs: DEFAULT_PLAYER_START_VARIABLE_JUMP_MAX_HOLD_MS, moveWhileJumping: DEFAULT_PLAYER_START_MOVE_WHILE_JUMPING, moveWhileFalling: DEFAULT_PLAYER_START_MOVE_WHILE_FALLING, directionOnly: DEFAULT_PLAYER_START_AIR_DIRECTION_ONLY, bunnyHop: DEFAULT_PLAYER_START_BUNNY_HOP, bunnyHopBoost: DEFAULT_PLAYER_START_BUNNY_HOP_BOOST }; export const DEFAULT_PLAYER_START_SPRINT_SETTINGS: PlayerStartSprintSettings = { speedMultiplier: DEFAULT_PLAYER_START_SPRINT_SPEED_MULTIPLIER }; export const DEFAULT_PLAYER_START_CROUCH_SETTINGS: PlayerStartCrouchSettings = { speedMultiplier: DEFAULT_PLAYER_START_CROUCH_SPEED_MULTIPLIER }; export const DEFAULT_PLAYER_START_EDGE_ASSIST_SETTINGS: PlayerStartEdgeAssistSettings = { enabled: DEFAULT_PLAYER_START_EDGE_ASSIST_ENABLED, pushToTopHeight: DEFAULT_PLAYER_START_PUSH_TO_TOP_HEIGHT }; export const RESPONSIVE_PLAYER_START_JUMP_BUFFER_MS = 120; export const RESPONSIVE_PLAYER_START_COYOTE_TIME_MS = 120; export const RESPONSIVE_PLAYER_START_VARIABLE_JUMP_MAX_HOLD_MS = 180; export const RESPONSIVE_PLAYER_START_JUMP_SETTINGS: PlayerStartJumpSettings = { speed: DEFAULT_PLAYER_START_JUMP_SPEED, bufferMs: RESPONSIVE_PLAYER_START_JUMP_BUFFER_MS, coyoteTimeMs: RESPONSIVE_PLAYER_START_COYOTE_TIME_MS, variableHeight: true, maxHoldMs: RESPONSIVE_PLAYER_START_VARIABLE_JUMP_MAX_HOLD_MS, moveWhileJumping: DEFAULT_PLAYER_START_MOVE_WHILE_JUMPING, moveWhileFalling: DEFAULT_PLAYER_START_MOVE_WHILE_FALLING, directionOnly: DEFAULT_PLAYER_START_AIR_DIRECTION_ONLY, bunnyHop: DEFAULT_PLAYER_START_BUNNY_HOP, bunnyHopBoost: DEFAULT_PLAYER_START_BUNNY_HOP_BOOST }; export const DEFAULT_PLAYER_START_KEYBOARD_BINDINGS: PlayerStartKeyboardBindings = { moveForward: "KeyW", moveBackward: "KeyS", moveLeft: "KeyA", moveRight: "KeyD", jump: "Space", sprint: "ShiftLeft", crouch: "ControlLeft", climb: "KeyE", interact: "MouseLeft", clearTarget: "KeyQ", pauseTime: "KeyP" }; export const DEFAULT_PLAYER_START_GAMEPAD_BINDINGS: PlayerStartGamepadBindings = { moveForward: "leftStickUp", moveBackward: "leftStickDown", moveLeft: "leftStickLeft", moveRight: "leftStickRight", jump: "buttonSouth", sprint: "leftStickPress", crouch: "buttonEast", climb: "rightShoulder", interact: "buttonWest", clearTarget: "buttonNorth", pauseTime: "buttonMenu", cameraLook: "rightStick" }; export const DEFAULT_SCENE_ENTRY_YAW_DEGREES = 0; export const DEFAULT_NPC_YAW_DEGREES = 0; export const DEFAULT_NPC_SCALE: Vec3 = { x: 1, y: 1, z: 1 }; export const DEFAULT_NPC_MODEL_ASSET_ID: string | null = null; export const DEFAULT_NPC_DIALOGUE_ID: string | null = null; export const DEFAULT_NPC_COLLIDER_MODE: PlayerStartColliderMode = "capsule"; export const DEFAULT_NPC_TIME_WINDOW_START_HOUR = 9; export const DEFAULT_NPC_TIME_WINDOW_END_HOUR = 17; export const DEFAULT_PLAYER_START_COLLIDER_MODE: PlayerStartColliderMode = "capsule"; export const DEFAULT_PLAYER_START_EYE_HEIGHT = 1.6; export const DEFAULT_PLAYER_START_CAPSULE_RADIUS = 0.3; export const DEFAULT_PLAYER_START_CAPSULE_HEIGHT = 1.8; export const DEFAULT_PLAYER_START_INTERACTION_REACH_METERS = 1.5; export const DEFAULT_PLAYER_START_INTERACTION_ANGLE_DEGREES = 30; export const DEFAULT_PLAYER_START_ALLOW_LOOK_INPUT_TARGET_SWITCH = true; export const DEFAULT_PLAYER_START_TARGET_BUTTON_CYCLES_ACTIVE_TARGET = false; export const DEFAULT_PLAYER_START_INVERT_MOUSE_CAMERA_HORIZONTAL = false; export const DEFAULT_PLAYER_START_BOX_SIZE: Vec3 = { x: 0.6, y: 1.8, z: 0.6 }; export const DEFAULT_SOUND_EMITTER_AUDIO_ASSET_ID: string | null = null; export const DEFAULT_SOUND_EMITTER_VOLUME = 1; export const DEFAULT_SOUND_EMITTER_GAIN = DEFAULT_SOUND_EMITTER_VOLUME; export const DEFAULT_SOUND_EMITTER_REF_DISTANCE = 6; export const DEFAULT_SOUND_EMITTER_RADIUS = DEFAULT_SOUND_EMITTER_REF_DISTANCE; export const DEFAULT_SOUND_EMITTER_MAX_DISTANCE = 24; export const DEFAULT_TRIGGER_VOLUME_SIZE: Vec3 = { x: 2, y: 2, z: 2 }; export const DEFAULT_TRIGGER_VOLUME_ROTATION_DEGREES: Vec3 = { x: 0, y: 0, z: 0 }; export const DEFAULT_TELEPORT_TARGET_YAW_DEGREES = 0; export const DEFAULT_INTERACTABLE_RADIUS = 1.5; export const DEFAULT_INTERACTABLE_PROMPT = "Use"; function cloneVec3(vector: Vec3): Vec3 { return { x: vector.x, y: vector.y, z: vector.z }; } function areVec3Equal(left: Vec3, right: Vec3): boolean { return left.x === right.x && left.y === right.y && left.z === right.z; } function assertFiniteVec3(vector: Vec3, label: string) { if ( !Number.isFinite(vector.x) || !Number.isFinite(vector.y) || !Number.isFinite(vector.z) ) { throw new Error(`${label} must be finite on every axis.`); } } function assertPositiveFiniteNumber(value: number, label: string) { if (!Number.isFinite(value) || value <= 0) { throw new Error(`${label} must be a finite number greater than zero.`); } } function assertPositiveFiniteVec3(vector: Vec3, label: string) { assertFiniteVec3(vector, label); if (vector.x <= 0 || vector.y <= 0 || vector.z <= 0) { throw new Error(`${label} must remain positive on every axis.`); } } function assertNonNegativeFiniteNumber(value: number, label: string) { if (!Number.isFinite(value) || value < 0) { throw new Error( `${label} must be a finite number greater than or equal to zero.` ); } } function assertHexColorString(value: string, label: string) { if (!isHexColorString(value)) { throw new Error(`${label} must use #RRGGBB format.`); } } function assertNonZeroVec3(vector: Vec3, label: string) { if (vector.x === 0 && vector.y === 0 && vector.z === 0) { throw new Error(`${label} must not be the zero vector.`); } } function assertBoolean(value: boolean, label: string) { if (typeof value !== "boolean") { throw new Error(`${label} must be a boolean.`); } } export function isPlayerStartColliderMode( value: string ): value is PlayerStartColliderMode { return PLAYER_START_COLLIDER_MODES.includes(value as PlayerStartColliderMode); } export function isPlayerStartNavigationMode( value: string ): value is PlayerStartNavigationMode { return PLAYER_START_NAVIGATION_MODES.includes( value as PlayerStartNavigationMode ); } export function isCameraRigType(value: string): value is CameraRigType { return CAMERA_RIG_TYPES.includes(value as CameraRigType); } export function isCameraRigTargetKind( value: unknown ): value is CameraRigTargetKind { return ( typeof value === "string" && CAMERA_RIG_TARGET_KINDS.includes(value as CameraRigTargetKind) ); } export function isCameraRigTransitionMode( value: string ): value is CameraRigTransitionMode { return CAMERA_RIG_TRANSITION_MODES.includes(value as CameraRigTransitionMode); } export function isCameraRigRailPlacementMode( value: unknown ): value is CameraRigRailPlacementMode { return ( typeof value === "string" && CAMERA_RIG_RAIL_PLACEMENT_MODES.includes( value as CameraRigRailPlacementMode ) ); } export function isPlayerStartKeyboardBindingCode( value: unknown ): value is PlayerStartKeyboardBindingCode { return typeof value === "string" && value.trim().length > 0; } export function isPlayerStartMouseBindingCode( value: string ): value is PlayerStartMouseBindingCode { return PLAYER_START_MOUSE_BINDING_CODES.includes( value as PlayerStartMouseBindingCode ); } export function getPlayerStartMouseBindingCodeForButton( button: number ): PlayerStartMouseBindingCode | null { switch (button) { case 0: return "MouseLeft"; case 1: return "MouseMiddle"; case 2: return "MouseRight"; case 3: return "MouseBack"; case 4: return "MouseForward"; default: return null; } } export function isPlayerStartGamepadBinding( value: string ): value is PlayerStartGamepadBinding { return PLAYER_START_GAMEPAD_BINDINGS.includes( value as PlayerStartGamepadBinding ); } export function isPlayerStartGamepadActionBinding( value: string ): value is PlayerStartGamepadActionBinding { return PLAYER_START_GAMEPAD_ACTION_BINDINGS.includes( value as PlayerStartGamepadActionBinding ); } export function isPlayerStartGamepadCameraLookBinding( value: string ): value is PlayerStartGamepadCameraLookBinding { return PLAYER_START_GAMEPAD_CAMERA_LOOK_BINDINGS.includes( value as PlayerStartGamepadCameraLookBinding ); } function cloneCharacterColliderSettings( settings: CharacterColliderSettings ): CharacterColliderSettings { return { mode: settings.mode, eyeHeight: settings.eyeHeight, capsuleRadius: settings.capsuleRadius, capsuleHeight: settings.capsuleHeight, boxSize: cloneVec3(settings.boxSize) }; } export function clonePlayerStartColliderSettings( settings: PlayerStartColliderSettings ): PlayerStartColliderSettings { return cloneCharacterColliderSettings(settings); } export function cloneNpcColliderSettings( settings: NpcColliderSettings ): NpcColliderSettings { return cloneCharacterColliderSettings(settings); } function normalizeCameraRigTargetActorId(actorId: string): string { const normalizedActorId = actorId.trim(); if (normalizedActorId.length === 0) { throw new Error( "Camera Rig actor targets must reference a non-empty actor id." ); } return normalizedActorId; } function normalizeCameraRigTargetEntityId(entityId: string): string { const normalizedEntityId = entityId.trim(); if (normalizedEntityId.length === 0) { throw new Error( "Camera Rig entity targets must reference a non-empty entity id." ); } return normalizedEntityId; } function normalizeCameraRigPathId(pathId: string | undefined): string { if (pathId === undefined) { return ""; } return pathId.trim(); } export function createCameraRigPlayerTargetRef(): CameraRigPlayerTargetRef { return { kind: "player" }; } export function createCameraRigActorTargetRef( actorId: string ): CameraRigActorTargetRef { return { kind: "actor", actorId: normalizeCameraRigTargetActorId(actorId) }; } export function createCameraRigEntityTargetRef( entityId: string ): CameraRigEntityTargetRef { return { kind: "entity", entityId: normalizeCameraRigTargetEntityId(entityId) }; } export function createCameraRigWorldPointTargetRef( point: Vec3 = DEFAULT_ENTITY_POSITION ): CameraRigWorldPointTargetRef { const normalizedPoint = cloneVec3(point); assertFiniteVec3(normalizedPoint, "Camera Rig world-point target"); return { kind: "worldPoint", point: normalizedPoint }; } export function cloneCameraRigTargetRef( target: CameraRigTargetRef ): CameraRigTargetRef { switch (target.kind) { case "player": return createCameraRigPlayerTargetRef(); case "actor": return createCameraRigActorTargetRef(target.actorId); case "entity": return createCameraRigEntityTargetRef(target.entityId); case "worldPoint": return createCameraRigWorldPointTargetRef(target.point); } } function normalizeCameraRigTargetRef( target: CameraRigTargetRef | undefined ): CameraRigTargetRef { if (target === undefined) { return createCameraRigPlayerTargetRef(); } return cloneCameraRigTargetRef(target); } export function areCameraRigTargetRefsEqual( left: CameraRigTargetRef, right: CameraRigTargetRef ): boolean { if (left.kind !== right.kind) { return false; } switch (left.kind) { case "player": return true; case "actor": return right.kind === "actor" && left.actorId === right.actorId; case "entity": return right.kind === "entity" && left.entityId === right.entityId; case "worldPoint": return ( right.kind === "worldPoint" && areVec3Equal(left.point, right.point) ); } } export function createCameraRigLookAroundSettings( overrides: Partial = {} ): CameraRigLookAroundSettings { const enabled = overrides.enabled ?? DEFAULT_CAMERA_RIG_LOOK_AROUND_ENABLED; const yawLimitDegrees = overrides.yawLimitDegrees ?? DEFAULT_CAMERA_RIG_LOOK_AROUND_YAW_LIMIT_DEGREES; const pitchLimitDegrees = overrides.pitchLimitDegrees ?? DEFAULT_CAMERA_RIG_LOOK_AROUND_PITCH_LIMIT_DEGREES; const recenterSpeed = overrides.recenterSpeed ?? DEFAULT_CAMERA_RIG_LOOK_AROUND_RECENTER_SPEED; assertBoolean(enabled, "Camera Rig look-around enabled"); assertNonNegativeFiniteNumber( yawLimitDegrees, "Camera Rig look-around yaw limit" ); assertNonNegativeFiniteNumber( pitchLimitDegrees, "Camera Rig look-around pitch limit" ); assertNonNegativeFiniteNumber( recenterSpeed, "Camera Rig look-around recenter speed" ); return { enabled, yawLimitDegrees, pitchLimitDegrees, recenterSpeed }; } export function cloneCameraRigLookAroundSettings( settings: CameraRigLookAroundSettings ): CameraRigLookAroundSettings { return createCameraRigLookAroundSettings(settings); } export function areCameraRigLookAroundSettingsEqual( left: CameraRigLookAroundSettings, right: CameraRigLookAroundSettings ): boolean { return ( left.enabled === right.enabled && left.yawLimitDegrees === right.yawLimitDegrees && left.pitchLimitDegrees === right.pitchLimitDegrees && left.recenterSpeed === right.recenterSpeed ); } function getPrimaryCameraRigDocumentPlayerTarget( entities: Record ): PlayerStartEntity | null { return ( getPrimaryEnabledPlayerStartEntity(entities) ?? getPrimaryPlayerStartEntity(entities) ); } export function resolveCameraRigDocumentTargetPosition( target: CameraRigTargetRef, entities: Record ): Vec3 | null { switch (target.kind) { case "player": return ( getPrimaryCameraRigDocumentPlayerTarget(entities)?.position ?? null ); case "actor": { const enabledNpc = getEntityInstances(entities).find( (entity): entity is NpcEntity => entity.kind === "npc" && entity.enabled && entity.actorId === target.actorId ) ?? null; const fallbackNpc = enabledNpc ?? getEntityInstances(entities).find( (entity): entity is NpcEntity => entity.kind === "npc" && entity.actorId === target.actorId ) ?? null; return fallbackNpc === null ? null : cloneVec3(fallbackNpc.position); } case "entity": { const entity = entities[target.entityId] ?? null; if (entity === null || entity.kind === "cameraRig") { return null; } return cloneVec3(entity.position); } case "worldPoint": return cloneVec3(target.point); } } export function resolveCameraRigDocumentPosition( rig: CameraRigEntity, entities: Record, paths: Record, options: { fallbackToPathStart?: boolean } = {} ): Vec3 | null { switch (rig.rigType) { case "fixed": return cloneVec3(rig.position); case "rail": { const path = paths[rig.pathId] ?? null; if (path === null) { return null; } const resolvedPath = resolveScenePath(path); const baseTarget = resolveCameraRigDocumentTargetPosition( rig.target, entities ); if (baseTarget === null) { if (options.fallbackToPathStart !== true) { return null; } return rig.railPlacementMode === "mapTargetBetweenPoints" ? sampleResolvedScenePathPosition(resolvedPath, rig.railStartProgress) : resolvedPath.points.length > 0 ? cloneVec3(resolvedPath.points[0]!.position) : null; } if (rig.railPlacementMode === "mapTargetBetweenPoints") { const mappedProgress = mapWorldPointToScenePathProgressBetweenPoints({ point: baseTarget, trackStartPoint: rig.trackStartPoint, trackEndPoint: rig.trackEndPoint, railStartProgress: rig.railStartProgress, railEndProgress: rig.railEndProgress }); return sampleResolvedScenePathPosition( resolvedPath, mappedProgress.railProgress ); } return resolveNearestPointOnResolvedScenePath(resolvedPath, baseTarget) .position; } } } export function resolveCameraRigDocumentLookTarget( rig: Pick, entities: Record ): Vec3 | null { const baseTarget = resolveCameraRigDocumentTargetPosition( rig.target, entities ); if (baseTarget === null) { return null; } return { x: baseTarget.x + rig.targetOffset.x, y: baseTarget.y + rig.targetOffset.y, z: baseTarget.z + rig.targetOffset.z }; } function clonePlayerStartMovementCapabilities( capabilities: PlayerStartMovementCapabilities ): PlayerStartMovementCapabilities { return { jump: capabilities.jump, sprint: capabilities.sprint, crouch: capabilities.crouch }; } function clonePlayerStartJumpSettings( settings: PlayerStartJumpSettings ): PlayerStartJumpSettings { return { speed: settings.speed, bufferMs: settings.bufferMs, coyoteTimeMs: settings.coyoteTimeMs, variableHeight: settings.variableHeight, maxHoldMs: settings.maxHoldMs, moveWhileJumping: settings.moveWhileJumping, moveWhileFalling: settings.moveWhileFalling, directionOnly: settings.directionOnly, bunnyHop: settings.bunnyHop, bunnyHopBoost: settings.bunnyHopBoost }; } function clonePlayerStartSprintSettings( settings: PlayerStartSprintSettings ): PlayerStartSprintSettings { return { speedMultiplier: settings.speedMultiplier }; } function clonePlayerStartCrouchSettings( settings: PlayerStartCrouchSettings ): PlayerStartCrouchSettings { return { speedMultiplier: settings.speedMultiplier }; } function clonePlayerStartEdgeAssistSettings( settings: PlayerStartEdgeAssistSettings ): PlayerStartEdgeAssistSettings { return { enabled: settings.enabled, pushToTopHeight: settings.pushToTopHeight }; } export function clonePlayerStartMovementTemplate( template: PlayerStartMovementTemplate ): PlayerStartMovementTemplate { return { kind: template.kind, moveSpeed: template.moveSpeed, maxSpeed: template.maxSpeed, maxStepHeight: template.maxStepHeight, capabilities: clonePlayerStartMovementCapabilities(template.capabilities), jump: clonePlayerStartJumpSettings(template.jump), sprint: clonePlayerStartSprintSettings(template.sprint), crouch: clonePlayerStartCrouchSettings(template.crouch), edgeAssist: clonePlayerStartEdgeAssistSettings(template.edgeAssist) }; } export function clonePlayerStartInputBindings( bindings: PlayerStartInputBindings ): PlayerStartInputBindings { return { keyboard: { moveForward: bindings.keyboard.moveForward, moveBackward: bindings.keyboard.moveBackward, moveLeft: bindings.keyboard.moveLeft, moveRight: bindings.keyboard.moveRight, jump: bindings.keyboard.jump, sprint: bindings.keyboard.sprint, crouch: bindings.keyboard.crouch, climb: bindings.keyboard.climb, interact: bindings.keyboard.interact, clearTarget: bindings.keyboard.clearTarget, pauseTime: bindings.keyboard.pauseTime }, gamepad: { moveForward: bindings.gamepad.moveForward, moveBackward: bindings.gamepad.moveBackward, moveLeft: bindings.gamepad.moveLeft, moveRight: bindings.gamepad.moveRight, jump: bindings.gamepad.jump, sprint: bindings.gamepad.sprint, crouch: bindings.gamepad.crouch, climb: bindings.gamepad.climb, interact: bindings.gamepad.interact, clearTarget: bindings.gamepad.clearTarget, pauseTime: bindings.gamepad.pauseTime, cameraLook: bindings.gamepad.cameraLook } }; } export function createPlayerStartInputBindings( overrides: PlayerStartInputBindingOverrides = {} ): PlayerStartInputBindings { const keyboard: PlayerStartKeyboardBindings = { moveForward: overrides.keyboard?.moveForward ?? DEFAULT_PLAYER_START_KEYBOARD_BINDINGS.moveForward, moveBackward: overrides.keyboard?.moveBackward ?? DEFAULT_PLAYER_START_KEYBOARD_BINDINGS.moveBackward, moveLeft: overrides.keyboard?.moveLeft ?? DEFAULT_PLAYER_START_KEYBOARD_BINDINGS.moveLeft, moveRight: overrides.keyboard?.moveRight ?? DEFAULT_PLAYER_START_KEYBOARD_BINDINGS.moveRight, jump: overrides.keyboard?.jump ?? DEFAULT_PLAYER_START_KEYBOARD_BINDINGS.jump, sprint: overrides.keyboard?.sprint ?? DEFAULT_PLAYER_START_KEYBOARD_BINDINGS.sprint, crouch: overrides.keyboard?.crouch ?? DEFAULT_PLAYER_START_KEYBOARD_BINDINGS.crouch, climb: overrides.keyboard?.climb ?? DEFAULT_PLAYER_START_KEYBOARD_BINDINGS.climb, interact: overrides.keyboard?.interact ?? DEFAULT_PLAYER_START_KEYBOARD_BINDINGS.interact, clearTarget: overrides.keyboard?.clearTarget ?? DEFAULT_PLAYER_START_KEYBOARD_BINDINGS.clearTarget, pauseTime: overrides.keyboard?.pauseTime ?? DEFAULT_PLAYER_START_KEYBOARD_BINDINGS.pauseTime }; const gamepad: PlayerStartGamepadBindings = { moveForward: overrides.gamepad?.moveForward ?? DEFAULT_PLAYER_START_GAMEPAD_BINDINGS.moveForward, moveBackward: overrides.gamepad?.moveBackward ?? DEFAULT_PLAYER_START_GAMEPAD_BINDINGS.moveBackward, moveLeft: overrides.gamepad?.moveLeft ?? DEFAULT_PLAYER_START_GAMEPAD_BINDINGS.moveLeft, moveRight: overrides.gamepad?.moveRight ?? DEFAULT_PLAYER_START_GAMEPAD_BINDINGS.moveRight, jump: overrides.gamepad?.jump ?? DEFAULT_PLAYER_START_GAMEPAD_BINDINGS.jump, sprint: overrides.gamepad?.sprint ?? DEFAULT_PLAYER_START_GAMEPAD_BINDINGS.sprint, crouch: overrides.gamepad?.crouch ?? DEFAULT_PLAYER_START_GAMEPAD_BINDINGS.crouch, climb: overrides.gamepad?.climb ?? DEFAULT_PLAYER_START_GAMEPAD_BINDINGS.climb, interact: overrides.gamepad?.interact ?? DEFAULT_PLAYER_START_GAMEPAD_BINDINGS.interact, clearTarget: overrides.gamepad?.clearTarget ?? DEFAULT_PLAYER_START_GAMEPAD_BINDINGS.clearTarget, pauseTime: overrides.gamepad?.pauseTime ?? DEFAULT_PLAYER_START_GAMEPAD_BINDINGS.pauseTime, cameraLook: overrides.gamepad?.cameraLook ?? DEFAULT_PLAYER_START_GAMEPAD_BINDINGS.cameraLook }; if (!isPlayerStartKeyboardBindingCode(keyboard.moveForward)) { throw new Error( "Player Start move-forward keyboard binding must be supported." ); } if (!isPlayerStartKeyboardBindingCode(keyboard.moveBackward)) { throw new Error( "Player Start move-backward keyboard binding must be supported." ); } if (!isPlayerStartKeyboardBindingCode(keyboard.moveLeft)) { throw new Error( "Player Start move-left keyboard binding must be supported." ); } if (!isPlayerStartKeyboardBindingCode(keyboard.moveRight)) { throw new Error( "Player Start move-right keyboard binding must be supported." ); } if (!isPlayerStartKeyboardBindingCode(keyboard.jump)) { throw new Error("Player Start jump keyboard binding must be supported."); } if (!isPlayerStartKeyboardBindingCode(keyboard.sprint)) { throw new Error("Player Start sprint keyboard binding must be supported."); } if (!isPlayerStartKeyboardBindingCode(keyboard.crouch)) { throw new Error("Player Start crouch keyboard binding must be supported."); } if (!isPlayerStartKeyboardBindingCode(keyboard.climb)) { throw new Error("Player Start climb keyboard binding must be supported."); } if (!isPlayerStartKeyboardBindingCode(keyboard.interact)) { throw new Error( "Player Start interact keyboard binding must be supported." ); } if (!isPlayerStartKeyboardBindingCode(keyboard.clearTarget)) { throw new Error( "Player Start clear-target keyboard binding must be supported." ); } if (!isPlayerStartKeyboardBindingCode(keyboard.pauseTime)) { throw new Error("Player Start pause keyboard binding must be supported."); } if (!isPlayerStartGamepadBinding(gamepad.moveForward)) { throw new Error( "Player Start move-forward gamepad binding must be supported." ); } if (!isPlayerStartGamepadBinding(gamepad.moveBackward)) { throw new Error( "Player Start move-backward gamepad binding must be supported." ); } if (!isPlayerStartGamepadBinding(gamepad.moveLeft)) { throw new Error( "Player Start move-left gamepad binding must be supported." ); } if (!isPlayerStartGamepadBinding(gamepad.moveRight)) { throw new Error( "Player Start move-right gamepad binding must be supported." ); } if (!isPlayerStartGamepadActionBinding(gamepad.jump)) { throw new Error("Player Start jump gamepad binding must be supported."); } if (!isPlayerStartGamepadActionBinding(gamepad.sprint)) { throw new Error("Player Start sprint gamepad binding must be supported."); } if (!isPlayerStartGamepadActionBinding(gamepad.crouch)) { throw new Error("Player Start crouch gamepad binding must be supported."); } if (!isPlayerStartGamepadActionBinding(gamepad.climb)) { throw new Error("Player Start climb gamepad binding must be supported."); } if (!isPlayerStartGamepadActionBinding(gamepad.interact)) { throw new Error("Player Start interact gamepad binding must be supported."); } if (!isPlayerStartGamepadActionBinding(gamepad.clearTarget)) { throw new Error( "Player Start clear-target gamepad binding must be supported." ); } if (!isPlayerStartGamepadActionBinding(gamepad.pauseTime)) { throw new Error("Player Start pause gamepad binding must be supported."); } if (!isPlayerStartGamepadCameraLookBinding(gamepad.cameraLook)) { throw new Error( "Player Start camera-look gamepad binding must be supported." ); } return { keyboard, gamepad }; } export function isPlayerStartMovementTemplateKind( value: string ): value is PlayerStartMovementTemplateKind { return PLAYER_START_MOVEMENT_TEMPLATE_KINDS.includes( value as PlayerStartMovementTemplateKind ); } export function createPlayerStartMovementTemplate( overrides: PlayerStartMovementTemplateOverrides = {} ): PlayerStartMovementTemplate { const kind = overrides.kind ?? DEFAULT_PLAYER_START_MOVEMENT_TEMPLATE_KIND; if (!isPlayerStartMovementTemplateKind(kind)) { throw new Error( "Player Start movement template must be default, responsive, or custom." ); } const preset = kind === "responsive" ? { moveSpeed: DEFAULT_PLAYER_START_MOVE_SPEED, maxSpeed: DEFAULT_PLAYER_START_MAX_SPEED, maxStepHeight: DEFAULT_PLAYER_START_MAX_STEP_HEIGHT, capabilities: DEFAULT_PLAYER_START_MOVEMENT_CAPABILITIES, jump: RESPONSIVE_PLAYER_START_JUMP_SETTINGS, sprint: DEFAULT_PLAYER_START_SPRINT_SETTINGS, crouch: DEFAULT_PLAYER_START_CROUCH_SETTINGS, edgeAssist: DEFAULT_PLAYER_START_EDGE_ASSIST_SETTINGS } : { moveSpeed: DEFAULT_PLAYER_START_MOVE_SPEED, maxSpeed: DEFAULT_PLAYER_START_MAX_SPEED, maxStepHeight: DEFAULT_PLAYER_START_MAX_STEP_HEIGHT, capabilities: DEFAULT_PLAYER_START_MOVEMENT_CAPABILITIES, jump: DEFAULT_PLAYER_START_JUMP_SETTINGS, sprint: DEFAULT_PLAYER_START_SPRINT_SETTINGS, crouch: DEFAULT_PLAYER_START_CROUCH_SETTINGS, edgeAssist: DEFAULT_PLAYER_START_EDGE_ASSIST_SETTINGS }; const moveSpeed = overrides.moveSpeed ?? preset.moveSpeed; const maxSpeed = overrides.maxSpeed ?? preset.maxSpeed; const maxStepHeight = overrides.maxStepHeight ?? preset.maxStepHeight; const capabilities: PlayerStartMovementCapabilities = { jump: overrides.capabilities?.jump ?? preset.capabilities.jump, sprint: overrides.capabilities?.sprint ?? preset.capabilities.sprint, crouch: overrides.capabilities?.crouch ?? preset.capabilities.crouch }; const jump: PlayerStartJumpSettings = { speed: overrides.jump?.speed ?? preset.jump.speed, bufferMs: overrides.jump?.bufferMs ?? preset.jump.bufferMs, coyoteTimeMs: overrides.jump?.coyoteTimeMs ?? preset.jump.coyoteTimeMs, variableHeight: overrides.jump?.variableHeight ?? preset.jump.variableHeight, maxHoldMs: overrides.jump?.maxHoldMs ?? preset.jump.maxHoldMs, moveWhileJumping: overrides.jump?.moveWhileJumping ?? preset.jump.moveWhileJumping, moveWhileFalling: overrides.jump?.moveWhileFalling ?? preset.jump.moveWhileFalling, directionOnly: overrides.jump?.directionOnly ?? preset.jump.directionOnly, bunnyHop: overrides.jump?.bunnyHop ?? preset.jump.bunnyHop, bunnyHopBoost: overrides.jump?.bunnyHopBoost ?? preset.jump.bunnyHopBoost }; const sprint: PlayerStartSprintSettings = { speedMultiplier: overrides.sprint?.speedMultiplier ?? preset.sprint.speedMultiplier }; const crouch: PlayerStartCrouchSettings = { speedMultiplier: overrides.crouch?.speedMultiplier ?? preset.crouch.speedMultiplier }; const edgeAssist: PlayerStartEdgeAssistSettings = { enabled: overrides.edgeAssist?.enabled ?? preset.edgeAssist.enabled, pushToTopHeight: overrides.edgeAssist?.pushToTopHeight ?? preset.edgeAssist.pushToTopHeight }; assertPositiveFiniteNumber(moveSpeed, "Player Start move speed"); assertNonNegativeFiniteNumber(maxSpeed, "Player Start max speed"); assertNonNegativeFiniteNumber(maxStepHeight, "Player Start max step height"); assertBoolean( capabilities.jump, "Player Start movement template jump capability" ); assertBoolean( capabilities.sprint, "Player Start movement template sprint capability" ); assertBoolean( capabilities.crouch, "Player Start movement template crouch capability" ); assertPositiveFiniteNumber(jump.speed, "Player Start jump speed"); assertNonNegativeFiniteNumber( jump.bufferMs, "Player Start jump buffer milliseconds" ); assertNonNegativeFiniteNumber( jump.coyoteTimeMs, "Player Start coyote time milliseconds" ); assertBoolean( jump.variableHeight, "Player Start variable jump height setting" ); assertPositiveFiniteNumber( jump.maxHoldMs, "Player Start variable jump max hold milliseconds" ); assertBoolean( jump.moveWhileJumping, "Player Start move while jumping setting" ); assertBoolean( jump.moveWhileFalling, "Player Start move while falling setting" ); assertBoolean(jump.directionOnly, "Player Start air direction only setting"); assertBoolean(jump.bunnyHop, "Player Start bunny hop setting"); assertNonNegativeFiniteNumber( jump.bunnyHopBoost, "Player Start bunny hop boost" ); assertPositiveFiniteNumber( sprint.speedMultiplier, "Player Start sprint speed multiplier" ); assertPositiveFiniteNumber( crouch.speedMultiplier, "Player Start crouch speed multiplier" ); assertBoolean(edgeAssist.enabled, "Player Start edge assist enabled setting"); assertNonNegativeFiniteNumber( edgeAssist.pushToTopHeight, "Player Start push-to-top height" ); return { kind, moveSpeed, maxSpeed, maxStepHeight, capabilities, jump, sprint, crouch, edgeAssist }; } export function inferPlayerStartMovementTemplateKind( template: | Omit | PlayerStartMovementTemplate ): PlayerStartMovementTemplateKind { const candidate = createPlayerStartMovementTemplate({ kind: "custom", moveSpeed: template.moveSpeed, maxSpeed: template.maxSpeed, maxStepHeight: template.maxStepHeight, capabilities: template.capabilities, jump: template.jump, sprint: template.sprint, crouch: template.crouch, edgeAssist: template.edgeAssist }); for (const presetKind of PLAYER_START_MOVEMENT_TEMPLATE_KINDS) { if (presetKind === "custom") { continue; } if ( candidate.moveSpeed === createPlayerStartMovementTemplate({ kind: presetKind }).moveSpeed && candidate.maxSpeed === createPlayerStartMovementTemplate({ kind: presetKind }).maxSpeed && candidate.maxStepHeight === createPlayerStartMovementTemplate({ kind: presetKind }).maxStepHeight && candidate.capabilities.jump === createPlayerStartMovementTemplate({ kind: presetKind }).capabilities .jump && candidate.capabilities.sprint === createPlayerStartMovementTemplate({ kind: presetKind }).capabilities .sprint && candidate.capabilities.crouch === createPlayerStartMovementTemplate({ kind: presetKind }).capabilities .crouch && candidate.jump.speed === createPlayerStartMovementTemplate({ kind: presetKind }).jump.speed && candidate.jump.bufferMs === createPlayerStartMovementTemplate({ kind: presetKind }).jump.bufferMs && candidate.jump.coyoteTimeMs === createPlayerStartMovementTemplate({ kind: presetKind }).jump .coyoteTimeMs && candidate.jump.variableHeight === createPlayerStartMovementTemplate({ kind: presetKind }).jump .variableHeight && candidate.jump.maxHoldMs === createPlayerStartMovementTemplate({ kind: presetKind }).jump .maxHoldMs && candidate.jump.moveWhileJumping === createPlayerStartMovementTemplate({ kind: presetKind }).jump .moveWhileJumping && candidate.jump.moveWhileFalling === createPlayerStartMovementTemplate({ kind: presetKind }).jump .moveWhileFalling && candidate.jump.directionOnly === createPlayerStartMovementTemplate({ kind: presetKind }).jump .directionOnly && candidate.jump.bunnyHop === createPlayerStartMovementTemplate({ kind: presetKind }).jump.bunnyHop && candidate.jump.bunnyHopBoost === createPlayerStartMovementTemplate({ kind: presetKind }).jump .bunnyHopBoost && candidate.sprint.speedMultiplier === createPlayerStartMovementTemplate({ kind: presetKind }).sprint .speedMultiplier && candidate.crouch.speedMultiplier === createPlayerStartMovementTemplate({ kind: presetKind }).crouch .speedMultiplier && candidate.edgeAssist.enabled === createPlayerStartMovementTemplate({ kind: presetKind }).edgeAssist .enabled && candidate.edgeAssist.pushToTopHeight === createPlayerStartMovementTemplate({ kind: presetKind }).edgeAssist .pushToTopHeight ) { return presetKind; } } return "custom"; } export function arePlayerStartInputBindingsEqual( left: PlayerStartInputBindings, right: PlayerStartInputBindings ): boolean { return ( left.keyboard.moveForward === right.keyboard.moveForward && left.keyboard.moveBackward === right.keyboard.moveBackward && left.keyboard.moveLeft === right.keyboard.moveLeft && left.keyboard.moveRight === right.keyboard.moveRight && left.keyboard.jump === right.keyboard.jump && left.keyboard.sprint === right.keyboard.sprint && left.keyboard.crouch === right.keyboard.crouch && left.keyboard.climb === right.keyboard.climb && left.keyboard.interact === right.keyboard.interact && left.keyboard.clearTarget === right.keyboard.clearTarget && left.keyboard.pauseTime === right.keyboard.pauseTime && left.gamepad.moveForward === right.gamepad.moveForward && left.gamepad.moveBackward === right.gamepad.moveBackward && left.gamepad.moveLeft === right.gamepad.moveLeft && left.gamepad.moveRight === right.gamepad.moveRight && left.gamepad.jump === right.gamepad.jump && left.gamepad.sprint === right.gamepad.sprint && left.gamepad.crouch === right.gamepad.crouch && left.gamepad.climb === right.gamepad.climb && left.gamepad.interact === right.gamepad.interact && left.gamepad.clearTarget === right.gamepad.clearTarget && left.gamepad.pauseTime === right.gamepad.pauseTime && left.gamepad.cameraLook === right.gamepad.cameraLook ); } export function arePlayerStartMovementTemplatesEqual( left: PlayerStartMovementTemplate, right: PlayerStartMovementTemplate ): boolean { return ( left.kind === right.kind && left.moveSpeed === right.moveSpeed && left.maxSpeed === right.maxSpeed && left.maxStepHeight === right.maxStepHeight && left.capabilities.jump === right.capabilities.jump && left.capabilities.sprint === right.capabilities.sprint && left.capabilities.crouch === right.capabilities.crouch && left.jump.speed === right.jump.speed && left.jump.bufferMs === right.jump.bufferMs && left.jump.coyoteTimeMs === right.jump.coyoteTimeMs && left.jump.variableHeight === right.jump.variableHeight && left.jump.maxHoldMs === right.jump.maxHoldMs && left.jump.moveWhileJumping === right.jump.moveWhileJumping && left.jump.moveWhileFalling === right.jump.moveWhileFalling && left.jump.directionOnly === right.jump.directionOnly && left.jump.bunnyHop === right.jump.bunnyHop && left.jump.bunnyHopBoost === right.jump.bunnyHopBoost && left.sprint.speedMultiplier === right.sprint.speedMultiplier && left.crouch.speedMultiplier === right.crouch.speedMultiplier && left.edgeAssist.enabled === right.edgeAssist.enabled && left.edgeAssist.pushToTopHeight === right.edgeAssist.pushToTopHeight ); } function getCharacterColliderHeight( settings: CharacterColliderSettings ): number | null { switch (settings.mode) { case "capsule": return settings.capsuleHeight; case "box": return settings.boxSize.y; case "none": return null; } } export function getPlayerStartColliderHeight( settings: PlayerStartColliderSettings ): number | null { return getCharacterColliderHeight(settings); } export function getNpcColliderHeight( settings: NpcColliderSettings ): number | null { return getCharacterColliderHeight(settings); } function createCharacterColliderSettings( label: string, overrides: Partial = {}, defaults: Partial = {} ): CharacterColliderSettings { const mode = overrides.mode ?? defaults.mode ?? DEFAULT_PLAYER_START_COLLIDER_MODE; const eyeHeight = overrides.eyeHeight ?? defaults.eyeHeight ?? DEFAULT_PLAYER_START_EYE_HEIGHT; const capsuleRadius = overrides.capsuleRadius ?? defaults.capsuleRadius ?? DEFAULT_PLAYER_START_CAPSULE_RADIUS; const capsuleHeight = overrides.capsuleHeight ?? defaults.capsuleHeight ?? DEFAULT_PLAYER_START_CAPSULE_HEIGHT; const boxSize = cloneVec3( overrides.boxSize ?? defaults.boxSize ?? DEFAULT_PLAYER_START_BOX_SIZE ); if (!isPlayerStartColliderMode(mode)) { throw new Error(`${label} collider mode must be capsule, box, or none.`); } assertPositiveFiniteNumber(eyeHeight, `${label} eye height`); assertPositiveFiniteNumber(capsuleRadius, `${label} capsule radius`); assertPositiveFiniteNumber(capsuleHeight, `${label} capsule height`); assertPositiveFiniteVec3(boxSize, `${label} box size`); if (capsuleHeight < capsuleRadius * 2) { throw new Error( `${label} capsule height must be at least twice the capsule radius.` ); } if (mode === "capsule" && eyeHeight > capsuleHeight) { throw new Error( `${label} eye height must be less than or equal to the capsule height.` ); } if (mode === "box" && eyeHeight > boxSize.y) { throw new Error( `${label} eye height must be less than or equal to the box height.` ); } return { mode, eyeHeight, capsuleRadius, capsuleHeight, boxSize }; } export function createPlayerStartColliderSettings( overrides: Partial = {} ): PlayerStartColliderSettings { return createCharacterColliderSettings("Player Start", overrides); } export function createNpcColliderSettings( overrides: Partial = {} ): NpcColliderSettings { return createCharacterColliderSettings("NPC", overrides, { mode: DEFAULT_NPC_COLLIDER_MODE, eyeHeight: DEFAULT_PLAYER_START_EYE_HEIGHT, capsuleRadius: DEFAULT_PLAYER_START_CAPSULE_RADIUS, capsuleHeight: DEFAULT_PLAYER_START_CAPSULE_HEIGHT, boxSize: DEFAULT_PLAYER_START_BOX_SIZE }); } function normalizeSoundEmitterAudioAssetId( audioAssetId: string | null | undefined ): string | null { if (audioAssetId === undefined || audioAssetId === null) { return null; } const trimmedAudioAssetId = audioAssetId.trim(); if (trimmedAudioAssetId.length === 0) { throw new Error( "Sound Emitter audio asset id must be non-empty when authored." ); } return trimmedAudioAssetId; } export function normalizeEntityName( name: string | null | undefined ): string | undefined { if (name === undefined || name === null) { return undefined; } const trimmedName = name.trim(); return trimmedName.length === 0 ? undefined : trimmedName; } function resolveAuthoredEntityVisibility( visible: boolean | undefined ): boolean { const resolvedVisible = visible ?? DEFAULT_ENTITY_VISIBLE; assertBoolean(resolvedVisible, "Entity visible"); return resolvedVisible; } function resolveAuthoredEntityEnabled(enabled: boolean | undefined): boolean { const resolvedEnabled = enabled ?? DEFAULT_ENTITY_ENABLED; assertBoolean(resolvedEnabled, "Entity enabled"); return resolvedEnabled; } export function normalizeYawDegrees(yawDegrees: number): number { const normalizedYaw = yawDegrees % 360; return normalizedYaw < 0 ? normalizedYaw + 360 : normalizedYaw; } export function createNpcActorId(): string { return createOpaqueId("actor"); } export function isNpcPresenceMode(value: unknown): value is NpcPresenceMode { return ( typeof value === "string" && NPC_PRESENCE_MODES.includes(value as NpcPresenceMode) ); } export function createNpcAlwaysPresence(): NpcAlwaysPresence { return { mode: "always" }; } export function createNpcTimeWindowPresence( overrides: Partial> = {} ): NpcTimeWindowPresence { const startHour = normalizeTimeOfDayHours( overrides.startHour ?? DEFAULT_NPC_TIME_WINDOW_START_HOUR ); const endHour = normalizeTimeOfDayHours( overrides.endHour ?? DEFAULT_NPC_TIME_WINDOW_END_HOUR ); if (!Number.isFinite(startHour)) { throw new Error("NPC presence window start hour must be a finite number."); } if (!Number.isFinite(endHour)) { throw new Error("NPC presence window end hour must be a finite number."); } return { mode: "timeWindow", startHour, endHour }; } export function cloneNpcPresence(presence: NpcPresence): NpcPresence { switch (presence.mode) { case "always": return createNpcAlwaysPresence(); case "timeWindow": return createNpcTimeWindowPresence(presence); } } export function areNpcPresencesEqual( left: NpcPresence, right: NpcPresence ): boolean { if (left.mode !== right.mode) { return false; } if (left.mode === "always") { return true; } return ( right.mode === "timeWindow" && left.startHour === right.startHour && left.endHour === right.endHour ); } function normalizeNpcPresence(presence: NpcPresence | undefined): NpcPresence { if (presence === undefined) { return createNpcAlwaysPresence(); } switch (presence.mode) { case "always": return createNpcAlwaysPresence(); case "timeWindow": return createNpcTimeWindowPresence(presence); } } function normalizeNpcActorId(actorId: string | undefined): string { const resolvedActorId = actorId ?? createNpcActorId(); const normalizedActorId = resolvedActorId.trim(); if (normalizedActorId.length === 0) { throw new Error("NPC actorId must be a non-empty string."); } return normalizedActorId; } function normalizeNpcModelAssetId( modelAssetId: string | null | undefined ): string | null { if (modelAssetId === undefined || modelAssetId === null) { return null; } const normalizedModelAssetId = modelAssetId.trim(); return normalizedModelAssetId.length === 0 ? null : normalizedModelAssetId; } function normalizeNpcDialogues( dialogues: ProjectDialogue[] | undefined ): ProjectDialogue[] { if (dialogues === undefined) { return []; } return dialogues.map(cloneProjectDialogue); } function normalizeNpcDefaultDialogueId( defaultDialogueId: string | null | undefined, dialogues: readonly ProjectDialogue[] ): string | null { if (defaultDialogueId === undefined || defaultDialogueId === null) { return null; } const normalizedDefaultDialogueId = defaultDialogueId.trim(); if (normalizedDefaultDialogueId.length === 0) { return null; } return dialogues.some( (dialogue) => dialogue.id === normalizedDefaultDialogueId ) ? normalizedDefaultDialogueId : null; } export function normalizeInteractablePrompt(prompt: string): string { const normalizedPrompt = prompt.trim(); if (normalizedPrompt.length === 0) { throw new Error("Interactable prompt must be non-empty."); } return normalizedPrompt; } export function createPointLightEntity( overrides: Partial< Pick< PointLightEntity, | "id" | "name" | "visible" | "enabled" | "position" | "colorHex" | "intensity" | "distance" > > = {} ): PointLightEntity { const position = cloneVec3( overrides.position ?? DEFAULT_POINT_LIGHT_POSITION ); const colorHex = overrides.colorHex ?? DEFAULT_POINT_LIGHT_COLOR_HEX; const intensity = overrides.intensity ?? DEFAULT_POINT_LIGHT_INTENSITY; const distance = overrides.distance ?? DEFAULT_POINT_LIGHT_DISTANCE; assertFiniteVec3(position, "Point Light position"); assertHexColorString(colorHex, "Point Light color"); assertNonNegativeFiniteNumber(intensity, "Point Light intensity"); assertPositiveFiniteNumber(distance, "Point Light distance"); return { id: overrides.id ?? createOpaqueId("entity-point-light"), kind: "pointLight", name: normalizeEntityName(overrides.name), visible: resolveAuthoredEntityVisibility(overrides.visible), enabled: resolveAuthoredEntityEnabled(overrides.enabled), position, colorHex, intensity, distance }; } export function createSpotLightEntity( overrides: Partial< Pick< SpotLightEntity, | "id" | "name" | "visible" | "enabled" | "position" | "direction" | "colorHex" | "intensity" | "distance" | "angleDegrees" > > = {} ): SpotLightEntity { const position = cloneVec3(overrides.position ?? DEFAULT_SPOT_LIGHT_POSITION); const direction = cloneVec3( overrides.direction ?? DEFAULT_SPOT_LIGHT_DIRECTION ); const colorHex = overrides.colorHex ?? DEFAULT_SPOT_LIGHT_COLOR_HEX; const intensity = overrides.intensity ?? DEFAULT_SPOT_LIGHT_INTENSITY; const distance = overrides.distance ?? DEFAULT_SPOT_LIGHT_DISTANCE; const angleDegrees = overrides.angleDegrees ?? DEFAULT_SPOT_LIGHT_ANGLE_DEGREES; assertFiniteVec3(position, "Spot Light position"); assertFiniteVec3(direction, "Spot Light direction"); assertNonZeroVec3(direction, "Spot Light direction"); assertHexColorString(colorHex, "Spot Light color"); assertNonNegativeFiniteNumber(intensity, "Spot Light intensity"); assertPositiveFiniteNumber(distance, "Spot Light distance"); if ( !Number.isFinite(angleDegrees) || angleDegrees <= 0 || angleDegrees >= 180 ) { throw new Error( "Spot Light angle must be a finite degree value between 0 and 180." ); } return { id: overrides.id ?? createOpaqueId("entity-spot-light"), kind: "spotLight", name: normalizeEntityName(overrides.name), visible: resolveAuthoredEntityVisibility(overrides.visible), enabled: resolveAuthoredEntityEnabled(overrides.enabled), position, direction, colorHex, intensity, distance, angleDegrees }; } export function createPlayerStartEntity( overrides: Partial< Pick< PlayerStartEntity, | "id" | "name" | "visible" | "enabled" | "position" | "yawDegrees" | "navigationMode" | "interactionReachMeters" | "interactionAngleDegrees" | "allowLookInputTargetSwitch" | "targetButtonCyclesActiveTarget" | "invertMouseCameraHorizontal" > > & { movementTemplate?: PlayerStartMovementTemplateOverrides; inputBindings?: PlayerStartInputBindingOverrides; collider?: Partial; } = {} ): PlayerStartEntity { const position = cloneVec3( overrides.position ?? DEFAULT_PLAYER_START_POSITION ); const yawDegrees = overrides.yawDegrees ?? DEFAULT_PLAYER_START_YAW_DEGREES; const navigationMode = overrides.navigationMode ?? DEFAULT_PLAYER_START_NAVIGATION_MODE; const interactionReachMeters = overrides.interactionReachMeters ?? DEFAULT_PLAYER_START_INTERACTION_REACH_METERS; const interactionAngleDegrees = overrides.interactionAngleDegrees ?? DEFAULT_PLAYER_START_INTERACTION_ANGLE_DEGREES; const allowLookInputTargetSwitch = overrides.allowLookInputTargetSwitch ?? DEFAULT_PLAYER_START_ALLOW_LOOK_INPUT_TARGET_SWITCH; const targetButtonCyclesActiveTarget = overrides.targetButtonCyclesActiveTarget ?? DEFAULT_PLAYER_START_TARGET_BUTTON_CYCLES_ACTIVE_TARGET; const invertMouseCameraHorizontal = overrides.invertMouseCameraHorizontal ?? DEFAULT_PLAYER_START_INVERT_MOUSE_CAMERA_HORIZONTAL; const movementTemplate = createPlayerStartMovementTemplate( overrides.movementTemplate ); const inputBindings = createPlayerStartInputBindings(overrides.inputBindings); const collider = createPlayerStartColliderSettings(overrides.collider); assertFiniteVec3(position, "Player Start position"); if (!Number.isFinite(yawDegrees)) { throw new Error("Player Start yaw must be a finite number."); } if (!isPlayerStartNavigationMode(navigationMode)) { throw new Error( "Player Start navigation mode must be firstPerson or thirdPerson." ); } assertPositiveFiniteNumber( interactionReachMeters, "Player Start interaction reach" ); if ( !Number.isFinite(interactionAngleDegrees) || interactionAngleDegrees <= 0 || interactionAngleDegrees >= 180 ) { throw new Error( "Player Start interaction angle must be a finite number greater than zero and less than 180." ); } assertBoolean( allowLookInputTargetSwitch, "Player Start allow-look-input target switch" ); assertBoolean( targetButtonCyclesActiveTarget, "Player Start target-button cycles active target" ); assertBoolean( invertMouseCameraHorizontal, "Player Start invert-mouse-camera horizontal" ); return { id: overrides.id ?? createOpaqueId("entity-player-start"), kind: "playerStart", name: normalizeEntityName(overrides.name), visible: resolveAuthoredEntityVisibility(overrides.visible), enabled: resolveAuthoredEntityEnabled(overrides.enabled), position, yawDegrees: normalizeYawDegrees(yawDegrees), navigationMode, interactionReachMeters, interactionAngleDegrees, allowLookInputTargetSwitch, targetButtonCyclesActiveTarget, invertMouseCameraHorizontal, movementTemplate, inputBindings, collider }; } export function createSceneEntryEntity( overrides: Partial< Pick< SceneEntryEntity, "id" | "name" | "visible" | "enabled" | "position" | "yawDegrees" > > = {} ): SceneEntryEntity { const position = cloneVec3(overrides.position ?? DEFAULT_ENTITY_POSITION); const yawDegrees = overrides.yawDegrees ?? DEFAULT_SCENE_ENTRY_YAW_DEGREES; assertFiniteVec3(position, "Scene Entry position"); if (!Number.isFinite(yawDegrees)) { throw new Error("Scene Entry yaw must be a finite number."); } return { id: overrides.id ?? createOpaqueId("entity-scene-entry"), kind: "sceneEntry", name: normalizeEntityName(overrides.name), visible: resolveAuthoredEntityVisibility(overrides.visible), enabled: resolveAuthoredEntityEnabled(overrides.enabled), position, yawDegrees: normalizeYawDegrees(yawDegrees) }; } type FixedCameraRigEntityOverrides = Partial< Pick< FixedCameraRigEntity, | "id" | "name" | "visible" | "enabled" | "position" | "rigType" | "priority" | "defaultActive" | "target" | "targetOffset" | "transitionMode" | "transitionDurationSeconds" > > & { rigType?: "fixed"; lookAround?: Partial; }; type RailCameraRigEntityOverrides = Partial< Pick< RailCameraRigEntity, | "id" | "name" | "visible" | "enabled" | "rigType" | "pathId" | "railPlacementMode" | "priority" | "defaultActive" | "target" | "targetOffset" | "transitionMode" | "transitionDurationSeconds" > > & { rigType: "rail"; lookAround?: Partial; }; type MappedRailCameraRigEntityOverrides = Partial< Pick< MappedRailCameraRigEntity, | "id" | "name" | "visible" | "enabled" | "rigType" | "pathId" | "railPlacementMode" | "trackStartPoint" | "trackEndPoint" | "railStartProgress" | "railEndProgress" | "priority" | "defaultActive" | "target" | "targetOffset" | "transitionMode" | "transitionDurationSeconds" > > & { rigType: "rail"; railPlacementMode: "mapTargetBetweenPoints"; lookAround?: Partial; }; export type CameraRigEntityOverrides = | FixedCameraRigEntityOverrides | RailCameraRigEntityOverrides | MappedRailCameraRigEntityOverrides; export function createCameraRigEntity( overrides: FixedCameraRigEntityOverrides ): FixedCameraRigEntity; export function createCameraRigEntity( overrides: RailCameraRigEntityOverrides ): RailCameraRigEntity; export function createCameraRigEntity( overrides: MappedRailCameraRigEntityOverrides ): MappedRailCameraRigEntity; export function createCameraRigEntity( overrides?: CameraRigEntityOverrides ): CameraRigEntity; export function createCameraRigEntity( overrides: CameraRigEntityOverrides = {} ): CameraRigEntity { const rigType = overrides.rigType ?? "fixed"; const priority = overrides.priority ?? DEFAULT_CAMERA_RIG_PRIORITY; const defaultActive = overrides.defaultActive ?? DEFAULT_CAMERA_RIG_DEFAULT_ACTIVE; const target = normalizeCameraRigTargetRef(overrides.target); const targetOffset = cloneVec3( overrides.targetOffset ?? DEFAULT_CAMERA_RIG_TARGET_OFFSET ); const transitionMode = overrides.transitionMode ?? DEFAULT_CAMERA_RIG_TRANSITION_MODE; const transitionDurationSeconds = overrides.transitionDurationSeconds ?? DEFAULT_CAMERA_RIG_TRANSITION_DURATION_SECONDS; const lookAround = createCameraRigLookAroundSettings(overrides.lookAround); assertFiniteVec3(targetOffset, "Camera Rig target offset"); assertBoolean(defaultActive, "Camera Rig defaultActive"); assertNonNegativeFiniteNumber(priority, "Camera Rig priority"); assertNonNegativeFiniteNumber( transitionDurationSeconds, "Camera Rig transition duration" ); if (!isCameraRigType(rigType)) { throw new Error("Camera Rig type must be fixed or rail."); } if (!isCameraRigTransitionMode(transitionMode)) { throw new Error("Camera Rig transition mode must be cut or blend."); } const base = { id: overrides.id ?? createOpaqueId("entity-camera-rig"), kind: "cameraRig" as const, name: normalizeEntityName(overrides.name), visible: resolveAuthoredEntityVisibility(overrides.visible), enabled: resolveAuthoredEntityEnabled(overrides.enabled), rigType, priority, defaultActive, target, targetOffset, transitionMode, transitionDurationSeconds, lookAround }; if (rigType === "rail") { const railOverrides = overrides as | RailCameraRigEntityOverrides | MappedRailCameraRigEntityOverrides; const railPlacementMode = railOverrides.railPlacementMode ?? DEFAULT_CAMERA_RIG_RAIL_PLACEMENT_MODE; if (!isCameraRigRailPlacementMode(railPlacementMode)) { throw new Error( "Rail Camera Rig placement mode must be nearestToTarget or mapTargetBetweenPoints." ); } if (railPlacementMode === "mapTargetBetweenPoints") { const mappedRailOverrides = railOverrides as MappedRailCameraRigEntityOverrides; const trackStartPoint = cloneVec3( mappedRailOverrides.trackStartPoint ?? DEFAULT_CAMERA_RIG_TRACK_START_POINT ); const trackEndPoint = cloneVec3( mappedRailOverrides.trackEndPoint ?? DEFAULT_CAMERA_RIG_TRACK_END_POINT ); const railStartProgress = mappedRailOverrides.railStartProgress ?? DEFAULT_CAMERA_RIG_RAIL_START_PROGRESS; const railEndProgress = mappedRailOverrides.railEndProgress ?? DEFAULT_CAMERA_RIG_RAIL_END_PROGRESS; assertFiniteVec3(trackStartPoint, "Rail Camera Rig track start point"); assertFiniteVec3(trackEndPoint, "Rail Camera Rig track end point"); assertNonNegativeFiniteNumber( railStartProgress, "Rail Camera Rig start progress" ); assertNonNegativeFiniteNumber( railEndProgress, "Rail Camera Rig end progress" ); if (railStartProgress > 1 || railEndProgress > 1) { throw new Error( "Rail Camera Rig mapped progress values must remain between 0 and 1." ); } return { ...base, rigType: "rail", pathId: normalizeCameraRigPathId(railOverrides.pathId), railPlacementMode: "mapTargetBetweenPoints", trackStartPoint, trackEndPoint, railStartProgress, railEndProgress }; } return { ...base, rigType: "rail", pathId: normalizeCameraRigPathId(railOverrides.pathId), railPlacementMode: "nearestToTarget" }; } const fixedOverrides = overrides as FixedCameraRigEntityOverrides; const position = cloneVec3( fixedOverrides.position ?? DEFAULT_ENTITY_POSITION ); assertFiniteVec3(position, "Camera Rig position"); return { ...base, rigType: "fixed", position }; } export function createNpcEntity( overrides: Partial< Pick< NpcEntity, | "id" | "name" | "visible" | "enabled" | "position" | "actorId" | "presence" | "yawDegrees" | "scale" | "modelAssetId" | "dialogues" | "defaultDialogueId" > > & { collider?: Partial; } = {} ): NpcEntity { const position = cloneVec3(overrides.position ?? DEFAULT_ENTITY_POSITION); const actorId = normalizeNpcActorId(overrides.actorId); const presence = normalizeNpcPresence(overrides.presence); const yawDegrees = overrides.yawDegrees ?? DEFAULT_NPC_YAW_DEGREES; const scale = cloneVec3(overrides.scale ?? DEFAULT_NPC_SCALE); const modelAssetId = normalizeNpcModelAssetId( overrides.modelAssetId ?? DEFAULT_NPC_MODEL_ASSET_ID ); const dialogues = normalizeNpcDialogues(overrides.dialogues); const defaultDialogueId = normalizeNpcDefaultDialogueId( overrides.defaultDialogueId ?? DEFAULT_NPC_DIALOGUE_ID, dialogues ); const collider = createNpcColliderSettings(overrides.collider); assertFiniteVec3(position, "NPC position"); assertPositiveFiniteVec3(scale, "NPC scale"); if (!Number.isFinite(yawDegrees)) { throw new Error("NPC yaw must be a finite number."); } return { id: overrides.id ?? createOpaqueId("entity-npc"), kind: "npc", name: normalizeEntityName(overrides.name), visible: resolveAuthoredEntityVisibility(overrides.visible), enabled: resolveAuthoredEntityEnabled(overrides.enabled), position, actorId, presence, yawDegrees: normalizeYawDegrees(yawDegrees), scale, modelAssetId, dialogues, defaultDialogueId, collider }; } export function createSoundEmitterEntity( overrides: Partial< Pick< SoundEmitterEntity, | "id" | "name" | "visible" | "enabled" | "position" | "audioAssetId" | "volume" | "refDistance" | "maxDistance" | "autoplay" | "loop" > > = {} ): SoundEmitterEntity { const position = cloneVec3(overrides.position ?? DEFAULT_ENTITY_POSITION); const audioAssetId = normalizeSoundEmitterAudioAssetId( overrides.audioAssetId ?? DEFAULT_SOUND_EMITTER_AUDIO_ASSET_ID ); const volume = overrides.volume ?? DEFAULT_SOUND_EMITTER_VOLUME; const refDistance = overrides.refDistance ?? DEFAULT_SOUND_EMITTER_REF_DISTANCE; const maxDistance = overrides.maxDistance ?? DEFAULT_SOUND_EMITTER_MAX_DISTANCE; const autoplay = overrides.autoplay ?? false; const loop = overrides.loop ?? false; assertFiniteVec3(position, "Sound Emitter position"); assertNonNegativeFiniteNumber(volume, "Sound Emitter volume"); assertPositiveFiniteNumber(refDistance, "Sound Emitter ref distance"); assertPositiveFiniteNumber(maxDistance, "Sound Emitter max distance"); if (maxDistance < refDistance) { throw new Error( "Sound Emitter max distance must be greater than or equal to ref distance." ); } assertBoolean(autoplay, "Sound Emitter autoplay"); assertBoolean(loop, "Sound Emitter loop"); return { id: overrides.id ?? createOpaqueId("entity-sound-emitter"), kind: "soundEmitter", name: normalizeEntityName(overrides.name), visible: resolveAuthoredEntityVisibility(overrides.visible), enabled: resolveAuthoredEntityEnabled(overrides.enabled), position, audioAssetId, volume, refDistance, maxDistance, autoplay, loop }; } export function createTriggerVolumeEntity( overrides: Partial< Pick< TriggerVolumeEntity, | "id" | "name" | "visible" | "enabled" | "position" | "rotationDegrees" | "size" | "triggerOnEnter" | "triggerOnExit" > > = {} ): TriggerVolumeEntity { const position = cloneVec3(overrides.position ?? DEFAULT_ENTITY_POSITION); const rotationDegrees = cloneVec3( overrides.rotationDegrees ?? DEFAULT_TRIGGER_VOLUME_ROTATION_DEGREES ); const size = cloneVec3(overrides.size ?? DEFAULT_TRIGGER_VOLUME_SIZE); const triggerOnEnter = overrides.triggerOnEnter ?? true; const triggerOnExit = overrides.triggerOnExit ?? false; assertFiniteVec3(position, "Trigger Volume position"); assertFiniteVec3(rotationDegrees, "Trigger Volume rotation"); assertPositiveFiniteVec3(size, "Trigger Volume size"); assertBoolean(triggerOnEnter, "Trigger Volume triggerOnEnter"); assertBoolean(triggerOnExit, "Trigger Volume triggerOnExit"); return { id: overrides.id ?? createOpaqueId("entity-trigger-volume"), kind: "triggerVolume", name: normalizeEntityName(overrides.name), visible: resolveAuthoredEntityVisibility(overrides.visible), enabled: resolveAuthoredEntityEnabled(overrides.enabled), position, rotationDegrees, size, triggerOnEnter, triggerOnExit }; } export function createTeleportTargetEntity( overrides: Partial< Pick< TeleportTargetEntity, "id" | "name" | "visible" | "enabled" | "position" | "yawDegrees" > > = {} ): TeleportTargetEntity { const position = cloneVec3(overrides.position ?? DEFAULT_ENTITY_POSITION); const yawDegrees = overrides.yawDegrees ?? DEFAULT_TELEPORT_TARGET_YAW_DEGREES; assertFiniteVec3(position, "Teleport Target position"); if (!Number.isFinite(yawDegrees)) { throw new Error("Teleport Target yaw must be a finite number."); } return { id: overrides.id ?? createOpaqueId("entity-teleport-target"), kind: "teleportTarget", name: normalizeEntityName(overrides.name), visible: resolveAuthoredEntityVisibility(overrides.visible), enabled: resolveAuthoredEntityEnabled(overrides.enabled), position, yawDegrees: normalizeYawDegrees(yawDegrees) }; } export function createInteractableEntity( overrides: Partial< Pick< InteractableEntity, | "id" | "name" | "visible" | "enabled" | "position" | "radius" | "prompt" | "interactionEnabled" > > = {} ): InteractableEntity { const position = cloneVec3(overrides.position ?? DEFAULT_ENTITY_POSITION); const radius = overrides.radius ?? DEFAULT_INTERACTABLE_RADIUS; const prompt = normalizeInteractablePrompt( overrides.prompt ?? DEFAULT_INTERACTABLE_PROMPT ); const interactionEnabled = overrides.interactionEnabled ?? true; assertFiniteVec3(position, "Interactable position"); assertPositiveFiniteNumber(radius, "Interactable radius"); assertBoolean(interactionEnabled, "Interactable interactionEnabled"); return { id: overrides.id ?? createOpaqueId("entity-interactable"), kind: "interactable", name: normalizeEntityName(overrides.name), visible: resolveAuthoredEntityVisibility(overrides.visible), enabled: resolveAuthoredEntityEnabled(overrides.enabled), position, radius, prompt, interactionEnabled }; } export const ENTITY_REGISTRY: { [K in EntityKind]: EntityRegistryEntry>; } = { pointLight: { kind: "pointLight", label: "Point Light", description: "Authored local point light that illuminates nearby geometry in a spherical radius.", createDefaultEntity: createPointLightEntity }, spotLight: { kind: "spotLight", label: "Spot Light", description: "Authored local spotlight with an explicit direction and cone angle.", createDefaultEntity: createSpotLightEntity }, playerStart: { kind: "playerStart", label: "Player Start", description: "Primary authored spawn point for first-person or third-person runtime navigation.", createDefaultEntity: createPlayerStartEntity }, cameraRig: { kind: "cameraRig", label: "Camera Rig", description: "Authored runtime camera framing rig that can lock from a fixed world position or a scene path onto a typed target.", createDefaultEntity: (overrides) => createCameraRigEntity(overrides as CameraRigEntityOverrides) }, sceneEntry: { kind: "sceneEntry", label: "Scene Entry", description: "Explicit authored scene-transition arrival point with a facing direction.", createDefaultEntity: createSceneEntryEntity }, npc: { kind: "npc", label: "NPC", description: "Typed actor entity with a stable authored actor id and optional model visual for runner presence.", createDefaultEntity: createNpcEntity }, soundEmitter: { kind: "soundEmitter", label: "Sound Emitter", description: "Authored positional audio source wired to an audio asset and configurable for looping, volume, and distance falloff.", createDefaultEntity: createSoundEmitterEntity }, triggerVolume: { kind: "triggerVolume", label: "Trigger Volume", description: "Axis-aligned authored trigger volume for enter and exit events.", createDefaultEntity: createTriggerVolumeEntity }, teleportTarget: { kind: "teleportTarget", label: "Teleport Target", description: "Explicit authored teleport destination with a facing direction.", createDefaultEntity: createTeleportTargetEntity }, interactable: { kind: "interactable", label: "Interactable", description: "Explicit authored interaction point for later click and use behavior.", createDefaultEntity: createInteractableEntity } }; export function isEntityKind(value: unknown): value is EntityKind { return ( typeof value === "string" && Object.prototype.hasOwnProperty.call(ENTITY_REGISTRY, value) ); } export function getEntityRegistryEntry( kind: K ): EntityRegistryEntry> { return ENTITY_REGISTRY[kind]; } export function createDefaultEntityInstance( kind: "playerStart", overrides?: Partial ): PlayerStartEntity; export function createDefaultEntityInstance( kind: "cameraRig", overrides?: Partial ): CameraRigEntity; export function createDefaultEntityInstance( kind: "sceneEntry", overrides?: Partial ): SceneEntryEntity; export function createDefaultEntityInstance( kind: "npc", overrides?: Partial ): NpcEntity; export function createDefaultEntityInstance( kind: "pointLight", overrides?: Partial ): PointLightEntity; export function createDefaultEntityInstance( kind: "spotLight", overrides?: Partial ): SpotLightEntity; export function createDefaultEntityInstance( kind: "soundEmitter", overrides?: Partial ): SoundEmitterEntity; export function createDefaultEntityInstance( kind: "triggerVolume", overrides?: Partial ): TriggerVolumeEntity; export function createDefaultEntityInstance( kind: "teleportTarget", overrides?: Partial ): TeleportTargetEntity; export function createDefaultEntityInstance( kind: "interactable", overrides?: Partial ): InteractableEntity; export function createDefaultEntityInstance( kind: EntityKind, overrides: Partial = {} ): EntityInstance { switch (kind) { case "pointLight": return createPointLightEntity(overrides); case "spotLight": return createSpotLightEntity(overrides); case "playerStart": return createPlayerStartEntity(overrides); case "cameraRig": return createCameraRigEntity(overrides as CameraRigEntityOverrides); case "sceneEntry": return createSceneEntryEntity(overrides); case "npc": return createNpcEntity(overrides); case "soundEmitter": return createSoundEmitterEntity(overrides); case "triggerVolume": return createTriggerVolumeEntity(overrides); case "teleportTarget": return createTeleportTargetEntity(overrides); case "interactable": return createInteractableEntity(overrides); } } export function cloneEntityInstance(entity: EntityInstance): EntityInstance { switch (entity.kind) { case "pointLight": return createPointLightEntity(entity); case "spotLight": return createSpotLightEntity(entity); case "playerStart": return createPlayerStartEntity(entity); case "cameraRig": return createCameraRigEntity(entity); case "sceneEntry": return createSceneEntryEntity(entity); case "npc": return createNpcEntity(entity); case "soundEmitter": return createSoundEmitterEntity(entity); case "triggerVolume": return createTriggerVolumeEntity(entity); case "teleportTarget": return createTeleportTargetEntity(entity); case "interactable": return createInteractableEntity(entity); } } export function cloneEntityRegistry( entities: Record ): Record { return Object.fromEntries( Object.entries(entities).map(([entityId, entity]) => [ entityId, cloneEntityInstance(entity) ]) ); } export function areEntityInstancesEqual( left: EntityInstance, right: EntityInstance ): boolean { if ( left.kind !== right.kind || left.id !== right.id || left.name !== right.name || left.visible !== right.visible || left.enabled !== right.enabled ) { return false; } switch (left.kind) { case "pointLight": { const typedRight = right as PointLightEntity; return ( areVec3Equal(left.position, typedRight.position) && left.colorHex === typedRight.colorHex && left.intensity === typedRight.intensity && left.distance === typedRight.distance ); } case "spotLight": { const typedRight = right as SpotLightEntity; return ( areVec3Equal(left.position, typedRight.position) && areVec3Equal(left.direction, typedRight.direction) && left.colorHex === typedRight.colorHex && left.intensity === typedRight.intensity && left.distance === typedRight.distance && left.angleDegrees === typedRight.angleDegrees ); } case "playerStart": { const typedRight = right as PlayerStartEntity; return ( areVec3Equal(left.position, typedRight.position) && left.yawDegrees === typedRight.yawDegrees && left.navigationMode === typedRight.navigationMode && left.interactionReachMeters === typedRight.interactionReachMeters && left.interactionAngleDegrees === typedRight.interactionAngleDegrees && left.allowLookInputTargetSwitch === typedRight.allowLookInputTargetSwitch && left.targetButtonCyclesActiveTarget === typedRight.targetButtonCyclesActiveTarget && left.invertMouseCameraHorizontal === typedRight.invertMouseCameraHorizontal && arePlayerStartMovementTemplatesEqual( left.movementTemplate, typedRight.movementTemplate ) && arePlayerStartInputBindingsEqual( left.inputBindings, typedRight.inputBindings ) && left.collider.mode === typedRight.collider.mode && left.collider.eyeHeight === typedRight.collider.eyeHeight && left.collider.capsuleRadius === typedRight.collider.capsuleRadius && left.collider.capsuleHeight === typedRight.collider.capsuleHeight && areVec3Equal(left.collider.boxSize, typedRight.collider.boxSize) ); } case "cameraRig": { const typedRight = right as CameraRigEntity; return ( left.rigType === typedRight.rigType && (left.rigType === "fixed" ? typedRight.rigType === "fixed" && areVec3Equal(left.position, typedRight.position) : typedRight.rigType === "rail" && left.pathId === typedRight.pathId && left.railPlacementMode === typedRight.railPlacementMode && (left.railPlacementMode === "mapTargetBetweenPoints" ? typedRight.railPlacementMode === "mapTargetBetweenPoints" && areVec3Equal( left.trackStartPoint, typedRight.trackStartPoint ) && areVec3Equal(left.trackEndPoint, typedRight.trackEndPoint) && left.railStartProgress === typedRight.railStartProgress && left.railEndProgress === typedRight.railEndProgress : true)) && left.priority === typedRight.priority && left.defaultActive === typedRight.defaultActive && areCameraRigTargetRefsEqual(left.target, typedRight.target) && areVec3Equal(left.targetOffset, typedRight.targetOffset) && left.transitionMode === typedRight.transitionMode && left.transitionDurationSeconds === typedRight.transitionDurationSeconds && areCameraRigLookAroundSettingsEqual( left.lookAround, typedRight.lookAround ) ); } case "sceneEntry": { const typedRight = right as SceneEntryEntity; return ( areVec3Equal(left.position, typedRight.position) && left.yawDegrees === typedRight.yawDegrees ); } case "npc": { const typedRight = right as NpcEntity; return ( areVec3Equal(left.position, typedRight.position) && left.actorId === typedRight.actorId && areNpcPresencesEqual(left.presence, typedRight.presence) && left.yawDegrees === typedRight.yawDegrees && areVec3Equal(left.scale, typedRight.scale) && left.modelAssetId === typedRight.modelAssetId && left.defaultDialogueId === typedRight.defaultDialogueId && left.dialogues.length === typedRight.dialogues.length && left.dialogues.every((dialogue, index) => areProjectDialoguesEqual(dialogue, typedRight.dialogues[index]!) ) && left.collider.mode === typedRight.collider.mode && left.collider.eyeHeight === typedRight.collider.eyeHeight && left.collider.capsuleRadius === typedRight.collider.capsuleRadius && left.collider.capsuleHeight === typedRight.collider.capsuleHeight && areVec3Equal(left.collider.boxSize, typedRight.collider.boxSize) ); } case "soundEmitter": { const typedRight = right as SoundEmitterEntity; return ( areVec3Equal(left.position, typedRight.position) && left.audioAssetId === typedRight.audioAssetId && left.volume === typedRight.volume && left.refDistance === typedRight.refDistance && left.maxDistance === typedRight.maxDistance && left.autoplay === typedRight.autoplay && left.loop === typedRight.loop ); } case "triggerVolume": { const typedRight = right as TriggerVolumeEntity; return ( areVec3Equal(left.position, typedRight.position) && areVec3Equal(left.rotationDegrees, typedRight.rotationDegrees) && areVec3Equal(left.size, typedRight.size) && left.triggerOnEnter === typedRight.triggerOnEnter && left.triggerOnExit === typedRight.triggerOnExit ); } case "teleportTarget": { const typedRight = right as TeleportTargetEntity; return ( areVec3Equal(left.position, typedRight.position) && left.yawDegrees === typedRight.yawDegrees ); } case "interactable": { const typedRight = right as InteractableEntity; return ( areVec3Equal(left.position, typedRight.position) && left.radius === typedRight.radius && left.prompt === typedRight.prompt && left.interactionEnabled === typedRight.interactionEnabled ); } } } export function compareEntityInstances( left: EntityInstance, right: EntityInstance ): number { const leftOrder = ENTITY_KIND_ORDER.indexOf(left.kind); const rightOrder = ENTITY_KIND_ORDER.indexOf(right.kind); if (leftOrder !== rightOrder) { return leftOrder - rightOrder; } return left.id.localeCompare(right.id); } export function getEntityInstances( entities: Record ): EntityInstance[] { return Object.values(entities).sort(compareEntityInstances); } export function getEntitiesOfKind( entities: Record, kind: K ): Extract[] { return getEntityInstances(entities).filter( (entity): entity is Extract => entity.kind === kind ); } export function getPlayerStartEntities( entities: Record ): PlayerStartEntity[] { return getEntitiesOfKind(entities, "playerStart"); } export function getCameraRigEntities( entities: Record ): CameraRigEntity[] { return getEntitiesOfKind(entities, "cameraRig"); } export function getPrimaryPlayerStartEntity( entities: Record ): PlayerStartEntity | null { return getPlayerStartEntities(entities)[0] ?? null; } export function getPrimaryEnabledPlayerStartEntity( entities: Record ): PlayerStartEntity | null { return ( getPlayerStartEntities(entities).find((entity) => entity.enabled) ?? null ); } export function getEntityKindLabel(kind: EntityKind): string { return getEntityRegistryEntry(kind).label; }