279 lines
14 KiB
JavaScript
279 lines
14 KiB
JavaScript
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 };
|
||
}
|