Files
Battle-for-Tismo/shared/game_state/warfare.js
T
adrien ede258d39f Added warfare depth, focused research and a reworked HUD
- Tile improvements (fortifications, radar towers, coastal cannons), trenches,
  zone of control, support-unit capture and launch transports, with parabolic
  barrage arcs drawn between attacker and target.
- Focused research that pours points straight into the chosen technology, with
  per-technology progress kept when the focus changes, plus repeatable levels.
- Popularity reworked to integer points, war and pillage effects, minority
  policies and month-over-month arrows on the nation summary.
- City progress bars, square improvement buttons, a technology tab of wide
  short cards with progress bars, transport cargo readout and double-click to
  open a city panel.
2026-09-21 01:07:36 +02:00

363 lines
13 KiB
JavaScript

// War-time tile works: trenches dug by infantry, the military improvements a
// nation raises on its land (fortifications, radar towers, coastal cannons),
// and the damage, capture and upkeep that come with them.
import { key, parseKey } from "../hex.js";
import { ECONOMY } from "../data/economy.js";
import { TRANSPORT_BY_ID, tileImprovementById } from "../data/improvements.js";
import { TRENCH } from "../data/combat.js";
import { TILE_IMPROVEMENT_HP, COASTAL_CANNON, EMBARK_HOURS } from "./constants.js";
export const warfareMethods = {
// ----------------------------------------------------------- trenches --
// Infantry dig in on a land tile their nation controls. A trench shelters
// the garrison from melee and artillery at the cost of local GDP.
requestDigTrench(unitIds) {
const units = this._unitsForOrder(unitIds);
if (units.length === 0) return false;
const coords = units[0].coords;
if (!units.every((unit) => unit.coords.x === coords.x && unit.coords.y === coords.y)) {
return false;
}
const civ = units[0].civ;
if (!units.every((unit) => unit.civ === civ)) return false;
// Only infantry can dig, and only into their own land.
if (!units.every((unit) => {
const proto = this.unitProto(unit);
return proto && proto.infantry;
})) return false;
if (this.civAt(coords) !== civ) return false;
const tile = this.tiles[key(coords.x, coords.y)];
if (!tile || tile.terrainClass !== "Land") return false;
const k = key(coords.x, coords.y);
if (this.trenches.has(k)) return false;
this.trenches.add(k);
this._gdpPerCapitaCache.delete(k);
this._emitChanged();
return true;
},
// ------------------------------------------------- tile improvements --
// Queues a military tile improvement on a land tile the nation controls. The
// work takes the same GDP-scaled time as any other construction and completes
// on the hourly tile-works tick.
requestBuildTileImprovement(civ, coords, id) {
if (!this._validCiv(civ) || !coords) return false;
if (!Number.isFinite(coords.x) || !Number.isFinite(coords.y)) return false;
const proto = tileImprovementById(id);
if (!proto) return false;
const k = key(coords.x, coords.y);
const tile = this.tiles[k];
if (!tile || tile.terrainClass !== "Land") return false;
if (this.civAt(coords) !== civ) return false;
if (this.tileImprovements.has(k)) return false;
if (proto.coastal && !this._isCoastalTile(coords)) return false;
const gdp = this.getPlayerGdp(civ);
if (ECONOMY.productionCapacity(gdp) <= 0) return false;
if (!this._spendBudget(civ, proto.buildCost)) return false;
const work = {
coords: { x: coords.x, y: coords.y },
id,
elapsedHours: 0,
totalHours: ECONOMY.trainingHours(gdp, proto.buildCost) * this.constructionSpeedMultiplier(civ),
};
const queue = this.tileWorks.get(civ);
if (queue) queue.push(work);
else this.tileWorks.set(civ, [work]);
this._emitChanged();
return true;
},
// Tears down a military improvement (or a trench) on a controlled tile.
requestRemoveTileImprovement(civ, coords) {
if (!this._validCiv(civ) || !coords) return false;
const k = key(coords.x, coords.y);
if (this.civAt(coords) !== civ) return false;
let removed = false;
if (this.trenches.delete(k)) removed = true;
if (this.tileImprovements.delete(k)) {
this.tileImprovementOwner.delete(k);
this.tileImprovementHp.delete(k);
removed = true;
}
if (!removed) return false;
this._tileImprovementVersion += 1;
this._gdpPerCapitaCache.delete(k);
this._emitChanged();
return true;
},
// Advances every nation's queued tile works by an hour, completing the head
// of each queue and carrying the overflow into the next.
_tickTileWorks() {
for (const [civ, queue] of this.tileWorks) {
let hoursLeft = 1;
while (hoursLeft > 0 && queue.length > 0) {
const work = queue[0];
const remaining = Math.max(work.totalHours - work.elapsedHours, 0);
if (hoursLeft >= remaining) {
hoursLeft -= remaining;
this._completeTileWork(civ, work);
queue.shift();
} else {
work.elapsedHours += hoursLeft;
hoursLeft = 0;
}
}
if (queue.length === 0) this.tileWorks.delete(civ);
}
},
_completeTileWork(civ, work) {
const k = key(work.coords.x, work.coords.y);
// The tile may have been taken or already improved while the work ran.
if (this.civAt(work.coords) !== civ || this.tileImprovements.has(k)) return;
const proto = tileImprovementById(work.id);
if (!proto) return;
this.tileImprovements.set(k, work.id);
this.tileImprovementOwner.set(k, civ);
this.tileImprovementHp.set(k, TILE_IMPROVEMENT_HP[work.id] || 1000);
this._tileImprovementVersion += 1;
this._gdpPerCapitaCache.delete(k);
this._visibilityDirty = true;
this._emitChanged();
},
// Whether a land tile touches the sea, so a coastal cannon may be raised.
_isCoastalTile(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;
},
_tileImprovementAt(coords) {
const id = this.tileImprovements.get(key(coords.x, coords.y));
return id ? tileImprovementById(id) : null;
},
// The extra defense a tile's fortifications and trenches give a defender,
// against a melee or artillery attacker.
_tileFortificationMultiplier(coords, attackerProto) {
const k = key(coords.x, coords.y);
const artillery = this._isArtilleryAttack(attackerProto);
let multiplier = 1;
const id = this.tileImprovements.get(k);
if (id) {
const proto = tileImprovementById(id);
if (proto && proto.defense) {
multiplier *= 1 + (artillery ? proto.defense.artillery : proto.defense.melee);
}
}
if (this.trenches.has(k)) {
multiplier *= artillery
? TRENCH.artilleryDefenseMultiplier
: TRENCH.meleeDefenseMultiplier;
}
return multiplier;
},
// Whether an attacker's blow is artillery (or aircraft) fire rather than a
// melee charge, so trench and fortification bonuses can tell them apart.
_isArtilleryAttack(proto) {
if (!proto) return false;
if (proto.artillery || proto.air) return true;
return (proto.range || 1) > 1;
},
// Damage a tile improvement from ranged fire. Fortifications and coastal
// cannons are knocked down when their hit points run out; a radar tower is
// captured by infantry rather than destroyed.
_damageTileImprovement(coords, amount) {
const k = key(coords.x, coords.y);
const id = this.tileImprovements.get(k);
if (!id) return false;
const proto = tileImprovementById(id);
if (!proto || !(proto.takesRangedDamage || proto.takesDamage || proto.takesCombatDamage)) {
return false;
}
const hp = (this.tileImprovementHp.get(k) || 0) - amount;
if (hp <= 0) {
this.tileImprovements.delete(k);
this.tileImprovementOwner.delete(k);
this.tileImprovementHp.delete(k);
this._tileImprovementVersion += 1;
this._emitChanged();
return true;
}
this.tileImprovementHp.set(k, hp);
return true;
},
// An enemy walking onto a capturable improvement takes it for their nation.
_captureTileImprovement(coords, civ) {
const k = key(coords.x, coords.y);
const id = this.tileImprovements.get(k);
if (!id) return false;
const proto = tileImprovementById(id);
if (!proto || !proto.capturable) return false;
const owner = this.tileImprovementOwner.get(k);
if (owner === civ) return false;
this.tileImprovementOwner.set(k, civ);
this._tileImprovementVersion += 1;
this._emitChanged();
return true;
},
// The hourly maintenance of every military improvement a nation holds.
getTileImprovementUpkeep(civ) {
let total = 0;
for (const [k, id] of this.tileImprovements) {
if (this.tileImprovementOwner.get(k) !== civ) continue;
const proto = tileImprovementById(id);
if (proto) total += proto.maintenance;
}
return total;
},
_tileImprovementUpkeepSources(civ) {
const byType = new Map();
for (const [k, id] of this.tileImprovements) {
if (this.tileImprovementOwner.get(k) !== civ) continue;
const proto = tileImprovementById(id);
if (!proto) continue;
const entry = byType.get(id) || { label: proto.name, amount: 0, count: 0 };
entry.amount += proto.maintenance;
entry.count += 1;
byType.set(id, entry);
}
return Array.from(byType.values()).filter((entry) => entry.amount > 0);
},
// ------------------------------------------------------ transports --
// Infantry board a friendly launch. They must stand on the shore beside it;
// the boarding takes EMBARK_HOURS in-game hours to complete.
requestEmbark(infantryIds, launchId) {
const launch = this.findUnit(Number(launchId));
if (!launch) return false;
const proto = this.unitProto(launch);
if (!proto || !(proto.transportCapacity > 0)) return false;
if (launch.embarking) return false;
const cargo = launch.cargo || [];
const infantry = this._unitsForOrder(infantryIds).filter((unit) => {
const unitProto = this.unitProto(unit);
return unitProto && unitProto.infantry && unit.civ === launch.civ;
});
if (infantry.length === 0) return false;
if (cargo.length + infantry.length > proto.transportCapacity) return false;
// The troops must be on the shore next to the launch.
if (!infantry.every((unit) =>
this.topology.tileDistance(unit.coords, launch.coords) === 1)) {
return false;
}
launch.embarking = { ids: infantry.map((unit) => unit.id), hoursLeft: EMBARK_HOURS };
this._emitChanged();
return true;
},
// Finishes any boarding: at zero hours the infantry leave the map and become
// the launch's cargo, and are carried until put ashore.
_tickEmbark() {
for (const unit of this.units) {
if (!unit.embarking) continue;
unit.embarking.hoursLeft -= 1;
if (unit.embarking.hoursLeft > 0) continue;
const proto = this.unitProto(unit);
const capacity = proto ? proto.transportCapacity || 0 : 0;
if (!unit.cargo) unit.cargo = [];
for (const id of unit.embarking.ids) {
if (unit.cargo.length >= capacity) break;
const infantry = this.findUnit(id);
if (!infantry || infantry.civ !== unit.civ) continue;
unit.cargo.push({ proto: infantry.proto, hp: infantry.hp });
this._destroyUnit(infantry);
this._visibilityDirty = true;
}
unit.embarking = null;
this._emitChanged();
}
},
// Puts a whole stack of launches' cargo ashore on an adjacent coastal tile.
requestDisembark(launchIds, coords) {
if (!coords) return false;
const launches = this._unitsForOrder(launchIds).filter((unit) => {
const proto = this.unitProto(unit);
return proto && proto.transportCapacity > 0;
});
if (launches.length === 0) return false;
const civ = launches[0].civ;
if (!launches.every((unit) => unit.civ === civ)) return false;
const k = key(coords.x, coords.y);
const tile = this.tiles[k];
if (!tile || tile.terrainClass !== "Land") return false;
if (!launches.every((unit) => this.topology.tileDistance(unit.coords, coords) === 1)) {
return false;
}
let landed = false;
for (const launch of launches) {
const cargo = launch.cargo || [];
for (const passenger of cargo) {
const proto = this.protoUnits[passenger.proto];
if (!proto) continue;
const unit = this._spawnUnit(coords, civ, proto);
if (passenger.hp !== undefined) unit.hp = Math.min(proto.maxHp, passenger.hp);
landed = true;
}
launch.cargo = [];
}
if (landed) {
this._visibilityDirty = true;
this._emitChanged();
}
return landed;
},
// -------------------------------------------------- coastal cannons --
// Coastal cannons fire at hostile warships within range every hour.
_tickCoastalCannons() {
const damaged = new Map();
for (const [k, id] of this.tileImprovements) {
const proto = tileImprovementById(id);
if (!proto || !proto.automatic) continue;
const owner = this.tileImprovementOwner.get(k);
const coords = parseKey(k);
for (const target of this._unitsWithin(coords, proto.range || 1)) {
const targetProto = this.unitProto(target);
if (!targetProto || !targetProto.naval) continue;
if (target.civ === owner) continue;
const amount = COASTAL_CANNON.damage;
damaged.set(target, (damaged.get(target) || 0) + amount);
}
}
if (damaged.size === 0) return;
const destroyed = [];
for (const [unit, amount] of damaged) {
unit.hp -= amount;
if (unit.hp <= 0) {
unit.hp = 0;
destroyed.push(unit);
}
}
this._recordMilitaryCasualties(destroyed);
for (const unit of destroyed) this._destroyUnit(unit);
if (destroyed.length > 0) this._visibilityDirty = true;
this._emitChanged();
},
// Every unit of another nation within `radius` tiles of `coords`.
_unitsWithin(coords, radius) {
const result = [];
const origin = { x: coords.x, y: coords.y };
for (const unit of this.units) {
if (this.topology.tileDistance(origin, unit.coords) <= radius) result.push(unit);
}
return result;
},
};