Compare commits

..

4 Commits

Author SHA1 Message Date
JiWoong Sul
18af93824b feat(ui): 도움말 다이얼로그 및 UI 개선
- HelpDialog 추가
- 게임 화면에 통계/도움말 버튼 추가
- CombatLog에 디버프 이벤트 표시
- AudioService mp3 확장자 지원
- 설정 텍스트 l10n 추가
2025-12-30 15:58:40 +09:00
JiWoong Sul
d64b9654a3 feat(statistics): 게임 통계 시스템 추가
- GameStatistics 모델 (전투, 퀘스트, 아이템 통계)
- StatisticsStorage 영구 저장
- StatisticsDialog UI
2025-12-30 15:58:22 +09:00
JiWoong Sul
80b6cd63e3 feat(combat): 디버프 시스템 추가
- CombatEventType.playerDebuff 추가
- CombatState에 activeDebuffs 목록 추가
- SkillService.useDebuffSkill() 구현
- 스킬 자동 선택에 디버프 우선순위 추가
- 밸런스 상수 업데이트
2025-12-30 15:58:03 +09:00
JiWoong Sul
bdd3b45329 refactor(audio): 오디오 파일 wav → mp3 변환
- 파일 크기 최적화를 위해 mp3 포맷으로 변환
- title.mp3 BGM 추가
2025-12-30 15:57:42 +09:00
35 changed files with 2779 additions and 83 deletions

BIN
assets/audio/bgm/battle.mp3 Normal file

Binary file not shown.

Binary file not shown.

BIN
assets/audio/bgm/title.mp3 Normal file

Binary file not shown.

BIN
assets/audio/sfx/attack.mp3 Normal file

Binary file not shown.

Binary file not shown.

BIN
assets/audio/sfx/click.mp3 Normal file

Binary file not shown.

Binary file not shown.

BIN
assets/audio/sfx/hit.mp3 Normal file

Binary file not shown.

Binary file not shown.

