fix(audio): macOS 오디오 재생 오류 수정

- 싱글톤 패턴 적용으로 핫 리로드 안정성 확보
- static 플레이어 관리로 중복 생성 방지
- BGM 뮤텍스 추가로 동시 호출 직렬화
- 에셋 경로에 assets/ 접두사 추가
- Operation Stopped 에러 시 플레이어 재생성 로직 추가
- SFX 풀 크기 최적화 (4+3 → 2+2)
This commit is contained in:
JiWoong Sul
2026-01-05 19:41:31 +09:00
parent 20421dafd7
commit 33b7cd3b16
2 changed files with 506 additions and 204 deletions

View File

@@ -1,10 +1,49 @@
import 'dart:async' show Completer, unawaited;
import 'package:flutter/foundation.dart' show debugPrint, kIsWeb; import 'package:flutter/foundation.dart' show debugPrint, kIsWeb;
import 'package:just_audio/just_audio.dart'; import 'package:just_audio/just_audio.dart';
import 'package:asciineverdie/src/core/audio/sfx_channel_pool.dart'; import 'package:asciineverdie/src/core/audio/sfx_channel_pool.dart';
import 'package:asciineverdie/src/core/storage/settings_repository.dart'; import 'package:asciineverdie/src/core/storage/settings_repository.dart';
/// 게임 오디오 서비 /// BGM 작업 직렬화를 위한 간단한 뮤텍
class _BgmMutex {
Completer<void>? _completer;
String? _pendingBgm;
/// 현재 작업 중인지 확인
bool get isLocked => _completer != null && !_completer!.isCompleted;
/// 락 획득 시도 (이미 잠겨있으면 대기 BGM 설정 후 false 반환)
Future<bool> tryAcquire(String bgmName) async {
if (isLocked) {
// 이미 작업 중이면 대기 BGM 설정 (마지막 것만 유지)
_pendingBgm = bgmName;
return false;
}
_completer = Completer<void>();
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를 관리하며, 설정과 연동하여 볼륨을 조절합니다. /// BGM과 SFX를 관리하며, 설정과 연동하여 볼륨을 조절합니다.
/// 웹/WASM 환경에서는 제한적으로 작동할 수 있습니다. /// 웹/WASM 환경에서는 제한적으로 작동할 수 있습니다.
@@ -13,22 +52,59 @@ import 'package:asciineverdie/src/core/storage/settings_repository.dart';
/// - BGM: 단일 플레이어 (루프 재생) /// - BGM: 단일 플레이어 (루프 재생)
/// - Player SFX: 플레이어 이펙트 (공격, 스킬, 아이템 등) /// - Player SFX: 플레이어 이펙트 (공격, 스킬, 아이템 등)
/// - Monster SFX: 몬스터 이펙트 (몬스터 공격 = 플레이어 피격) /// - Monster SFX: 몬스터 이펙트 (몬스터 공격 = 플레이어 피격)
///
/// 모든 플레이어 인스턴스를 static으로 관리하여 핫 리로드에서도 안전합니다.
class AudioService { 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; final SettingsRepository _settingsRepository;
// BGM 플레이어 // ─────────────────────────────────────────────────────────────────────────
AudioPlayer? _bgmPlayer; // static 플레이어 관리 (핫 리로드에서 유지)
// ─────────────────────────────────────────────────────────────────────────
/// static BGM 플레이어 (핫 리로드에서 유지)
static AudioPlayer? _staticBgmPlayer;
/// static 초기화 완료 여부
static bool _staticInitialized = false;
/// static 초기화 진행 중 Completer (중복 초기화 방지)
static Completer<void>? _staticInitCompleter;
// ─────────────────────────────────────────────────────────────────────────
// 인스턴스 변수
// ─────────────────────────────────────────────────────────────────────────
// SFX 채널 풀 (채널별 분리, 완료 보장) // SFX 채널 풀 (채널별 분리, 완료 보장)
SfxChannelPool? _playerSfxPool; SfxChannelPool? _playerSfxPool;
SfxChannelPool? _monsterSfxPool; SfxChannelPool? _monsterSfxPool;
// 채널별 풀 크기 // 채널별 풀 크기 (줄임: 동시 재생 문제 완화)
static const int _playerPoolSize = 4; static const int _playerPoolSize = 2;
static const int _monsterPoolSize = 3; static const int _monsterPoolSize = 2;
// 현재 볼륨 // 현재 볼륨
double _bgmVolume = 0.7; double _bgmVolume = 0.7;
@@ -37,12 +113,6 @@ class AudioService {
// 현재 재생 중인 BGM // 현재 재생 중인 BGM
String? _currentBgm; String? _currentBgm;
// 초기화 여부
bool _initialized = false;
// 초기화 실패 여부 (WASM 등에서 오디오 지원 안됨)
bool _initFailed = false;
// 웹에서 사용자 상호작용 대기 중인 BGM (자동재생 정책 대응) // 웹에서 사용자 상호작용 대기 중인 BGM (자동재생 정책 대응)
String? _pendingBgm; String? _pendingBgm;
@@ -52,166 +122,294 @@ class AudioService {
// 오디오 일시정지 상태 (앱 백그라운드 시) // 오디오 일시정지 상태 (앱 백그라운드 시)
bool _isPaused = false; bool _isPaused = false;
// BGM 작업 직렬화 뮤텍스 (동시 호출 방지)
final _bgmMutex = _BgmMutex();
// ─────────────────────────────────────────────────────────────────────────
// 초기화
// ─────────────────────────────────────────────────────────────────────────
/// 초기화 완료 여부
bool get isInitialized => _staticInitialized;
/// 서비스 초기화 /// 서비스 초기화
Future<void> init() async { Future<void> init() async {
if (_initialized || _initFailed) return; // 이미 초기화 완료됨
if (_staticInitialized) {
return;
}
// 초기화 진행 중이면 완료 대기
if (_staticInitCompleter != null) {
await _staticInitCompleter!.future;
return;
}
// 초기화 시작
final completer = Completer<void>();
_staticInitCompleter = completer;
try { try {
// 설정에서 볼륨 불러오기 // 설정에서 볼륨 불러오기
_bgmVolume = await _settingsRepository.loadBgmVolume(); _bgmVolume = await _settingsRepository.loadBgmVolume();
_sfxVolume = await _settingsRepository.loadSfxVolume(); _sfxVolume = await _settingsRepository.loadSfxVolume();
// BGM 플레이어 초기화 // BGM 플레이어 초기화 (순차적, 지연 포함)
_bgmPlayer = AudioPlayer(); await _initBgmPlayer();
await _bgmPlayer!.setLoopMode(LoopMode.one);
await _bgmPlayer!.setVolume(_bgmVolume);
// SFX 채널 풀 초기화 (채널별 분리) // 지연 후 SFX 풀 초기화 (순차적)
_playerSfxPool = SfxChannelPool( await Future<void>.delayed(const Duration(milliseconds: 200));
name: 'Player', await _initSfxPools();
poolSize: _playerPoolSize,
volume: _sfxVolume,
);
await _playerSfxPool!.init();
_monsterSfxPool = SfxChannelPool( _staticInitialized = true;
name: 'Monster',
poolSize: _monsterPoolSize,
volume: _sfxVolume,
);
await _monsterSfxPool!.init();
_initialized = true;
// 모바일/데스크톱에서는 자동재생 제한 없음 // 모바일/데스크톱에서는 자동재생 제한 없음
if (!kIsWeb) { if (!kIsWeb) {
_userInteracted = true; _userInteracted = true;
} else {
debugPrint('[AudioService] Initialized on Web platform');
} }
debugPrint('[AudioService] Initialized successfully');
completer.complete();
} catch (e) { } catch (e) {
_initFailed = true; debugPrint('[AudioService] Init error: $e');
debugPrint('[AudioService] Init failed (likely WASM): $e'); // 에러여도 완료 처리 (오디오 없이 게임 진행 가능)
_staticInitialized = true;
completer.complete();
} finally {
_staticInitCompleter = null;
} }
} }
/// BGM 재생 /// BGM 플레이어 초기화 (재시도 포함)
Future<void> _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<void>.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<void> _initSfxPools() async {
// Player SFX 풀
_playerSfxPool = SfxChannelPool(
name: 'Player',
poolSize: _playerPoolSize,
volume: _sfxVolume,
);
await _playerSfxPool!.init();
// 지연 후 Monster SFX 풀
await Future<void>.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<void> playBgm(String name) async { Future<void> playBgm(String name) async {
if (_initFailed) return; // 초기화 실패 시 무시 if (_isPaused) return;
if (_isPaused) return; // 일시정지 상태면 무시 if (!_staticInitialized) await init();
if (!_initialized) await init(); if (_currentBgm == name) return;
if (_initFailed || !_initialized) return; if (_staticBgmPlayer == null) return;
if (_currentBgm == name) return; // 이미 재생 중
// 뮤텍스 획득 시도 (실패하면 대기열에 추가)
if (!await _bgmMutex.tryAcquire(name)) {
debugPrint('[AudioService] BGM $name queued (mutex locked)');
return;
}
try { try {
await _bgmPlayer!.setAsset('audio/bgm/$name.mp3'); await _playBgmInternal(name);
await _bgmPlayer!.play(); } finally {
// 락 해제 및 대기 중인 BGM 확인
final pendingBgm = _bgmMutex.release();
if (pendingBgm != null && pendingBgm != _currentBgm) {
// 대기 중인 BGM이 있으면 재귀 호출 (새 뮤텍스 획득)
unawaited(playBgm(pendingBgm));
}
}
}
/// 내부 BGM 재생 (뮤텍스 내에서 호출)
Future<void> _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; _currentBgm = name;
_pendingBgm = null; _pendingBgm = null;
_userInteracted = true; // 재생 성공 → 상호작용 확인됨 _userInteracted = true;
debugPrint('[AudioService] Playing BGM: $name'); debugPrint('[AudioService] Playing BGM: $name');
} on PlayerInterruptedException catch (e) {
debugPrint('[AudioService] BGM $name interrupted: ${e.message}');
} catch (e) { } catch (e) {
// 웹 자동재생 정책으로 실패 시 대기 상태로 저장 final errorStr = e.toString();
if (kIsWeb && e.toString().contains('NotAllowedError')) { 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; _pendingBgm = name;
debugPrint('[AudioService] BGM $name pending (waiting for user interaction)'); debugPrint('[AudioService] BGM $name pending (autoplay blocked)');
} else { } else {
debugPrint('[AudioService] Failed to play BGM $name: $e'); debugPrint('[AudioService] BGM play failed: $e');
} }
_currentBgm = null; _currentBgm = null;
} }
} }
/// BGM 플레이어 재생성 (에러 복구용)
Future<void> _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 정지 /// BGM 정지
Future<void> stopBgm() async { Future<void> stopBgm() async {
if (!_initialized) return; if (_staticBgmPlayer == null) return;
try {
await _bgmPlayer!.stop(); await _staticBgmPlayer!.stop();
} catch (_) {}
_currentBgm = null; _currentBgm = null;
} }
/// BGM 일시정지 /// BGM 일시정지
Future<void> pauseBgm() async { Future<void> pauseBgm() async {
if (!_initialized) return; if (_staticBgmPlayer == null) return;
await _bgmPlayer!.pause(); try {
await _staticBgmPlayer!.pause();
} catch (_) {}
} }
/// BGM 재개 /// BGM 재개
Future<void> resumeBgm() async { Future<void> resumeBgm() async {
if (!_initialized) return; if (_staticBgmPlayer == null || _currentBgm == null) return;
if (_currentBgm != null) { try {
await _bgmPlayer!.play(); await _staticBgmPlayer!.play();
} } catch (_) {}
} }
// ─────────────────────────────────────────────────────────────────────────
// 전체 오디오 제어
// ─────────────────────────────────────────────────────────────────────────
/// 전체 오디오 일시정지 (앱 백그라운드 시) /// 전체 오디오 일시정지 (앱 백그라운드 시)
///
/// BGM을 정지하고, 새로운 재생 요청을 무시합니다.
Future<void> pauseAll() async { Future<void> pauseAll() async {
_isPaused = true; _isPaused = true;
if (!_initialized) return; try {
await _staticBgmPlayer?.stop();
// BGM 정지 및 상태 초기화 } catch (_) {}
await _bgmPlayer?.stop();
_currentBgm = null; _currentBgm = null;
// SFX 채널 풀은 자동 완료되므로 별도 정지 불필요
// (새로운 재생 요청만 _isPaused로 차단)
debugPrint('[AudioService] All audio paused'); debugPrint('[AudioService] All audio paused');
} }
/// 전체 오디오 재개 (앱 포그라운드 복귀 시) /// 전체 오디오 재개 (앱 포그라운드 복귀 시)
///
/// 일시정지 상태를 해제하고 이전 BGM을 재개합니다.
Future<void> resumeAll() async { Future<void> resumeAll() async {
_isPaused = false; _isPaused = false;
debugPrint('[AudioService] Audio resumed'); debugPrint('[AudioService] Audio resumed');
} }
// ─────────────────────────────────────────────────────────────────────────
// SFX 재생
// ─────────────────────────────────────────────────────────────────────────
/// 플레이어 이펙트 SFX 재생 /// 플레이어 이펙트 SFX 재생
///
/// [name]은 audio/sfx/ 폴더 내 파일명 (확장자 제외)
/// 예: playPlayerSfx('attack') → audio/sfx/attack.mp3
///
/// 대기열 기반으로 모든 사운드의 완전 재생을 보장합니다.
Future<void> playPlayerSfx(String name) async { Future<void> playPlayerSfx(String name) async {
if (_initFailed) return;
if (_isPaused) return; if (_isPaused) return;
if (!_initialized) await init(); if (!_staticInitialized) await init();
if (_initFailed || !_initialized) return;
// 웹에서 대기 중인 BGM 재생 시도
_tryPlayPendingBgm(); _tryPlayPendingBgm();
await _playerSfxPool?.play('assets/audio/sfx/$name.mp3');
await _playerSfxPool?.play('audio/sfx/$name.mp3');
} }
/// 몬스터 이펙트 SFX 재생 /// 몬스터 이펙트 SFX 재생
///
/// [name]은 audio/sfx/ 폴더 내 파일명 (확장자 제외)
/// 예: playMonsterSfx('hit') → audio/sfx/hit.mp3
///
/// 대기열 기반으로 모든 사운드의 완전 재생을 보장합니다.
Future<void> playMonsterSfx(String name) async { Future<void> playMonsterSfx(String name) async {
if (_initFailed) return;
if (_isPaused) return; if (_isPaused) return;
if (!_initialized) await init(); if (!_staticInitialized) await init();
if (_initFailed || !_initialized) return;
// 웹에서 대기 중인 BGM 재생 시도
_tryPlayPendingBgm(); _tryPlayPendingBgm();
await _monsterSfxPool?.play('assets/audio/sfx/$name.mp3');
await _monsterSfxPool?.play('audio/sfx/$name.mp3');
} }
/// 웹에서 대기 중인 BGM 재생 시도 (사용자 상호작용 발생 시) /// 웹에서 대기 중인 BGM 재생 시도
void _tryPlayPendingBgm() { void _tryPlayPendingBgm() {
if (!_userInteracted && _pendingBgm != null) { if (!_userInteracted && _pendingBgm != null) {
_userInteracted = true; _userInteracted = true;
@@ -222,31 +420,29 @@ class AudioService {
} }
/// SFX 재생 (레거시 호환) /// SFX 재생 (레거시 호환)
/// @Deprecated('playPlayerSfx 또는 playMonsterSfx를 사용하세요.')
/// [name]은 audio/sfx/ 폴더 내 파일명 (확장자 제외)
/// 예: playSfx('attack') → audio/sfx/attack.mp3
///
/// @deprecated playPlayerSfx 또는 playMonsterSfx를 사용하세요.
Future<void> playSfx(String name) => playPlayerSfx(name); Future<void> playSfx(String name) => playPlayerSfx(name);
// ─────────────────────────────────────────────────────────────────────────
// 볼륨 제어
// ─────────────────────────────────────────────────────────────────────────
/// BGM 볼륨 설정 (0.0 ~ 1.0) /// BGM 볼륨 설정 (0.0 ~ 1.0)
Future<void> setBgmVolume(double volume) async { Future<void> setBgmVolume(double volume) async {
_bgmVolume = volume.clamp(0.0, 1.0); _bgmVolume = volume.clamp(0.0, 1.0);
if (_initialized && _bgmPlayer != null) { if (_staticBgmPlayer != null) {
await _bgmPlayer!.setVolume(_bgmVolume); try {
await _staticBgmPlayer!.setVolume(_bgmVolume);
} catch (_) {}
} }
await _settingsRepository.saveBgmVolume(_bgmVolume); await _settingsRepository.saveBgmVolume(_bgmVolume);
} }
/// SFX 볼륨 설정 (0.0 ~ 1.0) /// SFX 볼륨 설정 (0.0 ~ 1.0)
///
/// 모든 SFX 채널 (플레이어, 몬스터)에 동시 적용됩니다.
Future<void> setSfxVolume(double volume) async { Future<void> setSfxVolume(double volume) async {
_sfxVolume = volume.clamp(0.0, 1.0); _sfxVolume = volume.clamp(0.0, 1.0);
if (_initialized) { await _playerSfxPool?.setVolume(_sfxVolume);
await _playerSfxPool?.setVolume(_sfxVolume); await _monsterSfxPool?.setVolume(_sfxVolume);
await _monsterSfxPool?.setVolume(_sfxVolume);
}
await _settingsRepository.saveSfxVolume(_sfxVolume); await _settingsRepository.saveSfxVolume(_sfxVolume);
} }
@@ -259,14 +455,14 @@ class AudioService {
/// 현재 재생 중인 BGM /// 현재 재생 중인 BGM
String? get currentBgm => _currentBgm; String? get currentBgm => _currentBgm;
// ─────────────────────────────────────────────────────────────────────────
// 유틸리티
// ─────────────────────────────────────────────────────────────────────────
/// 사용자 상호작용 발생 알림 (웹 자동재생 정책 우회) /// 사용자 상호작용 발생 알림 (웹 자동재생 정책 우회)
///
/// 버튼 클릭 등 사용자 상호작용 시 호출하면
/// 대기 중인 BGM이 재생됩니다.
Future<void> notifyUserInteraction() async { Future<void> notifyUserInteraction() async {
if (_userInteracted) return; if (_userInteracted) return;
_userInteracted = true; _userInteracted = true;
if (_pendingBgm != null) { if (_pendingBgm != null) {
final pending = _pendingBgm; final pending = _pendingBgm;
_pendingBgm = null; _pendingBgm = null;
@@ -276,61 +472,48 @@ class AudioService {
/// 서비스 정리 /// 서비스 정리
Future<void> dispose() async { Future<void> dispose() async {
await _bgmPlayer?.dispose(); try {
await _staticBgmPlayer?.dispose();
} catch (_) {}
_staticBgmPlayer = null;
await _playerSfxPool?.dispose(); await _playerSfxPool?.dispose();
await _monsterSfxPool?.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 타입 열거형 /// BGM 타입 열거형
enum BgmType { enum BgmType {
/// 타이틀 화면 BGM
title, title,
/// 마을/상점 BGM
town, town,
/// 일반 전투 BGM
battle, battle,
/// 보스 전투 BGM
boss, boss,
/// 레벨업/퀘스트 완료 팡파레
victory, victory,
} }
/// SFX 타입 열거형 /// SFX 타입 열거형
enum SfxType { enum SfxType {
/// 공격
attack, attack,
/// 피격
hit, hit,
/// 스킬 사용
skill, skill,
/// 아이템 획득
item, item,
/// UI 클릭
click, click,
/// 레벨업
levelUp, levelUp,
/// 퀘스트 완료
questComplete, questComplete,
/// 회피 (Phase 11)
evade, evade,
/// 방패 방어 (Phase 11)
block, block,
/// 무기 쳐내기 (Phase 11)
parry, parry,
} }

View File

@@ -1,13 +1,16 @@
import 'dart:async';
import 'dart:collection'; import 'dart:collection';
import 'package:flutter/foundation.dart' show debugPrint; import 'package:flutter/foundation.dart' show debugPrint;
import 'package:just_audio/just_audio.dart'; import 'package:just_audio/just_audio.dart';
/// SFX 채널 풀 - 사운드 완료 보장 /// SFX 채널 풀 - 사운드 완료 보장 (핫 리로드 안전)
/// ///
/// 대기열 기반으로 모든 사운드의 완전 재생을 보장합니다. /// 대기열 기반으로 모든 사운드의 완전 재생을 보장합니다.
/// 사용 가능한 플레이어가 없으면 대기열에 추가하고, /// 사용 가능한 플레이어가 없으면 대기열에 추가하고,
/// 재생 완료 시 대기열의 다음 사운드를 자동 재생합니다. /// 재생 완료 시 대기열의 다음 사운드를 자동 재생합니다.
///
/// 플레이어 인스턴스를 static으로 관리하여 핫 리로드에서도 안전합니다.
class SfxChannelPool { class SfxChannelPool {
SfxChannelPool({ SfxChannelPool({
required this.name, required this.name,
@@ -24,61 +27,143 @@ class SfxChannelPool {
/// 채널 볼륨 /// 채널 볼륨
double _volume; double _volume;
/// AudioPlayer 풀 /// static 플레이어 저장소 (채널명 기반, 핫 리로드에서 유지)
final List<AudioPlayer> _players = []; static final Map<String, List<AudioPlayer>> _staticPlayers = {};
/// 각 플레이어의 재생 중 여부 추적 /// static busy 상태 (채널명 기반)
final List<bool> _playerBusy = []; static final Map<String, List<bool>> _staticBusy = {};
/// static 초기화 완료 여부 (채널명 기반)
static final Map<String, bool> _staticInitialized = {};
/// static 초기화 진행 중 Completer (중복 초기화 방지)
static final Map<String, Completer<void>?> _staticInitCompleters = {};
/// 대기열 (재생 대기 중인 에셋 경로) /// 대기열 (재생 대기 중인 에셋 경로)
final Queue<String> _pendingQueue = Queue(); final Queue<String> _pendingQueue = Queue();
/// 초기화 여부
bool _initialized = false;
/// 초기화 실패 여부
bool _initFailed = false;
/// 현재 볼륨 /// 현재 볼륨
double get volume => _volume; double get volume => _volume;
/// 초기화 완료 여부
bool get isInitialized => _staticInitialized[name] == true;
/// 사용 가능한 플레이어 수
int get availablePlayerCount => _staticPlayers[name]?.length ?? 0;
/// 초기화 /// 초기화
Future<void> init() async { Future<void> init() async {
if (_initialized || _initFailed) return; // 이미 초기화 완료됨
if (_staticInitialized[name] == true) {
return;
}
// 초기화 진행 중이면 완료 대기
if (_staticInitCompleters[name] != null) {
await _staticInitCompleters[name]!.future;
return;
}
// 초기화 시작
final completer = Completer<void>();
_staticInitCompleters[name] = completer;
try { 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++) { 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<AudioPlayer?> _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<void>.delayed(baseDelay * (attempt + 1));
}
final player = AudioPlayer(); final player = AudioPlayer();
await player.setVolume(_volume); await player.setVolume(_volume);
// 재생 완료 리스너 등록 // 재생 완료 리스너 등록
player.playerStateStream.listen((state) { player.playerStateStream.listen(
if (state.processingState == ProcessingState.completed) { (state) {
_onPlayerComplete(_players.indexOf(player)); if (state.processingState == ProcessingState.completed) {
} _onPlayerComplete(_staticPlayers[name]!.indexOf(player));
}); }
},
onError: (Object e) {
debugPrint('[SfxChannelPool:$name] Stream error: $e');
},
);
_players.add(player); return player;
_playerBusy.add(false); } 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<void> play(String assetPath) async { Future<void> play(String assetPath) async {
if (_initFailed) return; // 초기화 안됐으면 초기화 시도
if (!_initialized) await init(); if (!isInitialized) {
if (_initFailed || !_initialized) return; await init();
if (_volume == 0) return; // 볼륨이 0이면 재생 안함 }
// 플레이어가 없으면 무시
final players = _staticPlayers[name];
if (players == null || players.isEmpty) {
return;
}
// 볼륨이 0이면 재생 안함
if (_volume == 0) return;
// 사용 가능한 플레이어 찾기 // 사용 가능한 플레이어 찾기
final availableIndex = _findAvailablePlayer(); final availableIndex = _findAvailablePlayer();
@@ -87,38 +172,51 @@ class SfxChannelPool {
// 즉시 재생 // 즉시 재생
await _playOnPlayer(availableIndex, assetPath); await _playOnPlayer(availableIndex, assetPath);
} else { } else {
// 대기열에 추가 // 대기열에 추가 (최대 10개로 제한)
_pendingQueue.add(assetPath); if (_pendingQueue.length < 10) {
debugPrint('[SfxChannelPool:$name] Queued: $assetPath ' _pendingQueue.add(assetPath);
'(queue size: ${_pendingQueue.length})'); }
} }
} }
/// 볼륨 설정 (0.0 ~ 1.0) /// 볼륨 설정 (0.0 ~ 1.0)
Future<void> setVolume(double volume) async { Future<void> setVolume(double volume) async {
_volume = volume.clamp(0.0, 1.0); _volume = volume.clamp(0.0, 1.0);
if (_initialized) {
for (final player in _players) { final players = _staticPlayers[name];
await player.setVolume(_volume); if (players != null) {
for (final player in players) {
try {
await player.setVolume(_volume);
} catch (_) {}
} }
} }
} }
/// 리소스 해제 /// 리소스 해제
Future<void> dispose() async { Future<void> dispose() async {
for (final player in _players) { final players = _staticPlayers[name];
await player.dispose(); if (players != null) {
for (final player in players) {
try {
await player.dispose();
} catch (_) {}
}
players.clear();
} }
_players.clear();
_playerBusy.clear(); _staticBusy[name]?.clear();
_pendingQueue.clear(); _pendingQueue.clear();
_initialized = false; _staticInitialized[name] = false;
} }
/// 사용 가능한 플레이어 인덱스 반환 (-1: 없음) /// 사용 가능한 플레이어 인덱스 반환 (-1: 없음)
int _findAvailablePlayer() { int _findAvailablePlayer() {
for (var i = 0; i < _playerBusy.length; i++) { final busy = _staticBusy[name];
if (!_playerBusy[i]) { if (busy == null) return -1;
for (var i = 0; i < busy.length; i++) {
if (!busy[i]) {
return i; return i;
} }
} }
@@ -127,32 +225,53 @@ class SfxChannelPool {
/// 특정 플레이어에서 사운드 재생 /// 특정 플레이어에서 사운드 재생
Future<void> _playOnPlayer(int index, String assetPath) async { Future<void> _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]; if (players == null || busy == null) return;
_playerBusy[index] = true; if (index < 0 || index >= players.length) return;
final player = players[index];
busy[index] = true;
try { try {
await player.stop();
await player.setAsset(assetPath); await player.setAsset(assetPath);
await player.seek(Duration.zero); await player.seek(Duration.zero);
await player.play(); await player.play();
} catch (e) { } catch (e) {
// 파일이 없거나 오류 시 busy 상태 해제 busy[index] = false;
_playerBusy[index] = false; if (e.toString().contains('Unable to load asset')) {
debugPrint('[SfxChannelPool:$name] Play failed: $assetPath - $e'); debugPrint('[SfxChannelPool:$name] Asset not found: $assetPath');
}
} }
} }
/// 플레이어 재생 완료 시 호출 /// 플레이어 재생 완료 시 호출
void _onPlayerComplete(int index) { 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) { if (_pendingQueue.isNotEmpty) {
final nextAsset = _pendingQueue.removeFirst(); final nextAsset = _pendingQueue.removeFirst();
_playOnPlayer(index, nextAsset); _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();
}
} }