Compare commits
3 Commits
e69f8921e6
...
72676485d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72676485d3 | ||
|
|
764a8353fb | ||
|
|
43289ac848 |
@@ -1,12 +1,18 @@
|
||||
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();
|
||||
@@ -16,9 +22,13 @@ class AudioService {
|
||||
// BGM 플레이어
|
||||
AudioPlayer? _bgmPlayer;
|
||||
|
||||
// SFX 플레이어 풀 (동시 재생 지원)
|
||||
final List<AudioPlayer> _sfxPlayers = [];
|
||||
static const int _maxSfxPlayers = 5;
|
||||
// SFX 채널 풀 (채널별 분리, 완료 보장)
|
||||
SfxChannelPool? _playerSfxPool;
|
||||
SfxChannelPool? _monsterSfxPool;
|
||||
|
||||
// 채널별 풀 크기
|
||||
static const int _playerPoolSize = 4;
|
||||
static const int _monsterPoolSize = 3;
|
||||
|
||||
// 현재 볼륨
|
||||
double _bgmVolume = 0.7;
|
||||
@@ -56,12 +66,20 @@ class AudioService {
|
||||
await _bgmPlayer!.setLoopMode(LoopMode.one);
|
||||
await _bgmPlayer!.setVolume(_bgmVolume);
|
||||
|
||||
// SFX 플레이어 풀 초기화
|
||||
for (var i = 0; i < _maxSfxPlayers; i++) {
|
||||
final player = AudioPlayer();
|
||||
await player.setVolume(_sfxVolume);
|
||||
_sfxPlayers.add(player);
|
||||
}
|
||||
// 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;
|
||||
|
||||
@@ -143,10 +161,8 @@ class AudioService {
|
||||
await _bgmPlayer?.stop();
|
||||
_currentBgm = null;
|
||||
|
||||
// 모든 SFX 정지
|
||||
for (final player in _sfxPlayers) {
|
||||
await player.stop();
|
||||
}
|
||||
// SFX 채널 풀은 자동 완료되므로 별도 정지 불필요
|
||||
// (새로운 재생 요청만 _isPaused로 차단)
|
||||
|
||||
debugPrint('[AudioService] All audio paused');
|
||||
}
|
||||
@@ -159,51 +175,60 @@ class AudioService {
|
||||
debugPrint('[AudioService] Audio resumed');
|
||||
}
|
||||
|
||||
/// SFX 재생
|
||||
/// 플레이어 이펙트 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 {
|
||||
if (_initFailed) return; // 초기화 실패 시 무시
|
||||
if (_isPaused) return; // 일시정지 상태면 무시
|
||||
/// 대기열 기반으로 모든 사운드의 완전 재생을 보장합니다.
|
||||
Future<void> playPlayerSfx(String name) async {
|
||||
if (_initFailed) return;
|
||||
if (_isPaused) return;
|
||||
if (!_initialized) await init();
|
||||
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) {
|
||||
_userInteracted = true;
|
||||
final pending = _pendingBgm;
|
||||
_pendingBgm = null;
|
||||
// BGM 재생 (비동기로 진행)
|
||||
playBgm(pending!);
|
||||
}
|
||||
|
||||
// 사용 가능한 플레이어 찾기
|
||||
AudioPlayer? availablePlayer;
|
||||
for (final player in _sfxPlayers) {
|
||||
if (!player.playing) {
|
||||
availablePlayer = player;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 플레이어가 사용 중이면 첫 번째 플레이어 재사용
|
||||
availablePlayer ??= _sfxPlayers.first;
|
||||
|
||||
try {
|
||||
await availablePlayer.setAsset('assets/audio/sfx/$name.mp3');
|
||||
await availablePlayer.seek(Duration.zero);
|
||||
await availablePlayer.play();
|
||||
} catch (e) {
|
||||
// 파일이 없으면 무시
|
||||
debugPrint('[AudioService] Failed to play SFX $name: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// SFX 재생 (레거시 호환)
|
||||
///
|
||||
/// [name]은 assets/audio/sfx/ 폴더 내 파일명 (확장자 제외)
|
||||
/// 예: playSfx('attack') → assets/audio/sfx/attack.mp3
|
||||
///
|
||||
/// @deprecated playPlayerSfx 또는 playMonsterSfx를 사용하세요.
|
||||
Future<void> playSfx(String name) => playPlayerSfx(name);
|
||||
|
||||
/// BGM 볼륨 설정 (0.0 ~ 1.0)
|
||||
Future<void> setBgmVolume(double volume) async {
|
||||
_bgmVolume = volume.clamp(0.0, 1.0);
|
||||
@@ -214,12 +239,13 @@ class AudioService {
|
||||
}
|
||||
|
||||
/// SFX 볼륨 설정 (0.0 ~ 1.0)
|
||||
///
|
||||
/// 모든 SFX 채널 (플레이어, 몬스터)에 동시 적용됩니다.
|
||||
Future<void> setSfxVolume(double volume) async {
|
||||
_sfxVolume = volume.clamp(0.0, 1.0);
|
||||
if (_initialized) {
|
||||
for (final player in _sfxPlayers) {
|
||||
await player.setVolume(_sfxVolume);
|
||||
}
|
||||
await _playerSfxPool?.setVolume(_sfxVolume);
|
||||
await _monsterSfxPool?.setVolume(_sfxVolume);
|
||||
}
|
||||
await _settingsRepository.saveSfxVolume(_sfxVolume);
|
||||
}
|
||||
@@ -251,10 +277,8 @@ class AudioService {
|
||||
/// 서비스 정리
|
||||
Future<void> dispose() async {
|
||||
await _bgmPlayer?.dispose();
|
||||
for (final player in _sfxPlayers) {
|
||||
await player.dispose();
|
||||
}
|
||||
_sfxPlayers.clear();
|
||||
await _playerSfxPool?.dispose();
|
||||
await _monsterSfxPool?.dispose();
|
||||
_initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
158
lib/src/core/audio/sfx_channel_pool.dart
Normal file
158
lib/src/core/audio/sfx_channel_pool.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,8 +129,8 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
'${game_l10n.uiLevelUp} Lv.${state.traits.level}',
|
||||
CombatLogType.levelUp,
|
||||
);
|
||||
// 오디오: 레벨업 SFX
|
||||
widget.audioService?.playSfx('level_up');
|
||||
// 오디오: 레벨업 SFX (플레이어 채널)
|
||||
widget.audioService?.playPlayerSfx('level_up');
|
||||
_resetSpecialAnimationAfterFrame();
|
||||
|
||||
// Phase 9: Act 변경 감지 (레벨 기반)
|
||||
@@ -168,8 +168,8 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
CombatLogType.questComplete,
|
||||
);
|
||||
}
|
||||
// 오디오: 퀘스트 완료 SFX
|
||||
widget.audioService?.playSfx('quest_complete');
|
||||
// 오디오: 퀘스트 완료 SFX (플레이어 채널)
|
||||
widget.audioService?.playPlayerSfx('quest_complete');
|
||||
_resetSpecialAnimationAfterFrame();
|
||||
}
|
||||
_lastQuestCount = state.progress.questCount;
|
||||
@@ -283,26 +283,33 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
_wasInBattleTask = isInBattleTask;
|
||||
}
|
||||
|
||||
/// 전투 이벤트에 따른 SFX 재생
|
||||
/// 전투 이벤트별 SFX 재생 (채널 분리)
|
||||
///
|
||||
/// 플레이어 이펙트와 몬스터 이펙트를 별도 채널에서 재생하여
|
||||
/// 사운드 충돌을 방지하고 완료를 보장합니다.
|
||||
void _playCombatEventSfx(CombatEvent event) {
|
||||
final audio = widget.audioService;
|
||||
if (audio == null) return;
|
||||
|
||||
switch (event.type) {
|
||||
// 플레이어 채널: 플레이어가 발생시키는 이펙트
|
||||
case CombatEventType.playerAttack:
|
||||
audio.playSfx('attack');
|
||||
case CombatEventType.monsterAttack:
|
||||
audio.playSfx('hit');
|
||||
audio.playPlayerSfx('attack');
|
||||
case CombatEventType.playerSkill:
|
||||
audio.playSfx('skill');
|
||||
audio.playPlayerSfx('skill');
|
||||
case CombatEventType.playerHeal:
|
||||
case CombatEventType.playerPotion:
|
||||
audio.playSfx('item');
|
||||
case CombatEventType.potionDrop:
|
||||
audio.playSfx('item');
|
||||
audio.playPlayerSfx('item');
|
||||
case CombatEventType.playerBuff:
|
||||
case CombatEventType.playerDebuff:
|
||||
audio.playSfx('skill');
|
||||
audio.playPlayerSfx('skill');
|
||||
|
||||
// 몬스터 채널: 몬스터가 발생시키는 이펙트 (플레이어 피격)
|
||||
case CombatEventType.monsterAttack:
|
||||
audio.playMonsterSfx('hit');
|
||||
|
||||
// SFX 없음
|
||||
case CombatEventType.dotTick:
|
||||
// DOT 틱은 SFX 없음 (너무 자주 발생)
|
||||
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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ class SettingsScreen extends StatefulWidget {
|
||||
required this.currentThemeMode,
|
||||
required this.onThemeModeChange,
|
||||
this.onLocaleChange,
|
||||
this.onBgmVolumeChange,
|
||||
this.onSfxVolumeChange,
|
||||
});
|
||||
|
||||
final SettingsRepository settingsRepository;
|
||||
@@ -20,6 +22,12 @@ class SettingsScreen extends StatefulWidget {
|
||||
final void Function(ThemeMode mode) onThemeModeChange;
|
||||
final void Function(String locale)? onLocaleChange;
|
||||
|
||||
/// BGM 볼륨 변경 콜백 (AudioService 연동용)
|
||||
final void Function(double volume)? onBgmVolumeChange;
|
||||
|
||||
/// SFX 볼륨 변경 콜백 (AudioService 연동용)
|
||||
final void Function(double volume)? onSfxVolumeChange;
|
||||
|
||||
@override
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
|
||||
@@ -30,6 +38,8 @@ class SettingsScreen extends StatefulWidget {
|
||||
required ThemeMode currentThemeMode,
|
||||
required void Function(ThemeMode mode) onThemeModeChange,
|
||||
void Function(String locale)? onLocaleChange,
|
||||
void Function(double volume)? onBgmVolumeChange,
|
||||
void Function(double volume)? onSfxVolumeChange,
|
||||
}) {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@@ -45,6 +55,8 @@ class SettingsScreen extends StatefulWidget {
|
||||
currentThemeMode: currentThemeMode,
|
||||
onThemeModeChange: onThemeModeChange,
|
||||
onLocaleChange: onLocaleChange,
|
||||
onBgmVolumeChange: onBgmVolumeChange,
|
||||
onSfxVolumeChange: onSfxVolumeChange,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -147,6 +159,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
onChanged: (value) {
|
||||
setState(() => _bgmVolume = value);
|
||||
widget.settingsRepository.saveBgmVolume(value);
|
||||
widget.onBgmVolumeChange?.call(value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -157,6 +170,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
onChanged: (value) {
|
||||
setState(() => _sfxVolume = value);
|
||||
widget.settingsRepository.saveSfxVolume(value);
|
||||
widget.onSfxVolumeChange?.call(value);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
@@ -241,7 +255,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
Icon(
|
||||
icon,
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
? theme.colorScheme.onPrimaryContainer
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
@@ -251,7 +265,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
fontSize: 12,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
color: isSelected
|
||||
? theme.colorScheme.primary
|
||||
? theme.colorScheme.onPrimaryContainer
|
||||
: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user