|
@@ -37,8 +37,19 @@ export interface SceneModel {
|
|
|
mapHeight: number;
|
|
mapHeight: number;
|
|
|
/** buildingId → footprint for overlap checks; npc/animal are 1×1. */
|
|
/** buildingId → footprint for overlap checks; npc/animal are 1×1. */
|
|
|
footprints: Record<string, SceneFootprint>;
|
|
footprints: Record<string, SceneFootprint>;
|
|
|
|
|
+ /** Inverse-command stack (most recent last); non-empty ⇒ dirty. */
|
|
|
|
|
+ history: SceneHistoryEntry[];
|
|
|
|
|
+ /** Forward-command stack for redo (cleared on every new op). */
|
|
|
|
|
+ future: SceneHistoryEntry[];
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/** An inverse-recordable entity edit. Entities are snapshotted (deep-copied)
|
|
|
|
|
+ * into the entries so later mutations never corrupt the undo state. */
|
|
|
|
|
+export type SceneHistoryEntry =
|
|
|
|
|
+ | { kind: "add"; index: number; entity: SceneEntity }
|
|
|
|
|
+ | { kind: "remove"; index: number; entity: SceneEntity }
|
|
|
|
|
+ | { kind: "move"; index: number; before: { x: number; y: number }; after: { x: number; y: number } };
|
|
|
|
|
+
|
|
|
/** Result of a placement/edit operation; rejections carry a status-bar reason. */
|
|
/** Result of a placement/edit operation; rejections carry a status-bar reason. */
|
|
|
export type SceneOpResult = { ok: true } | { ok: false; reason: string };
|
|
export type SceneOpResult = { ok: true } | { ok: false; reason: string };
|
|
|
|
|
|
|
@@ -54,11 +65,11 @@ export const SELECT_COLOR = "#ffd54f";
|
|
|
export function createSceneModel(input: unknown, options: SceneModelOptions): SceneModel | null {
|
|
export function createSceneModel(input: unknown, options: SceneModelOptions): SceneModel | null {
|
|
|
const base = { mapWidth: options.mapWidth, mapHeight: options.mapHeight };
|
|
const base = { mapWidth: options.mapWidth, mapHeight: options.mapHeight };
|
|
|
if (input === undefined) {
|
|
if (input === undefined) {
|
|
|
- return { def: { id: options.defaultId ?? DEFAULT_SCENE_ID, entities: [] }, dirty: false, ...base, footprints: cloneFootprints(options.footprints) };
|
|
|
|
|
|
|
+ return { def: { id: options.defaultId ?? DEFAULT_SCENE_ID, entities: [] }, dirty: false, ...base, footprints: cloneFootprints(options.footprints), history: [], future: [] };
|
|
|
}
|
|
}
|
|
|
const result = sceneSchema.safeParse(input);
|
|
const result = sceneSchema.safeParse(input);
|
|
|
if (!result.success) return null;
|
|
if (!result.success) return null;
|
|
|
- return { def: cloneDef(result.data), dirty: false, ...base, footprints: cloneFootprints(options.footprints) };
|
|
|
|
|
|
|
+ return { def: cloneDef(result.data), dirty: false, ...base, footprints: cloneFootprints(options.footprints), history: [], future: [] };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** Place a building. Coords are clamped into `[0, map - footprint]`; a
|
|
/** Place a building. Coords are clamped into `[0, map - footprint]`; a
|
|
@@ -80,8 +91,10 @@ export function addBuilding(model: SceneModel, buildingId: string, x: number, y:
|
|
|
if (duplicate) return fail(`duplicate building "${buildingId}" at (${at.x},${at.y})`);
|
|
if (duplicate) return fail(`duplicate building "${buildingId}" at (${at.x},${at.y})`);
|
|
|
const hit = overlappingEntity(model, at.x, at.y, footprint.w, footprint.h);
|
|
const hit = overlappingEntity(model, at.x, at.y, footprint.w, footprint.h);
|
|
|
if (hit !== undefined) return fail(`placement overlaps ${describeEntity(hit)}`);
|
|
if (hit !== undefined) return fail(`placement overlaps ${describeEntity(hit)}`);
|
|
|
- model.def.entities.push({ type: "building", buildingId, x: at.x, y: at.y });
|
|
|
|
|
- model.dirty = true;
|
|
|
|
|
|
|
+ const entity: SceneEntity = { type: "building", buildingId, x: at.x, y: at.y };
|
|
|
|
|
+ const index = model.def.entities.length;
|
|
|
|
|
+ model.def.entities.push(entity);
|
|
|
|
|
+ recordHistory(model, { kind: "add", index, entity: cloneEntity(entity) });
|
|
|
return { ok: true };
|
|
return { ok: true };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -111,8 +124,9 @@ export function addNpc(
|
|
|
...(opts?.dialogue !== undefined && opts.dialogue.length > 0 ? { dialogue: opts.dialogue } : {}),
|
|
...(opts?.dialogue !== undefined && opts.dialogue.length > 0 ? { dialogue: opts.dialogue } : {}),
|
|
|
...(opts?.label !== undefined && opts.label.length > 0 ? { label: opts.label } : {}),
|
|
...(opts?.label !== undefined && opts.label.length > 0 ? { label: opts.label } : {}),
|
|
|
};
|
|
};
|
|
|
|
|
+ const index = model.def.entities.length;
|
|
|
model.def.entities.push(npc);
|
|
model.def.entities.push(npc);
|
|
|
- model.dirty = true;
|
|
|
|
|
|
|
+ recordHistory(model, { kind: "add", index, entity: cloneEntity(npc) });
|
|
|
return { ok: true };
|
|
return { ok: true };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -125,16 +139,19 @@ export function addAnimal(model: SceneModel, animalId: string, x: number, y: num
|
|
|
if (at === null) return fail(`placement does not fit within ${model.mapWidth}×${model.mapHeight}`);
|
|
if (at === null) return fail(`placement does not fit within ${model.mapWidth}×${model.mapHeight}`);
|
|
|
const hit = overlappingEntity(model, at.x, at.y, 1, 1);
|
|
const hit = overlappingEntity(model, at.x, at.y, 1, 1);
|
|
|
if (hit !== undefined) return fail(`placement overlaps ${describeEntity(hit)}`);
|
|
if (hit !== undefined) return fail(`placement overlaps ${describeEntity(hit)}`);
|
|
|
- model.def.entities.push({ type: "animal", animalId, x: at.x, y: at.y });
|
|
|
|
|
- model.dirty = true;
|
|
|
|
|
|
|
+ const animal: SceneEntity = { type: "animal", animalId, x: at.x, y: at.y };
|
|
|
|
|
+ const index = model.def.entities.length;
|
|
|
|
|
+ model.def.entities.push(animal);
|
|
|
|
|
+ recordHistory(model, { kind: "add", index, entity: cloneEntity(animal) });
|
|
|
return { ok: true };
|
|
return { ok: true };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** Remove the entity at `index`. Out-of-range indices are rejected. */
|
|
/** Remove the entity at `index`. Out-of-range indices are rejected. */
|
|
|
export function removeEntity(model: SceneModel, index: number): SceneOpResult {
|
|
export function removeEntity(model: SceneModel, index: number): SceneOpResult {
|
|
|
- if (model.def.entities[index] === undefined) return fail(`no entity at index ${index}`);
|
|
|
|
|
|
|
+ const entity = model.def.entities[index];
|
|
|
|
|
+ if (entity === undefined) return fail(`no entity at index ${index}`);
|
|
|
model.def.entities.splice(index, 1);
|
|
model.def.entities.splice(index, 1);
|
|
|
- model.dirty = true;
|
|
|
|
|
|
|
+ recordHistory(model, { kind: "remove", index, entity: cloneEntity(entity) });
|
|
|
return { ok: true };
|
|
return { ok: true };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -150,9 +167,10 @@ export function moveEntity(model: SceneModel, index: number, x: number, y: numbe
|
|
|
if (at === null) return fail(`placement does not fit within ${model.mapWidth}×${model.mapHeight}`);
|
|
if (at === null) return fail(`placement does not fit within ${model.mapWidth}×${model.mapHeight}`);
|
|
|
const hit = overlappingEntity(model, at.x, at.y, footprint.w, footprint.h, index);
|
|
const hit = overlappingEntity(model, at.x, at.y, footprint.w, footprint.h, index);
|
|
|
if (hit !== undefined) return fail(`placement overlaps ${describeEntity(hit)}`);
|
|
if (hit !== undefined) return fail(`placement overlaps ${describeEntity(hit)}`);
|
|
|
|
|
+ const before = { x: entity.x, y: entity.y };
|
|
|
entity.x = at.x;
|
|
entity.x = at.x;
|
|
|
entity.y = at.y;
|
|
entity.y = at.y;
|
|
|
- model.dirty = true;
|
|
|
|
|
|
|
+ recordHistory(model, { kind: "move", index, before, after: { x: at.x, y: at.y } });
|
|
|
return { ok: true };
|
|
return { ok: true };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -165,11 +183,108 @@ export function serialize(model: SceneModel): SceneDef | null {
|
|
|
return cloneDef(result.data);
|
|
return cloneDef(result.data);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/** Clear the unsaved-changes flag (call after a successful save). */
|
|
|
|
|
|
|
+/** Clear the unsaved-changes flag (call after a successful save). Saving is a
|
|
|
|
|
+ * checkpoint: both history stacks are cleared (E9, documented). */
|
|
|
export function markSaved(model: SceneModel): void {
|
|
export function markSaved(model: SceneModel): void {
|
|
|
|
|
+ model.history = [];
|
|
|
|
|
+ model.future = [];
|
|
|
model.dirty = false;
|
|
model.dirty = false;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/** Undo the most recent entity edit. Returns false when the stack is empty. */
|
|
|
|
|
+export function undo(model: SceneModel): boolean {
|
|
|
|
|
+ const entry = model.history.pop();
|
|
|
|
|
+ if (entry === undefined) return false;
|
|
|
|
|
+ applyInverse(model, entry);
|
|
|
|
|
+ model.future.push(entry);
|
|
|
|
|
+ model.dirty = model.history.length > 0;
|
|
|
|
|
+ return true;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** Redo the most recently undone edit. Returns false when there is nothing to
|
|
|
|
|
+ * redo (or a new op cleared the redo stack). */
|
|
|
|
|
+export function redo(model: SceneModel): boolean {
|
|
|
|
|
+ const entry = model.future.pop();
|
|
|
|
|
+ if (entry === undefined) return false;
|
|
|
|
|
+ applyForward(model, entry);
|
|
|
|
|
+ model.history.push(entry);
|
|
|
|
|
+ model.dirty = model.history.length > 0;
|
|
|
|
|
+ return true;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function canUndo(model: SceneModel): boolean {
|
|
|
|
|
+ return model.history.length > 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function canRedo(model: SceneModel): boolean {
|
|
|
|
|
+ return model.future.length > 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** Drop both stacks without touching the def or the dirty flag (the scene
|
|
|
|
|
+ * editor's resize path uses this — a coarse cross-model op is not undoable,
|
|
|
|
|
+ * but the resize itself IS an unsaved edit and must stay dirty). */
|
|
|
|
|
+export function clearHistory(model: SceneModel): void {
|
|
|
|
|
+ model.history = [];
|
|
|
|
|
+ model.future = [];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** Sync the scene's map bounds after a map resize (E9): updates
|
|
|
|
|
+ * `mapWidth`/`mapHeight` and clamps every entity into the new bounds. A
|
|
|
|
|
+ * building whose footprint does not fit is left in place (defensive — the UI
|
|
|
|
|
+ * must call `sceneFitsAfterResize` before resizing, which also rejects
|
|
|
|
|
+ * clamp-induced overlaps; documented). */
|
|
|
|
|
+export function syncMapSize(model: SceneModel, width: number, height: number): void {
|
|
|
|
|
+ model.mapWidth = width;
|
|
|
|
|
+ model.mapHeight = height;
|
|
|
|
|
+ for (const entity of model.def.entities) {
|
|
|
|
|
+ const footprint = entityFootprint(model, entity);
|
|
|
|
|
+ const maxX = width - footprint.w;
|
|
|
|
|
+ const maxY = height - footprint.h;
|
|
|
|
|
+ if (maxX < 0 || maxY < 0) continue;
|
|
|
|
|
+ entity.x = clampInt(entity.x, 0, maxX);
|
|
|
|
|
+ entity.y = clampInt(entity.y, 0, maxY);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** Whether every entity still fits in a map of the given size (E9): a building
|
|
|
|
|
+ * whose footprint exceeds the bounds cannot survive a shrink — the resize must
|
|
|
|
|
+ * be rejected, not the entity lost. npc/animal are 1×1 and always fit. */
|
|
|
|
|
+export function sceneFitsIn(model: SceneModel, width: number, height: number): SceneOpResult {
|
|
|
|
|
+ for (const entity of model.def.entities) {
|
|
|
|
|
+ if (entity.type !== "building") continue;
|
|
|
|
|
+ const footprint = entityFootprint(model, entity);
|
|
|
|
|
+ if (footprint.w > width || footprint.h > height) {
|
|
|
|
|
+ return fail(`building "${entity.buildingId}" (${footprint.w}×${footprint.h}) does not fit within ${width}×${height}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return { ok: true };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** The full resize guard (bug-fix round): `sceneFitsIn` PLUS a simulation of
|
|
|
|
|
+ * `syncMapSize`'s clamping — two entities that would be clamped into overlap
|
|
|
|
|
+ * would fail the engine's seed-time placement check, so the resize must be
|
|
|
|
|
+ * rejected (with an entity-describing reason), not shipped. */
|
|
|
|
|
+export function sceneFitsAfterResize(model: SceneModel, width: number, height: number): SceneOpResult {
|
|
|
|
|
+ const fit = sceneFitsIn(model, width, height);
|
|
|
|
|
+ if (!fit.ok) return fit;
|
|
|
|
|
+ const placements: { x: number; y: number; w: number; h: number; label: string }[] = [];
|
|
|
|
|
+ for (const entity of model.def.entities) {
|
|
|
|
|
+ const footprint = entityFootprint(model, entity);
|
|
|
|
|
+ const maxX = width - footprint.w;
|
|
|
|
|
+ const maxY = height - footprint.h;
|
|
|
|
|
+ if (maxX < 0 || maxY < 0) continue; // uncontainable buildings were rejected above
|
|
|
|
|
+ const x = clampInt(entity.x, 0, maxX);
|
|
|
|
|
+ const y = clampInt(entity.y, 0, maxY);
|
|
|
|
|
+ for (const other of placements) {
|
|
|
|
|
+ if (rectsIntersect(x, y, footprint.w, footprint.h, other.x, other.y, other.w, other.h)) {
|
|
|
|
|
+ return fail(`resize would overlap ${describeEntity(entity)} with ${other.label}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ placements.push({ x, y, w: footprint.w, h: footprint.h, label: describeEntity(entity) });
|
|
|
|
|
+ }
|
|
|
|
|
+ return { ok: true };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
/** Index of the topmost entity whose footprint contains (x, y), or -1. */
|
|
/** Index of the topmost entity whose footprint contains (x, y), or -1. */
|
|
|
export function entityIndexAt(model: SceneModel, x: number, y: number): number {
|
|
export function entityIndexAt(model: SceneModel, x: number, y: number): number {
|
|
|
let found = -1;
|
|
let found = -1;
|
|
@@ -257,7 +372,57 @@ function rectsIntersect(
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function cloneDef(def: SceneDef): SceneDef {
|
|
function cloneDef(def: SceneDef): SceneDef {
|
|
|
- return { id: def.id, entities: def.entities.map((entity) => ({ ...entity })) };
|
|
|
|
|
|
|
+ return { id: def.id, entities: def.entities.map((entity) => cloneEntity(entity)) };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function cloneEntity(entity: SceneEntity): SceneEntity {
|
|
|
|
|
+ return { ...entity };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** Push an inverse-recordable edit: new ops clear the redo stack, and the
|
|
|
|
|
+ * dirty flag follows the stack (dirty = history non-empty). */
|
|
|
|
|
+function recordHistory(model: SceneModel, entry: SceneHistoryEntry): void {
|
|
|
|
|
+ model.history.push(entry);
|
|
|
|
|
+ model.future = [];
|
|
|
|
|
+ model.dirty = true;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function applyInverse(model: SceneModel, entry: SceneHistoryEntry): void {
|
|
|
|
|
+ switch (entry.kind) {
|
|
|
|
|
+ case "add":
|
|
|
|
|
+ model.def.entities.splice(entry.index, 1);
|
|
|
|
|
+ break;
|
|
|
|
|
+ case "remove":
|
|
|
|
|
+ model.def.entities.splice(entry.index, 0, cloneEntity(entry.entity));
|
|
|
|
|
+ break;
|
|
|
|
|
+ case "move": {
|
|
|
|
|
+ const entity = model.def.entities[entry.index];
|
|
|
|
|
+ if (entity !== undefined) {
|
|
|
|
|
+ entity.x = entry.before.x;
|
|
|
|
|
+ entity.y = entry.before.y;
|
|
|
|
|
+ }
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function applyForward(model: SceneModel, entry: SceneHistoryEntry): void {
|
|
|
|
|
+ switch (entry.kind) {
|
|
|
|
|
+ case "add":
|
|
|
|
|
+ model.def.entities.splice(entry.index, 0, cloneEntity(entry.entity));
|
|
|
|
|
+ break;
|
|
|
|
|
+ case "remove":
|
|
|
|
|
+ model.def.entities.splice(entry.index, 1);
|
|
|
|
|
+ break;
|
|
|
|
|
+ case "move": {
|
|
|
|
|
+ const entity = model.def.entities[entry.index];
|
|
|
|
|
+ if (entity !== undefined) {
|
|
|
|
|
+ entity.x = entry.after.x;
|
|
|
|
|
+ entity.y = entry.after.y;
|
|
|
|
|
+ }
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function cloneFootprints(footprints: Record<string, SceneFootprint> | undefined): Record<string, SceneFootprint> {
|
|
function cloneFootprints(footprints: Record<string, SceneFootprint> | undefined): Record<string, SceneFootprint> {
|