Compare commits

..

3 Commits

Author SHA1 Message Date
JiWoong Sul
72676485d3 feat(audio): 화면들 채널별 SFX API 적용
- game_play_screen: playPlayerSfx/playMonsterSfx 분리 사용
- settings_screen: 오디오 설정 UI 개선
2025-12-31 01:33:18 +09:00
JiWoong Sul
764a8353fb refactor(audio): AudioService에 채널 풀 시스템 적용
- 단일 SFX 풀을 Player/Monster 채널로 분리
- playPlayerSfx(), playMonsterSfx() 메서드 추가
- playSfx()는 레거시 호환용으로 유지
- pauseAll() 간소화 (채널 풀 자동 완료)
2025-12-31 01:33:10 +09:00
JiWoong Sul
43289ac848 feat(audio): SFX 채널 풀 시스템 추가
- SfxChannelPool: 대기열 기반 SFX 재생 시스템
- 채널별 분리로 플레이어/몬스터 사운드 독립 재생
- 모든 사운드의 완전 재생 보장
2025-12-31 01:33:02 +09:00
4 changed files with 276 additions and 65 deletions

View File

@@ -1,12 +1,18 @@
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:askiineverdie/src/core/audio/sfx_channel_pool.dart';
import 'package:askiineverdie/src/core/storage/settings_repository.dart'; import 'package:askiineverdie/src/core/storage/settings_repository.dart';
/// 게임 오디오 서비스 /// 게임 오디오 서비스
/// ///
/// BGM과 SFX를 관리하며, 설정과 연동하여 볼륨을 조절합니다. /// BGM과 SFX를 관리하며, 설정과 연동하여 볼륨을 조절합니다.
/// 웹/WASM 환경에서는 제한적으로 작동할 수 있습니다. /// 웹/WASM 환경에서는 제한적으로 작동할 수 있습니다.
///
/// 채널 구조:
/// - BGM: 단일 플레이어 (루프 재생)
/// - Player SFX: 플레이어 이펙트 (공격, 스킬, 아이템 등)
/// - Monster SFX: 몬스터 이펙트 (몬스터 공격 = 플레이어 피격)
class AudioService { class AudioService {
AudioService({SettingsRepository? settingsRepository}) AudioService({SettingsRepository? settingsRepository})
: _settingsRepository = settingsRepository ?? SettingsRepository(); : _settingsRepository = settingsRepository ?? SettingsRepository();
@@ -16,9 +22,13 @@ class AudioService {
// BGM 플레이어 // BGM 플레이어
AudioPlayer? _bgmPlayer; AudioPlayer? _bgmPlayer;
// SFX 플레이어 풀 (동시 재생 지원) // SFX 채널 풀 (채널별 분리, 완료 보장)
final List<AudioPlayer> _sfxPlayers = []; SfxChannelPool? _playerSfxPool;
static const int _maxSfxPlayers = 5; SfxChannelPool? _monsterSfxPool;
// 채널별 풀 크기
static const int _playerPoolSize = 4;
static const int _monsterPoolSize = 3;
// 현재 볼륨 // 현재 볼륨
double _bgmVolume = 0.7; double _bgmVolume = 0.7;
@@ -56,12 +66,20 @@ class AudioService {
await _bgmPlayer!.setLoopMode(LoopMode.one); await _bgmPlayer!.setLoopMode(LoopMode.one);
await _bgmPlayer!.setVolume(_bgmVolume); await _bgmPlayer!.setVolume(_bgmVolume);
// SFX 플레이어 풀 초기화 // SFX 채널 풀 초기화 (채널별 분리)
for (var i = 0; i < _maxSfxPlayers; i++) { _playerSfxPool = SfxChannelPool(
final player = AudioPlayer(); name: 'Player',
await player.setVolume(_sfxVolume); poolSize: _playerPoolSize,
_sfxPlayers.add(player); volume: _sfxVolume,
} );
await _playerSfxPool!.init();
_monsterSfxPool = SfxChannelPool(
name: 'Monster',
poolSize: _monsterPoolSize,
volume: _sfxVolume,
);
await _monsterSfxPool!.init();
_initialized = true; _initialized = true;
@@ -143,10 +161,8 @@ class AudioService {
await _bgmPlayer?.stop(); await _bgmPlayer?.stop();
_currentBgm = null; _currentBgm = null;
// 모든 SFX 정지 // SFX 채널 풀은 자동 완료되므로 별도 정지 불필요
for (final player in _sfxPlayers) { // (새로운 재생 요청만 _isPaused로 차단)
await player.stop();
}
debugPrint('[AudioService] All audio paused'); debugPrint('[AudioService] All audio paused');
} }
@@ -159,50 +175,59 @@ class AudioService {
debugPrint('[AudioService] Audio resumed'); debugPrint('[AudioService] Audio resumed');
} }
/// SFX 재생 /// 플레이어 이펙트 SFX 재생
/// ///
/// [name]은 assets/audio/sfx/ 폴더 내 파일명 (확장자 제외) /// [name]은 assets/audio/sfx/ 폴더 내 파일명 (확장자 제외)
/// 예: playSfx('attack') → assets/audio/sfx/attack.mp3 /// 예: playPlayerSfx('attack') → assets/audio/sfx/attack.mp3
/// ///
/// 웹에서 대기 중인 BGM이 있으면 함께 재생 시작합니다. /// 대기열 기반으로 모든 사운드의 완전 재생을 보장합니다.
Future<void> playSfx(String name) async { Future<void> playPlayerSfx(String name) async {
if (_initFailed) return; // 초기화 실패 시 무시 if (_initFailed) return;
if (_isPaused) return; // 일시정지 상태면 무시 if (_isPaused) return;
if (!_initialized) await init(); if (!_initialized) await init();
if (_initFailed || !_initialized) return; if (_initFailed || !_initialized) return;
if (_sfxVolume == 0) return; // 볼륨이 0이면 재생 안함
if (_sfxPlayers.isEmpty) return;
// 웹에서 대기 중인 BGM 재생 시도 (사용자 상호작용 발생) // 웹에서 대기 중인 BGM 재생 시도
_tryPlayPendingBgm();
await _playerSfxPool?.play('assets/audio/sfx/$name.mp3');
}
/// 몬스터 이펙트 SFX 재생
///
/// [name]은 assets/audio/sfx/ 폴더 내 파일명 (확장자 제외)
/// 예: playMonsterSfx('hit') → assets/audio/sfx/hit.mp3
///
/// 대기열 기반으로 모든 사운드의 완전 재생을 보장합니다.
Future<void> 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) { if (!_userInteracted && _pendingBgm != null) {
_userInteracted = true; _userInteracted = true;
final pending = _pendingBgm; final pending = _pendingBgm;
_pendingBgm = null; _pendingBgm = null;
// BGM 재생 (비동기로 진행)
playBgm(pending!); playBgm(pending!);
} }
// 사용 가능한 플레이어 찾기
AudioPlayer? availablePlayer;
for (final player in _sfxPlayers) {
if (!player.playing) {
availablePlayer = player;
break;
}
} }
// 모든 플레이어가 사용 중이면 첫 번째 플레이어 재사용 /// SFX 재생 (레거시 호환)
availablePlayer ??= _sfxPlayers.first; ///
/// [name]은 assets/audio/sfx/ 폴더 내 파일명 (확장자 제외)
try { /// 예: playSfx('attack') → assets/audio/sfx/attack.mp3
await availablePlayer.setAsset('assets/audio/sfx/$name.mp3'); ///
await availablePlayer.seek(Duration.zero); /// @deprecated playPlayerSfx 또는 playMonsterSfx를 사용하세요.
await availablePlayer.play(); Future<void> playSfx(String name) => playPlayerSfx(name);
} catch (e) {
// 파일이 없으면 무시
debugPrint('[AudioService] Failed to play SFX $name: $e');
}
}
/// BGM 볼륨 설정 (0.0 ~ 1.0) /// BGM 볼륨 설정 (0.0 ~ 1.0)
Future<void> setBgmVolume(double volume) async { Future<void> setBgmVolume(double volume) async {
@@ -214,12 +239,13 @@ class AudioService {
} }
/// 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) { if (_initialized) {
for (final player in _sfxPlayers) { await _playerSfxPool?.setVolume(_sfxVolume);
await player.setVolume(_sfxVolume); await _monsterSfxPool?.setVolume(_sfxVolume);
}
} }
await _settingsRepository.saveSfxVolume(_sfxVolume); await _settingsRepository.saveSfxVolume(_sfxVolume);
} }
@@ -251,10 +277,8 @@ class AudioService {
/// 서비스 정리 /// 서비스 정리
Future<void> dispose() async { Future<void> dispose() async {
await _bgmPlayer?.dispose(); await _bgmPlayer?.dispose();
for (final player in _sfxPlayers) { await _playerSfxPool?.dispose();
await player.dispose(); await _monsterSfxPool?.dispose();
}
_sfxPlayers.clear();
_initialized = false; _initialized = false;
} }
} }

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);
}
}
}

