Files
Battle-for-Tismo/client/js/game_screen.js
T
2026-09-16 19:59:18 +02:00

404 lines
13 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.
import { MapView } from "./map_view.js";
import { TrainModal, BuildingsModal, NationModal } from "./modals.js";
import { compact, groupDigits, improvements as fmtImprovements } from "../../shared/text_format.js";
import {
CIVILISATIONS,
PROTO_UNITS,
TRAINABLE_UNIT_IDS,
BUILDINGS,
GOVERNMENTS,
TECHNOLOGIES,
} from "../../shared/data.js";
import { dateString } from "../../shared/game_clock.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.selectedUnitId = 0;
this.selectedCityId = 0;
this._trainingCityId = 0;
this._cityBuildings = new Map();
this._population = new Map();
this._territoryOwner = new Map();
this._researched = new Set();
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"),
territory: $("#layer-territory"),
borders: $("#layer-borders"),
fog: $("#layer-fog"),
paths: $("#layer-paths"),
entities: $("#layer-entities"),
}
);
this.trainModal = new TrainModal();
this.buildingsModal = new BuildingsModal();
this.nationModal = new NationModal();
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.$screen.removeClass("active");
}
// ------------------------------------------------------------- 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._territoryOwner = new Map(state.territory.map((e) => [`${e[0]},${e[1]}`, e[2]]));
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.map.setCatalogue(this.civilisations, this.protoUnits);
this.map.applySnapshot(state);
this.map.setSelectedUnit(this.selectedUnitId);
this._updateTopPanel(state);
this._updateBottomPanel();
this._updateTrainModal();
this._updateBuildingsModal();
this._updateNationModal();
}
// ---------------------------------------------------------------- HUD ----
_bindHud() {
$("#city-train").on("click", () => this._openTrainModal());
$("#city-build").on("click", () => this._openBuildingsModal());
$("#civ-flag").on("click", () => this._openNationModal());
this.trainModal.onTrain = (cityId, protoIndex) =>
this.sendOrder({ type: "train", city: cityId, proto: protoIndex });
this.buildingsModal.onBuild = (cityId, index) =>
this.sendOrder({ type: "build", city: cityId, building: index });
this.buildingsModal.onDemolish = (cityId, index) =>
this.sendOrder({ type: "demolish", city: cityId, building: index });
this.nationModal.onGovernment = (index) =>
this.sendOrder({ type: "government", government: index });
this.nationModal.onTechnology = (index) =>
this.sendOrder({ type: "technology", technology: index });
$(window).on("keydown.game", (event) => this._onKey(event));
}
_bindMap() {
this.map.onUnitSelected = (id) => {
if (!id) {
this.selectedUnitId = 0;
this._showUnit(null);
return;
}
const unit = (this.snapshot.units || []).find((u) => u.id === id);
if (!unit || unit.civ !== this.localCiv) {
this.map.setSelectedUnit(0);
this.selectedUnitId = 0;
return;
}
this.selectedUnitId = id;
this.selectedCityId = 0;
this.map.setSelectedUnit(id);
this._showUnit(unit);
};
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.selectedUnitId = 0;
this.map.setSelectedUnit(0);
this._showCity(city);
};
this.map.onTileRequested = (coords) => this._showTile(coords);
this.map.onMoveOrdered = (unitId, coords) => {
const unit = (this.snapshot.units || []).find((u) => u.id === unitId);
if (!unit || unit.civ !== this.localCiv) return;
this.sendOrder({ type: "move", unit: unitId, coords: [coords.x, coords.y] });
this.map.predictMove(unit, coords);
};
}
_updateTopPanel(state) {
const civ = this.civilisations[this.localCiv];
if (civ) {
$("#civ-name").text(civ.name);
const flag = civ.flag || (CIVILISATIONS.find((c) => c.id === civ.id) || {}).flag;
if (flag) $("#civ-flag-img").attr("src", `assets/flags/${flag}`);
}
$("#player-name").text(this.config.playerName || (this.network.players[this.network.localPlayerId] || {}).name || "");
$("#admin-label").toggle(this.network.isAdmin);
$("#stat-date").text(dateString(state.totalHours));
const stats = state.viewerStats || {};
$("#stat-pop").text(compact(stats.population || 0));
$("#stat-gdp").text(`€${compact(stats.gdp || 0)}`);
$("#stat-gdppc").text(`€${compact(stats.gdpPerCapita || 0)}`);
$("#stat-budget").text(`€${compact(stats.budget || 0)}`);
}
_updateBottomPanel() {
if (this.selectedUnitId) {
const unit = this._unit(this.selectedUnitId);
if (unit) this._showUnit(unit);
} else if (this.selectedCityId) {
const city = this._city(this.selectedCityId);
if (city) this._showCity(city);
}
}
_showUnit(unit) {
if (!unit) {
$("#unit-info").addClass("hidden");
this._refreshBottomVisibility();
return;
}
const proto = this.protoUnits[unit.proto];
$("#unit-name").text(proto ? proto.name : "Unit");
const ratio = unit.maxHp > 0 ? (unit.hp / unit.maxHp) * 100 : 0;
$("#unit-health-fill").css("width", `${Math.max(0, Math.min(100, ratio))}%`);
$("#tile-info, #city-info").addClass("hidden");
$("#unit-info").removeClass("hidden");
this._refreshBottomVisibility();
}
_showCity(city) {
if (!city) {
$("#city-info").addClass("hidden");
this._refreshBottomVisibility();
return;
}
$("#city-name").text(city.name);
$("#city-population").text(`Population: ${groupDigits(city.population)}`);
$("#city-improvements").text(`Improvements: ${fmtImprovements(city.improvements)}`);
$("#tile-info, #unit-info").addClass("hidden");
$("#city-info").removeClass("hidden");
this._refreshBottomVisibility();
}
_showTile(coords) {
const k = `${coords.x},${coords.y}`;
const owner = this._territoryOwner.has(k) ? this._territoryOwner.get(k) : -1;
const civ = this.civilisations[owner];
const tile = this.map.getTile(coords);
const city = (this.snapshot.cities || []).find(
(c) => c.coords[0] === coords.x && c.coords[1] === coords.y
);
$("#tile-coords").text(`Tile (${coords.x}, ${coords.y})`);
$("#tile-owner").text(`Owner: ${civ ? civ.name : "Unclaimed"}`);
$("#tile-terrain").text(`Terrain: ${tile ? tile.terrainType : "Unknown"}`);
$("#tile-population").text(`Population: ${groupDigits(this._population.get(k) || 0)}`);
$("#tile-improvements").text(`Improvements: ${fmtImprovements(city ? city.improvements : [])}`);
$("#city-info, #unit-info").addClass("hidden");
$("#tile-info").removeClass("hidden");
this._refreshBottomVisibility();
}
_refreshBottomVisibility() {
const visible =
!$("#tile-info").hasClass("hidden") ||
!$("#city-info").hasClass("hidden") ||
!$("#unit-info").hasClass("hidden");
$("#bottom-panel").toggleClass("hidden", !visible);
}
_unit(id) {
return (this.snapshot.units || []).find((u) => u.id === id) || null;
}
_city(id) {
return (this.snapshot.cities || []).find((c) => c.id === id) || null;
}
// -------------------------------------------------------------- modals ---
_openTrainModal() {
const city = this._city(this.selectedCityId);
if (!city) return;
const trainable = TRAINABLE_UNIT_IDS.map((id) => {
const index = this.protoUnits.findIndex((p) => p.id === id);
return index >= 0 ? { ...this.protoUnits[index], serverIndex: index } : null;
}).filter(Boolean);
this.buildingsModal.close();
this.trainModal.show(
{ id: city.id, name: city.name },
(this.snapshot.viewerStats || {}).gdp || 0,
trainable
);
this._updateTrainModal();
}
_updateTrainModal() {
if (!this.snapshot || !this.selectedCityId) return;
if (!this.trainModal.isShowing(this.selectedCityId)) {
this._trainingCityId = 0;
return;
}
const entry = (this.snapshot.training || {})[this.selectedCityId];
if (!entry) {
if (this._trainingCityId === this.selectedCityId) {
this.trainModal.endTraining();
this._trainingCityId = 0;
}
return;
}
const proto = this.protoUnits[entry.protoIndex];
const name = proto ? proto.name : "unit";
if (this._trainingCityId !== this.selectedCityId) {
this._trainingCityId = this.selectedCityId;
this.trainModal.beginTraining(name, entry.totalHours, entry.elapsedHours);
} else {
this.trainModal.setTrainingProgress(name, entry.elapsedHours, entry.totalHours);
}
}
_openBuildingsModal() {
const city = this._city(this.selectedCityId);
if (!city) return;
this.trainModal.close();
this.buildingsModal.show(
{ id: city.id, name: city.name },
(this.snapshot.viewerStats || {}).gdp || 0,
(this.snapshot.viewerStats || {}).budget || 0,
BUILDINGS,
this._cityBuildings.get(city.id) || {}
);
}
_updateBuildingsModal() {
if (!this.snapshot || !this.buildingsModal.isShowing(this.selectedCityId)) return;
const stats = this.snapshot.viewerStats || {};
this.buildingsModal.refresh(
stats.gdp || 0,
stats.budget || 0,
this._cityBuildings.get(this.selectedCityId) || {}
);
}
_openNationModal() {
this.nationModal.show(
GOVERNMENTS,
(this.snapshot.viewerStats || {}).government || 0,
TECHNOLOGIES,
this._researched,
(this.snapshot.viewerStats || {}).gdp || 0,
(this.snapshot.viewerStats || {}).budget || 0,
(this.snapshot.viewerStats || {}).research || 0
);
}
_updateNationModal() {
if (!this.snapshot || !this.nationModal.isShowing()) return;
const stats = this.snapshot.viewerStats || {};
this.nationModal.refresh(
stats.government || 0,
this._researched,
stats.gdp || 0,
stats.budget || 0,
stats.research || 0
);
}
// --------------------------------------------------------------- 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.trainModal.close();
this.buildingsModal.close();
this.nationModal.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));
}
}