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