274 lines
10 KiB
JavaScript
274 lines
10 KiB
JavaScript
// The technology tree's geometry and pure helpers.
|
|
//
|
|
// Every technology shares one graph: a technology's column is its distance from
|
|
// a root (the longest chain of prerequisites behind it), and each column holds
|
|
// every technology of that tier.
|
|
//
|
|
// Links are routed orthogonally so that no two runs ever lie on top of each
|
|
// other: each edge leaves its parent's right edge at its own port, travels down
|
|
// a vertical lane unique to its gutter, crosses a horizontal track unique to its
|
|
// row gap, then enters the child's left edge at its own port. That costs some
|
|
// width (the gutters widen with the number of lanes) but keeps every dependency
|
|
// readable even when a node has many parents or children.
|
|
|
|
// Each research theme borrows the building-card icon of its field, so a
|
|
// technology card reads with the same visual grammar as a building. The colour
|
|
// tints the card's left edge and keys the legend above the tree.
|
|
export const TECH_THEMES = {
|
|
science: { name: "Science", color: "#5aa9ff", icon: "icon_university.svg" },
|
|
economy: { name: "Economy", color: "#e0b050", icon: "icon_bank.svg" },
|
|
society: { name: "Society", color: "#9a7bff", icon: "icon_museum.svg" },
|
|
military: { name: "Military", color: "#ff6b5e", icon: "icon_fortifications.svg" },
|
|
};
|
|
export const TECH_THEME_ORDER = ["science", "economy", "society", "military"];
|
|
|
|
export function techTheme(id) {
|
|
return TECH_THEMES[id] || TECH_THEMES.science;
|
|
}
|
|
|
|
// Repeatable technologies carry a Roman-numeral level up to X; beyond that a
|
|
// plain number is used (exponential costs make it unreachable in practice).
|
|
const ROMAN = ["", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X"];
|
|
export function romanNumeral(n) {
|
|
return ROMAN[n] || String(n);
|
|
}
|
|
|
|
export const TECH_LAYOUT = {
|
|
W: 196,
|
|
H: 76,
|
|
GX: 36,
|
|
GY: 18,
|
|
PADX: 16,
|
|
PADY: 20,
|
|
LANE: 12,
|
|
LANE_MARGIN: 12,
|
|
TRACK: 12,
|
|
TRACK_MARGIN: 9,
|
|
};
|
|
|
|
// A technology's tier: one past the deepest prerequisite.
|
|
function depthOf(index, technologies, byId, cache) {
|
|
if (cache.has(index)) return cache.get(index);
|
|
let depth = 0;
|
|
for (const id of technologies[index].prerequisites || []) {
|
|
const parent = byId.get(id);
|
|
if (parent !== undefined) depth = Math.max(depth, 1 + depthOf(parent, technologies, byId, cache));
|
|
}
|
|
cache.set(index, depth);
|
|
return depth;
|
|
}
|
|
|
|
// Positions every card and routes every prerequisite link. The returned
|
|
// `placed` map gives each technology's column and pixel box; `edges` carries the
|
|
// link's polyline points (ready for `chamferPath`) and the dot where it meets
|
|
// the child.
|
|
export function layoutTechnologies(technologies) {
|
|
const { W, H, GX, GY: BASE_GY, PADX, PADY, LANE, LANE_MARGIN, TRACK, TRACK_MARGIN } = TECH_LAYOUT;
|
|
const byId = new Map(technologies.map((t, i) => [t.id, i]));
|
|
const cache = new Map();
|
|
const byDepth = new Map();
|
|
technologies.forEach((t, index) => {
|
|
const d = depthOf(index, technologies, byId, cache);
|
|
if (!byDepth.has(d)) byDepth.set(d, []);
|
|
byDepth.get(d).push(index);
|
|
});
|
|
|
|
// Rows within a column: order children by where their parents sit, so links
|
|
// cross as little as possible, then by name for a stable layout.
|
|
const rowOf = new Map();
|
|
const colRow = new Map();
|
|
const depths = [...byDepth.keys()].sort((a, b) => a - b);
|
|
for (const d of depths) {
|
|
const group = byDepth.get(d);
|
|
group.sort((a, b) => {
|
|
const key = (index) => {
|
|
const rows = (technologies[index].prerequisites || [])
|
|
.map((id) => rowOf.get(byId.get(id)))
|
|
.filter((r) => r !== undefined);
|
|
return rows.length ? rows.reduce((x, y) => x + y, 0) / rows.length : 0;
|
|
};
|
|
return key(a) - key(b) || technologies[a].name.localeCompare(technologies[b].name);
|
|
});
|
|
group.forEach((index, row) => {
|
|
rowOf.set(index, row);
|
|
colRow.set(index, { col: d, row });
|
|
});
|
|
}
|
|
const numCols = depths.length ? depths[depths.length - 1] + 1 : 0;
|
|
let maxRow = 0;
|
|
for (const pos of colRow.values()) maxRow = Math.max(maxRow, pos.row);
|
|
|
|
// Every prerequisite link, with the columns and rows at each end.
|
|
const edges = [];
|
|
technologies.forEach((tech, to) => {
|
|
for (const id of tech.prerequisites || []) {
|
|
const from = byId.get(id);
|
|
if (from === undefined || !colRow.has(from) || !colRow.has(to)) continue;
|
|
const a = colRow.get(from);
|
|
const b = colRow.get(to);
|
|
edges.push({ from, to, fromCol: a.col, fromRow: a.row, toCol: b.col, toRow: b.row, span: b.col - a.col });
|
|
}
|
|
});
|
|
edges.sort(
|
|
(a, b) =>
|
|
a.fromCol - b.fromCol || a.fromRow - b.fromRow || a.toCol - b.toCol || a.toRow - b.toRow
|
|
);
|
|
|
|
// Vertical lanes live in the gutter between two columns: links leaving a
|
|
// column near its left side, links arriving at the next column near its right
|
|
// side. Wide enough gutters keep those lanes apart.
|
|
const numGutters = Math.max(0, numCols - 1);
|
|
const parentRuns = Array.from({ length: numGutters }, () => []);
|
|
const childRuns = Array.from({ length: numGutters }, () => []);
|
|
for (const e of edges) {
|
|
if (e.span > 1) parentRuns[e.fromCol].push(e);
|
|
if (e.toCol >= 1) childRuns[e.toCol - 1].push(e);
|
|
}
|
|
const gutterWidth = [];
|
|
for (let g = 0; g < numGutters; g++) {
|
|
parentRuns[g].sort((a, b) => a.fromRow - b.fromRow || a.toCol - b.toCol || a.toRow - b.toRow);
|
|
// Child runs are ordered so the lane closest to the child belongs to the
|
|
// link whose port sits lowest. That keeps siblings from criss-crossing as
|
|
// their stubs leave one parent.
|
|
childRuns[g].sort((a, b) => b.toRow - a.toRow || a.fromCol - b.fromCol || a.fromRow - b.fromRow);
|
|
const lanes = parentRuns[g].length + childRuns[g].length;
|
|
gutterWidth[g] = lanes ? Math.max(GX, 2 * LANE_MARGIN + lanes * LANE) : GX;
|
|
}
|
|
|
|
const colX = [];
|
|
let x = PADX;
|
|
for (let c = 0; c < numCols; c++) {
|
|
colX[c] = x;
|
|
x += W + (gutterWidth[c] || 0);
|
|
}
|
|
const width = x + PADX;
|
|
|
|
// Horizontal tracks: one per spanning link, spread across the row gap just
|
|
// below its parent. The gap grows when several links fan out from one row.
|
|
const gapTracks = new Map();
|
|
for (const e of edges) {
|
|
if (e.span <= 1) continue;
|
|
const k = e.fromRow + 1;
|
|
gapTracks.set(k, (gapTracks.get(k) || 0) + 1);
|
|
}
|
|
const maxTracks = Math.max(1, ...gapTracks.values());
|
|
const GY = Math.max(BASE_GY, (maxTracks - 1) * TRACK + 2 * TRACK_MARGIN);
|
|
const rowY = (row) => PADY + row * (H + GY);
|
|
const gapCenter = (k) => PADY + k * (H + GY) - GY / 2;
|
|
const trackSeen = new Map();
|
|
for (const e of edges) {
|
|
if (e.span <= 1) continue;
|
|
const k = e.fromRow + 1;
|
|
const n = gapTracks.get(k);
|
|
const i = trackSeen.get(k) || 0;
|
|
trackSeen.set(k, i + 1);
|
|
e.trackY = n === 1 ? gapCenter(k) : gapCenter(k) + (i - (n - 1) / 2) * TRACK;
|
|
}
|
|
|
|
// Attach a lane x to each vertical run.
|
|
for (let g = 0; g < numGutters; g++) {
|
|
const left = colX[g] + W;
|
|
const right = colX[g + 1];
|
|
parentRuns[g].forEach((e, i) => {
|
|
e.parentLaneX = left + LANE_MARGIN + (i + 0.5) * LANE;
|
|
});
|
|
childRuns[g].forEach((e, i) => {
|
|
e.childLaneX = right - LANE_MARGIN - (i + 0.5) * LANE;
|
|
});
|
|
}
|
|
|
|
// A card has a single port each way: every dependency leaves the middle of
|
|
// its right edge and every dependency arrives at the middle of its left edge,
|
|
// so a fan of links spreads from one point rather than a row of stubs.
|
|
const midY = (row) => rowY(row) + H / 2;
|
|
for (const e of edges) {
|
|
e.outY = midY(e.fromRow);
|
|
e.inY = midY(e.toRow);
|
|
}
|
|
|
|
const placed = new Map();
|
|
for (const [index, pos] of colRow) {
|
|
placed.set(index, { col: pos.col, row: pos.row, x: colX[pos.col], y: rowY(pos.row) });
|
|
}
|
|
|
|
// Out of the parent's port, along its lane, across a track (only when the
|
|
// child is more than one column away), down the child's lane, into its port.
|
|
for (const e of edges) {
|
|
const parent = placed.get(e.from);
|
|
const child = placed.get(e.to);
|
|
const start = parent.x + W;
|
|
const end = child.x;
|
|
if (e.span <= 1) {
|
|
e.points = [[start, e.outY], [e.childLaneX, e.outY], [e.childLaneX, e.inY], [end, e.inY]];
|
|
} else {
|
|
e.points = [
|
|
[start, e.outY],
|
|
[e.parentLaneX, e.outY],
|
|
[e.parentLaneX, e.trackY],
|
|
[e.childLaneX, e.trackY],
|
|
[e.childLaneX, e.inY],
|
|
[end, e.inY],
|
|
];
|
|
}
|
|
e.dot = { x: end, y: e.inY };
|
|
}
|
|
|
|
const height = PADY * 2 + (maxRow + 1) * H + maxRow * GY;
|
|
return {
|
|
nodes: technologies.map((_, index) => ({ index })),
|
|
byId,
|
|
placed,
|
|
edges,
|
|
maxRow,
|
|
width,
|
|
height,
|
|
};
|
|
}
|
|
|
|
// A path of axis-aligned runs with each 90-degree corner cut by a short
|
|
// 45-degree chamfer.
|
|
export function chamferPath(points, size = 7) {
|
|
const pts = points.filter(
|
|
(p, i, a) => i === 0 || Math.hypot(p[0] - a[i - 1][0], p[1] - a[i - 1][1]) > 0.5
|
|
);
|
|
if (pts.length < 2) return "";
|
|
let d = `M ${pts[0][0]} ${pts[0][1]}`;
|
|
for (let i = 1; i < pts.length - 1; i++) {
|
|
const p = pts[i], prev = pts[i - 1], next = pts[i + 1];
|
|
const cross = (p[0] - prev[0]) * (next[1] - p[1]) - (p[1] - prev[1]) * (next[0] - p[0]);
|
|
if (Math.abs(cross) < 0.001) { d += ` L ${p[0]} ${p[1]}`; continue; }
|
|
const l1 = Math.hypot(p[0] - prev[0], p[1] - prev[1]);
|
|
const l2 = Math.hypot(next[0] - p[0], next[1] - p[1]);
|
|
const c = Math.min(size, l1 / 2, l2 / 2);
|
|
const u1 = [(prev[0] - p[0]) / l1, (prev[1] - p[1]) / l1];
|
|
const u2 = [(next[0] - p[0]) / l2, (next[1] - p[1]) / l2];
|
|
d += ` L ${p[0] + u1[0] * c} ${p[1] + u1[1] * c} L ${p[0] + u2[0] * c} ${p[1] + u2[1] * c}`;
|
|
}
|
|
const last = pts[pts.length - 1];
|
|
return `${d} L ${last[0]} ${last[1]}`;
|
|
}
|
|
|
|
// Every prerequisite still outstanding for the technology at `index`, at any
|
|
// depth: the trace of what stands between the nation and this research. A
|
|
// prerequisite gated behind a missing building counts as outstanding too.
|
|
export function unmetPrerequisites(index, technologies, researched, hasBuilding) {
|
|
const byId = new Map(technologies.map((t, i) => [t.id, i]));
|
|
const unmet = new Set();
|
|
const walk = (at) => {
|
|
for (const id of technologies[at].prerequisites || []) {
|
|
const parent = byId.get(id);
|
|
if (parent === undefined) continue;
|
|
const proto = technologies[parent];
|
|
const buildingMissing = !!proto.requiresBuilding && !hasBuilding(proto.requiresBuilding);
|
|
if (!researched.has(parent) || buildingMissing) {
|
|
if (!unmet.has(parent)) {
|
|
unmet.add(parent);
|
|
walk(parent);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
walk(index);
|
|
return unmet;
|
|
}
|