Added sound and credits, bombardment audio, and hid foreign production
This commit is contained in:
@@ -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/
|
||||
|
||||
@@ -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.
@@ -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,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>
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
},
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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]])));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user