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 isDot = false,
bool isBlock = false, bool isBlock = false,
bool isParry = false, bool isParry = false,
bool hideMonster = false,
}) { }) {
final layers = <AsciiLayer>[ final layers = <AsciiLayer>[
_createBackgroundLayer(environment, globalTick), _createBackgroundLayer(environment, globalTick),
_createCharacterLayer(phase, subFrame, attacker), _createCharacterLayer(phase, subFrame, attacker),
// PvP 모드: 몬스터 대신 상대 캐릭터(좌우 반전) 표시 // PvP 모드: 몬스터 대신 상대 캐릭터(좌우 반전) 표시
if (isPvP) // hideMonster: 몬스터 사망 애니메이션 중에는 렌더링 안함
_createOpponentCharacterLayer(phase, subFrame, attacker) if (!hideMonster)
else isPvP
_createMonsterLayer(phase, subFrame, attacker), ? _createOpponentCharacterLayer(phase, subFrame, attacker)
: _createMonsterLayer(phase, subFrame, attacker),
]; ];
// 이펙트 레이어 (준비/공격/히트 페이즈에서, 공격자 있을 때) // 이펙트 레이어 (준비/공격/히트 페이즈에서, 공격자 있을 때)
@@ -1460,3 +1462,13 @@ const _parryTextFrames = <List<String>>[
[r'*PARRY!*', r'========'], [r'*PARRY!*', r'========'],
[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; final isParried = parryRoll < defenderParryRate;
// 3. 기본 데미지 계산 (0.8 ~ 1.2 변동) // 3. 기본 데미지 계산 (0.8 ~ 1.2 변동)
// DEF 감산 비율: 0.5 (방어력 효과 상향, 몬스터 ATK 하향과 연동) // DEF 감산 비율: 0.4 (전체 데미지 상승 조정)
final damageVariation = 0.8 + rng.nextDouble() * 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. 크리티컬 판정 // 4. 크리티컬 판정
final criRoll = rng.nextDouble(); final criRoll = rng.nextDouble();
@@ -207,7 +207,7 @@ class CombatCalculator {
required MonsterCombatStats monster, required MonsterCombatStats monster,
}) { }) {
// 플레이어 DPS (초당 데미지) // 플레이어 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 playerHitsPerSecond = 1000 / player.attackDelayMs;
final playerDps = final playerDps =
playerDamagePerHit * playerHitsPerSecond * player.accuracy; playerDamagePerHit * playerHitsPerSecond * player.accuracy;
@@ -227,14 +227,14 @@ class CombatCalculator {
required MonsterCombatStats monster, 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 monsterHitsPerSecond = 1000 / monster.attackDelayMs;
final monsterDps = final monsterDps =
monsterDamagePerHit * monsterHitsPerSecond * monster.accuracy; monsterDamagePerHit * monsterHitsPerSecond * monster.accuracy;
final playerSurvivalTime = player.hpCurrent / monsterDps; 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 playerHitsPerSecond = 1000 / player.attackDelayMs;
final playerDps = final playerDps =
playerDamagePerHit * playerHitsPerSecond * player.accuracy; playerDamagePerHit * playerHitsPerSecond * player.accuracy;

View File

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

View File

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

View File

@@ -131,15 +131,29 @@ class MonsterCombatStats {
/// [level] 몬스터 레벨 (원본 데이터 기준) /// [level] 몬스터 레벨 (원본 데이터 기준)
/// [speedType] 공격 속도 타입 (기본: normal) /// [speedType] 공격 속도 타입 (기본: normal)
/// [monsterType] 몬스터 타입 (기본: normal) /// [monsterType] 몬스터 타입 (기본: normal)
/// [plotStageCount] 현재 Act (1=Prologue, 2=Act I, 3=Act II, ...)
factory MonsterCombatStats.fromLevel({ factory MonsterCombatStats.fromLevel({
required String name, required String name,
required int level, required int level,
MonsterSpeedType speedType = MonsterSpeedType.normal, MonsterSpeedType speedType = MonsterSpeedType.normal,
MonsterType monsterType = MonsterType.normal, MonsterType monsterType = MonsterType.normal,
int plotStageCount = 1,
}) { }) {
// balance_constants.dart의 MonsterBaseStats 사용 // balance_constants.dart의 MonsterBaseStats 사용
final baseStats = MonsterBaseStats.generate(level, monsterType); 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) // 크리티컬 확률: 레벨에 따라 천천히 증가 (0.02 ~ 0.3)
final criRate = (0.02 + level * 0.003).clamp(0.02, 0.3); final criRate = (0.02 + level * 0.003).clamp(0.02, 0.3);
@@ -164,14 +178,14 @@ class MonsterCombatStats {
level: level, level: level,
atk: baseStats.atk, atk: baseStats.atk,
def: baseStats.def, def: baseStats.def,
hpMax: baseStats.hp, hpMax: adjustedHp,
hpCurrent: baseStats.hp, hpCurrent: adjustedHp,
criRate: criRate, criRate: criRate,
criDamage: criDamage, criDamage: criDamage,
evasion: evasion, evasion: evasion,
accuracy: accuracy, accuracy: accuracy,
attackDelayMs: attackDelayMs, attackDelayMs: attackDelayMs,
expReward: baseStats.exp, expReward: adjustedExp,
); );
} }