View File

@@ -129,8 +129,8 @@ class _GamePlayScreenState extends State<GamePlayScreen>
'${game_l10n.uiLevelUp} Lv.${state.traits.level}', '${game_l10n.uiLevelUp} Lv.${state.traits.level}',
CombatLogType.levelUp, CombatLogType.levelUp,
); );
// 오디오: 레벨업 SFX // 오디오: 레벨업 SFX (플레이어 채널)
widget.audioService?.playSfx('level_up'); widget.audioService?.playPlayerSfx('level_up');
_resetSpecialAnimationAfterFrame(); _resetSpecialAnimationAfterFrame();
// Phase 9: Act 변경 감지 (레벨 기반) // Phase 9: Act 변경 감지 (레벨 기반)
@@ -168,8 +168,8 @@ class _GamePlayScreenState extends State<GamePlayScreen>
CombatLogType.questComplete, CombatLogType.questComplete,
); );
} }
// 오디오: 퀘스트 완료 SFX // 오디오: 퀘스트 완료 SFX (플레이어 채널)
widget.audioService?.playSfx('quest_complete'); widget.audioService?.playPlayerSfx('quest_complete');
_resetSpecialAnimationAfterFrame(); _resetSpecialAnimationAfterFrame();
} }
_lastQuestCount = state.progress.questCount; _lastQuestCount = state.progress.questCount;
@@ -283,26 +283,33 @@ class _GamePlayScreenState extends State<GamePlayScreen>
_wasInBattleTask = isInBattleTask; _wasInBattleTask = isInBattleTask;
} }
/// 전투 이벤트에 따른 SFX 재생 /// 전투 이벤트 SFX 재생 (채널 분리)
///
/// 플레이어 이펙트와 몬스터 이펙트를 별도 채널에서 재생하여
/// 사운드 충돌을 방지하고 완료를 보장합니다.
void _playCombatEventSfx(CombatEvent event) { void _playCombatEventSfx(CombatEvent event) {
final audio = widget.audioService; final audio = widget.audioService;
if (audio == null) return; if (audio == null) return;
switch (event.type) { switch (event.type) {
// 플레이어 채널: 플레이어가 발생시키는 이펙트
case CombatEventType.playerAttack: case CombatEventType.playerAttack:
audio.playSfx('attack'); audio.playPlayerSfx('attack');
case CombatEventType.monsterAttack:
audio.playSfx('hit');
case CombatEventType.playerSkill: case CombatEventType.playerSkill:
audio.playSfx('skill'); audio.playPlayerSfx('skill');
case CombatEventType.playerHeal: case CombatEventType.playerHeal:
case CombatEventType.playerPotion: case CombatEventType.playerPotion:
audio.playSfx('item');
case CombatEventType.potionDrop: case CombatEventType.potionDrop:
audio.playSfx('item'); audio.playPlayerSfx('item');
case CombatEventType.playerBuff: case CombatEventType.playerBuff:
case CombatEventType.playerDebuff: case CombatEventType.playerDebuff:
audio.playSfx('skill'); audio.playPlayerSfx('skill');
// 몬스터 채널: 몬스터가 발생시키는 이펙트 (플레이어 피격)
case CombatEventType.monsterAttack:
audio.playMonsterSfx('hit');
// SFX 없음
case CombatEventType.dotTick: case CombatEventType.dotTick:
// DOT 틱은 SFX 없음 (너무 자주 발생) // DOT 틱은 SFX 없음 (너무 자주 발생)
break; break;
@@ -685,6 +692,14 @@ class _GamePlayScreenState extends State<GamePlayScreen>
); );
} }
}, },
onBgmVolumeChange: (volume) {
setState(() => _bgmVolume = volume);
widget.audioService?.setBgmVolume(volume);
},
onSfxVolumeChange: (volume) {
setState(() => _sfxVolume = volume);
widget.audioService?.setSfxVolume(volume);
},
); );
} }

