404 lines
13 KiB
JavaScript
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));
|
|
}
|
|
}
|