1177 lines
50 KiB
JavaScript
1177 lines
50 KiB
JavaScript
import { clamp, hash2, MinHeap, pickEntities } from "./mapUtils.js";
|
|
import { LANDUSE } from "./landuseCodes.js";
|
|
import { generateEntityName } from "./names.js";
|
|
|
|
const POINT_LAYER_KEYS = [
|
|
"villages", "markets", "castles", "ports", "modernCities", "satelliteCities", "stations",
|
|
"interchanges", "industrialZones", "logisticsParks", "newTowns",
|
|
];
|
|
|
|
const PATH_LAYER_KEYS = [
|
|
"premodernRoads", "minorRoads", "nationalRoads", "ringRoads", "externalRoads",
|
|
"railways", "branchRailways", "ringRailways", "externalRailways", "expressways", "externalExpressways",
|
|
];
|
|
|
|
const SEGMENT_LAYER_KEYS = ["adminBorders"];
|
|
|
|
const FLOAT_FIELD_KEYS = [
|
|
"settlementScore", "populationDensity", "stationInfluence", "roadInfluence", "railInfluence2", "villageInfluence",
|
|
];
|
|
|
|
const INT_FIELD_DEFAULTS = new Map([
|
|
["adminId", -1],
|
|
["municipalityId", -1],
|
|
]);
|
|
|
|
function worldIndex(world, x, y) {
|
|
if (!world || x < 0 || y < 0 || x >= world.width || y >= world.height) return -1;
|
|
return y * world.width + x;
|
|
}
|
|
|
|
function rectWidth(rect) {
|
|
return Math.max(0, Math.floor(rect.x1) - Math.floor(rect.x0));
|
|
}
|
|
|
|
function rectHeight(rect) {
|
|
return Math.max(0, Math.floor(rect.y1) - Math.floor(rect.y0));
|
|
}
|
|
|
|
function rectArea(rect) {
|
|
return rectWidth(rect) * rectHeight(rect);
|
|
}
|
|
|
|
function insideRect(x, y, rect) {
|
|
return rect && x >= rect.x0 && y >= rect.y0 && x < rect.x1 && y < rect.y1;
|
|
}
|
|
|
|
function distanceToRectEdge(x, y, rect) {
|
|
return Math.min(x - rect.x0, y - rect.y0, rect.x1 - 1 - x, rect.y1 - 1 - y);
|
|
}
|
|
|
|
function expandRect(rect, margin, world) {
|
|
return {
|
|
x0: Math.max(0, rect.x0 - margin),
|
|
y0: Math.max(0, rect.y0 - margin),
|
|
x1: Math.min(world.width, rect.x1 + margin),
|
|
y1: Math.min(world.height, rect.y1 + margin),
|
|
};
|
|
}
|
|
|
|
function worldToSourcePoint(world, x, y) {
|
|
return { x: x - world.originX, y: y - world.originY };
|
|
}
|
|
|
|
function pointWorldX(world, p) {
|
|
if (Number.isFinite(p?.worldX)) return p.worldX;
|
|
return (p?.x || 0) + (world?.originX || 0);
|
|
}
|
|
|
|
function pointWorldY(world, p) {
|
|
if (Number.isFinite(p?.worldY)) return p.worldY;
|
|
return (p?.y || 0) + (world?.originY || 0);
|
|
}
|
|
|
|
function tupleWorldX(world, tuple) {
|
|
return (tuple?.[0] || 0) + (world?.originX || 0);
|
|
}
|
|
|
|
function tupleWorldY(world, tuple) {
|
|
return (tuple?.[1] || 0) + (world?.originY || 0);
|
|
}
|
|
|
|
function ensureSourceArray(sourceMap, key) {
|
|
if (!Array.isArray(sourceMap[key])) sourceMap[key] = [];
|
|
return sourceMap[key];
|
|
}
|
|
|
|
function ensureField(world, key, Constructor = Float32Array, fallback = 0) {
|
|
if (!world.fields[key] || world.fields[key].length !== world.width * world.height) {
|
|
world.fields[key] = new Constructor(world.width * world.height);
|
|
if (fallback !== 0) world.fields[key].fill(fallback);
|
|
}
|
|
return world.fields[key];
|
|
}
|
|
|
|
function seeded(seed, x, y, salt = 0) {
|
|
return hash2((x | 0) + salt * 8191, (y | 0) - salt * 131, seed >>> 0);
|
|
}
|
|
|
|
function localId(rect, x, y, salt = 0) {
|
|
return `${rect.x0}:${rect.y0}:${x}:${y}:${salt}`;
|
|
}
|
|
|
|
function makeName(seed, rect, x, y, entity, usedNames, salt = 0) {
|
|
const id = localId(rect, x, y, salt);
|
|
const name = generateEntityName(seed, id, entity, null, usedNames);
|
|
if (name) usedNames?.add(name);
|
|
return name || `隨ャ${Math.max(1, Math.floor(seeded(seed, x, y, salt) * 99))}逕コ`;
|
|
}
|
|
|
|
function isLand(world, x, y) {
|
|
const i = worldIndex(world, x, y);
|
|
return i >= 0 && !world.fields.sea?.[i];
|
|
}
|
|
|
|
function seaNeighbors(world, x, y, radius = 1) {
|
|
let count = 0;
|
|
for (let dy = -radius; dy <= radius; dy++) {
|
|
for (let dx = -radius; dx <= radius; dx++) {
|
|
if (!dx && !dy) continue;
|
|
const i = worldIndex(world, x + dx, y + dy);
|
|
if (i >= 0 && world.fields.sea?.[i]) count++;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function candidateScore(world, x, y, seed) {
|
|
const i = worldIndex(world, x, y);
|
|
if (i < 0 || world.fields.sea?.[i]) return -Infinity;
|
|
const elevation = world.fields.elevation?.[i] || 0;
|
|
const slope = world.fields.slope?.[i] || 0;
|
|
const plain = world.fields.plain?.[i] || 0;
|
|
const agriculture = world.fields.agriculture?.[i] || 0;
|
|
const river = world.fields.river?.[i] || 0;
|
|
const floodplain = world.fields.floodplain?.[i] || 0;
|
|
const coast = seaNeighbors(world, x, y, 2) > 0 ? 0.26 : 0;
|
|
const lowland = Math.max(0, 1 - Math.abs(elevation - 0.33) * 2.0);
|
|
const noise = seeded(seed, x, y, 47) * 0.18;
|
|
return plain * 0.72 + agriculture * 0.72 + floodplain * 0.45 + river * 0.30 + coast + lowland * 0.38 - slope * 1.35 + noise;
|
|
}
|
|
|
|
function collectLandCandidates(world, rect, seed, stride = 3) {
|
|
const candidates = [];
|
|
for (let y = rect.y0 + 2; y < rect.y1 - 2; y += stride) {
|
|
for (let x = rect.x0 + 2; x < rect.x1 - 2; x += stride) {
|
|
const score = candidateScore(world, x, y, seed);
|
|
if (score > 0.28) candidates.push({ x, y, score });
|
|
}
|
|
}
|
|
return candidates;
|
|
}
|
|
|
|
function collectPortCandidates(world, rect, seed) {
|
|
const out = [];
|
|
for (let y = rect.y0 + 2; y < rect.y1 - 2; y += 2) {
|
|
for (let x = rect.x0 + 2; x < rect.x1 - 2; x += 2) {
|
|
const i = worldIndex(world, x, y);
|
|
if (i < 0 || world.fields.sea?.[i]) continue;
|
|
const seaN = seaNeighbors(world, x, y, 2);
|
|
if (seaN < 3) continue;
|
|
const slope = world.fields.slope?.[i] || 0;
|
|
const suit = world.fields.portSuitability?.[i] || 0;
|
|
const score = seaN * 0.09 + suit * 1.10 + (1 - slope) * 0.35 + seeded(seed, x, y, 71) * 0.20;
|
|
if (score > 0.55) out.push({ x, y, score });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function usedNameSet(sourceMap) {
|
|
const out = new Set();
|
|
for (const key of ["villages", "markets", "castles", "ports", "modernCities", "satelliteCities", "stations", "industrialZones", "logisticsParks", "newTowns", "adminCenters"]) {
|
|
for (const p of sourceMap[key] || []) if (p?.name) out.add(p.name);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function hideAdminCentersInside(sourceMap, world, rect) {
|
|
if (!Array.isArray(sourceMap.adminCenters)) return 0;
|
|
let hidden = 0;
|
|
for (const p of sourceMap.adminCenters) {
|
|
if (!p || p.patchHidden) continue;
|
|
const wx = pointWorldX(world, p);
|
|
const wy = pointWorldY(world, p);
|
|
if (!insideRect(wx, wy, rect)) continue;
|
|
p.patchHidden = true;
|
|
p.patchHiddenAt = Date.now();
|
|
p.x = -100000;
|
|
p.y = -100000;
|
|
hidden++;
|
|
}
|
|
return hidden;
|
|
}
|
|
|
|
function pointLayerPreservedCount(sourceMap, world, userRect) {
|
|
let preserved = 0;
|
|
for (const key of [...POINT_LAYER_KEYS, "adminCenters"]) {
|
|
const arr = sourceMap[key];
|
|
if (!Array.isArray(arr)) continue;
|
|
for (const p of arr) {
|
|
if (!p || p.patchHidden) continue;
|
|
const wx = pointWorldX(world, p);
|
|
const wy = pointWorldY(world, p);
|
|
if (!insideRect(wx, wy, userRect)) preserved++;
|
|
}
|
|
}
|
|
return preserved;
|
|
}
|
|
|
|
function portStillValid(world, p, rect) {
|
|
const wx = Math.round(pointWorldX(world, p));
|
|
const wy = Math.round(pointWorldY(world, p));
|
|
const land = nearestLand(world, wx, wy, rect, 5);
|
|
return !!land && seaNeighbors(world, land.x, land.y, 2) >= 2;
|
|
}
|
|
|
|
function prunePointLayers(sourceMap, world, rects) {
|
|
let removed = 0;
|
|
let invalidPortsRemoved = 0;
|
|
const userRect = rects.writeRect || rects.userRect;
|
|
const blendRect = rects.coreRect || rects.blendRect;
|
|
for (const key of POINT_LAYER_KEYS) {
|
|
const arr = sourceMap[key];
|
|
if (!Array.isArray(arr)) continue;
|
|
const kept = [];
|
|
for (const p of arr) {
|
|
const wx = pointWorldX(world, p);
|
|
const wy = pointWorldY(world, p);
|
|
const inBlend = insideRect(wx, wy, blendRect);
|
|
const inUser = insideRect(wx, wy, userRect);
|
|
const invalidTransitionPort = key === "ports" && inUser && !inBlend && !portStillValid(world, p, userRect);
|
|
if (inBlend || invalidTransitionPort) {
|
|
removed++;
|
|
if (invalidTransitionPort) invalidPortsRemoved++;
|
|
} else {
|
|
kept.push(p);
|
|
}
|
|
}
|
|
sourceMap[key] = kept;
|
|
}
|
|
removed += hideAdminCentersInside(sourceMap, world, blendRect);
|
|
return { removedPoints: removed, invalidPortsRemoved };
|
|
}
|
|
|
|
function segmentTouchesRect(world, seg, rect) {
|
|
if (!Array.isArray(seg) || seg.length < 2) return false;
|
|
return insideRect(tupleWorldX(world, seg[0]), tupleWorldY(world, seg[0]), rect)
|
|
|| insideRect(tupleWorldX(world, seg[1]), tupleWorldY(world, seg[1]), rect);
|
|
}
|
|
|
|
function pathLayerPreservedCount(sourceMap, world, userRect) {
|
|
let preserved = 0;
|
|
for (const key of PATH_LAYER_KEYS) {
|
|
const arr = sourceMap[key];
|
|
if (!Array.isArray(arr)) continue;
|
|
for (const path of arr) {
|
|
if (!Array.isArray(path) || !path.length) continue;
|
|
if (path.every((tuple) => !insideRect(tupleWorldX(world, tuple), tupleWorldY(world, tuple), userRect))) preserved++;
|
|
}
|
|
}
|
|
return preserved;
|
|
}
|
|
|
|
function clipPathToOutsideAndAnchors(world, path, rects, mode, layerKey) {
|
|
const outsideParts = [];
|
|
const anchors = [];
|
|
let current = [];
|
|
let removedInside = 0;
|
|
let lastOutside = null;
|
|
let lastInside = null;
|
|
|
|
for (const tuple of path || []) {
|
|
const wx = tupleWorldX(world, tuple);
|
|
const wy = tupleWorldY(world, tuple);
|
|
const p = { x: Math.round(wx), y: Math.round(wy) };
|
|
const inUser = insideRect(p.x, p.y, rects.userRect);
|
|
if (!inUser) {
|
|
if (lastInside) anchors.push({ ...p, kind: mode, layerKey, source: "boundary" });
|
|
current.push(tuple);
|
|
lastOutside = p;
|
|
} else {
|
|
removedInside++;
|
|
if (lastOutside) anchors.push({ ...lastOutside, kind: mode, layerKey, source: "boundary" });
|
|
if (current.length >= 2) outsideParts.push(current);
|
|
current = [];
|
|
lastInside = p;
|
|
}
|
|
}
|
|
if (current.length >= 2) outsideParts.push(current);
|
|
|
|
const unique = [];
|
|
const seen = new Set();
|
|
for (const anchor of anchors) {
|
|
const land = nearestLand(world, anchor.x, anchor.y, rects.userRect, 10);
|
|
if (!land) continue;
|
|
const key = `${land.x},${land.y},${mode}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
unique.push({ ...anchor, x: land.x, y: land.y });
|
|
}
|
|
|
|
return { outsideParts, anchors: unique, removedInside };
|
|
}
|
|
|
|
function pruneLinearLayers(sourceMap, world, rects) {
|
|
let removed = 0;
|
|
const roadAnchors = [];
|
|
const railAnchors = [];
|
|
for (const key of PATH_LAYER_KEYS) {
|
|
const arr = sourceMap[key];
|
|
if (!Array.isArray(arr)) continue;
|
|
const kept = [];
|
|
for (const path of arr) {
|
|
const touchesUser = (path || []).some((tuple) => insideRect(tupleWorldX(world, tuple), tupleWorldY(world, tuple), rects.userRect));
|
|
if (!touchesUser) {
|
|
kept.push(path);
|
|
continue;
|
|
}
|
|
const mode = key.includes("Rail") || key.includes("rail") ? "rail" : "road";
|
|
const clipped = clipPathToOutsideAndAnchors(world, path, rects, mode, key);
|
|
kept.push(...clipped.outsideParts);
|
|
if (mode === "rail") railAnchors.push(...clipped.anchors);
|
|
else roadAnchors.push(...clipped.anchors);
|
|
removed++;
|
|
}
|
|
sourceMap[key] = kept;
|
|
}
|
|
for (const key of SEGMENT_LAYER_KEYS) {
|
|
const arr = sourceMap[key];
|
|
if (!Array.isArray(arr)) continue;
|
|
const kept = [];
|
|
for (const seg of arr) {
|
|
if (segmentTouchesRect(world, seg, rects.writeRect || rects.userRect)) removed++;
|
|
else kept.push(seg);
|
|
}
|
|
sourceMap[key] = kept;
|
|
}
|
|
return { removedLines: removed, roadAnchors, railAnchors };
|
|
}
|
|
|
|
function resetHumanFields(world, rect) {
|
|
for (const key of FLOAT_FIELD_KEYS) {
|
|
const field = ensureField(world, key, Float32Array, 0);
|
|
for (let y = rect.y0; y < rect.y1; y++) {
|
|
for (let x = rect.x0; x < rect.x1; x++) {
|
|
const i = worldIndex(world, x, y);
|
|
if (i >= 0) field[i] = 0;
|
|
}
|
|
}
|
|
}
|
|
const landuse = ensureField(world, "landuse", Uint8Array, LANDUSE.RURAL);
|
|
const adminId = ensureField(world, "adminId", Int32Array, -1);
|
|
const municipalityId = ensureField(world, "municipalityId", Int32Array, -1);
|
|
for (let y = rect.y0; y < rect.y1; y++) {
|
|
for (let x = rect.x0; x < rect.x1; x++) {
|
|
const i = worldIndex(world, x, y);
|
|
if (i < 0) continue;
|
|
landuse[i] = world.fields.sea?.[i] ? LANDUSE.RURAL : ((world.fields.slope?.[i] || 0) > 0.34 ? LANDUSE.FOREST : LANDUSE.RURAL);
|
|
adminId[i] = -1;
|
|
municipalityId[i] = -1;
|
|
}
|
|
}
|
|
}
|
|
|
|
function snapshotFields(world, rect, keys) {
|
|
const width = rectWidth(rect);
|
|
const out = { rect: { ...rect }, width, fields: {} };
|
|
for (const key of keys) {
|
|
const field = world.fields[key];
|
|
if (!field) continue;
|
|
const Constructor = field.constructor;
|
|
const copy = new Constructor(width * rectHeight(rect));
|
|
for (let y = rect.y0; y < rect.y1; y++) {
|
|
for (let x = rect.x0; x < rect.x1; x++) {
|
|
const wi = worldIndex(world, x, y);
|
|
const si = (y - rect.y0) * width + (x - rect.x0);
|
|
if (wi >= 0) copy[si] = field[wi];
|
|
}
|
|
}
|
|
out.fields[key] = copy;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function snapshotValue(snapshot, key, x, y) {
|
|
const field = snapshot?.fields?.[key];
|
|
const rect = snapshot?.rect;
|
|
if (!field || !insideRect(x, y, rect)) return undefined;
|
|
return field[(y - rect.y0) * snapshot.width + (x - rect.x0)];
|
|
}
|
|
|
|
function blendWeightFromRects(x, y, rects) {
|
|
if (insideRect(x, y, rects.blendRect)) return 1;
|
|
if (!insideRect(x, y, rects.repairRect)) return 0;
|
|
const band = Math.max(1, Math.min(
|
|
rects.blendRect.x0 - rects.repairRect.x0,
|
|
rects.blendRect.y0 - rects.repairRect.y0,
|
|
rects.repairRect.x1 - rects.blendRect.x1,
|
|
rects.repairRect.y1 - rects.blendRect.y1,
|
|
));
|
|
return clamp(distanceToRectEdge(x, y, rects.repairRect) / band);
|
|
}
|
|
|
|
function reconcileTransitionFields(world, rects, snapshot) {
|
|
let landUseCellsUpdated = 0;
|
|
const fields = ["settlementScore", "populationDensity", "stationInfluence", "roadInfluence", "railInfluence2", "villageInfluence"];
|
|
for (let y = rects.repairRect.y0; y < rects.repairRect.y1; y++) {
|
|
for (let x = rects.repairRect.x0; x < rects.repairRect.x1; x++) {
|
|
const i = worldIndex(world, x, y);
|
|
if (i < 0) continue;
|
|
const w = blendWeightFromRects(x, y, rects);
|
|
for (const key of fields) {
|
|
const oldValue = snapshotValue(snapshot, key, x, y);
|
|
if (oldValue === undefined || !world.fields[key]) continue;
|
|
world.fields[key][i] = oldValue * (1 - w) + world.fields[key][i] * w;
|
|
}
|
|
const oldLanduse = snapshotValue(snapshot, "landuse", x, y);
|
|
if (oldLanduse !== undefined && world.fields.landuse && w < 0.48) world.fields.landuse[i] = oldLanduse;
|
|
if (world.fields.landuse) landUseCellsUpdated++;
|
|
const oldAdmin = snapshotValue(snapshot, "adminId", x, y);
|
|
const oldMunicipality = snapshotValue(snapshot, "municipalityId", x, y);
|
|
if (oldAdmin !== undefined && world.fields.adminId && w < 0.35) world.fields.adminId[i] = oldAdmin;
|
|
if (oldMunicipality !== undefined && world.fields.municipalityId && w < 0.35) world.fields.municipalityId[i] = oldMunicipality;
|
|
}
|
|
}
|
|
return landUseCellsUpdated;
|
|
}
|
|
|
|
function sourcePoint(world, p) {
|
|
const src = worldToSourcePoint(world, p.x, p.y);
|
|
return { ...p, x: src.x, y: src.y, worldX: p.x, worldY: p.y, insidePrefecture: true, patchGenerated: true };
|
|
}
|
|
|
|
function sourcePath(world, path) {
|
|
return path.map(([x, y]) => [x - world.originX, y - world.originY]);
|
|
}
|
|
|
|
function addInfluence(world, point, radius, amount, fields, clipRect = null) {
|
|
const r = Math.max(1, Math.floor(radius));
|
|
for (let y = Math.max(0, point.y - r); y <= Math.min(world.height - 1, point.y + r); y++) {
|
|
for (let x = Math.max(0, point.x - r); x <= Math.min(world.width - 1, point.x + r); x++) {
|
|
const d = Math.hypot(x - point.x, y - point.y);
|
|
if (d > r) continue;
|
|
if (clipRect && !insideRect(x, y, clipRect)) continue;
|
|
const i = worldIndex(world, x, y);
|
|
if (i < 0 || world.fields.sea?.[i]) continue;
|
|
const w = (1 - d / r) ** 2 * amount;
|
|
for (const [key, mult] of fields) {
|
|
const field = ensureField(world, key, Float32Array, 0);
|
|
field[i] = clamp(field[i] + w * mult, 0, 1.8);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function setLanduseAround(world, point, radius, landuseCode, strength = 1, clipRect = null) {
|
|
const landuse = ensureField(world, "landuse", Uint8Array, LANDUSE.RURAL);
|
|
const r = Math.max(1, Math.floor(radius));
|
|
for (let y = Math.max(0, point.y - r); y <= Math.min(world.height - 1, point.y + r); y++) {
|
|
for (let x = Math.max(0, point.x - r); x <= Math.min(world.width - 1, point.x + r); x++) {
|
|
const d = Math.hypot(x - point.x, y - point.y);
|
|
if (d > r) continue;
|
|
if (clipRect && !insideRect(x, y, clipRect)) continue;
|
|
const i = worldIndex(world, x, y);
|
|
if (i < 0 || world.fields.sea?.[i]) continue;
|
|
const p = 1 - d / r;
|
|
if (p * strength < 0.22) continue;
|
|
if (landuseCode > landuse[i] || p > 0.62) landuse[i] = landuseCode;
|
|
}
|
|
}
|
|
}
|
|
|
|
function assignFarmlandAndForest(world, rect) {
|
|
const landuse = ensureField(world, "landuse", Uint8Array, LANDUSE.RURAL);
|
|
for (let y = rect.y0; y < rect.y1; y++) {
|
|
for (let x = rect.x0; x < rect.x1; x++) {
|
|
const i = worldIndex(world, x, y);
|
|
if (i < 0 || world.fields.sea?.[i]) continue;
|
|
if (landuse[i] >= LANDUSE.OLD_URBAN && landuse[i] <= LANDUSE.ROADSIDE) continue;
|
|
const slope = world.fields.slope?.[i] || 0;
|
|
const agriculture = world.fields.agriculture?.[i] || 0;
|
|
const plain = world.fields.plain?.[i] || 0;
|
|
if (slope > 0.42 || (world.fields.elevation?.[i] || 0) > 0.56) landuse[i] = LANDUSE.FOREST;
|
|
else if (agriculture > 0.28 || plain > 0.42) landuse[i] = LANDUSE.FARMLAND;
|
|
else landuse[i] = LANDUSE.RURAL;
|
|
}
|
|
}
|
|
}
|
|
|
|
function nextAdminId(sourceMap, world) {
|
|
let maxId = -1;
|
|
const field = world.fields.adminId;
|
|
if (field) {
|
|
for (let i = 0; i < field.length; i++) if (field[i] > maxId) maxId = field[i];
|
|
}
|
|
for (let i = 0; i < (sourceMap.adminCenters || []).length; i++) if (sourceMap.adminCenters[i]) maxId = Math.max(maxId, i);
|
|
return maxId + 1;
|
|
}
|
|
|
|
function assignLocalAdmin(world, sourceMap, rect, centers, seed, usedNames) {
|
|
const adminIdField = ensureField(world, "adminId", Int32Array, -1);
|
|
const municipalityField = ensureField(world, "municipalityId", Int32Array, -1);
|
|
const adminCenters = ensureSourceArray(sourceMap, "adminCenters");
|
|
let id = nextAdminId(sourceMap, world);
|
|
const centerRecords = [];
|
|
|
|
for (const center of centers) {
|
|
const population = center.population || 3500 + Math.round(seeded(seed, center.x, center.y, 221) * 21000 / 1000) * 1000;
|
|
const base = {
|
|
x: center.x,
|
|
y: center.y,
|
|
kind: "Municipal Center",
|
|
population,
|
|
municipalityPopulation: Math.max(population, Math.round(population * (1.8 + seeded(seed, center.x, center.y, 229) * 3.2))),
|
|
};
|
|
const name = center.name || makeName(seed, rect, center.x, center.y, { ...base, kind: "Municipal Center" }, usedNames, 230 + id);
|
|
const record = sourcePoint(world, { ...base, id, name, municipalityId: id, adminId: id, labelPriorityBase: 420 + Math.sqrt(population) });
|
|
adminCenters[id] = record;
|
|
centerRecords.push({ ...center, id, name, population: record.population, municipalityPopulation: record.municipalityPopulation });
|
|
id++;
|
|
}
|
|
|
|
if (!centerRecords.length) return centerRecords;
|
|
|
|
for (let y = rect.y0; y < rect.y1; y++) {
|
|
for (let x = rect.x0; x < rect.x1; x++) {
|
|
const i = worldIndex(world, x, y);
|
|
if (i < 0 || world.fields.sea?.[i]) continue;
|
|
let best = centerRecords[0];
|
|
let bestD = Infinity;
|
|
for (const c of centerRecords) {
|
|
const d = Math.hypot(x - c.x, y - c.y) * (1 + (world.fields.slope?.[i] || 0) * 1.8) - seeded(seed, x, y, c.id) * 2.2;
|
|
if (d < bestD) {
|
|
bestD = d;
|
|
best = c;
|
|
}
|
|
}
|
|
adminIdField[i] = best.id;
|
|
municipalityField[i] = best.id;
|
|
}
|
|
}
|
|
return centerRecords;
|
|
}
|
|
|
|
function collectExistingAdminCenters(world, sourceMap, rect) {
|
|
const centers = [];
|
|
for (const p of sourceMap.adminCenters || []) {
|
|
if (!p || p.patchHidden) continue;
|
|
const x = Math.round(pointWorldX(world, p));
|
|
const y = Math.round(pointWorldY(world, p));
|
|
if (insideRect(x, y, rect)) continue;
|
|
const id = Number.isFinite(p.adminId) ? p.adminId : Number.isFinite(p.municipalityId) ? p.municipalityId : Number.isFinite(p.id) ? p.id : null;
|
|
if (id === null || id < 0) continue;
|
|
const distance = Math.max(rect.x0 - x, x - (rect.x1 - 1), rect.y0 - y, y - (rect.y1 - 1), 0);
|
|
if (distance <= 48) centers.push({ x, y, id, name: p.name, population: p.population || p.municipalityPopulation || 3000, external: true });
|
|
}
|
|
return centers;
|
|
}
|
|
|
|
function reassignAdminRepair(world, sourceMap, rects, localCenters, seed) {
|
|
const adminIdField = ensureField(world, "adminId", Int32Array, -1);
|
|
const municipalityField = ensureField(world, "municipalityId", Int32Array, -1);
|
|
const externalCenters = collectExistingAdminCenters(world, sourceMap, rects.userRect);
|
|
const centers = [...externalCenters, ...(localCenters || [])].filter((p) => Number.isFinite(p?.id));
|
|
if (!centers.length) return { adminCellsReassigned: 0, adminBoundarySmoothed: 0 };
|
|
|
|
let reassigned = 0;
|
|
for (let y = rects.repairRect.y0; y < rects.repairRect.y1; y++) {
|
|
for (let x = rects.repairRect.x0; x < rects.repairRect.x1; x++) {
|
|
const i = worldIndex(world, x, y);
|
|
if (i < 0 || world.fields.sea?.[i]) continue;
|
|
let best = null;
|
|
let bestScore = Infinity;
|
|
for (const c of centers) {
|
|
const d = Math.hypot(x - c.x, y - c.y);
|
|
const externalBias = c.external ? -4.5 * (1 - blendWeightFromRects(x, y, rects)) : 0;
|
|
const score = d * (1 + (world.fields.slope?.[i] || 0) * 1.25) + externalBias - seeded(seed, x, y, c.id + 1300) * 1.5;
|
|
if (score < bestScore) {
|
|
bestScore = score;
|
|
best = c;
|
|
}
|
|
}
|
|
if (best && adminIdField[i] !== best.id) {
|
|
adminIdField[i] = best.id;
|
|
municipalityField[i] = best.id;
|
|
reassigned++;
|
|
}
|
|
}
|
|
}
|
|
|
|
let smoothed = 0;
|
|
for (let pass = 0; pass < 2; pass++) {
|
|
const changes = [];
|
|
for (let y = rects.repairRect.y0 + 1; y < rects.repairRect.y1 - 1; y++) {
|
|
for (let x = rects.repairRect.x0 + 1; x < rects.repairRect.x1 - 1; x++) {
|
|
const i = worldIndex(world, x, y);
|
|
if (i < 0 || world.fields.sea?.[i] || blendWeightFromRects(x, y, rects) < 0.2) continue;
|
|
const counts = new Map();
|
|
for (const [dx, dy] of [[1,0],[-1,0],[0,1],[0,-1]]) {
|
|
const ni = worldIndex(world, x + dx, y + dy);
|
|
const id = ni >= 0 ? adminIdField[ni] : -1;
|
|
if (id >= 0) counts.set(id, (counts.get(id) || 0) + 1);
|
|
}
|
|
const current = adminIdField[i];
|
|
const best = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
if (best && best[0] !== current && best[1] >= 3) changes.push([i, best[0]]);
|
|
}
|
|
}
|
|
for (const [i, id] of changes) {
|
|
adminIdField[i] = id;
|
|
municipalityField[i] = id;
|
|
smoothed++;
|
|
}
|
|
}
|
|
return { adminCellsReassigned: reassigned, adminBoundarySmoothed: smoothed };
|
|
}
|
|
|
|
function buildAdminBorders(world, rect) {
|
|
const adminId = world.fields.adminId;
|
|
const sea = world.fields.sea;
|
|
if (!adminId) return [];
|
|
const segments = [];
|
|
for (let y = rect.y0; y < rect.y1; y++) {
|
|
for (let x = rect.x0; x < rect.x1; x++) {
|
|
const i = worldIndex(world, x, y);
|
|
if (i < 0 || sea?.[i]) continue;
|
|
const id = adminId[i];
|
|
if (id < 0) continue;
|
|
const east = worldIndex(world, x + 1, y);
|
|
if (x + 1 < rect.x1 && east >= 0 && !sea?.[east] && adminId[east] >= 0 && adminId[east] !== id) {
|
|
segments.push(sourcePath(world, [[x + 1, y], [x + 1, y + 1]]));
|
|
}
|
|
const south = worldIndex(world, x, y + 1);
|
|
if (y + 1 < rect.y1 && south >= 0 && !sea?.[south] && adminId[south] >= 0 && adminId[south] !== id) {
|
|
segments.push(sourcePath(world, [[x, y + 1], [x + 1, y + 1]]));
|
|
}
|
|
}
|
|
}
|
|
return segments;
|
|
}
|
|
|
|
function clampToRect(x, y, rect) {
|
|
return {
|
|
x: Math.max(rect.x0, Math.min(rect.x1 - 1, Math.round(x))),
|
|
y: Math.max(rect.y0, Math.min(rect.y1 - 1, Math.round(y))),
|
|
};
|
|
}
|
|
|
|
function nearestLand(world, x, y, rect, maxRadius = 10) {
|
|
const start = clampToRect(x, y, rect);
|
|
if (isLand(world, start.x, start.y)) return start;
|
|
for (let r = 1; r <= maxRadius; r++) {
|
|
let best = null;
|
|
let bestD = Infinity;
|
|
for (let dy = -r; dy <= r; dy++) {
|
|
for (let dx = -r; dx <= r; dx++) {
|
|
if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue;
|
|
const p = clampToRect(start.x + dx, start.y + dy, rect);
|
|
if (!isLand(world, p.x, p.y)) continue;
|
|
const d = Math.hypot(p.x - x, p.y - y);
|
|
if (d < bestD) {
|
|
best = p;
|
|
bestD = d;
|
|
}
|
|
}
|
|
}
|
|
if (best) return best;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function findPath(world, startInput, endInput, rect, options = {}) {
|
|
const margin = options.margin ?? 8;
|
|
const searchRect = expandRect(rect, margin, world);
|
|
const start = nearestLand(world, startInput.x, startInput.y, searchRect, 12);
|
|
const end = nearestLand(world, endInput.x, endInput.y, searchRect, 12);
|
|
if (!start || !end) return [];
|
|
if (start.x === end.x && start.y === end.y) return [[start.x, start.y]];
|
|
|
|
const w = rectWidth(searchRect);
|
|
const h = rectHeight(searchRect);
|
|
const localIndex = (x, y) => (y - searchRect.y0) * w + (x - searchRect.x0);
|
|
const total = w * h;
|
|
const g = new Float32Array(total);
|
|
g.fill(Infinity);
|
|
const prev = new Int32Array(total);
|
|
prev.fill(-1);
|
|
const startIdx = localIndex(start.x, start.y);
|
|
const endIdx = localIndex(end.x, end.y);
|
|
const open = new MinHeap();
|
|
g[startIdx] = 0;
|
|
open.push({ i: startIdx, x: start.x, y: start.y, f: Math.hypot(end.x - start.x, end.y - start.y) });
|
|
const dirs = [[1,0],[-1,0],[0,1],[0,-1],[1,1],[1,-1],[-1,1],[-1,-1]];
|
|
let iterations = 0;
|
|
const maxIterations = Math.min(total * 8, 90000);
|
|
|
|
while (open.length && iterations++ < maxIterations) {
|
|
const current = open.pop();
|
|
if (!current) break;
|
|
if (current.i === endIdx) break;
|
|
if (current.f > g[current.i] + Math.hypot(end.x - current.x, end.y - current.y) * 1.45 + 100) continue;
|
|
|
|
for (const [dx, dy] of dirs) {
|
|
const nx = current.x + dx;
|
|
const ny = current.y + dy;
|
|
if (nx < searchRect.x0 || ny < searchRect.y0 || nx >= searchRect.x1 || ny >= searchRect.y1) continue;
|
|
const wi = worldIndex(world, nx, ny);
|
|
if (wi < 0 || world.fields.sea?.[wi]) continue;
|
|
const ni = localIndex(nx, ny);
|
|
const step = Math.hypot(dx, dy);
|
|
const slope = world.fields.slope?.[wi] || 0;
|
|
const river = world.fields.river?.[wi] || 0;
|
|
const plain = world.fields.plain?.[wi] || 0;
|
|
const roadEase = options.rail ? Math.max(0, slope - 0.10) * 9.5 : slope * 4.5;
|
|
const cost = step * (1 + roadEase - plain * 0.18 - river * 0.07);
|
|
const ng = g[current.i] + Math.max(0.2, cost);
|
|
if (ng >= g[ni]) continue;
|
|
g[ni] = ng;
|
|
prev[ni] = current.i;
|
|
const heuristic = Math.hypot(end.x - nx, end.y - ny) * (options.rail ? 1.20 : 1.05);
|
|
open.push({ i: ni, x: nx, y: ny, f: ng + heuristic });
|
|
}
|
|
}
|
|
|
|
if (!Number.isFinite(g[endIdx])) return straightFallback(world, start, end, searchRect);
|
|
const path = [];
|
|
let cursor = endIdx;
|
|
for (let guard = 0; cursor >= 0 && guard < total; guard++) {
|
|
const lx = cursor % w;
|
|
const ly = Math.floor(cursor / w);
|
|
path.push([searchRect.x0 + lx, searchRect.y0 + ly]);
|
|
if (cursor === startIdx) break;
|
|
cursor = prev[cursor];
|
|
}
|
|
path.reverse();
|
|
return simplifyPath(path);
|
|
}
|
|
|
|
function straightFallback(world, start, end, rect) {
|
|
const steps = Math.max(2, Math.ceil(Math.hypot(end.x - start.x, end.y - start.y)));
|
|
const path = [];
|
|
for (let k = 0; k <= steps; k++) {
|
|
const t = k / steps;
|
|
const p = nearestLand(world, start.x + (end.x - start.x) * t, start.y + (end.y - start.y) * t, rect, 5);
|
|
if (!p) continue;
|
|
if (!path.length || path[path.length - 1][0] !== p.x || path[path.length - 1][1] !== p.y) path.push([p.x, p.y]);
|
|
}
|
|
return simplifyPath(path);
|
|
}
|
|
|
|
function simplifyPath(path) {
|
|
if (!Array.isArray(path) || path.length <= 2) return path || [];
|
|
const out = [path[0]];
|
|
let lastDx = null;
|
|
let lastDy = null;
|
|
for (let i = 1; i < path.length - 1; i++) {
|
|
const prev = out[out.length - 1];
|
|
const cur = path[i];
|
|
const next = path[i + 1];
|
|
const dx1 = Math.sign(cur[0] - prev[0]);
|
|
const dy1 = Math.sign(cur[1] - prev[1]);
|
|
const dx2 = Math.sign(next[0] - cur[0]);
|
|
const dy2 = Math.sign(next[1] - cur[1]);
|
|
if (dx1 !== dx2 || dy1 !== dy2 || i % 8 === 0) out.push(cur);
|
|
lastDx = dx1;
|
|
lastDy = dy1;
|
|
}
|
|
out.push(path[path.length - 1]);
|
|
return out;
|
|
}
|
|
|
|
function writePathInfluence(world, path, key, radius, amount, clipRect = null) {
|
|
const field = ensureField(world, key, Float32Array, 0);
|
|
const r = Math.max(1, radius | 0);
|
|
for (const [px, py] of path || []) {
|
|
for (let y = Math.max(0, py - r); y <= Math.min(world.height - 1, py + r); y++) {
|
|
for (let x = Math.max(0, px - r); x <= Math.min(world.width - 1, px + r); x++) {
|
|
const d = Math.hypot(x - px, y - py);
|
|
if (d > r) continue;
|
|
if (clipRect && !insideRect(x, y, clipRect)) continue;
|
|
const i = worldIndex(world, x, y);
|
|
if (i < 0 || world.fields.sea?.[i]) continue;
|
|
field[i] = clamp(field[i] + (1 - d / r) * amount, 0, 1.6);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function chooseLocalCounts(rect, landCount, coastCount) {
|
|
const area = rectArea(rect);
|
|
const landArea = Math.max(0, landCount);
|
|
const scale = Math.sqrt(Math.max(1, area) / 3000);
|
|
const landScale = Math.sqrt(Math.max(1, landArea) / 3000);
|
|
return {
|
|
admin: Math.max(1, Math.min(10, Math.round(1 + landScale * 2.2))),
|
|
modern: Math.max(0, Math.min(7, Math.round(landScale * 1.35))),
|
|
markets: Math.max(1, Math.min(10, Math.round(landScale * 2.0))),
|
|
villages: Math.max(3, Math.min(22, Math.round(landScale * 5.2))),
|
|
ports: Math.max(0, Math.min(6, Math.round(Math.sqrt(Math.max(0, coastCount)) / 8))),
|
|
castles: Math.max(0, Math.min(4, Math.round(scale * 0.8))),
|
|
industrial: Math.max(0, Math.min(4, Math.round(landScale * 0.65))),
|
|
logistics: Math.max(0, Math.min(4, Math.round(landScale * 0.70))),
|
|
newTowns: Math.max(0, Math.min(4, Math.round(landScale * 0.55))),
|
|
};
|
|
}
|
|
|
|
function countLandAndCoast(world, rect) {
|
|
let land = 0;
|
|
let coast = 0;
|
|
for (let y = rect.y0; y < rect.y1; y++) {
|
|
for (let x = rect.x0; x < rect.x1; x++) {
|
|
if (!isLand(world, x, y)) continue;
|
|
land++;
|
|
if (seaNeighbors(world, x, y, 1) > 0) coast++;
|
|
}
|
|
}
|
|
return { land, coast };
|
|
}
|
|
|
|
function topN(points, n) {
|
|
return [...points].sort((a, b) => (b.score || 0) - (a.score || 0)).slice(0, Math.max(0, n));
|
|
}
|
|
|
|
function buildSettlementLayers(world, sourceMap, rect, seed, usedNames) {
|
|
const landStats = countLandAndCoast(world, rect);
|
|
if (landStats.land < 24) return { counts: {}, centers: [] };
|
|
const counts = chooseLocalCounts(rect, landStats.land, landStats.coast);
|
|
const landCandidates = collectLandCandidates(world, rect, seed, rectArea(rect) > 16000 ? 4 : 3);
|
|
const portCandidates = collectPortCandidates(world, rect, seed);
|
|
|
|
const ports = pickEntities(portCandidates, { max: counts.ports, minDistance: 18, threshold: 0.55, seed: seed + 110, jitter: 0.08 }).map((p, idx) => {
|
|
const portClass = idx === 0 && p.score > 1.05 ? "regional" : "fishing";
|
|
const pop = portClass === "regional" ? 9000 + Math.round(seeded(seed, p.x, p.y, 301) * 22000 / 1000) * 1000 : 1600 + Math.round(seeded(seed, p.x, p.y, 302) * 5200 / 100) * 100;
|
|
const point = { ...p, kind: portClass === "regional" ? "Regional Port" : "Fishing Port", portClass, population: pop, labelPriorityBase: portClass === "regional" ? 360 : 160 };
|
|
return sourcePoint(world, { ...point, name: makeName(seed, rect, p.x, p.y, point, usedNames, 300 + idx) });
|
|
});
|
|
|
|
const citySeeds = pickEntities(landCandidates, { max: counts.modern, minDistance: 24, threshold: 0.50, seed: seed + 120, jitter: 0.12 });
|
|
const modernCities = citySeeds.map((p, idx) => {
|
|
const rank = idx === 0 && rectArea(rect) > 9000 ? "Regional City" : "Local City";
|
|
const popBase = rank === "Regional City" ? 52000 : 18000;
|
|
const popSpan = rank === "Regional City" ? 140000 : 52000;
|
|
const population = popBase + Math.round(seeded(seed, p.x, p.y, 401) * popSpan / 1000) * 1000;
|
|
const point = { ...p, kind: rank, rank, population, labelPriorityBase: rank === "Regional City" ? 760 : 520, isRegionalCapital: rank === "Regional City" && idx === 0 };
|
|
return sourcePoint(world, { ...point, name: makeName(seed, rect, p.x, p.y, point, usedNames, 400 + idx) });
|
|
});
|
|
|
|
const marketSeeds = pickEntities(landCandidates, { max: counts.markets, minDistance: 14, threshold: 0.42, seed: seed + 130, jitter: 0.10 })
|
|
.filter((p) => citySeeds.every((c) => Math.hypot(p.x - c.x, p.y - c.y) >= 9));
|
|
const markets = marketSeeds.map((p, idx) => {
|
|
const population = 3000 + Math.round(seeded(seed, p.x, p.y, 501) * 13000 / 500) * 500;
|
|
const point = { ...p, kind: "Market Town", population, labelPriorityBase: 230 };
|
|
return sourcePoint(world, { ...point, name: makeName(seed, rect, p.x, p.y, point, usedNames, 500 + idx) });
|
|
});
|
|
|
|
const villageSeeds = pickEntities(landCandidates, { max: counts.villages, minDistance: 8, threshold: 0.31, seed: seed + 140, jitter: 0.12 })
|
|
.filter((p) => [...citySeeds, ...marketSeeds].every((c) => Math.hypot(p.x - c.x, p.y - c.y) >= 6));
|
|
const villages = villageSeeds.map((p, idx) => {
|
|
const population = 700 + Math.round(seeded(seed, p.x, p.y, 601) * 5200 / 100) * 100;
|
|
const point = { ...p, kind: "Village", population, labelPriorityBase: 80 };
|
|
return sourcePoint(world, { ...point, name: makeName(seed, rect, p.x, p.y, point, usedNames, 600 + idx) });
|
|
});
|
|
|
|
const castleSeeds = pickEntities(landCandidates.map((p) => ({ ...p, score: p.score + (world.fields.elevation?.[worldIndex(world, p.x, p.y)] || 0) * 0.4 })), { max: counts.castles, minDistance: 20, threshold: 0.52, seed: seed + 150, jitter: 0.15 });
|
|
const castles = castleSeeds.map((p, idx) => {
|
|
const point = { ...p, kind: "Castle", population: 0, labelPriorityBase: 190 };
|
|
return sourcePoint(world, { ...point, name: makeName(seed, rect, p.x, p.y, point, usedNames, 700 + idx) });
|
|
});
|
|
|
|
for (const p of ports) ensureSourceArray(sourceMap, "ports").push(p);
|
|
for (const p of modernCities) ensureSourceArray(sourceMap, "modernCities").push(p);
|
|
for (const p of markets) ensureSourceArray(sourceMap, "markets").push(p);
|
|
for (const p of villages) ensureSourceArray(sourceMap, "villages").push(p);
|
|
for (const p of castles) ensureSourceArray(sourceMap, "castles").push(p);
|
|
|
|
const centers = topN([
|
|
...modernCities.map((p) => ({ x: p.worldX, y: p.worldY, name: p.name, population: p.population, score: 2 + (p.population || 0) / 70000 })),
|
|
...markets.map((p) => ({ x: p.worldX, y: p.worldY, name: p.name, population: p.population, score: 1 + (p.population || 0) / 28000 })),
|
|
...ports.map((p) => ({ x: p.worldX, y: p.worldY, name: p.name, population: p.population, score: 0.8 + (p.population || 0) / 26000 })),
|
|
...villages.map((p) => ({ x: p.worldX, y: p.worldY, name: p.name, population: p.population, score: 0.4 + (p.population || 0) / 16000 })),
|
|
], counts.admin);
|
|
|
|
return {
|
|
counts: {
|
|
ports: ports.length,
|
|
modernCities: modernCities.length,
|
|
markets: markets.length,
|
|
villages: villages.length,
|
|
castles: castles.length,
|
|
},
|
|
centers,
|
|
localPoints: { ports, modernCities, markets, villages, castles },
|
|
};
|
|
}
|
|
|
|
function buildTransportLayers(world, sourceMap, rect, seed, localPoints, connectors = {}) {
|
|
const cities = (localPoints.modernCities || []).map((p) => ({ ...p, x: p.worldX, y: p.worldY }));
|
|
const ports = (localPoints.ports || []).map((p) => ({ ...p, x: p.worldX, y: p.worldY }));
|
|
const markets = (localPoints.markets || []).map((p) => ({ ...p, x: p.worldX, y: p.worldY }));
|
|
const villages = (localPoints.villages || []).map((p) => ({ ...p, x: p.worldX, y: p.worldY }));
|
|
const roadAnchors = (connectors.roadAnchors || []).map((p) => ({ ...p, score: 1.3 }));
|
|
const railAnchors = (connectors.railAnchors || []).map((p) => ({ ...p, score: 1.2 }));
|
|
const trunkNodes = topN([...cities, ...ports, ...markets], Math.min(7, Math.max(2, cities.length + ports.length + 1)));
|
|
let nationalRoads = 0;
|
|
let minorRoads = 0;
|
|
let railways = 0;
|
|
let stations = 0;
|
|
let roadConnectorsCreated = 0;
|
|
let railwayConnectorsCreated = 0;
|
|
|
|
for (let i = 1; i < trunkNodes.length; i++) {
|
|
const target = trunkNodes[i];
|
|
const previous = trunkNodes.slice(0, i).sort((a, b) => Math.hypot(a.x - target.x, a.y - target.y) - Math.hypot(b.x - target.x, b.y - target.y))[0];
|
|
const path = findPath(world, previous, target, rect, { margin: 10 });
|
|
if (path.length >= 3) {
|
|
ensureSourceArray(sourceMap, "nationalRoads").push(sourcePath(world, path));
|
|
writePathInfluence(world, path, "roadInfluence", 2, 0.36);
|
|
nationalRoads++;
|
|
}
|
|
}
|
|
|
|
const connectorTargets = trunkNodes.length ? trunkNodes : topN([...cities, ...ports, ...markets, ...villages], 4);
|
|
for (const anchor of roadAnchors.slice(0, 10)) {
|
|
if (!connectorTargets.length) break;
|
|
const nearest = connectorTargets
|
|
.filter((node) => Math.hypot(anchor.x - node.x, anchor.y - node.y) >= 4)
|
|
.sort((a, b) => Math.hypot(anchor.x - a.x, anchor.y - a.y) - Math.hypot(anchor.x - b.x, anchor.y - b.y))[0];
|
|
if (!nearest) continue;
|
|
const path = findPath(world, anchor, nearest, rect, { margin: 4 });
|
|
if (path.length >= 3) {
|
|
ensureSourceArray(sourceMap, "nationalRoads").push(sourcePath(world, path));
|
|
writePathInfluence(world, path, "roadInfluence", 2, 0.34);
|
|
nationalRoads++;
|
|
roadConnectorsCreated++;
|
|
}
|
|
}
|
|
|
|
for (const node of [...markets, ...villages]) {
|
|
const anchors = trunkNodes.length ? trunkNodes : cities;
|
|
if (!anchors.length) continue;
|
|
const nearest = anchors.sort((a, b) => Math.hypot(a.x - node.x, a.y - node.y) - Math.hypot(b.x - node.x, b.y - node.y))[0];
|
|
if (!nearest || Math.hypot(nearest.x - node.x, nearest.y - node.y) < 3) continue;
|
|
const path = findPath(world, node, nearest, rect, { margin: 8 });
|
|
if (path.length >= 3) {
|
|
ensureSourceArray(sourceMap, "minorRoads").push(sourcePath(world, path));
|
|
writePathInfluence(world, path, "roadInfluence", 1, 0.16);
|
|
minorRoads++;
|
|
}
|
|
}
|
|
|
|
const railNodes = topN([...cities, ...ports], Math.min(4, cities.length + ports.length));
|
|
for (let i = 1; i < railNodes.length; i++) {
|
|
const path = findPath(world, railNodes[i - 1], railNodes[i], rect, { margin: 12, rail: true });
|
|
if (path.length >= 6) {
|
|
ensureSourceArray(sourceMap, i === 1 ? "railways" : "branchRailways").push(sourcePath(world, path));
|
|
writePathInfluence(world, path, "railInfluence2", 2, 0.42);
|
|
railways++;
|
|
for (let k = 0; k < path.length; k += 14) {
|
|
const [x, y] = path[k];
|
|
if (!insideRect(x, y, rect) || !isLand(world, x, y)) continue;
|
|
const point = sourcePoint(world, { x, y, kind: k === 0 || k >= path.length - 14 ? "Major Station" : "Station", name: `Station ${Math.max(1, stations + 1)}`, population: 0, labelPriorityBase: 120 });
|
|
ensureSourceArray(sourceMap, "stations").push(point);
|
|
addInfluence(world, { x, y }, 5, 0.38, [["stationInfluence", 1.0], ["populationDensity", 0.25]]);
|
|
stations++;
|
|
}
|
|
}
|
|
}
|
|
|
|
const railTargets = railNodes.length ? railNodes : topN([...cities, ...ports], 3);
|
|
for (const anchor of railAnchors.slice(0, 6)) {
|
|
if (!railTargets.length) break;
|
|
const nearest = railTargets
|
|
.filter((node) => Math.hypot(anchor.x - node.x, anchor.y - node.y) >= 8)
|
|
.sort((a, b) => Math.hypot(anchor.x - a.x, anchor.y - a.y) - Math.hypot(anchor.x - b.x, anchor.y - b.y))[0];
|
|
if (!nearest) continue;
|
|
const path = findPath(world, anchor, nearest, rect, { margin: 6, rail: true });
|
|
if (path.length >= 6) {
|
|
ensureSourceArray(sourceMap, "branchRailways").push(sourcePath(world, path));
|
|
writePathInfluence(world, path, "railInfluence2", 2, 0.38);
|
|
railways++;
|
|
railwayConnectorsCreated++;
|
|
}
|
|
}
|
|
|
|
return { nationalRoads, minorRoads, railways, stations, roadConnectorsCreated, railwayConnectorsCreated };
|
|
}
|
|
|
|
function buildDevelopmentLayers(world, sourceMap, rect, seed, localPoints, usedNames) {
|
|
const bases = [
|
|
...(localPoints.modernCities || []).map((p) => ({ x: p.worldX, y: p.worldY, score: 1.4, name: p.name })),
|
|
...(localPoints.ports || []).map((p) => ({ x: p.worldX, y: p.worldY, score: 1.1, name: p.name })),
|
|
...(localPoints.markets || []).map((p) => ({ x: p.worldX, y: p.worldY, score: 0.8, name: p.name })),
|
|
];
|
|
const landCandidates = collectLandCandidates(world, rect, seed + 900, 4)
|
|
.map((p) => ({ ...p, score: p.score + (world.fields.roadInfluence?.[worldIndex(world, p.x, p.y)] || 0) * 0.9 + (world.fields.railInfluence2?.[worldIndex(world, p.x, p.y)] || 0) * 0.8 }));
|
|
const counts = chooseLocalCounts(rect, countLandAndCoast(world, rect).land, countLandAndCoast(world, rect).coast);
|
|
const industrialSeeds = pickEntities(landCandidates, { max: counts.industrial, minDistance: 18, threshold: 0.52, seed: seed + 910, jitter: 0.12 });
|
|
const logisticsSeeds = pickEntities(landCandidates, { max: counts.logistics, minDistance: 16, threshold: 0.50, seed: seed + 920, jitter: 0.12 });
|
|
const newTownSeeds = pickEntities(landCandidates, { max: counts.newTowns, minDistance: 18, threshold: 0.46, seed: seed + 930, jitter: 0.12 });
|
|
|
|
const industrialZones = industrialSeeds.map((p, idx) => {
|
|
const point = { ...p, kind: "Industrial Zone", population: 0, labelPriorityBase: 90 };
|
|
return sourcePoint(world, { ...point, name: `${makeName(seed, rect, p.x, p.y, point, usedNames, 910 + idx)} Industrial` });
|
|
});
|
|
const logisticsParks = logisticsSeeds.map((p, idx) => {
|
|
const point = { ...p, kind: "Logistics Park", population: 0, labelPriorityBase: 80 };
|
|
return sourcePoint(world, { ...point, name: null, facilityLabel: "Logistics Park", labelStyle: "facility", suppressSettlementLabel: true });
|
|
});
|
|
const newTowns = newTownSeeds.map((p, idx) => {
|
|
const population = 6000 + Math.round(seeded(seed, p.x, p.y, 931) * 26000 / 1000) * 1000;
|
|
const point = { ...p, kind: "New Town", population, labelPriorityBase: 180 };
|
|
return sourcePoint(world, { ...point, name: `${makeName(seed, rect, p.x, p.y, point, usedNames, 930 + idx)} New Town` });
|
|
});
|
|
|
|
for (const p of industrialZones) {
|
|
ensureSourceArray(sourceMap, "industrialZones").push(p);
|
|
setLanduseAround(world, { x: p.worldX, y: p.worldY }, 5, LANDUSE.INDUSTRIAL, 1.0);
|
|
addInfluence(world, { x: p.worldX, y: p.worldY }, 6, 0.25, [["populationDensity", 0.2]]);
|
|
}
|
|
for (const p of logisticsParks) {
|
|
ensureSourceArray(sourceMap, "logisticsParks").push(p);
|
|
setLanduseAround(world, { x: p.worldX, y: p.worldY }, 4, LANDUSE.LOGISTICS, 1.0);
|
|
}
|
|
for (const p of newTowns) {
|
|
ensureSourceArray(sourceMap, "newTowns").push(p);
|
|
setLanduseAround(world, { x: p.worldX, y: p.worldY }, 6, LANDUSE.NEW_TOWN, 1.0);
|
|
addInfluence(world, { x: p.worldX, y: p.worldY }, 8, 0.42, [["populationDensity", 1.0], ["settlementScore", 0.5]]);
|
|
}
|
|
|
|
return { industrialZones: industrialZones.length, logisticsParks: logisticsParks.length, newTowns: newTowns.length };
|
|
}
|
|
|
|
function applySettlementInfluence(world, localPoints) {
|
|
for (const p of localPoints.modernCities || []) {
|
|
const wp = { x: p.worldX, y: p.worldY };
|
|
setLanduseAround(world, wp, 5, LANDUSE.CBD, 1.0);
|
|
setLanduseAround(world, wp, 10, LANDUSE.SUBURB, 0.74);
|
|
addInfluence(world, wp, 13, 0.85, [["populationDensity", 1.0], ["settlementScore", 0.9]]);
|
|
}
|
|
for (const p of localPoints.markets || []) {
|
|
const wp = { x: p.worldX, y: p.worldY };
|
|
setLanduseAround(world, wp, 4, LANDUSE.OLD_URBAN, 0.86);
|
|
addInfluence(world, wp, 8, 0.52, [["populationDensity", 0.7], ["settlementScore", 0.8]]);
|
|
}
|
|
for (const p of localPoints.ports || []) {
|
|
const wp = { x: p.worldX, y: p.worldY };
|
|
setLanduseAround(world, wp, p.portClass === "regional" ? 5 : 3, LANDUSE.OLD_URBAN, 0.8);
|
|
addInfluence(world, wp, 7, 0.44, [["populationDensity", 0.55], ["settlementScore", 0.55]]);
|
|
}
|
|
for (const p of localPoints.villages || []) {
|
|
const wp = { x: p.worldX, y: p.worldY };
|
|
setLanduseAround(world, wp, 2, LANDUSE.FARMLAND, 0.72);
|
|
addInfluence(world, wp, 5, 0.28, [["populationDensity", 0.35], ["settlementScore", 0.45], ["villageInfluence", 1.0]]);
|
|
}
|
|
}
|
|
|
|
function applyPreservedInfluence(world, sourceMap, rect) {
|
|
const expanded = expandRect(rect, 20, world);
|
|
let pointsApplied = 0;
|
|
const pointConfigs = [
|
|
["modernCities", 13, 0.72, [["populationDensity", 1.0], ["settlementScore", 0.8]], LANDUSE.SUBURB],
|
|
["satelliteCities", 10, 0.55, [["populationDensity", 0.8], ["settlementScore", 0.65]], LANDUSE.SUBURB],
|
|
["markets", 8, 0.42, [["populationDensity", 0.65], ["settlementScore", 0.7]], LANDUSE.OLD_URBAN],
|
|
["ports", 7, 0.40, [["populationDensity", 0.55], ["settlementScore", 0.5]], LANDUSE.OLD_URBAN],
|
|
["villages", 5, 0.24, [["populationDensity", 0.32], ["settlementScore", 0.38], ["villageInfluence", 0.9]], LANDUSE.FARMLAND],
|
|
["stations", 5, 0.30, [["stationInfluence", 1.0], ["populationDensity", 0.20]], LANDUSE.ROADSIDE],
|
|
["industrialZones", 6, 0.22, [["populationDensity", 0.18]], LANDUSE.INDUSTRIAL],
|
|
["logisticsParks", 5, 0.18, [["roadInfluence", 0.25]], LANDUSE.LOGISTICS],
|
|
["newTowns", 8, 0.35, [["populationDensity", 0.85], ["settlementScore", 0.45]], LANDUSE.NEW_TOWN],
|
|
];
|
|
for (const [key, radius, amount, fields, landuseCode] of pointConfigs) {
|
|
for (const p of sourceMap[key] || []) {
|
|
if (!p || p.patchHidden) continue;
|
|
const point = { x: Math.round(pointWorldX(world, p)), y: Math.round(pointWorldY(world, p)) };
|
|
if (!insideRect(point.x, point.y, expanded)) continue;
|
|
addInfluence(world, point, radius, amount, fields, rect);
|
|
setLanduseAround(world, point, Math.max(2, Math.floor(radius * 0.45)), landuseCode, 0.45, rect);
|
|
pointsApplied++;
|
|
}
|
|
}
|
|
return pointsApplied;
|
|
}
|
|
|
|
function applyPreservedPathInfluence(world, sourceMap, rect) {
|
|
let roadPaths = 0;
|
|
let railPaths = 0;
|
|
for (const key of PATH_LAYER_KEYS) {
|
|
const isRail = key.includes("Rail") || key.includes("rail");
|
|
const influenceKey = isRail ? "railInfluence2" : "roadInfluence";
|
|
const radius = isRail ? 2 : 2;
|
|
const amount = isRail ? 0.30 : 0.22;
|
|
for (const path of sourceMap[key] || []) {
|
|
const worldPath = (path || []).map((tuple) => [Math.round(tupleWorldX(world, tuple)), Math.round(tupleWorldY(world, tuple))]);
|
|
if (!worldPath.some(([x, y]) => insideRect(x, y, expandRect(rect, 8, world)))) continue;
|
|
writePathInfluence(world, worldPath, influenceKey, radius, amount, rect);
|
|
if (isRail) railPaths++;
|
|
else roadPaths++;
|
|
}
|
|
}
|
|
return { roadPaths, railPaths };
|
|
}
|
|
|
|
function updatePopulationSummary(sourceMap) {
|
|
const popArrays = ["modernCities", "markets", "villages", "ports", "satelliteCities", "newTowns"];
|
|
let total = 0;
|
|
for (const key of popArrays) {
|
|
for (const p of sourceMap[key] || []) total += Number.isFinite(p?.population) ? p.population : 0;
|
|
}
|
|
sourceMap.totalPopulation = Math.max(0, Math.round(total));
|
|
}
|
|
|
|
export function regenerateHumanGeographyPatch(world, rects, options = {}) {
|
|
if (!world?.sourceMap || !rects?.repairRect || !rects?.blendRect) {
|
|
return { ok: false, reason: "World/source map/patch rects are missing." };
|
|
}
|
|
const sourceMap = world.sourceMap;
|
|
const seed = Number.isFinite(options.seed) ? options.seed >>> 0 : ((world.seed || 0) ^ 0xa511e9b3) >>> 0;
|
|
const userRect = rects.writeRect || rects.userRect;
|
|
const buildRect = rects.coreRect || rects.blendRect;
|
|
const repairRect = rects.repairRect;
|
|
const usedNames = usedNameSet(sourceMap);
|
|
const preservedExternalEntities = pointLayerPreservedCount(sourceMap, world, userRect) + pathLayerPreservedCount(sourceMap, world, userRect);
|
|
|
|
for (const [key, fallback] of INT_FIELD_DEFAULTS.entries()) ensureField(world, key, Int32Array, fallback);
|
|
ensureField(world, "landuse", Uint8Array, LANDUSE.RURAL);
|
|
for (const key of FLOAT_FIELD_KEYS) ensureField(world, key, Float32Array, 0);
|
|
|
|
const snapshot = snapshotFields(world, repairRect, [...FLOAT_FIELD_KEYS, "landuse", "adminId", "municipalityId"]);
|
|
const pointPrune = prunePointLayers(sourceMap, world, rects);
|
|
const linePrune = pruneLinearLayers(sourceMap, world, rects);
|
|
resetHumanFields(world, repairRect);
|
|
assignFarmlandAndForest(world, repairRect);
|
|
const preservedInfluencePoints = applyPreservedInfluence(world, sourceMap, repairRect);
|
|
const preservedInfluencePaths = applyPreservedPathInfluence(world, sourceMap, repairRect);
|
|
|
|
const settlements = buildSettlementLayers(world, sourceMap, buildRect, seed, usedNames);
|
|
const adminCenters = assignLocalAdmin(world, sourceMap, buildRect, settlements.centers || [], seed, usedNames);
|
|
const adminRepair = reassignAdminRepair(world, sourceMap, rects, adminCenters, seed);
|
|
const borders = buildAdminBorders(world, repairRect);
|
|
if (borders.length) ensureSourceArray(sourceMap, "adminBorders").push(...borders);
|
|
applySettlementInfluence(world, settlements.localPoints || {});
|
|
const transport = buildTransportLayers(world, sourceMap, repairRect, seed, settlements.localPoints || {}, linePrune);
|
|
const development = buildDevelopmentLayers(world, sourceMap, buildRect, seed, settlements.localPoints || {}, usedNames);
|
|
const landUseCellsUpdated = reconcileTransitionFields(world, rects, snapshot);
|
|
updatePopulationSummary(sourceMap);
|
|
|
|
const result = {
|
|
ok: true,
|
|
seed,
|
|
removedPoints: pointPrune.removedPoints,
|
|
removedLines: linePrune.removedLines,
|
|
removedLocalEntities: pointPrune.removedPoints + linePrune.removedLines,
|
|
preservedExternalEntities,
|
|
roadBoundaryAnchors: linePrune.roadAnchors.length,
|
|
railwayBoundaryAnchors: linePrune.railAnchors.length,
|
|
invalidPortsRemoved: pointPrune.invalidPortsRemoved,
|
|
logisticsLabelsFixed: true,
|
|
disconnectedRoadsRailsDetected: linePrune.removedLines,
|
|
preservedInfluencePoints,
|
|
preservedInfluenceRoads: preservedInfluencePaths.roadPaths,
|
|
preservedInfluenceRails: preservedInfluencePaths.railPaths,
|
|
landUseCellsUpdated,
|
|
...adminRepair,
|
|
adminCenters: adminCenters.length,
|
|
adminBorders: borders.length,
|
|
...settlements.counts,
|
|
...transport,
|
|
...development,
|
|
buildRect: { ...buildRect },
|
|
repairRect: { ...repairRect },
|
|
userRect: { ...userRect },
|
|
};
|
|
|
|
world.lastHumanPatchResult = result;
|
|
world.humanPatchHistory = [...(world.humanPatchHistory || []), { ...result, createdAt: Date.now() }];
|
|
return result;
|
|
}
|