Files
Battle-for-Tismo/client/js/ui/card.js
T
adrien 80429918c6 Made producers pay their own way and settled the world to steady prices
- The monthly budget breaks each expense into the resource bought, its quantity and the average price paid: tile improvements already did, and now every upkeep row (buildings, units, works, transport) does too.
- Private producers settle their daily wear from their own cash and borrow the shortfall from the central bank, so their cash never goes negative.
- Producers fund their own construction and upgrades on the same credit; the treasury pays only for public works, roads and railways, and no private purchase is filed as a public tile-improvement expense.
- The server settles the world until every commodity holds within 10% over a trailing seven days instead of a fixed 150, capped by --warmup (0 skips it).
- The shipped month report carries its label again, so the budget header and pager name the month.
2026-09-23 21:45:28 +02:00

218 lines
8.4 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";
import { formatMoney } from "../currency.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.
export function tooltipSections({
produce = [],
produceSuffix = "",
consume = [],
consumeSuffix = "",
cost = [],
} = {}) {
return [
{ 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 },
];
}
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;
$tooltip = $("<div class='card-tooltip'></div>");
for (const section of sections) {
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 price and time in its meta line. The money is an
// estimate, so it is italic and prefixed with a tilde; nothing is shown when
// the work has no price (locked or already standing). Callers whose cost is not
// money pass free `text` instead, shown verbatim.
export function setCardMeta($meta, { money = null, hours = "", text = "" } = {}) {
$meta.empty();
if (money != null) {
$meta.append($("<span class='bt-price'></span>").text(`~${formatMoney(money)}`));
if (hours) $meta.append(document.createTextNode(` · ${hours}`));
} else if (text) {
$meta.text(text);
}
}
// 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,
};
}