Add player start entity command and related changes

This commit is contained in:
2026-03-31 03:00:38 +02:00
parent 1b06b754a6
commit cd5d0b5535
5 changed files with 231 additions and 3 deletions

View File

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

View File

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

View File

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

View File

@@ -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<string, never>;
brushes: Record<string, Brush>;
modelInstances: Record<string, never>;
entities: Record<string, never>;
entities: Record<string, EntityInstance>;
interactionLinks: Record<string, never>;
}

View File

@@ -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<Pick<PlayerStartEntity, "id" | "position" | "yawDegrees">> = {}
): 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<string, EntityInstance>): Record<string, EntityInstance> {
return Object.fromEntries(Object.entries(entities).map(([entityId, entity]) => [entityId, cloneEntityInstance(entity)]));
}
export function getPlayerStartEntities(entities: Record<string, EntityInstance>): 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<string, EntityInstance>): PlayerStartEntity | null {
return getPlayerStartEntities(entities)[0] ?? null;
}