feat(audio): SFX 채널 풀 시스템 추가

- SfxChannelPool: 대기열 기반 SFX 재생 시스템
- 채널별 분리로 플레이어/몬스터 사운드 독립 재생
- 모든 사운드의 완전 재생 보장
This commit is contained in:
JiWoong Sul
2025-12-31 01:33:02 +09:00
parent e69f8921e6
commit 43289ac848

View File

@@ -0,0 +1,158 @@
import 'dart:collection';
import 'package:flutter/foundation.dart' show debugPrint;
import 'package:just_audio/just_audio.dart';
/// SFX 채널 풀 - 사운드 완료 보장
///
/// 대기열 기반으로 모든 사운드의 완전 재생을 보장합니다.
/// 사용 가능한 플레이어가 없으면 대기열에 추가하고,
/// 재생 완료 시 대기열의 다음 사운드를 자동 재생합니다.
class SfxChannelPool {
SfxChannelPool({
required this.name,
this.poolSize = 4,
double volume = 0.8,
}) : _volume = volume;
/// 채널명 (디버그용)
final String name;
/// 풀 크기
final int poolSize;
/// 채널 볼륨
double _volume;
/// AudioPlayer 풀
final List<AudioPlayer> _players = [];
/// 각 플레이어의 재생 중 여부 추적
final List<bool> _playerBusy = [];
/// 대기열 (재생 대기 중인 에셋 경로)
final Queue<String> _pendingQueue = Queue();
/// 초기화 여부
bool _initialized = false;
/// 초기화 실패 여부
bool _initFailed = false;
/// 현재 볼륨
double get volume => _volume;
/// 초기화
Future<void> init() async {
if (_initialized || _initFailed) return;
try {
for (var i = 0; i < poolSize; i++) {
final player = AudioPlayer();
await player.setVolume(_volume);
// 재생 완료 리스너 등록
player.playerStateStream.listen((state) {
if (state.processingState == ProcessingState.completed) {
_onPlayerComplete(_players.indexOf(player));
}
});
_players.add(player);
_playerBusy.add(false);
}
_initialized = true;
debugPrint('[SfxChannelPool:$name] Initialized with $poolSize players');
} catch (e) {
_initFailed = true;
debugPrint('[SfxChannelPool:$name] Init failed: $e');
}
}
/// 사운드 재생 (완료 보장)
///
/// 사용 가능한 플레이어가 있으면 즉시 재생하고,
/// 모든 플레이어가 사용 중이면 대기열에 추가합니다.
Future<void> play(String assetPath) async {
if (_initFailed) return;
if (!_initialized) await init();
if (_initFailed || !_initialized) return;
if (_volume == 0) return; // 볼륨이 0이면 재생 안함
// 사용 가능한 플레이어 찾기
final availableIndex = _findAvailablePlayer();
if (availableIndex != -1) {
// 즉시 재생
await _playOnPlayer(availableIndex, assetPath);
} else {
// 대기열에 추가
_pendingQueue.add(assetPath);
debugPrint('[SfxChannelPool:$name] Queued: $assetPath '
'(queue size: ${_pendingQueue.length})');
}
}
/// 볼륨 설정 (0.0 ~ 1.0)
Future<void> setVolume(double volume) async {
_volume = volume.clamp(0.0, 1.0);
if (_initialized) {
for (final player in _players) {
await player.setVolume(_volume);
}
}
}
/// 리소스 해제
Future<void> dispose() async {
for (final player in _players) {
await player.dispose();
}
_players.clear();
_playerBusy.clear();
_pendingQueue.clear();
_initialized = false;
}
/// 사용 가능한 플레이어 인덱스 반환 (-1: 없음)
int _findAvailablePlayer() {
for (var i = 0; i < _playerBusy.length; i++) {
if (!_playerBusy[i]) {
return i;
}
}
return -1;
}
/// 특정 플레이어에서 사운드 재생
Future<void> _playOnPlayer(int index, String assetPath) async {
if (index < 0 || index >= _players.length) return;
final player = _players[index];
_playerBusy[index] = true;
try {
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');
}
}
/// 플레이어 재생 완료 시 호출
void _onPlayerComplete(int index) {
if (index < 0 || index >= _playerBusy.length) return;
_playerBusy[index] = false;
// 대기열에 항목이 있으면 다음 사운드 재생
if (_pendingQueue.isNotEmpty) {
final nextAsset = _pendingQueue.removeFirst();
_playOnPlayer(index, nextAsset);
}
}
}