Compare commits
5 Commits
a41984d998
...
23f15f41d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23f15f41d3 | ||
|
|
133d516b94 | ||
|
|
07fb105d7c | ||
|
|
e77c3c4a05 | ||
|
|
f466e1c408 |
File diff suppressed because it is too large
Load Diff
262
lib/src/core/engine/act_progression_service.dart
Normal file
262
lib/src/core/engine/act_progression_service.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
///
|
||||
/// 모든 장비 상실 및 사망 정보 기록
|
||||
|
||||
@@ -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,
|
||||
|
||||
733
lib/src/core/model/combat_stats.freezed.dart
Normal file
733
lib/src/core/model/combat_stats.freezed.dart
Normal 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;
|
||||
}
|
||||
57
lib/src/core/model/combat_stats.g.dart
Normal file
57
lib/src/core/model/combat_stats.g.dart
Normal 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,
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
335
lib/src/core/model/equipment_item.freezed.dart
Normal file
335
lib/src/core/model/equipment_item.freezed.dart
Normal 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;
|
||||
}
|
||||
27
lib/src/core/model/equipment_item.g.dart
Normal file
27
lib/src/core/model/equipment_item.g.dart
Normal 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),
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
651
lib/src/core/model/item_stats.freezed.dart
Normal file
651
lib/src/core/model/item_stats.freezed.dart
Normal 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;
|
||||
}
|
||||
49
lib/src/core/model/item_stats.g.dart
Normal file
49
lib/src/core/model/item_stats.g.dart
Normal 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,
|
||||
};
|
||||
327
lib/src/core/util/pq_item.dart
Normal file
327
lib/src/core/util/pq_item.dart
Normal 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
283
lib/src/core/util/pq_monster.dart
Normal file
283
lib/src/core/util/pq_monster.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
283
lib/src/core/util/pq_quest.dart
Normal file
283
lib/src/core/util/pq_quest.dart
Normal 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)}';
|
||||
}
|
||||
61
lib/src/core/util/pq_random.dart
Normal file
61
lib/src/core/util/pq_random.dart
Normal 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];
|
||||
}
|
||||
64
lib/src/core/util/pq_stat.dart
Normal file
64
lib/src/core/util/pq_stat.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
55
lib/src/core/util/pq_string.dart
Normal file
55
lib/src/core/util/pq_string.dart
Normal 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;
|
||||
}
|
||||
97
lib/src/core/util/pq_task.dart
Normal file
97
lib/src/core/util/pq_task.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
184
lib/src/features/game/controllers/combat_log_controller.dart
Normal file
184
lib/src/features/game/controllers/combat_log_controller.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
251
lib/src/features/game/controllers/game_audio_controller.dart
Normal file
251
lib/src/features/game/controllers/game_audio_controller.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
255
lib/src/features/hall_of_fame/game_clear_dialog.dart
Normal file
255
lib/src/features/hall_of_fame/game_clear_dialog.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
263
lib/src/features/hall_of_fame/hall_of_fame_entry_card.dart
Normal file
263
lib/src/features/hall_of_fame/hall_of_fame_entry_card.dart
Normal 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
690
lib/src/features/hall_of_fame/hero_detail_dialog.dart
Normal file
690
lib/src/features/hall_of_fame/hero_detail_dialog.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
314
pubspec.lock
314
pubspec.lock
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user