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

231 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
};
}