Files
Battle-for-Tismo/client/js/ui/tech_tree.js
T

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