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 {
|
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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue