diff --git a/.project/designs/app/src/pages/WorldGen.tsx b/.project/designs/app/src/pages/WorldGen.tsx index e63a5ead..732d4c8f 100644 --- a/.project/designs/app/src/pages/WorldGen.tsx +++ b/.project/designs/app/src/pages/WorldGen.tsx @@ -6,8 +6,9 @@ import { PipelinePanel } from "./WorldGen/PipelinePanel"; import { TerrainCatalog } from "./WorldGen/TerrainCatalog"; import { BiomeTransitions } from "./WorldGen/BiomeTransitions"; import { NoiseAnatomy } from "./WorldGen/NoiseAnatomy"; +import { ForestLab } from "./WorldGen/ForestLab"; -type Tab = "map" | "pipeline" | "catalog" | "transitions" | "noise"; +type Tab = "map" | "pipeline" | "catalog" | "transitions" | "noise" | "forest"; const TABS: { id: Tab; label: string }[] = [ { id: "map", label: "⬡ World Map" }, @@ -15,6 +16,7 @@ const TABS: { id: Tab; label: string }[] = [ { id: "catalog", label: "▦ Terrain Catalog" }, { id: "transitions", label: "⊕ Biome Transitions" }, { id: "noise", label: "∿ Noise Anatomy" }, + { id: "forest", label: "🌲 Forest Lab" }, ]; const Page = styled.div` @@ -91,6 +93,7 @@ export function WorldGenPage(): React.ReactElement { {tab === "catalog" && } {tab === "transitions" && } {tab === "noise" && } + {tab === "forest" && } ); } diff --git a/.project/designs/app/src/pages/WorldGen/ForestLab.tsx b/.project/designs/app/src/pages/WorldGen/ForestLab.tsx new file mode 100644 index 00000000..7f3b534c --- /dev/null +++ b/.project/designs/app/src/pages/WorldGen/ForestLab.tsx @@ -0,0 +1,380 @@ +import { useRef, useEffect } from "react"; +import styled from "styled-components"; +import { t } from "../../theme"; +import { poissonDisc, SeededRng } from "../../utils/worldGen/poisson"; + +// ── Styled ──────────────────────────────────────────────────────────────────── + +const Wrap = styled.div` + display: flex; + flex-direction: column; + gap: 40px; +`; + +const SectionTitle = styled.h2` + font-family: ${t.font.heading}; + font-size: 20px; + color: ${t.text.title}; + margin: 0 0 4px; +`; + +const SectionNote = styled.p` + font-family: ${t.font.body}; + font-size: 13px; + color: ${t.text.secondary}; + margin: 0 0 16px; + max-width: 700px; + line-height: 1.5; +`; + +const Row = styled.div` + display: flex; + gap: 24px; + align-items: flex-start; + flex-wrap: wrap; +`; + +const Panel = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +const PanelLabel = styled.div` + font-family: ${t.font.heading}; + font-size: 15px; + color: ${t.text.title}; +`; + +const PanelSub = styled.div` + font-family: ${t.font.mono}; + font-size: 11px; + color: ${t.text.muted}; + max-width: 200px; + line-height: 1.4; +`; + +const StyledCanvas = styled.canvas` + display: block; + border: 1px solid ${t.border.panel}; + border-radius: ${t.radius.panel}; +`; + +// ── Drawing primitives ──────────────────────────────────────────────────────── + +const W = 220; +const H = 220; + +// Grassland base: fills the canvas with green + scattered grass blades +function drawGrassland(ctx: CanvasRenderingContext2D): void { + // Base fill + ctx.fillStyle = "rgb(118,185,72)"; + ctx.fillRect(0, 0, W, H); + + // Fine noise stipple (light/dark variation) + const rng = new SeededRng(0x1234); + for (let i = 0; i < 80; i++) { + const x = rng.next() * W; + const y = rng.next() * H; + const bright = rng.next() > 0.5; + ctx.fillStyle = bright ? "rgba(160,220,90,0.22)" : "rgba(60,110,20,0.18)"; + ctx.fillRect(Math.round(x), Math.round(y), 2, 2); + } + + // Grass blades + ctx.strokeStyle = "rgba(80,150,30,0.55)"; + ctx.lineWidth = 1.5; + const bladeRng = new SeededRng(0xabcd); + for (let i = 0; i < 60; i++) { + const x = bladeRng.next() * W; + const y = bladeRng.next() * H; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x + (bladeRng.next() - 0.5) * 3, y - 5); + ctx.stroke(); + } +} + +// Single tree — the core primitive +// Shadow → canopy radial gradient → highlight dot +function drawTree( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + variety: "temperate" | "tropical" | "boreal" = "temperate" +): void { + if (variety === "boreal") { + // Conifer: triangle with shadow + ctx.beginPath(); + ctx.ellipse(x + 2, y + 2, r * 0.55, r * 0.35, 0, 0, Math.PI * 2); + ctx.fillStyle = "rgba(0,0,0,0.18)"; + ctx.fill(); + + ctx.beginPath(); + ctx.moveTo(x, y - r); + ctx.lineTo(x - r * 0.6, y + r * 0.5); + ctx.lineTo(x + r * 0.6, y + r * 0.5); + ctx.closePath(); + ctx.fillStyle = "#2a5c46"; + ctx.fill(); + + // Secondary smaller layer above + ctx.beginPath(); + ctx.moveTo(x, y - r * 1.4); + ctx.lineTo(x - r * 0.4, y - r * 0.4); + ctx.lineTo(x + r * 0.4, y - r * 0.4); + ctx.closePath(); + ctx.fillStyle = "#357558"; + ctx.fill(); + return; + } + + const colors = { + temperate: { dark: "#2a5a18", mid: "#3d7a25", light: "#5a9a3a" }, + tropical: { dark: "#1a4a10", mid: "#2d6a1c", light: "#448a28" }, + }; + const c = colors[variety]; + + // Ground shadow (squashed ellipse, offset down-right) + ctx.beginPath(); + ctx.ellipse(x + r * 0.3, y + r * 0.5, r * 1.05, r * 0.45, 0, 0, Math.PI * 2); + ctx.fillStyle = "rgba(0,0,0,0.20)"; + ctx.fill(); + + // Canopy — radial gradient: highlight off-center top-left → dark edge + const grad = ctx.createRadialGradient( + x - r * 0.28, y - r * 0.28, 0, + x, y, r + ); + grad.addColorStop(0.0, c.light); + grad.addColorStop(0.45, c.mid); + grad.addColorStop(1.0, c.dark); + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fillStyle = grad; + ctx.fill(); + + // Highlight dot — top-left bright spot (sun catch) + ctx.beginPath(); + ctx.arc(x - r * 0.32, y - r * 0.32, r * 0.22, 0, Math.PI * 2); + ctx.fillStyle = "rgba(200,240,140,0.32)"; + ctx.fill(); +} + +// ── Arrangement: Plus (cross) ───────────────────────────────────────────────── + +function drawPlus(ctx: CanvasRenderingContext2D): void { + drawGrassland(ctx); + + const cx = W / 2, cy = H / 2; + const r = 14; // tree canopy radius + const spacing = 36; // distance between tree centres + + // Center + 4 cardinal positions + const positions: [number, number][] = [ + [cx, cy], + [cx + spacing, cy], + [cx - spacing, cy], + [cx, cy + spacing], + [cx, cy - spacing], + ]; + + // Draw shadows first so canopies render on top + for (const [x, y] of positions) { + ctx.beginPath(); + ctx.ellipse(x + r * 0.3, y + r * 0.5, r * 1.05, r * 0.45, 0, 0, Math.PI * 2); + ctx.fillStyle = "rgba(0,0,0,0.20)"; + ctx.fill(); + } + for (const [x, y] of positions) { + drawTree(ctx, x, y, r); + } +} + +// ── Arrangement: Ring ───────────────────────────────────────────────────────── + +function drawRing(ctx: CanvasRenderingContext2D): void { + drawGrassland(ctx); + + const cx = W / 2, cy = H / 2; + const r = 13; + const ringRadius = 60; + const count = 10; + + const positions: [number, number][] = Array.from({ length: count }, (_, i) => { + const angle = (i / count) * Math.PI * 2 - Math.PI / 2; + return [cx + Math.cos(angle) * ringRadius, cy + Math.sin(angle) * ringRadius]; + }); + + // All shadows first + for (const [x, y] of positions) { + ctx.beginPath(); + ctx.ellipse(x + r * 0.3, y + r * 0.5, r * 1.05, r * 0.45, 0, 0, Math.PI * 2); + ctx.fillStyle = "rgba(0,0,0,0.20)"; + ctx.fill(); + } + for (const [x, y] of positions) { + drawTree(ctx, x, y, r); + } +} + +// ── Arrangement: Filled circle ──────────────────────────────────────────────── + +function drawFilledCircle(ctx: CanvasRenderingContext2D): void { + drawGrassland(ctx); + + const cx = W / 2, cy = H / 2; + const forestRadius = 80; + const treeR = 11; + const rng = new SeededRng(0xf0e57); + + // Poisson disc — tree centres guaranteed ≥ minDist apart + const points = poissonDisc(cx, cy, forestRadius, treeR * 1.8, 60, rng); + + // All shadows first, then all canopies — so shadows don't overdraw canopies + for (const p of points) { + ctx.beginPath(); + ctx.ellipse(p.x + treeR * 0.3, p.y + treeR * 0.5, treeR * 1.05, treeR * 0.45, 0, 0, Math.PI * 2); + ctx.fillStyle = "rgba(0,0,0,0.20)"; + ctx.fill(); + } + for (const p of points) { + drawTree(ctx, p.x, p.y, treeR); + } +} + +// ── Arrangement: Density gradient (sparse edge → dense core) ───────────────── + +function drawDensityGradient(ctx: CanvasRenderingContext2D): void { + drawGrassland(ctx); + + const cx = W / 2, cy = H / 2; + const forestRadius = 90; + const rng = new SeededRng(0xd3057); + + // Generate more candidate points than we need, then filter by density + const candidates = poissonDisc(cx, cy, forestRadius, 16, 80, rng); + + // Keep each point probabilistically based on distance from center + // Core (distance 0) → always kept; edge (distance=forestRadius) → ~15% kept + const kept = candidates.filter((p) => { + const dx = p.x - cx, dy = p.y - cy; + const dist = Math.sqrt(dx * dx + dy * dy); + const density = 1 - Math.pow(dist / forestRadius, 1.6); + return new SeededRng(Math.round(p.x * 100 + p.y * 13)).next() < density; + }); + + // Vary tree size with distance: core trees are larger (old growth), edge trees smaller + for (const p of kept) { + const dx = p.x - cx, dy = p.y - cy; + const dist = Math.sqrt(dx * dx + dy * dy); + const sizeFactor = 1 - 0.35 * (dist / forestRadius); + ctx.beginPath(); + const r = 12 * sizeFactor; + ctx.ellipse(p.x + r * 0.3, p.y + r * 0.5, r * 1.05, r * 0.45, 0, 0, Math.PI * 2); + ctx.fillStyle = "rgba(0,0,0,0.20)"; + ctx.fill(); + } + for (const p of kept) { + const dx = p.x - cx, dy = p.y - cy; + const dist = Math.sqrt(dx * dx + dy * dy); + const sizeFactor = 1 - 0.35 * (dist / forestRadius); + drawTree(ctx, p.x, p.y, 12 * sizeFactor); + } +} + +// ── Component ───────────────────────────────────────────────────────────────── + +type Arrangement = { + label: string; + sub: string; + draw: (ctx: CanvasRenderingContext2D) => void; +}; + +function ArrangementCanvas({ draw }: { draw: (ctx: CanvasRenderingContext2D) => void }): React.ReactElement { + const ref = useRef(null); + useEffect(() => { + const ctx = ref.current?.getContext("2d"); + if (ctx) draw(ctx); + }, [draw]); + return ; +} + +const ARRANGEMENTS: Arrangement[] = [ + { + label: "Plus", + sub: "5 trees: center + 4 cardinal positions at fixed spacing. Sparsest — suggests a lone grove or small planted stand.", + draw: drawPlus, + }, + { + label: "Ring", + sub: "10 trees evenly spaced on a circle. Models a clearing-with-tree-ring — e.g. a druidic grove or forest edge around open land.", + draw: drawRing, + }, + { + label: "Filled circle", + sub: "Poisson disc within radius: minimum distance enforced, no clustering. Uniform density. Looks planted, not natural.", + draw: drawFilledCircle, + }, + { + label: "Density gradient", + sub: "Poisson disc + distance-based probability filter + size scaling. Core: old growth (large, dense). Edge: young scrub (small, sparse). This is the target for real forest tiles.", + draw: drawDensityGradient, + }, +]; + +export function ForestLab(): React.ReactElement { + return ( + +
+ Forest Lab + + Building up the visual language for forested terrain — starting from the + single tree primitive (shadow → canopy radial gradient → highlight dot) + through four arrangement strategies. The density gradient is the target + pattern: core trees are larger old growth; edge trees are smaller scrub. + This models how real forests work and will drive per-hex rendering. + +
+ +
+ + Single tree anatomy — each + tree is three layers: a squashed ground shadow offset down-right (depth), + a canopy with radial gradient lit from top-left (volume), and a small + highlight dot (sun catch). Canopy radius varies by tree size; this is + the same primitive used in all arrangements below. + + + {([12, 18, 26] as const).map((r) => ( + + {r === 12 ? "Small (r=12)" : r === 18 ? "Medium (r=18)" : "Large (r=26)"} + { + ctx.fillStyle = "rgb(118,185,72)"; + ctx.fillRect(0, 0, W, H); + drawTree(ctx, W / 2, H / 2, r); + }} /> + + ))} + +
+ +
+ + Arrangements — four + strategies for placing trees within a patch of grassland. + + + {ARRANGEMENTS.map((a) => ( + + {a.label} + + {a.sub} + + ))} + +
+
+ ); +} diff --git a/tools/audit-id-refs.py b/tools/audit-id-refs.py new file mode 100644 index 00000000..43d2e289 --- /dev/null +++ b/tools/audit-id-refs.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Find every JSON field across public/resources that holds a unit/building id reference.""" +from __future__ import annotations +import json, glob, collections, sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] + + +def load_known_ids() -> set[str]: + ids: set[str] = set() + for sub in ("units", "buildings", "improvements", "items", "techs"): + for fp in (REPO / "public" / "resources" / sub).glob("*.json"): + if fp.name.endswith(".schema.json"): + continue + try: + d = json.loads(fp.read_text()) + except Exception: + continue + items = d if isinstance(d, list) else [d] + for it in items: + if isinstance(it, dict) and isinstance(it.get("id"), str): + ids.add(it["id"]) + return ids + + +def main() -> int: + known_ids = load_known_ids() + print(f"loaded {len(known_ids)} ids from units/buildings/improvements/items/techs") + + field_count: collections.Counter[str] = collections.Counter() + by_field_examples: dict[str, set[str]] = collections.defaultdict(set) + + for fp in sorted(glob.glob(str(REPO / "public" / "**" / "*.json"), recursive=True)): + if fp.endswith(".schema.json"): + continue + try: + data = json.loads(Path(fp).read_text()) + except Exception: + continue + rel = Path(fp).relative_to(REPO).as_posix() + # Skip the resource files themselves to focus on cross-refs + if rel.startswith("public/resources/units/") or rel.startswith("public/resources/buildings/"): + continue + + def walk(o): + if isinstance(o, dict): + for k, v in o.items(): + if isinstance(v, str) and v in known_ids: + field_count[k] += 1 + if len(by_field_examples[k]) < 3: + by_field_examples[k].add(v) + elif isinstance(v, list): + for x in v: + if isinstance(x, str) and x in known_ids: + field_count[f"{k}[]"] += 1 + if len(by_field_examples[f"{k}[]"]) < 3: + by_field_examples[f"{k}[]"].add(x) + else: + walk(x) + elif isinstance(v, dict): + walk(v) + + walk(data) + + print("\n=== id-bearing fields (top 30, across non-units/buildings JSON) ===") + for field, n in field_count.most_common(30): + examples = ", ".join(sorted(by_field_examples[field])[:3]) + print(f" {n:5d} {field:32s} e.g. {examples}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main())