diff --git a/.project/designs/app/src/App.tsx b/.project/designs/app/src/App.tsx index f09a66cc..376da97e 100644 --- a/.project/designs/app/src/App.tsx +++ b/.project/designs/app/src/App.tsx @@ -49,7 +49,8 @@ export function App(): React.ReactElement { } /> } /> } /> - } /> + } /> + } /> ); diff --git a/.project/designs/app/src/pages/WorldGen.tsx b/.project/designs/app/src/pages/WorldGen.tsx index 732d4c8f..39a42378 100644 --- a/.project/designs/app/src/pages/WorldGen.tsx +++ b/.project/designs/app/src/pages/WorldGen.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; import styled from "styled-components"; import { t } from "../theme"; import { MapPanel } from "./WorldGen/MapPanel"; @@ -8,7 +8,9 @@ import { BiomeTransitions } from "./WorldGen/BiomeTransitions"; import { NoiseAnatomy } from "./WorldGen/NoiseAnatomy"; import { ForestLab } from "./WorldGen/ForestLab"; -type Tab = "map" | "pipeline" | "catalog" | "transitions" | "noise" | "forest"; +type Tab = "map" | "pipeline" | "catalog" | "transitions" | "noise" | "forest-lab"; + +const VALID_TABS: readonly Tab[] = ["map", "pipeline", "catalog", "transitions", "noise", "forest-lab"]; const TABS: { id: Tab; label: string }[] = [ { id: "map", label: "โฌก World Map" }, @@ -16,7 +18,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" }, + { id: "forest-lab", label: "๐ŸŒฒ Forest Lab" }, ]; const Page = styled.div` @@ -69,7 +71,11 @@ const TabBtn = styled.button<{ $active: boolean }>` `; export function WorldGenPage(): React.ReactElement { - const [tab, setTab] = useState("map"); + const { tab: tabParam } = useParams<{ tab?: string }>(); + const navigate = useNavigate(); + const tab: Tab = VALID_TABS.includes(tabParam as Tab) ? (tabParam as Tab) : "map"; + + const switchTab = (t: Tab) => navigate(`/world-gen/${t}`); return ( @@ -82,7 +88,7 @@ export function WorldGenPage(): React.ReactElement { {TABS.map(({ id, label }) => ( - setTab(id)}> + switchTab(id)}> {label} ))} @@ -93,7 +99,7 @@ export function WorldGenPage(): React.ReactElement { {tab === "catalog" && } {tab === "transitions" && } {tab === "noise" && } - {tab === "forest" && } + {tab === "forest-lab" && } ); } diff --git a/.project/designs/app/src/pages/WorldGen/ForestLab.tsx b/.project/designs/app/src/pages/WorldGen/ForestLab.tsx index 7f3b534c..8f9251bb 100644 --- a/.project/designs/app/src/pages/WorldGen/ForestLab.tsx +++ b/.project/designs/app/src/pages/WorldGen/ForestLab.tsx @@ -1,57 +1,527 @@ -import { useRef, useEffect } from "react"; +import { useRef, useEffect, useCallback, useState } from "react"; import styled from "styled-components"; import { t } from "../../theme"; import { poissonDisc, SeededRng } from "../../utils/worldGen/poisson"; +import { + hexToPixel, + hexCorners, + pixelToHex, + drawTree3Layer, + drawConifer3Layer, +} from "../../utils/worldGen/hexCanvas"; +import { classifyTerrain, TERRAIN_MAP } from "../../utils/worldGen/terrain"; +import type { GridCell } from "../../utils/worldGen/terrain"; +import faunaManifestRaw from "@game-data/manifests/fauna.json"; -// โ”€โ”€ Styled โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// โ”€โ”€ Canvas layout constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const COLS = 7; +const ROWS = 5; +const HEX_SIZE = 48; + +const HEX_H = HEX_SIZE * Math.sqrt(3); +const CANVAS_W = Math.ceil((COLS - 1) * HEX_SIZE * 2 * 0.75 + HEX_SIZE * 2 + 8); +const CANVAS_H = Math.ceil((ROWS - 1) * HEX_H + HEX_H + 8); + +// โ”€โ”€ Data: terrain โ†’ deposit lookup โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +type DepositEntry = { id: string; cat: "gem" | "ore" | "stone" | "bio" }; +type FaunaEntry = { id: string; domain: string; trophic: string }; + +const DEPOSIT_MODS = import.meta.glob( + "../../../../../../public/resources/deposits/*.json", + { eager: true } +) as Record; + +const FAUNA_MODS = import.meta.glob( + "../../../../../../public/resources/ecology/fauna/species/*.json", + { eager: true } +) as Record; + +const FAUNA_IDS = new Set((faunaManifestRaw as { species: string[] }).species); + +const BIOME_ALIAS: Record = { + temperate_forest: "forest", montane_forest: "forest", + tropical_forest: "jungle", alpine_tundra: "tundra", + arctic_tundra: "tundra", polar_desert: "snow", + sea_ice: "ice", river: "coast", + alpine_meadow: "hills", ocean_surface: "ocean", + deep_ocean: "ocean", reef: "coast", +}; + +const TERRAIN_DEPOSITS = new Map(); +for (const mod of Object.values(DEPOSIT_MODS)) { + const { id, category, terrains } = mod; + if (!id || !terrains) continue; + const cat: DepositEntry["cat"] = + category === "luxury" ? "gem" : + category === "strategic" ? "ore" : + category === "bonus" ? "stone" : "bio"; + for (const terrain of terrains) { + const list = TERRAIN_DEPOSITS.get(terrain) ?? []; + list.push({ id, cat }); + TERRAIN_DEPOSITS.set(terrain, list); + } +} + +const TERRAIN_FAUNA = new Map(); +for (const mod of Object.values(FAUNA_MODS)) { + const { id, domain, trophic_level, biomes } = mod; + if (!id || !biomes || !FAUNA_IDS.has(id)) continue; + for (const rawBiome of biomes) { + const terrain = BIOME_ALIAS[rawBiome] ?? rawBiome; + const list = TERRAIN_FAUNA.get(terrain) ?? []; + list.push({ id, domain: domain ?? "land", trophic: trophic_level ?? "predator" }); + TERRAIN_FAUNA.set(terrain, list); + } +} + +// โ”€โ”€ Lab state โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +type LabState = { + elevation: number; moisture: number; temp: number; ridginess: number; + floraOn: boolean; floraDensity: number; + mineralsOn: boolean; mineralRichness: number; + faunaOn: boolean; faunaActivity: number; +}; + +const DEFAULT: LabState = { + elevation: 0.65, moisture: 0.72, temp: 0.72, ridginess: 0.30, + floraOn: true, floraDensity: 1.0, + mineralsOn: false, mineralRichness: 0.5, + faunaOn: false, faunaActivity: 0.5, +}; + +// โ”€โ”€ Drawing helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function bright(color: [number, number, number], f: number): string { + return `rgb(${Math.round(Math.min(255, Math.max(0, color[0] * f)))},${Math.round(Math.min(255, Math.max(0, color[1] * f)))},${Math.round(Math.min(255, Math.max(0, color[2] * f)))})`; +} + +function drawHexBase( + ctx: CanvasRenderingContext2D, cx: number, cy: number, + color: [number, number, number], slope: number, rng: SeededRng +): void { + const corners = hexCorners(cx, cy, HEX_SIZE); + ctx.save(); + 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.clip(); + const grad = ctx.createLinearGradient(cx, cy - HEX_SIZE, cx, cy + HEX_SIZE); + grad.addColorStop(0, bright(color, 1 + slope * 0.35)); + grad.addColorStop(1, bright(color, 1 - slope * 0.45)); + ctx.fillStyle = grad; + ctx.fill(); + for (let i = 0; i < 10; i++) { + const a = rng.next() * Math.PI * 2, r = rng.next() * HEX_SIZE * 0.72; + ctx.fillStyle = rng.next() > 0.5 ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.07)"; + ctx.fillRect(Math.round(cx + Math.cos(a) * r), Math.round(cy + Math.sin(a) * r), 1, 1); + } + ctx.restore(); + 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 = "#1a1510"; + ctx.lineWidth = 1; + ctx.stroke(); +} + +function drawGemGlyph(ctx: CanvasRenderingContext2D, x: number, y: number, sz: number): void { + ctx.beginPath(); + for (let i = 0; i < 8; i++) { + const a = (i / 8) * Math.PI * 2 - Math.PI / 2, r = i % 2 === 0 ? sz : sz * 0.38; + if (i === 0) ctx.moveTo(x + Math.cos(a) * r, y + Math.sin(a) * r); + else ctx.lineTo(x + Math.cos(a) * r, y + Math.sin(a) * r); + } + ctx.closePath(); + ctx.fillStyle = "rgba(180,230,255,0.85)"; + ctx.fill(); + ctx.strokeStyle = "rgba(120,180,240,0.6)"; + ctx.lineWidth = 0.5; + ctx.stroke(); +} + +function drawOreGlyph(ctx: CanvasRenderingContext2D, x: number, y: number, sz: number): void { + ctx.beginPath(); + ctx.moveTo(x, y - sz); + ctx.lineTo(x + sz * 0.65, y + sz * 0.45); + ctx.lineTo(x - sz * 0.55, y + sz * 0.35); + ctx.closePath(); + ctx.fillStyle = "rgba(130,90,55,0.80)"; + ctx.fill(); + ctx.strokeStyle = "rgba(200,150,80,0.5)"; + ctx.lineWidth = 0.5; + ctx.stroke(); +} + +function drawStoneGlyph(ctx: CanvasRenderingContext2D, x: number, y: number, sz: number): void { + ctx.beginPath(); + ctx.ellipse(x, y, sz * 0.95, sz * 0.6, 0, 0, Math.PI * 2); + ctx.fillStyle = "rgba(95,90,85,0.65)"; + ctx.fill(); +} + +function drawMineralGlyphs( + ctx: CanvasRenderingContext2D, cx: number, cy: number, + deposits: DepositEntry[], richness: number, rng: SeededRng +): void { + const count = Math.max(1, Math.round(richness * 2.5)); + for (let i = 0; i < count; i++) { + const dep = deposits[i % deposits.length]; + const a = rng.next() * Math.PI * 2, d = rng.next() * HEX_SIZE * 0.52; + const gx = cx + Math.cos(a) * d, gy = cy + Math.sin(a) * d; + const sz = 2.5 + richness * 2; + if (dep.cat === "gem") drawGemGlyph(ctx, gx, gy, sz); + else if (dep.cat === "ore") drawOreGlyph(ctx, gx, gy, sz); + else drawStoneGlyph(ctx, gx, gy, sz); + } +} + +function drawMushroom(ctx: CanvasRenderingContext2D, x: number, y: number, r: number): void { + ctx.beginPath(); + ctx.rect(x - r * 0.18, y, r * 0.36, r * 0.65); + ctx.fillStyle = "rgba(210,190,150,0.7)"; + ctx.fill(); + ctx.beginPath(); + ctx.ellipse(x, y, r * 0.7, r * 0.42, 0, Math.PI, 0, true); + ctx.closePath(); + ctx.fillStyle = "rgba(170,55,28,0.78)"; + ctx.fill(); +} + +function drawLitter(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, angle: number): void { + ctx.beginPath(); + ctx.ellipse(x, y, r, r * 0.38, angle, 0, Math.PI * 2); + ctx.fillStyle = "rgba(90,60,28,0.42)"; + ctx.fill(); +} + +function drawShrub(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, dark: string, mid: string): void { + ctx.beginPath(); + ctx.ellipse(x + r * 0.2, y + r * 0.3, r * 0.82, r * 0.28, 0, 0, Math.PI * 2); + ctx.fillStyle = "rgba(0,0,0,0.14)"; + ctx.fill(); + const grad = ctx.createRadialGradient(x - r * 0.2, y - r * 0.2, 0, x, y, r); + grad.addColorStop(0, mid); + grad.addColorStop(1, dark); + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fillStyle = grad; + ctx.fill(); +} + +function drawPredatorGlyph(ctx: CanvasRenderingContext2D, x: number, y: number, sz: number): void { + ctx.beginPath(); + ctx.moveTo(x - sz, y - sz * 0.4); + ctx.lineTo(x, y + sz * 0.5); + ctx.lineTo(x + sz, y - sz * 0.4); + ctx.strokeStyle = "rgba(200,40,20,0.55)"; + ctx.lineWidth = 1.5; + ctx.lineJoin = "round"; + ctx.stroke(); +} + +function drawHerbivoreGlyph(ctx: CanvasRenderingContext2D, x: number, y: number, sz: number): void { + ctx.beginPath(); + ctx.arc(x, y, sz * 0.58, 0, Math.PI * 2); + ctx.fillStyle = "rgba(140,195,85,0.45)"; + ctx.fill(); + ctx.beginPath(); + ctx.arc(x - sz * 0.38, y - sz * 0.7, sz * 0.22, 0, Math.PI * 2); + ctx.arc(x + sz * 0.38, y - sz * 0.7, sz * 0.22, 0, Math.PI * 2); + ctx.fillStyle = "rgba(120,175,70,0.45)"; + ctx.fill(); +} + +function drawBirdGlyph(ctx: CanvasRenderingContext2D, x: number, y: number, sz: number): void { + ctx.beginPath(); + ctx.moveTo(x - sz, y); + ctx.quadraticCurveTo(x - sz * 0.4, y - sz * 0.55, x, y + sz * 0.1); + ctx.quadraticCurveTo(x + sz * 0.4, y - sz * 0.55, x + sz, y); + ctx.strokeStyle = "rgba(70,55,175,0.52)"; + ctx.lineWidth = 1.5; + ctx.stroke(); +} + +function drawFishGlyph(ctx: CanvasRenderingContext2D, x: number, y: number, sz: number): void { + ctx.beginPath(); + ctx.ellipse(x - sz * 0.18, y, sz * 0.65, sz * 0.32, 0, 0, Math.PI * 2); + ctx.fillStyle = "rgba(50,135,200,0.48)"; + ctx.fill(); + ctx.beginPath(); + ctx.moveTo(x + sz * 0.42, y); + ctx.lineTo(x + sz, y - sz * 0.38); + ctx.lineTo(x + sz, y + sz * 0.38); + ctx.closePath(); + ctx.fillStyle = "rgba(35,115,180,0.48)"; + ctx.fill(); +} + +function drawFaunaGlyphs( + ctx: CanvasRenderingContext2D, cx: number, cy: number, + fauna: FaunaEntry[], activity: number, rng: SeededRng +): void { + const count = Math.max(1, Math.round(activity * 2.5)); + for (let i = 0; i < count; i++) { + const f = fauna[Math.floor(rng.next() * fauna.length)]; + const a = rng.next() * Math.PI * 2; + const d = HEX_SIZE * 0.25 + rng.next() * HEX_SIZE * 0.35; + const gx = cx + Math.cos(a) * d, gy = cy + Math.sin(a) * d; + const sz = 4 + activity * 2.5; + if (f.domain === "air") drawBirdGlyph(ctx, gx, gy, sz); + else if (f.domain === "marine" || f.domain === "freshwater") drawFishGlyph(ctx, gx, gy, sz); + else if (f.trophic === "herbivore") drawHerbivoreGlyph(ctx, gx, gy, sz); + else drawPredatorGlyph(ctx, gx, gy, sz); + } +} + +// โ”€โ”€ Flora helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function canopyVariety(id: string): "temperate" | "tropical" | "boreal" | null { + if (id === "forest") return "temperate"; + if (id === "jungle") return "tropical"; + if (id === "boreal_forest") return "boreal"; + if (id === "swamp") return "temperate"; + return null; +} + +const SHRUB_COLORS: Record = { + forest: ["#2a5a18", "#4a8a2a"], + jungle: ["#1a4a10", "#3a7020"], + boreal_forest: ["#1e4a36", "#2e6048"], + swamp: ["#2a3a12", "#3a5018"], + grassland: ["#3a7020", "#5a9030"], + hills: ["#4a6025", "#6a8035"], +}; + +const GROUND_COVER = new Set([ + "forest", "jungle", "boreal_forest", "swamp", "grassland", "plains", "hills", +]); + +// โ”€โ”€ Grid generation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function buildGrid(st: LabState): GridCell[][] { + const rng = new SeededRng(0xAB121234); + const cold = 1 - st.temp; + return Array.from({ length: ROWS }, (_, row) => + Array.from({ length: COLS }, (_, col) => { + const e = Math.max(0.35, Math.min(0.99, st.elevation + (rng.next() - 0.5) * 0.06)); + const m = Math.max(0.00, Math.min(1.00, st.moisture + (rng.next() - 0.5) * 0.08)); + const c = Math.max(0.00, Math.min(1.00, cold + (rng.next() - 0.5) * 0.05)); + const ri = Math.max(0.00, Math.min(1.00, st.ridginess + (rng.next() - 0.5) * 0.05)); + return { + col, row, slope: rng.next() * 0.14, + elevation: e, moisture: m, + terrainId: classifyTerrain(e, m, c, 0.0, ri), + }; + }) + ); +} + +// โ”€โ”€ Canvas render โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function renderCanvas(ctx: CanvasRenderingContext2D, grid: GridCell[][], st: LabState): void { + ctx.clearRect(0, 0, CANVAS_W, CANVAS_H); + + // 1. Hex fills + borders + for (let row = 0; row < ROWS; row++) { + for (let col = 0; col < COLS; col++) { + const cell = grid[row][col]; + const terrain = TERRAIN_MAP.get(cell.terrainId); + if (!terrain) continue; + const { x: cx, y: cy } = hexToPixel(col, row, HEX_SIZE); + drawHexBase(ctx, cx, cy, terrain.color, cell.slope, + new SeededRng(0xAB12 ^ (col * 73856093) ^ (row * 19349663))); + } + } + + // 2. Minerals + if (st.mineralsOn) { + for (let row = 0; row < ROWS; row++) { + for (let col = 0; col < COLS; col++) { + const cell = grid[row][col]; + const deps = TERRAIN_DEPOSITS.get(cell.terrainId); + if (!deps || deps.length === 0) continue; + const { x: cx, y: cy } = hexToPixel(col, row, HEX_SIZE); + drawMineralGlyphs(ctx, cx, cy, deps, st.mineralRichness, + new SeededRng(0xDE57 ^ (col * 999) ^ (row * 333))); + } + } + } + + // 3. Flora โ€” three global Poisson passes: ground โ†’ understory โ†’ canopy + if (st.floraOn) { + const bigR = Math.hypot(CANVAS_W, CANVAS_H) / 2 + 20; + const ccx = CANVAS_W / 2, ccy = CANVAS_H / 2; + + // Ground: fungi / leaf litter + for (const p of poissonDisc(ccx, ccy, bigR, 6 / st.floraDensity, 800, new SeededRng(0x4001))) { + 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); + if (!GROUND_COVER.has(grid[row]?.[col]?.terrainId ?? "")) continue; + const rng = new SeededRng(Math.round(Math.abs(p.x * 7919 + p.y * 6271))); + if (rng.next() > 0.45) drawLitter(ctx, p.x, p.y, 2 + rng.next() * 1.5, rng.next() * Math.PI); + else drawMushroom(ctx, p.x, p.y, 2.5 + rng.next() * 1.5); + } + + // Understory: medium shrubs + for (const p of poissonDisc(ccx, ccy, bigR, 12 / st.floraDensity, 400, new SeededRng(0x4002))) { + 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 tid = grid[row]?.[col]?.terrainId ?? ""; + const colors = SHRUB_COLORS[tid]; + if (!colors) continue; + const rng = new SeededRng(Math.round(Math.abs(p.x * 5381 + p.y * 2749))); + drawShrub(ctx, p.x, p.y, 4 + rng.next() * 3, colors[0], colors[1]); + } + + // Canopy: 3-layer trees + for (const p of poissonDisc(ccx, ccy, bigR, 18 / st.floraDensity, 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 variety = canopyVariety(grid[row]?.[col]?.terrainId ?? ""); + if (!variety) continue; + const rng = new SeededRng(Math.round(Math.abs(p.x * 3571 + p.y * 1999))); + const r = 8 + rng.next() * 8; + if (variety === "boreal") drawConifer3Layer(ctx, p.x, p.y, r * 1.5); + else drawTree3Layer(ctx, p.x, p.y, r, variety); + } + } + + // 4. Fauna + if (st.faunaOn) { + for (let row = 0; row < ROWS; row++) { + for (let col = 0; col < COLS; col++) { + const cell = grid[row][col]; + const fauna = TERRAIN_FAUNA.get(cell.terrainId); + if (!fauna || fauna.length === 0) continue; + const { x: cx, y: cy } = hexToPixel(col, row, HEX_SIZE); + drawFaunaGlyphs(ctx, cx, cy, fauna, st.faunaActivity, + new SeededRng(0xFA0A ^ (col * 1234) ^ (row * 5678))); + } + } + } +} + +// โ”€โ”€ Styled components โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const Wrap = styled.div` display: flex; flex-direction: column; - gap: 40px; + gap: 28px; `; -const SectionTitle = styled.h2` +const PageHeader = styled.div``; + +const PageTitle = styled.h2` font-family: ${t.font.heading}; font-size: 20px; color: ${t.text.title}; margin: 0 0 4px; `; -const SectionNote = styled.p` +const PageSub = styled.p` font-family: ${t.font.body}; font-size: 13px; color: ${t.text.secondary}; - margin: 0 0 16px; + margin: 0; max-width: 700px; line-height: 1.5; `; -const Row = styled.div` +const Body = styled.div` display: flex; - gap: 24px; + gap: 28px; align-items: flex-start; - flex-wrap: wrap; `; -const Panel = styled.div` +const Controls = styled.div` display: flex; flex-direction: column; - gap: 8px; + gap: 20px; + min-width: 220px; `; -const PanelLabel = styled.div` - font-family: ${t.font.heading}; - font-size: 15px; - color: ${t.text.title}; +const ControlGroup = styled.div` + display: flex; + flex-direction: column; + gap: 10px; `; -const PanelSub = styled.div` +const GroupLabel = styled.div` font-family: ${t.font.mono}; - font-size: 11px; + font-size: 10px; color: ${t.text.muted}; - max-width: 200px; - line-height: 1.4; + text-transform: uppercase; + letter-spacing: 0.08em; + padding-bottom: 4px; + border-bottom: 1px solid ${t.border.divider}; +`; + +const SliderRow = styled.div` + display: flex; + flex-direction: column; + gap: 3px; +`; + +const SliderLabel = styled.div` + display: flex; + justify-content: space-between; + font-family: ${t.font.mono}; + font-size: 12px; + color: ${t.text.primary}; +`; + +const SliderVal = styled.span` + color: ${t.text.muted}; + font-size: 11px; +`; + +const RangeInput = styled.input` + width: 100%; + height: 4px; + accent-color: ${t.accent.gold}; +`; + +const ToggleRow = styled.div` + display: flex; + flex-direction: column; + gap: 6px; +`; + +const ToggleBtn = styled.button<{ $on: boolean }>` + display: flex; + align-items: center; + gap: 8px; + background: ${p => p.$on ? "rgba(242,217,115,0.12)" : "transparent"}; + border: 1px solid ${p => p.$on ? t.accent.gold : t.border.panel}; + border-radius: 20px; + color: ${p => p.$on ? t.accent.gold : t.text.muted}; + font-family: ${t.font.mono}; + font-size: 12px; + padding: 5px 12px; + cursor: pointer; + transition: all 100ms ease; + text-align: left; + &:hover { border-color: ${t.accent.gold}; color: ${t.accent.gold}; } +`; + +const ToggleDot = styled.span<{ $on: boolean }>` + width: 6px; + height: 6px; + border-radius: 50%; + background: ${p => p.$on ? t.accent.gold : t.border.panel}; + flex-shrink: 0; +`; + +const CanvasWrap = styled.div` + flex: 1; + display: flex; + flex-direction: column; + gap: 16px; `; const StyledCanvas = styled.canvas` @@ -60,321 +530,163 @@ const StyledCanvas = styled.canvas` border-radius: ${t.radius.panel}; `; -// โ”€โ”€ Drawing primitives โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +const InfoCard = styled.div` + background: ${t.bg.panel}; + border: 1px solid ${t.border.panel}; + border-radius: ${t.radius.panel}; + padding: 16px; + display: flex; + flex-direction: column; + gap: 8px; +`; -const W = 220; -const H = 220; +const InfoName = styled.div` + font-family: ${t.font.heading}; + font-size: 18px; + color: ${t.text.title}; +`; -// 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); +const InfoDesc = styled.div` + font-family: ${t.font.body}; + font-size: 13px; + color: ${t.text.secondary}; + line-height: 1.45; + max-width: 600px; +`; - // 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); - } +const InfoStats = styled.div` + display: flex; + gap: 16px; + font-family: ${t.font.mono}; + font-size: 11px; + color: ${t.text.muted}; +`; - // 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(); - } -} +const StatPill = styled.span<{ $hi: boolean }>` + color: ${p => p.$hi ? "#8bc96a" : "inherit"}; +`; -// 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(); +const InfoRow = styled.div` + font-family: ${t.font.mono}; + font-size: 11px; + color: ${t.text.muted}; +`; - 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(); +// โ”€โ”€ SliderControl โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - // 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 +function SliderControl({ + label, value, min, max, step, onChange, +}: { + label: string; value: number; min: number; max: number; step: number; + onChange: (v: number) => void; +}): React.ReactElement { + return ( + + {label} {value.toFixed(2)} + onChange(parseFloat(e.target.value))} + /> + ); - 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); - } +function toName(id: string): string { + return id.replace(/_/g, " ").replace(/\b\w/g, c => c.toUpperCase()); } -// โ”€โ”€ 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, - }, -]; +// โ”€โ”€ ForestLab โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ export function ForestLab(): React.ReactElement { + const canvasRef = useRef(null); + const [st, updateSt] = useState(DEFAULT); + + const set = useCallback((patch: Partial) => { + updateSt(prev => ({ ...prev, ...patch })); + }, []); + + const cold = 1 - st.temp; + const terrainId = classifyTerrain(Math.max(0.35, st.elevation), st.moisture, cold, 0.0, st.ridginess); + const terrain = TERRAIN_MAP.get(terrainId); + const faunaNames = [...new Set((TERRAIN_FAUNA.get(terrainId) ?? []).map(f => f.id))] + .slice(0, 5).map(toName); + + useEffect(() => { + const ctx = canvasRef.current?.getContext("2d"); + if (!ctx) return; + renderCanvas(ctx, buildGrid(st), st); + }, [st]); + 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. - -
+ + Terrain Dimensions Lab + + Adjust biome levers to navigate the parameter space. Toggle overlays to stack + minerals, flora (ground โ†’ understory โ†’ canopy), and fauna glyphs. Flora renders + as a continuous cross-tile point cloud โ€” trees straddle hex borders naturally. + + -
- - 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); - }} /> - - ))} - -
+ + + + Biome classifiers + set({ elevation: v })} /> + set({ moisture: v })} /> + set({ temp: v })} /> + set({ ridginess: v })} /> + -
- - Arrangements โ€” four - strategies for placing trees within a patch of grassland. - - - {ARRANGEMENTS.map((a) => ( - - {a.label} - - {a.sub} - - ))} - -
+ + Overlays + + set({ floraOn: !st.floraOn })}> + Flora + + {st.floraOn && ( + set({ floraDensity: v })} /> + )} + + + set({ mineralsOn: !st.mineralsOn })}> + Minerals + + {st.mineralsOn && ( + set({ mineralRichness: v })} /> + )} + + + set({ faunaOn: !st.faunaOn })}> + Fauna + + {st.faunaOn && ( + set({ faunaActivity: v })} /> + )} + + +
+ + + + {terrain && ( + + {terrain.name} + {terrain.description && {terrain.description}} + + 0}>Food {terrain.food} + 0}>Prod {terrain.production} + 0}>Trade {terrain.trade} + Def +{terrain.defense_bonus}% + Move ร—{terrain.movement_cost} + + {faunaNames.length > 0 && ( + Fauna ยท {faunaNames.join(" ยท ")} + )} + + )} + +
); } diff --git a/.project/designs/app/src/utils/worldGen/hexCanvas.ts b/.project/designs/app/src/utils/worldGen/hexCanvas.ts index 0ff20c8c..f440b8cb 100644 --- a/.project/designs/app/src/utils/worldGen/hexCanvas.ts +++ b/.project/designs/app/src/utils/worldGen/hexCanvas.ts @@ -109,37 +109,73 @@ function stipple( } } -// โ”€โ”€ Decorations per terrain โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// โ”€โ”€ 3-layer tree primitives (exported for lab use) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -function drawTree( +export type TreeVariety = "temperate" | "tropical"; + +export function drawTree3Layer( ctx: CanvasRenderingContext2D, x: number, y: number, r: number, - color: string + variety: TreeVariety = "temperate" ): void { + const colors: Record = { + temperate: { dark: "#2a5a18", mid: "#3d7a25", light: "#5a9a3a" }, + tropical: { dark: "#1a4a10", mid: "#2d6a1c", light: "#448a28" }, + }; + const c = colors[variety]; + + 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(); + + 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 = color; + ctx.fillStyle = grad; + ctx.fill(); + + 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(); } -function drawConifer( +export function drawConifer3Layer( ctx: CanvasRenderingContext2D, x: number, y: number, - h: number, - color: string + h: number ): void { + ctx.beginPath(); + ctx.ellipse(x + 2, y + h * 0.22, h * 0.34, h * 0.14, 0, 0, Math.PI * 2); + ctx.fillStyle = "rgba(0,0,0,0.18)"; + ctx.fill(); + ctx.beginPath(); ctx.moveTo(x, y - h); - ctx.lineTo(x - h * 0.4, y); - ctx.lineTo(x + h * 0.4, y); + ctx.lineTo(x - h * 0.6, y + h * 0.5); + ctx.lineTo(x + h * 0.6, y + h * 0.5); ctx.closePath(); - ctx.fillStyle = color; + ctx.fillStyle = "#2a5c46"; + ctx.fill(); + + ctx.beginPath(); + ctx.moveTo(x, y - h * 1.4); + ctx.lineTo(x - h * 0.4, y - h * 0.4); + ctx.lineTo(x + h * 0.4, y - h * 0.4); + ctx.closePath(); + ctx.fillStyle = "#357558"; ctx.fill(); } +// โ”€โ”€ Decorations per terrain โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + function drawPeak( ctx: CanvasRenderingContext2D, x: number, @@ -212,20 +248,20 @@ export function drawDecorations( switch (terrainId) { case "forest": { - const pts = poissonDisc(cx, cy, size, 6, 5, rng); + const pts = poissonDisc(cx, cy, size * 0.85, 7, 5, rng); for (const p of pts) - drawTree(ctx, p.x, p.y, 3 + rng.next() * 2.5, "rgba(45,100,30,0.85)"); + drawTree3Layer(ctx, p.x, p.y, 3 + rng.next() * 3, "temperate"); break; } case "jungle": { - const pts = poissonDisc(cx, cy, size, 5, 7, rng); + const pts = poissonDisc(cx, cy, size * 0.85, 6, 7, rng); for (const p of pts) - drawTree(ctx, p.x, p.y, 4 + rng.next() * 3.5, "rgba(25,85,15,0.9)"); + drawTree3Layer(ctx, p.x, p.y, 4 + rng.next() * 4, "tropical"); break; } case "boreal_forest": { - const pts = poissonDisc(cx, cy, size, 7, 4, rng); - for (const p of pts) drawConifer(ctx, p.x, p.y, 10, "rgba(30,75,55,0.9)"); + const pts = poissonDisc(cx, cy, size * 0.85, 8, 4, rng); + for (const p of pts) drawConifer3Layer(ctx, p.x, p.y, 9 + rng.next() * 5); break; } case "mountains": {