chinko
This commit is contained in:
parent
0359bb2445
commit
b17be0e0d2
21 changed files with 8034 additions and 2737 deletions
312
mapPostAdminTransport.js
Normal file
312
mapPostAdminTransport.js
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import { MAP_W, MAP_H, SIZE, indexOf, inside } from "./mapUtils.js";
|
||||
import { pathLengthCells } from "./mapTransport.js";
|
||||
|
||||
function cellKey(x, y) { return `${Math.round(x)},${Math.round(y)}`; }
|
||||
|
||||
function pathTouchesCell(path, x, y, radius = 0.65) {
|
||||
if (!path || path.length < 1) return false;
|
||||
for (const [px, py] of path) if (Math.hypot(px - x, py - y) <= radius) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function anyPathTouches(paths, p, radius = 0.65) {
|
||||
return (paths || []).some((path) => pathTouchesCell(path, p.x, p.y, radius));
|
||||
}
|
||||
|
||||
function pathTerrainRuns(path, terrain = null) {
|
||||
const sea = terrain?.sea;
|
||||
const elevation = terrain?.elevation;
|
||||
const ridgeField = terrain?.ridgeField;
|
||||
const naturalBarrierScore = terrain?.naturalBarrierScore;
|
||||
let seaRun = 0, maxSeaRun = 0, tunnelRun = 0, maxTunnelRun = 0;
|
||||
for (const [x, y] of path || []) {
|
||||
if (!inside(x, y)) continue;
|
||||
const i = indexOf(x, y);
|
||||
const isSea = Boolean(sea?.[i]);
|
||||
const isTunnel = !isSea && (((elevation?.[i] || 0) >= 0.74 && (ridgeField?.[i] || 0) >= 0.46) || (naturalBarrierScore?.[i] || 0) >= 0.82);
|
||||
if (isSea) { seaRun++; maxSeaRun = Math.max(maxSeaRun, seaRun); } else seaRun = 0;
|
||||
if (isTunnel) { tunnelRun++; maxTunnelRun = Math.max(maxTunnelRun, tunnelRun); } else tunnelRun = 0;
|
||||
}
|
||||
return { maxSeaRun, maxTunnelRun };
|
||||
}
|
||||
|
||||
function directPath(a, b, options = {}) {
|
||||
if (!a || !b) return [];
|
||||
const steps = Math.max(1, Math.ceil(Math.hypot(a.x - b.x, a.y - b.y)));
|
||||
const out = [];
|
||||
for (let s = 0; s <= steps; s++) {
|
||||
const t = s / steps;
|
||||
const x = Math.round(a.x + (b.x - a.x) * t);
|
||||
const y = Math.round(a.y + (b.y - a.y) * t);
|
||||
if (!inside(x, y)) return [];
|
||||
if (!out.length || out[out.length - 1][0] !== x || out[out.length - 1][1] !== y) out.push([x, y]);
|
||||
}
|
||||
if (options.maxLength && pathLengthCells(out) > options.maxLength) return [];
|
||||
if (options.terrain) {
|
||||
const runs = pathTerrainRuns(out, options.terrain);
|
||||
if (Number.isFinite(options.maxSeaRun) && runs.maxSeaRun > options.maxSeaRun) return [];
|
||||
if (Number.isFinite(options.maxTunnelRun) && runs.maxTunnelRun > options.maxTunnelRun) return [];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function nearestPointOnPaths(paths, p, maxDistance = Infinity) {
|
||||
let best = null;
|
||||
for (const path of paths || []) {
|
||||
for (const [x, y] of path || []) {
|
||||
const d = Math.hypot(p.x - x, p.y - y);
|
||||
if (d <= maxDistance && (!best || d < best.d)) best = { x, y, d };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function nearestEntity(entities, p, maxDistance = Infinity) {
|
||||
let best = null;
|
||||
for (const q of entities || []) {
|
||||
if (!q || !Number.isFinite(q.x) || !Number.isFinite(q.y) || (q.x === p.x && q.y === p.y)) continue;
|
||||
const d = Math.hypot(q.x - p.x, q.y - p.y);
|
||||
if (d <= maxDistance && (!best || d < best.d)) best = { ...q, d };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function dedupePaths(paths, sampleStep = 2) {
|
||||
const seen = new Set();
|
||||
const kept = [];
|
||||
for (const path of paths || []) {
|
||||
if (!path || path.length < 2) continue;
|
||||
const cleaned = [];
|
||||
for (const pt of path) {
|
||||
const x = Math.round(pt[0]);
|
||||
const y = Math.round(pt[1]);
|
||||
if (!inside(x, y)) continue;
|
||||
if (!cleaned.length || cleaned[cleaned.length - 1][0] !== x || cleaned[cleaned.length - 1][1] !== y) cleaned.push([x, y]);
|
||||
}
|
||||
if (cleaned.length < 2) continue;
|
||||
const sigFor = (arr) => arr.map((p, i) => (i % sampleStep === 0 || i === arr.length - 1) ? `${p[0]},${p[1]}` : "").filter(Boolean).join("|");
|
||||
const f = sigFor(cleaned);
|
||||
const r = sigFor([...cleaned].reverse());
|
||||
const sig = f < r ? f : r;
|
||||
if (seen.has(sig)) continue;
|
||||
seen.add(sig);
|
||||
kept.push(cleaned);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
function addInterchange(interchanges, x, y, source = "post-admin-expressway-endpoint") {
|
||||
x = Math.round(x); y = Math.round(y);
|
||||
if (!inside(x, y)) return false;
|
||||
if ((interchanges || []).some((p) => Math.hypot(p.x - x, p.y - y) <= 2.5)) return false;
|
||||
interchanges.push({ x, y, kind: "Interchange", score: 1, source });
|
||||
return true;
|
||||
}
|
||||
|
||||
function smoothPath(path, passes = 1) {
|
||||
let cur = (path || []).map(([x, y]) => [Math.round(x), Math.round(y)]);
|
||||
for (let pass = 0; pass < passes; pass++) {
|
||||
if (cur.length < 3) break;
|
||||
const next = [cur[0]];
|
||||
for (let i = 1; i < cur.length - 1; i++) {
|
||||
const [ax, ay] = cur[i - 1];
|
||||
const [bx, by] = cur[i];
|
||||
const [cx, cy] = cur[i + 1];
|
||||
const x = Math.round((ax + bx * 2 + cx) / 4);
|
||||
const y = Math.round((ay + by * 2 + cy) / 4);
|
||||
if (!next.length || next[next.length - 1][0] !== x || next[next.length - 1][1] !== y) next.push([x, y]);
|
||||
}
|
||||
next.push(cur[cur.length - 1]);
|
||||
cur = next;
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
function rebuildInfluence(paths, radius = 5) {
|
||||
const field = new Float32Array(SIZE);
|
||||
const r = Math.ceil(radius);
|
||||
for (const path of paths || []) {
|
||||
for (const [px, py] of path || []) {
|
||||
for (let dy = -r; dy <= r; dy++) for (let dx = -r; dx <= r; dx++) {
|
||||
const x = px + dx, y = py + dy;
|
||||
if (!inside(x, y)) continue;
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d > radius) continue;
|
||||
const i = indexOf(x, y);
|
||||
field[i] = Math.max(field[i], Math.max(0, 1 - d / Math.max(0.001, radius)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return field;
|
||||
}
|
||||
|
||||
export function finalizeAdminAwareTransport({ seed, terrain, features, admin, geography = null }) {
|
||||
if (!features || !admin) return features;
|
||||
const minorRoads = features.minorRoads || [];
|
||||
const nationalRoads = features.nationalRoads || [];
|
||||
const externalRoads = features.externalRoads || [];
|
||||
const expressways = features.expressways || [];
|
||||
const externalExpressways = features.externalExpressways || [];
|
||||
const interchanges = features.interchanges || [];
|
||||
const adminCenters = admin.adminCentersRaw || features.adminCenters || [];
|
||||
const townsForNational = [
|
||||
...(features.modernCities || []).filter((p) => (p.population || 0) >= 5000),
|
||||
...(features.markets || []).filter((p) => (p.population || 0) >= 5000),
|
||||
...(features.villages || []).filter((p) => (p.population || 0) >= 5000),
|
||||
...(features.ports || []).filter((p) => (p.population || 0) >= 5000 || p.portClass === "major" || p.portClass === "regional" || p.portClass === "fishing"),
|
||||
];
|
||||
const debug = { order: "settlements -> administration -> transport", adminCentersChecked: 0, adminLocalRoadsAdded: 0, nationalTownSpursAdded: 0, nationalTownChainsAdded: 0, nationalTownChainTownsCovered: 0, expresswayEndpointInterchangesAdded: 0, expresswaysSmoothed: 0 };
|
||||
|
||||
// Local roads after admin: every municipal office cell should lie on a road.
|
||||
const settlementTargets = [...(features.modernCities || []), ...(features.markets || []), ...(features.villages || []), ...(features.ports || [])];
|
||||
for (const center of adminCenters || []) {
|
||||
if (!center || !inside(center.x, center.y)) continue;
|
||||
debug.adminCentersChecked++;
|
||||
const roadSet = [...minorRoads, ...nationalRoads, ...externalRoads];
|
||||
if (anyPathTouches(roadSet, center, 0.65)) continue;
|
||||
const nearRoad = nearestPointOnPaths(roadSet, center, 22);
|
||||
const nearSettlement = nearestEntity(settlementTargets, center, 18);
|
||||
const target = nearRoad || nearSettlement;
|
||||
let path = target ? directPath(center, target, { maxLength: 34 }) : [];
|
||||
if (!path.length) {
|
||||
const x = center.x, y = center.y;
|
||||
const a = { x: Math.max(0, x - 2), y };
|
||||
const b = { x: Math.min(MAP_W - 1, x + 2), y };
|
||||
path = directPath(a, b, { maxLength: 8 });
|
||||
}
|
||||
if (path.length >= 2 && pathTouchesCell(path, center.x, center.y, 0.65)) {
|
||||
minorRoads.push(path);
|
||||
debug.adminLocalRoadsAdded++;
|
||||
}
|
||||
}
|
||||
|
||||
// National roads after admin/settlements: try to cover red-dot towns by chain routes instead of one spur per town.
|
||||
function concatPaths(parts) {
|
||||
const out = [];
|
||||
for (const part of parts || []) {
|
||||
if (!part || part.length < 2) continue;
|
||||
for (const pt of part) {
|
||||
if (!out.length || out[out.length - 1][0] !== pt[0] || out[out.length - 1][1] !== pt[1]) out.push(pt);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function townWeight(p) {
|
||||
return (p.population || 0) + (p.isPrefecturalCapital ? 800000 : 0) + (p.isRegionalCapital ? 350000 : 0) + (p.portClass === "major" ? 220000 : p.portClass === "regional" ? 120000 : p.portClass === "fishing" ? 45000 : 0);
|
||||
}
|
||||
function nearestTrunkOrHub(p, maxDistance = 85) {
|
||||
const trunk = nearestPointOnPaths([...nationalRoads, ...externalRoads], p, maxDistance);
|
||||
if (trunk) return trunk;
|
||||
return nearestEntity([...(features.modernCities || []), ...(features.ports || []), ...(features.markets || []), ...(features.externalGateways || [])], p, maxDistance);
|
||||
}
|
||||
function buildTownChain(start, pool, maxHops = 7) {
|
||||
const chain = [start];
|
||||
let cur = start;
|
||||
for (let hop = 1; hop < maxHops; hop++) {
|
||||
let best = null;
|
||||
for (const town of pool) {
|
||||
if (chain.includes(town)) continue;
|
||||
const d = Math.hypot(cur.x - town.x, cur.y - town.y);
|
||||
if (d > 42) continue;
|
||||
const score = d - Math.min(18, Math.sqrt(Math.max(0, townWeight(town))) / 70);
|
||||
if (!best || score < best.score) best = { town, d, score };
|
||||
}
|
||||
if (!best) break;
|
||||
chain.push(best.town);
|
||||
cur = best.town;
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
function addNationalTownChains() {
|
||||
let uncovered = townsForNational
|
||||
.filter((town) => town && inside(town.x, town.y) && !anyPathTouches([...nationalRoads, ...externalRoads], town, 0.65))
|
||||
.sort((a, b) => townWeight(b) - townWeight(a));
|
||||
let chainsAdded = 0;
|
||||
let townsCovered = 0;
|
||||
while (uncovered.length) {
|
||||
const start = uncovered.shift();
|
||||
const chain = buildTownChain(start, uncovered, 7);
|
||||
uncovered = uncovered.filter((town) => !chain.includes(town));
|
||||
const parts = [];
|
||||
const before = nearestTrunkOrHub(chain[0], 80);
|
||||
if (before) {
|
||||
const p = directPath(before, chain[0], { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 0 });
|
||||
if (p.length) parts.push(p);
|
||||
}
|
||||
for (let i = 1; i < chain.length; i++) {
|
||||
const d = Math.hypot(chain[i - 1].x - chain[i].x, chain[i - 1].y - chain[i].y);
|
||||
const p = directPath(chain[i - 1], chain[i], { maxLength: Math.max(28, d * 1.35 + 8), terrain, maxSeaRun: 10, maxTunnelRun: 0 });
|
||||
if (p.length) parts.push(p);
|
||||
}
|
||||
const after = chain.length >= 2 ? nearestTrunkOrHub(chain[chain.length - 1], 80) : null;
|
||||
if (after) {
|
||||
const p = directPath(chain[chain.length - 1], after, { maxLength: 90, terrain, maxSeaRun: 10, maxTunnelRun: 0 });
|
||||
if (p.length) parts.push(p);
|
||||
}
|
||||
let path = concatPaths(parts);
|
||||
if (path.length < 2) {
|
||||
const target = nearestTrunkOrHub(chain[0], 90);
|
||||
path = target ? directPath(chain[0], target, { maxLength: 100, terrain, maxSeaRun: 10, maxTunnelRun: 0 }) : [];
|
||||
}
|
||||
if (path.length >= 2 && chain.some((town) => pathTouchesCell(path, town.x, town.y, 0.65))) {
|
||||
nationalRoads.push(path);
|
||||
chainsAdded++;
|
||||
townsCovered += chain.filter((town) => pathTouchesCell(path, town.x, town.y, 0.65)).length;
|
||||
}
|
||||
}
|
||||
return { chainsAdded, townsCovered };
|
||||
}
|
||||
const chainDebug = addNationalTownChains();
|
||||
debug.nationalTownChainsAdded = chainDebug.chainsAdded;
|
||||
debug.nationalTownChainTownsCovered = chainDebug.townsCovered;
|
||||
debug.nationalTownSpursAdded = chainDebug.chainsAdded;
|
||||
|
||||
// Expressway finalization after administration: smooth and ensure both endpoints are ICs.
|
||||
for (let i = 0; i < expressways.length; i++) {
|
||||
const smoothed = smoothPath(expressways[i], 2);
|
||||
if (smoothed.length >= 2) {
|
||||
expressways[i] = smoothed;
|
||||
debug.expresswaysSmoothed++;
|
||||
}
|
||||
}
|
||||
for (const path of [...expressways, ...externalExpressways]) {
|
||||
if (!path || path.length < 2) continue;
|
||||
const a = path[0];
|
||||
const b = path[path.length - 1];
|
||||
if (addInterchange(interchanges, a[0], a[1])) debug.expresswayEndpointInterchangesAdded++;
|
||||
if (addInterchange(interchanges, b[0], b[1])) debug.expresswayEndpointInterchangesAdded++;
|
||||
}
|
||||
|
||||
features.minorRoads = dedupePaths(minorRoads, 2);
|
||||
features.nationalRoads = dedupePaths(nationalRoads, 1);
|
||||
features.externalRoads = dedupePaths(externalRoads, 1);
|
||||
features.expressways = dedupePaths(expressways, 2);
|
||||
features.externalExpressways = dedupePaths(externalExpressways, 2);
|
||||
features.interchanges = interchanges;
|
||||
|
||||
// Final invariant: after all dedupe passes, every municipal office cell has at least a local road cell on it.
|
||||
let finalAdminStubsAdded = 0;
|
||||
for (const center of adminCenters || []) {
|
||||
if (!center || !inside(center.x, center.y)) continue;
|
||||
if (anyPathTouches([...features.minorRoads, ...features.nationalRoads, ...features.externalRoads], center, 0.65)) continue;
|
||||
const x = Math.round(center.x), y = Math.round(center.y);
|
||||
const candidates = [
|
||||
[{ x: Math.max(0, x - 1), y }, { x, y }, { x: Math.min(MAP_W - 1, x + 1), y }],
|
||||
[{ x, y: Math.max(0, y - 1) }, { x, y }, { x, y: Math.min(MAP_H - 1, y + 1) }],
|
||||
];
|
||||
const stub = candidates
|
||||
.map((cand) => cand.map((p) => [p.x, p.y]).filter(([px, py], idx, arr) => idx === 0 || px !== arr[idx - 1][0] || py !== arr[idx - 1][1]))
|
||||
.find((p) => p.length >= 2) || [[x, y], [Math.min(MAP_W - 1, x + 1), y]];
|
||||
features.minorRoads.push(stub);
|
||||
finalAdminStubsAdded++;
|
||||
}
|
||||
debug.finalAdminStubsAdded = finalAdminStubsAdded;
|
||||
features.roadInfluence = rebuildInfluence([...features.nationalRoads, ...(features.ringRoads || []), ...features.externalRoads, ...features.minorRoads], 5.0);
|
||||
features.roadDensityInfluence = rebuildInfluence([...features.nationalRoads, ...(features.ringRoads || []), ...features.externalRoads, ...features.minorRoads], 9.0);
|
||||
features.transportDebug = {
|
||||
...(features.transportDebug || {}),
|
||||
generationOrder: debug.order,
|
||||
postAdminTransportFinalization: debug,
|
||||
};
|
||||
return features;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue