Files
Battle-for-Tismo/client/js/game_screen.js
T

333 lines
12 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 } from "./modals.js";
import { GameFeed } from "./feed.js";
import { CIVILISATIONS, PROTO_UNITS } from "../../shared/data.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;
this._trainingCityId = 0;
this._cityBuildings = new Map();
this._population = new Map();
this._tileGdpPenalties = new Map();
this._tileBattleGdpDeficits = new Map();
this._territoryOwner = new Map();
this._tileRegion = new Map();
this._battles = new Set();
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"),
{
overview: $("#layer-overview"),
terrain: $("#layer-terrain"),
roads: $("#layer-roads"),
borders: $("#layer-borders"),
fog: $("#layer-fog"),
paths: $("#layer-paths"),
entities: $("#layer-entities"),
labels: $("#layer-labels"),
}
);
this.cityModal = new CityModal();
this.nationModal = new NationModal();
this.confirmModal = new ConfirmModal();
this.newsModal = new NewsModal();
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));
}
leave() {
this._entered = false;
this.map.cancelPathfinding();
this.$screen.removeClass("active");
this.feed.clear();
this._seenNews = null;
this._hideStackMenu();
$(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,
});
}
// ----------------------------------------------------------- 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._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);
this.selectedUnitIds = this.selectedUnitIds.filter((id) => this._unit(id));
this.map.setSelectedUnits(this.selectedUnitIds);
this._updateTopPanel(state);
this._updateBottomPanel();
this._updateCityModal();
this._updateNationModal();
this._updateNewsModal();
}
_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 { targets, speed, routes } = await this._groupMovePlan(units, coords);
// Aircraft are refused an order outside their mission radius before it is
// ever sent, so the browser and the server never disagree on a route.
const outOfRange = units.some((unit) => {
const proto = this.protoUnits[unit.proto];
if (!proto || !proto.air) return false;
const route = routes.get(unit.id);
if (!route) return false;
// A bombing run is limited by the strike range; a ferry flight by the
// aircraft's mission radius.
const limit = type === "attack" && proto.strike
? (proto.range || 1)
: (proto.missionRange || Infinity);
return route.length - 1 > limit;
});
if (outOfRange) {
this.confirmModal.show("Out of range", "That target lies beyond this aircraft'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 bombing run is resolved instantly on the server without moving, so
// predicting a march there would only make the icon drift.
const strike = type === "attack" && units.every((unit) => {
const proto = this.protoUnits[unit.proto];
return proto && proto.strike;
});
if (strike) 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":
this.cityModal.close();
this.nationModal.close();
this.confirmModal.close();
this.newsModal.close();
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);