refactor(arena): 아레나 화면 및 위젯 정리

This commit is contained in:
JiWoong Sul
2026-01-12 16:17:16 +09:00
parent a404c82f35
commit 104d23cdfd
8 changed files with 336 additions and 230 deletions

View File

@@ -18,7 +18,6 @@ import 'package:asciineverdie/src/shared/retro_colors.dart';
// 임시 문자열 (추후 l10n으로 이동) // 임시 문자열 (추후 l10n으로 이동)
const _battleTitle = 'ARENA BATTLE'; const _battleTitle = 'ARENA BATTLE';
const _hpLabel = 'HP'; const _hpLabel = 'HP';
const _turnLabel = 'TURN';
/// 아레나 전투 화면 /// 아레나 전투 화면
/// ///
@@ -48,6 +47,9 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
/// 현재 턴 /// 현재 턴
int _currentTurn = 0; int _currentTurn = 0;
/// 전투 시작 시간 (경과 시간 계산용)
DateTime? _battleStartTime;
/// 도전자 HP/MP /// 도전자 HP/MP
late int _challengerHp; late int _challengerHp;
late int _challengerHpMax; late int _challengerHpMax;
@@ -114,7 +116,10 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
vsync: this, vsync: this,
); );
_challengerFlashAnimation = Tween<double>(begin: 1.0, end: 0.0).animate( _challengerFlashAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
CurvedAnimation(parent: _challengerFlashController, curve: Curves.easeOut), CurvedAnimation(
parent: _challengerFlashController,
curve: Curves.easeOut,
),
); );
_opponentFlashController = AnimationController( _opponentFlashController = AnimationController(
@@ -139,14 +144,17 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
} }
void _startBattle() { void _startBattle() {
_combatSubscription = _arenaService.simulateCombat(widget.match).listen( _battleStartTime = DateTime.now();
(turn) { _combatSubscription = _arenaService
_processTurn(turn); .simulateCombat(widget.match)
}, .listen(
onDone: () { (turn) {
_endBattle(); _processTurn(turn);
}, },
); onDone: () {
_endBattle();
},
);
} }
void _processTurn(ArenaCombatTurn turn) { void _processTurn(ArenaCombatTurn turn) {
@@ -174,22 +182,28 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
// 도전자 스킬 사용 로그 // 도전자 스킬 사용 로그
if (turn.challengerSkillUsed != null) { if (turn.challengerSkillUsed != null) {
_battleLog.add(CombatLogEntry( _battleLog.add(
message: '${widget.match.challenger.characterName} uses ' CombatLogEntry(
'${turn.challengerSkillUsed}!', message:
timestamp: DateTime.now(), '${widget.match.challenger.characterName} uses '
type: CombatLogType.skill, '${turn.challengerSkillUsed}!',
)); timestamp: DateTime.now(),
type: CombatLogType.skill,
),
);
} }
// 도전자 회복 로그 // 도전자 회복 로그
if (turn.challengerHealAmount != null && turn.challengerHealAmount! > 0) { if (turn.challengerHealAmount != null && turn.challengerHealAmount! > 0) {
_battleLog.add(CombatLogEntry( _battleLog.add(
message: '${widget.match.challenger.characterName} heals ' CombatLogEntry(
'${turn.challengerHealAmount} HP!', message:
timestamp: DateTime.now(), '${widget.match.challenger.characterName} heals '
type: CombatLogType.heal, '${turn.challengerHealAmount} HP!',
)); timestamp: DateTime.now(),
type: CombatLogType.heal,
),
);
} }
// 로그 추가 (CombatLogEntry 사용) // 로그 추가 (CombatLogEntry 사용)
@@ -199,48 +213,61 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
: CombatLogType.damage; : CombatLogType.damage;
final critText = turn.isChallengerCritical ? ' CRITICAL!' : ''; final critText = turn.isChallengerCritical ? ' CRITICAL!' : '';
final skillText = turn.challengerSkillUsed != null ? '' : ''; final skillText = turn.challengerSkillUsed != null ? '' : '';
_battleLog.add(CombatLogEntry( _battleLog.add(
message: '${widget.match.challenger.characterName} deals ' CombatLogEntry(
'${turn.challengerDamage}$critText$skillText', message:
timestamp: DateTime.now(), '${widget.match.challenger.characterName} deals '
type: type, '${turn.challengerDamage}$critText$skillText',
)); timestamp: DateTime.now(),
type: type,
),
);
} }
// 상대 회피/블록 이벤트 // 상대 회피/블록 이벤트
if (turn.isOpponentEvaded) { if (turn.isOpponentEvaded) {
_battleLog.add(CombatLogEntry( _battleLog.add(
message: '${widget.match.opponent.characterName} evaded!', CombatLogEntry(
timestamp: DateTime.now(), message: '${widget.match.opponent.characterName} evaded!',
type: CombatLogType.evade, timestamp: DateTime.now(),
)); type: CombatLogType.evade,
),
);
} }
if (turn.isOpponentBlocked) { if (turn.isOpponentBlocked) {
_battleLog.add(CombatLogEntry( _battleLog.add(
message: '${widget.match.opponent.characterName} blocked!', CombatLogEntry(
timestamp: DateTime.now(), message: '${widget.match.opponent.characterName} blocked!',
type: CombatLogType.block, timestamp: DateTime.now(),
)); type: CombatLogType.block,
),
);
} }
// 상대 스킬 사용 로그 // 상대 스킬 사용 로그
if (turn.opponentSkillUsed != null) { if (turn.opponentSkillUsed != null) {
_battleLog.add(CombatLogEntry( _battleLog.add(
message: '${widget.match.opponent.characterName} uses ' CombatLogEntry(
'${turn.opponentSkillUsed}!', message:
timestamp: DateTime.now(), '${widget.match.opponent.characterName} uses '
type: CombatLogType.skill, '${turn.opponentSkillUsed}!',
)); timestamp: DateTime.now(),
type: CombatLogType.skill,
),
);
} }
// 상대 회복 로그 // 상대 회복 로그
if (turn.opponentHealAmount != null && turn.opponentHealAmount! > 0) { if (turn.opponentHealAmount != null && turn.opponentHealAmount! > 0) {
_battleLog.add(CombatLogEntry( _battleLog.add(
message: '${widget.match.opponent.characterName} heals ' CombatLogEntry(
'${turn.opponentHealAmount} HP!', message:
timestamp: DateTime.now(), '${widget.match.opponent.characterName} heals '
type: CombatLogType.heal, '${turn.opponentHealAmount} HP!',
)); timestamp: DateTime.now(),
type: CombatLogType.heal,
),
);
} }
if (turn.opponentDamage != null) { if (turn.opponentDamage != null) {
@@ -248,28 +275,35 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
? CombatLogType.critical ? CombatLogType.critical
: CombatLogType.monsterAttack; : CombatLogType.monsterAttack;
final critText = turn.isOpponentCritical ? ' CRITICAL!' : ''; final critText = turn.isOpponentCritical ? ' CRITICAL!' : '';
_battleLog.add(CombatLogEntry( _battleLog.add(
message: '${widget.match.opponent.characterName} deals ' CombatLogEntry(
'${turn.opponentDamage}$critText', message:
timestamp: DateTime.now(), '${widget.match.opponent.characterName} deals '
type: type, '${turn.opponentDamage}$critText',
)); timestamp: DateTime.now(),
type: type,
),
);
} }
// 도전자 회피/블록 이벤트 // 도전자 회피/블록 이벤트
if (turn.isChallengerEvaded) { if (turn.isChallengerEvaded) {
_battleLog.add(CombatLogEntry( _battleLog.add(
message: '${widget.match.challenger.characterName} evaded!', CombatLogEntry(
timestamp: DateTime.now(), message: '${widget.match.challenger.characterName} evaded!',
type: CombatLogType.evade, timestamp: DateTime.now(),
)); type: CombatLogType.evade,
),
);
} }
if (turn.isChallengerBlocked) { if (turn.isChallengerBlocked) {
_battleLog.add(CombatLogEntry( _battleLog.add(
message: '${widget.match.challenger.characterName} blocked!', CombatLogEntry(
timestamp: DateTime.now(), message: '${widget.match.challenger.characterName} blocked!',
type: CombatLogType.block, timestamp: DateTime.now(),
)); type: CombatLogType.block,
),
);
} }
// 전투 이벤트 생성 (테두리 이펙트용) // 전투 이벤트 생성 (테두리 이펙트용)
@@ -405,10 +439,7 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
appBar: AppBar( appBar: AppBar(
title: Text( title: Text(
_battleTitle, _battleTitle,
style: const TextStyle( style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 12),
fontFamily: 'PressStart2P',
fontSize: 12,
),
), ),
centerTitle: true, centerTitle: true,
backgroundColor: RetroColors.panelBgOf(context), backgroundColor: RetroColors.panelBgOf(context),
@@ -433,6 +464,7 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
result: _result!, result: _result!,
turnCount: _currentTurn, turnCount: _currentTurn,
onContinue: _handleContinue, onContinue: _handleContinue,
battleLog: _battleLog,
), ),
], ],
), ),
@@ -463,9 +495,9 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
height: 120, height: 120,
child: AsciiAnimationCard( child: AsciiAnimationCard(
taskType: TaskType.kill, taskType: TaskType.kill,
raceId: widget.match.challenger.race, raceId: widget.match.challenger.raceId,
shieldName: _hasShield(widget.match.challenger) ? 'shield' : null, shieldName: _hasShield(widget.match.challenger) ? 'shield' : null,
opponentRaceId: widget.match.opponent.race, opponentRaceId: widget.match.opponent.raceId,
opponentHasShield: _hasShield(widget.match.opponent), opponentHasShield: _hasShield(widget.match.opponent),
latestCombatEvent: _latestCombatEvent, latestCombatEvent: _latestCombatEvent,
), ),
@@ -476,18 +508,22 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
/// 종료된 전투 영역 (승자 유지 + 패자 분해) /// 종료된 전투 영역 (승자 유지 + 패자 분해)
Widget _buildFinishedBattleArea() { Widget _buildFinishedBattleArea() {
final isVictory = _result!.isVictory; final isVictory = _result!.isVictory;
final winnerRaceId = final winnerRaceId = isVictory
isVictory ? widget.match.challenger.race : widget.match.opponent.race; ? widget.match.challenger.raceId
final loserRaceId = : widget.match.opponent.raceId;
isVictory ? widget.match.opponent.race : widget.match.challenger.race; final loserRaceId = isVictory
? widget.match.opponent.raceId
: widget.match.challenger.raceId;
// 패자 캐릭터 프레임 (idle 첫 프레임) // 패자 캐릭터 프레임 (idle 첫 프레임)
final loserFrameData = RaceCharacterFrames.get(loserRaceId) ?? final loserFrameData =
RaceCharacterFrames.get(loserRaceId) ??
RaceCharacterFrames.defaultFrames; RaceCharacterFrames.defaultFrames;
final loserLines = loserFrameData.idle.first.lines; final loserLines = loserFrameData.idle.first.lines;
// 승자 캐릭터 프레임 (idle 첫 프레임) // 승자 캐릭터 프레임 (idle 첫 프레임)
final winnerFrameData = RaceCharacterFrames.get(winnerRaceId) ?? final winnerFrameData =
RaceCharacterFrames.get(winnerRaceId) ??
RaceCharacterFrames.defaultFrames; RaceCharacterFrames.defaultFrames;
final winnerLines = winnerFrameData.idle.first.lines; final winnerLines = winnerFrameData.idle.first.lines;
@@ -522,10 +558,7 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
child: Center( child: Center(
child: isVictory child: isVictory
? AsciiDisintegrateWidget(characterLines: loserLines) ? AsciiDisintegrateWidget(characterLines: loserLines)
: _buildStaticCharacter( : _buildStaticCharacter(_mirrorLines(winnerLines), false),
_mirrorLines(winnerLines),
false,
),
), ),
), ),
], ],
@@ -540,15 +573,17 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: lines children: lines
.map((line) => Text( .map(
line, (line) => Text(
style: TextStyle( line,
fontFamily: 'JetBrainsMono', style: TextStyle(
fontSize: 10, fontFamily: 'JetBrainsMono',
color: textColor, fontSize: 10,
height: 1.2, color: textColor,
), height: 1.2,
)) ),
),
)
.toList(), .toList(),
); );
} }
@@ -583,20 +618,26 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
} }
Widget _buildTurnIndicator() { 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( return Container(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
color: RetroColors.panelBgOf(context).withValues(alpha: 0.5), color: RetroColors.panelBgOf(context).withValues(alpha: 0.5),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(Icons.timer, color: RetroColors.goldOf(context), size: 16),
Icons.sports_kabaddi,
color: RetroColors.goldOf(context),
size: 16,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'$_turnLabel $_currentTurn', elapsedTime,
style: TextStyle( style: TextStyle(
fontFamily: 'PressStart2P', fontFamily: 'PressStart2P',
fontSize: 10, fontSize: 10,
@@ -615,10 +656,7 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
decoration: BoxDecoration( decoration: BoxDecoration(
color: RetroColors.panelBgOf(context), color: RetroColors.panelBgOf(context),
border: Border( border: Border(
bottom: BorderSide( bottom: BorderSide(color: RetroColors.borderOf(context), width: 2),
color: RetroColors.borderOf(context),
width: 2,
),
), ),
), ),
child: Row( child: Row(
@@ -688,7 +726,9 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
final isDamage = hpChange < 0; final isDamage = hpChange < 0;
final flashColor = isDamage final flashColor = isDamage
? RetroColors.hpRed.withValues(alpha: flashAnimation.value * 0.4) ? 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( return Container(
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
@@ -703,8 +743,9 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
Column( Column(
crossAxisAlignment: crossAxisAlignment: isReversed
isReversed ? CrossAxisAlignment.end : CrossAxisAlignment.start, ? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [ children: [
// 이름 // 이름
Text( Text(
@@ -728,8 +769,9 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
const SizedBox(height: 2), const SizedBox(height: 2),
// HP 수치 // HP 수치
Row( Row(
mainAxisAlignment: mainAxisAlignment: isReversed
isReversed ? MainAxisAlignment.end : MainAxisAlignment.start, ? MainAxisAlignment.end
: MainAxisAlignment.start,
children: [ children: [
Text( Text(
_hpLabel, _hpLabel,
@@ -769,7 +811,9 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
fontFamily: 'PressStart2P', fontFamily: 'PressStart2P',
fontSize: 8, fontSize: 8,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: isDamage ? RetroColors.hpRed : RetroColors.expGreen, color: isDamage
? RetroColors.hpRed
: RetroColors.expGreen,
shadows: const [ shadows: const [
Shadow(color: Colors.black, blurRadius: 3), Shadow(color: Colors.black, blurRadius: 3),
Shadow(color: Colors.black, blurRadius: 6), Shadow(color: Colors.black, blurRadius: 6),
@@ -811,7 +855,9 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
border: Border( border: Border(
right: index < segmentCount - 1 right: index < segmentCount - 1
? BorderSide( ? BorderSide(
color: RetroColors.borderOf(context).withValues(alpha: 0.3), color: RetroColors.borderOf(
context,
).withValues(alpha: 0.3),
width: 1, width: 1,
) )
: BorderSide.none, : BorderSide.none,
@@ -823,14 +869,9 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all( border: Border.all(color: RetroColors.borderOf(context), width: 1),
color: RetroColors.borderOf(context),
width: 1,
),
),
child: Row(
children: isReversed ? segments.reversed.toList() : segments,
), ),
child: Row(children: isReversed ? segments.reversed.toList() : segments),
); );
} }
@@ -852,7 +893,8 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
/// 스킬 사용, 크리티컬, 블록, 회피 표시 /// 스킬 사용, 크리티컬, 블록, 회피 표시
Widget _buildCombatEventIcons() { Widget _buildCombatEventIcons() {
// 스킬 사용 또는 특수 액션만 표시 // 스킬 사용 또는 특수 액션만 표시
final hasSpecialEvent = _currentSkillName != null || final hasSpecialEvent =
_currentSkillName != null ||
_latestCombatEvent?.isCritical == true || _latestCombatEvent?.isCritical == true ||
_currentEventIcon == CombatEventType.playerBlock || _currentEventIcon == CombatEventType.playerBlock ||
_currentEventIcon == CombatEventType.playerEvade || _currentEventIcon == CombatEventType.playerEvade ||

View File

@@ -73,10 +73,7 @@ class _ArenaScreenState extends State<ArenaScreen> {
appBar: AppBar( appBar: AppBar(
title: Text( title: Text(
_arenaTitle, _arenaTitle,
style: const TextStyle( style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 12),
fontFamily: 'PressStart2P',
fontSize: 12,
),
), ),
centerTitle: true, centerTitle: true,
backgroundColor: RetroColors.panelBgOf(context), backgroundColor: RetroColors.panelBgOf(context),
@@ -164,5 +161,4 @@ class _ArenaScreenState extends State<ArenaScreen> {
), ),
); );
} }
} }

View File

@@ -133,10 +133,7 @@ class _ArenaSetupScreenState extends State<ArenaSetupScreen> {
appBar: AppBar( appBar: AppBar(
title: Text( title: Text(
_setupTitle, _setupTitle,
style: const TextStyle( style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 12),
fontFamily: 'PressStart2P',
fontSize: 12,
),
), ),
centerTitle: true, centerTitle: true,
backgroundColor: RetroColors.panelBgOf(context), backgroundColor: RetroColors.panelBgOf(context),
@@ -232,10 +229,12 @@ class _ArenaSetupScreenState extends State<ArenaSetupScreen> {
final myItem = _findItem(slot, _challenger!.finalEquipment); final myItem = _findItem(slot, _challenger!.finalEquipment);
final enemyItem = _findItem(slot, _opponent!.finalEquipment); final enemyItem = _findItem(slot, _opponent!.finalEquipment);
final myScore = final myScore = myItem != null
myItem != null ? ItemService.calculateEquipmentScore(myItem) : 0; ? ItemService.calculateEquipmentScore(myItem)
final enemyScore = : 0;
enemyItem != null ? ItemService.calculateEquipmentScore(enemyItem) : 0; final enemyScore = enemyItem != null
? ItemService.calculateEquipmentScore(enemyItem)
: 0;
final gain = enemyScore - myScore; final gain = enemyScore - myScore;
if (gain > maxGain) { if (gain > maxGain) {
@@ -354,8 +353,9 @@ class _ArenaSetupScreenState extends State<ArenaSetupScreen> {
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: RetroColors.goldOf(context), backgroundColor: RetroColors.goldOf(context),
foregroundColor: RetroColors.backgroundOf(context), foregroundColor: RetroColors.backgroundOf(context),
disabledBackgroundColor: disabledBackgroundColor: RetroColors.borderOf(
RetroColors.borderOf(context).withValues(alpha: 0.5), context,
).withValues(alpha: 0.5),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),

View File

@@ -175,10 +175,12 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
final isOpponentTarget = final isOpponentTarget =
widget.opponentBettingSlot == slot; // 상대가 선택 = 내 장비 손실 예정 widget.opponentBettingSlot == slot; // 상대가 선택 = 내 장비 손실 예정
final myScore = final myScore = myItem != null
myItem != null ? ItemService.calculateEquipmentScore(myItem) : 0; ? ItemService.calculateEquipmentScore(myItem)
final enemyScore = : 0;
enemyItem != null ? ItemService.calculateEquipmentScore(enemyItem) : 0; final enemyScore = enemyItem != null
? ItemService.calculateEquipmentScore(enemyItem)
: 0;
final scoreDiff = enemyScore - myScore; final scoreDiff = enemyScore - myScore;
return Column( return Column(
@@ -206,8 +208,8 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
color: isLocked color: isLocked
? RetroColors.borderOf(context).withValues(alpha: 0.1) ? RetroColors.borderOf(context).withValues(alpha: 0.1)
: isExpanded : isExpanded
? RetroColors.panelBgOf(context) ? RetroColors.panelBgOf(context)
: Colors.transparent, : Colors.transparent,
border: Border( border: Border(
bottom: BorderSide( bottom: BorderSide(
color: RetroColors.borderOf(context).withValues(alpha: 0.3), color: RetroColors.borderOf(context).withValues(alpha: 0.3),
@@ -297,8 +299,8 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
final textColor = isLocked final textColor = isLocked
? RetroColors.textMutedOf(context) ? RetroColors.textMutedOf(context)
: hasItem : hasItem
? rarityColor ? rarityColor
: RetroColors.textMutedOf(context); : RetroColors.textMutedOf(context);
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
@@ -331,8 +333,8 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
color: isLocked color: isLocked
? RetroColors.textMutedOf(context) ? RetroColors.textMutedOf(context)
: hasItem : hasItem
? RetroColors.textSecondaryOf(context) ? RetroColors.textSecondaryOf(context)
: RetroColors.textMutedOf(context), : RetroColors.textMutedOf(context),
), ),
), ),
], ],
@@ -393,8 +395,8 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
color: isLocked color: isLocked
? RetroColors.textMutedOf(context) ? RetroColors.textMutedOf(context)
: isSelected : isSelected
? RetroColors.goldOf(context) ? RetroColors.goldOf(context)
: RetroColors.textSecondaryOf(context), : RetroColors.textSecondaryOf(context),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
// 잠금 표시 또는 점수 변화 // 잠금 표시 또는 점수 변화
@@ -548,10 +550,7 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: RetroColors.goldOf(context).withValues(alpha: 0.2), color: RetroColors.goldOf(context).withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
border: Border.all( border: Border.all(color: RetroColors.goldOf(context), width: 2),
color: RetroColors.goldOf(context),
width: 2,
),
), ),
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -643,11 +642,7 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
if (statWidgets.isEmpty) return const SizedBox.shrink(); if (statWidgets.isEmpty) return const SizedBox.shrink();
return Wrap( return Wrap(spacing: 3, runSpacing: 3, children: statWidgets);
spacing: 3,
runSpacing: 3,
children: statWidgets,
);
} }
Widget _buildStatChip(String label, int value, Color color) { Widget _buildStatChip(String label, int value, Color color) {
@@ -659,11 +654,7 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
), ),
child: Text( child: Text(
'$label +$value', '$label +$value',
style: TextStyle( style: TextStyle(fontFamily: 'PressStart2P', fontSize: 4, color: color),
fontFamily: 'PressStart2P',
fontSize: 4,
color: color,
),
), ),
); );
} }

View File

@@ -143,8 +143,8 @@ class ArenaRankCard extends StatelessWidget {
compact compact
? 'Lv.${entry.level}' ? 'Lv.${entry.level}'
: '${GameDataL10n.getRaceName(context, entry.race)} ' : '${GameDataL10n.getRaceName(context, entry.race)} '
'${GameDataL10n.getKlassName(context, entry.klass)} ' '${GameDataL10n.getKlassName(context, entry.klass)} '
'Lv.${entry.level}', 'Lv.${entry.level}',
style: TextStyle( style: TextStyle(
fontFamily: 'PressStart2P', fontFamily: 'PressStart2P',
fontSize: compact ? 5 : 7, fontSize: compact ? 5 : 7,

View File

@@ -63,15 +63,10 @@ class ArenaResultDialog extends StatelessWidget {
actions: [ actions: [
FilledButton( FilledButton(
onPressed: onClose, onPressed: onClose,
style: FilledButton.styleFrom( style: FilledButton.styleFrom(backgroundColor: resultColor),
backgroundColor: resultColor,
),
child: Text( child: Text(
l10n.buttonConfirm, l10n.buttonConfirm,
style: const TextStyle( style: const TextStyle(fontFamily: 'PressStart2P', fontSize: 8),
fontFamily: 'PressStart2P',
fontSize: 8,
),
), ),
), ),
], ],
@@ -249,10 +244,12 @@ class ArenaResultDialog extends StatelessWidget {
EquipmentItem? newItem, EquipmentItem? newItem,
bool isWinner, bool isWinner,
) { ) {
final oldScore = final oldScore = oldItem != null
oldItem != null ? ItemService.calculateEquipmentScore(oldItem) : 0; ? ItemService.calculateEquipmentScore(oldItem)
final newScore = : 0;
newItem != null ? ItemService.calculateEquipmentScore(newItem) : 0; final newScore = newItem != null
? ItemService.calculateEquipmentScore(newItem)
: 0;
final scoreDiff = newScore - oldScore; final scoreDiff = newScore - oldScore;
final isGain = scoreDiff > 0; final isGain = scoreDiff > 0;
@@ -287,12 +284,7 @@ class ArenaResultDialog extends StatelessWidget {
children: [ children: [
// 이전 장비 // 이전 장비
Expanded( Expanded(
child: _buildItemChip( child: _buildItemChip(context, oldItem, oldScore, isOld: true),
context,
oldItem,
oldScore,
isOld: true,
),
), ),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.symmetric(horizontal: 4),
@@ -304,12 +296,7 @@ class ArenaResultDialog extends StatelessWidget {
), ),
// 새 장비 // 새 장비
Expanded( Expanded(
child: _buildItemChip( child: _buildItemChip(context, newItem, newScore, isOld: false),
context,
newItem,
newScore,
isOld: false,
),
), ),
], ],
), ),
@@ -355,9 +342,7 @@ class ArenaResultDialog extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: rarityColor.withValues(alpha: isOld ? 0.1 : 0.2), color: rarityColor.withValues(alpha: isOld ? 0.1 : 0.2),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
border: Border.all( border: Border.all(color: rarityColor.withValues(alpha: 0.5)),
color: rarityColor.withValues(alpha: 0.5),
),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,

View File

@@ -1,4 +1,9 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.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/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/src/core/engine/item_service.dart'; 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_item.dart';
import 'package:asciineverdie/src/core/model/equipment_slot.dart'; import 'package:asciineverdie/src/core/model/equipment_slot.dart';
import 'package:asciineverdie/src/core/model/item_stats.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'; import 'package:asciineverdie/src/shared/retro_colors.dart';
// 임시 문자열 // 임시 문자열
@@ -23,6 +29,7 @@ class ArenaResultPanel extends StatefulWidget {
required this.result, required this.result,
required this.turnCount, required this.turnCount,
required this.onContinue, required this.onContinue,
this.battleLog,
}); });
/// 대전 결과 /// 대전 결과
@@ -34,6 +41,9 @@ class ArenaResultPanel extends StatefulWidget {
/// Continue 콜백 /// Continue 콜백
final VoidCallback onContinue; final VoidCallback onContinue;
/// 배틀 로그 (디버그 모드 저장용)
final List<CombatLogEntry>? battleLog;
@override @override
State<ArenaResultPanel> createState() => _ArenaResultPanelState(); State<ArenaResultPanel> createState() => _ArenaResultPanelState();
} }
@@ -52,21 +62,18 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
vsync: this, vsync: this,
); );
_slideAnimation = Tween<Offset>( _slideAnimation =
begin: const Offset(0, 1), // 아래에서 위로 Tween<Offset>(
end: Offset.zero, begin: const Offset(0, 1), // 아래에서 위로
).animate(CurvedAnimation( end: Offset.zero,
parent: _slideController, ).animate(
curve: Curves.easeOutCubic, CurvedAnimation(parent: _slideController, curve: Curves.easeOutCubic),
)); );
_fadeAnimation = Tween<double>( _fadeAnimation = Tween<double>(
begin: 0.0, begin: 0.0,
end: 1.0, end: 1.0,
).animate(CurvedAnimation( ).animate(CurvedAnimation(parent: _slideController, curve: Curves.easeOut));
parent: _slideController,
curve: Curves.easeOut,
));
// 약간 지연 후 애니메이션 시작 (분해 애니메이션과 동기화) // 약간 지연 후 애니메이션 시작 (분해 애니메이션과 동기화)
Future.delayed(const Duration(milliseconds: 800), () { Future.delayed(const Duration(milliseconds: 800), () {
@@ -82,6 +89,63 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
super.dispose(); super.dispose();
} }
/// 배틀 로그 JSON 저장 (macOS 디버그 모드 전용)
Future<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isVictory = widget.result.isVictory; final isVictory = widget.result.isVictory;
@@ -137,6 +201,13 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
// 장비 교환 // 장비 교환
_buildExchangeSection(context), _buildExchangeSection(context),
const SizedBox(height: 12), const SizedBox(height: 12),
// 배틀로그 저장 버튼 (macOS 디버그 모드 전용)
if (kDebugMode &&
Platform.isMacOS &&
widget.battleLog != null) ...[
_buildSaveLogButton(context),
const SizedBox(height: 8),
],
// Continue 버튼 // Continue 버튼
_buildContinueButton(context, resultColor), _buildContinueButton(context, resultColor),
], ],
@@ -262,10 +333,12 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
slot, slot,
); );
final oldScore = final oldScore = oldItem != null
oldItem != null ? ItemService.calculateEquipmentScore(oldItem) : 0; ? ItemService.calculateEquipmentScore(oldItem)
final newScore = : 0;
newItem != null ? ItemService.calculateEquipmentScore(newItem) : 0; final newScore = newItem != null
? ItemService.calculateEquipmentScore(newItem)
: 0;
final scoreDiff = newScore - oldScore; final scoreDiff = newScore - oldScore;
return Container( return Container(
@@ -344,7 +417,9 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(
scoreDiff >= 0 ? Icons.arrow_upward : Icons.arrow_downward, scoreDiff >= 0
? Icons.arrow_upward
: Icons.arrow_downward,
size: 10, size: 10,
color: scoreDiff >= 0 ? Colors.green : Colors.red, color: scoreDiff >= 0 ? Colors.green : Colors.red,
), ),
@@ -366,11 +441,7 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
); );
} }
Widget _buildItemBadge( Widget _buildItemBadge(BuildContext context, EquipmentItem? item, int score) {
BuildContext context,
EquipmentItem? item,
int score,
) {
if (item == null || item.isEmpty) { if (item == null || item.isEmpty) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
@@ -433,9 +504,7 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: color, backgroundColor: color,
padding: const EdgeInsets.symmetric(vertical: 10), padding: const EdgeInsets.symmetric(vertical: 10),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
borderRadius: BorderRadius.circular(6),
),
), ),
child: Text( child: Text(
l10n.buttonConfirm, l10n.buttonConfirm,
@@ -449,6 +518,27 @@ class _ArenaResultPanelState extends State<ArenaResultPanel>
); );
} }
/// 배틀로그 저장 버튼 (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<EquipmentItem>? equipment, EquipmentSlot slot) { EquipmentItem? _findItem(List<EquipmentItem>? equipment, EquipmentSlot slot) {
if (equipment == null) return null; if (equipment == null) return null;
for (final item in equipment) { for (final item in equipment) {

View File

@@ -11,9 +11,9 @@ class AsciiParticle {
required this.vx, required this.vx,
required this.vy, required this.vy,
required this.delay, required this.delay,
}) : x = initialX, }) : x = initialX,
y = initialY, y = initialY,
opacity = 1.0; opacity = 1.0;
final String char; final String char;
final double initialX; final double initialX;
@@ -29,7 +29,10 @@ class AsciiParticle {
/// 진행도에 따라 파티클 상태 업데이트 /// 진행도에 따라 파티클 상태 업데이트
void update(double progress) { 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) { if (adjustedProgress <= 0) {
// 아직 분해 시작 전 // 아직 분해 시작 전
@@ -101,10 +104,7 @@ class _AsciiDisintegrateWidgetState extends State<AsciiDisintegrateWidget>
void initState() { void initState() {
super.initState(); super.initState();
_initParticles(); _initParticles();
_controller = AnimationController( _controller = AnimationController(duration: widget.duration, vsync: this)
duration: widget.duration,
vsync: this,
)
..addListener(() => setState(() {})) ..addListener(() => setState(() {}))
..addStatusListener((status) { ..addStatusListener((status) {
if (status == AnimationStatus.completed) { if (status == AnimationStatus.completed) {
@@ -129,16 +129,18 @@ class _AsciiDisintegrateWidgetState extends State<AsciiDisintegrateWidget>
final char = line[x]; final char = line[x];
// 공백은 파티클로 변환하지 않음 // 공백은 파티클로 변환하지 않음
if (char != ' ') { if (char != ' ') {
_particles.add(AsciiParticle( _particles.add(
char: char, AsciiParticle(
initialX: x.toDouble(), char: char,
initialY: y.toDouble(), initialX: x.toDouble(),
// 랜덤 속도 (위쪽 + 좌우로 퍼짐) initialY: y.toDouble(),
vx: (_random.nextDouble() - 0.5) * 4.0, // 랜덤 속도 (위쪽 + 좌우로 퍼짐)
vy: -_random.nextDouble() * 2.0 - 0.5, // 위쪽으로 vx: (_random.nextDouble() - 0.5) * 4.0,
// 랜덤 지연 (안쪽에서 바깥쪽으로 분해) vy: -_random.nextDouble() * 2.0 - 0.5, // 위쪽으로
delay: _random.nextDouble() * 0.3, // 랜덤 지연 (안쪽에서 바깥쪽으로 분해)
)); delay: _random.nextDouble() * 0.3,
),
);
} }
} }
} }
@@ -158,9 +160,9 @@ class _AsciiDisintegrateWidgetState extends State<AsciiDisintegrateWidget>
size: Size( size: Size(
widget.characterLines.isNotEmpty widget.characterLines.isNotEmpty
? widget.characterLines ? widget.characterLines
.map((l) => l.length) .map((l) => l.length)
.reduce((a, b) => a > b ? a : b) * .reduce((a, b) => a > b ? a : b) *
widget.charWidth widget.charWidth
: 0, : 0,
widget.characterLines.length * widget.charHeight, widget.characterLines.length * widget.charHeight,
), ),