import { describe, expect, it } from "vitest"; import { PerspectiveCamera, Vector3 } from "three"; import { resolveDialogueAttentionCameraSolution } from "../../src/runtime-three/dialogue-attention-camera"; function projectPoint(solution: ReturnType, point: { x: number; y: number; z: number }) { const camera = new PerspectiveCamera(70, 16 / 9, 0.05, 100); camera.position.set( solution.position.x, solution.position.y, solution.position.z ); camera.lookAt(solution.lookTarget.x, solution.lookTarget.y, solution.lookTarget.z); camera.updateMatrixWorld(true); return new Vector3(point.x, point.y, point.z).project(camera); } describe("resolveDialogueAttentionCameraSolution", () => { it("orbits around the conversation midpoint and frames the player and npc on opposite sides", () => { const playerFocusPoint = { x: 0, y: 1.6, z: 0 }; const npcFocusPoint = { x: 2, y: 1.7, z: 2 }; const solution = resolveDialogueAttentionCameraSolution({ playerFocusPoint, npcFocusPoint, referenceCameraPosition: { x: 4, y: 2.2, z: -2 }, referenceLookTarget: { x: 1, y: 1.65, z: 1 } }); const forward = new Vector3( solution.lookTarget.x - solution.position.x, solution.lookTarget.y - solution.position.y, solution.lookTarget.z - solution.position.z ).normalize(); const right = new Vector3().crossVectors(forward, new Vector3(0, 1, 0)).normalize(); const playerViewOffset = right.dot( new Vector3( playerFocusPoint.x - solution.position.x, playerFocusPoint.y - solution.position.y, playerFocusPoint.z - solution.position.z ) ); const npcViewOffset = right.dot( new Vector3( npcFocusPoint.x - solution.position.x, npcFocusPoint.y - solution.position.y, npcFocusPoint.z - solution.position.z ) ); const pairDirection = new Vector3( npcFocusPoint.x - playerFocusPoint.x, 0, npcFocusPoint.z - playerFocusPoint.z ).normalize(); const horizontalForward = new Vector3(forward.x, 0, forward.z).normalize(); expect(solution.sideSign).toBe(1); expect(solution.pivot).toEqual({ x: 1, y: 1.65, z: 1 }); expect(solution.position.y).toBeGreaterThan(2); expect(playerViewOffset * npcViewOffset).toBeLessThan(0); expect(Math.abs(horizontalForward.dot(pairDirection))).toBeGreaterThan(0.2); expect(Math.abs(horizontalForward.dot(pairDirection))).toBeLessThan(0.9); }); it("preserves the authored dialogue side sign across solver updates", () => { const solution = resolveDialogueAttentionCameraSolution({ playerFocusPoint: { x: 0, y: 1.6, z: 0 }, npcFocusPoint: { x: 2, y: 1.7, z: 2 }, referenceCameraPosition: { x: -4, y: 2.2, z: 4 }, referenceLookTarget: { x: 1, y: 1.65, z: 1 }, previousSideSign: 1 }); expect(solution.sideSign).toBe(1); }); it("backs the camera away as the conversation span grows", () => { const narrow = resolveDialogueAttentionCameraSolution({ playerFocusPoint: { x: 0, y: 1.6, z: 0 }, npcFocusPoint: { x: 1.5, y: 1.6, z: 1.5 }, referenceCameraPosition: { x: 3, y: 2.2, z: -2 }, referenceLookTarget: { x: 0.75, y: 1.6, z: 0.75 } }); const wide = resolveDialogueAttentionCameraSolution({ playerFocusPoint: { x: 0, y: 1.6, z: 0 }, npcFocusPoint: { x: 4, y: 1.6, z: 4 }, referenceCameraPosition: { x: 3, y: 2.2, z: -2 }, referenceLookTarget: { x: 2, y: 1.6, z: 2 } }); expect(wide.subjectDistance).toBeGreaterThan(narrow.subjectDistance); expect(wide.position.y).toBeGreaterThanOrEqual(narrow.position.y); expect(Math.hypot(wide.position.x, wide.position.z)).toBeGreaterThan( Math.hypot(narrow.position.x, narrow.position.z) ); }); it("keeps a wide conversation inside a safe frame", () => { const playerFocusPoint = { x: 0, y: 1.6, z: 0 }; const npcFocusPoint = { x: 4, y: 1.6, z: 4 }; const solution = resolveDialogueAttentionCameraSolution({ playerFocusPoint, npcFocusPoint, referenceCameraPosition: { x: 3, y: 2.2, z: -2 }, referenceLookTarget: { x: 2, y: 1.6, z: 2 }, cameraVerticalFovRadians: (70 * Math.PI) / 180, cameraAspect: 16 / 9 }); const samplePoints = [ playerFocusPoint, npcFocusPoint, { x: playerFocusPoint.x, y: playerFocusPoint.y + 0.26, z: playerFocusPoint.z }, { x: npcFocusPoint.x, y: npcFocusPoint.y + 0.26, z: npcFocusPoint.z }, { x: playerFocusPoint.x, y: playerFocusPoint.y - 0.82, z: playerFocusPoint.z }, { x: npcFocusPoint.x, y: npcFocusPoint.y - 0.82, z: npcFocusPoint.z } ].map((point) => projectPoint(solution, point)); for (const projectedPoint of samplePoints) { expect(Math.abs(projectedPoint.x)).toBeLessThan(0.82); expect(Math.abs(projectedPoint.y)).toBeLessThan(0.84); } }); it("keeps a narrow conversation readable instead of pushing too far away", () => { const playerFocusPoint = { x: 0, y: 1.6, z: 0 }; const npcFocusPoint = { x: 1.1, y: 1.6, z: 0.9 }; const solution = resolveDialogueAttentionCameraSolution({ playerFocusPoint, npcFocusPoint, referenceCameraPosition: { x: 2.5, y: 2.2, z: -2 }, referenceLookTarget: { x: 0.55, y: 1.6, z: 0.45 }, cameraVerticalFovRadians: (70 * Math.PI) / 180, cameraAspect: 16 / 9 }); const projectedPlayer = projectPoint(solution, playerFocusPoint); const projectedNpc = projectPoint(solution, npcFocusPoint); const projectedPlayerHead = projectPoint(solution, { x: playerFocusPoint.x, y: playerFocusPoint.y + 0.26, z: playerFocusPoint.z }); const projectedPlayerTorso = projectPoint(solution, { x: playerFocusPoint.x, y: playerFocusPoint.y - 0.82, z: playerFocusPoint.z }); const projectedNpcHead = projectPoint(solution, { x: npcFocusPoint.x, y: npcFocusPoint.y + 0.26, z: npcFocusPoint.z }); const projectedNpcTorso = projectPoint(solution, { x: npcFocusPoint.x, y: npcFocusPoint.y - 0.82, z: npcFocusPoint.z }); expect(projectedPlayer.x * projectedNpc.x).toBeLessThan(0); expect(Math.abs(projectedPlayer.x)).toBeLessThan(0.75); expect(Math.abs(projectedNpc.x)).toBeLessThan(0.75); expect(Math.abs(projectedPlayerHead.y - projectedPlayerTorso.y)).toBeGreaterThan(0.22); expect(Math.abs(projectedNpcHead.y - projectedNpcTorso.y)).toBeGreaterThan(0.22); }); });