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:
parent
4d864503f2
commit
543f751fe2
Binary file not shown.
|
After Width: | Height: | Size: 3.6 MiB |
|
|
@ -26,12 +26,29 @@ const D = { hud: 30, modal: 60, toast: 80 };
|
|||
export default class CivilizationGame extends Phaser.Scene {
|
||||
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) {
|
||||
this.gameDef = data.game ?? { slug: 'civilization', name: 'Civilization' };
|
||||
this.state = null;
|
||||
this.view = null;
|
||||
this.phase = 'setup';
|
||||
this.modalOpen = false;
|
||||
this.clickGuard = false;
|
||||
this.busy = false;
|
||||
this.endTurnFlashTween = null;
|
||||
}
|
||||
|
|
@ -143,7 +160,7 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
let oy = gy + rowsUsed * (cell + 18) + 40;
|
||||
|
||||
// --- 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) => {
|
||||
root.add(this.add.text(cx - 560, oy, label, {
|
||||
fontFamily: FONT, fontSize: '22px', color: COLORS.textHex,
|
||||
|
|
@ -252,6 +269,7 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
this.setupRoot = null;
|
||||
this.view = new CivilizationMapView(this, this.rules, this.state, {
|
||||
onCityClick: (city) => this.onCityClick(city),
|
||||
onUnitClick: (unit) => this.onUnitClick(unit),
|
||||
});
|
||||
this.view.buildMinimap(16, GAME_HEIGHT - 260, 300);
|
||||
this.buildHud();
|
||||
|
|
@ -374,6 +392,7 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
const wasDrag = dragged;
|
||||
dragStart = null;
|
||||
dragged = false;
|
||||
if (this.clickGuard) { this.clickGuard = false; return; }
|
||||
if (wasDrag || this.modalOpen || this.phase !== 'playing' || this.busy) return;
|
||||
if (pointer.y < 56 || pointer.y > GAME_HEIGHT - 44) return; // HUD bands
|
||||
// Clicks that landed on any interactive object (buttons, minimap, city
|
||||
|
|
@ -455,6 +474,15 @@ export default class CivilizationGame extends Phaser.Scene {
|
|||
}
|
||||
|
||||
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;
|
||||
// Edge-of-keyboard panning.
|
||||
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.C)) this.tryStep(sel, 1, 1);
|
||||
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.R)) this.tryWork(sel, this.canRail(sel) ? 'railroad' : 'road');
|
||||
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() {
|
||||
// Repaint tiles that may have changed (work orders complete on turn start,
|
||||
// but roads from engineers etc. show up next refresh — cheap full check
|
||||
|
|
|
|||
|
|
@ -499,7 +499,12 @@ export class CivilizationMapView {
|
|||
const y = this.isoY(city.x, city.y);
|
||||
const civ = this.state.civs[city.civ];
|
||||
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 themeKey = 'civilization-cities-classic';
|
||||
|
|
@ -538,7 +543,7 @@ export class CivilizationMapView {
|
|||
// instead of staying centered under the label — use setSize instead.
|
||||
banner.setSize(label.width + 16, 22);
|
||||
container.add([banner, label]);
|
||||
container.setDepth(y + 1);
|
||||
container.setDepth(depthY + 1);
|
||||
banner.setInteractive({ useHandCursor: true });
|
||||
banner.on('pointerdown', (pointer, lx, ly, event) => {
|
||||
event.stopPropagation();
|
||||
|
|
@ -556,13 +561,14 @@ export class CivilizationMapView {
|
|||
const y = this.isoY(c, r) + TILE_H / 2;
|
||||
const container = scene.add.container(x, y);
|
||||
|
||||
// Sprite mode: the 64x96 unit frame carries the same 32px headroom as
|
||||
// terrain, so its bottom 64px (the diamond zone) sits over the tile and
|
||||
// the top 32px rises into the tile behind it, like a tree or peak does.
|
||||
// Sprite mode: lift the unit frame so its bottom edge lands at the tile's
|
||||
// vertical middle (roughly the middle of the colored civ ring) rather
|
||||
// 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');
|
||||
if (spriteMode) {
|
||||
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]);
|
||||
} else {
|
||||
const g = scene.add.graphics();
|
||||
|
|
@ -580,7 +586,7 @@ export class CivilizationMapView {
|
|||
}
|
||||
// Badges ride at shoulder height: low against the flat procedural
|
||||
// roundel, higher up against the taller sprite frame.
|
||||
const badgeY = spriteMode ? -28 : -14;
|
||||
const badgeY = spriteMode ? -60 : -14;
|
||||
if (unit.vet) {
|
||||
container.add(scene.add.circle(12, badgeY, 4, 0xd4a017).setStrokeStyle(1, 0x000000, 0.6));
|
||||
}
|
||||
|
|
@ -591,6 +597,17 @@ export class CivilizationMapView {
|
|||
}).setOrigin(0.5);
|
||||
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);
|
||||
this.dynamic.add(container);
|
||||
this.unitContainers.set(unit.id, container);
|
||||
|
|
@ -605,7 +622,10 @@ export class CivilizationMapView {
|
|||
const x = this.isoX(unit.x, unit.y);
|
||||
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);
|
||||
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.selectionRing = ring;
|
||||
this.scene.tweens.add({
|
||||
|
|
@ -628,7 +648,7 @@ export class CivilizationMapView {
|
|||
const place = (p) => {
|
||||
container.setPosition(p.x, p.y);
|
||||
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]);
|
||||
const segDuration = duration / (points.length - 1);
|
||||
|
|
@ -703,7 +723,7 @@ export class CivilizationMapView {
|
|||
let roundel = null;
|
||||
if (spriteMode) {
|
||||
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]);
|
||||
} else {
|
||||
roundel = scene.add.graphics();
|
||||
|
|
|
|||
Loading…
Reference in New Issue