Compare commits

...

5 Commits

Author SHA1 Message Date
JiWoong Sul
23f15f41d3 refactor(util): pq_logic.dart 모듈 분할
- pq_random.dart: 랜덤/확률 함수 (61줄)
- pq_string.dart: 문자열 유틸리티 (55줄)
- pq_item.dart: 아이템/장비 생성 (327줄)
- pq_monster.dart: 몬스터 생성 (283줄)
- pq_quest.dart: 퀘스트/Act/시네마틱 (283줄)
- pq_task.dart: 태스크/큐 (97줄)
- pq_stat.dart: 스탯 관련 (64줄)
- 원본은 re-export 허브로 유지 (역호환성)
2026-01-15 17:05:46 +09:00
JiWoong Sul
133d516b94 refactor(ui): HallOfFameScreen 위젯 분해
- HeroDetailDialog: 상세 정보 다이얼로그 분리 (690줄)
- HallOfFameEntryCard: 엔트리 카드 분리 (263줄)
- GameClearDialog: 게임 클리어 다이얼로그 분리 (255줄)
- 메인 화면 264줄로 경량화
2026-01-15 17:05:39 +09:00
JiWoong Sul
07fb105d7c refactor(l10n): game_text_l10n 중복 패턴 제거
- _l() 헬퍼 함수로 로케일별 분기 추상화
- 1998줄 → 1108줄 (~45% 감소)
- 260회 반복 패턴을 한 줄 표현으로 변환
2026-01-15 17:05:32 +09:00
JiWoong Sul
e77c3c4a05 refactor(model): freezed 패키지 도입으로 보일러플레이트 제거
- ItemStats, CombatStats, EquipmentItem을 freezed로 마이그레이션
- copyWith, toJson/fromJson 자동 생성
- 세이브 파일 호환성 유지
2026-01-15 17:05:26 +09:00
JiWoong Sul
f466e1c408 refactor(engine): ActProgressionService 및 UI 컨트롤러 분리
- ActProgressionService: Act 진행 로직 추출
- GameAudioController: 오디오 제어 로직 분리
- CombatLogController: 전투 로그 관리 분리
- ProgressService, GamePlayScreen 경량화
2026-01-15 17:05:19 +09:00
29 changed files with 5992 additions and 4729 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,262 @@
import 'dart:math' as math;
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
import 'package:asciineverdie/src/core/model/combat_state.dart';
import 'package:asciineverdie/src/core/model/combat_stats.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
import 'package:asciineverdie/src/core/model/monster_grade.dart';
import 'package:asciineverdie/src/core/model/pq_config.dart';
import 'package:asciineverdie/src/core/util/balance_constants.dart';
import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic;
/// Act 진행 관련 로직을 처리하는 서비스
///
/// ProgressService에서 추출된 Act 완료, 보스 생성 등의 로직 담당.
class ActProgressionService {
const ActProgressionService({
required this.config,
});
final PqConfig config;
/// Act 완료 처리
///
/// 플롯 진행, Act Boss 시네마틱 후 호출.
/// Returns gameComplete=true if Final Boss was defeated (game ends).
({GameState state, bool gameComplete}) completeAct(GameState state) {
// Act V 완료 시 (plotStageCount == 6) → 최종 보스 전투 시작
// plotStageCount: 1=Prologue, 2=Act I, 3=Act II, 4=Act III, 5=Act IV, 6=Act V
if (state.progress.plotStageCount >= 6) {
// 이미 최종 보스가 처치되었으면 게임 클리어
if (state.progress.finalBossState == FinalBossState.defeated) {
final updatedPlotHistory = [
...state.progress.plotHistory.map(
(e) => e.isComplete ? e : e.copyWith(isComplete: true),
),
const HistoryEntry(caption: '*** THE END ***', isComplete: true),
];
final updatedProgress = state.progress.copyWith(
plotHistory: updatedPlotHistory,
);
return (
state: state.copyWith(progress: updatedProgress),
gameComplete: true,
);
}
// 최종 보스가 아직 등장하지 않았으면 보스 전투 시작
if (state.progress.finalBossState == FinalBossState.notSpawned) {
final updatedProgress = state.progress.copyWith(
finalBossState: FinalBossState.fighting,
);
// 게임은 아직 끝나지 않음 - 보스 전투 진행
return (
state: state.copyWith(progress: updatedProgress),
gameComplete: false,
);
}
// 보스 전투 중이면 계속 진행 (게임 종료 안 함)
return (state: state, gameComplete: false);
}
final actResult = pq_logic.completeAct(state.progress.plotStageCount);
var nextState = state;
// 보상 처리는 호출자(ProgressService)가 담당
// 여기서는 플롯 상태만 업데이트
final plotStages = nextState.progress.plotStageCount + 1;
// 플롯 히스토리 업데이트: 이전 플롯 완료 표시, 새 플롯 추가
final updatedPlotHistory = [
...nextState.progress.plotHistory.map(
(e) => e.isComplete ? e : e.copyWith(isComplete: true),
),
HistoryEntry(caption: actResult.actTitle, isComplete: false),
];
var updatedProgress = nextState.progress.copyWith(
plot: ProgressBarState(position: 0, max: actResult.plotBarMaxSeconds),
plotStageCount: plotStages,
plotHistory: updatedPlotHistory,
);
nextState = nextState.copyWith(progress: updatedProgress);
// Act I 완료 후(Prologue -> Act I) 첫 퀘스트 시작 (원본 Main.pas 로직)
// plotStages == 2는 Prologue(1) -> Act I(2) 전환을 의미
if (plotStages == 2) {
nextState = startFirstQuest(nextState);
}
return (state: nextState, gameComplete: false);
}
/// Act 완료 시 적용할 보상 목록 반환
List<pq_logic.RewardKind> getActRewards(int plotStageCount) {
final actResult = pq_logic.completeAct(plotStageCount);
return actResult.rewards;
}
/// 첫 퀘스트 시작 (Act I 시작 시)
GameState startFirstQuest(GameState state) {
final result = pq_logic.completeQuest(
config,
state.rng,
state.traits.level,
);
// 퀘스트 바 초기화
final questBar = ProgressBarState(
position: 0,
max: 50 + state.rng.nextInt(100),
);
// 첫 퀘스트 히스토리 추가
final questHistory = [
HistoryEntry(caption: result.caption, isComplete: false),
];
// 퀘스트 몬스터 정보 저장 (Exterminate 타입용)
final questMonster = result.monsterIndex != null
? QuestMonsterInfo(
monsterData: result.monsterName!,
monsterIndex: result.monsterIndex!,
)
: null;
// 첫 퀘스트 추가
final updatedQueue = QueueState(
entries: [
...state.queue.entries,
QueueEntry(
kind: QueueKind.task,
durationMillis: 50 + state.rng.nextInt(100),
caption: result.caption,
taskType: TaskType.neutral,
),
],
);
final progress = state.progress.copyWith(
quest: questBar,
questCount: 1,
questHistory: questHistory,
currentQuestMonster: questMonster,
);
return state.copyWith(progress: progress, queue: updatedQueue);
}
/// Act Boss 생성 (Act 완료 시)
///
/// 보스 레벨 = min(플레이어 레벨, Act 최소 레벨)로 설정하여
/// 플레이어가 이길 수 있는 수준 보장
CombatState createActBoss(GameState state) {
final plotStage = state.progress.plotStageCount;
final actNumber = plotStage + 1;
// 보스 레벨 = min(플레이어 레벨, Act 최소 레벨)
// → 플레이어가 현재 레벨보다 높은 보스를 만나지 않도록 보장
final actMinLevel = ActMonsterLevel.forPlotStage(actNumber);
final bossLevel = math.min(state.traits.level, actMinLevel);
// Named monster 생성 (pq_logic.namedMonster 활용)
final bossName = pq_logic.namedMonster(config, state.rng, bossLevel);
final bossStats = MonsterBaseStats.forLevel(bossLevel);
// 플레이어 전투 스탯 생성
final playerCombatStats = CombatStats.fromStats(
stats: state.stats,
equipment: state.equipment,
level: state.traits.level,
monsterLevel: bossLevel,
);
// Boss 몬스터 스탯 생성 (일반 몬스터 대비 강화)
final monsterCombatStats = MonsterCombatStats(
name: bossName,
level: bossLevel,
atk: (bossStats.atk * 1.5).round(), // Boss 보정 (1.5배)
def: (bossStats.def * 1.5).round(),
hpMax: (bossStats.hp * 2.0).round(), // HP는 2.0배 (보스다운 전투 시간)
hpCurrent: (bossStats.hp * 2.0).round(),
criRate: 0.05,
criDamage: 1.5,
evasion: 0.0,
accuracy: 0.8,
attackDelayMs: 1000,
expReward: (bossStats.exp * 2.5).round(), // 경험치 보상 증가
);
// 전투 상태 초기화
return CombatState.start(
playerStats: playerCombatStats,
monsterStats: monsterCombatStats,
);
}
/// 최종 보스(Glitch God) 전투 시작
///
/// Act V 플롯 완료 후 호출되며, 글리치 갓과의 전투를 설정합니다.
({ProgressState progress, QueueState queue}) startFinalBossFight(
GameState state,
ProgressState progress,
QueueState queue,
) {
final level = state.traits.level;
// Glitch God 생성 (레벨 100 최종 보스)
final glitchGod = MonsterCombatStats.glitchGod();
// 플레이어 전투 스탯 생성 (Phase 12: 보스 레벨 기반 페널티 적용)
final playerCombatStats = CombatStats.fromStats(
stats: state.stats,
equipment: state.equipment,
level: level,
monsterLevel: glitchGod.level,
);
// 전투 상태 초기화
final combatState = CombatState.start(
playerStats: playerCombatStats,
monsterStats: glitchGod,
);
// 전투 시간 추정 (보스 전투는 더 길게)
final combatCalculator = CombatCalculator(rng: state.rng);
final baseDuration = combatCalculator.estimateCombatDurationMs(
player: playerCombatStats,
monster: glitchGod,
);
// 최종 보스는 최소 10초, 최대 60초
final durationMillis = baseDuration.clamp(10000, 60000);
final taskResult = pq_logic.startTask(
progress,
l10n.taskFinalBoss(glitchGod.name),
durationMillis,
);
final updatedProgress = taskResult.progress.copyWith(
currentTask: TaskInfo(
caption: taskResult.caption,
type: TaskType.kill,
monsterBaseName: 'Glitch God',
monsterPart: '*', // 특수 전리품
monsterLevel: glitchGod.level,
monsterGrade: MonsterGrade.boss, // 최종 보스는 항상 boss 등급
),
currentCombat: combatState,
);
return (progress: updatedProgress, queue: queue);
}
}

View File

