feat(civilization): city screen overhaul with fat-cross map, scrollable builds, and yield tracking
- Add CivilizationCityMap.js: draws the 21-tile fat cross in the city screen with terrain, specials, city sprite, pips, borders, units, and veiled tiles - Replace fixed 28-row build list with a scrollable window; add shield cost and turn-estimate annotations per build choice - Add garrison display: fortified units shown as clickable roundels - Refactor tileYield() to accept a `notes` array that records each rule's delta, enabling accurate tooltips that can never drift from engine math - Extract paintTerrainDiamond() and specialColor() from CivilizationMapView so the city map and main map share identical rendering - Add cityCentreYield() enforcing the 1-shield/1-trade market-economy floor - Add cityTileStatus() for consistent worked/idle/taken/offmap classification - Add describeCityTileTooltip() with terrain title, yield breakdown, missing improvement explanations, and status line - Wire improvementSheet and iconSheet paths in civilization-artwork.json - Add comprehensive verifyCivilization checks for yield notes, centre floor, tile status, tooltips, and build list capacity
This commit is contained in:
parent
4fcae92e59
commit
3122bb2f28
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
|
@ -10,9 +10,9 @@
|
|||
],
|
||||
"terrainSheet": { "key": "civilization-terrain", "path": "assets/images/civilization/civilization-terrain.png", "frameWidth": 128, "frameHeight": 96 },
|
||||
"resourceSheet": { "key": "civilization-resources", "path": "assets/images/civilization/civilization-resources.png", "frameWidth": 64, "frameHeight": 64 },
|
||||
"improvementSheet": { "key": "civilization-improvements", "path": null, "frameWidth": 64, "frameHeight": 64 },
|
||||
"improvementSheet": { "key": "civilization-improvements", "path": "assets/images/civilization/civilization-improvements.png", "frameWidth": 64, "frameHeight": 64 },
|
||||
"unitSheet": { "key": "civilization-units", "path": "assets/images/civilization/civilization-units.png", "frameWidth": 64, "frameHeight": 96 },
|
||||
"iconSheet": { "key": "civilization-icons", "path": null, "frameWidth": 48, "frameHeight": 48 },
|
||||
"iconSheet": { "key": "civilization-icons", "path": "assets/images/civilization/civilization-icons.png", "frameWidth": 48, "frameHeight": 48 },
|
||||
"citySheets": {
|
||||
"classic": { "key": "civilization-cities-classic", "path": "assets/images/civilization/civilization-cities-classic.png", "frameWidth": 128, "frameHeight": 96 },
|
||||
"cyberpunk": { "key": "civilization-cities-cyberpunk", "path": "assets/images/civilization/civilization-cities-cyberpunk.png", "frameWidth": 128, "frameHeight": 96 },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,254 @@
|
|||
// Civilization — the fat-cross city map drawn in the city screen's lower-right
|
||||
// quarter: the 21 tiles this city can reach, what's on them, which ones its
|
||||
// citizens are working, and why the rest are off limits.
|
||||
//
|
||||
// This is a small, static, display-only view — not a second CivilizationMapView.
|
||||
// The main map is a chunked-RenderTexture system built for panning a whole
|
||||
// world; twenty-one tiles in a modal want plain game objects. What the two DO
|
||||
// share is the tile art itself (paintTerrainDiamond, specialColor, the frame
|
||||
// offsets and the city-sprite tier maths), so the inset can never drift from
|
||||
// the map it is a window onto.
|
||||
//
|
||||
// Tiles are drawn front-to-back by (dx + dy) exactly as CivilizationMapView
|
||||
// orders them, and each tile's overlays (border, pips, units) are drawn with
|
||||
// that tile rather than in a later pass — so a mountain in front correctly
|
||||
// occludes the tile behind it through its 32px of frame headroom.
|
||||
|
||||
import * as Phaser from 'phaser';
|
||||
import { COLORS } from '../../config.js';
|
||||
import * as Logic from './CivilizationLogic.js';
|
||||
import { shieldGrassAt } from './CivilizationWorldGen.js';
|
||||
import {
|
||||
TILE_W, TILE_H, FRAME_H, paintTerrainDiamond, specialColor,
|
||||
} from './CivilizationMapView.js';
|
||||
import { describeCityTileTooltip } from './CivilizationTooltips.js';
|
||||
|
||||
const FONT = '"Julius Sans One"';
|
||||
|
||||
// The fat cross spans 3 tiles either side of centre in iso space, so its art
|
||||
// is 4 tiles wide and (with the frame headroom) 4 tiles + 32px tall.
|
||||
export const MAP_W = TILE_W * 4;
|
||||
export const MAP_H = TILE_H * 4 + (FRAME_H - TILE_H);
|
||||
|
||||
const PIP_MAX = 5; // more than this on one tile is unreachable in practice
|
||||
const PIP_GAP = 7;
|
||||
const PIP_Y = 9; // below the diamond's centre, clear of the special marker
|
||||
|
||||
const DIAMOND = [
|
||||
[0, -TILE_H / 2], [TILE_W / 2, 0], [0, TILE_H / 2], [-TILE_W / 2, 0],
|
||||
];
|
||||
|
||||
function diamondPath(g) {
|
||||
g.beginPath();
|
||||
g.moveTo(DIAMOND[0][0], DIAMOND[0][1]);
|
||||
for (const [x, y] of DIAMOND.slice(1)) g.lineTo(x, y);
|
||||
g.closePath();
|
||||
}
|
||||
|
||||
// Draws the map centred so the CITY's own tile sits at (cx, cy). Adds every
|
||||
// object to `parent` and attaches hover tooltips to the shared `tooltip`.
|
||||
export function drawCityMap(scene, rules, state, city, parent, tooltip, cx, cy) {
|
||||
const { world } = state;
|
||||
const civ = state.civs[city.civ];
|
||||
const civColor = Phaser.Display.Color.HexStringToColor(civ.color).color;
|
||||
const human = state.humanIndex;
|
||||
|
||||
const tiles = [...Logic.CITY_RADIUS]
|
||||
.sort(([ax, ay], [bx, by]) => (ax + ay) - (bx + by) || ax - bx);
|
||||
|
||||
for (const [dx, dy] of tiles) {
|
||||
const tx = city.x + dx;
|
||||
const ty = city.y + dy;
|
||||
const dcx = cx + (dx - dy) * (TILE_W / 2);
|
||||
const dcy = cy + (dx + dy) * (TILE_H / 2);
|
||||
const status = Logic.cityTileStatus(state, city, tx, ty);
|
||||
|
||||
if (status.kind === 'offmap') {
|
||||
const g = scene.add.graphics({ x: dcx, y: dcy });
|
||||
g.fillStyle(0x05070a, 1);
|
||||
diamondPath(g);
|
||||
g.fillPath();
|
||||
g.lineStyle(1, COLORS.muted, 0.3);
|
||||
g.strokePath();
|
||||
parent.add(g);
|
||||
} else {
|
||||
drawTerrain(scene, rules, state, parent, world, tx, ty, dcx, dcy);
|
||||
if (status.kind === 'centre') drawCitySprite(scene, parent, civ, city, dcx, dcy);
|
||||
else drawSpecial(scene, rules, parent, world, tx, ty, dcx, dcy);
|
||||
|
||||
// Unavailable tiles are veiled rather than hidden: the player still sees
|
||||
// the wheat they can't reach, which is the point of showing it at all.
|
||||
if (status.kind === 'taken' || status.kind === 'city') {
|
||||
const veil = scene.add.graphics({ x: dcx, y: dcy });
|
||||
veil.fillStyle(0x000000, 0.45);
|
||||
diamondPath(veil);
|
||||
veil.fillPath();
|
||||
parent.add(veil);
|
||||
} else {
|
||||
drawPips(scene, rules, state, city, parent, tx, ty, dcx, dcy, status.kind === 'centre');
|
||||
}
|
||||
|
||||
drawBorder(scene, parent, dcx, dcy, status.kind, civColor);
|
||||
drawUnits(scene, rules, state, parent, human, tx, ty, dcx, dcy);
|
||||
}
|
||||
|
||||
// Exact diamond hit area so neighbouring tiles never steal each other's
|
||||
// hover. Hit-area coordinates are relative to the object's top-left, hence
|
||||
// the +TILE_W/2 / +TILE_H/2 shift off the DIAMOND points.
|
||||
const zone = scene.add.zone(dcx, dcy, TILE_W, TILE_H).setInteractive(
|
||||
new Phaser.Geom.Polygon(DIAMOND.map(([x, y]) => [x + TILE_W / 2, y + TILE_H / 2]).flat()),
|
||||
Phaser.Geom.Polygon.Contains,
|
||||
);
|
||||
tooltip.attachTo(zone, () => describeCityTileTooltip(rules, state, city, tx, ty));
|
||||
parent.add(zone);
|
||||
}
|
||||
|
||||
drawFooter(scene, rules, state, city, parent, cx, cy);
|
||||
}
|
||||
|
||||
function drawTerrain(scene, rules, state, parent, world, tx, ty, dcx, dcy) {
|
||||
const idx = Logic.tileIndex(world, tx, ty);
|
||||
const terr = rules.terrainList[world.terrain[idx]];
|
||||
const shieldGrass = shieldGrassAt(tx, ty) && world.special[idx] < 0;
|
||||
if (scene.textures.exists('civilization-terrain')) {
|
||||
const frame = terr.id === 'grassland' && shieldGrass ? rules.grasslandShieldFrame : terr.frame;
|
||||
// Same offsets as CivilizationMapView.paintTile: the frame's bottom edge
|
||||
// is the diamond's bottom vertex, with 32px of headroom above.
|
||||
parent.add(scene.add.image(dcx - TILE_W / 2, dcy - TILE_H / 2 - (FRAME_H - TILE_H),
|
||||
'civilization-terrain', frame).setOrigin(0, 0));
|
||||
return;
|
||||
}
|
||||
const g = scene.add.graphics({ x: dcx, y: dcy - TILE_H / 2 });
|
||||
paintTerrainDiamond(g, terr, shieldGrass);
|
||||
parent.add(g);
|
||||
}
|
||||
|
||||
function drawSpecial(scene, rules, parent, world, tx, ty, dcx, dcy) {
|
||||
const idx = Logic.tileIndex(world, tx, ty);
|
||||
if (world.special[idx] < 0) return;
|
||||
const spec = rules.specialList[world.special[idx]];
|
||||
if (scene.textures.exists('civilization-resources')) {
|
||||
parent.add(scene.add.image(dcx - 32, dcy - TILE_H / 2, 'civilization-resources', spec.frame)
|
||||
.setOrigin(0, 0));
|
||||
return;
|
||||
}
|
||||
const g = scene.add.graphics({ x: dcx, y: dcy });
|
||||
g.fillStyle(0xffffff, 0.85);
|
||||
g.fillCircle(0, -6, 9);
|
||||
g.fillStyle(specialColor(spec.id), 1);
|
||||
g.fillCircle(0, -6, 7);
|
||||
parent.add(g);
|
||||
}
|
||||
|
||||
// Mirrors CivilizationMapView.drawCity's tier/walled frame maths so the inset
|
||||
// shows the same city the map does.
|
||||
function drawCitySprite(scene, parent, civ, city, dcx, dcy) {
|
||||
const tier = city.size >= 13 ? 3 : city.size >= 8 ? 2 : city.size >= 4 ? 1 : 0;
|
||||
const walled = !!city.buildings.citywalls;
|
||||
const themeKey = `civilization-cities-${civ.citySheet ?? 'classic'}`;
|
||||
if (scene.textures.exists(themeKey)) {
|
||||
parent.add(scene.add.image(dcx, dcy - TILE_H / 2 - (FRAME_H - TILE_H) + 48,
|
||||
themeKey, walled ? 4 + tier : tier).setOrigin(0.5, 0));
|
||||
return;
|
||||
}
|
||||
const g = scene.add.graphics({ x: dcx, y: dcy });
|
||||
g.fillStyle(0x6b6255, 1);
|
||||
for (let i = 0; i <= tier; i += 1) {
|
||||
const bw = 26 - i * 3;
|
||||
const bh = 16 + i * 8;
|
||||
g.fillRect(-24 + i * 16, 4 - bh, bw, bh);
|
||||
g.fillStyle(0x7d7466, 1);
|
||||
}
|
||||
if (walled) {
|
||||
g.lineStyle(3, 0x9a8866, 1);
|
||||
g.strokeRect(-34, -14, 68, 22);
|
||||
}
|
||||
parent.add(g);
|
||||
}
|
||||
|
||||
// Food/shield/trade marks on the tile itself, so tiles can be compared without
|
||||
// hovering every one. Sits on a dark pill because the pips have to read against
|
||||
// desert and ocean alike.
|
||||
function drawPips(scene, rules, state, city, parent, tx, ty, dcx, dcy, isCentre) {
|
||||
const out = isCentre
|
||||
? Logic.cityCentreYield(rules, state, city)
|
||||
: Logic.tileYield(rules, state, city.civ, city, tx, ty);
|
||||
const marks = [
|
||||
...Array(Math.min(PIP_MAX, out.food)).fill('food'),
|
||||
...Array(Math.min(PIP_MAX, out.shield)).fill('shield'),
|
||||
...Array(Math.min(PIP_MAX, out.trade)).fill('trade'),
|
||||
];
|
||||
if (!marks.length) return;
|
||||
const g = scene.add.graphics({ x: dcx, y: dcy });
|
||||
const w = marks.length * PIP_GAP + 6;
|
||||
g.fillStyle(0x000000, 0.5);
|
||||
g.fillRoundedRect(-w / 2, PIP_Y - 6, w, 12, 6);
|
||||
marks.forEach((kind, i) => {
|
||||
const x = -w / 2 + 3 + PIP_GAP / 2 + i * PIP_GAP;
|
||||
if (kind === 'food') {
|
||||
g.fillStyle(0x6fd15f, 1);
|
||||
g.fillCircle(x, PIP_Y, 2.5);
|
||||
} else if (kind === 'shield') {
|
||||
g.fillStyle(0xc9cdd4, 1);
|
||||
g.fillRect(x - 2.5, PIP_Y - 2.5, 5, 5);
|
||||
} else {
|
||||
g.fillStyle(0xe8c84a, 1);
|
||||
g.fillTriangle(x, PIP_Y - 3, x + 3, PIP_Y + 2.5, x - 3, PIP_Y + 2.5);
|
||||
}
|
||||
});
|
||||
parent.add(g);
|
||||
}
|
||||
|
||||
function drawBorder(scene, parent, dcx, dcy, kind, civColor) {
|
||||
const g = scene.add.graphics({ x: dcx, y: dcy });
|
||||
const worked = kind === 'worked' || kind === 'centre';
|
||||
g.lineStyle(worked ? 3 : 1, worked ? civColor : COLORS.muted, worked ? 1 : 0.45);
|
||||
diamondPath(g);
|
||||
g.strokePath();
|
||||
parent.add(g);
|
||||
}
|
||||
|
||||
// Small civ-coloured roundels rather than the 64x96 unit sprites: at this size
|
||||
// a real unit frame would cover the tile it is standing on, and the point of
|
||||
// showing units here is "someone is sitting on my wheat", not unit detail.
|
||||
function drawUnits(scene, rules, state, parent, human, tx, ty, dcx, dcy) {
|
||||
if (!state.explored[human]?.[Logic.tileIndex(state.world, tx, ty)]) return;
|
||||
const units = Logic.unitsAt(state, tx, ty); // already excludes carried units
|
||||
if (!units.length) return;
|
||||
const civ = state.civs[units[0].civ];
|
||||
const color = Phaser.Display.Color.HexStringToColor(civ.color).color;
|
||||
const g = scene.add.graphics({ x: dcx, y: dcy });
|
||||
g.fillStyle(color, 0.95);
|
||||
g.fillCircle(0, -10, 11);
|
||||
g.lineStyle(2, 0xffffff, 0.85);
|
||||
g.strokeCircle(0, -10, 11);
|
||||
parent.add(g);
|
||||
parent.add(scene.add.text(dcx, dcy - 10, rules.units[units[0].type].abbr, {
|
||||
fontFamily: FONT, fontSize: '11px', color: '#ffffff', fontStyle: 'bold',
|
||||
}).setOrigin(0.5));
|
||||
if (units.length > 1) {
|
||||
parent.add(scene.add.circle(dcx + 12, dcy - 19, 7, 0x000000, 0.85));
|
||||
parent.add(scene.add.text(dcx + 12, dcy - 19, `${units.length}`, {
|
||||
fontFamily: FONT, fontSize: '10px', color: '#ffffff',
|
||||
}).setOrigin(0.5));
|
||||
}
|
||||
}
|
||||
|
||||
// Ties the map back to the yields panel on the left. Explicitly "before routes
|
||||
// and corruption" — those are city-wide, not per-tile, so the numbers here
|
||||
// would otherwise look wrong next to the panel's.
|
||||
function drawFooter(scene, rules, state, city, parent, cx, cy) {
|
||||
const total = Logic.cityCentreYield(rules, state, city);
|
||||
for (const idx of city.worked) {
|
||||
const tx = idx % state.world.cols;
|
||||
const ty = (idx / state.world.cols) | 0;
|
||||
const out = Logic.tileYield(rules, state, city.civ, city, tx, ty);
|
||||
total.food += out.food;
|
||||
total.shield += out.shield;
|
||||
total.trade += out.trade;
|
||||
}
|
||||
parent.add(scene.add.text(cx, cy + TILE_H * 2 + 22,
|
||||
`Worked ${city.worked.length}/${city.size} tiles · ${total.food} food · ${total.shield} shields · ${total.trade} trade (before routes and corruption)`, {
|
||||
fontFamily: FONT, fontSize: '16px', color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5, 0));
|
||||
}
|
||||
|
|
@ -1,13 +1,35 @@
|
|||
// Civilization — city detail modal: growth, yields, worked-tile emphasis,
|
||||
// build queue with buy, buildings, supported units and trade routes.
|
||||
// Civilization — city detail modal.
|
||||
//
|
||||
// Left half: growth, yields, worker emphasis, improvements, supported units,
|
||||
// trade routes. Right half is split — the scrollable build picker above, and
|
||||
// the fat-cross city map (CivilizationCityMap.js) below, so the numbers on the
|
||||
// left have visible land behind them.
|
||||
|
||||
import * as Phaser from 'phaser';
|
||||
import { GAME_WIDTH, GAME_HEIGHT, COLORS } from '../../config.js';
|
||||
import { Button } from '../../ui/Button.js';
|
||||
import { Tooltip } from '../../ui/Tooltip.js';
|
||||
import * as Logic from './CivilizationLogic.js';
|
||||
import { describeUnitTooltip, describeBuildingTooltip, describeSpecialBuildTooltip } from './CivilizationTooltips.js';
|
||||
import { drawCityMap, MAP_H } from './CivilizationCityMap.js';
|
||||
|
||||
const FONT = '"Julius Sans One"';
|
||||
const ROW_H = 38;
|
||||
|
||||
// Right-hand annotation on a build-list row: what the choice costs in shields
|
||||
// and how long it would take at this city's current output, or the per-turn
|
||||
// conversion rate for Coinage/Public Works (which never complete).
|
||||
function buildRowNote(rules, city, yields, choice) {
|
||||
if (choice.type === 'gold' || choice.type === 'food') {
|
||||
const spec = Logic.SPECIAL_BUILDS[choice.type];
|
||||
const gain = Math.floor(yields.shield / spec.den) * spec.num;
|
||||
return `+${gain} ${choice.type === 'gold' ? 'gold' : 'food'}/turn`;
|
||||
}
|
||||
const cost = choice.ruleObj.cost;
|
||||
if (yields.shield <= 0) return `${cost} shields · ∞`;
|
||||
const turns = Math.max(1, Math.ceil(cost / yields.shield));
|
||||
return `${cost} shields · ${turns} turn${turns === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
export function openCityScreen(scene, rules, state, city, onClose) {
|
||||
const root = scene.add.container(0, 0).setDepth(65);
|
||||
|
|
@ -25,8 +47,17 @@ export function openCityScreen(scene, rules, state, city, onClose) {
|
|||
root.add(dynamic);
|
||||
|
||||
const tooltip = new Tooltip(scene, { depth: 70 });
|
||||
// The build list's clipping mask is made with add:false, so it isn't a child
|
||||
// of `root` and won't be swept up by destroy(true) — it's tracked here and
|
||||
// released on every redraw and on close.
|
||||
let listMask = null;
|
||||
|
||||
const close = () => { tooltip.destroy(); root.destroy(true); onClose(); };
|
||||
const close = () => {
|
||||
tooltip.destroy();
|
||||
listMask?.destroy();
|
||||
root.destroy(true);
|
||||
onClose();
|
||||
};
|
||||
const closeBtn = new Button(scene, px + W / 2 - 80, py - H / 2 + 40, '✕', close,
|
||||
{ width: 60, height: 44, fontSize: 22, variant: 'ghost' });
|
||||
root.add(closeBtn);
|
||||
|
|
@ -111,82 +142,21 @@ export function openCityScreen(scene, rules, state, city, onClose) {
|
|||
const other = Logic.cityById(state, r.cityId);
|
||||
return other ? `${other.name} (+${r.amount})` : `(lost city +${r.amount})`;
|
||||
});
|
||||
dynamic.add(scene.add.text(left, top + 660, `Trade routes: ${routes.length ? routes.join(', ') : '(none)'}`, {
|
||||
const routesTxt = scene.add.text(left, top + 660, `Trade routes: ${routes.length ? routes.join(', ') : '(none)'}`, {
|
||||
fontFamily: FONT, fontSize: '18px', color: COLORS.textHex, wordWrap: { width: 640 },
|
||||
}));
|
||||
|
||||
// --- Build picker (right column)
|
||||
const rx = px + 60;
|
||||
const isSpecialBuild = city.build.type === 'gold' || city.build.type === 'food';
|
||||
if (isSpecialBuild) {
|
||||
const spec = Logic.SPECIAL_BUILDS[city.build.type];
|
||||
const gain = Math.floor((city.shieldBox + y.shield) / spec.den) * spec.num;
|
||||
const unitLabel = city.build.type === 'gold' ? 'gold' : 'food';
|
||||
dynamic.add(scene.add.text(rx, top + 64,
|
||||
`Producing: ${spec.name} +${gain} ${unitLabel}/turn`, {
|
||||
fontFamily: FONT, fontSize: '22px', color: COLORS.textHex,
|
||||
}));
|
||||
} else {
|
||||
const cost = Logic.buildCost(rules, city);
|
||||
const cur = city.build.type === 'unit' ? rules.units[city.build.id] : rules.buildings[city.build.id];
|
||||
const shownShields = Math.min(city.shieldBox, cost);
|
||||
const turns = y.shield > 0 ? Math.max(0, Math.ceil((cost - shownShields) / y.shield)) : '∞';
|
||||
dynamic.add(scene.add.text(rx, top + 64,
|
||||
`Building: ${cur.name} ${shownShields}/${cost} shields (${turns} turns)`, {
|
||||
fontFamily: FONT, fontSize: '22px', color: COLORS.textHex,
|
||||
}));
|
||||
const buyPrice = Logic.buyCost(rules, city);
|
||||
const buyBtn = new Button(scene, rx + 560, top + 120, `BUY (${buyPrice}g)`, () => {
|
||||
if (Logic.buyBuild(rules, state, city)) redraw();
|
||||
}, { width: 190, height: 46, fontSize: 18, variant: civ.gold >= buyPrice && !city.boughtThisTurn ? 'solid' : 'ghost' });
|
||||
dynamic.add(buyBtn);
|
||||
}
|
||||
dynamic.add(scene.add.text(rx, top + 108, `Treasury: ${civ.gold} gold`, {
|
||||
fontFamily: FONT, fontSize: '19px', color: COLORS.goldHex,
|
||||
}));
|
||||
|
||||
// Choices: Coinage/Public Works, then units, then buildings, two columns of rows.
|
||||
const specialChoices = Object.entries(Logic.SPECIAL_BUILDS)
|
||||
.map(([type, spec]) => ({ type, id: spec.id, label: spec.name }));
|
||||
const unitChoices = Logic.availableUnits(rules, state, civ, city)
|
||||
.map((u) => ({ type: 'unit', id: u.id, label: `${u.name} (${u.cost})`, ruleObj: u }));
|
||||
const bldChoices = Logic.availableBuildings(rules, state, civ, city)
|
||||
.map((b) => ({ type: 'building', id: b.id, label: `${b.name} (${b.cost})`, ruleObj: b }));
|
||||
const choices = [...specialChoices, ...unitChoices, ...bldChoices];
|
||||
const colW = 330;
|
||||
const rowH = 36;
|
||||
const perCol = 14; // leaves room below for the garrison strip
|
||||
choices.slice(0, perCol * 2).forEach((ch, i) => {
|
||||
const colX = rx + Math.floor(i / perCol) * (colW + 20);
|
||||
const rowY = top + 170 + (i % perCol) * rowH;
|
||||
const active = city.build.type === ch.type && city.build.id === ch.id;
|
||||
const rect = scene.add.rectangle(colX + colW / 2, rowY, colW, rowH - 4,
|
||||
active ? 0x3a3222 : 0x181510)
|
||||
.setStrokeStyle(1, active ? COLORS.gold : COLORS.muted, active ? 1 : 0.5);
|
||||
const rowColor = ch.type === 'unit' ? '#cfe3ff' : (ch.type === 'building' ? '#ffe9bd' : '#c9f2c0');
|
||||
const txt = scene.add.text(colX + 10, rowY, ch.label, {
|
||||
fontFamily: FONT, fontSize: '16px', color: rowColor,
|
||||
}).setOrigin(0, 0.5);
|
||||
rect.setInteractive({ useHandCursor: true });
|
||||
rect.on('pointerdown', () => {
|
||||
Logic.setBuild(rules, state, city, ch.type, ch.id);
|
||||
redraw();
|
||||
});
|
||||
tooltip.attachTo(rect, () => {
|
||||
if (ch.type === 'unit') return describeUnitTooltip(rules, ch.ruleObj);
|
||||
if (ch.type === 'building') return describeBuildingTooltip(rules, ch.ruleObj);
|
||||
return describeSpecialBuildTooltip(ch.type);
|
||||
});
|
||||
dynamic.add(rect);
|
||||
dynamic.add(txt);
|
||||
});
|
||||
dynamic.add(routesTxt);
|
||||
|
||||
// Garrison — units fortified here are hidden on the map (see
|
||||
// CivilizationMapView); clicking one un-fortifies it and closes the city.
|
||||
// Lives under the left column now that the right half belongs to the build
|
||||
// picker and the city map.
|
||||
const garrison = Logic.unitsAt(state, city.x, city.y).filter((u) => u.fortified);
|
||||
if (garrison.length) {
|
||||
const gTop = top + 170 + perCol * rowH + 20;
|
||||
dynamic.add(scene.add.text(rx, gTop, 'Garrison (click to unfortify)', {
|
||||
// Measured off the routes line rather than a fixed y: a city with three
|
||||
// trade routes wraps that text onto extra lines.
|
||||
const gTop = Math.max(top + 700, routesTxt.y + routesTxt.height + 20);
|
||||
dynamic.add(scene.add.text(left, gTop, 'Garrison (click to unfortify)', {
|
||||
fontFamily: FONT, fontSize: '16px', color: COLORS.mutedHex,
|
||||
}));
|
||||
const boxW = 150;
|
||||
|
|
@ -196,7 +166,7 @@ export function openCityScreen(scene, rules, state, city, onClose) {
|
|||
garrison.forEach((u, i) => {
|
||||
const col = i % perRow;
|
||||
const row = (i / perRow) | 0;
|
||||
const bx = rx + col * (boxW + gap) + boxW / 2;
|
||||
const bx = left + col * (boxW + gap) + boxW / 2;
|
||||
const by = gTop + 30 + row * (boxH + gap) + boxH / 2;
|
||||
const rect = scene.add.rectangle(bx, by, boxW, boxH, 0x181510)
|
||||
.setStrokeStyle(2, COLORS.gold, 1);
|
||||
|
|
@ -209,6 +179,134 @@ export function openCityScreen(scene, rules, state, city, onClose) {
|
|||
dynamic.add(label);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Right column, upper half: what this city is building, and the full
|
||||
// scrollable list of everything it could build instead.
|
||||
const rx = px + 40;
|
||||
const rW = W / 2 - 80;
|
||||
const isSpecialBuild = city.build.type === 'gold' || city.build.type === 'food';
|
||||
if (isSpecialBuild) {
|
||||
const spec = Logic.SPECIAL_BUILDS[city.build.type];
|
||||
const gain = Math.floor((city.shieldBox + y.shield) / spec.den) * spec.num;
|
||||
const unitLabel = city.build.type === 'gold' ? 'gold' : 'food';
|
||||
dynamic.add(scene.add.text(rx, top + 58,
|
||||
`Producing: ${spec.name} +${gain} ${unitLabel}/turn`, {
|
||||
fontFamily: FONT, fontSize: '22px', color: COLORS.textHex,
|
||||
}));
|
||||
} else {
|
||||
const cost = Logic.buildCost(rules, city);
|
||||
const cur = city.build.type === 'unit' ? rules.units[city.build.id] : rules.buildings[city.build.id];
|
||||
const shownShields = Math.min(city.shieldBox, cost);
|
||||
const turns = y.shield > 0 ? Math.max(0, Math.ceil((cost - shownShields) / y.shield)) : '∞';
|
||||
dynamic.add(scene.add.text(rx, top + 58,
|
||||
`Building: ${cur.name} ${shownShields}/${cost} shields (${turns} turns)`, {
|
||||
fontFamily: FONT, fontSize: '22px', color: COLORS.textHex,
|
||||
}));
|
||||
const buyPrice = Logic.buyCost(rules, city);
|
||||
const buyBtn = new Button(scene, rx + rW - 100, top + 104, `BUY (${buyPrice}g)`, () => {
|
||||
if (Logic.buyBuild(rules, state, city)) redraw();
|
||||
}, { width: 190, height: 46, fontSize: 18, variant: civ.gold >= buyPrice && !city.boughtThisTurn ? 'solid' : 'ghost' });
|
||||
dynamic.add(buyBtn);
|
||||
}
|
||||
dynamic.add(scene.add.text(rx, top + 96, `Treasury: ${civ.gold} gold`, {
|
||||
fontFamily: FONT, fontSize: '19px', color: COLORS.goldHex,
|
||||
}));
|
||||
|
||||
dynamic.add(scene.add.text(rx, top + 134, 'WHAT TO BUILD', {
|
||||
fontFamily: FONT, fontSize: '19px', color: COLORS.accentHex,
|
||||
}));
|
||||
|
||||
// Coinage/Public Works first, then units, then buildings — the whole list,
|
||||
// scrolled rather than truncated (a fully-teched civ has far more options
|
||||
// than fit, and anything cut off was simply unbuildable through this UI).
|
||||
const choices = [
|
||||
...Object.entries(Logic.SPECIAL_BUILDS).map(([type, spec]) => ({ type, id: spec.id, name: spec.name })),
|
||||
...Logic.availableUnits(rules, state, civ, city).map((u) => ({ type: 'unit', id: u.id, name: u.name, ruleObj: u })),
|
||||
...Logic.availableBuildings(rules, state, civ, city).map((b) => ({ type: 'building', id: b.id, name: b.name, ruleObj: b })),
|
||||
];
|
||||
const listX = rx;
|
||||
const listY = top + 160;
|
||||
const listH = 268;
|
||||
const contentH = choices.length * ROW_H + 12;
|
||||
|
||||
const listBg = scene.add.rectangle(listX + rW / 2, listY + listH / 2, rW, listH, 0x0a0d12)
|
||||
.setStrokeStyle(2, COLORS.muted, 0.7);
|
||||
dynamic.add(listBg);
|
||||
const listLayer = scene.add.container(0, 0);
|
||||
dynamic.add(listLayer);
|
||||
listMask?.destroy();
|
||||
listMask = scene.make.graphics({ add: false });
|
||||
listMask.fillStyle(0xffffff);
|
||||
listMask.fillRect(listX, listY, rW, listH);
|
||||
listLayer.setMask(listMask.createGeometryMask());
|
||||
|
||||
const rows = [];
|
||||
choices.forEach((ch, i) => {
|
||||
const rowY = listY + 6 + i * ROW_H + ROW_H / 2;
|
||||
const active = city.build.type === ch.type && city.build.id === ch.id;
|
||||
const rect = scene.add.rectangle(listX + rW / 2, rowY, rW - 22, ROW_H - 4,
|
||||
active ? 0x3a3222 : 0x181510)
|
||||
.setStrokeStyle(1, active ? COLORS.gold : COLORS.muted, active ? 1 : 0.5);
|
||||
const rowColor = ch.type === 'unit' ? '#cfe3ff' : (ch.type === 'building' ? '#ffe9bd' : '#c9f2c0');
|
||||
const name = scene.add.text(listX + 22, rowY, ch.name, {
|
||||
fontFamily: FONT, fontSize: '17px', color: rowColor,
|
||||
}).setOrigin(0, 0.5);
|
||||
const note = scene.add.text(listX + rW - 22, rowY, buildRowNote(rules, city, y, ch), {
|
||||
fontFamily: FONT, fontSize: '15px', color: COLORS.mutedHex,
|
||||
}).setOrigin(1, 0.5);
|
||||
rect.setInteractive({ useHandCursor: true });
|
||||
rect.on('pointerdown', () => {
|
||||
Logic.setBuild(rules, state, city, ch.type, ch.id);
|
||||
redraw();
|
||||
});
|
||||
tooltip.attachTo(rect, () => {
|
||||
if (ch.type === 'unit') return describeUnitTooltip(rules, ch.ruleObj);
|
||||
if (ch.type === 'building') return describeBuildingTooltip(rules, ch.ruleObj);
|
||||
return describeSpecialBuildTooltip(ch.type);
|
||||
});
|
||||
listLayer.add([rect, name, note]);
|
||||
rows.push({ rect, rowY });
|
||||
});
|
||||
|
||||
// Scrollbar, and the wheel handler scoped to this window rather than the
|
||||
// whole scene (see the diplomacy chat panel in CivilizationScreens.js).
|
||||
const maxScroll = Math.max(0, contentH - listH);
|
||||
let scrollY = 0;
|
||||
const thumbH = maxScroll ? Math.max(30, listH * (listH / contentH)) : 0;
|
||||
const thumb = maxScroll
|
||||
? scene.add.rectangle(listX + rW - 8, listY, 6, thumbH, COLORS.accent, 0.8).setOrigin(0.5, 0)
|
||||
: null;
|
||||
if (thumb) dynamic.add(thumb);
|
||||
// The geometry mask clips pixels, not input: a row scrolled out of the
|
||||
// window would still swallow hovers over the city map below it, so input
|
||||
// is toggled per row to match what is actually visible.
|
||||
const syncRows = () => {
|
||||
listLayer.y = -scrollY;
|
||||
for (const r of rows) {
|
||||
const screenY = r.rowY - scrollY;
|
||||
r.rect.input.enabled = screenY >= listY && screenY <= listY + listH;
|
||||
}
|
||||
if (thumb) thumb.y = listY + (listH - thumbH) * (scrollY / maxScroll);
|
||||
};
|
||||
listBg.setInteractive();
|
||||
listBg.on('wheel', (pointer, dx, dy) => {
|
||||
scrollY = Phaser.Math.Clamp(scrollY + dy * 0.5, 0, maxScroll);
|
||||
// A row disabled mid-hover never fires pointerout, so its tooltip would
|
||||
// hang around over the list.
|
||||
tooltip.hide();
|
||||
syncRows();
|
||||
});
|
||||
syncRows();
|
||||
|
||||
// --- Right column, lower half: the land this city works. The fat cross is
|
||||
// MAP_W x MAP_H (512x288) of tile art at 1:1 scale — it fits this column
|
||||
// without resampling, which is why the tiles stay as crisp as on the map.
|
||||
const mapCx = rx + rW / 2;
|
||||
const mapCy = top + 630;
|
||||
dynamic.add(scene.add.text(mapCx, mapCy - MAP_H / 2 - 42, 'THE CITY AND ITS LAND', {
|
||||
fontFamily: FONT, fontSize: '19px', color: COLORS.accentHex,
|
||||
}).setOrigin(0.5, 0));
|
||||
drawCityMap(scene, rules, state, city, dynamic, tooltip, mapCx, mapCy);
|
||||
}
|
||||
|
||||
redraw();
|
||||
|
|
|
|||
|
|
@ -257,7 +257,14 @@ export function isCoastal(rules, state, city) {
|
|||
// ---------------------------------------------------------------------------
|
||||
// Tile yields
|
||||
|
||||
export function tileYield(rules, state, civIdx, city, x, y) {
|
||||
// `notes`, when an array is passed, collects the provenance of the yields —
|
||||
// one { label, food, shield, trade } entry per contributing rule, each holding
|
||||
// the delta it actually made. That way a multiplier (farmland) or a penalty
|
||||
// (despotism) is described as accurately as a flat bonus, and the entries
|
||||
// always sum to the returned totals — the city screen's tile tooltip reads
|
||||
// them straight out (see describeCityTileTooltip) instead of re-deriving the
|
||||
// rules and drifting from them. verifyCivilization asserts the sum.
|
||||
export function tileYield(rules, state, civIdx, city, x, y, notes = null) {
|
||||
const { world } = state;
|
||||
const i = tileIndex(world, x, y);
|
||||
const terr = rules.terrainList[world.terrain[i]];
|
||||
|
|
@ -265,32 +272,71 @@ export function tileYield(rules, state, civIdx, city, x, y) {
|
|||
let food = special ? special.food : terr.food;
|
||||
let shield = special ? special.shield : terr.shield;
|
||||
let trade = special ? special.trade : terr.trade;
|
||||
|
||||
let pf = food;
|
||||
let ps = shield;
|
||||
let pt = trade;
|
||||
if (notes) notes.push({ label: special ? special.name : terr.name, food, shield, trade });
|
||||
const note = (label) => {
|
||||
if (!notes || (food === pf && shield === ps && trade === pt)) return;
|
||||
notes.push({ label, food: food - pf, shield: shield - ps, trade: trade - pt });
|
||||
pf = food; ps = shield; pt = trade;
|
||||
};
|
||||
|
||||
if (!special && terr.id === 'grassland' && shieldGrassAt(x, y)) shield += 1;
|
||||
note('Shield grassland');
|
||||
|
||||
const imp = world.improvements[i];
|
||||
if ((imp & IMP.IRRIGATION) && terr.irrigate) food += terr.irrigate;
|
||||
note('Irrigation');
|
||||
if ((imp & IMP.MINE) && terr.mine) shield += terr.mine;
|
||||
note('Mine');
|
||||
if ((imp & IMP.ROAD) && !terr.water && terr.move === 1) trade += 1;
|
||||
note('Road');
|
||||
if ((imp & IMP.RAILROAD) && shield >= 1) shield += 1;
|
||||
note('Railroad');
|
||||
if ((imp & IMP.FARMLAND) && city && city.buildings.supermarket) {
|
||||
food = Math.floor(food * 1.5);
|
||||
}
|
||||
note('Farmland (Supermarket)');
|
||||
if (city) {
|
||||
if (terr.water && city.buildings.harbor) food += 1;
|
||||
note('Harbor');
|
||||
if (terr.water && city.buildings.offshoreplatform) shield += 1;
|
||||
note('Offshore Platform');
|
||||
}
|
||||
|
||||
const civ = state.civs[civIdx];
|
||||
const gov = rules.governments[civ.government];
|
||||
if (gov.tradeBonus && trade >= 1) trade += gov.tradeBonus;
|
||||
note(`${gov.name} trade bonus`);
|
||||
if (gov.despotPenalty) {
|
||||
if (food >= 3) food -= 1;
|
||||
if (shield >= 3) shield -= 1;
|
||||
if (trade >= 3) trade -= 1;
|
||||
}
|
||||
note(`${gov.name} penalty`);
|
||||
return { food, shield, trade };
|
||||
}
|
||||
|
||||
// How a tile inside a city's fat cross relates to that city. The city screen's
|
||||
// map uses it to decide borders and dimming, and its tooltip to explain them,
|
||||
// so both agree by construction. 'taken'/'city' are the two cases
|
||||
// autoAssignTiles silently skips below — the reasons a city can look poorer
|
||||
// than its land suggests.
|
||||
export function cityTileStatus(state, city, x, y) {
|
||||
if (!inBounds(state.world, x, y)) return { kind: 'offmap' };
|
||||
if (x === city.x && y === city.y) return { kind: 'centre' };
|
||||
const here = cityAt(state, x, y);
|
||||
if (here) return { kind: 'city', other: here };
|
||||
const idx = tileIndex(state.world, x, y);
|
||||
if (city.worked.includes(idx)) return { kind: 'worked' };
|
||||
for (const other of state.cities) {
|
||||
if (other.id !== city.id && other.worked.includes(idx)) return { kind: 'taken', other };
|
||||
}
|
||||
return { kind: 'idle' };
|
||||
}
|
||||
|
||||
const EMPHASIS_WEIGHTS = {
|
||||
balanced: { food: 3, shield: 2, trade: 1 },
|
||||
food: { food: 6, shield: 1, trade: 1 },
|
||||
|
|
@ -323,13 +369,31 @@ export function autoAssignTiles(rules, state, city) {
|
|||
city.worked = options.slice(0, city.size).map((o) => o.idx);
|
||||
}
|
||||
|
||||
// The city's own tile as the city actually counts it: the centre always makes
|
||||
// at least 1 shield and 1 trade (the market-economy floor — without it a city
|
||||
// founded on the wrong terrain researches nothing forever). Shared with the
|
||||
// city screen so the map shows the same number the yields panel does.
|
||||
export function cityCentreYield(rules, state, city, notes = null) {
|
||||
const raw = tileYield(rules, state, city.civ, city, city.x, city.y, notes);
|
||||
const out = { food: raw.food, shield: Math.max(1, raw.shield), trade: Math.max(1, raw.trade) };
|
||||
if (notes && (out.shield !== raw.shield || out.trade !== raw.trade)) {
|
||||
notes.push({
|
||||
label: 'City centre minimum',
|
||||
food: 0,
|
||||
shield: out.shield - raw.shield,
|
||||
trade: out.trade - raw.trade,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function cityYields(rules, state, city) {
|
||||
const civ = state.civs[city.civ];
|
||||
const gov = rules.governments[civ.government];
|
||||
const centre = tileYield(rules, state, city.civ, city, city.x, city.y);
|
||||
const centre = cityCentreYield(rules, state, city);
|
||||
let food = centre.food;
|
||||
let shield = Math.max(1, centre.shield); // city tile always makes 1 shield...
|
||||
let trade = Math.max(1, centre.trade); // ...and 1 trade (the market economy floor)
|
||||
let shield = centre.shield;
|
||||
let trade = centre.trade;
|
||||
for (const idx of city.worked) {
|
||||
const x = idx % state.world.cols;
|
||||
const y = (idx / state.world.cols) | 0;
|
||||
|
|
|
|||
|
|
@ -35,6 +35,71 @@ const SEL_RING_W = SEL_RING_H * (TILE_W / TILE_H);
|
|||
|
||||
const ZOOMS = [0.5, 0.75, 1.0, 1.5, 2.0];
|
||||
|
||||
// Draws one terrain tile — filled diamond plus its per-terrain glyphs — into
|
||||
// `g` at the graphics object's own origin, with the diamond's centre at
|
||||
// (0, TILE_H / 2). Neither clears nor positions `g`, so callers own both:
|
||||
// this view stamps it into a chunk RenderTexture, while the city screen's
|
||||
// fat-cross map (CivilizationCityMap.js) places a Graphics per tile. This is
|
||||
// the fallback path used when data/civilization-artwork.json has no terrain
|
||||
// sheet painted; keeping it in one place stops the two views from drifting.
|
||||
export function paintTerrainDiamond(g, terr, shieldGrass = false) {
|
||||
const base = Phaser.Display.Color.HexStringToColor(terr.color).color;
|
||||
const cy = TILE_H / 2;
|
||||
g.fillStyle(base, 1);
|
||||
g.beginPath();
|
||||
g.moveTo(0, cy - TILE_H / 2);
|
||||
g.lineTo(TILE_W / 2, cy);
|
||||
g.lineTo(0, cy + TILE_H / 2);
|
||||
g.lineTo(-TILE_W / 2, cy);
|
||||
g.closePath();
|
||||
g.fillPath();
|
||||
g.lineStyle(1, 0x000000, 0.18);
|
||||
g.strokePath();
|
||||
|
||||
// Simple per-terrain glyphs.
|
||||
const darker = Phaser.Display.Color.ValueToColor(base).darken(25).color;
|
||||
const lighter = Phaser.Display.Color.ValueToColor(base).lighten(20).color;
|
||||
if (terr.id === 'forest' || terr.id === 'jungle') {
|
||||
g.fillStyle(darker, 1);
|
||||
for (const [tx, ty] of [[-24, 0], [0, -8], [22, 2]]) {
|
||||
g.fillTriangle(tx - 9, cy + ty + 8, tx + 9, cy + ty + 8, tx, cy + ty - 12);
|
||||
}
|
||||
} else if (terr.id === 'mountains') {
|
||||
g.fillStyle(darker, 1);
|
||||
g.fillTriangle(-28, cy + 12, 4, cy + 12, -12, cy - 22);
|
||||
g.fillTriangle(-4, cy + 14, 30, cy + 14, 13, cy - 16);
|
||||
g.fillStyle(0xffffff, 0.9);
|
||||
g.fillTriangle(-16, cy - 14, -8, cy - 14, -12, cy - 22);
|
||||
} else if (terr.id === 'hills') {
|
||||
g.fillStyle(darker, 1);
|
||||
g.fillEllipse(-16, cy + 4, 34, 16);
|
||||
g.fillEllipse(14, cy + 8, 38, 18);
|
||||
} else if (terr.id === 'ocean') {
|
||||
g.lineStyle(2, lighter, 0.7);
|
||||
for (const [tx, ty] of [[-24, -6], [8, 2], [-8, 10]]) {
|
||||
g.beginPath();
|
||||
g.moveTo(tx, cy + ty);
|
||||
g.lineTo(tx + 14, cy + ty);
|
||||
g.strokePath();
|
||||
}
|
||||
} else if (terr.id === 'swamp') {
|
||||
g.lineStyle(2, darker, 0.8);
|
||||
for (const [tx, ty] of [[-20, 4], [4, -4], [18, 8]]) {
|
||||
g.beginPath();
|
||||
g.moveTo(tx, cy + ty);
|
||||
g.lineTo(tx, cy + ty - 8);
|
||||
g.strokePath();
|
||||
}
|
||||
} else if (terr.id === 'desert') {
|
||||
g.fillStyle(darker, 0.6);
|
||||
g.fillEllipse(-14, cy + 4, 10, 4);
|
||||
g.fillEllipse(12, cy - 4, 12, 4);
|
||||
} else if (terr.id === 'grassland' && shieldGrass) {
|
||||
g.fillStyle(lighter, 1);
|
||||
g.fillCircle(18, cy - 6, 5);
|
||||
}
|
||||
}
|
||||
|
||||
export class CivilizationMapView {
|
||||
constructor(scene, rules, state, opponentsData, callbacks = {}) {
|
||||
this.scene = scene;
|
||||
|
|
@ -281,64 +346,8 @@ export class CivilizationMapView {
|
|||
paintProceduralTile(c, r, terr, x, y) {
|
||||
const g = this.stamp;
|
||||
g.clear();
|
||||
const base = Phaser.Display.Color.HexStringToColor(terr.color).color;
|
||||
const cy = TILE_H / 2; // stamp-local diamond centre
|
||||
g.fillStyle(base, 1);
|
||||
g.beginPath();
|
||||
g.moveTo(0, cy - TILE_H / 2);
|
||||
g.lineTo(TILE_W / 2, cy);
|
||||
g.lineTo(0, cy + TILE_H / 2);
|
||||
g.lineTo(-TILE_W / 2, cy);
|
||||
g.closePath();
|
||||
g.fillPath();
|
||||
g.lineStyle(1, 0x000000, 0.18);
|
||||
g.strokePath();
|
||||
|
||||
// Simple per-terrain glyphs.
|
||||
const darker = Phaser.Display.Color.ValueToColor(base).darken(25).color;
|
||||
const lighter = Phaser.Display.Color.ValueToColor(base).lighten(20).color;
|
||||
if (terr.id === 'forest' || terr.id === 'jungle') {
|
||||
g.fillStyle(darker, 1);
|
||||
for (const [tx, ty] of [[-24, 0], [0, -8], [22, 2]]) {
|
||||
g.fillTriangle(tx - 9, cy + ty + 8, tx + 9, cy + ty + 8, tx, cy + ty - 12);
|
||||
}
|
||||
} else if (terr.id === 'mountains') {
|
||||
g.fillStyle(darker, 1);
|
||||
g.fillTriangle(-28, cy + 12, 4, cy + 12, -12, cy - 22);
|
||||
g.fillTriangle(-4, cy + 14, 30, cy + 14, 13, cy - 16);
|
||||
g.fillStyle(0xffffff, 0.9);
|
||||
g.fillTriangle(-16, cy - 14, -8, cy - 14, -12, cy - 22);
|
||||
} else if (terr.id === 'hills') {
|
||||
g.fillStyle(darker, 1);
|
||||
g.fillEllipse(-16, cy + 4, 34, 16);
|
||||
g.fillEllipse(14, cy + 8, 38, 18);
|
||||
} else if (terr.id === 'ocean') {
|
||||
g.lineStyle(2, lighter, 0.7);
|
||||
for (const [tx, ty] of [[-24, -6], [8, 2], [-8, 10]]) {
|
||||
g.beginPath();
|
||||
g.moveTo(tx, cy + ty);
|
||||
g.lineTo(tx + 14, cy + ty);
|
||||
g.strokePath();
|
||||
}
|
||||
} else if (terr.id === 'swamp') {
|
||||
g.lineStyle(2, darker, 0.8);
|
||||
for (const [tx, ty] of [[-20, 4], [4, -4], [18, 8]]) {
|
||||
g.beginPath();
|
||||
g.moveTo(tx, cy + ty);
|
||||
g.lineTo(tx, cy + ty - 8);
|
||||
g.strokePath();
|
||||
}
|
||||
} else if (terr.id === 'desert') {
|
||||
g.fillStyle(darker, 0.6);
|
||||
g.fillEllipse(-14, cy + 4, 10, 4);
|
||||
g.fillEllipse(12, cy - 4, 12, 4);
|
||||
} else if (terr.id === 'grassland') {
|
||||
const idx = tileIndex(this.state.world, c, r);
|
||||
if (shieldGrassAt(c, r) && this.state.world.special[idx] < 0) {
|
||||
g.fillStyle(lighter, 1);
|
||||
g.fillCircle(18, cy - 6, 5);
|
||||
}
|
||||
}
|
||||
const idx = tileIndex(this.state.world, c, r);
|
||||
paintTerrainDiamond(g, terr, shieldGrassAt(c, r) && this.state.world.special[idx] < 0);
|
||||
this.drawStampAt(g, x, y);
|
||||
}
|
||||
|
||||
|
|
@ -1065,7 +1074,7 @@ export class CivilizationMapView {
|
|||
}
|
||||
}
|
||||
|
||||
function specialColor(id) {
|
||||
export function specialColor(id) {
|
||||
const map = {
|
||||
buffalo: 0x8a5a2a, wheat: 0xe8c84a, pheasant: 0xc06030, silk: 0xe8e8f0,
|
||||
coal: 0x30302e, wine: 0x7a2050, gold: 0xffd700, iron: 0x8a8a92,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { COLORS } from '../../config.js';
|
||||
import {
|
||||
cityById, cityAt, tileIndex, defenderStrength, unitsOnBoat, IMP, SPECIAL_BUILDS,
|
||||
tileYield, cityTileStatus, cityCentreYield,
|
||||
} from './CivilizationLogic.js';
|
||||
|
||||
// Flavor/ability text for unit flags — data/civilization-rules.json has no
|
||||
|
|
@ -238,6 +239,77 @@ export function describeCityTooltip(rules, civ, opponentsData, city) {
|
|||
};
|
||||
}
|
||||
|
||||
// Hover tooltip for one tile of a city's fat cross in the city screen. The
|
||||
// yield breakdown is not re-derived here — tileYield fills a `notes` array as
|
||||
// it applies each rule, so this can only ever say what the engine actually
|
||||
// did. IMP bits that changed nothing still get a line, since "why does my road
|
||||
// give me nothing?" is exactly the question this tooltip exists to answer.
|
||||
const IMP_BITS = [
|
||||
['irrigation', IMP.IRRIGATION], ['farmland', IMP.FARMLAND], ['mine', IMP.MINE],
|
||||
['road', IMP.ROAD], ['railroad', IMP.RAILROAD], ['fortress', IMP.FORTRESS],
|
||||
];
|
||||
|
||||
function yieldDelta({ food, shield, trade }) {
|
||||
const parts = [];
|
||||
const add = (n, label) => { if (n) parts.push(`${n > 0 ? '+' : '−'}${Math.abs(n)} ${label}`); };
|
||||
add(food, 'food');
|
||||
add(shield, 'shields');
|
||||
add(trade, 'trade');
|
||||
return parts.length ? parts.join(', ') : 'nothing';
|
||||
}
|
||||
|
||||
export function describeCityTileTooltip(rules, state, city, x, y) {
|
||||
const status = cityTileStatus(state, city, x, y);
|
||||
if (status.kind === 'offmap') {
|
||||
return {
|
||||
title: 'Beyond the map',
|
||||
lines: [{ text: 'The world ends here — this tile can never be worked.', color: COLORS.mutedHex }],
|
||||
};
|
||||
}
|
||||
const { world } = state;
|
||||
const i = tileIndex(world, x, y);
|
||||
const terr = rules.terrainList[world.terrain[i]];
|
||||
const special = world.special[i] >= 0 ? rules.specialList[world.special[i]] : null;
|
||||
const notes = [];
|
||||
const out = status.kind === 'centre'
|
||||
? cityCentreYield(rules, state, city, notes)
|
||||
: tileYield(rules, state, city.civ, city, x, y, notes);
|
||||
|
||||
const lines = [{
|
||||
text: `Food ${out.food} · Shields ${out.shield} · Trade ${out.trade}`,
|
||||
color: COLORS.goldHex,
|
||||
}];
|
||||
if (status.kind === 'centre') {
|
||||
lines.push({ text: 'The city centre is always worked, on top of the tiles its citizens work.' });
|
||||
}
|
||||
for (const n of notes) lines.push({ text: `• ${n.label}: ${yieldDelta(n)}` });
|
||||
|
||||
// Improvements that produced nothing here (a road on hills, a mine on
|
||||
// grassland, a fortress) get their blurb so the absence is explained.
|
||||
// Prefix match, not equality: tileYield labels the farmland step
|
||||
// "Farmland (Supermarket)" to name what made it work.
|
||||
const labels = notes.map((n) => n.label.toLowerCase());
|
||||
for (const [id, bit] of IMP_BITS) {
|
||||
if (!(world.improvements[i] & bit)) continue;
|
||||
const name = rules.improvements[id]?.name ?? id;
|
||||
if (labels.some((l) => l.startsWith(name.toLowerCase()))) continue;
|
||||
const text = IMPROVEMENT_TEXT[id];
|
||||
lines.push({ text: `• ${name} — no effect here.${text ? ` ${text}` : ''}`, color: COLORS.mutedHex });
|
||||
}
|
||||
|
||||
const civ = state.civs[city.civ];
|
||||
const STATUS = {
|
||||
centre: { text: `The heart of ${city.name}`, color: civ.color },
|
||||
worked: { text: `Worked by ${city.name}`, color: civ.color },
|
||||
idle: { text: 'Not worked — this city has no citizen to spare for it.', color: COLORS.mutedHex },
|
||||
taken: { text: `Worked by ${status.other?.name} — one city may work a tile.`, color: COLORS.mutedHex },
|
||||
city: { text: `${status.other?.name} stands here.`, color: COLORS.mutedHex },
|
||||
};
|
||||
lines.push(STATUS[status.kind] ?? STATUS.idle);
|
||||
|
||||
return { title: special ? `${special.name} (${terr.name})` : terr.name, lines };
|
||||
}
|
||||
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ import * as Logic from '../src/games/civilization/CivilizationLogic.js';
|
|||
import * as AI from '../src/games/civilization/CivilizationAI.js';
|
||||
import * as Diplo from '../src/games/civilization/CivilizationDiplomacy.js';
|
||||
import * as Chat from '../src/games/civilization/CivilizationChat.js';
|
||||
// Pure data module (no Phaser) even though its consumers are UI — the city
|
||||
// screen's tooltips are checkable headlessly, and worth checking.
|
||||
import * as Tooltips from '../src/games/civilization/CivilizationTooltips.js';
|
||||
|
||||
const QUICK = process.argv.includes('--quick');
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
|
@ -752,6 +755,157 @@ if (RULES) {
|
|||
check('idle AI city settles on Coinage or Public Works',
|
||||
city.build.type === 'gold' || city.build.type === 'food');
|
||||
}
|
||||
|
||||
// --- City-screen data layer (the fat-cross map + its tile tooltips).
|
||||
//
|
||||
// The drawing is Phaser and can't run here, but everything it reads can.
|
||||
// The load-bearing check is the first one: the tooltip explains a tile's
|
||||
// yields from tileYield's own `notes`, so if those ever stop summing to the
|
||||
// returned totals the tooltip starts lying.
|
||||
{
|
||||
const terrains = ['grassland', 'plains', 'hills', 'mountains', 'forest', 'desert', 'swamp', 'ocean'];
|
||||
const impSets = [
|
||||
0, Logic.IMP.ROAD, Logic.IMP.IRRIGATION, Logic.IMP.MINE, Logic.IMP.ROAD | Logic.IMP.RAILROAD,
|
||||
Logic.IMP.IRRIGATION | Logic.IMP.FARMLAND,
|
||||
Logic.IMP.IRRIGATION | Logic.IMP.FARMLAND | Logic.IMP.ROAD | Logic.IMP.RAILROAD,
|
||||
Logic.IMP.MINE | Logic.IMP.ROAD | Logic.IMP.FORTRESS,
|
||||
];
|
||||
let sumOk = true;
|
||||
let labelsOk = true;
|
||||
let why = '';
|
||||
for (const terrId of terrains) {
|
||||
for (const gov of ['despotism', 'republic', 'democracy']) {
|
||||
for (const imps of impSets) {
|
||||
for (const withBuildings of [false, true]) {
|
||||
// Grassland everywhere so the city can be founded, with only the
|
||||
// measured tile switched to the terrain under test — that keeps
|
||||
// the city tile's implicit road out of the measurement too.
|
||||
const st = makeFlatState();
|
||||
st.civs[0].government = gov;
|
||||
const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null));
|
||||
if (withBuildings) {
|
||||
city.buildings.supermarket = true;
|
||||
city.buildings.harbor = true;
|
||||
city.buildings.offshoreplatform = true;
|
||||
}
|
||||
const tx = 6;
|
||||
const ty = 5;
|
||||
const i = Logic.tileIndex(st.world, tx, ty);
|
||||
st.world.terrain[i] = T(terrId);
|
||||
st.world.improvements[i] = imps;
|
||||
if (withBuildings && RULES.specialList.length) st.world.special[i] = 0;
|
||||
const notes = [];
|
||||
const out = Logic.tileYield(RULES, st, 0, city, tx, ty, notes);
|
||||
const sum = notes.reduce((a, n) => ({
|
||||
food: a.food + n.food, shield: a.shield + n.shield, trade: a.trade + n.trade,
|
||||
}), { food: 0, shield: 0, trade: 0 });
|
||||
if (sum.food !== out.food || sum.shield !== out.shield || sum.trade !== out.trade) {
|
||||
sumOk = false;
|
||||
why = `${terrId}/${gov}/imp${imps}: notes ${JSON.stringify(sum)} vs ${JSON.stringify(out)}`;
|
||||
}
|
||||
if (notes.some((n) => !n.label || (!n.food && !n.shield && !n.trade && notes.indexOf(n) > 0))) {
|
||||
labelsOk = false;
|
||||
why = why || `${terrId}/${gov}: empty note`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
check('tile yield notes sum to the yields they explain', sumOk, why);
|
||||
check('tile yield notes are labelled and never empty', labelsOk, why);
|
||||
}
|
||||
|
||||
// The city centre's 1-shield/1-trade floor is applied in one place, so the
|
||||
// map, the tooltip and the yields panel can't disagree about it.
|
||||
{
|
||||
// Swamp yields 0 shields and 0 trade, and at move 2 the city tile's
|
||||
// implicit road adds no trade either — so both floors have to do work.
|
||||
const st = makeFlatState({ terrain: 'swamp' });
|
||||
const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null));
|
||||
const raw = Logic.tileYield(RULES, st, 0, city, 5, 5);
|
||||
const centre = Logic.cityCentreYield(RULES, st, city);
|
||||
check('city centre never yields under 1 shield / 1 trade',
|
||||
centre.shield >= 1 && centre.trade >= 1, `${JSON.stringify(centre)}`);
|
||||
const notes = [];
|
||||
Logic.cityCentreYield(RULES, st, city, notes);
|
||||
const sum = notes.reduce((a, n) => ({
|
||||
shield: a.shield + n.shield, trade: a.trade + n.trade,
|
||||
}), { shield: 0, trade: 0 });
|
||||
check('centre floor is itself explained in the breakdown',
|
||||
sum.shield === centre.shield && sum.trade === centre.trade,
|
||||
`raw ${JSON.stringify(raw)} centre ${JSON.stringify(centre)} notes ${JSON.stringify(sum)}`);
|
||||
}
|
||||
|
||||
// cityTileStatus drives both the map's borders/dimming and the tooltip's
|
||||
// closing line — every case a fat cross can actually contain.
|
||||
{
|
||||
const st = makeFlatState({ cols: 16, rows: 16 });
|
||||
const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null));
|
||||
const neighbour = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 8, 5, null));
|
||||
city.size = 3;
|
||||
Logic.autoAssignTiles(RULES, st, city);
|
||||
Logic.autoAssignTiles(RULES, st, neighbour);
|
||||
const kindAt = (x, yy) => Logic.cityTileStatus(st, city, x, yy).kind;
|
||||
check('centre tile reads as the city itself', kindAt(5, 5) === 'centre');
|
||||
check('a tile holding another city is flagged', kindAt(8, 5) === 'city');
|
||||
const workedIdx = city.worked[0];
|
||||
check('worked tiles read as worked',
|
||||
kindAt(workedIdx % st.world.cols, (workedIdx / st.world.cols) | 0) === 'worked');
|
||||
const takenIdx = neighbour.worked.find((idx) => {
|
||||
const x = idx % st.world.cols;
|
||||
const yy = (idx / st.world.cols) | 0;
|
||||
return Math.max(Math.abs(x - city.x), Math.abs(yy - city.y)) <= 2;
|
||||
});
|
||||
if (takenIdx !== undefined) {
|
||||
check('a neighbour\'s tile inside our radius is flagged as taken',
|
||||
kindAt(takenIdx % st.world.cols, (takenIdx / st.world.cols) | 0) === 'taken');
|
||||
}
|
||||
// Found at the map edge so part of the fat cross falls off the world.
|
||||
const edge = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 0, 0, null));
|
||||
check('tiles past the map edge are flagged',
|
||||
Logic.cityTileStatus(st, edge, -2, 0).kind === 'offmap');
|
||||
}
|
||||
|
||||
// The tooltip itself: title, the yield line, and the right closing status.
|
||||
{
|
||||
const st = makeFlatState({ cols: 16, rows: 16 });
|
||||
const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null));
|
||||
city.size = 2;
|
||||
Logic.autoAssignTiles(RULES, st, city);
|
||||
const tip = Tooltips.describeCityTileTooltip(RULES, st, city, 6, 5);
|
||||
const out = Logic.tileYield(RULES, st, 0, city, 6, 5);
|
||||
check('tile tooltip is titled with its terrain', tip.title === RULES.terrains.grassland.name);
|
||||
check('tile tooltip leads with the yields it explains',
|
||||
tip.lines[0].text === `Food ${out.food} · Shields ${out.shield} · Trade ${out.trade}`,
|
||||
tip.lines[0].text);
|
||||
check('tile tooltip closes with a status line', tip.lines.length >= 2
|
||||
&& /Worked|Not worked|heart of/.test(tip.lines[tip.lines.length - 1].text),
|
||||
tip.lines[tip.lines.length - 1].text);
|
||||
check('centre tooltip names the city', /heart of/.test(
|
||||
Tooltips.describeCityTileTooltip(RULES, st, city, 5, 5).lines.at(-1).text,
|
||||
));
|
||||
const off = Tooltips.describeCityTileTooltip(RULES, st, city, -1, 5);
|
||||
check('off-map tooltip degrades gracefully', off.title === 'Beyond the map' && off.lines.length > 0);
|
||||
// Every template must resolve — a stray {token} would ship to the player.
|
||||
let clean = true;
|
||||
for (const [dx, dy] of Logic.CITY_RADIUS) {
|
||||
const t = Tooltips.describeCityTileTooltip(RULES, st, city, city.x + dx, city.y + dy);
|
||||
if (/\{|undefined|NaN/.test(t.title + t.lines.map((l) => l.text).join(''))) clean = false;
|
||||
}
|
||||
check('no tile tooltip in the fat cross renders a hole', clean);
|
||||
}
|
||||
|
||||
// The build list used to be sliced to 28 rows; a developed city has more
|
||||
// options than that, so the scroll window is load-bearing, not decoration.
|
||||
{
|
||||
const st = makeFlatState();
|
||||
const city = Logic.foundCity(RULES, st, Logic.spawnUnit(RULES, st, 0, 'settlers', 5, 5, null));
|
||||
for (const t of RULES.techList) if (!t.repeatable) st.civs[0].known[t.id] = true;
|
||||
const total = 2 + Logic.availableUnits(RULES, st, st.civs[0], city).length
|
||||
+ Logic.availableBuildings(RULES, st, st.civs[0], city).length;
|
||||
check('a fully-teched city offers more builds than the old 28-row cap',
|
||||
total > 28, `${total} options`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue