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

460 lines
18 KiB
JavaScript

// The technology panel's tree: a draggable graph of cards and routed
// prerequisite links, plus the focus/progress strip above it.
//
// It is deliberately independent of the nation modal so the same view backs
// both the game and the stand-alone development page used when the technology
// catalogue is edited. The host supplies the data and the copy (a tooltip
// builder and the two order callbacks); everything else lives here.
import { technologyCost } from "../../../shared/data.js";
import { prerequisitesMet } from "../../../shared/rules.js";
import { groupDigits } from "../../../shared/text_format.js";
import { researchNode, showCardTooltip, hideCardTooltip } from "./card.js";
import {
TECH_THEMES,
TECH_THEME_ORDER,
techTheme,
TECH_LAYOUT,
romanNumeral,
layoutTechnologies,
chamferPath,
unmetPrerequisites,
} from "./tech_tree.js";
const EMPTY_TECH_INFO = {
focus: -1,
progress: 0,
progressByTech: {},
repeatCounts: {},
buildings: new Set(),
testing: false,
};
let nextInstance = 0;
export class TechnologyTreeView {
constructor({ graph, strip, tipFor, onFocusTechnology, onUnlockTechnology, assetBase }) {
// A namespace unique to this instance so two trees on one page (the game
// and the development preview do not share a page, but be safe) do not
// unbind each other's drag listeners.
this._ns = `.techpan${nextInstance++}`;
this.$graph = graph;
this.$strip = strip;
this.tipFor = tipFor || (() => []);
this.onFocusTechnology = onFocusTechnology || (() => {});
this.onUnlockTechnology = onUnlockTechnology || (() => {});
// Where the theme icons live, relative to the hosting page.
this.assetBase = assetBase || "assets/";
this.technologies = [];
this.researched = new Set();
this.techInfo = EMPTY_TECH_INFO;
this.cards = new Map();
this.edges = [];
this.layout = null;
this.selected = null;
this.hover = null;
this._panMoved = false;
}
// Rebuild the whole tree from the catalogue. The layout only depends on the
// catalogue, so this runs when the panel opens; `refresh` repaints states in
// place afterwards.
render({ technologies, researched, techInfo }) {
if (technologies) this.technologies = technologies;
if (researched) this.researched = researched;
if (techInfo) this.techInfo = techInfo;
const $graph = this.$graph;
if (!$graph || !$graph.length) return;
hideCardTooltip();
$(document).off(this._ns);
$(window).off(this._ns);
$graph.empty();
this.cards = new Map();
this.edges = [];
this.selected = null;
this.hover = null;
this._panMoved = false;
this.$pan = null;
this.$hints = null;
this._panState = null;
if (!this.technologies.length) {
this.layout = null;
this._renderLegend();
return;
}
const layout = layoutTechnologies(this.technologies);
this.layout = layout;
this._renderLegend();
const { W, H } = TECH_LAYOUT;
const $pan = $("<div class='tech-pan'></div>");
const $canvas = $("<div class='tech-canvas'></div>")
.css({ width: `${layout.width}px`, height: `${layout.height}px` });
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("class", "tech-links");
svg.setAttribute("width", layout.width);
svg.setAttribute("height", layout.height);
$canvas.append(svg);
// Links first, so a card always paints over the line that reaches it.
for (const edge of layout.edges) {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", chamferPath(edge.points));
path.dataset.from = edge.from;
path.dataset.to = edge.to;
svg.appendChild(path);
const dot = document.createElementNS("http://www.w3.org/2000/svg", "circle");
dot.setAttribute("cx", edge.dot.x);
dot.setAttribute("cy", edge.dot.y);
dot.setAttribute("r", 3);
dot.dataset.from = edge.from;
dot.dataset.to = edge.to;
svg.appendChild(dot);
this.edges.push({ from: edge.from, to: edge.to, $path: $(path), $dot: $(dot) });
}
for (const node of layout.nodes) {
const proto = this.technologies[node.index];
const pos = layout.placed.get(node.index);
const $card = $("<div class='building-tile tech-card'></div>")
.attr("data-index", node.index)
.addClass(`theme-${proto.theme}`)
.css({ left: `${pos.x}px`, top: `${pos.y}px`, width: `${W}px`, height: `${H}px` });
const $fill = $("<div class='card-fill'></div>");
const $status = $("<div class='bt-level'></div>");
const $foot = $("<div class='bt-foot'></div>");
const $body = $("<div class='card-body'></div>").append(
$("<div class='bt-top'></div>").append(
$("<img class='building-icon'>").attr("src", `${this.assetBase}${techTheme(proto.theme).icon}`).attr("alt", proto.name),
$("<div class='bt-id'></div>").append(
$("<div class='bt-name'></div>").text(proto.name),
$status
)
),
$foot
);
$card.append($fill, $body);
const ref = { index: node.index, proto, $card, $status, $fill, $foot, $free: null };
if (this.techInfo && this.techInfo.testing) {
ref.$free = $("<button class='aero free-button'></button>").text("Unlock now")
.on("click", (event) => { event.stopPropagation(); this.onUnlockTechnology(node.index); });
$card.append(ref.$free);
}
$card.on("mouseenter", () => {
showCardTooltip($card, this.tipFor(node.index));
if (!this._canFocus(node.index) && !this.researched.has(node.index)) {
this.hover = node.index;
this._applyHighlight(node.index);
}
});
$card.on("mouseleave", () => {
hideCardTooltip();
if (this.hover === node.index) this.hover = null;
if (this.selected !== null) this._applyHighlight(this.selected);
else this._clearHighlight();
});
$card.on("click", () => {
// A drag that happened to start on a card must not also select it.
if (this._panMoved) { this._panMoved = false; return; }
this.selected = this.selected === node.index ? null : node.index;
this.hover = null;
this.refresh();
});
this.cards.set(node.index, ref);
$canvas.append($card);
}
$pan.append($canvas);
// Faint fades and chevrons on whichever edges still hide part of the tree,
// so the panel reads as pannable without a scrollbar.
this.$pan = $pan;
this.$hints = {};
for (const side of ["top", "bottom", "left", "right"]) {
const $hint = $(`<div class='tech-hint tech-hint-${side}'></div>`)
.append("<span class='tech-hint-arrow'></span>");
this.$hints[side] = $hint;
$pan.append($hint);
}
$graph.append($pan);
this._bindPan($pan, $canvas, layout.width, layout.height);
this.refresh();
}
// The category key that sits between the focus strip and the tree. It is a
// sibling of the pannable area, so it stays put while the tree is dragged.
_renderLegend() {
if (this.$legend) this.$legend.remove();
this.$legend = null;
if (!this.$graph || !this.$graph.length || !this.technologies.length) return;
const $legend = $("<div class='tech-legend'></div>");
for (const id of TECH_THEME_ORDER) {
const theme = TECH_THEMES[id];
if (!theme) continue;
$legend.append(
$("<span class='tech-legend-item'></span>")
.attr("data-theme", id)
.append(
$("<span class='tech-legend-swatch'></span>").css("background", theme.color),
document.createTextNode(theme.name)
)
);
}
this.$legend = $legend;
this.$graph.before($legend);
}
// Drag anywhere on the tree to pan it, rather than a scrollbar.
_bindPan($pan, $canvas, width, height) {
let x = 0, y = 0, moved = false;
let dragging = false, startX = 0, startY = 0, startL = 0, startT = 0;
this._panState = { x: 0, y: 0, width, height };
const apply = () => {
$canvas.css("transform", `translate(${x}px, ${y}px)`);
this._panState.x = x;
this._panState.y = y;
this._updateScrollHints();
};
const clamp = () => {
const vw = $pan.width(), vh = $pan.height();
x = Math.max(Math.min(0, vw - width), Math.min(0, x));
y = Math.max(Math.min(0, vh - height), Math.min(0, y));
};
$pan.on("mousedown", (event) => {
if (event.button !== 0) return;
dragging = true;
moved = false;
startX = event.pageX;
startY = event.pageY;
startL = x;
startT = y;
$pan.addClass("dragging");
event.preventDefault();
});
$(document).on(`mousemove${this._ns}`, (event) => {
if (!dragging) return;
x = startL + (event.pageX - startX);
y = startT + (event.pageY - startY);
if (Math.abs(event.pageX - startX) > 4 || Math.abs(event.pageY - startY) > 4) moved = true;
clamp();
apply();
});
$(document).on(`mouseup${this._ns}`, () => {
if (!dragging) return;
dragging = false;
// Leave the flag for the click that follows, so a drag is not a selection.
this._panMoved = moved;
$pan.removeClass("dragging");
setTimeout(() => { this._panMoved = false; }, 0);
});
$(window).on(`resize${this._ns}`, () => { clamp(); apply(); });
$pan.on("click", (event) => {
if (moved) return;
if ($(event.target).closest(".tech-card").length) return;
this.selected = null;
this.refresh();
});
apply();
}
// Show a fade + chevron on every edge where the tree extends past the view.
_updateScrollHints() {
const hints = this.$hints;
const $pan = this.$pan;
const state = this._panState;
if (!hints || !$pan || !state || !$pan.length) return;
const vw = $pan.width(), vh = $pan.height();
const { x, y, width, height } = state;
const wide = width - vw > 1;
const tall = height - vh > 1;
hints.left.toggleClass("show", wide && x < -1);
hints.right.toggleClass("show", wide && x > vw - width + 1);
hints.top.toggleClass("show", tall && y < -1);
hints.bottom.toggleClass("show", tall && y > vh - height + 1);
}
_hasBuilding(id) {
return !!(this.techInfo && this.techInfo.buildings && this.techInfo.buildings.has(id));
}
// Whether a technology can be focused now: not already held (unless it is
// repeatable) and with every prerequisite met.
_canFocus(index) {
const proto = this.technologies[index];
if (!proto) return false;
if (this.researched.has(index) && !proto.repeatable) return false;
return prerequisitesMet(proto, this.researched, this.technologies, (id) => this._hasBuilding(id));
}
// Repaint every card and the focus strip from the current snapshot.
refresh({ researched, techInfo } = {}) {
if (researched) this.researched = researched;
if (techInfo) this.techInfo = techInfo;
if (!this.layout) {
this._renderStrip();
return;
}
const repeatCounts = this.techInfo.repeatCounts || {};
for (const ref of this.cards.values()) {
const proto = ref.proto;
const repeat = repeatCounts[proto.id] || 0;
const focusable = this._canFocus(ref.index);
const done = this.researched.has(ref.index) && !proto.repeatable;
const focused = this.techInfo.focus === ref.index;
const points = (this.techInfo.progressByTech || {})[ref.index] || 0;
const cost = technologyCost(proto, repeat);
const pct = cost > 0 ? Math.min(1, points / cost) : 0;
const fill = proto.repeatable ? Math.min(1, (repeat + pct) / 10) * 100 : pct * 100;
ref.$card.removeClass("built locked researching clickable");
ref.$card.toggleClass("clickable", focusable);
ref.$fill.css("width", `${fill}%`);
ref.$foot.empty();
if (done) {
ref.$card.addClass("built");
ref.$status.text("Researched").addClass("good");
ref.$foot.text("Complete");
} else if (!focusable) {
ref.$card.addClass("locked");
ref.$status.text("Locked").removeClass("good");
ref.$foot.append(researchNode(cost));
} else if (focused) {
ref.$card.addClass("researching");
ref.$status
.text(repeat > 0 ? `Level ${romanNumeral(repeat)} · Researching` : "Researching")
.addClass("good");
ref.$foot
.append(researchNode(points))
.append(document.createTextNode(` / ${groupDigits(Math.round(cost))}`));
} else {
ref.$status
.text(repeat > 0 ? `Level ${romanNumeral(repeat)}` : "Not researched")
.removeClass("good");
ref.$foot.append(researchNode(cost));
}
if (ref.$free) ref.$free.toggleClass("hidden", done);
}
this._renderStrip();
const traced = this.selected !== null ? this.selected : this.hover;
if (traced !== null && traced !== undefined) this._applyHighlight(traced);
else this._clearHighlight();
}
// The focus + progress strip above the tree. It follows the selected card
// (offering a Focus button for a technology that can be researched) and falls
// back to the technology already being researched.
_renderStrip() {
const $strip = this.$strip;
if (!$strip || !$strip.length) return;
const selected = this.selected;
if (selected !== null && selected !== undefined && this.technologies[selected]) {
this._renderSelectedStrip($strip, selected);
return;
}
const focus = this.techInfo.focus;
const proto = focus >= 0 ? this.technologies[focus] : null;
if (!proto) {
$strip.addClass("idle").empty()
.append($("<span class='tech-research-idle'></span>").text("No research focus — select a technology."));
return;
}
$strip.removeClass("idle").empty();
const repeat = (this.techInfo.repeatCounts || {})[proto.id] || 0;
const points = this.techInfo.progress || 0;
const cost = technologyCost(proto, repeat);
const pct = cost > 0 ? Math.max(0, Math.min(100, (points / cost) * 100)) : 0;
$strip
.append(this._stripHead(proto, proto.repeatable ? `Researching toward Level ${romanNumeral(repeat + 1)}` : "Researching"))
.append(
$("<div class='tech-research-bar'></div>").append(
$("<div class='tech-research-fill'></div>").css("width", `${pct}%`),
$("<span class='tech-research-count'></span>")
.append(researchNode(points))
.append(document.createTextNode(` / ${groupDigits(Math.round(cost))}`))
)
);
}
_renderSelectedStrip($strip, index) {
const proto = this.technologies[index];
const focusable = this._canFocus(index);
const focused = this.techInfo.focus === index;
const held = this.researched.has(index) && !proto.repeatable;
$strip.removeClass("idle").empty();
const sub = held ? "Researched" : focused ? "Researching" : focusable ? "Ready to research" : "Locked";
$strip.append(this._stripHead(proto, sub));
if (focused) {
const repeat = (this.techInfo.repeatCounts || {})[proto.id] || 0;
const points = this.techInfo.progress || 0;
const cost = technologyCost(proto, repeat);
const pct = cost > 0 ? Math.max(0, Math.min(100, (points / cost) * 100)) : 0;
$strip.append(
$("<div class='tech-research-bar'></div>").append(
$("<div class='tech-research-fill'></div>").css("width", `${pct}%`),
$("<span class='tech-research-count'></span>")
.append(researchNode(points))
.append(document.createTextNode(` / ${groupDigits(Math.round(cost))}`))
)
);
} else {
const repeat = (this.techInfo.repeatCounts || {})[proto.id] || 0;
$strip.append(
$("<div class='tech-research-cost'></div>").append(
researchNode(technologyCost(proto, repeat)),
document.createTextNode(held ? " complete" : focusable ? " to research" : " — prerequisites missing")
)
);
}
if (focusable && !focused) {
$strip.append(
$("<button type='button' class='aero tech-research-focus'></button>").text("Focus")
.on("click", () => {
// Show the commitment at once; a snapshot confirms it a moment later.
this.techInfo = {
...this.techInfo,
focus: index,
progress: (this.techInfo.progressByTech || {})[index] || 0,
};
this.selected = null;
this.onFocusTechnology(index);
this.refresh();
})
);
}
}
_stripHead(proto, sub) {
return $("<span class='tech-research-head'></span>").append(
$("<img class='tech-research-icon'>").attr("src", `${this.assetBase}${techTheme(proto.theme).icon}`).attr("alt", proto.name),
$("<div class='tech-research-text'></div>").append(
$("<div class='tech-research-name'></div>").text(proto.name),
$("<div class='tech-research-sub'></div>").text(sub)
)
);
}
// Trace every outstanding requirement of a locked card, and the links
// between them, so a chain of missing research reads at a glance.
_applyHighlight(index) {
this._clearHighlight();
const ref = this.cards.get(index);
if (ref) ref.$card.addClass("warn-self");
const unmet = unmetPrerequisites(index, this.technologies, this.researched, (id) => this._hasBuilding(id));
for (const i of unmet) {
const card = this.cards.get(i);
if (card) card.$card.addClass("warn");
}
for (const edge of this.edges) {
if (unmet.has(edge.from) && (edge.to === index || unmet.has(edge.to))) {
edge.$path.addClass("warn");
edge.$dot.addClass("warn");
}
}
}
_clearHighlight() {
for (const ref of this.cards.values()) ref.$card.removeClass("warn warn-self");
for (const edge of this.edges || []) {
edge.$path.removeClass("warn");
edge.$dot.removeClass("warn");
}
}
}