Compare commits
4 Commits
20421dafd7
...
ff24f2bb55
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff24f2bb55 | ||
|
|
02a59fb443 | ||
|
|
f13783a35b | ||
|
|
33b7cd3b16 |
@@ -229,7 +229,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
||||
),
|
||||
titleSmall: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 8,
|
||||
fontSize: 10,
|
||||
color: Color(0xFF1F1F28),
|
||||
),
|
||||
bodyLarge: TextStyle(fontSize: 14, color: Color(0xFF1F1F28)),
|
||||
@@ -237,17 +237,17 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
||||
bodySmall: TextStyle(fontSize: 10, color: Color(0xFF1F1F28)),
|
||||
labelLarge: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 10,
|
||||
fontSize: 11,
|
||||
color: Color(0xFF1F1F28),
|
||||
),
|
||||
labelMedium: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 8,
|
||||
fontSize: 10,
|
||||
color: Color(0xFF1F1F28),
|
||||
),
|
||||
labelSmall: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
fontSize: 8,
|
||||
color: Color(0xFF1F1F28),
|
||||
),
|
||||
),
|
||||
@@ -256,7 +256,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
||||
backgroundColor: Color(0xFFE8DDD0),
|
||||
labelStyle: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 8,
|
||||
fontSize: 10,
|
||||
color: Color(0xFF1F1F28),
|
||||
),
|
||||
side: BorderSide(color: Color(0xFF8B7355)),
|
||||
@@ -363,7 +363,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
||||
),
|
||||
titleSmall: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 8,
|
||||
fontSize: 10,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
bodyLarge: TextStyle(fontSize: 14, color: Color(0xFFC0CAF5)),
|
||||
@@ -371,17 +371,17 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
||||
bodySmall: TextStyle(fontSize: 10, color: Color(0xFFC0CAF5)),
|
||||
labelLarge: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 10,
|
||||
fontSize: 11,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
labelMedium: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 8,
|
||||
fontSize: 10,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
labelSmall: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
fontSize: 8,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
),
|
||||
@@ -390,7 +390,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
||||
backgroundColor: Color(0xFF2A2E3F),
|
||||
labelStyle: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 8,
|
||||
fontSize: 10,
|
||||
color: Color(0xFFC0CAF5),
|
||||
),
|
||||
side: BorderSide(color: Color(0xFF545C7E)),
|
||||
|
||||
@@ -1,10 +1,49 @@
|
||||
import 'dart:async' show Completer, unawaited;
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint, kIsWeb;
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
|
||||
import 'package:asciineverdie/src/core/audio/sfx_channel_pool.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를 관리하며, 설정과 연동하여 볼륨을 조절합니다.
|
||||
/// 웹/WASM 환경에서는 제한적으로 작동할 수 있습니다.
|
||||
@@ -13,22 +52,59 @@ import 'package:asciineverdie/src/core/storage/settings_repository.dart';
|
||||
/// - BGM: 단일 플레이어 (루프 재생)
|
||||
/// - Player SFX: 플레이어 이펙트 (공격, 스킬, 아이템 등)
|
||||
/// - Monster SFX: 몬스터 이펙트 (몬스터 공격 = 플레이어 피격)
|
||||
///
|
||||
/// 모든 플레이어 인스턴스를 static으로 관리하여 핫 리로드에서도 안전합니다.
|
||||
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;
|
||||
|
||||
// BGM 플레이어
|
||||
AudioPlayer? _bgmPlayer;
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// static 플레이어 관리 (핫 리로드에서 유지)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// static BGM 플레이어 (핫 리로드에서 유지)
|
||||
static AudioPlayer? _staticBgmPlayer;
|
||||
|
||||
/// static 초기화 완료 여부
|
||||
static bool _staticInitialized = false;
|
||||
|
||||
/// static 초기화 진행 중 Completer (중복 초기화 방지)
|
||||
static Completer<void>? _staticInitCompleter;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 인스턴스 변수
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// SFX 채널 풀 (채널별 분리, 완료 보장)
|
||||
SfxChannelPool? _playerSfxPool;
|
||||
SfxChannelPool? _monsterSfxPool;
|
||||
|
||||
// 채널별 풀 크기
|
||||
static const int _playerPoolSize = 4;
|
||||
static const int _monsterPoolSize = 3;
|
||||
// 채널별 풀 크기 (줄임: 동시 재생 문제 완화)
|
||||
static const int _playerPoolSize = 2;
|
||||
static const int _monsterPoolSize = 2;
|
||||
|
||||
// 현재 볼륨
|
||||
double _bgmVolume = 0.7;
|
||||
@@ -37,12 +113,6 @@ class AudioService {
|
||||
// 현재 재생 중인 BGM
|
||||
String? _currentBgm;
|
||||
|
||||
// 초기화 여부
|
||||
bool _initialized = false;
|
||||
|
||||
// 초기화 실패 여부 (WASM 등에서 오디오 지원 안됨)
|
||||
bool _initFailed = false;
|
||||
|
||||
// 웹에서 사용자 상호작용 대기 중인 BGM (자동재생 정책 대응)
|
||||
String? _pendingBgm;
|
||||
|
||||
@@ -52,166 +122,294 @@ class AudioService {
|
||||
// 오디오 일시정지 상태 (앱 백그라운드 시)
|
||||
bool _isPaused = false;
|
||||
|
||||
// BGM 작업 직렬화 뮤텍스 (동시 호출 방지)
|
||||
final _bgmMutex = _BgmMutex();
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 초기화
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 초기화 완료 여부
|
||||
bool get isInitialized => _staticInitialized;
|
||||
|
||||
/// 서비스 초기화
|
||||
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 {
|
||||
// 설정에서 볼륨 불러오기
|
||||
_bgmVolume = await _settingsRepository.loadBgmVolume();
|
||||
_sfxVolume = await _settingsRepository.loadSfxVolume();
|
||||
|
||||
// BGM 플레이어 초기화
|
||||
_bgmPlayer = AudioPlayer();
|
||||
await _bgmPlayer!.setLoopMode(LoopMode.one);
|
||||
await _bgmPlayer!.setVolume(_bgmVolume);
|
||||
// BGM 플레이어 초기화 (순차적, 지연 포함)
|
||||
await _initBgmPlayer();
|
||||
|
||||
// SFX 채널 풀 초기화 (채널별 분리)
|
||||
_playerSfxPool = SfxChannelPool(
|
||||
name: 'Player',
|
||||
poolSize: _playerPoolSize,
|
||||
volume: _sfxVolume,
|
||||
);
|
||||
await _playerSfxPool!.init();
|
||||
// 지연 후 SFX 풀 초기화 (순차적)
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
await _initSfxPools();
|
||||
|
||||
_monsterSfxPool = SfxChannelPool(
|
||||
name: 'Monster',
|
||||
poolSize: _monsterPoolSize,
|
||||
volume: _sfxVolume,
|
||||
);
|
||||
await _monsterSfxPool!.init();
|
||||
|
||||
_initialized = true;
|
||||
_staticInitialized = true;
|
||||
|
||||
// 모바일/데스크톱에서는 자동재생 제한 없음
|
||||
if (!kIsWeb) {
|
||||
_userInteracted = true;
|
||||
} else {
|
||||
debugPrint('[AudioService] Initialized on Web platform');
|
||||
}
|
||||
|
||||
debugPrint('[AudioService] Initialized successfully');
|
||||
completer.complete();
|
||||
} catch (e) {
|
||||
_initFailed = true;
|
||||
debugPrint('[AudioService] Init failed (likely WASM): $e');
|
||||
debugPrint('[AudioService] Init error: $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 {
|
||||
if (_initFailed) return; // 초기화 실패 시 무시
|
||||
if (_isPaused) return; // 일시정지 상태면 무시
|
||||
if (!_initialized) await init();
|
||||
if (_initFailed || !_initialized) return;
|
||||
if (_currentBgm == name) return; // 이미 재생 중
|
||||
if (_isPaused) return;
|
||||
if (!_staticInitialized) await init();
|
||||
if (_currentBgm == name) return;
|
||||
if (_staticBgmPlayer == null) return;
|
||||
|
||||
// 뮤텍스 획득 시도 (실패하면 대기열에 추가)
|
||||
if (!await _bgmMutex.tryAcquire(name)) {
|
||||
debugPrint('[AudioService] BGM $name queued (mutex locked)');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await _bgmPlayer!.setAsset('audio/bgm/$name.mp3');
|
||||
await _bgmPlayer!.play();
|
||||
await _playBgmInternal(name);
|
||||
} 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;
|
||||
_pendingBgm = null;
|
||||
_userInteracted = true; // 재생 성공 → 상호작용 확인됨
|
||||
_userInteracted = true;
|
||||
debugPrint('[AudioService] Playing BGM: $name');
|
||||
} on PlayerInterruptedException catch (e) {
|
||||
debugPrint('[AudioService] BGM $name interrupted: ${e.message}');
|
||||
} catch (e) {
|
||||
// 웹 자동재생 정책으로 실패 시 대기 상태로 저장
|
||||
if (kIsWeb && e.toString().contains('NotAllowedError')) {
|
||||
final errorStr = e.toString();
|
||||
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;
|
||||
debugPrint('[AudioService] BGM $name pending (waiting for user interaction)');
|
||||
debugPrint('[AudioService] BGM $name pending (autoplay blocked)');
|
||||
} else {
|
||||
debugPrint('[AudioService] Failed to play BGM $name: $e');
|
||||
debugPrint('[AudioService] BGM play failed: $e');
|
||||
}
|
||||
_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 정지
|
||||
Future<void> stopBgm() async {
|
||||
if (!_initialized) return;
|
||||
|
||||
await _bgmPlayer!.stop();
|
||||
if (_staticBgmPlayer == null) return;
|
||||
try {
|
||||
await _staticBgmPlayer!.stop();
|
||||
} catch (_) {}
|
||||
_currentBgm = null;
|
||||
}
|
||||
|
||||
/// BGM 일시정지
|
||||
Future<void> pauseBgm() async {
|
||||
if (!_initialized) return;
|
||||
await _bgmPlayer!.pause();
|
||||
if (_staticBgmPlayer == null) return;
|
||||
try {
|
||||
await _staticBgmPlayer!.pause();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// BGM 재개
|
||||
Future<void> resumeBgm() async {
|
||||
if (!_initialized) return;
|
||||
if (_currentBgm != null) {
|
||||
await _bgmPlayer!.play();
|
||||
}
|
||||
if (_staticBgmPlayer == null || _currentBgm == null) return;
|
||||
try {
|
||||
await _staticBgmPlayer!.play();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 전체 오디오 제어
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 전체 오디오 일시정지 (앱 백그라운드 시)
|
||||
///
|
||||
/// BGM을 정지하고, 새로운 재생 요청을 무시합니다.
|
||||
Future<void> pauseAll() async {
|
||||
_isPaused = true;
|
||||
if (!_initialized) return;
|
||||
|
||||
// BGM 정지 및 상태 초기화
|
||||
await _bgmPlayer?.stop();
|
||||
try {
|
||||
await _staticBgmPlayer?.stop();
|
||||
} catch (_) {}
|
||||
_currentBgm = null;
|
||||
|
||||
// SFX 채널 풀은 자동 완료되므로 별도 정지 불필요
|
||||
// (새로운 재생 요청만 _isPaused로 차단)
|
||||
|
||||
debugPrint('[AudioService] All audio paused');
|
||||
}
|
||||
|
||||
/// 전체 오디오 재개 (앱 포그라운드 복귀 시)
|
||||
///
|
||||
/// 일시정지 상태를 해제하고 이전 BGM을 재개합니다.
|
||||
Future<void> resumeAll() async {
|
||||
_isPaused = false;
|
||||
debugPrint('[AudioService] Audio resumed');
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// SFX 재생
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 플레이어 이펙트 SFX 재생
|
||||
///
|
||||
/// [name]은 audio/sfx/ 폴더 내 파일명 (확장자 제외)
|
||||
/// 예: playPlayerSfx('attack') → audio/sfx/attack.mp3
|
||||
///
|
||||
/// 대기열 기반으로 모든 사운드의 완전 재생을 보장합니다.
|
||||
Future<void> playPlayerSfx(String name) async {
|
||||
if (_initFailed) return;
|
||||
if (_isPaused) return;
|
||||
if (!_initialized) await init();
|
||||
if (_initFailed || !_initialized) return;
|
||||
|
||||
// 웹에서 대기 중인 BGM 재생 시도
|
||||
if (!_staticInitialized) await init();
|
||||
_tryPlayPendingBgm();
|
||||
|
||||
await _playerSfxPool?.play('audio/sfx/$name.mp3');
|
||||
await _playerSfxPool?.play('assets/audio/sfx/$name.mp3');
|
||||
}
|
||||
|
||||
/// 몬스터 이펙트 SFX 재생
|
||||
///
|
||||
/// [name]은 audio/sfx/ 폴더 내 파일명 (확장자 제외)
|
||||
/// 예: playMonsterSfx('hit') → 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 재생 시도
|
||||
if (!_staticInitialized) await init();
|
||||
_tryPlayPendingBgm();
|
||||
|
||||
await _monsterSfxPool?.play('audio/sfx/$name.mp3');
|
||||
await _monsterSfxPool?.play('assets/audio/sfx/$name.mp3');
|
||||
}
|
||||
|
||||
/// 웹에서 대기 중인 BGM 재생 시도 (사용자 상호작용 발생 시)
|
||||
/// 웹에서 대기 중인 BGM 재생 시도
|
||||
void _tryPlayPendingBgm() {
|
||||
if (!_userInteracted && _pendingBgm != null) {
|
||||
_userInteracted = true;
|
||||
@@ -222,31 +420,29 @@ class AudioService {
|
||||
}
|
||||
|
||||
/// SFX 재생 (레거시 호환)
|
||||
///
|
||||
/// [name]은 audio/sfx/ 폴더 내 파일명 (확장자 제외)
|
||||
/// 예: playSfx('attack') → audio/sfx/attack.mp3
|
||||
///
|
||||
/// @deprecated playPlayerSfx 또는 playMonsterSfx를 사용하세요.
|
||||
@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);
|
||||
if (_initialized && _bgmPlayer != null) {
|
||||
await _bgmPlayer!.setVolume(_bgmVolume);
|
||||
if (_staticBgmPlayer != null) {
|
||||
try {
|
||||
await _staticBgmPlayer!.setVolume(_bgmVolume);
|
||||
} catch (_) {}
|
||||
}
|
||||
await _settingsRepository.saveBgmVolume(_bgmVolume);
|
||||
}
|
||||
|
||||
/// SFX 볼륨 설정 (0.0 ~ 1.0)
|
||||
///
|
||||
/// 모든 SFX 채널 (플레이어, 몬스터)에 동시 적용됩니다.
|
||||
Future<void> setSfxVolume(double volume) async {
|
||||
_sfxVolume = volume.clamp(0.0, 1.0);
|
||||
if (_initialized) {
|
||||
await _playerSfxPool?.setVolume(_sfxVolume);
|
||||
await _monsterSfxPool?.setVolume(_sfxVolume);
|
||||
}
|
||||
await _playerSfxPool?.setVolume(_sfxVolume);
|
||||
await _monsterSfxPool?.setVolume(_sfxVolume);
|
||||
await _settingsRepository.saveSfxVolume(_sfxVolume);
|
||||
}
|
||||
|
||||
@@ -259,14 +455,14 @@ class AudioService {
|
||||
/// 현재 재생 중인 BGM
|
||||
String? get currentBgm => _currentBgm;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// 유틸리티
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 사용자 상호작용 발생 알림 (웹 자동재생 정책 우회)
|
||||
///
|
||||
/// 버튼 클릭 등 사용자 상호작용 시 호출하면
|
||||
/// 대기 중인 BGM이 재생됩니다.
|
||||
Future<void> notifyUserInteraction() async {
|
||||
if (_userInteracted) return;
|
||||
_userInteracted = true;
|
||||
|
||||
if (_pendingBgm != null) {
|
||||
final pending = _pendingBgm;
|
||||
_pendingBgm = null;
|
||||
@@ -276,61 +472,48 @@ class AudioService {
|
||||
|
||||
/// 서비스 정리
|
||||
Future<void> dispose() async {
|
||||
await _bgmPlayer?.dispose();
|
||||
try {
|
||||
await _staticBgmPlayer?.dispose();
|
||||
} catch (_) {}
|
||||
_staticBgmPlayer = null;
|
||||
await _playerSfxPool?.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 타입 열거형
|
||||
enum BgmType {
|
||||
/// 타이틀 화면 BGM
|
||||
title,
|
||||
|
||||
/// 마을/상점 BGM
|
||||
town,
|
||||
|
||||
/// 일반 전투 BGM
|
||||
battle,
|
||||
|
||||
/// 보스 전투 BGM
|
||||
boss,
|
||||
|
||||
/// 레벨업/퀘스트 완료 팡파레
|
||||
victory,
|
||||
}
|
||||
|
||||
/// SFX 타입 열거형
|
||||
enum SfxType {
|
||||
/// 공격
|
||||
attack,
|
||||
|
||||
/// 피격
|
||||
hit,
|
||||
|
||||
/// 스킬 사용
|
||||
skill,
|
||||
|
||||
/// 아이템 획득
|
||||
item,
|
||||
|
||||
/// UI 클릭
|
||||
click,
|
||||
|
||||
/// 레벨업
|
||||
levelUp,
|
||||
|
||||
/// 퀘스트 완료
|
||||
questComplete,
|
||||
|
||||
/// 회피 (Phase 11)
|
||||
evade,
|
||||
|
||||
/// 방패 방어 (Phase 11)
|
||||
block,
|
||||
|
||||
/// 무기 쳐내기 (Phase 11)
|
||||
parry,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:flutter/foundation.dart' show debugPrint;
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
|
||||
/// SFX 채널 풀 - 사운드 완료 보장
|
||||
/// SFX 채널 풀 - 사운드 완료 보장 (핫 리로드 안전)
|
||||
///
|
||||
/// 대기열 기반으로 모든 사운드의 완전 재생을 보장합니다.
|
||||
/// 사용 가능한 플레이어가 없으면 대기열에 추가하고,
|
||||
/// 재생 완료 시 대기열의 다음 사운드를 자동 재생합니다.
|
||||
///
|
||||
/// 플레이어 인스턴스를 static으로 관리하여 핫 리로드에서도 안전합니다.
|
||||
class SfxChannelPool {
|
||||
SfxChannelPool({
|
||||
required this.name,
|
||||
@@ -24,61 +27,143 @@ class SfxChannelPool {
|
||||
/// 채널 볼륨
|
||||
double _volume;
|
||||
|
||||
/// AudioPlayer 풀
|
||||
final List<AudioPlayer> _players = [];
|
||||
/// static 플레이어 저장소 (채널명 기반, 핫 리로드에서 유지)
|
||||
static final Map<String, List<AudioPlayer>> _staticPlayers = {};
|
||||
|
||||
/// 각 플레이어의 재생 중 여부 추적
|
||||
final List<bool> _playerBusy = [];
|
||||
/// static busy 상태 (채널명 기반)
|
||||
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();
|
||||
|
||||
/// 초기화 여부
|
||||
bool _initialized = false;
|
||||
|
||||
/// 초기화 실패 여부
|
||||
bool _initFailed = false;
|
||||
|
||||
/// 현재 볼륨
|
||||
double get volume => _volume;
|
||||
|
||||
/// 초기화 완료 여부
|
||||
bool get isInitialized => _staticInitialized[name] == true;
|
||||
|
||||
/// 사용 가능한 플레이어 수
|
||||
int get availablePlayerCount => _staticPlayers[name]?.length ?? 0;
|
||||
|
||||
/// 초기화
|
||||
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 {
|
||||
// 이 채널의 플레이어 리스트 초기화
|
||||
_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++) {
|
||||
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();
|
||||
await player.setVolume(_volume);
|
||||
|
||||
// 재생 완료 리스너 등록
|
||||
player.playerStateStream.listen((state) {
|
||||
if (state.processingState == ProcessingState.completed) {
|
||||
_onPlayerComplete(_players.indexOf(player));
|
||||
}
|
||||
});
|
||||
player.playerStateStream.listen(
|
||||
(state) {
|
||||
if (state.processingState == ProcessingState.completed) {
|
||||
_onPlayerComplete(_staticPlayers[name]!.indexOf(player));
|
||||
}
|
||||
},
|
||||
onError: (Object e) {
|
||||
debugPrint('[SfxChannelPool:$name] Stream error: $e');
|
||||
},
|
||||
);
|
||||
|
||||
_players.add(player);
|
||||
_playerBusy.add(false);
|
||||
return player;
|
||||
} 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 {
|
||||
if (_initFailed) return;
|
||||
if (!_initialized) await init();
|
||||
if (_initFailed || !_initialized) return;
|
||||
if (_volume == 0) return; // 볼륨이 0이면 재생 안함
|
||||
// 초기화 안됐으면 초기화 시도
|
||||
if (!isInitialized) {
|
||||
await init();
|
||||
}
|
||||
|
||||
// 플레이어가 없으면 무시
|
||||
final players = _staticPlayers[name];
|
||||
if (players == null || players.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 볼륨이 0이면 재생 안함
|
||||
if (_volume == 0) return;
|
||||
|
||||
// 사용 가능한 플레이어 찾기
|
||||
final availableIndex = _findAvailablePlayer();
|
||||
@@ -87,38 +172,51 @@ class SfxChannelPool {
|
||||
// 즉시 재생
|
||||
await _playOnPlayer(availableIndex, assetPath);
|
||||
} else {
|
||||
// 대기열에 추가
|
||||
_pendingQueue.add(assetPath);
|
||||
debugPrint('[SfxChannelPool:$name] Queued: $assetPath '
|
||||
'(queue size: ${_pendingQueue.length})');
|
||||
// 대기열에 추가 (최대 10개로 제한)
|
||||
if (_pendingQueue.length < 10) {
|
||||
_pendingQueue.add(assetPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 볼륨 설정 (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);
|
||||
|
||||
final players = _staticPlayers[name];
|
||||
if (players != null) {
|
||||
for (final player in players) {
|
||||
try {
|
||||
await player.setVolume(_volume);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 리소스 해제
|
||||
Future<void> dispose() async {
|
||||
for (final player in _players) {
|
||||
await player.dispose();
|
||||
final players = _staticPlayers[name];
|
||||
if (players != null) {
|
||||
for (final player in players) {
|
||||
try {
|
||||
await player.dispose();
|
||||
} catch (_) {}
|
||||
}
|
||||
players.clear();
|
||||
}
|
||||
_players.clear();
|
||||
_playerBusy.clear();
|
||||
|
||||
_staticBusy[name]?.clear();
|
||||
_pendingQueue.clear();
|
||||
_initialized = false;
|
||||
_staticInitialized[name] = false;
|
||||
}
|
||||
|
||||
/// 사용 가능한 플레이어 인덱스 반환 (-1: 없음)
|
||||
int _findAvailablePlayer() {
|
||||
for (var i = 0; i < _playerBusy.length; i++) {
|
||||
if (!_playerBusy[i]) {
|
||||
final busy = _staticBusy[name];
|
||||
if (busy == null) return -1;
|
||||
|
||||
for (var i = 0; i < busy.length; i++) {
|
||||
if (!busy[i]) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
@@ -127,32 +225,53 @@ class SfxChannelPool {
|
||||
|
||||
/// 특정 플레이어에서 사운드 재생
|
||||
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];
|
||||
_playerBusy[index] = true;
|
||||
if (players == null || busy == null) return;
|
||||
if (index < 0 || index >= players.length) return;
|
||||
|
||||
final player = players[index];
|
||||
busy[index] = true;
|
||||
|
||||
try {
|
||||
await player.stop();
|
||||
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');
|
||||
busy[index] = false;
|
||||
if (e.toString().contains('Unable to load asset')) {
|
||||
debugPrint('[SfxChannelPool:$name] Asset not found: $assetPath');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 플레이어 재생 완료 시 호출
|
||||
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) {
|
||||
final nextAsset = _pendingQueue.removeFirst();
|
||||
_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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,13 +543,6 @@ class ProgressService {
|
||||
questLevel,
|
||||
);
|
||||
|
||||
// 전투 스탯 생성
|
||||
final playerCombatStats = CombatStats.fromStats(
|
||||
stats: state.stats,
|
||||
equipment: state.equipment,
|
||||
level: level,
|
||||
);
|
||||
|
||||
// 전투용 몬스터 레벨 조정 (밸런스)
|
||||
// Act별 최소 레벨과 플레이어 레벨 중 큰 값을 기준으로 ±3 범위 제한
|
||||
final actMinLevel = ActMonsterLevel.forPlotStage(
|
||||
@@ -560,6 +553,14 @@ class ProgressService {
|
||||
.clamp(math.max(1, baseLevel - 3), baseLevel + 3)
|
||||
.toInt();
|
||||
|
||||
// 전투 스탯 생성 (Phase 12: 몬스터 레벨 기반 페널티 적용)
|
||||
final playerCombatStats = CombatStats.fromStats(
|
||||
stats: state.stats,
|
||||
equipment: state.equipment,
|
||||
level: level,
|
||||
monsterLevel: effectiveMonsterLevel,
|
||||
);
|
||||
|
||||
final monsterCombatStats = MonsterCombatStats.fromLevel(
|
||||
name: monsterResult.displayName,
|
||||
level: effectiveMonsterLevel,
|
||||
@@ -610,16 +611,17 @@ class ProgressService {
|
||||
) {
|
||||
final level = state.traits.level;
|
||||
|
||||
// 플레이어 전투 스탯 생성
|
||||
// Glitch God 생성 (레벨 100 최종 보스)
|
||||
final glitchGod = MonsterCombatStats.glitchGod();
|
||||
|
||||
// 플레이어 전투 스탯 생성 (Phase 12: 보스 레벨 기반 페널티 적용)
|
||||
final playerCombatStats = CombatStats.fromStats(
|
||||
stats: state.stats,
|
||||
equipment: state.equipment,
|
||||
level: level,
|
||||
monsterLevel: glitchGod.level,
|
||||
);
|
||||
|
||||
// Glitch God 생성 (레벨 100 최종 보스)
|
||||
final glitchGod = MonsterCombatStats.glitchGod();
|
||||
|
||||
// 전투 상태 초기화
|
||||
final combatState = CombatState.start(
|
||||
playerStats: playerCombatStats,
|
||||
|
||||
@@ -7,6 +7,51 @@ import 'package:asciineverdie/src/core/model/race_traits.dart';
|
||||
/// 기본 Stats와 Equipment를 기반으로 계산되는 전투 관련 수치.
|
||||
/// 불변(immutable) 객체로 설계되어 상태 변경 시 새 인스턴스 생성.
|
||||
class CombatStats {
|
||||
// ============================================================================
|
||||
// 레벨 페널티 상수 (Phase 12)
|
||||
// ============================================================================
|
||||
|
||||
/// 1레벨당 확률 감소율 (8%)
|
||||
static const double _levelPenaltyPerLevel = 0.08;
|
||||
|
||||
/// 최저 페널티 배율 (20%)
|
||||
static const double _minLevelMultiplier = 0.2;
|
||||
|
||||
// ============================================================================
|
||||
// 확률 캡 상수 (Phase 12)
|
||||
// ============================================================================
|
||||
|
||||
/// 크리티컬 확률 최대 (50%)
|
||||
static const double _maxCriRate = 0.5;
|
||||
|
||||
/// 회피율 최대 (40%)
|
||||
static const double _maxEvasion = 0.4;
|
||||
|
||||
/// 방패 방어율 최대 (50%)
|
||||
static const double _maxBlockRate = 0.5;
|
||||
|
||||
/// 무기 쳐내기 최대 (35%)
|
||||
static const double _maxParryRate = 0.35;
|
||||
|
||||
// ============================================================================
|
||||
// 레벨 페널티 함수 (Phase 12)
|
||||
// ============================================================================
|
||||
|
||||
/// 레벨 차이에 따른 확률 감소 배율 (플레이어 전용)
|
||||
///
|
||||
/// - levelDiff = monsterLevel - playerLevel (몬스터가 높으면 양수)
|
||||
/// - 0레벨 차이: 1.0 (100% 유지)
|
||||
/// - 10레벨 이상 차이: 0.2 (20% = 최저)
|
||||
/// - 상승 없음 (플레이어가 높아도 보너스 없음)
|
||||
static double _getLevelPenalty(int playerLevel, int monsterLevel) {
|
||||
final levelDiff = monsterLevel - playerLevel;
|
||||
if (levelDiff <= 0) return 1.0; // 플레이어가 높거나 같으면 페널티 없음
|
||||
|
||||
// 1레벨당 8%씩 감소 (100% → 92% → 84% → ... → 20%)
|
||||
final penalty = 1.0 - (levelDiff * _levelPenaltyPerLevel);
|
||||
return penalty.clamp(_minLevelMultiplier, 1.0);
|
||||
}
|
||||
|
||||
const CombatStats({
|
||||
// 기본 스탯 (Stats에서 복사)
|
||||
required this.str,
|
||||
@@ -204,13 +249,17 @@ class CombatStats {
|
||||
/// [level] 캐릭터 레벨 (스케일링용)
|
||||
/// [race] 종족 특성 (선택사항, Phase 5)
|
||||
/// [klass] 클래스 특성 (선택사항, Phase 5)
|
||||
/// [monsterLevel] 상대 몬스터 레벨 (레벨 페널티 계산용, Phase 12)
|
||||
factory CombatStats.fromStats({
|
||||
required Stats stats,
|
||||
required Equipment equipment,
|
||||
required int level,
|
||||
RaceTraits? race,
|
||||
ClassTraits? klass,
|
||||
int? monsterLevel,
|
||||
}) {
|
||||
// 레벨 페널티 계산 (Phase 12)
|
||||
final levelPenalty = _getLevelPenalty(level, monsterLevel ?? level);
|
||||
// 장비 총 스탯 가져오기
|
||||
final equipStats = equipment.totalStats;
|
||||
|
||||
@@ -362,9 +411,21 @@ class CombatStats {
|
||||
klass?.getPassiveValue(ClassPassiveType.criticalBonus) ?? 0.0;
|
||||
criRate += classCritBonus;
|
||||
|
||||
// 최종 클램핑
|
||||
criRate = criRate.clamp(0.05, 0.8);
|
||||
evasion = evasion.clamp(0.0, 0.6);
|
||||
// ========================================================================
|
||||
// 레벨 페널티 및 최종 클램핑 (Phase 12)
|
||||
// ========================================================================
|
||||
|
||||
// 레벨 페널티 적용 (크리/회피/블록/패리)
|
||||
criRate *= levelPenalty;
|
||||
evasion *= levelPenalty;
|
||||
var finalBlockRate = blockRate * levelPenalty;
|
||||
var finalParryRate = parryRate * levelPenalty;
|
||||
|
||||
// 최종 클램핑 (새 캡 적용)
|
||||
criRate = criRate.clamp(0.05, _maxCriRate);
|
||||
evasion = evasion.clamp(0.0, _maxEvasion);
|
||||
finalBlockRate = finalBlockRate.clamp(0.0, _maxBlockRate);
|
||||
finalParryRate = finalParryRate.clamp(0.0, _maxParryRate);
|
||||
|
||||
return CombatStats(
|
||||
str: effectiveStr,
|
||||
@@ -381,8 +442,8 @@ class CombatStats {
|
||||
criDamage: criDamage,
|
||||
evasion: evasion,
|
||||
accuracy: accuracy,
|
||||
blockRate: blockRate,
|
||||
parryRate: parryRate,
|
||||
blockRate: finalBlockRate,
|
||||
parryRate: finalParryRate,
|
||||
attackDelayMs: attackDelayMs,
|
||||
hpMax: totalHpMax,
|
||||
hpCurrent: stats.hp.clamp(0, totalHpMax),
|
||||
|
||||
@@ -262,7 +262,7 @@ class _SavedGameInfo extends StatelessWidget {
|
||||
'${preview.characterName} Lv.${preview.level} ${preview.actName}',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
fontSize: 9,
|
||||
color: RetroColors.textDisabled,
|
||||
),
|
||||
),
|
||||
@@ -285,7 +285,7 @@ class _CopyrightFooter extends StatelessWidget {
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
fontSize: 8,
|
||||
color: RetroColors.textDisabled,
|
||||
),
|
||||
),
|
||||
@@ -317,7 +317,7 @@ class _RetroTag extends StatelessWidget {
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
fontSize: 9,
|
||||
color: RetroColors.textLight,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1225,7 +1225,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
title.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
fontSize: 8,
|
||||
color: RetroColors.textDisabled,
|
||||
),
|
||||
),
|
||||
@@ -1303,7 +1303,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
t.$1.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
fontSize: 8,
|
||||
color: RetroColors.textDisabled,
|
||||
),
|
||||
),
|
||||
@@ -1313,7 +1313,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
t.$2,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
fontSize: 9,
|
||||
color: RetroColors.textLight,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -1337,7 +1337,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
L10n.of(context).noSpellsYet,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
fontSize: 9,
|
||||
color: RetroColors.textDisabled,
|
||||
),
|
||||
),
|
||||
@@ -1378,7 +1378,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
l10n.goldAmount(state.inventory.gold),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
fontSize: 9,
|
||||
color: RetroColors.gold,
|
||||
),
|
||||
),
|
||||
@@ -1401,7 +1401,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
l10n.gold.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
fontSize: 8,
|
||||
color: RetroColors.gold,
|
||||
),
|
||||
),
|
||||
@@ -1410,7 +1410,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
'${state.inventory.gold}',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
fontSize: 9,
|
||||
color: RetroColors.gold,
|
||||
),
|
||||
),
|
||||
@@ -1433,7 +1433,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
translatedName,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
fontSize: 8,
|
||||
color: RetroColors.textLight,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -1443,7 +1443,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
'${item.count}',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
fontSize: 8,
|
||||
color: RetroColors.cream,
|
||||
),
|
||||
),
|
||||
@@ -1464,7 +1464,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
l10n.prologue.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
fontSize: 9,
|
||||
color: RetroColors.textDisabled,
|
||||
),
|
||||
),
|
||||
@@ -1496,7 +1496,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
index == 0 ? l10n.prologue : l10n.actNumber(_toRoman(index)),
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
fontSize: 8,
|
||||
color: isCompleted
|
||||
? RetroColors.textDisabled
|
||||
: (isCurrent ? RetroColors.gold : RetroColors.textLight),
|
||||
@@ -1523,7 +1523,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
l10n.noActiveQuests.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
fontSize: 9,
|
||||
color: RetroColors.textDisabled,
|
||||
),
|
||||
),
|
||||
@@ -1563,7 +1563,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
||||
quest.caption,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
fontSize: 8,
|
||||
color: isCurrentQuest
|
||||
? RetroColors.gold
|
||||
: (quest.isComplete
|
||||
|
||||
@@ -49,7 +49,7 @@ class CarouselNavBar extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
height: 56,
|
||||
child: Row(
|
||||
children: CarouselPage.values.map((page) {
|
||||
final isSelected = page.index == currentPage;
|
||||
@@ -101,13 +101,13 @@ class _NavButton extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: color),
|
||||
Icon(icon, size: 20, color: color),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 5,
|
||||
fontSize: 8,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
color: color,
|
||||
),
|
||||
|
||||
@@ -258,7 +258,7 @@ class DeathOverlay extends StatelessWidget {
|
||||
causeText,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
fontSize: 9,
|
||||
color: hpColor,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
@@ -312,7 +312,7 @@ class DeathOverlay extends StatelessWidget {
|
||||
l10n.deathSacrificedToResurrect.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
fontSize: 8,
|
||||
color: muted,
|
||||
),
|
||||
),
|
||||
@@ -321,7 +321,7 @@ class DeathOverlay extends StatelessWidget {
|
||||
deathInfo.lostItemName!,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
fontSize: 9,
|
||||
color: hpColor,
|
||||
),
|
||||
),
|
||||
@@ -376,7 +376,7 @@ class DeathOverlay extends StatelessWidget {
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
fontSize: 8,
|
||||
color: muted,
|
||||
),
|
||||
),
|
||||
@@ -386,7 +386,7 @@ class DeathOverlay extends StatelessWidget {
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
fontSize: 9,
|
||||
color: valueColor,
|
||||
),
|
||||
),
|
||||
@@ -475,7 +475,7 @@ class DeathOverlay extends StatelessWidget {
|
||||
l10n.deathCombatLog.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
fontSize: 9,
|
||||
color: gold,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -302,7 +302,7 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: RetroColors.gold.withValues(alpha: blinkOpacity),
|
||||
),
|
||||
@@ -352,7 +352,7 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
|
||||
'$current/$max',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
fontSize: 8,
|
||||
color: RetroColors.textLight,
|
||||
),
|
||||
textAlign: TextAlign.right,
|
||||
@@ -444,7 +444,7 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
|
||||
'${(ratio * 100).toInt()}%',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
fontSize: 8,
|
||||
color: RetroColors.gold,
|
||||
),
|
||||
),
|
||||
@@ -466,7 +466,7 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
|
||||
: '$_monsterHpChange',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: _monsterHpChange < 0
|
||||
? RetroColors.gold
|
||||
|
||||
@@ -172,7 +172,7 @@ class _NotificationCard extends StatelessWidget {
|
||||
_getTypeLabel(notification.type),
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
fontSize: 9,
|
||||
color: accentColor,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
@@ -185,7 +185,7 @@ class _NotificationCard extends StatelessWidget {
|
||||
'[X]',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
fontSize: 9,
|
||||
color: textMuted,
|
||||
),
|
||||
),
|
||||
@@ -229,7 +229,7 @@ class _NotificationCard extends StatelessWidget {
|
||||
notification.subtitle!,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
fontSize: 9,
|
||||
color: textMuted,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -195,7 +195,7 @@ class _SkillRow extends StatelessWidget {
|
||||
),
|
||||
child: Text(
|
||||
l10n.uiDot,
|
||||
style: const TextStyle(fontSize: 7, color: Colors.white70),
|
||||
style: const TextStyle(fontSize: 9, color: Colors.white70),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
|
||||
@@ -533,7 +533,7 @@ class _VictoryOverlayState extends State<VictoryOverlay>
|
||||
theEnd,
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrainsMono',
|
||||
fontSize: 6,
|
||||
fontSize: 8,
|
||||
color: gold,
|
||||
height: 1.0,
|
||||
),
|
||||
|
||||
@@ -515,7 +515,7 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
||||
game_l10n.uiRollHistory(_rollHistory.length),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
fontSize: 8,
|
||||
color: RetroColors.textDisabled,
|
||||
),
|
||||
),
|
||||
@@ -540,7 +540,7 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
|
||||
label.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
fontSize: 8,
|
||||
color: RetroColors.gold,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -157,11 +157,11 @@ class RetroTabBar extends StatelessWidget {
|
||||
unselectedLabelColor: mutedColor,
|
||||
labelStyle: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
fontSize: 9,
|
||||
),
|
||||
unselectedLabelStyle: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
fontSize: 9,
|
||||
),
|
||||
dividerColor: Colors.transparent,
|
||||
tabs: tabs.map((t) => Tab(text: t.toUpperCase())).toList(),
|
||||
@@ -257,7 +257,7 @@ class RetroInfoBox extends StatelessWidget {
|
||||
content,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
fontSize: 9,
|
||||
color: textColor,
|
||||
height: 1.8,
|
||||
),
|
||||
@@ -297,7 +297,7 @@ class RetroStatRow extends StatelessWidget {
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
fontSize: 8,
|
||||
color: mutedColor,
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user