View File

@@ -13,6 +13,8 @@ class SettingsScreen extends StatefulWidget {
required this.currentThemeMode, required this.currentThemeMode,
required this.onThemeModeChange, required this.onThemeModeChange,
this.onLocaleChange, this.onLocaleChange,
this.onBgmVolumeChange,
this.onSfxVolumeChange,
}); });
final SettingsRepository settingsRepository; final SettingsRepository settingsRepository;
@@ -20,6 +22,12 @@ class SettingsScreen extends StatefulWidget {
final void Function(ThemeMode mode) onThemeModeChange; final void Function(ThemeMode mode) onThemeModeChange;
final void Function(String locale)? onLocaleChange; final void Function(String locale)? onLocaleChange;
/// BGM 볼륨 변경 콜백 (AudioService 연동용)
final void Function(double volume)? onBgmVolumeChange;
/// SFX 볼륨 변경 콜백 (AudioService 연동용)
final void Function(double volume)? onSfxVolumeChange;
@override @override
State<SettingsScreen> createState() => _SettingsScreenState(); State<SettingsScreen> createState() => _SettingsScreenState();
@@ -30,6 +38,8 @@ class SettingsScreen extends StatefulWidget {
required ThemeMode currentThemeMode, required ThemeMode currentThemeMode,
required void Function(ThemeMode mode) onThemeModeChange, required void Function(ThemeMode mode) onThemeModeChange,
void Function(String locale)? onLocaleChange, void Function(String locale)? onLocaleChange,
void Function(double volume)? onBgmVolumeChange,
void Function(double volume)? onSfxVolumeChange,
}) { }) {
return showModalBottomSheet<void>( return showModalBottomSheet<void>(
context: context, context: context,
@@ -45,6 +55,8 @@ class SettingsScreen extends StatefulWidget {
currentThemeMode: currentThemeMode, currentThemeMode: currentThemeMode,
onThemeModeChange: onThemeModeChange, onThemeModeChange: onThemeModeChange,
onLocaleChange: onLocaleChange, onLocaleChange: onLocaleChange,
onBgmVolumeChange: onBgmVolumeChange,
onSfxVolumeChange: onSfxVolumeChange,
), ),
), ),
); );
@@ -147,6 +159,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
onChanged: (value) { onChanged: (value) {
setState(() => _bgmVolume = value); setState(() => _bgmVolume = value);
widget.settingsRepository.saveBgmVolume(value); widget.settingsRepository.saveBgmVolume(value);
widget.onBgmVolumeChange?.call(value);
}, },
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -157,6 +170,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
onChanged: (value) { onChanged: (value) {
setState(() => _sfxVolume = value); setState(() => _sfxVolume = value);
widget.settingsRepository.saveSfxVolume(value); widget.settingsRepository.saveSfxVolume(value);
widget.onSfxVolumeChange?.call(value);
}, },
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
@@ -241,7 +255,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
Icon( Icon(
icon, icon,
color: isSelected color: isSelected
? theme.colorScheme.primary ? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurface, : theme.colorScheme.onSurface,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -251,7 +265,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
fontSize: 12, fontSize: 12,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected color: isSelected
? theme.colorScheme.primary ? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onSurface, : theme.colorScheme.onSurface,
), ),
), ),