BIN
assets/audio/sfx/item.mp3 Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
assets/audio/sfx/skill.mp3 Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -435,6 +435,12 @@ String combatBuffActivated(String skillName) {
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) {
if (isKoreanLocale) return '$skillName: $damage 지속 데미지';
if (isJapaneseLocale) return '$skillName: $damage 継続ダメージ';
@@ -1803,6 +1809,18 @@ String get uiSettings {
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 {
if (isKoreanLocale) return '테마';
if (isJapaneseLocale) return 'テーマ';
@@ -1851,6 +1869,12 @@ String get uiSfxVolume {
return 'SFX Volume';
}
String get uiSoundOff {
if (isKoreanLocale) return '음소거';
if (isJapaneseLocale) return 'ミュート';
return 'Muted';
}
String get uiAnimationSpeed {
if (isKoreanLocale) return '애니메이션 속도';
if (isJapaneseLocale) return 'アニメーション速度';

View File

@@ -161,10 +161,12 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
},
currentThemeMode: _themeMode,
onThemeModeChange: _changeThemeMode,
audioService: _audioService,
);
}
// 세이브 파일이 없으면 기존 프론트 화면
// 세이브 파일이 없으면 기존 프론트 화면 (타이틀 BGM 재생)
_audioService.playBgm('title');
return FrontScreen(
onNewCharacter: _navigateToNewCharacter,
onLoadSave: _loadSave,
@@ -238,6 +240,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
MaterialPageRoute<void>(
builder: (context) => GamePlayScreen(
controller: _controller,
audioService: _audioService,
forceCarouselLayout: testMode,
currentThemeMode: _themeMode,
onThemeModeChange: _changeThemeMode,
@@ -252,6 +255,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
MaterialPageRoute<void>(
builder: (context) => GamePlayScreen(
controller: _controller,
audioService: _audioService,
currentThemeMode: _themeMode,
onThemeModeChange: _changeThemeMode,
),
@@ -298,12 +302,14 @@ class _AutoLoadScreen extends StatefulWidget {
required this.onLoadFailed,
required this.currentThemeMode,
required this.onThemeModeChange,
this.audioService,
});
final GameSessionController controller;
final VoidCallback onLoadFailed;
final ThemeMode currentThemeMode;
final void Function(ThemeMode mode) onThemeModeChange;
final AudioService? audioService;
@override
State<_AutoLoadScreen> createState() => _AutoLoadScreenState();
@@ -313,6 +319,8 @@ class _AutoLoadScreenState extends State<_AutoLoadScreen> {
@override
void initState() {
super.initState();
// 로딩 중에도 타이틀 BGM 재생
widget.audioService?.playBgm('title');
_autoLoad();
}
@@ -327,6 +335,7 @@ class _AutoLoadScreenState extends State<_AutoLoadScreen> {
MaterialPageRoute<void>(
builder: (context) => GamePlayScreen(
controller: widget.controller,
audioService: widget.audioService,
currentThemeMode: widget.currentThemeMode,
onThemeModeChange: widget.onThemeModeChange,
),

View File

@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart' show debugPrint, kIsWeb;
import 'package:just_audio/just_audio.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를 관리하며, 설정과 연동하여 볼륨을 조절합니다.
/// 웹/WASM 환경에서는 제한적으로 작동할 수 있습니다.
class AudioService {
AudioService({SettingsRepository? settingsRepository})
: _settingsRepository = settingsRepository ?? SettingsRepository();
@@ -28,48 +30,58 @@ class AudioService {
// 초기화 여부
bool _initialized = false;
// 초기화 실패 여부 (WASM 등에서 오디오 지원 안됨)
bool _initFailed = false;
/// 서비스 초기화
Future<void> init() async {
if (_initialized) return;
if (_initialized || _initFailed) return;
// 설정에서 볼륨 불러오기
_bgmVolume = await _settingsRepository.loadBgmVolume();
_sfxVolume = await _settingsRepository.loadSfxVolume();
try {
// 설정에서 볼륨 불러오기
_bgmVolume = await _settingsRepository.loadBgmVolume();
_sfxVolume = await _settingsRepository.loadSfxVolume();
// BGM 플레이어 초기화
_bgmPlayer = AudioPlayer();
await _bgmPlayer!.setLoopMode(LoopMode.one);
await _bgmPlayer!.setVolume(_bgmVolume);
// BGM 플레이어 초기화
_bgmPlayer = AudioPlayer();
await _bgmPlayer!.setLoopMode(LoopMode.one);
await _bgmPlayer!.setVolume(_bgmVolume);
// SFX 플레이어 풀 초기화
for (var i = 0; i < _maxSfxPlayers; i++) {
final player = AudioPlayer();
await player.setVolume(_sfxVolume);
_sfxPlayers.add(player);
// SFX 플레이어 풀 초기화
for (var i = 0; i < _maxSfxPlayers; i++) {
final player = AudioPlayer();
await player.setVolume(_sfxVolume);
_sfxPlayers.add(player);
}
_initialized = true;
if (kIsWeb) {
debugPrint('[AudioService] Initialized on Web platform');
}
} catch (e) {
_initFailed = true;
debugPrint('[AudioService] Init failed (likely WASM): $e');
}
_initialized = true;
}
/// 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 {
if (_initFailed) return; // 초기화 실패 시 무시
if (!_initialized) await init();
if (_initFailed || !_initialized) return;
if (_currentBgm == name) return; // 이미 재생 중
try {
_currentBgm = name;
// WAV 먼저 시도, 실패하면 MP3 시도
try {
await _bgmPlayer!.setAsset('assets/audio/bgm/$name.wav');
} catch (_) {
await _bgmPlayer!.setAsset('assets/audio/bgm/$name.mp3');
}
await _bgmPlayer!.setAsset('assets/audio/bgm/$name.mp3');
await _bgmPlayer!.play();
debugPrint('[AudioService] Playing BGM: $name');
} catch (e) {
// 파일이 없으면 무시 (개발 중 에셋 미추가 상태)
debugPrint('[AudioService] Failed to play BGM $name: $e');
_currentBgm = null;
}
}
@@ -99,10 +111,13 @@ class AudioService {
/// 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 {
if (_initFailed) return; // 초기화 실패 시 무시
if (!_initialized) await init();
if (_initFailed || !_initialized) return;
if (_sfxVolume == 0) return; // 볼륨이 0이면 재생 안함
if (_sfxPlayers.isEmpty) return;
// 사용 가능한 플레이어 찾기
AudioPlayer? availablePlayer;
@@ -117,16 +132,12 @@ class AudioService {
availablePlayer ??= _sfxPlayers.first;
try {
// WAV 먼저 시도, 실패하면 MP3 시도
try {
await availablePlayer.setAsset('assets/audio/sfx/$name.wav');
} catch (_) {
await availablePlayer.setAsset('assets/audio/sfx/$name.mp3');
}
await availablePlayer.setAsset('assets/audio/sfx/$name.mp3');
await availablePlayer.seek(Duration.zero);
await availablePlayer.play();
} catch (e) {
// 파일이 없으면 무시
debugPrint('[AudioService] Failed to play SFX $name: $e');
}
}
@@ -172,6 +183,9 @@ class AudioService {
/// BGM 타입 열거형
enum BgmType {
/// 타이틀 화면 BGM
title,
/// 마을/상점 BGM
town,

View File

@@ -984,12 +984,20 @@ class ProgressService {
var updatedSkillSystem = skillSystem;
var activeDoTs = [...combat.activeDoTs];
var usedPotionTypes = {...combat.usedPotionTypes};
var activeDebuffs = [...combat.activeDebuffs];
PotionInventory? updatedPotionInventory;
// 새 전투 이벤트 수집
final newEvents = <CombatEvent>[];
final timestamp = updatedSkillSystem.elapsedMs;
// =========================================================================
// 만료된 디버프 정리
// =========================================================================
activeDebuffs = activeDebuffs
.where((debuff) => !debuff.isExpired(timestamp))
.toList();
// =========================================================================
// DOT 틱 처리
// =========================================================================
@@ -1090,6 +1098,7 @@ class ProgressService {
skillSystem: updatedSkillSystem,
availableSkillIds: availableSkillIds,
activeDoTs: activeDoTs,
activeDebuffs: activeDebuffs,
);
if (selectedSkill != null && selectedSkill.isAttack) {
@@ -1183,6 +1192,33 @@ class ProgressService {
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 {
// 일반 공격
final attackResult = calculator.playerAttackMonster(
@@ -1221,8 +1257,25 @@ class ProgressService {
// 몬스터가 살아있으면 반격
if (monsterStats.isAlive &&
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(
attacker: monsterStats,
attacker: debuffedMonster,
defender: playerStats,
);
playerStats = attackResult.updatedDefender;
@@ -1288,6 +1341,7 @@ class ProgressService {
recentEvents: recentEvents,
activeDoTs: activeDoTs,
usedPotionTypes: usedPotionTypes,
activeDebuffs: activeDebuffs,
),
skillSystem: updatedSkillSystem,
potionInventory: updatedPotionInventory,

View File

@@ -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 효과를 생성하여 반환. 호출자가 전투 상태의 activeDoTs에 추가해야 함.
@@ -248,16 +298,19 @@ class SkillService {
///
/// 우선순위:
/// 1. HP < 30% → 회복 스킬
/// 2. 몬스터 HP > 50% & DOT 없음 → DOT 스킬 (장기전 유리)
/// 3. 보스전 (레벨 차이 10 이상) → 가장 강력한 공격 스킬
/// 4. 일반 전투 → MP 효율이 좋은 스킬
/// 5. MP < 20% → null (일반 공격)
/// 2. HP > 70% & MP > 50% → 버프 스킬 (안전할 때)
/// 3. 몬스터 HP > 70% & 활성 디버프 없음 → 디버프 스킬
/// 4. 몬스터 HP > 50% & DOT 없음 → DOT 스킬 (장기전 유리)
/// 5. 보스전 (레벨 차이 10 이상) → 가장 강력한 공격 스킬
/// 6. 일반 전투 → MP 효율이 좋은 스킬
/// 7. MP < 20% → null (일반 공격)
Skill? selectAutoSkill({
required CombatStats player,
required MonsterCombatStats monster,
required SkillSystemState skillSystem,
required List<String> availableSkillIds,
List<DotEffect> activeDoTs = const [],
List<ActiveBuff> activeDebuffs = const [],
}) {
final currentMp = player.mpCurrent;
final mpRatio = player.mpRatio;
@@ -289,6 +342,18 @@ class SkillService {
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 스킬 사용
if (monster.hpRatio > 0.5 && activeDoTs.isEmpty) {
final dotSkill = _findBestDotSkill(availableSkills, currentMp);
@@ -369,6 +434,52 @@ class SkillService {
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 회복
// ============================================================================

View File

@@ -27,6 +27,9 @@ enum CombatEventType {
/// 플레이어 버프
playerBuff,
/// 플레이어 디버프 (적에게 적용)
playerDebuff,
/// DOT 틱 데미지
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 틱 이벤트 생성
factory CombatEvent.dotTick({
required int timestamp,

View File

@@ -21,6 +21,7 @@ class CombatState {
this.recentEvents = const [],
this.activeDoTs = const [],
this.usedPotionTypes = const {},
this.activeDebuffs = const [],
});
/// 플레이어 전투 스탯
@@ -56,6 +57,9 @@ class CombatState {
/// 이번 전투에서 사용한 물약 종류 (종류별 1회 제한)
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({
CombatStats? playerStats,
MonsterCombatStats? monsterStats,
@@ -100,6 +122,7 @@ class CombatState {
List<CombatEvent>? recentEvents,
List<DotEffect>? activeDoTs,
Set<PotionType>? usedPotionTypes,
List<ActiveBuff>? activeDebuffs,
}) {
return CombatState(
playerStats: playerStats ?? this.playerStats,
@@ -115,6 +138,7 @@ class CombatState {
recentEvents: recentEvents ?? this.recentEvents,
activeDoTs: activeDoTs ?? this.activeDoTs,
usedPotionTypes: usedPotionTypes ?? this.usedPotionTypes,
activeDebuffs: activeDebuffs ?? this.activeDebuffs,
);
}

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

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

View File

@@ -10,27 +10,42 @@ class ExpConstants {
/// 기본 경험치 값
static const int baseExp = 100;
/// 레벨 경험치 증가율 (1.15 = 15% 증가)
static const double expGrowthRate = 1.15;
/// 레벨업에 필요한 경험치 계산
///
/// 공식: baseExp * (expGrowthRate ^ level)
/// 레벨 10: ~405 exp
/// 레벨 50: ~108,366 exp
/// 레벨 100: ~11,739,085 exp
static int requiredExp(int level) {
if (level <= 0) return baseExp;
return (baseExp * _pow(expGrowthRate, level)).round();
/// 레벨 구간별 경험치 증가율 (tiered growth rate)
/// - 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;
}
/// 효율적인 거듭제곱 계산
static double _pow(double base, int exponent) {
double result = 1.0;
for (int i = 0; i < exponent; i++) {
result *= base;
/// 레벨업에 필요한 경험치 계산 (구간별 차등 적용)
///
/// 조정 후 예상:
/// 레벨 10: ~259 exp
/// 레벨 30: ~1,744 exp
/// 레벨 50: ~9,705 exp
/// 레벨 80: ~133,860 exp
/// 레벨 100: ~636,840 exp
static int requiredExp(int level) {
if (level <= 0) return baseExp;
// 구간별 복합 성장 계산
double result = baseExp.toDouble();
for (int i = 1; i <= level; i++) {
result *= _getGrowthRate(i);
}
return result;
return result.round();
}
/// 총 누적 경험치 계산 (특정 레벨까지)
static int totalExpToLevel(int level) {
int total = 0;
for (int i = 1; i < level; i++) {
total += requiredExp(i);
}
return total;
}
}
@@ -88,40 +103,40 @@ class MonsterTypeMultiplier {
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(
hp: 2.0,
atk: 1.3,
def: 1.2,
exp: 2.0,
gold: 2.0,
exp: 3.0, // 2.0 → 3.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(
hp: 5.0,
atk: 1.5,
def: 1.5,
exp: 5.0,
gold: 5.0,
exp: 8.0, // 5.0 → 8.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(
hp: 10.0,
atk: 2.0,
def: 2.0,
exp: 15.0,
gold: 10.0,
hp: 8.0, // 10.0 → 8.0 하향 (플레이어 접근성 개선)
atk: 1.8, // 2.0 → 1.8 하향
def: 1.8, // 2.0 → 1.8 하향
exp: 25.0, // 15.0 → 25.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(
hp: 20.0,
atk: 2.5,
def: 2.5,
exp: 50.0,
gold: 30.0,
hp: 12.0, // 20.0 → 12.0 대폭 하향 (클리어 가능성 확보)
atk: 2.2, // 2.5 → 2.2 하향
def: 2.2, // 2.5 → 2.2 하향
exp: 80.0, // 50.0 → 80.0 상향
gold: 50.0,
);
}
@@ -283,6 +298,8 @@ class BossStats extends MonsterBaseStats {
}
/// Kernel Panic Archon (Act IV 보스, 레벨 80)
///
/// Phase 6 밸런스 조정: enrageMultiplier 1.6 → 1.5
static BossStats kernelPanicArchon(int baseLevel) {
final base = MonsterBaseStats.generate(baseLevel, MonsterType.boss);
return BossStats(
@@ -293,7 +310,7 @@ class BossStats extends MonsterBaseStats {
gold: base.gold,
phases: 3,
enrageThreshold: 0.2,
enrageMultiplier: 1.6,
enrageMultiplier: 1.5, // 1.6 → 1.5 (분노 시 50% 스탯 증가)
hasShield: true,
shieldAmount: (base.hp * 0.2).round(),
abilities: [BossAbilityType.stunAttack],
@@ -301,6 +318,11 @@ class BossStats extends MonsterBaseStats {
}
/// Glitch God (최종 보스, 레벨 100)
///
/// Phase 6 밸런스 조정:
/// - enrageThreshold: 0.1 → 0.15 (분노 발동 시점 완화)
/// - enrageMultiplier: 2.0 → 1.7 (분노 시 스탯 증가 완화)
/// - shieldAmount: 50% → 35% (보호막 감소)
static BossStats glitchGod(int baseLevel) {
final base = MonsterBaseStats.generate(baseLevel, MonsterType.finalBoss);
return BossStats(
@@ -310,10 +332,10 @@ class BossStats extends MonsterBaseStats {
exp: base.exp,
gold: base.gold,
phases: 5,
enrageThreshold: 0.1,
enrageMultiplier: 2.0,
enrageThreshold: 0.15, // 0.1 → 0.15 (15% HP에서 분노)
enrageMultiplier: 1.7, // 2.0 → 1.7 (분노 시 70% 스탯 증가)
hasShield: true,
shieldAmount: (base.hp * 0.5).round(),
shieldAmount: (base.hp * 0.35).round(), // 0.5 → 0.35 (보호막 30% 감소)
abilities: [
BossAbilityType.phaseShift,
BossAbilityType.multiAttack,
@@ -398,11 +420,17 @@ class LevelTierSettings {
class PlayerScaling {
PlayerScaling._();
/// 레벨당 HP 증가량
static const int hpPerLevel = 10;
/// 레벨당 HP 증가량 (10 → 12 상향)
static const int hpPerLevel = 12;
/// 레벨당 MP 증가량
static const int mpPerLevel = 5;
/// 레벨당 MP 증가량 (5 → 6 상향)
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 계산
static ({int hpMax, int mpMax}) calculateResources({
@@ -412,8 +440,17 @@ class PlayerScaling {
required int conBonus,
required int intBonus,
}) {
final hpMax = baseHp + (level - 1) * hpPerLevel + conBonus * 5;
final mpMax = baseMp + (level - 1) * mpPerLevel + intBonus * 3;
final hpMax = baseHp + (level - 1) * hpPerLevel + conBonus * hpPerCon;
final mpMax = baseMp + (level - 1) * mpPerLevel + intBonus * mpPerInt;
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;
}
}

View File

@@ -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/layouts/mobile_carousel_layout.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/audio/audio_service.dart';
/// 게임 진행 화면 (Main.dfm 기반 3패널 레이아웃)
///
@@ -41,6 +44,7 @@ class GamePlayScreen extends StatefulWidget {
const GamePlayScreen({
super.key,
required this.controller,
this.audioService,
this.forceCarouselLayout = false,
this.forceDesktopLayout = false,
this.onThemeModeChange,
@@ -49,6 +53,9 @@ class GamePlayScreen extends StatefulWidget {
final GameSessionController controller;
/// 오디오 서비스 (BGM/SFX 재생)
final AudioService? audioService;
/// 테스트 모드: 웹에서도 모바일 캐로셀 레이아웃 강제 사용
final bool forceCarouselLayout;
@@ -89,6 +96,13 @@ class _GamePlayScreenState extends State<GamePlayScreen>
// 전투 이벤트 추적 (마지막 처리된 이벤트 수)
int _lastProcessedEventCount = 0;
// 오디오 상태 추적
bool _wasInCombat = false;
// 사운드 볼륨 상태 (모바일 설정 UI용)
double _bgmVolume = 0.7;
double _sfxVolume = 0.8;
void _checkSpecialEvents(GameState state) {
// Phase 8: 태스크 변경 시 로그 추가
final currentCaption = state.progress.currentTask.caption;
@@ -102,6 +116,9 @@ class _GamePlayScreenState extends State<GamePlayScreen>
// 전투 이벤트 처리 (Combat Events)
_processCombatEvents(state);
// 오디오: 전투 상태 변경 시 BGM 전환
_updateBgmForCombatState(state);
// 레벨업 감지
if (state.traits.level > _lastLevel && _lastLevel > 0) {
_specialAnimation = AsciiAnimationType.levelUp;
@@ -110,6 +127,8 @@ class _GamePlayScreenState extends State<GamePlayScreen>
'${game_l10n.uiLevelUp} Lv.${state.traits.level}',
CombatLogType.levelUp,
);
// 오디오: 레벨업 SFX
widget.audioService?.playSfx('level_up');
_resetSpecialAnimationAfterFrame();
// Phase 9: Act 변경 감지 (레벨 기반)
@@ -147,6 +166,8 @@ class _GamePlayScreenState extends State<GamePlayScreen>
CombatLogType.questComplete,
);
}
// 오디오: 퀘스트 완료 SFX
widget.audioService?.playSfx('quest_complete');
_resetSpecialAnimationAfterFrame();
}
_lastQuestCount = state.progress.questCount;
@@ -192,11 +213,74 @@ class _GamePlayScreenState extends State<GamePlayScreen>
for (final event in newEvents) {
final (message, type) = _formatCombatEvent(event);
_addCombatLog(message, type);
// 오디오: 전투 이벤트에 따른 SFX 재생
_playCombatEventSfx(event);
}
_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) {
final target = event.targetName ?? '';
@@ -256,6 +340,10 @@ class _GamePlayScreenState extends State<GamePlayScreen>
game_l10n.combatBuffActivated(skillName),
CombatLogType.buff,
),
CombatEventType.playerDebuff => (
game_l10n.combatDebuffApplied(skillName, target),
CombatLogType.debuff,
),
CombatEventType.dotTick => (
game_l10n.combatDotTick(skillName, event.damage),
CombatLogType.dotTick,
@@ -361,6 +449,29 @@ class _GamePlayScreenState extends State<GamePlayScreen>
_lastQuestCount = state.progress.questCount;
_lastPlotStageCount = state.progress.plotStageCount;
_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;
}
/// 통계 다이얼로그 표시
void _showStatisticsDialog(BuildContext context) {
StatisticsDialog.show(
context,
session: widget.controller.sessionStats,
cumulative: widget.controller.cumulativeStats,
);
}
/// 설정 화면 표시
void _showSettingsScreen(BuildContext context) {
final settingsRepo = SettingsRepository();
@@ -614,6 +734,17 @@ class _GamePlayScreenState extends State<GamePlayScreen>
},
currentThemeMode: widget.currentThemeMode,
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)
@@ -666,6 +797,18 @@ class _GamePlayScreenState extends State<GamePlayScreen>
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(
icon: const Icon(Icons.settings),

View File

@@ -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/shop_service.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/statistics_storage.dart';
import 'package:flutter/foundation.dart';
enum GameSessionStatus { idle, loading, running, error, dead }
@@ -18,12 +20,15 @@ class GameSessionController extends ChangeNotifier {
this.autoSaveConfig = const AutoSaveConfig(),
Duration tickInterval = const Duration(milliseconds: 50),
DateTime Function()? now,
StatisticsStorage? statisticsStorage,
}) : _tickInterval = tickInterval,
_now = now ?? DateTime.now;
_now = now ?? DateTime.now,
_statisticsStorage = statisticsStorage ?? StatisticsStorage();
final ProgressService progressService;
final SaveManager saveManager;
final AutoSaveConfig autoSaveConfig;
final StatisticsStorage _statisticsStorage;
final Duration _tickInterval;
final DateTime Function() _now;
@@ -36,12 +41,26 @@ class GameSessionController extends ChangeNotifier {
GameState? _state;
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;
GameState? get state => _state;
String? get error => _error;
bool get isRunning => _status == GameSessionStatus.running;
bool get cheatsEnabled => _cheatsEnabled;
/// 현재 세션 통계
SessionStatistics get sessionStats => _sessionStats;
/// 누적 통계
CumulativeStatistics get cumulativeStats => _cumulativeStats;
/// 현재 ProgressLoop 인스턴스 (치트 기능용)
ProgressLoop? get loop => _loop;
@@ -62,6 +81,13 @@ class GameSessionController extends ChangeNotifier {
_status = GameSessionStatus.running;
_cheatsEnabled = cheatsEnabled;
// 통계 초기화
if (isNewGame) {
_sessionStats = SessionStatistics.empty();
await _statisticsStorage.recordGameStart();
}
_initPreviousValues(state);
_loop = ProgressLoop(
initialState: state,
progressService: progressService,
@@ -74,6 +100,7 @@ class GameSessionController extends ChangeNotifier {
);
_subscription = _loop!.stream.listen((next) {
_updateStatistics(next);
_state = next;
notifyListeners();
});
@@ -82,6 +109,76 @@ class GameSessionController extends ChangeNotifier {
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({
String? fileName,
bool cheatsEnabled = false,
@@ -148,6 +245,7 @@ class GameSessionController extends ChangeNotifier {
/// 플레이어 사망 콜백 (ProgressLoop에서 호출)
void _onPlayerDied() {
_sessionStats = _sessionStats.recordDeath();
_status = GameSessionStatus.dead;
notifyListeners();
}

View File

@@ -39,6 +39,10 @@ class MobileCarouselLayout extends StatefulWidget {
this.specialAnimation,
this.currentThemeMode = ThemeMode.system,
this.onThemeModeChange,
this.bgmVolume = 0.7,
this.sfxVolume = 0.8,
this.onBgmVolumeChange,
this.onSfxVolumeChange,
});
final GameState state;
@@ -56,6 +60,18 @@ class MobileCarouselLayout extends StatefulWidget {
final ThemeMode currentThemeMode;
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
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) {
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(),
// 저장
@@ -381,7 +520,7 @@ class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
actions: [
// 옵션 버튼
IconButton(
icon: const Icon(Icons.more_vert),
icon: const Icon(Icons.settings),
onPressed: () => _showOptionsMenu(context),
tooltip: l10n.menuOptions,
),

View File

@@ -285,6 +285,15 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
false,
),
// 디버프 적용 → idle 페이즈 유지
CombatEventType.playerDebuff => (
BattlePhase.idle,
false,
false,
false,
false,
),
// DOT 틱 → attack 페이즈 (지속 피해)
CombatEventType.dotTick => (
BattlePhase.attack,

View File

@@ -28,6 +28,7 @@ enum CombatLogType {
parry, // 무기 쳐내기
monsterAttack, // 몬스터 공격
buff, // 버프 활성화
debuff, // 디버프 적용
dotTick, // DOT 틱 데미지
potion, // 물약 사용
potionDrop, // 물약 드랍
@@ -166,6 +167,7 @@ class _LogEntryTile extends StatelessWidget {
Icons.dangerous,
),
CombatLogType.buff => (Colors.lightBlue.shade300, Icons.trending_up),
CombatLogType.debuff => (Colors.deepOrange.shade300, Icons.trending_down),
CombatLogType.dotTick => (Colors.deepPurple.shade300, Icons.whatshot),
CombatLogType.potion => (Colors.pink.shade300, Icons.local_drink),
CombatLogType.potionDrop => (Colors.lime.shade300, Icons.card_giftcard),

View File

@@ -429,6 +429,11 @@ class DeathOverlay extends StatelessWidget {
Colors.lightBlue.shade300,
l10n.combatBuffActivated(event.skillName ?? ''),
),
CombatEventType.playerDebuff => (
Icons.trending_down,
Colors.deepOrange.shade300,
l10n.combatDebuffApplied(event.skillName ?? '', target),
),
CombatEventType.dotTick => (
Icons.whatshot,
Colors.deepOrange.shade300,

View 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,
),
),
),
],
);
}
}

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