Files
Battle-for-Tismo/shared/game_state/air.js
T
adrien 1a9d48cdc9 Added aircraft sorties, artillery bombardment and local testing mode
- aircraft attack orders fly a sortie to the target, bomb once, return to a
  friendly airport and re-arm (two-day cooldown, charged per aircraft); rebasing
  is bounded by fuel rather than the 10-tile combat radius
- artillery and warships open a sustained hourly bombardment on a standing
  target, drawn as a red dashed line and ring while either endpoint is selected
- a game launched from localhost can enable a testing mode whose train and build
  rows offer free, instant orders; the server only honours them for a loopback
  admin in a testing game
- an open city panel now unlocks aircraft as soon as an airport finishes
2026-09-18 21:55:30 +02:00

330 lines
12 KiB
JavaScript

// Air power and the placement rules shared by coastal and gated buildings:
// where a port may be raised, which cities may train aircraft or warships, and
// the endurance model that keeps aircraft tied to a friendly airport.
import { key } from "../hex.js";
import { AIR_STRIKE, BOMBARD } from "../data/combat.js";
export const airMethods = {
// ----------------------------------------------------- building rules --
buildingIndexById(id) {
return this.protoBuildings.findIndex((building) => building.id === id);
},
// Whether the city has raised at least one level of the building with `id`.
hasCityBuilding(city, id) {
const index = this.buildingIndexById(id);
return index >= 0 && this.getCityBuildingLevel(city, index) > 0;
},
// A tile is coastal when at least one of its neighbours is sea.
isCoastalCoords(coords) {
for (const neighbour of this._neighbours(coords)) {
const tile = this.tiles[key(neighbour.x, neighbour.y)];
if (tile && tile.terrainClass === "Sea") return true;
}
return false;
},
isCoastalCity(city) {
return this.isCoastalCoords(city.coords);
},
// -------------------------------------------------------------- air --
_isAirProto(proto) {
return !!(proto && proto.air);
},
_isAirUnit(unit) {
return this._isAirProto(this.unitProto(unit));
},
// A unit that attacks from range instead of charging into melee: every
// aircraft and anything with a reach beyond a single tile. A melee unit has a
// range of one and stays on the ground.
_isStrikeUnit(unit) {
const proto = this.unitProto(unit);
return !!(proto && (proto.strike || proto.air || (proto.range || 1) > 1));
},
// The friendly airport city on `coords`, or null. An aircraft parked here
// refuels and stops burning endurance.
_friendlyAirportAt(coords, civ) {
const city = this.cityAt(coords);
if (!city || city.civ !== civ) return null;
return this.hasCityBuilding(city, "airport") ? city : null;
},
// The airport an aircraft flies home to: its own base when still friendly,
// otherwise the nearest friendly airport anywhere.
_nearestFriendlyAirport(unit) {
const home = this.findCity(unit.homeCityId);
if (home && home.civ === unit.civ && this.hasCityBuilding(home, "airport")) return home;
let best = null;
let bestDistance = Infinity;
for (const city of this.cities) {
if (city.civ !== unit.civ || !this.hasCityBuilding(city, "airport")) continue;
const distance = this.topology.tileDistance(unit.coords, city.coords);
if (distance < bestDistance) {
bestDistance = distance;
best = city;
}
}
return best;
},
_pathHours(unit, path) {
let total = 0;
for (let i = 1; i < path.length; i++) total += this._tileTravelHours(unit, path[i]);
return total;
},
// Routes an aircraft back to a friendly airport, unless the trip would run
// past its remaining endurance, in which case it stays put and will crash.
_orderAirReturn(unit) {
const airport = this._nearestFriendlyAirport(unit);
if (!airport) return false;
const path = this.findPath(unit, unit.coords, airport.coords);
if (path.length < 2) return false;
const proto = this.unitProto(unit);
const fuel = unit.airHours === undefined ? proto.enduranceHours : unit.airHours;
if (this._pathHours(unit, path) > fuel + 1e-9) return false;
unit.path = path;
unit.pathIndex = 0;
unit.progressHours = 0;
unit.moveSpeed = null;
unit.waypoints = [];
unit.autoReturn = true;
return true;
},
// Runs once per in-game hour: burns one hour of endurance for every airborne
// aircraft, lands and refuels the ones sitting on a friendly airport, crashes
// the ones that ran dry, and sends the idle ones home. Aircraft whose fuel is
// about to run out turn for home even mid-route, so only losing every airport
// can strand a plane.
_tickAirUnits() {
const crashed = [];
for (const unit of this.units) {
const proto = this.unitProto(unit);
if (!this._isAirProto(proto)) continue;
if (this._friendlyAirportAt(unit.coords, unit.civ)) {
// A plane that has just flown a strike mission re-arms on the ground:
// its two-day cooldown begins once it is back on the runway.
if (unit.airborne && unit.missionFlown) {
unit.strikeReadyHour = this.totalHours + (AIR_STRIKE.cooldownHours || 0);
unit.missionFlown = false;
}
unit.airHours = proto.enduranceHours;
unit.airborne = false;
unit.autoReturn = false;
continue;
}
if (unit.airHours === undefined || unit.airHours === null) {
unit.airHours = proto.enduranceHours;
}
unit.airborne = true;
unit.airHours -= 1;
if (unit.airHours <= 0) crashed.push(unit);
}
for (const unit of crashed) {
this._recordMilitaryCasualties([unit]);
this._destroyUnit(unit);
}
if (crashed.length > 0) this._visibilityDirty = true;
for (const unit of this.units) {
const proto = this.unitProto(unit);
if (!this._isAirProto(proto) || !unit.airborne || unit.autoReturn) continue;
if (this._hasRoute(unit)) {
// Turn for home before the tanks run dry.
const airport = this._nearestFriendlyAirport(unit);
if (!airport) continue;
const home = this.findPath(unit, unit.coords, airport.coords);
if (home.length < 2) continue;
const fuel = unit.airHours === undefined ? proto.enduranceHours : unit.airHours;
if (this._pathHours(unit, home) >= fuel - 1e-9) this._orderAirReturn(unit);
continue;
}
this._orderAirReturn(unit);
}
},
// ----------------------------------------------------------- strike --
_strikeTargetAt(coords, civ) {
const enemies = this.unitsAt(coords).filter(
(unit) => unit.civ !== civ && this._isMilitary(unit)
);
if (enemies.length === 0) return null;
enemies.sort((a, b) => (a.hp - b.hp) || (a.id - b.id));
return enemies[0];
},
// How far a unit can reach: an aircraft's mission radius, a ground battery's
// weapon range.
_strikeRange(proto) {
if (!proto) return 1;
return this._isAirProto(proto)
? (proto.missionRange || proto.range || 1)
: (proto.range || 1);
},
// Whether a unit may strike `goal` right now: inside its reach and, for an
// aircraft, parked/armed with fuel in the tanks and not still rearming or
// already flying a mission.
_strikeReady(unit, goal) {
const proto = this.unitProto(unit);
if (!proto) return false;
const distance = this.topology.tileDistance(unit.coords, goal);
if (distance > this._strikeRange(proto)) return false;
if (!this._isAirProto(proto)) return true;
if (unit.strikeTarget) return false;
if (this.totalHours < (unit.strikeReadyHour || 0)) return false;
const fuel = unit.airHours === undefined ? proto.enduranceHours : unit.airHours;
return fuel > 0;
},
// A ground or naval battery opens a sustained bombardment of `goal`: it never
// moves and fires one small volley every in-game hour (`_tickStrikes`) until
// the target dies, moves out of reach, or the battery is ordered elsewhere.
// Declares war on the target if the two were at peace.
requestBombard(unitIds, goal) {
const units = this._unitsForOrder(unitIds).filter(
(unit) => this._isStrikeUnit(unit) && !this._isAirUnit(unit)
);
if (units.length === 0) return false;
const civ = units[0].civ;
const target = this._strikeTargetAt(goal, civ);
if (!target) return false;
if (units.some((unit) => unit.civ !== civ || !this._strikeReady(unit, goal))) return false;
this._declareWar(civ, target.civ);
for (const unit of units) {
this._stopUnit(unit);
unit.strikeTarget = { x: goal.x, y: goal.y };
}
this._emitChanged();
return true;
},
// An aircraft sortie: the plane flies to `goal` and bombs the hostile unit
// there on arrival, then heads home. The attack is charged up front, per
// aircraft, and the cooldown starts when the plane lands again.
requestAirStrike(unitIds, goal) {
const units = this._unitsForOrder(unitIds).filter((unit) => this._isAirUnit(unit));
if (units.length === 0) return false;
const civ = units[0].civ;
const target = this._strikeTargetAt(goal, civ);
if (!target) return false;
if (units.some((unit) => unit.civ !== civ || !this._strikeReady(unit, goal))) return false;
// Plan every flight before charging, so a rejected route costs nothing.
const paths = [];
for (const unit of units) {
const path = this.findPath(unit, unit.coords, goal);
if (path.length < 2) return false;
const proto = this.unitProto(unit);
const fuel = unit.airHours === undefined ? proto.enduranceHours : unit.airHours;
if (this._pathHours(unit, path) > fuel + 1e-9) return false;
paths.push(path);
}
const cost = (AIR_STRIKE.cost || 0) * units.length;
if (cost > 0 && !this._spendBudget(civ, cost)) return false;
this._declareWar(civ, target.civ);
units.forEach((unit, index) => {
unit.path = paths[index];
unit.pathIndex = 0;
unit.progressHours = 0;
unit.moveSpeed = null;
unit.waypoints = [];
unit.autoReturn = false;
unit.strikeTarget = { x: goal.x, y: goal.y };
});
this._emitChanged();
return true;
},
// Resolves a plane's bomb run the moment it reaches its target tile: one
// strike on the weakest hostile military unit there. The plane then turns for
// home and its cooldown is armed when it lands.
_resolveAirStrike(unit) {
const goal = unit.strikeTarget;
unit.strikeTarget = null;
unit.missionFlown = true;
if (goal) {
const target = this._strikeTargetAt(goal, unit.civ);
if (target) {
const damage = Math.max(1, this._unitAttack(unit) - this._effectiveDefense(target, true));
target.hp -= damage;
if (target.hp <= 0) {
target.hp = 0;
this._recordMilitaryCasualties([target]);
this._destroyUnit(target);
this._visibilityDirty = true;
}
}
}
this._emitChanged();
},
// Runs once per in-game hour: every ground/naval battery with a standing
// target fires a small volley, wearing the enemy down instead of destroying
// it outright. A target that has died or slipped out of reach clears the
// battery's orders.
_tickStrikes() {
const damage = new Map();
for (const unit of this.units) {
if (!unit.strikeTarget || this._isAirUnit(unit)) continue;
const goal = unit.strikeTarget;
const target = this._strikeTargetAt(goal, unit.civ);
if (!target || !this._strikeReady(unit, goal)) {
unit.strikeTarget = null;
continue;
}
const full = this._unitAttack(unit) - this._effectiveDefense(target, true);
const amount = Math.max(1, Math.round(full * (BOMBARD.hourlyDamageFraction || 0)));
damage.set(target, (damage.get(target) || 0) + amount);
}
if (damage.size === 0) return;
const destroyed = [];
for (const [target, amount] of damage) {
target.hp -= amount;
if (target.hp <= 0) {
target.hp = 0;
destroyed.push(target);
}
}
this._recordMilitaryCasualties(destroyed);
for (const unit of destroyed) this._destroyUnit(unit);
if (destroyed.length > 0) this._visibilityDirty = true;
this._emitChanged();
},
// ---------------------------------------------------------- training --
// Places a freshly trained unit: aircraft and land units appear in the city,
// while a warship is launched onto the least crowded adjacent sea tile.
_spawnTrainedUnit(city, proto) {
return this._spawnUnit(this._spawnCoordsFor(city, proto), city.civ, proto, city.id);
},
_spawnCoordsFor(city, proto) {
const terrains = proto.traversableTerrains || [];
const naval = terrains.length === 1 && terrains[0] === "Sea";
if (!naval) return city.coords;
let best = null;
let bestCount = Infinity;
for (const neighbour of this._neighbours(city.coords)) {
const tile = this.tiles[key(neighbour.x, neighbour.y)];
if (!tile || tile.terrainClass !== "Sea") continue;
const count = this.unitsAt(neighbour).length;
if (count < bestCount) {
bestCount = count;
best = neighbour;
}
}
return best || city.coords;
},
};