import { stringify } from "yaml"; import type { SceneEntity } from "@nengine/core-schema"; import { TILE_SIZE, addTileId, erase, isWalkableTile, markSaved as markMapSaved, paint, pointToTile, serialize as serializeMap, setSpawn, tileColor, toggleWalkable, } from "./map-model.js"; import type { MapModel } from "./map-model.js"; import { SELECT_COLOR, addAnimal, addBuilding, addNpc, describeEntity, entityColor, entityFootprint, entityIndexAt, markSaved as markSceneSaved, moveEntity, removeEntity, serialize as serializeScene, } from "./scene-model.js"; import type { SceneOpResult, SceneFootprint, SceneModel } from "./scene-model.js"; import type { PlaytestFlush } from "./playtest-session.js"; export interface SceneBuildingPaletteEntry { id: string; name?: string; footprint: SceneFootprint; } export interface SceneEditorOptions { root: string; scenePath: string; mapPath: string; mapModel: MapModel; sceneModel: SceneModel; /** Building palette entries with footprint data from the project's defs. */ buildings: SceneBuildingPaletteEntry[]; /** Animal ids from the project's animal defs. */ animals: string[]; onStatus: (message: string, isError?: boolean) => void; onSaved?: () => void; onFlush?: (collect: () => PlaytestFlush[]) => void; } type SceneEditorMode = "scene" | "map"; type MapTool = "paint" | "erase" | "spawn" | "walkable"; type ActivePlacement = { type: "building"; id: string } | { type: "animal"; id: string } | { type: "npc"; id: string } | null; const MAP_TOOLS: { id: MapTool; label: string; title: string }[] = [ { id: "paint", label: "Paint", title: "Left-click / drag paints the selected tile" }, { id: "erase", label: "Erase", title: "Left-drag erases; right-click erases anywhere" }, { id: "spawn", label: "Spawn", title: "Click moves the spawn point" }, { id: "walkable", label: "Walkable", title: "Shows blocked tiles and toggles a tile id's walkability on click" }, ]; export function mountSceneEditor(container: HTMLElement, options: SceneEditorOptions): () => void { const root = document.createElement("div"); root.className = "scene-editor"; container.replaceChildren(root); let mode: SceneEditorMode = "scene"; let activePlacement: ActivePlacement = null; let selectedIndex: number | null = null; let mapTool: MapTool = "paint"; let mapSelectedId = options.mapModel.palette[0] ?? ""; let painting = false; let disposed = false; const toolbar = document.createElement("div"); toolbar.className = "scene-toolbar"; const title = document.createElement("h3"); title.textContent = `Scene: ${options.sceneModel.def.id}`; const path = document.createElement("span"); path.className = "scene-path"; path.textContent = options.scenePath; path.title = options.scenePath; const modeToggle = document.createElement("div"); modeToggle.className = "scene-mode-toggle"; const modeButtons = new Map(); for (const [value, label] of [["scene", "Edit Scene"], ["map", "Edit Map"]] as const) { const button = document.createElement("button"); button.type = "button"; button.className = "scene-mode-button"; button.textContent = label; button.addEventListener("click", () => setMode(value)); modeButtons.set(value, button); modeToggle.appendChild(button); } const save = document.createElement("button"); save.type = "button"; save.className = "primary"; save.addEventListener("click", () => void saveAll()); toolbar.append(title, path, modeToggle, save); root.appendChild(toolbar); const body = document.createElement("div"); body.className = "scene-body"; const palette = document.createElement("div"); palette.className = "scene-palette"; body.appendChild(palette); const main = document.createElement("div"); main.className = "scene-main"; const wrap = document.createElement("div"); wrap.className = "map-canvas-wrap"; const canvas = document.createElement("canvas"); canvas.width = options.mapModel.def.width * TILE_SIZE; canvas.height = options.mapModel.def.height * TILE_SIZE; wrap.appendChild(canvas); main.appendChild(wrap); const entityList = document.createElement("div"); entityList.className = "scene-entity-list"; const listTitle = document.createElement("h4"); listTitle.textContent = "Entities"; const listBody = document.createElement("div"); listBody.className = "scene-entity-rows"; entityList.append(listTitle, listBody); main.appendChild(entityList); body.appendChild(main); root.appendChild(body); function setMode(next: SceneEditorMode): void { mode = next; activePlacement = null; selectedIndex = null; for (const [value, button] of modeButtons) button.classList.toggle("selected", value === next); renderPalette(); draw(); } function setArm(placement: NonNullable): void { activePlacement = placement; selectedIndex = null; renderPalette(); renderEntityList(); draw(); } function setSelected(index: number | null): void { activePlacement = null; selectedIndex = index; renderPalette(); renderEntityList(); draw(); } function renderPalette(): void { palette.replaceChildren(); if (mode === "scene") renderScenePalette(); else renderMapPalette(); } function renderScenePalette(): void { const buildings = sectionHeader("Buildings"); const buildingList = document.createElement("div"); buildingList.className = "scene-palette-list"; if (options.buildings.length === 0) { const none = document.createElement("span"); none.className = "muted"; none.textContent = "no building defs in the project"; buildingList.appendChild(none); } for (const entry of options.buildings) { const button = document.createElement("button"); button.type = "button"; button.className = "scene-palette-item"; const active = activePlacement !== null && activePlacement.type === "building" && activePlacement.id === entry.id; if (active) button.classList.add("selected"); const badge = document.createElement("span"); badge.className = "scene-palette-badge"; badge.textContent = `${entry.footprint.w}×${entry.footprint.h}`; const label = document.createElement("span"); label.textContent = entry.name ?? entry.id; button.title = `${entry.id} — ${entry.footprint.w}×${entry.footprint.h} footprint`; button.append(label, badge); button.addEventListener("click", () => setArm({ type: "building", id: entry.id })); buildingList.appendChild(button); } palette.append(buildings, buildingList); const animals = sectionHeader("Animals"); const animalList = document.createElement("div"); animalList.className = "scene-palette-list"; if (options.animals.length === 0) { const none = document.createElement("span"); none.className = "muted"; none.textContent = "no animal defs in the project"; animalList.appendChild(none); } for (const id of options.animals) { const button = document.createElement("button"); button.type = "button"; button.className = "scene-palette-item"; if (activePlacement !== null && activePlacement.type === "animal" && activePlacement.id === id) button.classList.add("selected"); const label = document.createElement("span"); label.textContent = id; button.append(label); button.addEventListener("click", () => setArm({ type: "animal", id })); animalList.appendChild(button); } palette.append(animals, animalList); const npcSection = sectionHeader("NPC"); const npcForm = document.createElement("div"); npcForm.className = "scene-npc-form"; const npcIdInput = document.createElement("input"); npcIdInput.type = "text"; npcIdInput.placeholder = "npc id"; npcIdInput.spellcheck = false; const dialogueInput = document.createElement("input"); dialogueInput.type = "text"; dialogueInput.placeholder = "dialogue id (optional)"; dialogueInput.spellcheck = false; const labelInput = document.createElement("input"); labelInput.type = "text"; labelInput.placeholder = "label (optional)"; labelInput.spellcheck = false; const npcButton = document.createElement("button"); npcButton.type = "button"; npcButton.textContent = "Place NPC"; npcButton.addEventListener("click", () => { const id = npcIdInput.value.trim(); if (id === "") { options.onStatus("enter an npc id first", true); return; } setArm({ type: "npc", id }); options.onStatus(`armed: place npc "${id}" on the map`); }); npcForm.append(npcIdInput, dialogueInput, labelInput, npcButton); palette.append(npcSection, npcForm); } function renderMapPalette(): void { const tools = document.createElement("div"); tools.className = "map-tools"; const toolButtons = new Map(); for (const tool of MAP_TOOLS) { const button = document.createElement("button"); button.type = "button"; button.className = "map-tool-button"; button.textContent = tool.label; button.title = tool.title; button.addEventListener("click", () => { mapTool = tool.id === mapTool && tool.id === "walkable" ? "paint" : tool.id; renderPalette(); draw(); }); toolButtons.set(tool.id, button); tools.appendChild(button); } for (const [id, button] of toolButtons) button.classList.toggle("selected", id === mapTool); palette.append(tools); const list = document.createElement("div"); list.className = "palette-list"; for (const id of options.mapModel.palette) { const swatch = document.createElement("button"); swatch.type = "button"; swatch.className = "palette-swatch"; if (id === mapSelectedId && mapTool === "paint") swatch.classList.add("selected"); const chip = document.createElement("span"); chip.className = "swatch-chip"; chip.style.background = tileColor(id); const label = document.createElement("span"); label.className = "swatch-label"; label.textContent = isWalkableTile(options.mapModel, id) ? id : `${id} (blocked)`; swatch.append(chip, label); swatch.addEventListener("click", () => { if (mapTool === "walkable") { toggleWalkable(options.mapModel, id); renderPalette(); draw(); updateSave(); } else { mapSelectedId = id; renderPalette(); } }); list.appendChild(swatch); } palette.append(list); const addForm = document.createElement("form"); addForm.className = "add-tile-form"; const addInput = document.createElement("input"); addInput.type = "text"; addInput.placeholder = "tile id"; const addButton = document.createElement("button"); addButton.type = "submit"; addButton.textContent = "Add"; addForm.append(addInput, addButton); addForm.addEventListener("submit", (event) => { event.preventDefault(); const value = addInput.value.trim(); addInput.value = ""; if (value !== "") { addTileId(options.mapModel, value); renderPalette(); } }); palette.append(addForm); } function renderEntityList(): void { listBody.replaceChildren(); if (options.sceneModel.def.entities.length === 0) { const none = document.createElement("span"); none.className = "muted"; none.textContent = "no entities yet — pick from the palette and click the map"; listBody.appendChild(none); return; } options.sceneModel.def.entities.forEach((entity, index) => { const row = document.createElement("div"); row.className = "scene-entity-row"; if (index === selectedIndex) row.classList.add("selected"); const dot = document.createElement("span"); dot.className = "scene-entity-dot"; dot.style.background = entityColor(entity); const label = document.createElement("span"); label.textContent = entityLabel(entity); label.title = describeEntity(entity); const coords = document.createElement("span"); coords.className = "scene-entity-coords"; coords.textContent = `(${entity.x},${entity.y})`; const removeButton = document.createElement("button"); removeButton.type = "button"; removeButton.className = "scene-entity-delete"; removeButton.textContent = "×"; removeButton.title = "Remove entity"; removeButton.addEventListener("click", (event) => { event.stopPropagation(); if (window.confirm(`Remove ${entityLabel(entity)}?`)) { const result = removeEntity(options.sceneModel, index); if (result.ok) { selectedIndex = null; options.onStatus(`removed ${entityLabel(entity)}`); } else { options.onStatus(result.reason, true); } renderEntityList(); draw(); updateSave(); } }); row.append(dot, label, coords, removeButton); row.addEventListener("click", () => setSelected(selectedIndex === index ? null : index)); listBody.appendChild(row); }); } function draw(): void { if (disposed) return; const ctx = canvas.getContext("2d"); if (ctx === null) return; ctx.clearRect(0, 0, canvas.width, canvas.height); drawTiles(ctx); drawGrid(ctx); if (mode === "map") { if (mapTool === "walkable") drawBlocked(ctx); drawSpawn(ctx); } drawEntities(ctx); } function drawTiles(ctx: CanvasRenderingContext2D): void { const data = options.mapModel.def.layers[options.mapModel.groundIndex]?.data; for (let y = 0; y < options.mapModel.def.height; y++) { for (let x = 0; x < options.mapModel.def.width; x++) { const tile = data?.[y]?.[x] ?? ""; ctx.fillStyle = tileColor(tile); ctx.fillRect(x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE); } } } function drawGrid(ctx: CanvasRenderingContext2D): void { ctx.strokeStyle = "rgba(10, 12, 16, 0.35)"; ctx.lineWidth = 1; ctx.beginPath(); for (let x = 0; x <= options.mapModel.def.width; x++) { ctx.moveTo(x * TILE_SIZE + 0.5, 0); ctx.lineTo(x * TILE_SIZE + 0.5, canvas.height); } for (let y = 0; y <= options.mapModel.def.height; y++) { ctx.moveTo(0, y * TILE_SIZE + 0.5); ctx.lineTo(canvas.width, y * TILE_SIZE + 0.5); } ctx.stroke(); } function drawBlocked(ctx: CanvasRenderingContext2D): void { const data = options.mapModel.def.layers[options.mapModel.groundIndex]?.data; for (let y = 0; y < options.mapModel.def.height; y++) { for (let x = 0; x < options.mapModel.def.width; x++) { const tile = data?.[y]?.[x] ?? ""; if (tile === "" || isWalkableTile(options.mapModel, tile)) continue; ctx.strokeStyle = "rgba(231, 76, 60, 0.9)"; ctx.lineWidth = 2; const px = x * TILE_SIZE; const py = y * TILE_SIZE; ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(px + TILE_SIZE, py + TILE_SIZE); ctx.moveTo(px + TILE_SIZE, py); ctx.lineTo(px, py + TILE_SIZE); ctx.stroke(); } } } function drawSpawn(ctx: CanvasRenderingContext2D): void { const sx = options.mapModel.def.spawn.x * TILE_SIZE + TILE_SIZE / 2; const sy = options.mapModel.def.spawn.y * TILE_SIZE + TILE_SIZE / 2; ctx.strokeStyle = "#ffd54f"; ctx.fillStyle = "#ffd54f"; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(sx, sy, TILE_SIZE * 0.32, 0, Math.PI * 2); ctx.stroke(); ctx.beginPath(); ctx.arc(sx, sy, TILE_SIZE * 0.14, 0, Math.PI * 2); ctx.fill(); } function drawEntities(ctx: CanvasRenderingContext2D): void { options.sceneModel.def.entities.forEach((entity, index) => { const footprint = entityFootprint(options.sceneModel, entity); const px = entity.x * TILE_SIZE; const py = entity.y * TILE_SIZE; if (entity.type === "building") { ctx.fillStyle = entityColor(entity); ctx.globalAlpha = 0.85; ctx.fillRect(px, py, footprint.w * TILE_SIZE, footprint.h * TILE_SIZE); ctx.globalAlpha = 1; ctx.strokeStyle = "rgba(10, 12, 16, 0.6)"; ctx.lineWidth = 1; ctx.strokeRect(px + 0.5, py + 0.5, footprint.w * TILE_SIZE - 1, footprint.h * TILE_SIZE - 1); ctx.fillStyle = "#e6e9ec"; ctx.font = "10px system-ui, sans-serif"; ctx.fillText(entity.buildingId, px + 3, py + 12); } else { const cx = px + TILE_SIZE / 2; const cy = py + TILE_SIZE / 2; const radius = entity.type === "npc" ? TILE_SIZE * 0.3 : TILE_SIZE * 0.25; ctx.fillStyle = entityColor(entity); ctx.beginPath(); ctx.arc(cx, cy, radius, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = "rgba(10, 12, 16, 0.6)"; ctx.lineWidth = 1; ctx.stroke(); ctx.fillStyle = "#e6e9ec"; ctx.font = "10px system-ui, sans-serif"; ctx.fillText(entity.type === "npc" ? entity.npcId : entity.animalId, px + 2, py + 24); } if (index === selectedIndex) { ctx.strokeStyle = SELECT_COLOR; ctx.lineWidth = 2; ctx.strokeRect(px - 1.5, py - 1.5, footprint.w * TILE_SIZE + 3, footprint.h * TILE_SIZE + 3); } }); } function cellAt(event: MouseEvent): { x: number; y: number } { const rect = canvas.getBoundingClientRect(); return pointToTile(event.clientX - rect.left, event.clientY - rect.top); } function applyPlacement(result: SceneOpResult, placed: string): void { if (result.ok) { const last = options.sceneModel.def.entities[options.sceneModel.def.entities.length - 1]; const at = last !== undefined ? ` at (${last.x},${last.y})` : ""; options.onStatus(`placed ${placed}${at}`); } else { options.onStatus(result.reason, true); } renderEntityList(); draw(); updateSave(); } function applyMapAction(x: number, y: number): void { const model = options.mapModel; const paletteBefore = model.palette.join("\u0000"); switch (mapTool) { case "paint": paint(model, x, y, mapSelectedId); break; case "erase": erase(model, x, y); break; case "spawn": setSpawn(model, x, y); break; case "walkable": { const tile = model.def.layers[model.groundIndex]?.data[y]?.[x] ?? ""; if (tile !== "") toggleWalkable(model, tile); break; } } draw(); if (mapTool === "walkable" || model.palette.join("\u0000") !== paletteBefore) renderPalette(); updateSave(); } function updateSave(): void { const dirty = options.sceneModel.dirty || options.mapModel.dirty; save.textContent = dirty ? "Save *" : "Save"; save.disabled = !dirty; } async function saveAll(): Promise { const saved: string[] = []; if (options.sceneModel.dirty) { const def = serializeScene(options.sceneModel); if (def === null) { options.onStatus(`${options.scenePath}: scene invalid — not saved`, true); return; } try { await window.nengine.project.writeYaml({ root: options.root, path: options.scenePath, content: stringify(def) }); markSceneSaved(options.sceneModel); saved.push(options.scenePath); } catch (err) { options.onStatus(err instanceof Error ? err.message : String(err), true); return; } } if (options.mapModel.dirty) { const def = serializeMap(options.mapModel); if (def === null) { options.onStatus(`${options.mapPath}: map invalid — not saved`, true); return; } try { await window.nengine.project.writeYaml({ root: options.root, path: options.mapPath, content: stringify(def) }); markMapSaved(options.mapModel); saved.push(options.mapPath); } catch (err) { options.onStatus(err instanceof Error ? err.message : String(err), true); return; } } if (saved.length > 0) { updateSave(); options.onStatus(`saved ${saved.join(" · ")}`); options.onSaved?.(); } } canvas.addEventListener("mousedown", (event) => { if (event.button === 2) { if (mode === "map") { const { x, y } = cellAt(event); erase(options.mapModel, x, y); draw(); updateSave(); } event.preventDefault(); return; } if (event.button !== 0) return; const { x, y } = cellAt(event); if (mode === "map") { painting = true; applyMapAction(x, y); return; } if (activePlacement !== null) { if (activePlacement.type === "npc") { applyPlacement(addNpc(options.sceneModel, activePlacement.id, x, y), `npc "${activePlacement.id}"`); } else if (activePlacement.type === "building") { applyPlacement(addBuilding(options.sceneModel, activePlacement.id, x, y), `building "${activePlacement.id}"`); } else { applyPlacement(addAnimal(options.sceneModel, activePlacement.id, x, y), `animal "${activePlacement.id}"`); } return; } const index = entityIndexAt(options.sceneModel, x, y); if (selectedIndex !== null) { if (index === -1) { const entity = options.sceneModel.def.entities[selectedIndex]; if (entity === undefined) return; const result = moveEntity(options.sceneModel, selectedIndex, x, y); if (result.ok) options.onStatus(`moved ${describeEntity(entity)} to (${x},${y})`); else options.onStatus(result.reason, true); renderEntityList(); draw(); updateSave(); return; } if (index === selectedIndex) setSelected(null); else setSelected(index); return; } setSelected(index === -1 ? null : index); }); canvas.addEventListener("mousemove", (event) => { if (mode !== "map" || !painting) return; const { x, y } = cellAt(event); applyMapAction(x, y); }); canvas.addEventListener("mouseup", () => { painting = false; }); canvas.addEventListener("mouseleave", () => { painting = false; }); canvas.addEventListener("contextmenu", (event) => event.preventDefault()); setMode("scene"); renderEntityList(); updateSave(); if (options.onFlush !== undefined) { options.onFlush(() => { const flushes: PlaytestFlush[] = []; if (options.sceneModel.dirty) { const def = serializeScene(options.sceneModel); if (def === null) flushes.push({ path: options.scenePath, error: "scene invalid — not saved" }); else flushes.push({ path: options.scenePath, content: stringify(def) }); } if (options.mapModel.dirty) { const def = serializeMap(options.mapModel); if (def === null) flushes.push({ path: options.mapPath, error: "map invalid — not saved" }); else flushes.push({ path: options.mapPath, content: stringify(def) }); } return flushes; }); } return () => { disposed = true; }; } function sectionHeader(text: string): HTMLHeadingElement { const header = document.createElement("h4"); header.className = "scene-palette-section"; header.textContent = text; return header; } function entityLabel(entity: SceneEntity): string { if (entity.type === "building") return `building ${entity.buildingId}`; if (entity.type === "npc") return `npc ${entity.npcId}`; return `animal ${entity.animalId}`; }