Visual improvements

This commit is contained in:
2026-09-17 00:56:59 +02:00
parent bc727c408f
commit b08074a2f3
19 changed files with 610 additions and 49 deletions
+88 -2
View File
@@ -28,7 +28,7 @@ html, body {
/* ---------------------------------------------------------------- menus -- */
#screen-menu, #screen-new, #screen-join {
#screen-menu, #screen-new, #screen-join, #screen-choose {
display: none;
align-items: center;
justify-content: center;
@@ -36,7 +36,7 @@ html, body {
radial-gradient(circle at 50% 20%, rgba(60, 90, 140, 0.35), transparent 60%),
linear-gradient(180deg, #0b1220, #05070c);
}
#screen-menu.active, #screen-new.active, #screen-join.active { display: flex; }
#screen-menu.active, #screen-new.active, #screen-join.active, #screen-choose.active { display: flex; }
.menu-card {
width: 380px;
@@ -104,6 +104,36 @@ input:focus, select:focus { border-color: var(--accent); }
.game-list li:hover { background: rgba(110, 175, 255, 0.15); }
.game-list li.selected { background: rgba(110, 175, 255, 0.3); }
/* Flag + name pickers used on the new-game and join screens. */
.civ-list {
list-style: none;
margin: 6px 0 14px;
padding: 0;
max-height: 220px;
overflow-y: auto;
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(150, 190, 240, 0.15);
}
.civ-list li {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 12px;
cursor: pointer;
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.civ-list li:hover { background: rgba(110, 175, 255, 0.15); }
.civ-list li.selected { background: rgba(110, 175, 255, 0.3); }
.civ-list img {
width: 32px;
height: 32px;
object-fit: cover;
border-radius: 3px;
border: 1px solid rgba(255, 255, 255, 0.15);
flex: 0 0 auto;
}
/* ------------------------------------------------------------- world map -- */
#screen-game.active { display: block; }
@@ -211,6 +241,21 @@ input:focus, select:focus { border-color: var(--accent); }
justify-content: center;
}
.unit .body img { width: 78%; height: 78%; image-rendering: auto; }
/* Owner's flag pinned to the top-left quarter of the unit. */
.unit .unit-flag {
position: absolute;
top: 0;
left: 0;
width: 50%;
height: 50%;
object-fit: cover;
border: 1px solid rgba(0, 0, 0, 0.65);
border-radius: 6px 0 4px 0;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
pointer-events: none;
z-index: 3;
display: block;
}
.unit.selected .body { box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.9), 0 2px 6px rgba(0,0,0,0.6); }
.unit .health {
position: absolute;
@@ -350,6 +395,47 @@ input:focus, select:focus { border-color: var(--accent); }
}
.unit-stat:hover { color: var(--text); }
/* Chat-like feed of short-lived news in the lower-left corner. */
.game-feed {
position: absolute;
left: 14px;
bottom: 14px;
width: 320px;
max-width: calc(50vw - 30px);
display: flex;
flex-direction: column;
gap: 4px;
pointer-events: none;
z-index: 40;
}
.feed-line {
align-self: flex-start;
max-width: 100%;
padding: 6px 10px;
border-radius: 7px;
background: rgba(8, 12, 20, 0.86);
border: 1px solid rgba(150, 190, 240, 0.35);
color: var(--text);
font-size: 12px;
line-height: 1.35;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
opacity: 1;
transform: translateY(0);
transition: opacity 0.4s ease, transform 0.4s ease;
animation: feed-in 0.18s ease;
}
.feed-line.leaving { opacity: 0; transform: translateY(8px); }
.feed-line.feed-alert {
background: rgba(80, 14, 14, 0.92);
border-color: #ff6a6a;
color: #ffd9d9;
font-weight: 600;
}
@keyframes feed-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.tooltip {
position: absolute;
z-index: 80;
+20 -4
View File
@@ -25,19 +25,19 @@
<h1>New game</h1>
<label class="field">
<span>Your civilisation</span>
<select id="new-civ"></select>
<ul id="new-civs" class="civ-list"></ul>
</label>
<label class="field">
<span>Nations in the world</span>
<input id="new-count" type="number" min="1" max="9" value="4" />
<input id="new-count" type="number" min="1" max="9" value="9" />
</label>
<label class="field">
<span>Player name</span>
<input id="new-name" type="text" autocomplete="off" />
<input id="new-name" type="text" autocomplete="off" value="adrien" />
</label>
<label class="field">
<span>Password</span>
<input id="new-password" type="password" />
<input id="new-password" type="password" value="admin" />
</label>
<div class="row">
<button id="new-back" class="aero">Back</button>
@@ -76,6 +76,19 @@
</div>
</section>
<!-- Civilisation picker for players joining an existing game -->
<section id="screen-choose" class="screen">
<div class="menu-card wide">
<h1>Choose your civilisation</h1>
<p class="tagline">Pick one of the nations no one has claimed.</p>
<ul id="choose-civs" class="civ-list"></ul>
<div class="row">
<button id="choose-back" class="aero">Back</button>
</div>
<p id="choose-status" class="status"></p>
</div>
</section>
<!-- World map -->
<section id="screen-game" class="screen">
<div id="map-viewport">
@@ -140,6 +153,9 @@
<div id="stat-tooltip" class="tooltip hidden"></div>
</div>
<!-- Chat-like feed of short-lived news, pinned to the lower left -->
<div id="game-feed" class="game-feed"></div>
<!-- Training modal -->
<div id="modal-train" class="modal hidden">
<div class="modal-card">
+62 -4
View File
@@ -19,6 +19,7 @@ let joining = false;
let mode = "join";
let games = [];
let selectedGame = -1;
let selectedCivId = CIVILISATIONS[0].id;
function showScreen(id) {
$(".screen").removeClass("active");
@@ -39,11 +40,27 @@ $("#menu-join").on("click", () => {
// ---------------------------------------------------------- new game ------
const $civSelect = $("#new-civ");
const $civSelect = $("#new-civs");
for (const civ of CIVILISATIONS) {
$civSelect.append($("<option></option>").val(civ.id).text(civ.name));
const $item = civOption(civ);
$item.on("click", () => {
selectedCivId = civ.id;
$civSelect.children().removeClass("selected");
$item.addClass("selected");
});
$civSelect.append($item);
}
$civSelect.children().first().addClass("selected");
$("#new-count").attr("max", CIVILISATIONS.length);
// A fresh game starts with every civilisation in the world.
$("#new-count").val(CIVILISATIONS.length);
function civOption(civ) {
return $("<li class='civ-option'></li>")
.attr("data-civ-id", civ.id)
.append($("<img alt=''>").attr("src", `assets/flags/${civ.flag}`))
.append($("<span></span>").text(civ.name));
}
$("#new-back").on("click", () => {
network.close();
@@ -51,7 +68,7 @@ $("#new-back").on("click", () => {
});
$("#new-start").on("click", () => {
const civId = $civSelect.val();
const civId = selectedCivId;
const count = Math.max(1, Math.min(CIVILISATIONS.length, Number($("#new-count").val()) || 1));
const name = $("#new-name").val().trim();
const password = $("#new-password").val();
@@ -81,7 +98,8 @@ $("#new-start").on("click", () => {
function setNewEnabled(enabled) {
$("#new-start, #new-back").prop("disabled", !enabled);
$("#new-count, #new-civ, #new-name, #new-password").prop("disabled", !enabled);
$("#new-count, #new-name, #new-password").prop("disabled", !enabled);
$("#new-civs").css("pointer-events", enabled ? "" : "none");
}
// -------------------------------------------------------------- join ------
@@ -95,6 +113,11 @@ $("#join-back").on("click", () => {
});
$("#join-create").on("click", () => showScreen("screen-new"));
$("#join-go").on("click", joinSelected);
$("#choose-back").on("click", () => {
network.close();
showScreen("screen-join");
setJoinEnabled(true);
});
function joinServerUrl() {
return network.normalizeUrl($("#join-server").val());
@@ -176,11 +199,46 @@ network.on("session_started", () => {
status("new-status", "");
startGameScreen(true);
} else {
// The server follows up with the unclaimed civilisations to pick from.
status("join-status", "");
}
});
// Offer the unclaimed civilisations; picking one claims it for this player.
network.on("choose_civ_received", (civs) => {
if (mode !== "join") return;
if (!civs || civs.length === 0) {
status("choose-status", "No unclaimed nation left — joining as a spectator.");
startGameScreen(false);
return;
}
renderCivChoices(civs);
showScreen("screen-choose");
});
network.on("civ_assigned", () => {
if (mode === "join" && gameScreen === null) {
status("choose-status", "");
startGameScreen(false);
}
});
function renderCivChoices(civs) {
const $list = $("#choose-civs").empty();
for (const entry of civs) {
const civ = CIVILISATIONS.find((c) => c.id === entry.id) || entry;
const $item = civOption(civ);
$item.on("click", () => {
$list.children().removeClass("selected");
$item.addClass("selected");
config.playerCivId = entry.id;
status("choose-status", "Claiming...");
network.claimCiv(entry.index);
});
$list.append($item);
}
}
network.on("session_failed", (message) => {
if (mode === "create") {
setNewEnabled(true);
+37 -2
View File
@@ -4,7 +4,8 @@
// same cost model as the server.
import { MapView } from "./map_view.js";
import { TrainModal, BuildingsModal, NationModal, ConfirmModal, NewsModal } from "./modals.js";
import { TrainModal, BuildingsModal, NationModal, ConfirmModal, NewsModal, describeNews } from "./modals.js";
import { GameFeed } from "./feed.js";
import { compact, groupDigits, improvements as fmtImprovements } from "../../shared/text_format.js";
import {
CIVILISATIONS,
@@ -13,6 +14,7 @@ import {
BUILDINGS,
GOVERNMENTS,
TECHNOLOGIES,
NEWS_WAR,
} from "../../shared/data.js";
import { defenseFactors, unitDefense } from "../../shared/rules.js";
import { dateString } from "../../shared/game_clock.js";
@@ -35,6 +37,7 @@ export class GameScreen {
this._researched = new Set();
this._wars = new Set();
this._news = [];
this._seenNews = null;
this._entered = false;
this._lastFrame = 0;
this._fpsFrames = 0;
@@ -58,6 +61,7 @@ export class GameScreen {
this.nationModal = new NationModal();
this.confirmModal = new ConfirmModal();
this.newsModal = new NewsModal();
this.feed = new GameFeed($("#game-feed"));
this.$statTooltip = $("#stat-tooltip");
this._hoveredStat = null;
@@ -76,6 +80,8 @@ export class GameScreen {
leave() {
this._entered = false;
this.$screen.removeClass("active");
this.feed.clear();
this._seenNews = null;
}
// ------------------------------------------------------------- orders ---
@@ -122,6 +128,7 @@ export class GameScreen {
this._wars.add(a < b ? `${a}:${b}` : `${b}:${a}`);
}
this._news = state.news || [];
this._updateFeed(this._news);
this.map.setCatalogue(this.civilisations, this.protoUnits);
this.map.applySnapshot(state);
@@ -218,9 +225,10 @@ export class GameScreen {
const targets = this._unalliedTerritoryOnRoute(unit, coords);
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 ${names} territory will declare war on ${names}. Continue?`,
`Marching into ${demonyms} territory will declare war on ${names}. Continue?`,
() => this._sendMove(unit, coords)
);
return;
@@ -266,6 +274,11 @@ export class GameScreen {
return civ ? civ.name : `Nation ${index + 1}`;
}
_civDemonym(index) {
const civ = this.civilisations[index];
return civ && civ.demonym ? civ.demonym : this._civName(index);
}
_updateTopPanel(state) {
const civ = this.civilisations[this.localCiv];
if (civ) {
@@ -505,6 +518,28 @@ export class GameScreen {
this.newsModal.refresh(this._news, this.civilisations);
}
// Pops newly arrived news into the corner feed. The first snapshot only
// records what is already known, so history never floods the screen.
_updateFeed(news) {
if (this._seenNews === null) {
this._seenNews = new Set(news.map((entry) => entry.id));
return;
}
for (const entry of news) {
if (this._seenNews.has(entry.id)) continue;
this._seenNews.add(entry.id);
this._pushNews(entry);
}
}
_pushNews(entry) {
if (entry.type === NEWS_WAR && entry.target === this.localCiv) {
this.feed.push(`${this._civName(entry.actor)} has declared war on you!`, { alert: true });
return;
}
this.feed.push(describeNews(entry, this.civilisations));
}
_finances(stats) {
return stats.breakdown || null;
}
+52 -4
View File
@@ -367,10 +367,14 @@ export class MapView {
_syncUnits(units) {
const seen = new Set();
// Store every unit's authoritative data first, so motion prediction can
// see where all of them are before any single one is advanced.
for (const data of units) {
seen.add(data.id);
this._unitData.set(data.id, data);
}
for (const data of units) {
const id = data.id;
seen.add(id);
this._unitData.set(id, data);
let view = this._unitViews.get(id);
if (!view) {
view = this._createUnitView(data);
@@ -380,6 +384,15 @@ export class MapView {
const civ = this.civilisations[data.civ];
view.querySelector(".body").style.background = civ ? civ.primaryColour : "#666";
view.querySelector(".body img").src = proto ? `assets/${proto.icon}` : "assets/person.png";
const flag = view.querySelector(".unit-flag");
if (flag) {
if (civ && civ.flag) {
flag.src = `assets/flags/${civ.flag}`;
flag.style.display = "";
} else {
flag.style.display = "none";
}
}
view.querySelector(".health .fill").style.width =
`${Math.max(0, Math.min(100, (data.hp / data.maxHp) * 100))}%`;
view.classList.toggle("selected", id === this._selectedUnitId);
@@ -416,6 +429,10 @@ export class MapView {
health.appendChild(fill);
const statuses = document.createElement("div");
statuses.className = "unit-statuses";
const flag = document.createElement("img");
flag.className = "unit-flag";
flag.alt = "";
view.appendChild(flag);
view.appendChild(body);
view.appendChild(health);
view.appendChild(statuses);
@@ -454,10 +471,41 @@ export class MapView {
// ------------------------------------------------------------- motion ---
// A living unit standing on the path blocks dead reckoning: the animation
// must stop before it. For an enemy military unit that is the fight itself,
// so the mover halts within its own firing range instead — an attacker is not
// really moving until the tile it wants is free.
_truncateAtOccupant(path, segmentHours, data) {
const proto = this.protoUnits[data.proto];
const range = proto && typeof proto.range === "number" ? Math.max(1, proto.range) : 1;
for (let i = 1; i < path.length; i++) {
const occupant = this._unitAtCoords(path[i], data.id);
if (!occupant) continue;
if (proto && proto.military && this._isMilitaryData(occupant) && occupant.civ !== data.civ) {
let stop = i - 1;
for (let j = 0; j <= i; j++) {
if (this.topology.tileDistance(path[j], path[i]) <= range) {
stop = j;
break;
}
}
return { path: path.slice(0, stop + 1), segmentHours: segmentHours.slice(0, stop) };
}
return {
path: path.slice(0, i),
segmentHours: segmentHours.slice(0, Math.max(0, i - 1)),
};
}
return { path, segmentHours };
}
_updateUnitMotion(data) {
const id = data.id;
const path = (data.path || []).map((p) => ({ x: p[0], y: p[1] }));
const segmentHours = data.segmentHours || [];
let path = (data.path || []).map((p) => ({ x: p[0], y: p[1] }));
let segmentHours = data.segmentHours || [];
const clipped = this._truncateAtOccupant(path, segmentHours, data);
path = clipped.path;
segmentHours = clipped.segmentHours;
const existing = this._unitMotion.get(id);
if (path.length < 2 || segmentHours.length === 0) {
if (existing && existing.predicting && existing.predictAge < PREDICTION_GRACE_HOURS) {
+7 -3
View File
@@ -501,13 +501,17 @@ export class NewsModal {
}
}
function describeNews(entry, civs) {
export function describeNews(entry, civs) {
const name = (index) => (civs[index] ? civs[index].name : `Nation ${index + 1}`);
const people = (index) => {
const civ = civs[index];
return civ && civ.demonym ? `The ${civ.demonym}` : name(index);
};
switch (entry.type) {
case "war":
return `${name(entry.actor)} declared war on ${name(entry.target)}`;
return `${people(entry.actor)} declared war on ${name(entry.target)}`;
case "capture":
return `${name(entry.to)} captured ${entry.city} from ${name(entry.from)}`;
return `${people(entry.to)} captured ${entry.city} from ${name(entry.from)}`;
default:
return entry.type;
}
+10
View File
@@ -129,6 +129,10 @@ export class Network {
this._send({ t: "configure_game", setup });
}
claimCiv(index) {
this._send({ t: "claim_civ", civ: index });
}
sendOrder(order) {
this._send({ t: "order", order });
}
@@ -176,6 +180,12 @@ export class Network {
case "game_configured":
this._emit("game_configured");
break;
case "choose_civ":
this._emit("choose_civ_received", message.civs || []);
break;
case "civ_assigned":
this._emit("civ_assigned", message.civ);
break;
case "game_state":
this._emit("game_state_received", message.state);
break;
+55 -3
View File
@@ -160,9 +160,19 @@ export class GameServer extends EventEmitter {
onPeerJoined(peerId, playerName = "") {
if (!this._configured) return;
this._assignCiv(peerId, playerName);
const civ = this.peerCiv.has(peerId) ? this.peerCiv.get(peerId) : -1;
this.network.send(peerId, { t: "game_state", state: this.state.snapshot(civ) });
// A returning player keeps the nation they claimed before.
if (this._reclaimIdentity(peerId, playerName)) {
this.network.send(peerId, { t: "game_state", state: this.state.snapshot(this.peerCiv.get(peerId)) });
return;
}
// Otherwise offer every nation no one has claimed yet and let the player
// pick. They stay a spectator (civ -1) until they answer.
this.peerCiv.set(peerId, -1);
const options = this.availableCivs();
this.network.send(peerId, { t: "choose_civ", civs: options });
if (options.length === 0) {
this.network.send(peerId, { t: "game_state", state: this.state.snapshot(-1) });
}
}
onPeerLeft(peerId) {
@@ -170,6 +180,48 @@ export class GameServer extends EventEmitter {
this.peerNames.delete(peerId);
}
// Indices of the civilisations no connected peer has claimed.
availableCivs() {
if (!this._configured || !this.state) return [];
const used = new Set(this.peerCiv.values());
const options = [];
for (let i = 0; i < this.state.civilisations.length; i++) {
if (used.has(i)) continue;
const civ = this.state.civilisations[i];
options.push({ index: i, id: civ.id, name: civ.name });
}
return options;
}
// Claims one of the offered civilisations for a joining peer. Rejects an
// index that is out of range or already taken by someone else.
claimCiv(peerId, index) {
if (!this._configured || !this.state) return false;
const choice = Number(index);
if (!Number.isInteger(choice) || choice < 0 || choice >= this.state.civilisations.length) {
return false;
}
if (Array.from(this.peerCiv.values()).includes(choice)) return false;
this.peerCiv.set(peerId, choice);
const name = this.peerNames.get(peerId);
if (name) this.identityCiv.set(name, choice);
this.network.send(peerId, { t: "civ_assigned", civ: choice });
this._broadcastState();
return true;
}
// Re-attaches a known player to the nation they held before, as long as no
// one else has claimed it in the meantime.
_reclaimIdentity(peerId, playerName) {
if (!playerName || !this.identityCiv.has(playerName)) return false;
const choice = this.identityCiv.get(playerName);
if (choice < 0 || choice >= this.state.civilisations.length) return false;
if (Array.from(this.peerCiv.values()).includes(choice)) return false;
this.peerCiv.set(peerId, choice);
this.network.send(peerId, { t: "civ_assigned", civ: choice });
return true;
}
_assignCiv(peerId, playerName = "") {
if (this._configured === false || this.state == null) return;
const used = Array.from(this.peerCiv.values());
+3
View File
@@ -132,6 +132,9 @@ export function startServer({ port = 27015, bind = "127.0.0.1", log = console.lo
peer.send(JSON.stringify({ t: "game_configured" }));
}
break;
case "claim_civ":
gameServer.claimCiv(connection.id, message.civ);
break;
case "order":
gameServer.handleOrder(connection.id, message.order || {});
break;
+23 -5
View File
@@ -32,6 +32,7 @@ export const CIVILISATIONS = [
{
id: "france",
name: "France",
demonym: "French",
primaryColour: "#0d0d5c",
accentColour: "#8c8c82",
flag: "flag_france.png",
@@ -40,6 +41,7 @@ export const CIVILISATIONS = [
{
id: "britain",
name: "Britain",
demonym: "British",
primaryColour: "#a81818",
accentColour: "#0722ff",
flag: "flag_britain.png",
@@ -48,14 +50,16 @@ export const CIVILISATIONS = [
{
id: "slovenia",
name: "Slovenia",
demonym: "Slovenian",
primaryColour: "#005da4",
accentColour: "#ed1c24",
flag: "flag_slovenia.png",
cityNames: ["Maribor", "Celje", "Kranj", "Koper", "Velenje", "Novo Mesto", "Ptuj", "Kamnik", "Jesenice"],
cityNames: ["Ljubljana", "Maribor", "Celje", "Kranj", "Koper", "Velenje", "Novo Mesto", "Ptuj", "Kamnik", "Jesenice"],
},
{
id: "poland",
name: "Poland",
demonym: "Polish",
primaryColour: "#a6000b",
accentColour: "#ffffff",
flag: "flag_poland.png",
@@ -64,6 +68,7 @@ export const CIVILISATIONS = [
{
id: "switzerland",
name: "Switzerland",
demonym: "Swiss",
primaryColour: "#da291c",
accentColour: "#ffffff",
flag: "flag_switzerland.png",
@@ -72,6 +77,7 @@ export const CIVILISATIONS = [
{
id: "ticino",
name: "Ticino",
demonym: "Ticinese",
primaryColour: "#4646e0",
accentColour: "#a6000b",
flag: "flag_ticino.png",
@@ -80,6 +86,7 @@ export const CIVILISATIONS = [
{
id: "galicia",
name: "Galicia",
demonym: "Galician",
primaryColour: "#006dad",
accentColour: "#ffffff",
flag: "flag_galicia.png",
@@ -88,6 +95,7 @@ export const CIVILISATIONS = [
{
id: "germany",
name: "Germany",
demonym: "German",
primaryColour: "#141414",
accentColour: "#ffce00",
flag: "flag_germany.png",
@@ -96,6 +104,7 @@ export const CIVILISATIONS = [
{
id: "iran",
name: "Iran",
demonym: "Iranian",
primaryColour: "#18a645",
accentColour: "#f01c21",
flag: "flag_iran.png",
@@ -164,12 +173,14 @@ export const TRAINABLE_UNIT_IDS = ["modern_infantry", "artillery"];
// Status effects a unit or city can carry. They are data-driven so new ones
// can be added without touching the simulation or the renderer: the server
// derives the active ids once a day, applies their daily fields, and ships the
// ids in snapshots; the browser draws one icon per id. A status may define
// `hpLossPerDay` (a fraction of maximum hit points taken each day) and a
// `cityHpLossPerDay` override for when the unit stands in a city.
// derives the active ids, applies their fields, and ships the ids in
// snapshots; the browser draws one icon per id. A status may define
// `hpLossPerDay` (a fraction of maximum hit points taken each day), a
// `cityHpLossPerDay` override for when the unit stands in a city, and an
// `upkeepMultiplier` applied to the unit's upkeep while it is active.
export const STATUS_ENCIRCLED = "encircled";
export const STATUS_BESIEGED = "besieged";
export const STATUS_BATTLE = "battle";
export const STATUSES = [
{
@@ -186,6 +197,13 @@ export const STATUSES = [
icon: "encirclement.svg",
description: "Cut off from every other allied city while a hostile unit is adjacent.",
},
{
id: STATUS_BATTLE,
label: "In battle",
icon: "icon_battle.svg",
description: "Fighting an enemy unit in range. Upkeep is tripled while the battle rages.",
upkeepMultiplier: 3,
},
];
export function statusById(id) {
+89 -15
View File
@@ -23,6 +23,7 @@ import {
EFFECT_CULTURE,
STATUS_ENCIRCLED,
STATUS_BESIEGED,
STATUS_BATTLE,
RELATION_PEACE,
RELATION_WAR,
NEWS_WAR,
@@ -349,19 +350,30 @@ export class GameState {
// resolves, so neither side can slip away mid-fight.
const engaged = this._engagedUnits();
for (const unit of this.units) {
const path = unit.path;
if (!path || path.length === 0) continue;
if (!unit.path || unit.path.length === 0) continue;
if (engaged.has(unit)) continue;
let hoursLeft = hours;
while (hoursLeft > 0 && unit.pathIndex < path.length - 1) {
while (hoursLeft > 0 && unit.pathIndex < unit.path.length - 1) {
// Stop as soon as the target is in range: a ranged unit holds its
// distance rather than closing to melee.
if (this._combatTarget(unit)) break;
const next = path[unit.pathIndex + 1];
// Any other unit on the next tile blocks the step. Military pairs are
// resolved by the daily combat round instead; the rest simply wait.
const next = unit.path[unit.pathIndex + 1];
const occupant = this.unitAt(next);
if (occupant && occupant !== unit) break;
if (occupant && occupant !== unit) {
// A hostile military unit in the way is the fight we are marching
// into: hold position until it dies rather than treating the tile as
// an obstacle. Anyone else is an obstacle; route around it, and when
// the destination has become unreachable, abandon the move instead of
// stalling on the spot forever.
if (this._isBlockingEnemy(unit, occupant)) break;
if (this._repathAround(unit)) {
const ahead = this.unitAt(unit.path[unit.pathIndex + 1]);
if (ahead && ahead !== unit) break;
continue;
}
this._stopUnit(unit);
break;
}
const cost = Math.max(this._tileTravelHours(unit, next), MIN_STEP_COST);
const needed = cost - unit.progressHours;
if (hoursLeft >= needed) {
@@ -376,14 +388,39 @@ export class GameState {
hoursLeft = 0;
}
}
if (unit.pathIndex >= path.length - 1) {
unit.path = [];
unit.pathIndex = 0;
unit.progressHours = 0;
if (unit.path && unit.pathIndex >= unit.path.length - 1) {
this._stopUnit(unit);
}
}
}
// A hostile military unit standing on the next tile is an opponent to fight,
// not an obstacle to route around.
_isBlockingEnemy(unit, occupant) {
return this._isMilitary(unit) && this._isMilitary(occupant) && occupant.civ !== unit.civ;
}
// Recomputes a route from where the unit stands to the destination it was
// already heading for. Returns false when no route exists any more.
_repathAround(unit) {
if (!unit.path || unit.path.length < 2) return false;
const goal = unit.path[unit.path.length - 1];
const path = this.findPath(unit, unit.coords, goal);
if (path.length < 2) return false;
unit.path = path;
unit.pathIndex = 0;
unit.progressHours = 0;
return true;
}
// Abandons every movement for the unit: no route, no attack, no partial step.
_stopUnit(unit) {
unit.path = [];
unit.pathIndex = 0;
unit.progressHours = 0;
unit.attackTarget = null;
}
_engagedUnits() {
const engaged = new Set();
for (const { attacker, defender } of this._combatPairs()) {
@@ -598,12 +635,43 @@ export class GameState {
return typeof def.hpLossPerDay === "number" ? def.hpLossPerDay : 0;
}
// The ids of every unit currently trading blows, on either side of the
// exchange. Out-ranged defenders cannot strike back but are still in battle.
_battleUnitIds() {
const ids = new Set();
for (const { attacker, defender } of this._combatPairs()) {
ids.add(attacker.id);
ids.add(defender.id);
}
return ids;
}
// The statuses shown for a unit this instant: the supply statuses stored on
// the unit plus "in battle", which is derived from the live combat state so
// it appears and clears with the fighting rather than only on the daily tick.
_effectiveStatuses(unit, inBattle) {
const statuses = unit.statuses || [];
if (!inBattle || statuses.includes(STATUS_BATTLE)) return statuses;
return [...statuses, STATUS_BATTLE];
}
// The combined upkeep multiplier of the given statuses.
_statusUpkeepMultiplier(statuses) {
let multiplier = 1;
for (const id of statuses) {
const def = statusById(id);
if (def && typeof def.upkeepMultiplier === "number") multiplier *= def.upkeepMultiplier;
}
return multiplier;
}
// Applies every active status once per day. The only effect so far is
// attrition, but the loop is generic so new daily fields slot in here.
_applyDailyStatusEffects() {
const battle = this._battleUnitIds();
const damage = new Map();
for (const unit of this.units) {
const statuses = unit.statuses || [];
const statuses = this._effectiveStatuses(unit, battle.has(unit.id));
if (statuses.length === 0) continue;
const inCity = !!this.cityAt(unit.coords);
let fraction = 0;
@@ -800,10 +868,13 @@ export class GameState {
getPlayerUpkeep(civ, gdp = -1) {
let total = this.getBuildingUpkeep(civ, gdp);
const battle = this._battleUnitIds();
for (const unit of this.units) {
if (unit.civ !== civ) continue;
const proto = this.unitProto(unit);
if (proto) total += proto.upkeep;
if (!proto) continue;
const statuses = this._effectiveStatuses(unit, battle.has(unit.id));
total += proto.upkeep * this._statusUpkeepMultiplier(statuses);
}
return total;
}
@@ -908,12 +979,14 @@ export class GameState {
_unitUpkeepSources(civ) {
const sources = new Map();
const battle = this._battleUnitIds();
for (const unit of this.units) {
if (unit.civ !== civ) continue;
const proto = this.unitProto(unit);
if (!proto || !proto.upkeep) continue;
const statuses = this._effectiveStatuses(unit, battle.has(unit.id));
const entry = sources.get(unit.proto) || { label: proto.name, kind: "unit", amount: 0, count: 0 };
entry.amount += proto.upkeep;
entry.amount += proto.upkeep * this._statusUpkeepMultiplier(statuses);
entry.count += 1;
sources.set(unit.proto, entry);
}
@@ -1600,6 +1673,7 @@ export class GameState {
}
_serializeUnits() {
const battle = this._battleUnitIds();
return this.units.map((unit) => {
const rawPath = unit.path || [];
const pathIndex = unit.pathIndex;
@@ -1619,7 +1693,7 @@ export class GameState {
coords: [unit.coords.x, unit.coords.y],
hp: unit.hp,
maxHp: unit.maxHp,
statuses: unit.statuses || [],
statuses: this._effectiveStatuses(unit, battle.has(unit.id)),
path,
pathIndex,
progressHours: unit.progressHours,
+54 -2
View File
@@ -11,7 +11,8 @@ export class AppTest extends TestCase {
try {
FakeWebSocket.reset();
await import(`../client/js/app.js?join=${Date.now()}`);
this.assertSize(env.$("#new-civ option"), CIVILISATIONS.length);
this.assertSize(env.$("#new-civs li"), CIVILISATIONS.length);
this.assertTrue(env.$("#new-civs li").first().find("img").length === 1, "civ picker shows flags");
env.$("#menu-join").click();
this.assertTrue(env.$("#screen-join").hasClass("active"));
@@ -34,8 +35,19 @@ export class AppTest extends TestCase {
await flush();
this.assertEqual(joinSocket.lastSent(), { t: "login", name: "Carol", password: "pw" });
joinSocket.simulateMessage({ t: "assigned", playerId: 2, admin: false, name: "Carol" });
this.assertTrue(env.$("#screen-game").hasClass("active"));
this.assertFalse(env.$("#screen-game").hasClass("active"), "no game screen before picking");
joinSocket.simulateMessage({
t: "choose_civ",
civs: [{ index: 1, id: "britain", name: "Britain" }],
});
this.assertTrue(env.$("#screen-choose").hasClass("active"));
this.assertSize(env.$("#choose-civs li"), 1);
env.$("#choose-civs li").first().click();
this.assertEqual(joinSocket.lastSent(), { t: "claim_civ", civ: 1 });
joinSocket.simulateMessage({ t: "civ_assigned", civ: 1 });
this.assertTrue(env.$("#screen-game").hasClass("active"));
joinSocket.simulateMessage({ t: "game_state", state: defaultState().snapshot(1) });
this.assertEqual(env.$("#civ-name").text(), "Britain");
this.assertFalse(env.$("#stat-pop").text() === "0");
@@ -44,6 +56,46 @@ export class AppTest extends TestCase {
}
}
async test_join_without_a_free_nation_becomes_a_spectator() {
const env = await setupDom();
try {
FakeWebSocket.reset();
await import(`../client/js/app.js?spectate=${Date.now()}`);
env.$("#menu-join").click();
const listSocket = FakeWebSocket.last;
listSocket.simulateOpen();
await flush();
listSocket.simulateMessage({ t: "game_list", games: [{ name: "Game 999" }] });
env.$("#join-name").val("Carol");
env.$("#join-password").val("pw");
env.$("#join-go").click();
const joinSocket = FakeWebSocket.last;
joinSocket.simulateOpen();
await flush();
joinSocket.simulateMessage({ t: "assigned", playerId: 2, admin: false, name: "Carol" });
joinSocket.simulateMessage({ t: "choose_civ", civs: [] });
this.assertTrue(env.$("#screen-game").hasClass("active"), "no choice means a spectator view");
} finally {
teardownDom(env);
}
}
async test_new_game_defaults() {
const env = await setupDom();
try {
FakeWebSocket.reset();
await import(`../client/js/app.js?defaults=${Date.now()}`);
env.$("#menu-create").click();
this.assertEqual(env.$("#new-name").val(), "adrien");
this.assertEqual(env.$("#new-password").val(), "admin");
this.assertEqual(Number(env.$("#new-count").val()), CIVILISATIONS.length, "all nations by default");
this.assertEqual(env.$("#new-civs li.selected").attr("data-civ-id"), CIVILISATIONS[0].id);
} finally {
teardownDom(env);
}
}
async test_create_flow_sends_setup_and_renders() {
const env = await setupDom();
try {
+10
View File
@@ -11,6 +11,8 @@ import {
governmentById,
technologyById,
effectValue,
statusById,
STATUS_BATTLE,
} from "../shared/data.js";
export class DataTest extends TestCase {
@@ -35,10 +37,18 @@ export class DataTest extends TestCase {
this.assertTrue(civ.name.length > 0, "name");
this.assertTrue(civ.primaryColour.startsWith("#"), `primary colour of ${civ.id}`);
this.assertTrue(civ.flag.endsWith(".png"), `flag of ${civ.id}`);
this.assertTrue(civ.demonym.length > 0, `demonym of ${civ.id}`);
this.assertNotEmpty(civ.cityNames, `city names of ${civ.id}`);
}
}
test_battle_status_is_rendered_and_triples_upkeep() {
const battle = statusById(STATUS_BATTLE);
this.assertNotNull(battle, "the battle status exists");
this.assertEqual(battle.icon, "icon_battle.svg");
this.assertEqual(battle.upkeepMultiplier, 3);
}
test_trainable_units_exist_in_the_catalogue() {
this.assertNotEmpty(TRAINABLE_UNIT_IDS);
for (const id of TRAINABLE_UNIT_IDS) {
+40
View File
@@ -408,4 +408,44 @@ export class GameScreenTest extends TestCase {
teardownDom(env);
}
}
async test_new_news_pops_up_in_the_corner_feed() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = defaultState();
// The first snapshot is history: it must not flood the feed.
screen.onState(state.snapshot(0));
this.assertSize(env.$("#game-feed .feed-line"), 0);
state.requestDeclareWar(0, 1);
screen.onState(state.snapshot(0));
this.assertSize(env.$("#game-feed .feed-line"), 1);
this.assertTrue(env.$("#game-feed .feed-line").text().includes("declared war on Britain"));
screen.leave();
} finally {
teardownDom(env);
}
}
async test_war_declared_on_the_player_is_an_alert() {
const env = await setupDom();
try {
const screen = new GameScreen(stubNetwork(), makeConfig());
screen.enter();
const state = defaultState();
screen.onState(state.snapshot(0));
state.requestDeclareWar(1, 0);
screen.onState(state.snapshot(0));
const alert = env.$("#game-feed .feed-line.feed-alert");
this.assertSize(alert, 1);
this.assertTrue(alert.text().includes("declared war on you"));
screen.leave();
this.assertSize(env.$("#game-feed .feed-line"), 0, "leaving clears the feed");
} finally {
teardownDom(env);
}
}
}
+34 -1
View File
@@ -162,16 +162,47 @@ export class GameServerOrdersTest extends TestCase {
}
test_extra_players_become_spectators() {
const { server } = makeServer();
const { server, network } = makeServer();
server.authenticate(1, "Host", "pw");
server.onPeerJoined(1, "Host");
this.assertTrue(server.claimCiv(1, 0));
server.authenticate(2, "Bob", "pw");
server.onPeerJoined(2, "Bob");
this.assertTrue(server.claimCiv(2, 1));
server.authenticate(3, "Cat", "pw");
server.onPeerJoined(3, "Cat");
this.assertEqual(server.peerCiv.get(1), 0);
this.assertEqual(server.peerCiv.get(2), 1);
this.assertEqual(server.peerCiv.get(3), -1, "a third player has no nation left");
this.assertEmpty(server.availableCivs());
}
test_joining_player_is_offered_the_unclaimed_civilisations() {
const { server, network } = makeServer();
server.authenticate(1, "Host", "pw");
server.onPeerJoined(1, "Host");
server.claimCiv(1, 0);
network.sent.length = 0;
server.authenticate(2, "Bob", "pw");
server.onPeerJoined(2, "Bob");
const choose = network.sent.find((m) => m.peerId === 2 && m.message.t === "choose_civ");
this.assertNotNull(choose, "the joiner is offered a choice");
this.assertEqual(choose.message.civs.map((c) => c.index), [1]);
this.assertEqual(choose.message.civs[0].id, "britain");
this.assertEqual(server.peerCiv.get(2), -1, "no nation until the player picks");
}
test_claiming_a_taken_or_unknown_civilisation_is_rejected() {
const { server } = makeServer();
server.authenticate(1, "Host", "pw");
server.onPeerJoined(1, "Host");
this.assertTrue(server.claimCiv(1, 0));
server.authenticate(2, "Bob", "pw");
server.onPeerJoined(2, "Bob");
this.assertFalse(server.claimCiv(2, 0), "already claimed");
this.assertFalse(server.claimCiv(2, 99), "out of range");
this.assertFalse(server.claimCiv(2, -1), "unknown index");
this.assertEqual(server.peerCiv.get(2), -1);
}
test_lifecycle_calls_are_safe_before_configuration() {
@@ -249,6 +280,7 @@ export class GameServerLoginTest extends TestCase {
const { server } = makeServer();
server.authenticate(1, "Alice", "pw");
server.onPeerJoined(1, "Alice");
this.assertTrue(server.claimCiv(1, 0));
const firstCiv = server.peerCiv.get(1);
server.onPeerLeft(1);
this.assertEmpty(server.peerCiv);
@@ -261,6 +293,7 @@ export class GameServerLoginTest extends TestCase {
const { server } = makeServer();
server.authenticate(1, "Alice", "pw");
server.onPeerJoined(1, "Alice");
server.claimCiv(1, 0);
const unit = server.state.units[0];
const goal = adjacentLand(server.state, unit);
this.assertTrue(server.handleOrder(1, { type: "move", unit: unit.id, coords: [goal.x, goal.y] }));
+15
View File
@@ -33,6 +33,21 @@ export class MapViewTest extends TestCase {
}
}
async test_unit_views_show_their_owners_flag() {
const state = defaultState();
const { env } = await this.build(state);
try {
const flags = env.$("#layer-entities .unit .unit-flag");
this.assertSize(flags, state.units.length, "every unit carries a flag");
const unit = state.units[0];
const civ = CIVILISATIONS.find((c) => c.id === state.civilisations[unit.civ].id);
const src = env.$("#layer-entities .unit").first().find(".unit-flag").attr("src") || "";
this.assertTrue(src.endsWith(civ.flag), `the flag is the owner's: ${src}`);
} finally {
teardownDom(env);
}
}
async test_centers_on_the_capital() {
const state = defaultState();
const { env, map } = await this.build(state);
+3 -3
View File
@@ -270,7 +270,7 @@ export class NewsModalTest extends TestCase {
const env = await setupDom();
try {
const modal = new NewsModal();
const civs = [{ name: "France" }, { name: "Britain" }];
const civs = [{ name: "France", demonym: "French" }, { name: "Britain", demonym: "British" }];
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" },
@@ -278,9 +278,9 @@ export class NewsModalTest extends TestCase {
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-text").text().includes("The French captured Lyon from Britain"));
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"));
this.assertTrue(items.eq(1).find(".news-text").text().includes("The French declared war on Britain"));
modal.refresh([], civs);
this.assertEqual(env.$("#news-list .news-item").length, 0);
+2
View File
@@ -29,6 +29,7 @@ import { NetTest } from "./net_test.js";
import { MapViewTest } from "./map_view_test.js";
import { TrainModalTest, BuildingsModalTest, NationModalTest, ConfirmModalTest, NewsModalTest } from "./modals_test.js";
import { GameScreenTest } from "./game_screen_test.js";
import { GameFeedTest } from "./feed_test.js";
import { AppTest } from "./app_test.js";
const SUITES = [
@@ -68,6 +69,7 @@ const SUITES = [
ConfirmModalTest,
NewsModalTest,
GameScreenTest,
GameFeedTest,
AppTest,
];
+6 -1
View File
@@ -210,8 +210,13 @@ export class ServerProtocolTest extends TestCase {
guest.send({ t: "login", name: "Carol", password: "pw" });
const assigned = await guest.next("assigned");
this.assertFalse(assigned.admin);
const choose = await guest.next("choose_civ");
this.assertEqual(choose.civs.map((c) => c.index), [1], "the free civilisation is offered");
guest.send({ t: "claim_civ", civ: 1 });
const civAssigned = await guest.next("civ_assigned");
this.assertEqual(civAssigned.civ, 1);
const state = await guest.next("game_state");
this.assertEqual(state.state.viewer, 1, "the second player is assigned the free civilisation");
this.assertEqual(state.state.viewer, 1, "the guest is assigned the free civilisation");
this.assertTrue(state.state.units.some((u) => u.civ === 1), "the guest has a unit");
host.close();