396 lines
14 KiB
JavaScript
396 lines
14 KiB
JavaScript
// World-map screen controller. It wires the map view, the HUD and the modals
|
|
// to the snapshots the server broadcasts, and turns player input into orders.
|
|
// It owns no simulation: movement between snapshots is dead reckoning with the
|
|
// same cost model as the server.
|
|
//
|
|
// The HUD, selection and modal behaviour lives in ./game_screen/panels.js and is
|
|
// composed onto the prototype below; this file keeps the screen lifecycle, the
|
|
// snapshot plumbing and the order flow.
|
|
|
|
import { MapView } from "./map_view.js";
|
|
import { CityModal, NationModal, ConfirmModal, NewsModal, MapModesModal } from "./modals.js";
|
|
import { GameFeed } from "./feed.js";
|
|
import { CIVILISATIONS, PROTO_UNITS } from "../../shared/data.js";
|
|
import { bootLog } from "./loading.js";
|
|
import { panelMethods } from "./game_screen/panels.js";
|
|
|
|
export class GameScreen {
|
|
constructor(network, config) {
|
|
this.network = network;
|
|
this.config = config;
|
|
this.$screen = $("#screen-game");
|
|
this.snapshot = null;
|
|
this.localCiv = 0;
|
|
this.civilisations = [];
|
|
this.protoUnits = PROTO_UNITS;
|
|
this.selectedUnitIds = [];
|
|
this.selectedCityId = 0;
|
|
// The tile whose info panel is open, if any (right-click inspection).
|
|
this._inspectedTile = null;
|
|
this._trainingCityId = 0;
|
|
this._cityBuildings = new Map();
|
|
this._population = new Map();
|
|
this._tileEthnicity = new Map();
|
|
this._tileGdpPenalties = new Map();
|
|
this._tileBattleGdpDeficits = new Map();
|
|
this._territoryOwner = new Map();
|
|
this._tileRegion = new Map();
|
|
this._gdpExpanded = false;
|
|
this._battles = new Set();
|
|
this._loading = false;
|
|
this._researched = new Set();
|
|
this._wars = new Set();
|
|
this._conflicts = [];
|
|
this._news = [];
|
|
this._seenNews = null;
|
|
this._entered = false;
|
|
this._lastFrame = 0;
|
|
this._fpsFrames = 0;
|
|
this._fpsElapsed = 0;
|
|
|
|
this.map = new MapView(
|
|
$("#map-viewport"),
|
|
$("#map-world"),
|
|
{
|
|
terrain: $("#layer-terrain"),
|
|
roads: $("#layer-roads"),
|
|
borders: $("#layer-borders"),
|
|
fog: $("#layer-fog"),
|
|
highlight: $("#layer-highlight"),
|
|
paths: $("#layer-paths"),
|
|
targets: $("#layer-targets"),
|
|
entities: $("#layer-entities"),
|
|
labels: $("#layer-labels"),
|
|
}
|
|
);
|
|
this.cityModal = new CityModal();
|
|
this.nationModal = new NationModal();
|
|
this.confirmModal = new ConfirmModal();
|
|
this.newsModal = new NewsModal();
|
|
this.mapModesModal = new MapModesModal();
|
|
this.feed = new GameFeed($("#game-feed"));
|
|
this.$stackMenu = $("#stack-menu");
|
|
this.$statTooltip = $("#stat-tooltip");
|
|
this._hoveredStat = null;
|
|
|
|
this._bindMap();
|
|
this._bindHud();
|
|
}
|
|
|
|
enter() {
|
|
this.$screen.addClass("active");
|
|
this._entered = true;
|
|
this.map._applyCamera();
|
|
this._lastFrame = performance.now();
|
|
requestAnimationFrame((t) => this._frame(t));
|
|
}
|
|
|
|
// Shows the boot-style log while the first snapshot's world is generated and
|
|
// uploaded. The real app calls this; tests never do, so they pay nothing.
|
|
beginLoading() {
|
|
this._loading = true;
|
|
bootLog.bind($("#loading-overlay")).begin();
|
|
}
|
|
|
|
leave() {
|
|
this._entered = false;
|
|
this.map.cancelPathfinding();
|
|
this.$screen.removeClass("active");
|
|
this.feed.clear();
|
|
this._seenNews = null;
|
|
this._hideStackMenu();
|
|
this.mapModesModal.close();
|
|
$(document).off("mousedown.stackmenu");
|
|
}
|
|
|
|
// ------------------------------------------------------------- orders ---
|
|
|
|
sendOrder(order) {
|
|
this.network.sendOrder(order);
|
|
}
|
|
|
|
sendSetup() {
|
|
const ids = this.config.civilisations.map((c) => c.id);
|
|
const playerIndex = Math.max(0, ids.indexOf(this.config.playerCivId));
|
|
this.network.sendGameSetup({
|
|
seed: this.config.seed || 0,
|
|
player_civ: playerIndex,
|
|
civilisations: ids,
|
|
testing: !!this.config.testing,
|
|
});
|
|
}
|
|
|
|
// ----------------------------------------------------------- snapshots ---
|
|
|
|
onState(state) {
|
|
this.snapshot = state;
|
|
this.localCiv = state.viewer;
|
|
this.civilisations = state.civs.map(
|
|
(c) => CIVILISATIONS.find((full) => full.id === c.id) || { id: c.id, name: c.name, primaryColour: "#888" }
|
|
);
|
|
this.protoUnits = state.protos.map(
|
|
(p) => PROTO_UNITS.find((full) => full.id === p.id) || { id: p.id, name: p.name }
|
|
);
|
|
this._population = new Map(state.population.map((e) => [`${e[0]},${e[1]}`, e[2]]));
|
|
this._tileEthnicity = new Map(
|
|
(state.tileEthnicity || []).map((e) => [`${e[0]},${e[1]}`, e[2]])
|
|
);
|
|
this._tileGdpPenalties = new Map(
|
|
(state.tileGdpPenalties || []).map((e) => [`${e[0]},${e[1]}`, e[2]])
|
|
);
|
|
this._tileBattleGdpDeficits = new Map(
|
|
(state.tileBattleGdpDeficits || []).map((e) => [`${e[0]},${e[1]}`, e[2]])
|
|
);
|
|
this._territoryOwner = new Map(state.territory.map((e) => [`${e[0]},${e[1]}`, e[2]]));
|
|
this._tileRegion = new Map((state.regions || []).map((e) => [`${e[0]},${e[1]}`, e[2]]));
|
|
this._battles = new Set((state.battles || []).map((e) => `${e[0]},${e[1]}`));
|
|
this._cityBuildings = new Map();
|
|
for (const city of state.cities) {
|
|
this._cityBuildings.set(city.id, { ...city.buildings });
|
|
}
|
|
this._researched = new Set(
|
|
(state.researched[this.localCiv] || []).map((index) => Number(index))
|
|
);
|
|
this._wars = new Set();
|
|
for (const entry of state.diplomacy || []) {
|
|
if (entry[2] !== "war") continue;
|
|
const a = entry[0];
|
|
const b = entry[1];
|
|
this._wars.add(a < b ? `${a}:${b}` : `${b}:${a}`);
|
|
}
|
|
this._conflicts = state.conflicts || [];
|
|
this._news = state.news || [];
|
|
this._updateFeed(this._news);
|
|
|
|
this.map.setCatalogue(this.civilisations, this.protoUnits);
|
|
this.map.applySnapshot(state);
|
|
// Refresh an active economic map mode against the new figures.
|
|
this._refreshEconomicMode();
|
|
this.selectedUnitIds = this.selectedUnitIds.filter((id) => this._unit(id));
|
|
this.map.setSelectedUnits(this.selectedUnitIds);
|
|
this._updateTileHighlight();
|
|
this._updateTopPanel(state);
|
|
this._updateBottomPanel();
|
|
this._updateCityModal();
|
|
this._updateNationModal();
|
|
this._updateNewsModal();
|
|
if (this._loading) {
|
|
this._loading = false;
|
|
bootLog.step("Updated the HUD");
|
|
bootLog.finish();
|
|
}
|
|
}
|
|
|
|
_bindMap() {
|
|
this.map.onUnitSelected = (id) => {
|
|
if (!id) {
|
|
this._selectUnits([]);
|
|
return;
|
|
}
|
|
const unit = this._unit(id);
|
|
if (!unit || unit.civ !== this.localCiv) {
|
|
this._selectUnits([]);
|
|
return;
|
|
}
|
|
// Clicking any member of a stack selects the whole stack, so the next
|
|
// order is given to every unit sharing the tile.
|
|
const stack = this.map.stackIds(unit.id).filter((stackId) => {
|
|
const data = this._unit(stackId);
|
|
return data && data.civ === this.localCiv;
|
|
});
|
|
this._selectUnits(stack.length > 0 ? stack : [unit.id]);
|
|
};
|
|
this.map.onCitySelected = (id) => {
|
|
const city = (this.snapshot.cities || []).find((c) => c.id === id);
|
|
if (!city || city.civ !== this.localCiv) return;
|
|
this.selectedCityId = id;
|
|
this.selectedUnitIds = [];
|
|
this.map.setSelectedUnits([]);
|
|
this._hideStackMenu();
|
|
this._showCity(city);
|
|
};
|
|
this.map.onTileRequested = (coords) => this._showTile(coords);
|
|
this.map.onStackMenu = (coords, ids, x, y) => this._openStackMenu(coords, ids, x, y);
|
|
this.map.onMoveOrdered = (unitIds, coords) => {
|
|
const units = unitIds.map((id) => this._unit(id)).filter((u) => u && u.civ === this.localCiv);
|
|
if (units.length === 0) return;
|
|
return this._orderMove(units, coords);
|
|
};
|
|
this.map.onAttackOrdered = (unitIds, coords) => {
|
|
const units = unitIds.map((id) => this._unit(id)).filter((u) => u && u.civ === this.localCiv);
|
|
if (units.length === 0) return;
|
|
return this._orderMove(units, coords, "attack");
|
|
};
|
|
this.map.onScheduleOrdered = (unitIds, coords) => {
|
|
const units = unitIds.map((id) => this._unit(id)).filter((u) => u && u.civ === this.localCiv);
|
|
if (units.length === 0) return;
|
|
return this._orderMove(units, coords, "schedule");
|
|
};
|
|
}
|
|
|
|
// Issues a move for the group, prompting first when the route would enter
|
|
// foreign territory at peace. `type` is "move", "attack" or "schedule".
|
|
async _orderMove(units, coords, type = "move") {
|
|
const airStrike = type === "attack" && this._airStrikeOrder(units, coords);
|
|
const striking = type === "attack" && this._rangedAttackOrder(units, coords);
|
|
// Aircraft may only be based at one of our airports; any other move (an
|
|
// empty tile or a city without a runway) is refused up front.
|
|
if ((type === "move" || type === "schedule") &&
|
|
units.length > 0 && units.every((unit) => this._isAirUnit(unit)) &&
|
|
!this._isFriendlyAirport(coords)) {
|
|
this.confirmModal.show(
|
|
"No runway",
|
|
"Aircraft may only fly to one of your cities with an airport, or attack an enemy unit.",
|
|
null
|
|
);
|
|
return;
|
|
}
|
|
if (airStrike) {
|
|
const blocked = this._airStrikeBlocked(units);
|
|
if (blocked) {
|
|
this.confirmModal.show(blocked.title, blocked.message, null);
|
|
return;
|
|
}
|
|
}
|
|
const { targets, speed, routes } = await this._groupMovePlan(units, coords);
|
|
// A strike is refused before it is ever sent when the target lies beyond
|
|
// the unit's reach, so the browser and the server never disagree. An air
|
|
// strike must fit the whole round trip, not just the flight out.
|
|
if (airStrike) {
|
|
const reason = this._airStrikeRangeError(units, coords);
|
|
if (reason) {
|
|
this.confirmModal.show("Out of range", reason, null);
|
|
return;
|
|
}
|
|
}
|
|
const outOfRange = units.some((unit) => {
|
|
const proto = this.protoUnits[unit.proto];
|
|
if (!proto) return false;
|
|
if (striking) {
|
|
const from = { x: unit.coords[0], y: unit.coords[1] };
|
|
return this.map.topology.tileDistance(from, coords) > (proto.range || 1);
|
|
}
|
|
const route = routes.get(unit.id);
|
|
if (!route) return false;
|
|
if (!proto.air) return false;
|
|
// A ferry flight is bounded by fuel, not the combat mission radius:
|
|
// endurance hours at the aircraft's speed.
|
|
const ferryTiles = (proto.enduranceHours || 0) * (proto.speed || 1);
|
|
return route.length - 1 > ferryTiles;
|
|
});
|
|
if (outOfRange) {
|
|
this.confirmModal.show("Out of range", "That target lies beyond this unit's reach.", null);
|
|
return;
|
|
}
|
|
if (targets.length > 0) {
|
|
const names = targets.map((index) => this._civName(index)).join(", ");
|
|
const demonyms = targets.map((index) => this._civDemonym(index)).join(", ");
|
|
this.confirmModal.show(
|
|
"Declare war?",
|
|
`Marching into ${demonyms} territory will declare war on ${names}. Continue?`,
|
|
() => this._sendMove(units, coords, speed, type, routes)
|
|
);
|
|
return;
|
|
}
|
|
return this._sendMove(units, coords, speed, type, routes);
|
|
}
|
|
|
|
_sendMove(units, coords, speed = null, type = "move", routes = null) {
|
|
const ids = units.map((unit) => unit.id);
|
|
const order = { type, coords: [coords.x, coords.y] };
|
|
if (ids.length === 1) order.unit = ids[0];
|
|
else order.units = ids;
|
|
if (type === "schedule") {
|
|
order.goals = [[coords.x, coords.y]];
|
|
order.append = true;
|
|
}
|
|
this.sendOrder(order);
|
|
// A ranged attack is resolved instantly on the server without moving, so
|
|
// predicting a march there would only make the icon drift.
|
|
const striking = type === "attack" && this._rangedAttackOrder(units, coords);
|
|
if (striking) return Promise.resolve();
|
|
const pending = [];
|
|
for (const unit of units) {
|
|
// An append joins the queue behind the leg already under way, so only an
|
|
// idle stack gets a fresh prediction.
|
|
const motion = this.map._unitMotion.get(unit.id);
|
|
if (type === "schedule" && motion && motion.predicting) continue;
|
|
// Reuse the route the war check already found instead of searching again.
|
|
const route = routes && routes.get(unit.id);
|
|
if (route) this.map.predictMove(unit, coords, speed, route);
|
|
else pending.push(unit);
|
|
}
|
|
// A fresh search cancels any other, so units still without a planned route
|
|
// wait their turn rather than fighting for the single search slot.
|
|
return pending.reduce(
|
|
(chain, unit) => chain.then(() => this.map.predictMove(unit, coords, speed)),
|
|
Promise.resolve()
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------- pillage -------
|
|
|
|
_pillageSelected() {
|
|
const units = this._selectedUnits();
|
|
if (units.length === 0) return;
|
|
this.sendOrder({ type: "pillage", units: units.map((unit) => unit.id) });
|
|
}
|
|
|
|
// --------------------------------------------------------------- input ---
|
|
|
|
_onKey(event) {
|
|
if (!this._entered) return;
|
|
const step = event.shiftKey ? 120 : 40;
|
|
switch (event.key) {
|
|
case "w": case "ArrowUp": this.map.panBy(0, step); break;
|
|
case "s": case "ArrowDown": this.map.panBy(0, -step); break;
|
|
case "a": case "ArrowLeft": this.map.panBy(step, 0); break;
|
|
case "d": case "ArrowRight": this.map.panBy(-step, 0); break;
|
|
case "+": case "=":
|
|
this.map.camera.zoom = Math.min(2, this.map.camera.zoom * 1.1);
|
|
this.map._applyCamera();
|
|
break;
|
|
case "-":
|
|
this.map.camera.zoom = Math.max(0.2, this.map.camera.zoom / 1.1);
|
|
this.map._applyCamera();
|
|
break;
|
|
case "Escape": {
|
|
// ESC closes an open modal first; over the map it clears the selection
|
|
// and the inspected tile, dismissing both bottom panels.
|
|
const modalOpen = $(".modal").not(".hidden").length > 0;
|
|
this.cityModal.close();
|
|
this.nationModal.close();
|
|
this.confirmModal.close();
|
|
this.newsModal.close();
|
|
this.mapModesModal.close();
|
|
if (!modalOpen) this.map.clearSelection();
|
|
break;
|
|
}
|
|
default:
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
}
|
|
|
|
// --------------------------------------------------------------- frame ---
|
|
|
|
_frame(now) {
|
|
if (!this._entered) return;
|
|
const dt = Math.min(0.1, (now - this._lastFrame) / 1000);
|
|
this._lastFrame = now;
|
|
this.map.animate(dt);
|
|
this._fpsFrames += 1;
|
|
this._fpsElapsed += dt;
|
|
if (this._fpsElapsed >= 0.25) {
|
|
const fps = Math.round(this._fpsFrames / this._fpsElapsed);
|
|
this._fpsElapsed = 0;
|
|
this._fpsFrames = 0;
|
|
const $label = $("#fps-label").text(`${fps} FPS`);
|
|
$label.css("color", fps < 30 ? "#f24" : fps < 50 ? "#fa4" : "#8fdc8f");
|
|
}
|
|
requestAnimationFrame((t) => this._frame(t));
|
|
}
|
|
}
|
|
|
|
Object.assign(GameScreen.prototype, panelMethods);
|