feat(core): 엔진, 모델, 애니메이션 개선
- ProgressService 로직 개선 - CombatCalculator 업데이트 - GameState, MonsterCombatStats 확장 - CanvasBattleComposer 개선
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -588,8 +588,18 @@ class ProgressService {
|
|||||||
|
|
||||||
// 4. 최종 보스 전투 체크
|
// 4. 최종 보스 전투 체크
|
||||||
// finalBossState == fighting이면 Glitch God 스폰
|
// finalBossState == fighting이면 Glitch God 스폰
|
||||||
|
// 단, 레벨링 모드 중이면 일반 몬스터로 레벨업 후 재도전
|
||||||
if (state.progress.finalBossState == FinalBossState.fighting) {
|
if (state.progress.finalBossState == FinalBossState.fighting) {
|
||||||
return _startFinalBossFight(state, progress, queue);
|
if (state.progress.isInBossLevelingMode) {
|
||||||
|
// 레벨링 모드: 일반 몬스터 전투로 대체 (아래 MonsterTask로 진행)
|
||||||
|
} else {
|
||||||
|
// 레벨링 모드 종료 또는 첫 도전: 보스전 시작
|
||||||
|
// 레벨링 모드가 끝났으면 타이머 초기화
|
||||||
|
if (state.progress.bossLevelingEndTime != null) {
|
||||||
|
progress = progress.copyWith(clearBossLevelingEndTime: true);
|
||||||
|
}
|
||||||
|
return _startFinalBossFight(state, progress, queue);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. MonsterTask 실행 (원본 678-684줄)
|
// 5. MonsterTask 실행 (원본 678-684줄)
|
||||||
@@ -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,30 +1688,36 @@ class ProgressService {
|
|||||||
final lastCombatEvents =
|
final lastCombatEvents =
|
||||||
state.progress.currentCombat?.recentEvents ?? const [];
|
state.progress.currentCombat?.recentEvents ?? const [];
|
||||||
|
|
||||||
// 무기(슬롯 0)를 제외한 장착된 장비 중 1개를 제물로 삭제
|
// 보스전 사망 여부 확인 (최종 보스 fighting 상태)
|
||||||
// 장착된 비무기 슬롯 인덱스 수집 (슬롯 1~10 중 장비가 있는 것)
|
final isBossDeath =
|
||||||
final equippedNonWeaponSlots = <int>[];
|
state.progress.finalBossState == FinalBossState.fighting;
|
||||||
for (var i = 1; i < Equipment.slotCount; i++) {
|
|
||||||
if (state.equipment.getItemByIndex(i).isNotEmpty) {
|
|
||||||
equippedNonWeaponSlots.add(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 제물로 바칠 장비 선택 및 삭제
|
// 보스전 사망이 아닐 경우에만 장비 손실
|
||||||
var newEquipment = state.equipment;
|
var newEquipment = state.equipment;
|
||||||
final lostCount = equippedNonWeaponSlots.isNotEmpty ? 1 : 0;
|
var lostCount = 0;
|
||||||
|
|
||||||
if (equippedNonWeaponSlots.isNotEmpty) {
|
if (!isBossDeath) {
|
||||||
// 랜덤하게 1개 슬롯 선택
|
// 무기(슬롯 0)를 제외한 장착된 장비 중 1개를 제물로 삭제
|
||||||
final sacrificeIndex =
|
final equippedNonWeaponSlots = <int>[];
|
||||||
equippedNonWeaponSlots[state.rng.nextInt(equippedNonWeaponSlots.length)];
|
for (var i = 1; i < Equipment.slotCount; i++) {
|
||||||
final slot = EquipmentSlot.values[sacrificeIndex];
|
if (state.equipment.getItemByIndex(i).isNotEmpty) {
|
||||||
|
equippedNonWeaponSlots.add(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 해당 슬롯을 빈 장비로 교체
|
if (equippedNonWeaponSlots.isNotEmpty) {
|
||||||
newEquipment = newEquipment.setItemByIndex(
|
lostCount = 1;
|
||||||
sacrificeIndex,
|
// 랜덤하게 1개 슬롯 선택
|
||||||
EquipmentItem.empty(slot),
|
final sacrificeIndex = equippedNonWeaponSlots[
|
||||||
);
|
state.rng.nextInt(equippedNonWeaponSlots.length)];
|
||||||
|
final slot = EquipmentSlot.values[sacrificeIndex];
|
||||||
|
|
||||||
|
// 해당 슬롯을 빈 장비로 교체
|
||||||
|
newEquipment = newEquipment.setItemByIndex(
|
||||||
|
sacrificeIndex,
|
||||||
|
EquipmentItem.empty(slot),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 사망 정보 생성 (전투 로그 포함)
|
// 사망 정보 생성 (전투 로그 포함)
|
||||||
@@ -1713,12 +1731,16 @@ class ProgressService {
|
|||||||
lastCombatEvents: lastCombatEvents,
|
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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user