From 104d23cdfd22180a9f289b3e0b3528dbc14e002f Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Mon, 12 Jan 2026 16:17:16 +0900 Subject: [PATCH] =?UTF-8?q?refactor(arena):=20=EC=95=84=EB=A0=88=EB=82=98?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=EB=B0=8F=20=EC=9C=84=EC=A0=AF=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/arena/arena_battle_screen.dart | 274 ++++++++++-------- lib/src/features/arena/arena_screen.dart | 6 +- .../features/arena/arena_setup_screen.dart | 20 +- .../widgets/arena_equipment_compare_list.dart | 43 ++- .../arena/widgets/arena_rank_card.dart | 4 +- .../arena/widgets/arena_result_dialog.dart | 37 +-- .../arena/widgets/arena_result_panel.dart | 138 +++++++-- .../widgets/ascii_disintegrate_widget.dart | 44 +-- 8 files changed, 336 insertions(+), 230 deletions(-) diff --git a/lib/src/features/arena/arena_battle_screen.dart b/lib/src/features/arena/arena_battle_screen.dart index 75d2107..110ff93 100644 --- a/lib/src/features/arena/arena_battle_screen.dart +++ b/lib/src/features/arena/arena_battle_screen.dart @@ -18,7 +18,6 @@ import 'package:asciineverdie/src/shared/retro_colors.dart'; // 임시 문자열 (추후 l10n으로 이동) const _battleTitle = 'ARENA BATTLE'; const _hpLabel = 'HP'; -const _turnLabel = 'TURN'; /// 아레나 전투 화면 /// @@ -48,6 +47,9 @@ class _ArenaBattleScreenState extends State /// 현재 턴 int _currentTurn = 0; + /// 전투 시작 시간 (경과 시간 계산용) + DateTime? _battleStartTime; + /// 도전자 HP/MP late int _challengerHp; late int _challengerHpMax; @@ -114,7 +116,10 @@ class _ArenaBattleScreenState extends State vsync: this, ); _challengerFlashAnimation = Tween(begin: 1.0, end: 0.0).animate( - CurvedAnimation(parent: _challengerFlashController, curve: Curves.easeOut), + CurvedAnimation( + parent: _challengerFlashController, + curve: Curves.easeOut, + ), ); _opponentFlashController = AnimationController( @@ -139,14 +144,17 @@ class _ArenaBattleScreenState extends State } void _startBattle() { - _combatSubscription = _arenaService.simulateCombat(widget.match).listen( - (turn) { - _processTurn(turn); - }, - onDone: () { - _endBattle(); - }, - ); + _battleStartTime = DateTime.now(); + _combatSubscription = _arenaService + .simulateCombat(widget.match) + .listen( + (turn) { + _processTurn(turn); + }, + onDone: () { + _endBattle(); + }, + ); } void _processTurn(ArenaCombatTurn turn) { @@ -174,22 +182,28 @@ class _ArenaBattleScreenState extends State // 도전자 스킬 사용 로그 if (turn.challengerSkillUsed != null) { - _battleLog.add(CombatLogEntry( - message: '${widget.match.challenger.characterName} uses ' - '${turn.challengerSkillUsed}!', - timestamp: DateTime.now(), - type: CombatLogType.skill, - )); + _battleLog.add( + CombatLogEntry( + message: + '${widget.match.challenger.characterName} uses ' + '${turn.challengerSkillUsed}!', + timestamp: DateTime.now(), + type: CombatLogType.skill, + ), + ); } // 도전자 회복 로그 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, - )); + _battleLog.add( + CombatLogEntry( + message: + '${widget.match.challenger.characterName} heals ' + '${turn.challengerHealAmount} HP!', + timestamp: DateTime.now(), + type: CombatLogType.heal, + ), + ); } // 로그 추가 (CombatLogEntry 사용) @@ -199,48 +213,61 @@ class _ArenaBattleScreenState extends State : CombatLogType.damage; final critText = turn.isChallengerCritical ? ' CRITICAL!' : ''; final skillText = turn.challengerSkillUsed != null ? '' : ''; - _battleLog.add(CombatLogEntry( - message: '${widget.match.challenger.characterName} deals ' - '${turn.challengerDamage}$critText$skillText', - timestamp: DateTime.now(), - type: type, - )); + _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, - )); + _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, - )); + _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.skill, - )); + _battleLog.add( + CombatLogEntry( + message: + '${widget.match.opponent.characterName} uses ' + '${turn.opponentSkillUsed}!', + timestamp: DateTime.now(), + type: CombatLogType.skill, + ), + ); } // 상대 회복 로그 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, - )); + _battleLog.add( + CombatLogEntry( + message: + '${widget.match.opponent.characterName} heals ' + '${turn.opponentHealAmount} HP!', + timestamp: DateTime.now(), + type: CombatLogType.heal, + ), + ); } if (turn.opponentDamage != null) { @@ -248,28 +275,35 @@ class _ArenaBattleScreenState extends State ? CombatLogType.critical : CombatLogType.monsterAttack; final critText = turn.isOpponentCritical ? ' CRITICAL!' : ''; - _battleLog.add(CombatLogEntry( - message: '${widget.match.opponent.characterName} deals ' - '${turn.opponentDamage}$critText', - timestamp: DateTime.now(), - type: type, - )); + _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, - )); + _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, - )); + _battleLog.add( + CombatLogEntry( + message: '${widget.match.challenger.characterName} blocked!', + timestamp: DateTime.now(), + type: CombatLogType.block, + ), + ); } // 전투 이벤트 생성 (테두리 이펙트용) @@ -405,10 +439,7 @@ class _ArenaBattleScreenState extends State appBar: AppBar( title: Text( _battleTitle, - style: const TextStyle( - fontFamily: 'PressStart2P', - fontSize: 12, - ), + style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 12), ), centerTitle: true, backgroundColor: RetroColors.panelBgOf(context), @@ -433,6 +464,7 @@ class _ArenaBattleScreenState extends State result: _result!, turnCount: _currentTurn, onContinue: _handleContinue, + battleLog: _battleLog, ), ], ), @@ -463,9 +495,9 @@ class _ArenaBattleScreenState extends State height: 120, child: AsciiAnimationCard( taskType: TaskType.kill, - raceId: widget.match.challenger.race, + raceId: widget.match.challenger.raceId, shieldName: _hasShield(widget.match.challenger) ? 'shield' : null, - opponentRaceId: widget.match.opponent.race, + opponentRaceId: widget.match.opponent.raceId, opponentHasShield: _hasShield(widget.match.opponent), latestCombatEvent: _latestCombatEvent, ), @@ -476,18 +508,22 @@ class _ArenaBattleScreenState extends State /// 종료된 전투 영역 (승자 유지 + 패자 분해) Widget _buildFinishedBattleArea() { final isVictory = _result!.isVictory; - final winnerRaceId = - isVictory ? widget.match.challenger.race : widget.match.opponent.race; - final loserRaceId = - isVictory ? widget.match.opponent.race : widget.match.challenger.race; + final winnerRaceId = isVictory + ? widget.match.challenger.raceId + : widget.match.opponent.raceId; + final loserRaceId = isVictory + ? widget.match.opponent.raceId + : widget.match.challenger.raceId; // 패자 캐릭터 프레임 (idle 첫 프레임) - final loserFrameData = RaceCharacterFrames.get(loserRaceId) ?? + final loserFrameData = + RaceCharacterFrames.get(loserRaceId) ?? RaceCharacterFrames.defaultFrames; final loserLines = loserFrameData.idle.first.lines; // 승자 캐릭터 프레임 (idle 첫 프레임) - final winnerFrameData = RaceCharacterFrames.get(winnerRaceId) ?? + final winnerFrameData = + RaceCharacterFrames.get(winnerRaceId) ?? RaceCharacterFrames.defaultFrames; final winnerLines = winnerFrameData.idle.first.lines; @@ -522,10 +558,7 @@ class _ArenaBattleScreenState extends State child: Center( child: isVictory ? AsciiDisintegrateWidget(characterLines: loserLines) - : _buildStaticCharacter( - _mirrorLines(winnerLines), - false, - ), + : _buildStaticCharacter(_mirrorLines(winnerLines), false), ), ), ], @@ -540,15 +573,17 @@ class _ArenaBattleScreenState extends State return Column( mainAxisSize: MainAxisSize.min, children: lines - .map((line) => Text( - line, - style: TextStyle( - fontFamily: 'JetBrainsMono', - fontSize: 10, - color: textColor, - height: 1.2, - ), - )) + .map( + (line) => Text( + line, + style: TextStyle( + fontFamily: 'JetBrainsMono', + fontSize: 10, + color: textColor, + height: 1.2, + ), + ), + ) .toList(), ); } @@ -583,20 +618,26 @@ class _ArenaBattleScreenState extends State } Widget _buildTurnIndicator() { + // 경과 시간 계산 (분:초 형식) + String elapsedTime = '00:00'; + if (_battleStartTime != null) { + final elapsed = DateTime.now().difference(_battleStartTime!); + final minutes = elapsed.inMinutes; + final seconds = elapsed.inSeconds % 60; + elapsedTime = + '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; + } + return Container( padding: const EdgeInsets.symmetric(vertical: 8), color: RetroColors.panelBgOf(context).withValues(alpha: 0.5), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - Icons.sports_kabaddi, - color: RetroColors.goldOf(context), - size: 16, - ), + Icon(Icons.timer, color: RetroColors.goldOf(context), size: 16), const SizedBox(width: 8), Text( - '$_turnLabel $_currentTurn', + elapsedTime, style: TextStyle( fontFamily: 'PressStart2P', fontSize: 10, @@ -615,10 +656,7 @@ class _ArenaBattleScreenState extends State decoration: BoxDecoration( color: RetroColors.panelBgOf(context), border: Border( - bottom: BorderSide( - color: RetroColors.borderOf(context), - width: 2, - ), + bottom: BorderSide(color: RetroColors.borderOf(context), width: 2), ), ), child: Row( @@ -688,7 +726,9 @@ class _ArenaBattleScreenState extends State final isDamage = hpChange < 0; final flashColor = isDamage ? RetroColors.hpRed.withValues(alpha: flashAnimation.value * 0.4) - : RetroColors.expGreen.withValues(alpha: flashAnimation.value * 0.4); + : RetroColors.expGreen.withValues( + alpha: flashAnimation.value * 0.4, + ); return Container( padding: const EdgeInsets.all(6), @@ -703,8 +743,9 @@ class _ArenaBattleScreenState extends State clipBehavior: Clip.none, children: [ Column( - crossAxisAlignment: - isReversed ? CrossAxisAlignment.end : CrossAxisAlignment.start, + crossAxisAlignment: isReversed + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, children: [ // 이름 Text( @@ -728,8 +769,9 @@ class _ArenaBattleScreenState extends State const SizedBox(height: 2), // HP 수치 Row( - mainAxisAlignment: - isReversed ? MainAxisAlignment.end : MainAxisAlignment.start, + mainAxisAlignment: isReversed + ? MainAxisAlignment.end + : MainAxisAlignment.start, children: [ Text( _hpLabel, @@ -769,7 +811,9 @@ class _ArenaBattleScreenState extends State fontFamily: 'PressStart2P', fontSize: 8, fontWeight: FontWeight.bold, - color: isDamage ? RetroColors.hpRed : RetroColors.expGreen, + color: isDamage + ? RetroColors.hpRed + : RetroColors.expGreen, shadows: const [ Shadow(color: Colors.black, blurRadius: 3), Shadow(color: Colors.black, blurRadius: 6), @@ -811,7 +855,9 @@ class _ArenaBattleScreenState extends State border: Border( right: index < segmentCount - 1 ? BorderSide( - color: RetroColors.borderOf(context).withValues(alpha: 0.3), + color: RetroColors.borderOf( + context, + ).withValues(alpha: 0.3), width: 1, ) : BorderSide.none, @@ -823,14 +869,9 @@ class _ArenaBattleScreenState extends State return Container( decoration: BoxDecoration( - border: Border.all( - color: RetroColors.borderOf(context), - width: 1, - ), - ), - child: Row( - children: isReversed ? segments.reversed.toList() : segments, + border: Border.all(color: RetroColors.borderOf(context), width: 1), ), + child: Row(children: isReversed ? segments.reversed.toList() : segments), ); } @@ -852,7 +893,8 @@ class _ArenaBattleScreenState extends State /// 스킬 사용, 크리티컬, 블록, 회피 표시 Widget _buildCombatEventIcons() { // 스킬 사용 또는 특수 액션만 표시 - final hasSpecialEvent = _currentSkillName != null || + final hasSpecialEvent = + _currentSkillName != null || _latestCombatEvent?.isCritical == true || _currentEventIcon == CombatEventType.playerBlock || _currentEventIcon == CombatEventType.playerEvade || diff --git a/lib/src/features/arena/arena_screen.dart b/lib/src/features/arena/arena_screen.dart index 6d62bf4..b6bc64a 100644 --- a/lib/src/features/arena/arena_screen.dart +++ b/lib/src/features/arena/arena_screen.dart @@ -73,10 +73,7 @@ class _ArenaScreenState extends State { appBar: AppBar( title: Text( _arenaTitle, - style: const TextStyle( - fontFamily: 'PressStart2P', - fontSize: 12, - ), + style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 12), ), centerTitle: true, backgroundColor: RetroColors.panelBgOf(context), @@ -164,5 +161,4 @@ class _ArenaScreenState extends State { ), ); } - } diff --git a/lib/src/features/arena/arena_setup_screen.dart b/lib/src/features/arena/arena_setup_screen.dart index ebbec40..7971b1e 100644 --- a/lib/src/features/arena/arena_setup_screen.dart +++ b/lib/src/features/arena/arena_setup_screen.dart @@ -133,10 +133,7 @@ class _ArenaSetupScreenState extends State { appBar: AppBar( title: Text( _setupTitle, - style: const TextStyle( - fontFamily: 'PressStart2P', - fontSize: 12, - ), + style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 12), ), centerTitle: true, backgroundColor: RetroColors.panelBgOf(context), @@ -232,10 +229,12 @@ class _ArenaSetupScreenState extends State { final myItem = _findItem(slot, _challenger!.finalEquipment); final enemyItem = _findItem(slot, _opponent!.finalEquipment); - final myScore = - myItem != null ? ItemService.calculateEquipmentScore(myItem) : 0; - final enemyScore = - enemyItem != null ? ItemService.calculateEquipmentScore(enemyItem) : 0; + final myScore = myItem != null + ? ItemService.calculateEquipmentScore(myItem) + : 0; + final enemyScore = enemyItem != null + ? ItemService.calculateEquipmentScore(enemyItem) + : 0; final gain = enemyScore - myScore; if (gain > maxGain) { @@ -354,8 +353,9 @@ class _ArenaSetupScreenState extends State { style: ElevatedButton.styleFrom( backgroundColor: RetroColors.goldOf(context), foregroundColor: RetroColors.backgroundOf(context), - disabledBackgroundColor: - RetroColors.borderOf(context).withValues(alpha: 0.5), + disabledBackgroundColor: RetroColors.borderOf( + context, + ).withValues(alpha: 0.5), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), 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 0ee59a8..48f1c20 100644 --- a/lib/src/features/arena/widgets/arena_equipment_compare_list.dart +++ b/lib/src/features/arena/widgets/arena_equipment_compare_list.dart @@ -175,10 +175,12 @@ class _ArenaEquipmentCompareListState extends State { final isOpponentTarget = widget.opponentBettingSlot == slot; // 상대가 선택 = 내 장비 손실 예정 - final myScore = - myItem != null ? ItemService.calculateEquipmentScore(myItem) : 0; - final enemyScore = - enemyItem != null ? ItemService.calculateEquipmentScore(enemyItem) : 0; + final myScore = myItem != null + ? ItemService.calculateEquipmentScore(myItem) + : 0; + final enemyScore = enemyItem != null + ? ItemService.calculateEquipmentScore(enemyItem) + : 0; final scoreDiff = enemyScore - myScore; return Column( @@ -206,8 +208,8 @@ class _ArenaEquipmentCompareListState extends State { color: isLocked ? RetroColors.borderOf(context).withValues(alpha: 0.1) : isExpanded - ? RetroColors.panelBgOf(context) - : Colors.transparent, + ? RetroColors.panelBgOf(context) + : Colors.transparent, border: Border( bottom: BorderSide( color: RetroColors.borderOf(context).withValues(alpha: 0.3), @@ -297,8 +299,8 @@ class _ArenaEquipmentCompareListState extends State { final textColor = isLocked ? RetroColors.textMutedOf(context) : hasItem - ? rarityColor - : RetroColors.textMutedOf(context); + ? rarityColor + : RetroColors.textMutedOf(context); return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), @@ -331,8 +333,8 @@ class _ArenaEquipmentCompareListState extends State { color: isLocked ? RetroColors.textMutedOf(context) : hasItem - ? RetroColors.textSecondaryOf(context) - : RetroColors.textMutedOf(context), + ? RetroColors.textSecondaryOf(context) + : RetroColors.textMutedOf(context), ), ), ], @@ -393,8 +395,8 @@ class _ArenaEquipmentCompareListState extends State { color: isLocked ? RetroColors.textMutedOf(context) : isSelected - ? RetroColors.goldOf(context) - : RetroColors.textSecondaryOf(context), + ? RetroColors.goldOf(context) + : RetroColors.textSecondaryOf(context), ), const SizedBox(height: 2), // 잠금 표시 또는 점수 변화 @@ -548,10 +550,7 @@ class _ArenaEquipmentCompareListState extends State { decoration: BoxDecoration( color: RetroColors.goldOf(context).withValues(alpha: 0.2), borderRadius: BorderRadius.circular(4), - border: Border.all( - color: RetroColors.goldOf(context), - width: 2, - ), + border: Border.all(color: RetroColors.goldOf(context), width: 2), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, @@ -643,11 +642,7 @@ class _ArenaEquipmentCompareListState extends State { if (statWidgets.isEmpty) return const SizedBox.shrink(); - return Wrap( - spacing: 3, - runSpacing: 3, - children: statWidgets, - ); + return Wrap(spacing: 3, runSpacing: 3, children: statWidgets); } Widget _buildStatChip(String label, int value, Color color) { @@ -659,11 +654,7 @@ class _ArenaEquipmentCompareListState extends State { ), child: Text( '$label +$value', - style: TextStyle( - fontFamily: 'PressStart2P', - fontSize: 4, - color: color, - ), + style: TextStyle(fontFamily: 'PressStart2P', fontSize: 4, color: color), ), ); } diff --git a/lib/src/features/arena/widgets/arena_rank_card.dart b/lib/src/features/arena/widgets/arena_rank_card.dart index c51ecfc..45ec08f 100644 --- a/lib/src/features/arena/widgets/arena_rank_card.dart +++ b/lib/src/features/arena/widgets/arena_rank_card.dart @@ -143,8 +143,8 @@ class ArenaRankCard extends StatelessWidget { compact ? 'Lv.${entry.level}' : '${GameDataL10n.getRaceName(context, entry.race)} ' - '${GameDataL10n.getKlassName(context, entry.klass)} ' - 'Lv.${entry.level}', + '${GameDataL10n.getKlassName(context, entry.klass)} ' + 'Lv.${entry.level}', style: TextStyle( fontFamily: 'PressStart2P', fontSize: compact ? 5 : 7, diff --git a/lib/src/features/arena/widgets/arena_result_dialog.dart b/lib/src/features/arena/widgets/arena_result_dialog.dart index 2c6d87c..5b80dd8 100644 --- a/lib/src/features/arena/widgets/arena_result_dialog.dart +++ b/lib/src/features/arena/widgets/arena_result_dialog.dart @@ -63,15 +63,10 @@ class ArenaResultDialog extends StatelessWidget { actions: [ FilledButton( onPressed: onClose, - style: FilledButton.styleFrom( - backgroundColor: resultColor, - ), + style: FilledButton.styleFrom(backgroundColor: resultColor), child: Text( l10n.buttonConfirm, - style: const TextStyle( - fontFamily: 'PressStart2P', - fontSize: 8, - ), + style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 8), ), ), ], @@ -249,10 +244,12 @@ class ArenaResultDialog extends StatelessWidget { EquipmentItem? newItem, bool isWinner, ) { - final oldScore = - oldItem != null ? ItemService.calculateEquipmentScore(oldItem) : 0; - final newScore = - newItem != null ? ItemService.calculateEquipmentScore(newItem) : 0; + final oldScore = oldItem != null + ? ItemService.calculateEquipmentScore(oldItem) + : 0; + final newScore = newItem != null + ? ItemService.calculateEquipmentScore(newItem) + : 0; final scoreDiff = newScore - oldScore; final isGain = scoreDiff > 0; @@ -287,12 +284,7 @@ class ArenaResultDialog extends StatelessWidget { children: [ // 이전 장비 Expanded( - child: _buildItemChip( - context, - oldItem, - oldScore, - isOld: true, - ), + child: _buildItemChip(context, oldItem, oldScore, isOld: true), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 4), @@ -304,12 +296,7 @@ class ArenaResultDialog extends StatelessWidget { ), // 새 장비 Expanded( - child: _buildItemChip( - context, - newItem, - newScore, - isOld: false, - ), + child: _buildItemChip(context, newItem, newScore, isOld: false), ), ], ), @@ -355,9 +342,7 @@ class ArenaResultDialog extends StatelessWidget { decoration: BoxDecoration( color: rarityColor.withValues(alpha: isOld ? 0.1 : 0.2), borderRadius: BorderRadius.circular(4), - border: Border.all( - color: rarityColor.withValues(alpha: 0.5), - ), + border: Border.all(color: rarityColor.withValues(alpha: 0.5)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/src/features/arena/widgets/arena_result_panel.dart b/lib/src/features/arena/widgets/arena_result_panel.dart index 9aa2ddb..2700991 100644 --- a/lib/src/features/arena/widgets/arena_result_panel.dart +++ b/lib/src/features/arena/widgets/arena_result_panel.dart @@ -1,4 +1,9 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:asciineverdie/data/game_text_l10n.dart' as l10n; import 'package:asciineverdie/src/core/engine/item_service.dart'; @@ -6,6 +11,7 @@ import 'package:asciineverdie/src/core/model/arena_match.dart'; import 'package:asciineverdie/src/core/model/equipment_item.dart'; import 'package:asciineverdie/src/core/model/equipment_slot.dart'; import 'package:asciineverdie/src/core/model/item_stats.dart'; +import 'package:asciineverdie/src/features/game/widgets/combat_log.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart'; // 임시 문자열 @@ -23,6 +29,7 @@ class ArenaResultPanel extends StatefulWidget { required this.result, required this.turnCount, required this.onContinue, + this.battleLog, }); /// 대전 결과 @@ -34,6 +41,9 @@ class ArenaResultPanel extends StatefulWidget { /// Continue 콜백 final VoidCallback onContinue; + /// 배틀 로그 (디버그 모드 저장용) + final List? battleLog; + @override State createState() => _ArenaResultPanelState(); } @@ -52,21 +62,18 @@ class _ArenaResultPanelState extends State vsync: this, ); - _slideAnimation = Tween( - begin: const Offset(0, 1), // 아래에서 위로 - end: Offset.zero, - ).animate(CurvedAnimation( - parent: _slideController, - curve: Curves.easeOutCubic, - )); + _slideAnimation = + Tween( + begin: const Offset(0, 1), // 아래에서 위로 + end: Offset.zero, + ).animate( + CurvedAnimation(parent: _slideController, curve: Curves.easeOutCubic), + ); _fadeAnimation = Tween( begin: 0.0, end: 1.0, - ).animate(CurvedAnimation( - parent: _slideController, - curve: Curves.easeOut, - )); + ).animate(CurvedAnimation(parent: _slideController, curve: Curves.easeOut)); // 약간 지연 후 애니메이션 시작 (분해 애니메이션과 동기화) Future.delayed(const Duration(milliseconds: 800), () { @@ -82,6 +89,63 @@ class _ArenaResultPanelState extends State super.dispose(); } + /// 배틀 로그 JSON 저장 (macOS 디버그 모드 전용) + Future _saveBattleLog() async { + if (widget.battleLog == null || widget.battleLog!.isEmpty) return; + + try { + // macOS: Downloads 폴더에 저장 (사용자가 쉽게 찾을 수 있도록) + final directory = await getDownloadsDirectory() ?? + await getApplicationDocumentsDirectory(); + final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-'); + final challenger = widget.result.match.challenger.characterName; + final opponent = widget.result.match.opponent.characterName; + final fileName = 'arena_${challenger}_vs_${opponent}_$timestamp.json'; + final file = File('${directory.path}/$fileName'); + + final jsonData = { + 'match': { + 'challenger': challenger, + 'opponent': opponent, + 'isVictory': widget.result.isVictory, + 'turnCount': widget.turnCount, + 'timestamp': DateTime.now().toIso8601String(), + }, + 'battleLog': widget.battleLog!.map((e) => e.toJson()).toList(), + }; + + await file.writeAsString( + const JsonEncoder.withIndent(' ').convert(jsonData), + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${l10n.uiSaved}: $fileName', + style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 6), + ), + backgroundColor: RetroColors.mpOf(context), + duration: const Duration(seconds: 3), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${l10n.uiError}: $e', + style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 6), + ), + backgroundColor: RetroColors.hpOf(context), + duration: const Duration(seconds: 3), + ), + ); + } + } + } + @override Widget build(BuildContext context) { final isVictory = widget.result.isVictory; @@ -137,6 +201,13 @@ class _ArenaResultPanelState extends State // 장비 교환 _buildExchangeSection(context), const SizedBox(height: 12), + // 배틀로그 저장 버튼 (macOS 디버그 모드 전용) + if (kDebugMode && + Platform.isMacOS && + widget.battleLog != null) ...[ + _buildSaveLogButton(context), + const SizedBox(height: 8), + ], // Continue 버튼 _buildContinueButton(context, resultColor), ], @@ -262,10 +333,12 @@ class _ArenaResultPanelState extends State slot, ); - final oldScore = - oldItem != null ? ItemService.calculateEquipmentScore(oldItem) : 0; - final newScore = - newItem != null ? ItemService.calculateEquipmentScore(newItem) : 0; + final oldScore = oldItem != null + ? ItemService.calculateEquipmentScore(oldItem) + : 0; + final newScore = newItem != null + ? ItemService.calculateEquipmentScore(newItem) + : 0; final scoreDiff = newScore - oldScore; return Container( @@ -344,7 +417,9 @@ class _ArenaResultPanelState extends State mainAxisSize: MainAxisSize.min, children: [ Icon( - scoreDiff >= 0 ? Icons.arrow_upward : Icons.arrow_downward, + scoreDiff >= 0 + ? Icons.arrow_upward + : Icons.arrow_downward, size: 10, color: scoreDiff >= 0 ? Colors.green : Colors.red, ), @@ -366,11 +441,7 @@ class _ArenaResultPanelState extends State ); } - Widget _buildItemBadge( - BuildContext context, - EquipmentItem? item, - int score, - ) { + Widget _buildItemBadge(BuildContext context, EquipmentItem? item, int score) { if (item == null || item.isEmpty) { return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), @@ -433,9 +504,7 @@ class _ArenaResultPanelState extends State style: FilledButton.styleFrom( backgroundColor: color, padding: const EdgeInsets.symmetric(vertical: 10), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(6), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), ), child: Text( l10n.buttonConfirm, @@ -449,6 +518,27 @@ class _ArenaResultPanelState extends State ); } + /// 배틀로그 저장 버튼 (macOS 디버그 모드 전용) + Widget _buildSaveLogButton(BuildContext context) { + return SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _saveBattleLog, + icon: const Icon(Icons.save_alt, size: 14), + label: Text( + l10n.uiSaveBattleLog, + style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 6), + ), + style: OutlinedButton.styleFrom( + foregroundColor: RetroColors.mpOf(context), + side: BorderSide(color: RetroColors.mpOf(context)), + padding: const EdgeInsets.symmetric(vertical: 8), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + ), + ), + ); + } + EquipmentItem? _findItem(List? equipment, EquipmentSlot slot) { if (equipment == null) return null; for (final item in equipment) { diff --git a/lib/src/features/arena/widgets/ascii_disintegrate_widget.dart b/lib/src/features/arena/widgets/ascii_disintegrate_widget.dart index db113aa..2073a85 100644 --- a/lib/src/features/arena/widgets/ascii_disintegrate_widget.dart +++ b/lib/src/features/arena/widgets/ascii_disintegrate_widget.dart @@ -11,9 +11,9 @@ class AsciiParticle { required this.vx, required this.vy, required this.delay, - }) : x = initialX, - y = initialY, - opacity = 1.0; + }) : x = initialX, + y = initialY, + opacity = 1.0; final String char; final double initialX; @@ -29,7 +29,10 @@ class AsciiParticle { /// 진행도에 따라 파티클 상태 업데이트 void update(double progress) { // 지연 적용 - final adjustedProgress = ((progress - delay) / (1.0 - delay)).clamp(0.0, 1.0); + final adjustedProgress = ((progress - delay) / (1.0 - delay)).clamp( + 0.0, + 1.0, + ); if (adjustedProgress <= 0) { // 아직 분해 시작 전 @@ -101,10 +104,7 @@ class _AsciiDisintegrateWidgetState extends State void initState() { super.initState(); _initParticles(); - _controller = AnimationController( - duration: widget.duration, - vsync: this, - ) + _controller = AnimationController(duration: widget.duration, vsync: this) ..addListener(() => setState(() {})) ..addStatusListener((status) { if (status == AnimationStatus.completed) { @@ -129,16 +129,18 @@ class _AsciiDisintegrateWidgetState extends State final char = line[x]; // 공백은 파티클로 변환하지 않음 if (char != ' ') { - _particles.add(AsciiParticle( - char: char, - initialX: x.toDouble(), - initialY: y.toDouble(), - // 랜덤 속도 (위쪽 + 좌우로 퍼짐) - vx: (_random.nextDouble() - 0.5) * 4.0, - vy: -_random.nextDouble() * 2.0 - 0.5, // 위쪽으로 - // 랜덤 지연 (안쪽에서 바깥쪽으로 분해) - delay: _random.nextDouble() * 0.3, - )); + _particles.add( + AsciiParticle( + char: char, + initialX: x.toDouble(), + initialY: y.toDouble(), + // 랜덤 속도 (위쪽 + 좌우로 퍼짐) + vx: (_random.nextDouble() - 0.5) * 4.0, + vy: -_random.nextDouble() * 2.0 - 0.5, // 위쪽으로 + // 랜덤 지연 (안쪽에서 바깥쪽으로 분해) + delay: _random.nextDouble() * 0.3, + ), + ); } } } @@ -158,9 +160,9 @@ class _AsciiDisintegrateWidgetState extends State size: Size( widget.characterLines.isNotEmpty ? widget.characterLines - .map((l) => l.length) - .reduce((a, b) => a > b ? a : b) * - widget.charWidth + .map((l) => l.length) + .reduce((a, b) => a > b ? a : b) * + widget.charWidth : 0, widget.characterLines.length * widget.charHeight, ),