diff --git a/tests/domain/build-runtime-scene.test.ts b/tests/domain/build-runtime-scene.test.ts index c287a2ef..8613f23f 100644 --- a/tests/domain/build-runtime-scene.test.ts +++ b/tests/domain/build-runtime-scene.test.ts @@ -13,6 +13,8 @@ import { createDefaultProjectTimeSettings } from "../../src/document/project-tim import { createTerrain } from "../../src/document/terrains"; import { createEmptySceneDocument } from "../../src/document/scene-document"; import { + createCameraRigEntity, + createCameraRigEntityTargetRef, DEFAULT_PLAYER_START_MOVE_SPEED, DEFAULT_PLAYER_START_MOVEMENT_CAPABILITIES, createNpcEntity, @@ -42,6 +44,86 @@ import { createFixtureLoadedModelAssetFromGeometry } from "../helpers/model-coll const defaultMovementTemplate = createPlayerStartMovementTemplate(); describe("buildRuntimeSceneFromDocument", () => { + it("builds enabled fixed camera rigs into runtime entities", () => { + const interactable = createInteractableEntity({ + id: "entity-interactable-camera-anchor", + position: { + x: 4, + y: 1, + z: -2 + }, + prompt: "Anchor" + }); + const cameraRig = createCameraRigEntity({ + id: "entity-camera-rig-overlook", + position: { + x: 8, + y: 3, + z: -6 + }, + priority: 7, + defaultActive: false, + target: createCameraRigEntityTargetRef(interactable.id), + targetOffset: { + x: 0, + y: 1.5, + z: 0 + }, + transitionMode: "blend", + transitionDurationSeconds: 0.6, + lookAround: { + enabled: true, + yawLimitDegrees: 10, + pitchLimitDegrees: 6, + recenterSpeed: 5 + } + }); + const disabledCameraRig = createCameraRigEntity({ + id: "entity-camera-rig-disabled", + enabled: false + }); + + const runtimeScene = buildRuntimeSceneFromDocument({ + ...createEmptySceneDocument({ name: "Camera Rig Runtime Scene" }), + entities: { + [interactable.id]: interactable, + [cameraRig.id]: cameraRig, + [disabledCameraRig.id]: disabledCameraRig + } + }); + + expect(runtimeScene.entities.cameraRigs).toEqual([ + { + entityId: cameraRig.id, + rigType: "fixed", + priority: 7, + defaultActive: false, + position: { + x: 8, + y: 3, + z: -6 + }, + target: { + kind: "entity", + entityId: interactable.id + }, + targetOffset: { + x: 0, + y: 1.5, + z: 0 + }, + transitionMode: "blend", + transitionDurationSeconds: 0.6, + lookAround: { + enabled: true, + yawLimitDegrees: 10, + pitchLimitDegrees: 6, + recenterSpeed: 5 + } + } + ]); + }); + it("builds runtime brush data, colliders, and an authored player spawn from the document", () => { const brush = createBoxBrush({ id: "brush-room-floor", diff --git a/tests/domain/scene-document-validation.test.ts b/tests/domain/scene-document-validation.test.ts index 3757db86..56421472 100644 --- a/tests/domain/scene-document-validation.test.ts +++ b/tests/domain/scene-document-validation.test.ts @@ -17,6 +17,9 @@ import { createScenePath } from "../../src/document/paths"; import { createEmptySceneDocument } from "../../src/document/scene-document"; import { validateSceneDocument } from "../../src/document/scene-document-validation"; import { + createCameraRigActorTargetRef, + createCameraRigEntity, + createCameraRigEntityTargetRef, createPointLightEntity, createInteractableEntity, createNpcEntity, @@ -753,6 +756,81 @@ describe("validateSceneDocument", () => { ); }); + it("validates fixed camera rig targets and authored settings", () => { + const otherCameraRig = createCameraRigEntity({ + id: "entity-camera-rig-other" + }); + const missingActorRig = createCameraRigEntity({ + id: "entity-camera-rig-missing-actor", + target: createCameraRigActorTargetRef("actor-missing") + }); + const missingEntityRig = createCameraRigEntity({ + id: "entity-camera-rig-missing-entity", + target: createCameraRigEntityTargetRef("entity-missing") + }); + const selfTargetRig = createCameraRigEntity({ + id: "entity-camera-rig-self" + }); + const cameraTargetRig = createCameraRigEntity({ + id: "entity-camera-rig-camera-target", + target: createCameraRigEntityTargetRef(otherCameraRig.id) + }); + const invalidRig = createCameraRigEntity({ + id: "entity-camera-rig-invalid" + }); + + selfTargetRig.target = createCameraRigEntityTargetRef(selfTargetRig.id); + invalidRig.priority = Number.NaN; + invalidRig.targetOffset = { + x: Number.POSITIVE_INFINITY, + y: 0, + z: 0 + }; + invalidRig.lookAround.enabled = "yes" as unknown as boolean; + invalidRig.lookAround.pitchLimitDegrees = Number.NaN; + + const validation = validateSceneDocument({ + ...createEmptySceneDocument(), + entities: { + [otherCameraRig.id]: otherCameraRig, + [missingActorRig.id]: missingActorRig, + [missingEntityRig.id]: missingEntityRig, + [selfTargetRig.id]: selfTargetRig, + [cameraTargetRig.id]: cameraTargetRig, + [invalidRig.id]: invalidRig + } + }); + + expect(validation.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "missing-camera-rig-target-actor" + }), + expect.objectContaining({ + code: "missing-camera-rig-target-entity" + }), + expect.objectContaining({ + code: "camera-rig-self-target" + }), + expect.objectContaining({ + code: "invalid-camera-rig-target-entity-kind" + }), + expect.objectContaining({ + code: "invalid-camera-rig-priority" + }), + expect.objectContaining({ + code: "invalid-camera-rig-target-offset" + }), + expect.objectContaining({ + code: "invalid-camera-rig-look-around-enabled" + }), + expect.objectContaining({ + code: "invalid-camera-rig-look-around-pitch-limit" + }) + ]) + ); + }); + it("detects missing and invalid audio asset references on Sound Emitters", () => { const audioAsset = { id: "asset-audio-main", diff --git a/tests/serialization/scene-document-json.test.ts b/tests/serialization/scene-document-json.test.ts index 5f28684f..d9226234 100644 --- a/tests/serialization/scene-document-json.test.ts +++ b/tests/serialization/scene-document-json.test.ts @@ -53,7 +53,9 @@ import { } from "../../src/document/scene-document"; import { migrateSceneDocument } from "../../src/document/migrate-scene-document"; import { + createCameraRigEntity, createNpcAlwaysPresence, + createCameraRigWorldPointTargetRef, createNpcEntity, createNpcTimeWindowPresence, createPointLightEntity, @@ -970,6 +972,103 @@ describe("scene document JSON", () => { ); }); + it("round-trips a document containing an authored Camera Rig entity", () => { + const cameraRig = createCameraRigEntity({ + id: "entity-camera-rig-main", + name: "Overlook", + position: { + x: 6, + y: 4, + z: -8 + }, + priority: 5, + defaultActive: false, + target: createCameraRigWorldPointTargetRef({ + x: 1, + y: 1.75, + z: -2 + }), + targetOffset: { + x: 0, + y: 0.25, + z: 0 + }, + transitionMode: "blend", + transitionDurationSeconds: 0.8, + lookAround: { + enabled: true, + yawLimitDegrees: 15, + pitchLimitDegrees: 9, + recenterSpeed: 4 + } + }); + const document = { + ...createEmptySceneDocument({ name: "Camera Rig Scene" }), + entities: { + [cameraRig.id]: cameraRig + } + }; + + expect(parseSceneDocumentJson(serializeSceneDocument(document))).toEqual( + document + ); + }); + + it("migrates version 73 camera rigs to include fixed-rig defaults", () => { + const cameraRig = createCameraRigEntity({ + id: "entity-camera-rig-legacy", + name: "Legacy Camera", + position: { + x: -4, + y: 3, + z: 9 + }, + target: createCameraRigWorldPointTargetRef({ + x: 2, + y: 1, + z: -3 + }) + }); + const legacyDocument = { + ...createEmptySceneDocument({ name: "Legacy Camera Rig Scene" }), + entities: { + [cameraRig.id]: cameraRig + } + }; + const serializedLegacyDocument = JSON.parse( + serializeSceneDocument(legacyDocument) + ) as { + version: number; + entities: Record>; + }; + const serializedCameraRig = serializedLegacyDocument.entities[cameraRig.id]; + + serializedLegacyDocument.version = + CAMERA_RIG_ENTITY_SCENE_DOCUMENT_VERSION - 1; + delete serializedCameraRig.priority; + delete serializedCameraRig.defaultActive; + delete serializedCameraRig.targetOffset; + delete serializedCameraRig.transitionMode; + delete serializedCameraRig.transitionDurationSeconds; + delete serializedCameraRig.lookAround; + + const migratedDocument = parseSceneDocumentJson( + JSON.stringify(serializedLegacyDocument) + ); + + expect(migratedDocument.entities[cameraRig.id]).toEqual( + createCameraRigEntity({ + id: cameraRig.id, + name: cameraRig.name, + visible: cameraRig.visible, + enabled: cameraRig.enabled, + position: cameraRig.position, + rigType: cameraRig.rigType, + target: cameraRig.target + }) + ); + }); + it("migrates version 14 documents without entity names", () => { const pointLight = createPointLightEntity({ id: "entity-point-light-legacy", diff --git a/tests/unit/entity-instances.test.ts b/tests/unit/entity-instances.test.ts index 417e74b6..e2acc637 100644 --- a/tests/unit/entity-instances.test.ts +++ b/tests/unit/entity-instances.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it } from "vitest"; import { + DEFAULT_CAMERA_RIG_DEFAULT_ACTIVE, + DEFAULT_CAMERA_RIG_LOOK_AROUND_ENABLED, + DEFAULT_CAMERA_RIG_LOOK_AROUND_PITCH_LIMIT_DEGREES, + DEFAULT_CAMERA_RIG_LOOK_AROUND_RECENTER_SPEED, + DEFAULT_CAMERA_RIG_LOOK_AROUND_YAW_LIMIT_DEGREES, + DEFAULT_CAMERA_RIG_PRIORITY, + DEFAULT_CAMERA_RIG_TARGET_OFFSET, + DEFAULT_CAMERA_RIG_TRANSITION_DURATION_SECONDS, + DEFAULT_CAMERA_RIG_TRANSITION_MODE, DEFAULT_POINT_LIGHT_COLOR_HEX, DEFAULT_POINT_LIGHT_DISTANCE, DEFAULT_POINT_LIGHT_INTENSITY, @@ -86,6 +95,26 @@ describe("entity registry defaults", () => { distance: DEFAULT_POINT_LIGHT_DISTANCE }); + expect(createDefaultEntityInstance("cameraRig")).toMatchObject({ + kind: "cameraRig", + position: { x: 0, y: 0, z: 0 }, + rigType: "fixed", + priority: DEFAULT_CAMERA_RIG_PRIORITY, + defaultActive: DEFAULT_CAMERA_RIG_DEFAULT_ACTIVE, + target: { + kind: "player" + }, + targetOffset: DEFAULT_CAMERA_RIG_TARGET_OFFSET, + transitionMode: DEFAULT_CAMERA_RIG_TRANSITION_MODE, + transitionDurationSeconds: DEFAULT_CAMERA_RIG_TRANSITION_DURATION_SECONDS, + lookAround: { + enabled: DEFAULT_CAMERA_RIG_LOOK_AROUND_ENABLED, + yawLimitDegrees: DEFAULT_CAMERA_RIG_LOOK_AROUND_YAW_LIMIT_DEGREES, + pitchLimitDegrees: DEFAULT_CAMERA_RIG_LOOK_AROUND_PITCH_LIMIT_DEGREES, + recenterSpeed: DEFAULT_CAMERA_RIG_LOOK_AROUND_RECENTER_SPEED + } + }); + expect(createDefaultEntityInstance("spotLight")).toMatchObject({ kind: "spotLight", position: { x: 0, y: 0, z: 0 }, diff --git a/tests/unit/viewport-focus.test.ts b/tests/unit/viewport-focus.test.ts index f37f10d0..fcebbb77 100644 --- a/tests/unit/viewport-focus.test.ts +++ b/tests/unit/viewport-focus.test.ts @@ -5,7 +5,13 @@ import { createBoxBrush } from "../../src/document/brushes"; import { createScenePath } from "../../src/document/paths"; import { createEmptySceneDocument } from "../../src/document/scene-document"; import { createTerrain } from "../../src/document/terrains"; -import { createPointLightEntity, createPlayerStartEntity, createSpotLightEntity, createTriggerVolumeEntity } from "../../src/entities/entity-instances"; +import { + createCameraRigEntity, + createPointLightEntity, + createPlayerStartEntity, + createSpotLightEntity, + createTriggerVolumeEntity +} from "../../src/entities/entity-instances"; import { resolveViewportFocusTarget } from "../../src/viewport-three/viewport-focus"; describe("resolveViewportFocusTarget", () => { @@ -169,6 +175,38 @@ describe("resolveViewportFocusTarget", () => { }); }); + it("frames the selected Camera Rig helper", () => { + const cameraRig = createCameraRigEntity({ + id: "entity-camera-rig-focus", + position: { + x: -3, + y: 2, + z: 5 + } + }); + const document = { + ...createEmptySceneDocument(), + entities: { + [cameraRig.id]: cameraRig + } + }; + + const focusTarget = resolveViewportFocusTarget(document, { + kind: "entities", + ids: [cameraRig.id] + }); + + expect(focusTarget).toEqual({ + center: { + x: -3, + y: 2.28, + z: 5 + }, + radius: expect.any(Number) + }); + expect(focusTarget?.radius).toBeGreaterThan(0.45); + }); + it("frames the selected Path around its authored point bounds", () => { const path = createScenePath({ id: "path-focus",