feat(world-gen): add forest lab tab integration

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-04-30 15:20:01 -04:00
parent a5dcc0a9b5
commit 3b0bdb0ba1
3 changed files with 459 additions and 1 deletions

View file

@ -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" && <TerrainCatalog />}
{tab === "transitions" && <BiomeTransitions />}
{tab === "noise" && <NoiseAnatomy />}
{tab === "forest" && <ForestLab />}
</Page>
);
}

View file

@ -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<HTMLCanvasElement>(null);
useEffect(() => {
const ctx = ref.current?.getContext("2d");
if (ctx) draw(ctx);
}, [draw]);
return <StyledCanvas ref={ref} width={W} height={H} />;
}
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 (
<Wrap>
<div>
<SectionTitle>Forest Lab</SectionTitle>
<SectionNote>
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.
</SectionNote>
</div>
<div>
<SectionNote style={{ marginBottom: 12 }}>
<strong style={{ color: "#f2d973" }}>Single tree anatomy</strong> 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.
</SectionNote>
<Row>
{([12, 18, 26] as const).map((r) => (
<Panel key={r}>
<PanelLabel>{r === 12 ? "Small (r=12)" : r === 18 ? "Medium (r=18)" : "Large (r=26)"}</PanelLabel>
<ArrangementCanvas draw={(ctx) => {
ctx.fillStyle = "rgb(118,185,72)";
ctx.fillRect(0, 0, W, H);
drawTree(ctx, W / 2, H / 2, r);
}} />
</Panel>
))}
</Row>
</div>
<div>
<SectionNote style={{ marginBottom: 12 }}>
<strong style={{ color: "#f2d973" }}>Arrangements</strong> four
strategies for placing trees within a patch of grassland.
</SectionNote>
<Row>
{ARRANGEMENTS.map((a) => (
<Panel key={a.label}>
<PanelLabel>{a.label}</PanelLabel>
<ArrangementCanvas draw={a.draw} />
<PanelSub>{a.sub}</PanelSub>
</Panel>
))}
</Row>
</div>
</Wrap>
);
}

75
tools/audit-id-refs.py Normal file
View file

@ -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())