3041 lines
90 KiB
TypeScript
3041 lines
90 KiB
TypeScript
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;
|
|
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<PlayerStartKeyboardBindings>;
|
|
gamepad?: Partial<PlayerStartGamepadBindings>;
|
|
}
|
|
|
|
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 PlayerStartMovementTemplate {
|
|
kind: PlayerStartMovementTemplateKind;
|
|
moveSpeed: number;
|
|
maxSpeed: number;
|
|
maxStepHeight: number;
|
|
capabilities: PlayerStartMovementCapabilities;
|
|
jump: PlayerStartJumpSettings;
|
|
sprint: PlayerStartSprintSettings;
|
|
crouch: PlayerStartCrouchSettings;
|
|
}
|
|
|
|
export interface PlayerStartMovementTemplateOverrides {
|
|
kind?: PlayerStartMovementTemplateKind;
|
|
moveSpeed?: number;
|
|
maxSpeed?: number;
|
|
maxStepHeight?: number;
|
|
capabilities?: Partial<PlayerStartMovementCapabilities>;
|
|
jump?: Partial<PlayerStartJumpSettings>;
|
|
sprint?: Partial<PlayerStartSprintSettings>;
|
|
crouch?: Partial<PlayerStartCrouchSettings>;
|
|
}
|
|
|
|
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";
|
|
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>): 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_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 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_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_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> = {}
|
|
): 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<string, EntityInstance>
|
|
): PlayerStartEntity | null {
|
|
return (
|
|
getPrimaryEnabledPlayerStartEntity(entities) ??
|
|
getPrimaryPlayerStartEntity(entities)
|
|
);
|
|
}
|
|
|
|
export function resolveCameraRigDocumentTargetPosition(
|
|
target: CameraRigTargetRef,
|
|
entities: Record<string, EntityInstance>
|
|
): 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<string, EntityInstance>,
|
|
paths: Record<string, ScenePath>,
|
|
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<CameraRigBaseEntity, "target" | "targetOffset">,
|
|
entities: Record<string, EntityInstance>
|
|
): 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
|
|
};
|
|
}
|
|
|
|
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)
|
|
};
|
|
}
|
|
|
|
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
|
|
}
|
|
: {
|
|
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
|
|
};
|
|
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
|
|
};
|
|
|
|
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"
|
|
);
|
|
|
|
return {
|
|
kind,
|
|
moveSpeed,
|
|
maxSpeed,
|
|
maxStepHeight,
|
|
capabilities,
|
|
jump,
|
|
sprint,
|
|
crouch
|
|
};
|
|
}
|
|
|
|
export function inferPlayerStartMovementTemplateKind(
|
|
template:
|
|
| Omit<PlayerStartMovementTemplate, "kind">
|
|
| 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
|
|
});
|
|
|
|
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
|
|
) {
|
|
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
|
|
);
|
|
}
|
|
|
|
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<CharacterColliderSettings> = {},
|
|
defaults: Partial<CharacterColliderSettings> = {}
|
|
): 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> = {}
|
|
): PlayerStartColliderSettings {
|
|
return createCharacterColliderSettings("Player Start", overrides);
|
|
}
|
|
|
|
export function createNpcColliderSettings(
|
|
overrides: Partial<NpcColliderSettings> = {}
|
|
): 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<Pick<NpcTimeWindowPresence, "startHour" | "endHour">> = {}
|
|
): 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<PlayerStartColliderSettings>;
|
|
} = {}
|
|
): 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<CameraRigLookAroundSettings>;
|
|
};
|
|
|
|
type RailCameraRigEntityOverrides = Partial<
|
|
Pick<
|
|
RailCameraRigEntity,
|
|
| "id"
|
|
| "name"
|
|
| "visible"
|
|
| "enabled"
|
|
| "rigType"
|
|
| "pathId"
|
|
| "railPlacementMode"
|
|
| "priority"
|
|
| "defaultActive"
|
|
| "target"
|
|
| "targetOffset"
|
|
| "transitionMode"
|
|
| "transitionDurationSeconds"
|
|
>
|
|
> & {
|
|
rigType: "rail";
|
|
lookAround?: Partial<CameraRigLookAroundSettings>;
|
|
};
|
|
|
|
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<CameraRigLookAroundSettings>;
|
|
};
|
|
|
|
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"
|
|
| "modelAssetId"
|
|
| "dialogues"
|
|
| "defaultDialogueId"
|
|
>
|
|
> & {
|
|
collider?: Partial<NpcColliderSettings>;
|
|
} = {}
|
|
): 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 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");
|
|
|
|
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),
|
|
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"
|
|
| "size"
|
|
| "triggerOnEnter"
|
|
| "triggerOnExit"
|
|
>
|
|
> = {}
|
|
): TriggerVolumeEntity {
|
|
const position = cloneVec3(overrides.position ?? DEFAULT_ENTITY_POSITION);
|
|
const size = cloneVec3(overrides.size ?? DEFAULT_TRIGGER_VOLUME_SIZE);
|
|
const triggerOnEnter = overrides.triggerOnEnter ?? true;
|
|
const triggerOnExit = overrides.triggerOnExit ?? false;
|
|
|
|
assertFiniteVec3(position, "Trigger Volume position");
|
|
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,
|
|
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<Extract<EntityInstance, { kind: K }>>;
|
|
} = {
|
|
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<K extends EntityKind>(
|
|
kind: K
|
|
): EntityRegistryEntry<Extract<EntityInstance, { kind: K }>> {
|
|
return ENTITY_REGISTRY[kind];
|
|
}
|
|
|
|
export function createDefaultEntityInstance(
|
|
kind: "playerStart",
|
|
overrides?: Partial<PlayerStartEntity>
|
|
): PlayerStartEntity;
|
|
export function createDefaultEntityInstance(
|
|
kind: "cameraRig",
|
|
overrides?: Partial<CameraRigEntity>
|
|
): CameraRigEntity;
|
|
export function createDefaultEntityInstance(
|
|
kind: "sceneEntry",
|
|
overrides?: Partial<SceneEntryEntity>
|
|
): SceneEntryEntity;
|
|
export function createDefaultEntityInstance(
|
|
kind: "npc",
|
|
overrides?: Partial<NpcEntity>
|
|
): NpcEntity;
|
|
export function createDefaultEntityInstance(
|
|
kind: "pointLight",
|
|
overrides?: Partial<PointLightEntity>
|
|
): PointLightEntity;
|
|
export function createDefaultEntityInstance(
|
|
kind: "spotLight",
|
|
overrides?: Partial<SpotLightEntity>
|
|
): SpotLightEntity;
|
|
export function createDefaultEntityInstance(
|
|
kind: "soundEmitter",
|
|
overrides?: Partial<SoundEmitterEntity>
|
|
): SoundEmitterEntity;
|
|
export function createDefaultEntityInstance(
|
|
kind: "triggerVolume",
|
|
overrides?: Partial<TriggerVolumeEntity>
|
|
): TriggerVolumeEntity;
|
|
export function createDefaultEntityInstance(
|
|
kind: "teleportTarget",
|
|
overrides?: Partial<TeleportTargetEntity>
|
|
): TeleportTargetEntity;
|
|
export function createDefaultEntityInstance(
|
|
kind: "interactable",
|
|
overrides?: Partial<InteractableEntity>
|
|
): InteractableEntity;
|
|
export function createDefaultEntityInstance(
|
|
kind: EntityKind,
|
|
overrides: Partial<EntityInstance> = {}
|
|
): 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<string, EntityInstance>
|
|
): Record<string, EntityInstance> {
|
|
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 &&
|
|
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.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<string, EntityInstance>
|
|
): EntityInstance[] {
|
|
return Object.values(entities).sort(compareEntityInstances);
|
|
}
|
|
|
|
export function getEntitiesOfKind<K extends EntityKind>(
|
|
entities: Record<string, EntityInstance>,
|
|
kind: K
|
|
): Extract<EntityInstance, { kind: K }>[] {
|
|
return getEntityInstances(entities).filter(
|
|
(entity): entity is Extract<EntityInstance, { kind: K }> =>
|
|
entity.kind === kind
|
|
);
|
|
}
|
|
|
|
export function getPlayerStartEntities(
|
|
entities: Record<string, EntityInstance>
|
|
): PlayerStartEntity[] {
|
|
return getEntitiesOfKind(entities, "playerStart");
|
|
}
|
|
|
|
export function getCameraRigEntities(
|
|
entities: Record<string, EntityInstance>
|
|
): CameraRigEntity[] {
|
|
return getEntitiesOfKind(entities, "cameraRig");
|
|
}
|
|
|
|
export function getPrimaryPlayerStartEntity(
|
|
entities: Record<string, EntityInstance>
|
|
): PlayerStartEntity | null {
|
|
return getPlayerStartEntities(entities)[0] ?? null;
|
|
}
|
|
|
|
export function getPrimaryEnabledPlayerStartEntity(
|
|
entities: Record<string, EntityInstance>
|
|
): PlayerStartEntity | null {
|
|
return (
|
|
getPlayerStartEntities(entities).find((entity) => entity.enabled) ?? null
|
|
);
|
|
}
|
|
|
|
export function getEntityKindLabel(kind: EntityKind): string {
|
|
return getEntityRegistryEntry(kind).label;
|
|
}
|