You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

313 lines
9.4 KiB
TypeScript

/**
* 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);
}