600 lines
24 KiB
JavaScript
600 lines
24 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, mapToLocal, flightLine, flightDistanceTiles } from "../hex.js";
|
|
import { AIR_DEFENSE, AIR_STRIKE, BOMBARD, CITY_BOMBARD } from "../data/combat.js";
|
|
import { BUILDING_MECHANIC } from "../data/buildings.js";
|
|
import { RESOURCE_RULES } from "../data/resources.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 flies straight over the hex grid instead of walking it: an
|
|
// aircraft, or a missile once launched. Their path is a list of world-pixel
|
|
// points, not tiles, and the browser predicts the same line.
|
|
_isFlierProto(proto) {
|
|
return !!(proto && (proto.air || proto.missile));
|
|
},
|
|
|
|
_isFlier(unit) {
|
|
return this._isFlierProto(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 friendly airport an aircraft would fly home to from `from`: the nearest
|
|
// one to where it is heading, so a plane rebased to a forward airfield does
|
|
// not have to cross the map back to its original base. Its home base is only
|
|
// special until another friendly airport is closer.
|
|
_returnAirport(unit, from = unit.coords) {
|
|
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(from, city.coords);
|
|
if (distance < bestDistance) {
|
|
bestDistance = distance;
|
|
best = city;
|
|
}
|
|
}
|
|
return best;
|
|
},
|
|
|
|
_nearestFriendlyAirport(unit) {
|
|
return this._returnAirport(unit, unit.coords);
|
|
},
|
|
|
|
_pathHours(unit, path) {
|
|
let total = 0;
|
|
for (const hours of this._segmentHours(unit, path)) total += hours;
|
|
return total;
|
|
},
|
|
|
|
// The per-segment hours of a route. A ground route is a list of tiles and
|
|
// each step costs its tile's travel time; a flight is a list of world-pixel
|
|
// points and each leg costs its straight-line distance. This is the one place
|
|
// the two movement models differ, so time and fuel can never drift apart.
|
|
_segmentHours(unit, path) {
|
|
const hours = [];
|
|
if (!path || path.length < 2) return hours;
|
|
if (this._isFlier(unit)) {
|
|
for (let i = 1; i < path.length; i++) {
|
|
hours.push(this._segmentFlightHours(unit, path[i - 1], path[i]));
|
|
}
|
|
return hours;
|
|
}
|
|
for (let i = 1; i < path.length; i++) hours.push(this._tileTravelHours(unit, path[i]));
|
|
return hours;
|
|
},
|
|
|
|
// Hours to fly the straight pixel segment `a` to `b`.
|
|
_segmentFlightHours(unit, a, b) {
|
|
const speed = Math.max(unit.moveSpeed || this._unitSpeed(unit), 0.0001);
|
|
return (Math.hypot(b.x - a.x, b.y - a.y) / this._maxStepLength) / speed;
|
|
},
|
|
|
|
// Hours to fly straight from tile `from` to tile `to`, the measure the fuel
|
|
// and strike-reach checks use.
|
|
_flightHours(unit, from, to) {
|
|
const speed = Math.max(unit.moveSpeed || this._unitSpeed(unit), 0.0001);
|
|
return flightDistanceTiles(from, to, this.topology, this._maxStepLength) / speed;
|
|
},
|
|
|
|
// Where a flier is right now as a world pixel, whether parked or mid-leg.
|
|
_flierPixel(unit) {
|
|
if (this._hasRoute(unit)) {
|
|
const a = unit.path[unit.pathIndex];
|
|
const b = unit.path[unit.pathIndex + 1];
|
|
const total = this._segmentFlightHours(unit, a, b);
|
|
const t = total > 0 ? Math.min(1, Math.max(0, unit.progressHours / total)) : 0;
|
|
return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
|
|
}
|
|
return mapToLocal(unit.coords.x, unit.coords.y);
|
|
},
|
|
|
|
// 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 from = this._flierPixel(unit);
|
|
const path = flightLine(from, airport.coords, this.topology);
|
|
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;
|
|
// Any bomb run is over once the plane is back on the tarmac.
|
|
unit.strikeTarget = null;
|
|
continue;
|
|
}
|
|
if (unit.airHours === undefined || unit.airHours === null) {
|
|
unit.airHours = proto.enduranceHours;
|
|
}
|
|
unit.airborne = true;
|
|
if (unit.strikeTarget) {
|
|
// The round trip was validated when the strike was ordered; press on.
|
|
} else if (unit.autoReturn) {
|
|
// Already heading for a runway.
|
|
} else if (this._hasRoute(unit)) {
|
|
// Ferrying: turn for home once the tanks can no longer cover both the
|
|
// rest of the trip and the flight back to a friendly airport.
|
|
const airport = this._nearestFriendlyAirport(unit);
|
|
const home = airport ? this.findPath(unit, unit.coords, airport.coords) : [];
|
|
if (home.length >= 2 && this._pathHours(unit, home) >= unit.airHours - 1e-9) {
|
|
this._orderAirReturn(unit);
|
|
}
|
|
} else {
|
|
this._orderAirReturn(unit);
|
|
}
|
|
unit.airHours -= 1;
|
|
// A plane with a runway to reach is never stranded: it lands on fumes.
|
|
// Only one that has no route to any airport goes down when the tanks dry.
|
|
if (unit.airHours <= 0 && !unit.strikeTarget && !this._hasRoute(unit)) {
|
|
crashed.push(unit);
|
|
}
|
|
}
|
|
for (const unit of crashed) {
|
|
this._recordMilitaryCasualties([unit]);
|
|
this._destroyUnit(unit);
|
|
}
|
|
if (crashed.length > 0) this._visibilityDirty = true;
|
|
// The anti-air corridor: every hostile battery within reach fires on each
|
|
// flier aloft. A parked aircraft is safe on its own runway, but anything
|
|
// crossing defended airspace takes fire for as long as it is inside.
|
|
for (const unit of this.units.slice()) {
|
|
if (!this._isFlier(unit) || !this._hasRoute(unit)) continue;
|
|
if (this._isAirUnit(unit) && this._friendlyAirportAt(unit.coords, unit.civ)) continue;
|
|
this._airDefenseVolley(unit);
|
|
}
|
|
},
|
|
|
|
// ----------------------------------------------------------- strike --
|
|
|
|
_strikeTargetAt(coords, civ) {
|
|
const enemies = this.unitsAt(coords).filter((unit) => {
|
|
if (unit.civ === civ) return false;
|
|
// Support units (mobile radar, launches) are legitimate targets for
|
|
// ranged fire even though they never fight back.
|
|
const proto = this.unitProto(unit);
|
|
return this._isMilitary(unit) || (proto && proto.capturable);
|
|
});
|
|
if (enemies.length === 0) return null;
|
|
// Infantry shelters the rest of a stack, so exposed support units are hit
|
|
// before dug-in troops.
|
|
enemies.sort((a, b) => {
|
|
const aExposed = this._isExposedTarget(a) ? 0 : 1;
|
|
const bExposed = this._isExposedTarget(b) ? 0 : 1;
|
|
return aExposed - bExposed || (a.hp - b.hp) || (a.id - b.id);
|
|
});
|
|
return enemies[0];
|
|
},
|
|
|
|
_isExposedTarget(unit) {
|
|
const proto = this.unitProto(unit);
|
|
return !!(proto && (proto.capturable || proto.artillery));
|
|
},
|
|
|
|
// An enemy city at the tile, the target of a bombardment when its garrison
|
|
// has already fallen.
|
|
_strikeCityAt(coords, civ) {
|
|
const city = this.cityAt(coords);
|
|
if (!city || city.civ === civ) return null;
|
|
return city;
|
|
},
|
|
|
|
// Ranged fire on a city with no garrison left: it kills people and leaves the
|
|
// usual battle scar, and the tile's structures take a share of the blow.
|
|
_bombardCity(city, amount) {
|
|
const k = key(city.coords.x, city.coords.y);
|
|
const population = this.tilePopulation.get(k) || 0;
|
|
// Wartime shelters keep a share of the people safe from the bombs.
|
|
const protection = this._civilianProtection(city);
|
|
const removed = Math.min(population, amount * CITY_BOMBARD.populationPerDamage * (1 - protection));
|
|
if (removed > 0) {
|
|
this.tilePopulation.set(k, population - removed);
|
|
this._populationVersion += 1;
|
|
}
|
|
const perCapita = this.getTileGdpPerCapita(city.coords);
|
|
if (perCapita > 0) {
|
|
const deficit = (this.tileBattleProductionDeficit.get(k) || 0) + perCapita * 0.1;
|
|
this.tileBattleProductionDeficit.set(k, deficit);
|
|
this.tileBattleRecoveryStart.set(k, this.totalHours + 30 * 24);
|
|
this._dropTileGdpCache(k);
|
|
}
|
|
this._damageTileImprovement(city.coords, Math.max(1, Math.round(amount * 0.1)));
|
|
this._razeCityBuildings(city, amount);
|
|
// The people and their city need materials to put themselves right again.
|
|
this._recordRepairDebt(city.coords, amount);
|
|
this._emitChanged();
|
|
},
|
|
|
|
// The share of a city's civilians its wartime shelters keep safe, capped at
|
|
// all of them. Shelters never reduce the damage other buildings suffer.
|
|
_civilianProtection(city) {
|
|
let protection = 0;
|
|
for (const [index, level] of Object.entries(city.buildings || {})) {
|
|
const proto = this.protoBuildings[index];
|
|
if (proto && proto.mechanic === BUILDING_MECHANIC.CIVILIAN_SHELTER) {
|
|
protection += (proto.civilianProtectionPerLevel || 0) * level;
|
|
}
|
|
}
|
|
return Math.min(1, protection);
|
|
},
|
|
|
|
// A ranged blow on a city can knock a building down a level, with no refund.
|
|
// Every building rolls once, so a wartime shelter (which resists almost all
|
|
// of it) never shields the rest of the city.
|
|
_razeCityBuildings(city, damage) {
|
|
if (!(damage > 0)) return false;
|
|
let razed = false;
|
|
for (const [index, level] of Object.entries(city.buildings || {})) {
|
|
if (level <= 0) continue;
|
|
const proto = this.protoBuildings[index];
|
|
if (!proto) continue;
|
|
const resistance = proto.destructionResistance === undefined ? 1 : proto.destructionResistance;
|
|
const chance = CITY_BOMBARD.buildingDestructionChancePerDamage * damage * resistance;
|
|
if (chance > 0 && this._random() < chance) {
|
|
if (level <= 1) delete city.buildings[index];
|
|
else city.buildings[index] = level - 1;
|
|
// The level is not lost for good: it waits to be rebuilt once its
|
|
// materials are paid for.
|
|
this._queueCityRepair(city, Number(index), level);
|
|
razed = true;
|
|
}
|
|
}
|
|
if (razed) {
|
|
this._clearTileGdpCache();
|
|
this._touchModifiers();
|
|
}
|
|
return razed;
|
|
},
|
|
|
|
// Every hostile anti-air battery within `rangeTiles` of the flier fires one
|
|
// in-game hour of continuous fire: damage scales with the building's level,
|
|
// and each battery that fires spends its ammunition. These are not tile-entry
|
|
// volleys any more -- a plane is under fire whenever it is inside the
|
|
// corridor, so skirting the map's tiles no longer offers cover. Returns true
|
|
// when the aircraft is shot down.
|
|
_airDefenseVolley(unit) {
|
|
let damage = 0;
|
|
for (const city of this.cities) {
|
|
if (city.civ === unit.civ) continue;
|
|
const level = this._cityMechanicLevel(city, BUILDING_MECHANIC.AIR_DEFENSE);
|
|
if (level <= 0) continue;
|
|
if (this.topology.tileDistance(unit.coords, city.coords) > AIR_DEFENSE.rangeTiles) {
|
|
continue;
|
|
}
|
|
// Air defense is cheap to keep ready and costly to fire: no ammunition,
|
|
// no fire.
|
|
if (!this.payCombatResources(city.civ, "airDefenseVolley", level)) continue;
|
|
damage += Math.max(1, Math.round(AIR_DEFENSE.damagePerLevelPerHour * level));
|
|
}
|
|
if (damage <= 0) return false;
|
|
unit.hp -= damage;
|
|
if (unit.hp > 0) return false;
|
|
unit.hp = 0;
|
|
this._recordMilitaryCasualties([unit]);
|
|
this._destroyUnit(unit);
|
|
this._visibilityDirty = true;
|
|
return true;
|
|
},
|
|
|
|
// Resolves one missile's warhead where it lands and consumes the rocket. The
|
|
// missile flies a straight line to its target (requestMissileStrike); this is
|
|
// the moment of detonation.
|
|
_detonateMissile(unit) {
|
|
const goal = unit.missileTarget;
|
|
const nuclear = !!unit.nuclearPayload;
|
|
unit.missileTarget = null;
|
|
unit.nuclearPayload = false;
|
|
if (goal) {
|
|
if (nuclear) this._applyNuclearBlast(goal, unit.civ);
|
|
else this._resolveConventionalMissile(unit, goal);
|
|
}
|
|
// A nuclear blast at the missile's own tile already destroyed it above.
|
|
if (this.findUnit(unit.id)) this._destroyUnit(unit);
|
|
this._invalidateUnitCaches();
|
|
this._visibilityDirty = true;
|
|
this._emitChanged();
|
|
},
|
|
|
|
// Applies one ranged blow to a target unit, with the special fates of exposed
|
|
// support units, and passes a tenth of the damage on to any structures on the
|
|
// tile.
|
|
_applyStrikeDamage(target, damage) {
|
|
const proto = this.unitProto(target);
|
|
const alone = this.unitsAt(target.coords).length <= 1;
|
|
if (proto && proto.destroyedByRangedAlone && alone) damage = target.hp;
|
|
else if (proto && proto.naval && proto.capturable && alone) damage = target.hp;
|
|
target.hp -= damage;
|
|
// The tile's structures always take a tenth of an artillery or aerial blow.
|
|
this._damageTileImprovement(target.coords, Math.max(1, Math.round(damage * 0.1)));
|
|
// A garrison inside a city does not spare its buildings the bombs.
|
|
const city = this.cityAt(target.coords);
|
|
if (city) this._razeCityBuildings(city, damage);
|
|
if (target.hp > 0) return false;
|
|
target.hp = 0;
|
|
this._recordMilitaryCasualties([target]);
|
|
this._destroyUnit(target);
|
|
this._visibilityDirty = true;
|
|
return true;
|
|
},
|
|
|
|
// 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 an aircraft has the fuel for the whole sortie: fly straight to
|
|
// `goal`, bomb, then run home to a friendly airport. A strike that could not
|
|
// make it back is refused up front, so a sortie never strands a plane. Both
|
|
// legs are measured along the straight line, so the fuel here and the hours
|
|
// the mover actually spends always agree.
|
|
_canStrikeAndReturn(unit, goal, fuel = null) {
|
|
const proto = this.unitProto(unit);
|
|
if (!proto) return false;
|
|
const airport = this._returnAirport(unit, goal);
|
|
if (!airport) return false;
|
|
const remaining = fuel === null
|
|
? (unit.airHours === undefined ? proto.enduranceHours : unit.airHours)
|
|
: fuel;
|
|
const hours =
|
|
this._flightHours(unit, unit.coords, goal) +
|
|
this._flightHours(unit, goal, airport.coords);
|
|
return hours <= remaining + 1e-9;
|
|
},
|
|
|
|
// Whether a unit may strike `goal` right now: inside its reach and, for an
|
|
// aircraft, parked/armed with fuel for the round trip 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;
|
|
if (fuel <= 0) return false;
|
|
return this._canStrikeAndReturn(unit, goal, fuel);
|
|
},
|
|
|
|
// 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) && !this._isMissileUnit(unit)
|
|
);
|
|
if (units.length === 0) return false;
|
|
const civ = units[0].civ;
|
|
const target = this._strikeTargetAt(goal, civ);
|
|
const city = target ? null : this._strikeCityAt(goal, civ);
|
|
if (!target && !city) return false;
|
|
if (units.some((unit) => unit.civ !== civ || !this._strikeReady(unit, goal))) return false;
|
|
this._declareWar(civ, target ? target.civ : city.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);
|
|
const city = target ? null : this._strikeCityAt(goal, civ);
|
|
if (!target && !city) 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.
|
|
// `_strikeReady` above already proved the round trip fits the fuel; this
|
|
// only stores the outbound leg the plane flies first.
|
|
const paths = [];
|
|
for (const unit of units) {
|
|
const path = this.findPath(unit, unit.coords, goal);
|
|
if (path.length < 2) return false;
|
|
paths.push(path);
|
|
}
|
|
// A sortie is paid for in munitions, not money: no materials, no strike.
|
|
if (!this.payCombatResources(civ, "airStrike", units.length)) return false;
|
|
this._declareWar(civ, target ? target.civ : city.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));
|
|
this._applyStrikeDamage(target, damage);
|
|
} else {
|
|
const city = this._strikeCityAt(goal, unit.civ);
|
|
if (city) {
|
|
this._bombardCity(city, Math.max(1, this._unitAttack(unit)));
|
|
}
|
|
}
|
|
}
|
|
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) || this._isMissileUnit(unit)) continue;
|
|
const goal = unit.strikeTarget;
|
|
const target = this._strikeTargetAt(goal, unit.civ);
|
|
if (!target) {
|
|
// No garrison left: the battery shells the city itself.
|
|
const city = this._strikeCityAt(goal, unit.civ);
|
|
if (city && this._strikeReady(unit, goal)) {
|
|
if (!this.payCombatResources(unit.civ, "artilleryVolley", 1)) {
|
|
unit.strikeTarget = null;
|
|
} else {
|
|
this._bombardCity(city, Math.max(1, Math.round(this._unitAttack(unit) * (BOMBARD.hourlyDamageFraction || 0) * 10)));
|
|
}
|
|
} else {
|
|
unit.strikeTarget = null;
|
|
}
|
|
continue;
|
|
}
|
|
if (!this._strikeReady(unit, goal)) {
|
|
unit.strikeTarget = null;
|
|
continue;
|
|
}
|
|
// Each hourly volley is a shell spent.
|
|
if (!this.payCombatResources(unit.civ, "artilleryVolley", 1)) {
|
|
unit.strikeTarget = null;
|
|
continue;
|
|
}
|
|
const full = this._unitAttack(unit) - this._effectiveDefense(target, true, this.unitProto(unit));
|
|
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) {
|
|
if (this._applyStrikeDamage(target, amount)) destroyed.push(target);
|
|
}
|
|
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. The
|
|
// unit's soldiers are drawn at random from the region's people.
|
|
_spawnTrainedUnit(city, proto) {
|
|
const unit = this._spawnUnit(this._spawnCoordsFor(city, proto), city.civ, proto, city.id);
|
|
const soldiers = proto.population || RESOURCE_RULES.unitPopulation;
|
|
if (soldiers > 0) this.drawRegionPopulation(city, soldiers);
|
|
return unit;
|
|
},
|
|
|
|
_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;
|
|
},
|
|
};
|