Compare commits
4 Commits
8f011689fb
...
18af93824b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18af93824b | ||
|
|
d64b9654a3 | ||
|
|
80b6cd63e3 | ||
|
|
bdd3b45329 |
BIN
assets/audio/bgm/battle.mp3
Normal file
BIN
assets/audio/bgm/battle.mp3
Normal file
Binary file not shown.
Binary file not shown.
BIN
assets/audio/bgm/title.mp3
Normal file
BIN
assets/audio/bgm/title.mp3
Normal file
Binary file not shown.
BIN
assets/audio/sfx/attack.mp3
Normal file
BIN
assets/audio/sfx/attack.mp3
Normal file
Binary file not shown.
Binary file not shown.
BIN
assets/audio/sfx/click.mp3
Normal file
BIN
assets/audio/sfx/click.mp3
Normal file
Binary file not shown.
Binary file not shown.
BIN
assets/audio/sfx/hit.mp3
Normal file
BIN
assets/audio/sfx/hit.mp3
Normal file
Binary file not shown.
Binary file not shown.
BIN
assets/audio/sfx/item.mp3
Normal file
BIN
assets/audio/sfx/item.mp3
Normal file
Binary file not shown.
Binary file not shown.
BIN
assets/audio/sfx/level_up.mp3
Normal file
BIN
assets/audio/sfx/level_up.mp3
Normal file
Binary file not shown.
Binary file not shown.
BIN
assets/audio/sfx/quest_complete.mp3
Normal file
BIN
assets/audio/sfx/quest_complete.mp3
Normal file
Binary file not shown.
Binary file not shown.
BIN
assets/audio/sfx/skill.mp3
Normal file
BIN
assets/audio/sfx/skill.mp3
Normal file
Binary file not shown.
Binary file not shown.
@@ -435,6 +435,12 @@ String combatBuffActivated(String skillName) {
|
|||||||
return '$skillName activated!';
|
return '$skillName activated!';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String combatDebuffApplied(String skillName, String targetName) {
|
||||||
|
if (isKoreanLocale) return '$skillName → $targetName에 적용!';
|
||||||
|
if (isJapaneseLocale) return '$skillName → $targetNameに適用!';
|
||||||
|
return '$skillName applied to $targetName!';
|
||||||
|
}
|
||||||
|
|
||||||
String combatDotTick(String skillName, int damage) {
|
String combatDotTick(String skillName, int damage) {
|
||||||
if (isKoreanLocale) return '$skillName: $damage 지속 데미지';
|
if (isKoreanLocale) return '$skillName: $damage 지속 데미지';
|
||||||
if (isJapaneseLocale) return '$skillName: $damage 継続ダメージ';
|
if (isJapaneseLocale) return '$skillName: $damage 継続ダメージ';
|
||||||
@@ -1803,6 +1809,18 @@ String get uiSettings {
|
|||||||
return 'Settings';
|
return 'Settings';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String get uiStatistics {
|
||||||
|
if (isKoreanLocale) return '통계';
|
||||||
|
if (isJapaneseLocale) return '統計';
|
||||||
|
return 'Statistics';
|
||||||
|
}
|
||||||
|
|
||||||
|
String get uiHelp {
|
||||||
|
if (isKoreanLocale) return '도움말';
|
||||||
|
if (isJapaneseLocale) return 'ヘルプ';
|
||||||
|
return 'Help';
|
||||||
|
}
|
||||||
|
|
||||||
String get uiTheme {
|
String get uiTheme {
|
||||||
if (isKoreanLocale) return '테마';
|
if (isKoreanLocale) return '테마';
|
||||||
if (isJapaneseLocale) return 'テーマ';
|
if (isJapaneseLocale) return 'テーマ';
|
||||||
@@ -1851,6 +1869,12 @@ String get uiSfxVolume {
|
|||||||
return 'SFX Volume';
|
return 'SFX Volume';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String get uiSoundOff {
|
||||||
|
if (isKoreanLocale) return '음소거';
|
||||||
|
if (isJapaneseLocale) return 'ミュート';
|
||||||
|
return 'Muted';
|
||||||
|
}
|
||||||
|
|
||||||
String get uiAnimationSpeed {
|
String get uiAnimationSpeed {
|
||||||
if (isKoreanLocale) return '애니메이션 속도';
|
if (isKoreanLocale) return '애니메이션 속도';
|
||||||
if (isJapaneseLocale) return 'アニメーション速度';
|
if (isJapaneseLocale) return 'アニメーション速度';
|
||||||
|
|||||||
@@ -161,10 +161,12 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
|||||||
},
|
},
|
||||||
currentThemeMode: _themeMode,
|
currentThemeMode: _themeMode,
|
||||||
onThemeModeChange: _changeThemeMode,
|
onThemeModeChange: _changeThemeMode,
|
||||||
|
audioService: _audioService,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 세이브 파일이 없으면 기존 프론트 화면
|
// 세이브 파일이 없으면 기존 프론트 화면 (타이틀 BGM 재생)
|
||||||
|
_audioService.playBgm('title');
|
||||||
return FrontScreen(
|
return FrontScreen(
|
||||||
onNewCharacter: _navigateToNewCharacter,
|
onNewCharacter: _navigateToNewCharacter,
|
||||||
onLoadSave: _loadSave,
|
onLoadSave: _loadSave,
|
||||||
@@ -238,6 +240,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
|||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
builder: (context) => GamePlayScreen(
|
builder: (context) => GamePlayScreen(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
|
audioService: _audioService,
|
||||||
forceCarouselLayout: testMode,
|
forceCarouselLayout: testMode,
|
||||||
currentThemeMode: _themeMode,
|
currentThemeMode: _themeMode,
|
||||||
onThemeModeChange: _changeThemeMode,
|
onThemeModeChange: _changeThemeMode,
|
||||||
@@ -252,6 +255,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
|||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
builder: (context) => GamePlayScreen(
|
builder: (context) => GamePlayScreen(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
|
audioService: _audioService,
|
||||||
currentThemeMode: _themeMode,
|
currentThemeMode: _themeMode,
|
||||||
onThemeModeChange: _changeThemeMode,
|
onThemeModeChange: _changeThemeMode,
|
||||||
),
|
),
|
||||||
@@ -298,12 +302,14 @@ class _AutoLoadScreen extends StatefulWidget {
|
|||||||
required this.onLoadFailed,
|
required this.onLoadFailed,
|
||||||
required this.currentThemeMode,
|
required this.currentThemeMode,
|
||||||
required this.onThemeModeChange,
|
required this.onThemeModeChange,
|
||||||
|
this.audioService,
|
||||||
});
|
});
|
||||||
|
|
||||||
final GameSessionController controller;
|
final GameSessionController controller;
|
||||||
final VoidCallback onLoadFailed;
|
final VoidCallback onLoadFailed;
|
||||||
final ThemeMode currentThemeMode;
|
final ThemeMode currentThemeMode;
|
||||||
final void Function(ThemeMode mode) onThemeModeChange;
|
final void Function(ThemeMode mode) onThemeModeChange;
|
||||||
|
final AudioService? audioService;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<_AutoLoadScreen> createState() => _AutoLoadScreenState();
|
State<_AutoLoadScreen> createState() => _AutoLoadScreenState();
|
||||||
@@ -313,6 +319,8 @@ class _AutoLoadScreenState extends State<_AutoLoadScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
// 로딩 중에도 타이틀 BGM 재생
|
||||||
|
widget.audioService?.playBgm('title');
|
||||||
_autoLoad();
|
_autoLoad();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,6 +335,7 @@ class _AutoLoadScreenState extends State<_AutoLoadScreen> {
|
|||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
builder: (context) => GamePlayScreen(
|
builder: (context) => GamePlayScreen(
|
||||||
controller: widget.controller,
|
controller: widget.controller,
|
||||||
|
audioService: widget.audioService,
|
||||||
currentThemeMode: widget.currentThemeMode,
|
currentThemeMode: widget.currentThemeMode,
|
||||||
onThemeModeChange: widget.onThemeModeChange,
|
onThemeModeChange: widget.onThemeModeChange,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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/storage/settings_repository.dart';
|
import 'package:askiineverdie/src/core/storage/settings_repository.dart';
|
||||||
@@ -5,6 +6,7 @@ import 'package:askiineverdie/src/core/storage/settings_repository.dart';
|
|||||||
/// 게임 오디오 서비스
|
/// 게임 오디오 서비스
|
||||||
///
|
///
|
||||||
/// BGM과 SFX를 관리하며, 설정과 연동하여 볼륨을 조절합니다.
|
/// BGM과 SFX를 관리하며, 설정과 연동하여 볼륨을 조절합니다.
|
||||||
|
/// 웹/WASM 환경에서는 제한적으로 작동할 수 있습니다.
|
||||||
class AudioService {
|
class AudioService {
|
||||||
AudioService({SettingsRepository? settingsRepository})
|
AudioService({SettingsRepository? settingsRepository})
|
||||||
: _settingsRepository = settingsRepository ?? SettingsRepository();
|
: _settingsRepository = settingsRepository ?? SettingsRepository();
|
||||||
@@ -28,10 +30,14 @@ class AudioService {
|
|||||||
// 초기화 여부
|
// 초기화 여부
|
||||||
bool _initialized = false;
|
bool _initialized = false;
|
||||||
|
|
||||||
|
// 초기화 실패 여부 (WASM 등에서 오디오 지원 안됨)
|
||||||
|
bool _initFailed = false;
|
||||||
|
|
||||||
/// 서비스 초기화
|
/// 서비스 초기화
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
if (_initialized) return;
|
if (_initialized || _initFailed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
// 설정에서 볼륨 불러오기
|
// 설정에서 볼륨 불러오기
|
||||||
_bgmVolume = await _settingsRepository.loadBgmVolume();
|
_bgmVolume = await _settingsRepository.loadBgmVolume();
|
||||||
_sfxVolume = await _settingsRepository.loadSfxVolume();
|
_sfxVolume = await _settingsRepository.loadSfxVolume();
|
||||||
@@ -49,27 +55,33 @@ class AudioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
|
if (kIsWeb) {
|
||||||
|
debugPrint('[AudioService] Initialized on Web platform');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_initFailed = true;
|
||||||
|
debugPrint('[AudioService] Init failed (likely WASM): $e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// BGM 재생
|
/// BGM 재생
|
||||||
///
|
///
|
||||||
/// [name]은 assets/audio/bgm/ 폴더 내 파일명 (확장자 제외)
|
/// [name]은 assets/audio/bgm/ 폴더 내 파일명 (확장자 제외)
|
||||||
/// 예: playBgm('battle') → assets/audio/bgm/battle.wav 또는 battle.mp3
|
/// 예: playBgm('battle') → assets/audio/bgm/battle.mp3
|
||||||
Future<void> playBgm(String name) async {
|
Future<void> playBgm(String name) async {
|
||||||
|
if (_initFailed) return; // 초기화 실패 시 무시
|
||||||
if (!_initialized) await init();
|
if (!_initialized) await init();
|
||||||
|
if (_initFailed || !_initialized) return;
|
||||||
if (_currentBgm == name) return; // 이미 재생 중
|
if (_currentBgm == name) return; // 이미 재생 중
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_currentBgm = name;
|
_currentBgm = name;
|
||||||
// WAV 먼저 시도, 실패하면 MP3 시도
|
|
||||||
try {
|
|
||||||
await _bgmPlayer!.setAsset('assets/audio/bgm/$name.wav');
|
|
||||||
} catch (_) {
|
|
||||||
await _bgmPlayer!.setAsset('assets/audio/bgm/$name.mp3');
|
await _bgmPlayer!.setAsset('assets/audio/bgm/$name.mp3');
|
||||||
}
|
|
||||||
await _bgmPlayer!.play();
|
await _bgmPlayer!.play();
|
||||||
|
debugPrint('[AudioService] Playing BGM: $name');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 파일이 없으면 무시 (개발 중 에셋 미추가 상태)
|
// 파일이 없으면 무시 (개발 중 에셋 미추가 상태)
|
||||||
|
debugPrint('[AudioService] Failed to play BGM $name: $e');
|
||||||
_currentBgm = null;
|
_currentBgm = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,10 +111,13 @@ class AudioService {
|
|||||||
/// SFX 재생
|
/// SFX 재생
|
||||||
///
|
///
|
||||||
/// [name]은 assets/audio/sfx/ 폴더 내 파일명 (확장자 제외)
|
/// [name]은 assets/audio/sfx/ 폴더 내 파일명 (확장자 제외)
|
||||||
/// 예: playSfx('attack') → assets/audio/sfx/attack.wav 또는 attack.mp3
|
/// 예: playSfx('attack') → assets/audio/sfx/attack.mp3
|
||||||
Future<void> playSfx(String name) async {
|
Future<void> playSfx(String name) async {
|
||||||
|
if (_initFailed) return; // 초기화 실패 시 무시
|
||||||
if (!_initialized) await init();
|
if (!_initialized) await init();
|
||||||
|
if (_initFailed || !_initialized) return;
|
||||||
if (_sfxVolume == 0) return; // 볼륨이 0이면 재생 안함
|
if (_sfxVolume == 0) return; // 볼륨이 0이면 재생 안함
|
||||||
|
if (_sfxPlayers.isEmpty) return;
|
||||||
|
|
||||||
// 사용 가능한 플레이어 찾기
|
// 사용 가능한 플레이어 찾기
|
||||||
AudioPlayer? availablePlayer;
|
AudioPlayer? availablePlayer;
|
||||||
@@ -117,16 +132,12 @@ class AudioService {
|
|||||||
availablePlayer ??= _sfxPlayers.first;
|
availablePlayer ??= _sfxPlayers.first;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// WAV 먼저 시도, 실패하면 MP3 시도
|
|
||||||
try {
|
|
||||||
await availablePlayer.setAsset('assets/audio/sfx/$name.wav');
|
|
||||||
} catch (_) {
|
|
||||||
await availablePlayer.setAsset('assets/audio/sfx/$name.mp3');
|
await availablePlayer.setAsset('assets/audio/sfx/$name.mp3');
|
||||||
}
|
|
||||||
await availablePlayer.seek(Duration.zero);
|
await availablePlayer.seek(Duration.zero);
|
||||||
await availablePlayer.play();
|
await availablePlayer.play();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 파일이 없으면 무시
|
// 파일이 없으면 무시
|
||||||
|
debugPrint('[AudioService] Failed to play SFX $name: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,6 +183,9 @@ class AudioService {
|
|||||||
|
|
||||||
/// BGM 타입 열거형
|
/// BGM 타입 열거형
|
||||||
enum BgmType {
|
enum BgmType {
|
||||||
|
/// 타이틀 화면 BGM
|
||||||
|
title,
|
||||||
|
|
||||||
/// 마을/상점 BGM
|
/// 마을/상점 BGM
|
||||||
town,
|
town,
|
||||||
|
|
||||||
|
|||||||
@@ -984,12 +984,20 @@ class ProgressService {
|
|||||||
var updatedSkillSystem = skillSystem;
|
var updatedSkillSystem = skillSystem;
|
||||||
var activeDoTs = [...combat.activeDoTs];
|
var activeDoTs = [...combat.activeDoTs];
|
||||||
var usedPotionTypes = {...combat.usedPotionTypes};
|
var usedPotionTypes = {...combat.usedPotionTypes};
|
||||||
|
var activeDebuffs = [...combat.activeDebuffs];
|
||||||
PotionInventory? updatedPotionInventory;
|
PotionInventory? updatedPotionInventory;
|
||||||
|
|
||||||
// 새 전투 이벤트 수집
|
// 새 전투 이벤트 수집
|
||||||
final newEvents = <CombatEvent>[];
|
final newEvents = <CombatEvent>[];
|
||||||
final timestamp = updatedSkillSystem.elapsedMs;
|
final timestamp = updatedSkillSystem.elapsedMs;
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// 만료된 디버프 정리
|
||||||
|
// =========================================================================
|
||||||
|
activeDebuffs = activeDebuffs
|
||||||
|
.where((debuff) => !debuff.isExpired(timestamp))
|
||||||
|
.toList();
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// DOT 틱 처리
|
// DOT 틱 처리
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -1090,6 +1098,7 @@ class ProgressService {
|
|||||||
skillSystem: updatedSkillSystem,
|
skillSystem: updatedSkillSystem,
|
||||||
availableSkillIds: availableSkillIds,
|
availableSkillIds: availableSkillIds,
|
||||||
activeDoTs: activeDoTs,
|
activeDoTs: activeDoTs,
|
||||||
|
activeDebuffs: activeDebuffs,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (selectedSkill != null && selectedSkill.isAttack) {
|
if (selectedSkill != null && selectedSkill.isAttack) {
|
||||||
@@ -1183,6 +1192,33 @@ class ProgressService {
|
|||||||
skillName: selectedSkill.name,
|
skillName: selectedSkill.name,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} else if (selectedSkill != null && selectedSkill.isDebuff) {
|
||||||
|
// 디버프 스킬 사용
|
||||||
|
final skillResult = skillService.useDebuffSkill(
|
||||||
|
skill: selectedSkill,
|
||||||
|
player: playerStats,
|
||||||
|
skillSystem: updatedSkillSystem,
|
||||||
|
currentDebuffs: activeDebuffs,
|
||||||
|
);
|
||||||
|
playerStats = skillResult.updatedPlayer;
|
||||||
|
updatedSkillSystem = skillResult.updatedSkillSystem;
|
||||||
|
|
||||||
|
// 디버프 효과 추가 (기존 같은 디버프 제거 후)
|
||||||
|
if (skillResult.debuffEffect != null) {
|
||||||
|
activeDebuffs = activeDebuffs
|
||||||
|
.where((d) => d.effect.id != skillResult.debuffEffect!.effect.id)
|
||||||
|
.toList()
|
||||||
|
..add(skillResult.debuffEffect!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 디버프 이벤트 생성
|
||||||
|
newEvents.add(
|
||||||
|
CombatEvent.playerDebuff(
|
||||||
|
timestamp: timestamp,
|
||||||
|
skillName: selectedSkill.name,
|
||||||
|
targetName: monsterStats.name,
|
||||||
|
),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
// 일반 공격
|
// 일반 공격
|
||||||
final attackResult = calculator.playerAttackMonster(
|
final attackResult = calculator.playerAttackMonster(
|
||||||
@@ -1221,8 +1257,25 @@ class ProgressService {
|
|||||||
// 몬스터가 살아있으면 반격
|
// 몬스터가 살아있으면 반격
|
||||||
if (monsterStats.isAlive &&
|
if (monsterStats.isAlive &&
|
||||||
monsterAccumulator >= monsterStats.attackDelayMs) {
|
monsterAccumulator >= monsterStats.attackDelayMs) {
|
||||||
|
// 디버프 효과 적용된 몬스터 스탯 계산
|
||||||
|
var debuffedMonster = monsterStats;
|
||||||
|
if (activeDebuffs.isNotEmpty) {
|
||||||
|
double atkMod = 0;
|
||||||
|
for (final debuff in activeDebuffs) {
|
||||||
|
if (!debuff.isExpired(timestamp)) {
|
||||||
|
atkMod += debuff.effect.atkModifier; // 음수 값
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ATK 감소 적용 (최소 10% ATK 유지)
|
||||||
|
final newAtk = (monsterStats.atk * (1 + atkMod)).round().clamp(
|
||||||
|
monsterStats.atk ~/ 10,
|
||||||
|
monsterStats.atk,
|
||||||
|
);
|
||||||
|
debuffedMonster = monsterStats.copyWith(atk: newAtk);
|
||||||
|
}
|
||||||
|
|
||||||
final attackResult = calculator.monsterAttackPlayer(
|
final attackResult = calculator.monsterAttackPlayer(
|
||||||
attacker: monsterStats,
|
attacker: debuffedMonster,
|
||||||
defender: playerStats,
|
defender: playerStats,
|
||||||
);
|
);
|
||||||
playerStats = attackResult.updatedDefender;
|
playerStats = attackResult.updatedDefender;
|
||||||
@@ -1288,6 +1341,7 @@ class ProgressService {
|
|||||||
recentEvents: recentEvents,
|
recentEvents: recentEvents,
|
||||||
activeDoTs: activeDoTs,
|
activeDoTs: activeDoTs,
|
||||||
usedPotionTypes: usedPotionTypes,
|
usedPotionTypes: usedPotionTypes,
|
||||||
|
activeDebuffs: activeDebuffs,
|
||||||
),
|
),
|
||||||
skillSystem: updatedSkillSystem,
|
skillSystem: updatedSkillSystem,
|
||||||
potionInventory: updatedPotionInventory,
|
potionInventory: updatedPotionInventory,
|
||||||
|
|||||||
@@ -185,6 +185,56 @@ class SkillService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 디버프 스킬 사용
|
||||||
|
///
|
||||||
|
/// 디버프 효과를 생성하여 반환. 호출자가 CombatState.activeDebuffs에 추가해야 함.
|
||||||
|
/// 디버프는 몬스터의 ATK/DEF를 감소시킴.
|
||||||
|
({
|
||||||
|
SkillUseResult result,
|
||||||
|
CombatStats updatedPlayer,
|
||||||
|
SkillSystemState updatedSkillSystem,
|
||||||
|
ActiveBuff? debuffEffect,
|
||||||
|
})
|
||||||
|
useDebuffSkill({
|
||||||
|
required Skill skill,
|
||||||
|
required CombatStats player,
|
||||||
|
required SkillSystemState skillSystem,
|
||||||
|
required List<ActiveBuff> currentDebuffs,
|
||||||
|
}) {
|
||||||
|
if (skill.buff == null) {
|
||||||
|
return (
|
||||||
|
result: SkillUseResult.failed(skill, SkillFailReason.invalidState),
|
||||||
|
updatedPlayer: player,
|
||||||
|
updatedSkillSystem: skillSystem,
|
||||||
|
debuffEffect: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 디버프 효과 생성
|
||||||
|
final newDebuff = ActiveBuff(
|
||||||
|
effect: skill.buff!,
|
||||||
|
startedMs: skillSystem.elapsedMs,
|
||||||
|
sourceSkillId: skill.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
// MP 소모
|
||||||
|
var updatedPlayer = player.withMp(player.mpCurrent - skill.mpCost);
|
||||||
|
|
||||||
|
// 스킬 상태 업데이트 (쿨타임 시작)
|
||||||
|
final updatedSkillSystem = _updateSkillCooldown(skillSystem, skill.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
result: SkillUseResult(
|
||||||
|
skill: skill,
|
||||||
|
success: true,
|
||||||
|
appliedBuff: newDebuff,
|
||||||
|
),
|
||||||
|
updatedPlayer: updatedPlayer,
|
||||||
|
updatedSkillSystem: updatedSkillSystem,
|
||||||
|
debuffEffect: newDebuff,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// DOT 스킬 사용
|
/// DOT 스킬 사용
|
||||||
///
|
///
|
||||||
/// DOT 효과를 생성하여 반환. 호출자가 전투 상태의 activeDoTs에 추가해야 함.
|
/// DOT 효과를 생성하여 반환. 호출자가 전투 상태의 activeDoTs에 추가해야 함.
|
||||||
@@ -248,16 +298,19 @@ class SkillService {
|
|||||||
///
|
///
|
||||||
/// 우선순위:
|
/// 우선순위:
|
||||||
/// 1. HP < 30% → 회복 스킬
|
/// 1. HP < 30% → 회복 스킬
|
||||||
/// 2. 몬스터 HP > 50% & DOT 없음 → DOT 스킬 (장기전 유리)
|
/// 2. HP > 70% & MP > 50% → 버프 스킬 (안전할 때)
|
||||||
/// 3. 보스전 (레벨 차이 10 이상) → 가장 강력한 공격 스킬
|
/// 3. 몬스터 HP > 70% & 활성 디버프 없음 → 디버프 스킬
|
||||||
/// 4. 일반 전투 → MP 효율이 좋은 스킬
|
/// 4. 몬스터 HP > 50% & DOT 없음 → DOT 스킬 (장기전 유리)
|
||||||
/// 5. MP < 20% → null (일반 공격)
|
/// 5. 보스전 (레벨 차이 10 이상) → 가장 강력한 공격 스킬
|
||||||
|
/// 6. 일반 전투 → MP 효율이 좋은 스킬
|
||||||
|
/// 7. MP < 20% → null (일반 공격)
|
||||||
Skill? selectAutoSkill({
|
Skill? selectAutoSkill({
|
||||||
required CombatStats player,
|
required CombatStats player,
|
||||||
required MonsterCombatStats monster,
|
required MonsterCombatStats monster,
|
||||||
required SkillSystemState skillSystem,
|
required SkillSystemState skillSystem,
|
||||||
required List<String> availableSkillIds,
|
required List<String> availableSkillIds,
|
||||||
List<DotEffect> activeDoTs = const [],
|
List<DotEffect> activeDoTs = const [],
|
||||||
|
List<ActiveBuff> activeDebuffs = const [],
|
||||||
}) {
|
}) {
|
||||||
final currentMp = player.mpCurrent;
|
final currentMp = player.mpCurrent;
|
||||||
final mpRatio = player.mpRatio;
|
final mpRatio = player.mpRatio;
|
||||||
@@ -289,6 +342,18 @@ class SkillService {
|
|||||||
if (healSkill != null) return healSkill;
|
if (healSkill != null) return healSkill;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HP > 70% & MP > 50% → 버프 스킬 (안전할 때)
|
||||||
|
if (hpRatio > 0.7 && mpRatio > 0.5) {
|
||||||
|
final buffSkill = _findBestBuffSkill(availableSkills, currentMp);
|
||||||
|
if (buffSkill != null) return buffSkill;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 몬스터 HP > 70% & 활성 디버프 없음 → 디버프 스킬
|
||||||
|
if (monster.hpRatio > 0.7 && activeDebuffs.isEmpty) {
|
||||||
|
final debuffSkill = _findBestDebuffSkill(availableSkills, currentMp);
|
||||||
|
if (debuffSkill != null) return debuffSkill;
|
||||||
|
}
|
||||||
|
|
||||||
// 몬스터 HP > 50% & 활성 DOT 없음 → DOT 스킬 사용
|
// 몬스터 HP > 50% & 활성 DOT 없음 → DOT 스킬 사용
|
||||||
if (monster.hpRatio > 0.5 && activeDoTs.isEmpty) {
|
if (monster.hpRatio > 0.5 && activeDoTs.isEmpty) {
|
||||||
final dotSkill = _findBestDotSkill(availableSkills, currentMp);
|
final dotSkill = _findBestDotSkill(availableSkills, currentMp);
|
||||||
@@ -369,6 +434,52 @@ class SkillService {
|
|||||||
return attackSkills.first;
|
return attackSkills.first;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 가장 좋은 버프 스킬 찾기
|
||||||
|
///
|
||||||
|
/// ATK 증가 버프 우선, 그 다음 복합 버프
|
||||||
|
Skill? _findBestBuffSkill(List<Skill> skills, int currentMp) {
|
||||||
|
final buffSkills = skills
|
||||||
|
.where((s) => s.isBuff && s.mpCost <= currentMp && s.buff != null)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (buffSkills.isEmpty) return null;
|
||||||
|
|
||||||
|
// ATK 증가량 기준 정렬
|
||||||
|
buffSkills.sort((a, b) {
|
||||||
|
final aValue = (a.buff?.atkModifier ?? 0) +
|
||||||
|
(a.buff?.defModifier ?? 0) * 0.5 +
|
||||||
|
(a.buff?.criRateModifier ?? 0) * 0.3;
|
||||||
|
final bValue = (b.buff?.atkModifier ?? 0) +
|
||||||
|
(b.buff?.defModifier ?? 0) * 0.5 +
|
||||||
|
(b.buff?.criRateModifier ?? 0) * 0.3;
|
||||||
|
return bValue.compareTo(aValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
return buffSkills.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 가장 좋은 디버프 스킬 찾기
|
||||||
|
///
|
||||||
|
/// 적 ATK 감소 디버프 우선
|
||||||
|
Skill? _findBestDebuffSkill(List<Skill> skills, int currentMp) {
|
||||||
|
final debuffSkills = skills
|
||||||
|
.where((s) => s.isDebuff && s.mpCost <= currentMp && s.buff != null)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (debuffSkills.isEmpty) return null;
|
||||||
|
|
||||||
|
// 디버프 효과 크기 기준 정렬 (음수 값이므로 절대값으로 비교)
|
||||||
|
debuffSkills.sort((a, b) {
|
||||||
|
final aValue = (a.buff?.atkModifier ?? 0).abs() +
|
||||||
|
(a.buff?.defModifier ?? 0).abs() * 0.5;
|
||||||
|
final bValue = (b.buff?.atkModifier ?? 0).abs() +
|
||||||
|
(b.buff?.defModifier ?? 0).abs() * 0.5;
|
||||||
|
return bValue.compareTo(aValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
return debuffSkills.first;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// MP 회복
|
// MP 회복
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ enum CombatEventType {
|
|||||||
/// 플레이어 버프
|
/// 플레이어 버프
|
||||||
playerBuff,
|
playerBuff,
|
||||||
|
|
||||||
|
/// 플레이어 디버프 (적에게 적용)
|
||||||
|
playerDebuff,
|
||||||
|
|
||||||
/// DOT 틱 데미지
|
/// DOT 틱 데미지
|
||||||
dotTick,
|
dotTick,
|
||||||
|
|
||||||
@@ -209,6 +212,20 @@ class CombatEvent {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 디버프 이벤트 생성 (적에게 디버프 적용)
|
||||||
|
factory CombatEvent.playerDebuff({
|
||||||
|
required int timestamp,
|
||||||
|
required String skillName,
|
||||||
|
required String targetName,
|
||||||
|
}) {
|
||||||
|
return CombatEvent(
|
||||||
|
type: CombatEventType.playerDebuff,
|
||||||
|
timestamp: timestamp,
|
||||||
|
skillName: skillName,
|
||||||
|
targetName: targetName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// DOT 틱 이벤트 생성
|
/// DOT 틱 이벤트 생성
|
||||||
factory CombatEvent.dotTick({
|
factory CombatEvent.dotTick({
|
||||||
required int timestamp,
|
required int timestamp,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class CombatState {
|
|||||||
this.recentEvents = const [],
|
this.recentEvents = const [],
|
||||||
this.activeDoTs = const [],
|
this.activeDoTs = const [],
|
||||||
this.usedPotionTypes = const {},
|
this.usedPotionTypes = const {},
|
||||||
|
this.activeDebuffs = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 플레이어 전투 스탯
|
/// 플레이어 전투 스탯
|
||||||
@@ -56,6 +57,9 @@ class CombatState {
|
|||||||
/// 이번 전투에서 사용한 물약 종류 (종류별 1회 제한)
|
/// 이번 전투에서 사용한 물약 종류 (종류별 1회 제한)
|
||||||
final Set<PotionType> usedPotionTypes;
|
final Set<PotionType> usedPotionTypes;
|
||||||
|
|
||||||
|
/// 몬스터에 적용된 활성 디버프 목록
|
||||||
|
final List<ActiveBuff> activeDebuffs;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 유틸리티
|
// 유틸리티
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -88,6 +92,24 @@ class CombatState {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 활성 디버프 존재 여부
|
||||||
|
bool get hasActiveDebuffs => activeDebuffs.isNotEmpty;
|
||||||
|
|
||||||
|
/// 몬스터에 적용된 총 디버프 효과 계산
|
||||||
|
///
|
||||||
|
/// 디버프 효과는 몬스터 ATK/DEF에 부정적 배율로 적용됨
|
||||||
|
({double atkMod, double defMod}) get totalDebuffModifiers {
|
||||||
|
double atkMod = 0;
|
||||||
|
double defMod = 0;
|
||||||
|
|
||||||
|
for (final debuff in activeDebuffs) {
|
||||||
|
atkMod += debuff.effect.atkModifier;
|
||||||
|
defMod += debuff.effect.defModifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (atkMod: atkMod, defMod: defMod);
|
||||||
|
}
|
||||||
|
|
||||||
CombatState copyWith({
|
CombatState copyWith({
|
||||||
CombatStats? playerStats,
|
CombatStats? playerStats,
|
||||||
MonsterCombatStats? monsterStats,
|
MonsterCombatStats? monsterStats,
|
||||||
@@ -100,6 +122,7 @@ class CombatState {
|
|||||||
List<CombatEvent>? recentEvents,
|
List<CombatEvent>? recentEvents,
|
||||||
List<DotEffect>? activeDoTs,
|
List<DotEffect>? activeDoTs,
|
||||||
Set<PotionType>? usedPotionTypes,
|
Set<PotionType>? usedPotionTypes,
|
||||||
|
List<ActiveBuff>? activeDebuffs,
|
||||||
}) {
|
}) {
|
||||||
return CombatState(
|
return CombatState(
|
||||||
playerStats: playerStats ?? this.playerStats,
|
playerStats: playerStats ?? this.playerStats,
|
||||||
@@ -115,6 +138,7 @@ class CombatState {
|
|||||||
recentEvents: recentEvents ?? this.recentEvents,
|
recentEvents: recentEvents ?? this.recentEvents,
|
||||||
activeDoTs: activeDoTs ?? this.activeDoTs,
|
activeDoTs: activeDoTs ?? this.activeDoTs,
|
||||||
usedPotionTypes: usedPotionTypes ?? this.usedPotionTypes,
|
usedPotionTypes: usedPotionTypes ?? this.usedPotionTypes,
|
||||||
|
activeDebuffs: activeDebuffs ?? this.activeDebuffs,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
626
lib/src/core/model/game_statistics.dart
Normal file
626
lib/src/core/model/game_statistics.dart
Normal file
@@ -0,0 +1,626 @@
|
|||||||
|
/// 게임 통계 (Game Statistics)
|
||||||
|
///
|
||||||
|
/// 세션 및 누적 통계를 추적하는 모델
|
||||||
|
class GameStatistics {
|
||||||
|
const GameStatistics({
|
||||||
|
required this.session,
|
||||||
|
required this.cumulative,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 현재 세션 통계
|
||||||
|
final SessionStatistics session;
|
||||||
|
|
||||||
|
/// 누적 통계
|
||||||
|
final CumulativeStatistics cumulative;
|
||||||
|
|
||||||
|
/// 빈 통계
|
||||||
|
factory GameStatistics.empty() => GameStatistics(
|
||||||
|
session: SessionStatistics.empty(),
|
||||||
|
cumulative: CumulativeStatistics.empty(),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 새 세션 시작 (세션 통계 초기화, 누적 통계 유지)
|
||||||
|
GameStatistics startNewSession() {
|
||||||
|
return GameStatistics(
|
||||||
|
session: SessionStatistics.empty(),
|
||||||
|
cumulative: cumulative,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 세션 종료 시 누적 통계 업데이트
|
||||||
|
GameStatistics endSession() {
|
||||||
|
return GameStatistics(
|
||||||
|
session: session,
|
||||||
|
cumulative: cumulative.mergeSession(session),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
GameStatistics copyWith({
|
||||||
|
SessionStatistics? session,
|
||||||
|
CumulativeStatistics? cumulative,
|
||||||
|
}) {
|
||||||
|
return GameStatistics(
|
||||||
|
session: session ?? this.session,
|
||||||
|
cumulative: cumulative ?? this.cumulative,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON 직렬화
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'session': session.toJson(),
|
||||||
|
'cumulative': cumulative.toJson(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON 역직렬화
|
||||||
|
factory GameStatistics.fromJson(Map<String, dynamic> json) {
|
||||||
|
return GameStatistics(
|
||||||
|
session: SessionStatistics.fromJson(
|
||||||
|
json['session'] as Map<String, dynamic>? ?? {},
|
||||||
|
),
|
||||||
|
cumulative: CumulativeStatistics.fromJson(
|
||||||
|
json['cumulative'] as Map<String, dynamic>? ?? {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 세션 통계 (Session Statistics)
|
||||||
|
///
|
||||||
|
/// 현재 게임 세션의 통계
|
||||||
|
class SessionStatistics {
|
||||||
|
const SessionStatistics({
|
||||||
|
required this.playTimeMs,
|
||||||
|
required this.monstersKilled,
|
||||||
|
required this.goldEarned,
|
||||||
|
required this.goldSpent,
|
||||||
|
required this.skillsUsed,
|
||||||
|
required this.criticalHits,
|
||||||
|
required this.maxCriticalStreak,
|
||||||
|
required this.currentCriticalStreak,
|
||||||
|
required this.totalDamageDealt,
|
||||||
|
required this.totalDamageTaken,
|
||||||
|
required this.potionsUsed,
|
||||||
|
required this.itemsSold,
|
||||||
|
required this.questsCompleted,
|
||||||
|
required this.deathCount,
|
||||||
|
required this.bossesDefeated,
|
||||||
|
required this.levelUps,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 플레이 시간 (밀리초)
|
||||||
|
final int playTimeMs;
|
||||||
|
|
||||||
|
/// 처치한 몬스터 수
|
||||||
|
final int monstersKilled;
|
||||||
|
|
||||||
|
/// 획득한 골드 총량
|
||||||
|
final int goldEarned;
|
||||||
|
|
||||||
|
/// 소비한 골드 총량
|
||||||
|
final int goldSpent;
|
||||||
|
|
||||||
|
/// 사용한 스킬 횟수
|
||||||
|
final int skillsUsed;
|
||||||
|
|
||||||
|
/// 크리티컬 히트 횟수
|
||||||
|
final int criticalHits;
|
||||||
|
|
||||||
|
/// 최대 연속 크리티컬
|
||||||
|
final int maxCriticalStreak;
|
||||||
|
|
||||||
|
/// 현재 연속 크리티컬 (내부 추적용)
|
||||||
|
final int currentCriticalStreak;
|
||||||
|
|
||||||
|
/// 총 입힌 데미지
|
||||||
|
final int totalDamageDealt;
|
||||||
|
|
||||||
|
/// 총 받은 데미지
|
||||||
|
final int totalDamageTaken;
|
||||||
|
|
||||||
|
/// 사용한 물약 수
|
||||||
|
final int potionsUsed;
|
||||||
|
|
||||||
|
/// 판매한 아이템 수
|
||||||
|
final int itemsSold;
|
||||||
|
|
||||||
|
/// 완료한 퀘스트 수
|
||||||
|
final int questsCompleted;
|
||||||
|
|
||||||
|
/// 사망 횟수
|
||||||
|
final int deathCount;
|
||||||
|
|
||||||
|
/// 처치한 보스 수
|
||||||
|
final int bossesDefeated;
|
||||||
|
|
||||||
|
/// 레벨업 횟수
|
||||||
|
final int levelUps;
|
||||||
|
|
||||||
|
/// 플레이 시간 Duration
|
||||||
|
Duration get playTime => Duration(milliseconds: playTimeMs);
|
||||||
|
|
||||||
|
/// 플레이 시간 포맷 (HH:MM:SS)
|
||||||
|
String get formattedPlayTime {
|
||||||
|
final hours = playTime.inHours;
|
||||||
|
final minutes = playTime.inMinutes % 60;
|
||||||
|
final seconds = playTime.inSeconds % 60;
|
||||||
|
return '${hours.toString().padLeft(2, '0')}:'
|
||||||
|
'${minutes.toString().padLeft(2, '0')}:'
|
||||||
|
'${seconds.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 평균 DPS (damage per second)
|
||||||
|
double get averageDps {
|
||||||
|
if (playTimeMs <= 0) return 0;
|
||||||
|
return totalDamageDealt / (playTimeMs / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 킬당 평균 골드
|
||||||
|
double get goldPerKill {
|
||||||
|
if (monstersKilled <= 0) return 0;
|
||||||
|
return goldEarned / monstersKilled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 크리티컬 비율
|
||||||
|
double get criticalRate {
|
||||||
|
if (skillsUsed <= 0) return 0;
|
||||||
|
return criticalHits / skillsUsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 빈 세션 통계
|
||||||
|
factory SessionStatistics.empty() => const SessionStatistics(
|
||||||
|
playTimeMs: 0,
|
||||||
|
monstersKilled: 0,
|
||||||
|
goldEarned: 0,
|
||||||
|
goldSpent: 0,
|
||||||
|
skillsUsed: 0,
|
||||||
|
criticalHits: 0,
|
||||||
|
maxCriticalStreak: 0,
|
||||||
|
currentCriticalStreak: 0,
|
||||||
|
totalDamageDealt: 0,
|
||||||
|
totalDamageTaken: 0,
|
||||||
|
potionsUsed: 0,
|
||||||
|
itemsSold: 0,
|
||||||
|
questsCompleted: 0,
|
||||||
|
deathCount: 0,
|
||||||
|
bossesDefeated: 0,
|
||||||
|
levelUps: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 이벤트 기록 메서드
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// 몬스터 처치 기록
|
||||||
|
SessionStatistics recordKill({bool isBoss = false}) {
|
||||||
|
return copyWith(
|
||||||
|
monstersKilled: monstersKilled + 1,
|
||||||
|
bossesDefeated: isBoss ? bossesDefeated + 1 : bossesDefeated,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 골드 획득 기록
|
||||||
|
SessionStatistics recordGoldEarned(int amount) {
|
||||||
|
return copyWith(goldEarned: goldEarned + amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 골드 소비 기록
|
||||||
|
SessionStatistics recordGoldSpent(int amount) {
|
||||||
|
return copyWith(goldSpent: goldSpent + amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 스킬 사용 기록
|
||||||
|
SessionStatistics recordSkillUse({required bool isCritical}) {
|
||||||
|
final newCriticalStreak =
|
||||||
|
isCritical ? currentCriticalStreak + 1 : 0;
|
||||||
|
final newMaxStreak = newCriticalStreak > maxCriticalStreak
|
||||||
|
? newCriticalStreak
|
||||||
|
: maxCriticalStreak;
|
||||||
|
|
||||||
|
return copyWith(
|
||||||
|
skillsUsed: skillsUsed + 1,
|
||||||
|
criticalHits: isCritical ? criticalHits + 1 : criticalHits,
|
||||||
|
currentCriticalStreak: newCriticalStreak,
|
||||||
|
maxCriticalStreak: newMaxStreak,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 데미지 기록
|
||||||
|
SessionStatistics recordDamage({
|
||||||
|
int dealt = 0,
|
||||||
|
int taken = 0,
|
||||||
|
}) {
|
||||||
|
return copyWith(
|
||||||
|
totalDamageDealt: totalDamageDealt + dealt,
|
||||||
|
totalDamageTaken: totalDamageTaken + taken,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 물약 사용 기록
|
||||||
|
SessionStatistics recordPotionUse() {
|
||||||
|
return copyWith(potionsUsed: potionsUsed + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 아이템 판매 기록
|
||||||
|
SessionStatistics recordItemSold(int count) {
|
||||||
|
return copyWith(itemsSold: itemsSold + count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 퀘스트 완료 기록
|
||||||
|
SessionStatistics recordQuestComplete() {
|
||||||
|
return copyWith(questsCompleted: questsCompleted + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 사망 기록
|
||||||
|
SessionStatistics recordDeath() {
|
||||||
|
return copyWith(deathCount: deathCount + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 레벨업 기록
|
||||||
|
SessionStatistics recordLevelUp() {
|
||||||
|
return copyWith(levelUps: levelUps + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 플레이 시간 업데이트
|
||||||
|
SessionStatistics updatePlayTime(int elapsedMs) {
|
||||||
|
return copyWith(playTimeMs: elapsedMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
SessionStatistics copyWith({
|
||||||
|
int? playTimeMs,
|
||||||
|
int? monstersKilled,
|
||||||
|
int? goldEarned,
|
||||||
|
int? goldSpent,
|
||||||
|
int? skillsUsed,
|
||||||
|
int? criticalHits,
|
||||||
|
int? maxCriticalStreak,
|
||||||
|
int? currentCriticalStreak,
|
||||||
|
int? totalDamageDealt,
|
||||||
|
int? totalDamageTaken,
|
||||||
|
int? potionsUsed,
|
||||||
|
int? itemsSold,
|
||||||
|
int? questsCompleted,
|
||||||
|
int? deathCount,
|
||||||
|
int? bossesDefeated,
|
||||||
|
int? levelUps,
|
||||||
|
}) {
|
||||||
|
return SessionStatistics(
|
||||||
|
playTimeMs: playTimeMs ?? this.playTimeMs,
|
||||||
|
monstersKilled: monstersKilled ?? this.monstersKilled,
|
||||||
|
goldEarned: goldEarned ?? this.goldEarned,
|
||||||
|
goldSpent: goldSpent ?? this.goldSpent,
|
||||||
|
skillsUsed: skillsUsed ?? this.skillsUsed,
|
||||||
|
criticalHits: criticalHits ?? this.criticalHits,
|
||||||
|
maxCriticalStreak: maxCriticalStreak ?? this.maxCriticalStreak,
|
||||||
|
currentCriticalStreak:
|
||||||
|
currentCriticalStreak ?? this.currentCriticalStreak,
|
||||||
|
totalDamageDealt: totalDamageDealt ?? this.totalDamageDealt,
|
||||||
|
totalDamageTaken: totalDamageTaken ?? this.totalDamageTaken,
|
||||||
|
potionsUsed: potionsUsed ?? this.potionsUsed,
|
||||||
|
itemsSold: itemsSold ?? this.itemsSold,
|
||||||
|
questsCompleted: questsCompleted ?? this.questsCompleted,
|
||||||
|
deathCount: deathCount ?? this.deathCount,
|
||||||
|
bossesDefeated: bossesDefeated ?? this.bossesDefeated,
|
||||||
|
levelUps: levelUps ?? this.levelUps,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON 직렬화
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'playTimeMs': playTimeMs,
|
||||||
|
'monstersKilled': monstersKilled,
|
||||||
|
'goldEarned': goldEarned,
|
||||||
|
'goldSpent': goldSpent,
|
||||||
|
'skillsUsed': skillsUsed,
|
||||||
|
'criticalHits': criticalHits,
|
||||||
|
'maxCriticalStreak': maxCriticalStreak,
|
||||||
|
'totalDamageDealt': totalDamageDealt,
|
||||||
|
'totalDamageTaken': totalDamageTaken,
|
||||||
|
'potionsUsed': potionsUsed,
|
||||||
|
'itemsSold': itemsSold,
|
||||||
|
'questsCompleted': questsCompleted,
|
||||||
|
'deathCount': deathCount,
|
||||||
|
'bossesDefeated': bossesDefeated,
|
||||||
|
'levelUps': levelUps,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON 역직렬화
|
||||||
|
factory SessionStatistics.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SessionStatistics(
|
||||||
|
playTimeMs: json['playTimeMs'] as int? ?? 0,
|
||||||
|
monstersKilled: json['monstersKilled'] as int? ?? 0,
|
||||||
|
goldEarned: json['goldEarned'] as int? ?? 0,
|
||||||
|
goldSpent: json['goldSpent'] as int? ?? 0,
|
||||||
|
skillsUsed: json['skillsUsed'] as int? ?? 0,
|
||||||
|
criticalHits: json['criticalHits'] as int? ?? 0,
|
||||||
|
maxCriticalStreak: json['maxCriticalStreak'] as int? ?? 0,
|
||||||
|
currentCriticalStreak: 0, // 세션간 유지 안 함
|
||||||
|
totalDamageDealt: json['totalDamageDealt'] as int? ?? 0,
|
||||||
|
totalDamageTaken: json['totalDamageTaken'] as int? ?? 0,
|
||||||
|
potionsUsed: json['potionsUsed'] as int? ?? 0,
|
||||||
|
itemsSold: json['itemsSold'] as int? ?? 0,
|
||||||
|
questsCompleted: json['questsCompleted'] as int? ?? 0,
|
||||||
|
deathCount: json['deathCount'] as int? ?? 0,
|
||||||
|
bossesDefeated: json['bossesDefeated'] as int? ?? 0,
|
||||||
|
levelUps: json['levelUps'] as int? ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 누적 통계 (Cumulative Statistics)
|
||||||
|
///
|
||||||
|
/// 모든 게임 세션의 누적 통계
|
||||||
|
class CumulativeStatistics {
|
||||||
|
const CumulativeStatistics({
|
||||||
|
required this.totalPlayTimeMs,
|
||||||
|
required this.totalMonstersKilled,
|
||||||
|
required this.totalGoldEarned,
|
||||||
|
required this.totalGoldSpent,
|
||||||
|
required this.totalSkillsUsed,
|
||||||
|
required this.totalCriticalHits,
|
||||||
|
required this.bestCriticalStreak,
|
||||||
|
required this.totalDamageDealt,
|
||||||
|
required this.totalDamageTaken,
|
||||||
|
required this.totalPotionsUsed,
|
||||||
|
required this.totalItemsSold,
|
||||||
|
required this.totalQuestsCompleted,
|
||||||
|
required this.totalDeaths,
|
||||||
|
required this.totalBossesDefeated,
|
||||||
|
required this.totalLevelUps,
|
||||||
|
required this.highestLevel,
|
||||||
|
required this.highestGoldHeld,
|
||||||
|
required this.gamesCompleted,
|
||||||
|
required this.gamesStarted,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 총 플레이 시간 (밀리초)
|
||||||
|
final int totalPlayTimeMs;
|
||||||
|
|
||||||
|
/// 총 처치한 몬스터 수
|
||||||
|
final int totalMonstersKilled;
|
||||||
|
|
||||||
|
/// 총 획득한 골드
|
||||||
|
final int totalGoldEarned;
|
||||||
|
|
||||||
|
/// 총 소비한 골드
|
||||||
|
final int totalGoldSpent;
|
||||||
|
|
||||||
|
/// 총 스킬 사용 횟수
|
||||||
|
final int totalSkillsUsed;
|
||||||
|
|
||||||
|
/// 총 크리티컬 히트 횟수
|
||||||
|
final int totalCriticalHits;
|
||||||
|
|
||||||
|
/// 최고 연속 크리티컬
|
||||||
|
final int bestCriticalStreak;
|
||||||
|
|
||||||
|
/// 총 입힌 데미지
|
||||||
|
final int totalDamageDealt;
|
||||||
|
|
||||||
|
/// 총 받은 데미지
|
||||||
|
final int totalDamageTaken;
|
||||||
|
|
||||||
|
/// 총 사용한 물약 수
|
||||||
|
final int totalPotionsUsed;
|
||||||
|
|
||||||
|
/// 총 판매한 아이템 수
|
||||||
|
final int totalItemsSold;
|
||||||
|
|
||||||
|
/// 총 완료한 퀘스트 수
|
||||||
|
final int totalQuestsCompleted;
|
||||||
|
|
||||||
|
/// 총 사망 횟수
|
||||||
|
final int totalDeaths;
|
||||||
|
|
||||||
|
/// 총 처치한 보스 수
|
||||||
|
final int totalBossesDefeated;
|
||||||
|
|
||||||
|
/// 총 레벨업 횟수
|
||||||
|
final int totalLevelUps;
|
||||||
|
|
||||||
|
/// 최고 달성 레벨
|
||||||
|
final int highestLevel;
|
||||||
|
|
||||||
|
/// 최대 보유 골드
|
||||||
|
final int highestGoldHeld;
|
||||||
|
|
||||||
|
/// 클리어한 게임 수
|
||||||
|
final int gamesCompleted;
|
||||||
|
|
||||||
|
/// 시작한 게임 수
|
||||||
|
final int gamesStarted;
|
||||||
|
|
||||||
|
/// 총 플레이 시간 Duration
|
||||||
|
Duration get totalPlayTime => Duration(milliseconds: totalPlayTimeMs);
|
||||||
|
|
||||||
|
/// 총 플레이 시간 포맷 (HH:MM:SS)
|
||||||
|
String get formattedTotalPlayTime {
|
||||||
|
final hours = totalPlayTime.inHours;
|
||||||
|
final minutes = totalPlayTime.inMinutes % 60;
|
||||||
|
final seconds = totalPlayTime.inSeconds % 60;
|
||||||
|
return '${hours.toString().padLeft(2, '0')}:'
|
||||||
|
'${minutes.toString().padLeft(2, '0')}:'
|
||||||
|
'${seconds.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 평균 게임당 플레이 시간
|
||||||
|
Duration get averagePlayTimePerGame {
|
||||||
|
if (gamesStarted <= 0) return Duration.zero;
|
||||||
|
return Duration(milliseconds: totalPlayTimeMs ~/ gamesStarted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 게임 완료율
|
||||||
|
double get completionRate {
|
||||||
|
if (gamesStarted <= 0) return 0;
|
||||||
|
return gamesCompleted / gamesStarted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 빈 누적 통계
|
||||||
|
factory CumulativeStatistics.empty() => const CumulativeStatistics(
|
||||||
|
totalPlayTimeMs: 0,
|
||||||
|
totalMonstersKilled: 0,
|
||||||
|
totalGoldEarned: 0,
|
||||||
|
totalGoldSpent: 0,
|
||||||
|
totalSkillsUsed: 0,
|
||||||
|
totalCriticalHits: 0,
|
||||||
|
bestCriticalStreak: 0,
|
||||||
|
totalDamageDealt: 0,
|
||||||
|
totalDamageTaken: 0,
|
||||||
|
totalPotionsUsed: 0,
|
||||||
|
totalItemsSold: 0,
|
||||||
|
totalQuestsCompleted: 0,
|
||||||
|
totalDeaths: 0,
|
||||||
|
totalBossesDefeated: 0,
|
||||||
|
totalLevelUps: 0,
|
||||||
|
highestLevel: 0,
|
||||||
|
highestGoldHeld: 0,
|
||||||
|
gamesCompleted: 0,
|
||||||
|
gamesStarted: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 세션 통계 병합
|
||||||
|
CumulativeStatistics mergeSession(SessionStatistics session) {
|
||||||
|
return CumulativeStatistics(
|
||||||
|
totalPlayTimeMs: totalPlayTimeMs + session.playTimeMs,
|
||||||
|
totalMonstersKilled: totalMonstersKilled + session.monstersKilled,
|
||||||
|
totalGoldEarned: totalGoldEarned + session.goldEarned,
|
||||||
|
totalGoldSpent: totalGoldSpent + session.goldSpent,
|
||||||
|
totalSkillsUsed: totalSkillsUsed + session.skillsUsed,
|
||||||
|
totalCriticalHits: totalCriticalHits + session.criticalHits,
|
||||||
|
bestCriticalStreak: session.maxCriticalStreak > bestCriticalStreak
|
||||||
|
? session.maxCriticalStreak
|
||||||
|
: bestCriticalStreak,
|
||||||
|
totalDamageDealt: totalDamageDealt + session.totalDamageDealt,
|
||||||
|
totalDamageTaken: totalDamageTaken + session.totalDamageTaken,
|
||||||
|
totalPotionsUsed: totalPotionsUsed + session.potionsUsed,
|
||||||
|
totalItemsSold: totalItemsSold + session.itemsSold,
|
||||||
|
totalQuestsCompleted: totalQuestsCompleted + session.questsCompleted,
|
||||||
|
totalDeaths: totalDeaths + session.deathCount,
|
||||||
|
totalBossesDefeated: totalBossesDefeated + session.bossesDefeated,
|
||||||
|
totalLevelUps: totalLevelUps + session.levelUps,
|
||||||
|
highestLevel: highestLevel, // 별도 업데이트 필요
|
||||||
|
highestGoldHeld: highestGoldHeld, // 별도 업데이트 필요
|
||||||
|
gamesCompleted: gamesCompleted, // 별도 업데이트 필요
|
||||||
|
gamesStarted: gamesStarted, // 별도 업데이트 필요
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 최고 레벨 업데이트
|
||||||
|
CumulativeStatistics updateHighestLevel(int level) {
|
||||||
|
if (level <= highestLevel) return this;
|
||||||
|
return copyWith(highestLevel: level);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 최대 골드 업데이트
|
||||||
|
CumulativeStatistics updateHighestGold(int gold) {
|
||||||
|
if (gold <= highestGoldHeld) return this;
|
||||||
|
return copyWith(highestGoldHeld: gold);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 새 게임 시작 기록
|
||||||
|
CumulativeStatistics recordGameStart() {
|
||||||
|
return copyWith(gamesStarted: gamesStarted + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 게임 클리어 기록
|
||||||
|
CumulativeStatistics recordGameComplete() {
|
||||||
|
return copyWith(gamesCompleted: gamesCompleted + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
CumulativeStatistics copyWith({
|
||||||
|
int? totalPlayTimeMs,
|
||||||
|
int? totalMonstersKilled,
|
||||||
|
int? totalGoldEarned,
|
||||||
|
int? totalGoldSpent,
|
||||||
|
int? totalSkillsUsed,
|
||||||
|
int? totalCriticalHits,
|
||||||
|
int? bestCriticalStreak,
|
||||||
|
int? totalDamageDealt,
|
||||||
|
int? totalDamageTaken,
|
||||||
|
int? totalPotionsUsed,
|
||||||
|
int? totalItemsSold,
|
||||||
|
int? totalQuestsCompleted,
|
||||||
|
int? totalDeaths,
|
||||||
|
int? totalBossesDefeated,
|
||||||
|
int? totalLevelUps,
|
||||||
|
int? highestLevel,
|
||||||
|
int? highestGoldHeld,
|
||||||
|
int? gamesCompleted,
|
||||||
|
int? gamesStarted,
|
||||||
|
}) {
|
||||||
|
return CumulativeStatistics(
|
||||||
|
totalPlayTimeMs: totalPlayTimeMs ?? this.totalPlayTimeMs,
|
||||||
|
totalMonstersKilled: totalMonstersKilled ?? this.totalMonstersKilled,
|
||||||
|
totalGoldEarned: totalGoldEarned ?? this.totalGoldEarned,
|
||||||
|
totalGoldSpent: totalGoldSpent ?? this.totalGoldSpent,
|
||||||
|
totalSkillsUsed: totalSkillsUsed ?? this.totalSkillsUsed,
|
||||||
|
totalCriticalHits: totalCriticalHits ?? this.totalCriticalHits,
|
||||||
|
bestCriticalStreak: bestCriticalStreak ?? this.bestCriticalStreak,
|
||||||
|
totalDamageDealt: totalDamageDealt ?? this.totalDamageDealt,
|
||||||
|
totalDamageTaken: totalDamageTaken ?? this.totalDamageTaken,
|
||||||
|
totalPotionsUsed: totalPotionsUsed ?? this.totalPotionsUsed,
|
||||||
|
totalItemsSold: totalItemsSold ?? this.totalItemsSold,
|
||||||
|
totalQuestsCompleted: totalQuestsCompleted ?? this.totalQuestsCompleted,
|
||||||
|
totalDeaths: totalDeaths ?? this.totalDeaths,
|
||||||
|
totalBossesDefeated: totalBossesDefeated ?? this.totalBossesDefeated,
|
||||||
|
totalLevelUps: totalLevelUps ?? this.totalLevelUps,
|
||||||
|
highestLevel: highestLevel ?? this.highestLevel,
|
||||||
|
highestGoldHeld: highestGoldHeld ?? this.highestGoldHeld,
|
||||||
|
gamesCompleted: gamesCompleted ?? this.gamesCompleted,
|
||||||
|
gamesStarted: gamesStarted ?? this.gamesStarted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON 직렬화
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'totalPlayTimeMs': totalPlayTimeMs,
|
||||||
|
'totalMonstersKilled': totalMonstersKilled,
|
||||||
|
'totalGoldEarned': totalGoldEarned,
|
||||||
|
'totalGoldSpent': totalGoldSpent,
|
||||||
|
'totalSkillsUsed': totalSkillsUsed,
|
||||||
|
'totalCriticalHits': totalCriticalHits,
|
||||||
|
'bestCriticalStreak': bestCriticalStreak,
|
||||||
|
'totalDamageDealt': totalDamageDealt,
|
||||||
|
'totalDamageTaken': totalDamageTaken,
|
||||||
|
'totalPotionsUsed': totalPotionsUsed,
|
||||||
|
'totalItemsSold': totalItemsSold,
|
||||||
|
'totalQuestsCompleted': totalQuestsCompleted,
|
||||||
|
'totalDeaths': totalDeaths,
|
||||||
|
'totalBossesDefeated': totalBossesDefeated,
|
||||||
|
'totalLevelUps': totalLevelUps,
|
||||||
|
'highestLevel': highestLevel,
|
||||||
|
'highestGoldHeld': highestGoldHeld,
|
||||||
|
'gamesCompleted': gamesCompleted,
|
||||||
|
'gamesStarted': gamesStarted,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JSON 역직렬화
|
||||||
|
factory CumulativeStatistics.fromJson(Map<String, dynamic> json) {
|
||||||
|
return CumulativeStatistics(
|
||||||
|
totalPlayTimeMs: json['totalPlayTimeMs'] as int? ?? 0,
|
||||||
|
totalMonstersKilled: json['totalMonstersKilled'] as int? ?? 0,
|
||||||
|
totalGoldEarned: json['totalGoldEarned'] as int? ?? 0,
|
||||||
|
totalGoldSpent: json['totalGoldSpent'] as int? ?? 0,
|
||||||
|
totalSkillsUsed: json['totalSkillsUsed'] as int? ?? 0,
|
||||||
|
totalCriticalHits: json['totalCriticalHits'] as int? ?? 0,
|
||||||
|
bestCriticalStreak: json['bestCriticalStreak'] as int? ?? 0,
|
||||||
|
totalDamageDealt: json['totalDamageDealt'] as int? ?? 0,
|
||||||
|
totalDamageTaken: json['totalDamageTaken'] as int? ?? 0,
|
||||||
|
totalPotionsUsed: json['totalPotionsUsed'] as int? ?? 0,
|
||||||
|
totalItemsSold: json['totalItemsSold'] as int? ?? 0,
|
||||||
|
totalQuestsCompleted: json['totalQuestsCompleted'] as int? ?? 0,
|
||||||
|
totalDeaths: json['totalDeaths'] as int? ?? 0,
|
||||||
|
totalBossesDefeated: json['totalBossesDefeated'] as int? ?? 0,
|
||||||
|
totalLevelUps: json['totalLevelUps'] as int? ?? 0,
|
||||||
|
highestLevel: json['highestLevel'] as int? ?? 0,
|
||||||
|
highestGoldHeld: json['highestGoldHeld'] as int? ?? 0,
|
||||||
|
gamesCompleted: json['gamesCompleted'] as int? ?? 0,
|
||||||
|
gamesStarted: json['gamesStarted'] as int? ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
126
lib/src/core/storage/statistics_storage.dart
Normal file
126
lib/src/core/storage/statistics_storage.dart
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:askiineverdie/src/core/model/game_statistics.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
/// 게임 통계 저장소 (Statistics Storage)
|
||||||
|
///
|
||||||
|
/// 누적 통계 데이터 저장/로드 관리
|
||||||
|
class StatisticsStorage {
|
||||||
|
StatisticsStorage();
|
||||||
|
|
||||||
|
static const String _fileName = 'game_statistics.json';
|
||||||
|
|
||||||
|
Directory? _storageDir;
|
||||||
|
|
||||||
|
Future<Directory> _getStorageDir() async {
|
||||||
|
if (_storageDir != null) return _storageDir!;
|
||||||
|
_storageDir = await getApplicationSupportDirectory();
|
||||||
|
return _storageDir!;
|
||||||
|
}
|
||||||
|
|
||||||
|
File _getFile(Directory dir) {
|
||||||
|
return File('${dir.path}/$_fileName');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 누적 통계 로드
|
||||||
|
Future<CumulativeStatistics> loadCumulative() async {
|
||||||
|
try {
|
||||||
|
final dir = await _getStorageDir();
|
||||||
|
final file = _getFile(dir);
|
||||||
|
|
||||||
|
if (!await file.exists()) {
|
||||||
|
return CumulativeStatistics.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
final content = await file.readAsString();
|
||||||
|
final json = jsonDecode(content) as Map<String, dynamic>;
|
||||||
|
|
||||||
|
// cumulative 키가 있으면 해당 데이터 사용
|
||||||
|
if (json.containsKey('cumulative')) {
|
||||||
|
return CumulativeStatistics.fromJson(
|
||||||
|
json['cumulative'] as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 직접 CumulativeStatistics JSON인 경우
|
||||||
|
return CumulativeStatistics.fromJson(json);
|
||||||
|
} catch (e) {
|
||||||
|
// 파일이 없거나 손상된 경우 빈 통계 반환
|
||||||
|
return CumulativeStatistics.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 누적 통계 저장
|
||||||
|
Future<bool> saveCumulative(CumulativeStatistics stats) async {
|
||||||
|
try {
|
||||||
|
final dir = await _getStorageDir();
|
||||||
|
final file = _getFile(dir);
|
||||||
|
|
||||||
|
final json = {
|
||||||
|
'version': 1,
|
||||||
|
'lastUpdated': DateTime.now().toIso8601String(),
|
||||||
|
'cumulative': stats.toJson(),
|
||||||
|
};
|
||||||
|
|
||||||
|
final content = const JsonEncoder.withIndent(' ').convert(json);
|
||||||
|
await file.writeAsString(content);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 세션 종료 시 누적 통계 업데이트
|
||||||
|
Future<bool> mergeSession(SessionStatistics session) async {
|
||||||
|
final current = await loadCumulative();
|
||||||
|
final updated = current.mergeSession(session);
|
||||||
|
return saveCumulative(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 최고 레벨 업데이트
|
||||||
|
Future<bool> updateHighestLevel(int level) async {
|
||||||
|
final current = await loadCumulative();
|
||||||
|
if (level <= current.highestLevel) return true;
|
||||||
|
final updated = current.updateHighestLevel(level);
|
||||||
|
return saveCumulative(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 최대 골드 업데이트
|
||||||
|
Future<bool> updateHighestGold(int gold) async {
|
||||||
|
final current = await loadCumulative();
|
||||||
|
if (gold <= current.highestGoldHeld) return true;
|
||||||
|
final updated = current.updateHighestGold(gold);
|
||||||
|
return saveCumulative(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 새 게임 시작 기록
|
||||||
|
Future<bool> recordGameStart() async {
|
||||||
|
final current = await loadCumulative();
|
||||||
|
final updated = current.recordGameStart();
|
||||||
|
return saveCumulative(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 게임 클리어 기록
|
||||||
|
Future<bool> recordGameComplete() async {
|
||||||
|
final current = await loadCumulative();
|
||||||
|
final updated = current.recordGameComplete();
|
||||||
|
return saveCumulative(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 통계 초기화 (테스트용)
|
||||||
|
Future<bool> clear() async {
|
||||||
|
try {
|
||||||
|
final dir = await _getStorageDir();
|
||||||
|
final file = _getFile(dir);
|
||||||
|
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.delete();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,27 +10,42 @@ class ExpConstants {
|
|||||||
/// 기본 경험치 값
|
/// 기본 경험치 값
|
||||||
static const int baseExp = 100;
|
static const int baseExp = 100;
|
||||||
|
|
||||||
/// 레벨당 경험치 증가율 (1.15 = 15% 증가)
|
/// 레벨 구간별 경험치 증가율 (tiered growth rate)
|
||||||
static const double expGrowthRate = 1.15;
|
/// - 1-30: 1.10 (초반 빠른 진행)
|
||||||
|
/// - 31-60: 1.12 (중반 적정 속도)
|
||||||
|
/// - 61-100: 1.14 (후반 도전)
|
||||||
|
static double _getGrowthRate(int level) {
|
||||||
|
if (level <= 30) return 1.10;
|
||||||
|
if (level <= 60) return 1.12;
|
||||||
|
return 1.14;
|
||||||
|
}
|
||||||
|
|
||||||
/// 레벨업에 필요한 경험치 계산
|
/// 레벨업에 필요한 경험치 계산 (구간별 차등 적용)
|
||||||
///
|
///
|
||||||
/// 공식: baseExp * (expGrowthRate ^ level)
|
/// 조정 후 예상:
|
||||||
/// 레벨 10: ~405 exp
|
/// 레벨 10: ~259 exp
|
||||||
/// 레벨 50: ~108,366 exp
|
/// 레벨 30: ~1,744 exp
|
||||||
/// 레벨 100: ~11,739,085 exp
|
/// 레벨 50: ~9,705 exp
|
||||||
|
/// 레벨 80: ~133,860 exp
|
||||||
|
/// 레벨 100: ~636,840 exp
|
||||||
static int requiredExp(int level) {
|
static int requiredExp(int level) {
|
||||||
if (level <= 0) return baseExp;
|
if (level <= 0) return baseExp;
|
||||||
return (baseExp * _pow(expGrowthRate, level)).round();
|
|
||||||
|
// 구간별 복합 성장 계산
|
||||||
|
double result = baseExp.toDouble();
|
||||||
|
for (int i = 1; i <= level; i++) {
|
||||||
|
result *= _getGrowthRate(i);
|
||||||
|
}
|
||||||
|
return result.round();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 효율적인 거듭제곱 계산
|
/// 총 누적 경험치 계산 (특정 레벨까지)
|
||||||
static double _pow(double base, int exponent) {
|
static int totalExpToLevel(int level) {
|
||||||
double result = 1.0;
|
int total = 0;
|
||||||
for (int i = 0; i < exponent; i++) {
|
for (int i = 1; i < level; i++) {
|
||||||
result *= base;
|
total += requiredExp(i);
|
||||||
}
|
}
|
||||||
return result;
|
return total;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,40 +103,40 @@ class MonsterTypeMultiplier {
|
|||||||
gold: 1.0,
|
gold: 1.0,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// 정예: HP 2배, ATK 1.3배, DEF 1.2배, 보상 2배
|
/// 정예: HP 2배, ATK 1.3배, DEF 1.2배, EXP 3배 (상향), GOLD 2.5배
|
||||||
static const elite = MonsterTypeMultiplier(
|
static const elite = MonsterTypeMultiplier(
|
||||||
hp: 2.0,
|
hp: 2.0,
|
||||||
atk: 1.3,
|
atk: 1.3,
|
||||||
def: 1.2,
|
def: 1.2,
|
||||||
exp: 2.0,
|
exp: 3.0, // 2.0 → 3.0 상향
|
||||||
gold: 2.0,
|
gold: 2.5,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// 미니보스: HP 5배, ATK/DEF 1.5배, 보상 5배
|
/// 미니보스: HP 5배, ATK/DEF 1.5배, EXP 8배 (상향), GOLD 6배
|
||||||
static const miniboss = MonsterTypeMultiplier(
|
static const miniboss = MonsterTypeMultiplier(
|
||||||
hp: 5.0,
|
hp: 5.0,
|
||||||
atk: 1.5,
|
atk: 1.5,
|
||||||
def: 1.5,
|
def: 1.5,
|
||||||
exp: 5.0,
|
exp: 8.0, // 5.0 → 8.0 상향
|
||||||
gold: 5.0,
|
gold: 6.0,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// 보스: HP 10배, ATK/DEF 2배, EXP 15배, GOLD 10배
|
/// 보스: HP 8배 (하향), ATK/DEF 1.8배 (하향), EXP 25배 (상향), GOLD 15배
|
||||||
static const boss = MonsterTypeMultiplier(
|
static const boss = MonsterTypeMultiplier(
|
||||||
hp: 10.0,
|
hp: 8.0, // 10.0 → 8.0 하향 (플레이어 접근성 개선)
|
||||||
atk: 2.0,
|
atk: 1.8, // 2.0 → 1.8 하향
|
||||||
def: 2.0,
|
def: 1.8, // 2.0 → 1.8 하향
|
||||||
exp: 15.0,
|
exp: 25.0, // 15.0 → 25.0 상향
|
||||||
gold: 10.0,
|
gold: 15.0,
|
||||||
);
|
);
|
||||||
|
|
||||||
/// 최종 보스: HP 20배, ATK/DEF 2.5배, EXP 50배, GOLD 30배
|
/// 최종 보스: HP 12배 (하향), ATK/DEF 2.2배 (하향), EXP 80배 (상향), GOLD 50배
|
||||||
static const finalBoss = MonsterTypeMultiplier(
|
static const finalBoss = MonsterTypeMultiplier(
|
||||||
hp: 20.0,
|
hp: 12.0, // 20.0 → 12.0 대폭 하향 (클리어 가능성 확보)
|
||||||
atk: 2.5,
|
atk: 2.2, // 2.5 → 2.2 하향
|
||||||
def: 2.5,
|
def: 2.2, // 2.5 → 2.2 하향
|
||||||
exp: 50.0,
|
exp: 80.0, // 50.0 → 80.0 상향
|
||||||
gold: 30.0,
|
gold: 50.0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,6 +298,8 @@ class BossStats extends MonsterBaseStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Kernel Panic Archon (Act IV 보스, 레벨 80)
|
/// Kernel Panic Archon (Act IV 보스, 레벨 80)
|
||||||
|
///
|
||||||
|
/// Phase 6 밸런스 조정: enrageMultiplier 1.6 → 1.5
|
||||||
static BossStats kernelPanicArchon(int baseLevel) {
|
static BossStats kernelPanicArchon(int baseLevel) {
|
||||||
final base = MonsterBaseStats.generate(baseLevel, MonsterType.boss);
|
final base = MonsterBaseStats.generate(baseLevel, MonsterType.boss);
|
||||||
return BossStats(
|
return BossStats(
|
||||||
@@ -293,7 +310,7 @@ class BossStats extends MonsterBaseStats {
|
|||||||
gold: base.gold,
|
gold: base.gold,
|
||||||
phases: 3,
|
phases: 3,
|
||||||
enrageThreshold: 0.2,
|
enrageThreshold: 0.2,
|
||||||
enrageMultiplier: 1.6,
|
enrageMultiplier: 1.5, // 1.6 → 1.5 (분노 시 50% 스탯 증가)
|
||||||
hasShield: true,
|
hasShield: true,
|
||||||
shieldAmount: (base.hp * 0.2).round(),
|
shieldAmount: (base.hp * 0.2).round(),
|
||||||
abilities: [BossAbilityType.stunAttack],
|
abilities: [BossAbilityType.stunAttack],
|
||||||
@@ -301,6 +318,11 @@ class BossStats extends MonsterBaseStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Glitch God (최종 보스, 레벨 100)
|
/// Glitch God (최종 보스, 레벨 100)
|
||||||
|
///
|
||||||
|
/// Phase 6 밸런스 조정:
|
||||||
|
/// - enrageThreshold: 0.1 → 0.15 (분노 발동 시점 완화)
|
||||||
|
/// - enrageMultiplier: 2.0 → 1.7 (분노 시 스탯 증가 완화)
|
||||||
|
/// - shieldAmount: 50% → 35% (보호막 감소)
|
||||||
static BossStats glitchGod(int baseLevel) {
|
static BossStats glitchGod(int baseLevel) {
|
||||||
final base = MonsterBaseStats.generate(baseLevel, MonsterType.finalBoss);
|
final base = MonsterBaseStats.generate(baseLevel, MonsterType.finalBoss);
|
||||||
return BossStats(
|
return BossStats(
|
||||||
@@ -310,10 +332,10 @@ class BossStats extends MonsterBaseStats {
|
|||||||
exp: base.exp,
|
exp: base.exp,
|
||||||
gold: base.gold,
|
gold: base.gold,
|
||||||
phases: 5,
|
phases: 5,
|
||||||
enrageThreshold: 0.1,
|
enrageThreshold: 0.15, // 0.1 → 0.15 (15% HP에서 분노)
|
||||||
enrageMultiplier: 2.0,
|
enrageMultiplier: 1.7, // 2.0 → 1.7 (분노 시 70% 스탯 증가)
|
||||||
hasShield: true,
|
hasShield: true,
|
||||||
shieldAmount: (base.hp * 0.5).round(),
|
shieldAmount: (base.hp * 0.35).round(), // 0.5 → 0.35 (보호막 30% 감소)
|
||||||
abilities: [
|
abilities: [
|
||||||
BossAbilityType.phaseShift,
|
BossAbilityType.phaseShift,
|
||||||
BossAbilityType.multiAttack,
|
BossAbilityType.multiAttack,
|
||||||
@@ -398,11 +420,17 @@ class LevelTierSettings {
|
|||||||
class PlayerScaling {
|
class PlayerScaling {
|
||||||
PlayerScaling._();
|
PlayerScaling._();
|
||||||
|
|
||||||
/// 레벨당 HP 증가량
|
/// 레벨당 HP 증가량 (10 → 12 상향)
|
||||||
static const int hpPerLevel = 10;
|
static const int hpPerLevel = 12;
|
||||||
|
|
||||||
/// 레벨당 MP 증가량
|
/// 레벨당 MP 증가량 (5 → 6 상향)
|
||||||
static const int mpPerLevel = 5;
|
static const int mpPerLevel = 6;
|
||||||
|
|
||||||
|
/// CON당 HP 보너스 (5 → 6 상향)
|
||||||
|
static const int hpPerCon = 6;
|
||||||
|
|
||||||
|
/// INT당 MP 보너스 (3 → 4 상향)
|
||||||
|
static const int mpPerInt = 4;
|
||||||
|
|
||||||
/// 레벨업 시 HP/MP 계산
|
/// 레벨업 시 HP/MP 계산
|
||||||
static ({int hpMax, int mpMax}) calculateResources({
|
static ({int hpMax, int mpMax}) calculateResources({
|
||||||
@@ -412,8 +440,17 @@ class PlayerScaling {
|
|||||||
required int conBonus,
|
required int conBonus,
|
||||||
required int intBonus,
|
required int intBonus,
|
||||||
}) {
|
}) {
|
||||||
final hpMax = baseHp + (level - 1) * hpPerLevel + conBonus * 5;
|
final hpMax = baseHp + (level - 1) * hpPerLevel + conBonus * hpPerCon;
|
||||||
final mpMax = baseMp + (level - 1) * mpPerLevel + intBonus * 3;
|
final mpMax = baseMp + (level - 1) * mpPerLevel + intBonus * mpPerInt;
|
||||||
return (hpMax: hpMax, mpMax: mpMax);
|
return (hpMax: hpMax, mpMax: mpMax);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 레벨 구간별 ATK 보너스 (후반 DPS 보조)
|
||||||
|
/// - 레벨 60+: +1 ATK per level
|
||||||
|
/// - 레벨 80+: +2 ATK per level
|
||||||
|
static int bonusAtk(int level) {
|
||||||
|
if (level >= 80) return (level - 80) * 2 + 20;
|
||||||
|
if (level >= 60) return level - 60;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ import 'package:askiineverdie/src/features/game/widgets/task_progress_panel.dart
|
|||||||
import 'package:askiineverdie/src/features/game/widgets/active_buff_panel.dart';
|
import 'package:askiineverdie/src/features/game/widgets/active_buff_panel.dart';
|
||||||
import 'package:askiineverdie/src/features/game/layouts/mobile_carousel_layout.dart';
|
import 'package:askiineverdie/src/features/game/layouts/mobile_carousel_layout.dart';
|
||||||
import 'package:askiineverdie/src/features/settings/settings_screen.dart';
|
import 'package:askiineverdie/src/features/settings/settings_screen.dart';
|
||||||
|
import 'package:askiineverdie/src/features/game/widgets/statistics_dialog.dart';
|
||||||
|
import 'package:askiineverdie/src/features/game/widgets/help_dialog.dart';
|
||||||
import 'package:askiineverdie/src/core/storage/settings_repository.dart';
|
import 'package:askiineverdie/src/core/storage/settings_repository.dart';
|
||||||
|
import 'package:askiineverdie/src/core/audio/audio_service.dart';
|
||||||
|
|
||||||
/// 게임 진행 화면 (Main.dfm 기반 3패널 레이아웃)
|
/// 게임 진행 화면 (Main.dfm 기반 3패널 레이아웃)
|
||||||
///
|
///
|
||||||
@@ -41,6 +44,7 @@ class GamePlayScreen extends StatefulWidget {
|
|||||||
const GamePlayScreen({
|
const GamePlayScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
|
this.audioService,
|
||||||
this.forceCarouselLayout = false,
|
this.forceCarouselLayout = false,
|
||||||
this.forceDesktopLayout = false,
|
this.forceDesktopLayout = false,
|
||||||
this.onThemeModeChange,
|
this.onThemeModeChange,
|
||||||
@@ -49,6 +53,9 @@ class GamePlayScreen extends StatefulWidget {
|
|||||||
|
|
||||||
final GameSessionController controller;
|
final GameSessionController controller;
|
||||||
|
|
||||||
|
/// 오디오 서비스 (BGM/SFX 재생)
|
||||||
|
final AudioService? audioService;
|
||||||
|
|
||||||
/// 테스트 모드: 웹에서도 모바일 캐로셀 레이아웃 강제 사용
|
/// 테스트 모드: 웹에서도 모바일 캐로셀 레이아웃 강제 사용
|
||||||
final bool forceCarouselLayout;
|
final bool forceCarouselLayout;
|
||||||
|
|
||||||
@@ -89,6 +96,13 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
// 전투 이벤트 추적 (마지막 처리된 이벤트 수)
|
// 전투 이벤트 추적 (마지막 처리된 이벤트 수)
|
||||||
int _lastProcessedEventCount = 0;
|
int _lastProcessedEventCount = 0;
|
||||||
|
|
||||||
|
// 오디오 상태 추적
|
||||||
|
bool _wasInCombat = false;
|
||||||
|
|
||||||
|
// 사운드 볼륨 상태 (모바일 설정 UI용)
|
||||||
|
double _bgmVolume = 0.7;
|
||||||
|
double _sfxVolume = 0.8;
|
||||||
|
|
||||||
void _checkSpecialEvents(GameState state) {
|
void _checkSpecialEvents(GameState state) {
|
||||||
// Phase 8: 태스크 변경 시 로그 추가
|
// Phase 8: 태스크 변경 시 로그 추가
|
||||||
final currentCaption = state.progress.currentTask.caption;
|
final currentCaption = state.progress.currentTask.caption;
|
||||||
@@ -102,6 +116,9 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
// 전투 이벤트 처리 (Combat Events)
|
// 전투 이벤트 처리 (Combat Events)
|
||||||
_processCombatEvents(state);
|
_processCombatEvents(state);
|
||||||
|
|
||||||
|
// 오디오: 전투 상태 변경 시 BGM 전환
|
||||||
|
_updateBgmForCombatState(state);
|
||||||
|
|
||||||
// 레벨업 감지
|
// 레벨업 감지
|
||||||
if (state.traits.level > _lastLevel && _lastLevel > 0) {
|
if (state.traits.level > _lastLevel && _lastLevel > 0) {
|
||||||
_specialAnimation = AsciiAnimationType.levelUp;
|
_specialAnimation = AsciiAnimationType.levelUp;
|
||||||
@@ -110,6 +127,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
|
||||||
|
widget.audioService?.playSfx('level_up');
|
||||||
_resetSpecialAnimationAfterFrame();
|
_resetSpecialAnimationAfterFrame();
|
||||||
|
|
||||||
// Phase 9: Act 변경 감지 (레벨 기반)
|
// Phase 9: Act 변경 감지 (레벨 기반)
|
||||||
@@ -147,6 +166,8 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
CombatLogType.questComplete,
|
CombatLogType.questComplete,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// 오디오: 퀘스트 완료 SFX
|
||||||
|
widget.audioService?.playSfx('quest_complete');
|
||||||
_resetSpecialAnimationAfterFrame();
|
_resetSpecialAnimationAfterFrame();
|
||||||
}
|
}
|
||||||
_lastQuestCount = state.progress.questCount;
|
_lastQuestCount = state.progress.questCount;
|
||||||
@@ -192,11 +213,74 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
for (final event in newEvents) {
|
for (final event in newEvents) {
|
||||||
final (message, type) = _formatCombatEvent(event);
|
final (message, type) = _formatCombatEvent(event);
|
||||||
_addCombatLog(message, type);
|
_addCombatLog(message, type);
|
||||||
|
|
||||||
|
// 오디오: 전투 이벤트에 따른 SFX 재생
|
||||||
|
_playCombatEventSfx(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
_lastProcessedEventCount = events.length;
|
_lastProcessedEventCount = events.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 전투 상태에 따른 BGM 전환
|
||||||
|
void _updateBgmForCombatState(GameState state) {
|
||||||
|
final audio = widget.audioService;
|
||||||
|
if (audio == null) return;
|
||||||
|
|
||||||
|
final combat = state.progress.currentCombat;
|
||||||
|
final isInCombat = combat != null && combat.isActive;
|
||||||
|
|
||||||
|
if (isInCombat && !_wasInCombat) {
|
||||||
|
// 전투 시작: 보스 여부에 따라 BGM 선택
|
||||||
|
// 몬스터 레벨이 플레이어보다 5 이상 높으면 보스로 간주
|
||||||
|
final monsterLevel = state.progress.currentTask.monsterLevel ?? 0;
|
||||||
|
final playerLevel = state.traits.level;
|
||||||
|
final isBoss = monsterLevel >= playerLevel + 5;
|
||||||
|
|
||||||
|
if (isBoss) {
|
||||||
|
audio.playBgm('boss');
|
||||||
|
} else {
|
||||||
|
audio.playBgm('battle');
|
||||||
|
}
|
||||||
|
} else if (!isInCombat && _wasInCombat) {
|
||||||
|
// 전투 종료: 마을 BGM으로 복귀
|
||||||
|
audio.playBgm('town');
|
||||||
|
}
|
||||||
|
|
||||||
|
_wasInCombat = isInCombat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 전투 이벤트에 따른 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');
|
||||||
|
case CombatEventType.playerSkill:
|
||||||
|
audio.playSfx('skill');
|
||||||
|
case CombatEventType.playerHeal:
|
||||||
|
case CombatEventType.playerPotion:
|
||||||
|
audio.playSfx('item');
|
||||||
|
case CombatEventType.potionDrop:
|
||||||
|
audio.playSfx('item');
|
||||||
|
case CombatEventType.playerBuff:
|
||||||
|
case CombatEventType.playerDebuff:
|
||||||
|
audio.playSfx('skill');
|
||||||
|
case CombatEventType.dotTick:
|
||||||
|
// DOT 틱은 SFX 없음 (너무 자주 발생)
|
||||||
|
break;
|
||||||
|
case CombatEventType.playerEvade:
|
||||||
|
case CombatEventType.monsterEvade:
|
||||||
|
case CombatEventType.playerBlock:
|
||||||
|
case CombatEventType.playerParry:
|
||||||
|
// 회피/방어는 별도 SFX 없음
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 전투 이벤트를 메시지와 타입으로 변환
|
/// 전투 이벤트를 메시지와 타입으로 변환
|
||||||
(String, CombatLogType) _formatCombatEvent(CombatEvent event) {
|
(String, CombatLogType) _formatCombatEvent(CombatEvent event) {
|
||||||
final target = event.targetName ?? '';
|
final target = event.targetName ?? '';
|
||||||
@@ -256,6 +340,10 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
game_l10n.combatBuffActivated(skillName),
|
game_l10n.combatBuffActivated(skillName),
|
||||||
CombatLogType.buff,
|
CombatLogType.buff,
|
||||||
),
|
),
|
||||||
|
CombatEventType.playerDebuff => (
|
||||||
|
game_l10n.combatDebuffApplied(skillName, target),
|
||||||
|
CombatLogType.debuff,
|
||||||
|
),
|
||||||
CombatEventType.dotTick => (
|
CombatEventType.dotTick => (
|
||||||
game_l10n.combatDotTick(skillName, event.damage),
|
game_l10n.combatDotTick(skillName, event.damage),
|
||||||
CombatLogType.dotTick,
|
CombatLogType.dotTick,
|
||||||
@@ -361,6 +449,29 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
_lastQuestCount = state.progress.questCount;
|
_lastQuestCount = state.progress.questCount;
|
||||||
_lastPlotStageCount = state.progress.plotStageCount;
|
_lastPlotStageCount = state.progress.plotStageCount;
|
||||||
_lastAct = getActForLevel(state.traits.level);
|
_lastAct = getActForLevel(state.traits.level);
|
||||||
|
|
||||||
|
// 초기 전투 상태 확인
|
||||||
|
final combat = state.progress.currentCombat;
|
||||||
|
_wasInCombat = combat != null && combat.isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 누적 통계 로드
|
||||||
|
widget.controller.loadCumulativeStats();
|
||||||
|
|
||||||
|
// 초기 BGM 재생 (마을 테마)
|
||||||
|
widget.audioService?.playBgm('town');
|
||||||
|
|
||||||
|
// 오디오 볼륨 초기화
|
||||||
|
_initAudioVolumes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 오디오 볼륨 초기화 (설정에서 로드)
|
||||||
|
Future<void> _initAudioVolumes() async {
|
||||||
|
final audio = widget.audioService;
|
||||||
|
if (audio != null) {
|
||||||
|
_bgmVolume = audio.bgmVolume;
|
||||||
|
_sfxVolume = audio.sfxVolume;
|
||||||
|
if (mounted) setState(() {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,6 +576,15 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
return platform == TargetPlatform.iOS || platform == TargetPlatform.android;
|
return platform == TargetPlatform.iOS || platform == TargetPlatform.android;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 통계 다이얼로그 표시
|
||||||
|
void _showStatisticsDialog(BuildContext context) {
|
||||||
|
StatisticsDialog.show(
|
||||||
|
context,
|
||||||
|
session: widget.controller.sessionStats,
|
||||||
|
cumulative: widget.controller.cumulativeStats,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 설정 화면 표시
|
/// 설정 화면 표시
|
||||||
void _showSettingsScreen(BuildContext context) {
|
void _showSettingsScreen(BuildContext context) {
|
||||||
final settingsRepo = SettingsRepository();
|
final settingsRepo = SettingsRepository();
|
||||||
@@ -614,6 +734,17 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
},
|
},
|
||||||
currentThemeMode: widget.currentThemeMode,
|
currentThemeMode: widget.currentThemeMode,
|
||||||
onThemeModeChange: widget.onThemeModeChange,
|
onThemeModeChange: widget.onThemeModeChange,
|
||||||
|
// 사운드 설정
|
||||||
|
bgmVolume: _bgmVolume,
|
||||||
|
sfxVolume: _sfxVolume,
|
||||||
|
onBgmVolumeChange: (volume) {
|
||||||
|
setState(() => _bgmVolume = volume);
|
||||||
|
widget.audioService?.setBgmVolume(volume);
|
||||||
|
},
|
||||||
|
onSfxVolumeChange: (volume) {
|
||||||
|
setState(() => _sfxVolume = volume);
|
||||||
|
widget.audioService?.setSfxVolume(volume);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
// 사망 오버레이
|
// 사망 오버레이
|
||||||
if (state.isDead && state.deathInfo != null)
|
if (state.isDead && state.deathInfo != null)
|
||||||
@@ -666,6 +797,18 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
onPressed: () => widget.controller.loop?.cheatCompletePlot(),
|
onPressed: () => widget.controller.loop?.cheatCompletePlot(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
// 통계 버튼
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.bar_chart),
|
||||||
|
tooltip: game_l10n.uiStatistics,
|
||||||
|
onPressed: () => _showStatisticsDialog(context),
|
||||||
|
),
|
||||||
|
// 도움말 버튼
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.help_outline),
|
||||||
|
tooltip: game_l10n.uiHelp,
|
||||||
|
onPressed: () => HelpDialog.show(context),
|
||||||
|
),
|
||||||
// 설정 버튼
|
// 설정 버튼
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.settings),
|
icon: const Icon(Icons.settings),
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import 'package:askiineverdie/src/core/engine/progress_service.dart';
|
|||||||
import 'package:askiineverdie/src/core/engine/resurrection_service.dart';
|
import 'package:askiineverdie/src/core/engine/resurrection_service.dart';
|
||||||
import 'package:askiineverdie/src/core/engine/shop_service.dart';
|
import 'package:askiineverdie/src/core/engine/shop_service.dart';
|
||||||
import 'package:askiineverdie/src/core/model/game_state.dart';
|
import 'package:askiineverdie/src/core/model/game_state.dart';
|
||||||
|
import 'package:askiineverdie/src/core/model/game_statistics.dart';
|
||||||
import 'package:askiineverdie/src/core/storage/save_manager.dart';
|
import 'package:askiineverdie/src/core/storage/save_manager.dart';
|
||||||
|
import 'package:askiineverdie/src/core/storage/statistics_storage.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
enum GameSessionStatus { idle, loading, running, error, dead }
|
enum GameSessionStatus { idle, loading, running, error, dead }
|
||||||
@@ -18,12 +20,15 @@ class GameSessionController extends ChangeNotifier {
|
|||||||
this.autoSaveConfig = const AutoSaveConfig(),
|
this.autoSaveConfig = const AutoSaveConfig(),
|
||||||
Duration tickInterval = const Duration(milliseconds: 50),
|
Duration tickInterval = const Duration(milliseconds: 50),
|
||||||
DateTime Function()? now,
|
DateTime Function()? now,
|
||||||
|
StatisticsStorage? statisticsStorage,
|
||||||
}) : _tickInterval = tickInterval,
|
}) : _tickInterval = tickInterval,
|
||||||
_now = now ?? DateTime.now;
|
_now = now ?? DateTime.now,
|
||||||
|
_statisticsStorage = statisticsStorage ?? StatisticsStorage();
|
||||||
|
|
||||||
final ProgressService progressService;
|
final ProgressService progressService;
|
||||||
final SaveManager saveManager;
|
final SaveManager saveManager;
|
||||||
final AutoSaveConfig autoSaveConfig;
|
final AutoSaveConfig autoSaveConfig;
|
||||||
|
final StatisticsStorage _statisticsStorage;
|
||||||
|
|
||||||
final Duration _tickInterval;
|
final Duration _tickInterval;
|
||||||
final DateTime Function() _now;
|
final DateTime Function() _now;
|
||||||
@@ -36,12 +41,26 @@ class GameSessionController extends ChangeNotifier {
|
|||||||
GameState? _state;
|
GameState? _state;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
||||||
|
// 통계 관련 필드
|
||||||
|
SessionStatistics _sessionStats = SessionStatistics.empty();
|
||||||
|
CumulativeStatistics _cumulativeStats = CumulativeStatistics.empty();
|
||||||
|
int _previousLevel = 0;
|
||||||
|
int _previousGold = 0;
|
||||||
|
int _previousMonstersKilled = 0;
|
||||||
|
int _previousQuestsCompleted = 0;
|
||||||
|
|
||||||
GameSessionStatus get status => _status;
|
GameSessionStatus get status => _status;
|
||||||
GameState? get state => _state;
|
GameState? get state => _state;
|
||||||
String? get error => _error;
|
String? get error => _error;
|
||||||
bool get isRunning => _status == GameSessionStatus.running;
|
bool get isRunning => _status == GameSessionStatus.running;
|
||||||
bool get cheatsEnabled => _cheatsEnabled;
|
bool get cheatsEnabled => _cheatsEnabled;
|
||||||
|
|
||||||
|
/// 현재 세션 통계
|
||||||
|
SessionStatistics get sessionStats => _sessionStats;
|
||||||
|
|
||||||
|
/// 누적 통계
|
||||||
|
CumulativeStatistics get cumulativeStats => _cumulativeStats;
|
||||||
|
|
||||||
/// 현재 ProgressLoop 인스턴스 (치트 기능용)
|
/// 현재 ProgressLoop 인스턴스 (치트 기능용)
|
||||||
ProgressLoop? get loop => _loop;
|
ProgressLoop? get loop => _loop;
|
||||||
|
|
||||||
@@ -62,6 +81,13 @@ class GameSessionController extends ChangeNotifier {
|
|||||||
_status = GameSessionStatus.running;
|
_status = GameSessionStatus.running;
|
||||||
_cheatsEnabled = cheatsEnabled;
|
_cheatsEnabled = cheatsEnabled;
|
||||||
|
|
||||||
|
// 통계 초기화
|
||||||
|
if (isNewGame) {
|
||||||
|
_sessionStats = SessionStatistics.empty();
|
||||||
|
await _statisticsStorage.recordGameStart();
|
||||||
|
}
|
||||||
|
_initPreviousValues(state);
|
||||||
|
|
||||||
_loop = ProgressLoop(
|
_loop = ProgressLoop(
|
||||||
initialState: state,
|
initialState: state,
|
||||||
progressService: progressService,
|
progressService: progressService,
|
||||||
@@ -74,6 +100,7 @@ class GameSessionController extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
|
|
||||||
_subscription = _loop!.stream.listen((next) {
|
_subscription = _loop!.stream.listen((next) {
|
||||||
|
_updateStatistics(next);
|
||||||
_state = next;
|
_state = next;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
});
|
});
|
||||||
@@ -82,6 +109,76 @@ class GameSessionController extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 이전 값 초기화 (통계 변화 추적용)
|
||||||
|
void _initPreviousValues(GameState state) {
|
||||||
|
_previousLevel = state.traits.level;
|
||||||
|
_previousGold = state.inventory.gold;
|
||||||
|
_previousMonstersKilled = state.progress.monstersKilled;
|
||||||
|
_previousQuestsCompleted = state.progress.questCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 상태 변화에 따른 통계 업데이트
|
||||||
|
void _updateStatistics(GameState next) {
|
||||||
|
// 플레이 시간 업데이트
|
||||||
|
_sessionStats = _sessionStats.updatePlayTime(next.skillSystem.elapsedMs);
|
||||||
|
|
||||||
|
// 레벨업 감지
|
||||||
|
if (next.traits.level > _previousLevel) {
|
||||||
|
final levelUps = next.traits.level - _previousLevel;
|
||||||
|
for (var i = 0; i < levelUps; i++) {
|
||||||
|
_sessionStats = _sessionStats.recordLevelUp();
|
||||||
|
}
|
||||||
|
_previousLevel = next.traits.level;
|
||||||
|
|
||||||
|
// 최고 레벨 업데이트
|
||||||
|
unawaited(_statisticsStorage.updateHighestLevel(next.traits.level));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 골드 변화 감지
|
||||||
|
if (next.inventory.gold > _previousGold) {
|
||||||
|
final earned = next.inventory.gold - _previousGold;
|
||||||
|
_sessionStats = _sessionStats.recordGoldEarned(earned);
|
||||||
|
|
||||||
|
// 최대 골드 업데이트
|
||||||
|
unawaited(_statisticsStorage.updateHighestGold(next.inventory.gold));
|
||||||
|
} else if (next.inventory.gold < _previousGold) {
|
||||||
|
final spent = _previousGold - next.inventory.gold;
|
||||||
|
_sessionStats = _sessionStats.recordGoldSpent(spent);
|
||||||
|
}
|
||||||
|
_previousGold = next.inventory.gold;
|
||||||
|
|
||||||
|
// 몬스터 처치 감지
|
||||||
|
if (next.progress.monstersKilled > _previousMonstersKilled) {
|
||||||
|
final kills = next.progress.monstersKilled - _previousMonstersKilled;
|
||||||
|
for (var i = 0; i < kills; i++) {
|
||||||
|
_sessionStats = _sessionStats.recordKill();
|
||||||
|
}
|
||||||
|
_previousMonstersKilled = next.progress.monstersKilled;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 퀘스트 완료 감지
|
||||||
|
if (next.progress.questCount > _previousQuestsCompleted) {
|
||||||
|
final quests = next.progress.questCount - _previousQuestsCompleted;
|
||||||
|
for (var i = 0; i < quests; i++) {
|
||||||
|
_sessionStats = _sessionStats.recordQuestComplete();
|
||||||
|
}
|
||||||
|
_previousQuestsCompleted = next.progress.questCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 누적 통계 로드
|
||||||
|
Future<void> loadCumulativeStats() async {
|
||||||
|
_cumulativeStats = await _statisticsStorage.loadCumulative();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 세션 통계를 누적 통계에 병합
|
||||||
|
Future<void> mergeSessionStats() async {
|
||||||
|
await _statisticsStorage.mergeSession(_sessionStats);
|
||||||
|
_cumulativeStats = await _statisticsStorage.loadCumulative();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> loadAndStart({
|
Future<void> loadAndStart({
|
||||||
String? fileName,
|
String? fileName,
|
||||||
bool cheatsEnabled = false,
|
bool cheatsEnabled = false,
|
||||||
@@ -148,6 +245,7 @@ class GameSessionController extends ChangeNotifier {
|
|||||||
|
|
||||||
/// 플레이어 사망 콜백 (ProgressLoop에서 호출)
|
/// 플레이어 사망 콜백 (ProgressLoop에서 호출)
|
||||||
void _onPlayerDied() {
|
void _onPlayerDied() {
|
||||||
|
_sessionStats = _sessionStats.recordDeath();
|
||||||
_status = GameSessionStatus.dead;
|
_status = GameSessionStatus.dead;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ class MobileCarouselLayout extends StatefulWidget {
|
|||||||
this.specialAnimation,
|
this.specialAnimation,
|
||||||
this.currentThemeMode = ThemeMode.system,
|
this.currentThemeMode = ThemeMode.system,
|
||||||
this.onThemeModeChange,
|
this.onThemeModeChange,
|
||||||
|
this.bgmVolume = 0.7,
|
||||||
|
this.sfxVolume = 0.8,
|
||||||
|
this.onBgmVolumeChange,
|
||||||
|
this.onSfxVolumeChange,
|
||||||
});
|
});
|
||||||
|
|
||||||
final GameState state;
|
final GameState state;
|
||||||
@@ -56,6 +60,18 @@ class MobileCarouselLayout extends StatefulWidget {
|
|||||||
final ThemeMode currentThemeMode;
|
final ThemeMode currentThemeMode;
|
||||||
final void Function(ThemeMode mode)? onThemeModeChange;
|
final void Function(ThemeMode mode)? onThemeModeChange;
|
||||||
|
|
||||||
|
/// BGM 볼륨 (0.0 ~ 1.0)
|
||||||
|
final double bgmVolume;
|
||||||
|
|
||||||
|
/// SFX 볼륨 (0.0 ~ 1.0)
|
||||||
|
final double sfxVolume;
|
||||||
|
|
||||||
|
/// BGM 볼륨 변경 콜백
|
||||||
|
final void Function(double volume)? onBgmVolumeChange;
|
||||||
|
|
||||||
|
/// SFX 볼륨 변경 콜백
|
||||||
|
final void Function(double volume)? onSfxVolumeChange;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<MobileCarouselLayout> createState() => _MobileCarouselLayoutState();
|
State<MobileCarouselLayout> createState() => _MobileCarouselLayoutState();
|
||||||
}
|
}
|
||||||
@@ -200,6 +216,108 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 사운드 상태 텍스트 가져오기
|
||||||
|
String _getSoundStatus() {
|
||||||
|
final bgmPercent = (widget.bgmVolume * 100).round();
|
||||||
|
final sfxPercent = (widget.sfxVolume * 100).round();
|
||||||
|
if (bgmPercent == 0 && sfxPercent == 0) {
|
||||||
|
return l10n.uiSoundOff;
|
||||||
|
}
|
||||||
|
return 'BGM $bgmPercent% / SFX $sfxPercent%';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 사운드 설정 다이얼로그 표시
|
||||||
|
void _showSoundDialog(BuildContext context) {
|
||||||
|
// StatefulBuilder를 사용하여 다이얼로그 내 상태 관리
|
||||||
|
var bgmVolume = widget.bgmVolume;
|
||||||
|
var sfxVolume = widget.sfxVolume;
|
||||||
|
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => StatefulBuilder(
|
||||||
|
builder: (context, setDialogState) => AlertDialog(
|
||||||
|
title: Text(l10n.uiSound),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// BGM 볼륨
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
bgmVolume == 0 ? Icons.music_off : Icons.music_note,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(l10n.uiBgmVolume),
|
||||||
|
Text('${(bgmVolume * 100).round()}%'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Slider(
|
||||||
|
value: bgmVolume,
|
||||||
|
onChanged: (value) {
|
||||||
|
setDialogState(() => bgmVolume = value);
|
||||||
|
widget.onBgmVolumeChange?.call(value);
|
||||||
|
},
|
||||||
|
divisions: 10,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// SFX 볼륨
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
sfxVolume == 0 ? Icons.volume_off : Icons.volume_up,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(l10n.uiSfxVolume),
|
||||||
|
Text('${(sfxVolume * 100).round()}%'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Slider(
|
||||||
|
value: sfxVolume,
|
||||||
|
onChanged: (value) {
|
||||||
|
setDialogState(() => sfxVolume = value);
|
||||||
|
widget.onSfxVolumeChange?.call(value);
|
||||||
|
},
|
||||||
|
divisions: 10,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: Text(l10n.buttonConfirm),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 세이브 삭제 확인 다이얼로그 표시
|
/// 세이브 삭제 확인 다이얼로그 표시
|
||||||
void _showDeleteConfirmDialog(BuildContext context) {
|
void _showDeleteConfirmDialog(BuildContext context) {
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
@@ -324,6 +442,27 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// 사운드 설정
|
||||||
|
if (widget.onBgmVolumeChange != null ||
|
||||||
|
widget.onSfxVolumeChange != null)
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
widget.bgmVolume == 0 && widget.sfxVolume == 0
|
||||||
|
? Icons.volume_off
|
||||||
|
: Icons.volume_up,
|
||||||
|
color: Colors.indigo,
|
||||||
|
),
|
||||||
|
title: Text(l10n.uiSound),
|
||||||
|
trailing: Text(
|
||||||
|
_getSoundStatus(),
|
||||||
|
style: TextStyle(color: Theme.of(context).colorScheme.primary),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_showSoundDialog(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
const Divider(),
|
const Divider(),
|
||||||
|
|
||||||
// 저장
|
// 저장
|
||||||
@@ -381,7 +520,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
|
|||||||
actions: [
|
actions: [
|
||||||
// 옵션 버튼
|
// 옵션 버튼
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.more_vert),
|
icon: const Icon(Icons.settings),
|
||||||
onPressed: () => _showOptionsMenu(context),
|
onPressed: () => _showOptionsMenu(context),
|
||||||
tooltip: l10n.menuOptions,
|
tooltip: l10n.menuOptions,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -285,6 +285,15 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
|||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// 디버프 적용 → idle 페이즈 유지
|
||||||
|
CombatEventType.playerDebuff => (
|
||||||
|
BattlePhase.idle,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
|
||||||
// DOT 틱 → attack 페이즈 (지속 피해)
|
// DOT 틱 → attack 페이즈 (지속 피해)
|
||||||
CombatEventType.dotTick => (
|
CombatEventType.dotTick => (
|
||||||
BattlePhase.attack,
|
BattlePhase.attack,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ enum CombatLogType {
|
|||||||
parry, // 무기 쳐내기
|
parry, // 무기 쳐내기
|
||||||
monsterAttack, // 몬스터 공격
|
monsterAttack, // 몬스터 공격
|
||||||
buff, // 버프 활성화
|
buff, // 버프 활성화
|
||||||
|
debuff, // 디버프 적용
|
||||||
dotTick, // DOT 틱 데미지
|
dotTick, // DOT 틱 데미지
|
||||||
potion, // 물약 사용
|
potion, // 물약 사용
|
||||||
potionDrop, // 물약 드랍
|
potionDrop, // 물약 드랍
|
||||||
@@ -166,6 +167,7 @@ class _LogEntryTile extends StatelessWidget {
|
|||||||
Icons.dangerous,
|
Icons.dangerous,
|
||||||
),
|
),
|
||||||
CombatLogType.buff => (Colors.lightBlue.shade300, Icons.trending_up),
|
CombatLogType.buff => (Colors.lightBlue.shade300, Icons.trending_up),
|
||||||
|
CombatLogType.debuff => (Colors.deepOrange.shade300, Icons.trending_down),
|
||||||
CombatLogType.dotTick => (Colors.deepPurple.shade300, Icons.whatshot),
|
CombatLogType.dotTick => (Colors.deepPurple.shade300, Icons.whatshot),
|
||||||
CombatLogType.potion => (Colors.pink.shade300, Icons.local_drink),
|
CombatLogType.potion => (Colors.pink.shade300, Icons.local_drink),
|
||||||
CombatLogType.potionDrop => (Colors.lime.shade300, Icons.card_giftcard),
|
CombatLogType.potionDrop => (Colors.lime.shade300, Icons.card_giftcard),
|
||||||
|
|||||||
@@ -429,6 +429,11 @@ class DeathOverlay extends StatelessWidget {
|
|||||||
Colors.lightBlue.shade300,
|
Colors.lightBlue.shade300,
|
||||||
l10n.combatBuffActivated(event.skillName ?? ''),
|
l10n.combatBuffActivated(event.skillName ?? ''),
|
||||||
),
|
),
|
||||||
|
CombatEventType.playerDebuff => (
|
||||||
|
Icons.trending_down,
|
||||||
|
Colors.deepOrange.shade300,
|
||||||
|
l10n.combatDebuffApplied(event.skillName ?? '', target),
|
||||||
|
),
|
||||||
CombatEventType.dotTick => (
|
CombatEventType.dotTick => (
|
||||||
Icons.whatshot,
|
Icons.whatshot,
|
||||||
Colors.deepOrange.shade300,
|
Colors.deepOrange.shade300,
|
||||||
|
|||||||
553
lib/src/features/game/widgets/help_dialog.dart
Normal file
553
lib/src/features/game/widgets/help_dialog.dart
Normal file
@@ -0,0 +1,553 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// 도움말 다이얼로그 (Help Dialog)
|
||||||
|
///
|
||||||
|
/// 게임 메카닉과 UI 설명을 제공
|
||||||
|
class HelpDialog extends StatefulWidget {
|
||||||
|
const HelpDialog({super.key});
|
||||||
|
|
||||||
|
/// 다이얼로그 표시
|
||||||
|
static Future<void> show(BuildContext context) {
|
||||||
|
return showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => const HelpDialog(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<HelpDialog> createState() => _HelpDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HelpDialogState extends State<HelpDialog>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late TabController _tabController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tabController = TabController(length: 4, vsync: this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isKorean = Localizations.localeOf(context).languageCode == 'ko';
|
||||||
|
final isJapanese = Localizations.localeOf(context).languageCode == 'ja';
|
||||||
|
|
||||||
|
return Dialog(
|
||||||
|
child: Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 500, maxHeight: 600),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// 헤더
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.primaryContainer,
|
||||||
|
borderRadius: const BorderRadius.vertical(
|
||||||
|
top: Radius.circular(28),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.help_outline,
|
||||||
|
color: theme.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
isKorean
|
||||||
|
? '게임 도움말'
|
||||||
|
: isJapanese
|
||||||
|
? 'ゲームヘルプ'
|
||||||
|
: 'Game Help',
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
color: theme.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 탭 바
|
||||||
|
TabBar(
|
||||||
|
controller: _tabController,
|
||||||
|
isScrollable: true,
|
||||||
|
tabs: [
|
||||||
|
Tab(
|
||||||
|
text: isKorean
|
||||||
|
? '기본'
|
||||||
|
: isJapanese
|
||||||
|
? '基本'
|
||||||
|
: 'Basics',
|
||||||
|
),
|
||||||
|
Tab(
|
||||||
|
text: isKorean
|
||||||
|
? '전투'
|
||||||
|
: isJapanese
|
||||||
|
? '戦闘'
|
||||||
|
: 'Combat',
|
||||||
|
),
|
||||||
|
Tab(
|
||||||
|
text: isKorean
|
||||||
|
? '스킬'
|
||||||
|
: isJapanese
|
||||||
|
? 'スキル'
|
||||||
|
: 'Skills',
|
||||||
|
),
|
||||||
|
Tab(
|
||||||
|
text: isKorean
|
||||||
|
? 'UI'
|
||||||
|
: isJapanese
|
||||||
|
? 'UI'
|
||||||
|
: 'UI',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// 탭 내용
|
||||||
|
Expanded(
|
||||||
|
child: TabBarView(
|
||||||
|
controller: _tabController,
|
||||||
|
children: [
|
||||||
|
_BasicsHelpView(
|
||||||
|
isKorean: isKorean,
|
||||||
|
isJapanese: isJapanese,
|
||||||
|
),
|
||||||
|
_CombatHelpView(
|
||||||
|
isKorean: isKorean,
|
||||||
|
isJapanese: isJapanese,
|
||||||
|
),
|
||||||
|
_SkillsHelpView(
|
||||||
|
isKorean: isKorean,
|
||||||
|
isJapanese: isJapanese,
|
||||||
|
),
|
||||||
|
_UIHelpView(
|
||||||
|
isKorean: isKorean,
|
||||||
|
isJapanese: isJapanese,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 기본 도움말 뷰
|
||||||
|
class _BasicsHelpView extends StatelessWidget {
|
||||||
|
const _BasicsHelpView({
|
||||||
|
required this.isKorean,
|
||||||
|
required this.isJapanese,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool isKorean;
|
||||||
|
final bool isJapanese;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
_HelpSection(
|
||||||
|
icon: Icons.info_outline,
|
||||||
|
title: isKorean
|
||||||
|
? '게임 소개'
|
||||||
|
: isJapanese
|
||||||
|
? 'ゲーム紹介'
|
||||||
|
: 'About the Game',
|
||||||
|
content: isKorean
|
||||||
|
? 'Askii Never Die는 자동 진행 RPG입니다. 캐릭터가 자동으로 몬스터와 싸우고, '
|
||||||
|
'퀘스트를 완료하며, 레벨업합니다. 여러분은 장비와 스킬을 관리하면 됩니다.'
|
||||||
|
: isJapanese
|
||||||
|
? 'Askii Never Dieは自動進行RPGです。キャラクターが自動でモンスターと戦い、'
|
||||||
|
'クエストを完了し、レベルアップします。装備とスキルの管理だけで大丈夫です。'
|
||||||
|
: 'Askii Never Die is an idle RPG. Your character automatically fights monsters, '
|
||||||
|
'completes quests, and levels up. You manage equipment and skills.',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_HelpSection(
|
||||||
|
icon: Icons.trending_up,
|
||||||
|
title: isKorean
|
||||||
|
? '진행 방식'
|
||||||
|
: isJapanese
|
||||||
|
? '進行方式'
|
||||||
|
: 'Progression',
|
||||||
|
content: isKorean
|
||||||
|
? '• 몬스터 처치 → 전리품 획득 → 장비 업그레이드\n'
|
||||||
|
'• 경험치 획득 → 레벨업 → 스탯 상승\n'
|
||||||
|
'• 퀘스트 완료 → 보상 획득\n'
|
||||||
|
'• 플롯 진행 → 새로운 Act 해금'
|
||||||
|
: isJapanese
|
||||||
|
? '• モンスター討伐 → 戦利品獲得 → 装備アップグレード\n'
|
||||||
|
'• 経験値獲得 → レベルアップ → ステータス上昇\n'
|
||||||
|
'• クエスト完了 → 報酬獲得\n'
|
||||||
|
'• プロット進行 → 新しいAct解放'
|
||||||
|
: '• Kill monsters → Get loot → Upgrade equipment\n'
|
||||||
|
'• Gain XP → Level up → Stats increase\n'
|
||||||
|
'• Complete quests → Get rewards\n'
|
||||||
|
'• Progress plot → Unlock new Acts',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_HelpSection(
|
||||||
|
icon: Icons.save,
|
||||||
|
title: isKorean
|
||||||
|
? '저장'
|
||||||
|
: isJapanese
|
||||||
|
? 'セーブ'
|
||||||
|
: 'Saving',
|
||||||
|
content: isKorean
|
||||||
|
? '게임은 자동으로 저장됩니다. 레벨업, 퀘스트 완료, Act 진행 시 자동 저장됩니다. '
|
||||||
|
'뒤로 가기 시 저장 여부를 선택할 수 있습니다.'
|
||||||
|
: isJapanese
|
||||||
|
? 'ゲームは自動保存されます。レベルアップ、クエスト完了、Act進行時に自動保存されます。'
|
||||||
|
'戻る時に保存するかどうか選択できます。'
|
||||||
|
: 'The game auto-saves. It saves on level up, quest completion, and Act progression. '
|
||||||
|
'When exiting, you can choose whether to save.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 전투 도움말 뷰
|
||||||
|
class _CombatHelpView extends StatelessWidget {
|
||||||
|
const _CombatHelpView({
|
||||||
|
required this.isKorean,
|
||||||
|
required this.isJapanese,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool isKorean;
|
||||||
|
final bool isJapanese;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
_HelpSection(
|
||||||
|
icon: Icons.sports_mma,
|
||||||
|
title: isKorean
|
||||||
|
? '전투 시스템'
|
||||||
|
: isJapanese
|
||||||
|
? '戦闘システム'
|
||||||
|
: 'Combat System',
|
||||||
|
content: isKorean
|
||||||
|
? '전투는 자동으로 진행됩니다. 플레이어와 몬스터가 번갈아 공격하며, '
|
||||||
|
'공격 속도(Attack Speed)에 따라 공격 빈도가 결정됩니다.'
|
||||||
|
: isJapanese
|
||||||
|
? '戦闘は自動で進行します。プレイヤーとモンスターが交互に攻撃し、'
|
||||||
|
'攻撃速度(Attack Speed)によって攻撃頻度が決まります。'
|
||||||
|
: 'Combat is automatic. Player and monster take turns attacking, '
|
||||||
|
'with attack frequency based on Attack Speed.',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_HelpSection(
|
||||||
|
icon: Icons.shield,
|
||||||
|
title: isKorean
|
||||||
|
? '방어 메카닉'
|
||||||
|
: isJapanese
|
||||||
|
? '防御メカニック'
|
||||||
|
: 'Defense Mechanics',
|
||||||
|
content: isKorean
|
||||||
|
? '• 회피(Evasion): DEX 기반, 공격을 완전히 피함\n'
|
||||||
|
'• 방패 방어(Block): 방패 장착 시, 피해 감소\n'
|
||||||
|
'• 무기 쳐내기(Parry): 무기로 공격 일부 막음\n'
|
||||||
|
'• 방어력(DEF): 모든 피해에서 차감'
|
||||||
|
: isJapanese
|
||||||
|
? '• 回避(Evasion): DEX基準、攻撃を完全に回避\n'
|
||||||
|
'• 盾防御(Block): 盾装備時、ダメージ軽減\n'
|
||||||
|
'• 武器受け流し(Parry): 武器で攻撃を一部防ぐ\n'
|
||||||
|
'• 防御力(DEF): 全ダメージから差し引き'
|
||||||
|
: '• Evasion: DEX-based, completely avoid attacks\n'
|
||||||
|
'• Block: With shield, reduce damage\n'
|
||||||
|
'• Parry: Deflect some damage with weapon\n'
|
||||||
|
'• DEF: Subtracted from all damage',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_HelpSection(
|
||||||
|
icon: Icons.favorite,
|
||||||
|
title: isKorean
|
||||||
|
? '사망과 부활'
|
||||||
|
: isJapanese
|
||||||
|
? '死亡と復活'
|
||||||
|
: 'Death & Revival',
|
||||||
|
content: isKorean
|
||||||
|
? 'HP가 0이 되면 사망합니다. 사망 시 장비 하나를 제물로 바쳐 부활할 수 있습니다. '
|
||||||
|
'부활 후 HP/MP가 완전 회복되고 빈 장비 슬롯에 기본 장비가 지급됩니다.'
|
||||||
|
: isJapanese
|
||||||
|
? 'HPが0になると死亡します。死亡時に装備1つを捧げて復活できます。'
|
||||||
|
'復活後HP/MPが完全回復し、空の装備スロットに基本装備が支給されます。'
|
||||||
|
: 'You die when HP reaches 0. Sacrifice one equipment piece to revive. '
|
||||||
|
'After revival, HP/MP fully restore and empty slots get basic equipment.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 스킬 도움말 뷰
|
||||||
|
class _SkillsHelpView extends StatelessWidget {
|
||||||
|
const _SkillsHelpView({
|
||||||
|
required this.isKorean,
|
||||||
|
required this.isJapanese,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool isKorean;
|
||||||
|
final bool isJapanese;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
_HelpSection(
|
||||||
|
icon: Icons.auto_awesome,
|
||||||
|
title: isKorean
|
||||||
|
? '스킬 종류'
|
||||||
|
: isJapanese
|
||||||
|
? 'スキル種類'
|
||||||
|
: 'Skill Types',
|
||||||
|
content: isKorean
|
||||||
|
? '• 공격(Attack): 적에게 직접 피해\n'
|
||||||
|
'• 회복(Heal): HP/MP 회복\n'
|
||||||
|
'• 버프(Buff): 자신에게 유리한 효과\n'
|
||||||
|
'• 디버프(Debuff): 적에게 불리한 효과\n'
|
||||||
|
'• DOT: 시간에 걸쳐 지속 피해'
|
||||||
|
: isJapanese
|
||||||
|
? '• 攻撃(Attack): 敵に直接ダメージ\n'
|
||||||
|
'• 回復(Heal): HP/MP回復\n'
|
||||||
|
'• バフ(Buff): 自分に有利な効果\n'
|
||||||
|
'• デバフ(Debuff): 敵に不利な効果\n'
|
||||||
|
'• DOT: 時間経過でダメージ'
|
||||||
|
: '• Attack: Deal direct damage\n'
|
||||||
|
'• Heal: Restore HP/MP\n'
|
||||||
|
'• Buff: Beneficial effects on self\n'
|
||||||
|
'• Debuff: Harmful effects on enemies\n'
|
||||||
|
'• DOT: Damage over time',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_HelpSection(
|
||||||
|
icon: Icons.psychology,
|
||||||
|
title: isKorean
|
||||||
|
? '자동 스킬 선택'
|
||||||
|
: isJapanese
|
||||||
|
? '自動スキル選択'
|
||||||
|
: 'Auto Skill Selection',
|
||||||
|
content: isKorean
|
||||||
|
? '스킬은 AI가 자동으로 선택합니다:\n'
|
||||||
|
'1. HP 낮음 → 회복 스킬 우선\n'
|
||||||
|
'2. HP/MP 충분 → 버프 스킬 사용\n'
|
||||||
|
'3. 몬스터 HP 높음 → 디버프 적용\n'
|
||||||
|
'4. 공격 스킬로 마무리'
|
||||||
|
: isJapanese
|
||||||
|
? 'スキルはAIが自動選択します:\n'
|
||||||
|
'1. HP低い → 回復スキル優先\n'
|
||||||
|
'2. HP/MP十分 → バフスキル使用\n'
|
||||||
|
'3. モンスターHP高い → デバフ適用\n'
|
||||||
|
'4. 攻撃スキルで仕上げ'
|
||||||
|
: 'Skills are auto-selected by AI:\n'
|
||||||
|
'1. Low HP → Heal skills priority\n'
|
||||||
|
'2. HP/MP sufficient → Use buff skills\n'
|
||||||
|
'3. Monster HP high → Apply debuffs\n'
|
||||||
|
'4. Finish with attack skills',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_HelpSection(
|
||||||
|
icon: Icons.upgrade,
|
||||||
|
title: isKorean
|
||||||
|
? '스킬 랭크'
|
||||||
|
: isJapanese
|
||||||
|
? 'スキルランク'
|
||||||
|
: 'Skill Ranks',
|
||||||
|
content: isKorean
|
||||||
|
? '스킬은 I ~ IX 랭크가 있습니다. 랭크가 높을수록:\n'
|
||||||
|
'• 데미지/회복량 증가\n'
|
||||||
|
'• MP 소모량 증가\n'
|
||||||
|
'• 쿨타임 증가\n'
|
||||||
|
'레벨업 시 랜덤하게 스킬을 배웁니다.'
|
||||||
|
: isJapanese
|
||||||
|
? 'スキルにはI~IXランクがあります。ランクが高いほど:\n'
|
||||||
|
'• ダメージ/回復量増加\n'
|
||||||
|
'• MP消費量増加\n'
|
||||||
|
'• クールタイム増加\n'
|
||||||
|
'レベルアップ時にランダムでスキルを習得します。'
|
||||||
|
: 'Skills have ranks I~IX. Higher rank means:\n'
|
||||||
|
'• More damage/healing\n'
|
||||||
|
'• More MP cost\n'
|
||||||
|
'• Longer cooldown\n'
|
||||||
|
'Learn random skills on level up.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UI 도움말 뷰
|
||||||
|
class _UIHelpView extends StatelessWidget {
|
||||||
|
const _UIHelpView({
|
||||||
|
required this.isKorean,
|
||||||
|
required this.isJapanese,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool isKorean;
|
||||||
|
final bool isJapanese;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
_HelpSection(
|
||||||
|
icon: Icons.view_column,
|
||||||
|
title: isKorean
|
||||||
|
? '화면 구성'
|
||||||
|
: isJapanese
|
||||||
|
? '画面構成'
|
||||||
|
: 'Screen Layout',
|
||||||
|
content: isKorean
|
||||||
|
? '• 상단: 전투 애니메이션, 태스크 진행바\n'
|
||||||
|
'• 좌측: 캐릭터 정보, HP/MP, 스탯\n'
|
||||||
|
'• 중앙: 장비, 인벤토리\n'
|
||||||
|
'• 우측: 플롯/퀘스트 진행, 스펠북'
|
||||||
|
: isJapanese
|
||||||
|
? '• 上部: 戦闘アニメーション、タスク進行バー\n'
|
||||||
|
'• 左側: キャラクター情報、HP/MP、ステータス\n'
|
||||||
|
'• 中央: 装備、インベントリ\n'
|
||||||
|
'• 右側: プロット/クエスト進行、スペルブック'
|
||||||
|
: '• Top: Combat animation, task progress bar\n'
|
||||||
|
'• Left: Character info, HP/MP, stats\n'
|
||||||
|
'• Center: Equipment, inventory\n'
|
||||||
|
'• Right: Plot/quest progress, spellbook',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_HelpSection(
|
||||||
|
icon: Icons.speed,
|
||||||
|
title: isKorean
|
||||||
|
? '속도 조절'
|
||||||
|
: isJapanese
|
||||||
|
? '速度調整'
|
||||||
|
: 'Speed Control',
|
||||||
|
content: isKorean
|
||||||
|
? '태스크 진행바 옆 속도 버튼으로 게임 속도를 조절할 수 있습니다:\n'
|
||||||
|
'• 1x: 기본 속도\n'
|
||||||
|
'• 2x: 2배 속도\n'
|
||||||
|
'• 5x: 5배 속도\n'
|
||||||
|
'• 10x: 10배 속도'
|
||||||
|
: isJapanese
|
||||||
|
? 'タスク進行バー横の速度ボタンでゲーム速度を調整できます:\n'
|
||||||
|
'• 1x: 基本速度\n'
|
||||||
|
'• 2x: 2倍速\n'
|
||||||
|
'• 5x: 5倍速\n'
|
||||||
|
'• 10x: 10倍速'
|
||||||
|
: 'Use the speed button next to task bar to adjust game speed:\n'
|
||||||
|
'• 1x: Normal speed\n'
|
||||||
|
'• 2x: 2x speed\n'
|
||||||
|
'• 5x: 5x speed\n'
|
||||||
|
'• 10x: 10x speed',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_HelpSection(
|
||||||
|
icon: Icons.pause,
|
||||||
|
title: isKorean
|
||||||
|
? '일시정지'
|
||||||
|
: isJapanese
|
||||||
|
? '一時停止'
|
||||||
|
: 'Pause',
|
||||||
|
content: isKorean
|
||||||
|
? '일시정지 버튼으로 게임을 멈출 수 있습니다. '
|
||||||
|
'일시정지 중에도 UI를 확인하고 설정을 변경할 수 있습니다.'
|
||||||
|
: isJapanese
|
||||||
|
? '一時停止ボタンでゲームを止められます。'
|
||||||
|
'一時停止中もUIを確認し設定を変更できます。'
|
||||||
|
: 'Use the pause button to stop the game. '
|
||||||
|
'You can still view UI and change settings while paused.',
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_HelpSection(
|
||||||
|
icon: Icons.bar_chart,
|
||||||
|
title: isKorean
|
||||||
|
? '통계'
|
||||||
|
: isJapanese
|
||||||
|
? '統計'
|
||||||
|
: 'Statistics',
|
||||||
|
content: isKorean
|
||||||
|
? '통계 버튼에서 현재 세션과 누적 게임 통계를 확인할 수 있습니다. '
|
||||||
|
'처치한 몬스터, 획득 골드, 플레이 시간 등을 추적합니다.'
|
||||||
|
: isJapanese
|
||||||
|
? '統計ボタンで現在のセッションと累積ゲーム統計を確認できます。'
|
||||||
|
'倒したモンスター、獲得ゴールド、プレイ時間などを追跡します。'
|
||||||
|
: 'View current session and cumulative stats in the statistics button. '
|
||||||
|
'Track monsters killed, gold earned, play time, etc.',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 도움말 섹션 위젯
|
||||||
|
class _HelpSection extends StatelessWidget {
|
||||||
|
const _HelpSection({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.content,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String content;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// 섹션 헤더
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 20, color: theme.colorScheme.primary),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// 내용
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
content,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
705
lib/src/features/game/widgets/statistics_dialog.dart
Normal file
705
lib/src/features/game/widgets/statistics_dialog.dart
Normal file
@@ -0,0 +1,705 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:askiineverdie/src/core/model/game_statistics.dart';
|
||||||
|
|
||||||
|
/// 게임 통계 다이얼로그 (Statistics Dialog)
|
||||||
|
///
|
||||||
|
/// 세션 통계와 누적 통계를 탭으로 표시
|
||||||
|
class StatisticsDialog extends StatefulWidget {
|
||||||
|
const StatisticsDialog({
|
||||||
|
super.key,
|
||||||
|
required this.session,
|
||||||
|
required this.cumulative,
|
||||||
|
});
|
||||||
|
|
||||||
|
final SessionStatistics session;
|
||||||
|
final CumulativeStatistics cumulative;
|
||||||
|
|
||||||
|
/// 다이얼로그 표시
|
||||||
|
static Future<void> show(
|
||||||
|
BuildContext context, {
|
||||||
|
required SessionStatistics session,
|
||||||
|
required CumulativeStatistics cumulative,
|
||||||
|
}) {
|
||||||
|
return showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => StatisticsDialog(
|
||||||
|
session: session,
|
||||||
|
cumulative: cumulative,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StatisticsDialog> createState() => _StatisticsDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatisticsDialogState extends State<StatisticsDialog>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late TabController _tabController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tabController = TabController(length: 2, vsync: this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isKorean = Localizations.localeOf(context).languageCode == 'ko';
|
||||||
|
final isJapanese = Localizations.localeOf(context).languageCode == 'ja';
|
||||||
|
|
||||||
|
return Dialog(
|
||||||
|
child: Container(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 400, maxHeight: 500),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// 헤더
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.primaryContainer,
|
||||||
|
borderRadius: const BorderRadius.vertical(
|
||||||
|
top: Radius.circular(28),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.bar_chart,
|
||||||
|
color: theme.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
isKorean
|
||||||
|
? '게임 통계'
|
||||||
|
: isJapanese
|
||||||
|
? 'ゲーム統計'
|
||||||
|
: 'Game Statistics',
|
||||||
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
color: theme.colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 탭 바
|
||||||
|
TabBar(
|
||||||
|
controller: _tabController,
|
||||||
|
tabs: [
|
||||||
|
Tab(
|
||||||
|
text: isKorean
|
||||||
|
? '현재 세션'
|
||||||
|
: isJapanese
|
||||||
|
? '現在のセッション'
|
||||||
|
: 'Session',
|
||||||
|
),
|
||||||
|
Tab(
|
||||||
|
text: isKorean
|
||||||
|
? '누적 통계'
|
||||||
|
: isJapanese
|
||||||
|
? '累積統計'
|
||||||
|
: 'Cumulative',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// 탭 내용
|
||||||
|
Expanded(
|
||||||
|
child: TabBarView(
|
||||||
|
controller: _tabController,
|
||||||
|
children: [
|
||||||
|
_SessionStatisticsView(stats: widget.session),
|
||||||
|
_CumulativeStatisticsView(stats: widget.cumulative),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 세션 통계 뷰
|
||||||
|
class _SessionStatisticsView extends StatelessWidget {
|
||||||
|
const _SessionStatisticsView({required this.stats});
|
||||||
|
|
||||||
|
final SessionStatistics stats;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isKorean = Localizations.localeOf(context).languageCode == 'ko';
|
||||||
|
final isJapanese = Localizations.localeOf(context).languageCode == 'ja';
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
_StatSection(
|
||||||
|
title: isKorean
|
||||||
|
? '전투'
|
||||||
|
: isJapanese
|
||||||
|
? '戦闘'
|
||||||
|
: 'Combat',
|
||||||
|
icon: Icons.sports_mma,
|
||||||
|
items: [
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '플레이 시간'
|
||||||
|
: isJapanese
|
||||||
|
? 'プレイ時間'
|
||||||
|
: 'Play Time',
|
||||||
|
value: stats.formattedPlayTime,
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '처치한 몬스터'
|
||||||
|
: isJapanese
|
||||||
|
? '倒したモンスター'
|
||||||
|
: 'Monsters Killed',
|
||||||
|
value: _formatNumber(stats.monstersKilled),
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '보스 처치'
|
||||||
|
: isJapanese
|
||||||
|
? 'ボス討伐'
|
||||||
|
: 'Bosses Defeated',
|
||||||
|
value: _formatNumber(stats.bossesDefeated),
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '사망 횟수'
|
||||||
|
: isJapanese
|
||||||
|
? '死亡回数'
|
||||||
|
: 'Deaths',
|
||||||
|
value: _formatNumber(stats.deathCount),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_StatSection(
|
||||||
|
title: isKorean
|
||||||
|
? '데미지'
|
||||||
|
: isJapanese
|
||||||
|
? 'ダメージ'
|
||||||
|
: 'Damage',
|
||||||
|
icon: Icons.flash_on,
|
||||||
|
items: [
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '입힌 데미지'
|
||||||
|
: isJapanese
|
||||||
|
? '与えたダメージ'
|
||||||
|
: 'Damage Dealt',
|
||||||
|
value: _formatNumber(stats.totalDamageDealt),
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '받은 데미지'
|
||||||
|
: isJapanese
|
||||||
|
? '受けたダメージ'
|
||||||
|
: 'Damage Taken',
|
||||||
|
value: _formatNumber(stats.totalDamageTaken),
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '평균 DPS'
|
||||||
|
: isJapanese
|
||||||
|
? '平均DPS'
|
||||||
|
: 'Average DPS',
|
||||||
|
value: stats.averageDps.toStringAsFixed(1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_StatSection(
|
||||||
|
title: isKorean
|
||||||
|
? '스킬'
|
||||||
|
: isJapanese
|
||||||
|
? 'スキル'
|
||||||
|
: 'Skills',
|
||||||
|
icon: Icons.auto_awesome,
|
||||||
|
items: [
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '스킬 사용'
|
||||||
|
: isJapanese
|
||||||
|
? 'スキル使用'
|
||||||
|
: 'Skills Used',
|
||||||
|
value: _formatNumber(stats.skillsUsed),
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '크리티컬 히트'
|
||||||
|
: isJapanese
|
||||||
|
? 'クリティカルヒット'
|
||||||
|
: 'Critical Hits',
|
||||||
|
value: _formatNumber(stats.criticalHits),
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '최대 연속 크리티컬'
|
||||||
|
: isJapanese
|
||||||
|
? '最大連続クリティカル'
|
||||||
|
: 'Max Critical Streak',
|
||||||
|
value: _formatNumber(stats.maxCriticalStreak),
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '크리티컬 비율'
|
||||||
|
: isJapanese
|
||||||
|
? 'クリティカル率'
|
||||||
|
: 'Critical Rate',
|
||||||
|
value: '${(stats.criticalRate * 100).toStringAsFixed(1)}%',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_StatSection(
|
||||||
|
title: isKorean
|
||||||
|
? '경제'
|
||||||
|
: isJapanese
|
||||||
|
? '経済'
|
||||||
|
: 'Economy',
|
||||||
|
icon: Icons.monetization_on,
|
||||||
|
items: [
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '획득 골드'
|
||||||
|
: isJapanese
|
||||||
|
? '獲得ゴールド'
|
||||||
|
: 'Gold Earned',
|
||||||
|
value: _formatNumber(stats.goldEarned),
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '소비 골드'
|
||||||
|
: isJapanese
|
||||||
|
? '消費ゴールド'
|
||||||
|
: 'Gold Spent',
|
||||||
|
value: _formatNumber(stats.goldSpent),
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '판매 아이템'
|
||||||
|
: isJapanese
|
||||||
|
? '売却アイテム'
|
||||||
|
: 'Items Sold',
|
||||||
|
value: _formatNumber(stats.itemsSold),
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '물약 사용'
|
||||||
|
: isJapanese
|
||||||
|
? 'ポーション使用'
|
||||||
|
: 'Potions Used',
|
||||||
|
value: _formatNumber(stats.potionsUsed),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_StatSection(
|
||||||
|
title: isKorean
|
||||||
|
? '진행'
|
||||||
|
: isJapanese
|
||||||
|
? '進行'
|
||||||
|
: 'Progress',
|
||||||
|
icon: Icons.trending_up,
|
||||||
|
items: [
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '레벨업'
|
||||||
|
: isJapanese
|
||||||
|
? 'レベルアップ'
|
||||||
|
: 'Level Ups',
|
||||||
|
value: _formatNumber(stats.levelUps),
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '완료한 퀘스트'
|
||||||
|
: isJapanese
|
||||||
|
? '完了したクエスト'
|
||||||
|
: 'Quests Completed',
|
||||||
|
value: _formatNumber(stats.questsCompleted),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 누적 통계 뷰
|
||||||
|
class _CumulativeStatisticsView extends StatelessWidget {
|
||||||
|
const _CumulativeStatisticsView({required this.stats});
|
||||||
|
|
||||||
|
final CumulativeStatistics stats;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isKorean = Localizations.localeOf(context).languageCode == 'ko';
|
||||||
|
final isJapanese = Localizations.localeOf(context).languageCode == 'ja';
|
||||||
|
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
_StatSection(
|
||||||
|
title: isKorean
|
||||||
|
? '기록'
|
||||||
|
: isJapanese
|
||||||
|
? '記録'
|
||||||
|
: 'Records',
|
||||||
|
icon: Icons.emoji_events,
|
||||||
|
items: [
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '최고 레벨'
|
||||||
|
: isJapanese
|
||||||
|
? '最高レベル'
|
||||||
|
: 'Highest Level',
|
||||||
|
value: _formatNumber(stats.highestLevel),
|
||||||
|
highlight: true,
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '최대 보유 골드'
|
||||||
|
: isJapanese
|
||||||
|
? '最大所持ゴールド'
|
||||||
|
: 'Highest Gold Held',
|
||||||
|
value: _formatNumber(stats.highestGoldHeld),
|
||||||
|
highlight: true,
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '최고 연속 크리티컬'
|
||||||
|
: isJapanese
|
||||||
|
? '最高連続クリティカル'
|
||||||
|
: 'Best Critical Streak',
|
||||||
|
value: _formatNumber(stats.bestCriticalStreak),
|
||||||
|
highlight: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_StatSection(
|
||||||
|
title: isKorean
|
||||||
|
? '총 플레이'
|
||||||
|
: isJapanese
|
||||||
|
? '総プレイ'
|
||||||
|
: 'Total Play',
|
||||||
|
icon: Icons.access_time,
|
||||||
|
items: [
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '총 플레이 시간'
|
||||||
|
: isJapanese
|
||||||
|
? '総プレイ時間'
|
||||||
|
: 'Total Play Time',
|
||||||
|
value: stats.formattedTotalPlayTime,
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '시작한 게임'
|
||||||
|
: isJapanese
|
||||||
|
? '開始したゲーム'
|
||||||
|
: 'Games Started',
|
||||||
|
value: _formatNumber(stats.gamesStarted),
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '클리어한 게임'
|
||||||
|
: isJapanese
|
||||||
|
? 'クリアしたゲーム'
|
||||||
|
: 'Games Completed',
|
||||||
|
value: _formatNumber(stats.gamesCompleted),
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '클리어율'
|
||||||
|
: isJapanese
|
||||||
|
? 'クリア率'
|
||||||
|
: 'Completion Rate',
|
||||||
|
value: '${(stats.completionRate * 100).toStringAsFixed(1)}%',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_StatSection(
|
||||||
|
title: isKorean
|
||||||
|
? '총 전투'
|
||||||
|
: isJapanese
|
||||||
|
? '総戦闘'
|
||||||
|
: 'Total Combat',
|
||||||
|
icon: Icons.sports_mma,
|
||||||
|
items: [
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '처치한 몬스터'
|
||||||
|
: isJapanese
|
||||||
|
? '倒したモンスター'
|
||||||
|
: 'Monsters Killed',
|
||||||
|
value: _formatNumber(stats.totalMonstersKilled),
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '보스 처치'
|
||||||
|
: isJapanese
|
||||||
|
? 'ボス討伐'
|
||||||
|
: 'Bosses Defeated',
|
||||||
|
value: _formatNumber(stats.totalBossesDefeated),
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '총 사망'
|
||||||
|
: isJapanese
|
||||||
|
? '総死亡'
|
||||||
|
: 'Total Deaths',
|
||||||
|
value: _formatNumber(stats.totalDeaths),
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '총 레벨업'
|
||||||
|
: isJapanese
|
||||||
|
? '総レベルアップ'
|
||||||
|
: 'Total Level Ups',
|
||||||
|
value: _formatNumber(stats.totalLevelUps),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_StatSection(
|
||||||
|
title: isKorean
|
||||||
|
? '총 데미지'
|
||||||
|
: isJapanese
|
||||||
|
? '総ダメージ'
|
||||||
|
: 'Total Damage',
|
||||||
|
icon: Icons.flash_on,
|
||||||
|
items: [
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '입힌 데미지'
|
||||||
|
: isJapanese
|
||||||
|
? '与えたダメージ'
|
||||||
|
: 'Damage Dealt',
|
||||||
|
value: _formatNumber(stats.totalDamageDealt),
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '받은 데미지'
|
||||||
|
: isJapanese
|
||||||
|
? '受けたダメージ'
|
||||||
|
: 'Damage Taken',
|
||||||
|
value: _formatNumber(stats.totalDamageTaken),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_StatSection(
|
||||||
|
title: isKorean
|
||||||
|
? '총 스킬'
|
||||||
|
: isJapanese
|
||||||
|
? '総スキル'
|
||||||
|
: 'Total Skills',
|
||||||
|
icon: Icons.auto_awesome,
|
||||||
|
items: [
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '스킬 사용'
|
||||||
|
: isJapanese
|
||||||
|
? 'スキル使用'
|
||||||
|
: 'Skills Used',
|
||||||
|
value: _formatNumber(stats.totalSkillsUsed),
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '크리티컬 히트'
|
||||||
|
: isJapanese
|
||||||
|
? 'クリティカルヒット'
|
||||||
|
: 'Critical Hits',
|
||||||
|
value: _formatNumber(stats.totalCriticalHits),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_StatSection(
|
||||||
|
title: isKorean
|
||||||
|
? '총 경제'
|
||||||
|
: isJapanese
|
||||||
|
? '総経済'
|
||||||
|
: 'Total Economy',
|
||||||
|
icon: Icons.monetization_on,
|
||||||
|
items: [
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '획득 골드'
|
||||||
|
: isJapanese
|
||||||
|
? '獲得ゴールド'
|
||||||
|
: 'Gold Earned',
|
||||||
|
value: _formatNumber(stats.totalGoldEarned),
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '소비 골드'
|
||||||
|
: isJapanese
|
||||||
|
? '消費ゴールド'
|
||||||
|
: 'Gold Spent',
|
||||||
|
value: _formatNumber(stats.totalGoldSpent),
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '판매 아이템'
|
||||||
|
: isJapanese
|
||||||
|
? '売却アイテム'
|
||||||
|
: 'Items Sold',
|
||||||
|
value: _formatNumber(stats.totalItemsSold),
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '물약 사용'
|
||||||
|
: isJapanese
|
||||||
|
? 'ポーション使用'
|
||||||
|
: 'Potions Used',
|
||||||
|
value: _formatNumber(stats.totalPotionsUsed),
|
||||||
|
),
|
||||||
|
_StatItem(
|
||||||
|
label: isKorean
|
||||||
|
? '완료 퀘스트'
|
||||||
|
: isJapanese
|
||||||
|
? '完了クエスト'
|
||||||
|
: 'Quests Completed',
|
||||||
|
value: _formatNumber(stats.totalQuestsCompleted),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 통계 섹션 위젯
|
||||||
|
class _StatSection extends StatelessWidget {
|
||||||
|
const _StatSection({
|
||||||
|
required this.title,
|
||||||
|
required this.icon,
|
||||||
|
required this.items,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final IconData icon;
|
||||||
|
final List<_StatItem> items;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// 섹션 헤더
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 18, color: theme.colorScheme.primary),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: theme.textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(height: 8),
|
||||||
|
// 통계 항목들
|
||||||
|
...items,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 개별 통계 항목 위젯
|
||||||
|
class _StatItem extends StatelessWidget {
|
||||||
|
const _StatItem({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
this.highlight = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final bool highlight;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: highlight
|
||||||
|
? const EdgeInsets.symmetric(horizontal: 8, vertical: 2)
|
||||||
|
: null,
|
||||||
|
decoration: highlight
|
||||||
|
? BoxDecoration(
|
||||||
|
color: theme.colorScheme.primaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: highlight
|
||||||
|
? theme.colorScheme.onPrimaryContainer
|
||||||
|
: theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 숫자 포맷팅 (천 단위 콤마)
|
||||||
|
String _formatNumber(int value) {
|
||||||
|
if (value < 1000) return value.toString();
|
||||||
|
|
||||||
|
final result = StringBuffer();
|
||||||
|
final str = value.toString();
|
||||||
|
final length = str.length;
|
||||||
|
|
||||||
|
for (var i = 0; i < length; i++) {
|
||||||
|
if (i > 0 && (length - i) % 3 == 0) {
|
||||||
|
result.write(',');
|
||||||
|
}
|
||||||
|
result.write(str[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.toString();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user