diff --git a/lib/src/core/audio/audio_service.dart b/lib/src/core/audio/audio_service.dart index 14b212e..36d54e2 100644 --- a/lib/src/core/audio/audio_service.dart +++ b/lib/src/core/audio/audio_service.dart @@ -1,10 +1,49 @@ +import 'dart:async' show Completer, unawaited; + import 'package:flutter/foundation.dart' show debugPrint, kIsWeb; import 'package:just_audio/just_audio.dart'; import 'package:asciineverdie/src/core/audio/sfx_channel_pool.dart'; import 'package:asciineverdie/src/core/storage/settings_repository.dart'; -/// 게임 오디오 서비스 +/// BGM 작업 직렬화를 위한 간단한 뮤텍스 +class _BgmMutex { + Completer? _completer; + String? _pendingBgm; + + /// 현재 작업 중인지 확인 + bool get isLocked => _completer != null && !_completer!.isCompleted; + + /// 락 획득 시도 (이미 잠겨있으면 대기 BGM 설정 후 false 반환) + Future tryAcquire(String bgmName) async { + if (isLocked) { + // 이미 작업 중이면 대기 BGM 설정 (마지막 것만 유지) + _pendingBgm = bgmName; + return false; + } + _completer = Completer(); + return true; + } + + /// 락 해제 및 대기 중인 BGM 반환 + String? release() { + _completer?.complete(); + _completer = null; + final pending = _pendingBgm; + _pendingBgm = null; + return pending; + } + + /// 강제 해제 (에러 시) + void forceRelease() { + if (_completer != null && !_completer!.isCompleted) { + _completer!.complete(); + } + _completer = null; + } +} + +/// 게임 오디오 서비스 (싱글톤, 핫 리로드 안전) /// /// BGM과 SFX를 관리하며, 설정과 연동하여 볼륨을 조절합니다. /// 웹/WASM 환경에서는 제한적으로 작동할 수 있습니다. @@ -13,22 +52,59 @@ import 'package:asciineverdie/src/core/storage/settings_repository.dart'; /// - BGM: 단일 플레이어 (루프 재생) /// - Player SFX: 플레이어 이펙트 (공격, 스킬, 아이템 등) /// - Monster SFX: 몬스터 이펙트 (몬스터 공격 = 플레이어 피격) +/// +/// 모든 플레이어 인스턴스를 static으로 관리하여 핫 리로드에서도 안전합니다. class AudioService { - AudioService({SettingsRepository? settingsRepository}) - : _settingsRepository = settingsRepository ?? SettingsRepository(); + // ───────────────────────────────────────────────────────────────────────── + // 싱글톤 패턴 + // ───────────────────────────────────────────────────────────────────────── + + /// 싱글톤 인스턴스 + static AudioService? _instance; + + /// 싱글톤 인스턴스 반환 + static AudioService get instance { + _instance ??= AudioService._internal(SettingsRepository()); + return _instance!; + } + + /// 팩토리 생성자 (싱글톤 반환) + factory AudioService({SettingsRepository? settingsRepository}) { + _instance ??= AudioService._internal( + settingsRepository ?? SettingsRepository(), + ); + return _instance!; + } + + /// private 생성자 + AudioService._internal(this._settingsRepository); final SettingsRepository _settingsRepository; - // BGM 플레이어 - AudioPlayer? _bgmPlayer; + // ───────────────────────────────────────────────────────────────────────── + // static 플레이어 관리 (핫 리로드에서 유지) + // ───────────────────────────────────────────────────────────────────────── + + /// static BGM 플레이어 (핫 리로드에서 유지) + static AudioPlayer? _staticBgmPlayer; + + /// static 초기화 완료 여부 + static bool _staticInitialized = false; + + /// static 초기화 진행 중 Completer (중복 초기화 방지) + static Completer? _staticInitCompleter; + + // ───────────────────────────────────────────────────────────────────────── + // 인스턴스 변수 + // ───────────────────────────────────────────────────────────────────────── // SFX 채널 풀 (채널별 분리, 완료 보장) SfxChannelPool? _playerSfxPool; SfxChannelPool? _monsterSfxPool; - // 채널별 풀 크기 - static const int _playerPoolSize = 4; - static const int _monsterPoolSize = 3; + // 채널별 풀 크기 (줄임: 동시 재생 문제 완화) + static const int _playerPoolSize = 2; + static const int _monsterPoolSize = 2; // 현재 볼륨 double _bgmVolume = 0.7; @@ -37,12 +113,6 @@ class AudioService { // 현재 재생 중인 BGM String? _currentBgm; - // 초기화 여부 - bool _initialized = false; - - // 초기화 실패 여부 (WASM 등에서 오디오 지원 안됨) - bool _initFailed = false; - // 웹에서 사용자 상호작용 대기 중인 BGM (자동재생 정책 대응) String? _pendingBgm; @@ -52,166 +122,294 @@ class AudioService { // 오디오 일시정지 상태 (앱 백그라운드 시) bool _isPaused = false; + // BGM 작업 직렬화 뮤텍스 (동시 호출 방지) + final _bgmMutex = _BgmMutex(); + + // ───────────────────────────────────────────────────────────────────────── + // 초기화 + // ───────────────────────────────────────────────────────────────────────── + + /// 초기화 완료 여부 + bool get isInitialized => _staticInitialized; + /// 서비스 초기화 Future init() async { - if (_initialized || _initFailed) return; + // 이미 초기화 완료됨 + if (_staticInitialized) { + return; + } + + // 초기화 진행 중이면 완료 대기 + if (_staticInitCompleter != null) { + await _staticInitCompleter!.future; + return; + } + + // 초기화 시작 + final completer = Completer(); + _staticInitCompleter = completer; try { // 설정에서 볼륨 불러오기 _bgmVolume = await _settingsRepository.loadBgmVolume(); _sfxVolume = await _settingsRepository.loadSfxVolume(); - // BGM 플레이어 초기화 - _bgmPlayer = AudioPlayer(); - await _bgmPlayer!.setLoopMode(LoopMode.one); - await _bgmPlayer!.setVolume(_bgmVolume); + // BGM 플레이어 초기화 (순차적, 지연 포함) + await _initBgmPlayer(); - // SFX 채널 풀 초기화 (채널별 분리) - _playerSfxPool = SfxChannelPool( - name: 'Player', - poolSize: _playerPoolSize, - volume: _sfxVolume, - ); - await _playerSfxPool!.init(); + // 지연 후 SFX 풀 초기화 (순차적) + await Future.delayed(const Duration(milliseconds: 200)); + await _initSfxPools(); - _monsterSfxPool = SfxChannelPool( - name: 'Monster', - poolSize: _monsterPoolSize, - volume: _sfxVolume, - ); - await _monsterSfxPool!.init(); - - _initialized = true; + _staticInitialized = true; // 모바일/데스크톱에서는 자동재생 제한 없음 if (!kIsWeb) { _userInteracted = true; - } else { - debugPrint('[AudioService] Initialized on Web platform'); } + + debugPrint('[AudioService] Initialized successfully'); + completer.complete(); } catch (e) { - _initFailed = true; - debugPrint('[AudioService] Init failed (likely WASM): $e'); + debugPrint('[AudioService] Init error: $e'); + // 에러여도 완료 처리 (오디오 없이 게임 진행 가능) + _staticInitialized = true; + completer.complete(); + } finally { + _staticInitCompleter = null; } } - /// BGM 재생 + /// BGM 플레이어 초기화 (재시도 포함) + Future _initBgmPlayer() async { + // 기존 플레이어가 있으면 재사용 (핫 리로드 대응) + if (_staticBgmPlayer != null) { + debugPrint('[AudioService] Reusing existing BGM player'); + try { + await _staticBgmPlayer!.setVolume(_bgmVolume); + } catch (_) {} + return; + } + + // 새 플레이어 생성 (재시도 포함) + const maxRetries = 3; + const baseDelay = Duration(milliseconds: 100); + + for (var attempt = 0; attempt < maxRetries; attempt++) { + try { + if (attempt > 0) { + await Future.delayed(baseDelay * (attempt + 1)); + } + + _staticBgmPlayer = AudioPlayer(); + await _staticBgmPlayer!.setLoopMode(LoopMode.one); + await _staticBgmPlayer!.setVolume(_bgmVolume); + debugPrint('[AudioService] BGM player created'); + return; + } catch (e) { + debugPrint( + '[AudioService] BGM player attempt ${attempt + 1} failed: $e'); + _staticBgmPlayer = null; + if (attempt == maxRetries - 1) { + debugPrint('[AudioService] BGM disabled'); + } + } + } + } + + /// SFX 채널 풀 순차 초기화 + Future _initSfxPools() async { + // Player SFX 풀 + _playerSfxPool = SfxChannelPool( + name: 'Player', + poolSize: _playerPoolSize, + volume: _sfxVolume, + ); + await _playerSfxPool!.init(); + + // 지연 후 Monster SFX 풀 + await Future.delayed(const Duration(milliseconds: 200)); + + _monsterSfxPool = SfxChannelPool( + name: 'Monster', + poolSize: _monsterPoolSize, + volume: _sfxVolume, + ); + await _monsterSfxPool!.init(); + } + + // ───────────────────────────────────────────────────────────────────────── + // BGM 재생 + // ───────────────────────────────────────────────────────────────────────── + + /// BGM 재생 (뮤텍스로 동시 호출 방지) /// - /// [name]은 audio/bgm/ 폴더 내 파일명 (확장자 제외) - /// 예: playBgm('battle') → audio/bgm/battle.mp3 - /// - /// 웹에서 사용자 상호작용 없이 호출되면 대기 상태로 저장되고, - /// 다음 SFX 재생 시 함께 시작됩니다. + /// 여러 곳에서 동시에 호출되어도 안전하게 처리합니다. + /// 진행 중인 작업이 있으면 대기열에 추가하고, 완료 후 마지막 요청만 실행합니다. Future playBgm(String name) async { - if (_initFailed) return; // 초기화 실패 시 무시 - if (_isPaused) return; // 일시정지 상태면 무시 - if (!_initialized) await init(); - if (_initFailed || !_initialized) return; - if (_currentBgm == name) return; // 이미 재생 중 + if (_isPaused) return; + if (!_staticInitialized) await init(); + if (_currentBgm == name) return; + if (_staticBgmPlayer == null) return; + + // 뮤텍스 획득 시도 (실패하면 대기열에 추가) + if (!await _bgmMutex.tryAcquire(name)) { + debugPrint('[AudioService] BGM $name queued (mutex locked)'); + return; + } try { - await _bgmPlayer!.setAsset('audio/bgm/$name.mp3'); - await _bgmPlayer!.play(); + await _playBgmInternal(name); + } finally { + // 락 해제 및 대기 중인 BGM 확인 + final pendingBgm = _bgmMutex.release(); + if (pendingBgm != null && pendingBgm != _currentBgm) { + // 대기 중인 BGM이 있으면 재귀 호출 (새 뮤텍스 획득) + unawaited(playBgm(pendingBgm)); + } + } + } + + /// 내부 BGM 재생 (뮤텍스 내에서 호출) + Future _playBgmInternal(String name) async { + final assetPath = 'assets/audio/bgm/$name.mp3'; + + try { + // 이전 BGM이 있을 때만 stop() 호출 + if (_currentBgm != null) { + debugPrint('[AudioService] Stopping previous BGM: $_currentBgm'); + await _staticBgmPlayer!.stop(); + } + + debugPrint('[AudioService] Loading BGM: $assetPath'); + await _staticBgmPlayer!.setAsset(assetPath); + + debugPrint('[AudioService] Starting BGM playback'); + await _staticBgmPlayer!.play(); + _currentBgm = name; _pendingBgm = null; - _userInteracted = true; // 재생 성공 → 상호작용 확인됨 + _userInteracted = true; debugPrint('[AudioService] Playing BGM: $name'); + } on PlayerInterruptedException catch (e) { + debugPrint('[AudioService] BGM $name interrupted: ${e.message}'); } catch (e) { - // 웹 자동재생 정책으로 실패 시 대기 상태로 저장 - if (kIsWeb && e.toString().contains('NotAllowedError')) { + final errorStr = e.toString(); + debugPrint('[AudioService] BGM error: $errorStr'); + + // macOS Operation Stopped 에러: 플레이어 재생성 후 재시도 + if (errorStr.contains('Operation Stopped') || + errorStr.contains('-11849') || + errorStr.contains('abort')) { + debugPrint('[AudioService] Recreating BGM player...'); + await _recreateBgmPlayer(); + + if (_staticBgmPlayer != null) { + try { + await _staticBgmPlayer!.setAsset(assetPath); + await _staticBgmPlayer!.play(); + _currentBgm = name; + _userInteracted = true; + debugPrint('[AudioService] Playing BGM: $name (after recreate)'); + return; + } catch (retryError) { + debugPrint('[AudioService] BGM retry failed: $retryError'); + } + } + } else if (kIsWeb && errorStr.contains('NotAllowedError')) { _pendingBgm = name; - debugPrint('[AudioService] BGM $name pending (waiting for user interaction)'); + debugPrint('[AudioService] BGM $name pending (autoplay blocked)'); } else { - debugPrint('[AudioService] Failed to play BGM $name: $e'); + debugPrint('[AudioService] BGM play failed: $e'); } _currentBgm = null; } } + /// BGM 플레이어 재생성 (에러 복구용) + Future _recreateBgmPlayer() async { + try { + await _staticBgmPlayer?.dispose(); + } catch (_) {} + _staticBgmPlayer = null; + + try { + _staticBgmPlayer = AudioPlayer(); + await _staticBgmPlayer!.setLoopMode(LoopMode.one); + await _staticBgmPlayer!.setVolume(_bgmVolume); + debugPrint('[AudioService] BGM player recreated'); + } catch (e) { + debugPrint('[AudioService] Failed to recreate BGM player: $e'); + _staticBgmPlayer = null; + } + } + /// BGM 정지 Future stopBgm() async { - if (!_initialized) return; - - await _bgmPlayer!.stop(); + if (_staticBgmPlayer == null) return; + try { + await _staticBgmPlayer!.stop(); + } catch (_) {} _currentBgm = null; } /// BGM 일시정지 Future pauseBgm() async { - if (!_initialized) return; - await _bgmPlayer!.pause(); + if (_staticBgmPlayer == null) return; + try { + await _staticBgmPlayer!.pause(); + } catch (_) {} } /// BGM 재개 Future resumeBgm() async { - if (!_initialized) return; - if (_currentBgm != null) { - await _bgmPlayer!.play(); - } + if (_staticBgmPlayer == null || _currentBgm == null) return; + try { + await _staticBgmPlayer!.play(); + } catch (_) {} } + // ───────────────────────────────────────────────────────────────────────── + // 전체 오디오 제어 + // ───────────────────────────────────────────────────────────────────────── + /// 전체 오디오 일시정지 (앱 백그라운드 시) - /// - /// BGM을 정지하고, 새로운 재생 요청을 무시합니다. Future pauseAll() async { _isPaused = true; - if (!_initialized) return; - - // BGM 정지 및 상태 초기화 - await _bgmPlayer?.stop(); + try { + await _staticBgmPlayer?.stop(); + } catch (_) {} _currentBgm = null; - - // SFX 채널 풀은 자동 완료되므로 별도 정지 불필요 - // (새로운 재생 요청만 _isPaused로 차단) - debugPrint('[AudioService] All audio paused'); } /// 전체 오디오 재개 (앱 포그라운드 복귀 시) - /// - /// 일시정지 상태를 해제하고 이전 BGM을 재개합니다. Future resumeAll() async { _isPaused = false; debugPrint('[AudioService] Audio resumed'); } + // ───────────────────────────────────────────────────────────────────────── + // SFX 재생 + // ───────────────────────────────────────────────────────────────────────── + /// 플레이어 이펙트 SFX 재생 - /// - /// [name]은 audio/sfx/ 폴더 내 파일명 (확장자 제외) - /// 예: playPlayerSfx('attack') → audio/sfx/attack.mp3 - /// - /// 대기열 기반으로 모든 사운드의 완전 재생을 보장합니다. Future playPlayerSfx(String name) async { - if (_initFailed) return; if (_isPaused) return; - if (!_initialized) await init(); - if (_initFailed || !_initialized) return; - - // 웹에서 대기 중인 BGM 재생 시도 + if (!_staticInitialized) await init(); _tryPlayPendingBgm(); - - await _playerSfxPool?.play('audio/sfx/$name.mp3'); + await _playerSfxPool?.play('assets/audio/sfx/$name.mp3'); } /// 몬스터 이펙트 SFX 재생 - /// - /// [name]은 audio/sfx/ 폴더 내 파일명 (확장자 제외) - /// 예: playMonsterSfx('hit') → audio/sfx/hit.mp3 - /// - /// 대기열 기반으로 모든 사운드의 완전 재생을 보장합니다. Future playMonsterSfx(String name) async { - if (_initFailed) return; if (_isPaused) return; - if (!_initialized) await init(); - if (_initFailed || !_initialized) return; - - // 웹에서 대기 중인 BGM 재생 시도 + if (!_staticInitialized) await init(); _tryPlayPendingBgm(); - - await _monsterSfxPool?.play('audio/sfx/$name.mp3'); + await _monsterSfxPool?.play('assets/audio/sfx/$name.mp3'); } - /// 웹에서 대기 중인 BGM 재생 시도 (사용자 상호작용 발생 시) + /// 웹에서 대기 중인 BGM 재생 시도 void _tryPlayPendingBgm() { if (!_userInteracted && _pendingBgm != null) { _userInteracted = true; @@ -222,31 +420,29 @@ class AudioService { } /// SFX 재생 (레거시 호환) - /// - /// [name]은 audio/sfx/ 폴더 내 파일명 (확장자 제외) - /// 예: playSfx('attack') → audio/sfx/attack.mp3 - /// - /// @deprecated playPlayerSfx 또는 playMonsterSfx를 사용하세요. + @Deprecated('playPlayerSfx 또는 playMonsterSfx를 사용하세요.') Future playSfx(String name) => playPlayerSfx(name); + // ───────────────────────────────────────────────────────────────────────── + // 볼륨 제어 + // ───────────────────────────────────────────────────────────────────────── + /// BGM 볼륨 설정 (0.0 ~ 1.0) Future setBgmVolume(double volume) async { _bgmVolume = volume.clamp(0.0, 1.0); - if (_initialized && _bgmPlayer != null) { - await _bgmPlayer!.setVolume(_bgmVolume); + if (_staticBgmPlayer != null) { + try { + await _staticBgmPlayer!.setVolume(_bgmVolume); + } catch (_) {} } await _settingsRepository.saveBgmVolume(_bgmVolume); } /// SFX 볼륨 설정 (0.0 ~ 1.0) - /// - /// 모든 SFX 채널 (플레이어, 몬스터)에 동시 적용됩니다. Future setSfxVolume(double volume) async { _sfxVolume = volume.clamp(0.0, 1.0); - if (_initialized) { - await _playerSfxPool?.setVolume(_sfxVolume); - await _monsterSfxPool?.setVolume(_sfxVolume); - } + await _playerSfxPool?.setVolume(_sfxVolume); + await _monsterSfxPool?.setVolume(_sfxVolume); await _settingsRepository.saveSfxVolume(_sfxVolume); } @@ -259,14 +455,14 @@ class AudioService { /// 현재 재생 중인 BGM String? get currentBgm => _currentBgm; + // ───────────────────────────────────────────────────────────────────────── + // 유틸리티 + // ───────────────────────────────────────────────────────────────────────── + /// 사용자 상호작용 발생 알림 (웹 자동재생 정책 우회) - /// - /// 버튼 클릭 등 사용자 상호작용 시 호출하면 - /// 대기 중인 BGM이 재생됩니다. Future notifyUserInteraction() async { if (_userInteracted) return; _userInteracted = true; - if (_pendingBgm != null) { final pending = _pendingBgm; _pendingBgm = null; @@ -276,61 +472,48 @@ class AudioService { /// 서비스 정리 Future dispose() async { - await _bgmPlayer?.dispose(); + try { + await _staticBgmPlayer?.dispose(); + } catch (_) {} + _staticBgmPlayer = null; await _playerSfxPool?.dispose(); await _monsterSfxPool?.dispose(); - _initialized = false; + _staticInitialized = false; + } + + /// 모든 static 리소스 정리 (테스트용) + static void resetAll() { + try { + _staticBgmPlayer?.dispose(); + } catch (_) {} + _staticBgmPlayer = null; + _staticInitialized = false; + _staticInitCompleter = null; + _instance = null; + SfxChannelPool.resetAll(); } } /// BGM 타입 열거형 enum BgmType { - /// 타이틀 화면 BGM title, - - /// 마을/상점 BGM town, - - /// 일반 전투 BGM battle, - - /// 보스 전투 BGM boss, - - /// 레벨업/퀘스트 완료 팡파레 victory, } /// SFX 타입 열거형 enum SfxType { - /// 공격 attack, - - /// 피격 hit, - - /// 스킬 사용 skill, - - /// 아이템 획득 item, - - /// UI 클릭 click, - - /// 레벨업 levelUp, - - /// 퀘스트 완료 questComplete, - - /// 회피 (Phase 11) evade, - - /// 방패 방어 (Phase 11) block, - - /// 무기 쳐내기 (Phase 11) parry, } diff --git a/lib/src/core/audio/sfx_channel_pool.dart b/lib/src/core/audio/sfx_channel_pool.dart index f432340..3acc4c8 100644 --- a/lib/src/core/audio/sfx_channel_pool.dart +++ b/lib/src/core/audio/sfx_channel_pool.dart @@ -1,13 +1,16 @@ +import 'dart:async'; import 'dart:collection'; import 'package:flutter/foundation.dart' show debugPrint; import 'package:just_audio/just_audio.dart'; -/// SFX 채널 풀 - 사운드 완료 보장 +/// SFX 채널 풀 - 사운드 완료 보장 (핫 리로드 안전) /// /// 대기열 기반으로 모든 사운드의 완전 재생을 보장합니다. /// 사용 가능한 플레이어가 없으면 대기열에 추가하고, /// 재생 완료 시 대기열의 다음 사운드를 자동 재생합니다. +/// +/// 플레이어 인스턴스를 static으로 관리하여 핫 리로드에서도 안전합니다. class SfxChannelPool { SfxChannelPool({ required this.name, @@ -24,61 +27,143 @@ class SfxChannelPool { /// 채널 볼륨 double _volume; - /// AudioPlayer 풀 - final List _players = []; + /// static 플레이어 저장소 (채널명 기반, 핫 리로드에서 유지) + static final Map> _staticPlayers = {}; - /// 각 플레이어의 재생 중 여부 추적 - final List _playerBusy = []; + /// static busy 상태 (채널명 기반) + static final Map> _staticBusy = {}; + + /// static 초기화 완료 여부 (채널명 기반) + static final Map _staticInitialized = {}; + + /// static 초기화 진행 중 Completer (중복 초기화 방지) + static final Map?> _staticInitCompleters = {}; /// 대기열 (재생 대기 중인 에셋 경로) final Queue _pendingQueue = Queue(); - /// 초기화 여부 - bool _initialized = false; - - /// 초기화 실패 여부 - bool _initFailed = false; - /// 현재 볼륨 double get volume => _volume; + /// 초기화 완료 여부 + bool get isInitialized => _staticInitialized[name] == true; + + /// 사용 가능한 플레이어 수 + int get availablePlayerCount => _staticPlayers[name]?.length ?? 0; + /// 초기화 Future init() async { - if (_initialized || _initFailed) return; + // 이미 초기화 완료됨 + if (_staticInitialized[name] == true) { + return; + } + + // 초기화 진행 중이면 완료 대기 + if (_staticInitCompleters[name] != null) { + await _staticInitCompleters[name]!.future; + return; + } + + // 초기화 시작 + final completer = Completer(); + _staticInitCompleters[name] = completer; try { + // 이 채널의 플레이어 리스트 초기화 + _staticPlayers[name] ??= []; + _staticBusy[name] ??= []; + + // 기존 플레이어가 있으면 재사용 (핫 리로드 대응) + if (_staticPlayers[name]!.isNotEmpty) { + debugPrint( + '[SfxChannelPool:$name] Reusing ${_staticPlayers[name]!.length} existing players'); + _staticInitialized[name] = true; + completer.complete(); + return; + } + + // 새 플레이어 순차적으로 생성 (지연 포함) + var successCount = 0; for (var i = 0; i < poolSize; i++) { + final player = await _createPlayerWithRetry(i); + if (player != null) { + _staticPlayers[name]!.add(player); + _staticBusy[name]!.add(false); + successCount++; + } + } + + if (successCount > 0) { + _staticInitialized[name] = true; + debugPrint( + '[SfxChannelPool:$name] Initialized with $successCount/$poolSize players'); + } else { + debugPrint('[SfxChannelPool:$name] All players failed - audio disabled'); + } + + completer.complete(); + } catch (e) { + debugPrint('[SfxChannelPool:$name] Init error: $e'); + completer.complete(); + } finally { + _staticInitCompleters[name] = null; + } + } + + /// 플레이어 생성 (재시도 포함) + Future _createPlayerWithRetry(int index) async { + const maxRetries = 3; + const baseDelay = Duration(milliseconds: 100); + + for (var attempt = 0; attempt < maxRetries; attempt++) { + try { + // 생성 전 지연 (첫 번째 시도에서도) + if (attempt > 0 || index > 0) { + await Future.delayed(baseDelay * (attempt + 1)); + } + final player = AudioPlayer(); await player.setVolume(_volume); // 재생 완료 리스너 등록 - player.playerStateStream.listen((state) { - if (state.processingState == ProcessingState.completed) { - _onPlayerComplete(_players.indexOf(player)); - } - }); + player.playerStateStream.listen( + (state) { + if (state.processingState == ProcessingState.completed) { + _onPlayerComplete(_staticPlayers[name]!.indexOf(player)); + } + }, + onError: (Object e) { + debugPrint('[SfxChannelPool:$name] Stream error: $e'); + }, + ); - _players.add(player); - _playerBusy.add(false); + return player; + } catch (e) { + debugPrint( + '[SfxChannelPool:$name] Player $index attempt ${attempt + 1} failed: $e'); + if (attempt == maxRetries - 1) { + return null; + } } - - _initialized = true; - debugPrint('[SfxChannelPool:$name] Initialized with $poolSize players'); - } catch (e) { - _initFailed = true; - debugPrint('[SfxChannelPool:$name] Init failed: $e'); } + return null; } /// 사운드 재생 (완료 보장) - /// - /// 사용 가능한 플레이어가 있으면 즉시 재생하고, - /// 모든 플레이어가 사용 중이면 대기열에 추가합니다. Future play(String assetPath) async { - if (_initFailed) return; - if (!_initialized) await init(); - if (_initFailed || !_initialized) return; - if (_volume == 0) return; // 볼륨이 0이면 재생 안함 + // 초기화 안됐으면 초기화 시도 + if (!isInitialized) { + await init(); + } + + // 플레이어가 없으면 무시 + final players = _staticPlayers[name]; + if (players == null || players.isEmpty) { + return; + } + + // 볼륨이 0이면 재생 안함 + if (_volume == 0) return; // 사용 가능한 플레이어 찾기 final availableIndex = _findAvailablePlayer(); @@ -87,38 +172,51 @@ class SfxChannelPool { // 즉시 재생 await _playOnPlayer(availableIndex, assetPath); } else { - // 대기열에 추가 - _pendingQueue.add(assetPath); - debugPrint('[SfxChannelPool:$name] Queued: $assetPath ' - '(queue size: ${_pendingQueue.length})'); + // 대기열에 추가 (최대 10개로 제한) + if (_pendingQueue.length < 10) { + _pendingQueue.add(assetPath); + } } } /// 볼륨 설정 (0.0 ~ 1.0) Future setVolume(double volume) async { _volume = volume.clamp(0.0, 1.0); - if (_initialized) { - for (final player in _players) { - await player.setVolume(_volume); + + final players = _staticPlayers[name]; + if (players != null) { + for (final player in players) { + try { + await player.setVolume(_volume); + } catch (_) {} } } } /// 리소스 해제 Future dispose() async { - for (final player in _players) { - await player.dispose(); + final players = _staticPlayers[name]; + if (players != null) { + for (final player in players) { + try { + await player.dispose(); + } catch (_) {} + } + players.clear(); } - _players.clear(); - _playerBusy.clear(); + + _staticBusy[name]?.clear(); _pendingQueue.clear(); - _initialized = false; + _staticInitialized[name] = false; } /// 사용 가능한 플레이어 인덱스 반환 (-1: 없음) int _findAvailablePlayer() { - for (var i = 0; i < _playerBusy.length; i++) { - if (!_playerBusy[i]) { + final busy = _staticBusy[name]; + if (busy == null) return -1; + + for (var i = 0; i < busy.length; i++) { + if (!busy[i]) { return i; } } @@ -127,32 +225,53 @@ class SfxChannelPool { /// 특정 플레이어에서 사운드 재생 Future _playOnPlayer(int index, String assetPath) async { - if (index < 0 || index >= _players.length) return; + final players = _staticPlayers[name]; + final busy = _staticBusy[name]; - final player = _players[index]; - _playerBusy[index] = true; + if (players == null || busy == null) return; + if (index < 0 || index >= players.length) return; + + final player = players[index]; + busy[index] = true; try { + await player.stop(); await player.setAsset(assetPath); await player.seek(Duration.zero); await player.play(); } catch (e) { - // 파일이 없거나 오류 시 busy 상태 해제 - _playerBusy[index] = false; - debugPrint('[SfxChannelPool:$name] Play failed: $assetPath - $e'); + busy[index] = false; + if (e.toString().contains('Unable to load asset')) { + debugPrint('[SfxChannelPool:$name] Asset not found: $assetPath'); + } } } /// 플레이어 재생 완료 시 호출 void _onPlayerComplete(int index) { - if (index < 0 || index >= _playerBusy.length) return; + final busy = _staticBusy[name]; + if (busy == null || index < 0 || index >= busy.length) return; - _playerBusy[index] = false; + busy[index] = false; - // 대기열에 항목이 있으면 다음 사운드 재생 if (_pendingQueue.isNotEmpty) { final nextAsset = _pendingQueue.removeFirst(); _playOnPlayer(index, nextAsset); } } + + /// 모든 static 리소스 정리 (테스트용) + static void resetAll() { + for (final players in _staticPlayers.values) { + for (final player in players) { + try { + player.dispose(); + } catch (_) {} + } + } + _staticPlayers.clear(); + _staticBusy.clear(); + _staticInitialized.clear(); + _staticInitCompleters.clear(); + } }