auto-git:
[change] src/app/App.tsx [change] src/controls/control-surface.ts [change] src/document/migrate-scene-document.ts [change] src/document/scene-document-validation.ts [change] src/document/scene-document.ts [change] src/interactions/interaction-links.ts [change] src/runtime-three/runtime-audio-system.ts [change] src/runtime-three/runtime-host.ts [change] src/runtime-three/runtime-interaction-system.ts [change] src/runtime-three/runtime-scene-build.ts [change] tests/domain/runtime-control-foundation.test.ts [change] tests/domain/runtime-interaction-system.test.ts [change] tests/serialization/control-interaction-links.test.ts [change] tests/unit/runtime-host.test.ts
This commit is contained in:
@@ -1,10 +1,20 @@
|
||||
import { AudioListener, Group, PositionalAudio, Scene, Vector3, type PerspectiveCamera } from "three";
|
||||
import {
|
||||
AudioListener,
|
||||
Group,
|
||||
PositionalAudio,
|
||||
Scene,
|
||||
Vector3,
|
||||
type PerspectiveCamera
|
||||
} from "three";
|
||||
|
||||
import type { LoadedAudioAsset } from "../assets/audio-assets";
|
||||
import type { ProjectAssetRecord } from "../assets/project-assets";
|
||||
import type { InteractionLink } from "../interactions/interaction-links";
|
||||
import type { RuntimePlayerAudioHookState } from "./navigation-controller";
|
||||
import type { RuntimeSceneDefinition, RuntimeSoundEmitter } from "./runtime-scene-build";
|
||||
import type {
|
||||
RuntimeSceneDefinition,
|
||||
RuntimeSoundEmitter
|
||||
} from "./runtime-scene-build";
|
||||
|
||||
interface RuntimeSoundEmitterState {
|
||||
entity: RuntimeSoundEmitter;
|
||||
@@ -24,12 +34,23 @@ function getErrorDetail(error: unknown): string {
|
||||
return "Unknown error.";
|
||||
}
|
||||
|
||||
function formatSoundEmitterLabel(entityId: string, link: InteractionLink | null): string {
|
||||
function formatSoundEmitterLabel(
|
||||
entityId: string,
|
||||
link: InteractionLink | null
|
||||
): string {
|
||||
return link === null ? entityId : `${entityId} (${link.id})`;
|
||||
}
|
||||
|
||||
export function computeSoundEmitterDistanceGain(distance: number, refDistance: number, maxDistance: number): number {
|
||||
if (!Number.isFinite(distance) || !Number.isFinite(refDistance) || !Number.isFinite(maxDistance)) {
|
||||
export function computeSoundEmitterDistanceGain(
|
||||
distance: number,
|
||||
refDistance: number,
|
||||
maxDistance: number
|
||||
): number {
|
||||
if (
|
||||
!Number.isFinite(distance) ||
|
||||
!Number.isFinite(refDistance) ||
|
||||
!Number.isFinite(maxDistance)
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -45,7 +66,8 @@ export function computeSoundEmitterDistanceGain(distance: number, refDistance: n
|
||||
return 0;
|
||||
}
|
||||
|
||||
const normalizedDistance = (distance - refDistance) / (maxDistance - refDistance);
|
||||
const normalizedDistance =
|
||||
(distance - refDistance) / (maxDistance - refDistance);
|
||||
const clampedDistance = Math.min(1, Math.max(0, normalizedDistance));
|
||||
const proximity = 1 - clampedDistance;
|
||||
const easedProximity = proximity * proximity * proximity * proximity;
|
||||
@@ -84,7 +106,9 @@ export class RuntimeAudioSystem {
|
||||
listener = new AudioListener();
|
||||
this.camera.add(listener);
|
||||
} catch (error) {
|
||||
console.warn(`Audio is unavailable in this browser environment: ${getErrorDetail(error)}`);
|
||||
console.warn(
|
||||
`Audio is unavailable in this browser environment: ${getErrorDetail(error)}`
|
||||
);
|
||||
}
|
||||
|
||||
this.listener = listener;
|
||||
@@ -100,7 +124,10 @@ export class RuntimeAudioSystem {
|
||||
this.queueAutoplayEmitters();
|
||||
}
|
||||
|
||||
updateAssets(projectAssets: Record<string, ProjectAssetRecord>, loadedAudioAssets: Record<string, LoadedAudioAsset>) {
|
||||
updateAssets(
|
||||
projectAssets: Record<string, ProjectAssetRecord>,
|
||||
loadedAudioAssets: Record<string, LoadedAudioAsset>
|
||||
) {
|
||||
this.projectAssets = projectAssets;
|
||||
this.loadedAudioAssets = loadedAudioAssets;
|
||||
this.rebuildSoundEmitters();
|
||||
@@ -151,25 +178,37 @@ export class RuntimeAudioSystem {
|
||||
const soundEmitter = this.soundEmitters.get(soundEmitterId);
|
||||
|
||||
if (soundEmitter === undefined) {
|
||||
this.setRuntimeMessage(`Sound emitter ${formatSoundEmitterLabel(soundEmitterId, link)} could not be found.`);
|
||||
this.setRuntimeMessage(
|
||||
`Sound emitter ${formatSoundEmitterLabel(soundEmitterId, link)} could not be found.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.listener === null) {
|
||||
this.setRuntimeMessage("Audio is unavailable in this browser environment.");
|
||||
this.setRuntimeMessage(
|
||||
"Audio is unavailable in this browser environment."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (soundEmitter.buffer === null) {
|
||||
const assetLabel = this.describeAudioAssetAvailability(soundEmitter.entity.audioAssetId);
|
||||
this.setRuntimeMessage(`Sound emitter ${formatSoundEmitterLabel(soundEmitterId, link)} cannot play because ${assetLabel}.`);
|
||||
console.warn(`playSound: ${soundEmitterId} has no playable audio buffer.`);
|
||||
const assetLabel = this.describeAudioAssetAvailability(
|
||||
soundEmitter.entity.audioAssetId
|
||||
);
|
||||
this.setRuntimeMessage(
|
||||
`Sound emitter ${formatSoundEmitterLabel(soundEmitterId, link)} cannot play because ${assetLabel}.`
|
||||
);
|
||||
console.warn(
|
||||
`playSound: ${soundEmitterId} has no playable audio buffer.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.listener.context.state !== "running") {
|
||||
this.pendingPlayEmitterIds.add(soundEmitterId);
|
||||
this.setRuntimeMessage("Audio is locked. Click the runner to enable sound.");
|
||||
this.setRuntimeMessage(
|
||||
"Audio is locked. Click the runner to enable sound."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -188,7 +227,9 @@ export class RuntimeAudioSystem {
|
||||
try {
|
||||
soundEmitter.audio.stop();
|
||||
} catch (error) {
|
||||
console.warn(`stopSound: ${soundEmitterId} could not be stopped: ${getErrorDetail(error)}`);
|
||||
console.warn(
|
||||
`stopSound: ${soundEmitterId} could not be stopped: ${getErrorDetail(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,7 +281,11 @@ export class RuntimeAudioSystem {
|
||||
|
||||
for (const entity of this.runtimeScene.entities.soundEmitters) {
|
||||
const group = new Group();
|
||||
group.position.set(entity.position.x, entity.position.y, entity.position.z);
|
||||
group.position.set(
|
||||
entity.position.x,
|
||||
entity.position.y,
|
||||
entity.position.z
|
||||
);
|
||||
|
||||
let audio: PositionalAudio | null = null;
|
||||
|
||||
@@ -339,7 +384,11 @@ export class RuntimeAudioSystem {
|
||||
private playBufferedSound(soundEmitterId: string) {
|
||||
const soundEmitter = this.soundEmitters.get(soundEmitterId);
|
||||
|
||||
if (soundEmitter === undefined || soundEmitter.audio === null || soundEmitter.buffer === null) {
|
||||
if (
|
||||
soundEmitter === undefined ||
|
||||
soundEmitter.audio === null ||
|
||||
soundEmitter.buffer === null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -355,7 +404,10 @@ export class RuntimeAudioSystem {
|
||||
soundEmitter.audio.play();
|
||||
}
|
||||
|
||||
private configurePositionalAudio(audio: PositionalAudio, entity: RuntimeSoundEmitter) {
|
||||
private configurePositionalAudio(
|
||||
audio: PositionalAudio,
|
||||
entity: RuntimeSoundEmitter
|
||||
) {
|
||||
audio.setLoop(entity.loop);
|
||||
audio.setRefDistance(entity.refDistance);
|
||||
audio.setMaxDistance(entity.maxDistance);
|
||||
@@ -381,8 +433,13 @@ export class RuntimeAudioSystem {
|
||||
this.camera.getWorldPosition(_listenerPosition);
|
||||
soundEmitter.group.getWorldPosition(_emitterPosition);
|
||||
const distance = _listenerPosition.distanceTo(_emitterPosition);
|
||||
const attenuation = computeSoundEmitterDistanceGain(distance, soundEmitter.entity.refDistance, soundEmitter.entity.maxDistance);
|
||||
const underwaterDuck = 1 - (this.playerAudioHooks?.underwaterAmount ?? 0) * 0.4;
|
||||
const attenuation = computeSoundEmitterDistanceGain(
|
||||
distance,
|
||||
soundEmitter.entity.refDistance,
|
||||
soundEmitter.entity.maxDistance
|
||||
);
|
||||
const underwaterDuck =
|
||||
1 - (this.playerAudioHooks?.underwaterAmount ?? 0) * 0.4;
|
||||
|
||||
soundEmitter.audio.setVolume(
|
||||
soundEmitter.entity.volume * attenuation * underwaterDuck
|
||||
|
||||
@@ -213,7 +213,10 @@ export class RuntimeHost {
|
||||
private readonly volumeAnimatedUniforms: Array<{ value: number }> = [];
|
||||
private readonly runtimeWaterContactUniforms: RuntimeWaterContactUniformBinding[] =
|
||||
[];
|
||||
private readonly localLightObjects = new Map<string, LocalLightRenderObjects>();
|
||||
private readonly localLightObjects = new Map<
|
||||
string,
|
||||
LocalLightRenderObjects
|
||||
>();
|
||||
private readonly modelRenderObjects = new Map<string, Group>();
|
||||
private readonly materialTextureCache = new Map<
|
||||
string,
|
||||
@@ -568,9 +571,7 @@ export class RuntimeHost {
|
||||
}
|
||||
|
||||
setSceneExitHandler(
|
||||
handler:
|
||||
| ((request: RuntimeSceneExitTransitionRequest) => void)
|
||||
| null
|
||||
handler: ((request: RuntimeSceneExitTransitionRequest) => void) | null
|
||||
) {
|
||||
this.sceneExitHandler = handler;
|
||||
}
|
||||
@@ -763,7 +764,10 @@ export class RuntimeHost {
|
||||
const resolvedTime =
|
||||
this.currentClockState === null
|
||||
? null
|
||||
: resolveRuntimeTimeState(this.runtimeScene.time, this.currentClockState);
|
||||
: resolveRuntimeTimeState(
|
||||
this.runtimeScene.time,
|
||||
this.currentClockState
|
||||
);
|
||||
|
||||
const resolvedWorld = resolveRuntimeDayNightWorldState(
|
||||
this.currentWorld,
|
||||
@@ -773,8 +777,8 @@ export class RuntimeHost {
|
||||
);
|
||||
const backgroundTexture =
|
||||
resolvedWorld.background.mode === "image"
|
||||
? this.loadedImageAssets[resolvedWorld.background.assetId]?.texture ??
|
||||
null
|
||||
? (this.loadedImageAssets[resolvedWorld.background.assetId]?.texture ??
|
||||
null)
|
||||
: null;
|
||||
const nightBackgroundOverlay = resolvedWorld.nightBackgroundOverlay;
|
||||
const backgroundOverlayState =
|
||||
@@ -785,8 +789,7 @@ export class RuntimeHost {
|
||||
this.loadedImageAssets[nightBackgroundOverlay.assetId]?.texture ??
|
||||
null,
|
||||
opacity: nightBackgroundOverlay.opacity,
|
||||
environmentIntensity:
|
||||
nightBackgroundOverlay.environmentIntensity
|
||||
environmentIntensity: nightBackgroundOverlay.environmentIntensity
|
||||
};
|
||||
const environmentState = resolveWorldEnvironmentState(
|
||||
resolvedWorld.background,
|
||||
@@ -1380,28 +1383,28 @@ export class RuntimeHost {
|
||||
const facingGroup = new Group();
|
||||
facingGroup.rotation.y = (npc.yawDegrees * Math.PI) / 180;
|
||||
group.add(facingGroup);
|
||||
const colliderTop = getNpcColliderHeight({
|
||||
mode: npc.collider.mode,
|
||||
eyeHeight: npc.collider.eyeHeight,
|
||||
capsuleRadius: npc.collider.mode === "capsule" ? npc.collider.radius : 0.35,
|
||||
capsuleHeight: npc.collider.mode === "capsule" ? npc.collider.height : 1.8,
|
||||
boxSize:
|
||||
npc.collider.mode === "box"
|
||||
? npc.collider.size
|
||||
: {
|
||||
x: 0.7,
|
||||
y: 1.8,
|
||||
z: 0.7
|
||||
}
|
||||
}) ?? 0.18;
|
||||
const colliderTop =
|
||||
getNpcColliderHeight({
|
||||
mode: npc.collider.mode,
|
||||
eyeHeight: npc.collider.eyeHeight,
|
||||
capsuleRadius:
|
||||
npc.collider.mode === "capsule" ? npc.collider.radius : 0.35,
|
||||
capsuleHeight:
|
||||
npc.collider.mode === "capsule" ? npc.collider.height : 1.8,
|
||||
boxSize:
|
||||
npc.collider.mode === "box"
|
||||
? npc.collider.size
|
||||
: {
|
||||
x: 0.7,
|
||||
y: 1.8,
|
||||
z: 0.7
|
||||
}
|
||||
}) ?? 0.18;
|
||||
|
||||
const body = new Mesh(new BoxGeometry(0.08, 0.08, 0.34), facingMaterial);
|
||||
body.position.set(0, colliderTop + 0.12, 0.06);
|
||||
|
||||
const arrowHead = new Mesh(
|
||||
new ConeGeometry(0.1, 0.22, 14),
|
||||
facingMaterial
|
||||
);
|
||||
const arrowHead = new Mesh(new ConeGeometry(0.1, 0.22, 14), facingMaterial);
|
||||
arrowHead.rotation.x = Math.PI * 0.5;
|
||||
arrowHead.position.set(0, colliderTop + 0.12, 0.28);
|
||||
|
||||
@@ -1471,7 +1474,7 @@ export class RuntimeHost {
|
||||
const asset =
|
||||
npc.modelAssetId === null
|
||||
? null
|
||||
: this.projectAssets[npc.modelAssetId] ?? null;
|
||||
: (this.projectAssets[npc.modelAssetId] ?? null);
|
||||
const renderGroup =
|
||||
npc.modelAssetId === null || asset?.kind !== "model"
|
||||
? this.createNpcColliderFallbackRenderGroup(npc)
|
||||
@@ -2209,7 +2212,9 @@ export class RuntimeHost {
|
||||
this.applyDayNightLighting();
|
||||
this.clockPublishAccumulator += dt;
|
||||
|
||||
if (this.clockPublishAccumulator >= RUNTIME_CLOCK_PUBLISH_INTERVAL_SECONDS) {
|
||||
if (
|
||||
this.clockPublishAccumulator >= RUNTIME_CLOCK_PUBLISH_INTERVAL_SECONDS
|
||||
) {
|
||||
this.clockPublishAccumulator = 0;
|
||||
this.publishRuntimeClockState();
|
||||
}
|
||||
@@ -2349,9 +2354,9 @@ export class RuntimeHost {
|
||||
return;
|
||||
}
|
||||
|
||||
this.runtimeScene.entities.npcs = this.runtimeScene.npcDefinitions.filter(
|
||||
(npc) => npc.active
|
||||
).map((npc) => createRuntimeNpcFromDefinition(npc));
|
||||
this.runtimeScene.entities.npcs = this.runtimeScene.npcDefinitions
|
||||
.filter((npc) => npc.active)
|
||||
.map((npc) => createRuntimeNpcFromDefinition(npc));
|
||||
this.runtimeScene.colliders = [
|
||||
...this.runtimeScene.staticColliders,
|
||||
...this.runtimeScene.entities.npcs
|
||||
|
||||
@@ -18,8 +18,17 @@ const DEFAULT_INTERACTABLE_TARGET_RADIUS = 0.75;
|
||||
export interface RuntimeInteractionDispatcher {
|
||||
teleportPlayer(target: RuntimeTeleportTarget, link: InteractionLink): void;
|
||||
activateSceneExit(sceneExit: RuntimeSceneExit): void;
|
||||
toggleBrushVisibility(brushId: string, visible: boolean | undefined, link: InteractionLink): void;
|
||||
playAnimation(instanceId: string, clipName: string, loop: boolean | undefined, link: InteractionLink): void;
|
||||
toggleBrushVisibility(
|
||||
brushId: string,
|
||||
visible: boolean | undefined,
|
||||
link: InteractionLink
|
||||
): void;
|
||||
playAnimation(
|
||||
instanceId: string,
|
||||
clipName: string,
|
||||
loop: boolean | undefined,
|
||||
link: InteractionLink
|
||||
): void;
|
||||
stopAnimation(instanceId: string, link: InteractionLink): void;
|
||||
playSound(soundEmitterId: string, link: InteractionLink): void;
|
||||
stopSound(soundEmitterId: string, link: InteractionLink): void;
|
||||
@@ -71,7 +80,10 @@ function normalizeVec3(vector: Vec3): Vec3 | null {
|
||||
return scaleVec3(vector, 1 / Math.sqrt(lengthSquared));
|
||||
}
|
||||
|
||||
function isPointInsideTriggerVolume(position: Vec3, triggerVolume: RuntimeTriggerVolume): boolean {
|
||||
function isPointInsideTriggerVolume(
|
||||
position: Vec3,
|
||||
triggerVolume: RuntimeTriggerVolume
|
||||
): boolean {
|
||||
const halfSize = {
|
||||
x: triggerVolume.size.x * 0.5,
|
||||
y: triggerVolume.size.y * 0.5,
|
||||
@@ -88,7 +100,12 @@ function isPointInsideTriggerVolume(position: Vec3, triggerVolume: RuntimeTrigge
|
||||
);
|
||||
}
|
||||
|
||||
function raySphereHitDistance(origin: Vec3, direction: Vec3, center: Vec3, radius: number): number | null {
|
||||
function raySphereHitDistance(
|
||||
origin: Vec3,
|
||||
direction: Vec3,
|
||||
center: Vec3,
|
||||
radius: number
|
||||
): number | null {
|
||||
const offset = subtractVec3(origin, center);
|
||||
const halfB = dotVec3(offset, direction);
|
||||
const c = dotVec3(offset, offset) - radius * radius;
|
||||
@@ -109,15 +126,30 @@ function raySphereHitDistance(origin: Vec3, direction: Vec3, center: Vec3, radiu
|
||||
return farHit >= 0 ? 0 : null;
|
||||
}
|
||||
|
||||
function resolveTeleportTarget(runtimeScene: RuntimeSceneDefinition, entityId: string): RuntimeTeleportTarget | null {
|
||||
return runtimeScene.entities.teleportTargets.find((teleportTarget) => teleportTarget.entityId === entityId) ?? null;
|
||||
function resolveTeleportTarget(
|
||||
runtimeScene: RuntimeSceneDefinition,
|
||||
entityId: string
|
||||
): RuntimeTeleportTarget | null {
|
||||
return (
|
||||
runtimeScene.entities.teleportTargets.find(
|
||||
(teleportTarget) => teleportTarget.entityId === entityId
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function hasTriggerLinks(runtimeScene: RuntimeSceneDefinition, sourceEntityId: string, trigger: InteractionLink["trigger"]): boolean {
|
||||
return runtimeScene.interactionLinks.some((link) => link.sourceEntityId === sourceEntityId && link.trigger === trigger);
|
||||
function hasTriggerLinks(
|
||||
runtimeScene: RuntimeSceneDefinition,
|
||||
sourceEntityId: string,
|
||||
trigger: InteractionLink["trigger"]
|
||||
): boolean {
|
||||
return runtimeScene.interactionLinks.some(
|
||||
(link) => link.sourceEntityId === sourceEntityId && link.trigger === trigger
|
||||
);
|
||||
}
|
||||
|
||||
function getInteractableTargetRadius(interactable: RuntimeInteractable): number {
|
||||
function getInteractableTargetRadius(
|
||||
interactable: RuntimeInteractable
|
||||
): number {
|
||||
return Math.min(DEFAULT_INTERACTABLE_TARGET_RADIUS, interactable.radius);
|
||||
}
|
||||
|
||||
@@ -143,7 +175,8 @@ function updateBestPrompt(
|
||||
(currentBestPrompt === null ||
|
||||
candidateDistance < currentBestPrompt.distance ||
|
||||
(candidateDistance === currentBestPrompt.distance &&
|
||||
candidateEntityId.localeCompare(currentBestPrompt.sourceEntityId) < 0)))
|
||||
candidateEntityId.localeCompare(currentBestPrompt.sourceEntityId) <
|
||||
0)))
|
||||
) {
|
||||
return {
|
||||
prompt: nextPrompt,
|
||||
@@ -164,15 +197,42 @@ export class RuntimeInteractionSystem {
|
||||
this.occupiedTriggerVolumes.clear();
|
||||
}
|
||||
|
||||
updatePlayerPosition(feetPosition: Vec3, runtimeScene: RuntimeSceneDefinition, dispatcher: RuntimeInteractionDispatcher) {
|
||||
updatePlayerPosition(
|
||||
feetPosition: Vec3,
|
||||
runtimeScene: RuntimeSceneDefinition,
|
||||
dispatcher: RuntimeInteractionDispatcher
|
||||
) {
|
||||
for (const triggerVolume of runtimeScene.entities.triggerVolumes) {
|
||||
const containsPlayer = isPointInsideTriggerVolume(feetPosition, triggerVolume);
|
||||
const wasOccupied = this.occupiedTriggerVolumes.has(triggerVolume.entityId);
|
||||
const containsPlayer = isPointInsideTriggerVolume(
|
||||
feetPosition,
|
||||
triggerVolume
|
||||
);
|
||||
const wasOccupied = this.occupiedTriggerVolumes.has(
|
||||
triggerVolume.entityId
|
||||
);
|
||||
|
||||
if (!wasOccupied && containsPlayer && hasTriggerLinks(runtimeScene, triggerVolume.entityId, "enter")) {
|
||||
this.dispatchLinks(triggerVolume.entityId, "enter", runtimeScene, dispatcher);
|
||||
} else if (wasOccupied && !containsPlayer && hasTriggerLinks(runtimeScene, triggerVolume.entityId, "exit")) {
|
||||
this.dispatchLinks(triggerVolume.entityId, "exit", runtimeScene, dispatcher);
|
||||
if (
|
||||
!wasOccupied &&
|
||||
containsPlayer &&
|
||||
hasTriggerLinks(runtimeScene, triggerVolume.entityId, "enter")
|
||||
) {
|
||||
this.dispatchLinks(
|
||||
triggerVolume.entityId,
|
||||
"enter",
|
||||
runtimeScene,
|
||||
dispatcher
|
||||
);
|
||||
} else if (
|
||||
wasOccupied &&
|
||||
!containsPlayer &&
|
||||
hasTriggerLinks(runtimeScene, triggerVolume.entityId, "exit")
|
||||
) {
|
||||
this.dispatchLinks(
|
||||
triggerVolume.entityId,
|
||||
"exit",
|
||||
runtimeScene,
|
||||
dispatcher
|
||||
);
|
||||
}
|
||||
|
||||
if (containsPlayer) {
|
||||
@@ -199,7 +259,10 @@ export class RuntimeInteractionSystem {
|
||||
let bestHitDistance = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const interactable of runtimeScene.entities.interactables) {
|
||||
if (!interactable.interactionEnabled || !hasTriggerLinks(runtimeScene, interactable.entityId, "click")) {
|
||||
if (
|
||||
!interactable.interactionEnabled ||
|
||||
!hasTriggerLinks(runtimeScene, interactable.entityId, "click")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -240,7 +303,10 @@ export class RuntimeInteractionSystem {
|
||||
continue;
|
||||
}
|
||||
|
||||
const distance = distanceBetweenVec3(interactionOrigin, sceneExit.position);
|
||||
const distance = distanceBetweenVec3(
|
||||
interactionOrigin,
|
||||
sceneExit.position
|
||||
);
|
||||
|
||||
if (distance > sceneExit.radius) {
|
||||
continue;
|
||||
@@ -273,7 +339,11 @@ export class RuntimeInteractionSystem {
|
||||
return bestPrompt;
|
||||
}
|
||||
|
||||
dispatchClickInteraction(sourceEntityId: string, runtimeScene: RuntimeSceneDefinition, dispatcher: RuntimeInteractionDispatcher) {
|
||||
dispatchClickInteraction(
|
||||
sourceEntityId: string,
|
||||
runtimeScene: RuntimeSceneDefinition,
|
||||
dispatcher: RuntimeInteractionDispatcher
|
||||
) {
|
||||
const sceneExit =
|
||||
runtimeScene.entities.sceneExits.find(
|
||||
(candidate) => candidate.entityId === sourceEntityId
|
||||
@@ -300,14 +370,20 @@ export class RuntimeInteractionSystem {
|
||||
|
||||
const controlEffect = getInteractionActionControlEffect(link.action);
|
||||
|
||||
if (controlEffect !== null && dispatcher.dispatchControlEffect !== undefined) {
|
||||
if (
|
||||
controlEffect !== null &&
|
||||
dispatcher.dispatchControlEffect !== undefined
|
||||
) {
|
||||
dispatcher.dispatchControlEffect(controlEffect, link);
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (link.action.type) {
|
||||
case "teleportPlayer": {
|
||||
const teleportTarget = resolveTeleportTarget(runtimeScene, link.action.targetEntityId);
|
||||
const teleportTarget = resolveTeleportTarget(
|
||||
runtimeScene,
|
||||
link.action.targetEntityId
|
||||
);
|
||||
|
||||
if (teleportTarget !== null) {
|
||||
dispatcher.teleportPlayer(teleportTarget, link);
|
||||
@@ -315,10 +391,19 @@ export class RuntimeInteractionSystem {
|
||||
break;
|
||||
}
|
||||
case "toggleVisibility":
|
||||
dispatcher.toggleBrushVisibility(link.action.targetBrushId, link.action.visible, link);
|
||||
dispatcher.toggleBrushVisibility(
|
||||
link.action.targetBrushId,
|
||||
link.action.visible,
|
||||
link
|
||||
);
|
||||
break;
|
||||
case "playAnimation":
|
||||
dispatcher.playAnimation(link.action.targetModelInstanceId, link.action.clipName, link.action.loop, link);
|
||||
dispatcher.playAnimation(
|
||||
link.action.targetModelInstanceId,
|
||||
link.action.clipName,
|
||||
link.action.loop,
|
||||
link
|
||||
);
|
||||
break;
|
||||
case "stopAnimation":
|
||||
dispatcher.stopAnimation(link.action.targetModelInstanceId, link);
|
||||
|
||||
@@ -37,7 +37,10 @@ import {
|
||||
type ScenePath,
|
||||
type ScenePathPoint
|
||||
} from "../document/paths";
|
||||
import { cloneWorldSettings, type WorldSettings } from "../document/world-settings";
|
||||
import {
|
||||
cloneWorldSettings,
|
||||
type WorldSettings
|
||||
} from "../document/world-settings";
|
||||
import {
|
||||
type CharacterColliderSettings,
|
||||
cloneNpcPresence,
|
||||
@@ -57,13 +60,27 @@ import {
|
||||
} from "../entities/entity-instances";
|
||||
import { getBoxBrushBounds } from "../geometry/box-brush";
|
||||
import { buildBoxBrushDerivedMeshData } from "../geometry/box-brush-mesh";
|
||||
import { buildGeneratedModelCollider, type GeneratedColliderBounds, type GeneratedModelCollider } from "../geometry/model-instance-collider-generation";
|
||||
import { cloneInteractionLink, getInteractionLinks, type InteractionLink } from "../interactions/interaction-links";
|
||||
import { cloneMaterialDef, type MaterialDef } from "../materials/starter-material-library";
|
||||
import {
|
||||
buildGeneratedModelCollider,
|
||||
type GeneratedColliderBounds,
|
||||
type GeneratedModelCollider
|
||||
} from "../geometry/model-instance-collider-generation";
|
||||
import {
|
||||
cloneInteractionLink,
|
||||
getInteractionLinks,
|
||||
type InteractionLink
|
||||
} from "../interactions/interaction-links";
|
||||
import {
|
||||
cloneMaterialDef,
|
||||
type MaterialDef
|
||||
} from "../materials/starter-material-library";
|
||||
import { assertRuntimeSceneBuildable } from "./runtime-scene-validation";
|
||||
import { resolveNpcPresenceActive } from "./runtime-npc-presence";
|
||||
import type { RuntimeClockState } from "./runtime-project-time";
|
||||
import { FIRST_PERSON_PLAYER_SHAPE, type FirstPersonPlayerShape } from "./player-collision";
|
||||
import {
|
||||
FIRST_PERSON_PLAYER_SHAPE,
|
||||
type FirstPersonPlayerShape
|
||||
} from "./player-collision";
|
||||
|
||||
export type RuntimeNavigationMode = "firstPerson" | "thirdPerson";
|
||||
|
||||
@@ -507,7 +524,10 @@ function buildRuntimePlayerMovement(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRuntimeMaterial(document: SceneDocument, materialId: string | null): MaterialDef | null {
|
||||
function resolveRuntimeMaterial(
|
||||
document: SceneDocument,
|
||||
materialId: string | null
|
||||
): MaterialDef | null {
|
||||
if (materialId === null) {
|
||||
return null;
|
||||
}
|
||||
@@ -521,7 +541,10 @@ function resolveRuntimeMaterial(document: SceneDocument, materialId: string | nu
|
||||
return cloneMaterialDef(material);
|
||||
}
|
||||
|
||||
function buildRuntimeBrush(brush: BoxBrush, document: SceneDocument): RuntimeBoxBrushInstance {
|
||||
function buildRuntimeBrush(
|
||||
brush: BoxBrush,
|
||||
document: SceneDocument
|
||||
): RuntimeBoxBrushInstance {
|
||||
return {
|
||||
id: brush.id,
|
||||
kind: "box",
|
||||
@@ -584,7 +607,9 @@ function buildRuntimeFogVolume(brush: BoxBrush): RuntimeFogVolume {
|
||||
|
||||
function buildRuntimeWaterVolume(brush: BoxBrush): RuntimeWaterVolume {
|
||||
if (brush.volume.mode !== "water") {
|
||||
throw new Error(`Cannot build water volume from non-water brush ${brush.id}.`);
|
||||
throw new Error(
|
||||
`Cannot build water volume from non-water brush ${brush.id}.`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -617,7 +642,9 @@ function buildRuntimeCollider(brush: BoxBrush): RuntimeBrushTriMeshCollider {
|
||||
};
|
||||
}
|
||||
|
||||
function buildRuntimeModelInstance(modelInstance: SceneDocument["modelInstances"][string]): RuntimeModelInstance {
|
||||
function buildRuntimeModelInstance(
|
||||
modelInstance: SceneDocument["modelInstances"][string]
|
||||
): RuntimeModelInstance {
|
||||
return {
|
||||
instanceId: modelInstance.id,
|
||||
assetId: modelInstance.assetId,
|
||||
@@ -663,7 +690,9 @@ function buildRuntimePath(path: ScenePath): RuntimePath {
|
||||
};
|
||||
}
|
||||
|
||||
function getColliderBounds(collider: RuntimeSceneCollider): GeneratedColliderBounds {
|
||||
function getColliderBounds(
|
||||
collider: RuntimeSceneCollider
|
||||
): GeneratedColliderBounds {
|
||||
return {
|
||||
min: cloneVec3(collider.worldBounds.min),
|
||||
max: cloneVec3(collider.worldBounds.max)
|
||||
@@ -751,7 +780,9 @@ export function buildRuntimeNpcCollider(
|
||||
}
|
||||
}
|
||||
|
||||
function combineColliderBounds(colliders: RuntimeSceneCollider[]): RuntimeSceneBounds | null {
|
||||
function combineColliderBounds(
|
||||
colliders: RuntimeSceneCollider[]
|
||||
): RuntimeSceneBounds | null {
|
||||
if (colliders.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -786,7 +817,9 @@ function combineColliderBounds(colliders: RuntimeSceneCollider[]): RuntimeSceneB
|
||||
};
|
||||
}
|
||||
|
||||
function buildFallbackSpawn(sceneBounds: RuntimeSceneBounds | null): RuntimeSpawnPoint {
|
||||
function buildFallbackSpawn(
|
||||
sceneBounds: RuntimeSceneBounds | null
|
||||
): RuntimeSpawnPoint {
|
||||
if (sceneBounds === null) {
|
||||
return {
|
||||
source: "fallback",
|
||||
@@ -829,7 +862,10 @@ function buildRuntimeControlSurface(
|
||||
const defaultSource = createDefaultResolvedControlSource();
|
||||
|
||||
for (const pointLight of collections.localLights.pointLights) {
|
||||
const target = createLightControlTargetRef("pointLight", pointLight.entityId);
|
||||
const target = createLightControlTargetRef(
|
||||
"pointLight",
|
||||
pointLight.entityId
|
||||
);
|
||||
const descriptor = createLightIntensityControlChannelDescriptor({
|
||||
target,
|
||||
defaultValue: pointLight.intensity
|
||||
@@ -843,7 +879,8 @@ function buildRuntimeControlSurface(
|
||||
resolved.discrete.push(
|
||||
createResolvedLightEnabledState({
|
||||
target,
|
||||
value: sourceEntity?.kind === "pointLight" ? sourceEntity.visible : true,
|
||||
value:
|
||||
sourceEntity?.kind === "pointLight" ? sourceEntity.visible : true,
|
||||
source: defaultSource
|
||||
})
|
||||
);
|
||||
@@ -934,7 +971,8 @@ function buildRuntimeControlSurface(
|
||||
}
|
||||
|
||||
for (const modelInstance of modelInstances) {
|
||||
const authoredModelInstance = document.modelInstances[modelInstance.instanceId];
|
||||
const authoredModelInstance =
|
||||
document.modelInstances[modelInstance.instanceId];
|
||||
const asset =
|
||||
authoredModelInstance === undefined
|
||||
? undefined
|
||||
@@ -1112,7 +1150,9 @@ function buildRuntimeSceneCollections(
|
||||
}
|
||||
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`Unsupported runtime entity: ${String((value as EntityInstance).kind)}`);
|
||||
throw new Error(
|
||||
`Unsupported runtime entity: ${String((value as EntityInstance).kind)}`
|
||||
);
|
||||
}
|
||||
|
||||
function buildRuntimePlayerShape(
|
||||
@@ -1161,8 +1201,13 @@ function resolveRuntimeSpawn(
|
||||
return buildFallbackSpawn(sceneBounds);
|
||||
}
|
||||
|
||||
export function buildRuntimeSceneFromDocument(document: SceneDocument, options: BuildRuntimeSceneOptions = {}): RuntimeSceneDefinition {
|
||||
const playerStartEntity = getPrimaryEnabledPlayerStartEntity(document.entities);
|
||||
export function buildRuntimeSceneFromDocument(
|
||||
document: SceneDocument,
|
||||
options: BuildRuntimeSceneOptions = {}
|
||||
): RuntimeSceneDefinition {
|
||||
const playerStartEntity = getPrimaryEnabledPlayerStartEntity(
|
||||
document.entities
|
||||
);
|
||||
const navigationMode = resolveRuntimeNavigationMode(
|
||||
playerStartEntity,
|
||||
options.navigationMode
|
||||
@@ -1173,8 +1218,12 @@ export function buildRuntimeSceneFromDocument(document: SceneDocument, options:
|
||||
loadedModelAssets: options.loadedModelAssets
|
||||
});
|
||||
|
||||
const enabledBrushes = Object.values(document.brushes).filter((brush) => brush.enabled);
|
||||
const brushes = enabledBrushes.map((brush) => buildRuntimeBrush(brush, document));
|
||||
const enabledBrushes = Object.values(document.brushes).filter(
|
||||
(brush) => brush.enabled
|
||||
);
|
||||
const brushes = enabledBrushes.map((brush) =>
|
||||
buildRuntimeBrush(brush, document)
|
||||
);
|
||||
const staticColliders: RuntimeSceneCollider[] = [];
|
||||
const volumes: RuntimeBoxVolumeCollection = {
|
||||
fog: [],
|
||||
@@ -1194,7 +1243,9 @@ export function buildRuntimeSceneFromDocument(document: SceneDocument, options:
|
||||
|
||||
volumes.water.push(buildRuntimeWaterVolume(brush));
|
||||
}
|
||||
const enabledModelInstances = getModelInstances(document.modelInstances).filter((modelInstance) => modelInstance.enabled);
|
||||
const enabledModelInstances = getModelInstances(
|
||||
document.modelInstances
|
||||
).filter((modelInstance) => modelInstance.enabled);
|
||||
const modelInstances = enabledModelInstances.map(buildRuntimeModelInstance);
|
||||
const paths = getScenePaths(document.paths)
|
||||
.filter((path) => path.enabled)
|
||||
@@ -1205,9 +1256,15 @@ export function buildRuntimeSceneFromDocument(document: SceneDocument, options:
|
||||
document,
|
||||
runtimeTimeOfDayHours
|
||||
);
|
||||
const control = buildRuntimeControlSurface(document, collections, modelInstances);
|
||||
const control = buildRuntimeControlSurface(
|
||||
document,
|
||||
collections,
|
||||
modelInstances
|
||||
);
|
||||
const enabledBrushIds = new Set(enabledBrushes.map((brush) => brush.id));
|
||||
const enabledModelInstanceIds = new Set(enabledModelInstances.map((modelInstance) => modelInstance.id));
|
||||
const enabledModelInstanceIds = new Set(
|
||||
enabledModelInstances.map((modelInstance) => modelInstance.id)
|
||||
);
|
||||
const enabledEntityIds = new Set(
|
||||
getEntityInstances(document.entities)
|
||||
.filter((entity) => entity.enabled)
|
||||
@@ -1267,7 +1324,11 @@ export function buildRuntimeSceneFromDocument(document: SceneDocument, options:
|
||||
continue;
|
||||
}
|
||||
|
||||
const generatedCollider = buildGeneratedModelCollider(modelInstance, asset, options.loadedModelAssets?.[modelInstance.assetId]);
|
||||
const generatedCollider = buildGeneratedModelCollider(
|
||||
modelInstance,
|
||||
asset,
|
||||
options.loadedModelAssets?.[modelInstance.assetId]
|
||||
);
|
||||
|
||||
if (generatedCollider !== null) {
|
||||
staticColliders.push(generatedCollider);
|
||||
|
||||
Reference in New Issue
Block a user