diff --git a/lib/src/core/engine/progress_service.dart b/lib/src/core/engine/progress_service.dart index 39f09a6..8523bee 100644 --- a/lib/src/core/engine/progress_service.dart +++ b/lib/src/core/engine/progress_service.dart @@ -1,12 +1,12 @@ import 'dart:math' as math; import 'package:asciineverdie/data/game_text_l10n.dart' as l10n; -import 'package:asciineverdie/data/skill_data.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'; +import 'package:asciineverdie/src/core/engine/market_service.dart'; import 'package:asciineverdie/src/core/engine/potion_service.dart'; import 'package:asciineverdie/src/core/engine/reward_service.dart'; -import 'package:asciineverdie/src/core/engine/shop_service.dart'; import 'package:asciineverdie/src/core/engine/skill_service.dart'; import 'package:asciineverdie/src/core/model/combat_event.dart'; import 'package:asciineverdie/src/core/model/combat_state.dart'; @@ -14,12 +14,10 @@ import 'package:asciineverdie/src/core/model/combat_stats.dart'; import 'package:asciineverdie/src/core/model/equipment_item.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/item_stats.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/potion.dart'; import 'package:asciineverdie/src/core/model/pq_config.dart'; -import 'package:asciineverdie/src/core/model/skill.dart'; import 'package:asciineverdie/src/core/util/balance_constants.dart'; import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic; @@ -210,18 +208,19 @@ class ProgressService { ? progress.task.max : uncapped; - // 킬 태스크 중 전투 진행 (스킬 자동 사용, DOT, 물약 포함) + // 킬 태스크 중 전투 진행 (CombatTickService 사용) var updatedCombat = progress.currentCombat; var updatedSkillSystem = nextState.skillSystem; var updatedPotionInventory = nextState.potionInventory; if (progress.currentTask.type == TaskType.kill && updatedCombat != null && updatedCombat.isActive) { - final combatResult = _processCombatTickWithSkills( - nextState, - updatedCombat, - updatedSkillSystem, - clamped, + final combatTickService = CombatTickService(rng: nextState.rng); + final combatResult = combatTickService.processTick( + state: nextState, + combat: updatedCombat, + skillSystem: updatedSkillSystem, + elapsedMs: clamped, ); updatedCombat = combatResult.combat; updatedSkillSystem = combatResult.skillSystem; @@ -353,15 +352,16 @@ class ProgressService { } } - // 시장/판매/구매 태스크 완료 시 처리 (원본 Main.pas:631-649) + // 시장/판매/구매 태스크 완료 시 처리 (MarketService 사용) + final marketService = MarketService(rng: nextState.rng); final taskType = progress.currentTask.type; if (taskType == TaskType.buying) { // 장비 구매 완료 (원본 631-634) - nextState = _completeBuying(nextState); + nextState = marketService.completeBuying(nextState); progress = nextState.progress; } else if (taskType == TaskType.market || taskType == TaskType.sell) { // 시장 도착 또는 판매 완료 (원본 635-649) - final sellResult = _processSell(nextState); + final sellResult = marketService.processSell(nextState); nextState = sellResult.state; progress = nextState.progress; queue = nextState.queue; @@ -524,7 +524,7 @@ class ProgressService { oldTaskType != TaskType.buying) { // Gold가 충분하면 장비 구매 (Common 장비 가격 기준) // 실제 구매 가격과 동일한 공식 사용: level * 50 - final gold = _getGold(state); + final gold = state.inventory.gold; final equipPrice = state.traits.level * 50; // Common 장비 1개 가격 if (gold > equipPrice) { final taskResult = pq_logic.startTask( @@ -1096,555 +1096,6 @@ class ProgressService { return s[0].toUpperCase() + s.substring(1); } - /// 인벤토리에서 Gold 수량 반환 - int _getGold(GameState state) { - return state.inventory.gold; - } - - /// 장비 구매 완료 처리 (개선된 로직) - /// - /// 1순위: 빈 슬롯에 Common 장비 최대한 채우기 - /// 2순위: 골드 남으면 물약 구매 - GameState _completeBuying(GameState state) { - var nextState = state; - final level = state.traits.level; - final shopService = ShopService(rng: nextState.rng); - - // 1. 빈 슬롯 목록 수집 - final emptySlots = []; - for (var i = 0; i < Equipment.slotCount; i++) { - if (nextState.equipment.getItemByIndex(i).isEmpty) { - emptySlots.add(i); - } - } - - // 2. 골드가 허용하는 한 빈 슬롯에 Common 장비 구매 - for (final slotIndex in emptySlots) { - final slot = EquipmentSlot.values[slotIndex]; - final item = shopService.generateShopItem( - playerLevel: level, - slot: slot, - targetRarity: ItemRarity.common, - ); - final price = shopService.calculateBuyPrice(item); - - if (nextState.inventory.gold >= price) { - nextState = nextState.copyWith( - inventory: nextState.inventory.copyWith( - gold: nextState.inventory.gold - price, - ), - equipment: nextState.equipment - .setItemByIndex(slotIndex, item) - .copyWith(bestIndex: slotIndex), - ); - } else { - break; // 골드 부족 시 중단 - } - } - - // 3. 물약 자동 구매 (남은 골드의 20% 사용) - final potionService = const PotionService(); - final purchaseResult = potionService.autoPurchasePotions( - playerLevel: level, - inventory: nextState.potionInventory, - gold: nextState.inventory.gold, - spendRatio: 0.20, - ); - - if (purchaseResult.success && purchaseResult.newInventory != null) { - nextState = nextState.copyWith( - inventory: nextState.inventory.copyWith(gold: purchaseResult.newGold), - potionInventory: purchaseResult.newInventory, - ); - } - - return nextState; - } - - /// 판매 처리 결과 - ({GameState state, bool continuesSelling}) _processSell(GameState state) { - final taskType = state.progress.currentTask.type; - var items = [...state.inventory.items]; - var goldAmount = state.inventory.gold; - - // sell 태스크 완료 시 아이템 판매 (원본 Main.pas:636-643) - if (taskType == TaskType.sell) { - // 첫 번째 아이템 찾기 (items에는 Gold가 없음) - if (items.isNotEmpty) { - final item = items.first; - final level = state.traits.level; - - // 가격 계산: 수량 * 레벨 - var price = item.count * level; - - // " of " 포함 시 보너스 (원본 639-640) - if (item.name.contains(' of ')) { - price = - price * - (1 + pq_logic.randomLow(state.rng, 10)) * - (1 + pq_logic.randomLow(state.rng, level)); - } - - // 아이템 삭제 - items.removeAt(0); - - // Gold 추가 (inventory.gold 필드 사용) - goldAmount += price; - } - } - - // 판매할 아이템이 남아있는지 확인 - final hasItemsToSell = items.isNotEmpty; - - if (hasItemsToSell) { - // 다음 아이템 판매 태스크 시작 - final nextItem = items.first; - final translatedName = l10n.translateItemNameL10n(nextItem.name); - final itemDesc = l10n.indefiniteL10n(translatedName, nextItem.count); - final taskResult = pq_logic.startTask( - state.progress, - l10n.taskSelling(itemDesc), - 1 * 1000, - ); - final progress = taskResult.progress.copyWith( - currentTask: TaskInfo(caption: taskResult.caption, type: TaskType.sell), - currentCombat: null, // 비전투 태스크이므로 전투 상태 초기화 - ); - return ( - state: state.copyWith( - inventory: state.inventory.copyWith(gold: goldAmount, items: items), - progress: progress, - ), - continuesSelling: true, - ); - } - - // 판매 완료 - 인벤토리 업데이트만 하고 다음 태스크로 - return ( - state: state.copyWith( - inventory: state.inventory.copyWith(gold: goldAmount, items: items), - ), - continuesSelling: false, - ); - } - - /// 전투 틱 처리 (스킬 자동 사용, DOT, 물약 포함) - /// - /// [state] 현재 게임 상태 - /// [combat] 현재 전투 상태 - /// [skillSystem] 스킬 시스템 상태 - /// [elapsedMs] 경과 시간 (밀리초) - /// Returns: 업데이트된 전투 상태, 스킬 시스템 상태, 물약 인벤토리 - ({ - CombatState combat, - SkillSystemState skillSystem, - PotionInventory? potionInventory, - }) - _processCombatTickWithSkills( - GameState state, - CombatState combat, - SkillSystemState skillSystem, - int elapsedMs, - ) { - if (!combat.isActive || combat.isCombatOver) { - return (combat: combat, skillSystem: skillSystem, potionInventory: null); - } - - final calculator = CombatCalculator(rng: state.rng); - final skillService = SkillService(rng: state.rng); - final potionService = const PotionService(); - var playerStats = combat.playerStats; - var monsterStats = combat.monsterStats; - var playerAccumulator = combat.playerAttackAccumulatorMs + elapsedMs; - var monsterAccumulator = combat.monsterAttackAccumulatorMs + elapsedMs; - var totalDamageDealt = combat.totalDamageDealt; - var totalDamageTaken = combat.totalDamageTaken; - var turnsElapsed = combat.turnsElapsed; - var updatedSkillSystem = skillSystem; - var activeDoTs = [...combat.activeDoTs]; - var usedPotionTypes = {...combat.usedPotionTypes}; - var activeDebuffs = [...combat.activeDebuffs]; - PotionInventory? updatedPotionInventory; - - // 새 전투 이벤트 수집 - final newEvents = []; - final timestamp = updatedSkillSystem.elapsedMs; - - // ========================================================================= - // 만료된 디버프 정리 - // ========================================================================= - activeDebuffs = activeDebuffs - .where((debuff) => !debuff.isExpired(timestamp)) - .toList(); - - // ========================================================================= - // DOT 틱 처리 - // ========================================================================= - var dotDamageThisTick = 0; - final updatedDoTs = []; - - for (final dot in activeDoTs) { - final (updatedDot, ticksTriggered) = dot.tick(elapsedMs); - - if (ticksTriggered > 0) { - final damage = dot.damagePerTick * ticksTriggered; - dotDamageThisTick += damage; - - // DOT 데미지 이벤트 생성 (skillId → name 변환) - final dotSkillName = - SkillData.getSkillById(dot.skillId)?.name ?? dot.skillId; - newEvents.add( - CombatEvent.dotTick( - timestamp: timestamp, - skillName: dotSkillName, - damage: damage, - targetName: monsterStats.name, - ), - ); - } - - // 만료되지 않은 DOT만 유지 - if (updatedDot.isActive) { - updatedDoTs.add(updatedDot); - } - } - - // DOT 데미지 적용 - if (dotDamageThisTick > 0 && monsterStats.isAlive) { - final newMonsterHp = (monsterStats.hpCurrent - dotDamageThisTick).clamp( - 0, - monsterStats.hpMax, - ); - monsterStats = monsterStats.copyWith(hpCurrent: newMonsterHp); - totalDamageDealt += dotDamageThisTick; - } - - activeDoTs = updatedDoTs; - - // ========================================================================= - // 긴급 물약 자동 사용 (HP < 30%) - // ========================================================================= - final hpRatio = playerStats.hpCurrent / playerStats.hpMax; - if (hpRatio <= PotionService.emergencyHpThreshold) { - final emergencyPotion = potionService.selectEmergencyHpPotion( - currentHp: playerStats.hpCurrent, - maxHp: playerStats.hpMax, - inventory: state.potionInventory, - playerLevel: state.traits.level, - ); - - if (emergencyPotion != null && !usedPotionTypes.contains(PotionType.hp)) { - final result = potionService.usePotion( - potionId: emergencyPotion.id, - inventory: state.potionInventory, - currentHp: playerStats.hpCurrent, - maxHp: playerStats.hpMax, - currentMp: playerStats.mpCurrent, - maxMp: playerStats.mpMax, - ); - - if (result.success) { - playerStats = playerStats.copyWith(hpCurrent: result.newHp); - usedPotionTypes = {...usedPotionTypes, PotionType.hp}; - updatedPotionInventory = result.newInventory; - - newEvents.add( - CombatEvent.playerPotion( - timestamp: timestamp, - potionName: emergencyPotion.name, - healAmount: result.healedAmount, - isHp: true, - ), - ); - } - } - } - - // 플레이어 공격 체크 - if (playerAccumulator >= playerStats.attackDelayMs) { - // 장착된 스킬 슬롯에서 사용 가능한 스킬 ID 목록 조회 - var availableSkillIds = state.skillSystem.equippedSkills.allSkills - .map((s) => s.id) - .toList(); - // 장착된 스킬이 없으면 기본 스킬 사용 - if (availableSkillIds.isEmpty) { - availableSkillIds = SkillData.defaultSkillIds; - } - - final selectedSkill = skillService.selectAutoSkill( - player: playerStats, - monster: monsterStats, - skillSystem: updatedSkillSystem, - availableSkillIds: availableSkillIds, - activeDoTs: activeDoTs, - activeDebuffs: activeDebuffs, - ); - - if (selectedSkill != null && selectedSkill.isAttack) { - // 스킬 랭크 조회 (SkillBook 기반) - final skillRank = skillService.getSkillRankFromSkillBook( - state.skillBook, - selectedSkill.id, - ); - // 랭크 스케일링 적용된 공격 스킬 사용 - final skillResult = skillService.useAttackSkillWithRank( - skill: selectedSkill, - player: playerStats, - monster: monsterStats, - skillSystem: updatedSkillSystem, - rank: skillRank, - ); - playerStats = skillResult.updatedPlayer; - monsterStats = skillResult.updatedMonster; - totalDamageDealt += skillResult.result.damage; - updatedSkillSystem = skillResult.updatedSkillSystem; - - // GCD 시작 (스킬 사용 후) - updatedSkillSystem = updatedSkillSystem.startGlobalCooldown(); - - // 스킬 공격 이벤트 생성 - newEvents.add( - CombatEvent.playerSkill( - timestamp: timestamp, - skillName: selectedSkill.name, - damage: skillResult.result.damage, - targetName: monsterStats.name, - attackDelayMs: playerStats.attackDelayMs, - ), - ); - } else if (selectedSkill != null && selectedSkill.isDot) { - // DOT 스킬 사용 - final skillResult = skillService.useDotSkill( - skill: selectedSkill, - player: playerStats, - skillSystem: updatedSkillSystem, - playerInt: state.stats.intelligence, - playerWis: state.stats.wis, - ); - playerStats = skillResult.updatedPlayer; - updatedSkillSystem = skillResult.updatedSkillSystem; - - // GCD 시작 (스킬 사용 후) - updatedSkillSystem = updatedSkillSystem.startGlobalCooldown(); - - // DOT 효과 추가 - if (skillResult.dotEffect != null) { - activeDoTs.add(skillResult.dotEffect!); - } - - // DOT 스킬 사용 이벤트 생성 - newEvents.add( - CombatEvent.playerSkill( - timestamp: timestamp, - skillName: selectedSkill.name, - damage: skillResult.result.damage, - targetName: monsterStats.name, - attackDelayMs: playerStats.attackDelayMs, - ), - ); - } else if (selectedSkill != null && selectedSkill.isHeal) { - // 회복 스킬 사용 - final skillResult = skillService.useHealSkill( - skill: selectedSkill, - player: playerStats, - skillSystem: updatedSkillSystem, - ); - playerStats = skillResult.updatedPlayer; - updatedSkillSystem = skillResult.updatedSkillSystem; - - // GCD 시작 (스킬 사용 후) - updatedSkillSystem = updatedSkillSystem.startGlobalCooldown(); - - // 회복 이벤트 생성 - newEvents.add( - CombatEvent.playerHeal( - timestamp: timestamp, - healAmount: skillResult.result.healedAmount, - skillName: selectedSkill.name, - ), - ); - } else if (selectedSkill != null && selectedSkill.isBuff) { - // 버프 스킬 사용 - final skillResult = skillService.useBuffSkill( - skill: selectedSkill, - player: playerStats, - skillSystem: updatedSkillSystem, - ); - playerStats = skillResult.updatedPlayer; - updatedSkillSystem = skillResult.updatedSkillSystem; - - // GCD 시작 (스킬 사용 후) - updatedSkillSystem = updatedSkillSystem.startGlobalCooldown(); - - // 버프 이벤트 생성 - newEvents.add( - CombatEvent.playerBuff( - timestamp: timestamp, - skillName: selectedSkill.name, - ), - ); - } else if (selectedSkill != null && selectedSkill.isDebuff) { - // 디버프 스킬 사용 - final skillResult = skillService.useDebuffSkill( - skill: selectedSkill, - player: playerStats, - skillSystem: updatedSkillSystem, - currentDebuffs: activeDebuffs, - ); - playerStats = skillResult.updatedPlayer; - updatedSkillSystem = skillResult.updatedSkillSystem; - - // GCD 시작 (스킬 사용 후) - updatedSkillSystem = updatedSkillSystem.startGlobalCooldown(); - - // 디버프 효과 추가 (기존 같은 디버프 제거 후) - if (skillResult.debuffEffect != null) { - activeDebuffs = - activeDebuffs - .where( - (d) => d.effect.id != skillResult.debuffEffect!.effect.id, - ) - .toList() - ..add(skillResult.debuffEffect!); - } - - // 디버프 이벤트 생성 - newEvents.add( - CombatEvent.playerDebuff( - timestamp: timestamp, - skillName: selectedSkill.name, - targetName: monsterStats.name, - ), - ); - } else { - // 일반 공격 - final attackResult = calculator.playerAttackMonster( - attacker: playerStats, - defender: monsterStats, - ); - monsterStats = attackResult.updatedDefender; - totalDamageDealt += attackResult.result.damage; - - // 일반 공격 이벤트 생성 - final result = attackResult.result; - if (result.isEvaded) { - newEvents.add( - CombatEvent.monsterEvade( - timestamp: timestamp, - targetName: monsterStats.name, - ), - ); - } else { - newEvents.add( - CombatEvent.playerAttack( - timestamp: timestamp, - damage: result.damage, - targetName: monsterStats.name, - isCritical: result.isCritical, - attackDelayMs: playerStats.attackDelayMs, - ), - ); - } - } - - playerAccumulator -= playerStats.attackDelayMs; - turnsElapsed++; - } - - // 몬스터가 살아있으면 반격 - if (monsterStats.isAlive && - monsterAccumulator >= monsterStats.attackDelayMs) { - // 디버프 효과 적용된 몬스터 스탯 계산 - var debuffedMonster = monsterStats; - if (activeDebuffs.isNotEmpty) { - double atkMod = 0; - for (final debuff in activeDebuffs) { - if (!debuff.isExpired(timestamp)) { - atkMod += debuff.effect.atkModifier; // 음수 값 - } - } - // ATK 감소 적용 (최소 10% ATK 유지) - final newAtk = (monsterStats.atk * (1 + atkMod)).round().clamp( - monsterStats.atk ~/ 10, - monsterStats.atk, - ); - debuffedMonster = monsterStats.copyWith(atk: newAtk); - } - - final attackResult = calculator.monsterAttackPlayer( - attacker: debuffedMonster, - defender: playerStats, - ); - playerStats = attackResult.updatedDefender; - totalDamageTaken += attackResult.result.damage; - monsterAccumulator -= monsterStats.attackDelayMs; - - // 몬스터 공격 이벤트 생성 - final result = attackResult.result; - if (result.isEvaded) { - newEvents.add( - CombatEvent.playerEvade( - timestamp: timestamp, - attackerName: monsterStats.name, - ), - ); - } else if (result.isBlocked) { - newEvents.add( - CombatEvent.playerBlock( - timestamp: timestamp, - reducedDamage: result.damage, - attackerName: monsterStats.name, - ), - ); - } else if (result.isParried) { - newEvents.add( - CombatEvent.playerParry( - timestamp: timestamp, - reducedDamage: result.damage, - attackerName: monsterStats.name, - ), - ); - } else { - newEvents.add( - CombatEvent.monsterAttack( - timestamp: timestamp, - damage: result.damage, - attackerName: monsterStats.name, - attackDelayMs: monsterStats.attackDelayMs, - ), - ); - } - } - - // 전투 종료 체크 - final isActive = playerStats.isAlive && monsterStats.isAlive; - - // 기존 이벤트와 합쳐서 최대 10개 유지 - final combinedEvents = [...combat.recentEvents, ...newEvents]; - final recentEvents = combinedEvents.length > 10 - ? combinedEvents.sublist(combinedEvents.length - 10) - : combinedEvents; - - return ( - combat: combat.copyWith( - playerStats: playerStats, - monsterStats: monsterStats, - playerAttackAccumulatorMs: playerAccumulator, - monsterAttackAccumulatorMs: monsterAccumulator, - totalDamageDealt: totalDamageDealt, - totalDamageTaken: totalDamageTaken, - turnsElapsed: turnsElapsed, - isActive: isActive, - recentEvents: recentEvents, - activeDoTs: activeDoTs, - usedPotionTypes: usedPotionTypes, - activeDebuffs: activeDebuffs, - ), - skillSystem: updatedSkillSystem, - potionInventory: updatedPotionInventory, - ); - } - /// Act Boss 생성 (Act 완료 시) /// /// 보스 레벨 = min(플레이어 레벨, Act 최소 레벨)로 설정하여 diff --git a/lib/src/core/model/item_stats.dart b/lib/src/core/model/item_stats.dart index 56d87cd..75eff1c 100644 --- a/lib/src/core/model/item_stats.dart +++ b/lib/src/core/model/item_stats.dart @@ -1,5 +1,3 @@ -import 'package:asciineverdie/src/core/animation/canvas/ascii_cell.dart'; - /// 아이템 희귀도 enum ItemRarity { common, @@ -25,17 +23,6 @@ enum ItemRarity { epic => 400, legendary => 1000, }; - - /// 공격 이펙트 셀 색상 (Phase 9: 무기 등급별 이펙트) - /// - /// common은 기본 positive(시안), 나머지는 등급별 고유 색상 - AsciiCellColor get effectCellColor => switch (this) { - ItemRarity.common => AsciiCellColor.positive, - ItemRarity.uncommon => AsciiCellColor.rarityUncommon, - ItemRarity.rare => AsciiCellColor.rarityRare, - ItemRarity.epic => AsciiCellColor.rarityEpic, - ItemRarity.legendary => AsciiCellColor.rarityLegendary, - }; } /// 아이템 스탯 보정치