All checks were successful
Build / build (push) Successful in 1m24s
Added stateKey field to VoiceMessage interface to explicitly link messages with warning states, preventing infinite repeat loops. Bug fixes: - Messages check warning state exists before re-queuing - clearWarningState() now purges matching messages from queue - Proper cleanup when resources increase above thresholds Changes to voiceAudioSystem.ts: - VoiceMessage interface: Added stateKey field - queueMessage(): Added stateKey parameter - handleStatusChange(): Pass state keys when queueing warnings - update(): Validate state exists before re-queuing repeating messages - clearWarningState(): Filter queue to remove messages with matching stateKey Also includes explosion audio improvements: - Use onEndedObservable instead of setTimeout for audio cleanup - Adjusted explosion force and duration parameters 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
360 lines
13 KiB
TypeScript
360 lines
13 KiB
TypeScript
import { AudioEngineV2, StaticSound, SoundState } from "@babylonjs/core";
|
|
import debugLog from "../core/debug";
|
|
import { ShipStatus, ShipStatusChangeEvent } from "./shipStatus";
|
|
import { ScoreEvent } from "../ui/hud/scoreboard";
|
|
|
|
/**
|
|
* Priority levels for voice messages
|
|
*/
|
|
export enum VoiceMessagePriority {
|
|
HIGH = 0, // Critical warnings (danger, immediate action needed)
|
|
NORMAL = 1, // Standard warnings and status updates
|
|
LOW = 2 // Informational messages
|
|
}
|
|
|
|
/**
|
|
* Voice message to be queued
|
|
*/
|
|
interface VoiceMessage {
|
|
sounds: string[]; // Array of sound names to play in sequence
|
|
priority: VoiceMessagePriority;
|
|
interrupt: boolean; // If true, interrupt current playback
|
|
repeatInterval?: number; // Milliseconds between repeats (0 or undefined = no repeat)
|
|
lastPlayedTime?: number; // Timestamp when message was last played (for repeat timing)
|
|
stateKey?: string; // Warning state key (e.g., "warning_fuel") to track ownership
|
|
}
|
|
|
|
/**
|
|
* Manages voice audio system for cockpit computer announcements
|
|
* Loads voice clips and plays them sequentially in response to game events
|
|
*/
|
|
export class VoiceAudioSystem {
|
|
private _audioEngine: AudioEngineV2 | null = null;
|
|
private _sounds: Map<string, StaticSound> = new Map();
|
|
private _queue: VoiceMessage[] = [];
|
|
private _currentMessage: VoiceMessage | null = null;
|
|
private _currentSoundIndex: number = 0;
|
|
private _isPlaying: boolean = false;
|
|
|
|
// Track which warnings have been issued to prevent spam
|
|
private _warningStates: Set<string> = new Set();
|
|
|
|
// Available voice clips
|
|
private readonly VOICE_FILES = [
|
|
'warning',
|
|
'danger',
|
|
'fuel',
|
|
'hull',
|
|
'ammo',
|
|
'full',
|
|
'empty',
|
|
'guns',
|
|
'armed',
|
|
'disarmed',
|
|
'exitarm',
|
|
'returntobase',
|
|
'returncomplete'
|
|
];
|
|
|
|
constructor() {
|
|
// Constructor intentionally empty - initialization happens in initialize()
|
|
}
|
|
|
|
/**
|
|
* Initialize voice audio system - load all voice clips
|
|
* Call this AFTER audio engine is unlocked
|
|
*/
|
|
public async initialize(audioEngine: AudioEngineV2): Promise<void> {
|
|
this._audioEngine = audioEngine;
|
|
|
|
debugLog('VoiceAudioSystem: Loading voice clips...');
|
|
|
|
// Load all voice files as non-spatial sounds
|
|
for (const fileName of this.VOICE_FILES) {
|
|
try {
|
|
const sound = await audioEngine.createSoundAsync(
|
|
`voice_${fileName}`,
|
|
`/assets/themes/default/audio/voice/${fileName}.mp3`,
|
|
{
|
|
loop: false,
|
|
volume: 0.8,
|
|
// Non-spatial - cockpit computer voice
|
|
}
|
|
);
|
|
this._sounds.set(fileName, sound);
|
|
} catch (error) {
|
|
debugLog(`VoiceAudioSystem: Failed to load ${fileName}.mp3`, error);
|
|
}
|
|
}
|
|
|
|
debugLog(`VoiceAudioSystem: Loaded ${this._sounds.size}/${this.VOICE_FILES.length} voice clips`);
|
|
}
|
|
|
|
/**
|
|
* Subscribe to game events to trigger voice messages
|
|
*/
|
|
public subscribeToEvents(shipStatus: ShipStatus): void {
|
|
// Subscribe to ship status changes
|
|
shipStatus.onStatusChanged.add((event: ShipStatusChangeEvent) => {
|
|
this.handleStatusChange(event);
|
|
});
|
|
|
|
debugLog('VoiceAudioSystem: Subscribed to game events');
|
|
}
|
|
|
|
/**
|
|
* Handle ship status changes (fuel, hull, ammo)
|
|
*/
|
|
private handleStatusChange(event: ShipStatusChangeEvent): void {
|
|
const { statusType, newValue, delta } = event;
|
|
const maxValue = 1;
|
|
const percentage = maxValue > 0 ? newValue / maxValue : 0;
|
|
debugLog(event);
|
|
|
|
// Clear warning states if resources increase above thresholds
|
|
if (delta > 0) {
|
|
if (percentage >= 0.3) {
|
|
this.clearWarningState(`warning_${statusType}`);
|
|
}
|
|
if (percentage >= 0.1) {
|
|
this.clearWarningState(`danger_${statusType}`);
|
|
}
|
|
if (newValue > 0) {
|
|
this.clearWarningState(`empty_${statusType}`);
|
|
}
|
|
return; // Don't trigger warnings on increases
|
|
}
|
|
|
|
|
|
if (percentage < 0.2 && !this._warningStates.has(`danger_${statusType}`)) {
|
|
debugLog(`VoiceAudioSystem: DANGER warning triggered for ${statusType} (${(percentage * 100).toFixed(1)}%)`);
|
|
this._warningStates.add(`danger_${statusType}`);
|
|
// Clear warning state if it exists (danger supersedes warning)
|
|
this.clearWarningState(`warning_${statusType}`);
|
|
this.queueMessage(['danger', statusType], VoiceMessagePriority.HIGH, false, 2000, `danger_${statusType}`);
|
|
}
|
|
// Warning (10% <= x < 30%) - repeat every 4 seconds ONLY if not in danger
|
|
else if (percentage >= 0.2 && percentage < 0.5 && !this._warningStates.has(`warning_${statusType}`) && !this._warningStates.has(`danger_${statusType}`)) {
|
|
debugLog(`VoiceAudioSystem: Warning triggered for ${statusType} (${(percentage * 100).toFixed(1)}%)`);
|
|
this._warningStates.add(`warning_${statusType}`);
|
|
this.queueMessage(['warning', statusType], VoiceMessagePriority.NORMAL, false, 4000, `warning_${statusType}`);
|
|
}
|
|
// Empty (= 0) - no repeat
|
|
else if (newValue === 0 && !this._warningStates.has(`empty_${statusType}`)) {
|
|
debugLog(`VoiceAudioSystem: EMPTY warning triggered for ${statusType}`);
|
|
this._warningStates.add(`empty_${statusType}`);
|
|
this.queueMessage([statusType, 'empty'], VoiceMessagePriority.HIGH, false, 0, `empty_${statusType}`);
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Queue a voice message to be played
|
|
*/
|
|
public queueMessage(
|
|
sounds: string[],
|
|
priority: VoiceMessagePriority = VoiceMessagePriority.NORMAL,
|
|
interrupt: boolean = false,
|
|
repeatInterval: number = 0,
|
|
stateKey?: string
|
|
): void {
|
|
if (!this._audioEngine) {
|
|
debugLog('VoiceAudioSystem: Cannot queue message - audio not initialized');
|
|
return;
|
|
}
|
|
|
|
const message: VoiceMessage = {
|
|
sounds,
|
|
priority,
|
|
interrupt,
|
|
repeatInterval: repeatInterval > 0 ? repeatInterval : undefined,
|
|
stateKey
|
|
};
|
|
|
|
// If interrupt flag is set, stop current playback and clear queue
|
|
if (interrupt) {
|
|
this.stopCurrent();
|
|
this._queue = [];
|
|
}
|
|
|
|
// Insert into queue based on priority (lower priority number = higher priority)
|
|
const insertIndex = this._queue.findIndex(m => m.priority > priority);
|
|
if (insertIndex === -1) {
|
|
this._queue.push(message);
|
|
} else {
|
|
this._queue.splice(insertIndex, 0, message);
|
|
}
|
|
|
|
const repeatInfo = repeatInterval > 0 ? ` (repeat every ${repeatInterval}ms)` : '';
|
|
debugLog(`VoiceAudioSystem: Queued message [${sounds.join(', ')}] with priority ${priority}${repeatInfo}`);
|
|
}
|
|
|
|
/**
|
|
* Play a message immediately, interrupting current playback
|
|
*/
|
|
public playImmediate(sounds: string[]): void {
|
|
this.queueMessage(sounds, VoiceMessagePriority.HIGH, true);
|
|
}
|
|
|
|
/**
|
|
* Update loop - call this every frame
|
|
* Checks if current sound finished and plays next in sequence
|
|
*/
|
|
public update(): void {
|
|
if (!this._audioEngine) {
|
|
return;
|
|
}
|
|
|
|
// If currently playing, check if sound finished
|
|
if (this._isPlaying && this._currentMessage) {
|
|
const currentSoundName = this._currentMessage.sounds[this._currentSoundIndex];
|
|
const currentSound = this._sounds.get(currentSoundName);
|
|
|
|
if (currentSound) {
|
|
const state = currentSound.state;
|
|
|
|
// Check if sound finished playing
|
|
if (state !== SoundState.Started && state !== SoundState.Starting) {
|
|
// Move to next sound in sequence
|
|
this._currentSoundIndex++;
|
|
|
|
if (this._currentSoundIndex < this._currentMessage.sounds.length) {
|
|
// Play next sound in sequence
|
|
this.playCurrentSound();
|
|
} else {
|
|
// Sequence complete
|
|
debugLog('VoiceAudioSystem: Sequence complete');
|
|
|
|
// Check if this message should repeat
|
|
if (this._currentMessage.repeatInterval && this._currentMessage.repeatInterval > 0) {
|
|
// Only re-queue if the warning state still exists
|
|
if (!this._currentMessage.stateKey || this._warningStates.has(this._currentMessage.stateKey)) {
|
|
// Record the time this message finished
|
|
this._currentMessage.lastPlayedTime = performance.now();
|
|
|
|
// Re-queue the message for repeat
|
|
this._queue.push({ ...this._currentMessage });
|
|
debugLog(`VoiceAudioSystem: Message re-queued for repeat in ${this._currentMessage.repeatInterval}ms`);
|
|
} else {
|
|
debugLog(`VoiceAudioSystem: Message NOT re-queued - warning state '${this._currentMessage.stateKey}' cleared`);
|
|
}
|
|
}
|
|
|
|
this._isPlaying = false;
|
|
this._currentMessage = null;
|
|
this._currentSoundIndex = 0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// If not playing and queue has messages, start next message
|
|
if (!this._isPlaying && this._queue.length > 0) {
|
|
// Check if the first message in queue needs to wait for repeat interval
|
|
const nextMessage = this._queue[0];
|
|
if (nextMessage.lastPlayedTime && nextMessage.repeatInterval) {
|
|
const now = performance.now();
|
|
const timeSinceLastPlay = now - nextMessage.lastPlayedTime;
|
|
|
|
if (timeSinceLastPlay < nextMessage.repeatInterval) {
|
|
// Not enough time has passed, skip this frame
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Ready to play - dequeue and start
|
|
this._currentMessage = this._queue.shift()!;
|
|
this._currentSoundIndex = 0;
|
|
this._isPlaying = true;
|
|
debugLog(`VoiceAudioSystem: Starting sequence [${this._currentMessage.sounds.join(' → ')}]`);
|
|
this.playCurrentSound();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Play the current sound in the current message
|
|
*/
|
|
private playCurrentSound(): void {
|
|
if (!this._currentMessage) {
|
|
return;
|
|
}
|
|
|
|
const soundName = this._currentMessage.sounds[this._currentSoundIndex];
|
|
const sound = this._sounds.get(soundName);
|
|
|
|
if (sound) {
|
|
sound.play();
|
|
debugLog(`VoiceAudioSystem: Playing ${soundName} (${this._currentSoundIndex + 1}/${this._currentMessage.sounds.length})`);
|
|
} else {
|
|
debugLog(`VoiceAudioSystem: Sound ${soundName} not found, skipping`);
|
|
// Skip to next sound
|
|
this._currentSoundIndex++;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Stop current playback
|
|
*/
|
|
private stopCurrent(): void {
|
|
if (this._currentMessage && this._currentSoundIndex < this._currentMessage.sounds.length) {
|
|
const currentSoundName = this._currentMessage.sounds[this._currentSoundIndex];
|
|
const currentSound = this._sounds.get(currentSoundName);
|
|
|
|
if (currentSound && (currentSound.state === SoundState.Started || currentSound.state === SoundState.Starting)) {
|
|
currentSound.stop();
|
|
}
|
|
}
|
|
|
|
this._isPlaying = false;
|
|
this._currentMessage = null;
|
|
this._currentSoundIndex = 0;
|
|
}
|
|
|
|
/**
|
|
* Clear all queued messages
|
|
*/
|
|
public clearQueue(): void {
|
|
this._queue = [];
|
|
debugLog('VoiceAudioSystem: Queue cleared');
|
|
}
|
|
|
|
/**
|
|
* Clear a specific warning state and remove matching messages from queue
|
|
*/
|
|
public clearWarningState(key: string): void {
|
|
if (this._warningStates.has(key)) {
|
|
this._warningStates.delete(key);
|
|
|
|
// Remove all messages with this state key from queue
|
|
const originalLength = this._queue.length;
|
|
this._queue = this._queue.filter(msg => msg.stateKey !== key);
|
|
|
|
const removed = originalLength - this._queue.length;
|
|
if (removed > 0) {
|
|
debugLog(`VoiceAudioSystem: Cleared warning state '${key}' and purged ${removed} queued message(s)`);
|
|
} else {
|
|
debugLog(`VoiceAudioSystem: Cleared warning state '${key}'`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reset warning states (useful when starting new level or respawning)
|
|
*/
|
|
public resetWarningStates(): void {
|
|
this._warningStates.clear();
|
|
debugLog('VoiceAudioSystem: Warning states reset');
|
|
}
|
|
|
|
/**
|
|
* Dispose of voice audio system
|
|
*/
|
|
public dispose(): void {
|
|
this.stopCurrent();
|
|
this.clearQueue();
|
|
this._sounds.clear();
|
|
this._warningStates.clear();
|
|
debugLog('VoiceAudioSystem: Disposed');
|
|
}
|
|
}
|