diff --git a/.project/designs/app/src/pages/WorldGen/Hydrology.tsx b/.project/designs/app/src/pages/WorldGen/Hydrology.tsx index af769751..2027fa95 100644 --- a/.project/designs/app/src/pages/WorldGen/Hydrology.tsx +++ b/.project/designs/app/src/pages/WorldGen/Hydrology.tsx @@ -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(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): void { @@ -166,7 +327,7 @@ export function HydrologyPage(): React.ReactElement { v.toFixed(0)} />
Overlay - {(["flow", "drainage", "stream_order", "riparian"] as const).map((id) => ( + {(["flow", "drainage", "stream_order", "riparian", "lakes", "rivers"] as const).map((id) => (
} diff --git a/.project/designs/app/src/pages/WorldGen/Lab.tsx b/.project/designs/app/src/pages/WorldGen/Lab.tsx index 9b347de5..eeadd618 100644 --- a/.project/designs/app/src/pages/WorldGen/Lab.tsx +++ b/.project/designs/app/src/pages/WorldGen/Lab.tsx @@ -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 = { + 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 = { @@ -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(null); const [st, updateSt] = useState(DEFAULT); const [wasmMod, setWasmMod] = useState(cachedWasmMod); + const [presets, setPresets] = useState(DEFAULT_PRESETS); + const [advancedOpen, setAdvancedOpen] = useState(false); const floraIdxRef = useRef(null); const faunaIdxRef = useRef(null); @@ -850,15 +1070,36 @@ export function Lab(): React.ReactElement { + + {(["landmass", "climate", "moisture", "age", "sea_level"] as const).map(axis => ( + + {axis.replace("_", " ")} + setPresets(p => ({ ...p, [axis]: e.target.value }))} + > + {(PRESET_AXES[axis] ?? []).map(opt => ( + + ))} + + + ))} + setAdvancedOpen(o => !o)}> + {advancedOpen ? "Hide advanced" : "Advanced"} + + + - - Biome classifiers - set({ elevation: v })} /> - set({ moisture: v })} /> - set({ temp: v })} /> - set({ ridginess: v })} /> - + {advancedOpen && ( + + Biome classifiers + set({ elevation: v })} /> + set({ moisture: v })} /> + set({ temp: v })} /> + set({ ridginess: v })} /> + + )} Overlays @@ -898,6 +1139,50 @@ export function Lab(): React.ReactElement { {wasmGrid === null && (WASM pending)} + + set({ platesOn: !st.platesOn })} + title={wasmGrid === null ? "WASM not loaded — plates unavailable" : undefined} + > + Plates + {wasmGrid === null && (WASM pending)} + + + + set({ hydrologyOn: !st.hydrologyOn })} + title={wasmGrid === null ? "WASM not loaded — hydrology unavailable" : undefined} + > + Hydrology + {wasmGrid === null && (WASM pending)} + + + + set({ climateOn: !st.climateOn })} + title={wasmGrid === null ? "WASM not loaded — climate unavailable" : undefined} + > + Climate + {wasmGrid === null && (WASM pending)} + + {st.climateOn && ( +
+ {(["temp", "precip"] as const).map(mode => ( + set({ climateMode: mode })} + style={{ fontSize: 10, padding: "2px 8px" }} + > + {mode} + + ))} +
+ )} +
diff --git a/.project/objectives/README.md b/.project/objectives/README.md index 232d63c5..763586d8 100644 --- a/.project/objectives/README.md +++ b/.project/objectives/README.md @@ -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** | @@ -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) diff --git a/.project/objectives/p1-46-design-lab-terrain-dimensions.md b/.project/objectives/p1-46-design-lab-terrain-dimensions.md index f13ebf35..ed2c8446 100644 --- a/.project/objectives/p1-46-design-lab-terrain-dimensions.md +++ b/.project/objectives/p1-46-design-lab-terrain-dimensions.md @@ -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:133–135; 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 ~469–475). - ◻ **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, + ``); + `.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 + 1–2; overlay ToggleBtns in JSX). +- ✓ **World-shape preset row** — `PresetBar` at top of Lab renders 5 + `` 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. diff --git a/.project/objectives/p1-47-river-hydrology-network.md b/.project/objectives/p1-47-river-hydrology-network.md index fbcefcdc..fa2c76d8 100644 --- a/.project/objectives/p1-47-river-hydrology-network.md +++ b/.project/objectives/p1-47-river-hydrology-network.md @@ -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 120–165 — `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 188–231 — 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 + 1–8; 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 233–264 — 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. diff --git a/.project/objectives/p1-49-fauna-species-renderer.md b/.project/objectives/p1-49-fauna-species-renderer.md index 4882ba4a..dda016c2 100644 --- a/.project/objectives/p1-49-fauna-species-renderer.md +++ b/.project/objectives/p1-49-fauna-species-renderer.md @@ -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_.png` will supersede the + geometric fallback when final art lands. Logged in p1-46 ## Remaining. + Evidence: `src/game/engine/src/rendering/indicator_renderer.gd:40–57`, + `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. diff --git a/.project/objectives/p2-51-world-shape-knobs.md b/.project/objectives/p2-51-world-shape-knobs.md index 1b9eb7ce..dd44f45f 100644 --- a/.project/objectives/p2-51-world-shape-knobs.md +++ b/.project/objectives/p2-51-world-shape-knobs.md @@ -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 139–178) 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` + diff --git a/public/games/age-of-dwarves/data/objectives.json b/public/games/age-of-dwarves/data/objectives.json index 49fc2cc2..3a771dad 100644 --- a/public/games/age-of-dwarves/data/objectives.json +++ b/public/games/age-of-dwarves/data/objectives.json @@ -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 4–6\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." }, { diff --git a/public/resources/wilds/wilds.json b/public/resources/wilds/wilds.json index 4dd06a0a..7f597ee2 100644 --- a/public/resources/wilds/wilds.json +++ b/public/resources/wilds/wilds.json @@ -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": { diff --git a/src/game/engine/scenes/menus/game_setup.tscn b/src/game/engine/scenes/menus/game_setup.tscn index f95a569f..e83b6319 100644 --- a/src/game/engine/scenes/menus/game_setup.tscn +++ b/src/game/engine/scenes/menus/game_setup.tscn @@ -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 diff --git a/src/game/engine/src/rendering/indicator_renderer.gd b/src/game/engine/src/rendering/indicator_renderer.gd index d991b45c..83f19bca 100644 --- a/src/game/engine/src/rendering/indicator_renderer.gd +++ b/src/game/engine/src/rendering/indicator_renderer.gd @@ -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