Add web map editor prototype

This commit is contained in:
2026-06-10 00:12:17 +08:00
parent fb54c9f207
commit 759abfd527
5 changed files with 1311 additions and 8 deletions
+45 -8
View File
@@ -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。
## 后续增强
+35
View File
@@ -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.
+903
View File
@@ -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 = `<div class="object-title">${escapeHtml(tileset.id)}</div><div class="object-meta">${escapeHtml(tileset.texture)} | ${tileset.tileWidth}x${tileset.tileHeight}</div>`;
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 = `<div class="object-title">${escapeHtml(object.title)}</div><div class="object-meta">${escapeHtml(object.meta)}</div>`;
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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`;
}
+97
View File
@@ -0,0 +1,97 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NS Unknown Game Map Editor</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header class="app-header">
<div>
<h1>Map Editor</h1>
<p>Edits Frostbite2D .map text files used by the game runtime.</p>
</div>
<div class="header-actions">
<button id="loadSampleButton" type="button">Load stage_01.map</button>
<label class="file-button">
Open .map
<input id="fileInput" type="file" accept=".map,text/plain">
</label>
<button id="downloadButton" type="button">Download .map</button>
</div>
</header>
<main class="editor-shell">
<aside class="panel left-panel">
<section>
<h2>Map</h2>
<div class="field-grid">
<label>
ID
<input id="mapIdInput" type="text">
</label>
<label>
World W
<input id="worldWidthInput" type="number" step="1">
</label>
<label>
World H
<input id="worldHeightInput" type="number" step="1">
</label>
<label>
Grid
<input id="gridSizeInput" type="number" step="1" min="1" value="10">
</label>
</div>
</section>
<section>
<h2>Add</h2>
<div class="button-grid">
<button id="addTilesetButton" type="button">Tileset</button>
<button id="addLayerButton" type="button">Layer</button>
<button id="addCollisionButton" type="button">Collision</button>
<button id="addTileRectButton" type="button">Tile Rect</button>
<button id="addBattleZoneButton" type="button">Battle Zone</button>
<button id="addSpawnButton" type="button">Spawn</button>
</div>
</section>
<section class="object-section">
<h2>Objects</h2>
<div id="objectList" class="object-list"></div>
</section>
</aside>
<section class="canvas-panel">
<div class="toolbar">
<span id="statusText">Ready</span>
<span id="viewText"></span>
</div>
<canvas id="mapCanvas" width="1280" height="720"></canvas>
</section>
<aside class="panel right-panel">
<section>
<h2>Selection</h2>
<div id="selectionSummary" class="selection-summary">Nothing selected.</div>
<div id="propertyPanel" class="property-panel"></div>
<button id="deleteButton" type="button" class="danger-button">Delete Selected</button>
</section>
<section>
<h2>Tilesets</h2>
<div id="tilesetList" class="tileset-list"></div>
</section>
<section>
<h2>Output</h2>
<textarea id="outputText" spellcheck="false"></textarea>
</section>
</aside>
</main>
<script src="app.js"></script>
</body>
</html>
+231
View File
@@ -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;
}