feat(ui): HP/MP 바 개선 및 전투 시스템 UI 업데이트

- HP/MP 변화 시 플래시 효과 및 변화량 표시 추가
- 전투 중 몬스터 HP 바 표시 기능 추가
- 몬스터 HP 바 Row 오버플로우 버그 수정 (Flexible 적용)
- 전투 상태 및 이벤트 모델 개선
- 캐릭터 애니메이션 및 전투 컴포저 업데이트
This commit is contained in:
JiWoong Sul
2025-12-18 18:10:22 +09:00
parent 45147da5ec
commit cf8fdaecde
14 changed files with 1220 additions and 153 deletions

View File

@@ -93,22 +93,29 @@ class BattleComposer {
_overlaySpriteWithSpaces(canvas, normalizedChar, charX, charY); _overlaySpriteWithSpaces(canvas, normalizedChar, charX, charY);
// 4. 몬스터 프레임 (정규화하여 오른쪽 정렬) // 4. 몬스터 프레임 (정규화하여 오른쪽 정렬)
// idle 프레임 기준 너비로 정렬하여 hit/alert 시 위치 이동 방지
final monsterRefWidth = _getMonsterReferenceWidth(monsterCategory, monsterSize);
final monsterFrames = final monsterFrames =
_getAnimatedMonsterFrames(monsterCategory, monsterSize, phase); _getAnimatedMonsterFrames(monsterCategory, monsterSize, phase);
final monsterFrame = monsterFrames[subFrame % monsterFrames.length]; final monsterFrame = monsterFrames[subFrame % monsterFrames.length];
final normalizedMonster = _normalizeSpriteRight(monsterFrame, monsterWidth); final normalizedMonster = _normalizeSpriteRight(
monsterFrame,
monsterWidth,
referenceWidth: monsterRefWidth,
);
final monsterX = frameWidth - monsterWidth; final monsterX = frameWidth - monsterWidth;
// 바닥 레이어(Y=7) 위에 서있도록 -1 // 바닥 레이어(Y=7) 위에 서있도록 -1
final monsterY = frameHeight - normalizedMonster.length - 1; final monsterY = frameHeight - normalizedMonster.length - 1;
_overlaySpriteWithSpaces(canvas, normalizedMonster, monsterX, monsterY); // 몬스터는 경계 내 완전 렌더링 (내부 공백에 배경이 비치지 않도록)
_overlaySpriteWithBounds(canvas, normalizedMonster, monsterX, monsterY);
// 5. 멀티라인 이펙트 오버레이 (공격/히트 페이즈) // 5. 멀티라인 이펙트 오버레이 (공격/히트 페이즈)
if (phase == BattlePhase.attack || phase == BattlePhase.hit) { if (phase == BattlePhase.attack || phase == BattlePhase.hit) {
final effect = getWeaponEffect(weaponCategory); final effect = getWeaponEffect(weaponCategory);
final effectLines = _getEffectLines(effect, phase, subFrame); final effectLines = _getEffectLines(effect, phase, subFrame);
if (effectLines.isNotEmpty) { if (effectLines.isNotEmpty) {
// 이펙트 Y 위치: 캐릭터 높이 (2번째 줄, 몸통) 기준 // 이펙트 Y 위치: 캐릭터 머리 높이 (1번째 줄) 기준 - 수정됨
final effectY = charY + 1; final effectY = charY;
// 이펙트 X 위치: 캐릭터 오른쪽에 붙여서 표시 // 이펙트 X 위치: 캐릭터 오른쪽에 붙여서 표시
final effectX = charX + 6; final effectX = charX + 6;
for (var i = 0; i < effectLines.length; i++) { for (var i = 0; i < effectLines.length; i++) {
@@ -129,16 +136,64 @@ class BattleComposer {
return sprite.map((line) => line.padRight(width).substring(0, width)).toList(); return sprite.map((line) => line.padRight(width).substring(0, width)).toList();
} }
/// 스프라이트를 지정 폭으로 정규화 (오른쪽 정렬) /// 스프라이트를 지정 폭으로 정규화 (오른쪽 정렬, 전체 스프라이트 기준)
List<String> _normalizeSpriteRight(List<String> sprite, int width) { ///
return sprite.map((line) { /// 모든 줄을 동일한 기준점에서 오른쪽 정렬하여
final trimmed = line.trimRight(); /// 머리와 몸통이 분리되지 않도록 함
if (trimmed.length >= width) return trimmed.substring(0, width); ///
return trimmed.padLeft(width); /// [referenceWidth] 지정 시 해당 너비를 기준으로 정렬 (idle/hit 프레임 일관성용)
List<String> _normalizeSpriteRight(
List<String> sprite,
int width, {
int? referenceWidth,
}) {
// 1. 각 줄의 실제 너비(오른쪽 공백 제외) 계산
final trimmedLines = sprite.map((line) => line.trimRight()).toList();
// 2. 기준 너비 결정 (referenceWidth 있으면 사용, 없으면 현재 스프라이트 기준)
int maxLineWidth;
if (referenceWidth != null) {
maxLineWidth = referenceWidth;
} else {
maxLineWidth = 0;
for (final line in trimmedLines) {
if (line.length > maxLineWidth) {
maxLineWidth = line.length;
}
}
}
// 3. 전체 스프라이트를 오른쪽 정렬 (width 기준)
// 모든 줄에 동일한 왼쪽 패딩 적용
final leftPadding = width - maxLineWidth;
final paddingStr = leftPadding > 0 ? ' ' * leftPadding : '';
return trimmedLines.map((line) {
// 각 줄을 왼쪽에 공통 패딩 추가 후 width로 자르기
final paddedLine = paddingStr + line;
if (paddedLine.length > width) {
return paddedLine.substring(paddedLine.length - width);
}
return paddedLine.padRight(width);
}).toList(); }).toList();
} }
/// 스프라이트를 캔버스에 오버레이 (공백도 덮어쓰기 - Z-order용) /// 몬스터 스프라이트의 기준 너비 계산 (idle 프레임 기준)
int _getMonsterReferenceWidth(MonsterCategory category, MonsterSize size) {
final idleFrames = _getMonsterIdleFrames(category, size);
int maxWidth = 0;
for (final frame in idleFrames) {
for (final line in frame) {
final trimmedLength = line.trimRight().length;
if (trimmedLength > maxWidth) {
maxWidth = trimmedLength;
}
}
}
return maxWidth;
}
/// 스프라이트를 캔버스에 오버레이 (공백은 투명 처리)
void _overlaySpriteWithSpaces( void _overlaySpriteWithSpaces(
List<List<String>> canvas, List<List<String>> canvas,
List<String> sprite, List<String> sprite,
@@ -163,6 +218,43 @@ class BattleComposer {
} }
} }
/// 스프라이트를 캔버스에 오버레이 (라인별 경계 내 완전 렌더링)
///
/// 각 라인에서 첫 번째와 마지막 비공백 문자 사이의 모든 문자를 그림.
/// 내부 공백도 그려져서 스크롤링 배경이 비치지 않음.
void _overlaySpriteWithBounds(
List<List<String>> canvas,
List<String> sprite,
int startX,
int startY,
) {
for (var i = 0; i < sprite.length; i++) {
final y = startY + i;
if (y < 0 || y >= frameHeight) continue;
final line = sprite[i];
// 각 라인에서 첫/마지막 비공백 문자 위치 찾기
int firstNonSpace = -1;
int lastNonSpace = -1;
for (var j = 0; j < line.length; j++) {
if (line[j] != ' ') {
if (firstNonSpace == -1) firstNonSpace = j;
lastNonSpace = j;
}
}
if (firstNonSpace == -1) continue; // 빈 라인
// 경계 내 모든 문자 그리기 (공백 포함)
for (var j = firstNonSpace; j <= lastNonSpace; j++) {
final x = startX + j;
if (x < 0 || x >= frameWidth) continue;
canvas[y][x] = line[j];
}
}
}
/// 배경 레이어를 캔버스에 그리기 /// 배경 레이어를 캔버스에 그리기
void _drawBackgroundLayer( void _drawBackgroundLayer(
List<List<String>> canvas, List<List<String>> canvas,

View File

@@ -108,7 +108,8 @@ const _prepareFrames = [
// ============================================================================ // ============================================================================
// 공격 프레임 (전진 + 휘두르기) - 5프레임, 심플 3줄 스타일 // 공격 프레임 (전진 + 휘두르기) - 5프레임, 심플 3줄 스타일
// 구조: [머리, 몸통+팔+무기, 다리] // 구조: [머리+공격, 몸통+팔, 다리]
// 수정: 공격 이펙트를 머리 줄로 통일 (1칸 위로)
// ============================================================================ // ============================================================================
const _attackFrames = [ const _attackFrames = [
CharacterFrame([ CharacterFrame([
@@ -122,13 +123,13 @@ const _attackFrames = [
r' / \ ', r' / \ ',
]), ]),
CharacterFrame([ CharacterFrame([
r' o ', r' o-- ',
r' /|-- ', r' /| ',
r' / \ ', r' / \ ',
]), ]),
CharacterFrame([ CharacterFrame([
r' o ', r' o-=>',
r' /|-=>', r' /| ',
r' / \ ', r' / \ ',
]), ]),
CharacterFrame([ CharacterFrame([
@@ -140,22 +141,23 @@ const _attackFrames = [
// ============================================================================ // ============================================================================
// 히트 프레임 (공격 명중) - 3프레임, 심플 3줄 스타일 // 히트 프레임 (공격 명중) - 3프레임, 심플 3줄 스타일
// 구조: [머리, 몸통+팔+이펙트, 다리] // 구조: [머리+이펙트, 몸통+팔, 다리]
// 수정: 히트 이펙트를 머리 줄로 통일 (1칸 위로)
// ============================================================================ // ============================================================================
const _hitFrames = [ const _hitFrames = [
CharacterFrame([ CharacterFrame([
r' o ', r' o-* ',
r' /|-* ', r' /| ',
r' / \ ', r' / \ ',
]), ]),
CharacterFrame([ CharacterFrame([
r' o ', r' o=* ',
r' /|=* ', r' /| ',
r' / \ ', r' / \ ',
]), ]),
CharacterFrame([ CharacterFrame([
r' o ', r' o~* ',
r' /|~* ', r' /| ',
r' / \ ', r' / \ ',
]), ]),
]; ];

View File

@@ -6,6 +6,7 @@ import 'package:askiineverdie/src/core/engine/combat_calculator.dart';
import 'package:askiineverdie/src/core/engine/game_mutations.dart'; import 'package:askiineverdie/src/core/engine/game_mutations.dart';
import 'package:askiineverdie/src/core/engine/reward_service.dart'; import 'package:askiineverdie/src/core/engine/reward_service.dart';
import 'package:askiineverdie/src/core/engine/skill_service.dart'; import 'package:askiineverdie/src/core/engine/skill_service.dart';
import 'package:askiineverdie/src/core/model/combat_event.dart';
import 'package:askiineverdie/src/core/model/combat_state.dart'; import 'package:askiineverdie/src/core/model/combat_state.dart';
import 'package:askiineverdie/src/core/model/combat_stats.dart'; import 'package:askiineverdie/src/core/model/combat_stats.dart';
import 'package:askiineverdie/src/core/model/equipment_item.dart'; import 'package:askiineverdie/src/core/model/equipment_item.dart';
@@ -225,11 +226,19 @@ class ProgressService {
// 킬 태스크 완료 시 전투 결과 반영 및 전리품 획득 // 킬 태스크 완료 시 전투 결과 반영 및 전리품 획득
if (gain) { if (gain) {
// 전투 결과에 따라 플레이어 HP 업데이트 // 전투 결과에 따라 플레이어 HP 업데이트 + 전투 후 회복
final combat = progress.currentCombat; final combat = progress.currentCombat;
if (combat != null && combat.isActive) { if (combat != null && combat.isActive) {
// 전투 중 데미지를 실제 Stats에 반영 // 전투 중 HP
final newHp = combat.playerStats.hpCurrent; final remainingHp = combat.playerStats.hpCurrent;
final maxHp = combat.playerStats.hpMax;
// 전투 승리 시 HP 회복 (50% + CON/2)
// 아이들 게임 특성상 전투 사이 HP가 회복되어야 지속 플레이 가능
final conBonus = nextState.stats.con ~/ 2;
final healAmount = (maxHp * 0.5).round() + conBonus;
final newHp = (remainingHp + healAmount).clamp(0, maxHp);
nextState = nextState.copyWith( nextState = nextState.copyWith(
stats: nextState.stats.copyWith(hpCurrent: newHp), stats: nextState.stats.copyWith(hpCurrent: newHp),
); );
@@ -456,9 +465,17 @@ class ProgressService {
level: level, level: level,
); );
// 전투용 몬스터 레벨 조정 (밸런스)
// config의 raw 레벨이 플레이어보다 너무 높으면 전투가 불가능
// 플레이어 레벨 ±3 범위로 제한 (최소 1)
final effectiveMonsterLevel = monsterResult.level.clamp(
math.max(1, level - 3),
level + 3,
).toInt();
final monsterCombatStats = MonsterCombatStats.fromLevel( final monsterCombatStats = MonsterCombatStats.fromLevel(
name: monsterResult.displayName, name: monsterResult.displayName,
level: monsterResult.level, level: effectiveMonsterLevel,
speedType: MonsterCombatStats.inferSpeedType(monsterResult.baseName), speedType: MonsterCombatStats.inferSpeedType(monsterResult.baseName),
); );
@@ -875,6 +892,10 @@ class ProgressService {
var turnsElapsed = combat.turnsElapsed; var turnsElapsed = combat.turnsElapsed;
var updatedSkillSystem = skillSystem; var updatedSkillSystem = skillSystem;
// 새 전투 이벤트 수집
final newEvents = <CombatEvent>[];
final timestamp = updatedSkillSystem.elapsedMs;
// 플레이어 공격 체크 // 플레이어 공격 체크
if (playerAccumulator >= playerStats.attackDelayMs) { if (playerAccumulator >= playerStats.attackDelayMs) {
// 스킬 자동 선택 // 스킬 자동 선택
@@ -905,6 +926,14 @@ class ProgressService {
monsterStats = skillResult.updatedMonster; monsterStats = skillResult.updatedMonster;
totalDamageDealt += skillResult.result.damage; totalDamageDealt += skillResult.result.damage;
updatedSkillSystem = skillResult.updatedSkillSystem; updatedSkillSystem = skillResult.updatedSkillSystem;
// 스킬 공격 이벤트 생성
newEvents.add(CombatEvent.playerSkill(
timestamp: timestamp,
skillName: selectedSkill.name,
damage: skillResult.result.damage,
targetName: monsterStats.name,
));
} else if (selectedSkill != null && selectedSkill.isHeal) { } else if (selectedSkill != null && selectedSkill.isHeal) {
// 회복 스킬 사용 // 회복 스킬 사용
final skillResult = skillService.useHealSkill( final skillResult = skillService.useHealSkill(
@@ -914,6 +943,13 @@ class ProgressService {
); );
playerStats = skillResult.updatedPlayer; playerStats = skillResult.updatedPlayer;
updatedSkillSystem = skillResult.updatedSkillSystem; updatedSkillSystem = skillResult.updatedSkillSystem;
// 회복 이벤트 생성
newEvents.add(CombatEvent.playerHeal(
timestamp: timestamp,
healAmount: skillResult.result.healedAmount,
skillName: selectedSkill.name,
));
} else if (selectedSkill != null && selectedSkill.isBuff) { } else if (selectedSkill != null && selectedSkill.isBuff) {
// 버프 스킬 사용 // 버프 스킬 사용
final skillResult = skillService.useBuffSkill( final skillResult = skillService.useBuffSkill(
@@ -923,6 +959,12 @@ class ProgressService {
); );
playerStats = skillResult.updatedPlayer; playerStats = skillResult.updatedPlayer;
updatedSkillSystem = skillResult.updatedSkillSystem; updatedSkillSystem = skillResult.updatedSkillSystem;
// 버프 이벤트 생성
newEvents.add(CombatEvent.playerBuff(
timestamp: timestamp,
skillName: selectedSkill.name,
));
} else { } else {
// 일반 공격 // 일반 공격
final attackResult = calculator.playerAttackMonster( final attackResult = calculator.playerAttackMonster(
@@ -931,6 +973,22 @@ class ProgressService {
); );
monsterStats = attackResult.updatedDefender; monsterStats = attackResult.updatedDefender;
totalDamageDealt += attackResult.result.damage; 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,
));
}
} }
playerAccumulator -= playerStats.attackDelayMs; playerAccumulator -= playerStats.attackDelayMs;
@@ -946,11 +1004,44 @@ class ProgressService {
playerStats = attackResult.updatedDefender; playerStats = attackResult.updatedDefender;
totalDamageTaken += attackResult.result.damage; totalDamageTaken += attackResult.result.damage;
monsterAccumulator -= monsterStats.attackDelayMs; 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,
));
}
} }
// 전투 종료 체크 // 전투 종료 체크
final isActive = playerStats.isAlive && monsterStats.isAlive; final isActive = playerStats.isAlive && monsterStats.isAlive;
// 기존 이벤트와 합쳐서 최대 10개 유지
final combinedEvents = [...combat.recentEvents, ...newEvents];
final recentEvents = combinedEvents.length > 10
? combinedEvents.sublist(combinedEvents.length - 10)
: combinedEvents;
return ( return (
combat: combat.copyWith( combat: combat.copyWith(
playerStats: playerStats, playerStats: playerStats,
@@ -961,6 +1052,7 @@ class ProgressService {
totalDamageTaken: totalDamageTaken, totalDamageTaken: totalDamageTaken,
turnsElapsed: turnsElapsed, turnsElapsed: turnsElapsed,
isActive: isActive, isActive: isActive,
recentEvents: recentEvents,
), ),
skillSystem: updatedSkillSystem, skillSystem: updatedSkillSystem,
); );
@@ -977,6 +1069,10 @@ class ProgressService {
// 상실할 장비 개수 계산 // 상실할 장비 개수 계산
final lostCount = state.equipment.equippedItems.length; final lostCount = state.equipment.equippedItems.length;
// 사망 직전 전투 이벤트 저장 (최대 10개)
final lastCombatEvents =
state.progress.currentCombat?.recentEvents ?? const [];
// 빈 장비 생성 (기본 무기만 유지) // 빈 장비 생성 (기본 무기만 유지)
final emptyEquipment = Equipment( final emptyEquipment = Equipment(
items: [ items: [
@@ -995,7 +1091,7 @@ class ProgressService {
bestIndex: 0, bestIndex: 0,
); );
// 사망 정보 생성 // 사망 정보 생성 (전투 로그 포함)
final deathInfo = DeathInfo( final deathInfo = DeathInfo(
cause: cause, cause: cause,
killerName: killerName, killerName: killerName,
@@ -1003,6 +1099,7 @@ class ProgressService {
goldAtDeath: state.inventory.gold, goldAtDeath: state.inventory.gold,
levelAtDeath: state.traits.level, levelAtDeath: state.traits.level,
timestamp: state.skillSystem.elapsedMs, timestamp: state.skillSystem.elapsedMs,
lastCombatEvents: lastCombatEvents,
); );
// 전투 상태 초기화 // 전투 상태 초기화

View File

@@ -1,7 +1,13 @@
import 'dart:math';
import 'package:askiineverdie/data/class_data.dart';
import 'package:askiineverdie/data/race_data.dart';
import 'package:askiineverdie/src/core/engine/shop_service.dart'; import 'package:askiineverdie/src/core/engine/shop_service.dart';
import 'package:askiineverdie/src/core/model/class_traits.dart';
import 'package:askiineverdie/src/core/model/equipment_item.dart'; import 'package:askiineverdie/src/core/model/equipment_item.dart';
import 'package:askiineverdie/src/core/model/equipment_slot.dart'; import 'package:askiineverdie/src/core/model/equipment_slot.dart';
import 'package:askiineverdie/src/core/model/game_state.dart'; import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/core/model/race_traits.dart';
/// 부활 시스템 서비스 (Phase 4) /// 부활 시스템 서비스 (Phase 4)
/// ///
@@ -17,7 +23,7 @@ class ResurrectionService {
/// 플레이어 사망 처리 /// 플레이어 사망 처리
/// ///
/// 1. 모든 장비 제거 (인벤토리로 이동하지 않음 - 상실) /// 1. 1개의 랜덤 장비 제거 (제물로 바침)
/// 2. 전투 상태 초기화 /// 2. 전투 상태 초기화
/// 3. 사망 정보 기록 /// 3. 사망 정보 기록
GameState processDeath({ GameState processDeath({
@@ -25,32 +31,40 @@ class ResurrectionService {
required String killerName, required String killerName,
required DeathCause cause, required DeathCause cause,
}) { }) {
// 상실할 장비 개수 계산 // 제물로 바칠 아이템 선택 (장착된 아이템 중 랜덤 1개)
final lostCount = state.equipment.equippedItems.length; final equippedItems = <int>[]; // 장착된 아이템의 슬롯 인덱스
for (var i = 0; i < Equipment.slotCount; i++) {
final item = state.equipment.getItemByIndex(i);
// 빈 슬롯과 기본 무기(Keyboard) 제외
if (item.isNotEmpty && item.name != 'Keyboard') {
equippedItems.add(i);
}
}
// 빈 장비 생성 (기본 무기만 유지) String? lostItemName;
final emptyEquipment = Equipment( var newEquipment = state.equipment;
items: [
EquipmentItem.defaultWeapon(), // 무기 슬롯에 기본 Keyboard if (equippedItems.isNotEmpty) {
EquipmentItem.empty(EquipmentSlot.shield), // 랜덤하게 1개 슬롯 선택
EquipmentItem.empty(EquipmentSlot.helm), final random = Random();
EquipmentItem.empty(EquipmentSlot.hauberk), final slotIndex = equippedItems[random.nextInt(equippedItems.length)];
EquipmentItem.empty(EquipmentSlot.brassairts), final lostItem = state.equipment.getItemByIndex(slotIndex);
EquipmentItem.empty(EquipmentSlot.vambraces), lostItemName = lostItem.name;
EquipmentItem.empty(EquipmentSlot.gauntlets),
EquipmentItem.empty(EquipmentSlot.gambeson), // 해당 슬롯만 빈 아이템으로 교체
EquipmentItem.empty(EquipmentSlot.cuisses), final slot = EquipmentSlot.values[slotIndex];
EquipmentItem.empty(EquipmentSlot.greaves), newEquipment = state.equipment.setItemByIndex(
EquipmentItem.empty(EquipmentSlot.sollerets), slotIndex,
], EquipmentItem.empty(slot),
bestIndex: 0, );
); }
// 사망 정보 생성 // 사망 정보 생성
final deathInfo = DeathInfo( final deathInfo = DeathInfo(
cause: cause, cause: cause,
killerName: killerName, killerName: killerName,
lostEquipmentCount: lostCount, lostEquipmentCount: lostItemName != null ? 1 : 0,
lostItemName: lostItemName,
goldAtDeath: state.inventory.gold, goldAtDeath: state.inventory.gold,
levelAtDeath: state.traits.level, levelAtDeath: state.traits.level,
timestamp: state.skillSystem.elapsedMs, timestamp: state.skillSystem.elapsedMs,
@@ -62,7 +76,7 @@ class ResurrectionService {
); );
return state.copyWith( return state.copyWith(
equipment: emptyEquipment, equipment: newEquipment,
progress: progress, progress: progress,
deathInfo: deathInfo, deathInfo: deathInfo,
); );
@@ -74,33 +88,37 @@ class ResurrectionService {
/// 플레이어 부활 처리 /// 플레이어 부활 처리
/// ///
/// 1. HP/MP 전체 회복 /// 1. 골드로 구매 가능한 장비 자동 구매
/// 2. 골드로 구매 가능한 장비 자동 구매 /// 2. HP/MP 전체 회복 (장비/종족/클래스 보너스 포함)
/// 3. 사망 상태 해제 /// 3. 사망 상태 해제
/// 4. 안전 지역으로 이동 태스크 설정 /// 4. 안전 지역으로 이동 태스크 설정
GameState processResurrection(GameState state) { GameState processResurrection(GameState state) {
if (!state.isDead) return state; if (!state.isDead) return state;
// HP/MP 전체 회복 // 1. 먼저 장비 구매 (HP 계산에 필요)
final autoBuyResult = shopService.autoBuyForEmptySlots(
playerLevel: state.traits.level,
currentGold: state.inventory.gold,
currentEquipment: state.equipment,
);
// 장비 적용
var nextState = state.copyWith( var nextState = state.copyWith(
stats: state.stats.copyWith( equipment: autoBuyResult.updatedEquipment,
hpCurrent: state.stats.hpMax, inventory: state.inventory.copyWith(
mpCurrent: state.stats.mpMax, gold: autoBuyResult.remainingGold,
), ),
); );
// 빈 슬롯에 자동 장비 구매 // 2. 전체 HP/MP 계산 (장비 + 종족 + 클래스 보너스 포함)
final autoBuyResult = shopService.autoBuyForEmptySlots( final totalHpMax = _calculateTotalHpMax(nextState);
playerLevel: nextState.traits.level, final totalMpMax = _calculateTotalMpMax(nextState);
currentGold: nextState.inventory.gold,
currentEquipment: nextState.equipment,
);
// 결과 적용 // HP/MP 전체 회복
nextState = nextState.copyWith( nextState = nextState.copyWith(
equipment: autoBuyResult.updatedEquipment, stats: nextState.stats.copyWith(
inventory: nextState.inventory.copyWith( hpCurrent: totalHpMax,
gold: autoBuyResult.remainingGold, mpCurrent: totalMpMax,
), ),
clearDeathInfo: true, // 사망 상태 해제 clearDeathInfo: true, // 사망 상태 해제
); );
@@ -115,6 +133,49 @@ class ResurrectionService {
return nextState; return nextState;
} }
/// 장비/종족/클래스 보너스를 포함한 전체 HP 계산
int _calculateTotalHpMax(GameState state) {
// 기본 HP + 장비 보너스
var totalHp = state.stats.hpMax + state.equipment.totalStats.hpBonus;
// 종족 HP 보너스 (예: Heap Troll +20%)
final race = RaceData.findById(state.traits.raceId);
if (race != null) {
final raceHpBonus = race.getPassiveValue(PassiveType.hpBonus);
if (raceHpBonus > 0) {
totalHp = (totalHp * (1 + raceHpBonus)).round();
}
}
// 클래스 HP 보너스 (예: Garbage Collector +30%)
final klass = ClassData.findById(state.traits.classId);
if (klass != null) {
final classHpBonus = klass.getPassiveValue(ClassPassiveType.hpBonus);
if (classHpBonus > 0) {
totalHp = (totalHp * (1 + classHpBonus)).round();
}
}
return totalHp;
}
/// 장비/종족/클래스 보너스를 포함한 전체 MP 계산
int _calculateTotalMpMax(GameState state) {
// 기본 MP + 장비 보너스
var totalMp = state.stats.mpMax + state.equipment.totalStats.mpBonus;
// 종족 MP 보너스 (예: Pointer Fairy +20%)
final race = RaceData.findById(state.traits.raceId);
if (race != null) {
final raceMpBonus = race.getPassiveValue(PassiveType.mpBonus);
if (raceMpBonus > 0) {
totalMp = (totalMp * (1 + raceMpBonus)).round();
}
}
return totalMp;
}
// ============================================================================ // ============================================================================
// 유틸리티 // 유틸리티
// ============================================================================ // ============================================================================

View File

@@ -0,0 +1,191 @@
/// 전투 이벤트 타입 (Combat Event Type)
enum CombatEventType {
/// 플레이어가 몬스터 공격
playerAttack,
/// 몬스터가 플레이어 공격
monsterAttack,
/// 플레이어 회피
playerEvade,
/// 몬스터 회피
monsterEvade,
/// 플레이어 방패 방어
playerBlock,
/// 플레이어 무기 쳐내기
playerParry,
/// 플레이어 스킬 사용
playerSkill,
/// 플레이어 회복
playerHeal,
/// 플레이어 버프
playerBuff,
}
/// 전투 이벤트 (Combat Event)
///
/// 개별 공격/방어/스킬 사용 등의 전투 행동을 기록
class CombatEvent {
const CombatEvent({
required this.type,
required this.timestamp,
this.damage = 0,
this.healAmount = 0,
this.isCritical = false,
this.skillName,
this.targetName,
});
/// 이벤트 타입
final CombatEventType type;
/// 발생 시간 (elapsedMs)
final int timestamp;
/// 데미지 (0이면 미스/회피)
final int damage;
/// 회복량 (회복 이벤트용)
final int healAmount;
/// 크리티컬 여부
final bool isCritical;
/// 사용한 스킬 이름 (스킬 이벤트용)
final String? skillName;
/// 대상 이름 (몬스터 또는 플레이어)
final String? targetName;
/// 플레이어 공격 이벤트 생성
factory CombatEvent.playerAttack({
required int timestamp,
required int damage,
required String targetName,
bool isCritical = false,
}) {
return CombatEvent(
type: CombatEventType.playerAttack,
timestamp: timestamp,
damage: damage,
targetName: targetName,
isCritical: isCritical,
);
}
/// 몬스터 공격 이벤트 생성
factory CombatEvent.monsterAttack({
required int timestamp,
required int damage,
required String attackerName,
}) {
return CombatEvent(
type: CombatEventType.monsterAttack,
timestamp: timestamp,
damage: damage,
targetName: attackerName,
);
}
/// 플레이어 회피 이벤트 생성
factory CombatEvent.playerEvade({
required int timestamp,
required String attackerName,
}) {
return CombatEvent(
type: CombatEventType.playerEvade,
timestamp: timestamp,
targetName: attackerName,
);
}
/// 몬스터 회피 이벤트 생성
factory CombatEvent.monsterEvade({
required int timestamp,
required String targetName,
}) {
return CombatEvent(
type: CombatEventType.monsterEvade,
timestamp: timestamp,
targetName: targetName,
);
}
/// 플레이어 방패 방어 이벤트 생성
factory CombatEvent.playerBlock({
required int timestamp,
required int reducedDamage,
required String attackerName,
}) {
return CombatEvent(
type: CombatEventType.playerBlock,
timestamp: timestamp,
damage: reducedDamage,
targetName: attackerName,
);
}
/// 플레이어 무기 쳐내기 이벤트 생성
factory CombatEvent.playerParry({
required int timestamp,
required int reducedDamage,
required String attackerName,
}) {
return CombatEvent(
type: CombatEventType.playerParry,
timestamp: timestamp,
damage: reducedDamage,
targetName: attackerName,
);
}
/// 스킬 사용 이벤트 생성
factory CombatEvent.playerSkill({
required int timestamp,
required String skillName,
required int damage,
required String targetName,
bool isCritical = false,
}) {
return CombatEvent(
type: CombatEventType.playerSkill,
timestamp: timestamp,
skillName: skillName,
damage: damage,
targetName: targetName,
isCritical: isCritical,
);
}
/// 회복 이벤트 생성
factory CombatEvent.playerHeal({
required int timestamp,
required int healAmount,
String? skillName,
}) {
return CombatEvent(
type: CombatEventType.playerHeal,
timestamp: timestamp,
healAmount: healAmount,
skillName: skillName,
);
}
/// 버프 이벤트 생성
factory CombatEvent.playerBuff({
required int timestamp,
required String skillName,
}) {
return CombatEvent(
type: CombatEventType.playerBuff,
timestamp: timestamp,
skillName: skillName,
);
}
}

View File

@@ -1,3 +1,4 @@
import 'package:askiineverdie/src/core/model/combat_event.dart';
import 'package:askiineverdie/src/core/model/combat_stats.dart'; import 'package:askiineverdie/src/core/model/combat_stats.dart';
import 'package:askiineverdie/src/core/model/monster_combat_stats.dart'; import 'package:askiineverdie/src/core/model/monster_combat_stats.dart';
@@ -15,6 +16,7 @@ class CombatState {
required this.totalDamageTaken, required this.totalDamageTaken,
required this.turnsElapsed, required this.turnsElapsed,
required this.isActive, required this.isActive,
this.recentEvents = const [],
}); });
/// 플레이어 전투 스탯 /// 플레이어 전투 스탯
@@ -41,6 +43,9 @@ class CombatState {
/// 전투 활성화 여부 /// 전투 활성화 여부
final bool isActive; final bool isActive;
/// 최근 전투 이벤트 목록 (최대 10개)
final List<CombatEvent> recentEvents;
// ============================================================================ // ============================================================================
// 유틸리티 // 유틸리티
// ============================================================================ // ============================================================================
@@ -69,6 +74,7 @@ class CombatState {
int? totalDamageTaken, int? totalDamageTaken,
int? turnsElapsed, int? turnsElapsed,
bool? isActive, bool? isActive,
List<CombatEvent>? recentEvents,
}) { }) {
return CombatState( return CombatState(
playerStats: playerStats ?? this.playerStats, playerStats: playerStats ?? this.playerStats,
@@ -81,6 +87,7 @@ class CombatState {
totalDamageTaken: totalDamageTaken ?? this.totalDamageTaken, totalDamageTaken: totalDamageTaken ?? this.totalDamageTaken,
turnsElapsed: turnsElapsed ?? this.turnsElapsed, turnsElapsed: turnsElapsed ?? this.turnsElapsed,
isActive: isActive ?? this.isActive, isActive: isActive ?? this.isActive,
recentEvents: recentEvents ?? this.recentEvents,
); );
} }

View File

@@ -1,5 +1,6 @@
import 'dart:collection'; import 'dart:collection';
import 'package:askiineverdie/src/core/model/combat_event.dart';
import 'package:askiineverdie/src/core/model/combat_state.dart'; import 'package:askiineverdie/src/core/model/combat_state.dart';
import 'package:askiineverdie/src/core/model/equipment_item.dart'; import 'package:askiineverdie/src/core/model/equipment_item.dart';
import 'package:askiineverdie/src/core/model/equipment_slot.dart'; import 'package:askiineverdie/src/core/model/equipment_slot.dart';
@@ -107,7 +108,7 @@ class GameState {
/// 사망 정보 (Phase 4) /// 사망 정보 (Phase 4)
/// ///
/// 사망 시점의 정보와 상실한 장비 목록을 기록 /// 사망 시점의 정보와 상실한 아이템을 기록
class DeathInfo { class DeathInfo {
const DeathInfo({ const DeathInfo({
required this.cause, required this.cause,
@@ -116,6 +117,8 @@ class DeathInfo {
required this.goldAtDeath, required this.goldAtDeath,
required this.levelAtDeath, required this.levelAtDeath,
required this.timestamp, required this.timestamp,
this.lostItemName,
this.lastCombatEvents = const [],
}); });
/// 사망 원인 /// 사망 원인
@@ -124,9 +127,12 @@ class DeathInfo {
/// 사망시킨 몬스터/원인 이름 /// 사망시킨 몬스터/원인 이름
final String killerName; final String killerName;
/// 상실한 장비 개수 /// 상실한 장비 개수 (0 또는 1)
final int lostEquipmentCount; final int lostEquipmentCount;
/// 제물로 바친 아이템 이름 (null이면 없음)
final String? lostItemName;
/// 사망 시점 골드 /// 사망 시점 골드
final int goldAtDeath; final int goldAtDeath;
@@ -136,21 +142,28 @@ class DeathInfo {
/// 사망 시각 (밀리초) /// 사망 시각 (밀리초)
final int timestamp; final int timestamp;
/// 사망 직전 전투 이벤트 (최대 10개)
final List<CombatEvent> lastCombatEvents;
DeathInfo copyWith({ DeathInfo copyWith({
DeathCause? cause, DeathCause? cause,
String? killerName, String? killerName,
int? lostEquipmentCount, int? lostEquipmentCount,
String? lostItemName,
int? goldAtDeath, int? goldAtDeath,
int? levelAtDeath, int? levelAtDeath,
int? timestamp, int? timestamp,
List<CombatEvent>? lastCombatEvents,
}) { }) {
return DeathInfo( return DeathInfo(
cause: cause ?? this.cause, cause: cause ?? this.cause,
killerName: killerName ?? this.killerName, killerName: killerName ?? this.killerName,
lostEquipmentCount: lostEquipmentCount ?? this.lostEquipmentCount, lostEquipmentCount: lostEquipmentCount ?? this.lostEquipmentCount,
lostItemName: lostItemName ?? this.lostItemName,
goldAtDeath: goldAtDeath ?? this.goldAtDeath, goldAtDeath: goldAtDeath ?? this.goldAtDeath,
levelAtDeath: levelAtDeath ?? this.levelAtDeath, levelAtDeath: levelAtDeath ?? this.levelAtDeath,
timestamp: timestamp ?? this.timestamp, timestamp: timestamp ?? this.timestamp,
lastCombatEvents: lastCombatEvents ?? this.lastCombatEvents,
); );
} }
} }

View File

@@ -4,6 +4,7 @@ import 'package:askiineverdie/data/story_data.dart';
import 'package:askiineverdie/l10n/app_localizations.dart'; import 'package:askiineverdie/l10n/app_localizations.dart';
import 'package:askiineverdie/src/core/animation/ascii_animation_type.dart'; import 'package:askiineverdie/src/core/animation/ascii_animation_type.dart';
import 'package:askiineverdie/src/core/engine/story_service.dart'; import 'package:askiineverdie/src/core/engine/story_service.dart';
import 'package:askiineverdie/src/core/model/combat_event.dart';
import 'package:askiineverdie/src/core/l10n/game_data_l10n.dart'; import 'package:askiineverdie/src/core/l10n/game_data_l10n.dart';
import 'package:askiineverdie/src/core/model/game_state.dart'; import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/core/model/hall_of_fame.dart'; import 'package:askiineverdie/src/core/model/hall_of_fame.dart';
@@ -14,6 +15,7 @@ import 'package:askiineverdie/src/features/game/game_session_controller.dart';
import 'package:askiineverdie/src/features/hall_of_fame/hall_of_fame_screen.dart'; import 'package:askiineverdie/src/features/hall_of_fame/hall_of_fame_screen.dart';
import 'package:askiineverdie/src/features/game/widgets/cinematic_view.dart'; import 'package:askiineverdie/src/features/game/widgets/cinematic_view.dart';
import 'package:askiineverdie/src/features/game/widgets/combat_log.dart'; import 'package:askiineverdie/src/features/game/widgets/combat_log.dart';
import 'package:askiineverdie/src/features/game/widgets/death_overlay.dart';
import 'package:askiineverdie/src/features/game/widgets/hp_mp_bar.dart'; import 'package:askiineverdie/src/features/game/widgets/hp_mp_bar.dart';
import 'package:askiineverdie/src/features/game/widgets/notification_overlay.dart'; import 'package:askiineverdie/src/features/game/widgets/notification_overlay.dart';
import 'package:askiineverdie/src/features/game/widgets/skill_panel.dart'; import 'package:askiineverdie/src/features/game/widgets/skill_panel.dart';
@@ -53,14 +55,22 @@ class _GamePlayScreenState extends State<GamePlayScreen>
int _lastQuestCount = 0; int _lastQuestCount = 0;
int _lastPlotStageCount = 0; int _lastPlotStageCount = 0;
// 전투 이벤트 추적 (마지막 처리된 이벤트 수)
int _lastProcessedEventCount = 0;
void _checkSpecialEvents(GameState state) { void _checkSpecialEvents(GameState state) {
// Phase 8: 태스크 변경 시 로그 추가 // Phase 8: 태스크 변경 시 로그 추가
final currentCaption = state.progress.currentTask.caption; final currentCaption = state.progress.currentTask.caption;
if (currentCaption.isNotEmpty && currentCaption != _lastTaskCaption) { if (currentCaption.isNotEmpty && currentCaption != _lastTaskCaption) {
_addCombatLog(currentCaption, CombatLogType.normal); _addCombatLog(currentCaption, CombatLogType.normal);
_lastTaskCaption = currentCaption; _lastTaskCaption = currentCaption;
// 새 태스크 시작 시 이벤트 카운터 리셋
_lastProcessedEventCount = 0;
} }
// 전투 이벤트 처리 (Combat Events)
_processCombatEvents(state);
// 레벨업 감지 // 레벨업 감지
if (state.traits.level > _lastLevel && _lastLevel > 0) { if (state.traits.level > _lastLevel && _lastLevel > 0) {
_specialAnimation = AsciiAnimationType.levelUp; _specialAnimation = AsciiAnimationType.levelUp;
@@ -129,6 +139,69 @@ class _GamePlayScreenState extends State<GamePlayScreen>
} }
} }
/// 전투 이벤트를 로그로 변환 (Convert Combat Events to Log)
void _processCombatEvents(GameState state) {
final combat = state.progress.currentCombat;
if (combat == null || !combat.isActive) {
_lastProcessedEventCount = 0;
return;
}
final events = combat.recentEvents;
if (events.isEmpty || events.length <= _lastProcessedEventCount) {
return;
}
// 새 이벤트만 처리
final newEvents = events.skip(_lastProcessedEventCount);
for (final event in newEvents) {
final (message, type) = _formatCombatEvent(event);
_addCombatLog(message, type);
}
_lastProcessedEventCount = events.length;
}
/// 전투 이벤트를 메시지와 타입으로 변환
(String, CombatLogType) _formatCombatEvent(CombatEvent event) {
return switch (event.type) {
CombatEventType.playerAttack => event.isCritical
? ('CRITICAL! ${event.damage} damage to ${event.targetName}!', CombatLogType.critical)
: ('You hit ${event.targetName} for ${event.damage} damage', CombatLogType.damage),
CombatEventType.monsterAttack => (
'${event.targetName} hits you for ${event.damage} damage',
CombatLogType.monsterAttack,
),
CombatEventType.playerEvade => (
'You evaded ${event.targetName}\'s attack!',
CombatLogType.evade,
),
CombatEventType.monsterEvade => (
'${event.targetName} evaded your attack!',
CombatLogType.evade,
),
CombatEventType.playerBlock => (
'Blocked! Reduced to ${event.damage} damage',
CombatLogType.block,
),
CombatEventType.playerParry => (
'Parried! Reduced to ${event.damage} damage',
CombatLogType.parry,
),
CombatEventType.playerSkill => event.isCritical
? ('CRITICAL ${event.skillName}! ${event.damage} damage!', CombatLogType.critical)
: ('${event.skillName}: ${event.damage} damage', CombatLogType.spell),
CombatEventType.playerHeal => (
'${event.skillName ?? "Heal"}: +${event.healAmount} HP',
CombatLogType.heal,
),
CombatEventType.playerBuff => (
'${event.skillName} activated!',
CombatLogType.buff,
),
};
}
/// Phase 9: Act 시네마틱 표시 (Show Act Cinematic) /// Phase 9: Act 시네마틱 표시 (Show Act Cinematic)
Future<void> _showCinematicForAct(StoryAct act) async { Future<void> _showCinematicForAct(StoryAct act) async {
if (_showingCinematic) return; if (_showingCinematic) return;
@@ -329,44 +402,60 @@ class _GamePlayScreenState extends State<GamePlayScreen>
], ],
], ],
), ),
body: Column( body: Stack(
children: [ children: [
// 상단: ASCII 애니메이션 + Task Progress (Phase 7: 고정 4색 팔레트) // 메인 게임 UI
TaskProgressPanel( Column(
progress: state.progress, children: [
speedMultiplier: widget.controller.loop?.speedMultiplier ?? 1, // 상단: ASCII 애니메이션 + Task Progress (Phase 7: 고정 4색 팔레트)
onSpeedCycle: () { TaskProgressPanel(
widget.controller.loop?.cycleSpeed(); progress: state.progress,
setState(() {}); speedMultiplier: widget.controller.loop?.speedMultiplier ?? 1,
}, onSpeedCycle: () {
isPaused: !widget.controller.isRunning, widget.controller.loop?.cycleSpeed();
onPauseToggle: () async { setState(() {});
await widget.controller.togglePause(); },
setState(() {}); isPaused: !widget.controller.isRunning,
}, onPauseToggle: () async {
specialAnimation: _specialAnimation, await widget.controller.togglePause();
weaponName: state.equipment.weapon, setState(() {});
shieldName: state.equipment.shield, },
characterLevel: state.traits.level, specialAnimation: _specialAnimation,
monsterLevel: state.progress.currentTask.monsterLevel, weaponName: state.equipment.weapon,
shieldName: state.equipment.shield,
characterLevel: state.traits.level,
monsterLevel: state.progress.currentTask.monsterLevel,
latestCombatEvent: state.progress.currentCombat?.recentEvents.lastOrNull,
),
// 메인 3패널 영역
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 좌측 패널: Character Sheet
Expanded(flex: 2, child: _buildCharacterPanel(state)),
// 중앙 패널: Equipment/Inventory
Expanded(flex: 3, child: _buildEquipmentPanel(state)),
// 우측 패널: Plot/Quest
Expanded(flex: 2, child: _buildQuestPanel(state)),
],
),
),
],
), ),
// 메인 3패널 영역 // Phase 4: 사망 오버레이 (Death Overlay)
Expanded( if (state.isDead && state.deathInfo != null)
child: Row( DeathOverlay(
crossAxisAlignment: CrossAxisAlignment.stretch, deathInfo: state.deathInfo!,
children: [ traits: state.traits,
// 좌측 패널: Character Sheet onResurrect: () async {
Expanded(flex: 2, child: _buildCharacterPanel(state)), await widget.controller.resurrect();
},
// 중앙 패널: Equipment/Inventory
Expanded(flex: 3, child: _buildEquipmentPanel(state)),
// 우측 패널: Plot/Quest
Expanded(flex: 2, child: _buildQuestPanel(state)),
],
), ),
),
], ],
), ),
), ),
@@ -392,12 +481,22 @@ class _GamePlayScreenState extends State<GamePlayScreen>
_buildSectionHeader(l10n.stats), _buildSectionHeader(l10n.stats),
Expanded(flex: 2, child: StatsPanel(stats: state.stats)), Expanded(flex: 2, child: StatsPanel(stats: state.stats)),
// Phase 8: HP/MP 바 (사망 위험 시 깜빡임) // Phase 8: HP/MP 바 (사망 위험 시 깜빡임, 전투 중 몬스터 HP 표시)
// 전투 중에는 전투 스탯의 HP/MP 사용, 비전투 시 기본 스탯 사용
HpMpBar( HpMpBar(
hpCurrent: state.stats.hp, hpCurrent: state.progress.currentCombat?.playerStats.hpCurrent ??
hpMax: state.stats.hpMax, state.stats.hp,
mpCurrent: state.stats.mp, hpMax: state.progress.currentCombat?.playerStats.hpMax ??
mpMax: state.stats.mpMax, state.stats.hpMax,
mpCurrent: state.progress.currentCombat?.playerStats.mpCurrent ??
state.stats.mp,
mpMax: state.progress.currentCombat?.playerStats.mpMax ??
state.stats.mpMax,
// 전투 중일 때 몬스터 HP 정보 전달
monsterHpCurrent:
state.progress.currentCombat?.monsterStats.hpCurrent,
monsterHpMax: state.progress.currentCombat?.monsterStats.hpMax,
monsterName: state.progress.currentCombat?.monsterStats.name,
), ),
// Experience 바 // Experience 바

View File

@@ -10,6 +10,7 @@ import 'package:askiineverdie/src/core/animation/character_frames.dart';
import 'package:askiineverdie/src/core/animation/monster_size.dart'; import 'package:askiineverdie/src/core/animation/monster_size.dart';
import 'package:askiineverdie/src/core/animation/weapon_category.dart'; import 'package:askiineverdie/src/core/animation/weapon_category.dart';
import 'package:askiineverdie/src/core/constants/ascii_colors.dart'; import 'package:askiineverdie/src/core/constants/ascii_colors.dart';
import 'package:askiineverdie/src/core/model/combat_event.dart';
import 'package:askiineverdie/src/core/model/game_state.dart'; import 'package:askiineverdie/src/core/model/game_state.dart';
/// ASCII 애니메이션 카드 위젯 /// ASCII 애니메이션 카드 위젯
@@ -30,6 +31,7 @@ class AsciiAnimationCard extends StatefulWidget {
this.characterLevel, this.characterLevel,
this.monsterLevel, this.monsterLevel,
this.isPaused = false, this.isPaused = false,
this.latestCombatEvent,
}); });
final TaskType taskType; final TaskType taskType;
@@ -57,6 +59,9 @@ class AsciiAnimationCard extends StatefulWidget {
/// 몬스터 레벨 (몬스터 크기 결정용) /// 몬스터 레벨 (몬스터 크기 결정용)
final int? monsterLevel; final int? monsterLevel;
/// 최근 전투 이벤트 (애니메이션 동기화용)
final CombatEvent? latestCombatEvent;
@override @override
State<AsciiAnimationCard> createState() => _AsciiAnimationCardState(); State<AsciiAnimationCard> createState() => _AsciiAnimationCardState();
} }
@@ -90,6 +95,10 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
int _phaseIndex = 0; int _phaseIndex = 0;
int _phaseFrameCount = 0; int _phaseFrameCount = 0;
// 전투 이벤트 동기화용 (Phase 5)
int? _lastEventTimestamp;
bool _showCriticalEffect = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -124,6 +133,12 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
return; return;
} }
// 전투 이벤트 동기화 (Phase 5)
if (widget.latestCombatEvent != null &&
widget.latestCombatEvent!.timestamp != _lastEventTimestamp) {
_handleCombatEvent(widget.latestCombatEvent!);
}
if (oldWidget.taskType != widget.taskType || if (oldWidget.taskType != widget.taskType ||
oldWidget.monsterBaseName != widget.monsterBaseName || oldWidget.monsterBaseName != widget.monsterBaseName ||
oldWidget.weaponName != widget.weaponName || oldWidget.weaponName != widget.weaponName ||
@@ -133,6 +148,45 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
} }
} }
/// 전투 이벤트에 따라 애니메이션 페이즈 강제 전환 (Phase 5)
void _handleCombatEvent(CombatEvent event) {
_lastEventTimestamp = event.timestamp;
// 전투 모드가 아니면 무시
if (!_isBattleMode) return;
// 이벤트 타입에 따라 페이즈 강제 전환
final (targetPhase, isCritical) = switch (event.type) {
// 플레이어 공격 → attack 페이즈
CombatEventType.playerAttack => (BattlePhase.attack, event.isCritical),
CombatEventType.playerSkill => (BattlePhase.attack, event.isCritical),
// 몬스터 공격/플레이어 피격 → hit 페이즈
CombatEventType.monsterAttack => (BattlePhase.hit, false),
CombatEventType.playerBlock => (BattlePhase.hit, false),
CombatEventType.playerParry => (BattlePhase.hit, false),
// 회피 → recover 페이즈 (빠른 회피 동작)
CombatEventType.playerEvade => (BattlePhase.recover, false),
CombatEventType.monsterEvade => (BattlePhase.idle, false),
// 회복/버프 → idle 페이즈 유지
CombatEventType.playerHeal => (BattlePhase.idle, false),
CombatEventType.playerBuff => (BattlePhase.idle, false),
};
setState(() {
_battlePhase = targetPhase;
_battleSubFrame = 0;
_phaseFrameCount = 0;
_showCriticalEffect = isCritical;
// 페이즈 인덱스 동기화
_phaseIndex = _battlePhaseSequence.indexWhere((p) => p.$1 == targetPhase);
if (_phaseIndex < 0) _phaseIndex = 0;
});
}
/// 현재 상태를 유지하면서 타이머만 재시작 /// 현재 상태를 유지하면서 타이머만 재시작
void _restartTimer() { void _restartTimer() {
_timer?.cancel(); _timer?.cancel();
@@ -286,6 +340,8 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
_phaseIndex = (_phaseIndex + 1) % _battlePhaseSequence.length; _phaseIndex = (_phaseIndex + 1) % _battlePhaseSequence.length;
_phaseFrameCount = 0; _phaseFrameCount = 0;
_battleSubFrame = 0; _battleSubFrame = 0;
// 크리티컬 이펙트 리셋 (페이즈 전환 시)
_showCriticalEffect = false;
} else { } else {
_battleSubFrame++; _battleSubFrame++;
} }
@@ -376,17 +432,23 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
frameText = _animationData.frames[frameIndex]; frameText = _animationData.frames[frameIndex];
} }
// 특수 애니메이션 중이면 테두리 표시 // 테두리 효과 결정 (특수 애니메이션 또는 크리티컬 히트)
final isSpecial = _currentSpecialAnimation != null; final isSpecial = _currentSpecialAnimation != null;
Border? borderEffect;
if (_showCriticalEffect) {
// 크리티컬 히트: 노란색 테두리 (Phase 5)
borderEffect = Border.all(color: Colors.yellow.withValues(alpha: 0.8), width: 2);
} else if (isSpecial) {
// 특수 애니메이션: 시안 테두리
borderEffect = Border.all(color: AsciiColors.positive.withValues(alpha: 0.5));
}
return Container( return Container(
padding: const EdgeInsets.all(4), padding: const EdgeInsets.all(4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: bgColor, color: bgColor,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
border: isSpecial border: borderEffect,
? Border.all(color: AsciiColors.positive.withValues(alpha: 0.5))
: null,
), ),
child: _isBattleMode child: _isBattleMode
? LayoutBuilder( ? LayoutBuilder(

View File

@@ -22,6 +22,12 @@ enum CombatLogType {
questComplete, // 퀘스트 완료 questComplete, // 퀘스트 완료
loot, // 전리품 획득 loot, // 전리품 획득
spell, // 주문 습득 spell, // 주문 습득
critical, // 크리티컬 히트
evade, // 회피
block, // 방패 방어
parry, // 무기 쳐내기
monsterAttack, // 몬스터 공격
buff, // 버프 활성화
} }
/// 전투 로그 위젯 (Phase 8: 실시간 전투 이벤트 표시) /// 전투 로그 위젯 (Phase 8: 실시간 전투 이벤트 표시)
@@ -145,6 +151,12 @@ class _LogEntryTile extends StatelessWidget {
CombatLogType.questComplete => (Colors.blue.shade300, Icons.check_circle), CombatLogType.questComplete => (Colors.blue.shade300, Icons.check_circle),
CombatLogType.loot => (Colors.orange.shade300, Icons.inventory_2), CombatLogType.loot => (Colors.orange.shade300, Icons.inventory_2),
CombatLogType.spell => (Colors.purple.shade300, Icons.auto_fix_high), CombatLogType.spell => (Colors.purple.shade300, Icons.auto_fix_high),
CombatLogType.critical => (Colors.yellow.shade300, Icons.flash_on),
CombatLogType.evade => (Colors.cyan.shade300, Icons.directions_run),
CombatLogType.block => (Colors.blueGrey.shade300, Icons.shield),
CombatLogType.parry => (Colors.teal.shade300, Icons.sports_kabaddi),
CombatLogType.monsterAttack => (Colors.deepOrange.shade300, Icons.dangerous),
CombatLogType.buff => (Colors.lightBlue.shade300, Icons.trending_up),
}; };
} }
} }

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:askiineverdie/src/core/model/combat_event.dart';
import 'package:askiineverdie/src/core/model/game_state.dart'; import 'package:askiineverdie/src/core/model/game_state.dart';
/// 사망 오버레이 위젯 (Phase 4) /// 사망 오버레이 위젯 (Phase 4)
@@ -70,6 +71,15 @@ class DeathOverlay extends StatelessWidget {
// 상실 정보 // 상실 정보
_buildLossInfo(context), _buildLossInfo(context),
// 전투 로그 (있는 경우만 표시)
if (deathInfo.lastCombatEvents.isNotEmpty) ...[
const SizedBox(height: 16),
Divider(color: colorScheme.outlineVariant),
const SizedBox(height: 8),
_buildCombatLog(context),
],
const SizedBox(height: 24), const SizedBox(height: 24),
// 부활 버튼 // 부활 버튼
@@ -169,16 +179,65 @@ class DeathOverlay extends StatelessWidget {
} }
Widget _buildLossInfo(BuildContext context) { Widget _buildLossInfo(BuildContext context) {
final theme = Theme.of(context);
final hasLostItem = deathInfo.lostItemName != null;
return Column( return Column(
children: [ children: [
_buildInfoRow( // 제물로 바친 아이템 표시
context, if (hasLostItem) ...[
icon: Icons.shield_outlined, Container(
label: 'Equipment Lost', padding: const EdgeInsets.all(12),
value: '${deathInfo.lostEquipmentCount} items', decoration: BoxDecoration(
isNegative: true, color: theme.colorScheme.errorContainer.withValues(alpha: 0.2),
), borderRadius: BorderRadius.circular(8),
const SizedBox(height: 8), border: Border.all(
color: theme.colorScheme.error.withValues(alpha: 0.3),
),
),
child: Row(
children: [
Icon(
Icons.local_fire_department,
size: 20,
color: theme.colorScheme.error,
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Sacrificed to Resurrect',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(
deathInfo.lostItemName!,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.error,
),
),
],
),
),
],
),
),
const SizedBox(height: 12),
] else ...[
_buildInfoRow(
context,
icon: Icons.check_circle_outline,
label: 'Equipment',
value: 'No sacrifice needed',
isNegative: false,
),
const SizedBox(height: 8),
],
_buildInfoRow( _buildInfoRow(
context, context,
icon: Icons.monetization_on_outlined, icon: Icons.monetization_on_outlined,
@@ -253,4 +312,118 @@ class DeathOverlay extends StatelessWidget {
), ),
); );
} }
/// 사망 직전 전투 로그 표시
Widget _buildCombatLog(BuildContext context) {
final theme = Theme.of(context);
final events = deathInfo.lastCombatEvents;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Combat Log',
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Container(
constraints: const BoxConstraints(maxHeight: 120),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: ListView.builder(
shrinkWrap: true,
padding: const EdgeInsets.all(8),
itemCount: events.length,
itemBuilder: (context, index) {
final event = events[index];
return _buildCombatEventTile(context, event);
},
),
),
],
);
}
/// 개별 전투 이벤트 타일
Widget _buildCombatEventTile(BuildContext context, CombatEvent event) {
final (icon, color, message) = _formatCombatEvent(event);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Icon(icon, size: 12, color: color),
const SizedBox(width: 6),
Expanded(
child: Text(
message,
style: TextStyle(
fontSize: 11,
color: color,
fontFamily: 'monospace',
),
),
),
],
),
);
}
/// 전투 이벤트를 아이콘, 색상, 메시지로 포맷
(IconData, Color, String) _formatCombatEvent(CombatEvent event) {
return switch (event.type) {
CombatEventType.playerAttack => (
event.isCritical ? Icons.flash_on : Icons.local_fire_department,
event.isCritical ? Colors.yellow.shade300 : Colors.green.shade300,
event.isCritical
? 'CRITICAL! ${event.damage} damage to ${event.targetName}'
: 'Hit ${event.targetName} for ${event.damage} damage',
),
CombatEventType.monsterAttack => (
Icons.dangerous,
Colors.red.shade300,
'${event.targetName} hits you for ${event.damage} damage',
),
CombatEventType.playerEvade => (
Icons.directions_run,
Colors.cyan.shade300,
'Evaded attack from ${event.targetName}',
),
CombatEventType.monsterEvade => (
Icons.directions_run,
Colors.orange.shade300,
'${event.targetName} evaded your attack',
),
CombatEventType.playerBlock => (
Icons.shield,
Colors.blueGrey.shade300,
'Blocked ${event.targetName}\'s attack (${event.damage} reduced)',
),
CombatEventType.playerParry => (
Icons.sports_kabaddi,
Colors.teal.shade300,
'Parried ${event.targetName}\'s attack (${event.damage} reduced)',
),
CombatEventType.playerSkill => (
Icons.auto_fix_high,
Colors.purple.shade300,
'${event.skillName} deals ${event.damage} damage',
),
CombatEventType.playerHeal => (
Icons.healing,
Colors.green.shade300,
'Healed for ${event.healAmount} HP',
),
CombatEventType.playerBuff => (
Icons.trending_up,
Colors.lightBlue.shade300,
'${event.skillName} activated',
),
};
}
} }

