feat(audio): 오디오 시스템 추가

- just_audio 패키지 추가
- AudioService 구현 (BGM/SFX 재생)
- assets/audio/bgm/, assets/audio/sfx/ 에셋 추가
This commit is contained in:
JiWoong Sul
2025-12-30 14:22:21 +09:00
parent 162a09c54a
commit 7d19905c01
15 changed files with 315 additions and 0 deletions

View File

@@ -0,0 +1,228 @@
import 'package:just_audio/just_audio.dart';
import 'package:askiineverdie/src/core/storage/settings_repository.dart';
/// 게임 오디오 서비스
///
/// BGM과 SFX를 관리하며, 설정과 연동하여 볼륨을 조절합니다.
class AudioService {
AudioService({SettingsRepository? settingsRepository})
: _settingsRepository = settingsRepository ?? SettingsRepository();
final SettingsRepository _settingsRepository;
// BGM 플레이어
AudioPlayer? _bgmPlayer;
// SFX 플레이어 풀 (동시 재생 지원)
final List<AudioPlayer> _sfxPlayers = [];
static const int _maxSfxPlayers = 5;
// 현재 볼륨
double _bgmVolume = 0.7;
double _sfxVolume = 0.8;
// 현재 재생 중인 BGM
String? _currentBgm;
// 초기화 여부
bool _initialized = false;
/// 서비스 초기화
Future<void> init() async {
if (_initialized) return;
// 설정에서 볼륨 불러오기
_bgmVolume = await _settingsRepository.loadBgmVolume();
_sfxVolume = await _settingsRepository.loadSfxVolume();
// BGM 플레이어 초기화
_bgmPlayer = AudioPlayer();
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);
}
_initialized = true;
}
/// BGM 재생
///
/// [name]은 assets/audio/bgm/ 폴더 내 파일명 (확장자 제외)
/// 예: playBgm('battle') → assets/audio/bgm/battle.wav 또는 battle.mp3
Future<void> playBgm(String name) async {
if (!_initialized) await init();
if (_currentBgm == name) return; // 이미 재생 중
try {
_currentBgm = name;
// WAV 먼저 시도, 실패하면 MP3 시도
try {
await _bgmPlayer!.setAsset('assets/audio/bgm/$name.wav');
} catch (_) {
await _bgmPlayer!.setAsset('assets/audio/bgm/$name.mp3');
}
await _bgmPlayer!.play();
} catch (e) {
// 파일이 없으면 무시 (개발 중 에셋 미추가 상태)
_currentBgm = null;
}
}
/// BGM 정지
Future<void> stopBgm() async {
if (!_initialized) return;
await _bgmPlayer!.stop();
_currentBgm = null;
}
/// BGM 일시정지
Future<void> pauseBgm() async {
if (!_initialized) return;
await _bgmPlayer!.pause();
}
/// BGM 재개
Future<void> resumeBgm() async {
if (!_initialized) return;
if (_currentBgm != null) {
await _bgmPlayer!.play();
}
}
/// SFX 재생
///
/// [name]은 assets/audio/sfx/ 폴더 내 파일명 (확장자 제외)
/// 예: playSfx('attack') → assets/audio/sfx/attack.wav 또는 attack.mp3
Future<void> playSfx(String name) async {
if (!_initialized) await init();
if (_sfxVolume == 0) return; // 볼륨이 0이면 재생 안함
// 사용 가능한 플레이어 찾기
AudioPlayer? availablePlayer;
for (final player in _sfxPlayers) {
if (!player.playing) {
availablePlayer = player;
break;
}
}
// 모든 플레이어가 사용 중이면 첫 번째 플레이어 재사용
availablePlayer ??= _sfxPlayers.first;
try {
// WAV 먼저 시도, 실패하면 MP3 시도
try {
await availablePlayer.setAsset('assets/audio/sfx/$name.wav');
} catch (_) {
await availablePlayer.setAsset('assets/audio/sfx/$name.mp3');
}
await availablePlayer.seek(Duration.zero);
await availablePlayer.play();
} catch (e) {
// 파일이 없으면 무시
}
}
/// BGM 볼륨 설정 (0.0 ~ 1.0)
Future<void> 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)
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 _settingsRepository.saveSfxVolume(_sfxVolume);
}
/// 현재 BGM 볼륨
double get bgmVolume => _bgmVolume;
/// 현재 SFX 볼륨
double get sfxVolume => _sfxVolume;
/// 현재 재생 중인 BGM
String? get currentBgm => _currentBgm;
/// 서비스 정리
Future<void> dispose() async {
await _bgmPlayer?.dispose();
for (final player in _sfxPlayers) {
await player.dispose();
}
_sfxPlayers.clear();
_initialized = false;
}
}
/// BGM 타입 열거형
enum BgmType {
/// 마을/상점 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',
};
}