Map mode
diff --git a/client/js/app.js b/client/js/app.js
index 0905b58..0539739 100644
--- a/client/js/app.js
+++ b/client/js/app.js
@@ -4,6 +4,8 @@
import { Network } from "./net.js";
import { GameScreen } from "./game_screen.js";
import { bootLog } from "./loading.js";
+import { audio } from "./audio.js";
+import { renderCredits } from "./credits.js";
import { chooseMenuBackground } from "./menu_background.js";
import { CIVILISATIONS } from "../../shared/data.js";
@@ -47,6 +49,31 @@ function status(id, message) {
// and stays behind the boot log while a world loads.
chooseMenuBackground();
+// -------------------------------------------------------------- audio -----
+
+// The sound toggle appears both on the main menu and in the in-game dock, so
+// both buttons are relabelled whenever the setting changes.
+function refreshAudioButtons() {
+ $("#menu-audio").text(audio.enabled ? "Sound: On" : "Sound: Off");
+ $("#audio-button").text(audio.enabled ? "Sound on" : "Sound off");
+}
+audio.onChange = refreshAudioButtons;
+refreshAudioButtons();
+
+// Every button in the game clicks; the manager ignores the call when muted.
+$(document).on("click", "button", () => audio.play("click"));
+$("#menu-audio, #audio-button").on("click", () => audio.toggle());
+
+// ------------------------------------------------------------ credits -----
+
+renderCredits($("#credits-list"));
+const $creditsModal = $("#modal-credits");
+$("#menu-credits").on("click", () => $creditsModal.removeClass("hidden"));
+$creditsModal.find(".modal-close").on("click", () => $creditsModal.addClass("hidden"));
+$(document).on("keydown", (event) => {
+ if (event.key === "Escape") $creditsModal.addClass("hidden");
+});
+
// -------------------------------------------------------------- menu ------
$("#menu-create").on("click", () => showScreen("screen-new"));
diff --git a/client/js/audio.js b/client/js/audio.js
new file mode 100644
index 0000000..a6a642c
--- /dev/null
+++ b/client/js/audio.js
@@ -0,0 +1,321 @@
+// Sound effects and looping background music for the browser client.
+//
+// Everything is built lazily on the HTML5 Audio element, so callers never have
+// to special-case a machine without audio (or the jsdom test harness): when the
+// constructor is missing every call is a silent no-op. Browsers refuse to start
+// playback before the first user gesture, so the music retries on the next
+// interaction when its first attempt was blocked.
+
+// One-shot effects. Only the handful of source files the game actually plays
+// ship under client/assets/sounds/; the downloaded libraries stay untracked.
+const EFFECT_SOURCES = {
+ // Click_Electronic_5.
+ click: "assets/sounds/button_click.mp3",
+ // Click_Electronic_14, played when a building, technology or unit finishes.
+ complete: "assets/sounds/build_complete.mp3",
+ // Mouth_08, the neutral notice (for now, a player joining).
+ notification: "assets/sounds/notification.mp3",
+ // DeathFlash, the distant impacts of a bombardment (see game_screen/bombing.js).
+ bomb: "assets/sounds/death_flash.flac",
+};
+
+// Crashed Ship (Ted Kerr), looped softly under the world map.
+const MUSIC_SOURCE = "assets/sounds/crashed_ship.ogg";
+
+const EFFECT_VOLUMES = { click: 0.35, complete: 0.6, notification: 0.6 };
+const MUSIC_VOLUME = 0.3;
+
+// Effects that are shaped with Web Audio rather than played dry. The artillery
+// impacts get a low-pass to muffle them like a far-off blast, plus a short
+// feedback delay for the echo rolling back off the ground.
+export const EFFECT_PRESETS = {
+ bomb: {
+ lowpassHz: 780,
+ lowpassQ: 0.7,
+ echoDelay: 0.28,
+ echoFeedback: 0.32,
+ echoMix: 0.35,
+ // How long after the clip the echo tail is left to ring before the graph is
+ // torn down.
+ echoTail: 2.0,
+ },
+};
+
+function clamp01(value) {
+ return Math.min(1, Math.max(0, value));
+}
+
+// A real AudioContext, or null where Web Audio is unavailable (jsdom, an old
+// browser). Kept in a function so merely importing this module never touches it.
+function defaultContextFactory() {
+ const Ctor =
+ typeof AudioContext !== "undefined"
+ ? AudioContext
+ : typeof window !== "undefined" && window.webkitAudioContext
+ ? window.webkitAudioContext
+ : null;
+ return Ctor ? new Ctor() : null;
+}
+
+export class AudioManager {
+ constructor(options = {}) {
+ this.enabled = true;
+ this.effects = { ...EFFECT_SOURCES };
+ // Per-instance copies so a caller (or a test) can retune one preset.
+ this.presets = Object.fromEntries(
+ Object.entries(EFFECT_PRESETS).map(([name, preset]) => [name, { ...preset }])
+ );
+ this.musicSource = MUSIC_SOURCE;
+ // Whether the game has asked for music; kept apart from `enabled` so the
+ // sound toggle can mute and unmute without losing the request.
+ this._wanted = false;
+ this._music = null;
+ this._retryArmed = false;
+ // Web Audio context for the shaped effects, built on first use. `null`
+ // after a failure so the plain element path is used from then on.
+ this._context = null;
+ this._contextFailed = false;
+ this._contextFactory = options.contextFactory || defaultContextFactory;
+ this._resumeArmed = false;
+ // Called with the new `enabled` value so the UI can relabel its toggles.
+ this.onChange = null;
+ }
+
+ supported() {
+ return typeof Audio !== "undefined" && typeof Audio === "function";
+ }
+
+ // Plays a one-shot effect by name. Unknown names and missing audio support are
+ // ignored so the game logic stays blissfully unaware of the device. `volume`
+ // (0..1) overrides the effect's default, and `duration` (seconds) cuts a long
+ // clip short. An effect with a preset (the bombardment impacts) is routed
+ // through the Web Audio filter/echo chain when that is available.
+ play(name, options = {}) {
+ if (!this.enabled || !this.supported()) return;
+ const source = this.effects[name];
+ if (!source) return;
+ const fallback = EFFECT_VOLUMES[name];
+ const volume = clamp01(
+ options.volume !== undefined ? options.volume : fallback === undefined ? 0.5 : fallback
+ );
+ // Touch the context on every play, so the first click (a user gesture)
+ // unlocks it for the shaped effects later on.
+ const context = this._ensureContext();
+ const preset = options.effects || this.presets[name];
+ if (preset && context && this._playShaped(context, source, volume, options, preset)) return;
+ this._playPlain(source, volume, options.duration);
+ }
+
+ // The dry path: a bare Audio element, used for every effect without a preset
+ // and as the fallback when Web Audio is unavailable.
+ _playPlain(source, volume, duration) {
+ try {
+ const audio = new Audio(source);
+ audio.volume = volume;
+ const playback = audio.play();
+ if (playback && typeof playback.catch === "function") playback.catch(() => {});
+ if (duration > 0) this._stopAfter(audio, duration);
+ } catch {
+ // A device or browser that refuses the element: the game is unaffected.
+ }
+ }
+
+ // Routes the clip through a low-pass filter with a feedback echo. Returns
+ // false when the graph cannot be built, so the caller falls back to `_playPlain`.
+ _playShaped(context, source, volume, options, preset) {
+ try {
+ const audio = new Audio(source);
+ audio.volume = volume;
+ const node = context.createMediaElementSource(audio);
+ const filter = context.createBiquadFilter();
+ filter.type = "lowpass";
+ filter.frequency.value = preset.lowpassHz;
+ filter.Q.value = preset.lowpassQ;
+ const dry = context.createGain();
+ dry.gain.value = 1;
+ const wet = context.createGain();
+ wet.gain.value = preset.echoMix;
+ const delay = context.createDelay(1);
+ delay.delayTime.value = preset.echoDelay;
+ const feedback = context.createGain();
+ feedback.gain.value = preset.echoFeedback;
+ node.connect(filter);
+ filter.connect(dry);
+ dry.connect(context.destination);
+ filter.connect(delay);
+ delay.connect(feedback);
+ feedback.connect(delay);
+ delay.connect(wet);
+ wet.connect(context.destination);
+ const playback = audio.play();
+ if (playback && typeof playback.catch === "function") playback.catch(() => {});
+ if (options.duration > 0) this._stopAfter(audio, options.duration);
+ // Tear the graph down once the echo has faded, so the feedback loop does
+ // not keep the nodes alive for the rest of the game.
+ const life = (options.duration > 0 ? options.duration : 1) + preset.echoTail;
+ this._scheduleCleanup(() => {
+ for (const part of [node, filter, dry, wet, delay, feedback]) {
+ try {
+ part.disconnect();
+ } catch {
+ // already disconnected
+ }
+ }
+ }, life);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ // The shared AudioContext. Must be created (and resumed) around a user
+ // gesture; the first click does that, and a suspended context is retried on
+ // the next interaction.
+ _ensureContext() {
+ if (this._context || this._contextFailed) return this._context;
+ try {
+ const context = this._contextFactory();
+ if (!context) {
+ this._contextFailed = true;
+ return null;
+ }
+ this._context = context;
+ this._resumeContext();
+ return context;
+ } catch {
+ this._contextFailed = true;
+ return null;
+ }
+ }
+
+ _resumeContext() {
+ const context = this._context;
+ if (!context || context.state !== "suspended" || typeof context.resume !== "function") return;
+ const resumed = context.resume();
+ if (resumed && typeof resumed.catch === "function") resumed.catch(() => {});
+ this._armResume();
+ }
+
+ // Retries resuming the context on the next interaction, the way the music
+ // does, in case the context was created outside a gesture.
+ _armResume() {
+ if (this._resumeArmed || typeof document === "undefined" || !document.addEventListener) {
+ return;
+ }
+ this._resumeArmed = true;
+ const resume = () => {
+ document.removeEventListener("pointerdown", resume);
+ document.removeEventListener("keydown", resume);
+ this._resumeArmed = false;
+ if (this._context && this._context.state === "suspended") this._resumeContext();
+ };
+ document.addEventListener("pointerdown", resume);
+ document.addEventListener("keydown", resume);
+ }
+
+ // Pauses a one-shot effect after `seconds`, so a long clip can stand in for a
+ // short burst. The element is otherwise discarded, so nothing else is needed.
+ _stopAfter(audio, seconds) {
+ if (typeof setTimeout !== "function") return;
+ setTimeout(() => {
+ try {
+ audio.pause();
+ } catch {
+ // ignore
+ }
+ }, seconds * 1000);
+ }
+
+ _scheduleCleanup(cleanup, seconds) {
+ if (typeof setTimeout !== "function") return;
+ setTimeout(cleanup, seconds * 1000);
+ }
+
+ // Asks for the looping background music. GameScreen calls this when the world
+ // map opens and stopMusic when it closes.
+ requestMusic() {
+ this._wanted = true;
+ this._startMusic();
+ }
+
+ stopMusic() {
+ this._wanted = false;
+ this._stopMusic();
+ }
+
+ setEnabled(enabled) {
+ this.enabled = !!enabled;
+ if (this.enabled) {
+ if (this._wanted) this._startMusic();
+ } else {
+ this._stopMusic();
+ }
+ if (typeof this.onChange === "function") this.onChange(this.enabled);
+ }
+
+ toggle() {
+ this.setEnabled(!this.enabled);
+ }
+
+ _startMusic() {
+ if (!this.enabled || !this._wanted || !this.supported() || this._music) return;
+ try {
+ const music = new Audio(this.musicSource);
+ music.loop = true;
+ music.volume = MUSIC_VOLUME;
+ this._music = music;
+ const playback = music.play();
+ if (playback && typeof playback.catch === "function") {
+ // Autoplay was blocked: drop the element and retry on the next gesture.
+ playback.catch(() => {
+ if (this._music === music) this._releaseMusic();
+ });
+ }
+ } catch {
+ this._releaseMusic();
+ }
+ }
+
+ _releaseMusic() {
+ if (this._music) {
+ try {
+ this._music.pause();
+ } catch {
+ // ignore
+ }
+ this._music = null;
+ }
+ if (this._wanted) this._armRetry();
+ }
+
+ _stopMusic() {
+ if (!this._music) return;
+ const music = this._music;
+ this._music = null;
+ try {
+ music.pause();
+ music.currentTime = 0;
+ } catch {
+ // ignore
+ }
+ }
+
+ // Retries the music on the first interaction after a blocked autoplay. The
+ // listeners let go of themselves, so at most one retry is ever pending.
+ _armRetry() {
+ if (this._retryArmed || typeof document === "undefined" || !document.addEventListener) {
+ return;
+ }
+ this._retryArmed = true;
+ const retry = () => {
+ document.removeEventListener("pointerdown", retry);
+ document.removeEventListener("keydown", retry);
+ this._retryArmed = false;
+ if (this._wanted && this.enabled) this._startMusic();
+ };
+ document.addEventListener("pointerdown", retry);
+ document.addEventListener("keydown", retry);
+ }
+}
+
+export const audio = new AudioManager();
diff --git a/client/js/credits.js b/client/js/credits.js
new file mode 100644
index 0000000..7b65933
--- /dev/null
+++ b/client/js/credits.js
@@ -0,0 +1,97 @@
+// The credits shown from the main menu. The icon artists are the authors of the
+// individual game-icons.net SVGs used by the unit, building and action
+// buttons; the catalogue is data-driven so a new credited asset only needs an
+// entry here.
+
+export const CREDIT_SECTIONS = [
+ {
+ title: "Visual artists",
+ note:
+ "Unit, building and nation icons drawn for game-icons.net by the artists " +
+ "below, used under CC BY 3.0.",
+ entries: [
+ {
+ name: "Delapouite",
+ detail:
+ "bank, caesar, clapperboard, control tower, delivery drone, " +
+ "encirclement, graduate cap, habitat dome, jet fighter, military " +
+ "fort, modern city, old wagon, rail road, road, ship bow, shop, " +
+ "stone bust, throne",
+ },
+ {
+ name: "Lorc",
+ detail: "anchor, crossed sabres, radar dish, rocket",
+ },
+ {
+ name: "Lord Berandas",
+ detail: "bomber, microscope",
+ },
+ { name: "Cathelineau", detail: "battleship" },
+ { name: "Pierre Leducq", detail: "anti-aircraft gun" },
+ { name: "Quoting", detail: "field gun" },
+ { name: "Skoll", detail: "FAMAS" },
+ ],
+ },
+ {
+ title: "Websites",
+ entries: [
+ {
+ name: "game-icons.net",
+ url: "https://game-icons.net",
+ detail: "the military, building and action icons, by the artists above",
+ },
+ {
+ name: "opengameart.org",
+ url: "https://opengameart.org",
+ detail: "the terrain textures, map flags and other visual assets",
+ },
+ {
+ name: "Mapbox Maki",
+ url: "https://labs.mapbox.com/maki-icons/",
+ detail: "the launch/ferry icon",
+ },
+ ],
+ },
+ {
+ title: "Music and sound",
+ entries: [
+ {
+ name: "Crashed Ship",
+ detail: "in-game background music, copyright Ted Kerr",
+ },
+ {
+ name: "UI Sound Library",
+ detail: "button, completion and notification effects",
+ },
+ ],
+ },
+];
+
+// Renders the sections into the credits modal's list element.
+export function renderCredits($list) {
+ $list.empty();
+ for (const section of CREDIT_SECTIONS) {
+ $list.append($("
").text(section.title));
+ if (section.note) {
+ $list.append($("
").text(section.note));
+ }
+ const $entries = $("
");
+ for (const entry of section.entries) {
+ const $item = $("
");
+ if (entry.url) {
+ $item.append(
+ $("
")
+ .attr({ href: entry.url, target: "_blank", rel: "noopener noreferrer" })
+ .text(entry.name)
+ );
+ } else {
+ $item.append($("
").text(entry.name));
+ }
+ if (entry.detail) {
+ $item.append($("
").text(entry.detail));
+ }
+ $entries.append($item);
+ }
+ $list.append($entries);
+ }
+}
diff --git a/client/js/game_screen.js b/client/js/game_screen.js
index 055087e..aa4615f 100644
--- a/client/js/game_screen.js
+++ b/client/js/game_screen.js
@@ -14,6 +14,9 @@ import { CIVILISATIONS, PROTO_UNITS } from "../../shared/data.js";
import { bootLog } from "./loading.js";
import { formatCapabilityReport } from "./capabilities.js";
import { panelMethods } from "./game_screen/panels.js";
+import { CompletionWatcher } from "./game_screen/completions.js";
+import { BombardmentSfx, AirStrikeSfx, zoomVolume } from "./game_screen/bombing.js";
+import { audio } from "./audio.js";
export class GameScreen {
constructor(network, config) {
@@ -54,6 +57,20 @@ export class GameScreen {
this._news = [];
this._seenNews = null;
this._entered = false;
+ // Detects buildings and units the local player finishes between snapshots;
+ // technologies are picked up from the news feed instead.
+ this.completions = new CompletionWatcher();
+ // Ambient impacts while a bombardment falls inside the viewport.
+ this.bombing = new BombardmentSfx((volume, duration) =>
+ audio.play("bomb", { volume, duration })
+ );
+ // The one big burst a plane makes when its bomb run actually resolves.
+ this.airBombing = new AirStrikeSfx((volume, duration) =>
+ audio.play("bomb", { volume, duration })
+ );
+ // Previous snapshot's strike state per unit, to spot a plane's bomb run
+ // ending (see `_detectAirStrikes`).
+ this._airStrikeState = null;
// The capability report is shown at most once per join, when the GPU map
// renderer was not used (see `_reportCapabilities`).
this._capabilitiesReported = false;
@@ -96,6 +113,7 @@ export class GameScreen {
this._entered = true;
this.map._applyCamera();
this._lastFrame = performance.now();
+ audio.requestMusic();
requestAnimationFrame((t) => this._frame(t));
}
@@ -117,6 +135,11 @@ export class GameScreen {
this.$screen.removeClass("active");
this.feed.clear();
this._seenNews = null;
+ this.completions.reset();
+ this.bombing.reset();
+ this.airBombing.reset();
+ this._airStrikeState = null;
+ audio.stopMusic();
this._hideStackMenu();
this.mapModesModal.close();
$(document).off("mousedown.stackmenu");
@@ -221,6 +244,11 @@ export class GameScreen {
this._updateCityModal();
this._updateNationModal();
this._updateNewsModal();
+ // A finished building or freshly trained unit gets a chime. The first
+ // snapshot only sets the baseline, so joining never plays a burst of them.
+ const done = this.completions.update(state, this.localCiv);
+ if (!done.first && (done.building || done.unit)) audio.play("complete");
+ this._detectAirStrikes(state);
if (this._loading) {
this._worldMirrored = true;
bootLog.step("Updated the HUD");
@@ -507,6 +535,60 @@ export class GameScreen {
event.preventDefault();
}
+ // -------------------------------------------------------------- sound ---
+
+ // Drives both bombardment effects. The ambient barrage plays while a ground
+ // or naval battery's target is on screen; an air strike's one-shot burst is
+ // started by `_detectAirStrikes` and simply runs down here.
+ _updateBombing(dtSeconds) {
+ const dtMs = dtSeconds * 1000;
+ this.bombing.update(dtMs, this._bombingInView(), zoomVolume(this.map.camera.zoom));
+ this.airBombing.update(dtMs);
+ }
+
+ _bombingInView() {
+ const units = (this.snapshot && this.snapshot.units) || [];
+ for (const unit of units) {
+ if (!unit.strikeTarget) continue;
+ // Aircraft do not rumble continuously: their sound waits for the bombs to
+ // actually fall (see `_detectAirStrikes`).
+ const proto = this.protoUnits[unit.proto];
+ if (proto && proto.air) continue;
+ if (this.map.tileInView({ x: unit.strikeTarget[0], y: unit.strikeTarget[1] })) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // A plane clears its strike target the instant it reaches it and drops its
+ // bombs; that transition, seen while the target is on screen, is the actual
+ // bombing and gets one large burst. A plane shot down on approach is simply
+ // gone from the next snapshot, and one still flying keeps its target, so
+ // neither triggers anything. The first snapshot only sets the baseline.
+ _detectAirStrikes(state) {
+ const next = new Map();
+ for (const unit of state.units || []) {
+ const proto = this.protoUnits[unit.proto];
+ next.set(unit.id, {
+ air: !!(proto && proto.air),
+ target: unit.strikeTarget || null,
+ });
+ }
+ const previous = this._airStrikeState;
+ this._airStrikeState = next;
+ if (!previous) return;
+ const volume = zoomVolume(this.map.camera.zoom);
+ for (const [id, before] of previous) {
+ if (!before.air || !before.target) continue;
+ const after = next.get(id);
+ if (!after || after.target) continue;
+ if (this.map.tileInView({ x: before.target[0], y: before.target[1] })) {
+ this.airBombing.trigger(volume);
+ }
+ }
+ }
+
// --------------------------------------------------------------- frame ---
_frame(now) {
@@ -518,6 +600,7 @@ export class GameScreen {
this._lastFrame = now;
try {
this.map.animate(dt);
+ this._updateBombing(dt);
if (this._loading) this._pollLoading();
} catch (error) {
if (typeof console !== "undefined" && console.error) {
diff --git a/client/js/game_screen/bombing.js b/client/js/game_screen/bombing.js
new file mode 100644
index 0000000..093b7d8
--- /dev/null
+++ b/client/js/game_screen/bombing.js
@@ -0,0 +1,118 @@
+// Bombardment sound.
+//
+// Two effects share the same clip. A standing ground or naval barrage plays
+// spaced impacts at random intervals while its target is in view. An aircraft
+// bomb run is different: the plane flies to its target and only then drops its
+// bombs, so it gets one large burst when the run actually resolves, not an
+// ambience during the flight. Both rise in loudness as the camera zooms in, and
+// all the timing is frame-driven, so closing the map stops them without leaving
+// a timer behind.
+
+import { ZOOM_MIN, ZOOM_MAX } from "../map_view/constants.js";
+
+export const BOMB_BURST = { min: 1, max: 3 };
+// Time between the impacts inside one burst.
+export const BOMB_GAP_MS = { min: 160, max: 380 };
+// Time between bursts.
+export const BOMB_IDLE_MS = { min: 900, max: 2400 };
+// How much of the clip each hit plays.
+export const BOMB_CLIP_S = { min: 0.3, max: 0.7 };
+export const BOMB_VOLUME = { min: 0.06, max: 0.6 };
+
+// An aircraft's bomb run arrives all at once: a large, fast burst.
+export const AIR_BOMB_BURST = { min: 7, max: 10 };
+export const AIR_BOMB_GAP_MS = { min: 55, max: 150 };
+export const AIR_BOMB_CLIP_S = { min: 0.25, max: 0.5 };
+// Ceiling on a burst so several squadrons bombing together cannot swamp the mix.
+const AIR_BOMB_LIMIT = AIR_BOMB_BURST.max * 3;
+
+function between(range, random) {
+ return range.min + random() * (range.max - range.min);
+}
+
+// The impact volume for a camera zoom: quiet when zoomed far out, louder the
+// closer the camera is to the ground.
+export function zoomVolume(zoom) {
+ const t = Math.min(1, Math.max(0, (zoom - ZOOM_MIN) / (ZOOM_MAX - ZOOM_MIN)));
+ return BOMB_VOLUME.min + t * (BOMB_VOLUME.max - BOMB_VOLUME.min);
+}
+
+export class BombardmentSfx {
+ // `play(volume, durationSeconds)` is called once per impact. `random` is
+ // injectable so a test can pin the intervals.
+ constructor(play, random = Math.random) {
+ this.play = play;
+ this.random = random;
+ this.reset();
+ }
+
+ reset() {
+ this._idle = 0;
+ this._left = 0;
+ this._gap = 0;
+ }
+
+ // Advances one frame. `dtMs` is the elapsed time, `active` whether a barrage
+ // is falling in view, and `volume` the zoom-scaled loudness of each hit.
+ update(dtMs, active, volume) {
+ if (!active) {
+ this.reset();
+ return;
+ }
+ if (this._left > 0) {
+ this._gap -= dtMs;
+ while (this._left > 0 && this._gap <= 0) {
+ this.play(volume, between(BOMB_CLIP_S, this.random));
+ this._left -= 1;
+ // Reset rather than carry the leftover, so a large frame step can never
+ // bunch the impacts together.
+ this._gap = between(BOMB_GAP_MS, this.random);
+ }
+ if (this._left <= 0) this._idle = between(BOMB_IDLE_MS, this.random);
+ return;
+ }
+ this._idle -= dtMs;
+ if (this._idle > 0) return;
+ this._left = Math.min(
+ BOMB_BURST.max,
+ BOMB_BURST.min + Math.floor(this.random() * (BOMB_BURST.max - BOMB_BURST.min + 1))
+ );
+ this._gap = 0;
+ }
+}
+
+export class AirStrikeSfx {
+ constructor(play, random = Math.random) {
+ this.play = play;
+ this.random = random;
+ this.reset();
+ }
+
+ reset() {
+ this._left = 0;
+ this._gap = 0;
+ this._volume = 0;
+ }
+
+ // Starts (or extends) a bomb-run burst. Each triggered strike adds at least
+ // AIR_BOMB_BURST.min explosions, so planes bombing together sound heavier.
+ trigger(volume) {
+ const count =
+ AIR_BOMB_BURST.min +
+ Math.floor(this.random() * (AIR_BOMB_BURST.max - AIR_BOMB_BURST.min + 1));
+ this._left = Math.min(AIR_BOMB_LIMIT, this._left + count);
+ this._gap = 0;
+ this._volume = volume;
+ }
+
+ // Advances the burst. It runs to completion even if the camera moves away.
+ update(dtMs) {
+ if (this._left <= 0) return;
+ this._gap -= dtMs;
+ while (this._left > 0 && this._gap <= 0) {
+ this.play(this._volume, between(AIR_BOMB_CLIP_S, this.random));
+ this._left -= 1;
+ this._gap = between(AIR_BOMB_GAP_MS, this.random);
+ }
+ }
+}
diff --git a/client/js/game_screen/completions.js b/client/js/game_screen/completions.js
new file mode 100644
index 0000000..da1614b
--- /dev/null
+++ b/client/js/game_screen/completions.js
@@ -0,0 +1,99 @@
+// Detects what the local player finished between two snapshots: a city building
+// that gained a level, or a unit that joined the army.
+//
+// The first snapshot only primes the baseline, so opening or joining a game in
+// progress does not fire a burst of completion sounds. A fresh unit id is
+// usually a training completion, but landing a transport's cargo mints new
+// units too, so a unit only counts when no local transport has just unloaded.
+
+// The local player's cities, as a map of city id -> building index -> level.
+export function localBuildingLevels(state, localCiv) {
+ const levels = new Map();
+ for (const city of state.cities || []) {
+ if (city.civ !== localCiv) continue;
+ levels.set(city.id, { ...(city.buildings || {}) });
+ }
+ return levels;
+}
+
+// The ids of every unit the local player owns.
+export function localUnitIds(state, localCiv) {
+ const ids = new Set();
+ for (const unit of state.units || []) {
+ if (unit.civ === localCiv) ids.add(unit.id);
+ }
+ return ids;
+}
+
+// How many passengers each local transport is carrying, by unit id. Only loaded
+// transports are listed, so unloading one makes it disappear from the map.
+export function localCargo(state, localCiv) {
+ const cargo = new Map();
+ for (const unit of state.units || []) {
+ if (unit.civ !== localCiv) continue;
+ const count = (unit.cargo || []).length + (unit.embarking ? unit.embarking.count : 0);
+ if (count > 0) cargo.set(unit.id, count);
+ }
+ return cargo;
+}
+
+// True when any city the player already held has raised a building level. A
+// city absent from `previous` was just captured and is not a completion.
+export function buildingLevelGained(previous, next) {
+ for (const [cityId, levels] of next) {
+ const before = previous.get(cityId);
+ if (!before) continue;
+ for (const index of Object.keys(levels)) {
+ if ((before[index] || 0) < levels[index]) return true;
+ }
+ }
+ return false;
+}
+
+// True when a transport carried fewer passengers than before, which is the
+// signature of a disembark (the units it minted are not freshly trained).
+export function cargoUnloaded(previous, next) {
+ for (const [unitId, count] of previous) {
+ if ((next.get(unitId) || 0) < count) return true;
+ }
+ return false;
+}
+
+function hasFreshUnit(previous, next) {
+ for (const id of next) {
+ if (!previous.has(id)) return true;
+ }
+ return false;
+}
+
+export class CompletionWatcher {
+ constructor() {
+ this.reset();
+ }
+
+ reset() {
+ this._buildings = null;
+ this._units = null;
+ this._cargo = null;
+ }
+
+ // Feeds one snapshot and reports what changed. `first` marks the priming
+ // snapshot, which never reports a completion.
+ update(state, localCiv) {
+ const buildings = localBuildingLevels(state, localCiv);
+ const units = localUnitIds(state, localCiv);
+ const cargo = localCargo(state, localCiv);
+ if (this._buildings === null) {
+ this._buildings = buildings;
+ this._units = units;
+ this._cargo = cargo;
+ return { first: true, building: false, unit: false };
+ }
+ const building = buildingLevelGained(this._buildings, buildings);
+ const unit = !cargoUnloaded(this._cargo, cargo) && hasFreshUnit(this._units, units);
+ this._buildings = buildings;
+ this._units = units;
+ this._cargo = cargo;
+ return { first: false, building, unit };
+ }
+}
diff --git a/client/js/game_screen/panels.js b/client/js/game_screen/panels.js
index cff27d5..b1c965c 100644
--- a/client/js/game_screen/panels.js
+++ b/client/js/game_screen/panels.js
@@ -12,6 +12,8 @@ import {
GOVERNMENTS,
TECHNOLOGIES,
NEWS_WAR,
+ NEWS_TECHNOLOGY,
+ NEWS_JOIN,
ECONOMY,
EFFECT_GDP,
APPROVAL,
@@ -30,6 +32,7 @@ import { parseKey } from "../../../shared/hex.js";
import { ethnicityChart } from "../ui/ethnicity.js";
import { isEconomicMode } from "../map_view/economic.js";
import { isWaterTile } from "../map_view/textures.js";
+import { audio } from "../audio.js";
// A tiny memo cache keyed by bucket and key, used while a per-tile economic
// figure is computed for every tile at once.
@@ -979,11 +982,13 @@ export const panelMethods = {
},
// The city panel's quick view of what is currently being produced there, so
- // the player can read progress without opening the city modal.
+ // the player can read progress without opening the city modal. A foreign
+ // city's production is never shown, even when its tile is inspected.
_renderCityProduction(city) {
const $el = $("#city-production");
if (!$el.length) return;
$el.empty();
+ if (!city || city.civ !== this.localCiv) return;
const queue = city ? this._trainingQueue(city.id) : null;
const entry = queue && queue[0];
if (!entry) return;
@@ -1686,6 +1691,13 @@ export const panelMethods = {
this.feed.push(`${this._civName(entry.actor)} has declared war on you!`, { alert: true });
return;
}
+ // A technology the viewer finishes chimes like a building or unit; someone
+ // else taking control of a nation is the neutral notice (Mouth_08).
+ if (entry.type === NEWS_TECHNOLOGY && entry.civ === this.localCiv) {
+ audio.play("complete");
+ } else if (entry.type === NEWS_JOIN && entry.civ !== this.localCiv) {
+ audio.play("notification");
+ }
this.feed.push(describeNews(entry, this.civilisations));
},
diff --git a/client/js/map_view/entities.js b/client/js/map_view/entities.js
index ab54366..52f074b 100644
--- a/client/js/map_view/entities.js
+++ b/client/js/map_view/entities.js
@@ -275,12 +275,15 @@ export const entityMethods = {
for (const data of cities) {
const id = data.id;
seen.add(id);
+ const owned = data.civ === this.localCiv;
let view = this._cityViews.get(id);
if (!view) {
view = this._createCityView(data);
this._cityViews.set(id, view);
}
- this._updateCityProgress(view, training && training[id]);
+ // A foreign city never shows its production: even if a queue slipped
+ // through, the progress bar stays hidden.
+ this._updateCityProgress(view, owned && training ? training[id] : null);
this._syncCityBuildings(view, data);
const coords = { x: data.coords[0], y: data.coords[1] };
this._cityCiv.set(key(coords.x, coords.y), data.civ);
@@ -293,7 +296,6 @@ export const entityMethods = {
const groundY = local.y * CAMERA_TILT;
view.style.left = `${wrappedX}px`;
view.style.top = `${groundY}px`;
- const owned = data.civ === this.localCiv;
view.dataset.cityId = String(id);
view.dataset.civ = String(data.civ);
const label = view._label;
diff --git a/client/js/map_view/input.js b/client/js/map_view/input.js
index f2e2492..b464bb1 100644
--- a/client/js/map_view/input.js
+++ b/client/js/map_view/input.js
@@ -47,6 +47,26 @@ export const inputMethods = {
this._glRender();
},
+ // Whether a map tile falls inside the current viewport, optionally widened by
+ // `marginTiles`. The test runs in world space against the wrapped copy the
+ // entities are actually drawn at, so a target near the wrap seam is judged
+ // where it appears rather than at its unwrapped coordinate.
+ tileInView(coords, marginTiles = 0) {
+ if (!coords) return false;
+ const width = this.$viewport.width();
+ const height = this.$viewport.height();
+ if (!width || !height) return false;
+ const zoom = this.camera.zoom || 1;
+ const local = mapToLocal(coords.x, coords.y);
+ const wrapped = this._wrappedPosition(local);
+ const halfW = width / (2 * zoom) + marginTiles * HEX_W;
+ const halfH = height / (2 * zoom * CAMERA_TILT) + marginTiles * HEX_H;
+ return (
+ Math.abs(wrapped.x - this.camera.x) <= halfW &&
+ Math.abs(local.y - this.camera.y) <= halfH
+ );
+ },
+
_setupCameraInput() {
let dragging = false;
let dragged = false;
diff --git a/server/server.js b/server/server.js
index 4adf238..d3bfe1c 100644
--- a/server/server.js
+++ b/server/server.js
@@ -29,7 +29,9 @@ const MIME = {
".jpeg": "image/jpeg",
".gif": "image/gif",
".wav": "audio/wav",
+ ".mp3": "audio/mpeg",
".ogg": "audio/ogg",
+ ".flac": "audio/flac",
".ttf": "font/ttf",
".woff": "font/woff",
".woff2": "font/woff2",
diff --git a/shared/game_state/serialization.js b/shared/game_state/serialization.js
index 467c461..a3c03de 100644
--- a/shared/game_state/serialization.js
+++ b/shared/game_state/serialization.js
@@ -122,6 +122,10 @@ export const serializationMethods = {
result.viewerStats = stats || this.viewerStats(viewerCiv);
result.explored = this._cachedExplored(viewerCiv);
result.visible = serializeSet(this.visible.get(viewerCiv) || new Set());
+ // What a city is producing is its owner's business: every other viewer sees
+ // an empty queue, so neither the map bar nor the city panel can reveal
+ // whether a foreign city is busy.
+ result.training = trainingForViewer(shared.training, shared.cities, viewerCiv);
return result;
},
@@ -649,6 +653,21 @@ function roundTo(value, digits) {
return Math.round(value * factor) / factor;
}
+// The production queues a viewer may see: only its own cities'. A spectator
+// (viewer -1) sees none. City ids are object keys, so they arrive as strings.
+function trainingForViewer(training, cities, viewerCiv) {
+ if (!training || viewerCiv < 0) return {};
+ const own = new Set();
+ for (const city of cities || []) {
+ if (city.civ === viewerCiv) own.add(city.id);
+ }
+ const filtered = {};
+ for (const [cityId, queue] of Object.entries(training)) {
+ if (own.has(Number(cityId))) filtered[cityId] = queue;
+ }
+ return filtered;
+}
+
// A cheap, stable fingerprint of the serialised city stats. Comparing it lets
// the server treat cityStats like the other versioned collections without
// having to hook every economy-affecting mutation (pillage, battle scars,
diff --git a/tests/audio_test.js b/tests/audio_test.js
new file mode 100644
index 0000000..8366f3f
--- /dev/null
+++ b/tests/audio_test.js
@@ -0,0 +1,134 @@
+import { TestCase } from "./framework/test_case.js";
+import { setupDom, teardownDom, FakeAudio } from "./framework/dom.js";
+import { AudioManager, EFFECT_PRESETS } from "../client/js/audio.js";
+
+// A minimal Web Audio stand-in that records every node it hands out and the
+// connections made between them.
+function fakeAudioContext() {
+ const nodes = [];
+ const node = (kind) => {
+ const created = {
+ kind,
+ connected: [],
+ connect(target) {
+ this.connected.push(target);
+ return target;
+ },
+ disconnect() {
+ this.disconnected = true;
+ },
+ };
+ nodes.push(created);
+ return created;
+ };
+ return {
+ nodes,
+ state: "running",
+ destination: node("destination"),
+ resume() {
+ return Promise.resolve();
+ },
+ createMediaElementSource: () => node("source"),
+ createBiquadFilter: () =>
+ Object.assign(node("biquad"), { type: "", frequency: { value: 0 }, Q: { value: 0 } }),
+ createGain: () => Object.assign(node("gain"), { gain: { value: 0 } }),
+ createDelay: () => Object.assign(node("delay"), { delayTime: { value: 0 } }),
+ };
+}
+
+export class AudioTest extends TestCase {
+ test_effect_sources_pick_the_chosen_library_files() {
+ const manager = new AudioManager();
+ this.assertEqual(manager.effects.click, "assets/sounds/button_click.mp3");
+ this.assertEqual(manager.effects.complete, "assets/sounds/build_complete.mp3");
+ this.assertEqual(manager.effects.notification, "assets/sounds/notification.mp3");
+ this.assertEqual(manager.effects.bomb, "assets/sounds/death_flash.flac");
+ this.assertEqual(manager.musicSource, "assets/sounds/crashed_ship.ogg");
+ }
+
+ async test_plays_effects_and_mutes() {
+ const env = await setupDom();
+ try {
+ FakeAudio.reset();
+ const manager = new AudioManager();
+ manager.play("click");
+ this.assertEqual(FakeAudio.playedSources, ["assets/sounds/button_click.mp3"]);
+
+ // The bombardment passes an explicit, zoom-scaled volume.
+ manager.play("bomb", { volume: 0.25 });
+ const bomb = FakeAudio.instances[FakeAudio.instances.length - 1];
+ this.assertEqual(bomb.src, "assets/sounds/death_flash.flac");
+ this.assertApprox(bomb.volume, 0.25, 1e-9, "the volume option overrides the default");
+
+ manager.setEnabled(false);
+ const muted = FakeAudio.playedSources.length;
+ manager.play("click");
+ manager.play("bomb", { volume: 0.9 });
+ this.assertEqual(FakeAudio.playedSources.length, muted, "muted effects are silent");
+
+ let changed = null;
+ manager.onChange = (value) => {
+ changed = value;
+ };
+ manager.setEnabled(true);
+ this.assertEqual(changed, true, "the toggle reports its new state");
+ } finally {
+ teardownDom(env);
+ }
+ }
+
+ async test_bombardment_uses_a_lowpass_and_echo() {
+ const env = await setupDom();
+ try {
+ FakeAudio.reset();
+ const context = fakeAudioContext();
+ const manager = new AudioManager({ contextFactory: () => context });
+ // Keep the graph-cleanup timer short so the test does not linger.
+ manager.presets.bomb.echoTail = 0.01;
+ manager.play("bomb", { volume: 0.4, duration: 0.01 });
+
+ const filter = context.nodes.find((node) => node.kind === "biquad");
+ this.assertNotNull(filter, "the clip runs through a biquad filter");
+ this.assertEqual(filter.type, "lowpass", "the blasts are muffled");
+ this.assertApprox(filter.frequency.value, EFFECT_PRESETS.bomb.lowpassHz);
+
+ const delay = context.nodes.find((node) => node.kind === "delay");
+ this.assertNotNull(delay, "an echo delay is built");
+ this.assertApprox(delay.delayTime.value, EFFECT_PRESETS.bomb.echoDelay);
+ const feedback = delay.connected.find(
+ (node) => node.kind === "gain" && node.connected.includes(delay)
+ );
+ this.assertNotNull(feedback, "the delay feeds back on itself");
+ this.assertApprox(feedback.gain.value, EFFECT_PRESETS.bomb.echoFeedback);
+
+ const played = FakeAudio.instances.find((audio) => audio.src === "assets/sounds/death_flash.flac");
+ this.assertNotNull(played, "the clip is played through the graph");
+ } finally {
+ teardownDom(env);
+ }
+ }
+
+ async test_music_loops_and_stops() {
+ const env = await setupDom();
+ try {
+ FakeAudio.reset();
+ const manager = new AudioManager();
+ manager.requestMusic();
+ const music = FakeAudio.instances.find((audio) => audio.src === manager.musicSource);
+ this.assertNotNull(music, "the music element was created");
+ this.assertTrue(music.loop, "the music loops");
+ this.assertEqual(music.plays, 1, "the music was started once");
+
+ manager.stopMusic();
+ this.assertTrue(music.paused, "stopping pauses the music");
+
+ // Muting while music is wanted stops it; unmuting starts it again.
+ manager.requestMusic();
+ manager.setEnabled(false);
+ manager.setEnabled(true);
+ this.assertGreater(FakeAudio.instances.length, 1, "music restarts when unmuted");
+ } finally {
+ teardownDom(env);
+ }
+ }
+}
diff --git a/tests/bombing_test.js b/tests/bombing_test.js
new file mode 100644
index 0000000..c5ec357
--- /dev/null
+++ b/tests/bombing_test.js
@@ -0,0 +1,108 @@
+import { TestCase } from "./framework/test_case.js";
+import {
+ BombardmentSfx,
+ AirStrikeSfx,
+ zoomVolume,
+ BOMB_VOLUME,
+ BOMB_CLIP_S,
+ BOMB_GAP_MS,
+ BOMB_IDLE_MS,
+ AIR_BOMB_BURST,
+} from "../client/js/game_screen/bombing.js";
+
+export class BombingTest extends TestCase {
+ test_volume_grows_with_zoom() {
+ this.assertLess(zoomVolume(0.2), zoomVolume(1.0), "closer is louder");
+ this.assertLess(zoomVolume(1.0), zoomVolume(2.0), "and louder still up close");
+ this.assertApprox(zoomVolume(0.2), BOMB_VOLUME.min);
+ this.assertApprox(zoomVolume(2.0), BOMB_VOLUME.max);
+ // Out-of-range zooms clamp rather than going silent or overdriven.
+ this.assertApprox(zoomVolume(-5), BOMB_VOLUME.min);
+ this.assertApprox(zoomVolume(50), BOMB_VOLUME.max);
+ }
+
+ test_silent_while_nothing_is_bombing() {
+ const plays = [];
+ const sfx = new BombardmentSfx((volume, duration) => plays.push({ volume, duration }));
+ for (let i = 0; i < 50; i++) sfx.update(100, false, 0.5);
+ this.assertEqual(plays.length, 0);
+ }
+
+ test_bursts_play_short_clips_at_the_zoom_volume() {
+ const plays = [];
+ let now = 0;
+ const sfx = new BombardmentSfx(
+ (volume, duration) => plays.push({ volume, duration, at: now }),
+ () => 0.5
+ );
+ // Ten seconds of frames, 100 ms apart, with a bombardment in view.
+ for (let i = 0; i < 100; i++) {
+ now += 100;
+ sfx.update(100, true, 0.4);
+ }
+ this.assertGreater(plays.length, 0, "impacts play");
+ this.assertLess(plays.length, 100, "the impacts arrive in bursts, not every frame");
+ for (const play of plays) {
+ this.assertApprox(play.volume, 0.4, 1e-9, "the impact uses the zoom volume");
+ this.assertGreaterOrEqual(play.duration, BOMB_CLIP_S.min);
+ this.assertLess(play.duration, BOMB_CLIP_S.max + 1e-9, "each clip is cut short");
+ }
+ }
+
+ test_impacts_keep_their_spacing() {
+ const times = [];
+ let now = 0;
+ const sfx = new BombardmentSfx(() => times.push(now), () => 0.99);
+ // Forty seconds of frames, 100 ms apart.
+ for (let i = 0; i < 400; i++) {
+ now += 100;
+ sfx.update(100, true, 0.5);
+ }
+ this.assertGreater(times.length, 3, "impacts play");
+ const gaps = [];
+ for (let i = 1; i < times.length; i++) gaps.push(times[i] - times[i - 1]);
+ for (const gap of gaps) {
+ this.assertGreaterOrEqual(gap, BOMB_GAP_MS.min, "impacts never bunch up");
+ }
+ this.assertTrue(
+ gaps.some((gap) => gap >= BOMB_IDLE_MS.min),
+ "and there are quiet gaps between bursts"
+ );
+ }
+
+ test_air_strike_is_one_large_one_shot_burst() {
+ const plays = [];
+ let now = 0;
+ const sfx = new AirStrikeSfx(
+ (volume, duration) => plays.push({ volume, duration, at: now }),
+ () => 0.99
+ );
+ sfx.trigger(0.5);
+ // Two seconds of frames, 50 ms apart, cover the whole burst.
+ for (let i = 0; i < 40; i++) {
+ now += 50;
+ sfx.update(50);
+ }
+ this.assertGreaterOrEqual(plays.length, AIR_BOMB_BURST.min, "at least seven explosions");
+ for (const play of plays) this.assertApprox(play.volume, 0.5, 1e-9);
+ // It is a one-shot: no further impacts once the burst has run out.
+ const after = plays.length;
+ for (let i = 0; i < 20; i++) {
+ now += 50;
+ sfx.update(50);
+ }
+ this.assertEqual(plays.length, after, "the burst does not keep repeating");
+ }
+
+ test_stopping_the_bombing_stops_the_sound() {
+ const plays = [];
+ const sfx = new BombardmentSfx((volume, duration) => plays.push(duration), () => 0);
+ for (let i = 0; i < 10; i++) sfx.update(100, true, 0.5);
+ const before = plays.length;
+ this.assertGreater(before, 0, "something played while active");
+ for (let i = 0; i < 10; i++) sfx.update(100, false, 0.5);
+ this.assertEqual(plays.length, before, "no impacts once the bombing stops");
+ for (let i = 0; i < 5; i++) sfx.update(100, true, 0.5);
+ this.assertGreater(plays.length, before, "the ambience resumes with the next barrage");
+ }
+}
diff --git a/tests/completions_test.js b/tests/completions_test.js
new file mode 100644
index 0000000..eb13bc2
--- /dev/null
+++ b/tests/completions_test.js
@@ -0,0 +1,97 @@
+import { TestCase } from "./framework/test_case.js";
+import {
+ CompletionWatcher,
+ buildingLevelGained,
+ cargoUnloaded,
+} from "../client/js/game_screen/completions.js";
+
+const city = (id, civ, buildings = {}) => ({ id, civ, buildings });
+const unit = (id, civ, cargo = []) => ({ id, civ, cargo });
+const snapshot = ({ cities = [], units = [] } = {}) => ({ cities, units });
+
+export class CompletionsTest extends TestCase {
+ test_first_snapshot_only_primes_the_baseline() {
+ const watcher = new CompletionWatcher();
+ const done = watcher.update(
+ snapshot({ cities: [city(1, 0, { 0: 2 })], units: [unit(7, 0)] }),
+ 0
+ );
+ this.assertTrue(done.first);
+ this.assertFalse(done.building);
+ this.assertFalse(done.unit);
+ }
+
+ test_detects_a_building_gaining_a_level() {
+ const watcher = new CompletionWatcher();
+ watcher.update(snapshot({ cities: [city(1, 0, { 0: 1 })] }), 0);
+ const done = watcher.update(snapshot({ cities: [city(1, 0, { 0: 2 })] }), 0);
+ this.assertTrue(done.building);
+ this.assertFalse(done.unit);
+ }
+
+ test_detects_a_freshly_trained_unit() {
+ const watcher = new CompletionWatcher();
+ watcher.update(snapshot({ units: [unit(7, 0)] }), 0);
+ const done = watcher.update(snapshot({ units: [unit(7, 0), unit(8, 0)] }), 0);
+ this.assertTrue(done.unit);
+ this.assertFalse(done.building);
+ }
+
+ test_a_second_snapshot_with_no_change_is_silent() {
+ const watcher = new CompletionWatcher();
+ const state = snapshot({ cities: [city(1, 0, { 0: 1 })], units: [unit(7, 0)] });
+ watcher.update(state, 0);
+ const done = watcher.update(
+ snapshot({ cities: [city(1, 0, { 0: 1 })], units: [unit(7, 0)] }),
+ 0
+ );
+ this.assertFalse(done.building);
+ this.assertFalse(done.unit);
+ }
+
+ test_a_captured_city_does_not_count_as_a_completion() {
+ const watcher = new CompletionWatcher();
+ watcher.update(snapshot({ cities: [city(1, 0, {})] }), 0);
+ const done = watcher.update(
+ snapshot({ cities: [city(1, 0, {}), city(2, 0, { 0: 3 })] }),
+ 0
+ );
+ this.assertFalse(done.building);
+ }
+
+ test_only_the_local_player_is_watched() {
+ const watcher = new CompletionWatcher();
+ watcher.update(snapshot({ cities: [city(1, 1, {}), city(2, 0, {})] }), 0);
+ const done = watcher.update(
+ snapshot({ cities: [city(1, 1, { 0: 4 }), city(2, 0, {})] }),
+ 0
+ );
+ this.assertFalse(done.building);
+ }
+
+ test_landing_troops_is_not_a_trained_unit() {
+ const watcher = new CompletionWatcher();
+ watcher.update(snapshot({ units: [unit(5, 0, [{}]), unit(6, 1)] }), 0);
+ // The transport empties and a fresh infantry id appears on the shore.
+ const done = watcher.update(snapshot({ units: [unit(5, 0, []), unit(9, 0)] }), 0);
+ this.assertFalse(done.unit, "disembarking does not chime");
+ }
+
+ test_reset_primes_the_baseline_again() {
+ const watcher = new CompletionWatcher();
+ watcher.update(snapshot({ cities: [city(1, 0, {})] }), 0);
+ watcher.update(snapshot({ cities: [city(1, 0, { 0: 1 })] }), 0);
+ watcher.reset();
+ const done = watcher.update(snapshot({ cities: [city(1, 0, { 0: 1 })] }), 0);
+ this.assertTrue(done.first);
+ }
+
+ test_helpers_compare_levels_and_cargo() {
+ this.assertTrue(buildingLevelGained(new Map([[1, { 0: 1 }]]), new Map([[1, { 0: 2 }]])));
+ this.assertFalse(
+ buildingLevelGained(new Map([[1, { 0: 1 }]]), new Map([[1, { 0: 1 }], [2, { 0: 5 }]]))
+ );
+ this.assertTrue(cargoUnloaded(new Map([[5, 2]]), new Map([[5, 0]])));
+ this.assertFalse(cargoUnloaded(new Map([[5, 2]]), new Map([[5, 2]])));
+ }
+}
diff --git a/tests/credits_test.js b/tests/credits_test.js
new file mode 100644
index 0000000..a8c52f0
--- /dev/null
+++ b/tests/credits_test.js
@@ -0,0 +1,64 @@
+import { TestCase } from "./framework/test_case.js";
+import { setupDom, teardownDom, FakeAudio } from "./framework/dom.js";
+import { CREDIT_SECTIONS } from "../client/js/credits.js";
+import { audio } from "../client/js/audio.js";
+
+export class CreditsTest extends TestCase {
+ test_credits_name_the_required_sources() {
+ const text = JSON.stringify(CREDIT_SECTIONS);
+ for (const needle of [
+ "game-icons.net",
+ "opengameart.org",
+ "Ted Kerr",
+ "Delapouite",
+ "Lorc",
+ "Mapbox Maki",
+ ]) {
+ this.assertTrue(text.includes(needle), `credits mention ${needle}`);
+ }
+ }
+
+ async test_menu_button_opens_the_credits_and_clicks_sound() {
+ const env = await setupDom();
+ try {
+ FakeAudio.reset();
+ await import(`../client/js/app.js?credits=${Date.now()}`);
+ audio.setEnabled(true);
+ this.assertTrue(env.$("#modal-credits").hasClass("hidden"));
+
+ env.$("#menu-credits").click();
+ this.assertFalse(env.$("#modal-credits").hasClass("hidden"));
+ this.assertGreaterOrEqual(env.$("#credits-list .credits-title").length, 3);
+ this.assertTrue(
+ FakeAudio.playedSources.includes("assets/sounds/button_click.mp3"),
+ "a button click plays the click effect"
+ );
+
+ env.$("#modal-credits .modal-close").click();
+ this.assertTrue(env.$("#modal-credits").hasClass("hidden"));
+ } finally {
+ teardownDom(env);
+ }
+ }
+
+ async test_audio_toggle_relabels_both_buttons() {
+ const env = await setupDom();
+ try {
+ await import(`../client/js/app.js?audio=${Date.now()}`);
+ audio.setEnabled(true);
+ this.assertEqual(env.$("#menu-audio").text(), "Sound: On");
+ this.assertEqual(env.$("#audio-button").text(), "Sound on");
+
+ env.$("#menu-audio").click();
+ this.assertFalse(audio.enabled);
+ this.assertEqual(env.$("#menu-audio").text(), "Sound: Off");
+
+ env.$("#audio-button").click();
+ this.assertTrue(audio.enabled);
+ this.assertEqual(env.$("#menu-audio").text(), "Sound: On");
+ } finally {
+ audio.setEnabled(true);
+ teardownDom(env);
+ }
+ }
+}
diff --git a/tests/framework/dom.js b/tests/framework/dom.js
index 4878288..aa0a06b 100644
--- a/tests/framework/dom.js
+++ b/tests/framework/dom.js
@@ -13,7 +13,7 @@ const JQUERY_PATH = fileURLToPath(new URL("../../client/vendor/jquery.min.js", i
// Only the globals the client actually reads. Some Node globals (navigator,
// location) are getter-only, so they are left alone.
const MANAGED_GLOBALS = [
- "window", "document", "$", "jQuery", "WebSocket",
+ "window", "document", "$", "jQuery", "WebSocket", "Audio",
"requestAnimationFrame", "cancelAnimationFrame",
];
@@ -73,6 +73,41 @@ export class FakeWebSocket {
}
}
+// Stand-in for the browser Audio element. jsdom's play() is unimplemented and
+// noisy, and the tests only care that the right source was asked to play.
+export class FakeAudio {
+ static instances = [];
+
+ static reset() {
+ FakeAudio.instances = [];
+ }
+
+ // Every source that was actually played, in order.
+ static get playedSources() {
+ return FakeAudio.instances.filter((audio) => audio.plays > 0).map((audio) => audio.src);
+ }
+
+ constructor(src) {
+ this.src = src;
+ this.volume = 1;
+ this.loop = false;
+ this.currentTime = 0;
+ this.paused = true;
+ this.plays = 0;
+ FakeAudio.instances.push(this);
+ }
+
+ play() {
+ this.plays += 1;
+ this.paused = false;
+ return Promise.resolve();
+ }
+
+ pause() {
+ this.paused = true;
+ }
+}
+
export async function setupDom(options = {}) {
const html = await readFile(INDEX_PATH, "utf8");
const jquerySource = await readFile(JQUERY_PATH, "utf8");
@@ -96,6 +131,7 @@ export async function setupDom(options = {}) {
install("$", $);
install("jQuery", $);
install("WebSocket", options.webSocket || FakeWebSocket);
+ install("Audio", options.audio || FakeAudio);
// Keep the render loop out of tests: enter() schedules a frame but the stub
// never runs it, so no timer keeps the process alive.
install("requestAnimationFrame", () => 0);
diff --git a/tests/game_screen_test.js b/tests/game_screen_test.js
index b827dc6..d569485 100644
--- a/tests/game_screen_test.js
+++ b/tests/game_screen_test.js
@@ -1,7 +1,7 @@
import { TestCase } from "./framework/test_case.js";
import { setupDom, teardownDom } from "./framework/dom.js";
import { GameScreen } from "../client/js/game_screen.js";
-import { BUILDINGS, CIVILISATIONS, TECHNOLOGIES, ECONOMY } from "../shared/data.js";
+import { BUILDINGS, CIVILISATIONS, TECHNOLOGIES, PROTO_UNITS, ECONOMY } from "../shared/data.js";
import { HOURS_PER_DAY } from "../shared/game_state/constants.js";
import { hoursInNextMonth } from "../shared/game_clock.js";
import { compact } from "../shared/text_format.js";
@@ -1706,6 +1706,52 @@ export class GameScreenTest extends TestCase {
teardownDom(env);
}
}
+
+ async test_air_strike_burst_fires_on_the_bomb_run_not_the_flight() {
+ const env = await setupDom();
+ try {
+ const screen = new GameScreen(stubNetwork(), makeConfig());
+ screen.enter();
+ screen.protoUnits = PROTO_UNITS;
+ screen.map.tileInView = () => true;
+ const airIndex = PROTO_UNITS.findIndex((proto) => proto.air);
+ const groundIndex = PROTO_UNITS.findIndex((proto) => proto.military && !proto.air);
+ const triggers = [];
+ screen.airBombing.trigger = (volume) => triggers.push(volume);
+
+ // The first snapshot only primes the baseline: no burst while the plane
+ // is still flying towards its target.
+ screen._detectAirStrikes({ units: [{ id: 1, proto: airIndex, strikeTarget: [5, 5] }] });
+ this.assertEmpty(triggers, "nothing while the plane is en route");
+ screen._detectAirStrikes({ units: [{ id: 1, proto: airIndex, strikeTarget: [5, 5] }] });
+ this.assertEmpty(triggers, "still nothing while it flies");
+
+ // Reaching the target clears the run: the bombs fall.
+ screen._detectAirStrikes({ units: [{ id: 1, proto: airIndex, strikeTarget: null }] });
+ this.assertSize(triggers, 1, "the resolved bomb run triggers the burst");
+
+ // A plane shot down before bombing is gone, not detonated.
+ screen._detectAirStrikes({ units: [{ id: 2, proto: airIndex, strikeTarget: [6, 6] }] });
+ screen._detectAirStrikes({ units: [] });
+ this.assertSize(triggers, 1, "a downed plane does not bomb");
+
+ // An off-screen bomb run is silent.
+ screen.map.tileInView = () => false;
+ screen._detectAirStrikes({ units: [{ id: 3, proto: airIndex, strikeTarget: [7, 7] }] });
+ screen._detectAirStrikes({ units: [{ id: 3, proto: airIndex, strikeTarget: null }] });
+ this.assertSize(triggers, 1, "the burst only plays in view");
+
+ // Aircraft do not drive the ambient barrage, ground batteries still do.
+ screen.map.tileInView = () => true;
+ screen.snapshot = { units: [{ id: 4, proto: airIndex, strikeTarget: [1, 1] }] };
+ this.assertFalse(screen._bombingInView(), "aircraft stay out of the ambience");
+ screen.snapshot = { units: [{ id: 5, proto: groundIndex, strikeTarget: [1, 1] }] };
+ this.assertTrue(screen._bombingInView(), "a ground battery still rumbles");
+ screen.leave();
+ } finally {
+ teardownDom(env);
+ }
+ }
}
function findEnemyTile(state) {
diff --git a/tests/game_state_test.js b/tests/game_state_test.js
index 08bf1c3..accfb75 100644
--- a/tests/game_state_test.js
+++ b/tests/game_state_test.js
@@ -330,13 +330,39 @@ export class GameStateTest extends TestCase {
for (const key of [
"seed", "totalHours", "civs", "protos", "buildings", "governments", "technologies",
"cities", "units", "territory", "population", "budgets", "culture",
- "government", "researched", "training",
+ "government", "researched",
]) {
this.assertEqual(first[key], second[key], `key ${key} is shared`);
}
this.assertNotEqual(first.visible, second.visible, "visibility stays per viewer");
}
+ test_snapshot_only_shows_the_viewers_own_production() {
+ const state = smallState();
+ state.budgets.set(0, 1.0e15);
+ state.budgets.set(1, 1.0e15);
+ const mine = state.cities.find((c) => c.civ === 0);
+ const theirs = state.cities.find((c) => c.civ === 1);
+ grantBuilding(state, mine, "barracks");
+ grantBuilding(state, theirs, "barracks");
+ this.assertTrue(state.requestTrain(mine.id, 0));
+ this.assertTrue(state.requestTrain(theirs.id, 0));
+
+ const asMine = state.snapshot(0).training;
+ this.assertHas(asMine, mine.id);
+ this.assertFalse(
+ Object.prototype.hasOwnProperty.call(asMine, theirs.id),
+ "no enemy queue is shipped"
+ );
+ const asTheirs = state.snapshot(1).training;
+ this.assertHas(asTheirs, theirs.id);
+ this.assertFalse(
+ Object.prototype.hasOwnProperty.call(asTheirs, mine.id),
+ "the enemy sees nothing either"
+ );
+ this.assertEmpty(state.snapshot(-1).training, "a spectator sees no production");
+ }
+
test_explored_serialisation_cache_invalidates_on_discovery() {
const state = smallState();
state.explored.get(0).clear();
diff --git a/tests/map_view_test.js b/tests/map_view_test.js
index 4b581ad..58f7e1d 100644
--- a/tests/map_view_test.js
+++ b/tests/map_view_test.js
@@ -154,6 +154,24 @@ export class MapViewTest extends TestCase {
}
}
+ async test_tile_in_view_follows_the_camera() {
+ const state = smallState();
+ const { env, map } = await this.build(state);
+ try {
+ const capital = state.cities.find((c) => c.civ === 0 && c.isCapital);
+ const coords = { x: capital.coords.x, y: capital.coords.y };
+ map.centerOn(coords);
+ this.assertTrue(map.tileInView(coords), "the centred tile is in view");
+ this.assertTrue(map.tileInView(coords, 1), "still in view with a margin");
+
+ // Pan away along the (unwrapped) north-south axis: the tile drops out.
+ map.camera.y += 100000;
+ this.assertFalse(map.tileInView(coords), "a far tile is out of view");
+ } finally {
+ teardownDom(env);
+ }
+ }
+
async test_tile_at_screen_round_trips() {
const state = smallState();
const { env, map } = await this.build(state);
@@ -834,6 +852,23 @@ export class MapViewTest extends TestCase {
}
}
+ async test_foreign_city_hides_its_production() {
+ const state = smallState();
+ state.budgets.set(1, 1.0e15);
+ const enemy = state.cities.find((c) => c.civ === 1);
+ grantBuilding(state, enemy, "barracks");
+ this.assertTrue(state.requestTrain(enemy.id, 0));
+ state.training.get(enemy.id)[0].elapsedHours = 1;
+ const { env, map } = await this.build(state);
+ try {
+ const bar = map._cityViews.get(enemy.id).querySelector(".city-progress");
+ this.assertNotNull(bar, "the enemy city has a progress element");
+ this.assertEqual(bar.style.display, "none", "a foreign city never shows production");
+ } finally {
+ teardownDom(env);
+ }
+ }
+
async test_each_building_level_adds_a_square_around_the_city() {
const state = smallState();
const city = state.cities.find((c) => c.civ === 0);