diff --git a/assets/images/civilization/civilization-terrain.png b/assets/images/civilization/civilization-terrain.png new file mode 100644 index 0000000..5b19be1 Binary files /dev/null and b/assets/images/civilization/civilization-terrain.png differ diff --git a/assets/images/civilization/civilization-terrain.psd b/assets/images/civilization/civilization-terrain.psd new file mode 100644 index 0000000..ef75782 Binary files /dev/null and b/assets/images/civilization/civilization-terrain.psd differ diff --git a/src/games/civilization/CivilizationCityScreen.js b/src/games/civilization/CivilizationCityScreen.js index 437362a..3878505 100644 --- a/src/games/civilization/CivilizationCityScreen.js +++ b/src/games/civilization/CivilizationCityScreen.js @@ -136,7 +136,7 @@ export function openCityScreen(scene, rules, state, city, onClose) { const choices = [...unitChoices, ...bldChoices]; const colW = 330; const rowH = 36; - const perCol = 17; + 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; @@ -156,6 +156,35 @@ export function openCityScreen(scene, rules, state, city, onClose) { dynamic.add(rect); dynamic.add(txt); }); + + // Garrison — units fortified here are hidden on the map (see + // CivilizationMapView); clicking one un-fortifies it and closes the city. + 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)', { + fontFamily: FONT, fontSize: '16px', color: COLORS.mutedHex, + })); + const boxW = 150; + const boxH = 36; + const gap = 10; + const perRow = 4; + garrison.forEach((u, i) => { + const col = i % perRow; + const row = (i / perRow) | 0; + const bx = rx + 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); + const label = scene.add.text(bx, by, rules.units[u.type].name, { + fontFamily: FONT, fontSize: '14px', color: COLORS.textHex, + }).setOrigin(0.5); + rect.setInteractive({ useHandCursor: true }); + rect.on('pointerdown', () => { u.fortified = false; close(); }); + dynamic.add(rect); + dynamic.add(label); + }); + } } redraw(); diff --git a/src/games/civilization/CivilizationGame.js b/src/games/civilization/CivilizationGame.js index 4e08897..4ce4732 100644 --- a/src/games/civilization/CivilizationGame.js +++ b/src/games/civilization/CivilizationGame.js @@ -389,9 +389,21 @@ export default class CivilizationGame extends Phaser.Scene { onTileClick(c, r) { const state = this.state; const human = state.humanIndex; - const myUnits = Logic.unitsAt(state, c, r).filter((u) => u.civ === human); + const city = Logic.cityAt(state, c, r); + // Fortified units in a city are hidden on the map (see CivilizationMapView) — + // clicking the tile should open the city, not select an invisible unit. + const myUnits = Logic.unitsAt(state, c, r) + .filter((u) => u.civ === human && !(city && u.fortified)); const sel = this.selectedUnit(); + // A movable selected unit clicking a different owned city is ambiguous — + // ask whether to move it in or just inspect the city. + if (city && city.civ === human && sel && sel.mp > 0 && state.current === human + && (sel.x !== c || sel.y !== r)) { + this.confirmMoveOrInspectCity(sel, city, c, r); + return; + } + if (myUnits.length && (!sel || sel.x !== c || sel.y !== r)) { this.selectUnit(myUnits[0]); return; @@ -402,22 +414,32 @@ export default class CivilizationGame extends Phaser.Scene { this.selectUnit(myUnits[(i + 1) % myUnits.length]); return; } - const city = Logic.cityAt(state, c, r); if (city && city.civ === human && !myUnits.length) { this.onCityClick(city); return; } // Move the selected unit toward the clicked tile. - if (sel && state.current === human) { - if (Logic.cheb(sel.x, sel.y, c, r) === 1) { - this.tryStep(sel, c - sel.x, r - sel.y); - return; - } - const path = Logic.findPath(this.rules, state, sel, c, r); - if (!path) { this.toast('No route there'); return; } - this.view.showPath(path); - this.walkPath(sel, path); + if (sel) this.moveTo(sel, c, r); + } + + moveTo(unit, c, r) { + if (this.state.current !== this.state.humanIndex) return; + if (Logic.cheb(unit.x, unit.y, c, r) === 1) { + this.tryStep(unit, c - unit.x, r - unit.y); + return; } + const path = Logic.findPath(this.rules, this.state, unit, c, r); + if (!path) { this.toast('No route there'); return; } + this.view.showPath(path); + this.walkPath(unit, path); + } + + confirmMoveOrInspectCity(unit, city, c, r) { + const name = this.rules.units[unit.type].name; + this.confirmDialog(`Move ${name} into ${city.name}, or inspect the city?`, + () => this.moveTo(unit, c, r), + () => this.onCityClick(city), + 'MOVE', 'INSPECT'); } onCityClick(city) { @@ -736,7 +758,7 @@ export default class CivilizationGame extends Phaser.Scene { this.confirmDialog(`Attack ${name}? This means WAR!`, onYes, () => {}); } - confirmDialog(message, onYes, onNo) { + confirmDialog(message, onYes, onNo, yesLabel = 'YES', noLabel = 'NO') { this.modalOpen = true; const root = this.add.container(0, 0).setDepth(D.modal); const dim = this.add.rectangle(GAME_WIDTH / 2, GAME_HEIGHT / 2, GAME_WIDTH, GAME_HEIGHT, 0x000000, 0.55) @@ -748,9 +770,9 @@ export default class CivilizationGame extends Phaser.Scene { wordWrap: { width: 580 }, align: 'center', }).setOrigin(0.5); const close = (fn) => () => { root.destroy(true); this.modalOpen = false; fn?.(); }; - const yes = new Button(this, GAME_WIDTH / 2 - 110, GAME_HEIGHT / 2 + 50, 'YES', close(onYes), + const yes = new Button(this, GAME_WIDTH / 2 - 110, GAME_HEIGHT / 2 + 50, yesLabel, close(onYes), { width: 180, height: 52 }); - const no = new Button(this, GAME_WIDTH / 2 + 110, GAME_HEIGHT / 2 + 50, 'NO', close(onNo), + const no = new Button(this, GAME_WIDTH / 2 + 110, GAME_HEIGHT / 2 + 50, noLabel, close(onNo), { width: 180, height: 52, variant: 'ghost' }); root.add([dim, panel, txt, yes, no]); } diff --git a/src/games/civilization/CivilizationMapView.js b/src/games/civilization/CivilizationMapView.js index 26616e3..aef8d83 100644 --- a/src/games/civilization/CivilizationMapView.js +++ b/src/games/civilization/CivilizationMapView.js @@ -444,17 +444,22 @@ export class CivilizationMapView { const visible = computeVisible(state, povIdx); const explored = this.humanIdx >= 0 ? state.explored[povIdx] : null; + const cityTiles = new Set(); for (const city of state.cities) { const idx = tileIndex(state.world, city.x, city.y); + cityTiles.add(idx); if (explored && !explored[idx]) continue; this.drawCity(city); } // One marker per occupied tile (top defender), with a stack badge. + // Own units fortified inside a city are tucked away instead of drawn — + // they no longer overlap the city graphic; see them via onCityClick. const byTile = new Map(); for (const u of state.units) { if (u.carriedBy) continue; const idx = tileIndex(state.world, u.x, u.y); + if (u.fortified && u.civ === povIdx && cityTiles.has(idx)) continue; if (u.civ !== povIdx) { if (!visible.has(idx)) continue; if (!isUnitVisibleTo(rules, state, u, povIdx)) continue;