diff --git a/lib/src/core/animation/canvas/canvas_battle_composer.dart b/lib/src/core/animation/canvas/canvas_battle_composer.dart index 27349fd..8516ce7 100644 --- a/lib/src/core/animation/canvas/canvas_battle_composer.dart +++ b/lib/src/core/animation/canvas/canvas_battle_composer.dart @@ -68,15 +68,17 @@ class CanvasBattleComposer { bool isDot = false, bool isBlock = false, bool isParry = false, + bool hideMonster = false, }) { final layers = [ _createBackgroundLayer(environment, globalTick), _createCharacterLayer(phase, subFrame, attacker), // PvP 모드: 몬스터 대신 상대 캐릭터(좌우 반전) 표시 - if (isPvP) - _createOpponentCharacterLayer(phase, subFrame, attacker) - else - _createMonsterLayer(phase, subFrame, attacker), + // hideMonster: 몬스터 사망 애니메이션 중에는 렌더링 안함 + if (!hideMonster) + isPvP + ? _createOpponentCharacterLayer(phase, subFrame, attacker) + : _createMonsterLayer(phase, subFrame, attacker), ]; // 이펙트 레이어 (준비/공격/히트 페이즈에서, 공격자 있을 때) @@ -1460,3 +1462,13 @@ const _parryTextFrames = >[ [r'*PARRY!*', r'========'], [r'=PARRY!=', r'********'], ]; + +/// 몬스터 Idle 프레임 가져오기 (외부에서 접근 가능) +/// +/// 몬스터 사망 애니메이션에서 분해할 프레임을 가져올 때 사용 +List> getMonsterIdleFrames( + MonsterCategory category, + MonsterSize size, +) { + return _getMonsterIdleFrames(category, size); +} diff --git a/lib/src/core/engine/combat_calculator.dart b/lib/src/core/engine/combat_calculator.dart index 91f8e28..21dac8b 100644 --- a/lib/src/core/engine/combat_calculator.dart +++ b/lib/src/core/engine/combat_calculator.dart @@ -98,9 +98,9 @@ class CombatCalculator { final isParried = parryRoll < defenderParryRate; // 3. 기본 데미지 계산 (0.8 ~ 1.2 변동) - // DEF 감산 비율: 0.5 (방어력 효과 상향, 몬스터 ATK 하향과 연동) + // DEF 감산 비율: 0.4 (전체 데미지 상승 조정) final damageVariation = 0.8 + rng.nextDouble() * 0.4; - var baseDamage = (attackerAtk * damageVariation - defenderDef * 0.5); + var baseDamage = (attackerAtk * damageVariation - defenderDef * 0.4); // 4. 크리티컬 판정 final criRoll = rng.nextDouble(); @@ -207,7 +207,7 @@ class CombatCalculator { required MonsterCombatStats monster, }) { // 플레이어 DPS (초당 데미지) - final playerDamagePerHit = math.max(1, player.atk - monster.def * 0.5); + final playerDamagePerHit = math.max(1, player.atk - monster.def * 0.4); final playerHitsPerSecond = 1000 / player.attackDelayMs; final playerDps = playerDamagePerHit * playerHitsPerSecond * player.accuracy; @@ -227,14 +227,14 @@ class CombatCalculator { required MonsterCombatStats monster, }) { // 플레이어 예상 생존 시간 - final monsterDamagePerHit = math.max(1, monster.atk - player.def * 0.5); + final monsterDamagePerHit = math.max(1, monster.atk - player.def * 0.4); final monsterHitsPerSecond = 1000 / monster.attackDelayMs; final monsterDps = monsterDamagePerHit * monsterHitsPerSecond * monster.accuracy; final playerSurvivalTime = player.hpCurrent / monsterDps; // 몬스터 예상 생존 시간 - final playerDamagePerHit = math.max(1, player.atk - monster.def * 0.5); + final playerDamagePerHit = math.max(1, player.atk - monster.def * 0.4); final playerHitsPerSecond = 1000 / player.attackDelayMs; final playerDps = playerDamagePerHit * playerHitsPerSecond * player.accuracy; diff --git a/lib/src/core/engine/progress_service.dart b/lib/src/core/engine/progress_service.dart index d98528c..fcb2e24 100644 --- a/lib/src/core/engine/progress_service.dart +++ b/lib/src/core/engine/progress_service.dart @@ -588,8 +588,18 @@ class ProgressService { // 4. 최종 보스 전투 체크 // finalBossState == fighting이면 Glitch God 스폰 + // 단, 레벨링 모드 중이면 일반 몬스터로 레벨업 후 재도전 if (state.progress.finalBossState == FinalBossState.fighting) { - return _startFinalBossFight(state, progress, queue); + if (state.progress.isInBossLevelingMode) { + // 레벨링 모드: 일반 몬스터 전투로 대체 (아래 MonsterTask로 진행) + } else { + // 레벨링 모드 종료 또는 첫 도전: 보스전 시작 + // 레벨링 모드가 끝났으면 타이머 초기화 + if (state.progress.bossLevelingEndTime != null) { + progress = progress.copyWith(clearBossLevelingEndTime: true); + } + return _startFinalBossFight(state, progress, queue); + } } // 5. MonsterTask 실행 (원본 678-684줄) @@ -634,6 +644,7 @@ class ProgressService { name: monsterResult.displayName, level: effectiveMonsterLevel, speedType: MonsterCombatStats.inferSpeedType(monsterResult.baseName), + plotStageCount: state.progress.plotStageCount, ); // 전투 상태 초기화 @@ -1667,6 +1678,7 @@ class ProgressService { /// 플레이어 사망 처리 (Phase 4) /// /// 모든 장비 상실 및 사망 정보 기록 + /// 보스전 사망 시: 장비 보호 + 5분 레벨링 모드 진입 GameState _processPlayerDeath( GameState state, { required String killerName, @@ -1676,30 +1688,36 @@ class ProgressService { final lastCombatEvents = state.progress.currentCombat?.recentEvents ?? const []; - // 무기(슬롯 0)를 제외한 장착된 장비 중 1개를 제물로 삭제 - // 장착된 비무기 슬롯 인덱스 수집 (슬롯 1~10 중 장비가 있는 것) - final equippedNonWeaponSlots = []; - for (var i = 1; i < Equipment.slotCount; i++) { - if (state.equipment.getItemByIndex(i).isNotEmpty) { - equippedNonWeaponSlots.add(i); - } - } + // 보스전 사망 여부 확인 (최종 보스 fighting 상태) + final isBossDeath = + state.progress.finalBossState == FinalBossState.fighting; - // 제물로 바칠 장비 선택 및 삭제 + // 보스전 사망이 아닐 경우에만 장비 손실 var newEquipment = state.equipment; - final lostCount = equippedNonWeaponSlots.isNotEmpty ? 1 : 0; + var lostCount = 0; - if (equippedNonWeaponSlots.isNotEmpty) { - // 랜덤하게 1개 슬롯 선택 - final sacrificeIndex = - equippedNonWeaponSlots[state.rng.nextInt(equippedNonWeaponSlots.length)]; - final slot = EquipmentSlot.values[sacrificeIndex]; + if (!isBossDeath) { + // 무기(슬롯 0)를 제외한 장착된 장비 중 1개를 제물로 삭제 + final equippedNonWeaponSlots = []; + for (var i = 1; i < Equipment.slotCount; i++) { + if (state.equipment.getItemByIndex(i).isNotEmpty) { + equippedNonWeaponSlots.add(i); + } + } - // 해당 슬롯을 빈 장비로 교체 - newEquipment = newEquipment.setItemByIndex( - sacrificeIndex, - EquipmentItem.empty(slot), - ); + if (equippedNonWeaponSlots.isNotEmpty) { + lostCount = 1; + // 랜덤하게 1개 슬롯 선택 + final sacrificeIndex = equippedNonWeaponSlots[ + state.rng.nextInt(equippedNonWeaponSlots.length)]; + final slot = EquipmentSlot.values[sacrificeIndex]; + + // 해당 슬롯을 빈 장비로 교체 + newEquipment = newEquipment.setItemByIndex( + sacrificeIndex, + EquipmentItem.empty(slot), + ); + } } // 사망 정보 생성 (전투 로그 포함) @@ -1713,12 +1731,16 @@ class ProgressService { lastCombatEvents: lastCombatEvents, ); + // 보스전 사망 시 5분 레벨링 모드 진입 + final bossLevelingEndTime = isBossDeath + ? DateTime.now().millisecondsSinceEpoch + (5 * 60 * 1000) // 5분 + : null; + // 전투 상태 초기화 및 사망 횟수 증가 - // pendingActCompletion 플래그는 유지 (Boss 리트라이를 위해) final progress = state.progress.copyWith( currentCombat: null, deathCount: state.progress.deathCount + 1, - // pendingActCompletion은 copyWith에서 명시하지 않으면 기존 값 유지 + bossLevelingEndTime: bossLevelingEndTime, ); return state.copyWith( diff --git a/lib/src/core/model/game_state.dart b/lib/src/core/model/game_state.dart index d03724c..4ee5b5e 100644 --- a/lib/src/core/model/game_state.dart +++ b/lib/src/core/model/game_state.dart @@ -475,7 +475,8 @@ class Inventory { final int gold; final List items; - factory Inventory.empty() => const Inventory(gold: 0, items: []); + /// 초기 골드 1000 지급 (캐릭터 생성 시) + factory Inventory.empty() => const Inventory(gold: 1000, items: []); Inventory copyWith({int? gold, List? items}) { return Inventory(gold: gold ?? this.gold, items: items ?? this.items); @@ -800,6 +801,7 @@ class ProgressState { this.deathCount = 0, this.finalBossState = FinalBossState.notSpawned, this.pendingActCompletion = false, + this.bossLevelingEndTime, }); final ProgressBarState task; @@ -835,6 +837,10 @@ class ProgressState { /// Act Boss 처치 대기 중 여부 (처치 후 시네마틱 재생 트리거) final bool pendingActCompletion; + /// 보스 사망 후 레벨링 모드 종료 시간 (milliseconds since epoch) + /// null이면 레벨링 모드 아님, 값이 있으면 해당 시간까지 레벨링 + final int? bossLevelingEndTime; + factory ProgressState.empty() => ProgressState( task: ProgressBarState.empty(), quest: ProgressBarState.empty(), @@ -867,6 +873,8 @@ class ProgressState { int? deathCount, FinalBossState? finalBossState, bool? pendingActCompletion, + int? bossLevelingEndTime, + bool clearBossLevelingEndTime = false, }) { return ProgressState( task: task ?? this.task, @@ -885,8 +893,17 @@ class ProgressState { deathCount: deathCount ?? this.deathCount, finalBossState: finalBossState ?? this.finalBossState, pendingActCompletion: pendingActCompletion ?? this.pendingActCompletion, + bossLevelingEndTime: clearBossLevelingEndTime + ? null + : (bossLevelingEndTime ?? this.bossLevelingEndTime), ); } + + /// 현재 레벨링 모드인지 확인 + bool get isInBossLevelingMode { + if (bossLevelingEndTime == null) return false; + return DateTime.now().millisecondsSinceEpoch < bossLevelingEndTime!; + } } class QueueEntry { diff --git a/lib/src/core/model/monster_combat_stats.dart b/lib/src/core/model/monster_combat_stats.dart index 5dcc0c2..0ac6e0a 100644 --- a/lib/src/core/model/monster_combat_stats.dart +++ b/lib/src/core/model/monster_combat_stats.dart @@ -131,15 +131,29 @@ class MonsterCombatStats { /// [level] 몬스터 레벨 (원본 데이터 기준) /// [speedType] 공격 속도 타입 (기본: normal) /// [monsterType] 몬스터 타입 (기본: normal) + /// [plotStageCount] 현재 Act (1=Prologue, 2=Act I, 3=Act II, ...) factory MonsterCombatStats.fromLevel({ required String name, required int level, MonsterSpeedType speedType = MonsterSpeedType.normal, MonsterType monsterType = MonsterType.normal, + int plotStageCount = 1, }) { // balance_constants.dart의 MonsterBaseStats 사용 final baseStats = MonsterBaseStats.generate(level, monsterType); + // Act II 이후 (plotStageCount >= 3) HP 10% 상승 + final hpMultiplier = plotStageCount >= 3 ? 1.1 : 1.0; + final adjustedHp = (baseStats.hp * hpMultiplier).round(); + + // Act별 경험치 배율 (후반부 레벨업 가속) + final expMultiplier = switch (plotStageCount) { + 5 => 1.3, // Act IV: 30% 보너스 + 6 => 1.8, // Act V: 80% 보너스 (보스전 대비) + _ => 1.0, // 기본 + }; + final adjustedExp = (baseStats.exp * expMultiplier).round(); + // 크리티컬 확률: 레벨에 따라 천천히 증가 (0.02 ~ 0.3) final criRate = (0.02 + level * 0.003).clamp(0.02, 0.3); @@ -164,14 +178,14 @@ class MonsterCombatStats { level: level, atk: baseStats.atk, def: baseStats.def, - hpMax: baseStats.hp, - hpCurrent: baseStats.hp, + hpMax: adjustedHp, + hpCurrent: adjustedHp, criRate: criRate, criDamage: criDamage, evasion: evasion, accuracy: accuracy, attackDelayMs: attackDelayMs, - expReward: baseStats.exp, + expReward: adjustedExp, ); }