From 759abfd527b1ee6d49a247f4630f13127f86751c Mon Sep 17 00:00:00 2001
From: 1165565692-lang <1165565692@qq.com>
Date: Wed, 10 Jun 2026 00:12:17 +0800
Subject: [PATCH] Add web map editor prototype
---
game/docs/modules/map_editor.md | 53 +-
tools/map_editor/README.md | 35 ++
tools/map_editor/app.js | 903 ++++++++++++++++++++++++++++++++
tools/map_editor/index.html | 97 ++++
tools/map_editor/styles.css | 231 ++++++++
5 files changed, 1311 insertions(+), 8 deletions(-)
create mode 100644 tools/map_editor/README.md
create mode 100644 tools/map_editor/app.js
create mode 100644 tools/map_editor/index.html
create mode 100644 tools/map_editor/styles.css
diff --git a/game/docs/modules/map_editor.md b/game/docs/modules/map_editor.md
index 77bb46f..e3035fe 100644
--- a/game/docs/modules/map_editor.md
+++ b/game/docs/modules/map_editor.md
@@ -63,6 +63,43 @@ tools/map_editor/
当前优先推荐轻量网页工具,因为它能更快完成 `.map` 可视化编辑,且不影响引擎编译链路。
+## 当前实现
+
+首版网页工具已建立:
+
+```text
+tools/map_editor/
+├─ index.html
+├─ styles.css
+├─ app.js
+└─ README.md
+```
+
+本地运行:
+
+```powershell
+python -m http.server 8787
+```
+
+打开:
+
+```text
+http://127.0.0.1:8787/tools/map_editor/
+```
+
+当前能力:
+
+- 从仓库静态服务加载 `game/assets/map/stage_01.map`。
+- 通过文件选择打开任意 `.map`。
+- Canvas 可视化 world、camera、player spawn、collision、rect、tile_rect、battle zone、spawn。
+- 对象列表选择和画布点击选择。
+- 网格吸附拖动对象。
+- 属性面板编辑选中对象的数值或文本。
+- 新增/删除 tileset、layer、collision、tile_rect、battle zone、spawn。
+- 导出兼容 `StageMapLoader` 的 `.map` 文本。
+
+浏览器安全限制下,纯静态网页不能直接覆盖本地源文件;当前通过 Download 导出 `.map`。后续部署到服务器时,可以增加一个保存接口,把编辑器导出的文本写回服务端文件或数据库。
+
## 数据边界
编辑器只负责编辑 `.map` 数据,不负责:
@@ -79,14 +116,14 @@ tools/map_editor/
完成时应达到:
-1. 能载入当前 `stage_01.map`。
-2. 能看到地面、平台、battle zone、spawn point。
-3. 能新增/移动/删除一个 `collision`。
-4. 能新增/移动/删除一个 `tile_rect`。
-5. 能保存 `.map`。
-6. 保存后的 `.map` 用游戏启动不报解析错误。
-7. Windows 构建通过,短启动通过。
-8. Switch 构建能把保存后的 `.map` 打进 RomFS。
+1. 已完成:能载入当前 `stage_01.map`。
+2. 已完成:能看到地面、平台、battle zone、spawn point。
+3. 已完成:能新增/移动/删除一个 `collision`。
+4. 已完成:能新增/移动/删除一个 `tile_rect`。
+5. 已完成:能导出 `.map`。
+6. 已完成:编辑器 parser/serializer 烟测通过,保存格式保持 `StageMapLoader` 兼容。
+7. 待后续地图修改后复测:Windows 构建通过,短启动通过。
+8. 待后续地图修改后复测:Switch 构建能把保存后的 `.map` 打进 RomFS。
## 后续增强
diff --git a/tools/map_editor/README.md b/tools/map_editor/README.md
new file mode 100644
index 0000000..1aa6f7a
--- /dev/null
+++ b/tools/map_editor/README.md
@@ -0,0 +1,35 @@
+# Map Editor
+
+Static browser-based editor for the game's `.map` files.
+
+## Run Locally
+
+From the repository root:
+
+```powershell
+python -m http.server 8787
+```
+
+Open:
+
+```text
+http://127.0.0.1:8787/tools/map_editor/
+```
+
+The editor can fetch `game/assets/map/stage_01.map` when served from the repo root. It can also open any `.map` file through the file picker.
+
+## Current Scope
+
+- Open `.map` text files.
+- Visualize world, camera, player spawn, collision, stage rects, tile rects, battle zones, and spawn points.
+- Select objects from the canvas or object list.
+- Drag objects on the canvas with grid snapping.
+- Edit selected object numeric properties.
+- Add/delete collision rectangles, tile rectangles, battle zones, and spawn points.
+- Download a serialized `.map` file compatible with `StageMapLoader`.
+
+## Limitations
+
+- Browser security prevents direct overwrite of the original local file. Use Download and replace the project `.map` manually, or deploy with a server-side save endpoint later.
+- Tileset image preview is not implemented yet; tile rects use fallback colors.
+- Undo/redo and multi-select are not implemented yet.
diff --git a/tools/map_editor/app.js b/tools/map_editor/app.js
new file mode 100644
index 0000000..009ad3e
--- /dev/null
+++ b/tools/map_editor/app.js
@@ -0,0 +1,903 @@
+const ROLE_ORDER = ["BackgroundFar", "BackgroundMid", "LevelVisual", "PropsBack", "PropsFront"];
+
+const state = {
+ map: createEmptyMap(),
+ selected: null,
+ view: { x: -80, y: -40, scale: 0.35 },
+ gridSize: 10,
+ dragging: null,
+};
+
+const elements = {};
+
+document.addEventListener("DOMContentLoaded", () => {
+ bindElements();
+ bindEvents();
+ resizeCanvas();
+ loadSampleMap();
+});
+
+function bindElements() {
+ for (const id of [
+ "mapCanvas", "fileInput", "loadSampleButton", "downloadButton",
+ "mapIdInput", "worldWidthInput", "worldHeightInput", "gridSizeInput",
+ "objectList", "propertyPanel", "selectionSummary", "deleteButton",
+ "tilesetList", "outputText", "statusText", "viewText",
+ "addTilesetButton", "addLayerButton", "addCollisionButton",
+ "addTileRectButton", "addBattleZoneButton", "addSpawnButton",
+ ]) {
+ elements[id] = document.getElementById(id);
+ }
+}
+
+function bindEvents() {
+ window.addEventListener("resize", resizeCanvas);
+ elements.loadSampleButton.addEventListener("click", loadSampleMap);
+ elements.fileInput.addEventListener("change", openFile);
+ elements.downloadButton.addEventListener("click", downloadMap);
+ elements.deleteButton.addEventListener("click", deleteSelected);
+
+ elements.mapIdInput.addEventListener("change", () => {
+ state.map.id = elements.mapIdInput.value.trim() || "stage";
+ refresh();
+ });
+ elements.worldWidthInput.addEventListener("change", () => {
+ state.map.world.width = numberValue(elements.worldWidthInput, state.map.world.width);
+ refresh();
+ });
+ elements.worldHeightInput.addEventListener("change", () => {
+ state.map.world.height = numberValue(elements.worldHeightInput, state.map.world.height);
+ refresh();
+ });
+ elements.gridSizeInput.addEventListener("change", () => {
+ state.gridSize = Math.max(1, numberValue(elements.gridSizeInput, state.gridSize));
+ refresh();
+ });
+
+ elements.addCollisionButton.addEventListener("click", addCollision);
+ elements.addTilesetButton.addEventListener("click", addTileset);
+ elements.addLayerButton.addEventListener("click", addLayer);
+ elements.addTileRectButton.addEventListener("click", addTileRect);
+ elements.addBattleZoneButton.addEventListener("click", addBattleZone);
+ elements.addSpawnButton.addEventListener("click", addSpawn);
+
+ const canvas = elements.mapCanvas;
+ canvas.addEventListener("mousedown", onCanvasMouseDown);
+ canvas.addEventListener("mousemove", onCanvasMouseMove);
+ canvas.addEventListener("mouseup", onCanvasMouseUp);
+ canvas.addEventListener("mouseleave", onCanvasMouseUp);
+ canvas.addEventListener("wheel", onCanvasWheel, { passive: false });
+}
+
+async function loadSampleMap() {
+ try {
+ const response = await fetch("../../game/assets/map/stage_01.map");
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+ loadMapText(await response.text(), "Loaded stage_01.map");
+ } catch (error) {
+ setStatus(`Could not fetch stage_01.map. Use Open .map. ${error.message}`);
+ loadMapText(defaultMapText(), "Loaded built-in starter map");
+ }
+}
+
+function openFile(event) {
+ const file = event.target.files[0];
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = () => loadMapText(String(reader.result || ""), `Opened ${file.name}`);
+ reader.readAsText(file);
+}
+
+function loadMapText(text, status) {
+ try {
+ state.map = parseMap(text);
+ state.selected = null;
+ fitWorldToCanvas();
+ setStatus(status);
+ refresh();
+ } catch (error) {
+ setStatus(`Parse failed: ${error.message}`);
+ }
+}
+
+function createEmptyMap() {
+ return {
+ id: "stage",
+ world: { width: 1280, height: 720 },
+ camera: { x: 0, y: 0, width: 1280, height: 720 },
+ playerSpawn: { x: 120, y: 520 },
+ collisions: [],
+ tilesets: [],
+ layers: [],
+ battleZones: [],
+ };
+}
+
+function parseMap(text) {
+ const map = createEmptyMap();
+ map.collisions = [];
+ map.tilesets = [];
+ map.layers = [];
+ map.battleZones = [];
+
+ let currentLayer = null;
+ let currentBattleZone = null;
+ const lines = text.split(/\r?\n/);
+ for (let index = 0; index < lines.length; index += 1) {
+ const tokens = tokenize(lines[index]);
+ if (!tokens.length) continue;
+ const command = tokens[0];
+ const lineNumber = index + 1;
+
+ if (command === "map") {
+ map.id = requireToken(tokens, 1, lineNumber);
+ } else if (command === "world") {
+ map.world = { width: num(tokens, 1, lineNumber), height: num(tokens, 2, lineNumber) };
+ } else if (command === "camera") {
+ map.camera = rectFrom(tokens, 1, lineNumber);
+ } else if (command === "player_spawn") {
+ map.playerSpawn = { x: num(tokens, 1, lineNumber), y: num(tokens, 2, lineNumber) };
+ } else if (command === "collision") {
+ map.collisions.push(rectFrom(tokens, 1, lineNumber));
+ } else if (command === "tileset") {
+ map.tilesets.push({
+ id: requireToken(tokens, 1, lineNumber),
+ texture: requireToken(tokens, 2, lineNumber),
+ tileWidth: num(tokens, 3, lineNumber),
+ tileHeight: num(tokens, 4, lineNumber),
+ color: colorFrom(tokens, 5, lineNumber, true),
+ });
+ } else if (command === "layer") {
+ currentLayer = {
+ id: requireToken(tokens, 1, lineNumber),
+ role: requireToken(tokens, 2, lineNumber),
+ parallax: num(tokens, 3, lineNumber),
+ rects: [],
+ tileRects: [],
+ };
+ map.layers.push(currentLayer);
+ } else if (command === "endlayer") {
+ currentLayer = null;
+ } else if (command === "rect") {
+ requireLayer(currentLayer, lineNumber);
+ currentLayer.rects.push({ bounds: rectFrom(tokens, 1, lineNumber), color: colorFrom(tokens, 5, lineNumber, true) });
+ } else if (command === "rect_sprite" || command === "rect_sprite_src") {
+ requireLayer(currentLayer, lineNumber);
+ const item = {
+ bounds: rectFrom(tokens, 1, lineNumber),
+ color: colorFrom(tokens, 5, lineNumber, true),
+ texture: requireToken(tokens, 9, lineNumber),
+ source: null,
+ };
+ if (command === "rect_sprite_src") {
+ item.source = rectFrom(tokens, 10, lineNumber);
+ }
+ currentLayer.rects.push(item);
+ } else if (command === "tile_rect") {
+ requireLayer(currentLayer, lineNumber);
+ currentLayer.tileRects.push({
+ tileset: requireToken(tokens, 1, lineNumber),
+ x: num(tokens, 2, lineNumber),
+ y: num(tokens, 3, lineNumber),
+ columns: intNum(tokens, 4, lineNumber),
+ rows: intNum(tokens, 5, lineNumber),
+ tileIndex: intNum(tokens, 6, lineNumber),
+ color: colorFrom(tokens, 7, lineNumber, false),
+ });
+ } else if (command === "battle_zone") {
+ currentBattleZone = {
+ id: requireToken(tokens, 1, lineNumber),
+ trigger: rectFrom(tokens, 2, lineNumber),
+ camera: rectFrom(tokens, 6, lineNumber),
+ spawns: [],
+ };
+ map.battleZones.push(currentBattleZone);
+ } else if (command === "spawn") {
+ if (!currentBattleZone) throw new Error(`Line ${lineNumber}: spawn outside battle_zone`);
+ currentBattleZone.spawns.push({
+ id: requireToken(tokens, 1, lineNumber),
+ x: num(tokens, 2, lineNumber),
+ y: num(tokens, 3, lineNumber),
+ });
+ } else if (command === "endbattle_zone") {
+ currentBattleZone = null;
+ } else {
+ throw new Error(`Line ${lineNumber}: unknown command ${command}`);
+ }
+ }
+
+ return map;
+}
+
+function serializeMap(map) {
+ const lines = [];
+ lines.push(`map ${map.id}`);
+ lines.push(`world ${fmt(map.world.width)} ${fmt(map.world.height)}`);
+ lines.push(`camera ${rectText(map.camera)}`);
+ lines.push(`player_spawn ${fmt(map.playerSpawn.x)} ${fmt(map.playerSpawn.y)}`);
+ lines.push("");
+
+ for (const item of map.collisions) lines.push(`collision ${rectText(item)}`);
+ lines.push("");
+
+ for (const tileset of map.tilesets) {
+ lines.push(`tileset ${tileset.id} ${tileset.texture} ${fmt(tileset.tileWidth)} ${fmt(tileset.tileHeight)} ${colorText(tileset.color, true)}`);
+ }
+ lines.push("");
+
+ for (const layer of sortLayers(map.layers)) {
+ lines.push(`layer ${layer.id} ${layer.role} ${fmt(layer.parallax)}`);
+ for (const rect of layer.rects) {
+ if (rect.texture && rect.source) {
+ lines.push(`rect_sprite_src ${rectText(rect.bounds)} ${colorText(rect.color, true)} ${rect.texture} ${rectText(rect.source)}`);
+ } else if (rect.texture) {
+ lines.push(`rect_sprite ${rectText(rect.bounds)} ${colorText(rect.color, true)} ${rect.texture}`);
+ } else {
+ lines.push(`rect ${rectText(rect.bounds)} ${colorText(rect.color, true)}`);
+ }
+ }
+ for (const tileRect of layer.tileRects) {
+ lines.push(`tile_rect ${tileRect.tileset} ${fmt(tileRect.x)} ${fmt(tileRect.y)} ${fmt(tileRect.columns)} ${fmt(tileRect.rows)} ${fmt(tileRect.tileIndex)} ${colorText(tileRect.color, false)}`);
+ }
+ lines.push("endlayer");
+ lines.push("");
+ }
+
+ for (const zone of map.battleZones) {
+ lines.push(`battle_zone ${zone.id} ${rectText(zone.trigger)} ${rectText(zone.camera)}`);
+ for (const spawn of zone.spawns) lines.push(`spawn ${spawn.id} ${fmt(spawn.x)} ${fmt(spawn.y)}`);
+ lines.push("endbattle_zone");
+ lines.push("");
+ }
+
+ return `${lines.join("\n").trim()}\n`;
+}
+
+function refresh() {
+ elements.mapIdInput.value = state.map.id;
+ elements.worldWidthInput.value = state.map.world.width;
+ elements.worldHeightInput.value = state.map.world.height;
+ elements.gridSizeInput.value = state.gridSize;
+ elements.outputText.value = serializeMap(state.map);
+ renderTilesets();
+ renderObjectList();
+ renderPropertyPanel();
+ drawCanvas();
+}
+
+function renderTilesets() {
+ elements.tilesetList.innerHTML = "";
+ for (const tileset of state.map.tilesets) {
+ const node = document.createElement("button");
+ node.type = "button";
+ node.className = "tileset-item";
+ node.innerHTML = `
${escapeHtml(tileset.id)}
${escapeHtml(tileset.texture)} | ${tileset.tileWidth}x${tileset.tileHeight}
`;
+ node.addEventListener("click", () => {
+ const index = state.map.tilesets.indexOf(tileset);
+ state.selected = `tileset:${index}`;
+ refresh();
+ });
+ elements.tilesetList.appendChild(node);
+ }
+}
+
+function renderObjectList() {
+ const objects = collectObjects();
+ elements.objectList.innerHTML = "";
+ for (const object of objects) {
+ const node = document.createElement("button");
+ node.type = "button";
+ node.className = `object-item ${sameSelection(object.key, state.selected) ? "active" : ""}`;
+ node.innerHTML = `${escapeHtml(object.title)}
${escapeHtml(object.meta)}
`;
+ node.addEventListener("click", () => {
+ state.selected = object.key;
+ refresh();
+ });
+ elements.objectList.appendChild(node);
+ }
+}
+
+function renderPropertyPanel() {
+ const object = getSelectedObject();
+ elements.propertyPanel.innerHTML = "";
+ if (!object) {
+ elements.selectionSummary.textContent = "Nothing selected.";
+ elements.deleteButton.disabled = true;
+ return;
+ }
+
+ elements.selectionSummary.textContent = `${object.title} | ${object.meta}`;
+ elements.deleteButton.disabled = !object.deletable;
+ const fields = object.fields();
+ for (const field of fields) {
+ const label = document.createElement("label");
+ label.textContent = field.label;
+ let input;
+ if (field.options) {
+ input = document.createElement("select");
+ for (const option of field.options) {
+ const node = document.createElement("option");
+ node.value = option;
+ node.textContent = option;
+ input.appendChild(node);
+ }
+ } else {
+ input = document.createElement("input");
+ input.type = field.type || "number";
+ if (input.type === "number") input.step = field.step || "1";
+ }
+ input.value = field.get();
+ input.addEventListener("change", () => {
+ field.set(input.type === "number" ? Number(input.value) : input.value);
+ refresh();
+ });
+ label.appendChild(input);
+ elements.propertyPanel.appendChild(label);
+ }
+}
+
+function drawCanvas() {
+ const canvas = elements.mapCanvas;
+ const ctx = canvas.getContext("2d");
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+ ctx.save();
+ ctx.translate(-state.view.x * state.view.scale, -state.view.y * state.view.scale);
+ ctx.scale(state.view.scale, state.view.scale);
+
+ drawWorld(ctx);
+ drawGrid(ctx);
+ drawLayers(ctx);
+ drawCollisions(ctx);
+ drawBattleZones(ctx);
+ drawPlayerSpawn(ctx);
+ drawSelection(ctx);
+
+ ctx.restore();
+ elements.viewText.textContent = `zoom ${state.view.scale.toFixed(2)} | grid ${state.gridSize}`;
+}
+
+function drawWorld(ctx) {
+ ctx.fillStyle = "#151a20";
+ ctx.fillRect(0, 0, state.map.world.width, state.map.world.height);
+ ctx.strokeStyle = "#53606d";
+ ctx.lineWidth = 2 / state.view.scale;
+ ctx.strokeRect(0, 0, state.map.world.width, state.map.world.height);
+
+ ctx.strokeStyle = "#4f7cff";
+ ctx.setLineDash([12 / state.view.scale, 8 / state.view.scale]);
+ ctx.strokeRect(state.map.camera.x, state.map.camera.y, state.map.camera.width, state.map.camera.height);
+ ctx.setLineDash([]);
+}
+
+function drawGrid(ctx) {
+ const step = Math.max(1, state.gridSize);
+ ctx.strokeStyle = "rgba(255,255,255,0.05)";
+ ctx.lineWidth = 1 / state.view.scale;
+ for (let x = 0; x <= state.map.world.width; x += step) {
+ ctx.beginPath();
+ ctx.moveTo(x, 0);
+ ctx.lineTo(x, state.map.world.height);
+ ctx.stroke();
+ }
+ for (let y = 0; y <= state.map.world.height; y += step) {
+ ctx.beginPath();
+ ctx.moveTo(0, y);
+ ctx.lineTo(state.map.world.width, y);
+ ctx.stroke();
+ }
+}
+
+function drawLayers(ctx) {
+ for (const layer of sortLayers(state.map.layers)) {
+ for (const rect of layer.rects) drawFilledRect(ctx, rect.bounds, rect.color, 0.55, "#7d8794");
+ for (const tileRect of layer.tileRects) drawTileRect(ctx, tileRect);
+ }
+}
+
+function drawTileRect(ctx, tileRect) {
+ const tileset = state.map.tilesets.find((item) => item.id === tileRect.tileset);
+ const tw = tileset ? tileset.tileWidth : 10;
+ const th = tileset ? tileset.tileHeight : 10;
+ const bounds = { x: tileRect.x, y: tileRect.y, width: tileRect.columns * tw, height: tileRect.rows * th };
+ drawFilledRect(ctx, bounds, tileRect.color, 0.75, "#d7e1ea");
+ ctx.strokeStyle = "rgba(255,255,255,0.22)";
+ ctx.lineWidth = 1 / state.view.scale;
+ for (let x = bounds.x; x <= bounds.x + bounds.width; x += tw) line(ctx, x, bounds.y, x, bounds.y + bounds.height);
+ for (let y = bounds.y; y <= bounds.y + bounds.height; y += th) line(ctx, bounds.x, y, bounds.x + bounds.width, y);
+}
+
+function drawCollisions(ctx) {
+ for (const rect of state.map.collisions) drawStrokeRect(ctx, rect, "rgba(255,104,104,0.95)", "rgba(255,104,104,0.10)");
+}
+
+function drawBattleZones(ctx) {
+ for (const zone of state.map.battleZones) {
+ drawStrokeRect(ctx, zone.trigger, "rgba(255,214,102,0.95)", "rgba(255,214,102,0.08)");
+ drawStrokeRect(ctx, zone.camera, "rgba(84,168,255,0.75)", "rgba(84,168,255,0.05)");
+ for (const spawn of zone.spawns) drawPoint(ctx, spawn.x, spawn.y, "#9ed06a");
+ }
+}
+
+function drawPlayerSpawn(ctx) {
+ drawPoint(ctx, state.map.playerSpawn.x, state.map.playerSpawn.y, "#ffffff");
+}
+
+function drawSelection(ctx) {
+ const object = getSelectedObject();
+ if (!object) return;
+ const bounds = object.bounds && object.bounds();
+ ctx.strokeStyle = "#54a8ff";
+ ctx.lineWidth = 3 / state.view.scale;
+ if (bounds) ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
+}
+
+function drawFilledRect(ctx, rect, color, alpha, stroke) {
+ ctx.fillStyle = rgba(color, alpha);
+ ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
+ ctx.strokeStyle = stroke;
+ ctx.lineWidth = 1 / state.view.scale;
+ ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
+}
+
+function drawStrokeRect(ctx, rect, stroke, fill) {
+ ctx.fillStyle = fill;
+ ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
+ ctx.strokeStyle = stroke;
+ ctx.lineWidth = 2 / state.view.scale;
+ ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
+}
+
+function drawPoint(ctx, x, y, color) {
+ const r = 8 / state.view.scale;
+ ctx.fillStyle = color;
+ ctx.beginPath();
+ ctx.arc(x, y, r, 0, Math.PI * 2);
+ ctx.fill();
+ ctx.strokeStyle = "#0b0d10";
+ ctx.lineWidth = 2 / state.view.scale;
+ ctx.stroke();
+}
+
+function line(ctx, x1, y1, x2, y2) {
+ ctx.beginPath();
+ ctx.moveTo(x1, y1);
+ ctx.lineTo(x2, y2);
+ ctx.stroke();
+}
+
+function collectObjects() {
+ const objects = [
+ objectForPoint("player", "Player Spawn", state.map.playerSpawn),
+ objectForRect("camera", "Camera", state.map.camera),
+ ];
+ state.map.tilesets.forEach((tileset, index) => objects.push(objectForTileset(`tileset:${index}`, `Tileset ${tileset.id}`, tileset, true)));
+ state.map.collisions.forEach((rect, index) => objects.push(objectForRect(`collision:${index}`, `Collision ${index + 1}`, rect, true)));
+ state.map.layers.forEach((layer, layerIndex) => {
+ objects.push(objectForLayer(`layer:${layerIndex}`, `Layer ${layer.id}`, layer, true));
+ layer.rects.forEach((rect, rectIndex) => objects.push(objectForRect(`rect:${layerIndex}:${rectIndex}`, `${layer.id} Rect ${rectIndex + 1}`, rect.bounds, true)));
+ layer.tileRects.forEach((tileRect, tileIndex) => objects.push(objectForTileRect(`tile:${layerIndex}:${tileIndex}`, `${layer.id} TileRect ${tileIndex + 1}`, tileRect, true)));
+ });
+ state.map.battleZones.forEach((zone, zoneIndex) => {
+ objects.push(objectForRect(`zone:${zoneIndex}`, `Battle Zone ${zoneIndex + 1}`, zone.trigger, true, zone.id));
+ objects.push(objectForRect(`zone_camera:${zoneIndex}`, `Zone Camera ${zoneIndex + 1}`, zone.camera, false, zone.id));
+ zone.spawns.forEach((spawn, spawnIndex) => objects.push(objectForPoint(`spawn:${zoneIndex}:${spawnIndex}`, `Spawn ${spawn.id}`, spawn, true)));
+ });
+ return objects;
+}
+
+function objectForTileset(id, title, tileset, deletable) {
+ return {
+ key: id,
+ title,
+ meta: `${tileset.texture} | ${tileset.tileWidth}x${tileset.tileHeight}`,
+ deletable,
+ fields: () => [
+ textField("ID", () => tileset.id, (v) => { renameTileset(tileset.id, v || tileset.id); }),
+ textField("Texture", () => tileset.texture, (v) => { tileset.texture = v || tileset.texture; }),
+ numberField("Tile W", () => tileset.tileWidth, (v) => { tileset.tileWidth = Math.max(1, v); }),
+ numberField("Tile H", () => tileset.tileHeight, (v) => { tileset.tileHeight = Math.max(1, v); }),
+ ...colorFields(tileset.color, true),
+ ],
+ };
+}
+
+function objectForLayer(id, title, layer, deletable) {
+ return {
+ key: id,
+ title,
+ meta: `${layer.role} | parallax ${layer.parallax}`,
+ deletable,
+ fields: () => [
+ textField("ID", () => layer.id, (v) => { layer.id = v || layer.id; }),
+ textField("Role", () => layer.role, (v) => { layer.role = v; }, ROLE_ORDER),
+ numberField("Parallax", () => layer.parallax, (v) => { layer.parallax = v; }, "0.05"),
+ ],
+ };
+}
+
+function objectForRect(id, title, rect, deletable = false, meta = null) {
+ return {
+ key: id,
+ title,
+ meta: meta || `${fmt(rect.x)}, ${fmt(rect.y)}, ${fmt(rect.width)}x${fmt(rect.height)}`,
+ deletable,
+ bounds: () => rect,
+ move(dx, dy) { rect.x += dx; rect.y += dy; },
+ fields: () => rectFields(rect),
+ };
+}
+
+function objectForPoint(id, title, point, deletable = false) {
+ return {
+ key: id,
+ title,
+ meta: `${fmt(point.x)}, ${fmt(point.y)}`,
+ deletable,
+ bounds: () => ({ x: point.x - 8, y: point.y - 8, width: 16, height: 16 }),
+ move(dx, dy) { point.x += dx; point.y += dy; },
+ fields: () => pointFields(point),
+ };
+}
+
+function objectForTileRect(id, title, tileRect, deletable) {
+ const tileset = state.map.tilesets.find((item) => item.id === tileRect.tileset);
+ const tw = tileset ? tileset.tileWidth : 10;
+ const th = tileset ? tileset.tileHeight : 10;
+ return {
+ key: id,
+ title,
+ meta: `${tileRect.tileset} ${tileRect.columns}x${tileRect.rows}`,
+ deletable,
+ bounds: () => ({ x: tileRect.x, y: tileRect.y, width: tileRect.columns * tw, height: tileRect.rows * th }),
+ move(dx, dy) { tileRect.x += dx; tileRect.y += dy; },
+ fields: () => [
+ textField("Tileset", () => tileRect.tileset, (v) => { tileRect.tileset = v; }, state.map.tilesets.map((item) => item.id)),
+ numberField("X", () => tileRect.x, (v) => { tileRect.x = v; }),
+ numberField("Y", () => tileRect.y, (v) => { tileRect.y = v; }),
+ numberField("Columns", () => tileRect.columns, (v) => { tileRect.columns = Math.max(1, Math.round(v)); }),
+ numberField("Rows", () => tileRect.rows, (v) => { tileRect.rows = Math.max(1, Math.round(v)); }),
+ numberField("Tile Index", () => tileRect.tileIndex, (v) => { tileRect.tileIndex = Math.max(0, Math.round(v)); }),
+ ...colorFields(tileRect.color, false),
+ ],
+ };
+}
+
+function getSelectedObject() {
+ if (!state.selected) return null;
+ return collectObjects().find((object) => sameSelection(object.key, state.selected)) || null;
+}
+
+function onCanvasMouseDown(event) {
+ const world = screenToWorld(event.offsetX, event.offsetY);
+ const object = hitTest(world.x, world.y);
+ if (object) {
+ state.selected = object.key;
+ state.dragging = { last: snapPoint(world) };
+ refresh();
+ } else {
+ state.dragging = { pan: true, x: event.clientX, y: event.clientY, viewX: state.view.x, viewY: state.view.y };
+ }
+}
+
+function onCanvasMouseMove(event) {
+ if (!state.dragging) return;
+ if (state.dragging.pan) {
+ state.view.x = state.dragging.viewX - (event.clientX - state.dragging.x) / state.view.scale;
+ state.view.y = state.dragging.viewY - (event.clientY - state.dragging.y) / state.view.scale;
+ drawCanvas();
+ return;
+ }
+ const object = getSelectedObject();
+ if (!object || !object.move) return;
+ const current = snapPoint(screenToWorld(event.offsetX, event.offsetY));
+ const dx = current.x - state.dragging.last.x;
+ const dy = current.y - state.dragging.last.y;
+ if (dx || dy) {
+ object.move(dx, dy);
+ state.dragging.last = current;
+ refresh();
+ }
+}
+
+function onCanvasMouseUp() {
+ state.dragging = null;
+}
+
+function onCanvasWheel(event) {
+ event.preventDefault();
+ const before = screenToWorld(event.offsetX, event.offsetY);
+ const factor = event.deltaY < 0 ? 1.12 : 0.88;
+ state.view.scale = clamp(state.view.scale * factor, 0.08, 4);
+ const after = screenToWorld(event.offsetX, event.offsetY);
+ state.view.x += before.x - after.x;
+ state.view.y += before.y - after.y;
+ drawCanvas();
+}
+
+function hitTest(x, y) {
+ const objects = collectObjects().reverse();
+ for (const object of objects) {
+ const bounds = object.bounds && object.bounds();
+ if (!bounds) continue;
+ if (x >= bounds.x && x <= bounds.x + bounds.width && y >= bounds.y && y <= bounds.y + bounds.height) return object;
+ }
+ return null;
+}
+
+function addCollision() {
+ const point = snapPoint(viewCenter());
+ state.map.collisions.push({ x: point.x, y: point.y, width: 160, height: 40 });
+ state.selected = `collision:${state.map.collisions.length - 1}`;
+ refresh();
+}
+
+function addTileset() {
+ const index = state.map.tilesets.length + 1;
+ state.map.tilesets.push({
+ id: `tileset_${index}`,
+ texture: `assets/stage/stage_01/tiles/tileset_${index}.png`,
+ tileWidth: 10,
+ tileHeight: 10,
+ color: { r: 0.62, g: 0.68, b: 0.73, a: 1 },
+ });
+ state.selected = `tileset:${state.map.tilesets.length - 1}`;
+ refresh();
+}
+
+function addLayer() {
+ const index = state.map.layers.length + 1;
+ state.map.layers.push({
+ id: `layer_${index}`,
+ role: "PropsBack",
+ parallax: 1,
+ rects: [],
+ tileRects: [],
+ });
+ state.selected = `layer:${state.map.layers.length - 1}`;
+ refresh();
+}
+
+function addTileRect() {
+ ensureLevelVisualLayer();
+ if (!state.map.tilesets.length) {
+ state.map.tilesets.push({ id: "graybox_tile", texture: "assets/stage/stage_01/tiles/graybox_tile.png", tileWidth: 10, tileHeight: 10, color: { r: 0.62, g: 0.68, b: 0.73, a: 1 } });
+ }
+ const layerIndex = state.map.layers.findIndex((layer) => layer.role === "LevelVisual");
+ const layer = state.map.layers[layerIndex];
+ const point = snapPoint(viewCenter());
+ layer.tileRects.push({ tileset: state.map.tilesets[0].id, x: point.x, y: point.y, columns: 8, rows: 4, tileIndex: 0, color: { r: 0.62, g: 0.68, b: 0.73 } });
+ state.selected = `tile:${layerIndex}:${layer.tileRects.length - 1}`;
+ refresh();
+}
+
+function addBattleZone() {
+ const point = snapPoint(viewCenter());
+ state.map.battleZones.push({
+ id: `zone_${String(state.map.battleZones.length + 1).padStart(2, "0")}`,
+ trigger: { x: point.x, y: point.y, width: 520, height: 320 },
+ camera: { x: Math.max(0, point.x - 520), y: 0, width: 1280, height: state.map.world.height },
+ spawns: [],
+ });
+ state.selected = `zone:${state.map.battleZones.length - 1}`;
+ refresh();
+}
+
+function addSpawn() {
+ if (!state.map.battleZones.length) addBattleZone();
+ const zoneIndex = Math.max(0, state.map.battleZones.length - 1);
+ const zone = state.map.battleZones[zoneIndex];
+ const point = snapPoint(viewCenter());
+ zone.spawns.push({ id: `${zone.id}_enemy_${zone.spawns.length + 1}`, x: point.x, y: point.y });
+ state.selected = `spawn:${zoneIndex}:${zone.spawns.length - 1}`;
+ refresh();
+}
+
+function deleteSelected() {
+ if (!state.selected) return;
+ const parts = state.selected.split(":");
+ if (parts[0] === "collision") state.map.collisions.splice(Number(parts[1]), 1);
+ if (parts[0] === "tileset") {
+ const tileset = state.map.tilesets[Number(parts[1])];
+ if (tileset && isTilesetUsed(tileset.id)) {
+ setStatus(`Cannot delete tileset ${tileset.id}: it is used by tile_rect.`);
+ return;
+ }
+ state.map.tilesets.splice(Number(parts[1]), 1);
+ }
+ if (parts[0] === "layer") state.map.layers.splice(Number(parts[1]), 1);
+ if (parts[0] === "rect") state.map.layers[Number(parts[1])].rects.splice(Number(parts[2]), 1);
+ if (parts[0] === "tile") state.map.layers[Number(parts[1])].tileRects.splice(Number(parts[2]), 1);
+ if (parts[0] === "zone") state.map.battleZones.splice(Number(parts[1]), 1);
+ if (parts[0] === "spawn") state.map.battleZones[Number(parts[1])].spawns.splice(Number(parts[2]), 1);
+ state.selected = null;
+ refresh();
+}
+
+function downloadMap() {
+ const blob = new Blob([serializeMap(state.map)], { type: "text/plain" });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = `${state.map.id || "stage"}.map`;
+ link.click();
+ URL.revokeObjectURL(url);
+}
+
+function fitWorldToCanvas() {
+ const canvas = elements.mapCanvas;
+ const scaleX = canvas.width / Math.max(1, state.map.world.width);
+ const scaleY = canvas.height / Math.max(1, state.map.world.height);
+ state.view.scale = Math.max(0.08, Math.min(scaleX, scaleY) * 0.92);
+ state.view.x = -((canvas.width / state.view.scale - state.map.world.width) / 2);
+ state.view.y = -((canvas.height / state.view.scale - state.map.world.height) / 2);
+}
+
+function resizeCanvas() {
+ const canvas = elements.mapCanvas;
+ const rect = canvas.getBoundingClientRect();
+ canvas.width = Math.max(1, Math.floor(rect.width * window.devicePixelRatio));
+ canvas.height = Math.max(1, Math.floor(rect.height * window.devicePixelRatio));
+ const ctx = canvas.getContext("2d");
+ ctx.setTransform(window.devicePixelRatio, 0, 0, window.devicePixelRatio, 0, 0);
+ canvas.width = Math.max(1, Math.floor(rect.width));
+ canvas.height = Math.max(1, Math.floor(rect.height));
+ drawCanvas();
+}
+
+function rectFields(rect) {
+ return [
+ numberField("X", () => rect.x, (v) => { rect.x = v; }),
+ numberField("Y", () => rect.y, (v) => { rect.y = v; }),
+ numberField("W", () => rect.width, (v) => { rect.width = Math.max(1, v); }),
+ numberField("H", () => rect.height, (v) => { rect.height = Math.max(1, v); }),
+ ];
+}
+
+function pointFields(point) {
+ return [
+ numberField("X", () => point.x, (v) => { point.x = v; }),
+ numberField("Y", () => point.y, (v) => { point.y = v; }),
+ ];
+}
+
+function colorFields(color, includeAlpha) {
+ const fields = [
+ numberField("R", () => color.r, (v) => { color.r = clamp(v, 0, 1); }, "0.01"),
+ numberField("G", () => color.g, (v) => { color.g = clamp(v, 0, 1); }, "0.01"),
+ numberField("B", () => color.b, (v) => { color.b = clamp(v, 0, 1); }, "0.01"),
+ ];
+ if (includeAlpha) fields.push(numberField("A", () => color.a, (v) => { color.a = clamp(v, 0, 1); }, "0.01"));
+ return fields;
+}
+
+function numberField(label, get, set, step = "1") {
+ return { label, type: "number", step, get, set };
+}
+
+function textField(label, get, set, options = null) {
+ return { label, type: "text", options, get, set };
+}
+
+function ensureLevelVisualLayer() {
+ if (!state.map.layers.some((layer) => layer.role === "LevelVisual")) {
+ state.map.layers.push({ id: "level_visual", role: "LevelVisual", parallax: 1, rects: [], tileRects: [] });
+ }
+}
+
+function sortLayers(layers) {
+ return [...layers].sort((a, b) => ROLE_ORDER.indexOf(a.role) - ROLE_ORDER.indexOf(b.role));
+}
+
+function tokenize(line) {
+ const tokens = [];
+ for (const token of line.trim().split(/\s+/).filter(Boolean)) {
+ if (token.startsWith("#")) break;
+ tokens.push(token);
+ }
+ return tokens;
+}
+
+function requireLayer(layer, lineNumber) {
+ if (!layer) throw new Error(`Line ${lineNumber}: command must be inside layer`);
+}
+
+function requireToken(tokens, index, lineNumber) {
+ if (tokens.length <= index) throw new Error(`Line ${lineNumber}: missing token ${index}`);
+ return tokens[index];
+}
+
+function num(tokens, index, lineNumber) {
+ const value = Number(requireToken(tokens, index, lineNumber));
+ if (!Number.isFinite(value)) throw new Error(`Line ${lineNumber}: invalid number ${tokens[index]}`);
+ return value;
+}
+
+function intNum(tokens, index, lineNumber) {
+ return Math.round(num(tokens, index, lineNumber));
+}
+
+function rectFrom(tokens, start, lineNumber) {
+ return { x: num(tokens, start, lineNumber), y: num(tokens, start + 1, lineNumber), width: num(tokens, start + 2, lineNumber), height: num(tokens, start + 3, lineNumber) };
+}
+
+function colorFrom(tokens, start, lineNumber, includeAlpha) {
+ return { r: num(tokens, start, lineNumber), g: num(tokens, start + 1, lineNumber), b: num(tokens, start + 2, lineNumber), a: includeAlpha ? num(tokens, start + 3, lineNumber) : 1 };
+}
+
+function rectText(rect) {
+ return `${fmt(rect.x)} ${fmt(rect.y)} ${fmt(rect.width)} ${fmt(rect.height)}`;
+}
+
+function colorText(color, includeAlpha) {
+ return includeAlpha ? `${fmt(color.r)} ${fmt(color.g)} ${fmt(color.b)} ${fmt(color.a)}` : `${fmt(color.r)} ${fmt(color.g)} ${fmt(color.b)}`;
+}
+
+function fmt(value) {
+ return Number(value).toFixed(3).replace(/\.?0+$/, "");
+}
+
+function rgba(color, alphaMul) {
+ const a = color.a === undefined ? 1 : color.a;
+ return `rgba(${Math.round(color.r * 255)},${Math.round(color.g * 255)},${Math.round(color.b * 255)},${a * alphaMul})`;
+}
+
+function escapeHtml(value) {
+ return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char]));
+}
+
+function sameSelection(a, b) {
+ return a === b;
+}
+
+function numberValue(input, fallback) {
+ const value = Number(input.value);
+ return Number.isFinite(value) ? value : fallback;
+}
+
+function screenToWorld(x, y) {
+ return { x: state.view.x + x / state.view.scale, y: state.view.y + y / state.view.scale };
+}
+
+function viewCenter() {
+ const canvas = elements.mapCanvas;
+ return screenToWorld(canvas.width / 2, canvas.height / 2);
+}
+
+function snapPoint(point) {
+ return { x: Math.round(point.x / state.gridSize) * state.gridSize, y: Math.round(point.y / state.gridSize) * state.gridSize };
+}
+
+function clamp(value, min, max) {
+ return Math.max(min, Math.min(max, value));
+}
+
+function setStatus(text) {
+ elements.statusText.textContent = text;
+}
+
+function renameTileset(oldId, newId) {
+ const cleanId = String(newId).trim();
+ if (!cleanId) return;
+ if (cleanId !== oldId && state.map.tilesets.some((item) => item.id === cleanId)) {
+ setStatus(`Cannot rename tileset: ${cleanId} already exists.`);
+ return;
+ }
+ const tileset = state.map.tilesets.find((item) => item.id === oldId);
+ if (!tileset) return;
+ tileset.id = cleanId;
+ for (const layer of state.map.layers) {
+ for (const tileRect of layer.tileRects) {
+ if (tileRect.tileset === oldId) tileRect.tileset = cleanId;
+ }
+ }
+}
+
+function isTilesetUsed(id) {
+ return state.map.layers.some((layer) => layer.tileRects.some((tileRect) => tileRect.tileset === id));
+}
+
+function defaultMapText() {
+ return `map stage\nworld 1280 720\ncamera 0 0 1280 720\nplayer_spawn 120 520\n\ncollision 0 600 1280 80\n\ntileset graybox assets/stage/stage_01/tiles/graybox.png 10 10 0.62 0.68 0.73 1\n\nlayer level_visual LevelVisual 1\ntile_rect graybox 0 600 128 8 0 0.62 0.68 0.73\nendlayer\n`;
+}
diff --git a/tools/map_editor/index.html b/tools/map_editor/index.html
new file mode 100644
index 0000000..7779a74
--- /dev/null
+++ b/tools/map_editor/index.html
@@ -0,0 +1,97 @@
+
+
+
+
+
+ NS Unknown Game Map Editor
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/map_editor/styles.css b/tools/map_editor/styles.css
new file mode 100644
index 0000000..e45bd30
--- /dev/null
+++ b/tools/map_editor/styles.css
@@ -0,0 +1,231 @@
+:root {
+ color-scheme: dark;
+ --bg: #101214;
+ --panel: #181b1f;
+ --panel-2: #20242a;
+ --line: #343a42;
+ --text: #e8edf2;
+ --muted: #9aa4af;
+ --accent: #54a8ff;
+ --accent-2: #9ed06a;
+ --danger: #ff6868;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--text);
+ font-family: "Segoe UI", system-ui, sans-serif;
+ overflow: hidden;
+}
+
+button,
+input,
+textarea,
+select {
+ font: inherit;
+}
+
+button,
+.file-button {
+ border: 1px solid var(--line);
+ background: var(--panel-2);
+ color: var(--text);
+ padding: 8px 10px;
+ border-radius: 6px;
+ cursor: pointer;
+}
+
+button:hover,
+.file-button:hover {
+ border-color: var(--accent);
+}
+
+.app-header {
+ height: 72px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 10px 16px;
+ border-bottom: 1px solid var(--line);
+ background: #0d0f12;
+}
+
+.app-header h1 {
+ margin: 0;
+ font-size: 20px;
+ font-weight: 700;
+}
+
+.app-header p {
+ margin: 4px 0 0;
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.header-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.file-button input {
+ display: none;
+}
+
+.editor-shell {
+ height: calc(100vh - 72px);
+ display: grid;
+ grid-template-columns: 300px minmax(420px, 1fr) 340px;
+}
+
+.panel {
+ background: var(--panel);
+ border-right: 1px solid var(--line);
+ overflow: auto;
+ padding: 14px;
+}
+
+.right-panel {
+ border-right: 0;
+ border-left: 1px solid var(--line);
+}
+
+section + section {
+ margin-top: 18px;
+}
+
+h2 {
+ margin: 0 0 10px;
+ font-size: 13px;
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.field-grid,
+.property-panel {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+}
+
+label {
+ display: grid;
+ gap: 4px;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+input,
+select,
+textarea {
+ width: 100%;
+ min-width: 0;
+ border: 1px solid var(--line);
+ background: #0f1114;
+ color: var(--text);
+ border-radius: 5px;
+ padding: 7px 8px;
+}
+
+.button-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 8px;
+}
+
+.object-section {
+ min-height: 0;
+}
+
+.object-list,
+.tileset-list {
+ display: grid;
+ gap: 6px;
+}
+
+.object-item,
+.tileset-item {
+ border: 1px solid var(--line);
+ background: #111418;
+ border-radius: 6px;
+ padding: 8px;
+ cursor: pointer;
+}
+
+.object-item:hover {
+ border-color: var(--accent);
+}
+
+.object-item.active {
+ border-color: var(--accent);
+ background: #122236;
+}
+
+.object-title {
+ font-size: 13px;
+ font-weight: 600;
+}
+
+.object-meta {
+ margin-top: 3px;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.canvas-panel {
+ min-width: 0;
+ display: grid;
+ grid-template-rows: 40px 1fr;
+ background: #0b0d10;
+}
+
+.toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 12px;
+ border-bottom: 1px solid var(--line);
+ color: var(--muted);
+ font-size: 13px;
+}
+
+#mapCanvas {
+ width: 100%;
+ height: 100%;
+ display: block;
+ background: #0e1115;
+ cursor: crosshair;
+}
+
+.selection-summary {
+ min-height: 34px;
+ color: var(--muted);
+ font-size: 13px;
+ margin-bottom: 10px;
+}
+
+.danger-button {
+ width: 100%;
+ margin-top: 10px;
+ color: #ffd8d8;
+ border-color: #633;
+}
+
+.danger-button:hover {
+ border-color: var(--danger);
+}
+
+#outputText {
+ height: 220px;
+ resize: vertical;
+ white-space: pre;
+ font-family: Consolas, "Cascadia Mono", monospace;
+ font-size: 12px;
+}