Add runtime entity collection and focus target handling

This commit is contained in:
2026-03-31 05:52:01 +02:00
parent feb7639086
commit 3ea9814058
2 changed files with 267 additions and 24 deletions

View File

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

View File

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