feat(ui): 게임 위젯 개선

- AsciiAnimationCard 확장
- EnhancedAnimationPanel 개선
- HpMpBar UI 개선
This commit is contained in:
JiWoong Sul
2026-01-14 00:18:10 +09:00
parent d52dea56ea
commit f65bab6312
3 changed files with 242 additions and 70 deletions

View File

@@ -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,
),
),
],
),
);
}
}

View File

@@ -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,
),
),
],

View File

@@ -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,
),
],
),