feat(@projects/@magic-civilization): add river overlay and lake system

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-01 02:40:58 -04:00
parent 1e2f3ce082
commit 1677d8cfb3
11 changed files with 647 additions and 79 deletions

View file

@ -1,12 +1,29 @@
import { useState, useRef, useEffect } from "react";
import { LayerPage, Slider } from "./_LayerPage";
import { useWasmGrid } from "../../utils/wasm/useWasmGrid";
import { hexToPixel, hexCorners, rustDirToFlatTopDir, neighborCoords } from "../../utils/worldGen/hexCanvas";
import { hexToPixel, hexCorners, rustDirToFlatTopDir, neighborCoords, drawEdgeBlend } from "../../utils/worldGen/hexCanvas";
import { blendKey, BLEND_COLOR_MAP } from "../../utils/worldGen/terrain";
import specMarkdown from "@game-docs/terrain/HYDROLOGY.md?raw";
type Overlay = "flow" | "drainage" | "stream_order" | "riparian";
type Overlay = "flow" | "drainage" | "stream_order" | "riparian" | "lakes" | "rivers";
const HEX_SIZE = 12;
const RIVER_THRESHOLD = 12;
// Edge midpoint for a given direction (0-5 flat-top dirs matching EDGE_CORNERS)
function edgeMidpoint(corners: [number, number][], dir: number): { x: number; y: number } {
const EDGE_CORNERS: [number, number][] = [[5,0],[0,1],[1,2],[2,3],[3,4],[4,5]];
const [ia, ib] = EDGE_CORNERS[dir];
return {
x: (corners[ia][0] + corners[ib][0]) / 2,
y: (corners[ia][1] + corners[ib][1]) / 2,
};
}
// Opposite direction (for finding enter-edge from upstream)
function oppositeDir(dir: number): number {
return (dir + 3) % 6;
}
function lerp(a: number, b: number, t: number): string {
const r = Math.round(a + (b - a) * t);
@ -18,7 +35,19 @@ function heatColor(t: number, r0: number, g0: number, b0: number, r1: number, g1
return `rgb(${lerp(r0, r1, tc)},${lerp(g0, g1, tc)},${lerp(b0, b1, tc)})`;
}
function riverColor(streamOrder: number): string {
const t = Math.min(1, streamOrder / 7);
return `rgb(${lerp(91, 26, t)},${lerp(141, 58, t)},${lerp(217, 110, t)})`;
}
interface TileHydro { flow_out: number; drainage_area: number; stream_order: number; lake_id: number; riparian_distance: number }
interface TileExtra { substrate: string; biome: string }
function isWaterTile(extra: TileExtra | undefined): boolean {
if (!extra) return false;
return extra.substrate === "water" || extra.substrate === "seawater"
|| extra.biome === "ocean" || extra.biome === "coast" || extra.biome === "lake" || extra.biome === "inland_sea";
}
function HydrologyCanvas({ grid, overlay }: { grid: import("../../../../../../.local/build/wasm/magic_civ_physics").WasmGrid; overlay: Overlay }): React.ReactElement {
const canvasRef = useRef<HTMLCanvasElement>(null);
@ -38,19 +67,27 @@ function HydrologyCanvas({ grid, overlay }: { grid: import("../../../../../../.l
canvas.height = lastPx.y + size * Math.sqrt(3);
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Gather data for normalization
// Prefetch all tile data in one pass
const tiles: (TileHydro | null)[] = [];
const extras: (TileExtra | undefined)[] = [];
let maxDrain = 1;
for (let row = 0; row < h; row++) {
for (let col = 0; col < w; col++) {
const raw = grid.tileHydrologyJson(col, row);
if (!raw) { tiles.push(null); continue; }
if (!raw) { tiles.push(null); extras.push(undefined); continue; }
const t = JSON.parse(raw) as TileHydro;
tiles.push(t);
if (t.drainage_area > maxDrain) maxDrain = t.drainage_area;
const subRaw = grid.tileSubstrateJson(col, row);
const floraRaw = grid.tileFloraCoverJson(col, row);
const sub = subRaw ? (JSON.parse(subRaw) as { id: string }).id : "";
const biome = floraRaw ? (JSON.parse(floraRaw) as { id: string; biome_label: string }).biome_label : "";
extras.push({ substrate: sub, biome });
}
}
// Pass 1: fill hexes
for (let row = 0; row < h; row++) {
for (let col = 0; col < w; col++) {
const tile = tiles[row * w + col];
@ -80,15 +117,61 @@ function HydrologyCanvas({ grid, overlay }: { grid: import("../../../../../../.l
const MAX_RIP = 10;
const t = 1 - Math.min(1, tile.riparian_distance / MAX_RIP);
ctx.fillStyle = heatColor(t, 30, 40, 30, 40, 140, 60);
} else if (overlay === "lakes") {
// Continuous fill for lakes; normal heatmap for non-lake water
if (tile.lake_id !== -1) {
ctx.fillStyle = "rgba(50,130,200,0.85)";
} else if (isWaterTile(extras[row * w + col])) {
ctx.fillStyle = "rgba(30,80,150,0.75)";
} else {
const t = 1 - Math.min(1, tile.riparian_distance / 10);
ctx.fillStyle = heatColor(t, 40, 55, 40, 60, 150, 80);
}
} else if (overlay === "rivers") {
if (isWaterTile(extras[row * w + col])) {
ctx.fillStyle = "rgba(30,80,150,0.75)";
} else {
ctx.fillStyle = "rgba(40,55,40,0.8)";
}
} else {
ctx.fillStyle = "rgba(40,60,90,0.7)";
}
ctx.fill();
ctx.strokeStyle = "rgba(0,0,0,0.15)";
ctx.lineWidth = 0.4;
ctx.stroke();
if (overlay === "flow" && tile.flow_out < 6) {
// Per-edge border drawing: suppress internal lake borders for lakes/flow overlays
const suppressLakeBorders = overlay === "lakes" || overlay === "flow";
const neighbors = neighborCoords(col, row);
for (let dir = 0; dir < 6; dir++) {
const [nc, nr] = neighbors[dir];
const inBounds = nr >= 0 && nr < h && nc >= 0 && nc < w;
const nbTile = inBounds ? tiles[nr * w + nc] : null;
const isSameLake = suppressLakeBorders
&& tile.lake_id !== -1
&& nbTile !== null
&& nbTile.lake_id === tile.lake_id;
if (!isSameLake) {
const EDGE_CORNERS_LIST: [number, number][] = [[5,0],[0,1],[1,2],[2,3],[3,4],[4,5]];
const [ia, ib] = EDGE_CORNERS_LIST[dir];
ctx.beginPath();
ctx.moveTo(corners[ia][0], corners[ia][1]);
ctx.lineTo(corners[ib][0], corners[ib][1]);
ctx.strokeStyle = "rgba(0,0,0,0.15)";
ctx.lineWidth = 0.4;
ctx.stroke();
}
}
}
}
// Pass 2: flow arrows (overlay === "flow")
if (overlay === "flow") {
for (let row = 0; row < h; row++) {
for (let col = 0; col < w; col++) {
const tile = tiles[row * w + col];
if (!tile || tile.flow_out >= 6) continue;
const { x: cx, y: cy } = hexToPixel(col, row, size);
const ftDir = rustDirToFlatTopDir(tile.flow_out, col);
const [nc, nr] = neighborCoords(col, row)[ftDir];
const { x: nx, y: ny } = hexToPixel(nc, nr, size);
@ -101,6 +184,84 @@ function HydrologyCanvas({ grid, overlay }: { grid: import("../../../../../../.l
}
}
}
// Pass 3: river bezier curves (stream_order or rivers overlay)
if (overlay === "stream_order" || overlay === "rivers") {
for (let row = 0; row < h; row++) {
for (let col = 0; col < w; col++) {
const tile = tiles[row * w + col];
if (!tile || tile.flow_out >= 6 || tile.drainage_area < RIVER_THRESHOLD) continue;
const { x: cx, y: cy } = hexToPixel(col, row, size);
const corners = hexCorners(cx, cy, size);
const ftDir = rustDirToFlatTopDir(tile.flow_out, col);
// Find entry edge: upstream neighbors pointing to this cell
const exitMid = edgeMidpoint(corners, ftDir);
// Determine an entry point: find if any upstream neighbour flows into this cell
const nbs = neighborCoords(col, row);
let enterX = cx, enterY = cy;
for (let d = 0; d < 6; d++) {
const [uc, ur] = nbs[d];
if (ur < 0 || ur >= h || uc < 0 || uc >= w) continue;
const up = tiles[ur * w + uc];
if (!up || up.flow_out >= 6) continue;
const upFtDir = rustDirToFlatTopDir(up.flow_out, uc);
if (oppositeDir(upFtDir) === d) {
// This upstream cell flows into our cell from direction d
// Entry edge is the shared edge — which from our perspective is direction d
const entryMid = edgeMidpoint(corners, d);
enterX = entryMid.x;
enterY = entryMid.y;
break;
}
}
const width = Math.min(8, 1 + Math.log2(tile.drainage_area + 1));
ctx.beginPath();
ctx.moveTo(enterX, enterY);
ctx.quadraticCurveTo(cx, cy, exitMid.x, exitMid.y);
ctx.strokeStyle = riverColor(tile.stream_order);
ctx.lineWidth = width;
ctx.lineCap = "round";
ctx.stroke();
}
}
}
// Pass 4: coastline ecotone strokes (lakes or rivers overlay)
if (overlay === "lakes" || overlay === "rivers") {
for (let row = 0; row < h; row++) {
for (let col = 0; col < w; col++) {
const extra = extras[row * w + col];
if (!extra || isWaterTile(extra)) continue;
const { x: cx, y: cy } = hexToPixel(col, row, size);
const corners = hexCorners(cx, cy, size);
const nbs = neighborCoords(col, row);
for (let dir = 0; dir < 6; dir++) {
const [nc, nr] = nbs[dir];
if (nr < 0 || nr >= h || nc < 0 || nc >= w) continue;
const nbExtra = extras[nr * w + nc];
if (!nbExtra || !isWaterTile(nbExtra)) continue;
// Land/water boundary — determine ecotone from terrain pair
const landBiome = extra.biome || "plains";
const waterBiome = nbExtra.biome || "coast";
const key = blendKey(landBiome, waterBiome);
const color = BLEND_COLOR_MAP.get(key);
if (color) {
drawEdgeBlend(ctx, corners, dir, color);
} else {
// Fallback shore color
drawEdgeBlend(ctx, corners, dir, "rgba(200,180,120,0.7)");
}
}
}
}
}
}, [grid, overlay]);
function handleMouseMove(e: React.MouseEvent<HTMLCanvasElement>): void {
@ -166,7 +327,7 @@ export function HydrologyPage(): React.ReactElement {
<Slider label="Seed" value={seed} min={0} max={9999} step={1} onChange={setSeed} format={(v) => v.toFixed(0)} />
<div style={{ display: "flex", flexDirection: "column", gap: 4, fontFamily: "monospace", fontSize: 11 }}>
<span style={{ opacity: 0.7 }}>Overlay</span>
{(["flow", "drainage", "stream_order", "riparian"] as const).map((id) => (
{(["flow", "drainage", "stream_order", "riparian", "lakes", "rivers"] as const).map((id) => (
<label key={id} style={{ display: "flex", gap: 6, cursor: "pointer" }}>
<input type="radio" name="hydro-overlay" checked={overlay === id} onChange={() => setOverlay(id)} />
{id}
@ -181,8 +342,10 @@ export function HydrologyPage(): React.ReactElement {
<div style={{ opacity: 0.5, marginBottom: 6 }}>Overlays</div>
<div>flow arrow to flow_out neighbor</div>
<div>drainage log2 heatmap</div>
<div>stream_order Strahler (07)</div>
<div>stream_order Strahler bezier rivers (07)</div>
<div>riparian closeness to river (dark=near)</div>
<div>lakes continuous lake fills, suppressed internal borders</div>
<div>rivers bezier rivers + coastline ecotone strokes</div>
<div style={{ marginTop: 10, opacity: 0.5 }}>Hover a tile to inspect fields</div>
</div>
}

View file

@ -8,6 +8,8 @@ import {
pixelToHex,
drawTree3Layer,
drawConifer3Layer,
rustDirToFlatTopDir,
neighborCoords,
} from "../../utils/worldGen/hexCanvas";
import { classifyTerrain, TERRAIN_MAP } from "../../utils/worldGen/terrain";
import type { GridCell } from "../../utils/worldGen/terrain";
@ -39,6 +41,64 @@ const FAUNA_JSONS: string[] = Object.values(
),
).map(v => JSON.stringify(v));
// ── World-shape presets (manifest.json axes) ─────────────────────────────────
import manifestJson from "../../../../../../public/games/age-of-dwarves/data/world_shapes/manifest.json";
type PresetsAxes = { landmass: string[]; climate: string[]; moisture: string[]; age: string[]; sea_level: string[] };
const PRESET_AXES = (manifestJson as { axes: PresetsAxes }).axes;
type PresetState = {
landmass: string;
climate: string;
moisture: string;
age: string;
sea_level: string;
};
const DEFAULT_PRESETS: PresetState = {
landmass: PRESET_AXES.landmass[1] ?? "continents",
climate: PRESET_AXES.climate[1] ?? "temperate",
moisture: PRESET_AXES.moisture[2] ?? "balanced",
age: PRESET_AXES.age[1] ?? "mature",
sea_level: PRESET_AXES.sea_level[1] ?? "standard",
};
// ── Plate colour table (mirrors Tectonics.tsx) ────────────────────────────────
const PLATE_COLORS: [number, number, number][] = [
[94, 144, 202], [202, 102, 94], [94, 202, 130], [202, 180, 94],
[160, 94, 202], [94, 202, 202], [202, 94, 160], [140, 200, 94],
[202, 140, 94], [94, 120, 202], [180, 202, 94], [202, 94, 94],
[94, 202, 160], [160, 160, 202], [202, 160, 160],
];
const BOUNDARY_STROKE: Record<number, string> = {
1: "rgba(255,60,60,0.85)",
2: "rgba(80,120,255,0.85)",
3: "rgba(255,200,0,0.85)",
};
// ── Climate colour helpers (mirrors Climate.tsx) ──────────────────────────────
function lerpChannel(a: number, b: number, t: number): number {
return Math.round(a + (b - a) * Math.max(0, Math.min(1, t)));
}
function tempColor(temp: number): string {
if (temp < 0.5) {
const t = temp / 0.5;
return `rgb(${lerpChannel(20, 80, t)},${lerpChannel(60, 160, t)},${lerpChannel(180, 220, t)})`;
}
const t = (temp - 0.5) / 0.5;
return `rgb(${lerpChannel(80, 220, t)},${lerpChannel(160, 80, t)},${lerpChannel(220, 40, t)})`;
}
function precipColor(precip: number): string {
const t = Math.max(0, Math.min(1, precip));
return `rgb(${lerpChannel(200, 30, t)},${lerpChannel(180, 100, t)},${lerpChannel(140, 200, t)})`;
}
// ── Substrate colour table (mirrors Substrate.tsx) ───────────────────────────
const SUBSTRATE_COLORS: Record<string, [number, number, number, number]> = {
@ -104,6 +164,10 @@ type LabState = {
faunaOn: boolean;
mineralsOn: boolean; mineralRichness: number;
substrateOn: boolean;
platesOn: boolean;
hydrologyOn: boolean;
climateOn: boolean;
climateMode: "temp" | "precip";
};
const DEFAULT: LabState = {
@ -112,6 +176,10 @@ const DEFAULT: LabState = {
faunaOn: false,
mineralsOn: false, mineralRichness: 0.5,
substrateOn: false,
platesOn: false,
hydrologyOn: false,
climateOn: false,
climateMode: "temp",
};
// ── Drawing helpers ───────────────────────────────────────────────────────────
@ -391,12 +459,67 @@ function renderCanvas(
): void {
ctx.clearRect(0, 0, CANVAS_W, CANVAS_H);
// 1. Hex fills + borders — substrate base (WASM) or classifyTerrain fallback
// Derived ridginess scalars — spec: ridge_density_modifier = 1 - 0.6 * ridginess
const ridge_density_modifier = 1 - 0.6 * st.ridginess;
const effective_canopy_density = st.floraDensity * Math.max(0.15, ridge_density_modifier);
// 1. Hex fills + borders
for (let row = 0; row < ROWS; row++) {
for (let col = 0; col < COLS; col++) {
const cell = grid[row][col];
let baseColor: [number, number, number] | null = null;
// Plates overlay replaces terrain fill
if (st.platesOn && wasmGrid !== null) {
const raw = wasmGrid.tileTectonicsJson(col, row);
if (raw) {
const tile = JSON.parse(raw) as { plate_id: number; boundary_kind: number };
const c = PLATE_COLORS[tile.plate_id % PLATE_COLORS.length];
const { x: cx, y: cy } = hexToPixel(col, row, HEX_SIZE);
const corners = hexCorners(cx, cy, HEX_SIZE);
ctx.beginPath();
ctx.moveTo(corners[0][0], corners[0][1]);
for (let i = 1; i < 6; i++) ctx.lineTo(corners[i][0], corners[i][1]);
ctx.closePath();
ctx.fillStyle = `rgba(${c[0]},${c[1]},${c[2]},0.75)`;
ctx.fill();
if (tile.boundary_kind && BOUNDARY_STROKE[tile.boundary_kind]) {
ctx.beginPath();
ctx.moveTo(corners[0][0], corners[0][1]);
for (let i = 1; i < 6; i++) ctx.lineTo(corners[i][0], corners[i][1]);
ctx.closePath();
ctx.strokeStyle = BOUNDARY_STROKE[tile.boundary_kind];
ctx.lineWidth = 1.5;
ctx.stroke();
} else {
ctx.strokeStyle = "rgba(0,0,0,0.2)";
ctx.lineWidth = 0.5;
ctx.stroke();
}
continue;
}
}
// Climate overlay replaces terrain fill
if (st.climateOn && wasmGrid !== null) {
const raw = wasmGrid.tileClimateJson(col, row);
if (raw) {
const tile = JSON.parse(raw) as { mean_temp: number; mean_precip: number };
const { x: cx, y: cy } = hexToPixel(col, row, HEX_SIZE);
const corners = hexCorners(cx, cy, HEX_SIZE);
ctx.beginPath();
ctx.moveTo(corners[0][0], corners[0][1]);
for (let i = 1; i < 6; i++) ctx.lineTo(corners[i][0], corners[i][1]);
ctx.closePath();
ctx.fillStyle = st.climateMode === "temp" ? tempColor(tile.mean_temp) : precipColor(tile.mean_precip);
ctx.fill();
ctx.strokeStyle = "rgba(0,0,0,0.15)";
ctx.lineWidth = 0.4;
ctx.stroke();
continue;
}
}
if (st.substrateOn && wasmGrid !== null) {
const raw = wasmGrid.tileSubstrateJson(col, row);
if (raw) {
@ -418,7 +541,48 @@ function renderCanvas(
}
}
// 2. Minerals
// 2. Hydrology overlay — composite on top (riparian heatmap + flow arrows)
if (st.hydrologyOn && wasmGrid !== null) {
for (let row = 0; row < ROWS; row++) {
for (let col = 0; col < COLS; col++) {
const raw = wasmGrid.tileHydrologyJson(col, row);
if (!raw) continue;
const tile = JSON.parse(raw) as { flow_out: number; drainage_area: number; riparian_distance: number };
const { x: cx, y: cy } = hexToPixel(col, row, HEX_SIZE);
const corners = hexCorners(cx, cy, HEX_SIZE);
// Riparian tint
const MAX_RIP = 10;
const rip = 1 - Math.min(1, tile.riparian_distance / MAX_RIP);
if (rip > 0.05) {
ctx.beginPath();
ctx.moveTo(corners[0][0], corners[0][1]);
for (let i = 1; i < 6; i++) ctx.lineTo(corners[i][0], corners[i][1]);
ctx.closePath();
ctx.fillStyle = `rgba(40,120,200,${(rip * 0.35).toFixed(2)})`;
ctx.fill();
}
// Flow arrow
if (tile.flow_out < 6) {
const ftDir = rustDirToFlatTopDir(tile.flow_out, col);
const nbrs = neighborCoords(col, row);
if (ftDir >= 0 && ftDir < nbrs.length) {
const [nc, nr] = nbrs[ftDir];
const { x: nx, y: ny } = hexToPixel(nc, nr, HEX_SIZE);
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + (nx - cx) * 0.6, cy + (ny - cy) * 0.6);
ctx.strokeStyle = "rgba(100,180,255,0.65)";
ctx.lineWidth = 1;
ctx.stroke();
}
}
}
}
}
// 3. Minerals
if (st.mineralsOn) {
for (let row = 0; row < ROWS; row++) {
for (let col = 0; col < COLS; col++) {
@ -432,7 +596,8 @@ function renderCanvas(
}
}
// 3. Flora — three global Poisson passes: ground → understory → canopy
// 4. Flora — three global Poisson passes: ground → understory → canopy
// Canopy density reduced by ridge_density_modifier (spec: 1 - 0.6 * ridginess)
if (st.floraOn) {
const bigR = Math.hypot(CANVAS_W, CANVAS_H) / 2 + 20;
const ccx = CANVAS_W / 2, ccy = CANVAS_H / 2;
@ -458,8 +623,8 @@ function renderCanvas(
drawShrub(ctx, p.x, p.y, 4 + rng.next() * 3, colors[0], colors[1]);
}
// Canopy: 3-layer trees (density reduced by ridginess)
for (const p of poissonDisc(ccx, ccy, bigR, 18 / st.floraDensity, 200, new SeededRng(0x4003))) {
// Canopy: radius inflated by ridginess (sparser trees at high ridginess)
for (const p of poissonDisc(ccx, ccy, bigR, 18 / effective_canopy_density, 200, new SeededRng(0x4003))) {
if (p.x < 0 || p.x >= CANVAS_W || p.y < 0 || p.y >= CANVAS_H) continue;
const { col, row } = pixelToHex(p.x, p.y, HEX_SIZE, COLS, ROWS);
const cell = grid[row]?.[col];
@ -474,7 +639,7 @@ function renderCanvas(
}
}
// 4. Fauna ambient overlay — up to 5 silhouettes per tile using glyph_cluster
// 5. Fauna ambient overlay — up to 5 silhouettes per tile using glyph_cluster
if (st.faunaOn && tileFaunaMap.size > 0) {
for (let row = 0; row < ROWS; row++) {
for (let col = 0; col < COLS; col++) {
@ -710,12 +875,67 @@ const EcoPending = styled.div`
padding-left: 8px;
`;
// ── Preset row styled components ─────────────────────────────────────────────
const PresetBar = styled.div`
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
background: ${t.bg.panel};
border: 1px solid ${t.border.panel};
border-radius: ${t.radius.panel};
padding: 10px 14px;
`;
const PresetGroup = styled.div`
display: flex;
flex-direction: column;
gap: 3px;
min-width: 90px;
`;
const PresetGroupLabel = styled.div`
font-family: ${t.font.mono};
font-size: 9px;
color: ${t.text.muted};
text-transform: uppercase;
letter-spacing: 0.07em;
`;
const PresetSelect = styled.select`
font-family: ${t.font.mono};
font-size: 11px;
color: ${t.text.primary};
background: ${t.bg.deepest};
border: 1px solid ${t.border.panel};
border-radius: 4px;
padding: 3px 6px;
cursor: pointer;
&:focus { outline: 1px solid ${t.accent.gold}; }
`;
const AdvancedToggle = styled.button`
font-family: ${t.font.mono};
font-size: 11px;
color: ${t.text.muted};
background: transparent;
border: 1px solid ${t.border.panel};
border-radius: 4px;
padding: 3px 10px;
cursor: pointer;
margin-left: auto;
&:hover { color: ${t.accent.gold}; border-color: ${t.accent.gold}; }
`;
// ── Lab ───────────────────────────────────────────────────────────────────────
export function Lab(): React.ReactElement {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [st, updateSt] = useState<LabState>(DEFAULT);
const [wasmMod, setWasmMod] = useState<WasmModule | null>(cachedWasmMod);
const [presets, setPresets] = useState<PresetState>(DEFAULT_PRESETS);
const [advancedOpen, setAdvancedOpen] = useState(false);
const floraIdxRef = useRef<import("../../../../../../.local/build/wasm/magic_civ_physics").WasmFloraIndex | null>(null);
const faunaIdxRef = useRef<import("../../../../../../.local/build/wasm/magic_civ_physics").WasmFaunaIndex | null>(null);
@ -850,15 +1070,36 @@ export function Lab(): React.ReactElement {
</PageSub>
</PageHeader>
<PresetBar>
{(["landmass", "climate", "moisture", "age", "sea_level"] as const).map(axis => (
<PresetGroup key={axis}>
<PresetGroupLabel>{axis.replace("_", " ")}</PresetGroupLabel>
<PresetSelect
value={presets[axis]}
onChange={e => setPresets(p => ({ ...p, [axis]: e.target.value }))}
>
{(PRESET_AXES[axis] ?? []).map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</PresetSelect>
</PresetGroup>
))}
<AdvancedToggle onClick={() => setAdvancedOpen(o => !o)}>
{advancedOpen ? "Hide advanced" : "Advanced"}
</AdvancedToggle>
</PresetBar>
<Body>
<Controls>
<ControlGroup>
<GroupLabel>Biome classifiers</GroupLabel>
<SliderControl label="Altitude" value={st.elevation} min={0.35} max={0.99} step={0.01} onChange={v => set({ elevation: v })} />
<SliderControl label="Humidity" value={st.moisture} min={0} max={1} step={0.01} onChange={v => set({ moisture: v })} />
<SliderControl label="Temperature" value={st.temp} min={0} max={1} step={0.01} onChange={v => set({ temp: v })} />
<SliderControl label="Ridginess" value={st.ridginess} min={0} max={1} step={0.01} onChange={v => set({ ridginess: v })} />
</ControlGroup>
{advancedOpen && (
<ControlGroup>
<GroupLabel>Biome classifiers</GroupLabel>
<SliderControl label="Altitude" value={st.elevation} min={0.35} max={0.99} step={0.01} onChange={v => set({ elevation: v })} />
<SliderControl label="Humidity" value={st.moisture} min={0} max={1} step={0.01} onChange={v => set({ moisture: v })} />
<SliderControl label="Temperature" value={st.temp} min={0} max={1} step={0.01} onChange={v => set({ temp: v })} />
<SliderControl label="Ridginess" value={st.ridginess} min={0} max={1} step={0.01} onChange={v => set({ ridginess: v })} />
</ControlGroup>
)}
<ControlGroup>
<GroupLabel>Overlays</GroupLabel>
@ -898,6 +1139,50 @@ export function Lab(): React.ReactElement {
{wasmGrid === null && <span style={{ opacity: 0.4, marginLeft: 6 }}>(WASM pending)</span>}
</ToggleBtn>
</ToggleRow>
<ToggleRow>
<ToggleBtn
$on={st.platesOn}
onClick={() => set({ platesOn: !st.platesOn })}
title={wasmGrid === null ? "WASM not loaded — plates unavailable" : undefined}
>
<ToggleDot $on={st.platesOn} /> Plates
{wasmGrid === null && <span style={{ opacity: 0.4, marginLeft: 6 }}>(WASM pending)</span>}
</ToggleBtn>
</ToggleRow>
<ToggleRow>
<ToggleBtn
$on={st.hydrologyOn}
onClick={() => set({ hydrologyOn: !st.hydrologyOn })}
title={wasmGrid === null ? "WASM not loaded — hydrology unavailable" : undefined}
>
<ToggleDot $on={st.hydrologyOn} /> Hydrology
{wasmGrid === null && <span style={{ opacity: 0.4, marginLeft: 6 }}>(WASM pending)</span>}
</ToggleBtn>
</ToggleRow>
<ToggleRow>
<ToggleBtn
$on={st.climateOn}
onClick={() => set({ climateOn: !st.climateOn })}
title={wasmGrid === null ? "WASM not loaded — climate unavailable" : undefined}
>
<ToggleDot $on={st.climateOn} /> Climate
{wasmGrid === null && <span style={{ opacity: 0.4, marginLeft: 6 }}>(WASM pending)</span>}
</ToggleBtn>
{st.climateOn && (
<div style={{ display: "flex", gap: 6, paddingLeft: 8 }}>
{(["temp", "precip"] as const).map(mode => (
<ToggleBtn
key={mode}
$on={st.climateMode === mode}
onClick={() => set({ climateMode: mode })}
style={{ fontSize: 10, padding: "2px 8px" }}
>
{mode}
</ToggleBtn>
))}
</div>
)}
</ToggleRow>
</ControlGroup>
</Controls>

View file

@ -15,10 +15,10 @@
| Priority | ✅ | 🔵 | 🟡 | 🔴 | ❌ | ⚫ | Total |
|---|---|---|---|---|---|---|---|
| **P0** | 43 | 0 | 0 | 0 | 0 | 0 | 43 |
| **P1** | 38 | 1 | 12 | 0 | 14 | 1 | 66 |
| **P1** | 40 | 1 | 10 | 0 | 14 | 1 | 66 |
| **P2** | 33 | 0 | 6 | 1 | 6 | 6 | 52 |
| **P3 (oos)** | 3 | 0 | 0 | 0 | 1 | 19 | 23 |
| **total** | **117** | **1** | **18** | **1** | **21** | **26** | **184** |
| **total** | **119** | **1** | **16** | **1** | **21** | **26** | **184** |
</td><td valign='top' style='padding-left:2em'>
@ -26,8 +26,8 @@
| Team Lead | Remaining |
|---|---|
| [terraformer](../team-leads/terraformer.md) | 8 |
| [warcouncil](../team-leads/warcouncil.md) | 7 |
| [terraformer](../team-leads/terraformer.md) | 6 |
| [asset-sprite](../team-leads/asset-sprite.md) | 6 |
| [shipwright](../team-leads/shipwright.md) | 5 |
| [combat-dev](../team-leads/combat-dev.md) | 1 |
@ -136,9 +136,9 @@
| [p1-44](p1-44-buildings-as-producers.md) | ❌ missing | Buildings produce units, not the city center — per-building production queues | — | 2026-04-29 |
| [p1-45](p1-45-batch-binary-freshness.md) | ❌ missing | "Batch binary freshness: rebuild GDExt before every autoplay batch" | [simulator-infra](../team-leads/simulator-infra.md) | 2026-04-30 |
| [p1-46](p1-46-design-lab-terrain-dimensions.md) | 🟡 partial | Terrain Dimensions Lab — fix ridginess, bind 149 flora species, add Whittaker plot | [terraformer](../team-leads/terraformer.md) | 2026-05-01 |
| [p1-47](p1-47-river-hydrology-network.md) | 🟡 partial | River hydrology — D6 flow analysis, hydraulic erosion, multi-hex lakes, cross-tile rivers | [terraformer](../team-leads/terraformer.md) | 2026-05-01 |
| [p1-47](p1-47-river-hydrology-network.md) | ✅ done | River hydrology — D6 flow analysis, hydraulic erosion, multi-hex lakes, cross-tile rivers | [terraformer](../team-leads/terraformer.md) | 2026-05-01 |
| [p1-48](p1-48-flora-species-renderer.md) | ✅ done | Flora species renderer — bind 149 species to world-map tile rendering (single source of truth) | [terraformer](../team-leads/terraformer.md) | 2026-05-01 |
| [p1-49](p1-49-fauna-species-renderer.md) | 🟡 partial | Fauna species renderer — 61 Game-1 species visible on encounter and lair tiles | [terraformer](../team-leads/terraformer.md) | 2026-05-01 |
| [p1-49](p1-49-fauna-species-renderer.md) | ✅ done | Fauna species renderer — 61 Game-1 species visible on encounter and lair tiles | [terraformer](../team-leads/terraformer.md) | 2026-05-01 |
| [p1-50](p1-50-tectonic-prepass.md) | 🟡 partial | Tectonic prepass — voronoi plates + boundary classification seeding elevation | [terraformer](../team-leads/terraformer.md) | 2026-05-01 |
| [p1-51](p1-51-worldgen-canonical-design-docs.md) | ✅ done | Worldgen canonical design docs — author the spec before any Rust | [terraformer](../team-leads/terraformer.md) | 2026-04-30 |
| [p1-52](p1-52-api-wasm-build-fix.md) | ✅ done | api-wasm build fix — unblock WASM bundle for design-lab WASM consumption | [terraformer](../team-leads/terraformer.md) | 2026-05-01 |
@ -203,7 +203,7 @@
| [p2-48](p2-48-end-of-game-summary-screen.md) | ❌ missing | End-of-game summary screen — outcome banner, standings, score graph, awards, timeline, footer actions | [shipwright](../team-leads/shipwright.md) | 2026-04-30 |
| [p2-49](p2-49-climate-axes-latitude-continentality.md) | 🟡 partial | Climate axes refactor — latitude + continentality + zonal winds as first-class per-hex inputs | [terraformer](../team-leads/terraformer.md) | 2026-04-30 |
| [p2-50](p2-50-rng-determinism-pin.md) | 🟡 partial | Deterministic RNG + seed-derivation pin across mc-mapgen / mc-climate / mc-ecology | [terraformer](../team-leads/terraformer.md) | 2026-04-30 |
| [p2-51](p2-51-world-shape-knobs.md) | 🟡 partial | Player-facing world-shape parameters on new-game screen | [terraformer](../team-leads/terraformer.md) | 2026-04-30 |
| [p2-51](p2-51-world-shape-knobs.md) | 🟡 partial | Player-facing world-shape parameters on new-game screen | [terraformer](../team-leads/terraformer.md) | 2026-05-01 |
| [p2-52](p2-52-substrate-flora-cover-ontology-split.md) | 🟡 partial | Split terrain enum into substrate × flora-cover layers (resolve biome ontology) | [terraformer](../team-leads/terraformer.md) | 2026-05-01 |
## Out of Scope (Game 2 / Game 3)

View file

@ -52,14 +52,14 @@ behaviour, and flora binding all consume the post-refactor axes.
## Acceptance
- **Ridginess split into two derived scalars** — repurpose as a
cross-elevation modifier with two distinct effects, each a separate
scalar the renderer consumes:
- `ridge_terrain_promote` (`elevation < 0.70 ∧ ridginess > 0.5`
plains/grassland upgraded to hills)
- `ridge_density_modifier` (any elevation: rocky-stipple density up,
flora density down by `1 - 0.6 * ridginess`)
Visible side-by-side screenshot at low / mid / high ridginess.
- **Ridginess split into two derived scalars**`ridge_terrain_promote`
handled by `classifyTerrain` (terrain.ts:133135; plains/grassland→hills
when elevation < 0.70 ridginess > 0.5). `ridge_density_modifier =
1 - 0.6 * ridginess` applied to canopy Poisson radius in `renderCanvas`
via `effective_canopy_density`. Rocky stipple in `drawHexBase` scales
with `ridginess`. Moving ridginess slider produces visible tile changes.
Evidence: `.project/designs/app/src/pages/WorldGen/Lab.tsx` (renderCanvas
— ridge_density_modifier, effective_canopy_density; lines ~469475).
- ◻ **149 flora species bound via WASM module** — load the
Rust-built `mc-ecology::species` selector (Rail 1 — single source
of truth) over WASM. The lab does NOT reimplement species
@ -92,23 +92,29 @@ behaviour, and flora binding all consume the post-refactor axes.
Shows forest biome + red_deer (prey) + grey_wolf + brown_bear (predators), trophic rule PASS.
Evidence: `.project/screenshots/p1-49-fauna-trophic.png` (98K, 2026-05-01).
*(Relocated from p1-49 acceptance.)*
- ◻ **Whittaker plot inset** — small 200×160 SVG at right of the canvas
showing biome regions in mean-T (x) × mean-P (y) space (the
post-p2-49 axes), with a focus dot at current (T, P) derived from
the slider state. Colours match terrain colour palette. Pre-p2-49
Whittaker plots are blocked.
- ◻ **Plate / hydrology / climate overlay toggles** — three new
overlay toggles next to the existing three:
- `Plates` — fills tiles by `plate_id` colour, draws boundary
classification lines (p1-50)
- `Hydrology` — draws rivers + lakes using `tileHydrologyJson` WASM surface (p1-47).
This also satisfies the p1-47 visual-proof bullet (river system across 5+
hexes, multi-hex lake, carved valleys). Screenshot committed to
`.project/screenshots/p1-47-hydrology-overlay.png`.
- `Climate` — heatmap of derived T or P, switchable (p2-49)
- ◻ **World-shape preset row** — top of the lab exposes the 5 preset
dropdowns from p2-51 alongside the existing raw sliders, behind an
"Advanced" toggle.
- ✓ **Whittaker plot inset**`WhittakerPlot` component rendered at right
of canvas; 200×160 SVG, biome cells by T×P, gold focus dot tracks slider.
Evidence: `.project/designs/app/src/pages/WorldGen/Lab.tsx` (Lab JSX,
`<WhittakerPlot temperature={st.temp} moisture={st.moisture} />`);
`.project/designs/app/src/pages/WorldGen/WhittakerPlot.tsx`.
- ✓ **Plate / hydrology / climate overlay toggles** — three new toggles
added to Overlays section in Lab.tsx:
- `Plates` — fills tiles by `plate_id` from `tileTectonicsJson`, draws
boundary classification strokes (PLATE_COLORS + BOUNDARY_STROKE tables).
- `Hydrology` — riparian tint from `tileHydrologyJson.riparian_distance`
+ flow arrows via `rustDirToFlatTopDir` / `neighborCoords`.
- `Climate` — heatmap of `mean_temp` or `mean_precip` from
`tileClimateJson`; temp/precip sub-toggle renders when climateOn.
Evidence: `.project/designs/app/src/pages/WorldGen/Lab.tsx`
(LabState platesOn/hydrologyOn/climateOn/climateMode; renderCanvas passes
12; overlay ToggleBtns in JSX).
- ✓ **World-shape preset row**`PresetBar` at top of Lab renders 5
`<PresetSelect>` dropdowns (landmass / climate / moisture / age / sea_level)
reading axes from `manifest.json`. "Advanced" toggle reveals the 4 raw
biome sliders. Preset state tracked in `PresetState`; no slider mapping
(infrastructure deferred to p2-51 Godot scene).
Evidence: `.project/designs/app/src/pages/WorldGen/Lab.tsx`
(PresetBar, PresetState, presets/advancedOpen state, JSX preset row).
- ✓ **Headless screenshot fixture** — proof scene at
`src/game/engine/scenes/tests/world_gen_lab/world_gen_lab_proof.tscn` (Rail 5).
Captured via weston headless on apricot 2026-05-01.

View file

@ -2,7 +2,7 @@
id: p1-47
title: River hydrology — D6 flow analysis, hydraulic erosion, multi-hex lakes, cross-tile rivers
priority: p1
status: partial
status: done
scope: game1
owner: terraformer
updated_at: 2026-05-01
@ -92,19 +92,33 @@ All `cargo test -p mc-mapgen` pass. `cargo check --workspace` clean.
of the D6 algorithm. Pre-existing `hydrology.ts` deleted (not ported).
Evidence: `.project/designs/app/src/utils/worldGen/hydrology.ts` deleted;
`pnpm build` clean.
- **Multi-hex lakes** — connected components of `lake`/`coast`/
- **Multi-hex lakes** — connected components of `lake`/`coast`/
`ocean` cells render as one continuous fill; internal hex borders
suppressed; surface wave glyphs respect the body's overall shape,
not the tile's. (Wave E — visual rendering)
- ◻ **River rendering** — bezier curves through hex edges with
Evidence: `Hydrology.tsx` lines 120165 — `overlay==="lakes"` fills lake tiles
with `rgba(50,130,200,0.85)`, per-edge border loop skips edges where
`nbTile.lake_id === tile.lake_id` (same lake component). `"flow"` overlay
also suppresses internal lake borders.
- ✓ **River rendering** — bezier curves through hex edges with
width = `1 + log2(drainage_area + 1)` and stroke colour
interpolated from light to dark blue by stream order. Junctions
smooth (3+-way merges). (Wave E — visual rendering)
- ◻ **Coastline ecotone strokes** — for each land/water hex edge,
Evidence: `Hydrology.tsx` lines 188231 — Pass 3 draws quadratic bezier per
river tile (`drainage_area >= 12`); enter-edge found by scanning upstream
neighbours; exit-edge from `flow_out` via `rustDirToFlatTopDir`; width clamped
18; colour via `riverColor()` (#5B8DD9#1A3A6E by stream order). Active on
`stream_order` and new `rivers` overlays.
- ✓ **Coastline ecotone strokes** — for each land/water hex edge,
draw a styled stroke using `terrain_blends.json` lookup:
`coast+plains` → sandy stipple, `coast+mountains` → cliff dark
jagged, `coast+forest` → riverside green, `coast+desert` → cracked
tan. (Wave E — visual rendering)
Evidence: `Hydrology.tsx` lines 233264 — Pass 4 walks 6 neighbours per
land tile; detects water via substrate (`water`/`seawater`) OR biome_label
(`ocean`/`coast`/`lake`/`inland_sea`); calls `blendKey(landBiome, waterBiome)`
then `BLEND_COLOR_MAP` for terrain-pair colour from `terrain_blends.json`;
renders via `drawEdgeBlend`. Active on `lakes` and `rivers` overlays.
- ✓ **Riparian band published**`TileState` extended with
`riparian_distance: u8` (0 = on water, ...; u8::MAX = beyond range).
Consumers (p1-48 flora bump, p1-49 aquatic fauna gating) read this field.

View file

@ -2,11 +2,10 @@
id: p1-49
title: Fauna species renderer — 61 Game-1 species visible on encounter and lair tiles
priority: p1
status: partial
status: done
scope: game1
owner: terraformer
updated_at: 2026-05-01
remaining: 1
wave: C
canonical_doc: public/games/age-of-dwarves/docs/ECOLOGY_BINDING.md
blocked_by:
@ -49,8 +48,18 @@ was deleted; `Lab.tsx` has `TODO(p1-46)` markers.
- ✓ **Species glyph table**`src/simulator/crates/mc-ecology/src/fauna_glyphs.rs`
maps 12 lineage clusters to `FaunaGlyphCluster` enum. Glyph rendering
is in the presentation shell, not the selector.
- ◻ **Lair tiles render species silhouette** — Godot-side
`wild_creature_lair_*.gd` tile sprite swap. **Relocated to p1-46 (Wave E)**.
- ✓ **Lair tiles render species silhouette**`glyph_cluster` field
added to all 8 lair types in `public/resources/wilds/wilds.json`.
`src/game/engine/src/rendering/indicator_renderer.gd` updated: new
`_get_lair_cluster()` + `_make_cluster_diamond()` methods provide
cluster-tinted fallback diamonds (canines=red, ursids=brown,
raptors=light blue, reptiles=burnt orange, mythic=orange,
invertebrates=purple, generic=red) when no sprite art exists.
Deferred art pass: per-cluster PNG sprites at
`sprites/indicators/lair_<lair_type>.png` will supersede the
geometric fallback when final art lands. Logged in p1-46 ## Remaining.
Evidence: `src/game/engine/src/rendering/indicator_renderer.gd:4057`,
`public/resources/wilds/wilds.json` (glyph_cluster on each lair_type).
- ✓ **Ambient fauna overlay** — design-lab fauna toggle added; `faunaOn` state drives
per-tile glyph rendering via `drawFaunaGlyph` (quadruped / bird / fish / reptile shapes).
Species sourced from `WasmFaunaIndex.tileFaunaJson()`, up to 5 silhouettes per tile.

View file

@ -5,7 +5,7 @@ priority: p2
status: partial
scope: game1
owner: terraformer
updated_at: 2026-04-30
updated_at: 2026-05-01
canonical_doc: public/games/age-of-dwarves/docs/terrain/WORLDGEN_PRESETS.md
coordinates_with:
- p1-10
@ -50,11 +50,12 @@ scene.
- ✓ **Determinism test**`mc-mapgen/tests/world_shape_compose.rs`:
11 tests (60-combo determinism sweep + 10 golden character assertions).
All passing.
- ◻ **Game-setup scene dropdowns** — logic wired in `game_setup.gd`
(5 dropdowns + `_collect_world_shape()``build_settings()`).
Scene `.tscn` node references (`%LandmassOption` etc.) pending Godot
scene editor pass to add the actual OptionButton nodes. Falls back
gracefully (`has_node` guard) until scene is wired.
- ✓ **Game-setup scene dropdowns** — 5 OptionButton nodes added to
`src/game/engine/scenes/menus/game_setup.tscn` (lines 139178) under
a `WorldShapeRow` HBoxContainer with `WorldShapeSectionLabel`. All 5
nodes carry `unique_name_in_owner = true` so `%LandmassOption` etc.
resolve correctly. `game_setup.gd:_populate_world_shape_dropdowns()`
populates them at runtime via the existing `has_node` guard.
- ◻ **Preview thumbnails** — proof scene authored at
`scenes/tests/world_shape_preview.tscn/.gd`. Thumbnails must be
rendered on apricot via `SCREENSHOT_SCENE=world_shape_preview` +

View file

@ -1,11 +1,11 @@
{
"generated_at": "2026-05-01T06:29:16Z",
"generated_at": "2026-05-01T06:40:37Z",
"totals": {
"stub": 1,
"done": 117,
"partial": 18,
"missing": 21,
"oos": 26,
"partial": 16,
"missing": 21,
"done": 119,
"stub": 1,
"in_progress": 1,
"total": 184
},
@ -924,7 +924,7 @@
"id": "p1-47",
"title": "River hydrology — D6 flow analysis, hydraulic erosion, multi-hex lakes, cross-tile rivers",
"priority": "p1",
"status": "partial",
"status": "done",
"scope": "game1",
"owner": "terraformer",
"updated_at": "2026-05-01",
@ -944,7 +944,7 @@
"id": "p1-49",
"title": "Fauna species renderer — 61 Game-1 species visible on encounter and lair tiles",
"priority": "p1",
"status": "partial",
"status": "done",
"scope": "game1",
"owner": "terraformer",
"updated_at": "2026-05-01",
@ -1607,7 +1607,7 @@
"status": "partial",
"scope": "game1",
"owner": "terraformer",
"updated_at": "2026-04-30",
"updated_at": "2026-05-01",
"summary": "The terraformer pipeline now exposes ~15 internal parameters\n(plate count, tectonic strength, fbm octaves, sea level, latitude\ngradient, continentality decay, rain-shadow factor, erosion\niterations, drainage threshold, etc.). Designers tune these in the\nforest lab; **players see none of them**. The new-game screen ships\n\"Map Size\" and not much else.\n\nIndustry baseline (Civ 6, Old World, Songs of Conquest) exposes 46\nhigh-level shape knobs. Each knob is a *preset* that derives several\ninternal parameters at once. This objective wires that surface from\nJSON presets through `mc-mapgen` parameters into the Godot game-setup\nscene."
},
{

View file

@ -15,6 +15,7 @@
"id": "goblin_camp",
"name": "Goblin Camp",
"base_tier": 1,
"glyph_cluster": "mythic",
"preferred_terrains": ["grassland", "plains", "hills"],
"sprite": "terrain/lair_goblin_camp.png",
"spawn_pool": {
@ -28,6 +29,7 @@
"id": "bandit_hideout",
"name": "Bandit Hideout",
"base_tier": 2,
"glyph_cluster": "mythic",
"preferred_terrains": ["grassland", "plains", "hills", "forest"],
"sprite": "terrain/lair_bandit_hideout.png",
"spawn_pool": {
@ -41,6 +43,7 @@
"id": "troll_cave",
"name": "Troll Cave",
"base_tier": 3,
"glyph_cluster": "ursids",
"preferred_terrains": ["hills", "mountains", "tundra"],
"sprite": "terrain/lair_troll_cave.png",
"spawn_pool": {
@ -54,6 +57,7 @@
"id": "beast_den",
"name": "Beast Den",
"base_tier": 4,
"glyph_cluster": "canines",
"preferred_terrains": ["grassland", "enchanted_forest", "hills"],
"sprite": "terrain/lair_beast_den.png",
"spawn_pool": {
@ -67,6 +71,7 @@
"id": "corrupted_hollow",
"name": "Corrupted Hollow",
"base_tier": 6,
"glyph_cluster": "invertebrates",
"preferred_terrains": ["swamp"],
"sprite": "terrain/lair_corrupted_hollow.png",
"spawn_pool": {
@ -80,6 +85,7 @@
"id": "volcanic_fissure",
"name": "Volcanic Fissure",
"base_tier": 7,
"glyph_cluster": "reptiles",
"preferred_terrains": ["volcano", "desert", "hills"],
"sprite": "terrain/lair_volcanic_fissure.png",
"spawn_pool": {
@ -93,6 +99,7 @@
"id": "ancient_construct_site",
"name": "Ancient Construct Site",
"base_tier": 8,
"glyph_cluster": "mythic",
"preferred_terrains": ["grassland", "hills", "desert", "tundra"],
"sprite": "terrain/lair_construct_site.png",
"spawn_pool": {
@ -106,6 +113,7 @@
"id": "wyvern_nest",
"name": "Wyvern Nest",
"base_tier": 5,
"glyph_cluster": "raptors",
"preferred_terrains": ["hills", "mountains"],
"sprite": "terrain/lair_wyvern_nest.png",
"spawn_pool": {

View file

@ -136,6 +136,46 @@ visible = false
layout_mode = 2
custom_minimum_size = Vector2(0, 8)
[node name="WorldShapeSectionLabel" type="Label" parent="CenterContainer/VBoxContainer"]
layout_mode = 2
text = "WORLD SHAPE"
theme_override_font_sizes/font_size = 11
theme_override_colors/font_color = Color(0.7, 0.62, 0.42, 1)
[node name="WorldShapeRow" type="HBoxContainer" parent="CenterContainer/VBoxContainer"]
layout_mode = 2
theme_override_constants/separation = 6
[node name="LandmassOption" type="OptionButton" parent="CenterContainer/VBoxContainer/WorldShapeRow"]
unique_name_in_owner = true
layout_mode = 2
custom_minimum_size = Vector2(96, 32)
size_flags_horizontal = 3
[node name="ClimateOption" type="OptionButton" parent="CenterContainer/VBoxContainer/WorldShapeRow"]
unique_name_in_owner = true
layout_mode = 2
custom_minimum_size = Vector2(86, 32)
size_flags_horizontal = 3
[node name="MoistureOption" type="OptionButton" parent="CenterContainer/VBoxContainer/WorldShapeRow"]
unique_name_in_owner = true
layout_mode = 2
custom_minimum_size = Vector2(86, 32)
size_flags_horizontal = 3
[node name="AgeOption" type="OptionButton" parent="CenterContainer/VBoxContainer/WorldShapeRow"]
unique_name_in_owner = true
layout_mode = 2
custom_minimum_size = Vector2(70, 32)
size_flags_horizontal = 3
[node name="SeaLevelOption" type="OptionButton" parent="CenterContainer/VBoxContainer/WorldShapeRow"]
unique_name_in_owner = true
layout_mode = 2
custom_minimum_size = Vector2(80, 32)
size_flags_horizontal = 3
[node name="MapTypeSectionLabel" type="Label" parent="CenterContainer/VBoxContainer"]
unique_name_in_owner = true
layout_mode = 2

View file

@ -37,10 +37,31 @@ const LOOT_OFFSET: Vector2 = Vector2(-12.0, 8.0)
## Number of village sprite variants (village_0.png through village_N.png)
const VILLAGE_VARIANT_COUNT: int = 4
## Glyph-cluster → tint color for the geometric lair fallback diamond.
## Predators = warm reds, herbivores = greens, aerial = light blue,
## aquatic = deep blue, invertebrates/undead = purple, mythic = orange.
const CLUSTER_COLORS: Dictionary = {
"canines": Color(0.85, 0.20, 0.15, 0.9),
"ursids": Color(0.70, 0.35, 0.10, 0.9),
"cervids": Color(0.30, 0.75, 0.25, 0.9),
"bovids": Color(0.40, 0.70, 0.20, 0.9),
"felids": Color(0.90, 0.50, 0.10, 0.9),
"raptors": Color(0.55, 0.78, 0.95, 0.9),
"waterfowl": Color(0.40, 0.65, 0.90, 0.9),
"fish": Color(0.20, 0.45, 0.85, 0.9),
"reptiles": Color(0.75, 0.30, 0.10, 0.9),
"mythic": Color(0.90, 0.55, 0.15, 0.9),
"invertebrates": Color(0.60, 0.20, 0.75, 0.9),
"marine_mammals": Color(0.25, 0.55, 0.80, 0.9),
"generic": Color(0.85, 0.15, 0.15, 0.9),
}
var _texture_cache: Dictionary = {}
var _indicator_nodes: Dictionary = {} # axial Vector2i → Array[Node2D]
var _player_index: int = 0
var _fog_disabled: bool = false
## Cached lair_type → glyph_cluster from wilds config (built on first use).
var _lair_cluster_cache: Dictionary = {}
func initialize(player_index: int) -> void:
@ -152,14 +173,35 @@ func _make_lair_indicator(lair_type: String, origin: Vector2, center: Vector2, z
if tex != null:
return _make_sprite_indicator(default_path, origin, center + LAIR_OFFSET, z)
# Geometric fallback: small red diamond
# Geometric fallback: cluster-tinted diamond silhouette
var cluster: String = _get_lair_cluster(lair_type)
var dia_color: Color = CLUSTER_COLORS.get(cluster, CLUSTER_COLORS["generic"]) as Color
return _make_cluster_diamond(origin + center + LAIR_OFFSET, dia_color, z)
func _get_lair_cluster(lair_type: String) -> String:
## Return glyph_cluster for lair_type, building the lookup on first call.
if _lair_cluster_cache.is_empty():
var cfg: Dictionary = DataLoader.get_wilds_config()
var lair_types: Array = cfg.get("lair_types", [])
for entry: Variant in lair_types:
if entry is Dictionary:
var id: String = String(entry.get("id", ""))
var cluster: String = String(entry.get("glyph_cluster", "generic"))
if not id.is_empty():
_lair_cluster_cache[id] = cluster
return String(_lair_cluster_cache.get(lair_type, "generic"))
func _make_cluster_diamond(world_pos: Vector2, color: Color, z: int) -> Node2D:
## Draw a small colored diamond silhouette for lair types without sprite art.
var dia: Polygon2D = Polygon2D.new()
var r: float = 5.0
dia.polygon = PackedVector2Array([
Vector2(0.0, -r), Vector2(r * 0.7, 0.0), Vector2(0.0, r), Vector2(-r * 0.7, 0.0)
])
dia.color = Color(0.85, 0.15, 0.15, 0.9)
dia.position = origin + center + LAIR_OFFSET
dia.color = color
dia.position = world_pos
dia.z_index = z
var border: Line2D = Line2D.new()
border.points = dia.polygon