feat(core): 엔진, 모델, 애니메이션 개선

- ProgressService 로직 개선
- CombatCalculator 업데이트
- GameState, MonsterCombatStats 확장
- CanvasBattleComposer 개선
This commit is contained in:
JiWoong Sul
2026-01-14 00:17:59 +09:00
parent 4e9265ab87
commit f89017e5ba
5 changed files with 101 additions and 36 deletions

View File

@@ -68,15 +68,17 @@ class CanvasBattleComposer {
bool isDot = false,
bool isBlock = false,
bool isParry = false,
bool hideMonster = false,
}) {
final layers = <AsciiLayer>[
_createBackgroundLayer(environment, globalTick),
_createCharacterLayer(phase, subFrame, attacker),
// PvP 모드: 몬스터 대신 상대 캐릭터(좌우 반전) 표시
if (isPvP)
_createOpponentCharacterLayer(phase, subFrame, attacker)
else
_createMonsterLayer(phase, subFrame, attacker),
// hideMonster: 몬스터 사망 애니메이션 중에는 렌더링 안함
if (!hideMonster)
isPvP
? _createOpponentCharacterLayer(phase, subFrame, attacker)
: _createMonsterLayer(phase, subFrame, attacker),
];
// 이펙트 레이어 (준비/공격/히트 페이즈에서, 공격자 있을 때)
@@ -1460,3 +1462,13 @@ const _parryTextFrames = <List<String>>[
[r'*PARRY!*', r'========'],
[r'=PARRY!=', r'********'],
];
/// 몬스터 Idle 프레임 가져오기 (외부에서 접근 가능)
///
/// 몬스터 사망 애니메이션에서 분해할 프레임을 가져올 때 사용
List<List<String>> getMonsterIdleFrames(
MonsterCategory category,
MonsterSize size,
) {
return _getMonsterIdleFrames(category, size);
}

View File

@@ -98,9 +98,9 @@ class CombatCalculator {
final isParried = parryRoll < defenderParryRate;
// 3. 기본 데미지 계산 (0.8 ~ 1.2 변동)
// DEF 감산 비율: 0.5 (방어력 효과 상향, 몬스터 ATK 하향과 연동)
// DEF 감산 비율: 0.4 (전체 데미지 상승 조정)
final damageVariation = 0.8 + rng.nextDouble() * 0.4;
var baseDamage = (attackerAtk * damageVariation - defenderDef * 0.5);
var baseDamage = (attackerAtk * damageVariation - defenderDef * 0.4);
// 4. 크리티컬 판정
final criRoll = rng.nextDouble();
@@ -207,7 +207,7 @@ class CombatCalculator {
required MonsterCombatStats monster,
}) {
// 플레이어 DPS (초당 데미지)
final playerDamagePerHit = math.max(1, player.atk - monster.def * 0.5);
final playerDamagePerHit = math.max(1, player.atk - monster.def * 0.4);
final playerHitsPerSecond = 1000 / player.attackDelayMs;
final playerDps =
playerDamagePerHit * playerHitsPerSecond * player.accuracy;
@@ -227,14 +227,14 @@ class CombatCalculator {
required MonsterCombatStats monster,
}) {
// 플레이어 예상 생존 시간
final monsterDamagePerHit = math.max(1, monster.atk - player.def * 0.5);
final monsterDamagePerHit = math.max(1, monster.atk - player.def * 0.4);
final monsterHitsPerSecond = 1000 / monster.attackDelayMs;
final monsterDps =
monsterDamagePerHit * monsterHitsPerSecond * monster.accuracy;
final playerSurvivalTime = player.hpCurrent / monsterDps;
// 몬스터 예상 생존 시간
final playerDamagePerHit = math.max(1, player.atk - monster.def * 0.5);
final playerDamagePerHit = math.max(1, player.atk - monster.def * 0.4);
final playerHitsPerSecond = 1000 / player.attackDelayMs;
final playerDps =
playerDamagePerHit * playerHitsPerSecond * player.accuracy;

View File

@@ -588,9 +588,19 @@ class ProgressService {
// 4. 최종 보스 전투 체크
// finalBossState == fighting이면 Glitch God 스폰
// 단, 레벨링 모드 중이면 일반 몬스터로 레벨업 후 재도전
if (state.progress.finalBossState == FinalBossState.fighting) {
if (state.progress.isInBossLevelingMode) {
// 레벨링 모드: 일반 몬스터 전투로 대체 (아래 MonsterTask로 진행)
} else {
// 레벨링 모드 종료 또는 첫 도전: 보스전 시작
// 레벨링 모드가 끝났으면 타이머 초기화
if (state.progress.bossLevelingEndTime != null) {
progress = progress.copyWith(clearBossLevelingEndTime: true);
}
return _startFinalBossFight(state, progress, queue);
}
}
// 5. MonsterTask 실행 (원본 678-684줄)
final level = state.traits.level;
@@ -634,6 +644,7 @@ class ProgressService {
name: monsterResult.displayName,
level: effectiveMonsterLevel,
speedType: MonsterCombatStats.inferSpeedType(monsterResult.baseName),
plotStageCount: state.progress.plotStageCount,
);
// 전투 상태 초기화
@@ -1667,6 +1678,7 @@ class ProgressService {
/// 플레이어 사망 처리 (Phase 4)
///
/// 모든 장비 상실 및 사망 정보 기록
/// 보스전 사망 시: 장비 보호 + 5분 레벨링 모드 진입
GameState _processPlayerDeath(
GameState state, {
required String killerName,
@@ -1676,8 +1688,16 @@ class ProgressService {
final lastCombatEvents =
state.progress.currentCombat?.recentEvents ?? const [];
// 보스전 사망 여부 확인 (최종 보스 fighting 상태)
final isBossDeath =
state.progress.finalBossState == FinalBossState.fighting;
// 보스전 사망이 아닐 경우에만 장비 손실
var newEquipment = state.equipment;
var lostCount = 0;
if (!isBossDeath) {
// 무기(슬롯 0)를 제외한 장착된 장비 중 1개를 제물로 삭제
// 장착된 비무기 슬롯 인덱스 수집 (슬롯 1~10 중 장비가 있는 것)
final equippedNonWeaponSlots = <int>[];
for (var i = 1; i < Equipment.slotCount; i++) {
if (state.equipment.getItemByIndex(i).isNotEmpty) {
@@ -1685,14 +1705,11 @@ class ProgressService {
}
}
// 제물로 바칠 장비 선택 및 삭제
var newEquipment = state.equipment;
final lostCount = equippedNonWeaponSlots.isNotEmpty ? 1 : 0;
if (equippedNonWeaponSlots.isNotEmpty) {
lostCount = 1;
// 랜덤하게 1개 슬롯 선택
final sacrificeIndex =
equippedNonWeaponSlots[state.rng.nextInt(equippedNonWeaponSlots.length)];
final sacrificeIndex = equippedNonWeaponSlots[
state.rng.nextInt(equippedNonWeaponSlots.length)];
final slot = EquipmentSlot.values[sacrificeIndex];
// 해당 슬롯을 빈 장비로 교체
@@ -1701,6 +1718,7 @@ class ProgressService {
EquipmentItem.empty(slot),
);
}
}
// 사망 정보 생성 (전투 로그 포함)
final deathInfo = DeathInfo(
@@ -1713,12 +1731,16 @@ class ProgressService {
lastCombatEvents: lastCombatEvents,
);
// 보스전 사망 시 5분 레벨링 모드 진입
final bossLevelingEndTime = isBossDeath
? DateTime.now().millisecondsSinceEpoch + (5 * 60 * 1000) // 5분
: null;
// 전투 상태 초기화 및 사망 횟수 증가
// pendingActCompletion 플래그는 유지 (Boss 리트라이를 위해)
final progress = state.progress.copyWith(
currentCombat: null,
deathCount: state.progress.deathCount + 1,
// pendingActCompletion은 copyWith에서 명시하지 않으면 기존 값 유지
bossLevelingEndTime: bossLevelingEndTime,
);
return state.copyWith(

View File

@@ -475,7 +475,8 @@ class Inventory {
final int gold;
final List<InventoryEntry> items;
factory Inventory.empty() => const Inventory(gold: 0, items: []);
/// 초기 골드 1000 지급 (캐릭터 생성 시)
factory Inventory.empty() => const Inventory(gold: 1000, items: []);
Inventory copyWith({int? gold, List<InventoryEntry>? items}) {
return Inventory(gold: gold ?? this.gold, items: items ?? this.items);
@@ -800,6 +801,7 @@ class ProgressState {
this.deathCount = 0,
this.finalBossState = FinalBossState.notSpawned,
this.pendingActCompletion = false,
this.bossLevelingEndTime,
});
final ProgressBarState task;
@@ -835,6 +837,10 @@ class ProgressState {
/// Act Boss 처치 대기 중 여부 (처치 후 시네마틱 재생 트리거)
final bool pendingActCompletion;
/// 보스 사망 후 레벨링 모드 종료 시간 (milliseconds since epoch)
/// null이면 레벨링 모드 아님, 값이 있으면 해당 시간까지 레벨링
final int? bossLevelingEndTime;
factory ProgressState.empty() => ProgressState(
task: ProgressBarState.empty(),
quest: ProgressBarState.empty(),
@@ -867,6 +873,8 @@ class ProgressState {
int? deathCount,
FinalBossState? finalBossState,
bool? pendingActCompletion,
int? bossLevelingEndTime,
bool clearBossLevelingEndTime = false,
}) {
return ProgressState(
task: task ?? this.task,
@@ -885,8 +893,17 @@ class ProgressState {
deathCount: deathCount ?? this.deathCount,
finalBossState: finalBossState ?? this.finalBossState,
pendingActCompletion: pendingActCompletion ?? this.pendingActCompletion,
bossLevelingEndTime: clearBossLevelingEndTime
? null
: (bossLevelingEndTime ?? this.bossLevelingEndTime),
);
}
/// 현재 레벨링 모드인지 확인
bool get isInBossLevelingMode {
if (bossLevelingEndTime == null) return false;
return DateTime.now().millisecondsSinceEpoch < bossLevelingEndTime!;
}
}
class QueueEntry {

View File

@@ -131,15 +131,29 @@ class MonsterCombatStats {
/// [level] 몬스터 레벨 (원본 데이터 기준)
/// [speedType] 공격 속도 타입 (기본: normal)
/// [monsterType] 몬스터 타입 (기본: normal)
/// [plotStageCount] 현재 Act (1=Prologue, 2=Act I, 3=Act II, ...)
factory MonsterCombatStats.fromLevel({
required String name,
required int level,
MonsterSpeedType speedType = MonsterSpeedType.normal,
MonsterType monsterType = MonsterType.normal,
int plotStageCount = 1,
}) {
// balance_constants.dart의 MonsterBaseStats 사용
final baseStats = MonsterBaseStats.generate(level, monsterType);
// Act II 이후 (plotStageCount >= 3) HP 10% 상승
final hpMultiplier = plotStageCount >= 3 ? 1.1 : 1.0;
final adjustedHp = (baseStats.hp * hpMultiplier).round();
// Act별 경험치 배율 (후반부 레벨업 가속)
final expMultiplier = switch (plotStageCount) {
5 => 1.3, // Act IV: 30% 보너스
6 => 1.8, // Act V: 80% 보너스 (보스전 대비)
_ => 1.0, // 기본
};
final adjustedExp = (baseStats.exp * expMultiplier).round();
// 크리티컬 확률: 레벨에 따라 천천히 증가 (0.02 ~ 0.3)
final criRate = (0.02 + level * 0.003).clamp(0.02, 0.3);
@@ -164,14 +178,14 @@ class MonsterCombatStats {
level: level,
atk: baseStats.atk,
def: baseStats.def,
hpMax: baseStats.hp,
hpCurrent: baseStats.hp,
hpMax: adjustedHp,
hpCurrent: adjustedHp,
criRate: criRate,
criDamage: criDamage,
evasion: evasion,
accuracy: accuracy,
attackDelayMs: attackDelayMs,
expReward: baseStats.exp,
expReward: adjustedExp,
);
}