auto-git:

[change] src/runtime-three/runtime-host.ts
 [change] tests/unit/runtime-host.test.ts
This commit is contained in:
2026-04-25 04:04:59 +02:00
parent 3f34880710
commit 8a23bd52a9
2 changed files with 114 additions and 4 deletions

View File

@@ -313,6 +313,7 @@ const DIALOGUE_ATTENTION_CAMERA_TRANSITION_DURATION_SECONDS = 0.35;
const DIALOGUE_ATTENTION_PLAYER_FOCUS_HEIGHT_FACTOR = 0.82; const DIALOGUE_ATTENTION_PLAYER_FOCUS_HEIGHT_FACTOR = 0.82;
const DIALOGUE_ATTENTION_NPC_FOCUS_HEIGHT_FACTOR = 0.88; const DIALOGUE_ATTENTION_NPC_FOCUS_HEIGHT_FACTOR = 0.88;
const DIALOGUE_PARTICIPANT_MIN_SURFACE_DISTANCE = 0.5; const DIALOGUE_PARTICIPANT_MIN_SURFACE_DISTANCE = 0.5;
const DIALOGUE_PARTICIPANT_PUSHBACK_DURATION_SECONDS = 0.3;
const DIALOGUE_PARTICIPANT_YAW_BLEND_RATE = 8; const DIALOGUE_PARTICIPANT_YAW_BLEND_RATE = 8;
const DIALOGUE_PARTICIPANT_RESTORE_EPSILON_DEGREES = 0.5; const DIALOGUE_PARTICIPANT_RESTORE_EPSILON_DEGREES = 0.5;
@@ -324,6 +325,16 @@ function clampScalar(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value)); return Math.max(min, Math.min(max, value));
} }
function lerpScalar(start: number, end: number, t: number) {
return start + (end - start) * t;
}
function smoothStep01(value: number) {
const t = clampScalar(value, 0, 1);
return t * t * (3 - 2 * t);
}
function normalizeDegrees(value: number) { function normalizeDegrees(value: number) {
const wrapped = ((value + 180) % 360 + 360) % 360 - 180; const wrapped = ((value + 180) % 360 + 360) % 360 - 180;
@@ -409,7 +420,10 @@ interface RuntimeDialogueParticipantState {
npcCurrentYawDegrees: number; npcCurrentYawDegrees: number;
npcTargetYawDegrees: number; npcTargetYawDegrees: number;
npcRestoreYawDegrees: number; npcRestoreYawDegrees: number;
playerStartFeetPosition: RuntimeTeleportTarget["position"];
playerTargetFeetPosition: RuntimeTeleportTarget["position"]; playerTargetFeetPosition: RuntimeTeleportTarget["position"];
playerPositionBlendElapsedSeconds: number;
playerPositionBlendDurationSeconds: number;
playerCurrentYawDegrees: number; playerCurrentYawDegrees: number;
playerTargetYawDegrees: number; playerTargetYawDegrees: number;
} }
@@ -1428,6 +1442,51 @@ export class RuntimeHost {
return (Math.atan2(to.x - from.x, to.z - from.z) * 180) / Math.PI; return (Math.atan2(to.x - from.x, to.z - from.z) * 180) / Math.PI;
} }
private resolveDialogueParticipantPlayerFeetPosition(
state: RuntimeDialogueParticipantState
) {
if (state.playerPositionBlendDurationSeconds <= 0) {
return state.playerTargetFeetPosition;
}
const blendT = smoothStep01(
state.playerPositionBlendElapsedSeconds /
state.playerPositionBlendDurationSeconds
);
return {
x: lerpScalar(
state.playerStartFeetPosition.x,
state.playerTargetFeetPosition.x,
blendT
),
y: lerpScalar(
state.playerStartFeetPosition.y,
state.playerTargetFeetPosition.y,
blendT
),
z: lerpScalar(
state.playerStartFeetPosition.z,
state.playerTargetFeetPosition.z,
blendT
)
};
}
private isDialogueAttentionCameraReady(npcEntityId: string) {
const state = this.dialogueParticipantState;
if (state === null || state.npcEntityId !== npcEntityId) {
return true;
}
return (
state.playerPositionBlendDurationSeconds <= 0 ||
state.playerPositionBlendElapsedSeconds >=
state.playerPositionBlendDurationSeconds - 1e-4
);
}
private resolveDialogueParticipantState( private resolveDialogueParticipantState(
npc: RuntimeNpc npc: RuntimeNpc
): RuntimeDialogueParticipantState | null { ): RuntimeDialogueParticipantState | null {
@@ -1526,7 +1585,15 @@ export class RuntimeHost {
npcCurrentYawDegrees: npc.yawDegrees, npcCurrentYawDegrees: npc.yawDegrees,
npcTargetYawDegrees, npcTargetYawDegrees,
npcRestoreYawDegrees: npc.yawDegrees, npcRestoreYawDegrees: npc.yawDegrees,
playerStartFeetPosition: {
...playerFeetPosition
},
playerTargetFeetPosition: targetFeetPosition, playerTargetFeetPosition: targetFeetPosition,
playerPositionBlendElapsedSeconds: 0,
playerPositionBlendDurationSeconds:
currentHorizontalDistance < desiredHorizontalDistance - 1e-4
? DIALOGUE_PARTICIPANT_PUSHBACK_DURATION_SECONDS
: 0,
playerCurrentYawDegrees: currentPlayerYawDegrees, playerCurrentYawDegrees: currentPlayerYawDegrees,
playerTargetYawDegrees playerTargetYawDegrees
}; };
@@ -1597,10 +1664,16 @@ export class RuntimeHost {
z: state.playerTargetFeetPosition.z z: state.playerTargetFeetPosition.z
} }
); );
state.playerPositionBlendElapsedSeconds = Math.min(
state.playerPositionBlendDurationSeconds,
state.playerPositionBlendElapsedSeconds + dt
);
const playerFeetPosition =
this.resolveDialogueParticipantPlayerFeetPosition(state);
state.playerTargetYawDegrees = this.resolveYawDegreesTowards( state.playerTargetYawDegrees = this.resolveYawDegreesTowards(
{ {
x: state.playerTargetFeetPosition.x, x: playerFeetPosition.x,
z: state.playerTargetFeetPosition.z z: playerFeetPosition.z
}, },
{ {
x: npc.position.x, x: npc.position.x,
@@ -1620,7 +1693,7 @@ export class RuntimeHost {
dt dt
); );
this.applyTeleportPlayerAction({ this.applyTeleportPlayerAction({
position: state.playerTargetFeetPosition, position: playerFeetPosition,
yawDegrees: state.playerCurrentYawDegrees yawDegrees: state.playerCurrentYawDegrees
}); });
this.setRuntimeNpcYawDegrees(state.npcEntityId, state.npcCurrentYawDegrees); this.setRuntimeNpcYawDegrees(state.npcEntityId, state.npcCurrentYawDegrees);
@@ -2057,6 +2130,12 @@ export class RuntimeHost {
const dialogueNpc = this.resolveDialogueAttentionNpc(); const dialogueNpc = this.resolveDialogueAttentionNpc();
if (dialogueNpc !== null) { if (dialogueNpc !== null) {
if (!this.isDialogueAttentionCameraReady(dialogueNpc.entityId)) {
return {
kind: "gameplay"
};
}
return { return {
kind: "dialogue", kind: "dialogue",
state: state:

View File

@@ -707,7 +707,9 @@ describe("RuntimeHost", () => {
const hostInternals = host as unknown as { const hostInternals = host as unknown as {
sceneReady: boolean; sceneReady: boolean;
camera: PerspectiveCamera;
collisionWorld: RapierCollisionWorld | null; collisionWorld: RapierCollisionWorld | null;
activeCameraSourceKey: string | null;
currentPlayerControllerTelemetry: currentPlayerControllerTelemetry:
| { | {
feetPosition: { x: number; y: number; z: number }; feetPosition: { x: number; y: number; z: number };
@@ -718,6 +720,13 @@ describe("RuntimeHost", () => {
runtimeScene: ReturnType<typeof buildRuntimeSceneFromDocument> | null; runtimeScene: ReturnType<typeof buildRuntimeSceneFromDocument> | null;
activateDesiredNavigationController(): void; activateDesiredNavigationController(): void;
updateRuntimeDialogueParticipants(dt: number): void; updateRuntimeDialogueParticipants(dt: number): void;
applyActiveCameraRig(
dt: number,
previousCameraPose?: {
position: Vector3;
lookTarget: Vector3;
}
): { entityId: string } | null;
createInteractionDispatcher(): { createInteractionDispatcher(): {
startNpcDialogue( startNpcDialogue(
npcEntityId: string, npcEntityId: string,
@@ -758,6 +767,10 @@ describe("RuntimeHost", () => {
hostInternals.updateRuntimeDialogueParticipants(0.05); hostInternals.updateRuntimeDialogueParticipants(0.05);
hostInternals.updateRuntimeDialogueParticipants(0.05); hostInternals.updateRuntimeDialogueParticipants(0.05);
hostInternals.applyActiveCameraRig(
0.1,
captureCameraPose(hostInternals.camera)
);
const playerTelemetry = hostInternals.currentPlayerControllerTelemetry; const playerTelemetry = hostInternals.currentPlayerControllerTelemetry;
const runtimeNpc = const runtimeNpc =
@@ -783,7 +796,9 @@ describe("RuntimeHost", () => {
180) / 180) /
Math.PI; Math.PI;
expect(playerDistanceFromNpc).toBeGreaterThanOrEqual(1.09); expect(playerDistanceFromNpc).toBeGreaterThan(0.1);
expect(playerDistanceFromNpc).toBeLessThan(1.09);
expect(hostInternals.activeCameraSourceKey).toBe("gameplay");
expect( expect(
Math.abs( Math.abs(
resolveShortestAngleDeltaDegrees( resolveShortestAngleDeltaDegrees(
@@ -798,6 +813,22 @@ describe("RuntimeHost", () => {
) )
).toBeGreaterThan(10); ).toBeGreaterThan(10);
hostInternals.updateRuntimeDialogueParticipants(0.1);
hostInternals.updateRuntimeDialogueParticipants(0.1);
hostInternals.applyActiveCameraRig(
0.1,
captureCameraPose(hostInternals.camera)
);
const stagedPlayerTelemetry = hostInternals.currentPlayerControllerTelemetry;
const stagedPlayerDistanceFromNpc = Math.hypot(
(stagedPlayerTelemetry?.feetPosition.x ?? 0) - npc.position.x,
(stagedPlayerTelemetry?.feetPosition.z ?? 0) - npc.position.z
);
expect(stagedPlayerDistanceFromNpc).toBeGreaterThanOrEqual(1.09);
expect(hostInternals.activeCameraSourceKey).toBe(`dialogue:${npc.id}`);
host.closeRuntimeDialogue(); host.closeRuntimeDialogue();
hostInternals.updateRuntimeDialogueParticipants(0.05); hostInternals.updateRuntimeDialogueParticipants(0.05);