Add player start entity command and related changes
This commit is contained in:
82
src/commands/set-player-start-command.ts
Normal file
82
src/commands/set-player-start-command.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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)) ||
|
||||
|
||||
@@ -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")
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
70
src/entities/entity-instances.ts
Normal file
70
src/entities/entity-instances.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user