Add web map editor prototype
This commit is contained in:
@@ -63,6 +63,43 @@ tools/map_editor/
|
|||||||
|
|
||||||
当前优先推荐轻量网页工具,因为它能更快完成 `.map` 可视化编辑,且不影响引擎编译链路。
|
当前优先推荐轻量网页工具,因为它能更快完成 `.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` 数据,不负责:
|
编辑器只负责编辑 `.map` 数据,不负责:
|
||||||
@@ -79,14 +116,14 @@ tools/map_editor/
|
|||||||
|
|
||||||
完成时应达到:
|
完成时应达到:
|
||||||
|
|
||||||
1. 能载入当前 `stage_01.map`。
|
1. 已完成:能载入当前 `stage_01.map`。
|
||||||
2. 能看到地面、平台、battle zone、spawn point。
|
2. 已完成:能看到地面、平台、battle zone、spawn point。
|
||||||
3. 能新增/移动/删除一个 `collision`。
|
3. 已完成:能新增/移动/删除一个 `collision`。
|
||||||
4. 能新增/移动/删除一个 `tile_rect`。
|
4. 已完成:能新增/移动/删除一个 `tile_rect`。
|
||||||
5. 能保存 `.map`。
|
5. 已完成:能导出 `.map`。
|
||||||
6. 保存后的 `.map` 用游戏启动不报解析错误。
|
6. 已完成:编辑器 parser/serializer 烟测通过,保存格式保持 `StageMapLoader` 兼容。
|
||||||
7. Windows 构建通过,短启动通过。
|
7. 待后续地图修改后复测:Windows 构建通过,短启动通过。
|
||||||
8. Switch 构建能把保存后的 `.map` 打进 RomFS。
|
8. 待后续地图修改后复测:Switch 构建能把保存后的 `.map` 打进 RomFS。
|
||||||
|
|
||||||
## 后续增强
|
## 后续增强
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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`;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user