diff --git a/assets/images/civilization/background.png b/assets/images/civilization/background.png new file mode 100644 index 0000000..30651d3 Binary files /dev/null and b/assets/images/civilization/background.png differ diff --git a/src/games/civilization/CivilizationGame.js b/src/games/civilization/CivilizationGame.js index bf8e9ec..4b5bd06 100644 --- a/src/games/civilization/CivilizationGame.js +++ b/src/games/civilization/CivilizationGame.js @@ -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 diff --git a/src/games/civilization/CivilizationMapView.js b/src/games/civilization/CivilizationMapView.js index bcb3c84..de533b8 100644 --- a/src/games/civilization/CivilizationMapView.js +++ b/src/games/civilization/CivilizationMapView.js @@ -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();