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 + + + +
+
+

Map Editor

+

Edits Frostbite2D .map text files used by the game runtime.

+
+
+ + + +
+
+ +
+ + +
+
+ Ready + +
+ +
+ + +
+ + + + 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; +}