Add NPC entity support with creation and normalization functions

This commit is contained in:
2026-04-13 16:16:59 +02:00
parent 0904ff8bfb
commit 7f76024628

View File

@@ -40,6 +40,13 @@ export interface SceneEntryEntity extends PositionedEntity {
yawDegrees: number;
}
export interface NpcEntity extends PositionedEntity {
kind: "npc";
actorId: string;
yawDegrees: number;
modelAssetId: string | null;
}
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 = [
@@ -237,6 +244,7 @@ export type EntityInstance =
| SpotLightEntity
| PlayerStartEntity
| SceneEntryEntity
| NpcEntity
| SoundEmitterEntity
| TriggerVolumeEntity
| TeleportTargetEntity
@@ -257,6 +265,7 @@ export const ENTITY_KIND_ORDER = [
"spotLight",
"playerStart",
"sceneEntry",
"npc",
"soundEmitter",
"triggerVolume",
"teleportTarget",
@@ -377,6 +386,8 @@ export const DEFAULT_PLAYER_START_GAMEPAD_BINDINGS: PlayerStartGamepadBindings =
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_PLAYER_START_COLLIDER_MODE: PlayerStartColliderMode = "capsule";
export const DEFAULT_PLAYER_START_EYE_HEIGHT = 1.6;
export const DEFAULT_PLAYER_START_CAPSULE_RADIUS = 0.3;
@@ -1086,6 +1097,32 @@ export function normalizeYawDegrees(yawDegrees: number): number {
return normalizedYaw < 0 ? normalizedYaw + 360 : normalizedYaw;
}
export function createNpcActorId(): string {
return createOpaqueId("actor");
}
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;
}
export function normalizeInteractablePrompt(prompt: string): string {
const normalizedPrompt = prompt.trim();
@@ -1230,6 +1267,47 @@ export function createSceneEntryEntity(
};
}
export function createNpcEntity(
overrides: Partial<
Pick<
NpcEntity,
| "id"
| "name"
| "visible"
| "enabled"
| "position"
| "actorId"
| "yawDegrees"
| "modelAssetId"
>
> = {}
): NpcEntity {
const position = cloneVec3(overrides.position ?? DEFAULT_ENTITY_POSITION);
const actorId = normalizeNpcActorId(overrides.actorId);
const yawDegrees = overrides.yawDegrees ?? DEFAULT_NPC_YAW_DEGREES;
const modelAssetId = normalizeNpcModelAssetId(
overrides.modelAssetId ?? DEFAULT_NPC_MODEL_ASSET_ID
);
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,
yawDegrees: normalizeYawDegrees(yawDegrees),
modelAssetId
};
}
export function createSoundEmitterEntity(
overrides: Partial<
Pick<
@@ -1426,6 +1504,13 @@ export const ENTITY_REGISTRY: { [K in EntityKind]: EntityRegistryEntry<Extract<E
"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",
@@ -1469,6 +1554,7 @@ export function getEntityRegistryEntry<K extends EntityKind>(kind: K): EntityReg
export function createDefaultEntityInstance(kind: "playerStart", overrides?: Partial<PlayerStartEntity>): PlayerStartEntity;
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;
@@ -1486,6 +1572,8 @@ export function createDefaultEntityInstance(kind: EntityKind, overrides: Partial
return createPlayerStartEntity(overrides);
case "sceneEntry":
return createSceneEntryEntity(overrides);
case "npc":
return createNpcEntity(overrides);
case "soundEmitter":
return createSoundEmitterEntity(overrides);
case "triggerVolume":
@@ -1509,6 +1597,8 @@ export function cloneEntityInstance(entity: EntityInstance): EntityInstance {
return createPlayerStartEntity(entity);
case "sceneEntry":
return createSceneEntryEntity(entity);
case "npc":
return createNpcEntity(entity);
case "soundEmitter":
return createSoundEmitterEntity(entity);
case "triggerVolume":
@@ -1581,6 +1671,14 @@ export function areEntityInstancesEqual(left: EntityInstance, right: EntityInsta
const typedRight = right as SceneEntryEntity;
return left.yawDegrees === typedRight.yawDegrees;
}
case "npc": {
const typedRight = right as NpcEntity;
return (
left.actorId === typedRight.actorId &&
left.yawDegrees === typedRight.yawDegrees &&
left.modelAssetId === typedRight.modelAssetId
);
}
case "soundEmitter": {
const typedRight = right as SoundEmitterEntity;
return (