fix: cap city shields at build cost to prevent overflow display

When a city cannot complete a build (e.g., size < 2 for settlers),
shields were accumulating past the build cost, showing confusing
values like "60/40 shields" or negative turn counts.

- Cap shieldBox at buildCost each turn in processCity
- Clamp displayed shields to min(shieldBox, cost) in city screen
- Add tests verifying shield capping and stalled build completion
This commit is contained in:
Brian Fertig 2026-07-16 10:18:11 -06:00
parent 4d864503f2
commit 543f751fe2
3 changed files with 166 additions and 12 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

View File

@ -26,12 +26,29 @@ const D = { hud: 30, modal: 60, toast: 80 };
export default class CivilizationGame extends Phaser.Scene { export default class CivilizationGame extends Phaser.Scene {
constructor() { super('CivilizationGame'); } constructor() { super('CivilizationGame'); }
// Any window opening or closing flips `modalOpen`. Buttons fire their
// onClick on 'pointerup', so a click that closes a window (e.g. a modal's
// own Close/Cancel button) has already set modalOpen back to false by the
// time the scene-wide pointerup handler below runs its own check — without
// this guard that same mouse-up would fall through and register as a map
// click (moving the selected unit, etc). Flipping modalOpen either way arms
// the guard; the next pointerup (almost always the one in progress right
// now) consumes it and is swallowed, so the map only responds to a fresh
// click made after the mouse button has been released.
get modalOpen() { return this._modalOpen; }
set modalOpen(value) {
if (value !== this._modalOpen) this.clickGuard = true;
this._modalOpen = value;
}
init(data) { init(data) {
this.gameDef = data.game ?? { slug: 'civilization', name: 'Civilization' }; this.gameDef = data.game ?? { slug: 'civilization', name: 'Civilization' };
this.state = null; this.state = null;
this.view = null; this.view = null;
this.phase = 'setup'; this.phase = 'setup';
this.modalOpen = false; this.modalOpen = false;
this.clickGuard = false;
this.busy = false; this.busy = false;
this.endTurnFlashTween = null; this.endTurnFlashTween = null;
} }
@ -143,7 +160,7 @@ export default class CivilizationGame extends Phaser.Scene {
let oy = gy + rowsUsed * (cell + 18) + 40; let oy = gy + rowsUsed * (cell + 18) + 40;
// --- option rows // --- option rows
this.setupOpts = this.setupOpts ?? { sizeId: 'medium', opponents: 3, difficultyId: 'prince' }; this.setupOpts = this.setupOpts ?? { sizeId: 'medium', opponents: 5, difficultyId: 'prince' };
const mkRow = (label, options, current, onPick) => { const mkRow = (label, options, current, onPick) => {
root.add(this.add.text(cx - 560, oy, label, { root.add(this.add.text(cx - 560, oy, label, {
fontFamily: FONT, fontSize: '22px', color: COLORS.textHex, fontFamily: FONT, fontSize: '22px', color: COLORS.textHex,
@ -252,6 +269,7 @@ export default class CivilizationGame extends Phaser.Scene {
this.setupRoot = null; this.setupRoot = null;
this.view = new CivilizationMapView(this, this.rules, this.state, { this.view = new CivilizationMapView(this, this.rules, this.state, {
onCityClick: (city) => this.onCityClick(city), onCityClick: (city) => this.onCityClick(city),
onUnitClick: (unit) => this.onUnitClick(unit),
}); });
this.view.buildMinimap(16, GAME_HEIGHT - 260, 300); this.view.buildMinimap(16, GAME_HEIGHT - 260, 300);
this.buildHud(); this.buildHud();
@ -374,6 +392,7 @@ export default class CivilizationGame extends Phaser.Scene {
const wasDrag = dragged; const wasDrag = dragged;
dragStart = null; dragStart = null;
dragged = false; dragged = false;
if (this.clickGuard) { this.clickGuard = false; return; }
if (wasDrag || this.modalOpen || this.phase !== 'playing' || this.busy) return; if (wasDrag || this.modalOpen || this.phase !== 'playing' || this.busy) return;
if (pointer.y < 56 || pointer.y > GAME_HEIGHT - 44) return; // HUD bands if (pointer.y < 56 || pointer.y > GAME_HEIGHT - 44) return; // HUD bands
// Clicks that landed on any interactive object (buttons, minimap, city // Clicks that landed on any interactive object (buttons, minimap, city
@ -455,6 +474,15 @@ export default class CivilizationGame extends Phaser.Scene {
} }
update() { update() {
// Phaser Containers render children in list order, not by their .depth —
// .setDepth() alone (used throughout CivilizationMapView for units/cities/
// combat ghosts/selection ring) has no effect on draw order inside
// this.view.dynamic unless the list is explicitly re-sorted. Units and
// cities are added in arbitrary (state-array) order each refresh, and
// move/combat animations update depth continuously, so this has to run
// every frame — including mid-animation, hence it runs before the
// busy/modal early-return below.
this.view?.dynamic?.sort('depth');
if (this.phase !== 'playing' || this.modalOpen || this.busy) return; if (this.phase !== 'playing' || this.modalOpen || this.busy) return;
// Edge-of-keyboard panning. // Edge-of-keyboard panning.
const k = this.keys; const k = this.keys;
@ -481,7 +509,7 @@ export default class CivilizationGame extends Phaser.Scene {
else if (just(k.Z)) this.tryStep(sel, -1, 1); else if (just(k.Z)) this.tryStep(sel, -1, 1);
else if (just(k.C)) this.tryStep(sel, 1, 1); else if (just(k.C)) this.tryStep(sel, 1, 1);
else if (just(k.SPACE)) { sel.mp = 0; this.selectNextUnit(); } else if (just(k.SPACE)) { sel.mp = 0; this.selectNextUnit(); }
else if (just(k.F)) { sel.fortified = true; sel.mp = 0; this.afterAction(); this.selectNextUnit(); } else if (just(k.F)) this.tryFortify(sel);
else if (just(k.B)) this.tryFound(sel); else if (just(k.B)) this.tryFound(sel);
else if (just(k.R)) this.tryWork(sel, this.canRail(sel) ? 'railroad' : 'road'); else if (just(k.R)) this.tryWork(sel, this.canRail(sel) ? 'railroad' : 'road');
else if (just(k.I)) this.tryWork(sel, 'irrigation'); else if (just(k.I)) this.tryWork(sel, 'irrigation');
@ -715,6 +743,112 @@ export default class CivilizationGame extends Phaser.Scene {
} }
} }
tryFortify(unit) {
unit.fortified = true;
unit.mp = 0;
this.afterAction();
this.selectNextUnit();
}
// ---------------------------------------------------------------------------
// Unit action popup (clicking the currently-selected unit's sprite)
onUnitClick(unit) {
if (this.modalOpen || this.busy || this.state.current !== this.state.humanIndex) return;
this.openUnitActionMenu(unit);
}
// Mirrors the same eligibility checks the keyboard shortcuts use (tryFound,
// tryWork, tryCaravan, tryFortify) so the menu never offers something that
// would just toast a failure.
getUnitActions(unit) {
const { rules, state } = this;
const def = rules.units[unit.type];
const actions = [];
if (!unit.fortified) actions.push({ label: 'Fortify', onSelect: () => this.tryFortify(unit) });
if (def.flags.includes('settler') && Logic.canFoundCity(rules, state, unit.x, unit.y)) {
actions.push({ label: 'Found City', onSelect: () => this.tryFound(unit) });
}
const roadImp = this.canRail(unit) ? 'railroad' : 'road';
if (Logic.canWork(rules, state, unit, roadImp)) {
const label = roadImp === 'railroad' ? 'Build Railroad' : 'Build Road';
actions.push({ label, onSelect: () => this.tryWork(unit, roadImp) });
}
if (Logic.canWork(rules, state, unit, 'irrigation')) {
actions.push({ label: 'Irrigate', onSelect: () => this.tryWork(unit, 'irrigation') });
}
if (Logic.canWork(rules, state, unit, 'mine')) {
actions.push({ label: 'Build Mine', onSelect: () => this.tryWork(unit, 'mine') });
}
if (Logic.canWork(rules, state, unit, 'fortress')) {
actions.push({ label: 'Build Fortress', onSelect: () => this.tryWork(unit, 'fortress') });
}
if (Logic.canWork(rules, state, unit, 'transform')) {
actions.push({ label: 'Transform Terrain', onSelect: () => this.tryWork(unit, 'transform') });
}
if (Logic.canEstablishRoute(rules, state, unit)) {
actions.push({ label: 'Establish Trade Route', onSelect: () => this.tryCaravan(unit) });
}
actions.push({ label: 'Skip Turn', onSelect: () => { unit.mp = 0; this.selectNextUnit(); } });
return actions;
}
openUnitActionMenu(unit) {
this.modalOpen = true;
const def = this.rules.units[unit.type];
const actions = this.getUnitActions(unit);
const cx = GAME_WIDTH / 2;
const cy = GAME_HEIGHT / 2;
const itemH = 52;
const gap = 10;
const pw = 380;
const ph = 96 + actions.length * (itemH + gap) + 56;
const px = cx - pw / 2;
const py = cy - ph / 2;
const root = this.add.container(0, 0).setDepth(D.modal);
const dim = this.add.rectangle(cx, cy, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55).setInteractive();
const panel = this.add.rectangle(cx, py + ph / 2, pw, ph, COLORS.panel).setStrokeStyle(2, COLORS.accent);
const title = this.add.text(cx, py + 34, def.name, {
fontFamily: 'Righteous', fontSize: '24px', color: COLORS.accentHex,
}).setOrigin(0.5);
root.add([dim, panel, title]);
// This menu opens from a pointerdown on the unit's sprite, so the mouse
// button is typically still held when these buttons appear. Selected
// units are frequently screen-centered (selectNextUnit centers the view
// on them), same as this menu, so the imminent mouse-up can land right
// on a button and fire it before the player ever sees the menu. Ignore
// clicks until that one release has passed.
let armed = !this.input.activePointer.isDown;
const arm = () => { armed = true; };
if (!armed) this.input.once('pointerup', arm);
const close = () => {
this.input.off('pointerup', arm);
root.destroy(true);
this.modalOpen = false;
};
actions.forEach((action, i) => {
const by = py + 76 + i * (itemH + gap);
const btn = new Button(this, cx, by + itemH / 2, action.label, () => {
if (!armed) return;
close();
action.onSelect();
}, { width: pw - 48, height: itemH, fontSize: 18 });
root.add(btn);
});
const cancelY = py + 76 + actions.length * (itemH + gap) + 8;
const cancel = new Button(this, cx, cancelY + 20, 'CANCEL', () => {
if (!armed) return;
close();
}, { width: pw - 48, height: 44, fontSize: 16, variant: 'ghost' });
root.add(cancel);
}
afterAction() { afterAction() {
// Repaint tiles that may have changed (work orders complete on turn start, // Repaint tiles that may have changed (work orders complete on turn start,
// but roads from engineers etc. show up next refresh — cheap full check // but roads from engineers etc. show up next refresh — cheap full check

View File

@ -499,7 +499,12 @@ export class CivilizationMapView {
const y = this.isoY(city.x, city.y); const y = this.isoY(city.x, city.y);
const civ = this.state.civs[city.civ]; const civ = this.state.civs[city.civ];
const color = Phaser.Display.Color.HexStringToColor(civ.color).color; const color = Phaser.Display.Color.HexStringToColor(civ.color).color;
const container = scene.add.container(x, y).setDepth(y); // Depth uses the same isoY + TILE_H/2 "ground" base as units/ghosts so
// cities and units sort correctly against each other; the container's
// actual (x, y) position stays at the bare isoY so the city art doesn't
// shift on screen.
const depthY = y + TILE_H / 2;
const container = scene.add.container(x, y).setDepth(depthY);
const walled = !!city.buildings.citywalls; const walled = !!city.buildings.citywalls;
const themeKey = 'civilization-cities-classic'; const themeKey = 'civilization-cities-classic';
@ -538,7 +543,7 @@ export class CivilizationMapView {
// instead of staying centered under the label — use setSize instead. // instead of staying centered under the label — use setSize instead.
banner.setSize(label.width + 16, 22); banner.setSize(label.width + 16, 22);
container.add([banner, label]); container.add([banner, label]);
container.setDepth(y + 1); container.setDepth(depthY + 1);
banner.setInteractive({ useHandCursor: true }); banner.setInteractive({ useHandCursor: true });
banner.on('pointerdown', (pointer, lx, ly, event) => { banner.on('pointerdown', (pointer, lx, ly, event) => {
event.stopPropagation(); event.stopPropagation();
@ -556,13 +561,14 @@ export class CivilizationMapView {
const y = this.isoY(c, r) + TILE_H / 2; const y = this.isoY(c, r) + TILE_H / 2;
const container = scene.add.container(x, y); const container = scene.add.container(x, y);
// Sprite mode: the 64x96 unit frame carries the same 32px headroom as // Sprite mode: lift the unit frame so its bottom edge lands at the tile's
// terrain, so its bottom 64px (the diamond zone) sits over the tile and // vertical middle (roughly the middle of the colored civ ring) rather
// the top 32px rises into the tile behind it, like a tree or peak does. // than the diamond's lower point, so units read as standing on the tile
// instead of at its front edge.
const spriteMode = scene.textures.exists('civilization-units'); const spriteMode = scene.textures.exists('civilization-units');
if (spriteMode) { if (spriteMode) {
const ring = scene.add.circle(0, 0, 22, color, 0.5).setStrokeStyle(2, color, 1); const ring = scene.add.circle(0, 0, 22, color, 0.5).setStrokeStyle(2, color, 1);
const img = scene.add.image(0, TILE_H / 2 - UNIT_FRAME_H / 2, 'civilization-units', def.frame); const img = scene.add.image(0, -UNIT_FRAME_H / 2, 'civilization-units', def.frame);
container.add([ring, img]); container.add([ring, img]);
} else { } else {
const g = scene.add.graphics(); const g = scene.add.graphics();
@ -580,7 +586,7 @@ export class CivilizationMapView {
} }
// Badges ride at shoulder height: low against the flat procedural // Badges ride at shoulder height: low against the flat procedural
// roundel, higher up against the taller sprite frame. // roundel, higher up against the taller sprite frame.
const badgeY = spriteMode ? -28 : -14; const badgeY = spriteMode ? -60 : -14;
if (unit.vet) { if (unit.vet) {
container.add(scene.add.circle(12, badgeY, 4, 0xd4a017).setStrokeStyle(1, 0x000000, 0.6)); container.add(scene.add.circle(12, badgeY, 4, 0xd4a017).setStrokeStyle(1, 0x000000, 0.6));
} }
@ -591,6 +597,17 @@ export class CivilizationMapView {
}).setOrigin(0.5); }).setOrigin(0.5);
container.add([badge, num]); container.add([badge, num]);
} }
// Only the currently-selected unit is clickable — clicking any other
// unit's tile still goes through the normal tile-click select/move flow
// in CivilizationGame (see bindPointer/onTileClick).
if (unit.id === this.selectedUnitId) {
container.setSize(64, 64);
container.setInteractive({ useHandCursor: true });
container.on('pointerdown', (pointer, lx, ly, event) => {
event.stopPropagation();
this.cb.onUnitClick?.(unit);
});
}
container.setDepth(y + 2); container.setDepth(y + 2);
this.dynamic.add(container); this.dynamic.add(container);
this.unitContainers.set(unit.id, container); this.unitContainers.set(unit.id, container);
@ -605,7 +622,10 @@ export class CivilizationMapView {
const x = this.isoX(unit.x, unit.y); const x = this.isoX(unit.x, unit.y);
const y = this.isoY(unit.x, unit.y) + TILE_H / 2; const y = this.isoY(unit.x, unit.y) + TILE_H / 2;
const ring = this.scene.add.circle(x, y - 2, 24).setStrokeStyle(3, 0xffffff, 1); const ring = this.scene.add.circle(x, y - 2, 24).setStrokeStyle(3, 0xffffff, 1);
ring.setDepth(y + 3); // Depth below the unit's own y + 2 (see drawUnit) so the unit sprite
// renders in front of its own pulsing selection ring instead of the ring
// sitting on top of it.
ring.setDepth(y + 1);
this.dynamic.add(ring); this.dynamic.add(ring);
this.selectionRing = ring; this.selectionRing = ring;
this.scene.tweens.add({ this.scene.tweens.add({
@ -628,7 +648,7 @@ export class CivilizationMapView {
const place = (p) => { const place = (p) => {
container.setPosition(p.x, p.y); container.setPosition(p.x, p.y);
container.setDepth(p.y + 2); container.setDepth(p.y + 2);
if (ring) { ring.setPosition(p.x, p.y - 2); ring.setDepth(p.y + 3); } if (ring) { ring.setPosition(p.x, p.y - 2); ring.setDepth(p.y + 1); }
}; };
place(points[0]); place(points[0]);
const segDuration = duration / (points.length - 1); const segDuration = duration / (points.length - 1);
@ -703,7 +723,7 @@ export class CivilizationMapView {
let roundel = null; let roundel = null;
if (spriteMode) { if (spriteMode) {
const ring = scene.add.circle(0, 0, 22, color, 0.5).setStrokeStyle(2, color, 1); const ring = scene.add.circle(0, 0, 22, color, 0.5).setStrokeStyle(2, color, 1);
img = scene.add.image(0, TILE_H / 2 - UNIT_FRAME_H / 2, 'civilization-units', def.frame); img = scene.add.image(0, -UNIT_FRAME_H / 2, 'civilization-units', def.frame);
container.add([ring, img]); container.add([ring, img]);
} else { } else {
roundel = scene.add.graphics(); roundel = scene.add.graphics();