Visual improvements

This commit is contained in:
2026-09-17 00:28:17 +02:00
parent dd75a545e4
commit bc727c408f
13 changed files with 691 additions and 62 deletions
+32 -5
View File
@@ -144,7 +144,6 @@ input:focus, select:focus { border-color: var(--accent); }
background-repeat: no-repeat;
background-size: 441px 56px;
}
.hex.territory { pointer-events: none; }
.hex.fog { pointer-events: none; }
.fog-unexplored { background: #04040a; }
.fog-explored { background: rgba(4, 6, 18, 0.62); }
@@ -192,6 +191,14 @@ input:focus, select:focus { border-color: var(--accent); }
display: block;
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.9));
}
.unit .status-icon {
width: calc(var(--unit-size, 38px) / 3);
height: calc(var(--unit-size, 38px) / 3);
}
.city .status-icon {
width: calc(var(--city-size, 26px) / 3);
height: calc(var(--city-size, 26px) / 3);
}
.unit .body {
width: 100%;
height: 100%;
@@ -219,12 +226,13 @@ input:focus, select:focus { border-color: var(--accent); }
.unit .health .fill { height: 100%; background: #46d36a; }
.city {
--city-size: 26px;
transform: translate(-50%, -50%);
text-align: center;
}
.city .marker {
width: 26px;
height: 26px;
width: var(--city-size);
height: var(--city-size);
margin: 0 auto;
border-radius: 5px;
border: 2px solid rgba(255, 255, 255, 0.8);
@@ -238,8 +246,8 @@ input:focus, select:focus { border-color: var(--accent); }
.city-label-layer { pointer-events: none; }
.city-label {
position: absolute;
transform: translate(-50%, 0);
margin-top: 15px;
transform: translate(-50%, -100%);
margin-top: -21px;
font-size: 11px;
color: #fff;
text-align: center;
@@ -247,6 +255,11 @@ input:focus, select:focus { border-color: var(--accent); }
white-space: nowrap;
pointer-events: none;
}
.city-label.capital::before {
content: "\2605";
color: #ffd54a;
margin-right: 3px;
}
.path-seg {
position: absolute;
@@ -376,6 +389,8 @@ input:focus, select:focus { border-color: var(--accent); }
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.7);
overflow: hidden;
}
.modal-card-small { width: 420px; }
.modal-card-small .row { padding-bottom: 18px; }
.modal-head {
display: flex;
justify-content: space-between;
@@ -436,6 +451,18 @@ input:focus, select:focus { border-color: var(--accent); }
.progress { height: 14px; margin-top: 8px; background: rgba(0, 0, 0, 0.5); border-radius: 7px; overflow: hidden; }
#train-progress, #buildings-progress { height: 100%; width: 0; background: linear-gradient(180deg, #7fc4ff, #2f7fd0); }
.news-item {
padding: 10px 12px;
margin-bottom: 8px;
border-radius: 9px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.news-item .news-text { font-weight: 600; }
.news-item .news-date { color: var(--muted); font-size: 12px; margin-top: 3px; }
.news-empty { color: var(--muted); padding: 8px 0; }
#news-button { flex: 0 0 auto; }
.banner {
position: absolute;
left: 50%;
+33 -2
View File
@@ -82,7 +82,6 @@
<div id="map-world">
<div class="layer" id="layer-overview"></div>
<div class="layer" id="layer-terrain"></div>
<div class="layer" id="layer-territory"></div>
<div class="layer" id="layer-borders"></div>
<div class="layer" id="layer-fog"></div>
<div class="layer" id="layer-paths"></div>
@@ -96,6 +95,7 @@
<button id="civ-flag" class="flag-button" title="Government and technology">
<img id="civ-flag-img" alt="" />
</button>
<button id="news-button" class="aero" title="News">News</button>
<div class="top-text">
<div id="civ-name" class="civ-name">—</div>
<div id="player-name" class="player-name"></div>
@@ -167,7 +167,33 @@
</div>
</div>
<!-- Nation modal: government, technology and budget tabs -->
<!-- Generic confirmation modal -->
<div id="modal-confirm" class="modal hidden">
<div class="modal-card modal-card-small">
<div class="modal-head">
<span id="confirm-title">Confirm</span>
<button class="modal-close aero">✕</button>
</div>
<div id="confirm-message" class="modal-body"></div>
<div class="row">
<button id="confirm-cancel" class="aero">Cancel</button>
<button id="confirm-ok" class="aero primary">Confirm</button>
</div>
</div>
</div>
<!-- News feed modal -->
<div id="modal-news" class="modal hidden">
<div class="modal-card">
<div class="modal-head">
<span id="news-title">News</span>
<button class="modal-close aero">✕</button>
</div>
<div id="news-list" class="modal-body"></div>
</div>
</div>
<!-- Nation modal: government, technology, diplomacy and budget tabs -->
<div id="modal-nation" class="modal hidden">
<div class="modal-card">
<div class="modal-head">
@@ -177,6 +203,7 @@
<div class="tabs">
<button class="tab active" data-tab="government">Government</button>
<button class="tab" data-tab="technology">Technology</button>
<button class="tab" data-tab="diplomacy">Diplomacy</button>
<button class="tab" data-tab="budget">Budget</button>
</div>
<div class="tab-page active" id="tab-government">
@@ -187,6 +214,10 @@
<div id="technology-sub" class="modal-sub"></div>
<div id="technology-list" class="modal-body"></div>
</div>
<div class="tab-page" id="tab-diplomacy">
<div id="diplomacy-sub" class="modal-sub"></div>
<div id="diplomacy-list" class="modal-body"></div>
</div>
<div class="tab-page" id="tab-budget">
<div id="budget-sub" class="modal-sub"></div>
<div id="budget-list" class="modal-body"></div>
+88 -6
View File
@@ -4,7 +4,7 @@
// same cost model as the server.
import { MapView } from "./map_view.js";
import { TrainModal, BuildingsModal, NationModal } from "./modals.js";
import { TrainModal, BuildingsModal, NationModal, ConfirmModal, NewsModal } from "./modals.js";
import { compact, groupDigits, improvements as fmtImprovements } from "../../shared/text_format.js";
import {
CIVILISATIONS,
@@ -33,6 +33,8 @@ export class GameScreen {
this._population = new Map();
this._territoryOwner = new Map();
this._researched = new Set();
this._wars = new Set();
this._news = [];
this._entered = false;
this._lastFrame = 0;
this._fpsFrames = 0;
@@ -44,7 +46,6 @@ export class GameScreen {
{
overview: $("#layer-overview"),
terrain: $("#layer-terrain"),
territory: $("#layer-territory"),
borders: $("#layer-borders"),
fog: $("#layer-fog"),
paths: $("#layer-paths"),
@@ -55,6 +56,8 @@ export class GameScreen {
this.trainModal = new TrainModal();
this.buildingsModal = new BuildingsModal();
this.nationModal = new NationModal();
this.confirmModal = new ConfirmModal();
this.newsModal = new NewsModal();
this.$statTooltip = $("#stat-tooltip");
this._hoveredStat = null;
@@ -111,6 +114,14 @@ export class GameScreen {
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._news = state.news || [];
this.map.setCatalogue(this.civilisations, this.protoUnits);
this.map.applySnapshot(state);
@@ -120,6 +131,7 @@ export class GameScreen {
this._updateTrainModal();
this._updateBuildingsModal();
this._updateNationModal();
this._updateNewsModal();
}
// ---------------------------------------------------------------- HUD ----
@@ -128,6 +140,7 @@ export class GameScreen {
$("#city-train").on("click", () => this._openTrainModal());
$("#city-build").on("click", () => this._openBuildingsModal());
$("#civ-flag").on("click", () => this._openNationModal());
$("#news-button").on("click", () => this._openNewsModal());
this.trainModal.onTrain = (cityId, protoIndex) =>
this.sendOrder({ type: "train", city: cityId, proto: protoIndex });
@@ -139,6 +152,8 @@ export class GameScreen {
this.sendOrder({ type: "government", government: index });
this.nationModal.onTechnology = (index) =>
this.sendOrder({ type: "technology", technology: index });
this.nationModal.onDeclareWar = (target) =>
this.sendOrder({ type: "declare_war", target });
$("#unit-attack")
.on("mouseenter", (event) => this._showStatTooltip($(event.currentTarget)))
@@ -200,11 +215,57 @@ export class GameScreen {
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);
const targets = this._unalliedTerritoryOnRoute(unit, coords);
if (targets.length > 0) {
const names = targets.map((index) => this._civName(index)).join(", ");
this.confirmModal.show(
"Declare war?",
`Marching into ${names} territory will declare war on ${names}. Continue?`,
() => this._sendMove(unit, coords)
);
return;
}
this._sendMove(unit, coords);
};
}
_sendMove(unit, coords) {
this.sendOrder({ type: "move", unit: unit.id, coords: [coords.x, coords.y] });
this.map.predictMove(unit, coords);
}
// The civilisations, other than our own, whose territory the predicted route
// would enter while we are at peace with them. Only military units can start
// a war this way; civilians wander in without one.
_unalliedTerritoryOnRoute(unit, coords) {
if (!this._isMilitaryUnit(unit)) return [];
const route = this.map.planMove(unit, coords);
if (!route || route.length < 2) return [];
const targets = [];
for (const point of route) {
const owner = this.map.territory.get(`${point.x},${point.y}`);
if (owner === undefined || owner < 0 || owner === unit.civ) continue;
if (this._isAtWar(unit.civ, owner)) continue;
if (!targets.includes(owner)) targets.push(owner);
}
return targets;
}
_isMilitaryUnit(unit) {
const proto = this.protoUnits[unit.proto];
return !!(proto && proto.military);
}
_isAtWar(a, b) {
if (a < 0 || b < 0 || a === b) return false;
return this._wars.has(a < b ? `${a}:${b}` : `${b}:${a}`);
}
_civName(index) {
const civ = this.civilisations[index];
return civ ? civ.name : `Nation ${index + 1}`;
}
_updateTopPanel(state) {
const civ = this.civilisations[this.localCiv];
if (civ) {
@@ -408,7 +469,8 @@ export class GameScreen {
stats.gdp || 0,
stats.budget || 0,
stats.research || 0,
this._finances(stats)
this._finances(stats),
this._diplomacyInfo()
);
}
@@ -421,10 +483,28 @@ export class GameScreen {
stats.gdp || 0,
stats.budget || 0,
stats.research || 0,
this._finances(stats)
this._finances(stats),
this._diplomacyInfo()
);
}
_diplomacyInfo() {
return {
civs: this.civilisations,
localCiv: this.localCiv,
relations: (this.snapshot && this.snapshot.diplomacy) || [],
};
}
_openNewsModal() {
this.newsModal.show(this._news, this.civilisations);
}
_updateNewsModal() {
if (!this.snapshot || !this.newsModal.isShowing()) return;
this.newsModal.refresh(this._news, this.civilisations);
}
_finances(stats) {
return stats.breakdown || null;
}
@@ -451,6 +531,8 @@ export class GameScreen {
this.trainModal.close();
this.buildingsModal.close();
this.nationModal.close();
this.confirmModal.close();
this.newsModal.close();
break;
default:
return;
+30 -42
View File
@@ -39,7 +39,6 @@ export class MapView {
this.$viewport = $viewport;
this.$world = $world;
this.$terrain = layers.terrain;
this.$territory = layers.territory;
this.$borders = layers.borders;
this.$fog = layers.fog;
this.$paths = layers.paths;
@@ -99,7 +98,6 @@ export class MapView {
this._worldMinX = 0;
this._worldMinY = 0;
this._terrainWrap = [];
this._territoryWrap = [];
this._borderWrap = [];
this._fogWrap = [];
this._wrapCameraX = null;
@@ -151,11 +149,9 @@ export class MapView {
this._territorySignature = "";
this._fogSignature = "";
this._territoryWrap = [];
this._borderWrap = [];
this._fogWrap = [];
this._wrapCameraX = null;
this.$territory.empty();
this.$borders.empty();
this.$fog.empty();
this.$entities.empty();
@@ -225,31 +221,9 @@ export class MapView {
this._territorySignature = signature;
this.territory = new Map();
this._territoryWrap = [];
const territoryFragment = document.createDocumentFragment();
for (const entry of entries) {
const x = entry[0];
const y = entry[1];
const owner = entry[2];
this.territory.set(key(x, y), owner);
if (owner < 0 || !this.civilisations[owner]) continue;
const local = mapToLocal(x, y);
const div = document.createElement("div");
div.className = "hex territory detail-layer";
div.style.left = `${local.x - HEX_W / 2}px`;
div.style.top = `${local.y - HEX_H / 2}px`;
div.style.background = this.civilisations[owner].primaryColour;
div.style.opacity = "0.28";
territoryFragment.appendChild(div);
this._territoryWrap.push({
el: div,
centerX: local.x,
shift: null,
baseTransform: "",
});
this.territory.set(key(entry[0], entry[1]), entry[2]);
}
this.$territory.empty()[0].appendChild(territoryFragment);
this.$territory.addClass("detail-layer");
this._renderBorders();
this._drawOverview();
@@ -257,7 +231,7 @@ export class MapView {
}
// Draws a thick tinted segment along every edge shared with a different owner
// (or the open sea), matching the territory overlay. Each segment is
// (or the open sea). Each segment is
// nudged towards its owner's tile by half its width so two neighbours' borders
// sit side by side instead of painting over each other on the shared edge.
_renderBorders() {
@@ -344,7 +318,8 @@ export class MapView {
view.style.top = `${local.y}px`;
const owned = data.civ === this.localCiv;
const label = view._label;
label.textContent = data.isCapital ? `${data.name} (capital)` : data.name;
label.textContent = data.name;
label.classList.toggle("capital", !!data.isCapital);
label.style.left = `${wrappedX}px`;
label.style.top = `${local.y}px`;
const marker = view.querySelector(".marker");
@@ -618,7 +593,7 @@ export class MapView {
return { x: point.x + this._wrapShift(point.x), y: point.y };
}
// Shifts the static layers (terrain, territory, borders, fog) so they cover
// Shifts the static layers (terrain, borders, fog) so they cover
// the visible period. Only elements whose period index changed are touched.
_applyWrap(force = false) {
if (force) this._wrapCameraX = null;
@@ -627,7 +602,7 @@ export class MapView {
if (this.lod) return;
if (this._wrapCameraX === this.camera.x) return;
this._wrapCameraX = this.camera.x;
for (const list of [this._terrainWrap, this._territoryWrap, this._borderWrap, this._fogWrap]) {
for (const list of [this._terrainWrap, this._borderWrap, this._fogWrap]) {
for (const item of list) {
const shift = this._wrapShift(item.centerX);
if (shift === item.shift) continue;
@@ -937,8 +912,30 @@ export class MapView {
// -------------------------------------------------------- move preview --
// Mirrors GameState.find_path closely enough that a click feels instant; the
// next snapshot confirms or corrects the route.
// next snapshot confirms or corrects the route. `planMove` computes the route
// without touching the view, so callers can inspect it (for example to warn
// before entering foreign territory) before committing to the prediction.
planMove(unitData, goal) {
const plan = this._planMove(unitData, goal);
return plan ? plan.route : null;
}
predictMove(unitData, goal) {
const plan = this._planMove(unitData, goal);
if (!plan) return null;
this._unitMotion.set(unitData.id, {
path: plan.route,
segmentHours: plan.segmentHours,
pathHours: 0,
coords: plan.start,
predicting: true,
predictAge: 0,
});
this.animate(0);
return plan.route;
}
_planMove(unitData, goal) {
const proto = this.protoUnits[unitData.proto];
if (!proto || !proto.moveable || !this.topology) return null;
const civ = unitData.civ;
@@ -1010,16 +1007,7 @@ export class MapView {
for (let i = 1; i < route.length; i++) {
segmentHours.push(this._stepHours(proto, civ, route[i]));
}
this._unitMotion.set(unitData.id, {
path: route,
segmentHours,
pathHours: 0,
coords: start,
predicting: true,
predictAge: 0,
});
this.animate(0);
return route;
return { route, segmentHours, start };
}
_isMilitaryData(data) {
+142 -3
View File
@@ -197,12 +197,15 @@ export class NationModal {
this.$technologyList = $("#technology-list");
this.$budgetSub = $("#budget-sub");
this.$budgetList = $("#budget-list");
this.$diplomacySub = $("#diplomacy-sub");
this.$diplomacyList = $("#diplomacy-list");
this.governments = [];
this.technologies = [];
this.currentGovernment = 0;
this.researched = new Set();
this.governmentRows = [];
this.technologyRows = [];
this.diplomacy = null;
this.budgetRows = {
income: this._makeBudgetRow("Tax income", "State revenue drawn from the nation's GDP."),
building: this._makeBudgetRow("Building upkeep", "Upkeep of every building across your cities."),
@@ -211,6 +214,7 @@ export class NationModal {
};
this.onGovernment = () => {};
this.onTechnology = () => {};
this.onDeclareWar = () => {};
this.$modal.find(".modal-close").on("click", () => this.close());
this.$modal.find(".tab").on("click", (event) => {
const tab = $(event.currentTarget).data("tab");
@@ -225,12 +229,13 @@ export class NationModal {
return !this.$modal.hasClass("hidden");
}
show(governments, currentGovernment, technologies, researched, gdp, budget, research, finances) {
show(governments, currentGovernment, technologies, researched, gdp, budget, research, finances, diplomacy = null) {
this.governments = governments;
this.technologies = technologies;
this.diplomacy = diplomacy;
this.governmentRows = governments.map((proto, index) => this._makeRow(this.$governmentList, proto, index, "government"));
this.technologyRows = technologies.map((proto, index) => this._makeRow(this.$technologyList, proto, index, "technology"));
this.refresh(currentGovernment, researched, gdp, budget, research, finances);
this.refresh(currentGovernment, researched, gdp, budget, research, finances, diplomacy);
this.$modal.removeClass("hidden");
}
@@ -283,9 +288,10 @@ export class NationModal {
return { proto, index, kind, $name, $effects, $cost, $action };
}
refresh(currentGovernment, researched, gdp, budget, research, finances = {}) {
refresh(currentGovernment, researched, gdp, budget, research, finances = {}, diplomacy = null) {
this.currentGovernment = currentGovernment;
this.researched = researched instanceof Set ? researched : new Set(Object.keys(researched).map(Number));
if (diplomacy !== null) this.diplomacy = diplomacy;
this.$governmentSub.text(
`Government — ${this.governments[currentGovernment] ? this.governments[currentGovernment].name : "None"} · Budget: €${groupDigits(Math.round(budget))}`
);
@@ -367,9 +373,142 @@ export class NationModal {
row.$action.text(`Research ${groupDigits(Math.round(proto.cost))}`).prop("disabled", research < proto.cost);
}
}
this._renderDiplomacy();
}
// Lists every other nation with its relation to the player and a button to
// declare war. `diplomacy` is { civs, localCiv, relations }, where relations
// is the snapshot's [a, b, status] matrix.
_renderDiplomacy() {
if (!this.$diplomacyList || !this.$diplomacyList.length) return;
this.$diplomacyList.empty();
const info = this.diplomacy;
if (!info) return;
const civs = info.civs || [];
const localCiv = info.localCiv;
const wars = new Set();
for (const entry of info.relations || []) {
if (entry[2] !== "war") continue;
if (entry[0] === localCiv) wars.add(entry[1]);
else if (entry[1] === localCiv) wars.add(entry[0]);
}
if (this.$diplomacySub && this.$diplomacySub.length) {
const atWar = wars.size;
this.$diplomacySub.text(
atWar === 0
? "Your nation is at peace with every other nation."
: `Your nation is at war with ${atWar} nation${atWar === 1 ? "" : "s"}.`
);
}
for (let index = 0; index < civs.length; index++) {
if (index === localCiv) continue;
const $card = $("<div class='row-card'></div>");
const $info = $("<div class='info'></div>");
const $name = $("<div class='name'></div>").text(civs[index].name);
const atWar = wars.has(index);
const $status = $("<div class='desc'></div>").text(atWar ? "At war" : "At peace");
$info.append($name, $status);
const $actions = $("<div class='actions'></div>");
const $action = $("<button class='aero'></button>");
if (atWar) {
$action.text("At war").prop("disabled", true);
} else {
$action.text("Declare war");
$action.on("click", () => this.onDeclareWar(index));
}
$actions.append($action);
$card.append($info, $actions);
this.$diplomacyList.append($card);
}
}
close() {
this.$modal.addClass("hidden");
}
}
// A generic yes/no prompt. `show` takes the callbacks it should run when the
// player confirms; cancelling (or closing) runs nothing.
export class ConfirmModal {
constructor() {
this.$modal = $("#modal-confirm");
this.$title = $("#confirm-title");
this.$message = $("#confirm-message");
this._onConfirm = null;
this.$modal.find(".modal-close").on("click", () => this.close());
this.$modal.find("#confirm-cancel").on("click", () => this.close());
this.$modal.find("#confirm-ok").on("click", () => {
const callback = this._onConfirm;
this.close();
if (callback) callback();
});
}
isShowing() {
return !this.$modal.hasClass("hidden");
}
show(title, message, onConfirm) {
this.$title.text(title);
this.$message.text(message);
this._onConfirm = onConfirm || null;
this.$modal.removeClass("hidden");
}
close() {
this.$modal.addClass("hidden");
this._onConfirm = null;
}
}
// The news feed: a reverse-chronological list of newsworthy events. Civ indices
// are resolved to names through the catalogue the game screen passes in.
export class NewsModal {
constructor() {
this.$modal = $("#modal-news");
this.$list = $("#news-list");
this.$modal.find(".modal-close").on("click", () => this.close());
}
isShowing() {
return !this.$modal.hasClass("hidden");
}
show(news, civs) {
this.refresh(news, civs);
this.$modal.removeClass("hidden");
}
refresh(news, civs = []) {
if (!this.$list || !this.$list.length) return;
this.$list.empty();
const entries = (news || []).slice().reverse();
if (entries.length === 0) {
this.$list.append($("<div class='news-empty'></div>").text("No news yet."));
return;
}
for (const entry of entries) {
const $item = $("<div class='news-item'></div>");
$item.append($("<div class='news-text'></div>").text(describeNews(entry, civs)));
$item.append($("<div class='news-date'></div>").text(entry.date || ""));
this.$list.append($item);
}
}
close() {
this.$modal.addClass("hidden");
}
}
function describeNews(entry, civs) {
const name = (index) => (civs[index] ? civs[index].name : `Nation ${index + 1}`);
switch (entry.type) {
case "war":
return `${name(entry.actor)} declared war on ${name(entry.target)}`;
case "capture":
return `${name(entry.to)} captured ${entry.city} from ${name(entry.from)}`;
default:
return entry.type;
}
}
+7
View File
@@ -114,6 +114,8 @@ export class GameServer extends EventEmitter {
return this._handleGovernment(civ, order);
case "technology":
return this._handleTechnology(civ, order);
case "declare_war":
return this._handleDeclareWar(civ, order);
default:
return false;
}
@@ -151,6 +153,11 @@ export class GameServer extends EventEmitter {
return this.state.requestResearchTechnology(civ, Number(order.technology));
}
_handleDeclareWar(civ, order) {
const target = Number(order.target !== undefined ? order.target : order.civ);
return this.state.requestDeclareWar(civ, target);
}
onPeerJoined(peerId, playerName = "") {
if (!this._configured) return;
this._assignCiv(peerId, playerName);
+12
View File
@@ -7,6 +7,18 @@ export const EFFECT_RESEARCH = "research";
export const EFFECT_CULTURE = "culture";
export const EFFECT_BUDGET_INCOME = "budget_income";
// Diplomatic relations between two civilisations. Only peace and war exist for
// now, but the catalogue is data-driven so further statuses (alliances,
// truces, ...) can be slotted in without touching the simulation.
export const RELATION_PEACE = "peace";
export const RELATION_WAR = "war";
export const RELATIONS = [RELATION_PEACE, RELATION_WAR];
// Kinds of news item shown in the feed. Newsworthy events are recorded in
// GameState with their in-game hour so every client can render them.
export const NEWS_WAR = "war";
export const NEWS_CAPTURE = "capture";
function effect(stat, baseValue, exponent = 1.0) {
return { stat, baseValue, exponent };
}
+105
View File
@@ -23,9 +23,18 @@ import {
EFFECT_CULTURE,
STATUS_ENCIRCLED,
STATUS_BESIEGED,
RELATION_PEACE,
RELATION_WAR,
NEWS_WAR,
NEWS_CAPTURE,
statusById,
} from "./data.js";
import { buildingBuildCost, buildingRefund, buildingUpkeep, prerequisitesMet, defenseMultiplier } from "./rules.js";
import { dateString } from "./game_clock.js";
// The news feed keeps the most recent entries and drops the oldest ones, so a
// long-running game does not grow the snapshot without bound.
export const MAX_NEWS = 100;
export const UNIT_VISION = 2;
export const TERRITORY_VISION = 1;
@@ -65,6 +74,10 @@ export class GameState {
this.researched = new Map();
this.explored = new Map();
this.visible = new Map();
// Diplomatic relations, stored as a set of canonical "a:b" keys of pairs
// at war. Every other pair is at peace by default.
this.wars = new Set();
this.news = [];
this.totalHours = 0;
@@ -75,6 +88,7 @@ export class GameState {
this._maxStepLength = 1.0;
this._nextUnitId = 1;
this._nextCityId = 1;
this._nextNewsId = 1;
this._cityNamesUsed = new Map();
this._unitById = new Map();
this._cityById = new Map();
@@ -127,6 +141,9 @@ export class GameState {
this.culture = new Map();
this.government = new Map();
this.researched = new Map();
this.wars = new Set();
this.news = [];
this._nextNewsId = 1;
this._nextUnitId = 1;
this._nextCityId = 1;
this._unitById = new Map();
@@ -234,6 +251,59 @@ export class GameState {
return true;
}
// ---------------------------------------------------------- diplomacy --
isAtWar(a, b) {
if (a < 0 || b < 0 || a === b) return false;
return this.wars.has(warKey(a, b));
}
getRelation(a, b) {
return this.isAtWar(a, b) ? RELATION_WAR : RELATION_PEACE;
}
// Declares war from `civ` on `target`. Fails for unknown or identical
// civilisations and when the two are already at war. The declaration is
// newsworthy and reaches every player through the next snapshot.
requestDeclareWar(civ, target) {
if (!this._validCiv(civ) || !this._validCiv(target) || civ === target) return false;
if (!this._declareWar(civ, target)) return false;
this._emitChanged();
return true;
}
_validCiv(civ) {
return Number.isInteger(civ) && civ >= 0 && civ < this.civilisations.length;
}
// Records the war and its news item without emitting; the caller decides when
// to publish. Returns false when the pair is already at war.
_declareWar(actor, target) {
if (actor === target) return false;
const k = warKey(actor, target);
if (this.wars.has(k)) return false;
this.wars.add(k);
this._addNews({ type: NEWS_WAR, actor, target });
return true;
}
getNews() {
return this.news;
}
_addNews(entry) {
const record = {
id: this._nextNewsId,
totalHours: this.totalHours,
date: dateString(this.totalHours),
...entry,
};
this._nextNewsId += 1;
this.news.push(record);
while (this.news.length > MAX_NEWS) this.news.shift();
return record;
}
requestMove(unitId, goal) {
const unit = this.findUnit(unitId);
if (!unit) return false;
@@ -251,6 +321,16 @@ export class GameState {
unit.attackTarget = null;
return false;
}
// Marching a military unit into, or attacking, a country we are at peace
// with declares war on it. The browser confirms the move first, but the
// server enforces the rule so the two can never disagree.
if (this._isMilitary(unit)) {
if (attacking) this._declareWar(unit.civ, target.civ);
for (const coords of path) {
const owner = this.civAt(coords);
if (owner >= 0 && owner !== unit.civ) this._declareWar(unit.civ, owner);
}
}
unit.path = path;
unit.pathIndex = 0;
unit.progressHours = 0;
@@ -1200,6 +1280,7 @@ export class GameState {
this._reindexTerritory();
this._rebuildCityIndex();
this._visibilityDirty = true;
this._addNews({ type: NEWS_CAPTURE, city: city.name, from: oldCiv, to: newCiv });
this._emitChanged();
return true;
}
@@ -1460,6 +1541,8 @@ export class GameState {
culture: this._serializeCivFloats(this.culture),
government: this._serializeCivInts(this.government),
researched: this._serializeResearched(),
diplomacy: this._serializeDiplomacy(),
news: this._serializeNews(),
training: Object.fromEntries(
Array.from(this.training.entries()).map(([cityId, entry]) => [
cityId,
@@ -1580,6 +1663,28 @@ export class GameState {
}
return result;
}
// Every unordered pair with its current status. With a handful of
// civilisations the whole matrix is tiny, and it lets the client show the
// status of every nation without a second lookup.
_serializeDiplomacy() {
const result = [];
for (let a = 0; a < this.civilisations.length; a++) {
for (let b = a + 1; b < this.civilisations.length; b++) {
result.push([a, b, this.getRelation(a, b)]);
}
}
return result;
}
_serializeNews() {
return this.news.map((entry) => ({ ...entry }));
}
}
// Canonical key for an unordered pair of civilisations.
function warKey(a, b) {
return a < b ? `${a}:${b}` : `${b}:${a}`;
}
function effectAt(e, level) {
-1
View File
@@ -55,7 +55,6 @@ export function mapLayers($) {
return {
overview: $("#layer-overview"),
terrain: $("#layer-terrain"),
territory: $("#layer-territory"),
borders: $("#layer-borders"),
fog: $("#layer-fog"),
paths: $("#layer-paths"),
+111
View File
@@ -297,4 +297,115 @@ export class GameScreenTest extends TestCase {
teardownDom(env);
}
}
async test_move_into_peaceful_territory_prompts_for_war() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = defaultState();
screen.onState(state.snapshot(0));
const unit = state.units.find((u) => u.civ === 0);
const goal = { x: 900, y: 900 };
screen.map.territory.set("900,900", 1);
screen.map.planMove = () => [{ x: 1, y: 1 }, goal];
let predicted = null;
screen.map.predictMove = (u, coords) => {
predicted = { u, coords };
};
screen.map.onMoveOrdered(unit.id, goal);
this.assertFalse(env.$("#modal-confirm").hasClass("hidden"), "the war prompt opens");
this.assertEmpty(network.orders, "nothing is sent before confirming");
this.assertTrue(env.$("#confirm-message").text().includes("Britain"));
env.$("#confirm-ok").click();
this.assertEqual(network.orders[0], { type: "move", unit: unit.id, coords: [900, 900] });
this.assertNotNull(predicted, "the confirmed move is predicted locally");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_cancelling_the_war_prompt_sends_nothing() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = defaultState();
screen.onState(state.snapshot(0));
const unit = state.units.find((u) => u.civ === 0);
const goal = { x: 902, y: 902 };
screen.map.territory.set("902,902", 1);
screen.map.planMove = () => [{ x: 1, y: 1 }, goal];
screen.map.onMoveOrdered(unit.id, goal);
env.$("#confirm-cancel").click();
this.assertEmpty(network.orders, "cancelling keeps the peace");
screen.leave();
} finally {
teardownDom(env);
}
}
async test_move_into_enemy_territory_skips_the_prompt() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
const state = defaultState();
state.requestDeclareWar(0, 1);
screen.onState(state.snapshot(0));
const unit = state.units.find((u) => u.civ === 0);
const goal = { x: 901, y: 901 };
screen.map.territory.set("901,901", 1);
screen.map.planMove = () => [{ x: 1, y: 1 }, goal];
screen.map.predictMove = () => null;
screen.map.onMoveOrdered(unit.id, goal);
this.assertTrue(env.$("#modal-confirm").hasClass("hidden"));
this.assertEqual(network.orders[0], { type: "move", unit: unit.id, coords: [901, 901] });
screen.leave();
} finally {
teardownDom(env);
}
}
async test_diplomacy_tab_sends_a_declare_war_order() {
const env = await setupDom();
try {
const network = stubNetwork();
const screen = new GameScreen(network, makeConfig());
screen.enter();
screen.onState(defaultState().snapshot(0));
env.$("#civ-flag").click();
env.$("#modal-nation .tab[data-tab=diplomacy]").click();
const row = env.$("#diplomacy-list .row-card").first();
this.assertTrue(row.find(".name").text().length > 0);
row.find("button").click();
this.assertEqual(network.orders[0].type, "declare_war");
this.assertEqual(network.orders[0].target, 1);
screen.leave();
} finally {
teardownDom(env);
}
}
async test_news_feed_shows_declarations() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = defaultState();
state.requestDeclareWar(0, 1);
screen.onState(state.snapshot(0));
env.$("#news-button").click();
this.assertFalse(env.$("#modal-news").hasClass("hidden"));
this.assertTrue(env.$("#news-list").text().includes("declared war on Britain"));
screen.leave();
} finally {
teardownDom(env);
}
}
}
+22 -1
View File
@@ -22,7 +22,7 @@ export class MapViewTest extends TestCase {
const { env, map, snapshot } = await this.build(state);
try {
this.assertSize(env.$("#layer-terrain .hex"), 64 * 40);
this.assertSize(env.$("#layer-territory .hex"), snapshot.territory.length);
this.assertSize(map.territory, snapshot.territory.length);
this.assertSize(env.$("#layer-fog .hex"), 64 * 40 - snapshot.visible.length);
this.assertGreater(env.$("#layer-borders .border-seg").length, 0);
this.assertSize(env.$("#layer-entities .city"), snapshot.cities.length);
@@ -246,6 +246,27 @@ export class MapViewTest extends TestCase {
}
}
async test_plan_move_computes_a_route_without_predicting() {
const state = defaultState();
const { env, map, snapshot } = await this.build(state);
try {
const unit = snapshot.units.find((u) => u.civ === 0);
const stateUnit = state.units.find((u) => u.id === unit.id);
const goal = adjacentLand(state, stateUnit);
const before = map._unitMotion.get(unit.id);
const route = map.planMove(unit, goal);
this.assertNotNull(route, "a route was planned");
this.assertGreaterOrEqual(route.length, 2);
this.assertEqual(map._unitMotion.get(unit.id), before, "planning does not start a prediction");
this.assertFalse(map._unitMotion.get(unit.id).predicting);
const applied = map.predictMove(unit, goal);
this.assertEqual(applied, route, "predicting follows the planned route");
this.assertTrue(map._unitMotion.get(unit.id).predicting);
} finally {
teardownDom(env);
}
}
async test_pan_moves_the_camera() {
const state = defaultState();
const { env, map } = await this.build(state);
+103 -1
View File
@@ -1,6 +1,6 @@
import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.js";
import { TrainModal, BuildingsModal, NationModal } from "../client/js/modals.js";
import { TrainModal, BuildingsModal, NationModal, ConfirmModal, NewsModal } from "../client/js/modals.js";
import { BUILDINGS, GOVERNMENTS, TECHNOLOGIES, PROTO_UNITS } from "../shared/data.js";
export class TrainModalTest extends TestCase {
@@ -189,4 +189,106 @@ export class NationModalTest extends TestCase {
teardownDom(env);
}
}
async test_diplomacy_tab_lists_nations_and_declares_war() {
const env = await setupDom();
try {
const modal = new NationModal();
let declared = null;
modal.onDeclareWar = (index) => {
declared = index;
};
const civs = [{ name: "France" }, { name: "Britain" }, { name: "Poland" }];
const relations = [[0, 1, "peace"], [0, 2, "war"], [1, 2, "peace"]];
modal.show(
GOVERNMENTS, 0, TECHNOLOGIES, new Set(), 1.0e9, 1.0e15, 1.0e6, {},
{ civs, localCiv: 0, relations }
);
env.$("#modal-nation .tab[data-tab=diplomacy]").click();
this.assertTrue(env.$("#tab-diplomacy").hasClass("active"));
const rows = env.$("#diplomacy-list .row-card");
this.assertSize(rows, 2, "every other nation is listed");
this.assertTrue(rows.eq(0).find(".name").text() === "Britain");
this.assertTrue(rows.eq(0).find(".desc").text().includes("At peace"));
this.assertTrue(rows.eq(1).find(".name").text() === "Poland");
this.assertTrue(rows.eq(1).find(".desc").text().includes("At war"));
this.assertTrue(rows.eq(1).find("button").prop("disabled"), "already at war");
this.assertTrue(env.$("#diplomacy-sub").text().includes("at war with 1"));
rows.eq(0).find("button").click();
this.assertEqual(declared, 1);
modal.close();
this.assertTrue(env.$("#modal-nation").hasClass("hidden"));
} finally {
teardownDom(env);
}
}
}
export class ConfirmModalTest extends TestCase {
async test_confirm_runs_the_callback_and_cancel_does_not() {
const env = await setupDom();
try {
const modal = new ConfirmModal();
let confirmed = 0;
modal.show("Declare war?", "Marching in will declare war.", () => { confirmed += 1; });
this.assertTrue(modal.isShowing());
this.assertEqual(env.$("#confirm-title").text(), "Declare war?");
this.assertTrue(env.$("#confirm-message").text().includes("declare war"));
env.$("#confirm-cancel").click();
this.assertFalse(modal.isShowing());
this.assertEqual(confirmed, 0, "cancelling runs nothing");
modal.show("Again", "Sure?", () => { confirmed += 1; });
env.$("#confirm-ok").click();
this.assertEqual(confirmed, 1, "confirming runs the callback once");
this.assertFalse(modal.isShowing());
} finally {
teardownDom(env);
}
}
async test_close_button_cancels() {
const env = await setupDom();
try {
const modal = new ConfirmModal();
let confirmed = 0;
modal.show("T", "M", () => { confirmed += 1; });
env.$("#modal-confirm .modal-close").click();
this.assertEqual(confirmed, 0);
this.assertFalse(modal.isShowing());
} finally {
teardownDom(env);
}
}
}
export class NewsModalTest extends TestCase {
async test_lists_events_newest_first_with_dates() {
const env = await setupDom();
try {
const modal = new NewsModal();
const civs = [{ name: "France" }, { name: "Britain" }];
const news = [
{ id: 1, type: "war", actor: 0, target: 1, date: "1 January 2000, 00:00" },
{ id: 2, type: "capture", city: "Lyon", from: 1, to: 0, date: "2 January 2000, 05:00" },
];
modal.show(news, civs);
const items = env.$("#news-list .news-item");
this.assertSize(items, 2);
this.assertTrue(items.eq(0).find(".news-text").text().includes("captured Lyon"));
this.assertTrue(items.eq(0).find(".news-date").text().includes("05:00"));
this.assertTrue(items.eq(1).find(".news-text").text().includes("France declared war on Britain"));
modal.refresh([], civs);
this.assertEqual(env.$("#news-list .news-item").length, 0);
this.assertTrue(env.$("#news-list").text().includes("No news"));
modal.close();
this.assertTrue(env.$("#modal-news").hasClass("hidden"));
} finally {
teardownDom(env);
}
}
}
+6 -1
View File
@@ -21,12 +21,13 @@ import { GameStateTest } from "./game_state_test.js";
import { GameStateBuildingsTest } from "./game_state_buildings_test.js";
import { GameStateGovernmentTest } from "./game_state_government_test.js";
import { GameStateTechnologyTest } from "./game_state_technology_test.js";
import { DiplomacyStateTest, DiplomacyServerTest } from "./diplomacy_test.js";
import { GameServerTest, GameServerOrdersTest, GameServerLoginTest, GameServerLobbyTest } from "./game_server_test.js";
import { ServerStaticTest, ServerLoopTest, ServerProtocolTest } from "./server_test.js";
import { WebSocketProtocolTest } from "./websocket_test.js";
import { NetTest } from "./net_test.js";
import { MapViewTest } from "./map_view_test.js";
import { TrainModalTest, BuildingsModalTest, NationModalTest } from "./modals_test.js";
import { TrainModalTest, BuildingsModalTest, NationModalTest, ConfirmModalTest, NewsModalTest } from "./modals_test.js";
import { GameScreenTest } from "./game_screen_test.js";
import { AppTest } from "./app_test.js";
@@ -49,6 +50,8 @@ const SUITES = [
GameStateBuildingsTest,
GameStateGovernmentTest,
GameStateTechnologyTest,
DiplomacyStateTest,
DiplomacyServerTest,
GameServerTest,
GameServerOrdersTest,
GameServerLoginTest,
@@ -62,6 +65,8 @@ const SUITES = [
TrainModalTest,
BuildingsModalTest,
NationModalTest,
ConfirmModalTest,
NewsModalTest,
GameScreenTest,
AppTest,
];