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:
2026-04-14 01:40:15 +02:00
parent 3ae63c83d1
commit c1f1ff7488
14 changed files with 1354 additions and 810 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -199,7 +199,9 @@ function assertNonEmptyString(value: string, label: string) {
function assertNonNegativeFiniteNumber(value: number, label: string) {
if (!Number.isFinite(value) || value < 0) {
throw new Error(`${label} must be a finite number greater than or equal to zero.`);
throw new Error(
`${label} must be a finite number greater than or equal to zero.`
);
}
}
@@ -212,9 +214,7 @@ function assertBoolean(value: boolean, label: string) {
export function isControlEntityTargetKind(
value: unknown
): value is ControlEntityTargetKind {
return CONTROL_ENTITY_TARGET_KINDS.includes(
value as ControlEntityTargetKind
);
return CONTROL_ENTITY_TARGET_KINDS.includes(value as ControlEntityTargetKind);
}
export function isControlInteractionTargetKind(
@@ -412,10 +412,7 @@ export function createLightIntensityControlChannelDescriptor(options: {
options.defaultValue,
"Control light intensity default"
);
assertNonNegativeFiniteNumber(
minValue,
"Control light intensity minimum"
);
assertNonNegativeFiniteNumber(minValue, "Control light intensity minimum");
return {
channel: "light.intensity",
@@ -751,7 +748,9 @@ export function areControlEffectsEqual(
return false;
}
if (getControlTargetRefKey(left.target) !== getControlTargetRefKey(right.target)) {
if (
getControlTargetRefKey(left.target) !== getControlTargetRefKey(right.target)
) {
return false;
}
@@ -767,8 +766,7 @@ export function areControlEffectsEqual(
return true;
case "setInteractionEnabled":
return (
left.enabled ===
(right as SetInteractionEnabledControlEffect).enabled
left.enabled === (right as SetInteractionEnabledControlEffect).enabled
);
case "setLightEnabled":
return left.enabled === (right as SetLightEnabledControlEffect).enabled;
@@ -868,7 +866,8 @@ export function applyControlEffectToResolvedState(
});
const stateKey = getResolvedDiscreteControlStateKey(nextState);
const existingIndex = nextResolved.discrete.findIndex(
(candidate) => getResolvedDiscreteControlStateKey(candidate) === stateKey
(candidate) =>
getResolvedDiscreteControlStateKey(candidate) === stateKey
);
if (existingIndex >= 0) {
@@ -886,7 +885,8 @@ export function applyControlEffectToResolvedState(
});
const stateKey = getResolvedDiscreteControlStateKey(nextState);
const existingIndex = nextResolved.discrete.findIndex(
(candidate) => getResolvedDiscreteControlStateKey(candidate) === stateKey
(candidate) =>
getResolvedDiscreteControlStateKey(candidate) === stateKey
);
if (existingIndex >= 0) {

View File

@@ -288,7 +288,9 @@ function readSceneEditorPanelPreferences(
viewMode !== "front" &&
viewMode !== "side"
) {
throw new Error(`${label}.viewMode must be a supported viewport view mode.`);
throw new Error(
`${label}.viewMode must be a supported viewport view mode.`
);
}
if (
@@ -1287,7 +1289,8 @@ function readPlayerStartKeyboardBindingCode(
label,
fallback,
(candidate): candidate is typeof fallback =>
typeof candidate === "string" && isPlayerStartKeyboardBindingCode(candidate)
typeof candidate === "string" &&
isPlayerStartKeyboardBindingCode(candidate)
);
}
@@ -2254,7 +2257,7 @@ function readWorldTimeOfDaySettings(
),
ambientColorHex: expectHexColor(
isRecord(nightValue)
? nightValue.ambientColorHex ?? nightDefaults.ambientColorHex
? (nightValue.ambientColorHex ?? nightDefaults.ambientColorHex)
: nightDefaults.ambientColorHex,
`${label}.night.ambientColorHex`
),
@@ -2265,7 +2268,7 @@ function readWorldTimeOfDaySettings(
),
lightColorHex: expectHexColor(
isRecord(nightValue)
? nightValue.lightColorHex ?? nightDefaults.lightColorHex
? (nightValue.lightColorHex ?? nightDefaults.lightColorHex)
: nightDefaults.lightColorHex,
`${label}.night.lightColorHex`
),
@@ -2351,8 +2354,16 @@ function readPointLightEntity(value: unknown, label: string): EntityInstance {
const entity = createPointLightEntity({
id: expectString(value.id, `${label}.id`),
name: readOptionalEntityName(value.name, `${label}.name`),
visible: readOptionalBoolean(value.visible, `${label}.visible`, DEFAULT_ENTITY_VISIBLE),
enabled: readOptionalBoolean(value.enabled, `${label}.enabled`, DEFAULT_ENTITY_ENABLED),
visible: readOptionalBoolean(
value.visible,
`${label}.visible`,
DEFAULT_ENTITY_VISIBLE
),
enabled: readOptionalBoolean(
value.enabled,
`${label}.enabled`,
DEFAULT_ENTITY_ENABLED
),
position: readVec3(value.position, `${label}.position`),
colorHex: expectHexColor(value.colorHex, `${label}.colorHex`),
intensity: expectNonNegativeFiniteNumber(
@@ -2378,8 +2389,16 @@ function readSpotLightEntity(value: unknown, label: string): EntityInstance {
const entity = createSpotLightEntity({
id: expectString(value.id, `${label}.id`),
name: readOptionalEntityName(value.name, `${label}.name`),
visible: readOptionalBoolean(value.visible, `${label}.visible`, DEFAULT_ENTITY_VISIBLE),
enabled: readOptionalBoolean(value.enabled, `${label}.enabled`, DEFAULT_ENTITY_ENABLED),
visible: readOptionalBoolean(
value.visible,
`${label}.visible`,
DEFAULT_ENTITY_VISIBLE
),
enabled: readOptionalBoolean(
value.enabled,
`${label}.enabled`,
DEFAULT_ENTITY_ENABLED
),
position: readVec3(value.position, `${label}.position`),
direction: readVec3(value.direction, `${label}.direction`),
colorHex: expectHexColor(value.colorHex, `${label}.colorHex`),
@@ -2410,8 +2429,16 @@ function readPlayerStartEntity(value: unknown, label: string): EntityInstance {
const entity = createPlayerStartEntity({
id: expectString(value.id, `${label}.id`),
name: readOptionalEntityName(value.name, `${label}.name`),
visible: readOptionalBoolean(value.visible, `${label}.visible`, DEFAULT_ENTITY_VISIBLE),
enabled: readOptionalBoolean(value.enabled, `${label}.enabled`, DEFAULT_ENTITY_ENABLED),
visible: readOptionalBoolean(
value.visible,
`${label}.visible`,
DEFAULT_ENTITY_VISIBLE
),
enabled: readOptionalBoolean(
value.enabled,
`${label}.enabled`,
DEFAULT_ENTITY_ENABLED
),
position: readVec3(value.position, `${label}.position`),
yawDegrees: expectFiniteNumber(value.yawDegrees, `${label}.yawDegrees`),
navigationMode: readPlayerStartNavigationMode(
@@ -2448,8 +2475,16 @@ function readSoundEmitterEntity(value: unknown, label: string): EntityInstance {
const entity = createSoundEmitterEntity({
id: expectString(value.id, `${label}.id`),
name: readOptionalEntityName(value.name, `${label}.name`),
visible: readOptionalBoolean(value.visible, `${label}.visible`, DEFAULT_ENTITY_VISIBLE),
enabled: readOptionalBoolean(value.enabled, `${label}.enabled`, DEFAULT_ENTITY_ENABLED),
visible: readOptionalBoolean(
value.visible,
`${label}.visible`,
DEFAULT_ENTITY_VISIBLE
),
enabled: readOptionalBoolean(
value.enabled,
`${label}.enabled`,
DEFAULT_ENTITY_ENABLED
),
position: readVec3(value.position, `${label}.position`),
audioAssetId:
value.audioAssetId === undefined || value.audioAssetId === null
@@ -2488,8 +2523,16 @@ function readLegacySoundEmitterEntity(
const entity = createSoundEmitterEntity({
id: expectString(value.id, `${label}.id`),
name: readOptionalEntityName(value.name, `${label}.name`),
visible: readOptionalBoolean(value.visible, `${label}.visible`, DEFAULT_ENTITY_VISIBLE),
enabled: readOptionalBoolean(value.enabled, `${label}.enabled`, DEFAULT_ENTITY_ENABLED),
visible: readOptionalBoolean(
value.visible,
`${label}.visible`,
DEFAULT_ENTITY_VISIBLE
),
enabled: readOptionalBoolean(
value.enabled,
`${label}.enabled`,
DEFAULT_ENTITY_ENABLED
),
position: readVec3(value.position, `${label}.position`),
refDistance: radius,
maxDistance: radius,
@@ -2527,8 +2570,16 @@ function readTriggerVolumeEntity(
const entity = createTriggerVolumeEntity({
id: expectString(value.id, `${label}.id`),
name: readOptionalEntityName(value.name, `${label}.name`),
visible: readOptionalBoolean(value.visible, `${label}.visible`, DEFAULT_ENTITY_VISIBLE),
enabled: readOptionalBoolean(value.enabled, `${label}.enabled`, DEFAULT_ENTITY_ENABLED),
visible: readOptionalBoolean(
value.visible,
`${label}.visible`,
DEFAULT_ENTITY_VISIBLE
),
enabled: readOptionalBoolean(
value.enabled,
`${label}.enabled`,
DEFAULT_ENTITY_ENABLED
),
position: readVec3(value.position, `${label}.position`),
size,
triggerOnEnter: expectBoolean(
@@ -2561,8 +2612,16 @@ function readTeleportTargetEntity(
const entity = createTeleportTargetEntity({
id: expectString(value.id, `${label}.id`),
name: readOptionalEntityName(value.name, `${label}.name`),
visible: readOptionalBoolean(value.visible, `${label}.visible`, DEFAULT_ENTITY_VISIBLE),
enabled: readOptionalBoolean(value.enabled, `${label}.enabled`, DEFAULT_ENTITY_ENABLED),
visible: readOptionalBoolean(
value.visible,
`${label}.visible`,
DEFAULT_ENTITY_VISIBLE
),
enabled: readOptionalBoolean(
value.enabled,
`${label}.enabled`,
DEFAULT_ENTITY_ENABLED
),
position: readVec3(value.position, `${label}.position`),
yawDegrees: expectFiniteNumber(value.yawDegrees, `${label}.yawDegrees`)
});
@@ -2587,11 +2646,19 @@ function readInteractableEntity(value: unknown, label: string): EntityInstance {
const entity = createInteractableEntity({
id: expectString(value.id, `${label}.id`),
name: readOptionalEntityName(value.name, `${label}.name`),
visible: readOptionalBoolean(value.visible, `${label}.visible`, DEFAULT_ENTITY_VISIBLE),
visible: readOptionalBoolean(
value.visible,
`${label}.visible`,
DEFAULT_ENTITY_VISIBLE
),
enabled:
value.interactionEnabled === undefined
? DEFAULT_ENTITY_ENABLED
: readOptionalBoolean(value.enabled, `${label}.enabled`, DEFAULT_ENTITY_ENABLED),
: readOptionalBoolean(
value.enabled,
`${label}.enabled`,
DEFAULT_ENTITY_ENABLED
),
position: readVec3(value.position, `${label}.position`),
radius: expectPositiveFiniteNumber(value.radius, `${label}.radius`),
prompt: expectString(value.prompt, `${label}.prompt`),
@@ -2614,8 +2681,16 @@ function readSceneEntryEntity(value: unknown, label: string): EntityInstance {
const entity = createSceneEntryEntity({
id: expectString(value.id, `${label}.id`),
name: readOptionalEntityName(value.name, `${label}.name`),
visible: readOptionalBoolean(value.visible, `${label}.visible`, DEFAULT_ENTITY_VISIBLE),
enabled: readOptionalBoolean(value.enabled, `${label}.enabled`, DEFAULT_ENTITY_ENABLED),
visible: readOptionalBoolean(
value.visible,
`${label}.visible`,
DEFAULT_ENTITY_VISIBLE
),
enabled: readOptionalBoolean(
value.enabled,
`${label}.enabled`,
DEFAULT_ENTITY_ENABLED
),
position: readVec3(value.position, `${label}.position`),
yawDegrees: expectFiniteNumber(value.yawDegrees, `${label}.yawDegrees`)
});
@@ -2703,11 +2778,19 @@ function readSceneExitEntity(value: unknown, label: string): EntityInstance {
const entity = createSceneExitEntity({
id: expectString(value.id, `${label}.id`),
name: readOptionalEntityName(value.name, `${label}.name`),
visible: readOptionalBoolean(value.visible, `${label}.visible`, DEFAULT_ENTITY_VISIBLE),
visible: readOptionalBoolean(
value.visible,
`${label}.visible`,
DEFAULT_ENTITY_VISIBLE
),
enabled:
value.interactionEnabled === undefined
? DEFAULT_ENTITY_ENABLED
: readOptionalBoolean(value.enabled, `${label}.enabled`, DEFAULT_ENTITY_ENABLED),
: readOptionalBoolean(
value.enabled,
`${label}.enabled`,
DEFAULT_ENTITY_ENABLED
),
position: readVec3(value.position, `${label}.position`),
radius: expectPositiveFiniteNumber(value.radius, `${label}.radius`),
prompt: expectString(value.prompt, `${label}.prompt`),
@@ -2811,7 +2894,9 @@ function readControlTargetRef(value: unknown, label: string): ControlTargetRef {
const entityKind = expectString(value.entityKind, `${label}.entityKind`);
if (!isControlEntityTargetKind(entityKind)) {
throw new Error(`${label}.entityKind must be a supported control entity kind.`);
throw new Error(
`${label}.entityKind must be a supported control entity kind.`
);
}
return createEntityControlTargetRef(
@@ -3169,7 +3254,9 @@ function readScenePathValue(value: unknown, label: string): ScenePath {
return createScenePath({
id: expectString(value.id, `${label}.id`),
name:
value.name === undefined ? undefined : expectString(value.name, `${label}.name`),
value.name === undefined
? undefined
: expectString(value.name, `${label}.name`),
visible: expectBoolean(value.visible, `${label}.visible`),
enabled: expectBoolean(value.enabled, `${label}.enabled`),
loop: expectBoolean(value.loop, `${label}.loop`),
@@ -3609,13 +3696,15 @@ export function migrateSceneDocument(source: unknown): SceneDocument {
source.version !== PROJECT_TIME_NIGHT_BACKGROUND_SCENE_DOCUMENT_VERSION &&
source.version !== 33 &&
source.version !== AUTHORED_OBJECT_STATE_SCENE_DOCUMENT_VERSION &&
source.version !== PLAYER_START_AIR_DIRECTION_CONTROL_SCENE_DOCUMENT_VERSION &&
source.version !==
PLAYER_START_AIR_DIRECTION_CONTROL_SCENE_DOCUMENT_VERSION &&
source.version !== PLAYER_START_AIR_CONTROL_SCENE_DOCUMENT_VERSION &&
source.version !== PLAYER_START_MOVEMENT_TEMPLATE_SCENE_DOCUMENT_VERSION &&
source.version !== PROJECT_NAME_SCENE_DOCUMENT_VERSION &&
source.version !== STATIC_SIMPLE_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION &&
source.version !== SCENE_EDITOR_PREFERENCES_SCENE_DOCUMENT_VERSION &&
source.version !== PLAYER_START_GAMEPAD_CAMERA_LOOK_SCENE_DOCUMENT_VERSION &&
source.version !==
PLAYER_START_GAMEPAD_CAMERA_LOOK_SCENE_DOCUMENT_VERSION &&
source.version !== PLAYER_START_INPUT_BINDINGS_SCENE_DOCUMENT_VERSION &&
source.version !== PLAYER_START_NAVIGATION_MODE_SCENE_DOCUMENT_VERSION &&
source.version !== SCENE_TRANSITION_ENTITIES_SCENE_DOCUMENT_VERSION &&

View File

@@ -55,10 +55,7 @@ import {
HOURS_PER_DAY,
type ProjectTimeSettings
} from "./project-time-settings";
import {
MIN_SCENE_PATH_POINT_COUNT,
type ScenePath
} from "./paths";
import { MIN_SCENE_PATH_POINT_COUNT, type ScenePath } from "./paths";
import {
isAdvancedRenderingWaterReflectionMode,
isAdvancedRenderingShadowMapSize,
@@ -368,7 +365,9 @@ function validateWorldSettings(
);
}
if (!isNonNegativeFiniteNumber(world.timeOfDay.night.ambientIntensityFactor)) {
if (
!isNonNegativeFiniteNumber(world.timeOfDay.night.ambientIntensityFactor)
) {
diagnostics.push(
createDiagnostic(
"error",
@@ -1524,7 +1523,11 @@ function validateEntityName(
}
}
function validateScenePath(pathValue: ScenePath, path: string, diagnostics: SceneDiagnostic[]) {
function validateScenePath(
pathValue: ScenePath,
path: string,
diagnostics: SceneDiagnostic[]
) {
if (!isBoolean(pathValue.visible)) {
diagnostics.push(
createDiagnostic(
@@ -1793,9 +1796,7 @@ function validatePlayerStartEntity(
);
}
if (
!isNonNegativeFiniteNumber(entity.movementTemplate?.jump?.coyoteTimeMs)
) {
if (!isNonNegativeFiniteNumber(entity.movementTemplate?.jump?.coyoteTimeMs)) {
diagnostics.push(
createDiagnostic(
"error",
@@ -1916,7 +1917,11 @@ function validatePlayerStartEntity(
);
}
if (!isPlayerStartKeyboardBindingCode(entity.inputBindings?.keyboard.moveForward)) {
if (
!isPlayerStartKeyboardBindingCode(
entity.inputBindings?.keyboard.moveForward
)
) {
diagnostics.push(
createDiagnostic(
"error",
@@ -1927,7 +1932,11 @@ function validatePlayerStartEntity(
);
}
if (!isPlayerStartKeyboardBindingCode(entity.inputBindings?.keyboard.moveBackward)) {
if (
!isPlayerStartKeyboardBindingCode(
entity.inputBindings?.keyboard.moveBackward
)
) {
diagnostics.push(
createDiagnostic(
"error",
@@ -1938,7 +1947,9 @@ function validatePlayerStartEntity(
);
}
if (!isPlayerStartKeyboardBindingCode(entity.inputBindings?.keyboard.moveLeft)) {
if (
!isPlayerStartKeyboardBindingCode(entity.inputBindings?.keyboard.moveLeft)
) {
diagnostics.push(
createDiagnostic(
"error",
@@ -1949,7 +1960,9 @@ function validatePlayerStartEntity(
);
}
if (!isPlayerStartKeyboardBindingCode(entity.inputBindings?.keyboard.moveRight)) {
if (
!isPlayerStartKeyboardBindingCode(entity.inputBindings?.keyboard.moveRight)
) {
diagnostics.push(
createDiagnostic(
"error",
@@ -1971,7 +1984,9 @@ function validatePlayerStartEntity(
);
}
if (!isPlayerStartKeyboardBindingCode(entity.inputBindings?.keyboard.sprint)) {
if (
!isPlayerStartKeyboardBindingCode(entity.inputBindings?.keyboard.sprint)
) {
diagnostics.push(
createDiagnostic(
"error",
@@ -1982,7 +1997,9 @@ function validatePlayerStartEntity(
);
}
if (!isPlayerStartKeyboardBindingCode(entity.inputBindings?.keyboard.crouch)) {
if (
!isPlayerStartKeyboardBindingCode(entity.inputBindings?.keyboard.crouch)
) {
diagnostics.push(
createDiagnostic(
"error",
@@ -2004,7 +2021,9 @@ function validatePlayerStartEntity(
);
}
if (!isPlayerStartGamepadBinding(entity.inputBindings?.gamepad.moveBackward)) {
if (
!isPlayerStartGamepadBinding(entity.inputBindings?.gamepad.moveBackward)
) {
diagnostics.push(
createDiagnostic(
"error",
@@ -2048,7 +2067,9 @@ function validatePlayerStartEntity(
);
}
if (!isPlayerStartGamepadActionBinding(entity.inputBindings?.gamepad.sprint)) {
if (
!isPlayerStartGamepadActionBinding(entity.inputBindings?.gamepad.sprint)
) {
diagnostics.push(
createDiagnostic(
"error",
@@ -2059,7 +2080,9 @@ function validatePlayerStartEntity(
);
}
if (!isPlayerStartGamepadActionBinding(entity.inputBindings?.gamepad.crouch)) {
if (
!isPlayerStartGamepadActionBinding(entity.inputBindings?.gamepad.crouch)
) {
diagnostics.push(
createDiagnostic(
"error",
@@ -2761,7 +2784,10 @@ function validateNpcEntity(
);
}
if (typeof entity.actorId !== "string" || entity.actorId.trim().length === 0) {
if (
typeof entity.actorId !== "string" ||
entity.actorId.trim().length === 0
) {
diagnostics.push(
createDiagnostic(
"error",
@@ -2985,7 +3011,9 @@ function validateControlEffect(
return;
}
case "stopModelAnimation":
if (document.modelInstances[effect.target.modelInstanceId] === undefined) {
if (
document.modelInstances[effect.target.modelInstanceId] === undefined
) {
diagnostics.push(
createDiagnostic(
"error",
@@ -3012,7 +3040,10 @@ function validateControlEffect(
return;
}
if (targetEntity.kind !== effect.target.entityKind || targetEntity.kind !== "soundEmitter") {
if (
targetEntity.kind !== effect.target.entityKind ||
targetEntity.kind !== "soundEmitter"
) {
diagnostics.push(
createDiagnostic(
"error",
@@ -3886,9 +3917,7 @@ export function validateSceneDocument(
}
}
for (const [pathKey, pathValue] of Object.entries(
document.paths
)) {
for (const [pathKey, pathValue] of Object.entries(document.paths)) {
const path = `paths.${pathKey}`;
if (pathValue.id !== pathKey) {
@@ -4133,8 +4162,7 @@ export function validateProjectDocument(
}
const targetScenePath = `${scenePath}.entities.${entityId}.targetSceneId`;
const targetEntryPath =
`${scenePath}.entities.${entityId}.targetEntryEntityId`;
const targetEntryPath = `${scenePath}.entities.${entityId}.targetEntryEntityId`;
const targetScene = document.scenes[entity.targetSceneId];
if (targetScene === undefined) {

View File

@@ -28,7 +28,8 @@ export const NPC_COLLIDER_SCENE_DOCUMENT_VERSION = 42 as const;
export const NPC_ENTITY_FOUNDATION_SCENE_DOCUMENT_VERSION = 41 as const;
export const WORLD_TIME_ENVIRONMENT_SCENE_DOCUMENT_VERSION = 40 as const;
export const PROJECT_TIME_NIGHT_BACKGROUND_SCENE_DOCUMENT_VERSION = 39 as const;
export const PROJECT_TIME_DAY_NIGHT_PROFILE_SCENE_DOCUMENT_VERSION = 38 as const;
export const PROJECT_TIME_DAY_NIGHT_PROFILE_SCENE_DOCUMENT_VERSION =
38 as const;
export const PROJECT_TIME_SYSTEM_SCENE_DOCUMENT_VERSION = 37 as const;
export const AUTHORED_OBJECT_STATE_SCENE_DOCUMENT_VERSION = 36 as const;
export const PLAYER_START_AIR_DIRECTION_CONTROL_SCENE_DOCUMENT_VERSION =
@@ -41,7 +42,8 @@ export const PLAYER_START_MOVEMENT_TEMPLATE_SCENE_DOCUMENT_VERSION =
export const STATIC_SIMPLE_MODEL_COLLIDERS_SCENE_DOCUMENT_VERSION = 30 as const;
export const SCENE_EDITOR_PREFERENCES_SCENE_DOCUMENT_VERSION = 29 as const;
export const PROJECT_NAME_SCENE_DOCUMENT_VERSION = 28 as const;
export const PLAYER_START_GAMEPAD_CAMERA_LOOK_SCENE_DOCUMENT_VERSION = 27 as const;
export const PLAYER_START_GAMEPAD_CAMERA_LOOK_SCENE_DOCUMENT_VERSION =
27 as const;
export const PLAYER_START_INPUT_BINDINGS_SCENE_DOCUMENT_VERSION = 26 as const;
export const PLAYER_START_NAVIGATION_MODE_SCENE_DOCUMENT_VERSION = 25 as const;
export const SCENE_TRANSITION_ENTITIES_SCENE_DOCUMENT_VERSION = 24 as const;
@@ -105,7 +107,10 @@ export interface SceneEditorPreferences {
x: number;
y: number;
};
viewportPanels: Record<SceneEditorViewportPanelId, SceneEditorPanelPreferences>;
viewportPanels: Record<
SceneEditorViewportPanelId,
SceneEditorPanelPreferences
>;
}
export interface SceneLoadingScreenSettings {
@@ -206,12 +211,7 @@ export function createEmptyProjectDocument(
overrides: Partial<
Pick<
ProjectDocument,
| "name"
| "time"
| "activeSceneId"
| "materials"
| "textures"
| "assets"
"name" | "time" | "activeSceneId" | "materials" | "textures" | "assets"
>
> & {
sceneId?: string;

View File

@@ -206,7 +206,9 @@ export function createToggleVisibilityInteractionLink(
assertNonEmptyString(options.targetBrushId, "Visibility target brush id");
if (options.visible !== undefined && typeof options.visible !== "boolean") {
throw new Error("Visibility action visible must be a boolean when authored.");
throw new Error(
"Visibility action visible must be a boolean when authored."
);
}
return {
@@ -326,13 +328,17 @@ export function getInteractionActionControlEffect(
switch (action.type) {
case "playAnimation":
return createPlayModelAnimationControlEffect({
target: createModelInstanceControlTargetRef(action.targetModelInstanceId),
target: createModelInstanceControlTargetRef(
action.targetModelInstanceId
),
clipName: action.clipName,
loop: action.loop
});
case "stopAnimation":
return createStopModelAnimationControlEffect({
target: createModelInstanceControlTargetRef(action.targetModelInstanceId)
target: createModelInstanceControlTargetRef(
action.targetModelInstanceId
)
});
case "playSound":
return createPlaySoundControlEffect({
@@ -410,7 +416,8 @@ export function areInteractionLinksEqual(
return (
left.action.targetModelInstanceId ===
(right.action as PlayAnimationAction).targetModelInstanceId &&
left.action.clipName === (right.action as PlayAnimationAction).clipName &&
left.action.clipName ===
(right.action as PlayAnimationAction).clipName &&
left.action.loop === (right.action as PlayAnimationAction).loop
);
case "stopAnimation":

View File

@@ -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

View File

@@ -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

View File

@@ -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);

View File

@@ -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);