/** * Terrain and props around server-defined towns and roads. * * 1) Town centers + radii define settlements (plaza + rim). * 2) A closed ring through towns (sorted by levelMin) defines the main road corridor. * 3) Optional `activeRoute` waypoints from `route_assigned` refine the path for the current leg. * 4) Wild tiles use the nearest town's biome as the base, then light noise. */ /** One town for terrain influence (from REST /towns or TownData). */ export interface TownTerrainInfluence { id: number; cx: number; cy: number; radius: number; biome: string; levelMin: number; } /** Built once per frame batch / when towns or route change. */ export interface WorldTerrainContext { towns: TownTerrainInfluence[]; /** Town centers in travel order (sorted by levelMin), for the ring road. */ networkCenters: Array<{ x: number; y: number }>; /** Current road polyline from server (may be empty). */ activeRoute: Array<{ x: number; y: number }> | null; } /** Deterministic integer hash for tile coordinates -> [0, 1) */ export function tileHash(x: number, y: number, seed: number): number { let h = (Math.imul(x | 0, 374761393) + Math.imul(y | 0, 668265263) + seed) | 0; h = Math.imul(h ^ (h >>> 13), 1274126177); h = h ^ (h >>> 16); return (h & 0x7fffffff) / 0x7fffffff; } function distPointToSegment( px: number, py: number, ax: number, ay: number, bx: number, by: number, ): number { const abx = bx - ax; const aby = by - ay; const apx = px - ax; const apy = py - ay; const ab2 = abx * abx + aby * aby; let t = ab2 > 1e-12 ? (apx * abx + apy * aby) / ab2 : 0; t = Math.max(0, Math.min(1, t)); const qx = ax + abx * t; const qy = ay + aby * t; return Math.hypot(px - qx, py - qy); } /** Open polyline: consecutive vertex pairs. */ export function minDistToOpenPolyline( px: number, py: number, pts: Array<{ x: number; y: number }>, ): number { if (pts.length < 2) return Number.POSITIVE_INFINITY; let min = Number.POSITIVE_INFINITY; for (let i = 0; i < pts.length - 1; i++) { const a = pts[i]!; const b = pts[i + 1]!; const d = distPointToSegment(px, py, a.x, a.y, b.x, b.y); if (d < min) min = d; } return min; } /** Closed ring through centers: (c0,c1)…(c_{n-2},c_{n-1}),(c_{n-1},c0). */ export function minDistToRing( px: number, py: number, centers: Array<{ x: number; y: number }>, ): number { const n = centers.length; if (n < 2) return Number.POSITIVE_INFINITY; let min = Number.POSITIVE_INFINITY; for (let i = 0; i < n; i++) { const a = centers[i]!; const b = centers[(i + 1) % n]!; const d = distPointToSegment(px, py, a.x, a.y, b.x, b.y); if (d < min) min = d; } return min; } /** Sort towns like the server chain (level_min, then id). */ export function orderedTownCenters(towns: TownTerrainInfluence[]): Array<{ x: number; y: number }> { const sorted = [...towns].sort((a, b) => a.levelMin - b.levelMin || a.id - b.id); return sorted.map((t) => ({ x: t.cx, y: t.cy })); } export function buildWorldTerrainContext( towns: TownTerrainInfluence[], activeRoute: Array<{ x: number; y: number }> | null, ): WorldTerrainContext { return { towns, networkCenters: orderedTownCenters(towns), activeRoute: activeRoute && activeRoute.length >= 2 ? activeRoute : null, }; } /** Helpers for Minimap / tests: Town[] -> influences */ export function townsApiToInfluences( towns: Array<{ id: number; worldX: number; worldY: number; radius: number; biome: string; levelMin: number; }>, ): TownTerrainInfluence[] { return towns.map((t) => ({ id: t.id, cx: t.worldX, cy: t.worldY, radius: t.radius, biome: t.biome, levelMin: t.levelMin, })); } function roadClearance(wx: number, wy: number, ctx: WorldTerrainContext): number { const ringD = minDistToRing(wx, wy, ctx.networkCenters); const activeD = ctx.activeRoute ? minDistToOpenPolyline(wx, wy, ctx.activeRoute) : Number.POSITIVE_INFINITY; return Math.min(ringD, activeD); } function nearestTown(wx: number, wy: number, towns: TownTerrainInfluence[]): TownTerrainInfluence | null { if (towns.length === 0) return null; let best = towns[0]!; let bestD = Math.hypot(wx - best.cx, wy - best.cy); for (let i = 1; i < towns.length; i++) { const t = towns[i]!; const d = Math.hypot(wx - t.cx, wy - t.cy); if (d < bestD) { bestD = d; best = t; } } return best; } /** Map server biome id to a ground tile key (colors in renderer). */ export function biomeToTerrainKey(biome: string): string { const b = biome.toLowerCase(); if (b === 'forest') return 'forest_floor'; if (b === 'ruins') return 'ruins_floor'; if (b === 'canyon') return 'canyon_floor'; if (b === 'swamp') return 'swamp_floor'; if (b === 'volcanic') return 'volcanic_floor'; if (b === 'astral') return 'astral_floor'; if (b === 'meadow') return 'grass'; return 'grass'; } /** * Terrain at integer tile (wx, wy). * With `context`, roads follow the town ring + active route; wild tiles follow nearest town biome. * Without context, simple grass + noise (no synthetic diagonal road). */ export function proceduralTerrain( wx: number, wy: number, context?: WorldTerrainContext | null, ): string { const ctx = context; if (!ctx || ctx.towns.length === 0) { const h = tileHash(wx, wy, 42); if (h < 0.06) return 'dirt'; if (h < 0.1) return 'stone'; return 'grass'; } let minEdge = Number.POSITIVE_INFINITY; for (const t of ctx.towns) { const d = Math.hypot(wx - t.cx, wy - t.cy) - t.radius; if (d < minEdge) minEdge = d; } if (minEdge < -0.8) return 'plaza'; if (minEdge < 2.2) return 'dirt'; const rd = roadClearance(wx, wy, ctx); if (rd < 1.15) return 'road'; if (rd < 2.65) return 'dirt'; const near = nearestTown(wx, wy, ctx.towns); let base = near ? biomeToTerrainKey(near.biome) : 'grass'; const h = tileHash(wx, wy, 42); if (base === 'forest_floor' && h < 0.12) return 'dirt'; if (base === 'swamp_floor' && h < 0.1) return 'dirt'; if (h < 0.04) return 'stone'; if (h < 0.09) return 'dirt'; return base; } /** * Compute the distance from the nearest town edge (negative = inside town). * Returns Infinity if no towns. */ function townEdgeDist(wx: number, wy: number, towns: TownTerrainInfluence[]): number { let minEdge = Number.POSITIVE_INFINITY; for (const t of towns) { const d = Math.hypot(wx - t.cx, wy - t.cy) - t.radius; if (d < minEdge) minEdge = d; } return minEdge; } /** * Cluster-based noise: tiles near a "cluster seed" tile are more likely to share objects. * Returns a hash that correlates with nearby tiles (spatial coherence ~3 tiles). */ function clusterHash(wx: number, wy: number, seed: number): number { const cx = Math.floor(wx / 3); const cy = Math.floor(wy / 3); return tileHash(cx, cy, seed); } /** * Prop types for ground layer. * Uses zoning: plaza (sparse, server provides buildings), town edge (fences/barrels), * road buffer (clear), wild (clustered natural objects). */ export function proceduralObject( wx: number, wy: number, terrain: string, context?: WorldTerrainContext | null, ): string | null { // Inside plaza: no procedural props (server buildings handle this) if (terrain === 'plaza') { return null; } const ctx = context; const rd = ctx ? roadClearance(wx, wy, ctx) : Number.POSITIVE_INFINITY; // Road buffer: keep clear for passage if (rd < 3.0) return null; // Town edge zone: sparse village decor (no trees/ruins) if (ctx && ctx.towns.length > 0) { const edge = townEdgeDist(wx, wy, ctx.towns); if (edge >= -2.0 && edge < 5.0) { const h = tileHash(wx, wy, 301); if (h < 0.04) return 'barrel'; if (h < 0.065) return 'cart'; if (h < 0.085) return 'bush'; if (h < 0.095) return 'leaves'; return null; } } // Wild zone: cluster-based natural objects for spatial coherence const h = tileHash(wx, wy, 137); const ch = clusterHash(wx, wy, 137); // Trees appear in groves (cluster hash controls grove probability) let treeTh = ch < 0.3 ? 0.08 : 0.02; if (terrain === 'forest_floor') treeTh = ch < 0.4 ? 0.14 : 0.03; if (terrain === 'swamp_floor') treeTh = ch < 0.25 ? 0.06 : 0.01; if (h < treeTh) return 'tree'; // Bushes cluster around trees const bushTh = ch < 0.35 ? 0.06 : 0.02; if (h < treeTh + bushTh) return 'bush'; // Rocks appear in rocky patches const rockCh = clusterHash(wx, wy, 241); if (rockCh < 0.2 && h < treeTh + bushTh + 0.04) return 'rock'; // Sparse scattered objects (less chaotic than before) const sparse = tileHash(wx, wy, 199); if (sparse < 0.008) return 'stump'; if (sparse < 0.014) return 'cart'; if (sparse < 0.018) return 'bones'; if (sparse < 0.024) return 'mushroom'; if (sparse < 0.028) return 'leaves'; if (terrain === 'ruins_floor' && sparse < 0.04) return 'ruin'; return null; } /** Blocking object types that hero cannot walk through */ const BLOCKING_TYPES = new Set(['tree', 'bush', 'rock', 'ruin', 'stall', 'well', 'barrel']); /** * Check if a tile at the given world coordinate is blocked by a procedural obstacle. */ export function isProcedurallyBlocked( wx: number, wy: number, context?: WorldTerrainContext | null, ): boolean { const ix = Math.floor(wx); const iy = Math.floor(wy); const terrain = proceduralTerrain(ix, iy, context); const obj = proceduralObject(ix, iy, terrain, context); if (obj === null) return false; return BLOCKING_TYPES.has(obj); }