scene-editor.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664
  1. import { stringify } from "yaml";
  2. import type { SceneEntity } from "@nengine/core-schema";
  3. import {
  4. TILE_SIZE,
  5. addTileId,
  6. erase,
  7. isWalkableTile,
  8. markSaved as markMapSaved,
  9. paint,
  10. pointToTile,
  11. serialize as serializeMap,
  12. setSpawn,
  13. tileColor,
  14. toggleWalkable,
  15. } from "./map-model.js";
  16. import type { MapModel } from "./map-model.js";
  17. import {
  18. SELECT_COLOR,
  19. addAnimal,
  20. addBuilding,
  21. addNpc,
  22. describeEntity,
  23. entityColor,
  24. entityFootprint,
  25. entityIndexAt,
  26. markSaved as markSceneSaved,
  27. moveEntity,
  28. removeEntity,
  29. serialize as serializeScene,
  30. } from "./scene-model.js";
  31. import type { SceneOpResult, SceneFootprint, SceneModel } from "./scene-model.js";
  32. import type { PlaytestFlush } from "./playtest-session.js";
  33. export interface SceneBuildingPaletteEntry {
  34. id: string;
  35. name?: string;
  36. footprint: SceneFootprint;
  37. }
  38. export interface SceneEditorOptions {
  39. root: string;
  40. scenePath: string;
  41. mapPath: string;
  42. mapModel: MapModel;
  43. sceneModel: SceneModel;
  44. /** Building palette entries with footprint data from the project's defs. */
  45. buildings: SceneBuildingPaletteEntry[];
  46. /** Animal ids from the project's animal defs. */
  47. animals: string[];
  48. onStatus: (message: string, isError?: boolean) => void;
  49. onSaved?: () => void;
  50. onFlush?: (collect: () => PlaytestFlush[]) => void;
  51. }
  52. type SceneEditorMode = "scene" | "map";
  53. type MapTool = "paint" | "erase" | "spawn" | "walkable";
  54. type ActivePlacement = { type: "building"; id: string } | { type: "animal"; id: string } | { type: "npc"; id: string } | null;
  55. const MAP_TOOLS: { id: MapTool; label: string; title: string }[] = [
  56. { id: "paint", label: "Paint", title: "Left-click / drag paints the selected tile" },
  57. { id: "erase", label: "Erase", title: "Left-drag erases; right-click erases anywhere" },
  58. { id: "spawn", label: "Spawn", title: "Click moves the spawn point" },
  59. { id: "walkable", label: "Walkable", title: "Shows blocked tiles and toggles a tile id's walkability on click" },
  60. ];
  61. export function mountSceneEditor(container: HTMLElement, options: SceneEditorOptions): () => void {
  62. const root = document.createElement("div");
  63. root.className = "scene-editor";
  64. container.replaceChildren(root);
  65. let mode: SceneEditorMode = "scene";
  66. let activePlacement: ActivePlacement = null;
  67. let selectedIndex: number | null = null;
  68. let mapTool: MapTool = "paint";
  69. let mapSelectedId = options.mapModel.palette[0] ?? "";
  70. let painting = false;
  71. let disposed = false;
  72. const toolbar = document.createElement("div");
  73. toolbar.className = "scene-toolbar";
  74. const title = document.createElement("h3");
  75. title.textContent = `Scene: ${options.sceneModel.def.id}`;
  76. const path = document.createElement("span");
  77. path.className = "scene-path";
  78. path.textContent = options.scenePath;
  79. path.title = options.scenePath;
  80. const modeToggle = document.createElement("div");
  81. modeToggle.className = "scene-mode-toggle";
  82. const modeButtons = new Map<SceneEditorMode, HTMLButtonElement>();
  83. for (const [value, label] of [["scene", "Edit Scene"], ["map", "Edit Map"]] as const) {
  84. const button = document.createElement("button");
  85. button.type = "button";
  86. button.className = "scene-mode-button";
  87. button.textContent = label;
  88. button.addEventListener("click", () => setMode(value));
  89. modeButtons.set(value, button);
  90. modeToggle.appendChild(button);
  91. }
  92. const save = document.createElement("button");
  93. save.type = "button";
  94. save.className = "primary";
  95. save.addEventListener("click", () => void saveAll());
  96. toolbar.append(title, path, modeToggle, save);
  97. root.appendChild(toolbar);
  98. const body = document.createElement("div");
  99. body.className = "scene-body";
  100. const palette = document.createElement("div");
  101. palette.className = "scene-palette";
  102. body.appendChild(palette);
  103. const main = document.createElement("div");
  104. main.className = "scene-main";
  105. const wrap = document.createElement("div");
  106. wrap.className = "map-canvas-wrap";
  107. const canvas = document.createElement("canvas");
  108. canvas.width = options.mapModel.def.width * TILE_SIZE;
  109. canvas.height = options.mapModel.def.height * TILE_SIZE;
  110. wrap.appendChild(canvas);
  111. main.appendChild(wrap);
  112. const entityList = document.createElement("div");
  113. entityList.className = "scene-entity-list";
  114. const listTitle = document.createElement("h4");
  115. listTitle.textContent = "Entities";
  116. const listBody = document.createElement("div");
  117. listBody.className = "scene-entity-rows";
  118. entityList.append(listTitle, listBody);
  119. main.appendChild(entityList);
  120. body.appendChild(main);
  121. root.appendChild(body);
  122. function setMode(next: SceneEditorMode): void {
  123. mode = next;
  124. activePlacement = null;
  125. selectedIndex = null;
  126. for (const [value, button] of modeButtons) button.classList.toggle("selected", value === next);
  127. renderPalette();
  128. draw();
  129. }
  130. function setArm(placement: NonNullable<ActivePlacement>): void {
  131. activePlacement = placement;
  132. selectedIndex = null;
  133. renderPalette();
  134. renderEntityList();
  135. draw();
  136. }
  137. function setSelected(index: number | null): void {
  138. activePlacement = null;
  139. selectedIndex = index;
  140. renderPalette();
  141. renderEntityList();
  142. draw();
  143. }
  144. function renderPalette(): void {
  145. palette.replaceChildren();
  146. if (mode === "scene") renderScenePalette();
  147. else renderMapPalette();
  148. }
  149. function renderScenePalette(): void {
  150. const buildings = sectionHeader("Buildings");
  151. const buildingList = document.createElement("div");
  152. buildingList.className = "scene-palette-list";
  153. if (options.buildings.length === 0) {
  154. const none = document.createElement("span");
  155. none.className = "muted";
  156. none.textContent = "no building defs in the project";
  157. buildingList.appendChild(none);
  158. }
  159. for (const entry of options.buildings) {
  160. const button = document.createElement("button");
  161. button.type = "button";
  162. button.className = "scene-palette-item";
  163. const active = activePlacement !== null && activePlacement.type === "building" && activePlacement.id === entry.id;
  164. if (active) button.classList.add("selected");
  165. const badge = document.createElement("span");
  166. badge.className = "scene-palette-badge";
  167. badge.textContent = `${entry.footprint.w}×${entry.footprint.h}`;
  168. const label = document.createElement("span");
  169. label.textContent = entry.name ?? entry.id;
  170. button.title = `${entry.id} — ${entry.footprint.w}×${entry.footprint.h} footprint`;
  171. button.append(label, badge);
  172. button.addEventListener("click", () => setArm({ type: "building", id: entry.id }));
  173. buildingList.appendChild(button);
  174. }
  175. palette.append(buildings, buildingList);
  176. const animals = sectionHeader("Animals");
  177. const animalList = document.createElement("div");
  178. animalList.className = "scene-palette-list";
  179. if (options.animals.length === 0) {
  180. const none = document.createElement("span");
  181. none.className = "muted";
  182. none.textContent = "no animal defs in the project";
  183. animalList.appendChild(none);
  184. }
  185. for (const id of options.animals) {
  186. const button = document.createElement("button");
  187. button.type = "button";
  188. button.className = "scene-palette-item";
  189. if (activePlacement !== null && activePlacement.type === "animal" && activePlacement.id === id) button.classList.add("selected");
  190. const label = document.createElement("span");
  191. label.textContent = id;
  192. button.append(label);
  193. button.addEventListener("click", () => setArm({ type: "animal", id }));
  194. animalList.appendChild(button);
  195. }
  196. palette.append(animals, animalList);
  197. const npcSection = sectionHeader("NPC");
  198. const npcForm = document.createElement("div");
  199. npcForm.className = "scene-npc-form";
  200. const npcIdInput = document.createElement("input");
  201. npcIdInput.type = "text";
  202. npcIdInput.placeholder = "npc id";
  203. npcIdInput.spellcheck = false;
  204. const dialogueInput = document.createElement("input");
  205. dialogueInput.type = "text";
  206. dialogueInput.placeholder = "dialogue id (optional)";
  207. dialogueInput.spellcheck = false;
  208. const labelInput = document.createElement("input");
  209. labelInput.type = "text";
  210. labelInput.placeholder = "label (optional)";
  211. labelInput.spellcheck = false;
  212. const npcButton = document.createElement("button");
  213. npcButton.type = "button";
  214. npcButton.textContent = "Place NPC";
  215. npcButton.addEventListener("click", () => {
  216. const id = npcIdInput.value.trim();
  217. if (id === "") {
  218. options.onStatus("enter an npc id first", true);
  219. return;
  220. }
  221. setArm({ type: "npc", id });
  222. options.onStatus(`armed: place npc "${id}" on the map`);
  223. });
  224. npcForm.append(npcIdInput, dialogueInput, labelInput, npcButton);
  225. palette.append(npcSection, npcForm);
  226. }
  227. function renderMapPalette(): void {
  228. const tools = document.createElement("div");
  229. tools.className = "map-tools";
  230. const toolButtons = new Map<MapTool, HTMLButtonElement>();
  231. for (const tool of MAP_TOOLS) {
  232. const button = document.createElement("button");
  233. button.type = "button";
  234. button.className = "map-tool-button";
  235. button.textContent = tool.label;
  236. button.title = tool.title;
  237. button.addEventListener("click", () => {
  238. mapTool = tool.id === mapTool && tool.id === "walkable" ? "paint" : tool.id;
  239. renderPalette();
  240. draw();
  241. });
  242. toolButtons.set(tool.id, button);
  243. tools.appendChild(button);
  244. }
  245. for (const [id, button] of toolButtons) button.classList.toggle("selected", id === mapTool);
  246. palette.append(tools);
  247. const list = document.createElement("div");
  248. list.className = "palette-list";
  249. for (const id of options.mapModel.palette) {
  250. const swatch = document.createElement("button");
  251. swatch.type = "button";
  252. swatch.className = "palette-swatch";
  253. if (id === mapSelectedId && mapTool === "paint") swatch.classList.add("selected");
  254. const chip = document.createElement("span");
  255. chip.className = "swatch-chip";
  256. chip.style.background = tileColor(id);
  257. const label = document.createElement("span");
  258. label.className = "swatch-label";
  259. label.textContent = isWalkableTile(options.mapModel, id) ? id : `${id} (blocked)`;
  260. swatch.append(chip, label);
  261. swatch.addEventListener("click", () => {
  262. if (mapTool === "walkable") {
  263. toggleWalkable(options.mapModel, id);
  264. renderPalette();
  265. draw();
  266. updateSave();
  267. } else {
  268. mapSelectedId = id;
  269. renderPalette();
  270. }
  271. });
  272. list.appendChild(swatch);
  273. }
  274. palette.append(list);
  275. const addForm = document.createElement("form");
  276. addForm.className = "add-tile-form";
  277. const addInput = document.createElement("input");
  278. addInput.type = "text";
  279. addInput.placeholder = "tile id";
  280. const addButton = document.createElement("button");
  281. addButton.type = "submit";
  282. addButton.textContent = "Add";
  283. addForm.append(addInput, addButton);
  284. addForm.addEventListener("submit", (event) => {
  285. event.preventDefault();
  286. const value = addInput.value.trim();
  287. addInput.value = "";
  288. if (value !== "") {
  289. addTileId(options.mapModel, value);
  290. renderPalette();
  291. }
  292. });
  293. palette.append(addForm);
  294. }
  295. function renderEntityList(): void {
  296. listBody.replaceChildren();
  297. if (options.sceneModel.def.entities.length === 0) {
  298. const none = document.createElement("span");
  299. none.className = "muted";
  300. none.textContent = "no entities yet — pick from the palette and click the map";
  301. listBody.appendChild(none);
  302. return;
  303. }
  304. options.sceneModel.def.entities.forEach((entity, index) => {
  305. const row = document.createElement("div");
  306. row.className = "scene-entity-row";
  307. if (index === selectedIndex) row.classList.add("selected");
  308. const dot = document.createElement("span");
  309. dot.className = "scene-entity-dot";
  310. dot.style.background = entityColor(entity);
  311. const label = document.createElement("span");
  312. label.textContent = entityLabel(entity);
  313. label.title = describeEntity(entity);
  314. const coords = document.createElement("span");
  315. coords.className = "scene-entity-coords";
  316. coords.textContent = `(${entity.x},${entity.y})`;
  317. const removeButton = document.createElement("button");
  318. removeButton.type = "button";
  319. removeButton.className = "scene-entity-delete";
  320. removeButton.textContent = "×";
  321. removeButton.title = "Remove entity";
  322. removeButton.addEventListener("click", (event) => {
  323. event.stopPropagation();
  324. if (window.confirm(`Remove ${entityLabel(entity)}?`)) {
  325. const result = removeEntity(options.sceneModel, index);
  326. if (result.ok) {
  327. selectedIndex = null;
  328. options.onStatus(`removed ${entityLabel(entity)}`);
  329. } else {
  330. options.onStatus(result.reason, true);
  331. }
  332. renderEntityList();
  333. draw();
  334. updateSave();
  335. }
  336. });
  337. row.append(dot, label, coords, removeButton);
  338. row.addEventListener("click", () => setSelected(selectedIndex === index ? null : index));
  339. listBody.appendChild(row);
  340. });
  341. }
  342. function draw(): void {
  343. if (disposed) return;
  344. const ctx = canvas.getContext("2d");
  345. if (ctx === null) return;
  346. ctx.clearRect(0, 0, canvas.width, canvas.height);
  347. drawTiles(ctx);
  348. drawGrid(ctx);
  349. if (mode === "map") {
  350. if (mapTool === "walkable") drawBlocked(ctx);
  351. drawSpawn(ctx);
  352. }
  353. drawEntities(ctx);
  354. }
  355. function drawTiles(ctx: CanvasRenderingContext2D): void {
  356. const data = options.mapModel.def.layers[options.mapModel.groundIndex]?.data;
  357. for (let y = 0; y < options.mapModel.def.height; y++) {
  358. for (let x = 0; x < options.mapModel.def.width; x++) {
  359. const tile = data?.[y]?.[x] ?? "";
  360. ctx.fillStyle = tileColor(tile);
  361. ctx.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE);
  362. }
  363. }
  364. }
  365. function drawGrid(ctx: CanvasRenderingContext2D): void {
  366. ctx.strokeStyle = "rgba(10, 12, 16, 0.35)";
  367. ctx.lineWidth = 1;
  368. ctx.beginPath();
  369. for (let x = 0; x <= options.mapModel.def.width; x++) {
  370. ctx.moveTo(x * TILE_SIZE + 0.5, 0);
  371. ctx.lineTo(x * TILE_SIZE + 0.5, canvas.height);
  372. }
  373. for (let y = 0; y <= options.mapModel.def.height; y++) {
  374. ctx.moveTo(0, y * TILE_SIZE + 0.5);
  375. ctx.lineTo(canvas.width, y * TILE_SIZE + 0.5);
  376. }
  377. ctx.stroke();
  378. }
  379. function drawBlocked(ctx: CanvasRenderingContext2D): void {
  380. const data = options.mapModel.def.layers[options.mapModel.groundIndex]?.data;
  381. for (let y = 0; y < options.mapModel.def.height; y++) {
  382. for (let x = 0; x < options.mapModel.def.width; x++) {
  383. const tile = data?.[y]?.[x] ?? "";
  384. if (tile === "" || isWalkableTile(options.mapModel, tile)) continue;
  385. ctx.strokeStyle = "rgba(231, 76, 60, 0.9)";
  386. ctx.lineWidth = 2;
  387. const px = x * TILE_SIZE;
  388. const py = y * TILE_SIZE;
  389. ctx.beginPath();
  390. ctx.moveTo(px, py);
  391. ctx.lineTo(px + TILE_SIZE, py + TILE_SIZE);
  392. ctx.moveTo(px + TILE_SIZE, py);
  393. ctx.lineTo(px, py + TILE_SIZE);
  394. ctx.stroke();
  395. }
  396. }
  397. }
  398. function drawSpawn(ctx: CanvasRenderingContext2D): void {
  399. const sx = options.mapModel.def.spawn.x * TILE_SIZE + TILE_SIZE / 2;
  400. const sy = options.mapModel.def.spawn.y * TILE_SIZE + TILE_SIZE / 2;
  401. ctx.strokeStyle = "#ffd54f";
  402. ctx.fillStyle = "#ffd54f";
  403. ctx.lineWidth = 3;
  404. ctx.beginPath();
  405. ctx.arc(sx, sy, TILE_SIZE * 0.32, 0, Math.PI * 2);
  406. ctx.stroke();
  407. ctx.beginPath();
  408. ctx.arc(sx, sy, TILE_SIZE * 0.14, 0, Math.PI * 2);
  409. ctx.fill();
  410. }
  411. function drawEntities(ctx: CanvasRenderingContext2D): void {
  412. options.sceneModel.def.entities.forEach((entity, index) => {
  413. const footprint = entityFootprint(options.sceneModel, entity);
  414. const px = entity.x * TILE_SIZE;
  415. const py = entity.y * TILE_SIZE;
  416. if (entity.type === "building") {
  417. ctx.fillStyle = entityColor(entity);
  418. ctx.globalAlpha = 0.85;
  419. ctx.fillRect(px, py, footprint.w * TILE_SIZE, footprint.h * TILE_SIZE);
  420. ctx.globalAlpha = 1;
  421. ctx.strokeStyle = "rgba(10, 12, 16, 0.6)";
  422. ctx.lineWidth = 1;
  423. ctx.strokeRect(px + 0.5, py + 0.5, footprint.w * TILE_SIZE - 1, footprint.h * TILE_SIZE - 1);
  424. ctx.fillStyle = "#e6e9ec";
  425. ctx.font = "10px system-ui, sans-serif";
  426. ctx.fillText(entity.buildingId, px + 3, py + 12);
  427. } else {
  428. const cx = px + TILE_SIZE / 2;
  429. const cy = py + TILE_SIZE / 2;
  430. const radius = entity.type === "npc" ? TILE_SIZE * 0.3 : TILE_SIZE * 0.25;
  431. ctx.fillStyle = entityColor(entity);
  432. ctx.beginPath();
  433. ctx.arc(cx, cy, radius, 0, Math.PI * 2);
  434. ctx.fill();
  435. ctx.strokeStyle = "rgba(10, 12, 16, 0.6)";
  436. ctx.lineWidth = 1;
  437. ctx.stroke();
  438. ctx.fillStyle = "#e6e9ec";
  439. ctx.font = "10px system-ui, sans-serif";
  440. ctx.fillText(entity.type === "npc" ? entity.npcId : entity.animalId, px + 2, py + 24);
  441. }
  442. if (index === selectedIndex) {
  443. ctx.strokeStyle = SELECT_COLOR;
  444. ctx.lineWidth = 2;
  445. ctx.strokeRect(px - 1.5, py - 1.5, footprint.w * TILE_SIZE + 3, footprint.h * TILE_SIZE + 3);
  446. }
  447. });
  448. }
  449. function cellAt(event: MouseEvent): { x: number; y: number } {
  450. const rect = canvas.getBoundingClientRect();
  451. return pointToTile(event.clientX - rect.left, event.clientY - rect.top);
  452. }
  453. function applyPlacement(result: SceneOpResult, placed: string): void {
  454. if (result.ok) {
  455. const last = options.sceneModel.def.entities[options.sceneModel.def.entities.length - 1];
  456. const at = last !== undefined ? ` at (${last.x},${last.y})` : "";
  457. options.onStatus(`placed ${placed}${at}`);
  458. } else {
  459. options.onStatus(result.reason, true);
  460. }
  461. renderEntityList();
  462. draw();
  463. updateSave();
  464. }
  465. function applyMapAction(x: number, y: number): void {
  466. const model = options.mapModel;
  467. const paletteBefore = model.palette.join("\u0000");
  468. switch (mapTool) {
  469. case "paint":
  470. paint(model, x, y, mapSelectedId);
  471. break;
  472. case "erase":
  473. erase(model, x, y);
  474. break;
  475. case "spawn":
  476. setSpawn(model, x, y);
  477. break;
  478. case "walkable": {
  479. const tile = model.def.layers[model.groundIndex]?.data[y]?.[x] ?? "";
  480. if (tile !== "") toggleWalkable(model, tile);
  481. break;
  482. }
  483. }
  484. draw();
  485. if (mapTool === "walkable" || model.palette.join("\u0000") !== paletteBefore) renderPalette();
  486. updateSave();
  487. }
  488. function updateSave(): void {
  489. const dirty = options.sceneModel.dirty || options.mapModel.dirty;
  490. save.textContent = dirty ? "Save *" : "Save";
  491. save.disabled = !dirty;
  492. }
  493. async function saveAll(): Promise<void> {
  494. const saved: string[] = [];
  495. if (options.sceneModel.dirty) {
  496. const def = serializeScene(options.sceneModel);
  497. if (def === null) {
  498. options.onStatus(`${options.scenePath}: scene invalid — not saved`, true);
  499. return;
  500. }
  501. try {
  502. await window.nengine.project.writeYaml({ root: options.root, path: options.scenePath, content: stringify(def) });
  503. markSceneSaved(options.sceneModel);
  504. saved.push(options.scenePath);
  505. } catch (err) {
  506. options.onStatus(err instanceof Error ? err.message : String(err), true);
  507. return;
  508. }
  509. }
  510. if (options.mapModel.dirty) {
  511. const def = serializeMap(options.mapModel);
  512. if (def === null) {
  513. options.onStatus(`${options.mapPath}: map invalid — not saved`, true);
  514. return;
  515. }
  516. try {
  517. await window.nengine.project.writeYaml({ root: options.root, path: options.mapPath, content: stringify(def) });
  518. markMapSaved(options.mapModel);
  519. saved.push(options.mapPath);
  520. } catch (err) {
  521. options.onStatus(err instanceof Error ? err.message : String(err), true);
  522. return;
  523. }
  524. }
  525. if (saved.length > 0) {
  526. updateSave();
  527. options.onStatus(`saved ${saved.join(" · ")}`);
  528. options.onSaved?.();
  529. }
  530. }
  531. canvas.addEventListener("mousedown", (event) => {
  532. if (event.button === 2) {
  533. if (mode === "map") {
  534. const { x, y } = cellAt(event);
  535. erase(options.mapModel, x, y);
  536. draw();
  537. updateSave();
  538. }
  539. event.preventDefault();
  540. return;
  541. }
  542. if (event.button !== 0) return;
  543. const { x, y } = cellAt(event);
  544. if (mode === "map") {
  545. painting = true;
  546. applyMapAction(x, y);
  547. return;
  548. }
  549. if (activePlacement !== null) {
  550. if (activePlacement.type === "npc") {
  551. applyPlacement(addNpc(options.sceneModel, activePlacement.id, x, y), `npc "${activePlacement.id}"`);
  552. } else if (activePlacement.type === "building") {
  553. applyPlacement(addBuilding(options.sceneModel, activePlacement.id, x, y), `building "${activePlacement.id}"`);
  554. } else {
  555. applyPlacement(addAnimal(options.sceneModel, activePlacement.id, x, y), `animal "${activePlacement.id}"`);
  556. }
  557. return;
  558. }
  559. const index = entityIndexAt(options.sceneModel, x, y);
  560. if (selectedIndex !== null) {
  561. if (index === -1) {
  562. const entity = options.sceneModel.def.entities[selectedIndex];
  563. if (entity === undefined) return;
  564. const result = moveEntity(options.sceneModel, selectedIndex, x, y);
  565. if (result.ok) options.onStatus(`moved ${describeEntity(entity)} to (${x},${y})`);
  566. else options.onStatus(result.reason, true);
  567. renderEntityList();
  568. draw();
  569. updateSave();
  570. return;
  571. }
  572. if (index === selectedIndex) setSelected(null);
  573. else setSelected(index);
  574. return;
  575. }
  576. setSelected(index === -1 ? null : index);
  577. });
  578. canvas.addEventListener("mousemove", (event) => {
  579. if (mode !== "map" || !painting) return;
  580. const { x, y } = cellAt(event);
  581. applyMapAction(x, y);
  582. });
  583. canvas.addEventListener("mouseup", () => {
  584. painting = false;
  585. });
  586. canvas.addEventListener("mouseleave", () => {
  587. painting = false;
  588. });
  589. canvas.addEventListener("contextmenu", (event) => event.preventDefault());
  590. setMode("scene");
  591. renderEntityList();
  592. updateSave();
  593. if (options.onFlush !== undefined) {
  594. options.onFlush(() => {
  595. const flushes: PlaytestFlush[] = [];
  596. if (options.sceneModel.dirty) {
  597. const def = serializeScene(options.sceneModel);
  598. if (def === null) flushes.push({ path: options.scenePath, error: "scene invalid — not saved" });
  599. else flushes.push({ path: options.scenePath, content: stringify(def) });
  600. }
  601. if (options.mapModel.dirty) {
  602. const def = serializeMap(options.mapModel);
  603. if (def === null) flushes.push({ path: options.mapPath, error: "map invalid — not saved" });
  604. else flushes.push({ path: options.mapPath, content: stringify(def) });
  605. }
  606. return flushes;
  607. });
  608. }
  609. return () => {
  610. disposed = true;
  611. };
  612. }
  613. function sectionHeader(text: string): HTMLHeadingElement {
  614. const header = document.createElement("h4");
  615. header.className = "scene-palette-section";
  616. header.textContent = text;
  617. return header;
  618. }
  619. function entityLabel(entity: SceneEntity): string {
  620. if (entity.type === "building") return `building ${entity.buildingId}`;
  621. if (entity.type === "npc") return `npc ${entity.npcId}`;
  622. return `animal ${entity.animalId}`;
  623. }