From d64b9654a359cb42beadac866f1c4bd79a9bb295 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Tue, 30 Dec 2025 15:58:22 +0900 Subject: [PATCH] =?UTF-8?q?feat(statistics):=20=EA=B2=8C=EC=9E=84=20?= =?UTF-8?q?=ED=86=B5=EA=B3=84=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GameStatistics 모델 (전투, 퀘스트, 아이템 통계) - StatisticsStorage 영구 저장 - StatisticsDialog UI --- lib/src/core/model/game_statistics.dart | 626 ++++++++++++++++ lib/src/core/storage/statistics_storage.dart | 126 ++++ .../game/widgets/statistics_dialog.dart | 705 ++++++++++++++++++ 3 files changed, 1457 insertions(+) create mode 100644 lib/src/core/model/game_statistics.dart create mode 100644 lib/src/core/storage/statistics_storage.dart create mode 100644 lib/src/features/game/widgets/statistics_dialog.dart diff --git a/lib/src/core/model/game_statistics.dart b/lib/src/core/model/game_statistics.dart new file mode 100644 index 0000000..5990d9c --- /dev/null +++ b/lib/src/core/model/game_statistics.dart @@ -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 toJson() { + return { + 'session': session.toJson(), + 'cumulative': cumulative.toJson(), + }; + } + + /// JSON 역직렬화 + factory GameStatistics.fromJson(Map json) { + return GameStatistics( + session: SessionStatistics.fromJson( + json['session'] as Map? ?? {}, + ), + cumulative: CumulativeStatistics.fromJson( + json['cumulative'] as Map? ?? {}, + ), + ); + } +} + +/// 세션 통계 (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 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 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 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 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, + ); + } +} diff --git a/lib/src/core/storage/statistics_storage.dart b/lib/src/core/storage/statistics_storage.dart new file mode 100644 index 0000000..8cc2669 --- /dev/null +++ b/lib/src/core/storage/statistics_storage.dart @@ -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 _getStorageDir() async { + if (_storageDir != null) return _storageDir!; + _storageDir = await getApplicationSupportDirectory(); + return _storageDir!; + } + + File _getFile(Directory dir) { + return File('${dir.path}/$_fileName'); + } + + /// 누적 통계 로드 + Future 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; + + // cumulative 키가 있으면 해당 데이터 사용 + if (json.containsKey('cumulative')) { + return CumulativeStatistics.fromJson( + json['cumulative'] as Map, + ); + } + + // 직접 CumulativeStatistics JSON인 경우 + return CumulativeStatistics.fromJson(json); + } catch (e) { + // 파일이 없거나 손상된 경우 빈 통계 반환 + return CumulativeStatistics.empty(); + } + } + + /// 누적 통계 저장 + Future 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 mergeSession(SessionStatistics session) async { + final current = await loadCumulative(); + final updated = current.mergeSession(session); + return saveCumulative(updated); + } + + /// 최고 레벨 업데이트 + Future updateHighestLevel(int level) async { + final current = await loadCumulative(); + if (level <= current.highestLevel) return true; + final updated = current.updateHighestLevel(level); + return saveCumulative(updated); + } + + /// 최대 골드 업데이트 + Future updateHighestGold(int gold) async { + final current = await loadCumulative(); + if (gold <= current.highestGoldHeld) return true; + final updated = current.updateHighestGold(gold); + return saveCumulative(updated); + } + + /// 새 게임 시작 기록 + Future recordGameStart() async { + final current = await loadCumulative(); + final updated = current.recordGameStart(); + return saveCumulative(updated); + } + + /// 게임 클리어 기록 + Future recordGameComplete() async { + final current = await loadCumulative(); + final updated = current.recordGameComplete(); + return saveCumulative(updated); + } + + /// 통계 초기화 (테스트용) + Future clear() async { + try { + final dir = await _getStorageDir(); + final file = _getFile(dir); + + if (await file.exists()) { + await file.delete(); + } + return true; + } catch (e) { + return false; + } + } +} diff --git a/lib/src/features/game/widgets/statistics_dialog.dart b/lib/src/features/game/widgets/statistics_dialog.dart new file mode 100644 index 0000000..b199398 --- /dev/null +++ b/lib/src/features/game/widgets/statistics_dialog.dart @@ -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 show( + BuildContext context, { + required SessionStatistics session, + required CumulativeStatistics cumulative, + }) { + return showDialog( + context: context, + builder: (_) => StatisticsDialog( + session: session, + cumulative: cumulative, + ), + ); + } + + @override + State createState() => _StatisticsDialogState(); +} + +class _StatisticsDialogState extends State + 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(); +}