feat(world-gen): ✨ add forest lab tab integration
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
a5dcc0a9b5
commit
3b0bdb0ba1
3 changed files with 459 additions and 1 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
380
.project/designs/app/src/pages/WorldGen/ForestLab.tsx
Normal file
380
.project/designs/app/src/pages/WorldGen/ForestLab.tsx
Normal 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
75
tools/audit-id-refs.py
Normal 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())
|
||||
Loading…
Add table
Reference in a new issue