@@ -1,6 +1,7 @@
import 'dart:math' as math;
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/src/core/engine/act_progression_service.dart';
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
import 'package:asciineverdie/src/core/engine/combat_tick_service.dart';
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
@@ -434,7 +435,8 @@ class ProgressService {
progress.plot.position >= progress.plot.max &&
!progress.pendingActCompletion) {
// Act Boss 소환 및 플래그 설정
final actBoss = _createActBoss(nextState);
final actProgressionService = ActProgressionService(config: config);
final actBoss = actProgressionService.createActBoss(nextState);
progress = progress.copyWith(
plot: progress.plot.copyWith(position: 0), // Plot bar 리셋
currentCombat: actBoss,
@@ -561,7 +563,8 @@ class ProgressService {
// 3. Act Boss 리트라이 체크
// pendingActCompletion이 true면 Act Boss 재소환
if (state.progress.pendingActCompletion) {
final actBoss = _createActBoss(state);
final actProgressionService = ActProgressionService(config: config);
final actBoss = actProgressionService.createActBoss(state);
final combatCalculator = CombatCalculator(rng: state.rng);
final durationMillis = combatCalculator.estimateCombatDurationMs(
player: actBoss.playerStats,
@@ -601,7 +604,8 @@ class ProgressService {
if (state.progress.bossLevelingEndTime != null) {
progress = progress.copyWith(clearBossLevelingEndTime: true);
}
return _startFinalBossFight(state, progress, queue);
final actProgressionService = ActProgressionService(config: config);
return actProgressionService.startFinalBossFight(state, progress, queue);
}
}
@@ -684,63 +688,6 @@ class ProgressService {
return (progress: progress, queue: queue);
}
/// 최종 보스(Glitch God) 전투 시작
///
/// Act V 플롯 완료 후 호출되며, 글리치 갓과의 전투를 설정합니다.
({ProgressState progress, QueueState queue}) _startFinalBossFight(
GameState state,
ProgressState progress,
QueueState queue,
) {
final level = state.traits.level;
// Glitch God 생성 (레벨 100 최종 보스)
final glitchGod = MonsterCombatStats.glitchGod();
// 플레이어 전투 스탯 생성 (Phase 12: 보스 레벨 기반 페널티 적용)
final playerCombatStats = CombatStats.fromStats(
stats: state.stats,
equipment: state.equipment,
level: level,
monsterLevel: glitchGod.level,
);
// 전투 상태 초기화
final combatState = CombatState.start(
playerStats: playerCombatStats,
monsterStats: glitchGod,
);
// 전투 시간 추정 (보스 전투는 더 길게)
final combatCalculator = CombatCalculator(rng: state.rng);
final baseDuration = combatCalculator.estimateCombatDurationMs(
player: playerCombatStats,
monster: glitchGod,
);
// 최종 보스는 최소 10초, 최대 60초
final durationMillis = baseDuration.clamp(10000, 60000);
final taskResult = pq_logic.startTask(
progress,
l10n.taskFinalBoss(glitchGod.name),
durationMillis,
);
final updatedProgress = taskResult.progress.copyWith(
currentTask: TaskInfo(
caption: taskResult.caption,
type: TaskType.kill,
monsterBaseName: 'Glitch God',
monsterPart: '*', // 특수 전리품
monsterLevel: glitchGod.level,
monsterGrade: MonsterGrade.boss, // 최종 보스는 항상 boss 등급
),
currentCombat: combatState,
);
return (progress: updatedProgress, queue: queue);
}
/// Advances quest completion, applies reward, and enqueues next quest task.
GameState completeQuest(GameState state) {
final result = pq_logic.completeQuest(
@@ -801,127 +748,24 @@ class ProgressService {
/// Advances plot to next act and applies any act-level rewards.
/// Returns gameComplete=true if Final Boss was defeated (game ends).
({GameState state, bool gameComplete}) completeAct(GameState state) {
// Act V 완료 시 (plotStageCount == 6) → 최종 보스 전투 시작
// plotStageCount: 1=Prologue, 2=Act I, 3=Act II, 4=Act III, 5=Act IV, 6=Act V
if (state.progress.plotStageCount >= 6) {
// 이미 최종 보스가 처치되었으면 게임 클리어
if (state.progress.finalBossState == FinalBossState.defeated) {
final updatedPlotHistory = [
...state.progress.plotHistory.map(
(e) => e.isComplete ? e : e.copyWith(isComplete: true),
),
const HistoryEntry(caption: '*** THE END ***', isComplete: true),
];
final actProgressionService = ActProgressionService(config: config);
final updatedProgress = state.progress.copyWith(
plotHistory: updatedPlotHistory,
);
return (
state: state.copyWith(progress: updatedProgress),
gameComplete: true,
);
}
// 최종 보스가 아직 등장하지 않았으면 보스 전투 시작
if (state.progress.finalBossState == FinalBossState.notSpawned) {
final updatedProgress = state.progress.copyWith(
finalBossState: FinalBossState.fighting,
);
// 게임은 아직 끝나지 않음 - 보스 전투 진행
return (
state: state.copyWith(progress: updatedProgress),
gameComplete: false,
);
}
// 보스 전투 중이면 계속 진행 (게임 종료 안 함)
return (state: state, gameComplete: false);
}
final actResult = pq_logic.completeAct(state.progress.plotStageCount);
// Act 보상 먼저 적용
final actRewards = actProgressionService.getActRewards(
state.progress.plotStageCount,
);
var nextState = state;
for (final reward in actResult.rewards) {
for (final reward in actRewards) {
nextState = _applyReward(nextState, reward);
}
final plotStages = nextState.progress.plotStageCount + 1;
// Act 완료 처리 (ActProgressionService 위임)
final result = actProgressionService.completeAct(nextState);
// 플롯 히스토리 업데이트: 이전 플롯 완료 표시, 새 플롯 추가
final updatedPlotHistory = [
...nextState.progress.plotHistory.map(
(e) => e.isComplete ? e : e.copyWith(isComplete: true),
),
HistoryEntry(caption: actResult.actTitle, isComplete: false),
];
var updatedProgress = nextState.progress.copyWith(
plot: ProgressBarState(position: 0, max: actResult.plotBarMaxSeconds),
plotStageCount: plotStages,
plotHistory: updatedPlotHistory,
return (
state: _recalculateEncumbrance(result.state),
gameComplete: result.gameComplete,
);
nextState = nextState.copyWith(progress: updatedProgress);
// Act I 완료 후(Prologue -> Act I) 첫 퀘스트 시작 (원본 Main.pas 로직)
// plotStages == 2는 Prologue(1) -> Act I(2) 전환을 의미
if (plotStages == 2) {
nextState = _startFirstQuest(nextState);
}
return (state: _recalculateEncumbrance(nextState), gameComplete: false);
}
/// 첫 퀘스트 시작 (Act I 시작 시)
GameState _startFirstQuest(GameState state) {
final result = pq_logic.completeQuest(
config,
state.rng,
state.traits.level,
);
// 퀘스트 바 초기화
final questBar = ProgressBarState(
position: 0,
max: 50 + state.rng.nextInt(100),
);
// 첫 퀘스트 히스토리 추가
final questHistory = [
HistoryEntry(caption: result.caption, isComplete: false),
];
// 퀘스트 몬스터 정보 저장 (Exterminate 타입용)
// 원본 fQuest.Caption = monsterData, fQuest.Tag = monsterIndex
final questMonster = result.monsterIndex != null
? QuestMonsterInfo(
monsterData: result.monsterName!,
monsterIndex: result.monsterIndex!,
)
: null;
// 첫 퀘스트 추가
final updatedQueue = QueueState(
entries: [
...state.queue.entries,
QueueEntry(
kind: QueueKind.task,
durationMillis: 50 + state.rng.nextInt(100),
caption: result.caption,
taskType: TaskType.neutral,
),
],
);
final progress = state.progress.copyWith(
quest: questBar,
questCount: 1,
questHistory: questHistory,
currentQuestMonster: questMonster,
);
return state.copyWith(progress: progress, queue: updatedQueue);
}
/// Developer-only cheat hooks for quickly finishing bars.
@@ -1096,55 +940,6 @@ class ProgressService {
return s[0].toUpperCase() + s.substring(1);
}
/// Act Boss 생성 (Act 완료 시)
///
/// 보스 레벨 = min(플레이어 레벨, Act 최소 레벨)로 설정하여
/// 플레이어가 이길 수 있는 수준 보장
CombatState _createActBoss(GameState state) {
final plotStage = state.progress.plotStageCount;
final actNumber = plotStage + 1;
// 보스 레벨 = min(플레이어 레벨, Act 최소 레벨)
// → 플레이어가 현재 레벨보다 높은 보스를 만나지 않도록 보장
final actMinLevel = ActMonsterLevel.forPlotStage(actNumber);
final bossLevel = math.min(state.traits.level, actMinLevel);
// Named monster 생성 (pq_logic.namedMonster 활용)
final bossName = pq_logic.namedMonster(config, state.rng, bossLevel);
final bossStats = MonsterBaseStats.forLevel(bossLevel);
// 플레이어 전투 스탯 생성
final playerCombatStats = CombatStats.fromStats(
stats: state.stats,
equipment: state.equipment,
level: state.traits.level,
monsterLevel: bossLevel,
);
// Boss 몬스터 스탯 생성 (일반 몬스터 대비 강화)
final monsterCombatStats = MonsterCombatStats(
name: bossName,
level: bossLevel,
atk: (bossStats.atk * 1.5).round(), // Boss 보정 (1.5배)
def: (bossStats.def * 1.5).round(),
hpMax: (bossStats.hp * 2.0).round(), // HP는 2.0배 (보스다운 전투 시간)
hpCurrent: (bossStats.hp * 2.0).round(),
criRate: 0.05,
criDamage: 1.5,
evasion: 0.0,
accuracy: 0.8,
attackDelayMs: 1000,
expReward: (bossStats.exp * 2.5).round(), // 경험치 보상 증가
);
// 전투 상태 초기화
return CombatState.start(
playerStats: playerCombatStats,
monsterStats: monsterCombatStats,
);
}
/// 플레이어 사망 처리 (Phase 4)
///
/// 모든 장비 상실 및 사망 정보 기록

View File

@@ -1,12 +1,18 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:asciineverdie/src/core/model/class_traits.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/race_traits.dart';
part 'combat_stats.freezed.dart';
part 'combat_stats.g.dart';
/// 전투용 파생 스탯
///
/// 기본 Stats와 Equipment를 기반으로 계산되는 전투 관련 수치.
/// 불변(immutable) 객체로 설계되어 상태 변경 시 새 인스턴스 생성.
class CombatStats {
@freezed
class CombatStats with _$CombatStats {
// ============================================================================
// 레벨 페널티 상수 (Phase 12)
// ============================================================================
@@ -38,121 +44,65 @@ class CombatStats {
// ============================================================================
/// 레벨 차이에 따른 확률 감소 배율 (플레이어 전용)
///
/// - levelDiff = monsterLevel - playerLevel (몬스터가 높으면 양수)
/// - 0레벨 차이: 1.0 (100% 유지)
/// - 10레벨 이상 차이: 0.2 (20% = 최저)
/// - 상승 없음 (플레이어가 높아도 보너스 없음)
static double _getLevelPenalty(int playerLevel, int monsterLevel) {
final levelDiff = monsterLevel - playerLevel;
if (levelDiff <= 0) return 1.0; // 플레이어가 높거나 같으면 페널티 없음
// 1레벨당 8%씩 감소 (100% → 92% → 84% → ... → 20%)
if (levelDiff <= 0) return 1.0;
final penalty = 1.0 - (levelDiff * _levelPenaltyPerLevel);
return penalty.clamp(_minLevelMultiplier, 1.0);
}
const CombatStats({
// 기본 스탯 (Stats에서 복사)
required this.str,
required this.con,
required this.dex,
required this.intelligence,
required this.wis,
required this.cha,
// 파생 스탯
required this.atk,
required this.def,
required this.magAtk,
required this.magDef,
required this.criRate,
required this.criDamage,
required this.evasion,
required this.accuracy,
required this.blockRate,
required this.parryRate,
required this.attackDelayMs,
const CombatStats._();
const factory CombatStats({
// 기본 스탯
/// 힘: 물리 공격력 보정
required int str,
/// 체력: HP, 방어력 보정
required int con,
/// 민첩: 회피율, 크리티컬율, 명중률, 공격 속도
required int dex,
/// 지능: 마법 공격력, MP
required int intelligence,
/// 지혜: 마법 방어력, MP 회복
required int wis,
/// 매력: 상점 가격, 드롭률 보정
required int cha,
// 파생 스탯 (전투용)
/// 물리 공격력
required int atk,
/// 물리 방어력
required int def,
/// 마법 공격력
required int magAtk,
/// 마법 방어력
required int magDef,
/// 크리티컬 확률 (0.0 ~ 1.0)
required double criRate,
/// 크리티컬 데미지 배율 (1.5 ~ 3.0)
required double criDamage,
/// 회피율 (0.0 ~ 0.5)
required double evasion,
/// 명중률 (0.8 ~ 1.0)
required double accuracy,
/// 방패 방어율 (0.0 ~ 0.4)
required double blockRate,
/// 무기로 쳐내기 확률 (0.0 ~ 0.3)
required double parryRate,
/// 공격 딜레이 (밀리초)
required int attackDelayMs,
// 자원
required this.hpMax,
required this.hpCurrent,
required this.mpMax,
required this.mpCurrent,
});
/// 최대 HP
required int hpMax,
/// 현재 HP
required int hpCurrent,
/// 최대 MP
required int mpMax,
/// 현재 MP
required int mpCurrent,
}) = _CombatStats;
// ============================================================================
// 기본 스탯
// ============================================================================
/// 힘: 물리 공격력 보정
final int str;
/// 체력: HP, 방어력 보정
final int con;
/// 민첩: 회피율, 크리티컬율, 명중률, 공격 속도
final int dex;
/// 지능: 마법 공격력, MP
final int intelligence;
/// 지혜: 마법 방어력, MP 회복
final int wis;
/// 매력: 상점 가격, 드롭률 보정
final int cha;
// ============================================================================
// 파생 스탯 (전투용)
// ============================================================================
/// 물리 공격력
final int atk;
/// 물리 방어력
final int def;
/// 마법 공격력
final int magAtk;
/// 마법 방어력
final int magDef;
/// 크리티컬 확률 (0.0 ~ 1.0)
final double criRate;
/// 크리티컬 데미지 배율 (1.5 ~ 3.0)
final double criDamage;
/// 회피율 (0.0 ~ 0.5)
final double evasion;
/// 명중률 (0.8 ~ 1.0)
final double accuracy;
/// 방패 방어율 (0.0 ~ 0.4)
final double blockRate;
/// 무기로 쳐내기 확률 (0.0 ~ 0.3)
final double parryRate;
/// 공격 딜레이 (밀리초)
final int attackDelayMs;
// ============================================================================
// 자원
// ============================================================================
/// 최대 HP
final int hpMax;
/// 현재 HP
final int hpCurrent;
/// 최대 MP
final int mpMax;
/// 현재 MP
final int mpCurrent;
factory CombatStats.fromJson(Map<String, dynamic> json) =>
_$CombatStatsFromJson(json);
// ============================================================================
// 유틸리티
@@ -190,66 +140,11 @@ class CombatStats {
return withHp(hpCurrent + amount);
}
CombatStats copyWith({
int? str,
int? con,
int? dex,
int? intelligence,
int? wis,
int? cha,
int? atk,
int? def,
int? magAtk,
int? magDef,
double? criRate,
double? criDamage,
double? evasion,
double? accuracy,
double? blockRate,
double? parryRate,
int? attackDelayMs,
int? hpMax,
int? hpCurrent,
int? mpMax,
int? mpCurrent,
}) {
return CombatStats(
str: str ?? this.str,
con: con ?? this.con,
dex: dex ?? this.dex,
intelligence: intelligence ?? this.intelligence,
wis: wis ?? this.wis,
cha: cha ?? this.cha,
atk: atk ?? this.atk,
def: def ?? this.def,
magAtk: magAtk ?? this.magAtk,
magDef: magDef ?? this.magDef,
criRate: criRate ?? this.criRate,
criDamage: criDamage ?? this.criDamage,
evasion: evasion ?? this.evasion,
accuracy: accuracy ?? this.accuracy,
blockRate: blockRate ?? this.blockRate,
parryRate: parryRate ?? this.parryRate,
attackDelayMs: attackDelayMs ?? this.attackDelayMs,
hpMax: hpMax ?? this.hpMax,
hpCurrent: hpCurrent ?? this.hpCurrent,
mpMax: mpMax ?? this.mpMax,
mpCurrent: mpCurrent ?? this.mpCurrent,
);
}
// ============================================================================
// 팩토리 메서드
// ============================================================================
/// Stats와 Equipment에서 CombatStats 생성
///
/// [stats] 캐릭터 기본 스탯
/// [equipment] 장착 장비 (장비 스탯 적용)
/// [level] 캐릭터 레벨 (스케일링용)
/// [race] 종족 특성 (선택사항, Phase 5)
/// [klass] 클래스 특성 (선택사항, Phase 5)
/// [monsterLevel] 상대 몬스터 레벨 (레벨 페널티 계산용, Phase 12)
factory CombatStats.fromStats({
required Stats stats,
required Equipment equipment,
@@ -321,7 +216,6 @@ class CombatStats {
final parryRate = (baseParryRate + equipStats.parryRate).clamp(0.0, 0.4);
// 공격 속도: 무기 기본 공속 + DEX 보정
// 무기 attackSpeed가 0이면 기본값 1000ms 사용
final weaponItem = equipment.items[0]; // 무기 슬롯
final weaponSpeed = weaponItem.stats.attackSpeed;
final baseAttackSpeed = weaponSpeed > 0 ? weaponSpeed : 1000;
@@ -339,32 +233,27 @@ class CombatStats {
// 종족 패시브 적용 (Phase 5)
// ========================================================================
// HP 보너스 (Heap Troll: +20%)
final raceHpBonus = race?.getPassiveValue(PassiveType.hpBonus) ?? 0.0;
if (raceHpBonus > 0) {
totalHpMax = (totalHpMax * (1 + raceHpBonus)).round();
}
// MP 보너스 (Pointer Fairy: +20%)
final raceMpBonus = race?.getPassiveValue(PassiveType.mpBonus) ?? 0.0;
if (raceMpBonus > 0) {
totalMpMax = (totalMpMax * (1 + raceMpBonus)).round();
}
// 마법 데미지 보너스 (Null Elf: +15%)
final raceMagicBonus =
race?.getPassiveValue(PassiveType.magicDamageBonus) ?? 0.0;
if (raceMagicBonus > 0) {
baseMagAtk = (baseMagAtk * (1 + raceMagicBonus)).round();
}
// 방어력 보너스 (Buffer Dwarf: +10%)
final raceDefBonus = race?.getPassiveValue(PassiveType.defenseBonus) ?? 0.0;
if (raceDefBonus > 0) {
baseDef = (baseDef * (1 + raceDefBonus)).round();
}
// 크리티컬 보너스 (Stack Goblin: +5%)
final raceCritBonus =
race?.getPassiveValue(PassiveType.criticalBonus) ?? 0.0;
criRate += raceCritBonus;
@@ -373,40 +262,34 @@ class CombatStats {
// 클래스 패시브 적용 (Phase 5)
// ========================================================================
// HP 보너스 (Garbage Collector: +30%)
final classHpBonus =
klass?.getPassiveValue(ClassPassiveType.hpBonus) ?? 0.0;
if (classHpBonus > 0) {
totalHpMax = (totalHpMax * (1 + classHpBonus)).round();
}
// 물리 공격력 보너스 (Bug Hunter: +20%)
final classPhysBonus =
klass?.getPassiveValue(ClassPassiveType.physicalDamageBonus) ?? 0.0;
if (classPhysBonus > 0) {
baseAtk = (baseAtk * (1 + classPhysBonus)).round();
}
// 방어력 보너스 (Debugger Paladin: +15%)
final classDefBonus =
klass?.getPassiveValue(ClassPassiveType.defenseBonus) ?? 0.0;
if (classDefBonus > 0) {
baseDef = (baseDef * (1 + classDefBonus)).round();
}
// 마법 데미지 보너스 (Compiler Mage: +25%)
final classMagBonus =
klass?.getPassiveValue(ClassPassiveType.magicDamageBonus) ?? 0.0;
if (classMagBonus > 0) {
baseMagAtk = (baseMagAtk * (1 + classMagBonus)).round();
}
// 회피율 보너스 (Refactor Monk: +15%)
final classEvasionBonus =
klass?.getPassiveValue(ClassPassiveType.evasionBonus) ?? 0.0;
evasion += classEvasionBonus;
// 크리티컬 보너스 (Pointer Assassin: +20%)
final classCritBonus =
klass?.getPassiveValue(ClassPassiveType.criticalBonus) ?? 0.0;
criRate += classCritBonus;
@@ -415,13 +298,11 @@ class CombatStats {
// 레벨 페널티 및 최종 클램핑 (Phase 12)
// ========================================================================
// 레벨 페널티 적용 (크리/회피/블록/패리)
criRate *= levelPenalty;
evasion *= levelPenalty;
var finalBlockRate = blockRate * levelPenalty;
var finalParryRate = parryRate * levelPenalty;
// 최종 클램핑 (새 캡 적용)
criRate = criRate.clamp(0.05, _maxCriRate);
evasion = evasion.clamp(0.0, _maxEvasion);
finalBlockRate = finalBlockRate.clamp(0.0, _maxBlockRate);
@@ -452,60 +333,6 @@ class CombatStats {
);
}
/// JSON으로 직렬화
Map<String, dynamic> toJson() {
return {
'str': str,
'con': con,
'dex': dex,
'intelligence': intelligence,
'wis': wis,
'cha': cha,
'atk': atk,
'def': def,
'magAtk': magAtk,
'magDef': magDef,
'criRate': criRate,
'criDamage': criDamage,
'evasion': evasion,
'accuracy': accuracy,
'blockRate': blockRate,
'parryRate': parryRate,
'attackDelayMs': attackDelayMs,
'hpMax': hpMax,
'hpCurrent': hpCurrent,
'mpMax': mpMax,
'mpCurrent': mpCurrent,
};
}
/// JSON에서 역직렬화
factory CombatStats.fromJson(Map<String, dynamic> json) {
return CombatStats(
str: json['str'] as int,
con: json['con'] as int,
dex: json['dex'] as int,
intelligence: json['intelligence'] as int,
wis: json['wis'] as int,
cha: json['cha'] as int,
atk: json['atk'] as int,
def: json['def'] as int,
magAtk: json['magAtk'] as int,
magDef: json['magDef'] as int,
criRate: (json['criRate'] as num).toDouble(),
criDamage: (json['criDamage'] as num).toDouble(),
evasion: (json['evasion'] as num).toDouble(),
accuracy: (json['accuracy'] as num).toDouble(),
blockRate: (json['blockRate'] as num).toDouble(),
parryRate: (json['parryRate'] as num).toDouble(),
attackDelayMs: json['attackDelayMs'] as int,
hpMax: json['hpMax'] as int,
hpCurrent: json['hpCurrent'] as int,
mpMax: json['mpMax'] as int,
mpCurrent: json['mpCurrent'] as int,
);
}
/// 테스트/디버그용 기본값
factory CombatStats.empty() => const CombatStats(
str: 10,

View File

@@ -0,0 +1,733 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'combat_stats.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
);
CombatStats _$CombatStatsFromJson(Map<String, dynamic> json) {
return _CombatStats.fromJson(json);
}
/// @nodoc
mixin _$CombatStats {
// 기본 스탯
/// 힘: 물리 공격력 보정
int get str => throw _privateConstructorUsedError;
/// 체력: HP, 방어력 보정
int get con => throw _privateConstructorUsedError;
/// 민첩: 회피율, 크리티컬율, 명중률, 공격 속도
int get dex => throw _privateConstructorUsedError;
/// 지능: 마법 공격력, MP
int get intelligence => throw _privateConstructorUsedError;
/// 지혜: 마법 방어력, MP 회복
int get wis => throw _privateConstructorUsedError;
/// 매력: 상점 가격, 드롭률 보정
int get cha => throw _privateConstructorUsedError; // 파생 스탯 (전투용)
/// 물리 공격력
int get atk => throw _privateConstructorUsedError;
/// 물리 방어력
int get def => throw _privateConstructorUsedError;
/// 마법 공격력
int get magAtk => throw _privateConstructorUsedError;
/// 마법 방어력
int get magDef => throw _privateConstructorUsedError;
/// 크리티컬 확률 (0.0 ~ 1.0)
double get criRate => throw _privateConstructorUsedError;
/// 크리티컬 데미지 배율 (1.5 ~ 3.0)
double get criDamage => throw _privateConstructorUsedError;
/// 회피율 (0.0 ~ 0.5)
double get evasion => throw _privateConstructorUsedError;
/// 명중률 (0.8 ~ 1.0)
double get accuracy => throw _privateConstructorUsedError;
/// 방패 방어율 (0.0 ~ 0.4)
double get blockRate => throw _privateConstructorUsedError;
/// 무기로 쳐내기 확률 (0.0 ~ 0.3)
double get parryRate => throw _privateConstructorUsedError;
/// 공격 딜레이 (밀리초)
int get attackDelayMs => throw _privateConstructorUsedError; // 자원
/// 최대 HP
int get hpMax => throw _privateConstructorUsedError;
/// 현재 HP
int get hpCurrent => throw _privateConstructorUsedError;
/// 최대 MP
int get mpMax => throw _privateConstructorUsedError;
/// 현재 MP
int get mpCurrent => throw _privateConstructorUsedError;
/// Serializes this CombatStats to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of CombatStats
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$CombatStatsCopyWith<CombatStats> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $CombatStatsCopyWith<$Res> {
factory $CombatStatsCopyWith(
CombatStats value,
$Res Function(CombatStats) then,
) = _$CombatStatsCopyWithImpl<$Res, CombatStats>;
@useResult
$Res call({
int str,
int con,
int dex,
int intelligence,
int wis,
int cha,
int atk,
int def,
int magAtk,
int magDef,
double criRate,
double criDamage,
double evasion,
double accuracy,
double blockRate,
double parryRate,
int attackDelayMs,
int hpMax,
int hpCurrent,
int mpMax,
int mpCurrent,
});
}
/// @nodoc
class _$CombatStatsCopyWithImpl<$Res, $Val extends CombatStats>
implements $CombatStatsCopyWith<$Res> {
_$CombatStatsCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of CombatStats
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? str = null,
Object? con = null,
Object? dex = null,
Object? intelligence = null,
Object? wis = null,
Object? cha = null,
Object? atk = null,
Object? def = null,
Object? magAtk = null,
Object? magDef = null,
Object? criRate = null,
Object? criDamage = null,
Object? evasion = null,
Object? accuracy = null,
Object? blockRate = null,
Object? parryRate = null,
Object? attackDelayMs = null,
Object? hpMax = null,
Object? hpCurrent = null,
Object? mpMax = null,
Object? mpCurrent = null,
}) {
return _then(
_value.copyWith(
str: null == str
? _value.str
: str // ignore: cast_nullable_to_non_nullable
as int,
con: null == con
? _value.con
: con // ignore: cast_nullable_to_non_nullable
as int,
dex: null == dex
? _value.dex
: dex // ignore: cast_nullable_to_non_nullable
as int,
intelligence: null == intelligence
? _value.intelligence
: intelligence // ignore: cast_nullable_to_non_nullable
as int,
wis: null == wis
? _value.wis
: wis // ignore: cast_nullable_to_non_nullable
as int,
cha: null == cha
? _value.cha
: cha // ignore: cast_nullable_to_non_nullable
as int,
atk: null == atk
? _value.atk
: atk // ignore: cast_nullable_to_non_nullable
as int,
def: null == def
? _value.def
: def // ignore: cast_nullable_to_non_nullable
as int,
magAtk: null == magAtk
? _value.magAtk
: magAtk // ignore: cast_nullable_to_non_nullable
as int,
magDef: null == magDef
? _value.magDef
: magDef // ignore: cast_nullable_to_non_nullable
as int,
criRate: null == criRate
? _value.criRate
: criRate // ignore: cast_nullable_to_non_nullable
as double,
criDamage: null == criDamage
? _value.criDamage
: criDamage // ignore: cast_nullable_to_non_nullable
as double,
evasion: null == evasion
? _value.evasion
: evasion // ignore: cast_nullable_to_non_nullable
as double,
accuracy: null == accuracy
? _value.accuracy
: accuracy // ignore: cast_nullable_to_non_nullable
as double,
blockRate: null == blockRate
? _value.blockRate
: blockRate // ignore: cast_nullable_to_non_nullable
as double,
parryRate: null == parryRate
? _value.parryRate
: parryRate // ignore: cast_nullable_to_non_nullable
as double,
attackDelayMs: null == attackDelayMs
? _value.attackDelayMs
: attackDelayMs // ignore: cast_nullable_to_non_nullable
as int,
hpMax: null == hpMax
? _value.hpMax
: hpMax // ignore: cast_nullable_to_non_nullable
as int,
hpCurrent: null == hpCurrent
? _value.hpCurrent
: hpCurrent // ignore: cast_nullable_to_non_nullable
as int,
mpMax: null == mpMax
? _value.mpMax
: mpMax // ignore: cast_nullable_to_non_nullable
as int,
mpCurrent: null == mpCurrent
? _value.mpCurrent
: mpCurrent // ignore: cast_nullable_to_non_nullable
as int,
)
as $Val,
);
}
}
/// @nodoc
abstract class _$$CombatStatsImplCopyWith<$Res>
implements $CombatStatsCopyWith<$Res> {
factory _$$CombatStatsImplCopyWith(
_$CombatStatsImpl value,
$Res Function(_$CombatStatsImpl) then,
) = __$$CombatStatsImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
int str,
int con,
int dex,
int intelligence,
int wis,
int cha,
int atk,
int def,
int magAtk,
int magDef,
double criRate,
double criDamage,
double evasion,
double accuracy,
double blockRate,
double parryRate,
int attackDelayMs,
int hpMax,
int hpCurrent,
int mpMax,
int mpCurrent,
});
}
/// @nodoc
class __$$CombatStatsImplCopyWithImpl<$Res>
extends _$CombatStatsCopyWithImpl<$Res, _$CombatStatsImpl>
implements _$$CombatStatsImplCopyWith<$Res> {
__$$CombatStatsImplCopyWithImpl(
_$CombatStatsImpl _value,
$Res Function(_$CombatStatsImpl) _then,
) : super(_value, _then);
/// Create a copy of CombatStats
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? str = null,
Object? con = null,
Object? dex = null,
Object? intelligence = null,
Object? wis = null,
Object? cha = null,
Object? atk = null,
Object? def = null,
Object? magAtk = null,
Object? magDef = null,
Object? criRate = null,
Object? criDamage = null,
Object? evasion = null,
Object? accuracy = null,
Object? blockRate = null,
Object? parryRate = null,
Object? attackDelayMs = null,
Object? hpMax = null,
Object? hpCurrent = null,
Object? mpMax = null,
Object? mpCurrent = null,
}) {
return _then(
_$CombatStatsImpl(
str: null == str
? _value.str
: str // ignore: cast_nullable_to_non_nullable
as int,
con: null == con
? _value.con
: con // ignore: cast_nullable_to_non_nullable
as int,
dex: null == dex
? _value.dex
: dex // ignore: cast_nullable_to_non_nullable
as int,
intelligence: null == intelligence
? _value.intelligence
: intelligence // ignore: cast_nullable_to_non_nullable
as int,
wis: null == wis
? _value.wis
: wis // ignore: cast_nullable_to_non_nullable
as int,
cha: null == cha
? _value.cha
: cha // ignore: cast_nullable_to_non_nullable
as int,
atk: null == atk
? _value.atk
: atk // ignore: cast_nullable_to_non_nullable
as int,
def: null == def
? _value.def
: def // ignore: cast_nullable_to_non_nullable
as int,
magAtk: null == magAtk
? _value.magAtk
: magAtk // ignore: cast_nullable_to_non_nullable
as int,
magDef: null == magDef
? _value.magDef
: magDef // ignore: cast_nullable_to_non_nullable
as int,
criRate: null == criRate
? _value.criRate
: criRate // ignore: cast_nullable_to_non_nullable
as double,
criDamage: null == criDamage
? _value.criDamage
: criDamage // ignore: cast_nullable_to_non_nullable
as double,
evasion: null == evasion
? _value.evasion
: evasion // ignore: cast_nullable_to_non_nullable
as double,
accuracy: null == accuracy
? _value.accuracy
: accuracy // ignore: cast_nullable_to_non_nullable
as double,
blockRate: null == blockRate
? _value.blockRate
: blockRate // ignore: cast_nullable_to_non_nullable
as double,
parryRate: null == parryRate
? _value.parryRate
: parryRate // ignore: cast_nullable_to_non_nullable
as double,
attackDelayMs: null == attackDelayMs
? _value.attackDelayMs
: attackDelayMs // ignore: cast_nullable_to_non_nullable
as int,
hpMax: null == hpMax
? _value.hpMax
: hpMax // ignore: cast_nullable_to_non_nullable
as int,
hpCurrent: null == hpCurrent
? _value.hpCurrent
: hpCurrent // ignore: cast_nullable_to_non_nullable
as int,
mpMax: null == mpMax
? _value.mpMax
: mpMax // ignore: cast_nullable_to_non_nullable
as int,
mpCurrent: null == mpCurrent
? _value.mpCurrent
: mpCurrent // ignore: cast_nullable_to_non_nullable
as int,
),
);
}
}
/// @nodoc
@JsonSerializable()
class _$CombatStatsImpl extends _CombatStats {
const _$CombatStatsImpl({
required this.str,
required this.con,
required this.dex,
required this.intelligence,
required this.wis,
required this.cha,
required this.atk,
required this.def,
required this.magAtk,
required this.magDef,
required this.criRate,
required this.criDamage,
required this.evasion,
required this.accuracy,
required this.blockRate,
required this.parryRate,
required this.attackDelayMs,
required this.hpMax,
required this.hpCurrent,
required this.mpMax,
required this.mpCurrent,
}) : super._();
factory _$CombatStatsImpl.fromJson(Map<String, dynamic> json) =>
_$$CombatStatsImplFromJson(json);
// 기본 스탯
/// 힘: 물리 공격력 보정
@override
final int str;
/// 체력: HP, 방어력 보정
@override
final int con;
/// 민첩: 회피율, 크리티컬율, 명중률, 공격 속도
@override
final int dex;
/// 지능: 마법 공격력, MP
@override
final int intelligence;
/// 지혜: 마법 방어력, MP 회복
@override
final int wis;
/// 매력: 상점 가격, 드롭률 보정
@override
final int cha;
// 파생 스탯 (전투용)
/// 물리 공격력
@override
final int atk;
/// 물리 방어력
@override
final int def;
/// 마법 공격력
@override
final int magAtk;
/// 마법 방어력
@override
final int magDef;
/// 크리티컬 확률 (0.0 ~ 1.0)
@override
final double criRate;
/// 크리티컬 데미지 배율 (1.5 ~ 3.0)
@override
final double criDamage;
/// 회피율 (0.0 ~ 0.5)
@override
final double evasion;
/// 명중률 (0.8 ~ 1.0)
@override
final double accuracy;
/// 방패 방어율 (0.0 ~ 0.4)
@override
final double blockRate;
/// 무기로 쳐내기 확률 (0.0 ~ 0.3)
@override
final double parryRate;
/// 공격 딜레이 (밀리초)
@override
final int attackDelayMs;
// 자원
/// 최대 HP
@override
final int hpMax;
/// 현재 HP
@override
final int hpCurrent;
/// 최대 MP
@override
final int mpMax;
/// 현재 MP
@override
final int mpCurrent;
@override
String toString() {
return 'CombatStats(str: $str, con: $con, dex: $dex, intelligence: $intelligence, wis: $wis, cha: $cha, atk: $atk, def: $def, magAtk: $magAtk, magDef: $magDef, criRate: $criRate, criDamage: $criDamage, evasion: $evasion, accuracy: $accuracy, blockRate: $blockRate, parryRate: $parryRate, attackDelayMs: $attackDelayMs, hpMax: $hpMax, hpCurrent: $hpCurrent, mpMax: $mpMax, mpCurrent: $mpCurrent)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$CombatStatsImpl &&
(identical(other.str, str) || other.str == str) &&
(identical(other.con, con) || other.con == con) &&
(identical(other.dex, dex) || other.dex == dex) &&
(identical(other.intelligence, intelligence) ||
other.intelligence == intelligence) &&
(identical(other.wis, wis) || other.wis == wis) &&
(identical(other.cha, cha) || other.cha == cha) &&
(identical(other.atk, atk) || other.atk == atk) &&
(identical(other.def, def) || other.def == def) &&
(identical(other.magAtk, magAtk) || other.magAtk == magAtk) &&
(identical(other.magDef, magDef) || other.magDef == magDef) &&
(identical(other.criRate, criRate) || other.criRate == criRate) &&
(identical(other.criDamage, criDamage) ||
other.criDamage == criDamage) &&
(identical(other.evasion, evasion) || other.evasion == evasion) &&
(identical(other.accuracy, accuracy) ||
other.accuracy == accuracy) &&
(identical(other.blockRate, blockRate) ||
other.blockRate == blockRate) &&
(identical(other.parryRate, parryRate) ||
other.parryRate == parryRate) &&
(identical(other.attackDelayMs, attackDelayMs) ||
other.attackDelayMs == attackDelayMs) &&
(identical(other.hpMax, hpMax) || other.hpMax == hpMax) &&
(identical(other.hpCurrent, hpCurrent) ||
other.hpCurrent == hpCurrent) &&
(identical(other.mpMax, mpMax) || other.mpMax == mpMax) &&
(identical(other.mpCurrent, mpCurrent) ||
other.mpCurrent == mpCurrent));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hashAll([
runtimeType,
str,
con,
dex,
intelligence,
wis,
cha,
atk,
def,
magAtk,
magDef,
criRate,
criDamage,
evasion,
accuracy,
blockRate,
parryRate,
attackDelayMs,
hpMax,
hpCurrent,
mpMax,
mpCurrent,
]);
/// Create a copy of CombatStats
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$CombatStatsImplCopyWith<_$CombatStatsImpl> get copyWith =>
__$$CombatStatsImplCopyWithImpl<_$CombatStatsImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$CombatStatsImplToJson(this);
}
}
abstract class _CombatStats extends CombatStats {
const factory _CombatStats({
required final int str,
required final int con,
required final int dex,
required final int intelligence,
required final int wis,
required final int cha,
required final int atk,
required final int def,
required final int magAtk,
required final int magDef,
required final double criRate,
required final double criDamage,
required final double evasion,
required final double accuracy,
required final double blockRate,
required final double parryRate,
required final int attackDelayMs,
required final int hpMax,
required final int hpCurrent,
required final int mpMax,
required final int mpCurrent,
}) = _$CombatStatsImpl;
const _CombatStats._() : super._();
factory _CombatStats.fromJson(Map<String, dynamic> json) =
_$CombatStatsImpl.fromJson;
// 기본 스탯
/// 힘: 물리 공격력 보정
@override
int get str;
/// 체력: HP, 방어력 보정
@override
int get con;
/// 민첩: 회피율, 크리티컬율, 명중률, 공격 속도
@override
int get dex;
/// 지능: 마법 공격력, MP
@override
int get intelligence;
/// 지혜: 마법 방어력, MP 회복
@override
int get wis;
/// 매력: 상점 가격, 드롭률 보정
@override
int get cha; // 파생 스탯 (전투용)
/// 물리 공격력
@override
int get atk;
/// 물리 방어력
@override
int get def;
/// 마법 공격력
@override
int get magAtk;
/// 마법 방어력
@override
int get magDef;
/// 크리티컬 확률 (0.0 ~ 1.0)
@override
double get criRate;
/// 크리티컬 데미지 배율 (1.5 ~ 3.0)
@override
double get criDamage;
/// 회피율 (0.0 ~ 0.5)
@override
double get evasion;
/// 명중률 (0.8 ~ 1.0)
@override
double get accuracy;
/// 방패 방어율 (0.0 ~ 0.4)
@override
double get blockRate;
/// 무기로 쳐내기 확률 (0.0 ~ 0.3)
@override
double get parryRate;
/// 공격 딜레이 (밀리초)
@override
int get attackDelayMs; // 자원
/// 최대 HP
@override
int get hpMax;
/// 현재 HP
@override
int get hpCurrent;
/// 최대 MP
@override
int get mpMax;
/// 현재 MP
@override
int get mpCurrent;
/// Create a copy of CombatStats
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$CombatStatsImplCopyWith<_$CombatStatsImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -0,0 +1,57 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'combat_stats.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$CombatStatsImpl _$$CombatStatsImplFromJson(Map<String, dynamic> json) =>
_$CombatStatsImpl(
str: (json['str'] as num).toInt(),
con: (json['con'] as num).toInt(),
dex: (json['dex'] as num).toInt(),
intelligence: (json['intelligence'] as num).toInt(),
wis: (json['wis'] as num).toInt(),
cha: (json['cha'] as num).toInt(),
atk: (json['atk'] as num).toInt(),
def: (json['def'] as num).toInt(),
magAtk: (json['magAtk'] as num).toInt(),
magDef: (json['magDef'] as num).toInt(),
criRate: (json['criRate'] as num).toDouble(),
criDamage: (json['criDamage'] as num).toDouble(),
evasion: (json['evasion'] as num).toDouble(),
accuracy: (json['accuracy'] as num).toDouble(),
blockRate: (json['blockRate'] as num).toDouble(),
parryRate: (json['parryRate'] as num).toDouble(),
attackDelayMs: (json['attackDelayMs'] as num).toInt(),
hpMax: (json['hpMax'] as num).toInt(),
hpCurrent: (json['hpCurrent'] as num).toInt(),
mpMax: (json['mpMax'] as num).toInt(),
mpCurrent: (json['mpCurrent'] as num).toInt(),
);
Map<String, dynamic> _$$CombatStatsImplToJson(_$CombatStatsImpl instance) =>
<String, dynamic>{
'str': instance.str,
'con': instance.con,
'dex': instance.dex,
'intelligence': instance.intelligence,
'wis': instance.wis,
'cha': instance.cha,
'atk': instance.atk,
'def': instance.def,
'magAtk': instance.magAtk,
'magDef': instance.magDef,
'criRate': instance.criRate,
'criDamage': instance.criDamage,
'evasion': instance.evasion,
'accuracy': instance.accuracy,
'blockRate': instance.blockRate,
'parryRate': instance.parryRate,
'attackDelayMs': instance.attackDelayMs,
'hpMax': instance.hpMax,
'hpCurrent': instance.hpCurrent,
'mpMax': instance.mpMax,
'mpCurrent': instance.mpCurrent,
};

View File

@@ -1,51 +1,38 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
import 'package:asciineverdie/src/core/model/item_stats.dart';
part 'equipment_item.freezed.dart';
part 'equipment_item.g.dart';
/// 장비 아이템
///
/// 개별 장비의 이름, 슬롯, 레벨, 스탯 등을 포함하는 클래스.
/// 불변(immutable) 객체로 설계됨.
class EquipmentItem {
const EquipmentItem({
required this.name,
required this.slot,
required this.level,
required this.weight,
required this.stats,
required this.rarity,
});
@freezed
class EquipmentItem with _$EquipmentItem {
const EquipmentItem._();
/// 아이템 이름 (예: "Flaming Sword of Doom")
final String name;
const factory EquipmentItem({
/// 아이템 이름 (예: "Flaming Sword of Doom")
required String name,
/// 장착 슬롯
@JsonKey(fromJson: _slotFromJson, toJson: _slotToJson)
required EquipmentSlot slot,
/// 아이템 레벨
required int level,
/// 무게 (STR 기반 휴대 제한용)
required int weight,
/// 아이템 스탯 보정치
required ItemStats stats,
/// 희귀도
@JsonKey(fromJson: _rarityFromJson, toJson: _rarityToJson)
required ItemRarity rarity,
}) = _EquipmentItem;
/// 장착 슬롯
final EquipmentSlot slot;
/// 아이템 레벨
final int level;
/// 무게 (STR 기반 휴대 제한용)
final int weight;
/// 아이템 스탯 보정치
final ItemStats stats;
/// 희귀도
final ItemRarity rarity;
/// 가중치 (자동 장착 비교용)
///
/// 가중치 = 기본값 + (레벨 * 10) + (희귀도 보너스) + (스탯 합계)
int get itemWeight {
const baseValue = 10;
return baseValue + (level * 10) + rarity.weightBonus + stats.totalStatValue;
}
/// 빈 아이템 여부
bool get isEmpty => name.isEmpty;
/// 유효한 아이템 여부
bool get isNotEmpty => name.isNotEmpty;
factory EquipmentItem.fromJson(Map<String, dynamic> json) =>
_$EquipmentItemFromJson(json);
/// 빈 아이템 생성 (특정 슬롯)
factory EquipmentItem.empty(EquipmentSlot slot) {
@@ -54,7 +41,7 @@ class EquipmentItem {
slot: slot,
level: 0,
weight: 0,
stats: ItemStats.empty,
stats: const ItemStats(),
rarity: ItemRarity.common,
);
}
@@ -71,59 +58,39 @@ class EquipmentItem {
);
}
EquipmentItem copyWith({
String? name,
EquipmentSlot? slot,
int? level,
int? weight,
ItemStats? stats,
ItemRarity? rarity,
}) {
return EquipmentItem(
name: name ?? this.name,
slot: slot ?? this.slot,
level: level ?? this.level,
weight: weight ?? this.weight,
stats: stats ?? this.stats,
rarity: rarity ?? this.rarity,
);
/// 가중치 (자동 장착 비교용)
///
/// 가중치 = 기본값 + (레벨 * 10) + (희귀도 보너스) + (스탯 합계)
int get itemWeight {
const baseValue = 10;
return baseValue + (level * 10) + rarity.weightBonus + stats.totalStatValue;
}
/// JSON으로 직렬화
Map<String, dynamic> toJson() {
return {
'name': name,
'slot': slot.name,
'level': level,
'weight': weight,
'stats': stats.toJson(),
'rarity': rarity.name,
};
}
/// 빈 아이템 여부
bool get isEmpty => name.isEmpty;
/// JSON에서 역직렬화
factory EquipmentItem.fromJson(Map<String, dynamic> json) {
final slotName = json['slot'] as String? ?? 'weapon';
final rarityName = json['rarity'] as String? ?? 'common';
return EquipmentItem(
name: json['name'] as String? ?? '',
slot: EquipmentSlot.values.firstWhere(
(s) => s.name == slotName,
orElse: () => EquipmentSlot.weapon,
),
level: json['level'] as int? ?? 0,
weight: json['weight'] as int? ?? 0,
stats: json['stats'] != null
? ItemStats.fromJson(json['stats'] as Map<String, dynamic>)
: ItemStats.empty,
rarity: ItemRarity.values.firstWhere(
(r) => r.name == rarityName,
orElse: () => ItemRarity.common,
),
);
}
/// 유효한 아이템 여부
bool get isNotEmpty => name.isNotEmpty;
@override
String toString() => name.isEmpty ? '(empty)' : name;
}
// JSON 변환 헬퍼 (세이브 파일 호환성 유지)
EquipmentSlot _slotFromJson(String? value) {
return EquipmentSlot.values.firstWhere(
(s) => s.name == value,
orElse: () => EquipmentSlot.weapon,
);
}
String _slotToJson(EquipmentSlot slot) => slot.name;
ItemRarity _rarityFromJson(String? value) {
return ItemRarity.values.firstWhere(
(r) => r.name == value,
orElse: () => ItemRarity.common,
);
}
String _rarityToJson(ItemRarity rarity) => rarity.name;

View File

@@ -0,0 +1,335 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'equipment_item.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
);
EquipmentItem _$EquipmentItemFromJson(Map<String, dynamic> json) {
return _EquipmentItem.fromJson(json);
}
/// @nodoc
mixin _$EquipmentItem {
/// 아이템 이름 (예: "Flaming Sword of Doom")
String get name => throw _privateConstructorUsedError;
/// 장착 슬롯
@JsonKey(fromJson: _slotFromJson, toJson: _slotToJson)
EquipmentSlot get slot => throw _privateConstructorUsedError;
/// 아이템 레벨
int get level => throw _privateConstructorUsedError;
/// 무게 (STR 기반 휴대 제한용)
int get weight => throw _privateConstructorUsedError;
/// 아이템 스탯 보정치
ItemStats get stats => throw _privateConstructorUsedError;
/// 희귀도
@JsonKey(fromJson: _rarityFromJson, toJson: _rarityToJson)
ItemRarity get rarity => throw _privateConstructorUsedError;
/// Serializes this EquipmentItem to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of EquipmentItem
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$EquipmentItemCopyWith<EquipmentItem> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $EquipmentItemCopyWith<$Res> {
factory $EquipmentItemCopyWith(
EquipmentItem value,
$Res Function(EquipmentItem) then,
) = _$EquipmentItemCopyWithImpl<$Res, EquipmentItem>;
@useResult
$Res call({
String name,
@JsonKey(fromJson: _slotFromJson, toJson: _slotToJson) EquipmentSlot slot,
int level,
int weight,
ItemStats stats,
@JsonKey(fromJson: _rarityFromJson, toJson: _rarityToJson)
ItemRarity rarity,
});
$ItemStatsCopyWith<$Res> get stats;
}
/// @nodoc
class _$EquipmentItemCopyWithImpl<$Res, $Val extends EquipmentItem>
implements $EquipmentItemCopyWith<$Res> {
_$EquipmentItemCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of EquipmentItem
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? name = null,
Object? slot = null,
Object? level = null,
Object? weight = null,
Object? stats = null,
Object? rarity = null,
}) {
return _then(
_value.copyWith(
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
slot: null == slot
? _value.slot
: slot // ignore: cast_nullable_to_non_nullable
as EquipmentSlot,
level: null == level
? _value.level
: level // ignore: cast_nullable_to_non_nullable
as int,
weight: null == weight
? _value.weight
: weight // ignore: cast_nullable_to_non_nullable
as int,
stats: null == stats
? _value.stats
: stats // ignore: cast_nullable_to_non_nullable
as ItemStats,
rarity: null == rarity
? _value.rarity
: rarity // ignore: cast_nullable_to_non_nullable
as ItemRarity,
)
as $Val,
);
}
/// Create a copy of EquipmentItem
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$ItemStatsCopyWith<$Res> get stats {
return $ItemStatsCopyWith<$Res>(_value.stats, (value) {
return _then(_value.copyWith(stats: value) as $Val);
});
}
}
/// @nodoc
abstract class _$$EquipmentItemImplCopyWith<$Res>
implements $EquipmentItemCopyWith<$Res> {
factory _$$EquipmentItemImplCopyWith(
_$EquipmentItemImpl value,
$Res Function(_$EquipmentItemImpl) then,
) = __$$EquipmentItemImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
String name,
@JsonKey(fromJson: _slotFromJson, toJson: _slotToJson) EquipmentSlot slot,
int level,
int weight,
ItemStats stats,
@JsonKey(fromJson: _rarityFromJson, toJson: _rarityToJson)
ItemRarity rarity,
});
@override
$ItemStatsCopyWith<$Res> get stats;
}
/// @nodoc
class __$$EquipmentItemImplCopyWithImpl<$Res>
extends _$EquipmentItemCopyWithImpl<$Res, _$EquipmentItemImpl>
implements _$$EquipmentItemImplCopyWith<$Res> {
__$$EquipmentItemImplCopyWithImpl(
_$EquipmentItemImpl _value,
$Res Function(_$EquipmentItemImpl) _then,
) : super(_value, _then);
/// Create a copy of EquipmentItem
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? name = null,
Object? slot = null,
Object? level = null,
Object? weight = null,
Object? stats = null,
Object? rarity = null,
}) {
return _then(
_$EquipmentItemImpl(
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
slot: null == slot
? _value.slot
: slot // ignore: cast_nullable_to_non_nullable
as EquipmentSlot,
level: null == level
? _value.level
: level // ignore: cast_nullable_to_non_nullable
as int,
weight: null == weight
? _value.weight
: weight // ignore: cast_nullable_to_non_nullable
as int,
stats: null == stats
? _value.stats
: stats // ignore: cast_nullable_to_non_nullable
as ItemStats,
rarity: null == rarity
? _value.rarity
: rarity // ignore: cast_nullable_to_non_nullable
as ItemRarity,
),
);
}
}
/// @nodoc
@JsonSerializable()
class _$EquipmentItemImpl extends _EquipmentItem {
const _$EquipmentItemImpl({
required this.name,
@JsonKey(fromJson: _slotFromJson, toJson: _slotToJson) required this.slot,
required this.level,
required this.weight,
required this.stats,
@JsonKey(fromJson: _rarityFromJson, toJson: _rarityToJson)
required this.rarity,
}) : super._();
factory _$EquipmentItemImpl.fromJson(Map<String, dynamic> json) =>
_$$EquipmentItemImplFromJson(json);
/// 아이템 이름 (예: "Flaming Sword of Doom")
@override
final String name;
/// 장착 슬롯
@override
@JsonKey(fromJson: _slotFromJson, toJson: _slotToJson)
final EquipmentSlot slot;
/// 아이템 레벨
@override
final int level;
/// 무게 (STR 기반 휴대 제한용)
@override
final int weight;
/// 아이템 스탯 보정치
@override
final ItemStats stats;
/// 희귀도
@override
@JsonKey(fromJson: _rarityFromJson, toJson: _rarityToJson)
final ItemRarity rarity;
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$EquipmentItemImpl &&
(identical(other.name, name) || other.name == name) &&
(identical(other.slot, slot) || other.slot == slot) &&
(identical(other.level, level) || other.level == level) &&
(identical(other.weight, weight) || other.weight == weight) &&
(identical(other.stats, stats) || other.stats == stats) &&
(identical(other.rarity, rarity) || other.rarity == rarity));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode =>
Object.hash(runtimeType, name, slot, level, weight, stats, rarity);
/// Create a copy of EquipmentItem
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$EquipmentItemImplCopyWith<_$EquipmentItemImpl> get copyWith =>
__$$EquipmentItemImplCopyWithImpl<_$EquipmentItemImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$EquipmentItemImplToJson(this);
}
}
abstract class _EquipmentItem extends EquipmentItem {
const factory _EquipmentItem({
required final String name,
@JsonKey(fromJson: _slotFromJson, toJson: _slotToJson)
required final EquipmentSlot slot,
required final int level,
required final int weight,
required final ItemStats stats,
@JsonKey(fromJson: _rarityFromJson, toJson: _rarityToJson)
required final ItemRarity rarity,
}) = _$EquipmentItemImpl;
const _EquipmentItem._() : super._();
factory _EquipmentItem.fromJson(Map<String, dynamic> json) =
_$EquipmentItemImpl.fromJson;
/// 아이템 이름 (예: "Flaming Sword of Doom")
@override
String get name;
/// 장착 슬롯
@override
@JsonKey(fromJson: _slotFromJson, toJson: _slotToJson)
EquipmentSlot get slot;
/// 아이템 레벨
@override
int get level;
/// 무게 (STR 기반 휴대 제한용)
@override
int get weight;
/// 아이템 스탯 보정치
@override
ItemStats get stats;
/// 희귀도
@override
@JsonKey(fromJson: _rarityFromJson, toJson: _rarityToJson)
ItemRarity get rarity;
/// Create a copy of EquipmentItem
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$EquipmentItemImplCopyWith<_$EquipmentItemImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -0,0 +1,27 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'equipment_item.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$EquipmentItemImpl _$$EquipmentItemImplFromJson(Map<String, dynamic> json) =>
_$EquipmentItemImpl(
name: json['name'] as String,
slot: _slotFromJson(json['slot'] as String?),
level: (json['level'] as num).toInt(),
weight: (json['weight'] as num).toInt(),
stats: ItemStats.fromJson(json['stats'] as Map<String, dynamic>),
rarity: _rarityFromJson(json['rarity'] as String?),
);
Map<String, dynamic> _$$EquipmentItemImplToJson(_$EquipmentItemImpl instance) =>
<String, dynamic>{
'name': instance.name,
'slot': _slotToJson(instance.slot),
'level': instance.level,
'weight': instance.weight,
'stats': instance.stats,
'rarity': _rarityToJson(instance.rarity),
};

View File

@@ -1,3 +1,8 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'item_stats.freezed.dart';
part 'item_stats.g.dart';
/// 아이템 희귀도
enum ItemRarity {
common,
@@ -29,80 +34,55 @@ enum ItemRarity {
///
/// 장비 아이템이 제공하는 스탯 보너스.
/// 모든 값은 기본 0이며, 장착 시 플레이어 스탯에 가산됨.
class ItemStats {
const ItemStats({
this.atk = 0,
this.def = 0,
this.magAtk = 0,
this.magDef = 0,
this.criRate = 0.0,
this.evasion = 0.0,
this.blockRate = 0.0,
this.parryRate = 0.0,
this.hpBonus = 0,
this.mpBonus = 0,
this.strBonus = 0,
this.conBonus = 0,
this.dexBonus = 0,
this.intBonus = 0,
this.wisBonus = 0,
this.chaBonus = 0,
this.attackSpeed = 0,
});
@freezed
class ItemStats with _$ItemStats {
const ItemStats._();
/// 물리 공격력 보정
final int atk;
const factory ItemStats({
/// 물리 공격력 보정
@Default(0) int atk,
/// 물리 방어력 보정
@Default(0) int def,
/// 마법 공격력 보정
@Default(0) int magAtk,
/// 마법 방어력 보정
@Default(0) int magDef,
/// 크리티컬 확률 보정 (0.0 ~ 1.0)
@Default(0.0) double criRate,
/// 회피율 보정 (0.0 ~ 1.0)
@Default(0.0) double evasion,
/// 방패 방어율 (방패 전용, 0.0 ~ 1.0)
@Default(0.0) double blockRate,
/// 무기 쳐내기 확률 (무기 전용, 0.0 ~ 1.0)
@Default(0.0) double parryRate,
/// HP 보너스
@Default(0) int hpBonus,
/// MP 보너스
@Default(0) int mpBonus,
/// STR 보너스
@Default(0) int strBonus,
/// CON 보너스
@Default(0) int conBonus,
/// DEX 보너스
@Default(0) int dexBonus,
/// INT 보너스
@Default(0) int intBonus,
/// WIS 보너스
@Default(0) int wisBonus,
/// CHA 보너스
@Default(0) int chaBonus,
/// 무기 공격속도 (밀리초, 무기 전용)
///
/// 0이면 기본값(1000ms) 사용, 값이 클수록 느린 공격.
/// 느린 무기는 높은 기본 데미지를 가짐.
@Default(0) int attackSpeed,
}) = _ItemStats;
/// 물리 방어력 보정
final int def;
factory ItemStats.fromJson(Map<String, dynamic> json) =>
_$ItemStatsFromJson(json);
/// 마법 공격력 보정
final int magAtk;
/// 마법 방어력 보정
final int magDef;
/// 크리티컬 확률 보정 (0.0 ~ 1.0)
final double criRate;
/// 회피율 보정 (0.0 ~ 1.0)
final double evasion;
/// 방패 방어율 (방패 전용, 0.0 ~ 1.0)
final double blockRate;
/// 무기 쳐내기 확률 (무기 전용, 0.0 ~ 1.0)
final double parryRate;
/// HP 보너스
final int hpBonus;
/// MP 보너스
final int mpBonus;
/// STR 보너스
final int strBonus;
/// CON 보너스
final int conBonus;
/// DEX 보너스
final int dexBonus;
/// INT 보너스
final int intBonus;
/// WIS 보너스
final int wisBonus;
/// CHA 보너스
final int chaBonus;
/// 무기 공격속도 (밀리초, 무기 전용)
///
/// 0이면 기본값(1000ms) 사용, 값이 클수록 느린 공격.
/// 느린 무기는 높은 기본 데미지를 가짐.
final int attackSpeed;
/// 빈 스탯 (보너스 없음)
static const empty = ItemStats();
/// 스탯 합계 (가중치 계산용)
int get totalStatValue {
@@ -124,55 +104,6 @@ class ItemStats {
chaBonus * 5;
}
/// 빈 스탯 (보너스 없음)
static const empty = ItemStats();
/// JSON으로 직렬화
Map<String, dynamic> toJson() {
return {
'atk': atk,
'def': def,
'magAtk': magAtk,
'magDef': magDef,
'criRate': criRate,
'evasion': evasion,
'blockRate': blockRate,
'parryRate': parryRate,
'hpBonus': hpBonus,
'mpBonus': mpBonus,
'strBonus': strBonus,
'conBonus': conBonus,
'dexBonus': dexBonus,
'intBonus': intBonus,
'wisBonus': wisBonus,
'chaBonus': chaBonus,
'attackSpeed': attackSpeed,
};
}
/// JSON에서 역직렬화
factory ItemStats.fromJson(Map<String, dynamic> json) {
return ItemStats(
atk: json['atk'] as int? ?? 0,
def: json['def'] as int? ?? 0,
magAtk: json['magAtk'] as int? ?? 0,
magDef: json['magDef'] as int? ?? 0,
criRate: (json['criRate'] as num?)?.toDouble() ?? 0.0,
evasion: (json['evasion'] as num?)?.toDouble() ?? 0.0,
blockRate: (json['blockRate'] as num?)?.toDouble() ?? 0.0,
parryRate: (json['parryRate'] as num?)?.toDouble() ?? 0.0,
hpBonus: json['hpBonus'] as int? ?? 0,
mpBonus: json['mpBonus'] as int? ?? 0,
strBonus: json['strBonus'] as int? ?? 0,
conBonus: json['conBonus'] as int? ?? 0,
dexBonus: json['dexBonus'] as int? ?? 0,
intBonus: json['intBonus'] as int? ?? 0,
wisBonus: json['wisBonus'] as int? ?? 0,
chaBonus: json['chaBonus'] as int? ?? 0,
attackSpeed: json['attackSpeed'] as int? ?? 0,
);
}
/// 두 스탯 합산
///
/// attackSpeed는 합산 대상 아님 (무기 슬롯 단일 값)
@@ -197,44 +128,4 @@ class ItemStats {
// attackSpeed는 무기에서만 직접 참조
);
}
ItemStats copyWith({
int? atk,
int? def,
int? magAtk,
int? magDef,
double? criRate,
double? evasion,
double? blockRate,
double? parryRate,
int? hpBonus,
int? mpBonus,
int? strBonus,
int? conBonus,
int? dexBonus,
int? intBonus,
int? wisBonus,
int? chaBonus,
int? attackSpeed,
}) {
return ItemStats(
atk: atk ?? this.atk,
def: def ?? this.def,
magAtk: magAtk ?? this.magAtk,
magDef: magDef ?? this.magDef,
criRate: criRate ?? this.criRate,
evasion: evasion ?? this.evasion,
blockRate: blockRate ?? this.blockRate,
parryRate: parryRate ?? this.parryRate,
hpBonus: hpBonus ?? this.hpBonus,
mpBonus: mpBonus ?? this.mpBonus,
strBonus: strBonus ?? this.strBonus,
conBonus: conBonus ?? this.conBonus,
dexBonus: dexBonus ?? this.dexBonus,
intBonus: intBonus ?? this.intBonus,
wisBonus: wisBonus ?? this.wisBonus,
chaBonus: chaBonus ?? this.chaBonus,
attackSpeed: attackSpeed ?? this.attackSpeed,
);
}
}

View File

@@ -0,0 +1,651 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'item_stats.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
);
ItemStats _$ItemStatsFromJson(Map<String, dynamic> json) {
return _ItemStats.fromJson(json);
}
/// @nodoc
mixin _$ItemStats {
/// 물리 공격력 보정
int get atk => throw _privateConstructorUsedError;
/// 물리 방어력 보정
int get def => throw _privateConstructorUsedError;
/// 마법 공격력 보정
int get magAtk => throw _privateConstructorUsedError;
/// 마법 방어력 보정
int get magDef => throw _privateConstructorUsedError;
/// 크리티컬 확률 보정 (0.0 ~ 1.0)
double get criRate => throw _privateConstructorUsedError;
/// 회피율 보정 (0.0 ~ 1.0)
double get evasion => throw _privateConstructorUsedError;
/// 방패 방어율 (방패 전용, 0.0 ~ 1.0)
double get blockRate => throw _privateConstructorUsedError;
/// 무기 쳐내기 확률 (무기 전용, 0.0 ~ 1.0)
double get parryRate => throw _privateConstructorUsedError;
/// HP 보너스
int get hpBonus => throw _privateConstructorUsedError;
/// MP 보너스
int get mpBonus => throw _privateConstructorUsedError;
/// STR 보너스
int get strBonus => throw _privateConstructorUsedError;
/// CON 보너스
int get conBonus => throw _privateConstructorUsedError;
/// DEX 보너스
int get dexBonus => throw _privateConstructorUsedError;
/// INT 보너스
int get intBonus => throw _privateConstructorUsedError;
/// WIS 보너스
int get wisBonus => throw _privateConstructorUsedError;
/// CHA 보너스
int get chaBonus => throw _privateConstructorUsedError;
/// 무기 공격속도 (밀리초, 무기 전용)
///
/// 0이면 기본값(1000ms) 사용, 값이 클수록 느린 공격.
/// 느린 무기는 높은 기본 데미지를 가짐.
int get attackSpeed => throw _privateConstructorUsedError;
/// Serializes this ItemStats to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of ItemStats
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$ItemStatsCopyWith<ItemStats> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $ItemStatsCopyWith<$Res> {
factory $ItemStatsCopyWith(ItemStats value, $Res Function(ItemStats) then) =
_$ItemStatsCopyWithImpl<$Res, ItemStats>;
@useResult
$Res call({
int atk,
int def,
int magAtk,
int magDef,
double criRate,
double evasion,
double blockRate,
double parryRate,
int hpBonus,
int mpBonus,
int strBonus,
int conBonus,
int dexBonus,
int intBonus,
int wisBonus,
int chaBonus,
int attackSpeed,
});
}
/// @nodoc
class _$ItemStatsCopyWithImpl<$Res, $Val extends ItemStats>
implements $ItemStatsCopyWith<$Res> {
_$ItemStatsCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of ItemStats
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? atk = null,
Object? def = null,
Object? magAtk = null,
Object? magDef = null,
Object? criRate = null,
Object? evasion = null,
Object? blockRate = null,
Object? parryRate = null,
Object? hpBonus = null,
Object? mpBonus = null,
Object? strBonus = null,
Object? conBonus = null,
Object? dexBonus = null,
Object? intBonus = null,
Object? wisBonus = null,
Object? chaBonus = null,
Object? attackSpeed = null,
}) {
return _then(
_value.copyWith(
atk: null == atk
? _value.atk
: atk // ignore: cast_nullable_to_non_nullable
as int,
def: null == def
? _value.def
: def // ignore: cast_nullable_to_non_nullable
as int,
magAtk: null == magAtk
? _value.magAtk
: magAtk // ignore: cast_nullable_to_non_nullable
as int,
magDef: null == magDef
? _value.magDef
: magDef // ignore: cast_nullable_to_non_nullable
as int,
criRate: null == criRate
? _value.criRate
: criRate // ignore: cast_nullable_to_non_nullable
as double,
evasion: null == evasion
? _value.evasion
: evasion // ignore: cast_nullable_to_non_nullable
as double,
blockRate: null == blockRate
? _value.blockRate
: blockRate // ignore: cast_nullable_to_non_nullable
as double,
parryRate: null == parryRate
? _value.parryRate
: parryRate // ignore: cast_nullable_to_non_nullable
as double,
hpBonus: null == hpBonus
? _value.hpBonus
: hpBonus // ignore: cast_nullable_to_non_nullable
as int,
mpBonus: null == mpBonus
? _value.mpBonus
: mpBonus // ignore: cast_nullable_to_non_nullable
as int,
strBonus: null == strBonus
? _value.strBonus
: strBonus // ignore: cast_nullable_to_non_nullable
as int,
conBonus: null == conBonus
? _value.conBonus
: conBonus // ignore: cast_nullable_to_non_nullable
as int,
dexBonus: null == dexBonus
? _value.dexBonus
: dexBonus // ignore: cast_nullable_to_non_nullable
as int,
intBonus: null == intBonus
? _value.intBonus
: intBonus // ignore: cast_nullable_to_non_nullable
as int,
wisBonus: null == wisBonus
? _value.wisBonus
: wisBonus // ignore: cast_nullable_to_non_nullable
as int,
chaBonus: null == chaBonus
? _value.chaBonus
: chaBonus // ignore: cast_nullable_to_non_nullable
as int,
attackSpeed: null == attackSpeed
? _value.attackSpeed
: attackSpeed // ignore: cast_nullable_to_non_nullable
as int,
)
as $Val,
);
}
}
/// @nodoc
abstract class _$$ItemStatsImplCopyWith<$Res>
implements $ItemStatsCopyWith<$Res> {
factory _$$ItemStatsImplCopyWith(
_$ItemStatsImpl value,
$Res Function(_$ItemStatsImpl) then,
) = __$$ItemStatsImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
int atk,
int def,
int magAtk,
int magDef,
double criRate,
double evasion,
double blockRate,
double parryRate,
int hpBonus,
int mpBonus,
int strBonus,
int conBonus,
int dexBonus,
int intBonus,
int wisBonus,
int chaBonus,
int attackSpeed,
});
}
/// @nodoc
class __$$ItemStatsImplCopyWithImpl<$Res>
extends _$ItemStatsCopyWithImpl<$Res, _$ItemStatsImpl>
implements _$$ItemStatsImplCopyWith<$Res> {
__$$ItemStatsImplCopyWithImpl(
_$ItemStatsImpl _value,
$Res Function(_$ItemStatsImpl) _then,
) : super(_value, _then);
/// Create a copy of ItemStats
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? atk = null,
Object? def = null,
Object? magAtk = null,
Object? magDef = null,
Object? criRate = null,
Object? evasion = null,
Object? blockRate = null,
Object? parryRate = null,
Object? hpBonus = null,
Object? mpBonus = null,
Object? strBonus = null,
Object? conBonus = null,
Object? dexBonus = null,
Object? intBonus = null,
Object? wisBonus = null,
Object? chaBonus = null,
Object? attackSpeed = null,
}) {
return _then(
_$ItemStatsImpl(
atk: null == atk
? _value.atk
: atk // ignore: cast_nullable_to_non_nullable
as int,
def: null == def
? _value.def
: def // ignore: cast_nullable_to_non_nullable
as int,
magAtk: null == magAtk
? _value.magAtk
: magAtk // ignore: cast_nullable_to_non_nullable
as int,
magDef: null == magDef
? _value.magDef
: magDef // ignore: cast_nullable_to_non_nullable
as int,
criRate: null == criRate
? _value.criRate
: criRate // ignore: cast_nullable_to_non_nullable
as double,
evasion: null == evasion
? _value.evasion
: evasion // ignore: cast_nullable_to_non_nullable
as double,
blockRate: null == blockRate
? _value.blockRate
: blockRate // ignore: cast_nullable_to_non_nullable
as double,
parryRate: null == parryRate
? _value.parryRate
: parryRate // ignore: cast_nullable_to_non_nullable
as double,
hpBonus: null == hpBonus
? _value.hpBonus
: hpBonus // ignore: cast_nullable_to_non_nullable
as int,
mpBonus: null == mpBonus
? _value.mpBonus
: mpBonus // ignore: cast_nullable_to_non_nullable
as int,
strBonus: null == strBonus
? _value.strBonus
: strBonus // ignore: cast_nullable_to_non_nullable
as int,
conBonus: null == conBonus
? _value.conBonus
: conBonus // ignore: cast_nullable_to_non_nullable
as int,
dexBonus: null == dexBonus
? _value.dexBonus
: dexBonus // ignore: cast_nullable_to_non_nullable
as int,
intBonus: null == intBonus
? _value.intBonus
: intBonus // ignore: cast_nullable_to_non_nullable
as int,
wisBonus: null == wisBonus
? _value.wisBonus
: wisBonus // ignore: cast_nullable_to_non_nullable
as int,
chaBonus: null == chaBonus
? _value.chaBonus
: chaBonus // ignore: cast_nullable_to_non_nullable
as int,
attackSpeed: null == attackSpeed
? _value.attackSpeed
: attackSpeed // ignore: cast_nullable_to_non_nullable
as int,
),
);
}
}
/// @nodoc
@JsonSerializable()
class _$ItemStatsImpl extends _ItemStats {
const _$ItemStatsImpl({
this.atk = 0,
this.def = 0,
this.magAtk = 0,
this.magDef = 0,
this.criRate = 0.0,
this.evasion = 0.0,
this.blockRate = 0.0,
this.parryRate = 0.0,
this.hpBonus = 0,
this.mpBonus = 0,
this.strBonus = 0,
this.conBonus = 0,
this.dexBonus = 0,
this.intBonus = 0,
this.wisBonus = 0,
this.chaBonus = 0,
this.attackSpeed = 0,
}) : super._();
factory _$ItemStatsImpl.fromJson(Map<String, dynamic> json) =>
_$$ItemStatsImplFromJson(json);
/// 물리 공격력 보정
@override
@JsonKey()
final int atk;
/// 물리 방어력 보정
@override
@JsonKey()
final int def;
/// 마법 공격력 보정
@override
@JsonKey()
final int magAtk;
/// 마법 방어력 보정
@override
@JsonKey()
final int magDef;
/// 크리티컬 확률 보정 (0.0 ~ 1.0)
@override
@JsonKey()
final double criRate;
/// 회피율 보정 (0.0 ~ 1.0)
@override
@JsonKey()
final double evasion;
/// 방패 방어율 (방패 전용, 0.0 ~ 1.0)
@override
@JsonKey()
final double blockRate;
/// 무기 쳐내기 확률 (무기 전용, 0.0 ~ 1.0)
@override
@JsonKey()
final double parryRate;
/// HP 보너스
@override
@JsonKey()
final int hpBonus;
/// MP 보너스
@override
@JsonKey()
final int mpBonus;
/// STR 보너스
@override
@JsonKey()
final int strBonus;
/// CON 보너스
@override
@JsonKey()
final int conBonus;
/// DEX 보너스
@override
@JsonKey()
final int dexBonus;
/// INT 보너스
@override
@JsonKey()
final int intBonus;
/// WIS 보너스
@override
@JsonKey()
final int wisBonus;
/// CHA 보너스
@override
@JsonKey()
final int chaBonus;
/// 무기 공격속도 (밀리초, 무기 전용)
///
/// 0이면 기본값(1000ms) 사용, 값이 클수록 느린 공격.
/// 느린 무기는 높은 기본 데미지를 가짐.
@override
@JsonKey()
final int attackSpeed;
@override
String toString() {
return 'ItemStats(atk: $atk, def: $def, magAtk: $magAtk, magDef: $magDef, criRate: $criRate, evasion: $evasion, blockRate: $blockRate, parryRate: $parryRate, hpBonus: $hpBonus, mpBonus: $mpBonus, strBonus: $strBonus, conBonus: $conBonus, dexBonus: $dexBonus, intBonus: $intBonus, wisBonus: $wisBonus, chaBonus: $chaBonus, attackSpeed: $attackSpeed)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$ItemStatsImpl &&
(identical(other.atk, atk) || other.atk == atk) &&
(identical(other.def, def) || other.def == def) &&
(identical(other.magAtk, magAtk) || other.magAtk == magAtk) &&
(identical(other.magDef, magDef) || other.magDef == magDef) &&
(identical(other.criRate, criRate) || other.criRate == criRate) &&
(identical(other.evasion, evasion) || other.evasion == evasion) &&
(identical(other.blockRate, blockRate) ||
other.blockRate == blockRate) &&
(identical(other.parryRate, parryRate) ||
other.parryRate == parryRate) &&
(identical(other.hpBonus, hpBonus) || other.hpBonus == hpBonus) &&
(identical(other.mpBonus, mpBonus) || other.mpBonus == mpBonus) &&
(identical(other.strBonus, strBonus) ||
other.strBonus == strBonus) &&
(identical(other.conBonus, conBonus) ||
other.conBonus == conBonus) &&
(identical(other.dexBonus, dexBonus) ||
other.dexBonus == dexBonus) &&
(identical(other.intBonus, intBonus) ||
other.intBonus == intBonus) &&
(identical(other.wisBonus, wisBonus) ||
other.wisBonus == wisBonus) &&
(identical(other.chaBonus, chaBonus) ||
other.chaBonus == chaBonus) &&
(identical(other.attackSpeed, attackSpeed) ||
other.attackSpeed == attackSpeed));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
atk,
def,
magAtk,
magDef,
criRate,
evasion,
blockRate,
parryRate,
hpBonus,
mpBonus,
strBonus,
conBonus,
dexBonus,
intBonus,
wisBonus,
chaBonus,
attackSpeed,
);
/// Create a copy of ItemStats
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$ItemStatsImplCopyWith<_$ItemStatsImpl> get copyWith =>
__$$ItemStatsImplCopyWithImpl<_$ItemStatsImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$ItemStatsImplToJson(this);
}
}
abstract class _ItemStats extends ItemStats {
const factory _ItemStats({
final int atk,
final int def,
final int magAtk,
final int magDef,
final double criRate,
final double evasion,
final double blockRate,
final double parryRate,
final int hpBonus,
final int mpBonus,
final int strBonus,
final int conBonus,
final int dexBonus,
final int intBonus,
final int wisBonus,
final int chaBonus,
final int attackSpeed,
}) = _$ItemStatsImpl;
const _ItemStats._() : super._();
factory _ItemStats.fromJson(Map<String, dynamic> json) =
_$ItemStatsImpl.fromJson;
/// 물리 공격력 보정
@override
int get atk;
/// 물리 방어력 보정
@override
int get def;
/// 마법 공격력 보정
@override
int get magAtk;
/// 마법 방어력 보정
@override
int get magDef;
/// 크리티컬 확률 보정 (0.0 ~ 1.0)
@override
double get criRate;
/// 회피율 보정 (0.0 ~ 1.0)
@override
double get evasion;
/// 방패 방어율 (방패 전용, 0.0 ~ 1.0)
@override
double get blockRate;
/// 무기 쳐내기 확률 (무기 전용, 0.0 ~ 1.0)
@override
double get parryRate;
/// HP 보너스
@override
int get hpBonus;
/// MP 보너스
@override
int get mpBonus;
/// STR 보너스
@override
int get strBonus;
/// CON 보너스
@override
int get conBonus;
/// DEX 보너스
@override
int get dexBonus;
/// INT 보너스
@override
int get intBonus;
/// WIS 보너스
@override
int get wisBonus;
/// CHA 보너스
@override
int get chaBonus;
/// 무기 공격속도 (밀리초, 무기 전용)
///
/// 0이면 기본값(1000ms) 사용, 값이 클수록 느린 공격.
/// 느린 무기는 높은 기본 데미지를 가짐.
@override
int get attackSpeed;
/// Create a copy of ItemStats
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$ItemStatsImplCopyWith<_$ItemStatsImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@@ -0,0 +1,49 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'item_stats.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$ItemStatsImpl _$$ItemStatsImplFromJson(Map<String, dynamic> json) =>
_$ItemStatsImpl(
atk: (json['atk'] as num?)?.toInt() ?? 0,
def: (json['def'] as num?)?.toInt() ?? 0,
magAtk: (json['magAtk'] as num?)?.toInt() ?? 0,
magDef: (json['magDef'] as num?)?.toInt() ?? 0,
criRate: (json['criRate'] as num?)?.toDouble() ?? 0.0,
evasion: (json['evasion'] as num?)?.toDouble() ?? 0.0,
blockRate: (json['blockRate'] as num?)?.toDouble() ?? 0.0,
parryRate: (json['parryRate'] as num?)?.toDouble() ?? 0.0,
hpBonus: (json['hpBonus'] as num?)?.toInt() ?? 0,
mpBonus: (json['mpBonus'] as num?)?.toInt() ?? 0,
strBonus: (json['strBonus'] as num?)?.toInt() ?? 0,
conBonus: (json['conBonus'] as num?)?.toInt() ?? 0,
dexBonus: (json['dexBonus'] as num?)?.toInt() ?? 0,
intBonus: (json['intBonus'] as num?)?.toInt() ?? 0,
wisBonus: (json['wisBonus'] as num?)?.toInt() ?? 0,
chaBonus: (json['chaBonus'] as num?)?.toInt() ?? 0,
attackSpeed: (json['attackSpeed'] as num?)?.toInt() ?? 0,
);
Map<String, dynamic> _$$ItemStatsImplToJson(_$ItemStatsImpl instance) =>
<String, dynamic>{
'atk': instance.atk,
'def': instance.def,
'magAtk': instance.magAtk,
'magDef': instance.magDef,
'criRate': instance.criRate,
'evasion': instance.evasion,
'blockRate': instance.blockRate,
'parryRate': instance.parryRate,
'hpBonus': instance.hpBonus,
'mpBonus': instance.mpBonus,
'strBonus': instance.strBonus,
'conBonus': instance.conBonus,
'dexBonus': instance.dexBonus,
'intBonus': instance.intBonus,
'wisBonus': instance.wisBonus,
'chaBonus': instance.chaBonus,
'attackSpeed': instance.attackSpeed,
};

View File

@@ -0,0 +1,327 @@
import 'dart:math' as math;
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
import 'package:asciineverdie/src/core/model/pq_config.dart';
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
import 'package:asciineverdie/src/core/util/pq_random.dart';
/// 아이템/장비 생성 관련 함수들
/// 원본 Main.pas의 아이템 생성 로직을 포팅
/// 장비 생성 결과 (구조화된 데이터로 l10n 지원)
class EquipResult {
const EquipResult({
required this.baseName,
this.modifiers = const [],
this.plusValue = 0,
});
/// 기본 장비 이름 (예: "VPN Cloak")
final String baseName;
/// 수식어 목록 (예: ["Holey", "Deprecated"])
final List<String> modifiers;
/// +/- 수치 (예: -1, +2)
final int plusValue;
/// 영문 전체 이름 생성 (기존 방식)
String get displayName {
var name = baseName;
for (final mod in modifiers) {
name = '$mod $name';
}
if (plusValue != 0) {
name = '${plusValue > 0 ? '+' : ''}$plusValue $name';
}
return name;
}
}
/// 아이템 생성 결과 (구조화된 데이터로 l10n 지원)
class ItemResult {
const ItemResult({this.attrib, this.special, this.itemOf, this.boringItem});
/// 아이템 속성 (예: "Golden")
final String? attrib;
/// 특수 아이템 (예: "Iterator")
final String? special;
/// "~의" 접미사 (예: "Monitoring")
final String? itemOf;
/// 단순 아이템 (보링 아이템용)
final String? boringItem;
/// 영문 전체 이름 생성 (기존 방식)
String get displayName {
if (boringItem != null) return boringItem!;
if (attrib != null && special != null && itemOf != null) {
return '$attrib $special of $itemOf';
}
if (attrib != null && special != null) {
return '$attrib $special';
}
return '';
}
}
// =============================================================================
// 아이템 생성 함수들
// =============================================================================
/// 단순 아이템 (Boring Item)
String boringItem(PqConfig config, DeterministicRandom rng) {
return pick(config.boringItems, rng);
}
/// 흥미로운 아이템 (Interesting Item)
String interestingItem(PqConfig config, DeterministicRandom rng) {
final attr = pick(config.itemAttrib, rng);
final special = pick(config.specials, rng);
return '$attr $special';
}
/// 특수 아이템 (Special Item)
String specialItem(PqConfig config, DeterministicRandom rng) {
return '${interestingItem(config, rng)} of ${pick(config.itemOfs, rng)}';
}
/// 구조화된 단순 아이템 결과 반환 (l10n 지원)
ItemResult boringItemStructured(PqConfig config, DeterministicRandom rng) {
return ItemResult(boringItem: pick(config.boringItems, rng));
}
/// 구조화된 흥미로운 아이템 결과 반환 (l10n 지원)
ItemResult interestingItemStructured(PqConfig config, DeterministicRandom rng) {
return ItemResult(
attrib: pick(config.itemAttrib, rng),
special: pick(config.specials, rng),
);
}
/// 구조화된 특수 아이템 결과 반환 (l10n 지원)
ItemResult specialItemStructured(PqConfig config, DeterministicRandom rng) {
return ItemResult(
attrib: pick(config.itemAttrib, rng),
special: pick(config.specials, rng),
itemOf: pick(config.itemOfs, rng),
);
}
/// 구조화된 승리 아이템 결과 반환 (l10n 지원)
ItemResult winItemStructured(
PqConfig config,
DeterministicRandom rng,
int inventoryCount,
) {
final threshold = math.max(250, rng.nextInt(999));
if (inventoryCount > threshold) {
return const ItemResult(); // 빈 결과
}
return specialItemStructured(config, rng);
}
/// 승리 아이템 (문자열 반환)
String winItem(PqConfig config, DeterministicRandom rng, int inventoryCount) {
final threshold = math.max(250, rng.nextInt(999));
if (inventoryCount > threshold) return '';
return specialItem(config, rng);
}
// =============================================================================
// 장비 선택 함수들
// =============================================================================
/// 무기 선택
String pickWeapon(PqConfig config, DeterministicRandom rng, int level) {
return _lPick(config.weapons, rng, level);
}
/// 방패 선택
String pickShield(PqConfig config, DeterministicRandom rng, int level) {
return _lPick(config.shields, rng, level);
}
/// 방어구 선택
String pickArmor(PqConfig config, DeterministicRandom rng, int level) {
return _lPick(config.armors, rng, level);
}
/// 주문 선택
String pickSpell(PqConfig config, DeterministicRandom rng, int goalLevel) {
return _lPick(config.spells, rng, goalLevel);
}
/// 원본 Main.pas:776-789 LPick: 6회 시도하여 목표 레벨에 가장 가까운 아이템 선택
String _lPick(List<String> items, DeterministicRandom rng, int goal) {
if (items.isEmpty) return '';
var result = pick(items, rng);
var bestLevel = _parseLevel(result);
for (var i = 0; i < 5; i++) {
final candidate = pick(items, rng);
final candLevel = _parseLevel(candidate);
if ((goal - candLevel).abs() < (goal - bestLevel).abs()) {
result = candidate;
bestLevel = candLevel;
}
}
return result;
}
/// 아이템 문자열에서 레벨 파싱
int _parseLevel(String entry) {
final parts = entry.split('|');
if (parts.length < 2) return 0;
return int.tryParse(parts[1].replaceAll('+', '')) ?? 0;
}
// =============================================================================
// 장비 생성 함수들
// =============================================================================
/// 수식어 추가
String addModifier(
DeterministicRandom rng,
String baseName,
List<String> modifiers,
int plus,
) {
var name = baseName;
var remaining = plus;
var count = 0;
while (count < 2 && remaining != 0) {
final modifier = pick(modifiers, rng);
final parts = modifier.split('|');
if (parts.isEmpty) break;
final label = parts[0];
final qual = parts.length > 1 ? int.tryParse(parts[1]) ?? 0 : 0;
if (name.contains(label)) break; // avoid repeats
if (remaining.abs() < qual.abs()) break;
name = '$label $name';
remaining -= qual;
count++;
}
if (remaining != 0) {
name = '${remaining > 0 ? '+' : ''}$remaining $name';
}
return name;
}
/// 장비 생성 (원본 Main.pas:791-830 WinEquip)
/// [slotIndex]: 0=Weapon, 1=Shield, 2-10=Armor 계열
String winEquip(
PqConfig config,
DeterministicRandom rng,
int level,
int slotIndex,
) {
final bool isWeapon = slotIndex == 0;
final List<String> items;
if (slotIndex == 0) {
items = config.weapons;
} else if (slotIndex == 1) {
items = config.shields;
} else {
items = config.armors;
}
final better = isWeapon ? config.offenseAttrib : config.defenseAttrib;
final worse = isWeapon ? config.offenseBad : config.defenseBad;
final base = _lPick(items, rng, level);
final parts = base.split('|');
final baseName = parts[0];
final qual = parts.length > 1
? int.tryParse(parts[1].replaceAll('+', '')) ?? 0
: 0;
final plus = level - qual;
final modifierList = plus >= 0 ? better : worse;
return addModifier(rng, baseName, modifierList, plus);
}
/// EquipmentSlot enum을 사용하는 편의 함수
String winEquipBySlot(
PqConfig config,
DeterministicRandom rng,
int level,
EquipmentSlot slot,
) {
return winEquip(config, rng, level, slot.index);
}
/// 구조화된 장비 생성 결과 반환 (l10n 지원)
EquipResult winEquipStructured(
PqConfig config,
DeterministicRandom rng,
int level,
int slotIndex,
) {
final bool isWeapon = slotIndex == 0;
final List<String> items;
if (slotIndex == 0) {
items = config.weapons;
} else if (slotIndex == 1) {
items = config.shields;
} else {
items = config.armors;
}
final better = isWeapon ? config.offenseAttrib : config.defenseAttrib;
final worse = isWeapon ? config.offenseBad : config.defenseBad;
final base = _lPick(items, rng, level);
final parts = base.split('|');
final baseName = parts[0];
final qual = parts.length > 1
? int.tryParse(parts[1].replaceAll('+', '')) ?? 0
: 0;
final plus = level - qual;
final modifierList = plus >= 0 ? better : worse;
return _addModifierStructured(rng, baseName, modifierList, plus);
}
/// 구조화된 장비 결과 반환 (내부 함수)
EquipResult _addModifierStructured(
DeterministicRandom rng,
String baseName,
List<String> modifiers,
int plus,
) {
final collectedModifiers = <String>[];
var remaining = plus;
var count = 0;
while (count < 2 && remaining != 0) {
final modifier = pick(modifiers, rng);
final parts = modifier.split('|');
if (parts.isEmpty) break;
final label = parts[0];
final qual = parts.length > 1 ? int.tryParse(parts[1]) ?? 0 : 0;
if (collectedModifiers.contains(label)) break; // avoid repeats
if (remaining.abs() < qual.abs()) break;
collectedModifiers.add(label);
remaining -= qual;
count++;
}
return EquipResult(
baseName: baseName,
modifiers: collectedModifiers,
plusValue: remaining,
);
}
/// EquipmentSlot enum을 사용하는 구조화된 버전
EquipResult winEquipBySlotStructured(
PqConfig config,
DeterministicRandom rng,
int level,
EquipmentSlot slot,
) {
return winEquipStructured(config, rng, level, slot.index);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,283 @@
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/src/core/model/monster_grade.dart';
import 'package:asciineverdie/src/core/model/pq_config.dart';
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
import 'package:asciineverdie/src/core/util/pq_random.dart';
/// 몬스터 생성 관련 함수들
/// 원본 Main.pas의 몬스터 생성 로직을 포팅
/// monsterTask의 반환 타입 (원본 fTask.Caption 정보 포함)
class MonsterTaskResult {
const MonsterTaskResult({
required this.displayName,
required this.baseName,
required this.level,
required this.part,
required this.grade,
});
/// 화면에 표시할 몬스터 이름 (형용사 포함, 예: "a sick Goblin")
final String displayName;
/// 기본 몬스터 이름 (형용사 제외, 예: "Goblin")
final String baseName;
/// 몬스터 레벨
final int level;
/// 전리품 부위 (예: "claw", "tail", "*"는 WinItem 호출)
final String part;
/// 몬스터 등급 (Normal/Elite/Boss)
final MonsterGrade grade;
}
/// 몬스터 등급 결정 (Normal 85%, Elite 12%, Boss 3%)
/// 몬스터 레벨이 플레이어 레벨보다 높으면 상위 등급 확률 증가
MonsterGrade _determineGrade(
int monsterLevel,
int playerLevel,
DeterministicRandom rng,
) {
final levelDiff = monsterLevel - playerLevel;
final eliteBonus = (levelDiff * 2).clamp(0, 10);
final bossBonus = (levelDiff * 0.5).clamp(0, 3).toInt();
final roll = rng.nextInt(100);
if (roll < 3 + bossBonus) return MonsterGrade.boss;
if (roll < 15 + eliteBonus) return MonsterGrade.elite;
return MonsterGrade.normal;
}
/// 몬스터 문자열에서 레벨 파싱
int _monsterLevel(String entry) {
final parts = entry.split('|');
if (parts.length < 2) return 0;
return int.tryParse(parts[1]) ?? 0;
}
/// 몬스터 태스크 생성 (원본 Main.pas:523-640)
MonsterTaskResult monsterTask(
PqConfig config,
DeterministicRandom rng,
int level,
String? questMonster,
int? questLevel,
) {
var targetLevel = level;
for (var i = level; i > 0; i--) {
if (rng.nextInt(5) < 2) {
targetLevel += rng.nextInt(2) * 2 - 1; // RandSign
}
}
if (targetLevel < 1) targetLevel = 1;
String monster;
int monsterLevel;
String part;
bool definite = false;
// 원본 Main.pas:537-547: 가끔 NPC를 몬스터로 사용
if (rng.nextInt(25) == 0) {
final raceEn = pick(config.races, rng).split('|').first;
final race = l10n.translateRace(raceEn);
if (rng.nextInt(2) == 0) {
final klassEn = pick(config.klasses, rng).split('|').first;
final klass = l10n.translateKlass(klassEn);
monster = l10n.modifierPassing('$race $klass');
} else {
final titleEn = pickLow(config.titles, rng);
final title = l10n.translateTitle(titleEn);
monster = l10n.namedMonsterFormat(generateName(rng), '$title $race');
definite = true;
}
monsterLevel = targetLevel;
part = '*';
} else if (questMonster != null && rng.nextInt(4) == 0) {
monster = questMonster;
final parts = questMonster.split('|');
monsterLevel = questLevel ?? targetLevel;
part = parts.length > 2 ? parts[2] : '';
} else {
monster = pick(config.monsters, rng);
monsterLevel = _monsterLevel(monster);
for (var i = 0; i < 5; i++) {
final candidate = pick(config.monsters, rng);
final candLevel = _monsterLevel(candidate);
if ((targetLevel - candLevel).abs() <
(targetLevel - monsterLevel).abs()) {
monster = candidate;
monsterLevel = candLevel;
}
}
final monsterParts = monster.split('|');
part = monsterParts.length > 2 ? monsterParts[2] : '';
}
final baseName = monster.split('|').first;
var qty = 1;
final levelDiff = targetLevel - monsterLevel;
var name = l10n.translateMonster(baseName);
if (levelDiff > 10) {
qty =
(targetLevel + rng.nextInt(monsterLevel == 0 ? 1 : monsterLevel)) ~/
(monsterLevel == 0 ? 1 : monsterLevel);
if (qty < 1) qty = 1;
targetLevel ~/= qty;
}
if (levelDiff <= -10) {
name = l10n.modifierImaginary(name);
} else if (levelDiff < -5) {
final i = 5 - rng.nextInt(10 + levelDiff + 1);
name = _sick(i, _young((monsterLevel - targetLevel) - i, name));
} else if (levelDiff < 0) {
if (rng.nextInt(2) == 1) {
name = _sick(levelDiff, name);
} else {
name = _young(levelDiff, name);
}
} else if (levelDiff >= 10) {
name = l10n.modifierMessianic(name);
} else if (levelDiff > 5) {
final i = 5 - rng.nextInt(10 - levelDiff + 1);
name = _big(i, _special((levelDiff) - i, name));
} else if (levelDiff > 0) {
if (rng.nextInt(2) == 1) {
name = _big(levelDiff, name);
} else {
name = _special(levelDiff, name);
}
}
if (!definite) {
name = l10n.indefiniteL10n(name, qty);
}
final grade = _determineGrade(monsterLevel, level, rng);
return MonsterTaskResult(
displayName: name,
baseName: baseName,
level: monsterLevel * qty,
part: part,
grade: grade,
);
}
/// 이름 있는 몬스터 생성 (NamedMonster, Main.pas:498-512)
String namedMonster(PqConfig config, DeterministicRandom rng, int level) {
String best = '';
int bestLevel = 0;
for (var i = 0; i < 5; i++) {
final m = pick(config.monsters, rng);
final parts = m.split('|');
final name = parts.first;
final lev = parts.length > 1 ? (int.tryParse(parts[1]) ?? 0) : 0;
if (best.isEmpty || (level - lev).abs() < (level - bestLevel).abs()) {
best = name;
bestLevel = lev;
}
}
final translatedMonster = l10n.translateMonster(best);
return l10n.namedMonsterFormat(generateName(rng), translatedMonster);
}
// =============================================================================
// 몬스터 수식어 함수들
// =============================================================================
String _sick(int m, String s) {
switch (m) {
case -5:
case 5:
return l10n.modifierDead(s);
case -4:
case 4:
return l10n.modifierComatose(s);
case -3:
case 3:
return l10n.modifierCrippled(s);
case -2:
case 2:
return l10n.modifierSick(s);
case -1:
case 1:
return l10n.modifierUndernourished(s);
default:
return '$m$s';
}
}
String _young(int m, String s) {
switch (-m) {
case -5:
case 5:
return l10n.modifierFoetal(s);
case -4:
case 4:
return l10n.modifierBaby(s);
case -3:
case 3:
return l10n.modifierPreadolescent(s);
case -2:
case 2:
return l10n.modifierTeenage(s);
case -1:
case 1:
return l10n.modifierUnderage(s);
default:
return '$m$s';
}
}
String _big(int m, String s) {
switch (m) {
case 1:
case -1:
return l10n.modifierGreater(s);
case 2:
case -2:
return l10n.modifierMassive(s);
case 3:
case -3:
return l10n.modifierEnormous(s);
case 4:
case -4:
return l10n.modifierGiant(s);
case 5:
case -5:
return l10n.modifierTitanic(s);
default:
return s;
}
}
String _special(int m, String s) {
switch (-m) {
case 1:
case -1:
return s.contains(' ') ? l10n.modifierVeteran(s) : l10n.modifierBattle(s);
case 2:
case -2:
return l10n.modifierCursed(s);
case 3:
case -3:
return s.contains(' ') ? l10n.modifierWarrior(s) : l10n.modifierWere(s);
case 4:
case -4:
return l10n.modifierUndead(s);
case 5:
case -5:
return l10n.modifierDemon(s);
default:
return s;
}
}

View File

@@ -0,0 +1,283 @@
import 'dart:math' as math;
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/pq_config.dart';
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
import 'package:asciineverdie/src/core/util/pq_item.dart';
import 'package:asciineverdie/src/core/util/pq_monster.dart';
import 'package:asciineverdie/src/core/util/pq_random.dart';
import 'package:asciineverdie/src/core/util/roman.dart';
/// 퀘스트/Act/시네마틱 관련 함수들
/// 원본 Main.pas의 퀘스트 로직을 포팅
/// 보상 종류
enum RewardKind { spell, equip, stat, item }
/// 퀘스트 결과
class QuestResult {
const QuestResult({
required this.caption,
required this.reward,
this.monsterName,
this.monsterLevel,
this.monsterIndex,
});
final String caption;
final RewardKind reward;
/// 몬스터 전체 데이터 (예: "Rat|5|tail") - 원본 fQuest.Caption
final String? monsterName;
/// 몬스터 레벨 (파싱된 값)
final int? monsterLevel;
/// 몬스터 인덱스 (config.monsters에서의 위치) - 원본 fQuest.Tag
final int? monsterIndex;
}
/// Act 결과
class ActResult {
const ActResult({
required this.actTitle,
required this.plotBarMaxSeconds,
required this.rewards,
});
final String actTitle;
final int plotBarMaxSeconds;
final List<RewardKind> rewards;
}
/// 몬스터 문자열에서 레벨 파싱
int _monsterLevel(String entry) {
final parts = entry.split('|');
if (parts.length < 2) return 0;
return int.tryParse(parts[1]) ?? 0;
}
/// 퀘스트 완료 처리 (원본 Main.pas:930-984)
QuestResult completeQuest(PqConfig config, DeterministicRandom rng, int level) {
final rewardRoll = rng.nextInt(4);
final reward = switch (rewardRoll) {
0 => RewardKind.spell,
1 => RewardKind.equip,
2 => RewardKind.stat,
_ => RewardKind.item,
};
final questRoll = rng.nextInt(5);
switch (questRoll) {
case 0:
// Exterminate: 4번 시도하여 레벨에 가장 가까운 몬스터 선택
var best = '';
var bestLevel = 0;
var bestIndex = 0;
for (var i = 0; i < 4; i++) {
final monsterIndex = rng.nextInt(config.monsters.length);
final m = config.monsters[monsterIndex];
final l = _monsterLevel(m);
if (i == 0 || (l - level).abs() < (bestLevel - level).abs()) {
best = m;
bestLevel = l;
bestIndex = monsterIndex;
}
}
final nameEn = best.split('|').first;
final name = l10n.translateMonster(nameEn);
return QuestResult(
caption: l10n.questPatch(l10n.definiteL10n(name, 2)),
reward: reward,
monsterName: best,
monsterLevel: bestLevel,
monsterIndex: bestIndex,
);
case 1:
// interestingItem: attrib + special 조합 후 번역
final attr = pick(config.itemAttrib, rng);
final special = pick(config.specials, rng);
final item = l10n.translateInterestingItem(attr, special);
return QuestResult(
caption: l10n.questLocate(l10n.definiteL10n(item, 1)),
reward: reward,
);
case 2:
final itemEn = boringItem(config, rng);
final item = l10n.translateBoringItem(itemEn);
return QuestResult(caption: l10n.questTransfer(item), reward: reward);
case 3:
final itemEn = boringItem(config, rng);
final item = l10n.translateBoringItem(itemEn);
return QuestResult(
caption: l10n.questDownload(l10n.indefiniteL10n(item, 1)),
reward: reward,
);
default:
// Stabilize: 2번 시도하여 레벨에 가장 가까운 몬스터 선택
var best = '';
var bestLevel = 0;
for (var i = 0; i < 2; i++) {
final m = pick(config.monsters, rng);
final l = _monsterLevel(m);
if (i == 0 || (l - level).abs() < (bestLevel - level).abs()) {
best = m;
bestLevel = l;
}
}
final nameEn = best.split('|').first;
final name = l10n.translateMonster(nameEn);
return QuestResult(
caption: l10n.questStabilize(l10n.definiteL10n(name, 2)),
reward: reward,
);
}
}
/// Act별 Plot Bar 최대값 (초) - 10시간 완주 목표
const _actPlotBarSeconds = [
300, // Prologue: 5분
7200, // Act I: 2시간
10800, // Act II: 3시간
10800, // Act III: 3시간
5400, // Act IV: 1.5시간
1800, // Act V: 30분
];
/// Act 완료 처리
ActResult completeAct(int existingActCount) {
final nextActIndex = existingActCount;
final title = l10n.actTitle(intToRoman(nextActIndex));
final plotBarMax = existingActCount < _actPlotBarSeconds.length
? _actPlotBarSeconds[existingActCount]
: 3600;
final rewards = <RewardKind>[];
if (existingActCount >= 1) {
rewards.add(RewardKind.equip);
}
if (existingActCount > 1) {
rewards.add(RewardKind.item);
}
return ActResult(
actTitle: title,
plotBarMaxSeconds: plotBarMax,
rewards: rewards,
);
}
// =============================================================================
// 시네마틱 관련 함수들 (Main.pas:456-521)
// =============================================================================
/// NPC 이름 생성 (ImpressiveGuy, Main.pas:514-521)
String impressiveGuy(PqConfig config, DeterministicRandom rng) {
final titleEn = pick(config.impressiveTitles, rng);
final title = l10n.translateImpressiveTitle(titleEn);
switch (rng.nextInt(2)) {
case 0:
final raceEn = pick(config.races, rng).split('|').first;
final race = l10n.translateRace(raceEn);
return l10n.impressiveGuyPattern1(title, race);
case 1:
final name1 = generateName(rng);
final name2 = generateName(rng);
return l10n.impressiveGuyPattern2(title, name1, name2);
default:
return title;
}
}
/// 플롯 간 시네마틱 이벤트 생성 (InterplotCinematic, Main.pas:456-495)
List<QueueEntry> interplotCinematic(
PqConfig config,
DeterministicRandom rng,
int level,
int plotCount,
) {
final entries = <QueueEntry>[];
void q(QueueKind kind, int seconds, String caption) {
entries.add(
QueueEntry(kind: kind, durationMillis: seconds * 1000, caption: caption),
);
}
switch (rng.nextInt(3)) {
case 0:
// 시나리오 1: 안전한 캐시 영역 도착
q(QueueKind.task, 1, l10n.cinematicCacheZone1());
q(QueueKind.task, 2, l10n.cinematicCacheZone2());
q(QueueKind.task, 2, l10n.cinematicCacheZone3());
q(QueueKind.task, 1, l10n.cinematicCacheZone4());
break;
case 1:
// 시나리오 2: 강력한 버그와의 전투
q(QueueKind.task, 1, l10n.cinematicCombat1());
final nemesis = namedMonster(config, rng, level + 3);
q(QueueKind.task, 4, l10n.cinematicCombat2(nemesis));
var s = rng.nextInt(3);
final combatRounds = rng.nextInt(1 + plotCount);
for (var i = 0; i < combatRounds; i++) {
s += 1 + rng.nextInt(2);
switch (s % 3) {
case 0:
q(QueueKind.task, 2, l10n.cinematicCombatLocked(nemesis));
break;
case 1:
q(QueueKind.task, 2, l10n.cinematicCombatCorrupts(nemesis));
break;
case 2:
q(QueueKind.task, 2, l10n.cinematicCombatWorking(nemesis));
break;
}
}
q(QueueKind.task, 3, l10n.cinematicCombatVictory(nemesis));
q(QueueKind.task, 2, l10n.cinematicCombatWakeUp());
break;
case 2:
// 시나리오 3: 내부자 위협 발견
final guy = impressiveGuy(config, rng);
final itemEn = boringItem(config, rng);
final item = l10n.translateBoringItem(itemEn);
q(QueueKind.task, 2, l10n.cinematicBetrayal1(guy));
q(QueueKind.task, 3, l10n.cinematicBetrayal2(guy));
q(QueueKind.task, 2, l10n.cinematicBetrayal3(item));
q(QueueKind.task, 2, l10n.cinematicBetrayal4());
q(QueueKind.task, 2, l10n.cinematicBetrayal5(guy));
q(QueueKind.task, 3, l10n.cinematicBetrayal6());
break;
}
q(QueueKind.plot, 2, l10n.taskCompiling);
return entries;
}
// =============================================================================
// 스펠 관련 함수
// =============================================================================
/// 스펠 획득 (원본 Main.pas:770-774)
String winSpell(
PqConfig config,
DeterministicRandom rng,
int wisdom,
int level,
) {
final maxIndex = math.min(wisdom + level, config.spells.length);
if (maxIndex <= 0) return '';
final index = randomLow(rng, maxIndex);
final entry = config.spells[index];
final parts = entry.split('|');
final name = parts[0];
final currentRank = romanToInt(parts.length > 1 ? parts[1] : 'I');
final nextRank = math.max(1, currentRank + 1);
return '$name|${intToRoman(nextRank)}';
}

View File

@@ -0,0 +1,61 @@
import 'dart:math' as math;
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
/// 랜덤/확률 관련 유틸리티 함수들
/// 원본 Main.pas의 랜덤 헬퍼 함수들을 포팅
/// 두 번의 랜덤 중 작은 값 반환 (낮은 인덱스 선호)
int randomLow(DeterministicRandom rng, int below) {
return math.min(rng.nextInt(below), rng.nextInt(below));
}
/// 리스트에서 랜덤 선택
String pick(List<String> values, DeterministicRandom rng) {
if (values.isEmpty) return '';
return values[rng.nextInt(values.length)];
}
/// 리스트에서 낮은 인덱스 선호하여 선택
String pickLow(List<String> values, DeterministicRandom rng) {
if (values.isEmpty) return '';
return values[randomLow(rng, values.length)];
}
/// 64비트 범위 랜덤 (큰 범위용)
int random64Below(DeterministicRandom rng, int below) {
if (below <= 0) return 0;
final hi = rng.nextUint32();
final lo = rng.nextUint32();
final combined = (hi << 32) | lo;
return (combined % below).toInt();
}
/// 3d6 스탯 롤
int rollStat(DeterministicRandom rng) {
return 3 + rng.nextInt(6) + rng.nextInt(6) + rng.nextInt(6);
}
/// 캐릭터 이름 생성 (원본 NewGuy.pas 기반)
String generateName(DeterministicRandom rng) {
const kParts = [
'br|cr|dr|fr|gr|j|kr|l|m|n|pr||||r|sh|tr|v|wh|x|y|z',
'a|a|e|e|i|i|o|o|u|u|ae|ie|oo|ou',
'b|ck|d|g|k|m|n|p|t|v|x|z',
];
var result = '';
for (var i = 0; i <= 5; i++) {
result += _pickFromPipe(kParts[i % 3], rng);
}
if (result.isEmpty) return result;
return '${result[0].toUpperCase()}${result.substring(1)}';
}
/// 파이프(|)로 구분된 문자열에서 랜덤 선택
String _pickFromPipe(String pipeSeparated, DeterministicRandom rng) {
final parts = pipeSeparated.split('|');
if (parts.isEmpty) return '';
final idx = rng.nextInt(parts.length);
return parts[idx];
}

View File

@@ -0,0 +1,64 @@
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
import 'package:asciineverdie/src/core/util/pq_random.dart';
/// 스탯 관련 함수들
/// 원본 Main.pas의 스탯 처리 로직을 포팅
/// 스탯 증가 인덱스 결정 (원본 Main.pas:870-883)
int winStatIndex(DeterministicRandom rng, List<int> statValues) {
// 50%: 모든 8개 스탯 중 랜덤
// 50%: 첫 6개(STR~CHA)만 제곱 가중치로 선택
if (rng.nextInt(2) == 0) {
return rng.nextInt(statValues.length);
}
// 첫 6개(STR, CON, DEX, INT, WIS, CHA)만 제곱 가중치 적용
const firstSixCount = 6;
var total = 0;
for (var i = 0; i < firstSixCount && i < statValues.length; i++) {
total += statValues[i] * statValues[i];
}
if (total == 0) return rng.nextInt(firstSixCount);
var pickValue = random64Below(rng, total);
for (var i = 0; i < firstSixCount; i++) {
pickValue -= statValues[i] * statValues[i];
if (pickValue < 0) return i;
}
return firstSixCount - 1;
}
/// 스탯 증가 적용
Stats winStat(Stats stats, DeterministicRandom rng) {
final values = <int>[
stats.str,
stats.con,
stats.dex,
stats.intelligence,
stats.wis,
stats.cha,
stats.hpMax,
stats.mpMax,
];
final idx = winStatIndex(rng, values);
switch (idx) {
case 0:
return stats.copyWith(str: stats.str + 1);
case 1:
return stats.copyWith(con: stats.con + 1);
case 2:
return stats.copyWith(dex: stats.dex + 1);
case 3:
return stats.copyWith(intelligence: stats.intelligence + 1);
case 4:
return stats.copyWith(wis: stats.wis + 1);
case 5:
return stats.copyWith(cha: stats.cha + 1);
case 6:
return stats.copyWith(hpMax: stats.hpMax + 1);
case 7:
return stats.copyWith(mpMax: stats.mpMax + 1);
default:
return stats;
}
}

View File

@@ -0,0 +1,55 @@
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
/// 문자열 유틸리티 함수들
/// 원본 Main.pas의 문자열 처리 함수들을 포팅
/// 단어를 복수형으로 변환 (영문)
String pluralize(String s) {
if (_ends(s, 'y')) return '${s.substring(0, s.length - 1)}ies';
if (_ends(s, 'us')) return '${s.substring(0, s.length - 2)}i';
if (_ends(s, 'ch') || _ends(s, 'x') || _ends(s, 's')) return '${s}es';
if (_ends(s, 'f')) return '${s.substring(0, s.length - 1)}ves';
if (_ends(s, 'man') || _ends(s, 'Man')) {
return '${s.substring(0, s.length - 2)}en';
}
return '${s}s';
}
/// 부정관사 + 명사 (a/an + 단수 또는 수량 + 복수)
String indefinite(String s, int qty) {
if (qty == 1) {
const vowels = 'AEIOUÜaeiouü';
final first = s.isNotEmpty ? s[0] : 'a';
final article = vowels.contains(first) ? 'an' : 'a';
return '$article $s';
}
return '$qty ${pluralize(s)}';
}
/// 정관사 + 명사 (the + 명사)
String definite(String s, int qty) {
if (qty > 1) {
s = pluralize(s);
}
return 'the $s';
}
/// 초 단위 시간을 사람이 읽기 쉬운 형태로 변환 (원본 Main.pas:1265-1271)
/// l10n 지원
String roughTime(int seconds) {
if (seconds < 120) {
return l10n.roughTimeSeconds(seconds);
} else if (seconds < 60 * 120) {
return l10n.roughTimeMinutes(seconds ~/ 60);
} else if (seconds < 60 * 60 * 48) {
return l10n.roughTimeHours(seconds ~/ 3600);
} else {
return l10n.roughTimeDays(seconds ~/ (3600 * 24));
}
}
/// 문자열이 특정 접미사로 끝나는지 확인
bool _ends(String s, String suffix) {
return s.length >= suffix.length &&
s.substring(s.length - suffix.length) == suffix;
}

View File

@@ -0,0 +1,97 @@
import 'dart:collection';
import 'package:asciineverdie/src/core/model/game_state.dart';
/// 태스크/큐/진행 관련 함수들
/// 원본 Main.pas의 태스크 처리 로직을 포팅
/// 레벨업에 필요한 시간 (초)
int levelUpTimeSeconds(int level) {
// Act 진행과 레벨업 동기화 (10시간 완주 목표)
if (level <= 20) {
// Act I: 레벨 1-20, 2시간 (7200초) → 평균 360초/레벨
return 300 + (level * 6);
} else if (level <= 40) {
// Act II: 레벨 21-40, 3시간 (10800초) → 평균 540초/레벨
return 400 + ((level - 20) * 10);
} else if (level <= 60) {
// Act III: 레벨 41-60, 3시간 (10800초) → 평균 540초/레벨
return 400 + ((level - 40) * 10);
} else if (level <= 80) {
// Act IV: 레벨 61-80, 1.5시간 (5400초) → 평균 270초/레벨
return 200 + ((level - 60) * 5);
} else {
// Act V: 레벨 81-100, 30분 (1800초) → 평균 90초/레벨
return 60 + ((level - 80) * 3);
}
}
/// levelUpTimeSeconds의 별칭 (호환성 유지)
int levelUpTime(int level) => levelUpTimeSeconds(level);
/// 태스크 결과
class TaskResult {
const TaskResult({
required this.caption,
required this.durationMillis,
required this.progress,
});
final String caption;
final int durationMillis;
final ProgressState progress;
}
/// 태스크 시작: 태스크 바 리셋 및 캡션 설정
TaskResult startTask(
ProgressState progress,
String caption,
int durationMillis,
) {
final updated = progress.copyWith(
task: ProgressBarState(position: 0, max: durationMillis),
);
return TaskResult(
caption: '$caption...',
durationMillis: durationMillis,
progress: updated,
);
}
/// 큐 처리 결과
class DequeueResult {
const DequeueResult({
required this.progress,
required this.queue,
required this.caption,
required this.taskType,
required this.kind,
});
final ProgressState progress;
final QueueState queue;
final String caption;
final TaskType taskType;
final QueueKind kind;
}
/// 큐에서 다음 태스크 꺼내기
/// 현재 태스크가 완료되면 큐에서 다음 태스크를 가져옴
DequeueResult? dequeue(ProgressState progress, QueueState queue) {
// 태스크 바가 완료되지 않으면 null 반환
if (progress.task.position < progress.task.max) return null;
if (queue.entries.isEmpty) return null;
final entries = Queue<QueueEntry>.from(queue.entries);
if (entries.isEmpty) return null;
final next = entries.removeFirst();
final taskResult = startTask(progress, next.caption, next.durationMillis);
return DequeueResult(
progress: taskResult.progress,
queue: QueueState(entries: entries.toList()),
caption: taskResult.caption,
taskType: next.taskType,
kind: next.kind,
);
}

View File

@@ -0,0 +1,184 @@
import 'package:flutter/foundation.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
import 'package:asciineverdie/src/core/model/combat_event.dart';
import 'package:asciineverdie/src/core/model/combat_state.dart';
import 'package:asciineverdie/src/features/game/widgets/combat_log.dart';
/// 전투 로그 컨트롤러
///
/// GamePlayScreen에서 추출된 전투 로그 관련 로직 담당:
/// - 로그 엔트리 관리 (최대 50개 유지)
/// - 전투 이벤트 → 로그 메시지 변환
/// - 태스크 변경 시 로그 추가
class CombatLogController extends ChangeNotifier {
CombatLogController({
this.onCombatEvent,
});
/// 전투 이벤트 발생 시 호출되는 콜백 (SFX 재생 등에 사용)
final void Function(CombatEvent event)? onCombatEvent;
// 로그 엔트리 목록
final List<CombatLogEntry> _entries = [];
// 이벤트 처리 추적
int _lastProcessedEventCount = 0;
String _lastTaskCaption = '';
/// 로그 엔트리 목록 (읽기 전용)
List<CombatLogEntry> get entries => List.unmodifiable(_entries);
/// 로그 엔트리 추가
void addLog(String message, CombatLogType type) {
_entries.add(
CombatLogEntry(message: message, timestamp: DateTime.now(), type: type),
);
// 최대 50개 유지
if (_entries.length > 50) {
_entries.removeAt(0);
}
notifyListeners();
}
/// 태스크 변경 처리
///
/// 새 태스크 시작 시 로그에 추가하고 이벤트 카운터 리셋
void onTaskChanged(String caption) {
if (caption.isNotEmpty && caption != _lastTaskCaption) {
addLog(caption, CombatLogType.normal);
_lastTaskCaption = caption;
// 새 태스크 시작 시 이벤트 카운터 리셋
_lastProcessedEventCount = 0;
}
}
/// 전투 이벤트 처리
///
/// 새 전투 이벤트를 로그로 변환하고 콜백 호출
void processCombatEvents(CombatState? combat) {
if (combat == null || !combat.isActive) {
_lastProcessedEventCount = 0;
return;
}
final events = combat.recentEvents;
if (events.isEmpty || events.length <= _lastProcessedEventCount) {
return;
}
// 새 이벤트만 처리
final newEvents = events.skip(_lastProcessedEventCount);
for (final event in newEvents) {
final (message, type) = _formatCombatEvent(event);
addLog(message, type);
// 오디오 콜백 호출 (SFX 재생)
onCombatEvent?.call(event);
}
_lastProcessedEventCount = events.length;
}
/// 레벨업 로그 추가
void addLevelUpLog(int level) {
addLog(
'${game_l10n.uiLevelUp} Lv.$level',
CombatLogType.levelUp,
);
}
/// 퀘스트 완료 로그 추가
void addQuestCompleteLog(String questCaption) {
addLog(
game_l10n.uiQuestComplete(questCaption),
CombatLogType.questComplete,
);
}
/// 전투 이벤트를 메시지와 타입으로 변환
(String, CombatLogType) _formatCombatEvent(CombatEvent event) {
final target = event.targetName ?? '';
// 스킬/포션 이름 번역 (전역 로케일 사용)
final skillName = event.skillName != null
? game_l10n.translateSpell(event.skillName!)
: '';
return switch (event.type) {
CombatEventType.playerAttack =>
event.isCritical
? (
game_l10n.combatCritical(event.damage, target),
CombatLogType.critical,
)
: (
game_l10n.combatYouHit(target, event.damage),
CombatLogType.damage,
),
CombatEventType.monsterAttack => (
game_l10n.combatMonsterHitsYou(target, event.damage),
CombatLogType.monsterAttack,
),
CombatEventType.playerEvade => (
game_l10n.combatYouEvaded(target),
CombatLogType.evade,
),
CombatEventType.monsterEvade => (
game_l10n.combatMonsterEvaded(target),
CombatLogType.evade,
),
CombatEventType.playerBlock => (
game_l10n.combatBlocked(event.damage),
CombatLogType.block,
),
CombatEventType.playerParry => (
game_l10n.combatParried(event.damage),
CombatLogType.parry,
),
CombatEventType.playerSkill =>
event.isCritical
? (
game_l10n.combatSkillCritical(skillName, event.damage),
CombatLogType.critical,
)
: (
game_l10n.combatSkillDamage(skillName, event.damage),
CombatLogType.skill,
),
CombatEventType.playerHeal => (
game_l10n.combatSkillHeal(
skillName.isNotEmpty ? skillName : game_l10n.uiHeal,
event.healAmount,
),
CombatLogType.heal,
),
CombatEventType.playerBuff => (
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,
),
CombatEventType.playerPotion => (
game_l10n.combatPotionUsed(skillName, event.healAmount, target),
CombatLogType.potion,
),
CombatEventType.potionDrop => (
game_l10n.combatPotionDrop(skillName),
CombatLogType.potionDrop,
),
};
}
/// 로그 초기화
void reset() {
_entries.clear();
_lastProcessedEventCount = 0;
_lastTaskCaption = '';
notifyListeners();
}
}

View File

@@ -0,0 +1,251 @@
import 'package:flutter/foundation.dart';
import 'package:asciineverdie/data/story_data.dart';
import 'package:asciineverdie/src/core/audio/audio_service.dart';
import 'package:asciineverdie/src/core/model/combat_event.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/monster_grade.dart';
/// 게임 오디오 컨트롤러
///
/// GamePlayScreen에서 추출된 오디오 관련 로직 담당:
/// - BGM 전환 (전투/마을/사망/엔딩)
/// - 전투 이벤트 SFX 재생
/// - 볼륨 관리
class GameAudioController extends ChangeNotifier {
GameAudioController({
required this.audioService,
this.getSpeedMultiplier,
});
final AudioService? audioService;
/// 현재 배속 값을 가져오는 콜백 (디바운스 계산용)
final int Function()? getSpeedMultiplier;
// 오디오 상태 추적
bool _wasInBattleTask = false;
bool _wasDead = false;
bool _wasComplete = false;
// 볼륨 상태
double _bgmVolume = 0.7;
double _sfxVolume = 0.8;
// SFX 디바운스 추적
final Map<String, int> _lastSfxPlayTime = {};
// Getters
double get bgmVolume => _bgmVolume;
double get sfxVolume => _sfxVolume;
/// 오디오 볼륨 초기화 (AudioService에서 로드)
Future<void> initVolumes() async {
final audio = audioService;
if (audio != null) {
_bgmVolume = audio.bgmVolume;
_sfxVolume = audio.sfxVolume;
notifyListeners();
}
}
/// BGM 볼륨 설정
void setBgmVolume(double volume) {
_bgmVolume = volume;
audioService?.setBgmVolume(volume);
notifyListeners();
}
/// SFX 볼륨 설정
void setSfxVolume(double volume) {
_sfxVolume = volume;
audioService?.setSfxVolume(volume);
notifyListeners();
}
/// 일시정지
void pauseAll() {
audioService?.pauseAll();
}
/// 재개
void resumeAll() {
audioService?.resumeAll();
}
/// 초기 BGM 재생 (게임 시작/로드 시)
void playInitialBgm(GameState state) {
final audio = audioService;
if (audio == null) return;
final taskType = state.progress.currentTask.type;
final isInBattleTask = taskType == TaskType.kill;
if (isInBattleTask) {
audio.playBgm(_getBattleBgm(state));
} else {
// 비전투 태스크: 마을 BGM
audio.playBgm('town');
}
_wasInBattleTask = isInBattleTask;
}
/// TaskType 기반 BGM 전환 (애니메이션과 동기화)
void updateBgmForTaskType(GameState state) {
final audio = audioService;
if (audio == null) return;
final taskType = state.progress.currentTask.type;
final isInBattleTask = taskType == TaskType.kill;
if (isInBattleTask) {
final expectedBgm = _getBattleBgm(state);
// 전환 시점이거나 현재 BGM이 일치하지 않으면 재생
if (!_wasInBattleTask || audio.currentBgm != expectedBgm) {
audio.playBgm(expectedBgm);
}
} else {
// 비전투 태스크: 항상 마을 BGM 유지 (이미 town이면 스킵)
if (audio.currentBgm != 'town') {
audio.playBgm('town');
}
}
_wasInBattleTask = isInBattleTask;
}
/// 사망/엔딩 BGM 전환 처리
void updateDeathEndingBgm(GameState state, {required bool isGameComplete}) {
final audio = audioService;
if (audio == null) return;
final isDead = state.isDead;
// 엔딩 BGM (게임 클리어 시)
if (isGameComplete && !_wasComplete) {
audio.playBgm('ending');
_wasComplete = true;
return;
}
// 사망 BGM (isDead 상태 진입 시)
if (isDead && !_wasDead) {
audio.playBgm('death');
_wasDead = true;
return;
}
// 부활 시 사망 상태 리셋 (다음 사망 감지 가능하도록)
if (!isDead && _wasDead) {
_wasDead = false;
// 부활 후 BGM은 updateBgmForTaskType에서 처리됨
}
}
/// 시네마틱 BGM 재생
void playCinematicBgm() {
audioService?.playBgm('act_cinemetic');
}
/// 레벨업 SFX 재생
void playLevelUpSfx() {
audioService?.playPlayerSfx('level_up');
}
/// 퀘스트 완료 SFX 재생
void playQuestCompleteSfx() {
audioService?.playPlayerSfx('quest_complete');
}
/// 전투 이벤트별 SFX 재생 (채널 분리 + 디바운스)
void playCombatEventSfx(CombatEvent event) {
final audio = audioService;
if (audio == null) return;
// 사운드 이름 결정
final sfxName = switch (event.type) {
CombatEventType.playerAttack => 'attack',
CombatEventType.playerSkill => 'skill',
CombatEventType.playerHeal => 'item',
CombatEventType.playerPotion => 'item',
CombatEventType.potionDrop => 'item',
CombatEventType.playerBuff => 'skill',
CombatEventType.playerDebuff => 'skill',
CombatEventType.monsterAttack => 'hit',
CombatEventType.playerEvade => 'evade',
CombatEventType.monsterEvade => 'evade',
CombatEventType.playerBlock => 'block',
CombatEventType.playerParry => 'parry',
CombatEventType.dotTick => null, // DOT 틱은 SFX 없음
};
if (sfxName == null) return;
// 디바운스 체크 (배속 시 같은 사운드 중복 재생 방지)
final now = DateTime.now().millisecondsSinceEpoch;
final lastTime = _lastSfxPlayTime[sfxName] ?? 0;
final speedMultiplier = getSpeedMultiplier?.call() ?? 1;
// 배속이 높을수록 디바운스 간격 증가 (1x=50ms, 8x=150ms)
final debounceMs = 50 + (speedMultiplier - 1) * 15;
if (now - lastTime < debounceMs) {
return; // 디바운스 기간 내 → 스킵
}
_lastSfxPlayTime[sfxName] = now;
// 채널별 재생
final isMonsterSfx = event.type == CombatEventType.monsterAttack;
if (isMonsterSfx) {
audio.playMonsterSfx(sfxName);
} else {
audio.playPlayerSfx(sfxName);
}
}
/// 전투 BGM 결정 (몬스터 등급 + 레벨 + Act 고려)
String _getBattleBgm(GameState state) {
final task = state.progress.currentTask;
final monsterGrade = task.monsterGrade;
final monsterLevel = task.monsterLevel ?? 0;
final playerLevel = state.traits.level;
// 1. 등급 보스 (3% 확률로 등장하는 특수 보스)
if (monsterGrade == MonsterGrade.boss) {
return 'act_boss';
}
// 2. 레벨 기반 보스 (강적)
if (monsterLevel >= playerLevel + 5) {
return 'boss';
}
// 3. 엘리트 몬스터 (12% 확률)
if (monsterGrade == MonsterGrade.elite) {
return 'elite';
}
// 4. 일반 전투 (Act별 분기)
return _getBattleBgmForLevel(playerLevel);
}
/// 레벨에 따른 전투 BGM 파일명 반환 (Act별 분기)
String _getBattleBgmForLevel(int playerLevel) {
final act = getActForLevel(playerLevel);
return switch (act) {
StoryAct.act4 => 'battle_act4',
StoryAct.act5 => 'battle_act5',
_ => 'battle',
};
}
/// 상태 리셋 (새 게임 시작 시)
void reset() {
_wasInBattleTask = false;
_wasDead = false;
_wasComplete = false;
_lastSfxPlayTime.clear();
}
}

View File

@@ -10,10 +10,8 @@ import 'package:asciineverdie/data/story_data.dart';
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart';
import 'package:asciineverdie/src/core/engine/story_service.dart';
import 'package:asciineverdie/src/core/model/combat_event.dart';
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/monster_grade.dart';
import 'package:asciineverdie/src/core/model/skill.dart';
import 'package:asciineverdie/src/core/notification/notification_service.dart';
import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic;
@@ -36,6 +34,8 @@ import 'package:asciineverdie/src/features/game/widgets/statistics_dialog.dart';
import 'package:asciineverdie/src/features/game/widgets/help_dialog.dart';
import 'package:asciineverdie/src/core/storage/settings_repository.dart';
import 'package:asciineverdie/src/core/audio/audio_service.dart';
import 'package:asciineverdie/src/features/game/controllers/combat_log_controller.dart';
import 'package:asciineverdie/src/features/game/controllers/game_audio_controller.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
/// 게임 진행 화면 (Main.dfm 기반 3패널 레이아웃)
@@ -85,58 +85,34 @@ class _GamePlayScreenState extends State<GamePlayScreen>
StoryAct _lastAct = StoryAct.prologue;
bool _showingCinematic = false;
// Phase 8: 전투 로그 (Combat Log)
final List<CombatLogEntry> _combatLogEntries = [];
String _lastTaskCaption = '';
// 이전 상태 추적 (레벨업/퀘스트/Act 완료 감지용)
int _lastLevel = 0;
int _lastQuestCount = 0;
int _lastPlotStageCount = 0;
// 전투 이벤트 추적 (마지막 처리된 이벤트 수)
int _lastProcessedEventCount = 0;
// Phase 2.4: 오디오 컨트롤러
late final GameAudioController _audioController;
// 오디오 상태 추적 (TaskType 기반)
bool _wasInBattleTask = false;
// 사망/엔딩 상태 추적 (BGM 전환용)
bool _wasDead = false;
// 사운드 디바운스 추적 (배속 시 사운드 누락 방지)
final Map<String, int> _lastSfxPlayTime = {};
bool _wasComplete = false;
// 사운드 볼륨 상태 (모바일 설정 UI용)
double _bgmVolume = 0.7;
double _sfxVolume = 0.8;
// Phase 2.5: 전투 로그 컨트롤러
late final CombatLogController _combatLogController;
void _checkSpecialEvents(GameState state) {
// Phase 8: 태스크 변경 시 로그 추가
final currentCaption = state.progress.currentTask.caption;
if (currentCaption.isNotEmpty && currentCaption != _lastTaskCaption) {
_addCombatLog(currentCaption, CombatLogType.normal);
_lastTaskCaption = currentCaption;
// 새 태스크 시작 시 이벤트 카운터 리셋
_lastProcessedEventCount = 0;
}
_combatLogController.onTaskChanged(state.progress.currentTask.caption);
// 전투 이벤트 처리 (Combat Events)
_processCombatEvents(state);
_combatLogController.processCombatEvents(state.progress.currentCombat);
// 오디오: TaskType 변경 시 BGM 전환 (애니메이션과 동기화)
_updateBgmForTaskType(state);
_audioController.updateBgmForTaskType(state);
// 레벨업 감지
if (state.traits.level > _lastLevel && _lastLevel > 0) {
_specialAnimation = AsciiAnimationType.levelUp;
_notificationService.showLevelUp(state.traits.level);
_addCombatLog(
'${game_l10n.uiLevelUp} Lv.${state.traits.level}',
CombatLogType.levelUp,
);
_combatLogController.addLevelUpLog(state.traits.level);
// 오디오: 레벨업 SFX (플레이어 채널)
widget.audioService?.playPlayerSfx('level_up');
_audioController.playLevelUpSfx();
_resetSpecialAnimationAfterFrame();
// Phase 9: Act 변경 감지 (레벨 기반)
@@ -164,13 +140,10 @@ class _GamePlayScreenState extends State<GamePlayScreen>
.lastOrNull;
if (completedQuest != null) {
_notificationService.showQuestComplete(completedQuest.caption);
_addCombatLog(
game_l10n.uiQuestComplete(completedQuest.caption),
CombatLogType.questComplete,
);
_combatLogController.addQuestCompleteLog(completedQuest.caption);
}
// 오디오: 퀘스트 완료 SFX (플레이어 채널)
widget.audioService?.playPlayerSfx('quest_complete');
_audioController.playQuestCompleteSfx();
_resetSpecialAnimationAfterFrame();
}
_lastQuestCount = state.progress.questCount;
@@ -187,292 +160,10 @@ class _GamePlayScreenState extends State<GamePlayScreen>
_lastPlotStageCount = state.progress.plotStageCount;
// 사망/엔딩 BGM 전환 (Death/Ending BGM Transition)
_updateDeathEndingBgm(state);
}
/// 사망/엔딩 BGM 전환 처리
void _updateDeathEndingBgm(GameState state) {
final audio = widget.audioService;
if (audio == null) return;
final isDead = state.isDead;
final isComplete = widget.controller.isComplete;
// 엔딩 BGM (게임 클리어 시)
if (isComplete && !_wasComplete) {
audio.playBgm('ending');
_wasComplete = true;
return;
}
// 사망 BGM (isDead 상태 진입 시)
if (isDead && !_wasDead) {
audio.playBgm('death');
_wasDead = true;
return;
}
// 부활 시 사망 상태 리셋 (다음 사망 감지 가능하도록)
if (!isDead && _wasDead) {
_wasDead = false;
// 부활 후 BGM은 _updateBgmForTaskType에서 처리됨
}
}
/// Phase 8: 전투 로그 추가 (Add Combat Log Entry)
void _addCombatLog(String message, CombatLogType type) {
_combatLogEntries.add(
CombatLogEntry(message: message, timestamp: DateTime.now(), type: type),
_audioController.updateDeathEndingBgm(
state,
isGameComplete: widget.controller.isComplete,
);
// 최대 50개 유지
if (_combatLogEntries.length > 50) {
_combatLogEntries.removeAt(0);
}
}
/// 전투 이벤트를 로그로 변환 (Convert Combat Events to Log)
void _processCombatEvents(GameState state) {
final combat = state.progress.currentCombat;
if (combat == null || !combat.isActive) {
_lastProcessedEventCount = 0;
return;
}
final events = combat.recentEvents;
if (events.isEmpty || events.length <= _lastProcessedEventCount) {
return;
}
// 새 이벤트만 처리
final newEvents = events.skip(_lastProcessedEventCount);
for (final event in newEvents) {
final (message, type) = _formatCombatEvent(event);
_addCombatLog(message, type);
// 오디오: 전투 이벤트에 따른 SFX 재생
_playCombatEventSfx(event);
}
_lastProcessedEventCount = events.length;
}
/// 초기 BGM 재생 (게임 시작/로드 시)
///
/// TaskType 기반으로 BGM 결정 (애니메이션과 동기화)
void _playInitialBgm(GameState state) {
final audio = widget.audioService;
if (audio == null) return;
final taskType = state.progress.currentTask.type;
final isInBattleTask = taskType == TaskType.kill;
if (isInBattleTask) {
audio.playBgm(_getBattleBgm(state));
} else {
// 비전투 태스크: 마을 BGM
audio.playBgm('town');
}
_wasInBattleTask = isInBattleTask;
}
/// 전투 BGM 결정 (몬스터 등급 + 레벨 + Act 고려)
///
/// 우선순위:
/// 1. MonsterGrade.boss → 'act_boss'
/// 2. 레벨 기반 보스 (monsterLevel >= playerLevel + 5) → 'boss'
/// 3. MonsterGrade.elite → 'elite'
/// 4. Act별 일반 전투 → 'battle', 'battle_act4', 'battle_act5'
String _getBattleBgm(GameState state) {
final task = state.progress.currentTask;
final monsterGrade = task.monsterGrade;
final monsterLevel = task.monsterLevel ?? 0;
final playerLevel = state.traits.level;
// 1. 등급 보스 (3% 확률로 등장하는 특수 보스)
if (monsterGrade == MonsterGrade.boss) {
return 'act_boss';
}
// 2. 레벨 기반 보스 (강적)
if (monsterLevel >= playerLevel + 5) {
return 'boss';
}
// 3. 엘리트 몬스터 (12% 확률)
if (monsterGrade == MonsterGrade.elite) {
return 'elite';
}
// 4. 일반 전투 (Act별 분기)
return _getBattleBgmForLevel(playerLevel);
}
/// 레벨에 따른 전투 BGM 파일명 반환 (Act별 분기)
String _getBattleBgmForLevel(int playerLevel) {
final act = getActForLevel(playerLevel);
return switch (act) {
StoryAct.act4 => 'battle_act4',
StoryAct.act5 => 'battle_act5',
_ => 'battle',
};
}
/// TaskType 기반 BGM 전환 (애니메이션과 동기화)
///
/// 애니메이션은 TaskType으로 결정되므로, BGM도 동일한 기준 사용
/// 전환 감지 외에도 현재 BGM이 TaskType과 일치하는지 검증
void _updateBgmForTaskType(GameState state) {
final audio = widget.audioService;
if (audio == null) return;
final taskType = state.progress.currentTask.type;
final isInBattleTask = taskType == TaskType.kill;
// 전투 태스크 상태 결정
if (isInBattleTask) {
final expectedBgm = _getBattleBgm(state);
// 전환 시점이거나 현재 BGM이 일치하지 않으면 재생
if (!_wasInBattleTask || audio.currentBgm != expectedBgm) {
audio.playBgm(expectedBgm);
}
} else {
// 비전투 태스크: 항상 마을 BGM 유지 (이미 town이면 스킵)
if (audio.currentBgm != 'town') {
audio.playBgm('town');
}
}
_wasInBattleTask = isInBattleTask;
}
/// 전투 이벤트별 SFX 재생 (채널 분리 + 디바운스)
///
/// 플레이어 이펙트와 몬스터 이펙트를 별도 채널에서 재생하여
/// 사운드 충돌을 방지하고 완료를 보장합니다.
/// 배속 모드에서는 디바운스를 적용하여 사운드 누락을 방지합니다.
void _playCombatEventSfx(CombatEvent event) {
final audio = widget.audioService;
if (audio == null) return;
// 사운드 이름 결정
final sfxName = switch (event.type) {
CombatEventType.playerAttack => 'attack',
CombatEventType.playerSkill => 'skill',
CombatEventType.playerHeal => 'item',
CombatEventType.playerPotion => 'item',
CombatEventType.potionDrop => 'item',
CombatEventType.playerBuff => 'skill',
CombatEventType.playerDebuff => 'skill',
CombatEventType.monsterAttack => 'hit',
CombatEventType.playerEvade => 'evade',
CombatEventType.monsterEvade => 'evade',
CombatEventType.playerBlock => 'block',
CombatEventType.playerParry => 'parry',
CombatEventType.dotTick => null, // DOT 틱은 SFX 없음
};
if (sfxName == null) return;
// 디바운스 체크 (배속 시 같은 사운드 100ms 내 중복 재생 방지)
final now = DateTime.now().millisecondsSinceEpoch;
final lastTime = _lastSfxPlayTime[sfxName] ?? 0;
final speedMultiplier = widget.controller.loop?.speedMultiplier ?? 1;
// 배속이 높을수록 디바운스 간격 증가 (1x=50ms, 8x=150ms)
final debounceMs = 50 + (speedMultiplier - 1) * 15;
if (now - lastTime < debounceMs) {
return; // 디바운스 기간 내 → 스킵
}
_lastSfxPlayTime[sfxName] = now;
// 채널별 재생
final isMonsterSfx = event.type == CombatEventType.monsterAttack;
if (isMonsterSfx) {
audio.playMonsterSfx(sfxName);
} else {
audio.playPlayerSfx(sfxName);
}
}
/// 전투 이벤트를 메시지와 타입으로 변환
(String, CombatLogType) _formatCombatEvent(CombatEvent event) {
final target = event.targetName ?? '';
// 스킬/포션 이름 번역 (전역 로케일 사용)
final skillName = event.skillName != null
? game_l10n.translateSpell(event.skillName!)
: '';
return switch (event.type) {
CombatEventType.playerAttack =>
event.isCritical
? (
game_l10n.combatCritical(event.damage, target),
CombatLogType.critical,
)
: (
game_l10n.combatYouHit(target, event.damage),
CombatLogType.damage,
),
CombatEventType.monsterAttack => (
game_l10n.combatMonsterHitsYou(target, event.damage),
CombatLogType.monsterAttack,
),
CombatEventType.playerEvade => (
game_l10n.combatYouEvaded(target),
CombatLogType.evade,
),
CombatEventType.monsterEvade => (
game_l10n.combatMonsterEvaded(target),
CombatLogType.evade,
),
CombatEventType.playerBlock => (
game_l10n.combatBlocked(event.damage),
CombatLogType.block,
),
CombatEventType.playerParry => (
game_l10n.combatParried(event.damage),
CombatLogType.parry,
),
CombatEventType.playerSkill =>
event.isCritical
? (
game_l10n.combatSkillCritical(skillName, event.damage),
CombatLogType.critical,
)
: (
game_l10n.combatSkillDamage(skillName, event.damage),
CombatLogType.skill,
),
CombatEventType.playerHeal => (
game_l10n.combatSkillHeal(
skillName.isNotEmpty ? skillName : game_l10n.uiHeal,
event.healAmount,
),
CombatLogType.heal,
),
CombatEventType.playerBuff => (
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,
),
CombatEventType.playerPotion => (
game_l10n.combatPotionUsed(skillName, event.healAmount, target),
CombatLogType.potion,
),
CombatEventType.potionDrop => (
game_l10n.combatPotionDrop(skillName),
CombatLogType.potionDrop,
),
};
}
/// Phase 9: Act 시네마틱 표시 (Show Act Cinematic)
@@ -484,7 +175,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
await widget.controller.pause(saveOnStop: false);
// 시네마틱 BGM 재생
widget.audioService?.playBgm('act_cinemetic');
_audioController.playCinematicBgm();
if (mounted) {
await showActCinematic(context, act);
@@ -520,6 +211,18 @@ class _GamePlayScreenState extends State<GamePlayScreen>
super.initState();
_notificationService = NotificationService();
_storyService = StoryService();
// 오디오 컨트롤러 초기화
_audioController = GameAudioController(
audioService: widget.audioService,
getSpeedMultiplier: () => widget.controller.loop?.speedMultiplier ?? 1,
);
// 전투 로그 컨트롤러 초기화
_combatLogController = CombatLogController(
onCombatEvent: (event) => _audioController.playCombatEventSfx(event),
);
widget.controller.addListener(_onControllerChanged);
WidgetsBinding.instance.addObserver(this);
@@ -531,8 +234,8 @@ class _GamePlayScreenState extends State<GamePlayScreen>
_lastPlotStageCount = state.progress.plotStageCount;
_lastAct = getActForLevel(state.traits.level);
// 초기 BGM 재생 (TaskType 기반, _wasInBattleTask도 함께 설정)
_playInitialBgm(state);
// 초기 BGM 재생 (TaskType 기반)
_audioController.playInitialBgm(state);
} else {
// 상태가 없으면 기본 마을 BGM
widget.audioService?.playBgm('town');
@@ -542,17 +245,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
widget.controller.loadCumulativeStats();
// 오디오 볼륨 초기화
_initAudioVolumes();
}
/// 오디오 볼륨 초기화 (설정에서 로드)
Future<void> _initAudioVolumes() async {
final audio = widget.audioService;
if (audio != null) {
_bgmVolume = audio.bgmVolume;
_sfxVolume = audio.sfxVolume;
if (mounted) setState(() {});
}
_audioController.initVolumes();
}
@override
@@ -592,13 +285,13 @@ class _GamePlayScreenState extends State<GamePlayScreen>
// 모바일: 게임 일시정지 + 전체 오디오 정지
if (isMobile) {
widget.controller.pause(saveOnStop: false);
widget.audioService?.pauseAll();
_audioController.pauseAll();
}
}
// 모바일: 앱이 포그라운드로 돌아올 때 전체 재로드
if (appState == AppLifecycleState.resumed && isMobile) {
widget.audioService?.resumeAll();
_audioController.resumeAll();
_reloadGameScreen();
}
}
@@ -745,12 +438,12 @@ class _GamePlayScreenState extends State<GamePlayScreen>
}
},
onBgmVolumeChange: (volume) {
setState(() => _bgmVolume = volume);
widget.audioService?.setBgmVolume(volume);
_audioController.setBgmVolume(volume);
setState(() {});
},
onSfxVolumeChange: (volume) {
setState(() => _sfxVolume = volume);
widget.audioService?.setSfxVolume(volume);
_audioController.setSfxVolume(volume);
setState(() {});
},
onCreateTestCharacter: () async {
final navigator = Navigator.of(context);
@@ -824,7 +517,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
children: [
MobileCarouselLayout(
state: state,
combatLogEntries: _combatLogEntries,
combatLogEntries: _combatLogController.entries,
speedMultiplier: widget.controller.loop?.speedMultiplier ?? 1,
onSpeedCycle: () {
widget.controller.loop?.cycleSpeed();
@@ -884,15 +577,15 @@ class _GamePlayScreenState extends State<GamePlayScreen>
currentThemeMode: widget.currentThemeMode,
onThemeModeChange: widget.onThemeModeChange,
// 사운드 설정
bgmVolume: _bgmVolume,
sfxVolume: _sfxVolume,
bgmVolume: _audioController.bgmVolume,
sfxVolume: _audioController.sfxVolume,
onBgmVolumeChange: (volume) {
setState(() => _bgmVolume = volume);
widget.audioService?.setBgmVolume(volume);
_audioController.setBgmVolume(volume);
setState(() {});
},
onSfxVolumeChange: (volume) {
setState(() => _sfxVolume = volume);
widget.audioService?.setSfxVolume(volume);
_audioController.setSfxVolume(volume);
setState(() {});
},
// 통계 및 도움말
onShowStatistics: () => _showStatisticsDialog(context),
@@ -1256,7 +949,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
// Phase 8: 전투 로그 (Combat Log)
_buildPanelHeader(l10n.combatLog),
Expanded(flex: 2, child: CombatLog(entries: _combatLogEntries)),
Expanded(flex: 2, child: CombatLog(entries: _combatLogController.entries)),
],
),
);

View File

@@ -0,0 +1,255 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
/// 게임 클리어 축하 다이얼로그 표시 함수
Future<void> showGameClearDialog(
BuildContext context, {
required HallOfFameEntry entry,
required VoidCallback onNewGame,
required VoidCallback onViewHallOfFame,
}) {
return showDialog(
context: context,
barrierDismissible: false,
builder: (context) => GameClearDialog(
entry: entry,
onNewGame: onNewGame,
onViewHallOfFame: onViewHallOfFame,
),
);
}
/// 게임 클리어 다이얼로그 위젯
class GameClearDialog extends StatelessWidget {
const GameClearDialog({
super.key,
required this.entry,
required this.onNewGame,
required this.onViewHallOfFame,
});
final HallOfFameEntry entry;
final VoidCallback onNewGame;
final VoidCallback onViewHallOfFame;
@override
Widget build(BuildContext context) {
final goldColor = RetroColors.goldOf(context);
final panelBg = RetroColors.panelBgOf(context);
final borderColor = RetroColors.borderOf(context);
return Dialog(
backgroundColor: Colors.transparent,
child: Container(
constraints: const BoxConstraints(maxWidth: 400),
decoration: BoxDecoration(
color: panelBg,
border: Border(
top: BorderSide(color: goldColor, width: 3),
left: BorderSide(color: goldColor, width: 3),
bottom: BorderSide(color: borderColor, width: 3),
right: BorderSide(color: borderColor, width: 3),
),
boxShadow: [
BoxShadow(
color: goldColor.withValues(alpha: 0.4),
blurRadius: 24,
spreadRadius: 4,
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 헤더
Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: goldColor.withValues(alpha: 0.2),
border: Border(bottom: BorderSide(color: goldColor, width: 2)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.emoji_events, color: goldColor, size: 20),
const SizedBox(width: 8),
Text(
l10n.hofVictory.toUpperCase(),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,
color: goldColor,
),
),
const SizedBox(width: 8),
Icon(Icons.emoji_events, color: goldColor, size: 20),
],
),
),
// 컨텐츠
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
Text(
l10n.hofDefeatedGlitchGod,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 7,
color: RetroColors.textPrimaryOf(context),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Container(height: 2, color: borderColor),
const SizedBox(height: 16),
// 캐릭터 정보
Text(
entry.characterName,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: goldColor,
),
),
const SizedBox(height: 6),
Text(
'${GameDataL10n.getRaceName(context, entry.race)} '
'${GameDataL10n.getKlassName(context, entry.klass)}',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 6,
color: RetroColors.textSecondaryOf(context),
),
),
const SizedBox(height: 16),
// 통계
Wrap(
spacing: 12,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
_buildStat(context, l10n.hofLevel, '${entry.level}'),
_buildStat(
context,
l10n.hofTime,
entry.formattedPlayTime,
),
_buildStat(
context,
l10n.hofDeaths,
'${entry.totalDeaths}',
),
_buildStat(
context,
l10n.hofQuests,
'${entry.questsCompleted}',
),
],
),
const SizedBox(height: 16),
Text(
l10n.hofLegendEnshrined,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 6,
fontStyle: FontStyle.italic,
color: goldColor,
),
textAlign: TextAlign.center,
),
],
),
),
// 버튼
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () {
Navigator.of(context).pop();
onViewHallOfFame();
},
style: OutlinedButton.styleFrom(
side: BorderSide(color: borderColor, width: 2),
padding: const EdgeInsets.symmetric(vertical: 10),
),
child: Text(
l10n.hofViewHallOfFame.toUpperCase(),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 6,
color: RetroColors.textSecondaryOf(context),
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: () {
Navigator.of(context).pop();
onNewGame();
},
style: FilledButton.styleFrom(
backgroundColor: goldColor,
padding: const EdgeInsets.symmetric(vertical: 10),
),
child: Text(
l10n.hofNewGame.toUpperCase(),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 6,
color: RetroColors.backgroundOf(context),
),
),
),
),
],
),
),
],
),
),
);
}
Widget _buildStat(BuildContext context, String label, String value) {
final goldColor = RetroColors.goldOf(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: goldColor.withValues(alpha: 0.1),
border: Border.all(color: goldColor.withValues(alpha: 0.3), width: 1),
),
child: Column(
children: [
Text(
value,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 8,
color: goldColor,
),
),
const SizedBox(height: 2),
Text(
label,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 5,
color: RetroColors.textMutedOf(context),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,263 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
import 'package:asciineverdie/src/features/hall_of_fame/hero_detail_dialog.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
/// 명예의 전당 엔트리 카드
class HallOfFameEntryCard extends StatelessWidget {
const HallOfFameEntryCard({
super.key,
required this.entry,
required this.rank,
required this.onDeleteRequest,
});
final HallOfFameEntry entry;
final int rank;
final VoidCallback onDeleteRequest;
void _showDetailDialog(BuildContext context) {
showDialog<void>(
context: context,
builder: (context) => HeroDetailDialog(entry: entry),
);
}
@override
Widget build(BuildContext context) {
final rankColor = _getRankColor(context, rank);
final rankIcon = _getRankIcon(rank);
final borderColor = RetroColors.borderOf(context);
final panelBg = RetroColors.panelBgOf(context);
return Container(
margin: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
color: panelBg,
border: Border.all(color: borderColor, width: 1),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => _showDetailDialog(context),
child: Padding(
padding: const EdgeInsets.all(10),
child: Row(
children: [
// 순위
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: rankColor.withValues(alpha: 0.2),
border: Border.all(color: rankColor, width: 2),
),
child: Center(
child: rankIcon != null
? Icon(rankIcon, color: rankColor, size: 18)
: Text(
'$rank',
style: TextStyle(
fontFamily: 'PressStart2P',
color: rankColor,
fontSize: 10,
),
),
),
),
const SizedBox(width: 10),
// 캐릭터 정보
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 이름 + 레벨
Row(
children: [
Flexible(
child: Text(
entry.characterName,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 8,
color: RetroColors.goldOf(context),
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: RetroColors.mpOf(
context,
).withValues(alpha: 0.2),
border: Border.all(
color: RetroColors.mpOf(context),
width: 1,
),
),
child: Text(
'Lv.${entry.level}',
style: TextStyle(
fontFamily: 'PressStart2P',
color: RetroColors.mpOf(context),
fontSize: 6,
),
),
),
],
),
const SizedBox(height: 4),
// 종족/클래스
Text(
'${GameDataL10n.getRaceName(context, entry.race)} '
'${GameDataL10n.getKlassName(context, entry.klass)}',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 6,
color: RetroColors.textSecondaryOf(context),
),
),
const SizedBox(height: 6),
// 통계
Row(
children: [
_buildStatChip(
context,
Icons.timer,
entry.formattedPlayTime,
RetroColors.expOf(context),
),
const SizedBox(width: 6),
_buildStatChip(
context,
Icons.heart_broken,
'${entry.totalDeaths}',
RetroColors.hpOf(context),
),
const SizedBox(width: 6),
_buildStatChip(
context,
Icons.check_circle,
'${entry.questsCompleted}Q',
RetroColors.warningOf(context),
),
],
),
],
),
),
// 클리어 날짜
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Icon(
Icons.calendar_today,
size: 12,
color: RetroColors.textMutedOf(context),
),
const SizedBox(height: 4),
Text(
entry.formattedClearedDate,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 5,
color: RetroColors.textMutedOf(context),
),
),
],
),
// 삭제 버튼 (디버그 모드 전용)
if (kDebugMode) ...[
const SizedBox(width: 8),
Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
// 이벤트 전파 중지 (카드 클릭 방지)
onDeleteRequest();
},
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: RetroColors.hpOf(
context,
).withValues(alpha: 0.2),
border: Border.all(
color: RetroColors.hpOf(context),
width: 1,
),
),
child: Icon(
Icons.delete_outline,
size: 16,
color: RetroColors.hpOf(context),
),
),
),
),
],
],
),
),
),
),
);
}
Widget _buildStatChip(
BuildContext context,
IconData icon,
String value,
Color color,
) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 10, color: color),
const SizedBox(width: 2),
Text(
value,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 5,
color: color,
),
),
],
);
}
Color _getRankColor(BuildContext context, int rank) {
switch (rank) {
case 1:
return RetroColors.goldOf(context);
case 2:
return Colors.grey.shade400;
case 3:
return Colors.brown.shade400;
default:
return RetroColors.mpOf(context);
}
}
IconData? _getRankIcon(int rank) {
switch (rank) {
case 1:
return Icons.emoji_events;
case 2:
return Icons.workspace_premium;
case 3:
return Icons.military_tech;
default:
return null;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,690 @@
import 'package:flutter/material.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
import 'package:asciineverdie/src/core/model/item_stats.dart';
import 'package:asciineverdie/src/features/game/widgets/ascii_animation_card.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
import 'package:asciineverdie/src/shared/widgets/retro_dialog.dart';
/// 명예의 전당 상세 정보 다이얼로그
class HeroDetailDialog extends StatelessWidget {
const HeroDetailDialog({super.key, required this.entry});
final HallOfFameEntry entry;
@override
Widget build(BuildContext context) {
return RetroDialog(
title: entry.characterName,
titleIcon: '👑',
maxWidth: 420,
maxHeight: 600,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 서브 타이틀
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text(
'${GameDataL10n.getRaceName(context, entry.race)} '
'${GameDataL10n.getKlassName(context, entry.klass)} - '
'Lv.${entry.level}',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 6,
color: RetroColors.textSecondaryOf(context),
),
),
),
// 스크롤 가능한 컨텐츠
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// 캐릭터 애니메이션 섹션
_buildSection(
context,
icon: Icons.movie,
title: l10n.hofCharacterPreview,
child: _buildAnimationPreview(context),
),
const SizedBox(height: 12),
// 통계 섹션
_buildSection(
context,
icon: Icons.analytics,
title: l10n.hofStats,
child: _buildStatsGrid(context),
),
const SizedBox(height: 12),
// 전투 스탯 섹션
if (entry.finalStats != null) ...[
_buildSection(
context,
icon: Icons.sports_mma,
title: l10n.hofCombatStats,
child: _buildCombatStatsGrid(context),
),
const SizedBox(height: 12),
],
// 장비 섹션
if (entry.finalEquipment != null) ...[
_buildSection(
context,
icon: Icons.shield,
title: l10n.navEquipment,
child: _buildEquipmentList(context),
),
const SizedBox(height: 12),
],
// 스킬 섹션 (스킬이 없어도 표시)
_buildSection(
context,
icon: Icons.auto_fix_high,
title: l10n.hofSkills,
child: _buildSkillList(context),
),
],
),
),
),
],
),
);
}
Widget _buildSection(
BuildContext context, {
required IconData icon,
required String title,
required Widget child,
}) {
final goldColor = RetroColors.goldOf(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 14, color: goldColor),
const SizedBox(width: 6),
Text(
title.toUpperCase(),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 7,
color: goldColor,
),
),
const SizedBox(width: 8),
Expanded(
child: Container(
height: 1,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
goldColor,
goldColor.withValues(alpha: 0.3),
Colors.transparent,
],
),
),
),
),
],
),
const SizedBox(height: 8),
child,
],
);
}
/// 캐릭터 애니메이션 미리보기 위젯
Widget _buildAnimationPreview(BuildContext context) {
// 장비에서 무기와 방패 이름 추출
String? weaponName;
String? shieldName;
if (entry.finalEquipment != null) {
for (final item in entry.finalEquipment!) {
if (item.slot == EquipmentSlot.weapon && item.isNotEmpty) {
weaponName = item.name;
} else if (item.slot == EquipmentSlot.shield && item.isNotEmpty) {
shieldName = item.name;
}
}
}
final borderColor = RetroColors.borderOf(context);
return Center(
child: Container(
decoration: BoxDecoration(
border: Border.all(color: borderColor, width: 1),
),
child: SizedBox(
width: 360,
height: 80,
child: AsciiAnimationCard(
taskType: TaskType.kill,
raceId: entry.race,
weaponName: weaponName,
shieldName: shieldName,
characterLevel: entry.level,
monsterLevel: entry.level,
monsterBaseName: 'Glitch God',
),
),
),
);
}
Widget _buildStatsGrid(BuildContext context) {
return Wrap(
spacing: 8,
runSpacing: 6,
children: [
_buildStatItem(
context,
Icons.timer,
l10n.hofTime,
entry.formattedPlayTime,
),
_buildStatItem(
context,
Icons.pest_control,
l10n.hofMonsters,
'${entry.monstersKilled}',
),
_buildStatItem(
context,
Icons.heart_broken,
l10n.hofDeaths,
'${entry.totalDeaths}',
),
_buildStatItem(
context,
Icons.check_circle,
l10n.hofQuests,
'${entry.questsCompleted}',
),
_buildStatItem(
context,
Icons.calendar_today,
l10n.hofCleared,
entry.formattedClearedDate,
),
],
);
}
Widget _buildCombatStatsGrid(BuildContext context) {
final stats = entry.finalStats!;
final borderColor = RetroColors.borderOf(context);
return Column(
children: [
// 기본 스탯 행
Wrap(
spacing: 6,
runSpacing: 4,
children: [
_buildCombatStatChip(l10n.statStr, '${stats.str}', Colors.red),
_buildCombatStatChip(l10n.statCon, '${stats.con}', Colors.orange),
_buildCombatStatChip(l10n.statDex, '${stats.dex}', Colors.green),
_buildCombatStatChip(
l10n.statInt,
'${stats.intelligence}',
Colors.blue,
),
_buildCombatStatChip(l10n.statWis, '${stats.wis}', Colors.purple),
_buildCombatStatChip(l10n.statCha, '${stats.cha}', Colors.pink),
],
),
const SizedBox(height: 6),
Container(height: 1, color: borderColor),
const SizedBox(height: 6),
// 공격 스탯 행
Wrap(
spacing: 6,
runSpacing: 4,
children: [
_buildCombatStatChip(
l10n.statAtk,
'${stats.atk}',
Colors.red.shade700,
),
_buildCombatStatChip(
l10n.statMAtk,
'${stats.magAtk}',
Colors.blue.shade700,
),
_buildCombatStatChip(
l10n.statCri,
'${(stats.criRate * 100).toStringAsFixed(1)}%',
Colors.amber.shade700,
),
],
),
const SizedBox(height: 6),
// 방어 스탯 행
Wrap(
spacing: 6,
runSpacing: 4,
children: [
_buildCombatStatChip(l10n.statDef, '${stats.def}', Colors.brown),
_buildCombatStatChip(
l10n.statMDef,
'${stats.magDef}',
Colors.indigo,
),
_buildCombatStatChip(
l10n.statEva,
'${(stats.evasion * 100).toStringAsFixed(1)}%',
Colors.teal,
),
_buildCombatStatChip(
l10n.statBlock,
'${(stats.blockRate * 100).toStringAsFixed(1)}%',
Colors.blueGrey,
),
],
),
const SizedBox(height: 6),
// HP/MP 행
Wrap(
spacing: 6,
runSpacing: 4,
children: [
_buildCombatStatChip(
l10n.statHp,
'${stats.hpMax}',
Colors.red.shade400,
),
_buildCombatStatChip(
l10n.statMp,
'${stats.mpMax}',
Colors.blue.shade400,
),
],
),
],
);
}
Widget _buildCombatStatChip(String label, String value, Color color) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
border: Border.all(color: color.withValues(alpha: 0.3), width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$label ',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 5,
color: color,
),
),
Text(
value,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 5,
color: color,
),
),
],
),
);
}
Widget _buildStatItem(
BuildContext context,
IconData icon,
String label,
String value,
) {
final goldColor = RetroColors.goldOf(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: goldColor.withValues(alpha: 0.1),
border: Border.all(color: goldColor.withValues(alpha: 0.3), width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12, color: goldColor),
const SizedBox(width: 4),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 6,
color: goldColor,
),
),
Text(
label,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 4,
color: RetroColors.textMutedOf(context),
),
),
],
),
],
),
);
}
Widget _buildEquipmentList(BuildContext context) {
final equipment = entry.finalEquipment!;
final mutedColor = RetroColors.textMutedOf(context);
return Column(
children: equipment.map((item) {
if (item.isEmpty) return const SizedBox.shrink();
final slotLabel = _getSlotLabel(item.slot);
final slotIcon = _getSlotIcon(item.slot);
final slotIndex = _getSlotIndex(item.slot);
final rarityColor = _getRarityColor(item.rarity);
// 장비 이름 번역
final translatedName = GameDataL10n.translateEquipString(
context,
item.name,
slotIndex,
);
// 주요 스탯 요약
final statSummary = _buildStatSummary(item.stats, item.slot);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: rarityColor.withValues(alpha: 0.1),
border: Border.all(
color: rarityColor.withValues(alpha: 0.3),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 슬롯 + 희귀도 표시
Row(
children: [
Icon(slotIcon, size: 12, color: rarityColor),
const SizedBox(width: 4),
Text(
slotLabel,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 5,
color: mutedColor,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
decoration: BoxDecoration(
color: rarityColor.withValues(alpha: 0.2),
border: Border.all(
color: rarityColor.withValues(alpha: 0.4),
width: 1,
),
),
child: Text(
_getRarityLabel(item.rarity),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 4,
color: rarityColor,
),
),
),
],
),
const SizedBox(height: 4),
// 장비 이름
Text(
translatedName,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 6,
color: rarityColor,
),
),
// 스탯 요약
if (statSummary.isNotEmpty) ...[
const SizedBox(height: 4),
Wrap(spacing: 6, runSpacing: 2, children: statSummary),
],
],
),
),
);
}).toList(),
);
}
String _getSlotLabel(EquipmentSlot slot) {
return switch (slot) {
EquipmentSlot.weapon => l10n.slotWeapon,
EquipmentSlot.shield => l10n.slotShield,
EquipmentSlot.helm => l10n.slotHelm,
EquipmentSlot.hauberk => l10n.slotHauberk,
EquipmentSlot.brassairts => l10n.slotBrassairts,
EquipmentSlot.vambraces => l10n.slotVambraces,
EquipmentSlot.gauntlets => l10n.slotGauntlets,
EquipmentSlot.gambeson => l10n.slotGambeson,
EquipmentSlot.cuisses => l10n.slotCuisses,
EquipmentSlot.greaves => l10n.slotGreaves,
EquipmentSlot.sollerets => l10n.slotSollerets,
};
}
IconData _getSlotIcon(EquipmentSlot slot) {
return switch (slot) {
EquipmentSlot.weapon => Icons.gavel,
EquipmentSlot.shield => Icons.shield,
EquipmentSlot.helm => Icons.sports_mma,
EquipmentSlot.hauberk => Icons.checkroom,
EquipmentSlot.brassairts => Icons.front_hand,
EquipmentSlot.vambraces => Icons.back_hand,
EquipmentSlot.gauntlets => Icons.sports_handball,
EquipmentSlot.gambeson => Icons.dry_cleaning,
EquipmentSlot.cuisses => Icons.airline_seat_legroom_normal,
EquipmentSlot.greaves => Icons.snowshoeing,
EquipmentSlot.sollerets => Icons.do_not_step,
};
}
int _getSlotIndex(EquipmentSlot slot) {
return switch (slot) {
EquipmentSlot.weapon => 0,
EquipmentSlot.shield => 1,
_ => 2,
};
}
Color _getRarityColor(ItemRarity rarity) {
return switch (rarity) {
ItemRarity.common => Colors.grey.shade600,
ItemRarity.uncommon => Colors.green.shade600,
ItemRarity.rare => Colors.blue.shade600,
ItemRarity.epic => Colors.purple.shade600,
ItemRarity.legendary => Colors.orange.shade700,
};
}
String _getRarityLabel(ItemRarity rarity) {
return switch (rarity) {
ItemRarity.common => l10n.rarityCommon,
ItemRarity.uncommon => l10n.rarityUncommon,
ItemRarity.rare => l10n.rarityRare,
ItemRarity.epic => l10n.rarityEpic,
ItemRarity.legendary => l10n.rarityLegendary,
};
}
List<Widget> _buildStatSummary(ItemStats stats, EquipmentSlot slot) {
final widgets = <Widget>[];
void addStat(String label, String value, Color color) {
widgets.add(
Text(
'$label $value',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 5,
color: color,
),
),
);
}
// 공격 스탯
if (stats.atk > 0) addStat(l10n.statAtk, '+${stats.atk}', Colors.red);
if (stats.magAtk > 0) {
addStat(l10n.statMAtk, '+${stats.magAtk}', Colors.blue);
}
// 방어 스탯
if (stats.def > 0) addStat(l10n.statDef, '+${stats.def}', Colors.brown);
if (stats.magDef > 0) {
addStat(l10n.statMDef, '+${stats.magDef}', Colors.indigo);
}
// 확률 스탯
if (stats.criRate > 0) {
addStat(
l10n.statCri,
'+${(stats.criRate * 100).toStringAsFixed(0)}%',
Colors.amber,
);
}
if (stats.blockRate > 0) {
addStat(
l10n.statBlock,
'+${(stats.blockRate * 100).toStringAsFixed(0)}%',
Colors.blueGrey,
);
}
if (stats.evasion > 0) {
addStat(
l10n.statEva,
'+${(stats.evasion * 100).toStringAsFixed(0)}%',
Colors.teal,
);
}
if (stats.parryRate > 0) {
addStat(
l10n.statParry,
'+${(stats.parryRate * 100).toStringAsFixed(0)}%',
Colors.cyan,
);
}
// 보너스 스탯
if (stats.hpBonus > 0) {
addStat(l10n.statHp, '+${stats.hpBonus}', Colors.red.shade400);
}
if (stats.mpBonus > 0) {
addStat(l10n.statMp, '+${stats.mpBonus}', Colors.blue.shade400);
}
// 능력치 보너스
if (stats.strBonus > 0) {
addStat(l10n.statStr, '+${stats.strBonus}', Colors.red.shade700);
}
if (stats.conBonus > 0) {
addStat(l10n.statCon, '+${stats.conBonus}', Colors.orange.shade700);
}
if (stats.dexBonus > 0) {
addStat(l10n.statDex, '+${stats.dexBonus}', Colors.green.shade700);
}
if (stats.intBonus > 0) {
addStat(l10n.statInt, '+${stats.intBonus}', Colors.blue.shade700);
}
return widgets;
}
Widget _buildSkillList(BuildContext context) {
final skills = entry.finalSkills;
const skillColor = Colors.purple;
// 스킬이 없는 경우 빈 상태 표시
if (skills == null || skills.isEmpty) {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: RetroColors.backgroundOf(context),
border: Border.all(color: RetroColors.borderOf(context), width: 1),
),
child: Text(
l10n.hofNoSkills.toUpperCase(),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 6,
color: RetroColors.textMutedOf(context),
),
),
);
}
return Wrap(
spacing: 6,
runSpacing: 4,
children: skills.map((skill) {
final name = skill['name'] ?? '';
final rank = skill['rank'] ?? '';
// 스킬 이름 번역 적용
final translatedName = GameDataL10n.getSpellName(context, name);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
decoration: BoxDecoration(
color: skillColor.withValues(alpha: 0.1),
border: Border.all(
color: skillColor.withValues(alpha: 0.3),
width: 1,
),
),
child: Text(
'$translatedName $rank',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 5,
color: skillColor.shade400,
),
),
);
}).toList(),
);
}
}

