diff --git a/src/commands/set-player-start-command.ts b/src/commands/set-player-start-command.ts new file mode 100644 index 00000000..582f26fc --- /dev/null +++ b/src/commands/set-player-start-command.ts @@ -0,0 +1,82 @@ +import type { EditorSelection } from "../core/selection"; +import type { Vec3 } from "../core/vector"; +import { createOpaqueId } from "../core/ids"; +import { createPlayerStartEntity } from "../entities/entity-instances"; + +import type { EditorCommand } from "./command"; + +interface SetPlayerStartCommandOptions { + entityId?: string; + position: Vec3; + yawDegrees: number; +} + +function setSinglePlayerStartSelection(entityId: string): EditorSelection { + return { + kind: "entities", + ids: [entityId] + }; +} + +export function createSetPlayerStartCommand(options: SetPlayerStartCommandOptions): EditorCommand { + const nextEntity = createPlayerStartEntity({ + id: options.entityId, + position: options.position, + yawDegrees: options.yawDegrees + }); + + let previousEntity = null as typeof nextEntity | null; + let previousSelection: EditorSelection | null = null; + + return { + id: createOpaqueId("command"), + label: options.entityId === undefined ? "Place player start" : "Move player start", + execute(context) { + const currentDocument = context.getDocument(); + const currentEntity = currentDocument.entities[nextEntity.id]; + + if (currentEntity !== undefined && currentEntity.kind !== "playerStart") { + throw new Error(`Entity ${nextEntity.id} is not a player start.`); + } + + if (previousSelection === null) { + previousSelection = context.getSelection().kind === "none" ? { kind: "none" } : structuredClone(context.getSelection()); + } + + if (previousEntity === null && currentEntity !== undefined) { + previousEntity = currentEntity; + } + + context.setDocument({ + ...currentDocument, + entities: { + ...currentDocument.entities, + [nextEntity.id]: nextEntity + } + }); + context.setSelection(setSinglePlayerStartSelection(nextEntity.id)); + context.setToolMode("select"); + }, + undo(context) { + const currentDocument = context.getDocument(); + const nextEntities = { + ...currentDocument.entities + }; + + if (previousEntity === null) { + delete nextEntities[nextEntity.id]; + } else { + nextEntities[nextEntity.id] = previousEntity; + } + + context.setDocument({ + ...currentDocument, + entities: nextEntities + }); + + if (previousSelection !== null) { + context.setSelection(previousSelection); + } + } + }; +} diff --git a/src/core/selection.ts b/src/core/selection.ts index 275278e1..d07b17b0 100644 --- a/src/core/selection.ts +++ b/src/core/selection.ts @@ -48,6 +48,14 @@ export function getSelectedBrushFaceId(selection: EditorSelection): BoxFaceId | return selection.faceId; } +export function getSingleSelectedEntityId(selection: EditorSelection): string | null { + if (selection.kind !== "entities" || selection.ids.length !== 1) { + return null; + } + + return selection.ids[0]; +} + export function isBrushSelected(selection: EditorSelection, brushId: string): boolean { return ( (selection.kind === "brushes" && selection.ids.includes(brushId)) || diff --git a/src/document/migrate-scene-document.ts b/src/document/migrate-scene-document.ts index 3b88270d..6821299c 100644 --- a/src/document/migrate-scene-document.ts +++ b/src/document/migrate-scene-document.ts @@ -1,4 +1,5 @@ import { createStarterMaterialRegistry, type MaterialDef, type MaterialPattern } from "../materials/starter-material-library"; +import { createPlayerStartEntity, type EntityInstance } from "../entities/entity-instances"; import { createBoxBrush, createDefaultFaceUvState, @@ -10,6 +11,7 @@ import { } from "./brushes"; import { BOX_BRUSH_SCENE_DOCUMENT_VERSION, + FACE_MATERIALS_SCENE_DOCUMENT_VERSION, FOUNDATION_SCENE_DOCUMENT_VERSION, SCENE_DOCUMENT_VERSION, type SceneDocument, @@ -321,6 +323,53 @@ function readWorldSettings(value: unknown): WorldSettings { }; } +function readPlayerStartEntity(value: unknown, label: string): EntityInstance { + if (!isRecord(value)) { + throw new Error(`${label} must be an object.`); + } + + const kind = expectLiteralString(value.kind, "playerStart", `${label}.kind`); + const entity = createPlayerStartEntity({ + id: expectString(value.id, `${label}.id`), + position: readVec3(value.position, `${label}.position`), + yawDegrees: expectFiniteNumber(value.yawDegrees, `${label}.yawDegrees`) + }); + + if (entity.kind !== kind) { + throw new Error(`${label}.kind must be playerStart.`); + } + + return entity; +} + +function readEntities(value: unknown): SceneDocument["entities"] { + if (!isRecord(value)) { + throw new Error("entities must be a record."); + } + + const entities: SceneDocument["entities"] = {}; + + for (const [entityId, entityValue] of Object.entries(value)) { + if (!isRecord(entityValue)) { + throw new Error(`entities.${entityId} must be an object.`); + } + + if (entityValue.kind !== "playerStart") { + throw new Error(`entities.${entityId}.kind must be a supported entity type.`); + } + + const entity = readPlayerStartEntity(entityValue, `entities.${entityId}`); + + if (entity.id !== entityId) { + throw new Error(`entities.${entityId}.id must match the registry key.`); + } + + entities[entityId] = entity; + } + + return entities; +} + export function migrateSceneDocument(source: unknown): SceneDocument { if (!isRecord(source)) { throw new Error("Scene document must be a JSON object."); @@ -362,6 +411,23 @@ export function migrateSceneDocument(source: unknown): SceneDocument { }; } + if (source.version === FACE_MATERIALS_SCENE_DOCUMENT_VERSION) { + const materials = readMaterialRegistry(source.materials, "materials"); + + return { + version: SCENE_DOCUMENT_VERSION, + name: expectString(source.name, "name"), + world: readWorldSettings(source.world), + materials, + textures: expectEmptyCollection(source.textures, "textures"), + assets: expectEmptyCollection(source.assets, "assets"), + brushes: readBrushes(source.brushes, materials, false), + modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"), + entities: expectEmptyCollection(source.entities, "entities"), + interactionLinks: expectEmptyCollection(source.interactionLinks, "interactionLinks") + }; + } + if (source.version !== SCENE_DOCUMENT_VERSION) { throw new Error(`Unsupported scene document version: ${String(source.version)}.`); } @@ -377,7 +443,7 @@ export function migrateSceneDocument(source: unknown): SceneDocument { assets: expectEmptyCollection(source.assets, "assets"), brushes: readBrushes(source.brushes, materials, false), modelInstances: expectEmptyCollection(source.modelInstances, "modelInstances"), - entities: expectEmptyCollection(source.entities, "entities"), + entities: readEntities(source.entities), interactionLinks: expectEmptyCollection(source.interactionLinks, "interactionLinks") }; } diff --git a/src/document/scene-document.ts b/src/document/scene-document.ts index 2331e4d6..4d0d90b8 100644 --- a/src/document/scene-document.ts +++ b/src/document/scene-document.ts @@ -1,10 +1,12 @@ import { DEFAULT_SUN_DIRECTION, type Vec3 } from "../core/vector"; import type { Brush } from "./brushes"; +import type { EntityInstance } from "../entities/entity-instances"; import { cloneMaterialRegistry, createStarterMaterialRegistry, type MaterialDef } from "../materials/starter-material-library"; -export const SCENE_DOCUMENT_VERSION = 3 as const; +export const SCENE_DOCUMENT_VERSION = 4 as const; export const FOUNDATION_SCENE_DOCUMENT_VERSION = 1 as const; export const BOX_BRUSH_SCENE_DOCUMENT_VERSION = 2 as const; +export const FACE_MATERIALS_SCENE_DOCUMENT_VERSION = 3 as const; export interface WorldBackgroundSettings { mode: "solid"; @@ -37,7 +39,7 @@ export interface SceneDocument { assets: Record; brushes: Record; modelInstances: Record; - entities: Record; + entities: Record; interactionLinks: Record; } diff --git a/src/entities/entity-instances.ts b/src/entities/entity-instances.ts new file mode 100644 index 00000000..78063372 --- /dev/null +++ b/src/entities/entity-instances.ts @@ -0,0 +1,70 @@ +import { createOpaqueId } from "../core/ids"; +import type { Vec3 } from "../core/vector"; + +export interface PlayerStartEntity { + id: string; + kind: "playerStart"; + position: Vec3; + yawDegrees: number; +} + +export type EntityInstance = PlayerStartEntity; + +export const DEFAULT_PLAYER_START_POSITION: Vec3 = { + x: 0, + y: 0, + z: 0 +}; + +export const DEFAULT_PLAYER_START_YAW_DEGREES = 0; + +function cloneVec3(vector: Vec3): Vec3 { + return { + x: vector.x, + y: vector.y, + z: vector.z + }; +} + +export function normalizeYawDegrees(yawDegrees: number): number { + const normalizedYaw = yawDegrees % 360; + return normalizedYaw < 0 ? normalizedYaw + 360 : normalizedYaw; +} + +export function createPlayerStartEntity( + overrides: Partial> = {} +): PlayerStartEntity { + const yawDegrees = overrides.yawDegrees ?? DEFAULT_PLAYER_START_YAW_DEGREES; + + if (!Number.isFinite(yawDegrees)) { + throw new Error("Player start yaw must be a finite number."); + } + + return { + id: overrides.id ?? createOpaqueId("entity-player-start"), + kind: "playerStart", + position: cloneVec3(overrides.position ?? DEFAULT_PLAYER_START_POSITION), + yawDegrees: normalizeYawDegrees(yawDegrees) + }; +} + +export function cloneEntityInstance(entity: EntityInstance): EntityInstance { + switch (entity.kind) { + case "playerStart": + return createPlayerStartEntity(entity); + } +} + +export function cloneEntityRegistry(entities: Record): Record { + return Object.fromEntries(Object.entries(entities).map(([entityId, entity]) => [entityId, cloneEntityInstance(entity)])); +} + +export function getPlayerStartEntities(entities: Record): PlayerStartEntity[] { + return Object.values(entities) + .filter((entity): entity is PlayerStartEntity => entity.kind === "playerStart") + .sort((left, right) => left.id.localeCompare(right.id)); +} + +export function getPrimaryPlayerStartEntity(entities: Record): PlayerStartEntity | null { + return getPlayerStartEntities(entities)[0] ?? null; +}