View File

@@ -1,8 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// HP/MP 바 위젯 (Phase 8: 사망 위험 시 깜빡임) /// HP/MP 바 위젯 (Phase 8: 변화 시 시각 효과)
/// ///
/// HP가 20% 미만일 때 빨간색 깜빡임 효과 표시 /// - HP가 20% 미만일 때 빨간색 깜빡임
/// - HP/MP 변화 시 색상 플래시 + 변화량 표시
/// - 전투 중 몬스터 HP 바 표시
class HpMpBar extends StatefulWidget { class HpMpBar extends StatefulWidget {
const HpMpBar({ const HpMpBar({
super.key, super.key,
@@ -10,6 +12,9 @@ class HpMpBar extends StatefulWidget {
required this.hpMax, required this.hpMax,
required this.mpCurrent, required this.mpCurrent,
required this.mpMax, required this.mpMax,
this.monsterHpCurrent,
this.monsterHpMax,
this.monsterName,
}); });
final int hpCurrent; final int hpCurrent;
@@ -17,40 +22,111 @@ class HpMpBar extends StatefulWidget {
final int mpCurrent; final int mpCurrent;
final int mpMax; final int mpMax;
/// 전투 중 몬스터 HP (null이면 비전투)
final int? monsterHpCurrent;
final int? monsterHpMax;
final String? monsterName;
@override @override
State<HpMpBar> createState() => _HpMpBarState(); State<HpMpBar> createState() => _HpMpBarState();
} }
class _HpMpBarState extends State<HpMpBar> with SingleTickerProviderStateMixin { class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
late AnimationController _blinkController; late AnimationController _blinkController;
late Animation<double> _blinkAnimation; late Animation<double> _blinkAnimation;
// HP/MP 변화 애니메이션
late AnimationController _hpFlashController;
late AnimationController _mpFlashController;
late Animation<double> _hpFlashAnimation;
late Animation<double> _mpFlashAnimation;
// 변화량 표시용
int _hpChange = 0;
int _mpChange = 0;
bool _hpDamage = false;
bool _mpDamage = false;
// 몬스터 HP 변화 애니메이션
late AnimationController _monsterFlashController;
late Animation<double> _monsterFlashAnimation;
int _monsterHpChange = 0;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// 위험 깜빡임
_blinkController = AnimationController( _blinkController = AnimationController(
duration: const Duration(milliseconds: 500), duration: const Duration(milliseconds: 500),
vsync: this, vsync: this,
); );
_blinkAnimation = Tween<double>(begin: 1.0, end: 0.3).animate( _blinkAnimation = Tween<double>(begin: 1.0, end: 0.3).animate(
CurvedAnimation(parent: _blinkController, curve: Curves.easeInOut), CurvedAnimation(parent: _blinkController, curve: Curves.easeInOut),
); );
// HP 플래시
_hpFlashController = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
_hpFlashAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
CurvedAnimation(parent: _hpFlashController, curve: Curves.easeOut),
);
// MP 플래시
_mpFlashController = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
_mpFlashAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
CurvedAnimation(parent: _mpFlashController, curve: Curves.easeOut),
);
// 몬스터 HP 플래시
_monsterFlashController = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
_monsterFlashAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
CurvedAnimation(parent: _monsterFlashController, curve: Curves.easeOut),
);
_updateBlinkState(); _updateBlinkState();
} }
@override @override
void didUpdateWidget(HpMpBar oldWidget) { void didUpdateWidget(HpMpBar oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
// HP 변화 감지
if (oldWidget.hpCurrent != widget.hpCurrent) {
_hpChange = widget.hpCurrent - oldWidget.hpCurrent;
_hpDamage = _hpChange < 0;
_hpFlashController.forward(from: 0.0);
}
// MP 변화 감지
if (oldWidget.mpCurrent != widget.mpCurrent) {
_mpChange = widget.mpCurrent - oldWidget.mpCurrent;
_mpDamage = _mpChange < 0;
_mpFlashController.forward(from: 0.0);
}
// 몬스터 HP 변화 감지
if (oldWidget.monsterHpCurrent != widget.monsterHpCurrent &&
widget.monsterHpCurrent != null &&
oldWidget.monsterHpCurrent != null) {
_monsterHpChange = widget.monsterHpCurrent! - oldWidget.monsterHpCurrent!;
_monsterFlashController.forward(from: 0.0);
}
_updateBlinkState(); _updateBlinkState();
} }
void _updateBlinkState() { void _updateBlinkState() {
final hpRatio = widget.hpMax > 0 ? widget.hpCurrent / widget.hpMax : 1.0; final hpRatio = widget.hpMax > 0 ? widget.hpCurrent / widget.hpMax : 1.0;
// HP < 20% 시 깜박임 시작
if (hpRatio < 0.2 && hpRatio > 0) { if (hpRatio < 0.2 && hpRatio > 0) {
if (!_blinkController.isAnimating) { if (!_blinkController.isAnimating) {
_blinkController.repeat(reverse: true); _blinkController.repeat(reverse: true);
@@ -64,6 +140,9 @@ class _HpMpBarState extends State<HpMpBar> with SingleTickerProviderStateMixin {
@override @override
void dispose() { void dispose() {
_blinkController.dispose(); _blinkController.dispose();
_hpFlashController.dispose();
_mpFlashController.dispose();
_monsterFlashController.dispose();
super.dispose(); super.dispose();
} }
@@ -72,44 +151,126 @@ class _HpMpBarState extends State<HpMpBar> with SingleTickerProviderStateMixin {
final hpRatio = widget.hpMax > 0 ? widget.hpCurrent / widget.hpMax : 0.0; final hpRatio = widget.hpMax > 0 ? widget.hpCurrent / widget.hpMax : 0.0;
final mpRatio = widget.mpMax > 0 ? widget.mpCurrent / widget.mpMax : 0.0; final mpRatio = widget.mpMax > 0 ? widget.mpCurrent / widget.mpMax : 0.0;
final hasMonster = widget.monsterHpCurrent != null &&
widget.monsterHpMax != null &&
widget.monsterHpMax! > 0;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// HP 바 // HP 바 (플래시 효과 포함)
_buildBar( _buildAnimatedBar(
label: 'HP', label: 'HP',
current: widget.hpCurrent, current: widget.hpCurrent,
max: widget.hpMax, max: widget.hpMax,
ratio: hpRatio, ratio: hpRatio,
color: Colors.red, color: Colors.red,
isLow: hpRatio < 0.2 && hpRatio > 0, isLow: hpRatio < 0.2 && hpRatio > 0,
flashController: _hpFlashAnimation,
change: _hpChange,
isDamage: _hpDamage,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
// MP 바
_buildBar( // MP 바 (플래시 효과 포함)
_buildAnimatedBar(
label: 'MP', label: 'MP',
current: widget.mpCurrent, current: widget.mpCurrent,
max: widget.mpMax, max: widget.mpMax,
ratio: mpRatio, ratio: mpRatio,
color: Colors.blue, color: Colors.blue,
isLow: false, isLow: false,
flashController: _mpFlashAnimation,
change: _mpChange,
isDamage: _mpDamage,
), ),
// 몬스터 HP 바 (전투 중일 때만)
if (hasMonster) ...[
const SizedBox(height: 8),
_buildMonsterBar(),
],
], ],
), ),
); );
} }
Widget _buildAnimatedBar({
required String label,
required int current,
required int max,
required double ratio,
required Color color,
required bool isLow,
required Animation<double> flashController,
required int change,
required bool isDamage,
}) {
return AnimatedBuilder(
animation: Listenable.merge([_blinkAnimation, flashController]),
builder: (context, child) {
// 플래시 색상 (데미지=빨강, 회복=녹색)
final flashColor = isDamage
? Colors.red.withValues(alpha: flashController.value * 0.4)
: Colors.green.withValues(alpha: flashController.value * 0.4);
// 위험 깜빡임 배경
final lowBgColor = isLow
? Colors.red.withValues(alpha: (1 - _blinkAnimation.value) * 0.3)
: Colors.transparent;
return Container(
decoration: BoxDecoration(
color: flashController.value > 0.1 ? flashColor : lowBgColor,
borderRadius: BorderRadius.circular(4),
),
child: Stack(
children: [
_buildBar(label: label, current: current, max: max, ratio: ratio, color: color),
// 플로팅 변화량 텍스트 (위로 떠오르며 사라짐)
if (change != 0 && flashController.value > 0.05)
Positioned(
right: 70,
top: 0,
bottom: 0,
child: Transform.translate(
// 위로 떠오르는 애니메이션 (최대 15픽셀 위로)
offset: Offset(0, -15 * (1 - flashController.value)),
child: Opacity(
opacity: flashController.value,
child: Text(
change > 0 ? '+$change' : '$change',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isDamage ? Colors.red : Colors.green,
shadows: const [
Shadow(color: Colors.black, blurRadius: 3),
Shadow(color: Colors.black, blurRadius: 6),
],
),
),
),
),
),
],
),
);
},
);
}
Widget _buildBar({ Widget _buildBar({
required String label, required String label,
required int current, required int current,
required int max, required int max,
required double ratio, required double ratio,
required Color color, required Color color,
required bool isLow,
}) { }) {
final bar = Row( return Row(
children: [ children: [
SizedBox( SizedBox(
width: 24, width: 24,
@@ -140,24 +301,115 @@ class _HpMpBarState extends State<HpMpBar> with SingleTickerProviderStateMixin {
), ),
], ],
); );
}
// HP < 20% 시 깜박임 효과 적용 /// 몬스터 HP 바
if (isLow) { Widget _buildMonsterBar() {
return AnimatedBuilder( final current = widget.monsterHpCurrent!;
animation: _blinkAnimation, final max = widget.monsterHpMax!;
builder: (context, child) { final ratio = max > 0 ? current / max : 0.0;
return Container( final name = widget.monsterName ?? 'Enemy';
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: (1 - _blinkAnimation.value) * 0.3),
borderRadius: BorderRadius.circular(4),
),
child: child,
);
},
child: bar,
);
}
return bar; return AnimatedBuilder(
animation: _monsterFlashAnimation,
builder: (context, child) {
// 데미지 플래시 (몬스터는 항상 데미지를 받음)
final flashColor = Colors.yellow.withValues(
alpha: _monsterFlashAnimation.value * 0.3,
);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration(
color: _monsterFlashAnimation.value > 0.1
? flashColor
: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
),
child: Stack(
clipBehavior: Clip.none,
children: [
Row(
children: [
// 몬스터 아이콘
const Icon(Icons.pest_control, size: 14, color: Colors.orange),
const SizedBox(width: 4),
// 이름 (Flexible로 공간 부족 시 축소)
Flexible(
flex: 0,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 50),
child: Text(
name.length > 8 ? '${name.substring(0, 6)}...' : name,
style: const TextStyle(
fontSize: 9,
fontWeight: FontWeight.bold,
color: Colors.orange,
),
overflow: TextOverflow.ellipsis,
),
),
),
const SizedBox(width: 4),
// HP 바
Expanded(
child: ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: ratio.clamp(0.0, 1.0),
backgroundColor: Colors.orange.withValues(alpha: 0.2),
valueColor:
const AlwaysStoppedAnimation<Color>(Colors.orange),
minHeight: 8,
),
),
),
const SizedBox(width: 4),
// HP 숫자
Text(
'$current/$max',
style: const TextStyle(fontSize: 8, color: Colors.orange),
textAlign: TextAlign.right,
),
],
),
// 플로팅 데미지 텍스트
if (_monsterHpChange != 0 && _monsterFlashAnimation.value > 0.05)
Positioned(
right: 60,
top: -5,
child: Transform.translate(
offset: Offset(0, -12 * (1 - _monsterFlashAnimation.value)),
child: Opacity(
opacity: _monsterFlashAnimation.value,
child: Text(
_monsterHpChange > 0
? '+$_monsterHpChange'
: '$_monsterHpChange',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: _monsterHpChange < 0
? Colors.yellow
: Colors.green,
shadows: const [
Shadow(color: Colors.black, blurRadius: 3),
Shadow(color: Colors.black, blurRadius: 6),
],
),
),
),
),
),
],
),
);
},
);
} }
} }

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:askiineverdie/l10n/app_localizations.dart'; import 'package:askiineverdie/l10n/app_localizations.dart';
import 'package:askiineverdie/src/core/animation/ascii_animation_type.dart'; import 'package:askiineverdie/src/core/animation/ascii_animation_type.dart';
import 'package:askiineverdie/src/core/model/combat_event.dart';
import 'package:askiineverdie/src/core/model/game_state.dart'; import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/features/game/widgets/ascii_animation_card.dart'; import 'package:askiineverdie/src/features/game/widgets/ascii_animation_card.dart';
@@ -21,6 +22,7 @@ class TaskProgressPanel extends StatelessWidget {
this.shieldName, this.shieldName,
this.characterLevel, this.characterLevel,
this.monsterLevel, this.monsterLevel,
this.latestCombatEvent,
}); });
final ProgressState progress; final ProgressState progress;
@@ -40,6 +42,9 @@ class TaskProgressPanel extends StatelessWidget {
final int? characterLevel; final int? characterLevel;
final int? monsterLevel; final int? monsterLevel;
/// 최근 전투 이벤트 (애니메이션 동기화용, Phase 5)
final CombatEvent? latestCombatEvent;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
@@ -65,6 +70,7 @@ class TaskProgressPanel extends StatelessWidget {
characterLevel: characterLevel, characterLevel: characterLevel,
monsterLevel: monsterLevel, monsterLevel: monsterLevel,
isPaused: isPaused, isPaused: isPaused,
latestCombatEvent: latestCombatEvent,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),

View File

@@ -154,12 +154,12 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
// 게임에 사용할 새 RNG 생성 // 게임에 사용할 새 RNG 생성
final gameSeed = math.Random().nextInt(0x7FFFFFFF); final gameSeed = math.Random().nextInt(0x7FFFFFFF);
// 원본 Main.pas:1380-1381 - 기본 롤 값(CON.Tag, INT.Tag)만 사용 // HP/MP 초기값 계산
// 종족/직업 보너스는 스탯에 적용되지 않음 (UI 힌트용) // 원본 공식: Random(8) + CON/6 → 약 1~10 HP (너무 낮음)
// Put(Stats,'HP Max',Random(8) + CON.Tag div 6); // 수정 공식: 50 + Random(8) + CON → 약 60~76 HP (전투 생존 가능)
// Put(Stats,'MP Max',Random(8) + INT.Tag div 6); // 이유: 원본 PQ는 "항상 승리"하지만 이 게임은 실제 전투로 사망 가능
final hpMax = math.Random().nextInt(8) + _con ~/ 6; final hpMax = 50 + math.Random().nextInt(8) + _con;
final mpMax = math.Random().nextInt(8) + _int ~/ 6; final mpMax = 30 + math.Random().nextInt(8) + _int;
// 원본 Main.pas:1375-1379 - 기본 롤 값 그대로 저장 (보너스 없음) // 원본 Main.pas:1375-1379 - 기본 롤 값 그대로 저장 (보너스 없음)
final finalStats = Stats( final finalStats = Stats(