Compare commits
6 Commits
6c92a323c0
...
a41984d998
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a41984d998 | ||
|
|
c33c1ff470 | ||
|
|
a4bbc6c7cb | ||
|
|
92e5fbbf1a | ||
|
|
90c133d577 | ||
|
|
77dfa48ddf |
@@ -8,6 +8,7 @@ import 'package:asciineverdie/src/core/animation/monster_size.dart';
|
||||
import 'package:asciineverdie/src/core/animation/race_character_frames.dart';
|
||||
import 'package:asciineverdie/src/core/animation/weapon_category.dart';
|
||||
import 'package:asciineverdie/src/core/animation/weapon_effects.dart';
|
||||
import 'package:asciineverdie/src/core/animation/canvas/rarity_color_mapper.dart';
|
||||
import 'package:asciineverdie/src/core/model/item_stats.dart';
|
||||
|
||||
/// Canvas용 전투 프레임 합성기
|
||||
|
||||
19
lib/src/core/animation/canvas/rarity_color_mapper.dart
Normal file
19
lib/src/core/animation/canvas/rarity_color_mapper.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
import 'package:asciineverdie/src/core/animation/canvas/ascii_cell.dart';
|
||||
import 'package:asciineverdie/src/core/model/item_stats.dart';
|
||||
|
||||
/// 아이템 희귀도와 애니메이션 색상 간의 매핑
|
||||
///
|
||||
/// Clean Architecture 준수를 위해 model(ItemRarity) → animation(AsciiCellColor)
|
||||
/// 의존성을 animation 레이어에서 처리
|
||||
extension ItemRarityColorMapper on ItemRarity {
|
||||
/// 공격 이펙트 셀 색상 (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,
|
||||
};
|
||||
}
|
||||
580
lib/src/core/engine/combat_tick_service.dart
Normal file
580
lib/src/core/engine/combat_tick_service.dart
Normal file
@@ -0,0 +1,580 @@
|
||||
import 'package:asciineverdie/data/skill_data.dart';
|
||||
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
||||
import 'package:asciineverdie/src/core/engine/potion_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';
|
||||
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/potion.dart';
|
||||
import 'package:asciineverdie/src/core/model/skill.dart';
|
||||
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||
|
||||
/// 전투 틱 처리 결과
|
||||
class CombatTickResult {
|
||||
const CombatTickResult({
|
||||
required this.combat,
|
||||
required this.skillSystem,
|
||||
this.potionInventory,
|
||||
});
|
||||
|
||||
final CombatState combat;
|
||||
final SkillSystemState skillSystem;
|
||||
final PotionInventory? potionInventory;
|
||||
}
|
||||
|
||||
/// 전투 틱 처리 서비스
|
||||
///
|
||||
/// ProgressService에서 분리된 전투 로직 담당:
|
||||
/// - 스킬 자동 사용
|
||||
/// - DOT 처리
|
||||
/// - 물약 자동 사용
|
||||
/// - 플레이어/몬스터 공격 처리
|
||||
class CombatTickService {
|
||||
CombatTickService({required this.rng});
|
||||
|
||||
final DeterministicRandom rng;
|
||||
|
||||
/// 전투 틱 처리 (스킬 자동 사용, DOT, 물약 포함)
|
||||
///
|
||||
/// [state] 현재 게임 상태
|
||||
/// [combat] 현재 전투 상태
|
||||
/// [skillSystem] 스킬 시스템 상태
|
||||
/// [elapsedMs] 경과 시간 (밀리초)
|
||||
CombatTickResult processTick({
|
||||
required GameState state,
|
||||
required CombatState combat,
|
||||
required SkillSystemState skillSystem,
|
||||
required int elapsedMs,
|
||||
}) {
|
||||
if (!combat.isActive || combat.isCombatOver) {
|
||||
return CombatTickResult(
|
||||
combat: combat,
|
||||
skillSystem: skillSystem,
|
||||
potionInventory: null,
|
||||
);
|
||||
}
|
||||
|
||||
final calculator = CombatCalculator(rng: rng);
|
||||
final skillService = SkillService(rng: 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 = <CombatEvent>[];
|
||||
final timestamp = updatedSkillSystem.elapsedMs;
|
||||
|
||||
// 만료된 디버프 정리
|
||||
activeDebuffs = activeDebuffs
|
||||
.where((debuff) => !debuff.isExpired(timestamp))
|
||||
.toList();
|
||||
|
||||
// DOT 틱 처리
|
||||
final dotResult = _processDotTicks(
|
||||
activeDoTs: activeDoTs,
|
||||
monsterStats: monsterStats,
|
||||
elapsedMs: elapsedMs,
|
||||
timestamp: timestamp,
|
||||
totalDamageDealt: totalDamageDealt,
|
||||
);
|
||||
activeDoTs = dotResult.activeDoTs;
|
||||
monsterStats = dotResult.monsterStats;
|
||||
totalDamageDealt = dotResult.totalDamageDealt;
|
||||
newEvents.addAll(dotResult.events);
|
||||
|
||||
// 긴급 물약 자동 사용 (HP < 30%)
|
||||
final potionResult = _tryEmergencyPotion(
|
||||
playerStats: playerStats,
|
||||
potionInventory: state.potionInventory,
|
||||
usedPotionTypes: usedPotionTypes,
|
||||
playerLevel: state.traits.level,
|
||||
timestamp: timestamp,
|
||||
potionService: potionService,
|
||||
);
|
||||
if (potionResult != null) {
|
||||
playerStats = potionResult.playerStats;
|
||||
usedPotionTypes = potionResult.usedPotionTypes;
|
||||
updatedPotionInventory = potionResult.potionInventory;
|
||||
newEvents.addAll(potionResult.events);
|
||||
}
|
||||
|
||||
// 플레이어 공격 체크
|
||||
if (playerAccumulator >= playerStats.attackDelayMs) {
|
||||
final attackResult = _processPlayerAttack(
|
||||
state: state,
|
||||
playerStats: playerStats,
|
||||
monsterStats: monsterStats,
|
||||
updatedSkillSystem: updatedSkillSystem,
|
||||
activeDoTs: activeDoTs,
|
||||
activeDebuffs: activeDebuffs,
|
||||
totalDamageDealt: totalDamageDealt,
|
||||
timestamp: timestamp,
|
||||
calculator: calculator,
|
||||
skillService: skillService,
|
||||
);
|
||||
|
||||
playerStats = attackResult.playerStats;
|
||||
monsterStats = attackResult.monsterStats;
|
||||
updatedSkillSystem = attackResult.skillSystem;
|
||||
activeDoTs = attackResult.activeDoTs;
|
||||
activeDebuffs = attackResult.activeDebuffs;
|
||||
totalDamageDealt = attackResult.totalDamageDealt;
|
||||
newEvents.addAll(attackResult.events);
|
||||
|
||||
playerAccumulator -= playerStats.attackDelayMs;
|
||||
turnsElapsed++;
|
||||
}
|
||||
|
||||
// 몬스터가 살아있으면 반격
|
||||
if (monsterStats.isAlive &&
|
||||
monsterAccumulator >= monsterStats.attackDelayMs) {
|
||||
final monsterAttackResult = _processMonsterAttack(
|
||||
playerStats: playerStats,
|
||||
monsterStats: monsterStats,
|
||||
activeDebuffs: activeDebuffs,
|
||||
totalDamageTaken: totalDamageTaken,
|
||||
timestamp: timestamp,
|
||||
calculator: calculator,
|
||||
);
|
||||
|
||||
playerStats = monsterAttackResult.playerStats;
|
||||
totalDamageTaken = monsterAttackResult.totalDamageTaken;
|
||||
newEvents.addAll(monsterAttackResult.events);
|
||||
monsterAccumulator -= 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 CombatTickResult(
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
/// DOT 틱 처리
|
||||
({
|
||||
List<DotEffect> activeDoTs,
|
||||
MonsterCombatStats monsterStats,
|
||||
int totalDamageDealt,
|
||||
List<CombatEvent> events,
|
||||
}) _processDotTicks({
|
||||
required List<DotEffect> activeDoTs,
|
||||
required MonsterCombatStats monsterStats,
|
||||
required int elapsedMs,
|
||||
required int timestamp,
|
||||
required int totalDamageDealt,
|
||||
}) {
|
||||
var dotDamageThisTick = 0;
|
||||
final updatedDoTs = <DotEffect>[];
|
||||
final events = <CombatEvent>[];
|
||||
var updatedMonster = monsterStats;
|
||||
|
||||
for (final dot in activeDoTs) {
|
||||
final (updatedDot, ticksTriggered) = dot.tick(elapsedMs);
|
||||
|
||||
if (ticksTriggered > 0) {
|
||||
final damage = dot.damagePerTick * ticksTriggered;
|
||||
dotDamageThisTick += damage;
|
||||
|
||||
// DOT 데미지 이벤트 생성
|
||||
final dotSkillName =
|
||||
SkillData.getSkillById(dot.skillId)?.name ?? dot.skillId;
|
||||
events.add(
|
||||
CombatEvent.dotTick(
|
||||
timestamp: timestamp,
|
||||
skillName: dotSkillName,
|
||||
damage: damage,
|
||||
targetName: updatedMonster.name,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 만료되지 않은 DOT만 유지
|
||||
if (updatedDot.isActive) {
|
||||
updatedDoTs.add(updatedDot);
|
||||
}
|
||||
}
|
||||
|
||||
// DOT 데미지 적용
|
||||
if (dotDamageThisTick > 0 && updatedMonster.isAlive) {
|
||||
final newMonsterHp = (updatedMonster.hpCurrent - dotDamageThisTick).clamp(
|
||||
0,
|
||||
updatedMonster.hpMax,
|
||||
);
|
||||
updatedMonster = updatedMonster.copyWith(hpCurrent: newMonsterHp);
|
||||
totalDamageDealt += dotDamageThisTick;
|
||||
}
|
||||
|
||||
return (
|
||||
activeDoTs: updatedDoTs,
|
||||
monsterStats: updatedMonster,
|
||||
totalDamageDealt: totalDamageDealt,
|
||||
events: events,
|
||||
);
|
||||
}
|
||||
|
||||
/// 긴급 물약 자동 사용
|
||||
({
|
||||
CombatStats playerStats,
|
||||
Set<PotionType> usedPotionTypes,
|
||||
PotionInventory potionInventory,
|
||||
List<CombatEvent> events,
|
||||
})? _tryEmergencyPotion({
|
||||
required CombatStats playerStats,
|
||||
required PotionInventory potionInventory,
|
||||
required Set<PotionType> usedPotionTypes,
|
||||
required int playerLevel,
|
||||
required int timestamp,
|
||||
required PotionService potionService,
|
||||
}) {
|
||||
final hpRatio = playerStats.hpCurrent / playerStats.hpMax;
|
||||
if (hpRatio > PotionService.emergencyHpThreshold) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final emergencyPotion = potionService.selectEmergencyHpPotion(
|
||||
currentHp: playerStats.hpCurrent,
|
||||
maxHp: playerStats.hpMax,
|
||||
inventory: potionInventory,
|
||||
playerLevel: playerLevel,
|
||||
);
|
||||
|
||||
if (emergencyPotion == null || usedPotionTypes.contains(PotionType.hp)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final result = potionService.usePotion(
|
||||
potionId: emergencyPotion.id,
|
||||
inventory: potionInventory,
|
||||
currentHp: playerStats.hpCurrent,
|
||||
maxHp: playerStats.hpMax,
|
||||
currentMp: playerStats.mpCurrent,
|
||||
maxMp: playerStats.mpMax,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
playerStats: playerStats.copyWith(hpCurrent: result.newHp),
|
||||
usedPotionTypes: {...usedPotionTypes, PotionType.hp},
|
||||
potionInventory: result.newInventory!,
|
||||
events: [
|
||||
CombatEvent.playerPotion(
|
||||
timestamp: timestamp,
|
||||
potionName: emergencyPotion.name,
|
||||
healAmount: result.healedAmount,
|
||||
isHp: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 플레이어 공격 처리
|
||||
({
|
||||
CombatStats playerStats,
|
||||
MonsterCombatStats monsterStats,
|
||||
SkillSystemState skillSystem,
|
||||
List<DotEffect> activeDoTs,
|
||||
List<ActiveBuff> activeDebuffs,
|
||||
int totalDamageDealt,
|
||||
List<CombatEvent> events,
|
||||
}) _processPlayerAttack({
|
||||
required GameState state,
|
||||
required CombatStats playerStats,
|
||||
required MonsterCombatStats monsterStats,
|
||||
required SkillSystemState updatedSkillSystem,
|
||||
required List<DotEffect> activeDoTs,
|
||||
required List<ActiveBuff> activeDebuffs,
|
||||
required int totalDamageDealt,
|
||||
required int timestamp,
|
||||
required CombatCalculator calculator,
|
||||
required SkillService skillService,
|
||||
}) {
|
||||
final events = <CombatEvent>[];
|
||||
var newPlayerStats = playerStats;
|
||||
var newMonsterStats = monsterStats;
|
||||
var newSkillSystem = updatedSkillSystem;
|
||||
var newActiveDoTs = [...activeDoTs];
|
||||
var newActiveBuffs = [...activeDebuffs];
|
||||
var newTotalDamageDealt = totalDamageDealt;
|
||||
|
||||
// 장착된 스킬 슬롯에서 사용 가능한 스킬 ID 목록 조회
|
||||
var availableSkillIds = state.skillSystem.equippedSkills.allSkills
|
||||
.map((s) => s.id)
|
||||
.toList();
|
||||
// 장착된 스킬이 없으면 기본 스킬 사용
|
||||
if (availableSkillIds.isEmpty) {
|
||||
availableSkillIds = SkillData.defaultSkillIds;
|
||||
}
|
||||
|
||||
final selectedSkill = skillService.selectAutoSkill(
|
||||
player: newPlayerStats,
|
||||
monster: newMonsterStats,
|
||||
skillSystem: newSkillSystem,
|
||||
availableSkillIds: availableSkillIds,
|
||||
activeDoTs: newActiveDoTs,
|
||||
activeDebuffs: newActiveBuffs,
|
||||
);
|
||||
|
||||
if (selectedSkill != null && selectedSkill.isAttack) {
|
||||
// 스킬 랭크 조회
|
||||
final skillRank = skillService.getSkillRankFromSkillBook(
|
||||
state.skillBook,
|
||||
selectedSkill.id,
|
||||
);
|
||||
// 랭크 스케일링 적용된 공격 스킬 사용
|
||||
final skillResult = skillService.useAttackSkillWithRank(
|
||||
skill: selectedSkill,
|
||||
player: newPlayerStats,
|
||||
monster: newMonsterStats,
|
||||
skillSystem: newSkillSystem,
|
||||
rank: skillRank,
|
||||
);
|
||||
newPlayerStats = skillResult.updatedPlayer;
|
||||
newMonsterStats = skillResult.updatedMonster;
|
||||
newTotalDamageDealt += skillResult.result.damage;
|
||||
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
|
||||
|
||||
events.add(
|
||||
CombatEvent.playerSkill(
|
||||
timestamp: timestamp,
|
||||
skillName: selectedSkill.name,
|
||||
damage: skillResult.result.damage,
|
||||
targetName: newMonsterStats.name,
|
||||
attackDelayMs: newPlayerStats.attackDelayMs,
|
||||
),
|
||||
);
|
||||
} else if (selectedSkill != null && selectedSkill.isDot) {
|
||||
final skillResult = skillService.useDotSkill(
|
||||
skill: selectedSkill,
|
||||
player: newPlayerStats,
|
||||
skillSystem: newSkillSystem,
|
||||
playerInt: state.stats.intelligence,
|
||||
playerWis: state.stats.wis,
|
||||
);
|
||||
newPlayerStats = skillResult.updatedPlayer;
|
||||
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
|
||||
|
||||
if (skillResult.dotEffect != null) {
|
||||
newActiveDoTs.add(skillResult.dotEffect!);
|
||||
}
|
||||
|
||||
events.add(
|
||||
CombatEvent.playerSkill(
|
||||
timestamp: timestamp,
|
||||
skillName: selectedSkill.name,
|
||||
damage: skillResult.result.damage,
|
||||
targetName: newMonsterStats.name,
|
||||
attackDelayMs: newPlayerStats.attackDelayMs,
|
||||
),
|
||||
);
|
||||
} else if (selectedSkill != null && selectedSkill.isHeal) {
|
||||
final skillResult = skillService.useHealSkill(
|
||||
skill: selectedSkill,
|
||||
player: newPlayerStats,
|
||||
skillSystem: newSkillSystem,
|
||||
);
|
||||
newPlayerStats = skillResult.updatedPlayer;
|
||||
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
|
||||
|
||||
events.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: newPlayerStats,
|
||||
skillSystem: newSkillSystem,
|
||||
);
|
||||
newPlayerStats = skillResult.updatedPlayer;
|
||||
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
|
||||
|
||||
events.add(
|
||||
CombatEvent.playerBuff(
|
||||
timestamp: timestamp,
|
||||
skillName: selectedSkill.name,
|
||||
),
|
||||
);
|
||||
} else if (selectedSkill != null && selectedSkill.isDebuff) {
|
||||
final skillResult = skillService.useDebuffSkill(
|
||||
skill: selectedSkill,
|
||||
player: newPlayerStats,
|
||||
skillSystem: newSkillSystem,
|
||||
currentDebuffs: newActiveBuffs,
|
||||
);
|
||||
newPlayerStats = skillResult.updatedPlayer;
|
||||
newSkillSystem = skillResult.updatedSkillSystem.startGlobalCooldown();
|
||||
|
||||
if (skillResult.debuffEffect != null) {
|
||||
newActiveBuffs = newActiveBuffs
|
||||
.where((d) => d.effect.id != skillResult.debuffEffect!.effect.id)
|
||||
.toList()
|
||||
..add(skillResult.debuffEffect!);
|
||||
}
|
||||
|
||||
events.add(
|
||||
CombatEvent.playerDebuff(
|
||||
timestamp: timestamp,
|
||||
skillName: selectedSkill.name,
|
||||
targetName: newMonsterStats.name,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// 일반 공격
|
||||
final attackResult = calculator.playerAttackMonster(
|
||||
attacker: newPlayerStats,
|
||||
defender: newMonsterStats,
|
||||
);
|
||||
newMonsterStats = attackResult.updatedDefender;
|
||||
newTotalDamageDealt += attackResult.result.damage;
|
||||
|
||||
final result = attackResult.result;
|
||||
if (result.isEvaded) {
|
||||
events.add(
|
||||
CombatEvent.monsterEvade(
|
||||
timestamp: timestamp,
|
||||
targetName: newMonsterStats.name,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
events.add(
|
||||
CombatEvent.playerAttack(
|
||||
timestamp: timestamp,
|
||||
damage: result.damage,
|
||||
targetName: newMonsterStats.name,
|
||||
isCritical: result.isCritical,
|
||||
attackDelayMs: newPlayerStats.attackDelayMs,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
playerStats: newPlayerStats,
|
||||
monsterStats: newMonsterStats,
|
||||
skillSystem: newSkillSystem,
|
||||
activeDoTs: newActiveDoTs,
|
||||
activeDebuffs: newActiveBuffs,
|
||||
totalDamageDealt: newTotalDamageDealt,
|
||||
events: events,
|
||||
);
|
||||
}
|
||||
|
||||
/// 몬스터 공격 처리
|
||||
({
|
||||
CombatStats playerStats,
|
||||
int totalDamageTaken,
|
||||
List<CombatEvent> events,
|
||||
}) _processMonsterAttack({
|
||||
required CombatStats playerStats,
|
||||
required MonsterCombatStats monsterStats,
|
||||
required List<ActiveBuff> activeDebuffs,
|
||||
required int totalDamageTaken,
|
||||
required int timestamp,
|
||||
required CombatCalculator calculator,
|
||||
}) {
|
||||
final events = <CombatEvent>[];
|
||||
|
||||
// 디버프 효과 적용된 몬스터 스탯 계산
|
||||
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,
|
||||
);
|
||||
|
||||
final result = attackResult.result;
|
||||
if (result.isEvaded) {
|
||||
events.add(
|
||||
CombatEvent.playerEvade(
|
||||
timestamp: timestamp,
|
||||
attackerName: monsterStats.name,
|
||||
),
|
||||
);
|
||||
} else if (result.isBlocked) {
|
||||
events.add(
|
||||
CombatEvent.playerBlock(
|
||||
timestamp: timestamp,
|
||||
reducedDamage: result.damage,
|
||||
attackerName: monsterStats.name,
|
||||
),
|
||||
);
|
||||
} else if (result.isParried) {
|
||||
events.add(
|
||||
CombatEvent.playerParry(
|
||||
timestamp: timestamp,
|
||||
reducedDamage: result.damage,
|
||||
attackerName: monsterStats.name,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
events.add(
|
||||
CombatEvent.monsterAttack(
|
||||
timestamp: timestamp,
|
||||
damage: result.damage,
|
||||
attackerName: monsterStats.name,
|
||||
attackDelayMs: monsterStats.attackDelayMs,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
playerStats: attackResult.updatedDefender,
|
||||
totalDamageTaken: totalDamageTaken + attackResult.result.damage,
|
||||
events: events,
|
||||
);
|
||||
}
|
||||
}
|
||||
166
lib/src/core/engine/market_service.dart
Normal file
166
lib/src/core/engine/market_service.dart
Normal file
@@ -0,0 +1,166 @@
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||
import 'package:asciineverdie/src/core/engine/potion_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/shop_service.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/util/deterministic_random.dart';
|
||||
import 'package:asciineverdie/src/core/util/pq_logic.dart' as pq_logic;
|
||||
|
||||
/// 판매 처리 결과
|
||||
class SellResult {
|
||||
const SellResult({
|
||||
required this.state,
|
||||
required this.continuesSelling,
|
||||
});
|
||||
|
||||
final GameState state;
|
||||
final bool continuesSelling;
|
||||
}
|
||||
|
||||
/// 시장/판매/구매 서비스
|
||||
///
|
||||
/// ProgressService에서 분리된 시장 로직 담당:
|
||||
/// - 장비 구매
|
||||
/// - 아이템 판매
|
||||
/// - 골드 관리
|
||||
class MarketService {
|
||||
const MarketService({required this.rng});
|
||||
|
||||
final DeterministicRandom rng;
|
||||
|
||||
/// 인벤토리에서 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: rng);
|
||||
|
||||
// 1. 빈 슬롯 목록 수집
|
||||
final emptySlots = <int>[];
|
||||
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;
|
||||
}
|
||||
|
||||
/// 판매 처리
|
||||
///
|
||||
/// [state] 현재 게임 상태
|
||||
/// Returns: 업데이트된 상태와 판매 계속 여부
|
||||
SellResult 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(rng, 10)) *
|
||||
(1 + pq_logic.randomLow(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 SellResult(
|
||||
state: state.copyWith(
|
||||
inventory: state.inventory.copyWith(gold: goldAmount, items: items),
|
||||
progress: progress,
|
||||
),
|
||||
continuesSelling: true,
|
||||
);
|
||||
}
|
||||
|
||||
// 판매 완료 - 인벤토리 업데이트만 하고 다음 태스크로
|
||||
return SellResult(
|
||||
state: state.copyWith(
|
||||
inventory: state.inventory.copyWith(gold: goldAmount, items: items),
|
||||
),
|
||||
continuesSelling: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 = <int>[];
|
||||
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 = <CombatEvent>[];
|
||||
final timestamp = updatedSkillSystem.elapsedMs;
|
||||
|
||||
// =========================================================================
|
||||
// 만료된 디버프 정리
|
||||
// =========================================================================
|
||||
activeDebuffs = activeDebuffs
|
||||
.where((debuff) => !debuff.isExpired(timestamp))
|
||||
.toList();
|
||||
|
||||
// =========================================================================
|
||||
// DOT 틱 처리
|
||||
// =========================================================================
|
||||
var dotDamageThisTick = 0;
|
||||
final updatedDoTs = <DotEffect>[];
|
||||
|
||||
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 최소 레벨)로 설정하여
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/// 아이템 스탯 보정치
|
||||
|
||||
@@ -175,6 +175,7 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
bool _showDeathAnimation = false;
|
||||
List<String>? _deathAnimationMonsterLines;
|
||||
String? _lastMonsterBaseName;
|
||||
int? _lastMonsterLevel; // 몬스터 레벨 캐시 (사망 시 크기 결정용)
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -237,10 +238,13 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
_handleCombatEvent(widget.latestCombatEvent!);
|
||||
}
|
||||
|
||||
// 몬스터 이름 저장 (사망 시 프레임 캡처용)
|
||||
// 몬스터 정보 저장 (사망 시 프레임 캡처용)
|
||||
if (widget.monsterBaseName != null) {
|
||||
_lastMonsterBaseName = widget.monsterBaseName;
|
||||
}
|
||||
if (widget.monsterLevel != null) {
|
||||
_lastMonsterLevel = widget.monsterLevel;
|
||||
}
|
||||
|
||||
// 새 몬스터 등장 시 사망 애니메이션 상태 리셋
|
||||
// (이전 몬스터 사망 애니메이션이 끝나기 전에 새 전투 시작 시 대응)
|
||||
@@ -613,7 +617,8 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
if (monsterName == null) return null;
|
||||
|
||||
final monsterCategory = getMonsterCategory(monsterName);
|
||||
final monsterSize = getMonsterSize(widget.monsterLevel);
|
||||
// 캐시된 레벨 사용 (사망 시점에 widget.monsterLevel이 null일 수 있음)
|
||||
final monsterSize = getMonsterSize(_lastMonsterLevel ?? widget.monsterLevel);
|
||||
|
||||
// 몬스터 Idle 프레임 가져오기
|
||||
final frames = getMonsterIdleFrames(monsterCategory, monsterSize);
|
||||
|
||||
@@ -197,6 +197,10 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
||||
Widget build(BuildContext context) {
|
||||
final combat = widget.progress.currentCombat;
|
||||
final isInCombat = combat != null && combat.isActive;
|
||||
// 몬스터 HP는 전투 중이면서 kill 태스크일 때만 표시
|
||||
// (전투 승리 후 타운 방문 시 HP UI 잔존 버그 방지)
|
||||
final isKillTask = widget.progress.currentTask.type == TaskType.kill;
|
||||
final shouldShowMonsterHp = isInCombat && isKillTask;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
@@ -258,9 +262,10 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
||||
// 우측: 몬스터 HP (전투 중) 또는 컨트롤 버튼
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: isInCombat
|
||||
? _buildMonsterHpBar(combat)
|
||||
: _buildControlButtons(),
|
||||
child: switch ((shouldShowMonsterHp, combat)) {
|
||||
(true, final c?) => _buildMonsterHpBar(c),
|
||||
_ => _buildControlButtons(),
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
181
lib/src/shared/retro_theme_constants.dart
Normal file
181
lib/src/shared/retro_theme_constants.dart
Normal file
@@ -0,0 +1,181 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
/// 레트로 테마 상수 모음
|
||||
///
|
||||
/// 패딩, 폰트 크기, 애니메이션 시간 등 UI 전반에 사용되는 상수 정의.
|
||||
/// 색상은 [RetroColors] 참조.
|
||||
abstract class RetroTheme {
|
||||
RetroTheme._();
|
||||
|
||||
// ===========================================================================
|
||||
// 패딩 (Padding/Spacing)
|
||||
// ===========================================================================
|
||||
|
||||
/// 4px - 아이콘 내부, 작은 요소 간격
|
||||
static const double paddingXs = 4.0;
|
||||
|
||||
/// 8px - 텍스트 줄 간격, 버튼 내부 패딩
|
||||
static const double paddingSm = 8.0;
|
||||
|
||||
/// 10px - 패널 내부 패딩 (소)
|
||||
static const double paddingMd = 10.0;
|
||||
|
||||
/// 12px - 섹션 간격, 패널 내부 패딩 (중)
|
||||
static const double paddingLg = 12.0;
|
||||
|
||||
/// 16px - 화면 여백, 패널 내부 패딩 (대)
|
||||
static const double paddingXl = 16.0;
|
||||
|
||||
/// 24px - 화면 간 여백
|
||||
static const double paddingXxl = 24.0;
|
||||
|
||||
// ===========================================================================
|
||||
// 폰트 크기 (Font Sizes)
|
||||
// ===========================================================================
|
||||
|
||||
/// 8px - 라벨, 작은 힌트
|
||||
static const double fontSizeXs = 8.0;
|
||||
|
||||
/// 10px - 보조 텍스트, 상태 표시
|
||||
static const double fontSizeSm = 10.0;
|
||||
|
||||
/// 11px - 툴팁, 버튼 라벨
|
||||
static const double fontSizeMd = 11.0;
|
||||
|
||||
/// 12px - 본문 텍스트
|
||||
static const double fontSizeLg = 12.0;
|
||||
|
||||
/// 14px - 부제목
|
||||
static const double fontSizeXl = 14.0;
|
||||
|
||||
/// 18px - 제목
|
||||
static const double fontSizeTitle = 18.0;
|
||||
|
||||
// ===========================================================================
|
||||
// 애니메이션 시간 (Animation Durations)
|
||||
// ===========================================================================
|
||||
|
||||
/// 100ms - 버튼 눌림 효과
|
||||
static const Duration animFast = Duration(milliseconds: 100);
|
||||
|
||||
/// 150ms - 호버 효과
|
||||
static const Duration animQuick = Duration(milliseconds: 150);
|
||||
|
||||
/// 200ms - 리스트 아이템 전환
|
||||
static const Duration animNormal = Duration(milliseconds: 200);
|
||||
|
||||
/// 300ms - 패널 전환
|
||||
static const Duration animMedium = Duration(milliseconds: 300);
|
||||
|
||||
/// 400ms - 페이드 인/아웃
|
||||
static const Duration animSlow = Duration(milliseconds: 400);
|
||||
|
||||
/// 500ms - 결과 패널 표시
|
||||
static const Duration animResult = Duration(milliseconds: 500);
|
||||
|
||||
/// 1500ms - ASCII 디스인테그레이트 효과
|
||||
static const Duration animDisintegrate = Duration(milliseconds: 1500);
|
||||
|
||||
// ===========================================================================
|
||||
// 테두리 (Border)
|
||||
// ===========================================================================
|
||||
|
||||
/// 기본 테두리 두께
|
||||
static const double borderWidth = 1.0;
|
||||
|
||||
/// 굵은 테두리 두께
|
||||
static const double borderWidthBold = 2.0;
|
||||
|
||||
/// 기본 테두리 반경 (픽셀 스타일로 0 사용)
|
||||
static const double borderRadius = 0.0;
|
||||
|
||||
/// 라운드 테두리 반경 (현대적 스타일)
|
||||
static const double borderRadiusRound = 4.0;
|
||||
|
||||
// ===========================================================================
|
||||
// 패널 스타일 (Panel Styles)
|
||||
// ===========================================================================
|
||||
|
||||
/// 패널 기본 EdgeInsets
|
||||
static const EdgeInsets panelPadding = EdgeInsets.all(paddingMd);
|
||||
|
||||
/// 패널 확장 EdgeInsets
|
||||
static const EdgeInsets panelPaddingLarge = EdgeInsets.all(paddingLg);
|
||||
|
||||
/// 화면 여백 EdgeInsets
|
||||
static const EdgeInsets screenPadding = EdgeInsets.all(paddingXl);
|
||||
|
||||
// ===========================================================================
|
||||
// 텍스트 스타일 팩토리 (Text Style Factories)
|
||||
// ===========================================================================
|
||||
|
||||
/// 패널 제목 스타일
|
||||
static TextStyle panelTitle(BuildContext context) => TextStyle(
|
||||
fontSize: fontSizeLg,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: RetroColors.goldOf(context),
|
||||
);
|
||||
|
||||
/// 섹션 제목 스타일
|
||||
static TextStyle sectionTitle(BuildContext context) => TextStyle(
|
||||
fontSize: fontSizeMd,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: RetroColors.textPrimaryOf(context),
|
||||
);
|
||||
|
||||
/// 본문 텍스트 스타일
|
||||
static TextStyle bodyText(BuildContext context) => TextStyle(
|
||||
fontSize: fontSizeLg,
|
||||
color: RetroColors.textPrimaryOf(context),
|
||||
);
|
||||
|
||||
/// 보조 텍스트 스타일
|
||||
static TextStyle bodyTextSecondary(BuildContext context) => TextStyle(
|
||||
fontSize: fontSizeSm,
|
||||
color: RetroColors.textSecondaryOf(context),
|
||||
);
|
||||
|
||||
/// 뮤트 텍스트 스타일
|
||||
static TextStyle bodyTextMuted(BuildContext context) => TextStyle(
|
||||
fontSize: fontSizeSm,
|
||||
color: RetroColors.textMutedOf(context),
|
||||
);
|
||||
|
||||
/// 라벨 텍스트 스타일
|
||||
static TextStyle labelText(BuildContext context) => TextStyle(
|
||||
fontSize: fontSizeXs,
|
||||
color: RetroColors.textSecondaryOf(context),
|
||||
);
|
||||
|
||||
/// 숫자/통계 스타일
|
||||
static TextStyle statText(BuildContext context) => TextStyle(
|
||||
fontSize: fontSizeSm,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: RetroColors.textPrimaryOf(context),
|
||||
);
|
||||
|
||||
// ===========================================================================
|
||||
// 박스 데코레이션 팩토리 (Box Decoration Factories)
|
||||
// ===========================================================================
|
||||
|
||||
/// 기본 패널 데코레이션
|
||||
static BoxDecoration panelDecoration(BuildContext context) => BoxDecoration(
|
||||
color: RetroColors.panelBgOf(context),
|
||||
border: Border.all(color: RetroColors.borderOf(context)),
|
||||
);
|
||||
|
||||
/// 강조 패널 데코레이션 (골드 테두리)
|
||||
static BoxDecoration panelDecorationHighlight(BuildContext context) =>
|
||||
BoxDecoration(
|
||||
color: RetroColors.panelBgOf(context),
|
||||
border: Border.all(color: RetroColors.goldOf(context)),
|
||||
);
|
||||
|
||||
/// 표면 데코레이션 (밝은 배경)
|
||||
static BoxDecoration surfaceDecoration(BuildContext context) => BoxDecoration(
|
||||
color: RetroColors.surfaceOf(context),
|
||||
border: Border.all(color: RetroColors.borderOf(context)),
|
||||
);
|
||||
}
|
||||
171
lib/src/shared/widgets/panel_header.dart
Normal file
171
lib/src/shared/widgets/panel_header.dart
Normal file
@@ -0,0 +1,171 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_theme_constants.dart';
|
||||
|
||||
/// 패널 헤더 변형
|
||||
enum PanelHeaderVariant {
|
||||
/// 기본 패널 헤더 (레트로 스타일, 골드 하단 테두리)
|
||||
///
|
||||
/// 사용처: 메인 패널 상단, 골드 강조 필요한 곳
|
||||
primary,
|
||||
|
||||
/// 섹션 헤더 (테마 색상 사용)
|
||||
///
|
||||
/// 사용처: 리스트 섹션 구분, 탭 페이지 내부
|
||||
section,
|
||||
|
||||
/// 장비 헤더 (3D 베벨 테두리)
|
||||
///
|
||||
/// 사용처: 장비 패널, 중요 강조 영역
|
||||
equipment,
|
||||
}
|
||||
|
||||
/// 레트로 스타일 패널 헤더 위젯
|
||||
///
|
||||
/// 여러 화면에서 반복되는 패널/섹션 헤더를 통합한 공통 위젯.
|
||||
/// [variant]에 따라 다른 스타일이 적용됨.
|
||||
///
|
||||
/// 사용 예시:
|
||||
/// ```dart
|
||||
/// PanelHeader(title: 'INVENTORY')
|
||||
/// PanelHeader(title: 'Stats', variant: PanelHeaderVariant.section)
|
||||
/// ```
|
||||
class PanelHeader extends StatelessWidget {
|
||||
const PanelHeader({
|
||||
required this.title,
|
||||
this.variant = PanelHeaderVariant.primary,
|
||||
this.icon,
|
||||
this.uppercase = true,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// 헤더 제목
|
||||
final String title;
|
||||
|
||||
/// 헤더 스타일 변형
|
||||
final PanelHeaderVariant variant;
|
||||
|
||||
/// 선택적 아이콘 (equipment 변형에서 주로 사용)
|
||||
final IconData? icon;
|
||||
|
||||
/// 대문자 변환 여부 (기본 true)
|
||||
final bool uppercase;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final displayTitle = uppercase ? title.toUpperCase() : title;
|
||||
|
||||
return switch (variant) {
|
||||
PanelHeaderVariant.primary => _buildPrimaryHeader(context, displayTitle),
|
||||
PanelHeaderVariant.section => _buildSectionHeader(context, displayTitle),
|
||||
PanelHeaderVariant.equipment =>
|
||||
_buildEquipmentHeader(context, displayTitle),
|
||||
};
|
||||
}
|
||||
|
||||
/// 기본 패널 헤더 (레트로 스타일)
|
||||
Widget _buildPrimaryHeader(BuildContext context, String displayTitle) {
|
||||
final gold = RetroColors.goldOf(context);
|
||||
final panelBg = RetroColors.panelBgOf(context);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: panelBg,
|
||||
border: Border(bottom: BorderSide(color: gold, width: 2)),
|
||||
),
|
||||
child: Text(
|
||||
displayTitle,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: RetroTheme.fontSizeXs,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: gold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 섹션 헤더 (테마 색상)
|
||||
Widget _buildSectionHeader(BuildContext context, String displayTitle) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
color: colorScheme.primaryContainer,
|
||||
child: Text(
|
||||
displayTitle,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 장비 헤더 (3D 베벨 테두리)
|
||||
Widget _buildEquipmentHeader(BuildContext context, String displayTitle) {
|
||||
final gold = RetroColors.goldOf(context);
|
||||
final goldDark = RetroColors.goldDarkOf(context);
|
||||
final panelBg = RetroColors.panelBgOf(context);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: panelBg,
|
||||
border: Border(
|
||||
top: BorderSide(color: gold, width: 2),
|
||||
left: BorderSide(color: gold, width: 2),
|
||||
bottom: BorderSide(color: goldDark, width: 2),
|
||||
right: BorderSide(color: goldDark, width: 2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 16, color: gold),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(
|
||||
displayTitle,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13,
|
||||
color: gold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 뮤트(회색) 섹션 헤더
|
||||
///
|
||||
/// game_play_screen.dart의 _buildSectionHeader와 동일한 스타일.
|
||||
/// 메인 패널 내부의 작은 섹션 구분에 사용.
|
||||
class MutedSectionHeader extends StatelessWidget {
|
||||
const MutedSectionHeader({required this.title, super.key});
|
||||
|
||||
/// 헤더 제목
|
||||
final String title;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textMuted = RetroColors.textMutedOf(context);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
child: Text(
|
||||
title.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: RetroTheme.fontSizeXs,
|
||||
color: textMuted,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
337
test/core/engine/combat_calculator_test.dart
Normal file
337
test/core/engine/combat_calculator_test.dart
Normal file
@@ -0,0 +1,337 @@
|
||||
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
||||
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('CombatCalculator', () {
|
||||
group('playerAttackMonster', () {
|
||||
test('데미지 = (ATK * variation) - (DEF * 0.4)', () {
|
||||
// 고정 시드로 예측 가능한 결과
|
||||
final rng = DeterministicRandom( 42);
|
||||
final calculator = CombatCalculator(rng: rng);
|
||||
|
||||
final player = CombatStats.empty().copyWith(
|
||||
atk: 100,
|
||||
accuracy: 1.0, // 100% 명중
|
||||
criRate: 0.0, // 크리티컬 없음
|
||||
criDamage: 1.5,
|
||||
);
|
||||
|
||||
final monster = MonsterCombatStats(
|
||||
name: 'Test Monster',
|
||||
level: 1,
|
||||
atk: 10,
|
||||
def: 50, // DEF 50 → 50 * 0.4 = 20 감소
|
||||
hpMax: 100,
|
||||
hpCurrent: 100,
|
||||
criRate: 0.05,
|
||||
criDamage: 1.5,
|
||||
evasion: 0.0, // 회피 없음
|
||||
accuracy: 0.8,
|
||||
attackDelayMs: 1000,
|
||||
expReward: 100,
|
||||
);
|
||||
|
||||
final result = calculator.playerAttackMonster(
|
||||
attacker: player,
|
||||
defender: monster,
|
||||
);
|
||||
|
||||
// ATK 100 * (0.8~1.2) - DEF 50 * 0.4 = 80~120 - 20 = 60~100
|
||||
expect(result.result.damage, greaterThanOrEqualTo(1));
|
||||
expect(result.result.isHit, isTrue);
|
||||
expect(result.result.isEvaded, isFalse);
|
||||
expect(result.updatedDefender.hpCurrent, lessThan(monster.hpCurrent));
|
||||
});
|
||||
|
||||
test('크리티컬 발동 시 데미지 배율 적용', () {
|
||||
// 크리티컬이 항상 발동하도록 criRate = 1.0
|
||||
final rng = DeterministicRandom( 123);
|
||||
final calculator = CombatCalculator(rng: rng);
|
||||
|
||||
final player = CombatStats.empty().copyWith(
|
||||
atk: 100,
|
||||
accuracy: 1.0, // 100% 명중
|
||||
criRate: 1.0, // 100% 크리티컬
|
||||
criDamage: 2.0, // 2배 데미지
|
||||
);
|
||||
|
||||
final monster = MonsterCombatStats(
|
||||
name: 'Test Monster',
|
||||
level: 1,
|
||||
atk: 10,
|
||||
def: 0, // DEF 0
|
||||
hpMax: 500,
|
||||
hpCurrent: 500,
|
||||
criRate: 0.05,
|
||||
criDamage: 1.5,
|
||||
evasion: 0.0, // 회피 없음
|
||||
accuracy: 0.8,
|
||||
attackDelayMs: 1000,
|
||||
expReward: 100,
|
||||
);
|
||||
|
||||
final result = calculator.playerAttackMonster(
|
||||
attacker: player,
|
||||
defender: monster,
|
||||
);
|
||||
|
||||
// 크리티컬 시 데미지가 2배 적용
|
||||
expect(result.result.isCritical, isTrue);
|
||||
// ATK 100 * (0.8~1.2) * 2.0 = 160~240
|
||||
expect(result.result.damage, greaterThanOrEqualTo(160));
|
||||
});
|
||||
|
||||
test('회피 발동 시 0 데미지', () {
|
||||
final rng = DeterministicRandom( 42);
|
||||
final calculator = CombatCalculator(rng: rng);
|
||||
|
||||
final player = CombatStats.empty().copyWith(
|
||||
atk: 100,
|
||||
accuracy: 0.0, // 0% 명중 → 항상 회피
|
||||
criRate: 0.0,
|
||||
criDamage: 1.5,
|
||||
);
|
||||
|
||||
final monster = MonsterCombatStats(
|
||||
name: 'Test Monster',
|
||||
level: 1,
|
||||
atk: 10,
|
||||
def: 0,
|
||||
hpMax: 100,
|
||||
hpCurrent: 100,
|
||||
criRate: 0.05,
|
||||
criDamage: 1.5,
|
||||
evasion: 1.0, // 100% 회피
|
||||
accuracy: 0.8,
|
||||
attackDelayMs: 1000,
|
||||
expReward: 100,
|
||||
);
|
||||
|
||||
final result = calculator.playerAttackMonster(
|
||||
attacker: player,
|
||||
defender: monster,
|
||||
);
|
||||
|
||||
// 회피 시 데미지 0
|
||||
expect(result.result.damage, equals(0));
|
||||
expect(result.result.isHit, isFalse);
|
||||
expect(result.result.isEvaded, isTrue);
|
||||
expect(result.updatedDefender.hpCurrent, equals(monster.hpCurrent));
|
||||
});
|
||||
});
|
||||
|
||||
group('monsterAttackPlayer', () {
|
||||
test('블록 발동 시 70% 감소', () {
|
||||
// 블록이 발동하는 시드 찾기
|
||||
// blockRate = 1.0으로 항상 블록
|
||||
final rng = DeterministicRandom( 99);
|
||||
final calculator = CombatCalculator(rng: rng);
|
||||
|
||||
final monster = MonsterCombatStats(
|
||||
name: 'Test Monster',
|
||||
level: 1,
|
||||
atk: 100,
|
||||
def: 0,
|
||||
hpMax: 100,
|
||||
hpCurrent: 100,
|
||||
criRate: 0.0, // 크리티컬 없음
|
||||
criDamage: 1.5,
|
||||
evasion: 0.0,
|
||||
accuracy: 1.0, // 100% 명중
|
||||
attackDelayMs: 1000,
|
||||
expReward: 100,
|
||||
);
|
||||
|
||||
final player = CombatStats.empty().copyWith(
|
||||
hpMax: 500,
|
||||
hpCurrent: 500,
|
||||
def: 0,
|
||||
evasion: 0.0,
|
||||
blockRate: 1.0, // 100% 블록
|
||||
parryRate: 0.0,
|
||||
);
|
||||
|
||||
final result = calculator.monsterAttackPlayer(
|
||||
attacker: monster,
|
||||
defender: player,
|
||||
);
|
||||
|
||||
// 블록 시 데미지 30% (70% 감소)
|
||||
// ATK 100 * (0.8~1.2) * 0.3 = 24~36
|
||||
expect(result.result.isBlocked, isTrue);
|
||||
expect(result.result.damage, lessThanOrEqualTo(40));
|
||||
});
|
||||
|
||||
test('패리 발동 시 50% 감소', () {
|
||||
final rng = DeterministicRandom( 77);
|
||||
final calculator = CombatCalculator(rng: rng);
|
||||
|
||||
final monster = MonsterCombatStats(
|
||||
name: 'Test Monster',
|
||||
level: 1,
|
||||
atk: 100,
|
||||
def: 0,
|
||||
hpMax: 100,
|
||||
hpCurrent: 100,
|
||||
criRate: 0.0,
|
||||
criDamage: 1.5,
|
||||
evasion: 0.0,
|
||||
accuracy: 1.0,
|
||||
attackDelayMs: 1000,
|
||||
expReward: 100,
|
||||
);
|
||||
|
||||
final player = CombatStats.empty().copyWith(
|
||||
hpMax: 500,
|
||||
hpCurrent: 500,
|
||||
def: 0,
|
||||
evasion: 0.0,
|
||||
blockRate: 0.0, // 블록 없음
|
||||
parryRate: 1.0, // 100% 패리
|
||||
);
|
||||
|
||||
final result = calculator.monsterAttackPlayer(
|
||||
attacker: monster,
|
||||
defender: player,
|
||||
);
|
||||
|
||||
// 패리 시 데미지 50%
|
||||
// ATK 100 * (0.8~1.2) * 0.5 = 40~60
|
||||
expect(result.result.isParried, isTrue);
|
||||
expect(result.result.damage, lessThanOrEqualTo(65));
|
||||
});
|
||||
});
|
||||
|
||||
group('estimateCombatDurationMs', () {
|
||||
test('범위 2000~30000ms 내 반환', () {
|
||||
final rng = DeterministicRandom( 42);
|
||||
final calculator = CombatCalculator(rng: rng);
|
||||
|
||||
final player = CombatStats.empty().copyWith(
|
||||
atk: 50,
|
||||
accuracy: 0.9,
|
||||
attackDelayMs: 1000,
|
||||
);
|
||||
|
||||
final monster = MonsterCombatStats(
|
||||
name: 'Test Monster',
|
||||
level: 1,
|
||||
atk: 10,
|
||||
def: 10,
|
||||
hpMax: 100,
|
||||
hpCurrent: 100,
|
||||
criRate: 0.05,
|
||||
criDamage: 1.5,
|
||||
evasion: 0.0,
|
||||
accuracy: 0.8,
|
||||
attackDelayMs: 1000,
|
||||
expReward: 100,
|
||||
);
|
||||
|
||||
final duration = calculator.estimateCombatDurationMs(
|
||||
player: player,
|
||||
monster: monster,
|
||||
);
|
||||
|
||||
expect(duration, greaterThanOrEqualTo(2000));
|
||||
expect(duration, lessThanOrEqualTo(30000));
|
||||
});
|
||||
|
||||
test('고레벨 몬스터는 더 긴 전투 시간', () {
|
||||
final rng = DeterministicRandom( 42);
|
||||
final calculator = CombatCalculator(rng: rng);
|
||||
|
||||
final player = CombatStats.empty().copyWith(
|
||||
atk: 50,
|
||||
accuracy: 0.9,
|
||||
attackDelayMs: 1000,
|
||||
);
|
||||
|
||||
final weakMonster = MonsterCombatStats(
|
||||
name: 'Weak Monster',
|
||||
level: 1,
|
||||
atk: 10,
|
||||
def: 5,
|
||||
hpMax: 50,
|
||||
hpCurrent: 50,
|
||||
criRate: 0.05,
|
||||
criDamage: 1.5,
|
||||
evasion: 0.0,
|
||||
accuracy: 0.8,
|
||||
attackDelayMs: 1000,
|
||||
expReward: 50,
|
||||
);
|
||||
|
||||
final strongMonster = MonsterCombatStats(
|
||||
name: 'Strong Monster',
|
||||
level: 10,
|
||||
atk: 50,
|
||||
def: 20,
|
||||
hpMax: 500,
|
||||
hpCurrent: 500,
|
||||
criRate: 0.05,
|
||||
criDamage: 1.5,
|
||||
evasion: 0.0,
|
||||
accuracy: 0.8,
|
||||
attackDelayMs: 1000,
|
||||
expReward: 200,
|
||||
);
|
||||
|
||||
final weakDuration = calculator.estimateCombatDurationMs(
|
||||
player: player,
|
||||
monster: weakMonster,
|
||||
);
|
||||
|
||||
final strongDuration = calculator.estimateCombatDurationMs(
|
||||
player: player,
|
||||
monster: strongMonster,
|
||||
);
|
||||
|
||||
// 강한 몬스터가 더 긴 전투 시간
|
||||
expect(strongDuration, greaterThan(weakDuration));
|
||||
});
|
||||
});
|
||||
|
||||
group('evaluateDifficulty', () {
|
||||
test('범위 0.0~1.0 내 반환', () {
|
||||
final rng = DeterministicRandom( 42);
|
||||
final calculator = CombatCalculator(rng: rng);
|
||||
|
||||
final player = CombatStats.empty().copyWith(
|
||||
hpMax: 100,
|
||||
hpCurrent: 100,
|
||||
atk: 50,
|
||||
def: 20,
|
||||
accuracy: 0.9,
|
||||
attackDelayMs: 1000,
|
||||
);
|
||||
|
||||
final monster = MonsterCombatStats(
|
||||
name: 'Test Monster',
|
||||
level: 1,
|
||||
atk: 30,
|
||||
def: 10,
|
||||
hpMax: 80,
|
||||
hpCurrent: 80,
|
||||
criRate: 0.05,
|
||||
criDamage: 1.5,
|
||||
evasion: 0.0,
|
||||
accuracy: 0.8,
|
||||
attackDelayMs: 1000,
|
||||
expReward: 100,
|
||||
);
|
||||
|
||||
final difficulty = calculator.evaluateDifficulty(
|
||||
player: player,
|
||||
monster: monster,
|
||||
);
|
||||
|
||||
expect(difficulty, greaterThanOrEqualTo(0.0));
|
||||
expect(difficulty, lessThanOrEqualTo(1.0));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,63 +1,18 @@
|
||||
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
|
||||
import 'package:asciineverdie/src/core/engine/progress_loop.dart';
|
||||
import 'package:asciineverdie/src/core/engine/progress_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/reward_service.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/pq_config.dart';
|
||||
import 'package:asciineverdie/src/core/storage/save_manager.dart';
|
||||
import 'package:asciineverdie/src/core/storage/save_repository.dart';
|
||||
import 'package:asciineverdie/src/core/storage/save_service.dart';
|
||||
import 'package:asciineverdie/src/core/util/balance_constants.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
class _FakeSaveManager implements SaveManager {
|
||||
final List<GameState> savedStates = [];
|
||||
|
||||
@override
|
||||
Future<SaveOutcome> saveState(
|
||||
GameState state, {
|
||||
String? fileName,
|
||||
bool cheatsEnabled = false,
|
||||
}) async {
|
||||
savedStates.add(state);
|
||||
return const SaveOutcome.success();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<(SaveOutcome, GameState?, bool)> loadState({String? fileName}) async {
|
||||
return (const SaveOutcome.success(), null, false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<SaveFileInfo>> listSaves() async => [];
|
||||
|
||||
@override
|
||||
Future<SaveOutcome> deleteSave({String? fileName}) async {
|
||||
return const SaveOutcome.success();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> saveExists({String? fileName}) async => false;
|
||||
}
|
||||
import '../../helpers/mock_factories.dart';
|
||||
|
||||
void main() {
|
||||
late ProgressService service;
|
||||
|
||||
setUp(() {
|
||||
const config = PqConfig();
|
||||
final mutations = GameMutations(config);
|
||||
service = ProgressService(
|
||||
config: config,
|
||||
mutations: mutations,
|
||||
rewards: RewardService(mutations, config),
|
||||
);
|
||||
});
|
||||
late final service = MockFactories.createProgressService();
|
||||
|
||||
test('autosaves on level-up and stop when configured', () async {
|
||||
final saveManager = _FakeSaveManager();
|
||||
final saveManager = FakeSaveManager();
|
||||
|
||||
// 레벨 1에서 레벨업에 필요한 경험치
|
||||
final requiredExp = ExpConstants.requiredExp(1);
|
||||
|
||||
679
test/core/engine/skill_service_test.dart
Normal file
679
test/core/engine/skill_service_test.dart
Normal file
@@ -0,0 +1,679 @@
|
||||
import 'package:asciineverdie/data/skill_data.dart';
|
||||
import 'package:asciineverdie/src/core/engine/skill_service.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/skill.dart';
|
||||
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
group('SkillService', () {
|
||||
group('canUseSkill', () {
|
||||
test('GCD 활성화 시 onGlobalCooldown 반환', () {
|
||||
final rng = DeterministicRandom(42);
|
||||
final service = SkillService(rng: rng);
|
||||
|
||||
const skill = SkillData.stackTrace;
|
||||
final skillSystem = SkillSystemState.empty().copyWith(
|
||||
elapsedMs: 1000,
|
||||
globalCooldownEndMs: 2500, // GCD 활성화 중
|
||||
);
|
||||
|
||||
final result = service.canUseSkill(
|
||||
skill: skill,
|
||||
currentMp: 100,
|
||||
skillSystem: skillSystem,
|
||||
);
|
||||
|
||||
expect(result, equals(SkillFailReason.onGlobalCooldown));
|
||||
});
|
||||
|
||||
test('MP 부족 시 notEnoughMp 반환', () {
|
||||
final rng = DeterministicRandom(42);
|
||||
final service = SkillService(rng: rng);
|
||||
|
||||
const skill = SkillData.stackTrace; // MP 10 필요
|
||||
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
|
||||
|
||||
final result = service.canUseSkill(
|
||||
skill: skill,
|
||||
currentMp: 5, // MP 부족
|
||||
skillSystem: skillSystem,
|
||||
);
|
||||
|
||||
expect(result, equals(SkillFailReason.notEnoughMp));
|
||||
});
|
||||
|
||||
test('쿨타임 중 onCooldown 반환', () {
|
||||
final rng = DeterministicRandom(42);
|
||||
final service = SkillService(rng: rng);
|
||||
|
||||
const skill = SkillData.stackTrace; // 쿨타임 3000ms
|
||||
final skillSystem = SkillSystemState.empty().copyWith(
|
||||
elapsedMs: 2000,
|
||||
skillStates: [
|
||||
const SkillState(
|
||||
skillId: 'stack_trace',
|
||||
lastUsedMs: 1000, // 1초 전 사용
|
||||
rank: 1,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final result = service.canUseSkill(
|
||||
skill: skill,
|
||||
currentMp: 100,
|
||||
skillSystem: skillSystem,
|
||||
);
|
||||
|
||||
expect(result, equals(SkillFailReason.onCooldown));
|
||||
});
|
||||
|
||||
test('모든 조건 충족 시 null 반환', () {
|
||||
final rng = DeterministicRandom(42);
|
||||
final service = SkillService(rng: rng);
|
||||
|
||||
const skill = SkillData.stackTrace;
|
||||
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
|
||||
|
||||
final result = service.canUseSkill(
|
||||
skill: skill,
|
||||
currentMp: 100,
|
||||
skillSystem: skillSystem,
|
||||
);
|
||||
|
||||
expect(result, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('useAttackSkill', () {
|
||||
test('기본 공격 데미지 계산', () {
|
||||
final rng = DeterministicRandom(42);
|
||||
final service = SkillService(rng: rng);
|
||||
|
||||
const skill = SkillData.stackTrace; // 2.0x 배율
|
||||
final player = CombatStats.empty().copyWith(
|
||||
atk: 100,
|
||||
mpCurrent: 50,
|
||||
mpMax: 100,
|
||||
);
|
||||
final monster = MonsterCombatStats(
|
||||
name: 'Test Monster',
|
||||
level: 1,
|
||||
atk: 10,
|
||||
def: 50, // DEF 50 → 50 * 0.3 = 15 감소
|
||||
hpMax: 500,
|
||||
hpCurrent: 500,
|
||||
criRate: 0.05,
|
||||
criDamage: 1.5,
|
||||
evasion: 0.0,
|
||||
accuracy: 0.8,
|
||||
attackDelayMs: 1000,
|
||||
expReward: 100,
|
||||
);
|
||||
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
|
||||
|
||||
final result = service.useAttackSkill(
|
||||
skill: skill,
|
||||
player: player,
|
||||
monster: monster,
|
||||
skillSystem: skillSystem,
|
||||
);
|
||||
|
||||
// ATK 100 * 2.0 - DEF 50 * 0.3 = 200 - 15 = 185
|
||||
expect(result.result.success, isTrue);
|
||||
expect(result.result.damage, equals(185));
|
||||
expect(result.updatedPlayer.mpCurrent, equals(40)); // 50 - 10
|
||||
expect(result.updatedMonster.hpCurrent, equals(315)); // 500 - 185
|
||||
});
|
||||
|
||||
test('버프 적용 시 데미지 증가', () {
|
||||
final rng = DeterministicRandom(42);
|
||||
final service = SkillService(rng: rng);
|
||||
|
||||
const skill = SkillData.stackTrace;
|
||||
final player = CombatStats.empty().copyWith(
|
||||
atk: 100,
|
||||
mpCurrent: 50,
|
||||
mpMax: 100,
|
||||
);
|
||||
final monster = MonsterCombatStats(
|
||||
name: 'Test Monster',
|
||||
level: 1,
|
||||
atk: 10,
|
||||
def: 0,
|
||||
hpMax: 500,
|
||||
hpCurrent: 500,
|
||||
criRate: 0.05,
|
||||
criDamage: 1.5,
|
||||
evasion: 0.0,
|
||||
accuracy: 0.8,
|
||||
attackDelayMs: 1000,
|
||||
expReward: 100,
|
||||
);
|
||||
final skillSystem = SkillSystemState.empty().copyWith(
|
||||
elapsedMs: 5000,
|
||||
activeBuffs: [
|
||||
const ActiveBuff(
|
||||
effect: BuffEffect(
|
||||
id: 'test_buff',
|
||||
name: 'Test Buff',
|
||||
durationMs: 10000,
|
||||
atkModifier: 0.5, // +50% ATK
|
||||
),
|
||||
startedMs: 4000,
|
||||
sourceSkillId: 'test',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final result = service.useAttackSkill(
|
||||
skill: skill,
|
||||
player: player,
|
||||
monster: monster,
|
||||
skillSystem: skillSystem,
|
||||
);
|
||||
|
||||
// ATK 100 * 2.0 * 1.5 = 300
|
||||
expect(result.result.damage, equals(300));
|
||||
});
|
||||
});
|
||||
|
||||
group('useAttackSkillWithRank', () {
|
||||
test('랭크 1 데미지 (기본 배율)', () {
|
||||
final rng = DeterministicRandom(42);
|
||||
final service = SkillService(rng: rng);
|
||||
|
||||
const skill = SkillData.stackTrace;
|
||||
final player = CombatStats.empty().copyWith(
|
||||
atk: 100,
|
||||
mpCurrent: 50,
|
||||
mpMax: 100,
|
||||
);
|
||||
final monster = MonsterCombatStats(
|
||||
name: 'Test Monster',
|
||||
level: 1,
|
||||
atk: 10,
|
||||
def: 0,
|
||||
hpMax: 500,
|
||||
hpCurrent: 500,
|
||||
criRate: 0.05,
|
||||
criDamage: 1.5,
|
||||
evasion: 0.0,
|
||||
accuracy: 0.8,
|
||||
attackDelayMs: 1000,
|
||||
expReward: 100,
|
||||
);
|
||||
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
|
||||
|
||||
final result = service.useAttackSkillWithRank(
|
||||
skill: skill,
|
||||
player: player,
|
||||
monster: monster,
|
||||
skillSystem: skillSystem,
|
||||
rank: 1,
|
||||
);
|
||||
|
||||
// 랭크 1: 1.0x → ATK 100 * 2.0 * 1.0 = 200
|
||||
expect(result.result.damage, equals(200));
|
||||
expect(result.updatedPlayer.mpCurrent, equals(40)); // MP 10 소모
|
||||
});
|
||||
|
||||
test('랭크 5 데미지 스케일링', () {
|
||||
final rng = DeterministicRandom(42);
|
||||
final service = SkillService(rng: rng);
|
||||
|
||||
const skill = SkillData.stackTrace;
|
||||
final player = CombatStats.empty().copyWith(
|
||||
atk: 100,
|
||||
mpCurrent: 50,
|
||||
mpMax: 100,
|
||||
);
|
||||
final monster = MonsterCombatStats(
|
||||
name: 'Test Monster',
|
||||
level: 1,
|
||||
atk: 10,
|
||||
def: 0,
|
||||
hpMax: 500,
|
||||
hpCurrent: 500,
|
||||
criRate: 0.05,
|
||||
criDamage: 1.5,
|
||||
evasion: 0.0,
|
||||
accuracy: 0.8,
|
||||
attackDelayMs: 1000,
|
||||
expReward: 100,
|
||||
);
|
||||
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
|
||||
|
||||
final result = service.useAttackSkillWithRank(
|
||||
skill: skill,
|
||||
player: player,
|
||||
monster: monster,
|
||||
skillSystem: skillSystem,
|
||||
rank: 5,
|
||||
);
|
||||
|
||||
// 랭크 5: 1.6x (1.0 + 4 * 0.15)
|
||||
// ATK 100 * 2.0 * 1.6 = 320
|
||||
expect(result.result.damage, equals(320));
|
||||
|
||||
// MP 비용: 10 * (1.0 - 4 * 0.03) = 10 * 0.88 = 9 (반올림)
|
||||
expect(result.updatedPlayer.mpCurrent, equals(41)); // 50 - 9
|
||||
});
|
||||
});
|
||||
|
||||
group('useHealSkill', () {
|
||||
test('고정 회복량', () {
|
||||
final rng = DeterministicRandom(42);
|
||||
final service = SkillService(rng: rng);
|
||||
|
||||
const skill = SkillData.hotReload; // healAmount: 30
|
||||
final player = CombatStats.empty().copyWith(
|
||||
hpMax: 200,
|
||||
hpCurrent: 100,
|
||||
mpMax: 100,
|
||||
mpCurrent: 50,
|
||||
);
|
||||
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
|
||||
|
||||
final result = service.useHealSkill(
|
||||
skill: skill,
|
||||
player: player,
|
||||
skillSystem: skillSystem,
|
||||
);
|
||||
|
||||
expect(result.result.success, isTrue);
|
||||
expect(result.result.healedAmount, equals(30));
|
||||
expect(result.updatedPlayer.hpCurrent, equals(130)); // 100 + 30
|
||||
expect(result.updatedPlayer.mpCurrent, equals(35)); // 50 - 15
|
||||
});
|
||||
|
||||
test('퍼센트 회복량', () {
|
||||
final rng = DeterministicRandom(42);
|
||||
final service = SkillService(rng: rng);
|
||||
|
||||
const skill = SkillData.garbageCollection; // healPercent: 0.3
|
||||
final player = CombatStats.empty().copyWith(
|
||||
hpMax: 200,
|
||||
hpCurrent: 100,
|
||||
mpMax: 100,
|
||||
mpCurrent: 50,
|
||||
);
|
||||
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
|
||||
|
||||
final result = service.useHealSkill(
|
||||
skill: skill,
|
||||
player: player,
|
||||
skillSystem: skillSystem,
|
||||
);
|
||||
|
||||
// healPercent 0.3 * hpMax 200 = 60
|
||||
expect(result.result.healedAmount, equals(60));
|
||||
expect(result.updatedPlayer.hpCurrent, equals(160)); // 100 + 60
|
||||
});
|
||||
|
||||
test('HP 캡 적용', () {
|
||||
final rng = DeterministicRandom(42);
|
||||
final service = SkillService(rng: rng);
|
||||
|
||||
const skill = SkillData.snapshotRestore; // healPercent: 0.5
|
||||
final player = CombatStats.empty().copyWith(
|
||||
hpMax: 200,
|
||||
hpCurrent: 180, // 거의 만피
|
||||
mpMax: 100,
|
||||
mpCurrent: 80,
|
||||
);
|
||||
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
|
||||
|
||||
final result = service.useHealSkill(
|
||||
skill: skill,
|
||||
player: player,
|
||||
skillSystem: skillSystem,
|
||||
);
|
||||
|
||||
// healPercent 0.5 * hpMax 200 = 100
|
||||
// 하지만 hpMax 캡으로 200까지만
|
||||
expect(result.updatedPlayer.hpCurrent, equals(200));
|
||||
});
|
||||
});
|
||||
|
||||
group('useDotSkill', () {
|
||||
test('DOT 효과 생성', () {
|
||||
final rng = DeterministicRandom(42);
|
||||
final service = SkillService(rng: rng);
|
||||
|
||||
const skill = SkillData.memoryDump;
|
||||
// baseDotDamage: 10, baseDotDurationMs: 6000, baseDotTickMs: 1000
|
||||
final player = CombatStats.empty().copyWith(
|
||||
mpMax: 100,
|
||||
mpCurrent: 50,
|
||||
);
|
||||
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
|
||||
|
||||
final result = service.useDotSkill(
|
||||
skill: skill,
|
||||
player: player,
|
||||
skillSystem: skillSystem,
|
||||
playerInt: 10, // 기본
|
||||
playerWis: 10, // 기본
|
||||
);
|
||||
|
||||
expect(result.result.success, isTrue);
|
||||
expect(result.dotEffect, isNotNull);
|
||||
expect(result.dotEffect!.baseDamage, equals(10));
|
||||
expect(result.dotEffect!.damagePerTick, equals(10)); // INT 10 → 1.0x
|
||||
expect(result.dotEffect!.tickIntervalMs, equals(1000)); // WIS 10 → 1.0x
|
||||
expect(result.dotEffect!.totalDurationMs, equals(6000));
|
||||
|
||||
// 예상 총 데미지: 6틱 × 10 = 60
|
||||
expect(result.result.damage, equals(60));
|
||||
});
|
||||
|
||||
test('INT 보정 적용 (데미지 증가)', () {
|
||||
final rng = DeterministicRandom(42);
|
||||
final service = SkillService(rng: rng);
|
||||
|
||||
const skill = SkillData.memoryDump;
|
||||
final player = CombatStats.empty().copyWith(
|
||||
mpMax: 100,
|
||||
mpCurrent: 50,
|
||||
);
|
||||
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
|
||||
|
||||
final result = service.useDotSkill(
|
||||
skill: skill,
|
||||
player: player,
|
||||
skillSystem: skillSystem,
|
||||
playerInt: 20, // INT +10 → +30% 데미지
|
||||
playerWis: 10,
|
||||
);
|
||||
|
||||
// INT 20: 1.0 + (20-10) * 0.03 = 1.3x
|
||||
// baseDotDamage 10 * 1.3 = 13
|
||||
expect(result.dotEffect!.damagePerTick, equals(13));
|
||||
});
|
||||
|
||||
test('WIS 보정 적용 (틱 간격 감소)', () {
|
||||
final rng = DeterministicRandom(42);
|
||||
final service = SkillService(rng: rng);
|
||||
|
||||
const skill = SkillData.memoryDump;
|
||||
final player = CombatStats.empty().copyWith(
|
||||
mpMax: 100,
|
||||
mpCurrent: 50,
|
||||
);
|
||||
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
|
||||
|
||||
final result = service.useDotSkill(
|
||||
skill: skill,
|
||||
player: player,
|
||||
skillSystem: skillSystem,
|
||||
playerInt: 10,
|
||||
playerWis: 20, // WIS +10 → 틱 간격 감소
|
||||
);
|
||||
|
||||
// WIS 20: 1.0 + (20-10) * 0.02 = 1.2x
|
||||
// baseDotTickMs 1000 / 1.2 = 833ms
|
||||
expect(result.dotEffect!.tickIntervalMs, equals(833));
|
||||
});
|
||||
});
|
||||
|
||||
group('calculateMpRegen', () {
|
||||
test('비전투 중 MP 회복 (50ms당 1)', () {
|
||||
final rng = DeterministicRandom(42);
|
||||
final service = SkillService(rng: rng);
|
||||
|
||||
final result = service.calculateMpRegen(
|
||||
elapsedMs: 1000, // 1초
|
||||
isInCombat: false,
|
||||
wis: 10,
|
||||
);
|
||||
|
||||
// 1000ms / 50ms = 20
|
||||
expect(result, equals(20));
|
||||
});
|
||||
|
||||
test('전투 중 MP 회복 (WIS 기반)', () {
|
||||
final rng = DeterministicRandom(42);
|
||||
final service = SkillService(rng: rng);
|
||||
|
||||
final result = service.calculateMpRegen(
|
||||
elapsedMs: 1000, // 1초
|
||||
isInCombat: true,
|
||||
wis: 10,
|
||||
);
|
||||
|
||||
// 전투 중: 500ms당 (1 + WIS/20)
|
||||
// WIS 10: 1 + 10/20 = 1 (정수 연산)
|
||||
// 1000ms / 500ms = 2틱
|
||||
// 2틱 * 1 = 2
|
||||
expect(result, equals(2));
|
||||
});
|
||||
|
||||
test('높은 WIS 전투 중 MP 회복', () {
|
||||
final rng = DeterministicRandom(42);
|
||||
final service = SkillService(rng: rng);
|
||||
|
||||
final result = service.calculateMpRegen(
|
||||
elapsedMs: 1000,
|
||||
isInCombat: true,
|
||||
wis: 40,
|
||||
);
|
||||
|
||||
// WIS 40: 1 + 40/20 = 3
|
||||
// 2틱 * 3 = 6
|
||||
expect(result, equals(6));
|
||||
});
|
||||
});
|
||||
|
||||
group('selectAutoSkill', () {
|
||||
test('MP 부족 시 null 반환 (일반 공격)', () {
|
||||
final rng = DeterministicRandom(42);
|
||||
final service = SkillService(rng: rng);
|
||||
|
||||
final player = CombatStats.empty().copyWith(
|
||||
hpMax: 100,
|
||||
hpCurrent: 100,
|
||||
mpMax: 100,
|
||||
mpCurrent: 10, // 10% MP
|
||||
);
|
||||
final monster = MonsterCombatStats(
|
||||
name: 'Test Monster',
|
||||
level: 1,
|
||||
atk: 10,
|
||||
def: 10,
|
||||
hpMax: 100,
|
||||
hpCurrent: 100,
|
||||
criRate: 0.05,
|
||||
criDamage: 1.5,
|
||||
evasion: 0.0,
|
||||
accuracy: 0.8,
|
||||
attackDelayMs: 1000,
|
||||
expReward: 100,
|
||||
);
|
||||
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
|
||||
|
||||
final result = service.selectAutoSkill(
|
||||
player: player,
|
||||
monster: monster,
|
||||
skillSystem: skillSystem,
|
||||
availableSkillIds: ['stack_trace', 'garbage_collection'],
|
||||
);
|
||||
|
||||
expect(result, isNull);
|
||||
});
|
||||
|
||||
test('HP 30% 미만 시 회복 스킬 우선', () {
|
||||
// 시드 조정하여 일반 공격 확률을 넘어가도록
|
||||
final rng = DeterministicRandom(999);
|
||||
final service = SkillService(rng: rng);
|
||||
|
||||
final player = CombatStats.empty().copyWith(
|
||||
hpMax: 100,
|
||||
hpCurrent: 20, // 20% HP
|
||||
mpMax: 100,
|
||||
mpCurrent: 80,
|
||||
);
|
||||
final monster = MonsterCombatStats(
|
||||
name: 'Test Monster',
|
||||
level: 1,
|
||||
atk: 10,
|
||||
def: 10,
|
||||
hpMax: 100,
|
||||
hpCurrent: 100,
|
||||
criRate: 0.05,
|
||||
criDamage: 1.5,
|
||||
evasion: 0.0,
|
||||
accuracy: 0.8,
|
||||
attackDelayMs: 1000,
|
||||
expReward: 100,
|
||||
);
|
||||
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
|
||||
|
||||
final result = service.selectAutoSkill(
|
||||
player: player,
|
||||
monster: monster,
|
||||
skillSystem: skillSystem,
|
||||
availableSkillIds: ['stack_trace', 'garbage_collection'],
|
||||
);
|
||||
|
||||
// HP < 30%이면 회복 스킬 반환 (garbage_collection)
|
||||
expect(result?.type, equals(SkillType.heal));
|
||||
});
|
||||
});
|
||||
|
||||
group('useBuffSkill', () {
|
||||
test('버프 적용', () {
|
||||
final rng = DeterministicRandom(42);
|
||||
final service = SkillService(rng: rng);
|
||||
|
||||
const skill = SkillData.debugMode; // ATK +25% 버프
|
||||
final player = CombatStats.empty().copyWith(
|
||||
mpMax: 100,
|
||||
mpCurrent: 50,
|
||||
);
|
||||
final skillSystem = SkillSystemState.empty().copyWith(elapsedMs: 5000);
|
||||
|
||||
final result = service.useBuffSkill(
|
||||
skill: skill,
|
||||
player: player,
|
||||
skillSystem: skillSystem,
|
||||
);
|
||||
|
||||
expect(result.result.success, isTrue);
|
||||
expect(result.result.appliedBuff, isNotNull);
|
||||
expect(
|
||||
result.result.appliedBuff!.effect.atkModifier,
|
||||
equals(0.25),
|
||||
);
|
||||
expect(result.updatedSkillSystem.activeBuffs.length, equals(1));
|
||||
expect(result.updatedPlayer.mpCurrent, equals(30)); // 50 - 20
|
||||
});
|
||||
|
||||
test('중복 버프 제거 후 새 버프 적용', () {
|
||||
final rng = DeterministicRandom(42);
|
||||
final service = SkillService(rng: rng);
|
||||
|
||||
const skill = SkillData.debugMode;
|
||||
final player = CombatStats.empty().copyWith(
|
||||
mpMax: 100,
|
||||
mpCurrent: 50,
|
||||
);
|
||||
final existingBuff = const ActiveBuff(
|
||||
effect: BuffEffect(
|
||||
id: 'debug_mode_buff',
|
||||
name: 'Debug Mode',
|
||||
durationMs: 10000,
|
||||
atkModifier: 0.25,
|
||||
),
|
||||
startedMs: 1000,
|
||||
sourceSkillId: 'debug_mode',
|
||||
);
|
||||
final skillSystem = SkillSystemState.empty().copyWith(
|
||||
elapsedMs: 5000,
|
||||
activeBuffs: [existingBuff],
|
||||
);
|
||||
|
||||
final result = service.useBuffSkill(
|
||||
skill: skill,
|
||||
player: player,
|
||||
skillSystem: skillSystem,
|
||||
);
|
||||
|
||||
// 기존 버프 제거 후 새 버프 추가 → 총 1개
|
||||
expect(result.updatedSkillSystem.activeBuffs.length, equals(1));
|
||||
expect(
|
||||
result.updatedSkillSystem.activeBuffs.first.startedMs,
|
||||
equals(5000), // 새 버프
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('cleanupExpiredBuffs', () {
|
||||
test('만료된 버프 제거', () {
|
||||
final rng = DeterministicRandom(42);
|
||||
final service = SkillService(rng: rng);
|
||||
|
||||
final skillSystem = SkillSystemState.empty().copyWith(
|
||||
elapsedMs: 20000,
|
||||
activeBuffs: [
|
||||
const ActiveBuff(
|
||||
effect: BuffEffect(
|
||||
id: 'buff1',
|
||||
name: 'Active Buff',
|
||||
durationMs: 15000, // 아직 활성
|
||||
),
|
||||
startedMs: 10000,
|
||||
sourceSkillId: 'test1',
|
||||
),
|
||||
const ActiveBuff(
|
||||
effect: BuffEffect(
|
||||
id: 'buff2',
|
||||
name: 'Expired Buff',
|
||||
durationMs: 5000, // 만료됨 (시작 5000 + 지속 5000 = 10000 < 20000)
|
||||
),
|
||||
startedMs: 5000,
|
||||
sourceSkillId: 'test2',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final result = service.cleanupExpiredBuffs(skillSystem);
|
||||
|
||||
expect(result.activeBuffs.length, equals(1));
|
||||
expect(result.activeBuffs.first.effect.id, equals('buff1'));
|
||||
});
|
||||
});
|
||||
|
||||
group('getRankMultiplier', () {
|
||||
test('랭크별 배율 계산', () {
|
||||
expect(getRankMultiplier(1), equals(1.0));
|
||||
expect(getRankMultiplier(2), closeTo(1.15, 0.001));
|
||||
expect(getRankMultiplier(3), closeTo(1.30, 0.001));
|
||||
expect(getRankMultiplier(5), closeTo(1.60, 0.001));
|
||||
expect(getRankMultiplier(10), closeTo(2.35, 0.001));
|
||||
});
|
||||
});
|
||||
|
||||
group('getRankCooldownMultiplier', () {
|
||||
test('랭크별 쿨타임 감소율', () {
|
||||
expect(getRankCooldownMultiplier(1), equals(1.0));
|
||||
expect(getRankCooldownMultiplier(2), closeTo(0.95, 0.001));
|
||||
expect(getRankCooldownMultiplier(5), closeTo(0.80, 0.001));
|
||||
// 최대 50% 감소
|
||||
expect(getRankCooldownMultiplier(20), closeTo(0.5, 0.001));
|
||||
});
|
||||
});
|
||||
|
||||
group('getRankMpMultiplier', () {
|
||||
test('랭크별 MP 비용 감소율', () {
|
||||
expect(getRankMpMultiplier(1), equals(1.0));
|
||||
expect(getRankMpMultiplier(2), closeTo(0.97, 0.001));
|
||||
expect(getRankMpMultiplier(5), closeTo(0.88, 0.001));
|
||||
// 최대 30% 감소
|
||||
expect(getRankMpMultiplier(20), closeTo(0.7, 0.001));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
356
test/core/engine/stat_calculator_test.dart
Normal file
356
test/core/engine/stat_calculator_test.dart
Normal file
@@ -0,0 +1,356 @@
|
||||
import 'package:asciineverdie/src/core/engine/stat_calculator.dart';
|
||||
import 'package:asciineverdie/src/core/model/class_traits.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/race_traits.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
const calculator = StatCalculator();
|
||||
|
||||
group('StatCalculator', () {
|
||||
group('applyModifiers', () {
|
||||
test('종족 스탯 보정 적용', () {
|
||||
final baseStats = const Stats(
|
||||
str: 10,
|
||||
con: 10,
|
||||
dex: 10,
|
||||
intelligence: 10,
|
||||
wis: 10,
|
||||
cha: 10,
|
||||
hpMax: 100,
|
||||
mpMax: 50,
|
||||
);
|
||||
|
||||
// +2 STR, -1 INT 종족
|
||||
final race = RaceTraits(
|
||||
raceId: 'test_race',
|
||||
name: 'Test Race',
|
||||
statModifiers: const {
|
||||
StatType.str: 2,
|
||||
StatType.intelligence: -1,
|
||||
},
|
||||
);
|
||||
|
||||
// 보정 없는 클래스
|
||||
const klass = ClassTraits(
|
||||
classId: 'test_class',
|
||||
name: 'Test Class',
|
||||
statModifiers: {},
|
||||
);
|
||||
|
||||
final result = calculator.applyModifiers(
|
||||
baseStats: baseStats,
|
||||
race: race,
|
||||
klass: klass,
|
||||
);
|
||||
|
||||
expect(result.str, equals(12)); // 10 + 2
|
||||
expect(result.intelligence, equals(9)); // 10 - 1
|
||||
expect(result.con, equals(10)); // 변화 없음
|
||||
});
|
||||
|
||||
test('클래스 스탯 보정 적용', () {
|
||||
final baseStats = const Stats(
|
||||
str: 10,
|
||||
con: 10,
|
||||
dex: 10,
|
||||
intelligence: 10,
|
||||
wis: 10,
|
||||
cha: 10,
|
||||
hpMax: 100,
|
||||
mpMax: 50,
|
||||
);
|
||||
|
||||
// 보정 없는 종족
|
||||
const race = RaceTraits(
|
||||
raceId: 'test_race',
|
||||
name: 'Test Race',
|
||||
statModifiers: {},
|
||||
);
|
||||
|
||||
// +3 CON, +1 DEX 클래스
|
||||
const klass = ClassTraits(
|
||||
classId: 'test_class',
|
||||
name: 'Test Class',
|
||||
statModifiers: {
|
||||
StatType.con: 3,
|
||||
StatType.dex: 1,
|
||||
},
|
||||
);
|
||||
|
||||
final result = calculator.applyModifiers(
|
||||
baseStats: baseStats,
|
||||
race: race,
|
||||
klass: klass,
|
||||
);
|
||||
|
||||
expect(result.con, equals(13)); // 10 + 3
|
||||
expect(result.dex, equals(11)); // 10 + 1
|
||||
});
|
||||
|
||||
test('HP 보너스 패시브 적용 (종족)', () {
|
||||
final baseStats = const Stats(
|
||||
str: 10,
|
||||
con: 10,
|
||||
dex: 10,
|
||||
intelligence: 10,
|
||||
wis: 10,
|
||||
cha: 10,
|
||||
hpMax: 100,
|
||||
mpMax: 50,
|
||||
);
|
||||
|
||||
// +20% HP 종족
|
||||
final race = RaceTraits(
|
||||
raceId: 'test_race',
|
||||
name: 'Test Race',
|
||||
statModifiers: const {},
|
||||
passives: const [
|
||||
PassiveAbility(type: PassiveType.hpBonus, value: 0.2),
|
||||
],
|
||||
);
|
||||
|
||||
const klass = ClassTraits(
|
||||
classId: 'test_class',
|
||||
name: 'Test Class',
|
||||
statModifiers: {},
|
||||
);
|
||||
|
||||
final result = calculator.applyModifiers(
|
||||
baseStats: baseStats,
|
||||
race: race,
|
||||
klass: klass,
|
||||
);
|
||||
|
||||
expect(result.hpMax, equals(120)); // 100 * 1.2
|
||||
});
|
||||
|
||||
test('MP 보너스 패시브 적용 (종족)', () {
|
||||
final baseStats = const Stats(
|
||||
str: 10,
|
||||
con: 10,
|
||||
dex: 10,
|
||||
intelligence: 10,
|
||||
wis: 10,
|
||||
cha: 10,
|
||||
hpMax: 100,
|
||||
mpMax: 50,
|
||||
);
|
||||
|
||||
// +20% MP 종족
|
||||
final race = RaceTraits(
|
||||
raceId: 'test_race',
|
||||
name: 'Test Race',
|
||||
statModifiers: const {},
|
||||
passives: const [
|
||||
PassiveAbility(type: PassiveType.mpBonus, value: 0.2),
|
||||
],
|
||||
);
|
||||
|
||||
const klass = ClassTraits(
|
||||
classId: 'test_class',
|
||||
name: 'Test Class',
|
||||
statModifiers: {},
|
||||
);
|
||||
|
||||
final result = calculator.applyModifiers(
|
||||
baseStats: baseStats,
|
||||
race: race,
|
||||
klass: klass,
|
||||
);
|
||||
|
||||
expect(result.mpMax, equals(60)); // 50 * 1.2
|
||||
});
|
||||
});
|
||||
|
||||
group('applyPassives', () {
|
||||
test('크리티컬 보너스 패시브 적용', () {
|
||||
final combatStats = CombatStats.empty().copyWith(
|
||||
atk: 50,
|
||||
def: 20,
|
||||
criRate: 0.1,
|
||||
);
|
||||
|
||||
// +5% 크리티컬 종족
|
||||
final race = RaceTraits(
|
||||
raceId: 'test_race',
|
||||
name: 'Test Race',
|
||||
statModifiers: const {},
|
||||
passives: const [
|
||||
PassiveAbility(type: PassiveType.criticalBonus, value: 0.05),
|
||||
],
|
||||
);
|
||||
|
||||
const klass = ClassTraits(
|
||||
classId: 'test_class',
|
||||
name: 'Test Class',
|
||||
statModifiers: {},
|
||||
);
|
||||
|
||||
final result = calculator.applyPassives(
|
||||
combatStats: combatStats,
|
||||
race: race,
|
||||
klass: klass,
|
||||
);
|
||||
|
||||
expect(result.criRate, closeTo(0.15, 0.001)); // 0.1 + 0.05
|
||||
});
|
||||
|
||||
test('크리티컬 확률 캡 (0.8) 적용', () {
|
||||
final combatStats = CombatStats.empty().copyWith(
|
||||
atk: 50,
|
||||
def: 20,
|
||||
criRate: 0.7,
|
||||
);
|
||||
|
||||
// +20% 크리티컬 종족 (합계 0.9 → 캡 0.8)
|
||||
final race = RaceTraits(
|
||||
raceId: 'test_race',
|
||||
name: 'Test Race',
|
||||
statModifiers: const {},
|
||||
passives: const [
|
||||
PassiveAbility(type: PassiveType.criticalBonus, value: 0.2),
|
||||
],
|
||||
);
|
||||
|
||||
const klass = ClassTraits(
|
||||
classId: 'test_class',
|
||||
name: 'Test Class',
|
||||
statModifiers: {},
|
||||
);
|
||||
|
||||
final result = calculator.applyPassives(
|
||||
combatStats: combatStats,
|
||||
race: race,
|
||||
klass: klass,
|
||||
);
|
||||
|
||||
expect(result.criRate, equals(0.8)); // 캡 적용
|
||||
});
|
||||
|
||||
test('회피율 캡 (0.6) 적용', () {
|
||||
final combatStats = CombatStats.empty().copyWith(
|
||||
atk: 50,
|
||||
def: 20,
|
||||
evasion: 0.5,
|
||||
);
|
||||
|
||||
const race = RaceTraits(
|
||||
raceId: 'test_race',
|
||||
name: 'Test Race',
|
||||
statModifiers: {},
|
||||
);
|
||||
|
||||
// +15% 회피 클래스 (합계 0.65 → 캡 0.6)
|
||||
const klass = ClassTraits(
|
||||
classId: 'test_class',
|
||||
name: 'Test Class',
|
||||
statModifiers: {},
|
||||
passives: [
|
||||
ClassPassive(
|
||||
type: ClassPassiveType.evasionBonus,
|
||||
value: 0.15,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final result = calculator.applyPassives(
|
||||
combatStats: combatStats,
|
||||
race: race,
|
||||
klass: klass,
|
||||
);
|
||||
|
||||
expect(result.evasion, equals(0.6)); // 캡 적용
|
||||
});
|
||||
|
||||
test('물리 공격력 보너스 적용', () {
|
||||
final combatStats = CombatStats.empty().copyWith(
|
||||
atk: 100,
|
||||
def: 20,
|
||||
);
|
||||
|
||||
const race = RaceTraits(
|
||||
raceId: 'test_race',
|
||||
name: 'Test Race',
|
||||
statModifiers: {},
|
||||
);
|
||||
|
||||
// +20% 물리 공격 클래스
|
||||
const klass = ClassTraits(
|
||||
classId: 'test_class',
|
||||
name: 'Test Class',
|
||||
statModifiers: {},
|
||||
passives: [
|
||||
ClassPassive(
|
||||
type: ClassPassiveType.physicalDamageBonus,
|
||||
value: 0.2,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final result = calculator.applyPassives(
|
||||
combatStats: combatStats,
|
||||
race: race,
|
||||
klass: klass,
|
||||
);
|
||||
|
||||
expect(result.atk, equals(120)); // 100 * 1.2
|
||||
});
|
||||
});
|
||||
|
||||
group('calculateExpMultiplier', () {
|
||||
test('경험치 배율 반환', () {
|
||||
final race = RaceTraits(
|
||||
raceId: 'test_race',
|
||||
name: 'Test Race',
|
||||
statModifiers: const {},
|
||||
expMultiplier: 1.10, // +10%
|
||||
);
|
||||
|
||||
final result = calculator.calculateExpMultiplier(race);
|
||||
|
||||
expect(result, equals(1.10));
|
||||
});
|
||||
});
|
||||
|
||||
group('calculatePostCombatHeal', () {
|
||||
test('전투 후 회복량 계산', () {
|
||||
// +5% 전투 후 회복 클래스
|
||||
const klass = ClassTraits(
|
||||
classId: 'test_class',
|
||||
name: 'Test Class',
|
||||
statModifiers: {},
|
||||
passives: [
|
||||
ClassPassive(
|
||||
type: ClassPassiveType.postCombatHeal,
|
||||
value: 0.05,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final result = calculator.calculatePostCombatHeal(
|
||||
klass: klass,
|
||||
maxHp: 200,
|
||||
);
|
||||
|
||||
expect(result, equals(10)); // 200 * 0.05
|
||||
});
|
||||
|
||||
test('패시브 없으면 0 반환', () {
|
||||
const klass = ClassTraits(
|
||||
classId: 'test_class',
|
||||
name: 'Test Class',
|
||||
statModifiers: {},
|
||||
);
|
||||
|
||||
final result = calculator.calculatePostCombatHeal(
|
||||
klass: klass,
|
||||
maxHp: 200,
|
||||
);
|
||||
|
||||
expect(result, equals(0));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,17 +1,12 @@
|
||||
import 'package:asciineverdie/l10n/app_localizations.dart';
|
||||
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
|
||||
import 'package:asciineverdie/src/core/engine/progress_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/reward_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/pq_config.dart';
|
||||
import 'package:asciineverdie/src/core/storage/save_manager.dart';
|
||||
import 'package:asciineverdie/src/core/storage/save_repository.dart';
|
||||
import 'package:asciineverdie/src/core/storage/save_service.dart';
|
||||
import 'package:asciineverdie/src/features/game/game_play_screen.dart';
|
||||
import 'package:asciineverdie/src/features/game/game_session_controller.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../helpers/mock_factories.dart';
|
||||
|
||||
/// 테스트용 MaterialApp 래퍼 (localization 포함)
|
||||
Widget _buildTestApp(Widget child) {
|
||||
return MaterialApp(
|
||||
@@ -21,33 +16,6 @@ Widget _buildTestApp(Widget child) {
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeSaveManager implements SaveManager {
|
||||
@override
|
||||
Future<SaveOutcome> saveState(
|
||||
GameState state, {
|
||||
String? fileName,
|
||||
bool cheatsEnabled = false,
|
||||
}) async {
|
||||
return const SaveOutcome.success();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<(SaveOutcome, GameState?, bool)> loadState({String? fileName}) async {
|
||||
return (const SaveOutcome.success(), null, false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<SaveFileInfo>> listSaves() async => [];
|
||||
|
||||
@override
|
||||
Future<SaveOutcome> deleteSave({String? fileName}) async {
|
||||
return const SaveOutcome.success();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> saveExists({String? fileName}) async => false;
|
||||
}
|
||||
|
||||
GameState _createTestState() {
|
||||
return GameState.withSeed(
|
||||
seed: 42,
|
||||
@@ -83,15 +51,9 @@ GameState _createTestState() {
|
||||
}
|
||||
|
||||
GameSessionController _createController() {
|
||||
const config = PqConfig();
|
||||
final mutations = GameMutations(config);
|
||||
return GameSessionController(
|
||||
progressService: ProgressService(
|
||||
config: config,
|
||||
mutations: mutations,
|
||||
rewards: RewardService(mutations, config),
|
||||
),
|
||||
saveManager: _FakeSaveManager(),
|
||||
progressService: MockFactories.createProgressService(),
|
||||
saveManager: FakeSaveManager(),
|
||||
tickInterval: const Duration(seconds: 10), // 느린 틱
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,58 +1,12 @@
|
||||
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
|
||||
import 'package:asciineverdie/src/core/engine/progress_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/reward_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/pq_config.dart';
|
||||
import 'package:asciineverdie/src/core/storage/save_manager.dart';
|
||||
import 'package:asciineverdie/src/core/storage/save_repository.dart';
|
||||
import 'package:asciineverdie/src/core/storage/save_service.dart';
|
||||
import 'package:asciineverdie/src/features/game/game_session_controller.dart';
|
||||
import 'package:fake_async/fake_async.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
class FakeSaveManager implements SaveManager {
|
||||
final List<GameState> savedStates = [];
|
||||
(SaveOutcome, GameState?, bool) Function(String?)? onLoad;
|
||||
SaveOutcome saveOutcome = const SaveOutcome.success();
|
||||
|
||||
@override
|
||||
Future<SaveOutcome> saveState(
|
||||
GameState state, {
|
||||
String? fileName,
|
||||
bool cheatsEnabled = false,
|
||||
}) async {
|
||||
savedStates.add(state);
|
||||
return saveOutcome;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<(SaveOutcome, GameState?, bool)> loadState({String? fileName}) async {
|
||||
if (onLoad != null) {
|
||||
return onLoad!(fileName);
|
||||
}
|
||||
return (const SaveOutcome.success(), null, false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<SaveFileInfo>> listSaves() async => [];
|
||||
|
||||
@override
|
||||
Future<SaveOutcome> deleteSave({String? fileName}) async {
|
||||
return const SaveOutcome.success();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> saveExists({String? fileName}) async => false;
|
||||
}
|
||||
import '../helpers/mock_factories.dart';
|
||||
|
||||
void main() {
|
||||
const config = PqConfig();
|
||||
final mutations = GameMutations(config);
|
||||
final progressService = ProgressService(
|
||||
config: config,
|
||||
mutations: mutations,
|
||||
rewards: RewardService(mutations, config),
|
||||
);
|
||||
final progressService = MockFactories.createProgressService();
|
||||
|
||||
GameSessionController buildController(
|
||||
FakeAsync async,
|
||||
|
||||
195
test/helpers/mock_factories.dart
Normal file
195
test/helpers/mock_factories.dart
Normal file
@@ -0,0 +1,195 @@
|
||||
import 'package:asciineverdie/src/core/engine/game_mutations.dart';
|
||||
import 'package:asciineverdie/src/core/engine/progress_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/reward_service.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/pq_config.dart';
|
||||
import 'package:asciineverdie/src/core/storage/save_manager.dart';
|
||||
import 'package:asciineverdie/src/core/storage/save_repository.dart';
|
||||
import 'package:asciineverdie/src/core/storage/save_service.dart';
|
||||
import 'package:asciineverdie/src/core/util/balance_constants.dart';
|
||||
|
||||
export 'package:asciineverdie/src/core/storage/save_repository.dart'
|
||||
show SaveOutcome;
|
||||
export 'package:asciineverdie/src/core/storage/save_service.dart'
|
||||
show SaveFileInfo;
|
||||
|
||||
/// 테스트용 Fake SaveManager
|
||||
///
|
||||
/// 여러 테스트 파일에서 중복되던 Mock을 통합
|
||||
class FakeSaveManager implements SaveManager {
|
||||
final List<GameState> savedStates = [];
|
||||
|
||||
/// 커스텀 로드 동작 설정
|
||||
(SaveOutcome, GameState?, bool) Function(String?)? onLoad;
|
||||
|
||||
/// 저장 결과 설정 (기본: 성공)
|
||||
SaveOutcome saveOutcome = const SaveOutcome.success();
|
||||
|
||||
@override
|
||||
Future<SaveOutcome> saveState(
|
||||
GameState state, {
|
||||
String? fileName,
|
||||
bool cheatsEnabled = false,
|
||||
}) async {
|
||||
savedStates.add(state);
|
||||
return saveOutcome;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<(SaveOutcome, GameState?, bool)> loadState({String? fileName}) async {
|
||||
if (onLoad != null) {
|
||||
return onLoad!(fileName);
|
||||
}
|
||||
return (const SaveOutcome.success(), null, false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<SaveFileInfo>> listSaves() async => [];
|
||||
|
||||
@override
|
||||
Future<SaveOutcome> deleteSave({String? fileName}) async {
|
||||
return const SaveOutcome.success();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> saveExists({String? fileName}) async => false;
|
||||
}
|
||||
|
||||
/// 테스트용 팩토리 클래스
|
||||
///
|
||||
/// 테스트에서 자주 사용되는 객체 생성을 중앙화
|
||||
class MockFactories {
|
||||
MockFactories._();
|
||||
|
||||
/// 기본 PqConfig 생성
|
||||
static const PqConfig config = PqConfig();
|
||||
|
||||
/// GameMutations 생성
|
||||
static GameMutations createMutations([PqConfig? cfg]) {
|
||||
return GameMutations(cfg ?? config);
|
||||
}
|
||||
|
||||
/// ProgressService 생성
|
||||
static ProgressService createProgressService([PqConfig? cfg]) {
|
||||
final c = cfg ?? config;
|
||||
final mutations = GameMutations(c);
|
||||
return ProgressService(
|
||||
config: c,
|
||||
mutations: mutations,
|
||||
rewards: RewardService(mutations, c),
|
||||
);
|
||||
}
|
||||
|
||||
/// 기본 GameState 생성
|
||||
///
|
||||
/// [seed]: 결정론적 랜덤 시드
|
||||
/// [level]: 캐릭터 레벨
|
||||
static GameState createGameState({
|
||||
int seed = 42,
|
||||
int level = 1,
|
||||
}) {
|
||||
return GameState.withSeed(seed: seed);
|
||||
}
|
||||
|
||||
/// 테스트용 CombatState 생성
|
||||
static CombatState createCombat({
|
||||
int playerHpMax = 100,
|
||||
int playerHpCurrent = 100,
|
||||
int monsterHpMax = 50,
|
||||
int monsterHpCurrent = 50,
|
||||
int monsterLevel = 1,
|
||||
String monsterName = 'Test Monster',
|
||||
}) {
|
||||
final playerStats = CombatStats.empty().copyWith(
|
||||
hpMax: playerHpMax,
|
||||
hpCurrent: playerHpCurrent,
|
||||
mpMax: 50,
|
||||
mpCurrent: 50,
|
||||
atk: 20,
|
||||
def: 10,
|
||||
attackDelayMs: 1000,
|
||||
);
|
||||
|
||||
final monsterStats = MonsterCombatStats(
|
||||
name: monsterName,
|
||||
level: monsterLevel,
|
||||
atk: 10,
|
||||
def: 5,
|
||||
hpMax: monsterHpMax,
|
||||
hpCurrent: monsterHpCurrent,
|
||||
criRate: 0.05,
|
||||
criDamage: 1.5,
|
||||
evasion: 0.0,
|
||||
accuracy: 0.8,
|
||||
attackDelayMs: 1000,
|
||||
expReward: 100,
|
||||
);
|
||||
|
||||
return CombatState(
|
||||
playerStats: playerStats,
|
||||
monsterStats: monsterStats,
|
||||
playerAttackAccumulatorMs: 0,
|
||||
monsterAttackAccumulatorMs: 0,
|
||||
totalDamageDealt: 0,
|
||||
totalDamageTaken: 0,
|
||||
turnsElapsed: 0,
|
||||
isActive: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// 테스트용 MonsterCombatStats 생성
|
||||
static MonsterCombatStats createMonsterStats({
|
||||
String name = 'Test Monster',
|
||||
int level = 1,
|
||||
int atk = 10,
|
||||
int def = 5,
|
||||
int hpMax = 100,
|
||||
int? hpCurrent,
|
||||
double criRate = 0.05,
|
||||
double criDamage = 1.5,
|
||||
double evasion = 0.0,
|
||||
double accuracy = 0.8,
|
||||
int attackDelayMs = 1000,
|
||||
int expReward = 100,
|
||||
}) {
|
||||
return MonsterCombatStats(
|
||||
name: name,
|
||||
level: level,
|
||||
atk: atk,
|
||||
def: def,
|
||||
hpMax: hpMax,
|
||||
hpCurrent: hpCurrent ?? hpMax,
|
||||
criRate: criRate,
|
||||
criDamage: criDamage,
|
||||
evasion: evasion,
|
||||
accuracy: accuracy,
|
||||
attackDelayMs: attackDelayMs,
|
||||
expReward: expReward,
|
||||
);
|
||||
}
|
||||
|
||||
/// 밸런스 상수 기반 몬스터 스탯 생성
|
||||
static MonsterCombatStats createBalancedMonsterStats({
|
||||
required int level,
|
||||
MonsterType type = MonsterType.normal,
|
||||
}) {
|
||||
final base = MonsterBaseStats.generate(level, type);
|
||||
return MonsterCombatStats(
|
||||
name: 'Balanced Monster Lv$level',
|
||||
level: level,
|
||||
atk: base.atk,
|
||||
def: base.def,
|
||||
hpMax: base.hp,
|
||||
hpCurrent: base.hp,
|
||||
criRate: 0.05,
|
||||
criDamage: 1.5,
|
||||
evasion: 0.0,
|
||||
accuracy: 0.8,
|
||||
attackDelayMs: 1000,
|
||||
expReward: base.exp,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user