View File

@@ -1,6 +1,22 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
url: "https://pub.dev"
source: hosted
version: "85.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d"
url: "https://pub.dev"
source: hosted
version: "7.7.1"
archive:
dependency: transitive
description:
@@ -41,6 +57,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
build:
dependency: transitive
description:
name: build
sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
build_config:
dependency: transitive
description:
name: build_config
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
build_daemon:
dependency: transitive
description:
name: build_daemon
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
url: "https://pub.dev"
source: hosted
version: "4.1.1"
build_resolvers:
dependency: transitive
description:
name: build_resolvers
sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62
url: "https://pub.dev"
source: hosted
version: "2.5.4"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
build_runner_core:
dependency: transitive
description:
name: build_runner_core
sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792"
url: "https://pub.dev"
source: hosted
version: "9.1.2"
built_collection:
dependency: transitive
description:
name: built_collection
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
url: "https://pub.dev"
source: hosted
version: "5.1.1"
built_value:
dependency: transitive
description:
name: built_value
sha256: "7931c90b84bc573fef103548e354258ae4c9d28d140e41961df6843c5d60d4d8"
url: "https://pub.dev"
source: hosted
version: "8.12.3"
characters:
dependency: transitive
description:
@@ -73,6 +153,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_builder:
dependency: transitive
description:
name: code_builder
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
url: "https://pub.dev"
source: hosted
version: "4.11.1"
collection:
dependency: transitive
description:
@@ -81,6 +169,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
crypto:
dependency: transitive
description:
@@ -97,6 +193,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.8"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
fake_async:
dependency: "direct dev"
description:
@@ -165,6 +269,70 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
freezed:
dependency: "direct dev"
description:
name: freezed
sha256: "59a584c24b3acdc5250bb856d0d3e9c0b798ed14a4af1ddb7dc1c7b41df91c9c"
url: "https://pub.dev"
source: hosted
version: "2.5.8"
freezed_annotation:
dependency: "direct main"
description:
name: freezed_annotation
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
url: "https://pub.dev"
source: hosted
version: "2.4.4"
frontend_server_client:
dependency: transitive
description:
name: frontend_server_client
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.dev"
source: hosted
version: "4.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
graphs:
dependency: transitive
description:
name: graphs
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: transitive
description:
@@ -181,14 +349,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.20.2"
json_annotation:
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
json_annotation:
dependency: "direct main"
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
json_serializable:
dependency: "direct dev"
description:
name: json_serializable
sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c
url: "https://pub.dev"
source: hosted
version: "6.9.5"
just_audio:
dependency: "direct main"
description:
@@ -245,6 +437,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.1.1"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
@@ -269,6 +469,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.16.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: transitive
description:
@@ -349,6 +565,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pool:
dependency: transitive
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
url: "https://pub.dev"
source: hosted
version: "1.5.2"
posix:
dependency: transitive
description:
@@ -357,6 +581,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.3"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
rxdart:
dependency: transitive
description:
@@ -421,11 +661,43 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_gen:
dependency: transitive
description:
name: source_gen
sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
source_helper:
dependency: transitive
description:
name: source_helper
sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca
url: "https://pub.dev"
source: hosted
version: "1.3.7"
source_span:
dependency: transitive
description:
@@ -450,6 +722,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
source: hosted
version: "2.1.1"
string_scanner:
dependency: transitive
description:
@@ -482,6 +762,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.6"
timing:
dependency: transitive
description:
name: timing
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
typed_data:
dependency: transitive
description:
@@ -514,6 +802,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "15.0.2"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web:
dependency: transitive
description:
@@ -522,6 +818,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
xdg_directories:
dependency: transitive
description:

View File

@@ -38,11 +38,18 @@ dependencies:
path_provider: ^2.1.4
shared_preferences: ^2.3.1
just_audio: ^0.9.42
# Code generation annotations
freezed_annotation: ^2.4.1
json_annotation: ^4.9.0
dev_dependencies:
flutter_test:
sdk: flutter
fake_async: ^1.3.2
# Code generation
build_runner: ^2.4.13
freezed: ^2.5.7
json_serializable: ^6.8.0
# The "flutter_lints" package below contains a set of recommended lints to
# encourage good coding practices. The lint set provided by the package is