feat: add bulk disembark for transport units

- Add disembark action to transport unit action menu when carrying units
- Highlight valid landing tiles adjacent to boats (clear of enemies)
- Unload all passengers onto a single chosen tile at once
- Implement targeting mode with cancel support (Escape or clicking away)
- Extract unitsOnBoat and disembarkTiles logic functions
- Add green diamond tile highlights in view layer
This commit is contained in:
Brian Fertig 2026-07-16 23:22:55 -06:00
parent a91db38a0f
commit bace0356c2
3 changed files with 102 additions and 2 deletions

View File

@ -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();

View File

@ -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);

View File

@ -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