feat(arena): 아레나 전투 화면 및 위젯 개선

- ArenaBattleScreen UI 개선
- 장비 비교 리스트 기능 확장
This commit is contained in:
JiWoong Sul
2026-01-06 18:29:06 +09:00
parent 2efd50a09d
commit afc3c18ae4
2 changed files with 386 additions and 58 deletions

View File

@@ -4,10 +4,12 @@ import 'package:flutter/material.dart';
import 'package:asciineverdie/src/core/engine/arena_service.dart'; import 'package:asciineverdie/src/core/engine/arena_service.dart';
import 'package:asciineverdie/src/core/model/arena_match.dart'; import 'package:asciineverdie/src/core/model/arena_match.dart';
import 'package:asciineverdie/src/core/model/combat_event.dart';
import 'package:asciineverdie/src/core/model/game_state.dart'; import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/core/model/hall_of_fame.dart'; import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
import 'package:asciineverdie/src/features/arena/widgets/arena_result_dialog.dart'; import 'package:asciineverdie/src/features/arena/widgets/arena_result_dialog.dart';
import 'package:asciineverdie/src/features/game/widgets/ascii_animation_card.dart'; import 'package:asciineverdie/src/features/game/widgets/ascii_animation_card.dart';
import 'package:asciineverdie/src/features/game/widgets/combat_log.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart';
// 임시 문자열 (추후 l10n으로 이동) // 임시 문자열 (추후 l10n으로 이동)
@@ -43,16 +45,20 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
/// 현재 턴 /// 현재 턴
int _currentTurn = 0; int _currentTurn = 0;
/// 도전자 HP /// 도전자 HP/MP
late int _challengerHp; late int _challengerHp;
late int _challengerHpMax; late int _challengerHpMax;
late int _challengerMp;
late int _challengerMpMax;
/// 상대 HP /// 상대 HP/MP
late int _opponentHp; late int _opponentHp;
late int _opponentHpMax; late int _opponentHpMax;
late int _opponentMp;
late int _opponentMpMax;
/// 전투 로그 /// 전투 로그 (CombatLogEntry 사용)
final List<String> _battleLog = []; final List<CombatLogEntry> _battleLog = [];
/// 전투 시뮬레이션 스트림 구독 /// 전투 시뮬레이션 스트림 구독
StreamSubscription<ArenaCombatTurn>? _combatSubscription; StreamSubscription<ArenaCombatTurn>? _combatSubscription;
@@ -70,15 +76,31 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
int _challengerHpChange = 0; int _challengerHpChange = 0;
int _opponentHpChange = 0; int _opponentHpChange = 0;
/// 최신 전투 이벤트 (테두리 이펙트용)
CombatEvent? _latestCombatEvent;
/// 전투 이벤트 아이콘 타이머 (페이드 아웃용)
Timer? _eventIconTimer;
/// 현재 표시 중인 이벤트 아이콘 타입
CombatEventType? _currentEventIcon;
/// 현재 표시 중인 스킬 이름
String? _currentSkillName;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// HP 초기화 // HP/MP 초기화
_challengerHpMax = widget.match.challenger.finalStats?.hpMax ?? 100; _challengerHpMax = widget.match.challenger.finalStats?.hpMax ?? 100;
_challengerHp = _challengerHpMax; _challengerHp = _challengerHpMax;
_challengerMpMax = widget.match.challenger.finalStats?.mpMax ?? 50;
_challengerMp = _challengerMpMax;
_opponentHpMax = widget.match.opponent.finalStats?.hpMax ?? 100; _opponentHpMax = widget.match.opponent.finalStats?.hpMax ?? 100;
_opponentHp = _opponentHpMax; _opponentHp = _opponentHpMax;
_opponentMpMax = widget.match.opponent.finalStats?.mpMax ?? 50;
_opponentMp = _opponentMpMax;
// 플래시 애니메이션 초기화 // 플래시 애니메이션 초기화
_challengerFlashController = AnimationController( _challengerFlashController = AnimationController(
@@ -104,6 +126,7 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
@override @override
void dispose() { void dispose() {
_combatSubscription?.cancel(); _combatSubscription?.cancel();
_eventIconTimer?.cancel();
_challengerFlashController.dispose(); _challengerFlashController.dispose();
_opponentFlashController.dispose(); _opponentFlashController.dispose();
super.dispose(); super.dispose();
@@ -128,6 +151,8 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
_currentTurn++; _currentTurn++;
_challengerHp = turn.challengerHp; _challengerHp = turn.challengerHp;
_opponentHp = turn.opponentHp; _opponentHp = turn.opponentHp;
_challengerMp = turn.challengerMp ?? _challengerMp;
_opponentMp = turn.opponentMp ?? _opponentMp;
// 도전자 HP 변화 감지 // 도전자 HP 변화 감지
if (oldChallengerHp != _challengerHp) { if (oldChallengerHp != _challengerHp) {
@@ -141,29 +166,210 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
_opponentFlashController.forward(from: 0.0); _opponentFlashController.forward(from: 0.0);
} }
// 로그 추가 // 도전자 스킬 사용 로그
if (turn.challengerSkillUsed != null) {
_battleLog.add(CombatLogEntry(
message: '${widget.match.challenger.characterName} uses '
'${turn.challengerSkillUsed}!',
timestamp: DateTime.now(),
type: CombatLogType.spell,
));
}
// 도전자 회복 로그
if (turn.challengerHealAmount != null && turn.challengerHealAmount! > 0) {
_battleLog.add(CombatLogEntry(
message: '${widget.match.challenger.characterName} heals '
'${turn.challengerHealAmount} HP!',
timestamp: DateTime.now(),
type: CombatLogType.heal,
));
}
// 로그 추가 (CombatLogEntry 사용)
if (turn.challengerDamage != null) { if (turn.challengerDamage != null) {
final type = turn.isChallengerCritical
? CombatLogType.critical
: CombatLogType.damage;
final critText = turn.isChallengerCritical ? ' CRITICAL!' : ''; final critText = turn.isChallengerCritical ? ' CRITICAL!' : '';
final evadeText = turn.isOpponentEvaded ? ' (Evaded)' : ''; final skillText = turn.challengerSkillUsed != null ? '' : '';
final blockText = turn.isOpponentBlocked ? ' (Blocked)' : ''; _battleLog.add(CombatLogEntry(
_battleLog.add( message: '${widget.match.challenger.characterName} deals '
'${widget.match.challenger.characterName} deals ' '${turn.challengerDamage}$critText$skillText',
'${turn.challengerDamage}$critText$evadeText$blockText', timestamp: DateTime.now(),
); type: type,
));
}
// 상대 회피/블록 이벤트
if (turn.isOpponentEvaded) {
_battleLog.add(CombatLogEntry(
message: '${widget.match.opponent.characterName} evaded!',
timestamp: DateTime.now(),
type: CombatLogType.evade,
));
}
if (turn.isOpponentBlocked) {
_battleLog.add(CombatLogEntry(
message: '${widget.match.opponent.characterName} blocked!',
timestamp: DateTime.now(),
type: CombatLogType.block,
));
}
// 상대 스킬 사용 로그
if (turn.opponentSkillUsed != null) {
_battleLog.add(CombatLogEntry(
message: '${widget.match.opponent.characterName} uses '
'${turn.opponentSkillUsed}!',
timestamp: DateTime.now(),
type: CombatLogType.spell,
));
}
// 상대 회복 로그
if (turn.opponentHealAmount != null && turn.opponentHealAmount! > 0) {
_battleLog.add(CombatLogEntry(
message: '${widget.match.opponent.characterName} heals '
'${turn.opponentHealAmount} HP!',
timestamp: DateTime.now(),
type: CombatLogType.heal,
));
} }
if (turn.opponentDamage != null) { if (turn.opponentDamage != null) {
final type = turn.isOpponentCritical
? CombatLogType.critical
: CombatLogType.monsterAttack;
final critText = turn.isOpponentCritical ? ' CRITICAL!' : ''; final critText = turn.isOpponentCritical ? ' CRITICAL!' : '';
final evadeText = turn.isChallengerEvaded ? ' (Evaded)' : ''; _battleLog.add(CombatLogEntry(
final blockText = turn.isChallengerBlocked ? ' (Blocked)' : ''; message: '${widget.match.opponent.characterName} deals '
_battleLog.add( '${turn.opponentDamage}$critText',
'${widget.match.opponent.characterName} deals ' timestamp: DateTime.now(),
'${turn.opponentDamage}$critText$evadeText$blockText', type: type,
); ));
}
// 도전자 회피/블록 이벤트
if (turn.isChallengerEvaded) {
_battleLog.add(CombatLogEntry(
message: '${widget.match.challenger.characterName} evaded!',
timestamp: DateTime.now(),
type: CombatLogType.evade,
));
}
if (turn.isChallengerBlocked) {
_battleLog.add(CombatLogEntry(
message: '${widget.match.challenger.characterName} blocked!',
timestamp: DateTime.now(),
type: CombatLogType.block,
));
}
// 전투 이벤트 생성 (테두리 이펙트용)
_latestCombatEvent = _createCombatEvent(turn);
// 전투 이벤트 아이콘 표시
_showEventIcon(turn);
});
}
/// 전투 이벤트 아이콘 표시 (일정 시간 후 사라짐)
void _showEventIcon(ArenaCombatTurn turn) {
// 이전 타이머 취소
_eventIconTimer?.cancel();
// 스킬 이름 저장
_currentSkillName = turn.challengerSkillUsed ?? turn.opponentSkillUsed;
// 이벤트 타입 결정 (우선순위: 스킬 > 크리티컬 > 블록 > 회피 > 일반공격)
CombatEventType? eventType;
if (_currentSkillName != null) {
eventType = CombatEventType.playerSkill;
} else if (turn.isChallengerCritical || turn.isOpponentCritical) {
eventType = CombatEventType.playerAttack; // 크리티컬 표시용
} else if (turn.isChallengerBlocked || turn.isOpponentBlocked) {
eventType = CombatEventType.playerBlock;
} else if (turn.isChallengerEvaded || turn.isOpponentEvaded) {
eventType = CombatEventType.playerEvade;
} else if (turn.challengerDamage != null || turn.opponentDamage != null) {
eventType = CombatEventType.playerAttack;
}
_currentEventIcon = eventType;
// 1초 후 아이콘 숨김
_eventIconTimer = Timer(const Duration(milliseconds: 800), () {
if (mounted) {
setState(() {
_currentEventIcon = null;
_currentSkillName = null;
});
} }
}); });
} }
/// ArenaCombatTurn에서 CombatEvent 생성 (테두리 이펙트용)
CombatEvent? _createCombatEvent(ArenaCombatTurn turn) {
final timestamp = DateTime.now().millisecondsSinceEpoch;
// 도전자 스킬 사용 (보라색 테두리)
if (turn.challengerSkillUsed != null && turn.challengerDamage != null) {
return CombatEvent.playerSkill(
timestamp: timestamp,
skillName: turn.challengerSkillUsed!,
damage: turn.challengerDamage!,
targetName: widget.match.opponent.characterName,
isCritical: turn.isChallengerCritical,
);
}
// 도전자 공격 이벤트 (우선순위: 크리티컬 > 일반 공격)
if (turn.challengerDamage != null) {
return CombatEvent.playerAttack(
timestamp: timestamp,
damage: turn.challengerDamage!,
targetName: widget.match.opponent.characterName,
isCritical: turn.isChallengerCritical,
);
}
// 도전자 회복 이벤트
if (turn.challengerHealAmount != null && turn.challengerSkillUsed != null) {
return CombatEvent.playerHeal(
timestamp: timestamp,
healAmount: turn.challengerHealAmount!,
skillName: turn.challengerSkillUsed,
);
}
// 도전자 방어 이벤트 (회피/블록)
if (turn.isChallengerEvaded) {
return CombatEvent.playerEvade(
timestamp: timestamp,
attackerName: widget.match.opponent.characterName,
);
}
if (turn.isChallengerBlocked) {
return CombatEvent.playerBlock(
timestamp: timestamp,
reducedDamage: turn.opponentDamage ?? 0,
attackerName: widget.match.opponent.characterName,
);
}
// 상대 공격 이벤트 (몬스터 공격으로 처리)
if (turn.opponentDamage != null) {
return CombatEvent.monsterAttack(
timestamp: timestamp,
damage: turn.opponentDamage!,
attackerName: widget.match.opponent.characterName,
);
}
return null;
}
void _endBattle() { void _endBattle() {
// 최종 결과 계산 // 최종 결과 계산
_result = _arenaService.executeCombat(widget.match); _result = _arenaService.executeCombat(widget.match);
@@ -205,6 +411,8 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
_buildTurnIndicator(), _buildTurnIndicator(),
// HP 바 (레트로 세그먼트 스타일) // HP 바 (레트로 세그먼트 스타일)
_buildRetroHpBars(), _buildRetroHpBars(),
// 전투 이벤트 아이콘 (HP 바와 애니메이션 사이)
_buildCombatEventIcons(),
// ASCII 애니메이션 (중앙) - 기존 AsciiAnimationCard 재사용 // ASCII 애니메이션 (중앙) - 기존 AsciiAnimationCard 재사용
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
@@ -216,6 +424,7 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
shieldName: _hasShield(widget.match.challenger) ? 'shield' : null, shieldName: _hasShield(widget.match.challenger) ? 'shield' : null,
opponentRaceId: widget.match.opponent.race, opponentRaceId: widget.match.opponent.race,
opponentHasShield: _hasShield(widget.match.opponent), opponentHasShield: _hasShield(widget.match.opponent),
latestCombatEvent: _latestCombatEvent,
), ),
), ),
), ),
@@ -489,30 +698,98 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
Widget _buildBattleLog() { Widget _buildBattleLog() {
return Container( return Container(
margin: const EdgeInsets.all(12), margin: const EdgeInsets.all(12),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: RetroColors.panelBgOf(context), color: RetroColors.panelBgOf(context),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all(color: RetroColors.borderOf(context)), border: Border.all(color: RetroColors.borderOf(context)),
), ),
child: ListView.builder( child: CombatLog(entries: _battleLog),
reverse: true, );
itemCount: _battleLog.length, }
itemBuilder: (context, index) {
final reversedIndex = _battleLog.length - 1 - index; /// 전투 이벤트 아이콘 영역 (HP 바와 애니메이션 사이)
return Padding( ///
padding: const EdgeInsets.symmetric(vertical: 2), /// 메인 게임의 _buildBuffIcons() 스타일을 따름
child: Text( /// 스킬 사용, 크리티컬, 블록, 회피 표시
_battleLog[reversedIndex], Widget _buildCombatEventIcons() {
style: TextStyle( // 스킬 사용 또는 특수 액션만 표시
fontFamily: 'JetBrainsMono', final hasSpecialEvent = _currentSkillName != null ||
fontSize: 7, _latestCombatEvent?.isCritical == true ||
color: RetroColors.textSecondaryOf(context), _currentEventIcon == CombatEventType.playerBlock ||
), _currentEventIcon == CombatEventType.playerEvade ||
_currentEventIcon == CombatEventType.playerParry ||
_currentEventIcon == CombatEventType.playerSkill;
if (!hasSpecialEvent) {
return const SizedBox(height: 28);
}
// 이벤트 타입에 따른 아이콘/색상 결정
final (icon, color) = _getEventIconData();
return AnimatedOpacity(
opacity: _currentEventIcon != null ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: SizedBox(
height: 28,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 버프 아이콘 스타일 (CircularProgressIndicator)
Stack(
alignment: Alignment.center,
children: [
// 원형 진행률 표시 (펄스 효과용)
SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
value: 1.0,
strokeWidth: 2,
backgroundColor: Colors.grey.shade700,
valueColor: AlwaysStoppedAnimation(color),
),
),
// 아이콘
Icon(icon, size: 12, color: color),
],
), ),
); // 스킬 이름 표시
}, if (_currentSkillName != null) ...[
const SizedBox(width: 6),
Text(
_currentSkillName!,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 6,
color: color,
),
),
],
],
),
), ),
); );
} }
/// 이벤트 타입에 따른 아이콘, 색상 반환
(IconData, Color) _getEventIconData() {
// 스킬 사용
if (_currentSkillName != null ||
_currentEventIcon == CombatEventType.playerSkill) {
return (Icons.auto_fix_high, Colors.purple);
}
// 크리티컬 체크 (latestCombatEvent에서)
if (_latestCombatEvent?.isCritical == true) {
return (Icons.flash_on, Colors.yellow.shade600);
}
return switch (_currentEventIcon) {
CombatEventType.playerBlock => (Icons.shield, Colors.blue),
CombatEventType.playerEvade => (Icons.directions_run, Colors.cyan),
CombatEventType.playerParry => (Icons.sports_kabaddi, Colors.purple),
_ => (Icons.trending_up, Colors.lightBlue),
};
}
} }

