editor first pass
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
<link rel="stylesheet" href="/map/map.css">
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<div class="d-flex flex-wrap align-items-end gap-2 mb-2">
|
||||
<div>
|
||||
<label class="form-label mb-0">X</label>
|
||||
<input type="number" id="coordX" class="form-control form-control-sm" style="width:5rem" value="0">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label mb-0">Y</label>
|
||||
<input type="number" id="coordY" class="form-control form-control-sm" style="width:5rem" value="0">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label mb-0">Z</label>
|
||||
<input type="number" id="coordZ" class="form-control form-control-sm" style="width:5rem" value="0">
|
||||
</div>
|
||||
<button id="btnLoad" type="button" class="btn btn-sm btn-secondary">Load</button>
|
||||
|
||||
<div class="dropdown">
|
||||
<button
|
||||
class="btn btn-sm btn-outline-secondary dropdown-toggle"
|
||||
type="button" data-bs-toggle="dropdown"
|
||||
>Browse</button>
|
||||
<ul class="dropdown-menu" id="chunkBrowseList"></ul>
|
||||
</div>
|
||||
|
||||
<div class="dpad" title="Navigate to the neighboring chunk">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary dpad-n" id="navN" title="North (+Y)">N</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary dpad-w" id="navW" title="West (-X)">W</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary dpad-e" id="navE" title="East (+X)">E</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary dpad-s" id="navS" title="South (-Y)">S</button>
|
||||
</div>
|
||||
<div class="d-flex flex-column gap-1">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="navUp" title="Up (+Z)">Up</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="navDown" title="Down (-Z)">Down</button>
|
||||
</div>
|
||||
|
||||
<button id="btnSave" type="button" class="btn btn-sm btn-primary ms-auto">
|
||||
Save & Compile
|
||||
</button>
|
||||
</div>
|
||||
<div id="statusLine" class="small text-muted mb-2"> </div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<label class="form-label mb-0">Z-Level</label>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="zLevelDown">-</button>
|
||||
<input type="range" id="zLevel" class="form-range" style="width:150px" min="0" max="3" value="0">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="zLevelUp">+</button>
|
||||
<span id="zLevelLabel">0</span>
|
||||
</div>
|
||||
<div class="d-flex gap-3">
|
||||
<div id="tilePalette" class="tile-palette"></div>
|
||||
<canvas id="gridCanvas" class="border tile-grid" width="512" height="512"></canvas>
|
||||
</div>
|
||||
|
||||
<h5 class="mt-3">Meshes</h5>
|
||||
<ul id="meshList" class="list-group mb-2"></ul>
|
||||
<div class="d-flex gap-2 align-items-end flex-wrap">
|
||||
<div style="min-width:220px">
|
||||
<label class="form-label mb-0">Model</label>
|
||||
<select id="meshPicker" class="form-select form-select-sm"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label mb-0">X</label>
|
||||
<input id="meshX" type="number" class="form-control form-control-sm" style="width:5rem" value="0">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label mb-0">Y</label>
|
||||
<input id="meshY" type="number" class="form-control form-control-sm" style="width:5rem" value="0">
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label mb-0">Z</label>
|
||||
<input id="meshZ" type="number" class="form-control form-control-sm" style="width:5rem" value="0">
|
||||
</div>
|
||||
<button id="btnAddMesh" type="button" class="btn btn-sm btn-outline-primary">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-6">
|
||||
<p class="small text-muted mb-2">Preview (scroll to zoom)</p>
|
||||
<canvas id="previewCanvas" class="border" width="512" height="512"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/common/dmf.js"></script>
|
||||
<script src="/common/chunkterrain.js"></script>
|
||||
<script src="/map/renderer.js"></script>
|
||||
<script src="/map/map.js"></script>
|
||||
@@ -0,0 +1,65 @@
|
||||
/* Copyright (c) 2026 Dominic Masters
|
||||
*
|
||||
* This software is released under the MIT License.
|
||||
* https://opensource.org/licenses/MIT
|
||||
*/
|
||||
|
||||
.tile-palette {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
max-height: 512px;
|
||||
}
|
||||
|
||||
.tile-swatch {
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tile-swatch.active {
|
||||
border-color: #fff;
|
||||
}
|
||||
|
||||
.tile-swatch-icon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dpad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 2rem);
|
||||
grid-template-rows: repeat(3, 2rem);
|
||||
grid-template-areas:
|
||||
". n ."
|
||||
"w . e"
|
||||
". s .";
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.dpad-n { grid-area: n; }
|
||||
.dpad-w { grid-area: w; }
|
||||
.dpad-e { grid-area: e; }
|
||||
.dpad-s { grid-area: s; }
|
||||
.dpad button { padding: 0; }
|
||||
|
||||
.tile-grid {
|
||||
image-rendering: pixelated;
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#previewCanvas {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
height: auto;
|
||||
}
|
||||
@@ -0,0 +1,701 @@
|
||||
// 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" },
|
||||
];
|
||||
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 },
|
||||
};
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
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 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 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();
|
||||
}
|
||||
|
||||
function rebuildTerrainMesh() {
|
||||
const floats = ChunkTerrain.buildTerrainVerts(tiles);
|
||||
terrainMesh = floats.length ? mapRenderer.createMesh(floats) : null;
|
||||
}
|
||||
|
||||
async function renderPreview() {
|
||||
if(!mapRenderer) return;
|
||||
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) => {
|
||||
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));
|
||||
|
||||
document.getElementById("btnSave").addEventListener("click", () => {
|
||||
saveChunk().catch((e) => updateStatus(`Save failed: ${e.message}`, true));
|
||||
});
|
||||
|
||||
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("keydown", (e) => {
|
||||
if((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
|
||||
e.preventDefault();
|
||||
undo();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
buildPalette();
|
||||
mapRenderer = MapRenderer.create(document.getElementById("previewCanvas"));
|
||||
bindEvents();
|
||||
await Promise.all([buildChunkBrowseList(), buildMeshPicker()]);
|
||||
await loadChunk(coord.x, coord.y, coord.z);
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,293 @@
|
||||
// Copyright (c) 2026 Dominic Masters
|
||||
//
|
||||
// This software is released under the MIT License.
|
||||
// https://opensource.org/licenses/MIT
|
||||
|
||||
"use strict";
|
||||
|
||||
// MapRenderer - a minimal, dependency-free WebGL renderer for the chunk
|
||||
// preview pane. The camera formula is ported 1:1 from
|
||||
// src/dusk/scene/overworld/sceneoverworld.c (lines 62-81) so the preview
|
||||
// approximates the game's own fixed-angle 3rd-person camera: the eye sits
|
||||
// worldH units "behind" the target along Y and a proportional distance
|
||||
// "above" along Z, always looking at the target, up = (0, 1, 0).
|
||||
//
|
||||
// The material model mirrors SHADER_UNLIT: a texture sampler multiplied by
|
||||
// a flat RGBA tint, with a 1x1 white fallback texture standing in for
|
||||
// untextured models.
|
||||
|
||||
const MapRenderer = (() => {
|
||||
const VERT_SRC = `
|
||||
attribute vec2 aUV;
|
||||
attribute vec3 aPos;
|
||||
uniform mat4 uModel;
|
||||
uniform mat4 uView;
|
||||
uniform mat4 uProjection;
|
||||
varying vec2 vUV;
|
||||
void main() {
|
||||
vUV = aUV;
|
||||
gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const FRAG_SRC = `
|
||||
precision mediump float;
|
||||
varying vec2 vUV;
|
||||
uniform sampler2D uTexture;
|
||||
uniform vec4 uColor;
|
||||
void main() {
|
||||
gl_FragColor = texture2D(uTexture, vUV) * uColor;
|
||||
}
|
||||
`;
|
||||
|
||||
function mat4Identity(out) {
|
||||
out.fill(0);
|
||||
out[0] = out[5] = out[10] = out[15] = 1;
|
||||
return out;
|
||||
}
|
||||
|
||||
function mat4Translation(out, v) {
|
||||
mat4Identity(out);
|
||||
out[12] = v[0];
|
||||
out[13] = v[1];
|
||||
out[14] = v[2];
|
||||
return out;
|
||||
}
|
||||
|
||||
function mat4Perspective(out, fovy, aspect, near, far) {
|
||||
const f = 1.0 / Math.tan(fovy / 2);
|
||||
out.fill(0);
|
||||
out[0] = f / aspect;
|
||||
out[5] = f;
|
||||
out[10] = (far + near) / (near - far);
|
||||
out[11] = -1;
|
||||
out[14] = (2 * far * near) / (near - far);
|
||||
return out;
|
||||
}
|
||||
|
||||
function vec3Sub(a, b) {
|
||||
return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
}
|
||||
|
||||
function vec3Cross(a, b) {
|
||||
return [
|
||||
a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0],
|
||||
];
|
||||
}
|
||||
|
||||
function vec3Normalize(a) {
|
||||
const len = Math.hypot(a[0], a[1], a[2]) || 1;
|
||||
return [a[0] / len, a[1] / len, a[2] / len];
|
||||
}
|
||||
|
||||
function mat4LookAt(out, eye, center, up) {
|
||||
const zAxis = vec3Normalize(vec3Sub(eye, center));
|
||||
const xAxis = vec3Normalize(vec3Cross(up, zAxis));
|
||||
const yAxis = vec3Cross(zAxis, xAxis);
|
||||
|
||||
out[0] = xAxis[0]; out[1] = yAxis[0]; out[2] = zAxis[0]; out[3] = 0;
|
||||
out[4] = xAxis[1]; out[5] = yAxis[1]; out[6] = zAxis[1]; out[7] = 0;
|
||||
out[8] = xAxis[2]; out[9] = yAxis[2]; out[10] = zAxis[2]; out[11] = 0;
|
||||
out[12] = -(xAxis[0] * eye[0] + xAxis[1] * eye[1] + xAxis[2] * eye[2]);
|
||||
out[13] = -(yAxis[0] * eye[0] + yAxis[1] * eye[1] + yAxis[2] * eye[2]);
|
||||
out[14] = -(zAxis[0] * eye[0] + zAxis[1] * eye[1] + zAxis[2] * eye[2]);
|
||||
out[15] = 1;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Ported from sceneoverworld.c:62-81. Returns { eye, view }.
|
||||
function computeCamera(target, worldH) {
|
||||
const fov = Math.PI / 4; // glm_rad(45.0f)
|
||||
const zDist = (worldH * 0.5) / Math.tan(fov * 0.5);
|
||||
const offset = -worldH;
|
||||
|
||||
const eye = [target[0], target[1] + offset, target[2] + zDist];
|
||||
const view = mat4LookAt(new Float32Array(16), eye, target, [0, 1, 0]);
|
||||
return { eye, view, fov };
|
||||
}
|
||||
|
||||
function compileShader(gl, type, src) {
|
||||
const shader = gl.createShader(type);
|
||||
gl.shaderSource(shader, src);
|
||||
gl.compileShader(shader);
|
||||
if(!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||
const info = gl.getShaderInfoLog(shader);
|
||||
gl.deleteShader(shader);
|
||||
throw new Error(`Shader compile failed: ${info}`);
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
function create(canvas) {
|
||||
const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
|
||||
if(!gl) throw new Error("WebGL is not available in this browser");
|
||||
|
||||
const program = gl.createProgram();
|
||||
gl.attachShader(program, compileShader(gl, gl.VERTEX_SHADER, VERT_SRC));
|
||||
gl.attachShader(program, compileShader(gl, gl.FRAGMENT_SHADER, FRAG_SRC));
|
||||
gl.linkProgram(program);
|
||||
if(!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||
throw new Error(`Program link failed: ${gl.getProgramInfoLog(program)}`);
|
||||
}
|
||||
|
||||
const attribs = {
|
||||
aUV: gl.getAttribLocation(program, "aUV"),
|
||||
aPos: gl.getAttribLocation(program, "aPos"),
|
||||
};
|
||||
const uniforms = {
|
||||
uModel: gl.getUniformLocation(program, "uModel"),
|
||||
uView: gl.getUniformLocation(program, "uView"),
|
||||
uProjection: gl.getUniformLocation(program, "uProjection"),
|
||||
uTexture: gl.getUniformLocation(program, "uTexture"),
|
||||
uColor: gl.getUniformLocation(program, "uColor"),
|
||||
};
|
||||
|
||||
const whiteTexture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, whiteTexture);
|
||||
gl.texImage2D(
|
||||
gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE,
|
||||
new Uint8Array([255, 255, 255, 255]),
|
||||
);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
|
||||
gl.enable(gl.DEPTH_TEST);
|
||||
gl.enable(gl.BLEND);
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||
// Terrain/mesh winding isn't guaranteed consistent relative to this
|
||||
// preview's fixed camera angle, so backface culling stays off - this
|
||||
// is an authoring preview, not a performance-sensitive renderer, and
|
||||
// an invisible mesh is a worse failure mode than a visible backface.
|
||||
|
||||
// Uploads interleaved [u, v, x, y, z] float data (DMF/terrain layout)
|
||||
// into a GL buffer. Returns { buffer, vertexCount }.
|
||||
function createMesh(floats) {
|
||||
const buffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, floats, gl.STATIC_DRAW);
|
||||
return { buffer, vertexCount: floats.length / 5 };
|
||||
}
|
||||
|
||||
function createTextureFromImage(image) {
|
||||
const texture = gl.createTexture();
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture);
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
|
||||
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||
return texture;
|
||||
}
|
||||
|
||||
function drawMesh(mesh, model, texture, color, depthWrite, mode) {
|
||||
gl.depthMask(depthWrite !== false);
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, mesh.buffer);
|
||||
gl.enableVertexAttribArray(attribs.aUV);
|
||||
gl.vertexAttribPointer(attribs.aUV, 2, gl.FLOAT, false, 20, 0);
|
||||
gl.enableVertexAttribArray(attribs.aPos);
|
||||
gl.vertexAttribPointer(attribs.aPos, 3, gl.FLOAT, false, 20, 8);
|
||||
|
||||
gl.uniformMatrix4fv(uniforms.uModel, false, model);
|
||||
gl.uniform4f(uniforms.uColor, color[0], color[1], color[2], color[3]);
|
||||
|
||||
gl.activeTexture(gl.TEXTURE0);
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture || whiteTexture);
|
||||
gl.uniform1i(uniforms.uTexture, 0);
|
||||
|
||||
gl.drawArrays(mode || gl.TRIANGLES, 0, mesh.vertexCount);
|
||||
}
|
||||
|
||||
// Builds a 4-vertex outline of the tile at (x, y, z), lifted slightly
|
||||
// to avoid z-fighting with the terrain surface beneath it.
|
||||
function buildHighlightMesh(x, y, z) {
|
||||
const eps = 0.02;
|
||||
return createMesh(new Float32Array([
|
||||
0, 0, x, y, z + eps,
|
||||
0, 0, x + 1, y, z + eps,
|
||||
0, 0, x + 1, y + 1, z + eps,
|
||||
0, 0, x, y + 1, z + eps,
|
||||
]));
|
||||
}
|
||||
|
||||
// Neighboring chunks are drawn dimmer and semi-transparent, so they
|
||||
// read as context rather than part of the chunk being edited.
|
||||
const NEIGHBOR_ALPHA = 0.35;
|
||||
const NEIGHBOR_DARKEN = 0.5;
|
||||
|
||||
// scene = {
|
||||
// target: [x, y, z], worldH: number,
|
||||
// terrain: { mesh, texture } | null,
|
||||
// models: [{ mesh, texture, color: [r,g,b,a in 0..1], offset: [x,y,z] }],
|
||||
// neighbors: [{
|
||||
// offset: [x,y,z],
|
||||
// terrain: { mesh, texture } | null,
|
||||
// models: [{ mesh, texture, color, offset }],
|
||||
// }],
|
||||
// highlight: { x, y, z } | null,
|
||||
// }
|
||||
function render(scene) {
|
||||
const width = canvas.width, height = canvas.height;
|
||||
gl.viewport(0, 0, width, height);
|
||||
gl.clearColor(0.16, 0.18, 0.22, 1);
|
||||
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
|
||||
gl.useProgram(program);
|
||||
gl.depthMask(true);
|
||||
|
||||
const { view, fov } = computeCamera(scene.target, scene.worldH);
|
||||
const projection = mat4Perspective(new Float32Array(16), fov, width / height, 0.1, 1000);
|
||||
gl.uniformMatrix4fv(uniforms.uProjection, false, projection);
|
||||
gl.uniformMatrix4fv(uniforms.uView, false, view);
|
||||
|
||||
const identity = mat4Identity(new Float32Array(16));
|
||||
|
||||
if(scene.terrain) {
|
||||
drawMesh(scene.terrain.mesh, identity, scene.terrain.texture, [1, 1, 1, 1], true);
|
||||
}
|
||||
|
||||
for(const model of scene.models) {
|
||||
const modelMatrix = mat4Translation(new Float32Array(16), model.offset);
|
||||
drawMesh(model.mesh, modelMatrix, model.texture, model.color, true);
|
||||
}
|
||||
|
||||
for(const neighbor of scene.neighbors || []) {
|
||||
if(neighbor.terrain) {
|
||||
const modelMatrix = mat4Translation(new Float32Array(16), neighbor.offset);
|
||||
drawMesh(
|
||||
neighbor.terrain.mesh, modelMatrix, neighbor.terrain.texture,
|
||||
[NEIGHBOR_DARKEN, NEIGHBOR_DARKEN, NEIGHBOR_DARKEN, NEIGHBOR_ALPHA], false,
|
||||
);
|
||||
}
|
||||
|
||||
for(const model of neighbor.models) {
|
||||
const worldOffset = [
|
||||
neighbor.offset[0] + model.offset[0],
|
||||
neighbor.offset[1] + model.offset[1],
|
||||
neighbor.offset[2] + model.offset[2],
|
||||
];
|
||||
const modelMatrix = mat4Translation(new Float32Array(16), worldOffset);
|
||||
const color = [
|
||||
model.color[0] * NEIGHBOR_DARKEN,
|
||||
model.color[1] * NEIGHBOR_DARKEN,
|
||||
model.color[2] * NEIGHBOR_DARKEN,
|
||||
model.color[3] * NEIGHBOR_ALPHA,
|
||||
];
|
||||
drawMesh(model.mesh, modelMatrix, model.texture, color, false);
|
||||
}
|
||||
}
|
||||
|
||||
if(scene.highlight) {
|
||||
const { x, y, z } = scene.highlight;
|
||||
const highlightMesh = buildHighlightMesh(x, y, z);
|
||||
drawMesh(highlightMesh, identity, null, [1, 0.92, 0.23, 1], false, gl.LINE_LOOP);
|
||||
}
|
||||
}
|
||||
|
||||
return { gl, createMesh, createTextureFromImage, render };
|
||||
}
|
||||
|
||||
return Object.freeze({ create, computeCamera });
|
||||
})();
|
||||
Reference in New Issue
Block a user