From ed9a0ba73922bf8678a2e22d92808738a35cf4a1 Mon Sep 17 00:00:00 2001 From: Dobidop <67412288+Dobidop@users.noreply.github.com> Date: Sun, 22 Mar 2026 12:04:48 +0100 Subject: [PATCH 1/8] implement basic tectonics + tectonic direction and velocity --- .claude/settings.local.json | 19 + public/config/tectonic-templates.js | 70 + public/modules/dynamic/heightmap-selection.js | 24 +- public/modules/ui/options.js | 8 +- public/modules/ui/tectonic-editor.js | 443 ++++++ public/modules/ui/tools.js | 1 + src/index.html | 21 + src/modules/heightmap-generator.ts | 27 +- src/modules/tectonic-generator.ts | 1364 +++++++++++++++++ src/types/TectonicMetadata.ts | 60 + src/types/global.ts | 1 + 11 files changed, 2029 insertions(+), 9 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 public/config/tectonic-templates.js create mode 100644 public/modules/ui/tectonic-editor.js create mode 100644 src/modules/tectonic-generator.ts create mode 100644 src/types/TectonicMetadata.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..6a1992c3 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,19 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:github.com)", + "Bash(xargs grep:*)", + "WebFetch(domain:raw.githubusercontent.com)", + "WebFetch(domain:api.github.com)", + "WebFetch(domain:jandjheydorn.com)", + "Bash(npx tsc:*)", + "Bash(npx vite:*)", + "Bash(node_modules/.bin/tsc --noEmit --pretty)", + "Bash(npm exec:*)", + "Bash(npm install:*)", + "mcp__puppeteer__puppeteer_navigate", + "mcp__puppeteer__puppeteer_screenshot", + "mcp__puppeteer__puppeteer_evaluate" + ] + } +} diff --git a/public/config/tectonic-templates.js b/public/config/tectonic-templates.js new file mode 100644 index 00000000..549a1b57 --- /dev/null +++ b/public/config/tectonic-templates.js @@ -0,0 +1,70 @@ +"use strict"; + +const tectonicTemplates = (function () { + return { + tectonic: { + id: 14, + name: "Tectonic", + template: "tectonic", + probability: 10, + config: { + plateCount: 20, + continentalRatio: 0.2, + collisionIntensity: 1.5, + noiseLevel: 0.3, + hotspotCount: 3, + smoothingPasses: 3, + erosionPasses: 5, + seaLevel: 5 + } + }, + tectonicPangea: { + id: 15, + name: "Tectonic Pangea", + template: "tectonic", + probability: 5, + config: { + plateCount: 8, + continentalRatio: 0.55, + collisionIntensity: 1.2, + noiseLevel: 0.25, + hotspotCount: 3, + smoothingPasses: 4, + erosionPasses: 2, + seaLevel: -3 + } + }, + tectonicArchipelago: { + id: 16, + name: "Tectonic Archipelago", + template: "tectonic", + probability: 5, + config: { + plateCount: 15, + continentalRatio: 0.25, + collisionIntensity: 0.8, + noiseLevel: 0.35, + hotspotCount: 5, + smoothingPasses: 3, + erosionPasses: 2, + seaLevel: 3 + } + }, + tectonicRift: { + id: 17, + name: "Tectonic Rift", + template: "tectonic", + probability: 3, + config: { + plateCount: 10, + continentalRatio: 0.4, + collisionIntensity: 1.5, + noiseLevel: 0.3, + hotspotCount: 2, + smoothingPasses: 3, + erosionPasses: 3, + seaLevel: 0 + } + } + }; +})(); diff --git a/public/modules/dynamic/heightmap-selection.js b/public/modules/dynamic/heightmap-selection.js index 3c1fe962..02955910 100644 --- a/public/modules/dynamic/heightmap-selection.js +++ b/public/modules/dynamic/heightmap-selection.js @@ -194,11 +194,19 @@ function insertHtml() { const sections = document.getElementsByClassName("heightmap-selection_container"); - sections[0].innerHTML = Object.keys(heightmapTemplates) + const allTemplateKeys = Object.keys(heightmapTemplates); + if (typeof tectonicTemplates !== "undefined") { + allTemplateKeys.push(...Object.keys(tectonicTemplates)); + } + + sections[0].innerHTML = allTemplateKeys .map(key => { - const name = heightmapTemplates[key].name; + const isTectonic = typeof tectonicTemplates !== "undefined" && key in tectonicTemplates; + const name = isTectonic ? tectonicTemplates[key].name : heightmapTemplates[key].name; Math.random = aleaPRNG(initialSeed); - const heights = HeightmapGenerator.fromTemplate(graph, key); + const heights = isTectonic + ? HeightmapGenerator.fromTectonic(graph, tectonicTemplates[key].config) + : HeightmapGenerator.fromTemplate(graph, key); return /* html */ `
${name} @@ -255,6 +263,8 @@ function getSeed() { } function getName(id) { + const isTectonic = typeof tectonicTemplates !== "undefined" && id in tectonicTemplates; + if (isTectonic) return tectonicTemplates[id].name; const isTemplate = id in heightmapTemplates; return isTemplate ? heightmapTemplates[id].name : precreatedHeightmaps[id].name; } @@ -266,7 +276,10 @@ function getGraph(currentGraph) { } function drawTemplatePreview(id) { - const heights = HeightmapGenerator.fromTemplate(graph, id); + const isTectonic = typeof tectonicTemplates !== "undefined" && id in tectonicTemplates; + const heights = isTectonic + ? HeightmapGenerator.fromTectonic(graph, tectonicTemplates[id].config) + : HeightmapGenerator.fromTemplate(graph, id); const dataUrl = getHeightmapPreview(heights); const article = byId("heightmapSelection").querySelector(`[data-id="${id}"]`); article.querySelector("img").src = dataUrl; @@ -294,8 +307,9 @@ function redrawAll() { const {id, seed} = article.dataset; Math.random = aleaPRNG(seed); + const isTectonic = typeof tectonicTemplates !== "undefined" && id in tectonicTemplates; const isTemplate = id in heightmapTemplates; - if (isTemplate) drawTemplatePreview(id); + if (isTectonic || isTemplate) drawTemplatePreview(id); else drawPrecreatedHeightmap(id); } } diff --git a/public/modules/ui/options.js b/public/modules/ui/options.js index 07d77cb0..17757c90 100644 --- a/public/modules/ui/options.js +++ b/public/modules/ui/options.js @@ -624,8 +624,14 @@ function randomizeHeightmapTemplate() { for (const key in heightmapTemplates) { templates[key] = heightmapTemplates[key].probability || 0; } + if (typeof tectonicTemplates !== "undefined") { + for (const key in tectonicTemplates) { + templates[key] = tectonicTemplates[key].probability || 0; + } + } const template = rw(templates); - const name = heightmapTemplates[template].name; + const isTectonic = typeof tectonicTemplates !== "undefined" && template in tectonicTemplates; + const name = isTectonic ? tectonicTemplates[template].name : heightmapTemplates[template].name; applyOption(byId("templateInput"), template, name); } diff --git a/public/modules/ui/tectonic-editor.js b/public/modules/ui/tectonic-editor.js new file mode 100644 index 00000000..fe60402e --- /dev/null +++ b/public/modules/ui/tectonic-editor.js @@ -0,0 +1,443 @@ +"use strict"; + +// Tectonic Plate Editor +// Visualizes tectonic plates and allows editing plate properties (type, velocity) +// then regenerates terrain from the modified plate configuration + +let tectonicViewMode = "plates"; // "plates" or "heights" + +function editTectonics() { + if (customization) return tip("Please exit the customization mode first", false, "error"); + + if (!window.tectonicGenerator || !window.tectonicMetadata) { + return tip("Tectonic data not available. Generate a map using a Tectonic template first.", false, "error"); + } + + closeDialogs(".stable"); + tectonicViewMode = "plates"; + + const plates = window.tectonicGenerator.getPlates(); + const plateIds = window.tectonicMetadata.plateIds; + const plateColors = generatePlateColors(plates.length); + + drawPlateOverlay(plateIds, plateColors, plates); + buildPlateList(plates, plateColors); + + $("#tectonicEditor").dialog({ + title: "Tectonic Plate Editor", + resizable: false, + width: "22em", + position: {my: "right top", at: "right-10 top+10", of: "svg"}, + close: closeTectonicEditor + }); + + if (modules.editTectonics) return; + modules.editTectonics = true; + + byId("tectonicRegenerate").addEventListener("click", regenerateFromEditor); + byId("tectonicToggleOverlay").addEventListener("click", togglePlateOverlay); + byId("tectonicApplyMap").addEventListener("click", applyToMap); + byId("tectonicClose").addEventListener("click", () => $("#tectonicEditor").dialog("close")); +} + +function generatePlateColors(count) { + const colors = []; + for (let i = 0; i < count; i++) { + const hue = (i * 360 / count + 15) % 360; + const sat = 60 + (i % 3) * 15; + const lit = 45 + (i % 2) * 15; + colors.push(`hsl(${hue}, ${sat}%, ${lit}%)`); + } + return colors; +} + +// Height-to-color function matching FMG's heightmap editor +function tectonicHeightColor(h) { + if (h < 20) { + // Ocean: deep blue to light blue + const t = h / 20; + const r = Math.round(30 + t * 40); + const g = Math.round(60 + t * 80); + const b = Math.round(120 + t * 100); + return `rgb(${r},${g},${b})`; + } else { + // Land: green to brown to white + const t = (h - 20) / 80; + if (t < 0.3) { + const s = t / 0.3; + return `rgb(${Math.round(80 + s * 60)},${Math.round(160 + s * 40)},${Math.round(60 + s * 20)})`; + } else if (t < 0.7) { + const s = (t - 0.3) / 0.4; + return `rgb(${Math.round(140 + s * 60)},${Math.round(200 - s * 80)},${Math.round(80 - s * 40)})`; + } else { + const s = (t - 0.7) / 0.3; + return `rgb(${Math.round(200 + s * 55)},${Math.round(120 + s * 135)},${Math.round(40 + s * 215)})`; + } + } +} + +function drawPlateOverlay(plateIds, plateColors, plates) { + viewbox.select("#tectonicOverlay").remove(); + const overlay = viewbox.insert("g", "#terrs").attr("id", "tectonicOverlay"); + const numCells = plateIds.length; + + for (let i = 0; i < numCells; i++) { + const pid = plateIds[i]; + if (pid < 0 || pid >= plates.length) continue; + + const points = getGridPolygon(i); + if (!points) continue; + + overlay.append("polygon") + .attr("points", points) + .attr("fill", plateColors[pid]) + .attr("fill-opacity", 0.35) + .attr("stroke", plateColors[pid]) + .attr("stroke-opacity", 0.5) + .attr("stroke-width", 0.2) + .attr("data-plate", pid) + .on("click", function () { + highlightPlate(pid, plateColors); + }); + } + + drawVelocityArrows(overlay, plates, plateIds, plateColors); +} + +function drawHeightOverlay(heights) { + viewbox.select("#tectonicOverlay").remove(); + const overlay = viewbox.insert("g", "#terrs").attr("id", "tectonicOverlay"); + const numCells = heights.length; + + for (let i = 0; i < numCells; i++) { + const points = getGridPolygon(i); + if (!points) continue; + + overlay.append("polygon") + .attr("points", points) + .attr("fill", tectonicHeightColor(heights[i])) + .attr("fill-opacity", 0.85) + .attr("stroke", tectonicHeightColor(heights[i])) + .attr("stroke-opacity", 0.5) + .attr("stroke-width", 0.1); + } +} + +function drawVelocityArrows(overlay, plates, plateIds, plateColors) { + const arrowGroup = overlay.append("g").attr("id", "velocityArrows"); + + for (const plate of plates) { + const centroid = computeGridPlateCentroid(plate.id, plateIds); + if (!centroid) continue; + + const [cx, cy] = centroid; + const vel = plate.velocity; + const arrowScale = 30; + const dx = vel[0] * arrowScale; + const dy = -vel[1] * arrowScale; + const mag = Math.sqrt(dx * dx + dy * dy); + if (mag < 2) continue; + + arrowGroup.append("line") + .attr("x1", cx).attr("y1", cy) + .attr("x2", cx + dx).attr("y2", cy + dy) + .attr("stroke", plateColors[plate.id]) + .attr("stroke-width", 2) + .attr("stroke-opacity", 0.9) + .attr("marker-end", "url(#tectonicArrowhead)"); + + arrowGroup.append("text") + .attr("x", cx).attr("y", cy - 5) + .attr("text-anchor", "middle") + .attr("font-size", "8px") + .attr("fill", plateColors[plate.id]) + .attr("stroke", "#000") + .attr("stroke-width", 0.3) + .attr("paint-order", "stroke") + .text(`P${plate.id}`); + } + + if (!document.getElementById("tectonicArrowhead")) { + const defs = d3.select("svg").select("defs"); + defs.append("marker") + .attr("id", "tectonicArrowhead") + .attr("viewBox", "0 0 10 10") + .attr("refX", 8).attr("refY", 5) + .attr("markerWidth", 6).attr("markerHeight", 6) + .attr("orient", "auto-start-reverse") + .append("path") + .attr("d", "M 0 0 L 10 5 L 0 10 z") + .attr("fill", "#fff") + .attr("stroke", "#333") + .attr("stroke-width", 0.5); + } +} + +function computeGridPlateCentroid(plateId, plateIds) { + let sumX = 0, sumY = 0, count = 0; + for (let i = 0; i < plateIds.length; i++) { + if (plateIds[i] !== plateId) continue; + const [x, y] = grid.points[i]; + sumX += x; + sumY += y; + count++; + } + if (count === 0) return null; + return [sumX / count, sumY / count]; +} + +function highlightPlate(plateId, plateColors) { + viewbox.select("#tectonicOverlay").selectAll("polygon") + .attr("fill-opacity", function () { + return +this.getAttribute("data-plate") === plateId ? 0.6 : 0.15; + }); + + const row = byId(`tectonicPlate_${plateId}`); + if (row) { + row.scrollIntoView({behavior: "smooth", block: "nearest"}); + row.style.outline = "2px solid " + plateColors[plateId]; + setTimeout(() => row.style.outline = "", 1500); + } +} + +function buildPlateList(plates, plateColors) { + const container = byId("tectonicPlateList"); + container.innerHTML = ""; + + const table = document.createElement("table"); + table.style.width = "100%"; + table.style.borderCollapse = "collapse"; + table.style.fontSize = "11px"; + + const header = document.createElement("tr"); + header.innerHTML = ` + ID + Type + Velocity + Dir + `; + table.appendChild(header); + + for (const plate of plates) { + const row = document.createElement("tr"); + row.id = `tectonicPlate_${plate.id}`; + row.style.borderBottom = "1px solid #444"; + row.style.cursor = "pointer"; + + const vel = plate.velocity; + const speed = Math.sqrt(vel[0] * vel[0] + vel[1] * vel[1] + vel[2] * vel[2]).toFixed(2); + const dirDeg = Math.round(Math.atan2(-vel[1], vel[0]) * 180 / Math.PI); + + row.innerHTML = ` + + + ${plate.id} + + + + + + + ${speed} + + + + + `; + + row.addEventListener("click", (e) => { + if (e.target.tagName === "SELECT" || e.target.tagName === "INPUT") return; + highlightPlate(plate.id, plateColors); + }); + + table.appendChild(row); + } + + container.appendChild(table); + + container.querySelectorAll(".plateTypeSelect").forEach(select => { + select.addEventListener("change", function () { + const pid = +this.getAttribute("data-plate"); + plates[pid].isOceanic = this.value === "oceanic"; + }); + }); + + container.querySelectorAll(".plateSpeedRange").forEach(slider => { + slider.addEventListener("input", function () { + const pid = +this.getAttribute("data-plate"); + const plate = plates[pid]; + const newSpeed = +this.value; + this.parentElement.querySelector(".plateSpeedLabel").textContent = newSpeed.toFixed(2); + + const oldSpeed = Math.sqrt(plate.velocity[0] ** 2 + plate.velocity[1] ** 2 + plate.velocity[2] ** 2); + if (oldSpeed > 0.001) { + const scale = newSpeed / oldSpeed; + plate.velocity[0] *= scale; + plate.velocity[1] *= scale; + plate.velocity[2] *= scale; + } else { + plate.velocity[0] = newSpeed; + plate.velocity[1] = 0; + plate.velocity[2] = 0; + } + }); + }); + + container.querySelectorAll(".plateDirInput").forEach(input => { + input.addEventListener("change", function () { + const pid = +this.getAttribute("data-plate"); + const plate = plates[pid]; + const dirRad = (+this.value) * Math.PI / 180; + const speed = Math.sqrt(plate.velocity[0] ** 2 + plate.velocity[1] ** 2 + plate.velocity[2] ** 2); + plate.velocity[0] = Math.cos(dirRad) * speed; + plate.velocity[1] = -Math.sin(dirRad) * speed; + plate.velocity[2] = 0; + }); + }); +} + +function regenerateFromEditor() { + const generator = window.tectonicGenerator; + if (!generator) return tip("No tectonic generator available", false, "error"); + + tip("Regenerating terrain from edited plates...", true, "warn"); + + setTimeout(() => { + try { + const result = generator.regenerate(); + + // Update grid heights + grid.cells.h = result.heights; + window.tectonicMetadata = result.metadata; + + // Show the regenerated heightmap as a visual overlay + tectonicViewMode = "heights"; + drawHeightOverlay(result.heights); + + // Log changes for debugging + let water = 0, land = 0, minH = 100, maxH = 0; + for (let i = 0; i < result.heights.length; i++) { + const h = result.heights[i]; + if (h < 20) water++; else land++; + if (h < minH) minH = h; + if (h > maxH) maxH = h; + } + console.log(`Tectonic regeneration: ${land} land (${(land / result.heights.length * 100).toFixed(1)}%), heights ${minH}-${maxH}`); + + tip("Terrain regenerated. Click 'Apply to Map' to regenerate the full map.", true, "success"); + } catch (e) { + console.error("Tectonic regeneration failed:", e); + tip("Regeneration failed: " + e.message, false, "error"); + } + }, 50); +} + +function applyToMap() { + if (!window.tectonicGenerator) return tip("No tectonic generator available", false, "error"); + + // Close the editor overlay + closeTectonicEditor(); + $("#tectonicEditor").dialog("close"); + + tip("Rebuilding map from edited tectonics...", true, "warn"); + + setTimeout(() => { + try { + // grid.cells.h is already set by regenerateFromEditor + // Run the full downstream pipeline WITHOUT regenerating the heightmap + undraw(); + pack = {}; + + Features.markupGrid(); + addLakesInDeepDepressions(); + openNearSeaLakes(); + + OceanLayers(); + defineMapSize(); + calculateMapCoordinates(); + calculateTemperatures(); + generatePrecipitation(); + + reGraph(); + Features.markupPack(); + createDefaultRuler(); + + Rivers.generate(); + Biomes.define(); + Features.defineGroups(); + + Ice.generate(); + + rankCells(); + Cultures.generate(); + Cultures.expand(); + + Burgs.generate(); + States.generate(); + Routes.generate(); + Religions.generate(); + + Burgs.specify(); + States.collectStatistics(); + States.defineStateForms(); + + Provinces.generate(); + Provinces.getPoles(); + + Rivers.specify(); + Lakes.defineNames(); + + Military.generate(); + Markers.generate(); + Zones.generate(); + + drawScaleBar(scaleBar, scale); + Names.getMapName(); + + drawLayers(); + if (ThreeD.options.isOn) ThreeD.redraw(); + + fitMapToScreen(); + clearMainTip(); + tip("Map rebuilt from edited tectonics", true, "success"); + } catch (e) { + console.error("Failed to rebuild map:", e); + tip("Rebuild failed: " + e.message, false, "error"); + } + }, 100); +} + +function togglePlateOverlay() { + const overlay = viewbox.select("#tectonicOverlay"); + + if (tectonicViewMode === "heights") { + // Switch back to plate view + tectonicViewMode = "plates"; + const plates = window.tectonicGenerator.getPlates(); + const plateColors = generatePlateColors(plates.length); + drawPlateOverlay(window.tectonicMetadata.plateIds, plateColors, plates); + return; + } + + if (overlay.empty()) { + const plates = window.tectonicGenerator.getPlates(); + const plateColors = generatePlateColors(plates.length); + drawPlateOverlay(window.tectonicMetadata.plateIds, plateColors, plates); + } else { + const visible = overlay.style("display") !== "none"; + overlay.style("display", visible ? "none" : null); + } +} + +function closeTectonicEditor() { + viewbox.select("#tectonicOverlay").remove(); + d3.select("#tectonicArrowhead").remove(); + tectonicViewMode = "plates"; +} diff --git a/public/modules/ui/tools.js b/public/modules/ui/tools.js index 34c1cd12..6e4495ec 100644 --- a/public/modules/ui/tools.js +++ b/public/modules/ui/tools.js @@ -9,6 +9,7 @@ toolsContent.addEventListener("click", function (event) { // click on open Editor buttons if (button === "editHeightmapButton") editHeightmap(); + else if (button === "editTectonicsButton") editTectonics(); else if (button === "editBiomesButton") editBiomes(); else if (button === "editStatesButton") editStates(); else if (button === "editProvincesButton") editProvinces(); diff --git a/src/index.html b/src/index.html index dffaa816..eee1e9c6 100644 --- a/src/index.html +++ b/src/index.html @@ -2123,6 +2123,13 @@ > Heightmap + @@ -4098,6 +4105,18 @@
+ +