fertig-classic-games/src/games/civilization/CivilizationTooltips.js

279 lines
14 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.

import { COLORS } from '../../config.js';
import { cityById, cityAt, tileIndex, defenderStrength, unitsOnBoat, IMP } from './CivilizationLogic.js';
// Flavor/ability text for unit flags — data/civilization-rules.json has no
// description field on units, so this is authored here. A few flags
// (aegis, paradrop, amphibious, fighter) aren't mechanically wired in
// CivilizationLogic.js/CivilizationAI.js yet; their text is standard
// Civilization-series flavor phrasing, harmless either way.
const FLAG_TEXT = {
settler: 'Can found new cities and build terrain improvements (irrigation, roads, mines).',
coastal: 'Coast-hugging vessel — cannot cross open ocean away from land.',
mounted: 'Fast cavalry — takes double damage from anti-mounted defenders.',
paradrop: 'Can drop directly onto a target tile within range, bypassing the terrain in between.',
ignoreterrain: 'Ignores terrain movement penalties — always costs 1 movement point per tile.',
antimounted: 'Doubles its defense strength against mounted attackers.',
aegis: 'Advanced systems grant bonus defense against air and missile attacks.',
spaceship: 'A spaceship component — built toward the space-race victory instead of combat.',
amphibious: 'No attack penalty when assaulting land units directly from a ship.',
engineer: 'Works twice as fast on terrain improvements and can perform advanced terraforming.',
missile: 'One-shot weapon — consumed whether or not the attack succeeds.',
nuke: 'Devastating strike that levels the target; can be intercepted by SDI Defense.',
fighter: 'Limited range — must return to a city or carrier to refuel.',
submarine: 'Stealthed — invisible to enemies unless one of their units or cities is adjacent.',
noncombat: 'Cannot attack or be used to initiate combat.',
ignorewalls: 'Attack ignores the defensive bonus from City Walls.',
caravan: 'Can be sent to another city to establish a trade route or help rush a Wonder.',
};
// Benefit text for building effects — again authored here since the rules
// JSON has no description field. Each entry is a function of the building
// so magnitude (building.value) can be interpolated where the game engine
// actually uses that value (see CivilizationLogic.js); a couple of effects
// (sizecap, palace, granary, veterans, airport, sdi) are described by what
// the engine actually does rather than by the value field, since that field
// isn't consumed literally for them.
const EFFECT_TEXT = {
roadtrade: (b) => `Boosts trade from roaded tiles by ${Math.round(b.value * 100)}%.`,
science: (b) => `+${Math.round(b.value * 100)}% science output from this city.`,
airport: () => 'Air units based here can rebase instantly and heal fully each turn.',
granary: () => 'Keeps half the food box on hand when the city grows, speeding regrowth.',
walls: (b) => `Multiplies this city's defense against land attacks by ${b.value} (unless the attacker ignores walls).`,
sdi: () => 'Automatically intercepts incoming nuclear missiles aimed at this city.',
farmfood: (b) => `+${Math.round(b.value * 100)}% food from farmland-improved tiles worked by this city.`,
defenseair: (b) => `Multiplies this city's defense against air attacks by ${b.value}.`,
power: (b) => `+${Math.round(b.value * 100)}% shield output from the city's Factory (requires a Factory).`,
corruption: (b) => `Cuts this city's corruption and waste by ${Math.round(b.value * 100)}%.`,
gold: (b) => `+${Math.round(b.value * 100)}% gold income from this city's trade.`,
veterans: () => 'Units built here start as veterans, with a combat bonus.',
sizecap: (b) => (b.id === 'sewersystem'
? 'Removes this city\'s population growth cap entirely.'
: 'Raises this city\'s population cap so it can keep growing.'),
oceanfood: (b) => `+${b.value} food from every ocean tile this city works.`,
shields: (b) => `+${Math.round(b.value * 100)}% shield (production) output from this city.`,
oceanshield: (b) => `+${b.value} shield from every ocean tile this city works.`,
palace: () => 'Makes this your capital — corruption and waste grow with distance from it.',
defensesea: (b) => `Multiplies this city's defense against naval attacks by ${b.value}.`,
};
// Short blurbs for the handful of tech-unlocked governments — again hand
// authored since the rules JSON carries only raw numeric fields, keyed off
// what CivilizationLogic.js actually does with those fields (tradeBonus,
// corruptionFactor, despotPenalty, freeUnits, unitUpkeep, warPenalty).
export const GOVERNMENT_TEXT = {
despotism: 'The starting government: heavy corruption and a yield penalty on productive tiles, but cheap unit support.',
anarchy: 'Lawless transition between governments — crippling corruption and no research. Ends on its own.',
monarchy: 'Cuts corruption well below Despotism and drops the Despotism penalty to tile yields.',
communism: 'Low, distance-independent corruption anywhere in the empire, plus 6 free unit upkeep per city.',
republic: 'Sharply reduces corruption and adds +1 trade to tiles already producing trade, at the cost of unit upkeep.',
democracy: 'Eliminates corruption entirely and adds +1 trade to productive tiles; units cost gold upkeep and broken treaties cost double reputation.',
};
// Short blurbs for tech-unlocked terrain improvements (worker orders), keyed
// off what CivilizationLogic.js actually does with each one.
const IMPROVEMENT_TEXT = {
road: 'Cuts movement between roaded tiles to a flat 1 point and adds +1 trade to tiles already producing trade.',
railroad: 'Movement between railroaded tiles is free, and adds +1 shield to tiles already producing shields.',
irrigation: 'Adds food to the tile, boosting the city that works it.',
farmland: 'With a Supermarket in the city, boosts food from irrigated tiles by 50%.',
mine: 'Adds shields to the tile, boosting the city that works it.',
fortress: 'Doubles a unit\'s defense on the tile and lets even a single unit protect it like a city would.',
transform: 'Permanently changes the tile\'s terrain type (Engineers only).',
};
export function describeUnitTooltip(rules, unit) {
const lines = [
{ text: `Attack ${unit.attack} · Defense ${unit.defense} · Move ${unit.move}`, color: COLORS.goldHex },
];
for (const flag of unit.flags ?? []) {
const text = FLAG_TEXT[flag];
if (text) lines.push({ text: `• ${text}` });
}
return { title: unit.name, lines };
}
export function describeBuildingTooltip(rules, building) {
const lines = [
{ text: `Cost ${building.cost} shields · Upkeep ${building.upkeep}/turn`, color: COLORS.goldHex },
];
const describe = EFFECT_TEXT[building.effect];
if (describe) lines.push({ text: `• ${describe(building)}` });
return { title: building.name, lines };
}
// Combat is always a duel against the tile's single strongest defender
// (pickDefender), so enemy tooltips surface that unit's effective defense —
// terrain, fortify, walls etc. included — instead of letting the raw stat
// lines suggest the stack defends with combined numbers. `attacker` is the
// player's currently-selected unit when there is one (its domain/flags can
// change the number, e.g. Pikemen vs mounted); otherwise a plain land unit
// stands in.
function topDefenderLines(rules, state, units, attacker) {
const atk = (attacker && attacker.civ !== units[0].civ) ? attacker : {
type: Object.keys(rules.units).find((id) => rules.units[id].domain === 'land'
&& !rules.units[id].flags.includes('mounted')),
};
let top = units[0];
let topD = -1;
for (const u of units) {
const d = defenderStrength(rules, state, u, atk);
if (d > topD) { topD = d; top = u; }
}
const vs = atk === attacker ? ` vs your ${rules.units[attacker.type].name}` : '';
const lines = [{
text: `Defends at ${topD.toFixed(1)}${vs}`,
color: COLORS.goldHex,
}];
if (units.length > 1) {
lines[0].text = `Top defender: ${rules.units[top.type].name} — defends at ${topD.toFixed(1)}${vs}`;
const idx = tileIndex(state.world, top.x, top.y);
const protectedStack = !!cityAt(state, top.x, top.y)
|| !!(state.world.improvements[idx] & IMP.FORTRESS);
lines.push({
text: protectedStack
? '• Attacks fight one defender at a time'
: '• If the defender falls, the whole stack is lost',
});
}
return lines;
}
// Carried units never appear in a tile's unit stack (the map view skips
// anything with carriedBy), so summarize a boat's hold here: "2× Musketeers,
// 1× Settlers" across every boat in `units`, or null when nothing is aboard.
function cargoSummary(rules, state, units) {
const counts = new Map();
for (const boat of units) {
for (const u of unitsOnBoat(state, boat)) {
const name = rules.units[u.type].name;
counts.set(name, (counts.get(name) ?? 0) + 1);
}
}
if (!counts.size) return null;
return [...counts].map(([name, count]) => `${count}× ${name}`).join(', ');
}
// Hover tooltip for a map unit/stack: which leader controls it plus a
// per-unit-type breakdown. `units` are live game-state unit instances (all
// sharing `civ`, since only one civ's units normally occupy a tile), not
// rule defs — look each one's def up via rules.units[u.type].
export function describeUnitStackTooltip(rules, state, civ, opponentsData, units, attacker = null) {
const opData = opponentsData?.find((o) => o.id === civ.leaderId);
const lines = [];
if (units.length === 1) {
const [u] = units;
const def = rules.units[u.type];
lines.push({ text: def.name, color: COLORS.goldHex });
lines.push({ text: `Attack ${def.attack} · Defense ${def.defense} · Move ${def.move}` });
if (civ.human) {
const home = cityById(state, u.homeCity);
lines.push({ text: `• Home: ${home ? home.name : 'none'}` });
const cargo = cargoSummary(rules, state, [u]);
if (cargo) lines.push({ text: `• Carrying: ${cargo}` });
}
if (u.vet) lines.push({ text: '• Veteran' });
if (u.fortified) lines.push({ text: '• Fortified' });
for (const flag of def.flags ?? []) {
const text = FLAG_TEXT[flag];
if (text) lines.push({ text: `• ${text}` });
}
} else {
// Friendly stacks group by type + home city so upkeep sources stay
// visible; enemy homes are hidden info, so their stacks group by type.
const byKey = new Map();
for (const u of units) {
const def = rules.units[u.type];
const home = civ.human ? cityById(state, u.homeCity) : null;
const key = `${def.name}|${home?.name ?? ''}`;
const entry = byKey.get(key) ?? { count: 0, def, home };
entry.count += 1;
byKey.set(key, entry);
}
lines.push({ text: `${units.length} units`, color: COLORS.goldHex });
for (const { count, def, home } of byKey.values()) {
const homeTxt = home ? ` · ${home.name}` : '';
lines.push({ text: `${count}× ${def.name} (A${def.attack} D${def.defense} M${def.move})${homeTxt}` });
}
if (civ.human) {
const cargo = cargoSummary(rules, state, units);
if (cargo) lines.push({ text: `Aboard ships: ${cargo}` });
}
}
if (!civ.human) lines.push(...topDefenderLines(rules, state, units, attacker));
return {
title: civ.name,
titleColor: civ.color,
lines,
icon: { texture: 'opponents', frame: opData?.spriteIndex ?? 0, color: civ.color, label: civ.name },
};
}
// Hover tooltip for a foreign city: which leader rules it plus the basic
// facts visible from outside (population, capital/walls status) — no build
// queue or yields, since this is a rival's city and this build has no
// espionage/embassy system to justify seeing that.
export function describeCityTooltip(rules, civ, opponentsData, city) {
const opData = opponentsData?.find((o) => o.id === civ.leaderId);
const lines = [
{ text: civ.name, color: civ.color },
{ text: `Population ${city.size}`, color: COLORS.goldHex },
];
if (city.buildings.palace) lines.push({ text: '• Capital' });
if (city.buildings.citywalls) lines.push({ text: '• Defended by City Walls' });
return {
title: city.name,
lines,
icon: { texture: 'opponents', frame: opData?.spriteIndex ?? 0, color: civ.color, label: civ.name },
};
}
// Hover tooltip for a tech-tree entry: prerequisites (only worth showing
// once — omitted for techs the civ already knows) followed by everything
// the tech unlocks, grouped by kind. `civ` is used only to decide whether
// the tech is already known/repeated, not to filter what's shown.
export function describeTechTooltip(rules, tech, civ) {
const known = !!civ.known[tech.id] || (tech.repeatable && civ.futureCount > 0);
const g = rules.techGates[tech.id];
const lines = [];
if (!known && tech.prereqs.length) {
lines.push({
text: `Requires: ${tech.prereqs.map((p) => rules.techs[p].name).join(' + ')}`,
color: COLORS.mutedHex,
});
}
const section = (label, items) => {
if (!items.length) return;
lines.push({ text: label, color: COLORS.goldHex });
for (const item of items) lines.push({ text: `• ${item}` });
};
section('Units:', g.units.map((id) => {
const u = rules.units[id];
return `${u.name} (A${u.attack} D${u.defense} M${u.move})`;
}));
section('Buildings:', g.buildings.map((id) => {
const b = rules.buildings[id];
const describe = EFFECT_TEXT[b.effect];
return describe ? `${b.name} — ${describe(b)}` : b.name;
}));
section('Government:', g.governments.map((id) => {
const gv = rules.governments[id];
const text = GOVERNMENT_TEXT[id];
return text ? `${gv.name} — ${text}` : gv.name;
}));
section('Terrain improvements:', g.improvements.map((id) => {
const im = rules.improvements[id];
const text = IMPROVEMENT_TEXT[id];
return text ? `${im.name} — ${text}` : im.name;
}));
section('Leads to further research:', g.prereqOf.map((id) => rules.techs[id].name));
if (tech.repeatable) {
lines.push({ text: 'Repeatable — each additional discovery adds to your score.', color: COLORS.mutedHex });
}
return { title: tech.name, lines };
}