Add web map editor prototype
This commit is contained in:
@@ -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`;
|
||||
}
|
||||
Reference in New Issue
Block a user