View File

@@ -9,7 +9,7 @@ import 'package:asciineverdie/src/shared/retro_colors.dart';
// 임시 문자열 (추후 l10n으로 이동) // 임시 문자열 (추후 l10n으로 이동)
const _myEquipmentTitle = 'MY EQUIPMENT'; const _myEquipmentTitle = 'MY EQUIPMENT';
const _enemyEquipmentTitle = 'ENEMY EQUIPMENT'; const _enemyEquipmentTitle = 'ENEMY EQUIPMENT';
const _selectSlotLabel = 'SELECT'; const _selectedLabel = 'SELECTED';
const _recommendedLabel = 'BEST'; const _recommendedLabel = 'BEST';
/// 좌우 대칭 장비 비교 리스트 /// 좌우 대칭 장비 비교 리스트
@@ -50,6 +50,41 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
/// 현재 확장된 슬롯 (탭하여 비교 중인 슬롯) /// 현재 확장된 슬롯 (탭하여 비교 중인 슬롯)
EquipmentSlot? _expandedSlot; EquipmentSlot? _expandedSlot;
/// 스크롤 컨트롤러 (자동 스크롤용)
final ScrollController _scrollController = ScrollController();
/// 슬롯별 행 높이 (대략적 계산용)
static const double _rowHeight = 40.0;
static const double _expandedHeight = 200.0;
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
/// 선택된 슬롯으로 자동 스크롤
void _scrollToSlot(EquipmentSlot slot) {
final index = EquipmentSlot.values.indexOf(slot);
if (index < 0) return;
// 현재 확장된 슬롯까지의 높이 계산
double targetOffset = 0;
for (int i = 0; i < index; i++) {
targetOffset += _rowHeight;
if (_expandedSlot == EquipmentSlot.values[i]) {
targetOffset += _expandedHeight;
}
}
// 부드럽게 스크롤
_scrollController.animateTo(
targetOffset,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
@@ -60,6 +95,7 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
// 장비 리스트 // 장비 리스트
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(
controller: _scrollController,
itemCount: EquipmentSlot.values.length, itemCount: EquipmentSlot.values.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final slot = EquipmentSlot.values[index]; final slot = EquipmentSlot.values[index];
@@ -137,9 +173,17 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
// 슬롯 행 (좌우 대칭) // 슬롯 행 (좌우 대칭)
GestureDetector( GestureDetector(
onTap: () { onTap: () {
// 탭하면 즉시 선택 + 확장 + 자동 스크롤
widget.onSlotSelected(slot);
setState(() { setState(() {
_expandedSlot = isExpanded ? null : slot; _expandedSlot = isExpanded ? null : slot;
}); });
// 확장될 때만 스크롤 (다음 프레임에서 실행)
if (!isExpanded) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToSlot(slot);
});
}
}, },
child: Container( child: Container(
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4), padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4),
@@ -393,30 +437,37 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
), ),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
// 선택 버튼 // 선택됨 인디케이터 (SELECT 버튼 대신)
SizedBox( Container(
width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: ElevatedButton( decoration: BoxDecoration(
onPressed: () { color: RetroColors.goldOf(context).withValues(alpha: 0.2),
widget.onSlotSelected(slot); borderRadius: BorderRadius.circular(4),
setState(() => _expandedSlot = null); border: Border.all(
}, color: RetroColors.goldOf(context),
style: ElevatedButton.styleFrom( width: 2,
backgroundColor: RetroColors.goldOf(context),
foregroundColor: RetroColors.backgroundOf(context),
padding: const EdgeInsets.symmetric(vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
), ),
child: Text( ),
_selectSlotLabel, child: Row(
style: TextStyle( mainAxisAlignment: MainAxisAlignment.center,
fontFamily: 'PressStart2P', mainAxisSize: MainAxisSize.min,
fontSize: 7, children: [
color: RetroColors.backgroundOf(context), Icon(
Icons.check_circle,
color: RetroColors.goldOf(context),
size: 16,
), ),
), const SizedBox(width: 6),
Text(
_selectedLabel,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 7,
color: RetroColors.goldOf(context),
fontWeight: FontWeight.bold,
),
),
],
), ),
), ),
], ],