const ROLE_ORDER = ["BackgroundFar", "BackgroundMid", "LevelVisual", "PropsBack", "PropsFront"]; const state = { map: createEmptyMap(), selected: null, view: { x: -80, y: -40, scale: 0.35 }, gridSize: 10, dragging: null, }; const elements = {}; document.addEventListener("DOMContentLoaded", () => { bindElements(); bindEvents(); resizeCanvas(); loadSampleMap(); }); function bindElements() { for (const id of [ "mapCanvas", "fileInput", "loadSampleButton", "downloadButton", "mapIdInput", "worldWidthInput", "worldHeightInput", "gridSizeInput", "objectList", "propertyPanel", "selectionSummary", "deleteButton", "tilesetList", "outputText", "statusText", "viewText", "addTilesetButton", "addLayerButton", "addCollisionButton", "addTileRectButton", "addBattleZoneButton", "addSpawnButton", ]) { elements[id] = document.getElementById(id); } } function bindEvents() { window.addEventListener("resize", resizeCanvas); elements.loadSampleButton.addEventListener("click", loadSampleMap); elements.fileInput.addEventListener("change", openFile); elements.downloadButton.addEventListener("click", downloadMap); elements.deleteButton.addEventListener("click", deleteSelected); elements.mapIdInput.addEventListener("change", () => { state.map.id = elements.mapIdInput.value.trim() || "stage"; refresh(); }); elements.worldWidthInput.addEventListener("change", () => { state.map.world.width = numberValue(elements.worldWidthInput, state.map.world.width); refresh(); }); elements.worldHeightInput.addEventListener("change", () => { state.map.world.height = numberValue(elements.worldHeightInput, state.map.world.height); refresh(); }); elements.gridSizeInput.addEventListener("change", () => { state.gridSize = Math.max(1, numberValue(elements.gridSizeInput, state.gridSize)); refresh(); }); elements.addCollisionButton.addEventListener("click", addCollision); elements.addTilesetButton.addEventListener("click", addTileset); elements.addLayerButton.addEventListener("click", addLayer); elements.addTileRectButton.addEventListener("click", addTileRect); elements.addBattleZoneButton.addEventListener("click", addBattleZone); elements.addSpawnButton.addEventListener("click", addSpawn); const canvas = elements.mapCanvas; canvas.addEventListener("mousedown", onCanvasMouseDown); canvas.addEventListener("mousemove", onCanvasMouseMove); canvas.addEventListener("mouseup", onCanvasMouseUp); canvas.addEventListener("mouseleave", onCanvasMouseUp); canvas.addEventListener("wheel", onCanvasWheel, { passive: false }); } async function loadSampleMap() { try { const response = await fetch("../../game/assets/map/stage_01.map"); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } loadMapText(await response.text(), "Loaded stage_01.map"); } catch (error) { setStatus(`Could not fetch stage_01.map. Use Open .map. ${error.message}`); loadMapText(defaultMapText(), "Loaded built-in starter map"); } } function openFile(event) { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = () => loadMapText(String(reader.result || ""), `Opened ${file.name}`); reader.readAsText(file); } function loadMapText(text, status) { try { state.map = parseMap(text); state.selected = null; fitWorldToCanvas(); setStatus(status); refresh(); } catch (error) { setStatus(`Parse failed: ${error.message}`); } } function createEmptyMap() { return { id: "stage", world: { width: 1280, height: 720 }, camera: { x: 0, y: 0, width: 1280, height: 720 }, playerSpawn: { x: 120, y: 520 }, collisions: [], tilesets: [], layers: [], battleZones: [], }; } function parseMap(text) { const map = createEmptyMap(); map.collisions = []; map.tilesets = []; map.layers = []; map.battleZones = []; let currentLayer = null; let currentBattleZone = null; const lines = text.split(/\r?\n/); for (let index = 0; index < lines.length; index += 1) { const tokens = tokenize(lines[index]); if (!tokens.length) continue; const command = tokens[0]; const lineNumber = index + 1; if (command === "map") { map.id = requireToken(tokens, 1, lineNumber); } else if (command === "world") { map.world = { width: num(tokens, 1, lineNumber), height: num(tokens, 2, lineNumber) }; } else if (command === "camera") { map.camera = rectFrom(tokens, 1, lineNumber); } else if (command === "player_spawn") { map.playerSpawn = { x: num(tokens, 1, lineNumber), y: num(tokens, 2, lineNumber) }; } else if (command === "collision") { map.collisions.push(rectFrom(tokens, 1, lineNumber)); } else if (command === "tileset") { map.tilesets.push({ id: requireToken(tokens, 1, lineNumber), texture: requireToken(tokens, 2, lineNumber), tileWidth: num(tokens, 3, lineNumber), tileHeight: num(tokens, 4, lineNumber), color: colorFrom(tokens, 5, lineNumber, true), }); } else if (command === "layer") { currentLayer = { id: requireToken(tokens, 1, lineNumber), role: requireToken(tokens, 2, lineNumber), parallax: num(tokens, 3, lineNumber), rects: [], tileRects: [], }; map.layers.push(currentLayer); } else if (command === "endlayer") { currentLayer = null; } else if (command === "rect") { requireLayer(currentLayer, lineNumber); currentLayer.rects.push({ bounds: rectFrom(tokens, 1, lineNumber), color: colorFrom(tokens, 5, lineNumber, true) }); } else if (command === "rect_sprite" || command === "rect_sprite_src") { requireLayer(currentLayer, lineNumber); const item = { bounds: rectFrom(tokens, 1, lineNumber), color: colorFrom(tokens, 5, lineNumber, true), texture: requireToken(tokens, 9, lineNumber), source: null, }; if (command === "rect_sprite_src") { item.source = rectFrom(tokens, 10, lineNumber); } currentLayer.rects.push(item); } else if (command === "tile_rect") { requireLayer(currentLayer, lineNumber); currentLayer.tileRects.push({ tileset: requireToken(tokens, 1, lineNumber), x: num(tokens, 2, lineNumber), y: num(tokens, 3, lineNumber), columns: intNum(tokens, 4, lineNumber), rows: intNum(tokens, 5, lineNumber), tileIndex: intNum(tokens, 6, lineNumber), color: colorFrom(tokens, 7, lineNumber, false), }); } else if (command === "battle_zone") { currentBattleZone = { id: requireToken(tokens, 1, lineNumber), trigger: rectFrom(tokens, 2, lineNumber), camera: rectFrom(tokens, 6, lineNumber), spawns: [], }; map.battleZones.push(currentBattleZone); } else if (command === "spawn") { if (!currentBattleZone) throw new Error(`Line ${lineNumber}: spawn outside battle_zone`); currentBattleZone.spawns.push({ id: requireToken(tokens, 1, lineNumber), x: num(tokens, 2, lineNumber), y: num(tokens, 3, lineNumber), }); } else if (command === "endbattle_zone") { currentBattleZone = null; } else { throw new Error(`Line ${lineNumber}: unknown command ${command}`); } } return map; } function serializeMap(map) { const lines = []; lines.push(`map ${map.id}`); lines.push(`world ${fmt(map.world.width)} ${fmt(map.world.height)}`); lines.push(`camera ${rectText(map.camera)}`); lines.push(`player_spawn ${fmt(map.playerSpawn.x)} ${fmt(map.playerSpawn.y)}`); lines.push(""); for (const item of map.collisions) lines.push(`collision ${rectText(item)}`); lines.push(""); for (const tileset of map.tilesets) { lines.push(`tileset ${tileset.id} ${tileset.texture} ${fmt(tileset.tileWidth)} ${fmt(tileset.tileHeight)} ${colorText(tileset.color, true)}`); } lines.push(""); for (const layer of sortLayers(map.layers)) { lines.push(`layer ${layer.id} ${layer.role} ${fmt(layer.parallax)}`); for (const rect of layer.rects) { if (rect.texture && rect.source) { lines.push(`rect_sprite_src ${rectText(rect.bounds)} ${colorText(rect.color, true)} ${rect.texture} ${rectText(rect.source)}`); } else if (rect.texture) { lines.push(`rect_sprite ${rectText(rect.bounds)} ${colorText(rect.color, true)} ${rect.texture}`); } else { lines.push(`rect ${rectText(rect.bounds)} ${colorText(rect.color, true)}`); } } for (const tileRect of layer.tileRects) { lines.push(`tile_rect ${tileRect.tileset} ${fmt(tileRect.x)} ${fmt(tileRect.y)} ${fmt(tileRect.columns)} ${fmt(tileRect.rows)} ${fmt(tileRect.tileIndex)} ${colorText(tileRect.color, false)}`); } lines.push("endlayer"); lines.push(""); } for (const zone of map.battleZones) { lines.push(`battle_zone ${zone.id} ${rectText(zone.trigger)} ${rectText(zone.camera)}`); for (const spawn of zone.spawns) lines.push(`spawn ${spawn.id} ${fmt(spawn.x)} ${fmt(spawn.y)}`); lines.push("endbattle_zone"); lines.push(""); } return `${lines.join("\n").trim()}\n`; } function refresh() { elements.mapIdInput.value = state.map.id; elements.worldWidthInput.value = state.map.world.width; elements.worldHeightInput.value = state.map.world.height; elements.gridSizeInput.value = state.gridSize; elements.outputText.value = serializeMap(state.map); renderTilesets(); renderObjectList(); renderPropertyPanel(); drawCanvas(); } function renderTilesets() { elements.tilesetList.innerHTML = ""; for (const tileset of state.map.tilesets) { const node = document.createElement("button"); node.type = "button"; node.className = "tileset-item"; node.innerHTML = `
${escapeHtml(tileset.id)}
${escapeHtml(tileset.texture)} | ${tileset.tileWidth}x${tileset.tileHeight}
`; node.addEventListener("click", () => { const index = state.map.tilesets.indexOf(tileset); state.selected = `tileset:${index}`; refresh(); }); elements.tilesetList.appendChild(node); } } function renderObjectList() { const objects = collectObjects(); elements.objectList.innerHTML = ""; for (const object of objects) { const node = document.createElement("button"); node.type = "button"; node.className = `object-item ${sameSelection(object.key, state.selected) ? "active" : ""}`; node.innerHTML = `
${escapeHtml(object.title)}
${escapeHtml(object.meta)}
`; node.addEventListener("click", () => { state.selected = object.key; refresh(); }); elements.objectList.appendChild(node); } } function renderPropertyPanel() { const object = getSelectedObject(); elements.propertyPanel.innerHTML = ""; if (!object) { elements.selectionSummary.textContent = "Nothing selected."; elements.deleteButton.disabled = true; return; } elements.selectionSummary.textContent = `${object.title} | ${object.meta}`; elements.deleteButton.disabled = !object.deletable; const fields = object.fields(); for (const field of fields) { const label = document.createElement("label"); label.textContent = field.label; let input; if (field.options) { input = document.createElement("select"); for (const option of field.options) { const node = document.createElement("option"); node.value = option; node.textContent = option; input.appendChild(node); } } else { input = document.createElement("input"); input.type = field.type || "number"; if (input.type === "number") input.step = field.step || "1"; } input.value = field.get(); input.addEventListener("change", () => { field.set(input.type === "number" ? Number(input.value) : input.value); refresh(); }); label.appendChild(input); elements.propertyPanel.appendChild(label); } } function drawCanvas() { const canvas = elements.mapCanvas; const ctx = canvas.getContext("2d"); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.save(); ctx.translate(-state.view.x * state.view.scale, -state.view.y * state.view.scale); ctx.scale(state.view.scale, state.view.scale); drawWorld(ctx); drawGrid(ctx); drawLayers(ctx); drawCollisions(ctx); drawBattleZones(ctx); drawPlayerSpawn(ctx); drawSelection(ctx); ctx.restore(); elements.viewText.textContent = `zoom ${state.view.scale.toFixed(2)} | grid ${state.gridSize}`; } function drawWorld(ctx) { ctx.fillStyle = "#151a20"; ctx.fillRect(0, 0, state.map.world.width, state.map.world.height); ctx.strokeStyle = "#53606d"; ctx.lineWidth = 2 / state.view.scale; ctx.strokeRect(0, 0, state.map.world.width, state.map.world.height); ctx.strokeStyle = "#4f7cff"; ctx.setLineDash([12 / state.view.scale, 8 / state.view.scale]); ctx.strokeRect(state.map.camera.x, state.map.camera.y, state.map.camera.width, state.map.camera.height); ctx.setLineDash([]); } function drawGrid(ctx) { const step = Math.max(1, state.gridSize); ctx.strokeStyle = "rgba(255,255,255,0.05)"; ctx.lineWidth = 1 / state.view.scale; for (let x = 0; x <= state.map.world.width; x += step) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, state.map.world.height); ctx.stroke(); } for (let y = 0; y <= state.map.world.height; y += step) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(state.map.world.width, y); ctx.stroke(); } } function drawLayers(ctx) { for (const layer of sortLayers(state.map.layers)) { for (const rect of layer.rects) drawFilledRect(ctx, rect.bounds, rect.color, 0.55, "#7d8794"); for (const tileRect of layer.tileRects) drawTileRect(ctx, tileRect); } } function drawTileRect(ctx, tileRect) { const tileset = state.map.tilesets.find((item) => item.id === tileRect.tileset); const tw = tileset ? tileset.tileWidth : 10; const th = tileset ? tileset.tileHeight : 10; const bounds = { x: tileRect.x, y: tileRect.y, width: tileRect.columns * tw, height: tileRect.rows * th }; drawFilledRect(ctx, bounds, tileRect.color, 0.75, "#d7e1ea"); ctx.strokeStyle = "rgba(255,255,255,0.22)"; ctx.lineWidth = 1 / state.view.scale; for (let x = bounds.x; x <= bounds.x + bounds.width; x += tw) line(ctx, x, bounds.y, x, bounds.y + bounds.height); for (let y = bounds.y; y <= bounds.y + bounds.height; y += th) line(ctx, bounds.x, y, bounds.x + bounds.width, y); } function drawCollisions(ctx) { for (const rect of state.map.collisions) drawStrokeRect(ctx, rect, "rgba(255,104,104,0.95)", "rgba(255,104,104,0.10)"); } function drawBattleZones(ctx) { for (const zone of state.map.battleZones) { drawStrokeRect(ctx, zone.trigger, "rgba(255,214,102,0.95)", "rgba(255,214,102,0.08)"); drawStrokeRect(ctx, zone.camera, "rgba(84,168,255,0.75)", "rgba(84,168,255,0.05)"); for (const spawn of zone.spawns) drawPoint(ctx, spawn.x, spawn.y, "#9ed06a"); } } function drawPlayerSpawn(ctx) { drawPoint(ctx, state.map.playerSpawn.x, state.map.playerSpawn.y, "#ffffff"); } function drawSelection(ctx) { const object = getSelectedObject(); if (!object) return; const bounds = object.bounds && object.bounds(); ctx.strokeStyle = "#54a8ff"; ctx.lineWidth = 3 / state.view.scale; if (bounds) ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height); } function drawFilledRect(ctx, rect, color, alpha, stroke) { ctx.fillStyle = rgba(color, alpha); ctx.fillRect(rect.x, rect.y, rect.width, rect.height); ctx.strokeStyle = stroke; ctx.lineWidth = 1 / state.view.scale; ctx.strokeRect(rect.x, rect.y, rect.width, rect.height); } function drawStrokeRect(ctx, rect, stroke, fill) { ctx.fillStyle = fill; ctx.fillRect(rect.x, rect.y, rect.width, rect.height); ctx.strokeStyle = stroke; ctx.lineWidth = 2 / state.view.scale; ctx.strokeRect(rect.x, rect.y, rect.width, rect.height); } function drawPoint(ctx, x, y, color) { const r = 8 / state.view.scale; ctx.fillStyle = color; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = "#0b0d10"; ctx.lineWidth = 2 / state.view.scale; ctx.stroke(); } function line(ctx, x1, y1, x2, y2) { ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); } function collectObjects() { const objects = [ objectForPoint("player", "Player Spawn", state.map.playerSpawn), objectForRect("camera", "Camera", state.map.camera), ]; state.map.tilesets.forEach((tileset, index) => objects.push(objectForTileset(`tileset:${index}`, `Tileset ${tileset.id}`, tileset, true))); state.map.collisions.forEach((rect, index) => objects.push(objectForRect(`collision:${index}`, `Collision ${index + 1}`, rect, true))); state.map.layers.forEach((layer, layerIndex) => { objects.push(objectForLayer(`layer:${layerIndex}`, `Layer ${layer.id}`, layer, true)); layer.rects.forEach((rect, rectIndex) => objects.push(objectForRect(`rect:${layerIndex}:${rectIndex}`, `${layer.id} Rect ${rectIndex + 1}`, rect.bounds, true))); layer.tileRects.forEach((tileRect, tileIndex) => objects.push(objectForTileRect(`tile:${layerIndex}:${tileIndex}`, `${layer.id} TileRect ${tileIndex + 1}`, tileRect, true))); }); state.map.battleZones.forEach((zone, zoneIndex) => { objects.push(objectForRect(`zone:${zoneIndex}`, `Battle Zone ${zoneIndex + 1}`, zone.trigger, true, zone.id)); objects.push(objectForRect(`zone_camera:${zoneIndex}`, `Zone Camera ${zoneIndex + 1}`, zone.camera, false, zone.id)); zone.spawns.forEach((spawn, spawnIndex) => objects.push(objectForPoint(`spawn:${zoneIndex}:${spawnIndex}`, `Spawn ${spawn.id}`, spawn, true))); }); return objects; } function objectForTileset(id, title, tileset, deletable) { return { key: id, title, meta: `${tileset.texture} | ${tileset.tileWidth}x${tileset.tileHeight}`, deletable, fields: () => [ textField("ID", () => tileset.id, (v) => { renameTileset(tileset.id, v || tileset.id); }), textField("Texture", () => tileset.texture, (v) => { tileset.texture = v || tileset.texture; }), numberField("Tile W", () => tileset.tileWidth, (v) => { tileset.tileWidth = Math.max(1, v); }), numberField("Tile H", () => tileset.tileHeight, (v) => { tileset.tileHeight = Math.max(1, v); }), ...colorFields(tileset.color, true), ], }; } function objectForLayer(id, title, layer, deletable) { return { key: id, title, meta: `${layer.role} | parallax ${layer.parallax}`, deletable, fields: () => [ textField("ID", () => layer.id, (v) => { layer.id = v || layer.id; }), textField("Role", () => layer.role, (v) => { layer.role = v; }, ROLE_ORDER), numberField("Parallax", () => layer.parallax, (v) => { layer.parallax = v; }, "0.05"), ], }; } function objectForRect(id, title, rect, deletable = false, meta = null) { return { key: id, title, meta: meta || `${fmt(rect.x)}, ${fmt(rect.y)}, ${fmt(rect.width)}x${fmt(rect.height)}`, deletable, bounds: () => rect, move(dx, dy) { rect.x += dx; rect.y += dy; }, fields: () => rectFields(rect), }; } function objectForPoint(id, title, point, deletable = false) { return { key: id, title, meta: `${fmt(point.x)}, ${fmt(point.y)}`, deletable, bounds: () => ({ x: point.x - 8, y: point.y - 8, width: 16, height: 16 }), move(dx, dy) { point.x += dx; point.y += dy; }, fields: () => pointFields(point), }; } function objectForTileRect(id, title, tileRect, deletable) { const tileset = state.map.tilesets.find((item) => item.id === tileRect.tileset); const tw = tileset ? tileset.tileWidth : 10; const th = tileset ? tileset.tileHeight : 10; return { key: id, title, meta: `${tileRect.tileset} ${tileRect.columns}x${tileRect.rows}`, deletable, bounds: () => ({ x: tileRect.x, y: tileRect.y, width: tileRect.columns * tw, height: tileRect.rows * th }), move(dx, dy) { tileRect.x += dx; tileRect.y += dy; }, fields: () => [ textField("Tileset", () => tileRect.tileset, (v) => { tileRect.tileset = v; }, state.map.tilesets.map((item) => item.id)), numberField("X", () => tileRect.x, (v) => { tileRect.x = v; }), numberField("Y", () => tileRect.y, (v) => { tileRect.y = v; }), numberField("Columns", () => tileRect.columns, (v) => { tileRect.columns = Math.max(1, Math.round(v)); }), numberField("Rows", () => tileRect.rows, (v) => { tileRect.rows = Math.max(1, Math.round(v)); }), numberField("Tile Index", () => tileRect.tileIndex, (v) => { tileRect.tileIndex = Math.max(0, Math.round(v)); }), ...colorFields(tileRect.color, false), ], }; } function getSelectedObject() { if (!state.selected) return null; return collectObjects().find((object) => sameSelection(object.key, state.selected)) || null; } function onCanvasMouseDown(event) { const world = screenToWorld(event.offsetX, event.offsetY); const object = hitTest(world.x, world.y); if (object) { state.selected = object.key; state.dragging = { last: snapPoint(world) }; refresh(); } else { state.dragging = { pan: true, x: event.clientX, y: event.clientY, viewX: state.view.x, viewY: state.view.y }; } } function onCanvasMouseMove(event) { if (!state.dragging) return; if (state.dragging.pan) { state.view.x = state.dragging.viewX - (event.clientX - state.dragging.x) / state.view.scale; state.view.y = state.dragging.viewY - (event.clientY - state.dragging.y) / state.view.scale; drawCanvas(); return; } const object = getSelectedObject(); if (!object || !object.move) return; const current = snapPoint(screenToWorld(event.offsetX, event.offsetY)); const dx = current.x - state.dragging.last.x; const dy = current.y - state.dragging.last.y; if (dx || dy) { object.move(dx, dy); state.dragging.last = current; refresh(); } } function onCanvasMouseUp() { state.dragging = null; } function onCanvasWheel(event) { event.preventDefault(); const before = screenToWorld(event.offsetX, event.offsetY); const factor = event.deltaY < 0 ? 1.12 : 0.88; state.view.scale = clamp(state.view.scale * factor, 0.08, 4); const after = screenToWorld(event.offsetX, event.offsetY); state.view.x += before.x - after.x; state.view.y += before.y - after.y; drawCanvas(); } function hitTest(x, y) { const objects = collectObjects().reverse(); for (const object of objects) { const bounds = object.bounds && object.bounds(); if (!bounds) continue; if (x >= bounds.x && x <= bounds.x + bounds.width && y >= bounds.y && y <= bounds.y + bounds.height) return object; } return null; } function addCollision() { const point = snapPoint(viewCenter()); state.map.collisions.push({ x: point.x, y: point.y, width: 160, height: 40 }); state.selected = `collision:${state.map.collisions.length - 1}`; refresh(); } function addTileset() { const index = state.map.tilesets.length + 1; state.map.tilesets.push({ id: `tileset_${index}`, texture: `assets/stage/stage_01/tiles/tileset_${index}.png`, tileWidth: 10, tileHeight: 10, color: { r: 0.62, g: 0.68, b: 0.73, a: 1 }, }); state.selected = `tileset:${state.map.tilesets.length - 1}`; refresh(); } function addLayer() { const index = state.map.layers.length + 1; state.map.layers.push({ id: `layer_${index}`, role: "PropsBack", parallax: 1, rects: [], tileRects: [], }); state.selected = `layer:${state.map.layers.length - 1}`; refresh(); } function addTileRect() { ensureLevelVisualLayer(); if (!state.map.tilesets.length) { state.map.tilesets.push({ id: "graybox_tile", texture: "assets/stage/stage_01/tiles/graybox_tile.png", tileWidth: 10, tileHeight: 10, color: { r: 0.62, g: 0.68, b: 0.73, a: 1 } }); } const layerIndex = state.map.layers.findIndex((layer) => layer.role === "LevelVisual"); const layer = state.map.layers[layerIndex]; const point = snapPoint(viewCenter()); layer.tileRects.push({ tileset: state.map.tilesets[0].id, x: point.x, y: point.y, columns: 8, rows: 4, tileIndex: 0, color: { r: 0.62, g: 0.68, b: 0.73 } }); state.selected = `tile:${layerIndex}:${layer.tileRects.length - 1}`; refresh(); } function addBattleZone() { const point = snapPoint(viewCenter()); state.map.battleZones.push({ id: `zone_${String(state.map.battleZones.length + 1).padStart(2, "0")}`, trigger: { x: point.x, y: point.y, width: 520, height: 320 }, camera: { x: Math.max(0, point.x - 520), y: 0, width: 1280, height: state.map.world.height }, spawns: [], }); state.selected = `zone:${state.map.battleZones.length - 1}`; refresh(); } function addSpawn() { if (!state.map.battleZones.length) addBattleZone(); const zoneIndex = Math.max(0, state.map.battleZones.length - 1); const zone = state.map.battleZones[zoneIndex]; const point = snapPoint(viewCenter()); zone.spawns.push({ id: `${zone.id}_enemy_${zone.spawns.length + 1}`, x: point.x, y: point.y }); state.selected = `spawn:${zoneIndex}:${zone.spawns.length - 1}`; refresh(); } function deleteSelected() { if (!state.selected) return; const parts = state.selected.split(":"); if (parts[0] === "collision") state.map.collisions.splice(Number(parts[1]), 1); if (parts[0] === "tileset") { const tileset = state.map.tilesets[Number(parts[1])]; if (tileset && isTilesetUsed(tileset.id)) { setStatus(`Cannot delete tileset ${tileset.id}: it is used by tile_rect.`); return; } state.map.tilesets.splice(Number(parts[1]), 1); } if (parts[0] === "layer") state.map.layers.splice(Number(parts[1]), 1); if (parts[0] === "rect") state.map.layers[Number(parts[1])].rects.splice(Number(parts[2]), 1); if (parts[0] === "tile") state.map.layers[Number(parts[1])].tileRects.splice(Number(parts[2]), 1); if (parts[0] === "zone") state.map.battleZones.splice(Number(parts[1]), 1); if (parts[0] === "spawn") state.map.battleZones[Number(parts[1])].spawns.splice(Number(parts[2]), 1); state.selected = null; refresh(); } function downloadMap() { const blob = new Blob([serializeMap(state.map)], { type: "text/plain" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `${state.map.id || "stage"}.map`; link.click(); URL.revokeObjectURL(url); } function fitWorldToCanvas() { const canvas = elements.mapCanvas; const scaleX = canvas.width / Math.max(1, state.map.world.width); const scaleY = canvas.height / Math.max(1, state.map.world.height); state.view.scale = Math.max(0.08, Math.min(scaleX, scaleY) * 0.92); state.view.x = -((canvas.width / state.view.scale - state.map.world.width) / 2); state.view.y = -((canvas.height / state.view.scale - state.map.world.height) / 2); } function resizeCanvas() { const canvas = elements.mapCanvas; const rect = canvas.getBoundingClientRect(); canvas.width = Math.max(1, Math.floor(rect.width * window.devicePixelRatio)); canvas.height = Math.max(1, Math.floor(rect.height * window.devicePixelRatio)); const ctx = canvas.getContext("2d"); ctx.setTransform(window.devicePixelRatio, 0, 0, window.devicePixelRatio, 0, 0); canvas.width = Math.max(1, Math.floor(rect.width)); canvas.height = Math.max(1, Math.floor(rect.height)); drawCanvas(); } function rectFields(rect) { return [ numberField("X", () => rect.x, (v) => { rect.x = v; }), numberField("Y", () => rect.y, (v) => { rect.y = v; }), numberField("W", () => rect.width, (v) => { rect.width = Math.max(1, v); }), numberField("H", () => rect.height, (v) => { rect.height = Math.max(1, v); }), ]; } function pointFields(point) { return [ numberField("X", () => point.x, (v) => { point.x = v; }), numberField("Y", () => point.y, (v) => { point.y = v; }), ]; } function colorFields(color, includeAlpha) { const fields = [ numberField("R", () => color.r, (v) => { color.r = clamp(v, 0, 1); }, "0.01"), numberField("G", () => color.g, (v) => { color.g = clamp(v, 0, 1); }, "0.01"), numberField("B", () => color.b, (v) => { color.b = clamp(v, 0, 1); }, "0.01"), ]; if (includeAlpha) fields.push(numberField("A", () => color.a, (v) => { color.a = clamp(v, 0, 1); }, "0.01")); return fields; } function numberField(label, get, set, step = "1") { return { label, type: "number", step, get, set }; } function textField(label, get, set, options = null) { return { label, type: "text", options, get, set }; } function ensureLevelVisualLayer() { if (!state.map.layers.some((layer) => layer.role === "LevelVisual")) { state.map.layers.push({ id: "level_visual", role: "LevelVisual", parallax: 1, rects: [], tileRects: [] }); } } function sortLayers(layers) { return [...layers].sort((a, b) => ROLE_ORDER.indexOf(a.role) - ROLE_ORDER.indexOf(b.role)); } function tokenize(line) { const tokens = []; for (const token of line.trim().split(/\s+/).filter(Boolean)) { if (token.startsWith("#")) break; tokens.push(token); } return tokens; } function requireLayer(layer, lineNumber) { if (!layer) throw new Error(`Line ${lineNumber}: command must be inside layer`); } function requireToken(tokens, index, lineNumber) { if (tokens.length <= index) throw new Error(`Line ${lineNumber}: missing token ${index}`); return tokens[index]; } function num(tokens, index, lineNumber) { const value = Number(requireToken(tokens, index, lineNumber)); if (!Number.isFinite(value)) throw new Error(`Line ${lineNumber}: invalid number ${tokens[index]}`); return value; } function intNum(tokens, index, lineNumber) { return Math.round(num(tokens, index, lineNumber)); } function rectFrom(tokens, start, lineNumber) { return { x: num(tokens, start, lineNumber), y: num(tokens, start + 1, lineNumber), width: num(tokens, start + 2, lineNumber), height: num(tokens, start + 3, lineNumber) }; } function colorFrom(tokens, start, lineNumber, includeAlpha) { return { r: num(tokens, start, lineNumber), g: num(tokens, start + 1, lineNumber), b: num(tokens, start + 2, lineNumber), a: includeAlpha ? num(tokens, start + 3, lineNumber) : 1 }; } function rectText(rect) { return `${fmt(rect.x)} ${fmt(rect.y)} ${fmt(rect.width)} ${fmt(rect.height)}`; } function colorText(color, includeAlpha) { return includeAlpha ? `${fmt(color.r)} ${fmt(color.g)} ${fmt(color.b)} ${fmt(color.a)}` : `${fmt(color.r)} ${fmt(color.g)} ${fmt(color.b)}`; } function fmt(value) { return Number(value).toFixed(3).replace(/\.?0+$/, ""); } function rgba(color, alphaMul) { const a = color.a === undefined ? 1 : color.a; return `rgba(${Math.round(color.r * 255)},${Math.round(color.g * 255)},${Math.round(color.b * 255)},${a * alphaMul})`; } function escapeHtml(value) { return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char])); } function sameSelection(a, b) { return a === b; } function numberValue(input, fallback) { const value = Number(input.value); return Number.isFinite(value) ? value : fallback; } function screenToWorld(x, y) { return { x: state.view.x + x / state.view.scale, y: state.view.y + y / state.view.scale }; } function viewCenter() { const canvas = elements.mapCanvas; return screenToWorld(canvas.width / 2, canvas.height / 2); } function snapPoint(point) { return { x: Math.round(point.x / state.gridSize) * state.gridSize, y: Math.round(point.y / state.gridSize) * state.gridSize }; } function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } function setStatus(text) { elements.statusText.textContent = text; } function renameTileset(oldId, newId) { const cleanId = String(newId).trim(); if (!cleanId) return; if (cleanId !== oldId && state.map.tilesets.some((item) => item.id === cleanId)) { setStatus(`Cannot rename tileset: ${cleanId} already exists.`); return; } const tileset = state.map.tilesets.find((item) => item.id === oldId); if (!tileset) return; tileset.id = cleanId; for (const layer of state.map.layers) { for (const tileRect of layer.tileRects) { if (tileRect.tileset === oldId) tileRect.tileset = cleanId; } } } function isTilesetUsed(id) { return state.map.layers.some((layer) => layer.tileRects.some((tileRect) => tileRect.tileset === id)); } function defaultMapText() { return `map stage\nworld 1280 720\ncamera 0 0 1280 720\nplayer_spawn 120 520\n\ncollision 0 600 1280 80\n\ntileset graybox assets/stage/stage_01/tiles/graybox.png 10 10 0.62 0.68 0.73 1\n\nlayer level_visual LevelVisual 1\ntile_rect graybox 0 600 128 8 0 0.62 0.68 0.73\nendlayer\n`; }