231 lines
8.9 KiB
JavaScript
231 lines
8.9 KiB
JavaScript
// The building / improvement card. The city buildings tab and the HUD's tile
|
||
// improvement panel draw one card per type: an icon, a name and a status up
|
||
// top, then an action button carrying only the estimated price and the build
|
||
// time. What the work produces, consumes and costs lives in a hover tooltip, so
|
||
// the card itself stays bare. Shared by both panels so they read the same.
|
||
|
||
import { resourceById } from "../../../shared/data.js";
|
||
import { formatResourceAmount } from "../../../shared/resources.js";
|
||
import { groupDigits } from "../../../shared/text_format.js";
|
||
|
||
// The materials a construction bills in, in reading order.
|
||
export const CARD_RESOURCES = ["steel", "energy", "hightech"];
|
||
|
||
// A commodity's icon as an image node, for a row that names a resource with a
|
||
// picture rather than the word.
|
||
export function resourceIcon(id) {
|
||
const proto = resourceById(id);
|
||
const $img = $("<img>");
|
||
if (proto) $img.attr("src", `assets/${proto.icon}`).attr("alt", proto.name);
|
||
return $img;
|
||
}
|
||
|
||
// A resource amount with its commodity icon. `sign` marks the direction of the
|
||
// flow: "+" for output, "−" for a cost.
|
||
function resourceLine(id, amount, sign) {
|
||
const proto = resourceById(id);
|
||
const $span = $("<span class='res'></span>");
|
||
if (proto) {
|
||
$span.append($("<img>").attr("src", `assets/${proto.icon}`).attr("alt", proto.name));
|
||
}
|
||
$span.append(document.createTextNode(`${sign}${formatResourceAmount(id, amount)}`));
|
||
return $span;
|
||
}
|
||
|
||
// A research-point figure with its erlenmeyer icon. The icon stands in for the
|
||
// word "research", so no unit text follows: the science buildings, the
|
||
// technology cards and the research rate all read the same way.
|
||
export function researchNode(amount, { sign = "", decimals = 0 } = {}) {
|
||
const proto = resourceById("research");
|
||
const $span = $("<span class='res research-points'></span>");
|
||
if (proto) {
|
||
$span.append($("<img>").attr("src", `assets/${proto.icon}`).attr("alt", proto.name));
|
||
}
|
||
const value = Number(amount) || 0;
|
||
const text = decimals > 0 ? value.toFixed(decimals) : groupDigits(Math.round(value));
|
||
$span.append(document.createTextNode(`${sign}${text}`));
|
||
return $span;
|
||
}
|
||
|
||
// An inline resource bill for a card: a muted label, then one icon-and-amount
|
||
// per material, then an optional suffix. Used for a unit's daily upkeep, which
|
||
// is paid in materials rather than money.
|
||
export function resourceBill(items, { label = "", sign = "−", suffix = "" } = {}) {
|
||
const $bill = $("<span class='bt-bill'></span>");
|
||
if (label) $bill.append($("<span class='bt-bill-label'></span>").text(label));
|
||
for (const item of items) $bill.append(resourceLine(item.id, item.amount, sign));
|
||
if (suffix) $bill.append($("<span class='bt-bill-suffix'></span>").text(suffix));
|
||
return $bill;
|
||
}
|
||
|
||
// The three tooltip sections from a work's flows. Each entry is a resource
|
||
// { id, amount } or free text { text }; free text is shown exactly as written.
|
||
// `desc` is an optional prose line shown first, with no section heading.
|
||
export function tooltipSections({
|
||
desc = "",
|
||
produce = [],
|
||
produceSuffix = "",
|
||
consume = [],
|
||
consumeSuffix = "",
|
||
cost = [],
|
||
} = {}) {
|
||
const sections = [
|
||
{ label: "Produces", cls: "tip-produce", sign: "+", suffix: produceSuffix, items: produce },
|
||
{ label: "Consumes", cls: "tip-consume", sign: "−", suffix: consumeSuffix, items: consume },
|
||
{ label: "Costs", cls: "tip-cost", sign: "−", suffix: "", items: cost },
|
||
];
|
||
if (desc) sections.unshift({ desc });
|
||
return sections;
|
||
}
|
||
|
||
let $tooltip = null;
|
||
|
||
// Removes any open card tooltip. Called on mouseleave and whenever the panel
|
||
// rebuilds, so a stale tooltip never lingers over fresh content.
|
||
export function hideCardTooltip() {
|
||
if ($tooltip) {
|
||
$tooltip.remove();
|
||
$tooltip = null;
|
||
}
|
||
}
|
||
|
||
// Opens the tooltip beside `$anchor`, flipping left or up when it would run off
|
||
// the viewport. It is fixed to the body, so a scrolling card list never clips
|
||
// it.
|
||
export function showCardTooltip($anchor, sections) {
|
||
hideCardTooltip();
|
||
if (!$anchor || !$anchor.length) return;
|
||
if (!sections || !sections.length) return;
|
||
$tooltip = $("<div class='card-tooltip'></div>");
|
||
for (const section of sections) {
|
||
if (section.desc) {
|
||
$tooltip.append($("<div class='tip-section tip-desc'></div>").text(section.desc));
|
||
continue;
|
||
}
|
||
const $section = $("<div class='tip-section'></div>");
|
||
$section.append($("<div class='tip-head'></div>").text(section.label));
|
||
if (!section.items || section.items.length === 0) {
|
||
$section.append($("<div class='tip-line tip-none'></div>").text("nothing"));
|
||
} else {
|
||
for (const item of section.items) {
|
||
const $line = $("<div></div>").addClass(`tip-line ${section.cls}`);
|
||
if (item.node) $line.append(item.node);
|
||
else if (item.id) $line.append(resourceLine(item.id, item.amount, section.sign));
|
||
else $line.text(item.text);
|
||
if (section.suffix) $line.append(document.createTextNode(section.suffix));
|
||
$section.append($line);
|
||
}
|
||
}
|
||
$tooltip.append($section);
|
||
}
|
||
$("body").append($tooltip);
|
||
const rect = $anchor[0].getBoundingClientRect();
|
||
$tooltip.css({ left: rect.right + 10, top: rect.top });
|
||
const tip = $tooltip[0].getBoundingClientRect();
|
||
if (tip.right > window.innerWidth - 8 && tip.width) {
|
||
$tooltip.css("left", rect.left - tip.width - 10);
|
||
}
|
||
if (tip.bottom > window.innerHeight - 8) {
|
||
$tooltip.css("top", Math.max(8, window.innerHeight - tip.height - 8));
|
||
}
|
||
}
|
||
|
||
// Puts the action button's material cost and build time in its meta line.
|
||
// `resources` is the work's material bill (an array of { id, amount }), drawn
|
||
// with the commodity icons and a minus sign; the hours follow it. A work with
|
||
// nothing to spend (locked, or already standing) shows nothing. Callers whose
|
||
// cost is not a resource bill pass free `text` instead, shown verbatim. The
|
||
// estimated euro price lives in the hover tooltip, never here, so a long bill
|
||
// cannot overflow the button.
|
||
export function setCardMeta($meta, { resources = null, hours = "", text = "" } = {}) {
|
||
$meta.empty();
|
||
if (resources && resources.length) {
|
||
$meta.append(resourceBill(resources, { sign: "−" }));
|
||
if (hours) $meta.append(document.createTextNode(` · ${hours}`));
|
||
} else if (text) {
|
||
$meta.text(text);
|
||
} else if (hours) {
|
||
$meta.text(hours);
|
||
}
|
||
}
|
||
|
||
// Builds a card. `spec` carries:
|
||
// icon, name, status, statusBuilt, built, locked
|
||
// note (a muted line under the header, e.g. a unit's upkeep)
|
||
// action { label, money, hours, text, disabled }
|
||
// progress (the action button carries a fill the caller sets,
|
||
// e.g. focused research; label and meta sit above it)
|
||
// remove { label, title, enabled } (omit for no remove control)
|
||
// tip () => tooltip sections
|
||
// onAction, onRemove
|
||
// It returns the pieces the caller keeps to refresh the card in place.
|
||
export function buildCard(spec) {
|
||
const $card = $("<div class='building-tile'></div>");
|
||
if (spec.built) $card.addClass("built");
|
||
if (spec.locked) $card.addClass("locked");
|
||
|
||
const $top = $("<div class='bt-top'></div>");
|
||
if (spec.icon) {
|
||
$top.append(
|
||
$("<img class='building-icon'>").attr("src", `assets/${spec.icon}`).attr("alt", spec.name)
|
||
);
|
||
}
|
||
const $id = $("<div class='bt-id'></div>");
|
||
$id.append($("<div class='bt-name'></div>").text(spec.name));
|
||
const $status = $("<div class='bt-level'></div>")
|
||
.text(spec.status || "")
|
||
.toggleClass("built", !!spec.statusBuilt);
|
||
$id.append($status);
|
||
$top.append($id);
|
||
$card.append($top);
|
||
|
||
let $note = null;
|
||
if (spec.note) {
|
||
$note = $("<div class='bt-note'></div>");
|
||
if (typeof spec.note === "string") $note.text(spec.note);
|
||
else $note.append(spec.note);
|
||
$card.append($note);
|
||
}
|
||
|
||
const action = spec.action || {};
|
||
const $action = $("<button class='aero bt-upgrade'></button>").prop("disabled", !!action.disabled);
|
||
// A progress fill lives behind the button's own label and meta, so the
|
||
// button itself reads as the bar.
|
||
let $progressFill = null;
|
||
if (spec.progress) {
|
||
$progressFill = $("<span class='bt-progress-fill'></span>");
|
||
$action.append($progressFill);
|
||
}
|
||
$action.append($("<span class='bt-up-label'></span>").text(action.label || ""));
|
||
const $meta = $("<span class='bt-meta'></span>");
|
||
setCardMeta($meta, action);
|
||
$action.append($meta);
|
||
$card.append($action);
|
||
if (spec.onAction) $action.on("click", spec.onAction);
|
||
if (spec.tip) {
|
||
$action.on("mouseenter", () => showCardTooltip($action, spec.tip()));
|
||
$action.on("mouseleave", hideCardTooltip);
|
||
}
|
||
|
||
let $remove = null;
|
||
if (spec.remove) {
|
||
$remove = $("<button class='aero bt-downgrade'></button>")
|
||
.text(spec.remove.label || "✕")
|
||
.attr("title", spec.remove.title || "")
|
||
.prop("disabled", !spec.remove.enabled);
|
||
if (spec.onRemove) $remove.on("click", spec.onRemove);
|
||
$card.append($remove);
|
||
}
|
||
|
||
return {
|
||
$card,
|
||
$status,
|
||
$action,
|
||
$label: $action.find(".bt-up-label"),
|
||
$meta,
|
||
$remove,
|
||
$note,
|
||
$progressFill,
|
||
};
|
||
}
|