import 'package:flutter/foundation.dart' show debugPrint, kIsWeb; import 'package:just_audio/just_audio.dart'; import 'package:askiineverdie/src/core/audio/sfx_channel_pool.dart'; import 'package:askiineverdie/src/core/storage/settings_repository.dart'; /// 게임 오디오 서비스 /// /// BGM과 SFX를 관리하며, 설정과 연동하여 볼륨을 조절합니다. /// 웹/WASM 환경에서는 제한적으로 작동할 수 있습니다. /// /// 채널 구조: /// - BGM: 단일 플레이어 (루프 재생) /// - Player SFX: 플레이어 이펙트 (공격, 스킬, 아이템 등) /// - Monster SFX: 몬스터 이펙트 (몬스터 공격 = 플레이어 피격) class AudioService { AudioService({SettingsRepository? settingsRepository}) : _settingsRepository = settingsRepository ?? SettingsRepository(); final SettingsRepository _settingsRepository; // BGM 플레이어 AudioPlayer? _bgmPlayer; // SFX 채널 풀 (채널별 분리, 완료 보장) SfxChannelPool? _playerSfxPool; SfxChannelPool? _monsterSfxPool; // 채널별 풀 크기 static const int _playerPoolSize = 4; static const int _monsterPoolSize = 3; // 현재 볼륨 double _bgmVolume = 0.7; double _sfxVolume = 0.8; // 현재 재생 중인 BGM String? _currentBgm; // 초기화 여부 bool _initialized = false; // 초기화 실패 여부 (WASM 등에서 오디오 지원 안됨) bool _initFailed = false; // 웹에서 사용자 상호작용 대기 중인 BGM (자동재생 정책 대응) String? _pendingBgm; // 사용자 상호작용 발생 여부 (웹 자동재생 정책 우회용) bool _userInteracted = false; // 오디오 일시정지 상태 (앱 백그라운드 시) bool _isPaused = false; /// 서비스 초기화 Future init() async { if (_initialized || _initFailed) return; try { // 설정에서 볼륨 불러오기 _bgmVolume = await _settingsRepository.loadBgmVolume(); _sfxVolume = await _settingsRepository.loadSfxVolume(); // BGM 플레이어 초기화 _bgmPlayer = AudioPlayer(); await _bgmPlayer!.setLoopMode(LoopMode.one); await _bgmPlayer!.setVolume(_bgmVolume); // SFX 채널 풀 초기화 (채널별 분리) _playerSfxPool = SfxChannelPool( name: 'Player', poolSize: _playerPoolSize, volume: _sfxVolume, ); await _playerSfxPool!.init(); _monsterSfxPool = SfxChannelPool( name: 'Monster', poolSize: _monsterPoolSize, volume: _sfxVolume, ); await _monsterSfxPool!.init(); _initialized = true; // 모바일/데스크톱에서는 자동재생 제한 없음 if (!kIsWeb) { _userInteracted = true; } else { debugPrint('[AudioService] Initialized on Web platform'); } } catch (e) { _initFailed = true; debugPrint('[AudioService] Init failed (likely WASM): $e'); } } /// BGM 재생 /// /// [name]은 assets/audio/bgm/ 폴더 내 파일명 (확장자 제외) /// 예: playBgm('battle') → assets/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; // 이미 재생 중 try { await _bgmPlayer!.setAsset('assets/audio/bgm/$name.mp3'); await _bgmPlayer!.play(); _currentBgm = name; _pendingBgm = null; _userInteracted = true; // 재생 성공 → 상호작용 확인됨 debugPrint('[AudioService] Playing BGM: $name'); } catch (e) { // 웹 자동재생 정책으로 실패 시 대기 상태로 저장 if (kIsWeb && e.toString().contains('NotAllowedError')) { _pendingBgm = name; debugPrint('[AudioService] BGM $name pending (waiting for user interaction)'); } else { debugPrint('[AudioService] Failed to play BGM $name: $e'); } _currentBgm = null; } } /// BGM 정지 Future stopBgm() async { if (!_initialized) return; await _bgmPlayer!.stop(); _currentBgm = null; } /// BGM 일시정지 Future pauseBgm() async { if (!_initialized) return; await _bgmPlayer!.pause(); } /// BGM 재개 Future resumeBgm() async { if (!_initialized) return; if (_currentBgm != null) { await _bgmPlayer!.play(); } } /// 전체 오디오 일시정지 (앱 백그라운드 시) /// /// BGM을 정지하고, 새로운 재생 요청을 무시합니다. Future pauseAll() async { _isPaused = true; if (!_initialized) return; // BGM 정지 및 상태 초기화 await _bgmPlayer?.stop(); _currentBgm = null; // SFX 채널 풀은 자동 완료되므로 별도 정지 불필요 // (새로운 재생 요청만 _isPaused로 차단) debugPrint('[AudioService] All audio paused'); } /// 전체 오디오 재개 (앱 포그라운드 복귀 시) /// /// 일시정지 상태를 해제하고 이전 BGM을 재개합니다. Future resumeAll() async { _isPaused = false; debugPrint('[AudioService] Audio resumed'); } /// 플레이어 이펙트 SFX 재생 /// /// [name]은 assets/audio/sfx/ 폴더 내 파일명 (확장자 제외) /// 예: playPlayerSfx('attack') → assets/audio/sfx/attack.mp3 /// /// 대기열 기반으로 모든 사운드의 완전 재생을 보장합니다. Future playPlayerSfx(String name) async { if (_initFailed) return; if (_isPaused) return; if (!_initialized) await init(); if (_initFailed || !_initialized) return; // 웹에서 대기 중인 BGM 재생 시도 _tryPlayPendingBgm(); await _playerSfxPool?.play('assets/audio/sfx/$name.mp3'); } /// 몬스터 이펙트 SFX 재생 /// /// [name]은 assets/audio/sfx/ 폴더 내 파일명 (확장자 제외) /// 예: playMonsterSfx('hit') → assets/audio/sfx/hit.mp3 /// /// 대기열 기반으로 모든 사운드의 완전 재생을 보장합니다. Future playMonsterSfx(String name) async { if (_initFailed) return; if (_isPaused) return; if (!_initialized) await init(); if (_initFailed || !_initialized) return; // 웹에서 대기 중인 BGM 재생 시도 _tryPlayPendingBgm(); await _monsterSfxPool?.play('assets/audio/sfx/$name.mp3'); } /// 웹에서 대기 중인 BGM 재생 시도 (사용자 상호작용 발생 시) void _tryPlayPendingBgm() { if (!_userInteracted && _pendingBgm != null) { _userInteracted = true; final pending = _pendingBgm; _pendingBgm = null; playBgm(pending!); } } /// SFX 재생 (레거시 호환) /// /// [name]은 assets/audio/sfx/ 폴더 내 파일명 (확장자 제외) /// 예: playSfx('attack') → assets/audio/sfx/attack.mp3 /// /// @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); } 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 _settingsRepository.saveSfxVolume(_sfxVolume); } /// 현재 BGM 볼륨 double get bgmVolume => _bgmVolume; /// 현재 SFX 볼륨 double get sfxVolume => _sfxVolume; /// 현재 재생 중인 BGM String? get currentBgm => _currentBgm; /// 사용자 상호작용 발생 알림 (웹 자동재생 정책 우회) /// /// 버튼 클릭 등 사용자 상호작용 시 호출하면 /// 대기 중인 BGM이 재생됩니다. Future notifyUserInteraction() async { if (_userInteracted) return; _userInteracted = true; if (_pendingBgm != null) { final pending = _pendingBgm; _pendingBgm = null; await playBgm(pending!); } } /// 서비스 정리 Future dispose() async { await _bgmPlayer?.dispose(); await _playerSfxPool?.dispose(); await _monsterSfxPool?.dispose(); _initialized = false; } } /// BGM 타입 열거형 enum BgmType { /// 타이틀 화면 BGM title, /// 마을/상점 BGM town, /// 일반 전투 BGM battle, /// 보스 전투 BGM boss, /// 레벨업/퀘스트 완료 팡파레 victory, } /// SFX 타입 열거형 enum SfxType { /// 공격 attack, /// 피격 hit, /// 스킬 사용 skill, /// 아이템 획득 item, /// UI 클릭 click, /// 레벨업 levelUp, /// 퀘스트 완료 questComplete, } /// BgmType을 파일명으로 변환 extension BgmTypeExtension on BgmType { String get fileName => name; } /// SfxType을 파일명으로 변환 extension SfxTypeExtension on SfxType { String get fileName => switch (this) { SfxType.attack => 'attack', SfxType.hit => 'hit', SfxType.skill => 'skill', SfxType.item => 'item', SfxType.click => 'click', SfxType.levelUp => 'level_up', SfxType.questComplete => 'quest_complete', }; }