Added sound and credits, bombardment audio, and hid foreign production

This commit is contained in:
2026-09-21 13:36:28 +02:00
parent a33dfe8132
commit f2b52fc2b4
28 changed files with 1395 additions and 6 deletions
+4
View File
@@ -8,3 +8,7 @@
# direnv
/.direnv/
# Source sound library, kept locally but untracked: only the effects the game
# actually plays are committed under client/assets/sounds/.
/UI Sound Library/
+1
View File
@@ -0,0 +1 @@
<svg style="height: 512px; width: 512px;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g class="" style="" transform="translate(0,0)"><path d="m472.7 22.8-38.5 44.91L448 84.24l48-34.65zm-50.4 55.05L312.2 178.7l-25.3 8.1-66.3 69.6 22.8 24.7 69.3-65.4 12.9-21.5L435.7 93.27zm-8.7 72.65-19.9 25.6 10.3 13.4 26.6-19.6zm-32.4 37.1-68.5 62.3-16 3.6-42.1 39.2 20.7 22.3 43.6-39.5 6.4-13.8 65.2-62.4zm-245.8 67.3c-26.6.3-52.06 25.8-52.33 52.4-.33 17.8 9.08 34.4 24.53 43.3l23.6-7.6c-18-2.1-31.59-17.5-31.39-35.7 0-19.7 15.89-35.7 35.59-35.7 10-.2 19.5 3.8 26.4 11v-17c-8-4.7-17.1-11-26.4-10.7zm44.5 8.2v78.5L120.5 365v23.5h145v-55.2l-65.4-70.2zM87.51 407.2l-43.29 43h13.47l11.54 8 35.57-27.3h107.6l11.5 19.3H245l6.1-19.3h23.4l97.3 28.2 9.6-8.9h16l-106.5-43zm-50.68 59.9-20.85 22.1h62.55l-20.84-22.1zm187.07 0L203 489.2h62.9L245 467.1zm160.7 0-19.3 22.1h61L407 467.1z" fill="#c9c9c9" fill-opacity="1" stroke="#000000" stroke-opacity="1" stroke-width="5"></path></g></svg>

After

Width:  |  Height:  |  Size: 975 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+22
View File
@@ -1014,6 +1014,28 @@ input:focus, select:focus { border-color: var(--accent); }
.modal-close { padding: 4px 11px; }
/* Credits list opened from the main menu. */
#modal-credits .modal-card { width: 560px; }
.credits-title {
margin: 18px 0 6px;
font-size: 13px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
}
.credits-title:first-child { margin-top: 0; }
.credits-note { margin: 0 0 10px; color: var(--muted); font-size: 13px; }
.credits-entries { list-style: none; margin: 0 0 4px; padding: 0; }
.credits-entries li {
padding: 6px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
font-size: 13px;
}
.credits-entries .credits-name { font-weight: 600; }
.credits-entries a { color: var(--text); font-weight: 600; text-decoration: none; }
.credits-entries a:hover { text-decoration: underline; }
.credits-detail { display: block; margin-top: 2px; color: var(--muted); font-size: 12px; }
/* Capability report: one row per WebGL/WASM feature with a status badge. */
.capability-row {
display: flex;
+16
View File
@@ -16,6 +16,21 @@
<p class="tagline">A persistent, continuous-time strategy game.</p>
<button id="menu-create" class="aero big">Create game</button>
<button id="menu-join" class="aero big">Join game</button>
<div class="row">
<button id="menu-credits" class="aero">Credits</button>
<button id="menu-audio" class="aero">Sound: On</button>
</div>
</div>
<!-- Credits, opened from the main menu -->
<div id="modal-credits" class="modal hidden">
<div class="modal-card">
<div class="modal-head">
<span>Credits</span>
<button class="modal-close aero">✕</button>
</div>
<div id="credits-list" class="modal-body"></div>
</div>
</div>
</section>
@@ -178,6 +193,7 @@
<div class="dock-buttons">
<button id="news-button" class="aero news-button" title="News">News</button>
<button id="map-modes-button" class="aero news-button" title="Map modes">Map modes</button>
<button id="audio-button" class="aero news-button" title="Sound">Sound on</button>
<!-- Opens directly above its button. -->
<div id="map-modes-popover" class="map-modes-popover hidden">
<div class="map-modes-title">Map mode</div>
+27
View File
@@ -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"));
+321
View File
@@ -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();
+97
View File
@@ -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($("<h3 class='credits-title'></h3>").text(section.title));
if (section.note) {
$list.append($("<p class='credits-note'></p>").text(section.note));
}
const $entries = $("<ul class='credits-entries'></ul>");
for (const entry of section.entries) {
const $item = $("<li></li>");
if (entry.url) {
$item.append(
$("<a></a>")
.attr({ href: entry.url, target: "_blank", rel: "noopener noreferrer" })
.text(entry.name)
);
} else {
$item.append($("<span class='credits-name'></span>").text(entry.name));
}
if (entry.detail) {
$item.append($("<span class='credits-detail'></span>").text(entry.detail));
}
$entries.append($item);
}
$list.append($entries);
}
}
+83
View File
@@ -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) {
+118
View File
@@ -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);
}
}
}
+99
View File
@@ -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 };
}
}
+13 -1
View File
@@ -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));
},
+4 -2
View File
@@ -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;
+20
View File
@@ -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;
+2
View File
@@ -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",
+19
View File
@@ -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,
+134
View File
@@ -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);
}
}
}
+108
View File
@@ -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");
}
}
+97
View File
@@ -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]])));
}
}
+64
View File
@@ -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);
}
}
}
+37 -1
View File
@@ -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);
+47 -1
View File
@@ -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) {
+27 -1
View File
@@ -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();
+35
View File
@@ -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);