Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
92ef7b8
Replace sound effects with new bespoke audio set
evanpelle Sep 11, 2026
ccca33a
Wire the full sound delivery: music, new cues, ambiences, master trim
evanpelle Sep 11, 2026
b67d55e
Play the spawn cue on every spawn placement, not just the first
evanpelle Sep 11, 2026
6e21f5e
Fix ambience view-center double-transform; fade ambience out
evanpelle Sep 11, 2026
b68e5eb
Stop muted ambience directly; no defeat sting for spectators
evanpelle Sep 11, 2026
7cfd962
Guard spawn cue against spectators, throttle nuke warnings, skip mute…
evanpelle Sep 11, 2026
0f2d1e3
Throttle the train-station cue so mass conversions play once
evanpelle Sep 11, 2026
abb2c4a
Keep ka-ching for conquering bots/nations; battle cue for humans
evanpelle Sep 11, 2026
d632ca5
Re-home the settings slider tick onto the sliders' new owner
Celant Sep 11, 2026
56109fb
Add per-channel audio volumes with read-through from the old two sliders
Celant Sep 11, 2026
e5edbe9
Route audio through a per-channel mixer and fix the levels
Celant Sep 11, 2026
2350ab8
Play the settings slider tick through the mixer, not the event bus
Celant Sep 11, 2026
596c731
Reach the mixer through a hook so UI components do not import howler
Celant Sep 11, 2026
b41f6f1
Keep the new sound tests inside the ES2020 lib target
Celant Sep 11, 2026
e2ca691
Never issue a Howler fade whose start equals its end
Celant Sep 11, 2026
f26e7da
Fade the ambience loop out before dropping its envelope to zero
Celant Sep 11, 2026
daa310b
Stream the two music tracks instead of decoding them up front
Celant Sep 11, 2026
e3eaa44
Re-arm the menu theme, and stop the mixer cutting a fade-in short
Celant Sep 11, 2026
925913f
Ease the menu theme in instead of starting it at full level
Celant Sep 11, 2026
f7b87af
Ramp the menu theme in evenly in dB, not in amplitude
Celant Sep 11, 2026
702d9ef
Limit the cue bus so concurrent one-shots cannot clip
Celant Sep 11, 2026
86479d9
Start the menu ramp when playback begins, not when play is called
Celant Sep 11, 2026
51b8478
Read the music target on every ramp tick, not once at the start
Celant Sep 11, 2026
8de6bbf
Decide the menu ramp skip on the slider, not on the focus duck
Celant Sep 11, 2026
067080d
Release a channel slot when a cue fails to load or play
Celant Sep 12, 2026
4da946e
Sweep train-station tracking for structures that are gone
Celant Sep 12, 2026
34bd5f5
Stop the incoming ambience level landing on the outgoing loop
Celant Sep 14, 2026
3cea25d
Unload a discarded cue instead of stranding it in Howler
Celant Sep 14, 2026
74a7f65
Start cue Howls silent so none plays above its channel level
Celant Sep 14, 2026
c2acd31
Settle a preview whose cue never loads
Celant Sep 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file removed proprietary/sounds/music/evan.mp3
Binary file not shown.
Binary file added proprietary/sounds/music/gameplay.mp3
Binary file not shown.
Binary file added proprietary/sounds/music/menu-theme.mp3
Binary file not shown.
Binary file removed proprietary/sounds/music/of2.mp3
Binary file not shown.
Binary file removed proprietary/sounds/music/of4.mp3
Binary file not shown.
Binary file removed proprietary/sounds/music/openfront.mp3
Binary file not shown.
Binary file removed proprietary/sounds/music/war.mp3
Binary file not shown.
Binary file removed proprietary/sounds/music/win.mp3
Binary file not shown.
Binary file added resources/sounds/ambience/city.mp3
Binary file not shown.
Binary file added resources/sounds/ambience/factory.mp3
Binary file not shown.
Binary file added resources/sounds/ambience/missile-silo.mp3
Binary file not shown.
Binary file added resources/sounds/ambience/sam-silo.mp3
Binary file not shown.
Binary file added resources/sounds/effects/alliance-accepted.mp3
Binary file not shown.
Binary file modified resources/sounds/effects/alliance-broken.mp3
Binary file not shown.
Binary file added resources/sounds/effects/alliance-declined.mp3
Binary file not shown.
Binary file modified resources/sounds/effects/alliance-suggested.mp3
Binary file not shown.
Binary file modified resources/sounds/effects/atom-hit.mp3
Binary file not shown.
Binary file modified resources/sounds/effects/atom-launch.mp3
Binary file not shown.
Binary file modified resources/sounds/effects/build-city.mp3
Binary file not shown.
Binary file modified resources/sounds/effects/build-defense-post.mp3
Binary file not shown.
Binary file added resources/sounds/effects/build-factory.mp3
Binary file not shown.
Binary file modified resources/sounds/effects/build-port.mp3
Binary file not shown.
Binary file not shown.
Binary file modified resources/sounds/effects/build-warship.mp3
Binary file not shown.
Binary file added resources/sounds/effects/click-1.mp3
Binary file not shown.
Binary file added resources/sounds/effects/click-2.mp3
Binary file not shown.
Binary file added resources/sounds/effects/click-3.mp3
Binary file not shown.
Binary file modified resources/sounds/effects/click.mp3
Binary file not shown.
Binary file added resources/sounds/effects/conquered.mp3
Binary file not shown.
Binary file added resources/sounds/effects/defeat.mp3
Binary file not shown.
Binary file added resources/sounds/effects/game-start.mp3
Binary file not shown.
Binary file modified resources/sounds/effects/hydrogen-hit.mp3
Binary file not shown.
Binary file modified resources/sounds/effects/hydrogen-launch.mp3
Binary file not shown.
Binary file modified resources/sounds/effects/ka-ching.mp3
Binary file not shown.
Binary file removed resources/sounds/effects/message.mp3
Binary file not shown.
Binary file modified resources/sounds/effects/mirv-launch.mp3
Binary file not shown.
Binary file added resources/sounds/effects/nuke-warning.mp3
Binary file not shown.
Binary file modified resources/sounds/effects/sam-built.mp3
Binary file not shown.
Binary file modified resources/sounds/effects/sam-hit.mp3
Binary file not shown.
Binary file modified resources/sounds/effects/silo-built.mp3
Binary file not shown.
Binary file added resources/sounds/effects/slider.mp3
Binary file not shown.
Binary file added resources/sounds/effects/spawn.mp3
Binary file not shown.
Binary file added resources/sounds/effects/transport-ship.mp3
Binary file not shown.
Binary file added resources/sounds/effects/victory.mp3
Binary file not shown.
8 changes: 7 additions & 1 deletion src/client/ClientGameRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import {
trackGLInit,
} from "./render/gl";
import { ALL_UNIT_TYPES, UnitState } from "./render/types";
import { audioMixer, initAudioMixer } from "./sound/AudioMixer";
import { SoundManager } from "./sound/SoundManager";
import { themeProvider } from "./theme/ThemeProvider";
import { GameView, PlayerView } from "./view";
Expand Down Expand Up @@ -712,7 +713,12 @@ async function createClientGame(
inputOverlay.style.touchAction = "none";
document.body.appendChild(inputOverlay);

const soundManager = new SoundManager(eventBus, userSettings);
// Main.ts creates the mixer on page load; fall back for entry points that
// start a game without it (tests, embedded shells).
const soundManager = new SoundManager(
eventBus,
audioMixer() ?? initAudioMixer(userSettings),
);
try {
// Resolve render settings (defaults + user overrides) up front so the
// renderer is built with the final values — no construct-with-defaults,
Expand Down
12 changes: 12 additions & 0 deletions src/client/Main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ import "./components/BannedModal";
import "./components/DesktopStatusBar";
import "./components/MarketingConsentToast";
import "./components/PurchaseNudgeModal";
import { initAudioMixer } from "./sound/AudioMixer";
import { startMenuMusic } from "./sound/MenuMusic";
import {
installCtrlWheelZoomBlocker,
installDoubleTapZoomBlocker,
Expand Down Expand Up @@ -220,6 +222,7 @@ declare global {
"session-cleared": CustomEvent;
"leave-lobby": CustomEvent;
"game-starting": CustomEvent;
"menu-restored": CustomEvent;
"update-game-config": CustomEvent;
}
}
Expand Down Expand Up @@ -331,6 +334,10 @@ class Client {
// prefill for an already-signed-in visitor hitting /c/CODE directly.
consumeCreatorCodePath();

// One mixer for the page: the menu theme here and the SoundManager a game
// creates later both route through it, so the volume sliders reach both.
startMenuMusic(initAudioMixer(this.userSettings));

// Snapshot the lapse-notice marker SYNCHRONOUSLY, before the first await.
//
// Reading it inside onUserMe is too late, and not by a little.
Expand Down Expand Up @@ -1680,6 +1687,11 @@ class Client {
if (menuChromeIsTornDown()) {
this.gameModeSelector?.start();
restoreMenuChrome();
// The counterpart to "game-starting", and the only signal that the home
// page is live again without a navigation. MenuMusic tore its gesture
// listeners down at prestart and needs them back, or the menu theme is
// silent for the rest of the session.
document.dispatchEvent(new CustomEvent("menu-restored"));
}

if (this.joinModal.isOpen()) {
Expand Down
26 changes: 23 additions & 3 deletions src/client/UserSettingModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
import { isDesktopShell } from "./DesktopShell";
import { pushMapLayerState } from "./MapLayerSettings";
import { Platform } from "./Platform";
import { playCue } from "./sound/CuePlayer";
import {
SetBackgroundMusicVolumeEvent,
SetSoundEffectsVolumeEvent,
Expand Down Expand Up @@ -498,11 +499,13 @@ export class UserSettingModal extends BaseModal {
return;
}
const volume = value / 100;
// Writing the setting is the whole job: AudioMixer follows
// USER_SETTINGS_CHANGED_EVENT, which reaches the menu theme on this page
// and a running game's music alike. The bus emit is kept only until the
// Audio tab lands and the legacy events are removed.
this.userSettings.setBackgroundMusicVolume(volume);
// SoundManager reads UserSettings once at construction, so a running game
// only follows the slider through the bus. The page instance has no bus
// and nothing playing, where storing the value is the whole job.
this.eventBus?.emit(new SetBackgroundMusicVolumeEvent(volume));
this.playSliderTick();
this.requestUpdate();
}

Expand All @@ -515,9 +518,26 @@ export class UserSettingModal extends BaseModal {
const volume = value / 100;
this.userSettings.setSoundEffectsVolume(volume);
this.eventBus?.emit(new SetSoundEffectsVolumeEvent(volume));
this.playSliderTick();
this.requestUpdate();
}

// @change fires throughout a drag; rate-limit the tick so dragging sounds
// like a ratchet rather than a buzz. Re-homed from SettingsModal, which no
// longer owns the sliders.
private lastSliderTickMs = 0;

private playSliderTick() {
const now = Date.now();
if (now - this.lastSliderTickMs < 150) return;
this.lastSliderTickMs = now;
// Through the mixer, not the bus: this component is mounted twice, and
// the page instance has no bus, so a bus hop is silent exactly where the
// player is most likely to be dragging a slider. Via CuePlayer so the
// modal does not drag howler into every test that mounts it.
playCue("slider");
}

private renderAudioSettings() {
return html`
<setting-slider
Expand Down
97 changes: 97 additions & 0 deletions src/client/controllers/AmbienceController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { EventBus } from "../../core/EventBus";
import { UnitType } from "../../core/game/Game";
import { Controller } from "../Controller";
import { AmbienceTrack, SetAmbienceEvent } from "../sound/Sounds";
import { TransformHandler } from "../TransformHandler";
import { GameView } from "../view";

// Only play ambience when zoomed in far enough that a single structure fills
// a meaningful part of the view (scale is pixels per tile; default is 1.8,
// clamped to [0.2, 20]).
const AMBIENCE_ZOOM_SCALE = 8;
const MAX_VIEW_SCALE = 20;
// The sound designer's spec: -20 dB below the channel at the deepest zoom,
// fading to silence as the player pulls back out. 10^(-20/20) = 0.1.
const AMBIENCE_PEAK_GAIN = 0.1;
// Re-emitting on every sub-perceptible step would put an event on the bus
// each tick of a slow zoom; a step is roughly a quarter of a dB here.
const GAIN_EPSILON = 0.003;
// The structure must be this close (in tiles) to the center of the view.
const AMBIENCE_RANGE_TILES = 20;

const AMBIENCE_BY_TYPE = new Map<UnitType, AmbienceTrack>([
[UnitType.City, "city"],
[UnitType.Factory, "factory"],
[UnitType.MissileSilo, "missile-silo"],
[UnitType.SAMLauncher, "sam-silo"],
]);

const AMBIENT_STRUCTURE_TYPES: readonly UnitType[] = [
...AMBIENCE_BY_TYPE.keys(),
];

/**
* Plays a structure's looping ambience when the player zooms in on it: the
* structure closest to the view center wins, and zooming back out fades the
* loop away (SoundManager owns the actual Howls and fading).
*/
export class AmbienceController implements Controller {
private current: AmbienceTrack | null = null;
private currentGain = 0;

constructor(
private readonly game: GameView,
private readonly eventBus: EventBus,
private readonly transformHandler: TransformHandler,
) {}

tick(): void {
const next = this.desiredTrack();
const gain = next === null ? 0 : this.zoomGain();
if (
next === this.current &&
Math.abs(gain - this.currentGain) < GAIN_EPSILON
) {
return;
}
this.current = next;
this.currentGain = gain;
this.eventBus.emit(new SetAmbienceEvent(next, gain));
}

/**
* Zoom envelope, 0 at the threshold rising to the designer's -20 dB ceiling
* at maximum zoom, so a structure fades up as the player leans into it
* rather than snapping on.
*/
private zoomGain(): number {
const span = MAX_VIEW_SCALE - AMBIENCE_ZOOM_SCALE;
const t = (this.transformHandler.scale - AMBIENCE_ZOOM_SCALE) / span;
return AMBIENCE_PEAK_GAIN * Math.max(0, Math.min(1, t));
}

private desiredTrack(): AmbienceTrack | null {
if (this.transformHandler.scale < AMBIENCE_ZOOM_SCALE) return null;
// Despite the field names, screenCenter() returns world coordinates
// (see TransformHandler.goTo(), which compares it to a world Cell).
const { screenX, screenY } = this.transformHandler.screenCenter();
if (!this.game.isValidCoord(screenX, screenY)) return null;
const tile = this.game.ref(screenX, screenY);
let closest: { type: UnitType; distSquared: number } | null = null;
for (const nearby of this.game.nearbyUnits(
tile,
AMBIENCE_RANGE_TILES,
AMBIENT_STRUCTURE_TYPES,
)) {
if (closest === null || nearby.distSquared < closest.distSquared) {
closest = {
type: nearby.unit.type(),
distSquared: nearby.distSquared,
};
}
}
return closest === null
? null
: (AMBIENCE_BY_TYPE.get(closest.type) ?? null);
}
}
Loading
Loading