Compare commits

...

2 Commits

Author SHA1 Message Date
JiWoong Sul
c3a8bc305a feat(arena): 아레나 전투 로그 위젯 추가
- ArenaCombatLog: 전투 로그 표시 위젯
- ArenaBattleScreen 연동
2026-01-06 19:19:05 +09:00
JiWoong Sul
a2d62f1f4f refactor(arena): 아레나 서비스 로직 개선
- 전투 로직 리팩토링
- 상태 관리 개선
2026-01-06 19:19:00 +09:00
3 changed files with 538 additions and 188 deletions

View File

@@ -2,7 +2,6 @@ import 'package:asciineverdie/data/skill_data.dart';
import 'package:asciineverdie/src/core/engine/combat_calculator.dart'; import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
import 'package:asciineverdie/src/core/engine/skill_service.dart'; import 'package:asciineverdie/src/core/engine/skill_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_stats.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/game_state.dart'; import 'package:asciineverdie/src/core/model/game_state.dart';
@@ -47,64 +46,38 @@ class ArenaService {
return skills; return skills;
} }
/// AI 스킬 선택 (우선순위: 회복 > 버프 > 공격) /// 스킬 ID 목록 추출 (HallOfFameEntry에서)
Skill? _selectBestSkill({ List<String> _getSkillIdsFromEntry(HallOfFameEntry entry) {
required List<Skill> skills, return _getSkillsFromEntry(entry).map((s) => s.id).toList();
required CombatStats stats, }
required SkillSystemState skillSystem,
required MonsterCombatStats? target,
}) {
if (skills.isEmpty) return null;
final currentMp = stats.mpCurrent; /// 스킬 랭크 조회 (HallOfFameEntry의 finalSkills에서)
final hpRatio = stats.hpCurrent / stats.hpMax; int _getSkillRankFromEntry(HallOfFameEntry entry, String skillId) {
final skill = SkillData.getSkillById(skillId);
if (skill == null) return 1;
// HP가 낮으면 회복 스킬 우선 final skillData = entry.finalSkills;
if (hpRatio < 0.4) { if (skillData == null || skillData.isEmpty) return 1;
for (final skill in skills) {
if (skill.type == SkillType.heal && for (final data in skillData) {
_skillService.canUseSkill( if (data['name'] == skill.name) {
skill: skill, final rankStr = data['rank'] ?? 'I';
currentMp: currentMp, return _romanToInt(rankStr);
skillSystem: skillSystem,
) ==
null) {
return skill;
}
} }
} }
return 1;
}
// 버프가 없으면 버프 스킬 /// 로마 숫자 → 정수 변환
if (skillSystem.activeBuffs.isEmpty) { int _romanToInt(String roman) {
for (final skill in skills) { return switch (roman) {
if (skill.type == SkillType.buff && 'I' => 1,
_skillService.canUseSkill( 'II' => 2,
skill: skill, 'III' => 3,
currentMp: currentMp, 'IV' => 4,
skillSystem: skillSystem, 'V' => 5,
) == _ => 1,
null) { };
return skill;
}
}
}
// MP가 충분하면 공격 스킬
if (currentMp >= 30) {
for (final skill in skills) {
if (skill.type == SkillType.attack &&
_skillService.canUseSkill(
skill: skill,
currentMp: currentMp,
skillSystem: skillSystem,
) ==
null) {
return skill;
}
}
}
return null; // 스킬 사용 안 함 (기본 공격)
} }
// ============================================================================ // ============================================================================
@@ -223,8 +196,40 @@ class ArenaService {
); );
} }
/// 시뮬레이션 결과를 기반으로 전투 결과 생성
///
/// [match] 대전 정보
/// [challengerHp] 도전자 최종 HP
/// [opponentHp] 상대 최종 HP
/// [turns] 총 턴 수
/// Returns: 대전 결과 (승패, 장비 교환 후 캐릭터)
ArenaMatchResult createResultFromSimulation({
required ArenaMatch match,
required int challengerHp,
required int opponentHp,
required int turns,
}) {
// 도전자 HP가 0보다 크면 승리
final isVictory = challengerHp > 0 && opponentHp <= 0;
// 장비 교환
final (updatedChallenger, updatedOpponent) = _exchangeEquipment(
match: match,
isVictory: isVictory,
);
return ArenaMatchResult(
match: match,
isVictory: isVictory,
turns: turns,
updatedChallenger: updatedChallenger,
updatedOpponent: updatedOpponent,
);
}
/// 전투 시뮬레이션 (애니메이션용 스트림) /// 전투 시뮬레이션 (애니메이션용 스트림)
/// ///
/// progress_service._processCombatTickWithSkills()와 동일한 로직 사용
/// [match] 대전 정보 /// [match] 대전 정보
/// Returns: 턴별 전투 상황 스트림 /// Returns: 턴별 전투 상황 스트림
Stream<ArenaCombatTurn> simulateCombat(ArenaMatch match) async* { Stream<ArenaCombatTurn> simulateCombat(ArenaMatch match) async* {
@@ -237,30 +242,48 @@ class ArenaService {
return; return;
} }
// 스킬 목록 로드 // 스킬 ID 목록 로드 (SkillBook과 동일한 방식)
final challengerSkills = _getSkillsFromEntry(match.challenger); var challengerSkillIds = _getSkillIdsFromEntry(match.challenger);
final opponentSkills = _getSkillsFromEntry(match.opponent); var opponentSkillIds = _getSkillIdsFromEntry(match.opponent);
// 스킬이 없으면 기본 스킬 사용
if (challengerSkillIds.isEmpty) {
challengerSkillIds = SkillData.defaultSkillIds;
}
if (opponentSkillIds.isEmpty) {
opponentSkillIds = SkillData.defaultSkillIds;
}
// 스킬 시스템 상태 초기화 // 스킬 시스템 상태 초기화
var challengerSkillSystem = SkillSystemState.empty(); var challengerSkillSystem = SkillSystemState.empty();
var opponentSkillSystem = SkillSystemState.empty(); var opponentSkillSystem = SkillSystemState.empty();
// DOT 및 디버프 추적 (일반 전투와 동일)
var challengerDoTs = <DotEffect>[];
var opponentDoTs = <DotEffect>[];
var challengerDebuffs = <ActiveBuff>[];
var opponentDebuffs = <ActiveBuff>[];
var playerCombatStats = challengerStats.copyWith( var playerCombatStats = challengerStats.copyWith(
hpCurrent: challengerStats.hpMax, hpCurrent: challengerStats.hpMax,
mpCurrent: challengerStats.mpMax, mpCurrent: challengerStats.mpMax,
); );
// 상대도 CombatStats로 관리 (스킬 사용 위해)
var opponentCombatStats = opponentStats.copyWith( var opponentCombatStats = opponentStats.copyWith(
hpCurrent: opponentStats.hpMax, hpCurrent: opponentStats.hpMax,
mpCurrent: opponentStats.mpMax, mpCurrent: opponentStats.mpMax,
); );
var opponentMonsterStats = MonsterCombatStats.fromCombatStats( var opponentMonsterStats = MonsterCombatStats.fromCombatStats(
opponentStats, opponentCombatStats,
match.opponent.characterName, match.opponent.characterName,
); );
var challengerMonsterStats = MonsterCombatStats.fromCombatStats(
playerCombatStats,
match.challenger.characterName,
);
int playerAccum = 0; int playerAccum = 0;
int opponentAccum = 0; int opponentAccum = 0;
int elapsedMs = 0; int elapsedMs = 0;
@@ -301,47 +324,164 @@ class ArenaService {
int? challengerHealAmount; int? challengerHealAmount;
int? opponentHealAmount; int? opponentHealAmount;
// 도전자 턴 // =========================================================================
// DOT 틱 처리 (도전자 → 상대에게 적용된 DOT)
// =========================================================================
var dotDamageToOpponent = 0;
final updatedChallengerDoTs = <DotEffect>[];
for (final dot in challengerDoTs) {
final (updatedDot, ticksTriggered) = dot.tick(tickMs);
if (ticksTriggered > 0) {
dotDamageToOpponent += dot.damagePerTick * ticksTriggered;
}
if (updatedDot.isActive) {
updatedChallengerDoTs.add(updatedDot);
}
}
challengerDoTs = updatedChallengerDoTs;
if (dotDamageToOpponent > 0 && opponentCombatStats.hpCurrent > 0) {
opponentCombatStats = opponentCombatStats.copyWith(
hpCurrent: (opponentCombatStats.hpCurrent - dotDamageToOpponent)
.clamp(0, opponentCombatStats.hpMax),
);
}
// DOT 틱 처리 (상대 → 도전자에게 적용된 DOT)
var dotDamageToChallenger = 0;
final updatedOpponentDoTs = <DotEffect>[];
for (final dot in opponentDoTs) {
final (updatedDot, ticksTriggered) = dot.tick(tickMs);
if (ticksTriggered > 0) {
dotDamageToChallenger += dot.damagePerTick * ticksTriggered;
}
if (updatedDot.isActive) {
updatedOpponentDoTs.add(updatedDot);
}
}
opponentDoTs = updatedOpponentDoTs;
if (dotDamageToChallenger > 0 && playerCombatStats.isAlive) {
playerCombatStats = playerCombatStats.copyWith(
hpCurrent: (playerCombatStats.hpCurrent - dotDamageToChallenger)
.clamp(0, playerCombatStats.hpMax),
);
}
// =========================================================================
// 만료된 디버프 정리
// =========================================================================
challengerDebuffs = challengerDebuffs
.where((ActiveBuff d) => !d.isExpired(elapsedMs))
.toList();
opponentDebuffs = opponentDebuffs
.where((ActiveBuff d) => !d.isExpired(elapsedMs))
.toList();
// =========================================================================
// 도전자 턴 (selectAutoSkill 사용 - 일반 전투와 동일)
// =========================================================================
if (playerAccum >= playerCombatStats.attackDelayMs) { if (playerAccum >= playerCombatStats.attackDelayMs) {
playerAccum = 0; playerAccum = 0;
// 스킬 선택 // 상대 몬스터 스탯 동기화
final skill = _selectBestSkill( opponentMonsterStats = MonsterCombatStats.fromCombatStats(
skills: challengerSkills, opponentCombatStats,
stats: playerCombatStats, match.opponent.characterName,
skillSystem: challengerSkillSystem,
target: opponentMonsterStats,
); );
if (skill != null) { // 스킬 자동 선택 (progress_service와 동일한 로직)
// 스킬 사용 final selectedSkill = _skillService.selectAutoSkill(
final skillResult = _useSkill( player: playerCombatStats,
skill: skill, monster: opponentMonsterStats,
attacker: playerCombatStats, skillSystem: challengerSkillSystem,
defender: opponentCombatStats, availableSkillIds: challengerSkillIds,
activeDoTs: challengerDoTs,
activeDebuffs: opponentDebuffs,
);
if (selectedSkill != null && selectedSkill.isAttack) {
// 스킬 랭크 조회 및 적용
final skillRank = _getSkillRankFromEntry(
match.challenger,
selectedSkill.id,
);
final skillResult = _skillService.useAttackSkillWithRank(
skill: selectedSkill,
player: playerCombatStats,
monster: opponentMonsterStats,
skillSystem: challengerSkillSystem,
rank: skillRank,
);
playerCombatStats = skillResult.updatedPlayer;
opponentCombatStats = opponentCombatStats.copyWith(
hpCurrent: skillResult.updatedMonster.hpCurrent,
);
challengerSkillSystem = skillResult.updatedSkillSystem;
challengerSkillUsed = selectedSkill.name;
challengerDamage = skillResult.result.damage;
} else if (selectedSkill != null && selectedSkill.isDot) {
// DOT 스킬 사용
final skillResult = _skillService.useDotSkill(
skill: selectedSkill,
player: playerCombatStats,
skillSystem: challengerSkillSystem,
playerInt: playerCombatStats.atk ~/ 10,
playerWis: playerCombatStats.def ~/ 10,
);
playerCombatStats = skillResult.updatedPlayer;
challengerSkillSystem = skillResult.updatedSkillSystem;
if (skillResult.dotEffect != null) {
challengerDoTs.add(skillResult.dotEffect!);
}
challengerSkillUsed = selectedSkill.name;
} else if (selectedSkill != null && selectedSkill.isHeal) {
// 회복 스킬 사용
final skillResult = _skillService.useHealSkill(
skill: selectedSkill,
player: playerCombatStats,
skillSystem: challengerSkillSystem, skillSystem: challengerSkillSystem,
); );
playerCombatStats = skillResult.updatedAttacker; playerCombatStats = skillResult.updatedPlayer;
opponentCombatStats = skillResult.updatedDefender;
challengerSkillSystem = skillResult.updatedSkillSystem; challengerSkillSystem = skillResult.updatedSkillSystem;
challengerSkillUsed = skill.name; challengerSkillUsed = selectedSkill.name;
challengerDamage = skillResult.damage; challengerHealAmount = skillResult.result.healedAmount;
challengerHealAmount = skillResult.healAmount; } else if (selectedSkill != null && selectedSkill.isBuff) {
isChallengerCritical = skillResult.isCritical; // 버프 스킬 사용
} else { final skillResult = _skillService.useBuffSkill(
// 기본 공격 skill: selectedSkill,
// 상대 몬스터 스탯 동기화 player: playerCombatStats,
opponentMonsterStats = MonsterCombatStats.fromCombatStats( skillSystem: challengerSkillSystem,
opponentCombatStats,
match.opponent.characterName,
); );
playerCombatStats = skillResult.updatedPlayer;
challengerSkillSystem = skillResult.updatedSkillSystem;
challengerSkillUsed = selectedSkill.name;
} else if (selectedSkill != null && selectedSkill.isDebuff) {
// 디버프 스킬 사용
final skillResult = _skillService.useDebuffSkill(
skill: selectedSkill,
player: playerCombatStats,
skillSystem: challengerSkillSystem,
currentDebuffs: opponentDebuffs,
);
playerCombatStats = skillResult.updatedPlayer;
challengerSkillSystem = skillResult.updatedSkillSystem;
final debuffEffect = skillResult.debuffEffect;
if (debuffEffect != null) {
opponentDebuffs = opponentDebuffs
.where((ActiveBuff d) => d.effect.id != debuffEffect.effect.id)
.toList()
..add(debuffEffect);
}
challengerSkillUsed = selectedSkill.name;
} else {
// 일반 공격
final result = calculator.playerAttackMonster( final result = calculator.playerAttackMonster(
attacker: playerCombatStats, attacker: playerCombatStats,
defender: opponentMonsterStats, defender: opponentMonsterStats,
); );
opponentMonsterStats = result.updatedDefender;
opponentCombatStats = opponentCombatStats.copyWith( opponentCombatStats = opponentCombatStats.copyWith(
hpCurrent: opponentMonsterStats.hpCurrent, hpCurrent: result.updatedDefender.hpCurrent,
); );
if (result.result.isHit) { if (result.result.isHit) {
@@ -353,38 +493,121 @@ class ArenaService {
} }
} }
// 상대 턴 // =========================================================================
// 상대 턴 (selectAutoSkill 사용 - 일반 전투와 동일)
// =========================================================================
if (opponentCombatStats.hpCurrent > 0 && if (opponentCombatStats.hpCurrent > 0 &&
opponentAccum >= opponentCombatStats.attackDelayMs) { opponentAccum >= opponentCombatStats.attackDelayMs) {
opponentAccum = 0; opponentAccum = 0;
// 상대 스킬 선택 // 도전자 몬스터 스탯 동기화
final skill = _selectBestSkill( challengerMonsterStats = MonsterCombatStats.fromCombatStats(
skills: opponentSkills, playerCombatStats,
stats: opponentCombatStats, match.challenger.characterName,
skillSystem: opponentSkillSystem,
target: null,
); );
if (skill != null) { // 스킬 자동 선택 (progress_service와 동일한 로직)
// 스킬 사용 final selectedSkill = _skillService.selectAutoSkill(
final skillResult = _useSkill( player: opponentCombatStats,
skill: skill, monster: challengerMonsterStats,
attacker: opponentCombatStats, skillSystem: opponentSkillSystem,
defender: playerCombatStats, availableSkillIds: opponentSkillIds,
activeDoTs: opponentDoTs,
activeDebuffs: challengerDebuffs,
);
if (selectedSkill != null && selectedSkill.isAttack) {
// 스킬 랭크 조회 및 적용
final skillRank = _getSkillRankFromEntry(
match.opponent,
selectedSkill.id,
);
final skillResult = _skillService.useAttackSkillWithRank(
skill: selectedSkill,
player: opponentCombatStats,
monster: challengerMonsterStats,
skillSystem: opponentSkillSystem,
rank: skillRank,
);
opponentCombatStats = skillResult.updatedPlayer;
playerCombatStats = playerCombatStats.copyWith(
hpCurrent: skillResult.updatedMonster.hpCurrent,
);
opponentSkillSystem = skillResult.updatedSkillSystem;
opponentSkillUsed = selectedSkill.name;
opponentDamage = skillResult.result.damage;
} else if (selectedSkill != null && selectedSkill.isDot) {
// DOT 스킬 사용
final skillResult = _skillService.useDotSkill(
skill: selectedSkill,
player: opponentCombatStats,
skillSystem: opponentSkillSystem,
playerInt: opponentCombatStats.atk ~/ 10,
playerWis: opponentCombatStats.def ~/ 10,
);
opponentCombatStats = skillResult.updatedPlayer;
opponentSkillSystem = skillResult.updatedSkillSystem;
if (skillResult.dotEffect != null) {
opponentDoTs.add(skillResult.dotEffect!);
}
opponentSkillUsed = selectedSkill.name;
} else if (selectedSkill != null && selectedSkill.isHeal) {
// 회복 스킬 사용
final skillResult = _skillService.useHealSkill(
skill: selectedSkill,
player: opponentCombatStats,
skillSystem: opponentSkillSystem, skillSystem: opponentSkillSystem,
); );
opponentCombatStats = skillResult.updatedAttacker; opponentCombatStats = skillResult.updatedPlayer;
playerCombatStats = skillResult.updatedDefender;
opponentSkillSystem = skillResult.updatedSkillSystem; opponentSkillSystem = skillResult.updatedSkillSystem;
opponentSkillUsed = skill.name; opponentSkillUsed = selectedSkill.name;
opponentDamage = skillResult.damage; opponentHealAmount = skillResult.result.healedAmount;
opponentHealAmount = skillResult.healAmount; } else if (selectedSkill != null && selectedSkill.isBuff) {
isOpponentCritical = skillResult.isCritical; // 버프 스킬 사용
final skillResult = _skillService.useBuffSkill(
skill: selectedSkill,
player: opponentCombatStats,
skillSystem: opponentSkillSystem,
);
opponentCombatStats = skillResult.updatedPlayer;
opponentSkillSystem = skillResult.updatedSkillSystem;
opponentSkillUsed = selectedSkill.name;
} else if (selectedSkill != null && selectedSkill.isDebuff) {
// 디버프 스킬 사용
final skillResult = _skillService.useDebuffSkill(
skill: selectedSkill,
player: opponentCombatStats,
skillSystem: opponentSkillSystem,
currentDebuffs: challengerDebuffs,
);
opponentCombatStats = skillResult.updatedPlayer;
opponentSkillSystem = skillResult.updatedSkillSystem;
final debuffEffect = skillResult.debuffEffect;
if (debuffEffect != null) {
challengerDebuffs = challengerDebuffs
.where((ActiveBuff d) => d.effect.id != debuffEffect.effect.id)
.toList()
..add(debuffEffect);
}
opponentSkillUsed = selectedSkill.name;
} else { } else {
// 기본 공격 (몬스터 형태로) // 일반 공격 (디버프 효과 적용)
var debuffedOpponent = opponentCombatStats;
if (challengerDebuffs.isNotEmpty) {
double atkMod = 0;
for (final debuff in challengerDebuffs) {
if (!debuff.isExpired(elapsedMs)) {
atkMod += debuff.effect.atkModifier;
}
}
final newAtk = (opponentCombatStats.atk * (1 + atkMod))
.round()
.clamp(opponentCombatStats.atk ~/ 10, opponentCombatStats.atk);
debuffedOpponent = opponentCombatStats.copyWith(atk: newAtk);
}
opponentMonsterStats = MonsterCombatStats.fromCombatStats( opponentMonsterStats = MonsterCombatStats.fromCombatStats(
opponentCombatStats, debuffedOpponent,
match.opponent.characterName, match.opponent.characterName,
); );
final result = calculator.monsterAttackPlayer( final result = calculator.monsterAttackPlayer(
@@ -444,84 +667,6 @@ class ArenaService {
if (turns > 1000) break; if (turns > 1000) break;
} }
} }
/// 스킬 사용 (내부 헬퍼)
({
CombatStats updatedAttacker,
CombatStats updatedDefender,
SkillSystemState updatedSkillSystem,
int? damage,
int? healAmount,
bool isCritical,
}) _useSkill({
required Skill skill,
required CombatStats attacker,
required CombatStats defender,
required SkillSystemState skillSystem,
}) {
int? damage;
int? healAmount;
var updatedAttacker = attacker;
var updatedDefender = defender;
var updatedSkillSystem = skillSystem;
switch (skill.type) {
case SkillType.attack:
final monsterStats = MonsterCombatStats.fromCombatStats(defender, '');
final result = _skillService.useAttackSkill(
skill: skill,
player: attacker,
monster: monsterStats,
skillSystem: skillSystem,
);
updatedAttacker = result.updatedPlayer;
updatedDefender = defender.copyWith(
hpCurrent: result.updatedMonster.hpCurrent,
);
updatedSkillSystem = result.updatedSkillSystem;
damage = result.result.damage;
case SkillType.heal:
final result = _skillService.useHealSkill(
skill: skill,
player: attacker,
skillSystem: skillSystem,
);
updatedAttacker = result.updatedPlayer;
updatedSkillSystem = result.updatedSkillSystem;
healAmount = result.result.healedAmount;
case SkillType.buff:
final result = _skillService.useBuffSkill(
skill: skill,
player: attacker,
skillSystem: skillSystem,
);
updatedAttacker = result.updatedPlayer;
updatedSkillSystem = result.updatedSkillSystem;
case SkillType.debuff:
// 디버프 스킬 사용
final debuffResult = _skillService.useDebuffSkill(
skill: skill,
player: attacker,
skillSystem: skillSystem,
currentDebuffs: [],
);
updatedAttacker = debuffResult.updatedPlayer;
updatedSkillSystem = debuffResult.updatedSkillSystem;
}
return (
updatedAttacker: updatedAttacker,
updatedDefender: updatedDefender,
updatedSkillSystem: updatedSkillSystem,
damage: damage,
healAmount: healAmount,
isCritical: false, // 스킬 크리티컬은 별도 처리 필요
);
}
// ============================================================================ // ============================================================================
// 장비 교환 // 장비 교환
// ============================================================================ // ============================================================================

View File

@@ -8,6 +8,7 @@ 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/core/animation/race_character_frames.dart'; import 'package:asciineverdie/src/core/animation/race_character_frames.dart';
import 'package:asciineverdie/src/features/arena/widgets/arena_combat_log.dart';
import 'package:asciineverdie/src/features/arena/widgets/arena_result_panel.dart'; import 'package:asciineverdie/src/features/arena/widgets/arena_result_panel.dart';
import 'package:asciineverdie/src/features/arena/widgets/ascii_disintegrate_widget.dart'; import 'package:asciineverdie/src/features/arena/widgets/ascii_disintegrate_widget.dart';
import 'package:asciineverdie/src/features/game/widgets/ascii_animation_card.dart'; import 'package:asciineverdie/src/features/game/widgets/ascii_animation_card.dart';
@@ -376,8 +377,13 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
} }
void _endBattle() { void _endBattle() {
// 최종 결과 계산 // 시뮬레이션 HP 결과를 기반으로 최종 결과 계산
_result = _arenaService.executeCombat(widget.match); _result = _arenaService.createResultFromSimulation(
match: widget.match,
challengerHp: _challengerHp,
opponentHp: _opponentHp,
turns: _currentTurn,
);
// 전투 종료 상태로 전환 (인라인 결과 패널 표시) // 전투 종료 상태로 전환 (인라인 결과 패널 표시)
setState(() { setState(() {
@@ -836,7 +842,7 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all(color: RetroColors.borderOf(context)), border: Border.all(color: RetroColors.borderOf(context)),
), ),
child: CombatLog(entries: _battleLog), child: ArenaCombatLog(entries: _battleLog),
); );
} }

View File

@@ -0,0 +1,199 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:asciineverdie/src/features/game/widgets/combat_log.dart';
/// 아레나 전용 전투 로그 위젯
///
/// 일반 CombatLog와 다른 점:
/// - 최신 메시지가 상단에 표시 (reverse order)
/// - 사용자 조작 시 자동 스크롤 중지
/// - 5초 미조작 시 자동 스크롤 재개
class ArenaCombatLog extends StatefulWidget {
const ArenaCombatLog({
super.key,
required this.entries,
this.maxEntries = 50,
});
final List<CombatLogEntry> entries;
final int maxEntries;
@override
State<ArenaCombatLog> createState() => _ArenaCombatLogState();
}
class _ArenaCombatLogState extends State<ArenaCombatLog> {
final ScrollController _scrollController = ScrollController();
/// 자동 스크롤 활성화 여부
bool _autoScrollEnabled = true;
/// 사용자 조작 후 자동 스크롤 재개 타이머
Timer? _resumeAutoScrollTimer;
/// 자동 스크롤 재개까지 대기 시간 (5초)
static const _autoScrollResumeDelay = Duration(seconds: 5);
int _previousLength = 0;
@override
void didUpdateWidget(ArenaCombatLog oldWidget) {
super.didUpdateWidget(oldWidget);
// 새 로그 추가 시 자동 스크롤 (활성화된 경우에만)
if (widget.entries.length > _previousLength && _autoScrollEnabled) {
_scrollToTop();
}
_previousLength = widget.entries.length;
}
/// 최상단으로 스크롤 (최신 메시지가 상단이므로 position 0)
void _scrollToTop() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
/// 사용자 스크롤 감지 시 호출
void _onUserScroll() {
// 자동 스크롤 비활성화
setState(() {
_autoScrollEnabled = false;
});
// 기존 타이머 취소
_resumeAutoScrollTimer?.cancel();
// 5초 후 자동 스크롤 재활성화
_resumeAutoScrollTimer = Timer(_autoScrollResumeDelay, () {
if (mounted) {
setState(() {
_autoScrollEnabled = true;
});
// 재활성화 시 최상단으로 스크롤
_scrollToTop();
}
});
}
@override
void dispose() {
_resumeAutoScrollTimer?.cancel();
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// 최신 메시지가 상단에 오도록 역순 리스트 생성
final reversedEntries = widget.entries.reversed.toList();
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
// 사용자가 직접 스크롤할 때만 감지 (UserScrollNotification)
if (notification is UserScrollNotification) {
_onUserScroll();
}
return false;
},
child: ListView.builder(
controller: _scrollController,
itemCount: reversedEntries.length,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
itemBuilder: (context, index) {
final entry = reversedEntries[index];
return _ArenaLogEntryTile(entry: entry);
},
),
);
}
}
/// 개별 로그 엔트리 타일 (아레나용)
class _ArenaLogEntryTile extends StatelessWidget {
const _ArenaLogEntryTile({required this.entry});
final CombatLogEntry entry;
@override
Widget build(BuildContext context) {
final (color, icon) = _getStyleForType(entry.type);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 타임스탬프(timestamp)
Text(
_formatTime(entry.timestamp),
style: TextStyle(
fontSize: 10,
color: Theme.of(context).colorScheme.outline,
fontFamily: 'JetBrainsMono',
),
),
const SizedBox(width: 4),
// 아이콘
if (icon != null)
Padding(
padding: const EdgeInsets.only(right: 4),
child: Icon(icon, size: 12, color: color),
),
// 메시지
Expanded(
child: Text(
entry.message,
style: TextStyle(
fontSize: 11,
color: color ?? Theme.of(context).colorScheme.onSurface,
),
),
),
],
),
);
}
String _formatTime(DateTime time) {
return '${time.hour.toString().padLeft(2, '0')}:'
'${time.minute.toString().padLeft(2, '0')}:'
'${time.second.toString().padLeft(2, '0')}';
}
(Color?, IconData?) _getStyleForType(CombatLogType type) {
return switch (type) {
CombatLogType.normal => (null, null),
CombatLogType.damage => (
Colors.red.shade300,
Icons.local_fire_department,
),
CombatLogType.heal => (Colors.green.shade300, Icons.healing),
CombatLogType.levelUp => (Colors.amber, Icons.arrow_upward),
CombatLogType.questComplete => (Colors.blue.shade300, Icons.check_circle),
CombatLogType.loot => (Colors.orange.shade300, Icons.inventory_2),
CombatLogType.skill => (Colors.purple.shade300, Icons.auto_fix_high),
CombatLogType.critical => (Colors.yellow.shade300, Icons.flash_on),
CombatLogType.evade => (Colors.cyan.shade300, Icons.directions_run),
CombatLogType.block => (Colors.blueGrey.shade300, Icons.shield),
CombatLogType.parry => (Colors.teal.shade300, Icons.sports_kabaddi),
CombatLogType.monsterAttack => (
Colors.deepOrange.shade300,
Icons.dangerous,
),
CombatLogType.buff => (Colors.lightBlue.shade300, Icons.trending_up),
CombatLogType.debuff => (Colors.deepOrange.shade300, Icons.trending_down),
CombatLogType.dotTick => (Colors.deepPurple.shade300, Icons.whatshot),
CombatLogType.potion => (Colors.pink.shade300, Icons.local_drink),
CombatLogType.potionDrop => (Colors.lime.shade300, Icons.card_giftcard),
};
}
}