diff --git a/src/games/civilization/CivilizationGame.js b/src/games/civilization/CivilizationGame.js index 3e3196e..3449dba 100644 --- a/src/games/civilization/CivilizationGame.js +++ b/src/games/civilization/CivilizationGame.js @@ -70,6 +70,7 @@ export default class CivilizationGame extends Phaser.Scene { this.logScrollY = 0; this.statusQueue = []; this.pendingCityAttacks = []; + this.disembarkPending = null; } create() { @@ -104,6 +105,7 @@ export default class CivilizationGame extends Phaser.Scene { } onEscape() { + if (this.disembarkPending) { this.cancelDisembark(); return; } if (this.modalOpen) return; // modals close themselves if (this.phase === 'playing') this.openMenu(); else this.scene.start('GameMenu'); @@ -671,6 +673,7 @@ export default class CivilizationGame extends Phaser.Scene { } onTileClick(c, r) { + if (this.disembarkPending) { this.resolveDisembarkClick(c, r); return; } const state = this.state; const human = state.humanIndex; const city = Logic.cityAt(state, c, r); @@ -807,6 +810,7 @@ export default class CivilizationGame extends Phaser.Scene { } selectUnit(unit) { + this.cancelDisembark(); this.selectedUnitId = unit?.id ?? null; this.view.selectedUnitId = this.selectedUnitId; this.view.showPath(null); @@ -1030,6 +1034,7 @@ export default class CivilizationGame extends Phaser.Scene { onUnitClick(unit) { if (this.modalOpen || this.busy || this.state.current !== this.state.humanIndex) return; + this.cancelDisembark(); this.openUnitActionMenu(unit); } @@ -1064,10 +1069,52 @@ export default class CivilizationGame extends Phaser.Scene { if (Logic.canEstablishRoute(rules, state, unit)) { actions.push({ label: 'Establish Trade Route', onSelect: () => this.tryCaravan(unit) }); } + if (Logic.unitsOnBoat(state, unit).length) { + const tiles = Logic.disembarkTiles(rules, state, unit); + if (tiles.length) { + actions.push({ label: 'Disembark', onSelect: () => this.beginDisembark(unit, tiles) }); + } + } actions.push({ label: 'Skip Turn', onSelect: () => { unit.mp = 0; this.selectNextUnit(); } }); return actions; } + // "Disembark" arms target-picking mode: the next tile click either unloads + // the whole hold there (if it's one of the highlighted candidate tiles) or + // just cancels, same as clicking away from any other targeting cursor. + beginDisembark(boat, tiles) { + this.disembarkPending = { boatId: boat.id, tiles }; + this.view.showDisembarkTiles(tiles); + } + + cancelDisembark() { + if (!this.disembarkPending) return; + this.disembarkPending = null; + this.view?.showDisembarkTiles(null); + } + + resolveDisembarkClick(c, r) { + const { boatId, tiles } = this.disembarkPending; + this.cancelDisembark(); + const match = tiles.find((t) => t.x === c && t.y === r); + if (!match) return; + const boat = Logic.unitById(this.state, boatId); + if (!boat) return; + this.tryDisembarkAll(boat, match.dx, match.dy); + } + + // disembarkTiles() only ever offers tiles clear of foreign cities/units, so + // every passenger's individual disembark() below is guaranteed to succeed — + // no war-confirmation branch needed here (contrast tryStep's single-unit + // path, which can walk a unit into a tile that does need one). + tryDisembarkAll(boat, dx, dy) { + if (this.state.current !== this.state.humanIndex) return; + for (const u of Logic.unitsOnBoat(this.state, boat)) { + Logic.disembark(this.rules, this.state, u, dx, dy); + } + this.afterAction(); + } + openUnitActionMenu(unit) { const def = this.rules.units[unit.type]; this.openActionListMenu(def.name, this.getUnitActions(unit)); @@ -1191,6 +1238,7 @@ export default class CivilizationGame extends Phaser.Scene { onEndTurn() { if (this.busy || this.modalOpen || this.phase !== 'playing') return; if (this.state.current !== this.state.humanIndex) return; + this.cancelDisembark(); Logic.endCivTurn(this.rules, this.state, this.state.humanIndex); this.saveGame(); this.runToHumanTurn(); diff --git a/src/games/civilization/CivilizationLogic.js b/src/games/civilization/CivilizationLogic.js index e9d48d3..50135ad 100644 --- a/src/games/civilization/CivilizationLogic.js +++ b/src/games/civilization/CivilizationLogic.js @@ -810,8 +810,12 @@ function spendMove(unit, cost) { unit.moved = true; } +export function unitsOnBoat(state, boat) { + return state.units.filter((u) => u.carriedBy === boat.id); +} + function cargoCount(state, boat) { - return state.units.filter((u) => u.carriedBy === boat.id).length; + return unitsOnBoat(state, boat).length; } function dropCarried(rules, state, unit, x, y) { @@ -836,6 +840,29 @@ export function disembark(rules, state, unit, dx, dy) { return out; } +const ADJACENT_DIRS = [[1, 0], [-1, 0], [0, 1], [0, -1], [1, 1], [1, -1], [-1, 1], [-1, -1]]; + +// Land tiles next to `boat` that are clear of any foreign city/units — used +// to offer the human player a "Disembark" action (see CivilizationGame.js) +// that unloads the whole hold onto a single chosen tile at once. Kept clear +// of enemies so it never surprises the player with a fight; attacking off a +// transport is still possible unit-by-unit via the AI's own logic, just not +// through this bulk convenience command. +export function disembarkTiles(rules, state, boat) { + const tiles = []; + for (const [dx, dy] of ADJACENT_DIRS) { + const x = boat.x + dx; + const y = boat.y + dy; + if (!inBounds(state.world, x, y)) continue; + if (terrainAt(rules, state.world, x, y).water) continue; + const city = cityAt(state, x, y); + if (city && city.civ !== boat.civ) continue; + if (unitsAt(state, x, y).some((u) => u.civ !== boat.civ)) continue; + tiles.push({ x, y, dx, dy }); + } + return tiles; +} + function resolveHut(rules, state, unit) { const civ = state.civs[unit.civ]; const roll = rand(state); diff --git a/src/games/civilization/CivilizationMapView.js b/src/games/civilization/CivilizationMapView.js index af7b1ed..0206679 100644 --- a/src/games/civilization/CivilizationMapView.js +++ b/src/games/civilization/CivilizationMapView.js @@ -74,10 +74,11 @@ export class CivilizationMapView { } } this.pathGfx = scene.add.graphics(); + this.disembarkGfx = scene.add.graphics(); this.dynamic = scene.add.container(0, 0); this.root.add([ ...this.terrainChunks.map((c) => c.rt), - this.pathGfx, this.dynamic, + this.pathGfx, this.disembarkGfx, this.dynamic, ...this.fogChunks.map((c) => c.rt), ]); @@ -968,6 +969,30 @@ export class CivilizationMapView { } } + // Highlights candidate landing tiles while a "Disembark" action is armed + // (see CivilizationGame.js) — pass null/empty to clear. Diamond shape + // mirrors paintProceduralTile's tile stamp so the outline sits flush with + // the tile underneath it. + showDisembarkTiles(tiles) { + this.disembarkGfx.clear(); + if (!tiles || !tiles.length) return; + const cy = TILE_H / 2; + this.disembarkGfx.lineStyle(3, 0x4a9e44, 0.95); + this.disembarkGfx.fillStyle(0x4a9e44, 0.22); + for (const { x: c, y: r } of tiles) { + const x = this.isoX(c, r); + const y = this.isoY(c, r); + this.disembarkGfx.beginPath(); + this.disembarkGfx.moveTo(x, y + cy - TILE_H / 2); + this.disembarkGfx.lineTo(x + TILE_W / 2, y + cy); + this.disembarkGfx.lineTo(x, y + cy + TILE_H / 2); + this.disembarkGfx.lineTo(x - TILE_W / 2, y + cy); + this.disembarkGfx.closePath(); + this.disembarkGfx.fillPath(); + this.disembarkGfx.strokePath(); + } + } + // ------------------------------------------------------------------------- // Minimap