// Copyright (c) 2026 Dominic Masters // // This software is released under the MIT License. // https://opensource.org/licenses/MIT "use strict"; (() => { const SHAPES = [ { type: ChunkTerrain.TILE_SHAPE_NULL, name: "Erase", color: "#20232a" }, { type: ChunkTerrain.TILE_SHAPE_GROUND, name: "Ground", color: "#4caf50" }, { type: ChunkTerrain.TILE_SHAPE_RAMP_NORTH, name: "Ramp North", color: "#2196f3" }, { type: ChunkTerrain.TILE_SHAPE_RAMP_EAST, name: "Ramp East", color: "#03a9f4" }, { type: ChunkTerrain.TILE_SHAPE_RAMP_SOUTH, name: "Ramp South", color: "#00bcd4" }, { type: ChunkTerrain.TILE_SHAPE_RAMP_WEST, name: "Ramp West", color: "#009688" }, { type: ChunkTerrain.TILE_SHAPE_RAMP_NORTHEAST, name: "Ramp NE", color: "#ff9800" }, { type: ChunkTerrain.TILE_SHAPE_RAMP_NORTHWEST, name: "Ramp NW", color: "#ff5722" }, { type: ChunkTerrain.TILE_SHAPE_RAMP_SOUTHEAST, name: "Ramp SE", color: "#9c27b0" }, { type: ChunkTerrain.TILE_SHAPE_RAMP_SOUTHWEST, name: "Ramp SW", color: "#e91e63" }, { type: ChunkTerrain.TILE_SHAPE_RAMP_NORTHEAST_INNER, name: "Ramp NE Inner", color: "#795548" }, { type: ChunkTerrain.TILE_SHAPE_RAMP_NORTHWEST_INNER, name: "Ramp NW Inner", color: "#607d8b" }, { type: ChunkTerrain.TILE_SHAPE_RAMP_SOUTHEAST_INNER, name: "Ramp SE Inner", color: "#8bc34a" }, { type: ChunkTerrain.TILE_SHAPE_RAMP_SOUTHWEST_INNER, name: "Ramp SW Inner", color: "#ffc107" }, ]; const SHAPE_COLORS = Object.fromEntries(SHAPES.map((s) => [s.type, s.color])); // Arrow direction per ramp, in canvas/screen space: north is up (dy < 0), // east is right (dx > 0) - matches renderGrid()'s north-is-up convention. const SHAPE_DIRECTIONS = { [ChunkTerrain.TILE_SHAPE_RAMP_NORTH]: { dx: 0, dy: -1 }, [ChunkTerrain.TILE_SHAPE_RAMP_SOUTH]: { dx: 0, dy: 1 }, [ChunkTerrain.TILE_SHAPE_RAMP_EAST]: { dx: 1, dy: 0 }, [ChunkTerrain.TILE_SHAPE_RAMP_WEST]: { dx: -1, dy: 0 }, [ChunkTerrain.TILE_SHAPE_RAMP_NORTHEAST]: { dx: 1, dy: -1 }, [ChunkTerrain.TILE_SHAPE_RAMP_NORTHWEST]: { dx: -1, dy: -1 }, [ChunkTerrain.TILE_SHAPE_RAMP_SOUTHEAST]: { dx: 1, dy: 1 }, [ChunkTerrain.TILE_SHAPE_RAMP_SOUTHWEST]: { dx: -1, dy: 1 }, [ChunkTerrain.TILE_SHAPE_RAMP_NORTHEAST_INNER]: { dx: 1, dy: -1 }, [ChunkTerrain.TILE_SHAPE_RAMP_NORTHWEST_INNER]: { dx: -1, dy: -1 }, [ChunkTerrain.TILE_SHAPE_RAMP_SOUTHEAST_INNER]: { dx: 1, dy: 1 }, [ChunkTerrain.TILE_SHAPE_RAMP_SOUTHWEST_INNER]: { dx: -1, dy: 1 }, }; // Draws an arrow centered at (cx, cy) pointing toward the ramp's high // side, so ramp direction is visible at a glance on both the palette // swatches and the tile grid. function drawArrow(ctx, cx, cy, size, dx, dy) { const mag = Math.hypot(dx, dy) || 1; const ux = dx / mag, uy = dy / mag; const len = size * 0.32; const tipX = cx + ux * len, tipY = cy + uy * len; const tailX = cx - ux * len, tailY = cy - uy * len; ctx.strokeStyle = "#ffffff"; ctx.fillStyle = "#ffffff"; ctx.lineWidth = Math.max(1, size * 0.08); ctx.lineCap = "round"; ctx.beginPath(); ctx.moveTo(tailX, tailY); ctx.lineTo(tipX, tipY); ctx.stroke(); const headLen = size * 0.18; const angle = Math.atan2(uy, ux); const leftAngle = angle + Math.PI * 0.8; const rightAngle = angle - Math.PI * 0.8; ctx.beginPath(); ctx.moveTo(tipX, tipY); ctx.lineTo(tipX + Math.cos(leftAngle) * headLen, tipY + Math.sin(leftAngle) * headLen); ctx.lineTo(tipX + Math.cos(rightAngle) * headLen, tipY + Math.sin(rightAngle) * headLen); ctx.closePath(); ctx.fill(); } // Draws a small pencil icon centered in a `size`x`size` canvas. function drawPencilIcon(ctx, size) { ctx.save(); ctx.translate(size / 2, size / 2); ctx.rotate(-Math.PI / 4); const shaftW = size * 0.18; const shaftH = size * 0.62; ctx.fillStyle = "#e0e0e0"; ctx.fillRect(-shaftW / 2, shaftH * 0.28, shaftW, shaftH * 0.22); ctx.fillStyle = "#f0c419"; ctx.fillRect(-shaftW / 2, -shaftH * 0.5, shaftW, shaftH * 0.78); ctx.fillStyle = "#8d6e63"; ctx.beginPath(); ctx.moveTo(-shaftW / 2, -shaftH * 0.5); ctx.lineTo(shaftW / 2, -shaftH * 0.5); ctx.lineTo(0, -shaftH * 0.5 - shaftW); ctx.closePath(); ctx.fill(); ctx.restore(); } // Draws a small paint bucket icon centered in a `size`x`size` canvas. function drawBucketIcon(ctx, size) { ctx.save(); ctx.translate(size / 2, size / 2); const w = size * 0.5, h = size * 0.34; ctx.strokeStyle = "#e0e0e0"; ctx.lineWidth = Math.max(1, size * 0.06); ctx.beginPath(); ctx.arc(0, -h * 0.4, w * 0.4, Math.PI, 0); ctx.stroke(); ctx.fillStyle = "#03a9f4"; ctx.beginPath(); ctx.moveTo(-w / 2, -h * 0.2); ctx.lineTo(w / 2, -h * 0.2); ctx.lineTo(w * 0.32, h * 0.6); ctx.lineTo(-w * 0.32, h * 0.6); ctx.closePath(); ctx.fill(); ctx.beginPath(); ctx.arc(-w * 0.55, h * 0.75, size * 0.06, 0, Math.PI * 2); ctx.fill(); ctx.restore(); } const TOOLS = [ { id: "pencil", name: "Pencil", draw: drawPencilIcon }, { id: "bucket", name: "Paint Bucket", draw: drawBucketIcon }, ]; let coord = { x: 0, y: 0, z: 0 }; let tiles = new Int32Array(ChunkTerrain.TILE_COUNT); let meshes = []; let chunkExists = false; let currentZLevel = 0; let selectedShape = ChunkTerrain.TILE_SHAPE_GROUND; let zoomWorldH = 20; let dirty = false; let hoverTile = null; let activeTool = "pencil"; let mapRenderer = null; let terrainMesh = null; let terrainTexturePromise = null; let modelIndex = null; let neighborChunks = []; const modelCache = new Map(); const UNDO_LIMIT = 100; let undoStack = []; function markDirty() { dirty = true; } function snapshotState() { return { tiles: tiles.slice(), meshes: meshes.map((m) => ({ file: m.file, pos: m.pos.slice() })), }; } function pushUndo() { undoStack.push(snapshotState()); if(undoStack.length > UNDO_LIMIT) undoStack.shift(); } function undo() { if(!undoStack.length) return; const snap = undoStack.pop(); tiles = snap.tiles; meshes = snap.meshes; dirty = true; renderGrid(); renderMeshList(); renderPreview(); updateStatus("Undid last change."); } // Returns true if it's OK to proceed (no unsaved changes, or the user // confirmed discarding them). function confirmDiscardIfDirty() { if(!dirty) return true; return window.confirm("You have unsaved changes to this chunk. Discard them?"); } function statusLine() { return document.getElementById("statusLine"); } function updateStatus(msg, isError) { const el = statusLine(); el.textContent = msg; el.classList.toggle("text-danger", !!isError); el.classList.toggle("text-muted", !isError); } async function apiReadJson(path) { const res = await fetch(`/api/read?path=${encodeURIComponent(path)}`); const data = await res.json(); return { ok: res.ok, data }; } async function apiWrite(path, content) { return fetch(`/api/write?path=${encodeURIComponent(path)}`, { method: "POST", body: JSON.stringify({ content }), }); } async function apiDelete(path) { return fetch(`/api/delete?path=${encodeURIComponent(path)}`, { method: "POST" }); } async function apiCompileChunk(x, y, z) { const res = await fetch(`/api/compile-chunk?x=${x}&y=${y}&z=${z}`, { method: "POST" }); return res.json(); } async function apiLs(path) { const res = await fetch(`/api/ls?path=${encodeURIComponent(path)}`); return res.json(); } async function apiFind(path, ext) { const res = await fetch(`/api/find?path=${encodeURIComponent(path)}&ext=${encodeURIComponent(ext)}`); return res.json(); } function base64ToBuffer(base64) { const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); return bytes.buffer; } async function loadImageTexture(assetPath) { const { ok, data } = await apiReadJson(assetPath); if(!ok || data.error) return null; const blob = new Blob([base64ToBuffer(data.content)], { type: "image/png" }); const bitmap = await createImageBitmap(blob); return mapRenderer.createTextureFromImage(bitmap); } async function readDmfVertices(assetPath) { const { ok, data } = await apiReadJson(assetPath); if(!ok || data.error) throw new Error(`Failed to read ${assetPath}`); return DMF.decode(base64ToBuffer(data.content)).floats; } async function ensureModelIndex() { if(modelIndex) return modelIndex; const res = await apiFind("assets/models", ".json"); modelIndex = new Map(); for(const file of res.files || []) { const base = file.split("/").pop().replace(/\.json$/, ""); modelIndex.set(base, file); } return modelIndex; } // Resolves a chunk mesh entry's bare filename (e.g. "house_5_3.dmf") to // its model JSON by basename, mirroring find_model() in // tools/asset/chunk/__main__.py - the chunk JSON only ever stores a bare // filename, not a path. async function resolveModelForMeshFile(file) { await ensureModelIndex(); const base = file.replace(/\.[^.]+$/, ""); const rel = modelIndex.get(base); if(!rel) return null; const modelPath = `assets/models/${rel}`; if(modelCache.has(modelPath)) return modelCache.get(modelPath); const promise = (async () => { const { ok, data } = await apiReadJson(modelPath); if(!ok || data.error) throw new Error(`Failed to read model ${modelPath}`); const modelDef = JSON.parse(data.content); const meshFloats = await readDmfVertices(`assets/${modelDef.mesh}`); const mesh = mapRenderer.createMesh(meshFloats); const texture = modelDef.texture ? await loadImageTexture(`assets/${modelDef.texture}`) : null; const colorBytes = modelDef.color || [255, 255, 255, 255]; const color = colorBytes.map((c) => c / 255); return { mesh, texture, color }; })(); modelCache.set(modelPath, promise); return promise; } function chunkPaths(x, y, z) { return { raw: `assetsraw/chunks/chunk_${x}_${y}_${z}.json`, dcf: `assets/chunks/${x}_${y}_${z}.dcf`, terrainDmf: `assets/meshes/chunks/chunk_${x}_${y}_${z}_0.dmf`, terrainModel: `assets/models/chunks/chunk_${x}_${y}_${z}_0.json`, }; } // Loads the 8 same-Z neighbors around `coord` (read-only, for context in // the 3D preview) and pre-builds their render data so renderPreview() // doesn't have to rebuild GL buffers on every paint stroke. async function loadNeighborChunks() { const offsets = []; for(let dy = -1; dy <= 1; dy++) { for(let dx = -1; dx <= 1; dx++) { if(dx || dy) offsets.push({ dx, dy }); } } const results = await Promise.all(offsets.map(async ({ dx, dy }) => { const nx = coord.x + dx, ny = coord.y + dy, nz = coord.z; const { ok, data } = await apiReadJson(chunkPaths(nx, ny, nz).raw); if(!ok || data.error) return null; const json = JSON.parse(data.content); const neighborTiles = new Int32Array(ChunkTerrain.TILE_COUNT); for(const t of json.tiles || []) { const [tx, ty, tz] = t.pos; neighborTiles[ChunkTerrain.tileIndex(tx, ty, tz)] = t.type; } const neighborMeshes = (json.meshes || []).map((m) => ({ file: m.file, pos: m.pos || [0, 0, 0] })); const floats = ChunkTerrain.buildTerrainVerts(neighborTiles); const terrain = floats.length ? { mesh: mapRenderer.createMesh(floats) } : null; const models = []; for(const m of neighborMeshes) { try { const resolved = await resolveModelForMeshFile(m.file); if(resolved) models.push({ ...resolved, offset: m.pos }); } catch(e) { console.warn("Failed to load neighbor mesh preview", m.file, e); } } return { offset: [dx * ChunkTerrain.CHUNK_WIDTH, dy * ChunkTerrain.CHUNK_HEIGHT, 0], terrain, models, }; })); neighborChunks = results.filter(Boolean); } async function loadChunk(x, y, z) { coord = { x, y, z }; tiles = new Int32Array(ChunkTerrain.TILE_COUNT); meshes = []; chunkExists = false; const { ok, data } = await apiReadJson(chunkPaths(x, y, z).raw); if(ok && !data.error) { chunkExists = true; const json = JSON.parse(data.content); for(const t of json.tiles || []) { const [tx, ty, tz] = t.pos; tiles[ChunkTerrain.tileIndex(tx, ty, tz)] = t.type; } meshes = (json.meshes || []).map((m) => ({ file: m.file, pos: m.pos || [0, 0, 0] })); } await loadNeighborChunks(); dirty = false; undoStack = []; renderGrid(); renderMeshList(); renderPreview(); updateStatus(chunkExists ? `Loaded chunk_${x}_${y}_${z}.json` : `New (empty) chunk at ${x}, ${y}, ${z} - paint a tile and save to create it.`); } async function saveChunk() { const tilesArr = []; for(let z = 0; z < ChunkTerrain.CHUNK_DEPTH; z++) { for(let y = 0; y < ChunkTerrain.CHUNK_HEIGHT; y++) { for(let x = 0; x < ChunkTerrain.CHUNK_WIDTH; x++) { const type = tiles[ChunkTerrain.tileIndex(x, y, z)]; if(type && type !== ChunkTerrain.TILE_SHAPE_NULL) { tilesArr.push({ pos: [x, y, z], type, tile: 0 }); } } } } const paths = chunkPaths(coord.x, coord.y, coord.z); if(tilesArr.length === 0 && meshes.length === 0) { await Promise.all(Object.values(paths).map((p) => apiDelete(p))); chunkExists = false; dirty = false; updateStatus(`Chunk ${coord.x}, ${coord.y}, ${coord.z} is empty - deleted.`); return; } const json = { tiles: tilesArr, meshes: meshes.map((m) => ({ file: m.file, pos: m.pos })) }; await apiWrite(paths.raw, JSON.stringify(json, null, 2)); chunkExists = true; dirty = false; updateStatus("Saved. Compiling..."); const result = await apiCompileChunk(coord.x, coord.y, coord.z); if(result.ok) { updateStatus(`Compiled OK.\n${result.stdout}`); } else { updateStatus(`Compile FAILED (code ${result.returncode}):\n${result.stderr}`, true); } } function buildPalette() { const el = document.getElementById("tilePalette"); el.innerHTML = ""; for(const s of SHAPES) { const btn = document.createElement("button"); btn.type = "button"; btn.className = "tile-swatch" + (s.type === selectedShape ? " active" : ""); btn.style.background = s.color; btn.title = s.name; btn.addEventListener("click", () => { selectedShape = s.type; buildPalette(); }); const dir = SHAPE_DIRECTIONS[s.type]; if(dir) { const icon = document.createElement("canvas"); icon.width = 32; icon.height = 32; icon.className = "tile-swatch-icon"; drawArrow(icon.getContext("2d"), 16, 16, 32, dir.dx, dir.dy); btn.appendChild(icon); } el.appendChild(btn); } } function buildToolPalette() { const el = document.getElementById("toolPalette"); el.innerHTML = ""; for(const tool of TOOLS) { const btn = document.createElement("button"); btn.type = "button"; btn.className = "tile-swatch" + (tool.id === activeTool ? " active" : ""); btn.style.background = "#2a2d33"; btn.title = tool.name; btn.addEventListener("click", () => { activeTool = tool.id; buildToolPalette(); }); const icon = document.createElement("canvas"); icon.width = 32; icon.height = 32; icon.className = "tile-swatch-icon"; tool.draw(icon.getContext("2d"), 32); btn.appendChild(icon); el.appendChild(btn); } } function canvasToTile(e, canvas) { const rect = canvas.getBoundingClientRect(); const cell = canvas.width / ChunkTerrain.CHUNK_WIDTH; const px = (e.clientX - rect.left) * (canvas.width / rect.width); const py = (e.clientY - rect.top) * (canvas.height / rect.height); const x = Math.floor(px / cell); const yFromTop = Math.floor(py / cell); const y = ChunkTerrain.CHUNK_HEIGHT - 1 - yFromTop; if(x < 0 || x >= ChunkTerrain.CHUNK_WIDTH || y < 0 || y >= ChunkTerrain.CHUNK_HEIGHT) return null; return { x, y }; } function renderGrid() { const canvas = document.getElementById("gridCanvas"); const ctx = canvas.getContext("2d"); const cell = canvas.width / ChunkTerrain.CHUNK_WIDTH; ctx.clearRect(0, 0, canvas.width, canvas.height); for(let y = 0; y < ChunkTerrain.CHUNK_HEIGHT; y++) { for(let x = 0; x < ChunkTerrain.CHUNK_WIDTH; x++) { const type = tiles[ChunkTerrain.tileIndex(x, y, currentZLevel)]; const sy = (ChunkTerrain.CHUNK_HEIGHT - 1 - y) * cell; ctx.fillStyle = SHAPE_COLORS[type] || "#15171b"; ctx.fillRect(x * cell, sy, cell, cell); ctx.strokeStyle = "rgba(255, 255, 255, 0.08)"; ctx.strokeRect(x * cell, sy, cell, cell); const dir = SHAPE_DIRECTIONS[type]; if(dir) drawArrow(ctx, x * cell + cell / 2, sy + cell / 2, cell, dir.dx, dir.dy); } } ctx.fillStyle = "#ffffff"; for(const m of meshes) { const [mx, my, mz] = m.pos; if(Math.round(mz) !== currentZLevel) continue; const sy = (ChunkTerrain.CHUNK_HEIGHT - 1 - my) * cell; ctx.beginPath(); ctx.arc(mx * cell + cell / 2, sy + cell / 2, cell * 0.3, 0, Math.PI * 2); ctx.fill(); } if(hoverTile) { const sy = (ChunkTerrain.CHUNK_HEIGHT - 1 - hoverTile.y) * cell; ctx.strokeStyle = "#ffeb3b"; ctx.lineWidth = 2; ctx.strokeRect(hoverTile.x * cell + 1, sy + 1, cell - 2, cell - 2); } } function hitTestMesh(e, canvas) { const rect = canvas.getBoundingClientRect(); const cell = canvas.width / ChunkTerrain.CHUNK_WIDTH; const px = (e.clientX - rect.left) * (canvas.width / rect.width); const py = (e.clientY - rect.top) * (canvas.height / rect.height); let closest = -1; let closestDist = cell * 0.5; meshes.forEach((m, i) => { const [mx, my, mz] = m.pos; if(Math.round(mz) !== currentZLevel) return; const cx = mx * cell + cell / 2; const cy = (ChunkTerrain.CHUNK_HEIGHT - 1 - my) * cell + cell / 2; const dist = Math.hypot(px - cx, py - cy); if(dist < closestDist) { closestDist = dist; closest = i; } }); return closest; } function moveMeshTo(e, index) { const canvas = document.getElementById("gridCanvas"); const t = canvasToTile(e, canvas); if(!t) return; meshes[index].pos[0] = t.x; meshes[index].pos[1] = t.y; markDirty(); renderGrid(); renderMeshList(); renderPreview(); } function paintAt(e) { const canvas = document.getElementById("gridCanvas"); const t = canvasToTile(e, canvas); if(!t) return; tiles[ChunkTerrain.tileIndex(t.x, t.y, currentZLevel)] = selectedShape; markDirty(); renderGrid(); renderPreview(); } // 4-connected flood fill of same-type tiles at the current Z-level, // starting from (startX, startY), replacing them with selectedShape. function floodFill(startX, startY) { const targetType = tiles[ChunkTerrain.tileIndex(startX, startY, currentZLevel)]; if(targetType === selectedShape) return; const stack = [[startX, startY]]; const visited = new Set(); while(stack.length) { const [x, y] = stack.pop(); const key = `${x},${y}`; if(visited.has(key)) continue; if(x < 0 || x >= ChunkTerrain.CHUNK_WIDTH || y < 0 || y >= ChunkTerrain.CHUNK_HEIGHT) continue; visited.add(key); const idx = ChunkTerrain.tileIndex(x, y, currentZLevel); if(tiles[idx] !== targetType) continue; tiles[idx] = selectedShape; stack.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]); } } function fillAt(e) { const canvas = document.getElementById("gridCanvas"); const t = canvasToTile(e, canvas); if(!t) return; pushUndo(); floodFill(t.x, t.y); markDirty(); renderGrid(); renderPreview(); } function rebuildTerrainMesh() { const floats = ChunkTerrain.buildTerrainVerts(tiles); terrainMesh = floats.length ? mapRenderer.createMesh(floats) : null; } // Keeps the preview canvas's backing-store resolution matched to its // actual displayed CSS size (times devicePixelRatio) - the canvas // otherwise stays at its fixed width/height attributes (512x512) while // CSS stretches it to fill the column, which upscales and blurs the // WebGL output on wider or high-DPI screens. function resizePreviewCanvas() { const canvas = document.getElementById("previewCanvas"); const dpr = window.devicePixelRatio || 1; const displayWidth = Math.max(1, Math.round(canvas.clientWidth * dpr)); const displayHeight = Math.max(1, Math.round(canvas.clientHeight * dpr)); if(canvas.width !== displayWidth || canvas.height !== displayHeight) { canvas.width = displayWidth; canvas.height = displayHeight; } } async function renderPreview() { if(!mapRenderer) return; resizePreviewCanvas(); rebuildTerrainMesh(); const models = []; for(const m of meshes) { try { const resolved = await resolveModelForMeshFile(m.file); if(resolved) models.push({ ...resolved, offset: m.pos }); } catch(e) { console.warn("Failed to load mesh preview", m.file, e); } } let terrainTexture = null; if(terrainMesh || neighborChunks.some((n) => n.terrain)) { if(!terrainTexturePromise) terrainTexturePromise = loadImageTexture("assets/tiles.png"); terrainTexture = await terrainTexturePromise; } const neighbors = neighborChunks.map((n) => ({ offset: n.offset, terrain: n.terrain ? { mesh: n.terrain.mesh, texture: terrainTexture } : null, models: n.models, })); mapRenderer.render({ target: [ChunkTerrain.CHUNK_WIDTH / 2, ChunkTerrain.CHUNK_HEIGHT / 2, currentZLevel], worldH: zoomWorldH, terrain: terrainMesh ? { mesh: terrainMesh, texture: terrainTexture } : null, models, neighbors, highlight: hoverTile ? { x: hoverTile.x, y: hoverTile.y, z: currentZLevel } : null, }); } function renderMeshList() { const ul = document.getElementById("meshList"); ul.innerHTML = ""; meshes.forEach((m, i) => { const li = document.createElement("li"); li.className = "list-group-item d-flex align-items-center gap-2 flex-wrap"; const label = document.createElement("span"); label.className = "me-auto"; label.textContent = m.file; li.appendChild(label); ["X", "Y", "Z"].forEach((axis, axisIndex) => { const input = document.createElement("input"); input.type = "number"; input.className = "form-control form-control-sm"; input.style.width = "5rem"; input.title = axis; input.value = m.pos[axisIndex]; input.addEventListener("change", () => { pushUndo(); m.pos[axisIndex] = parseFloat(input.value) || 0; markDirty(); renderGrid(); renderPreview(); }); li.appendChild(input); }); const btn = document.createElement("button"); btn.type = "button"; btn.className = "btn btn-sm btn-outline-danger"; btn.textContent = "Remove"; btn.addEventListener("click", () => { pushUndo(); meshes.splice(i, 1); markDirty(); renderMeshList(); renderGrid(); renderPreview(); }); li.appendChild(btn); ul.appendChild(li); }); } async function buildMeshPicker() { await ensureModelIndex(); const select = document.getElementById("meshPicker"); select.innerHTML = ""; for(const [base, rel] of [...modelIndex.entries()].sort()) { const opt = document.createElement("option"); opt.value = base; opt.textContent = rel; select.appendChild(opt); } } async function buildChunkBrowseList() { const list = document.getElementById("chunkBrowseList"); list.innerHTML = ""; const res = await apiLs("assetsraw/chunks"); for(const entry of res.entries || []) { const m = entry.name.match(/^chunk_(-?\d+)_(-?\d+)_(-?\d+)\.json$/); if(!m) continue; const [, x, y, z] = m; const li = document.createElement("li"); const a = document.createElement("a"); a.className = "dropdown-item"; a.href = "#"; a.textContent = `${x}, ${y}, ${z}`; a.addEventListener("click", (e) => { e.preventDefault(); goToChunk(parseInt(x, 10), parseInt(y, 10), parseInt(z, 10)); }); li.appendChild(a); list.appendChild(li); } } function setCoordInputs(x, y, z) { document.getElementById("coordX").value = x; document.getElementById("coordY").value = y; document.getElementById("coordZ").value = z; } function goToChunk(x, y, z) { if(!confirmDiscardIfDirty()) return; setCoordInputs(x, y, z); loadChunk(x, y, z); } function bindEvents() { const gridCanvas = document.getElementById("gridCanvas"); let painting = false; let draggingMeshIndex = -1; gridCanvas.addEventListener("mousedown", (e) => { if(activeTool === "bucket") { fillAt(e); return; } const hit = hitTestMesh(e, gridCanvas); pushUndo(); if(hit !== -1) { draggingMeshIndex = hit; return; } painting = true; paintAt(e); }); window.addEventListener("mouseup", () => { painting = false; draggingMeshIndex = -1; }); gridCanvas.addEventListener("mousemove", (e) => { hoverTile = canvasToTile(e, gridCanvas); if(draggingMeshIndex !== -1) { moveMeshTo(e, draggingMeshIndex); return; } if(painting) { paintAt(e); return; } renderGrid(); renderPreview(); }); gridCanvas.addEventListener("mouseleave", () => { hoverTile = null; renderGrid(); renderPreview(); }); function setZLevel(z) { currentZLevel = Math.max(0, Math.min(ChunkTerrain.CHUNK_DEPTH - 1, z)); document.getElementById("zLevel").value = currentZLevel; document.getElementById("zLevelLabel").textContent = currentZLevel; renderGrid(); renderPreview(); } document.getElementById("zLevel").addEventListener("input", (e) => { setZLevel(parseInt(e.target.value, 10)); }); document.getElementById("zLevelDown").addEventListener("click", () => setZLevel(currentZLevel - 1)); document.getElementById("zLevelUp").addEventListener("click", () => setZLevel(currentZLevel + 1)); document.getElementById("btnLoad").addEventListener("click", () => { const x = parseInt(document.getElementById("coordX").value, 10) || 0; const y = parseInt(document.getElementById("coordY").value, 10) || 0; const z = parseInt(document.getElementById("coordZ").value, 10) || 0; goToChunk(x, y, z); }); // NORTH=+Y, EAST=+X (matches tile-shape convention used by the tile // grid and tools/asset/chunk/__main__.py). document.getElementById("navN").addEventListener("click", () => goToChunk(coord.x, coord.y + 1, coord.z)); document.getElementById("navS").addEventListener("click", () => goToChunk(coord.x, coord.y - 1, coord.z)); document.getElementById("navE").addEventListener("click", () => goToChunk(coord.x + 1, coord.y, coord.z)); document.getElementById("navW").addEventListener("click", () => goToChunk(coord.x - 1, coord.y, coord.z)); document.getElementById("navUp").addEventListener("click", () => goToChunk(coord.x, coord.y, coord.z + 1)); document.getElementById("navDown").addEventListener("click", () => goToChunk(coord.x, coord.y, coord.z - 1)); function runSave() { saveChunk().catch((e) => updateStatus(`Save failed: ${e.message}`, true)); } document.getElementById("btnSave").addEventListener("click", runSave); document.getElementById("btnAddMesh").addEventListener("click", () => { const base = document.getElementById("meshPicker").value; if(!base) return; const pos = [ parseFloat(document.getElementById("meshX").value) || 0, parseFloat(document.getElementById("meshY").value) || 0, parseFloat(document.getElementById("meshZ").value) || 0, ]; pushUndo(); meshes.push({ file: `${base}.dmf`, pos }); markDirty(); renderMeshList(); renderGrid(); renderPreview(); }); document.getElementById("previewCanvas").addEventListener("wheel", (e) => { e.preventDefault(); zoomWorldH = Math.min(60, Math.max(4, zoomWorldH + Math.sign(e.deltaY))); renderPreview(); }, { passive: false }); window.addEventListener("resize", () => renderPreview()); window.addEventListener("keydown", (e) => { if(!(e.ctrlKey || e.metaKey)) return; const key = e.key.toLowerCase(); if(key === "z") { e.preventDefault(); undo(); } else if(key === "s") { e.preventDefault(); runSave(); } }); } document.addEventListener("DOMContentLoaded", async () => { buildPalette(); buildToolPalette(); mapRenderer = MapRenderer.create(document.getElementById("previewCanvas")); bindEvents(); await Promise.all([buildChunkBrowseList(), buildMeshPicker()]); await loadChunk(coord.x, coord.y, coord.z); }); })();