feat(ui): 게임 위젯 개선
- AsciiAnimationCard 확장 - EnhancedAnimationPanel 개선 - HpMpBar UI 개선
This commit is contained in:
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/src/core/animation/ascii_animation_data.dart';
|
||||
import 'package:asciineverdie/src/shared/widgets/ascii_disintegrate_widget.dart';
|
||||
import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart';
|
||||
import 'package:asciineverdie/src/core/animation/background_layer.dart';
|
||||
import 'package:asciineverdie/src/core/animation/canvas/ascii_canvas_widget.dart';
|
||||
@@ -47,6 +48,8 @@ class AsciiAnimationCard extends StatefulWidget {
|
||||
this.monsterLevel,
|
||||
this.monsterGrade,
|
||||
this.isPaused = false,
|
||||
this.isInCombat = true,
|
||||
this.monsterDied = false,
|
||||
this.latestCombatEvent,
|
||||
this.raceId,
|
||||
this.weaponRarity,
|
||||
@@ -59,6 +62,12 @@ class AsciiAnimationCard extends StatefulWidget {
|
||||
/// 일시정지 상태 (true면 애니메이션 정지)
|
||||
final bool isPaused;
|
||||
|
||||
/// 전투 활성 상태 (false면 kill 태스크여도 walking 애니메이션)
|
||||
final bool isInCombat;
|
||||
|
||||
/// 몬스터 사망 여부 (true면 분해 애니메이션 재생)
|
||||
final bool monsterDied;
|
||||
|
||||
/// 전투 중인 몬스터 기본 이름 (kill 타입일 때만 사용)
|
||||
final String? monsterBaseName;
|
||||
final AsciiColorTheme colorTheme;
|
||||
@@ -162,6 +171,11 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
// 특수 애니메이션 프레임 수는 ascii_animation_type.dart의
|
||||
// specialAnimationFrameCounts 상수 사용
|
||||
|
||||
// 몬스터 사망 분해 애니메이션 상태
|
||||
bool _showDeathAnimation = false;
|
||||
List<String>? _deathAnimationMonsterLines;
|
||||
String? _lastMonsterBaseName;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -204,12 +218,32 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
return;
|
||||
}
|
||||
|
||||
// 몬스터 사망 애니메이션 트리거
|
||||
if (!oldWidget.monsterDied && widget.monsterDied && !_showDeathAnimation) {
|
||||
// 현재 몬스터 프레임 캡처 (분해 애니메이션용)
|
||||
_deathAnimationMonsterLines = _captureMonsterFrame();
|
||||
if (_deathAnimationMonsterLines != null) {
|
||||
setState(() {
|
||||
_showDeathAnimation = true;
|
||||
});
|
||||
return; // 사망 애니메이션 중에는 다른 업데이트 무시
|
||||
}
|
||||
}
|
||||
|
||||
// 사망 애니메이션 중에는 다른 업데이트 무시
|
||||
if (_showDeathAnimation) return;
|
||||
|
||||
// 전투 이벤트 동기화 (Phase 5)
|
||||
if (widget.latestCombatEvent != null &&
|
||||
widget.latestCombatEvent!.timestamp != _lastEventTimestamp) {
|
||||
_handleCombatEvent(widget.latestCombatEvent!);
|
||||
}
|
||||
|
||||
// 몬스터 이름 저장 (사망 시 프레임 캡처용)
|
||||
if (widget.monsterBaseName != null) {
|
||||
_lastMonsterBaseName = widget.monsterBaseName;
|
||||
}
|
||||
|
||||
if (oldWidget.taskType != widget.taskType ||
|
||||
oldWidget.monsterBaseName != widget.monsterBaseName ||
|
||||
oldWidget.weaponName != widget.weaponName ||
|
||||
@@ -218,7 +252,8 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
oldWidget.raceId != widget.raceId ||
|
||||
oldWidget.weaponRarity != widget.weaponRarity ||
|
||||
oldWidget.opponentRaceId != widget.opponentRaceId ||
|
||||
oldWidget.opponentHasShield != widget.opponentHasShield) {
|
||||
oldWidget.opponentHasShield != widget.opponentHasShield ||
|
||||
oldWidget.isInCombat != widget.isInCombat) {
|
||||
_updateAnimation();
|
||||
}
|
||||
}
|
||||
@@ -505,12 +540,18 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
|
||||
switch (animationType) {
|
||||
case AsciiAnimationType.battle:
|
||||
_animationMode = AnimationMode.battle;
|
||||
_setupBattleComposer();
|
||||
_battlePhase = BattlePhase.idle;
|
||||
_battleSubFrame = 0;
|
||||
_phaseIndex = 0;
|
||||
_phaseFrameCount = 0;
|
||||
// 전투 비활성 상태면 walking 모드로 전환 (몬스터 처치 후 이동 중)
|
||||
if (!widget.isInCombat) {
|
||||
_animationMode = AnimationMode.walking;
|
||||
_walkingComposer = CanvasWalkingComposer(raceId: widget.raceId);
|
||||
} else {
|
||||
_animationMode = AnimationMode.battle;
|
||||
_setupBattleComposer();
|
||||
_battlePhase = BattlePhase.idle;
|
||||
_battleSubFrame = 0;
|
||||
_phaseIndex = 0;
|
||||
_phaseFrameCount = 0;
|
||||
}
|
||||
|
||||
case AsciiAnimationType.town:
|
||||
_animationMode = AnimationMode.town;
|
||||
@@ -556,6 +597,31 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 현재 몬스터 프레임을 텍스트 라인으로 캡처 (분해 애니메이션용)
|
||||
List<String>? _captureMonsterFrame() {
|
||||
final monsterName = _lastMonsterBaseName ?? widget.monsterBaseName;
|
||||
if (monsterName == null) return null;
|
||||
|
||||
final monsterCategory = getMonsterCategory(monsterName);
|
||||
final monsterSize = getMonsterSize(widget.monsterLevel);
|
||||
|
||||
// 몬스터 Idle 프레임 가져오기
|
||||
final frames = getMonsterIdleFrames(monsterCategory, monsterSize);
|
||||
if (frames.isEmpty) return null;
|
||||
|
||||
return frames.first;
|
||||
}
|
||||
|
||||
/// 사망 애니메이션 완료 콜백
|
||||
void _onDeathAnimationComplete() {
|
||||
setState(() {
|
||||
_showDeathAnimation = false;
|
||||
_deathAnimationMonsterLines = null;
|
||||
});
|
||||
// Walking 모드로 전환
|
||||
_updateAnimation();
|
||||
}
|
||||
|
||||
void _advanceBattleFrame() {
|
||||
_phaseFrameCount++;
|
||||
final currentPhase = _battlePhaseSequence[_phaseIndex];
|
||||
@@ -617,6 +683,7 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
isDot: _showDotEffect,
|
||||
isBlock: _showBlockEffect,
|
||||
isParry: _showParryEffect,
|
||||
hideMonster: _showDeathAnimation,
|
||||
) ??
|
||||
[AsciiLayer.empty()],
|
||||
AnimationMode.walking =>
|
||||
@@ -676,7 +743,26 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: borderEffect,
|
||||
),
|
||||
child: AsciiCanvasWidget(layers: _composeLayers()),
|
||||
child: Stack(
|
||||
children: [
|
||||
// 기본 애니메이션
|
||||
AsciiCanvasWidget(layers: _composeLayers()),
|
||||
|
||||
// 몬스터 사망 분해 애니메이션 오버레이
|
||||
// 몬스터 위치: 캔버스 60열 중 30~48열 (중앙값 41열)
|
||||
// Alignment x = (41/60) * 2 - 1 = 0.37
|
||||
if (_showDeathAnimation && _deathAnimationMonsterLines != null)
|
||||
Align(
|
||||
alignment: const Alignment(0.37, 0.0),
|
||||
child: AsciiDisintegrateWidget(
|
||||
characterLines: _deathAnimationMonsterLines!,
|
||||
duration: const Duration(milliseconds: 800),
|
||||
textColor: widget.monsterGrade?.displayColor,
|
||||
onComplete: _onDeathAnimationComplete,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,10 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
||||
int _lastMp = 0;
|
||||
int _lastMonsterHp = 0;
|
||||
|
||||
// 몬스터 사망 상태 추적
|
||||
bool _monsterDied = false;
|
||||
bool _wasInCombat = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -145,6 +149,27 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
||||
} else if (newMonsterHp == null) {
|
||||
_lastMonsterHp = 0;
|
||||
}
|
||||
|
||||
// 몬스터 사망 감지: 전투 중 → 비전투 전환 시 몬스터 사망
|
||||
final combat = widget.progress.currentCombat;
|
||||
final isNowInCombat = combat != null && combat.isActive;
|
||||
if (_wasInCombat && !isNowInCombat) {
|
||||
// 전투가 끝났고, 태스크가 여전히 kill이면 몬스터 사망 (플레이어 승리)
|
||||
if (widget.progress.currentTask.type == TaskType.kill) {
|
||||
setState(() {
|
||||
_monsterDied = true;
|
||||
});
|
||||
// 잠시 후 리셋 (애니메이션 완료 후)
|
||||
Future.delayed(const Duration(milliseconds: 900), () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_monsterDied = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
_wasInCombat = isNowInCombat;
|
||||
}
|
||||
|
||||
int get _currentHp =>
|
||||
@@ -197,6 +222,8 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
||||
monsterLevel: widget.monsterLevel,
|
||||
monsterGrade: widget.monsterGrade,
|
||||
isPaused: widget.isPaused,
|
||||
isInCombat: isInCombat,
|
||||
monsterDied: _monsterDied,
|
||||
latestCombatEvent: widget.latestCombatEvent,
|
||||
raceId: widget.raceId,
|
||||
weaponRarity: widget.weaponRarity,
|
||||
@@ -480,10 +507,14 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
||||
}
|
||||
|
||||
/// 몬스터 HP 바 (전투 중)
|
||||
/// - HP바 중앙에 HP% 오버레이
|
||||
/// - 하단에 레벨.이름 표시
|
||||
Widget _buildMonsterHpBar(CombatState combat) {
|
||||
final max = _currentMonsterHpMax ?? 1;
|
||||
final current = _currentMonsterHp ?? 0;
|
||||
final ratio = max > 0 ? current / max : 0.0;
|
||||
final monsterName = combat.monsterStats.name;
|
||||
final monsterLevel = widget.monsterLevel ?? combat.monsterStats.level;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _monsterFlashAnimation,
|
||||
@@ -492,7 +523,7 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
height: 32,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
@@ -501,27 +532,58 @@ class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// HP 바
|
||||
// HP 바 (HP% 중앙 오버레이)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
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(Colors.orange),
|
||||
minHeight: 8,
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// HP 바
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: ratio.clamp(0.0, 1.0),
|
||||
backgroundColor: Colors.orange.withValues(
|
||||
alpha: 0.2,
|
||||
),
|
||||
valueColor: const AlwaysStoppedAnimation(
|
||||
Colors.orange,
|
||||
),
|
||||
minHeight: 12,
|
||||
),
|
||||
),
|
||||
// HP% 중앙 오버레이
|
||||
Text(
|
||||
'${(ratio * 100).toInt()}%',
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black.withValues(alpha: 0.8),
|
||||
blurRadius: 2,
|
||||
),
|
||||
const Shadow(color: Colors.black, blurRadius: 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// 퍼센트
|
||||
Text(
|
||||
'${(ratio * 100).toInt()}%',
|
||||
style: const TextStyle(
|
||||
fontSize: 9,
|
||||
color: Colors.orange,
|
||||
fontWeight: FontWeight.bold,
|
||||
// 레벨.이름 표시
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Text(
|
||||
'Lv.$monsterLevel $monsterName',
|
||||
style: const TextStyle(
|
||||
fontSize: 9,
|
||||
color: Colors.orange,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -19,6 +19,7 @@ class HpMpBar extends StatefulWidget {
|
||||
this.monsterHpCurrent,
|
||||
this.monsterHpMax,
|
||||
this.monsterName,
|
||||
this.monsterLevel,
|
||||
});
|
||||
|
||||
final int hpCurrent;
|
||||
@@ -30,6 +31,7 @@ class HpMpBar extends StatefulWidget {
|
||||
final int? monsterHpCurrent;
|
||||
final int? monsterHpMax;
|
||||
final String? monsterName;
|
||||
final int? monsterLevel;
|
||||
|
||||
@override
|
||||
State<HpMpBar> createState() => _HpMpBarState();
|
||||
@@ -368,11 +370,17 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
|
||||
}
|
||||
|
||||
/// 몬스터 HP 바 (레트로 스타일)
|
||||
/// - HP바 중앙에 HP% 오버레이
|
||||
/// - 하단에 레벨.이름 표시
|
||||
Widget _buildMonsterBar() {
|
||||
final max = widget.monsterHpMax!;
|
||||
final ratio = max > 0 ? widget.monsterHpCurrent! / max : 0.0;
|
||||
const segmentCount = 10;
|
||||
final filledSegments = (ratio.clamp(0.0, 1.0) * segmentCount).round();
|
||||
final levelPrefix = widget.monsterLevel != null
|
||||
? 'Lv.${widget.monsterLevel} '
|
||||
: '';
|
||||
final monsterName = widget.monsterName ?? '';
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _monsterFlashAnimation,
|
||||
@@ -396,62 +404,78 @@ class _HpMpBarState extends State<HpMpBar> with TickerProviderStateMixin {
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Row(
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 몬스터 아이콘
|
||||
const Icon(
|
||||
Icons.pest_control,
|
||||
size: 12,
|
||||
color: RetroColors.gold,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
// 세그먼트 HP 바
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: RetroColors.hpRedDark.withValues(alpha: 0.3),
|
||||
border: Border.all(
|
||||
color: RetroColors.panelBorderOuter,
|
||||
width: 1,
|
||||
// HP 바 (HP% 중앙 오버레이)
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// 세그먼트 HP 바
|
||||
Container(
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: RetroColors.hpRedDark.withValues(alpha: 0.3),
|
||||
border: Border.all(
|
||||
color: RetroColors.panelBorderOuter,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: List.generate(segmentCount, (index) {
|
||||
final isFilled = index < filledSegments;
|
||||
return Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isFilled
|
||||
? RetroColors.gold
|
||||
: RetroColors.panelBorderOuter.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
border: Border(
|
||||
right: index < segmentCount - 1
|
||||
? BorderSide(
|
||||
color: RetroColors.panelBorderOuter
|
||||
.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
)
|
||||
: BorderSide.none,
|
||||
child: Row(
|
||||
children: List.generate(segmentCount, (index) {
|
||||
final isFilled = index < filledSegments;
|
||||
return Expanded(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isFilled
|
||||
? RetroColors.gold
|
||||
: RetroColors.panelBorderOuter.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
border: Border(
|
||||
right: index < segmentCount - 1
|
||||
? BorderSide(
|
||||
color: RetroColors.panelBorderOuter
|
||||
.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
)
|
||||
: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
// HP% 중앙 오버레이
|
||||
Text(
|
||||
'${(ratio * 100).toInt()}%',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 8,
|
||||
color: RetroColors.textLight,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Colors.black.withValues(alpha: 0.8),
|
||||
blurRadius: 2,
|
||||
),
|
||||
const Shadow(color: Colors.black, blurRadius: 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
// HP 퍼센트
|
||||
const SizedBox(height: 4),
|
||||
// 레벨.이름 표시
|
||||
Text(
|
||||
'${(ratio * 100).toInt()}%',
|
||||
'$levelPrefix$monsterName',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 8,
|
||||
fontSize: 7,
|
||||
color: RetroColors.gold,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user