diff --git a/src/runtime-three/runtime-scene-build.ts b/src/runtime-three/runtime-scene-build.ts index f1fcd8cb..90d921c4 100644 --- a/src/runtime-three/runtime-scene-build.ts +++ b/src/runtime-three/runtime-scene-build.ts @@ -2,7 +2,7 @@ import type { Vec3 } from "../core/vector"; import type { BoxBrush, BoxFaceId, FaceUvState } from "../document/brushes"; import type { SceneDocument, WorldSettings } from "../document/scene-document"; import { cloneWorldSettings } from "../document/world-settings"; -import { getPrimaryPlayerStartEntity } from "../entities/entity-instances"; +import { getEntityInstances, getPrimaryPlayerStartEntity, type EntityInstance } from "../entities/entity-instances"; import { getBoxBrushBounds } from "../geometry/box-brush"; import { cloneMaterialDef, type MaterialDef } from "../materials/starter-material-library"; import { cloneFaceUvState } from "../document/brushes"; @@ -44,6 +44,45 @@ export interface RuntimePlayerStart { yawDegrees: number; } +export interface RuntimeSoundEmitter { + entityId: string; + position: Vec3; + radius: number; + gain: number; + autoplay: boolean; + loop: boolean; +} + +export interface RuntimeTriggerVolume { + entityId: string; + position: Vec3; + size: Vec3; + triggerOnEnter: boolean; + triggerOnExit: boolean; +} + +export interface RuntimeTeleportTarget { + entityId: string; + position: Vec3; + yawDegrees: number; +} + +export interface RuntimeInteractable { + entityId: string; + position: Vec3; + radius: number; + prompt: string; + enabled: boolean; +} + +export interface RuntimeEntityCollection { + playerStarts: RuntimePlayerStart[]; + soundEmitters: RuntimeSoundEmitter[]; + triggerVolumes: RuntimeTriggerVolume[]; + teleportTargets: RuntimeTeleportTarget[]; + interactables: RuntimeInteractable[]; +} + export interface RuntimeSpawnPoint { source: "playerStart" | "fallback"; entityId: string | null; @@ -56,6 +95,7 @@ export interface RuntimeSceneDefinition { brushes: RuntimeBoxBrushInstance[]; colliders: RuntimeBoxCollider[]; sceneBounds: RuntimeSceneBounds | null; + entities: RuntimeEntityCollection; playerStart: RuntimePlayerStart | null; spawn: RuntimeSpawnPoint; } @@ -197,12 +237,78 @@ function buildFallbackSpawn(sceneBounds: RuntimeSceneBounds | null): RuntimeSpaw }; } +function buildRuntimeEntityCollection(document: SceneDocument): RuntimeEntityCollection { + const runtimeEntities: RuntimeEntityCollection = { + playerStarts: [], + soundEmitters: [], + triggerVolumes: [], + teleportTargets: [], + interactables: [] + }; + + for (const entity of getEntityInstances(document.entities)) { + switch (entity.kind) { + case "playerStart": + runtimeEntities.playerStarts.push({ + entityId: entity.id, + position: cloneVec3(entity.position), + yawDegrees: entity.yawDegrees + }); + break; + case "soundEmitter": + runtimeEntities.soundEmitters.push({ + entityId: entity.id, + position: cloneVec3(entity.position), + radius: entity.radius, + gain: entity.gain, + autoplay: entity.autoplay, + loop: entity.loop + }); + break; + case "triggerVolume": + runtimeEntities.triggerVolumes.push({ + entityId: entity.id, + position: cloneVec3(entity.position), + size: cloneVec3(entity.size), + triggerOnEnter: entity.triggerOnEnter, + triggerOnExit: entity.triggerOnExit + }); + break; + case "teleportTarget": + runtimeEntities.teleportTargets.push({ + entityId: entity.id, + position: cloneVec3(entity.position), + yawDegrees: entity.yawDegrees + }); + break; + case "interactable": + runtimeEntities.interactables.push({ + entityId: entity.id, + position: cloneVec3(entity.position), + radius: entity.radius, + prompt: entity.prompt, + enabled: entity.enabled + }); + break; + default: + assertNever(entity); + } + } + + return runtimeEntities; +} + +function assertNever(value: never): never { + throw new Error(`Unsupported runtime entity: ${String((value as EntityInstance).kind)}`); +} + export function buildRuntimeSceneFromDocument(document: SceneDocument, options: BuildRuntimeSceneOptions = {}): RuntimeSceneDefinition { assertRuntimeSceneBuildable(document, options.navigationMode ?? "orbitVisitor"); const brushes = Object.values(document.brushes).map((brush) => buildRuntimeBrush(brush, document)); const colliders = Object.values(document.brushes).map((brush) => buildRuntimeCollider(brush)); const sceneBounds = combineColliderBounds(colliders); + const entities = buildRuntimeEntityCollection(document); const playerStartEntity = getPrimaryPlayerStartEntity(document.entities); const playerStart = playerStartEntity === null @@ -218,6 +324,7 @@ export function buildRuntimeSceneFromDocument(document: SceneDocument, options: brushes, colliders, sceneBounds, + entities, playerStart, spawn: playerStart === null diff --git a/src/viewport-three/viewport-focus.ts b/src/viewport-three/viewport-focus.ts index 9167883e..6ca11231 100644 --- a/src/viewport-three/viewport-focus.ts +++ b/src/viewport-three/viewport-focus.ts @@ -2,6 +2,7 @@ import { getSingleSelectedBrushId, getSingleSelectedEntityId, type EditorSelecti import type { Vec3 } from "../core/vector"; import type { BoxBrush } from "../document/brushes"; import type { SceneDocument } from "../document/scene-document"; +import type { EntityInstance } from "../entities/entity-instances"; const PLAYER_START_FOCUS_HALF_EXTENTS: Vec3 = { x: 0.35, @@ -9,6 +10,12 @@ const PLAYER_START_FOCUS_HALF_EXTENTS: Vec3 = { z: 0.55 }; +const TELEPORT_TARGET_FOCUS_HALF_EXTENTS: Vec3 = { + x: 0.42, + y: 0.28, + z: 0.42 +}; + interface FocusBoundsAccumulator { min: Vec3; max: Vec3; @@ -73,24 +80,6 @@ function createBrushFocusTarget(brush: BoxBrush): ViewportFocusTarget { }; } -function createPlayerStartFocusTarget(position: Vec3): ViewportFocusTarget { - return { - center: { - x: position.x, - y: position.y + PLAYER_START_FOCUS_HALF_EXTENTS.y, - z: position.z - }, - radius: Math.max( - 0.45, - Math.hypot( - PLAYER_START_FOCUS_HALF_EXTENTS.x, - PLAYER_START_FOCUS_HALF_EXTENTS.y, - PLAYER_START_FOCUS_HALF_EXTENTS.z - ) - ) - }; -} - function includeBrush(bounds: FocusBoundsAccumulator, brush: BoxBrush) { const halfSize = { x: brush.size.x * 0.5, @@ -129,6 +118,155 @@ function includePlayerStart(bounds: FocusBoundsAccumulator, position: Vec3) { ); } +function createBoundsFocusTarget(center: Vec3, halfExtents: Vec3, minimumRadius: number): ViewportFocusTarget { + return { + center, + radius: Math.max(minimumRadius, Math.hypot(halfExtents.x, halfExtents.y, halfExtents.z)) + }; +} + +function createPlayerStartFocusTarget(position: Vec3): ViewportFocusTarget { + return createBoundsFocusTarget( + { + x: position.x, + y: position.y + PLAYER_START_FOCUS_HALF_EXTENTS.y, + z: position.z + }, + PLAYER_START_FOCUS_HALF_EXTENTS, + 0.45 + ); +} + +function includeTeleportTarget(bounds: FocusBoundsAccumulator, position: Vec3) { + includeBounds( + bounds, + { + x: position.x - TELEPORT_TARGET_FOCUS_HALF_EXTENTS.x, + y: position.y, + z: position.z - TELEPORT_TARGET_FOCUS_HALF_EXTENTS.z + }, + { + x: position.x + TELEPORT_TARGET_FOCUS_HALF_EXTENTS.x, + y: position.y + TELEPORT_TARGET_FOCUS_HALF_EXTENTS.y * 2, + z: position.z + TELEPORT_TARGET_FOCUS_HALF_EXTENTS.z + } + ); +} + +function createTeleportTargetFocusTarget(position: Vec3): ViewportFocusTarget { + return createBoundsFocusTarget( + { + x: position.x, + y: position.y + TELEPORT_TARGET_FOCUS_HALF_EXTENTS.y, + z: position.z + }, + TELEPORT_TARGET_FOCUS_HALF_EXTENTS, + 0.45 + ); +} + +function includeSphereEntity(bounds: FocusBoundsAccumulator, position: Vec3, radius: number) { + includeBounds( + bounds, + { + x: position.x - radius, + y: position.y - radius, + z: position.z - radius + }, + { + x: position.x + radius, + y: position.y + radius, + z: position.z + radius + } + ); +} + +function createSphereEntityFocusTarget(position: Vec3, radius: number, minimumRadius: number): ViewportFocusTarget { + return { + center: { + x: position.x, + y: position.y, + z: position.z + }, + radius: Math.max(minimumRadius, radius) + }; +} + +function includeTriggerVolume(bounds: FocusBoundsAccumulator, position: Vec3, size: Vec3) { + const halfSize = { + x: size.x * 0.5, + y: size.y * 0.5, + z: size.z * 0.5 + }; + + includeBounds( + bounds, + { + x: position.x - halfSize.x, + y: position.y - halfSize.y, + z: position.z - halfSize.z + }, + { + x: position.x + halfSize.x, + y: position.y + halfSize.y, + z: position.z + halfSize.z + } + ); +} + +function createTriggerVolumeFocusTarget(position: Vec3, size: Vec3): ViewportFocusTarget { + const halfSize = { + x: size.x * 0.5, + y: size.y * 0.5, + z: size.z * 0.5 + }; + + return createBoundsFocusTarget( + { + x: position.x, + y: position.y, + z: position.z + }, + halfSize, + 0.75 + ); +} + +function includeEntity(bounds: FocusBoundsAccumulator, entity: EntityInstance) { + switch (entity.kind) { + case "playerStart": + includePlayerStart(bounds, entity.position); + break; + case "soundEmitter": + includeSphereEntity(bounds, entity.position, Math.max(0.4, entity.radius)); + break; + case "triggerVolume": + includeTriggerVolume(bounds, entity.position, entity.size); + break; + case "teleportTarget": + includeTeleportTarget(bounds, entity.position); + break; + case "interactable": + includeSphereEntity(bounds, entity.position, Math.max(0.4, entity.radius)); + break; + } +} + +function createEntityFocusTarget(entity: EntityInstance): ViewportFocusTarget { + switch (entity.kind) { + case "playerStart": + return createPlayerStartFocusTarget(entity.position); + case "soundEmitter": + return createSphereEntityFocusTarget(entity.position, entity.radius, 0.75); + case "triggerVolume": + return createTriggerVolumeFocusTarget(entity.position, entity.size); + case "teleportTarget": + return createTeleportTargetFocusTarget(entity.position); + case "interactable": + return createSphereEntityFocusTarget(entity.position, entity.radius, 0.65); + } +} + function getSceneFocusTarget(document: SceneDocument): ViewportFocusTarget | null { const bounds = createEmptyBoundsAccumulator(); @@ -137,9 +275,7 @@ function getSceneFocusTarget(document: SceneDocument): ViewportFocusTarget | nul } for (const entity of Object.values(document.entities)) { - if (entity.kind === "playerStart") { - includePlayerStart(bounds, entity.position); - } + includeEntity(bounds, entity); } return finishBounds(bounds); @@ -161,8 +297,8 @@ export function resolveViewportFocusTarget(document: SceneDocument, selection: E if (selectedEntityId !== null) { const entity = document.entities[selectedEntityId]; - if (entity?.kind === "playerStart") { - return createPlayerStartFocusTarget(entity.position); + if (entity !== undefined) { + return createEntityFocusTarget(entity); } }