874c6258ab
Build Dusk / run-tests (push) Failing after 5m33s
Build Dusk / build-linux (push) Successful in 5m3s
Build Dusk / build-psp (push) Successful in 1m17s
Build Dusk / build-knulli (push) Successful in 4m45s
Build Dusk / build-gamecube (push) Successful in 3m49s
Build Dusk / build-gamecube-iso (push) Successful in 3m50s
Build Dusk / build-wii (push) Successful in 3m29s
Build Dusk / build-wii-iso (push) Successful in 4m4s
861 lines
29 KiB
JavaScript
861 lines
29 KiB
JavaScript
// 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);
|
|
});
|
|
})();
|