feat: alive UI wave, galaxy presence, spread and fleet enhancements
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled
Dashboard ambient layer, comrade presence, Mission Deck and War Room, Emberwake supply chain, spread/docs publishing, fleet policy and modules API, CI docker mining, and refreshed USB pack.
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
BGM_STORAGE_KEY,
|
||||
BGM_VOLUME_KEY,
|
||||
AMBIENT_MUSIC_SRC,
|
||||
MODAL_AMBIENT_DUCK_FACTOR,
|
||||
} from './ambientMusic';
|
||||
|
||||
describe('ambientMusic prefs', () => {
|
||||
@@ -40,4 +41,15 @@ describe('ambientMusic prefs', () => {
|
||||
it('points at public audio path', () => {
|
||||
expect(AMBIENT_MUSIC_SRC).toBe('/audio/ambient.mp3');
|
||||
});
|
||||
|
||||
it('ducks effective volume 30% while modal registered', () => {
|
||||
const p = new AmbientMusicPlayer();
|
||||
p.setVolume(1);
|
||||
p.setPageIntensity(0.8);
|
||||
const base = p.getEffectiveVolume();
|
||||
const unregister = p.registerModalDuck();
|
||||
expect(p.getEffectiveVolume()).toBeCloseTo(base * MODAL_AMBIENT_DUCK_FACTOR);
|
||||
unregister();
|
||||
expect(p.isModalDuckActive()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,36 @@ export const BGM_VOLUME_KEY = 'aetherforge-bgm-volume';
|
||||
/** Served from Vite public/ — place ambient.mp3 here before enabling in Settings. */
|
||||
export const AMBIENT_MUSIC_SRC = '/audio/ambient.mp3';
|
||||
|
||||
/** Route → playback multiplier (0–1). User volume × intensity = effective output. */
|
||||
export const PAGE_AMBIENT_INTENSITY: Record<string, number> = {
|
||||
'/forge': 1,
|
||||
'/builder': 1,
|
||||
'/mission-deck': 0.95,
|
||||
'/emberwake': 0.8,
|
||||
'/spread': 0.8,
|
||||
'/crucible': 0.75,
|
||||
'/agents': 0.7,
|
||||
'/dashboard': 0.65,
|
||||
'/builds': 0.55,
|
||||
'/pathtracer': 0.4,
|
||||
'/settings': 0.25,
|
||||
};
|
||||
|
||||
/** Multiply page intensity by this when a modal/wizard is open (30% duck). */
|
||||
export const MODAL_AMBIENT_DUCK_FACTOR = 0.7;
|
||||
export const MODAL_AMBIENT_SWELL_MS = 400;
|
||||
|
||||
export function resolvePageAmbientIntensity(pathname: string): number {
|
||||
const path = pathname.split('?')[0].replace(/\/$/, '') || '/';
|
||||
if (PAGE_AMBIENT_INTENSITY[path] !== undefined) {
|
||||
return PAGE_AMBIENT_INTENSITY[path];
|
||||
}
|
||||
for (const [prefix, intensity] of Object.entries(PAGE_AMBIENT_INTENSITY)) {
|
||||
if (prefix !== '/' && path.startsWith(prefix)) return intensity;
|
||||
}
|
||||
return 0.65;
|
||||
}
|
||||
|
||||
export function loadBgmEnabled(): boolean {
|
||||
try {
|
||||
const v = localStorage.getItem(BGM_STORAGE_KEY);
|
||||
@@ -47,6 +77,11 @@ export class AmbientMusicPlayer {
|
||||
private audio: HTMLAudioElement | null = null;
|
||||
private enabled = loadBgmEnabled();
|
||||
private volume = loadBgmVolume();
|
||||
private pageIntensity = 1;
|
||||
private modalDuckRegistrations = 0;
|
||||
/** 0 = full duck, 1 = no duck — animated on swell. */
|
||||
private duckBlend = 1;
|
||||
private swellFrame: number | null = null;
|
||||
private unlocked = false;
|
||||
private playing = false;
|
||||
private listeners = new Set<(playing: boolean) => void>();
|
||||
@@ -63,6 +98,82 @@ export class AmbientMusicPlayer {
|
||||
return this.volume;
|
||||
}
|
||||
|
||||
getPageIntensity() {
|
||||
return this.pageIntensity;
|
||||
}
|
||||
|
||||
getEffectiveVolume() {
|
||||
return this.volume * this.pageIntensity * this.getDuckMultiplier();
|
||||
}
|
||||
|
||||
isModalDuckActive() {
|
||||
return this.modalDuckRegistrations > 0 || this.duckBlend < 1;
|
||||
}
|
||||
|
||||
private getDuckMultiplier() {
|
||||
return MODAL_AMBIENT_DUCK_FACTOR + this.duckBlend * (1 - MODAL_AMBIENT_DUCK_FACTOR);
|
||||
}
|
||||
|
||||
/** Register an open modal/wizard; returns unregister (runs swell when last closes). */
|
||||
registerModalDuck(): () => void {
|
||||
this.cancelSwell();
|
||||
this.modalDuckRegistrations += 1;
|
||||
if (this.modalDuckRegistrations === 1) {
|
||||
this.duckBlend = 0;
|
||||
this.applyVolume();
|
||||
}
|
||||
return () => {
|
||||
this.modalDuckRegistrations = Math.max(0, this.modalDuckRegistrations - 1);
|
||||
if (this.modalDuckRegistrations === 0) {
|
||||
this.startSwell();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private cancelSwell() {
|
||||
if (this.swellFrame !== null && typeof cancelAnimationFrame !== 'undefined') {
|
||||
cancelAnimationFrame(this.swellFrame);
|
||||
this.swellFrame = null;
|
||||
}
|
||||
}
|
||||
|
||||
private startSwell() {
|
||||
this.cancelSwell();
|
||||
const startBlend = this.duckBlend;
|
||||
const startTime = typeof performance !== 'undefined' ? performance.now() : 0;
|
||||
const duration = MODAL_AMBIENT_SWELL_MS;
|
||||
|
||||
const tick = (now: number) => {
|
||||
const t = Math.min(1, (now - startTime) / duration);
|
||||
const eased = 1 - (1 - t) * (1 - t);
|
||||
this.duckBlend = startBlend + (1 - startBlend) * eased;
|
||||
this.applyVolume();
|
||||
if (t < 1 && typeof requestAnimationFrame !== 'undefined') {
|
||||
this.swellFrame = requestAnimationFrame(tick);
|
||||
} else {
|
||||
this.duckBlend = 1;
|
||||
this.swellFrame = null;
|
||||
this.applyVolume();
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof requestAnimationFrame !== 'undefined') {
|
||||
this.swellFrame = requestAnimationFrame(tick);
|
||||
} else {
|
||||
this.duckBlend = 1;
|
||||
this.applyVolume();
|
||||
}
|
||||
}
|
||||
|
||||
setPageIntensity(intensity: number) {
|
||||
this.pageIntensity = Math.min(1, Math.max(0, intensity));
|
||||
this.applyVolume();
|
||||
}
|
||||
|
||||
private applyVolume() {
|
||||
if (this.audio) this.audio.volume = this.getEffectiveVolume();
|
||||
}
|
||||
|
||||
subscribe(fn: (playing: boolean) => void) {
|
||||
this.listeners.add(fn);
|
||||
return () => { this.listeners.delete(fn); };
|
||||
@@ -88,7 +199,7 @@ export class AmbientMusicPlayer {
|
||||
setVolume(volume: number) {
|
||||
this.volume = Math.min(1, Math.max(0, volume));
|
||||
persistBgmVolume(this.volume);
|
||||
if (this.audio) this.audio.volume = this.volume;
|
||||
this.applyVolume();
|
||||
}
|
||||
|
||||
/** Browsers block autoplay until a user gesture unlocks audio. */
|
||||
@@ -116,7 +227,7 @@ export class AmbientMusicPlayer {
|
||||
const el = new Audio(AMBIENT_MUSIC_SRC);
|
||||
el.loop = true;
|
||||
el.preload = 'auto';
|
||||
el.volume = this.volume;
|
||||
el.volume = this.getEffectiveVolume();
|
||||
el.addEventListener('play', () => this.setPlaying(true));
|
||||
el.addEventListener('pause', () => this.setPlaying(false));
|
||||
el.addEventListener('ended', () => this.setPlaying(false));
|
||||
@@ -136,7 +247,7 @@ export class AmbientMusicPlayer {
|
||||
if (!this.enabled) return false;
|
||||
this.ensureAudio();
|
||||
if (!this.audio) return false;
|
||||
this.audio.volume = this.volume;
|
||||
this.applyVolume();
|
||||
try {
|
||||
await this.audio.play();
|
||||
this.setPlaying(true);
|
||||
|
||||
Reference in New Issue
Block a user