421 lines
12 KiB
TypeScript
421 lines
12 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { createModelInstance } from "../../src/assets/model-instances";
|
|
import {
|
|
createProjectAssetStorageKey,
|
|
type AudioAssetRecord,
|
|
type ModelAssetRecord
|
|
} from "../../src/assets/project-assets";
|
|
import {
|
|
createInteractableEntity,
|
|
createNpcEntity,
|
|
createPointLightEntity,
|
|
createSoundEmitterEntity,
|
|
createTriggerVolumeEntity
|
|
} from "../../src/entities/entity-instances";
|
|
import {
|
|
createActorControlTargetRef,
|
|
createLightControlTargetRef,
|
|
createProjectGlobalControlTargetRef,
|
|
createSetActorPresenceControlEffect,
|
|
createSetLightEnabledControlEffect
|
|
} from "../../src/controls/control-surface";
|
|
import { createEmptySceneDocument } from "../../src/document/scene-document";
|
|
import { createControlInteractionLink } from "../../src/interactions/interaction-links";
|
|
import { createProjectScheduleRoutine } from "../../src/scheduler/project-scheduler";
|
|
import { RuntimeInteractionSystem } from "../../src/runtime-three/runtime-interaction-system";
|
|
import { buildRuntimeSceneFromDocument } from "../../src/runtime-three/runtime-scene-build";
|
|
|
|
describe("runtime control foundation", () => {
|
|
it("builds a typed control surface for light, interaction, sound, and model targets", () => {
|
|
const pointLight = createPointLightEntity({
|
|
id: "entity-point-light-main",
|
|
visible: false,
|
|
intensity: 2.5
|
|
});
|
|
const interactable = createInteractableEntity({
|
|
id: "entity-interactable-console",
|
|
interactionEnabled: false
|
|
});
|
|
const soundEmitter = createSoundEmitterEntity({
|
|
id: "entity-sound-main",
|
|
audioAssetId: "asset-audio-main",
|
|
autoplay: true,
|
|
volume: 0.4
|
|
});
|
|
const npc = createNpcEntity({
|
|
id: "entity-npc-vendor",
|
|
actorId: "actor-market-vendor"
|
|
});
|
|
const triggerVolume = createTriggerVolumeEntity({
|
|
id: "entity-trigger-main"
|
|
});
|
|
const modelAsset = {
|
|
id: "asset-model-animated",
|
|
kind: "model" as const,
|
|
sourceName: "animated.glb",
|
|
mimeType: "model/gltf-binary",
|
|
storageKey: createProjectAssetStorageKey("asset-model-animated"),
|
|
byteLength: 1024,
|
|
metadata: {
|
|
kind: "model" as const,
|
|
format: "glb" as const,
|
|
sceneName: null,
|
|
nodeCount: 1,
|
|
meshCount: 1,
|
|
materialNames: [],
|
|
textureNames: [],
|
|
animationNames: ["Idle"],
|
|
boundingBox: null,
|
|
warnings: []
|
|
}
|
|
} satisfies ModelAssetRecord;
|
|
const audioAsset = {
|
|
id: "asset-audio-main",
|
|
kind: "audio" as const,
|
|
sourceName: "loop.ogg",
|
|
mimeType: "audio/ogg",
|
|
storageKey: createProjectAssetStorageKey("asset-audio-main"),
|
|
byteLength: 2048,
|
|
metadata: {
|
|
kind: "audio" as const,
|
|
durationSeconds: 2,
|
|
channelCount: 2,
|
|
sampleRateHz: 48000,
|
|
warnings: []
|
|
}
|
|
} satisfies AudioAssetRecord;
|
|
const modelInstance = createModelInstance({
|
|
id: "model-instance-animated",
|
|
assetId: modelAsset.id,
|
|
animationClipName: "Idle",
|
|
animationAutoplay: true
|
|
});
|
|
|
|
const document = createEmptySceneDocument();
|
|
document.scheduler.routines["routine-market-vendor"] =
|
|
createProjectScheduleRoutine({
|
|
id: "routine-market-vendor",
|
|
title: "Market Hours",
|
|
target: createActorControlTargetRef(npc.actorId),
|
|
startHour: 9,
|
|
endHour: 17,
|
|
effect: createSetActorPresenceControlEffect({
|
|
target: createActorControlTargetRef(npc.actorId),
|
|
active: true
|
|
})
|
|
});
|
|
|
|
const runtimeScene = buildRuntimeSceneFromDocument({
|
|
...document,
|
|
assets: {
|
|
[modelAsset.id]: modelAsset,
|
|
[audioAsset.id]: audioAsset
|
|
},
|
|
modelInstances: {
|
|
[modelInstance.id]: modelInstance
|
|
},
|
|
entities: {
|
|
[pointLight.id]: pointLight,
|
|
[interactable.id]: interactable,
|
|
[soundEmitter.id]: soundEmitter,
|
|
[npc.id]: npc,
|
|
[triggerVolume.id]: triggerVolume
|
|
}
|
|
});
|
|
|
|
expect(runtimeScene.control.targets).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
target: {
|
|
kind: "global",
|
|
scope: "project"
|
|
},
|
|
capabilities: ["projectTimePause"]
|
|
}),
|
|
expect.objectContaining({
|
|
target: {
|
|
kind: "scene",
|
|
scope: "activeScene"
|
|
},
|
|
capabilities: [
|
|
"ambientLightIntensity",
|
|
"ambientLightColor",
|
|
"sunLightIntensity",
|
|
"sunLightColor"
|
|
]
|
|
}),
|
|
expect.objectContaining({
|
|
target: {
|
|
kind: "actor",
|
|
actorId: npc.actorId
|
|
},
|
|
capabilities: ["actorPresence"]
|
|
}),
|
|
expect.objectContaining({
|
|
target: {
|
|
kind: "entity",
|
|
entityKind: "pointLight",
|
|
entityId: pointLight.id
|
|
},
|
|
capabilities: ["lightEnabled", "lightIntensity", "lightColor"]
|
|
}),
|
|
expect.objectContaining({
|
|
target: {
|
|
kind: "interaction",
|
|
interactionKind: "interactable",
|
|
entityId: interactable.id
|
|
},
|
|
capabilities: ["interactionAvailability"]
|
|
}),
|
|
expect.objectContaining({
|
|
target: {
|
|
kind: "entity",
|
|
entityKind: "soundEmitter",
|
|
entityId: soundEmitter.id
|
|
},
|
|
capabilities: ["soundPlayback", "soundVolume"]
|
|
}),
|
|
expect.objectContaining({
|
|
target: {
|
|
kind: "modelInstance",
|
|
modelInstanceId: modelInstance.id
|
|
},
|
|
capabilities: ["animationPlayback", "modelVisibility"]
|
|
})
|
|
])
|
|
);
|
|
expect(runtimeScene.control.channels).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
channel: "ambientLight.intensity",
|
|
target: {
|
|
kind: "scene",
|
|
scope: "activeScene"
|
|
}
|
|
}),
|
|
expect.objectContaining({
|
|
channel: "sunLight.intensity",
|
|
target: {
|
|
kind: "scene",
|
|
scope: "activeScene"
|
|
}
|
|
}),
|
|
expect.objectContaining({
|
|
channel: "light.intensity",
|
|
target: {
|
|
kind: "entity",
|
|
entityKind: "pointLight",
|
|
entityId: pointLight.id
|
|
},
|
|
minValue: 0,
|
|
defaultValue: 2.5
|
|
}),
|
|
expect.objectContaining({
|
|
channel: "sound.volume",
|
|
target: {
|
|
kind: "entity",
|
|
entityKind: "soundEmitter",
|
|
entityId: soundEmitter.id
|
|
},
|
|
defaultValue: 0.4
|
|
})
|
|
])
|
|
);
|
|
expect(runtimeScene.control.resolved.discrete).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
type: "projectTimePaused",
|
|
target: createProjectGlobalControlTargetRef(),
|
|
value: false
|
|
}),
|
|
expect.objectContaining({
|
|
type: "ambientLightColor",
|
|
target: {
|
|
kind: "scene",
|
|
scope: "activeScene"
|
|
}
|
|
}),
|
|
expect.objectContaining({
|
|
type: "sunLightColor",
|
|
target: {
|
|
kind: "scene",
|
|
scope: "activeScene"
|
|
}
|
|
}),
|
|
expect.objectContaining({
|
|
type: "actorPresence",
|
|
target: {
|
|
kind: "actor",
|
|
actorId: npc.actorId
|
|
},
|
|
value: true
|
|
}),
|
|
expect.objectContaining({
|
|
type: "lightEnabled",
|
|
target: {
|
|
kind: "entity",
|
|
entityKind: "pointLight",
|
|
entityId: pointLight.id
|
|
},
|
|
value: false
|
|
}),
|
|
expect.objectContaining({
|
|
type: "lightColor",
|
|
target: {
|
|
kind: "entity",
|
|
entityKind: "pointLight",
|
|
entityId: pointLight.id
|
|
},
|
|
value: pointLight.colorHex
|
|
}),
|
|
expect.objectContaining({
|
|
type: "interactionEnabled",
|
|
target: {
|
|
kind: "interaction",
|
|
interactionKind: "interactable",
|
|
entityId: interactable.id
|
|
},
|
|
value: false
|
|
}),
|
|
expect.objectContaining({
|
|
type: "soundPlayback",
|
|
target: {
|
|
kind: "entity",
|
|
entityKind: "soundEmitter",
|
|
entityId: soundEmitter.id
|
|
},
|
|
value: true
|
|
}),
|
|
expect.objectContaining({
|
|
type: "modelVisibility",
|
|
target: {
|
|
kind: "modelInstance",
|
|
modelInstanceId: modelInstance.id
|
|
},
|
|
value: true
|
|
}),
|
|
expect.objectContaining({
|
|
type: "modelAnimationPlayback",
|
|
target: {
|
|
kind: "modelInstance",
|
|
modelInstanceId: modelInstance.id
|
|
},
|
|
clipName: "Idle"
|
|
})
|
|
])
|
|
);
|
|
expect(runtimeScene.control.resolved.channels).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
type: "ambientLightIntensity",
|
|
descriptor: expect.objectContaining({
|
|
channel: "ambientLight.intensity",
|
|
target: {
|
|
kind: "scene",
|
|
scope: "activeScene"
|
|
}
|
|
})
|
|
}),
|
|
expect.objectContaining({
|
|
type: "sunLightIntensity",
|
|
descriptor: expect.objectContaining({
|
|
channel: "sunLight.intensity",
|
|
target: {
|
|
kind: "scene",
|
|
scope: "activeScene"
|
|
}
|
|
})
|
|
}),
|
|
expect.objectContaining({
|
|
type: "lightIntensity",
|
|
descriptor: expect.objectContaining({
|
|
channel: "light.intensity",
|
|
target: {
|
|
kind: "entity",
|
|
entityKind: "pointLight",
|
|
entityId: pointLight.id
|
|
}
|
|
}),
|
|
value: 2.5
|
|
}),
|
|
expect.objectContaining({
|
|
type: "soundVolume",
|
|
descriptor: expect.objectContaining({
|
|
channel: "sound.volume",
|
|
target: {
|
|
kind: "entity",
|
|
entityKind: "soundEmitter",
|
|
entityId: soundEmitter.id
|
|
}
|
|
}),
|
|
value: 0.4
|
|
})
|
|
])
|
|
);
|
|
});
|
|
|
|
it("dispatches authored control effects through the interaction system", () => {
|
|
const triggerVolume = createTriggerVolumeEntity({
|
|
id: "entity-trigger-main"
|
|
});
|
|
const pointLight = createPointLightEntity({
|
|
id: "entity-point-light-main"
|
|
});
|
|
const lightOffEffect = createSetLightEnabledControlEffect({
|
|
target: createLightControlTargetRef("pointLight", pointLight.id),
|
|
enabled: false
|
|
});
|
|
const runtimeScene = buildRuntimeSceneFromDocument({
|
|
...createEmptySceneDocument(),
|
|
entities: {
|
|
[triggerVolume.id]: triggerVolume,
|
|
[pointLight.id]: pointLight
|
|
},
|
|
interactionLinks: {
|
|
"link-light-off": createControlInteractionLink({
|
|
id: "link-light-off",
|
|
sourceEntityId: triggerVolume.id,
|
|
trigger: "enter",
|
|
effect: lightOffEffect
|
|
})
|
|
}
|
|
});
|
|
const interactionSystem = new RuntimeInteractionSystem();
|
|
const dispatches: Array<{ type: string; enabled: boolean }> = [];
|
|
|
|
interactionSystem.updatePlayerPosition(
|
|
{
|
|
x: 0,
|
|
y: 0,
|
|
z: 0
|
|
},
|
|
runtimeScene,
|
|
{
|
|
teleportPlayer: () => {},
|
|
startSceneTransition: () => {},
|
|
toggleBrushVisibility: () => {},
|
|
playAnimation: () => {},
|
|
stopAnimation: () => {},
|
|
playSound: () => {},
|
|
stopSound: () => {},
|
|
startNpcDialogue: () => {},
|
|
startDialogue: () => {},
|
|
dispatchControlEffect: (effect, link) => {
|
|
dispatches.push({
|
|
type: `${link.id}:${effect.type}`,
|
|
enabled: effect.type === "setLightEnabled" ? effect.enabled : true
|
|
});
|
|
}
|
|
}
|
|
);
|
|
|
|
expect(dispatches).toEqual([
|
|
{
|
|
type: "link-light-off:setLightEnabled",
|
|
enabled: false
|
|
}
|
|
]);
|
|
});
|
|
});
|