diff --git a/lib/src/features/arena/arena_battle_screen.dart b/lib/src/features/arena/arena_battle_screen.dart index 121896a..fb8dfbc 100644 --- a/lib/src/features/arena/arena_battle_screen.dart +++ b/lib/src/features/arena/arena_battle_screen.dart @@ -4,10 +4,12 @@ import 'package:flutter/material.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/combat_event.dart'; import 'package:asciineverdie/src/core/model/game_state.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/game/widgets/ascii_animation_card.dart'; +import 'package:asciineverdie/src/features/game/widgets/combat_log.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart'; // 임시 문자열 (추후 l10n으로 이동) @@ -43,16 +45,20 @@ class _ArenaBattleScreenState extends State /// 현재 턴 int _currentTurn = 0; - /// 도전자 HP + /// 도전자 HP/MP late int _challengerHp; late int _challengerHpMax; + late int _challengerMp; + late int _challengerMpMax; - /// 상대 HP + /// 상대 HP/MP late int _opponentHp; late int _opponentHpMax; + late int _opponentMp; + late int _opponentMpMax; - /// 전투 로그 - final List _battleLog = []; + /// 전투 로그 (CombatLogEntry 사용) + final List _battleLog = []; /// 전투 시뮬레이션 스트림 구독 StreamSubscription? _combatSubscription; @@ -70,15 +76,31 @@ class _ArenaBattleScreenState extends State int _challengerHpChange = 0; int _opponentHpChange = 0; + /// 최신 전투 이벤트 (테두리 이펙트용) + CombatEvent? _latestCombatEvent; + + /// 전투 이벤트 아이콘 타이머 (페이드 아웃용) + Timer? _eventIconTimer; + + /// 현재 표시 중인 이벤트 아이콘 타입 + CombatEventType? _currentEventIcon; + + /// 현재 표시 중인 스킬 이름 + String? _currentSkillName; + @override void initState() { super.initState(); - // HP 초기화 + // HP/MP 초기화 _challengerHpMax = widget.match.challenger.finalStats?.hpMax ?? 100; _challengerHp = _challengerHpMax; + _challengerMpMax = widget.match.challenger.finalStats?.mpMax ?? 50; + _challengerMp = _challengerMpMax; _opponentHpMax = widget.match.opponent.finalStats?.hpMax ?? 100; _opponentHp = _opponentHpMax; + _opponentMpMax = widget.match.opponent.finalStats?.mpMax ?? 50; + _opponentMp = _opponentMpMax; // 플래시 애니메이션 초기화 _challengerFlashController = AnimationController( @@ -104,6 +126,7 @@ class _ArenaBattleScreenState extends State @override void dispose() { _combatSubscription?.cancel(); + _eventIconTimer?.cancel(); _challengerFlashController.dispose(); _opponentFlashController.dispose(); super.dispose(); @@ -128,6 +151,8 @@ class _ArenaBattleScreenState extends State _currentTurn++; _challengerHp = turn.challengerHp; _opponentHp = turn.opponentHp; + _challengerMp = turn.challengerMp ?? _challengerMp; + _opponentMp = turn.opponentMp ?? _opponentMp; // 도전자 HP 변화 감지 if (oldChallengerHp != _challengerHp) { @@ -141,29 +166,210 @@ class _ArenaBattleScreenState extends State _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) { + final type = turn.isChallengerCritical + ? CombatLogType.critical + : CombatLogType.damage; final critText = turn.isChallengerCritical ? ' CRITICAL!' : ''; - final evadeText = turn.isOpponentEvaded ? ' (Evaded)' : ''; - final blockText = turn.isOpponentBlocked ? ' (Blocked)' : ''; - _battleLog.add( - '${widget.match.challenger.characterName} deals ' - '${turn.challengerDamage}$critText$evadeText$blockText', - ); + final skillText = turn.challengerSkillUsed != null ? '' : ''; + _battleLog.add(CombatLogEntry( + message: '${widget.match.challenger.characterName} deals ' + '${turn.challengerDamage}$critText$skillText', + 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) { + final type = turn.isOpponentCritical + ? CombatLogType.critical + : CombatLogType.monsterAttack; final critText = turn.isOpponentCritical ? ' CRITICAL!' : ''; - final evadeText = turn.isChallengerEvaded ? ' (Evaded)' : ''; - final blockText = turn.isChallengerBlocked ? ' (Blocked)' : ''; - _battleLog.add( - '${widget.match.opponent.characterName} deals ' - '${turn.opponentDamage}$critText$evadeText$blockText', - ); + _battleLog.add(CombatLogEntry( + message: '${widget.match.opponent.characterName} deals ' + '${turn.opponentDamage}$critText', + timestamp: DateTime.now(), + 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() { // 최종 결과 계산 _result = _arenaService.executeCombat(widget.match); @@ -205,6 +411,8 @@ class _ArenaBattleScreenState extends State _buildTurnIndicator(), // HP 바 (레트로 세그먼트 스타일) _buildRetroHpBars(), + // 전투 이벤트 아이콘 (HP 바와 애니메이션 사이) + _buildCombatEventIcons(), // ASCII 애니메이션 (중앙) - 기존 AsciiAnimationCard 재사용 Padding( padding: const EdgeInsets.symmetric(horizontal: 8), @@ -216,6 +424,7 @@ class _ArenaBattleScreenState extends State shieldName: _hasShield(widget.match.challenger) ? 'shield' : null, opponentRaceId: widget.match.opponent.race, opponentHasShield: _hasShield(widget.match.opponent), + latestCombatEvent: _latestCombatEvent, ), ), ), @@ -489,30 +698,98 @@ class _ArenaBattleScreenState extends State Widget _buildBattleLog() { return Container( margin: const EdgeInsets.all(12), - padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: RetroColors.panelBgOf(context), borderRadius: BorderRadius.circular(8), border: Border.all(color: RetroColors.borderOf(context)), ), - child: ListView.builder( - reverse: true, - itemCount: _battleLog.length, - itemBuilder: (context, index) { - final reversedIndex = _battleLog.length - 1 - index; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Text( - _battleLog[reversedIndex], - style: TextStyle( - fontFamily: 'JetBrainsMono', - fontSize: 7, - color: RetroColors.textSecondaryOf(context), - ), + child: CombatLog(entries: _battleLog), + ); + } + + /// 전투 이벤트 아이콘 영역 (HP 바와 애니메이션 사이) + /// + /// 메인 게임의 _buildBuffIcons() 스타일을 따름 + /// 스킬 사용, 크리티컬, 블록, 회피 표시 + Widget _buildCombatEventIcons() { + // 스킬 사용 또는 특수 액션만 표시 + final hasSpecialEvent = _currentSkillName != null || + _latestCombatEvent?.isCritical == true || + _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), + }; + } } diff --git a/lib/src/features/arena/widgets/arena_equipment_compare_list.dart b/lib/src/features/arena/widgets/arena_equipment_compare_list.dart index 98f1441..203b46a 100644 --- a/lib/src/features/arena/widgets/arena_equipment_compare_list.dart +++ b/lib/src/features/arena/widgets/arena_equipment_compare_list.dart @@ -9,7 +9,7 @@ import 'package:asciineverdie/src/shared/retro_colors.dart'; // 임시 문자열 (추후 l10n으로 이동) const _myEquipmentTitle = 'MY EQUIPMENT'; const _enemyEquipmentTitle = 'ENEMY EQUIPMENT'; -const _selectSlotLabel = 'SELECT'; +const _selectedLabel = 'SELECTED'; const _recommendedLabel = 'BEST'; /// 좌우 대칭 장비 비교 리스트 @@ -50,6 +50,41 @@ class _ArenaEquipmentCompareListState extends State { /// 현재 확장된 슬롯 (탭하여 비교 중인 슬롯) 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 Widget build(BuildContext context) { return Column( @@ -60,6 +95,7 @@ class _ArenaEquipmentCompareListState extends State { // 장비 리스트 Expanded( child: ListView.builder( + controller: _scrollController, itemCount: EquipmentSlot.values.length, itemBuilder: (context, index) { final slot = EquipmentSlot.values[index]; @@ -137,9 +173,17 @@ class _ArenaEquipmentCompareListState extends State { // 슬롯 행 (좌우 대칭) GestureDetector( onTap: () { + // 탭하면 즉시 선택 + 확장 + 자동 스크롤 + widget.onSlotSelected(slot); setState(() { _expandedSlot = isExpanded ? null : slot; }); + // 확장될 때만 스크롤 (다음 프레임에서 실행) + if (!isExpanded) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToSlot(slot); + }); + } }, child: Container( padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4), @@ -393,30 +437,37 @@ class _ArenaEquipmentCompareListState extends State { ), ), const SizedBox(height: 10), - // 선택 버튼 - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () { - widget.onSlotSelected(slot); - setState(() => _expandedSlot = null); - }, - style: ElevatedButton.styleFrom( - backgroundColor: RetroColors.goldOf(context), - foregroundColor: RetroColors.backgroundOf(context), - padding: const EdgeInsets.symmetric(vertical: 8), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), - ), + // 선택됨 인디케이터 (SELECT 버튼 대신) + Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + decoration: BoxDecoration( + color: RetroColors.goldOf(context).withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: RetroColors.goldOf(context), + width: 2, ), - child: Text( - _selectSlotLabel, - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 7, - color: RetroColors.backgroundOf(context), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + 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, + ), + ), + ], ), ), ],