Enhance runtime targeting logic and add unit tests for target switching and loss conditions

This commit is contained in:
2026-04-25 15:33:19 +02:00
parent dd0f6868ac
commit 99dc1871d9
2 changed files with 220 additions and 0 deletions

View File

@@ -329,6 +329,9 @@ const DIALOGUE_PARTICIPANT_RESTORE_EPSILON_DEGREES = 0.5;
const TARGETING_LUX_FOLLOW_RATE = 8;
const TARGETING_LUX_BOB_RATE = 4.2;
const TARGETING_LUX_PULSE_RATE = 6.5;
const TARGETING_SIDE_SWITCH_YAW_THRESHOLD_RADIANS = (12 * Math.PI) / 180;
const TARGETING_CANCEL_YAW_THRESHOLD_RADIANS = (60 * Math.PI) / 180;
const TARGETING_MAX_ACTIVE_TARGET_DISTANCE = 35;
// Proposed-target camera nudging is intentionally disabled for now. Lux alone
// should communicate proposal without moving the gameplay camera.
// const PROPOSED_TARGET_CAMERA_ASSIST_STRENGTH = 0.28;
@@ -369,6 +372,13 @@ function lerpScalar(start: number, end: number, t: number) {
return start + (end - start) * t;
}
function distanceBetweenPoints(
left: { x: number; y: number; z: number },
right: { x: number; y: number; z: number }
) {
return Math.hypot(left.x - right.x, left.y - right.y, left.z - right.z);
}
function smoothStep01(value: number) {
const t = clampScalar(value, 0, 1);

View File

@@ -3197,6 +3197,216 @@ describe("RuntimeHost", () => {
host.dispose();
});
it("switches an active target toward the user's horizontal camera look intent", () => {
const host = new RuntimeHost({
enableRendering: false
});
const hostInternals = host as unknown as {
runtimeScene: unknown;
activeController: unknown;
thirdPersonController: unknown;
currentPlayerControllerTelemetry: unknown;
runtimeTargetCandidates: Array<{
kind: "npc";
entityId: string;
center: { x: number; y: number; z: number };
score: number;
}>;
activeRuntimeTargetReference: {
kind: "npc";
entityId: string;
} | null;
reportThirdPersonCameraLookIntent(yawDeltaRadians: number): void;
updateActiveRuntimeTargetLockState(): void;
};
hostInternals.runtimeScene = {
entities: {
npcs: [
{
entityId: "npc-active",
visible: true,
position: { x: 0, y: 0, z: 5 },
collider: { mode: "capsule", radius: 0.35, height: 1.8, eyeHeight: 1.6 },
name: "Active",
defaultDialogueId: null,
dialogues: []
},
{
entityId: "npc-right",
visible: true,
position: { x: 2, y: 0, z: 5 },
collider: { mode: "capsule", radius: 0.35, height: 1.8, eyeHeight: 1.6 },
name: "Right",
defaultDialogueId: null,
dialogues: []
}
],
interactables: [],
cameraRigs: []
},
interactionLinks: [
{ id: "link-active", sourceEntityId: "npc-active", trigger: "click", action: { type: "runSequence", sequenceId: "noop" } },
{ id: "link-right", sourceEntityId: "npc-right", trigger: "click", action: { type: "runSequence", sequenceId: "noop" } }
]
} as never;
hostInternals.activeController = hostInternals.thirdPersonController;
hostInternals.currentPlayerControllerTelemetry = {
eyePosition: { x: 0, y: 1.6, z: 0 }
};
hostInternals.runtimeTargetCandidates = [
{
kind: "npc",
entityId: "npc-active",
center: { x: 0, y: 0.9, z: 5 },
score: 3
},
{
kind: "npc",
entityId: "npc-right",
center: { x: 2, y: 0.9, z: 5 },
score: 2.5
}
];
hostInternals.activeRuntimeTargetReference = {
kind: "npc",
entityId: "npc-active"
};
hostInternals.reportThirdPersonCameraLookIntent((15 * Math.PI) / 180);
hostInternals.updateActiveRuntimeTargetLockState();
expect(hostInternals.activeRuntimeTargetReference).toEqual({
kind: "npc",
entityId: "npc-right"
});
host.dispose();
});
it("clears an active target after a large horizontal camera turn when another target is available", () => {
const host = new RuntimeHost({
enableRendering: false
});
const hostInternals = host as unknown as {
runtimeScene: unknown;
activeController: unknown;
thirdPersonController: unknown;
currentPlayerControllerTelemetry: unknown;
runtimeTargetCandidates: Array<{
kind: "npc";
entityId: string;
center: { x: number; y: number; z: number };
score: number;
}>;
activeRuntimeTargetReference: {
kind: "npc";
entityId: string;
} | null;
reportThirdPersonCameraLookIntent(yawDeltaRadians: number): void;
updateActiveRuntimeTargetLockState(): void;
};
hostInternals.runtimeScene = {
entities: {
npcs: [
{
entityId: "npc-active",
visible: true,
position: { x: 0, y: 0, z: 5 },
collider: { mode: "capsule", radius: 0.35, height: 1.8, eyeHeight: 1.6 },
name: "Active",
defaultDialogueId: null,
dialogues: []
}
],
interactables: [],
cameraRigs: []
},
interactionLinks: [
{ id: "link-active", sourceEntityId: "npc-active", trigger: "click", action: { type: "runSequence", sequenceId: "noop" } }
]
} as never;
hostInternals.activeController = hostInternals.thirdPersonController;
hostInternals.currentPlayerControllerTelemetry = {
eyePosition: { x: 0, y: 1.6, z: 0 }
};
hostInternals.runtimeTargetCandidates = [
{
kind: "npc",
entityId: "npc-active",
center: { x: 0, y: 0.9, z: 5 },
score: 3
},
{
kind: "npc",
entityId: "npc-other",
center: { x: -2, y: 0.9, z: 5 },
score: 2.5
}
];
hostInternals.activeRuntimeTargetReference = {
kind: "npc",
entityId: "npc-active"
};
hostInternals.reportThirdPersonCameraLookIntent((70 * Math.PI) / 180);
hostInternals.updateActiveRuntimeTargetLockState();
expect(hostInternals.activeRuntimeTargetReference).toBeNull();
host.dispose();
});
it("clears an active target when the player moves too far away", () => {
const host = new RuntimeHost({
enableRendering: false
});
const hostInternals = host as unknown as {
runtimeScene: unknown;
activeController: unknown;
thirdPersonController: unknown;
currentPlayerControllerTelemetry: unknown;
activeRuntimeTargetReference: {
kind: "npc";
entityId: string;
} | null;
updateActiveRuntimeTargetLockState(): void;
};
hostInternals.runtimeScene = {
entities: {
npcs: [
{
entityId: "npc-far",
visible: true,
position: { x: 0, y: 0, z: 40 },
collider: { mode: "capsule", radius: 0.35, height: 1.8, eyeHeight: 1.6 },
name: "Far",
defaultDialogueId: null,
dialogues: []
}
],
interactables: [],
cameraRigs: []
},
interactionLinks: [
{ id: "link-far", sourceEntityId: "npc-far", trigger: "click", action: { type: "runSequence", sequenceId: "noop" } }
]
} as never;
hostInternals.activeController = hostInternals.thirdPersonController;
hostInternals.currentPlayerControllerTelemetry = {
eyePosition: { x: 0, y: 1.6, z: 0 }
};
hostInternals.activeRuntimeTargetReference = {
kind: "npc",
entityId: "npc-far"
};
hostInternals.updateActiveRuntimeTargetLockState();
expect(hostInternals.activeRuntimeTargetReference).toBeNull();
host.dispose();
});
it("clears runtime targeting when switching into first-person mode", () => {
const host = new RuntimeHost({
enableRendering: false