feat(statistics): 게임 통계 시스템 추가

- GameStatistics 모델 (전투, 퀘스트, 아이템 통계)
- StatisticsStorage 영구 저장
- StatisticsDialog UI
This commit is contained in:
JiWoong Sul
2025-12-30 15:58:22 +09:00
parent 80b6cd63e3
commit d64b9654a3
3 changed files with 1457 additions and 0 deletions

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