Compare commits
2 Commits
cfa60f11d1
...
afc3c18ae4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afc3c18ae4 | ||
|
|
2efd50a09d |
@@ -1,9 +1,14 @@
|
|||||||
|
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/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/hall_of_fame.dart';
|
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
||||||
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/skill.dart';
|
||||||
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||||
|
|
||||||
/// 아레나 서비스
|
/// 아레나 서비스
|
||||||
@@ -18,6 +23,90 @@ class ArenaService {
|
|||||||
|
|
||||||
final DeterministicRandom _rng;
|
final DeterministicRandom _rng;
|
||||||
|
|
||||||
|
late final SkillService _skillService = SkillService(rng: _rng);
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 스킬 시스템 헬퍼
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// HallOfFameEntry의 finalSpells에서 Skill 목록 추출
|
||||||
|
List<Skill> _getSkillsFromEntry(HallOfFameEntry entry) {
|
||||||
|
final spells = entry.finalSpells;
|
||||||
|
if (spells == null || spells.isEmpty) return [];
|
||||||
|
|
||||||
|
final skills = <Skill>[];
|
||||||
|
for (final spell in spells) {
|
||||||
|
final spellName = spell['name'];
|
||||||
|
if (spellName != null) {
|
||||||
|
final skill = SkillData.getSkillBySpellName(spellName);
|
||||||
|
if (skill != null) {
|
||||||
|
skills.add(skill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return skills;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI 스킬 선택 (우선순위: 회복 > 버프 > 공격)
|
||||||
|
Skill? _selectBestSkill({
|
||||||
|
required List<Skill> skills,
|
||||||
|
required CombatStats stats,
|
||||||
|
required SkillSystemState skillSystem,
|
||||||
|
required MonsterCombatStats? target,
|
||||||
|
}) {
|
||||||
|
if (skills.isEmpty) return null;
|
||||||
|
|
||||||
|
final currentMp = stats.mpCurrent;
|
||||||
|
final hpRatio = stats.hpCurrent / stats.hpMax;
|
||||||
|
|
||||||
|
// HP가 낮으면 회복 스킬 우선
|
||||||
|
if (hpRatio < 0.4) {
|
||||||
|
for (final skill in skills) {
|
||||||
|
if (skill.type == SkillType.heal &&
|
||||||
|
_skillService.canUseSkill(
|
||||||
|
skill: skill,
|
||||||
|
currentMp: currentMp,
|
||||||
|
skillSystem: skillSystem,
|
||||||
|
) ==
|
||||||
|
null) {
|
||||||
|
return skill;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 버프가 없으면 버프 스킬
|
||||||
|
if (skillSystem.activeBuffs.isEmpty) {
|
||||||
|
for (final skill in skills) {
|
||||||
|
if (skill.type == SkillType.buff &&
|
||||||
|
_skillService.canUseSkill(
|
||||||
|
skill: skill,
|
||||||
|
currentMp: currentMp,
|
||||||
|
skillSystem: skillSystem,
|
||||||
|
) ==
|
||||||
|
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; // 스킬 사용 안 함 (기본 공격)
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 상대 결정
|
// 상대 결정
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -148,11 +237,25 @@ class ArenaService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 스킬 목록 로드
|
||||||
|
final challengerSkills = _getSkillsFromEntry(match.challenger);
|
||||||
|
final opponentSkills = _getSkillsFromEntry(match.opponent);
|
||||||
|
|
||||||
|
// 스킬 시스템 상태 초기화
|
||||||
|
var challengerSkillSystem = SkillSystemState.empty();
|
||||||
|
var opponentSkillSystem = SkillSystemState.empty();
|
||||||
|
|
||||||
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(
|
||||||
|
hpCurrent: opponentStats.hpMax,
|
||||||
|
mpCurrent: opponentStats.mpMax,
|
||||||
|
);
|
||||||
|
|
||||||
var opponentMonsterStats = MonsterCombatStats.fromCombatStats(
|
var opponentMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||||
opponentStats,
|
opponentStats,
|
||||||
match.opponent.characterName,
|
match.opponent.characterName,
|
||||||
@@ -160,20 +263,30 @@ class ArenaService {
|
|||||||
|
|
||||||
int playerAccum = 0;
|
int playerAccum = 0;
|
||||||
int opponentAccum = 0;
|
int opponentAccum = 0;
|
||||||
|
int elapsedMs = 0;
|
||||||
const tickMs = 200;
|
const tickMs = 200;
|
||||||
int turns = 0;
|
int turns = 0;
|
||||||
|
|
||||||
// 초기 상태 전송
|
// 초기 상태 전송
|
||||||
yield ArenaCombatTurn(
|
yield ArenaCombatTurn(
|
||||||
challengerHp: playerCombatStats.hpCurrent,
|
challengerHp: playerCombatStats.hpCurrent,
|
||||||
opponentHp: opponentMonsterStats.hpCurrent,
|
opponentHp: opponentCombatStats.hpCurrent,
|
||||||
challengerHpMax: playerCombatStats.hpMax,
|
challengerHpMax: playerCombatStats.hpMax,
|
||||||
opponentHpMax: opponentMonsterStats.hpMax,
|
opponentHpMax: opponentCombatStats.hpMax,
|
||||||
|
challengerMp: playerCombatStats.mpCurrent,
|
||||||
|
opponentMp: opponentCombatStats.mpCurrent,
|
||||||
|
challengerMpMax: playerCombatStats.mpMax,
|
||||||
|
opponentMpMax: opponentCombatStats.mpMax,
|
||||||
);
|
);
|
||||||
|
|
||||||
while (playerCombatStats.isAlive && opponentMonsterStats.isAlive) {
|
while (playerCombatStats.isAlive && opponentCombatStats.hpCurrent > 0) {
|
||||||
playerAccum += tickMs;
|
playerAccum += tickMs;
|
||||||
opponentAccum += tickMs;
|
opponentAccum += tickMs;
|
||||||
|
elapsedMs += tickMs;
|
||||||
|
|
||||||
|
// 스킬 시스템 시간 업데이트
|
||||||
|
challengerSkillSystem = challengerSkillSystem.copyWith(elapsedMs: elapsedMs);
|
||||||
|
opponentSkillSystem = opponentSkillSystem.copyWith(elapsedMs: elapsedMs);
|
||||||
|
|
||||||
int? challengerDamage;
|
int? challengerDamage;
|
||||||
int? opponentDamage;
|
int? opponentDamage;
|
||||||
@@ -183,59 +296,144 @@ class ArenaService {
|
|||||||
bool isOpponentEvaded = false;
|
bool isOpponentEvaded = false;
|
||||||
bool isChallengerBlocked = false;
|
bool isChallengerBlocked = false;
|
||||||
bool isOpponentBlocked = false;
|
bool isOpponentBlocked = false;
|
||||||
|
String? challengerSkillUsed;
|
||||||
|
String? opponentSkillUsed;
|
||||||
|
int? challengerHealAmount;
|
||||||
|
int? opponentHealAmount;
|
||||||
|
|
||||||
// 플레이어 공격
|
// 도전자 턴
|
||||||
if (playerAccum >= playerCombatStats.attackDelayMs) {
|
if (playerAccum >= playerCombatStats.attackDelayMs) {
|
||||||
final result = calculator.playerAttackMonster(
|
|
||||||
attacker: playerCombatStats,
|
|
||||||
defender: opponentMonsterStats,
|
|
||||||
);
|
|
||||||
opponentMonsterStats = result.updatedDefender;
|
|
||||||
playerAccum = 0;
|
playerAccum = 0;
|
||||||
|
|
||||||
if (result.result.isHit) {
|
// 스킬 선택
|
||||||
challengerDamage = result.result.damage;
|
final skill = _selectBestSkill(
|
||||||
isChallengerCritical = result.result.isCritical;
|
skills: challengerSkills,
|
||||||
|
stats: playerCombatStats,
|
||||||
|
skillSystem: challengerSkillSystem,
|
||||||
|
target: opponentMonsterStats,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (skill != null) {
|
||||||
|
// 스킬 사용
|
||||||
|
final skillResult = _useSkill(
|
||||||
|
skill: skill,
|
||||||
|
attacker: playerCombatStats,
|
||||||
|
defender: opponentCombatStats,
|
||||||
|
skillSystem: challengerSkillSystem,
|
||||||
|
);
|
||||||
|
playerCombatStats = skillResult.updatedAttacker;
|
||||||
|
opponentCombatStats = skillResult.updatedDefender;
|
||||||
|
challengerSkillSystem = skillResult.updatedSkillSystem;
|
||||||
|
challengerSkillUsed = skill.name;
|
||||||
|
challengerDamage = skillResult.damage;
|
||||||
|
challengerHealAmount = skillResult.healAmount;
|
||||||
|
isChallengerCritical = skillResult.isCritical;
|
||||||
} else {
|
} else {
|
||||||
isOpponentEvaded = true;
|
// 기본 공격
|
||||||
|
// 상대 몬스터 스탯 동기화
|
||||||
|
opponentMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||||
|
opponentCombatStats,
|
||||||
|
match.opponent.characterName,
|
||||||
|
);
|
||||||
|
final result = calculator.playerAttackMonster(
|
||||||
|
attacker: playerCombatStats,
|
||||||
|
defender: opponentMonsterStats,
|
||||||
|
);
|
||||||
|
opponentMonsterStats = result.updatedDefender;
|
||||||
|
opponentCombatStats = opponentCombatStats.copyWith(
|
||||||
|
hpCurrent: opponentMonsterStats.hpCurrent,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.result.isHit) {
|
||||||
|
challengerDamage = result.result.damage;
|
||||||
|
isChallengerCritical = result.result.isCritical;
|
||||||
|
} else {
|
||||||
|
isOpponentEvaded = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 상대 공격
|
// 상대 턴
|
||||||
if (opponentMonsterStats.isAlive &&
|
if (opponentCombatStats.hpCurrent > 0 &&
|
||||||
opponentAccum >= opponentMonsterStats.attackDelayMs) {
|
opponentAccum >= opponentCombatStats.attackDelayMs) {
|
||||||
final result = calculator.monsterAttackPlayer(
|
|
||||||
attacker: opponentMonsterStats,
|
|
||||||
defender: playerCombatStats,
|
|
||||||
);
|
|
||||||
playerCombatStats = result.updatedDefender;
|
|
||||||
opponentAccum = 0;
|
opponentAccum = 0;
|
||||||
|
|
||||||
if (result.result.isHit) {
|
// 상대 스킬 선택
|
||||||
opponentDamage = result.result.damage;
|
final skill = _selectBestSkill(
|
||||||
isOpponentCritical = result.result.isCritical;
|
skills: opponentSkills,
|
||||||
isChallengerBlocked = result.result.isBlocked;
|
stats: opponentCombatStats,
|
||||||
|
skillSystem: opponentSkillSystem,
|
||||||
|
target: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (skill != null) {
|
||||||
|
// 스킬 사용
|
||||||
|
final skillResult = _useSkill(
|
||||||
|
skill: skill,
|
||||||
|
attacker: opponentCombatStats,
|
||||||
|
defender: playerCombatStats,
|
||||||
|
skillSystem: opponentSkillSystem,
|
||||||
|
);
|
||||||
|
opponentCombatStats = skillResult.updatedAttacker;
|
||||||
|
playerCombatStats = skillResult.updatedDefender;
|
||||||
|
opponentSkillSystem = skillResult.updatedSkillSystem;
|
||||||
|
opponentSkillUsed = skill.name;
|
||||||
|
opponentDamage = skillResult.damage;
|
||||||
|
opponentHealAmount = skillResult.healAmount;
|
||||||
|
isOpponentCritical = skillResult.isCritical;
|
||||||
} else {
|
} else {
|
||||||
isChallengerEvaded = true;
|
// 기본 공격 (몬스터 형태로)
|
||||||
|
opponentMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||||
|
opponentCombatStats,
|
||||||
|
match.opponent.characterName,
|
||||||
|
);
|
||||||
|
final result = calculator.monsterAttackPlayer(
|
||||||
|
attacker: opponentMonsterStats,
|
||||||
|
defender: playerCombatStats,
|
||||||
|
);
|
||||||
|
playerCombatStats = result.updatedDefender;
|
||||||
|
|
||||||
|
if (result.result.isHit) {
|
||||||
|
opponentDamage = result.result.damage;
|
||||||
|
isOpponentCritical = result.result.isCritical;
|
||||||
|
isChallengerBlocked = result.result.isBlocked;
|
||||||
|
} else {
|
||||||
|
isChallengerEvaded = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 공격이 발생했을 때만 턴 전송
|
// 액션이 발생했을 때만 턴 전송
|
||||||
if (challengerDamage != null || opponentDamage != null) {
|
final hasAction = challengerDamage != null ||
|
||||||
|
opponentDamage != null ||
|
||||||
|
challengerHealAmount != null ||
|
||||||
|
opponentHealAmount != null ||
|
||||||
|
challengerSkillUsed != null ||
|
||||||
|
opponentSkillUsed != null;
|
||||||
|
|
||||||
|
if (hasAction) {
|
||||||
turns++;
|
turns++;
|
||||||
yield ArenaCombatTurn(
|
yield ArenaCombatTurn(
|
||||||
challengerDamage: challengerDamage,
|
challengerDamage: challengerDamage,
|
||||||
opponentDamage: opponentDamage,
|
opponentDamage: opponentDamage,
|
||||||
challengerHp: playerCombatStats.hpCurrent,
|
challengerHp: playerCombatStats.hpCurrent,
|
||||||
opponentHp: opponentMonsterStats.hpCurrent,
|
opponentHp: opponentCombatStats.hpCurrent,
|
||||||
challengerHpMax: playerCombatStats.hpMax,
|
challengerHpMax: playerCombatStats.hpMax,
|
||||||
opponentHpMax: opponentMonsterStats.hpMax,
|
opponentHpMax: opponentCombatStats.hpMax,
|
||||||
|
challengerMp: playerCombatStats.mpCurrent,
|
||||||
|
opponentMp: opponentCombatStats.mpCurrent,
|
||||||
|
challengerMpMax: playerCombatStats.mpMax,
|
||||||
|
opponentMpMax: opponentCombatStats.mpMax,
|
||||||
isChallengerCritical: isChallengerCritical,
|
isChallengerCritical: isChallengerCritical,
|
||||||
isOpponentCritical: isOpponentCritical,
|
isOpponentCritical: isOpponentCritical,
|
||||||
isChallengerEvaded: isChallengerEvaded,
|
isChallengerEvaded: isChallengerEvaded,
|
||||||
isOpponentEvaded: isOpponentEvaded,
|
isOpponentEvaded: isOpponentEvaded,
|
||||||
isChallengerBlocked: isChallengerBlocked,
|
isChallengerBlocked: isChallengerBlocked,
|
||||||
isOpponentBlocked: isOpponentBlocked,
|
isOpponentBlocked: isOpponentBlocked,
|
||||||
|
challengerSkillUsed: challengerSkillUsed,
|
||||||
|
opponentSkillUsed: opponentSkillUsed,
|
||||||
|
challengerHealAmount: challengerHealAmount,
|
||||||
|
opponentHealAmount: opponentHealAmount,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 애니메이션을 위한 딜레이
|
// 애니메이션을 위한 딜레이
|
||||||
@@ -247,6 +445,83 @@ class ArenaService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 스킬 사용 (내부 헬퍼)
|
||||||
|
({
|
||||||
|
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, // 스킬 크리티컬은 별도 처리 필요
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 장비 교환
|
// 장비 교환
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -62,12 +62,20 @@ class ArenaCombatTurn {
|
|||||||
required this.opponentHp,
|
required this.opponentHp,
|
||||||
required this.challengerHpMax,
|
required this.challengerHpMax,
|
||||||
required this.opponentHpMax,
|
required this.opponentHpMax,
|
||||||
|
this.challengerMp,
|
||||||
|
this.opponentMp,
|
||||||
|
this.challengerMpMax,
|
||||||
|
this.opponentMpMax,
|
||||||
this.isChallengerCritical = false,
|
this.isChallengerCritical = false,
|
||||||
this.isOpponentCritical = false,
|
this.isOpponentCritical = false,
|
||||||
this.isChallengerEvaded = false,
|
this.isChallengerEvaded = false,
|
||||||
this.isOpponentEvaded = false,
|
this.isOpponentEvaded = false,
|
||||||
this.isChallengerBlocked = false,
|
this.isChallengerBlocked = false,
|
||||||
this.isOpponentBlocked = false,
|
this.isOpponentBlocked = false,
|
||||||
|
this.challengerSkillUsed,
|
||||||
|
this.opponentSkillUsed,
|
||||||
|
this.challengerHealAmount,
|
||||||
|
this.opponentHealAmount,
|
||||||
}) : timestamp = DateTime.now().microsecondsSinceEpoch;
|
}) : timestamp = DateTime.now().microsecondsSinceEpoch;
|
||||||
|
|
||||||
/// 턴 식별용 타임스탬프
|
/// 턴 식별용 타임스탬프
|
||||||
@@ -91,6 +99,18 @@ class ArenaCombatTurn {
|
|||||||
/// 상대 최대 HP
|
/// 상대 최대 HP
|
||||||
final int opponentHpMax;
|
final int opponentHpMax;
|
||||||
|
|
||||||
|
/// 도전자 현재 MP
|
||||||
|
final int? challengerMp;
|
||||||
|
|
||||||
|
/// 상대 현재 MP
|
||||||
|
final int? opponentMp;
|
||||||
|
|
||||||
|
/// 도전자 최대 MP
|
||||||
|
final int? challengerMpMax;
|
||||||
|
|
||||||
|
/// 상대 최대 MP
|
||||||
|
final int? opponentMpMax;
|
||||||
|
|
||||||
/// 도전자 크리티컬 여부
|
/// 도전자 크리티컬 여부
|
||||||
final bool isChallengerCritical;
|
final bool isChallengerCritical;
|
||||||
|
|
||||||
@@ -108,4 +128,16 @@ class ArenaCombatTurn {
|
|||||||
|
|
||||||
/// 상대 블록 여부
|
/// 상대 블록 여부
|
||||||
final bool isOpponentBlocked;
|
final bool isOpponentBlocked;
|
||||||
|
|
||||||
|
/// 도전자 사용 스킬명 (null이면 기본 공격)
|
||||||
|
final String? challengerSkillUsed;
|
||||||
|
|
||||||
|
/// 상대 사용 스킬명 (null이면 기본 공격)
|
||||||
|
final String? opponentSkillUsed;
|
||||||
|
|
||||||
|
/// 도전자 회복량
|
||||||
|
final int? challengerHealAmount;
|
||||||
|
|
||||||
|
/// 상대 회복량
|
||||||
|
final int? opponentHealAmount;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,12 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import 'package:asciineverdie/src/core/engine/arena_service.dart';
|
import 'package:asciineverdie/src/core/engine/arena_service.dart';
|
||||||
import 'package:asciineverdie/src/core/model/arena_match.dart';
|
import 'package:asciineverdie/src/core/model/arena_match.dart';
|
||||||
|
import 'package:asciineverdie/src/core/model/combat_event.dart';
|
||||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||||
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
||||||
import 'package:asciineverdie/src/features/arena/widgets/arena_result_dialog.dart';
|
import 'package:asciineverdie/src/features/arena/widgets/arena_result_dialog.dart';
|
||||||
import 'package:asciineverdie/src/features/game/widgets/ascii_animation_card.dart';
|
import 'package:asciineverdie/src/features/game/widgets/ascii_animation_card.dart';
|
||||||
|
import 'package:asciineverdie/src/features/game/widgets/combat_log.dart';
|
||||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||||
|
|
||||||
// 임시 문자열 (추후 l10n으로 이동)
|
// 임시 문자열 (추후 l10n으로 이동)
|
||||||
@@ -43,16 +45,20 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
|
|||||||
/// 현재 턴
|
/// 현재 턴
|
||||||
int _currentTurn = 0;
|
int _currentTurn = 0;
|
||||||
|
|
||||||
/// 도전자 HP
|
/// 도전자 HP/MP
|
||||||
late int _challengerHp;
|
late int _challengerHp;
|
||||||
late int _challengerHpMax;
|
late int _challengerHpMax;
|
||||||
|
late int _challengerMp;
|
||||||
|
late int _challengerMpMax;
|
||||||
|
|
||||||
/// 상대 HP
|
/// 상대 HP/MP
|
||||||
late int _opponentHp;
|
late int _opponentHp;
|
||||||
late int _opponentHpMax;
|
late int _opponentHpMax;
|
||||||
|
late int _opponentMp;
|
||||||
|
late int _opponentMpMax;
|
||||||
|
|
||||||
/// 전투 로그
|
/// 전투 로그 (CombatLogEntry 사용)
|
||||||
final List<String> _battleLog = [];
|
final List<CombatLogEntry> _battleLog = [];
|
||||||
|
|
||||||
/// 전투 시뮬레이션 스트림 구독
|
/// 전투 시뮬레이션 스트림 구독
|
||||||
StreamSubscription<ArenaCombatTurn>? _combatSubscription;
|
StreamSubscription<ArenaCombatTurn>? _combatSubscription;
|
||||||
@@ -70,15 +76,31 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
|
|||||||
int _challengerHpChange = 0;
|
int _challengerHpChange = 0;
|
||||||
int _opponentHpChange = 0;
|
int _opponentHpChange = 0;
|
||||||
|
|
||||||
|
/// 최신 전투 이벤트 (테두리 이펙트용)
|
||||||
|
CombatEvent? _latestCombatEvent;
|
||||||
|
|
||||||
|
/// 전투 이벤트 아이콘 타이머 (페이드 아웃용)
|
||||||
|
Timer? _eventIconTimer;
|
||||||
|
|
||||||
|
/// 현재 표시 중인 이벤트 아이콘 타입
|
||||||
|
CombatEventType? _currentEventIcon;
|
||||||
|
|
||||||
|
/// 현재 표시 중인 스킬 이름
|
||||||
|
String? _currentSkillName;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
// HP 초기화
|
// HP/MP 초기화
|
||||||
_challengerHpMax = widget.match.challenger.finalStats?.hpMax ?? 100;
|
_challengerHpMax = widget.match.challenger.finalStats?.hpMax ?? 100;
|
||||||
_challengerHp = _challengerHpMax;
|
_challengerHp = _challengerHpMax;
|
||||||
|
_challengerMpMax = widget.match.challenger.finalStats?.mpMax ?? 50;
|
||||||
|
_challengerMp = _challengerMpMax;
|
||||||
_opponentHpMax = widget.match.opponent.finalStats?.hpMax ?? 100;
|
_opponentHpMax = widget.match.opponent.finalStats?.hpMax ?? 100;
|
||||||
_opponentHp = _opponentHpMax;
|
_opponentHp = _opponentHpMax;
|
||||||
|
_opponentMpMax = widget.match.opponent.finalStats?.mpMax ?? 50;
|
||||||
|
_opponentMp = _opponentMpMax;
|
||||||
|
|
||||||
// 플래시 애니메이션 초기화
|
// 플래시 애니메이션 초기화
|
||||||
_challengerFlashController = AnimationController(
|
_challengerFlashController = AnimationController(
|
||||||
@@ -104,6 +126,7 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_combatSubscription?.cancel();
|
_combatSubscription?.cancel();
|
||||||
|
_eventIconTimer?.cancel();
|
||||||
_challengerFlashController.dispose();
|
_challengerFlashController.dispose();
|
||||||
_opponentFlashController.dispose();
|
_opponentFlashController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -128,6 +151,8 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
|
|||||||
_currentTurn++;
|
_currentTurn++;
|
||||||
_challengerHp = turn.challengerHp;
|
_challengerHp = turn.challengerHp;
|
||||||
_opponentHp = turn.opponentHp;
|
_opponentHp = turn.opponentHp;
|
||||||
|
_challengerMp = turn.challengerMp ?? _challengerMp;
|
||||||
|
_opponentMp = turn.opponentMp ?? _opponentMp;
|
||||||
|
|
||||||
// 도전자 HP 변화 감지
|
// 도전자 HP 변화 감지
|
||||||
if (oldChallengerHp != _challengerHp) {
|
if (oldChallengerHp != _challengerHp) {
|
||||||
@@ -141,29 +166,210 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
|
|||||||
_opponentFlashController.forward(from: 0.0);
|
_opponentFlashController.forward(from: 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 로그 추가
|
// 도전자 스킬 사용 로그
|
||||||
|
if (turn.challengerSkillUsed != null) {
|
||||||
|
_battleLog.add(CombatLogEntry(
|
||||||
|
message: '${widget.match.challenger.characterName} uses '
|
||||||
|
'${turn.challengerSkillUsed}!',
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
type: CombatLogType.spell,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 도전자 회복 로그
|
||||||
|
if (turn.challengerHealAmount != null && turn.challengerHealAmount! > 0) {
|
||||||
|
_battleLog.add(CombatLogEntry(
|
||||||
|
message: '${widget.match.challenger.characterName} heals '
|
||||||
|
'${turn.challengerHealAmount} HP!',
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
type: CombatLogType.heal,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로그 추가 (CombatLogEntry 사용)
|
||||||
if (turn.challengerDamage != null) {
|
if (turn.challengerDamage != null) {
|
||||||
|
final type = turn.isChallengerCritical
|
||||||
|
? CombatLogType.critical
|
||||||
|
: CombatLogType.damage;
|
||||||
final critText = turn.isChallengerCritical ? ' CRITICAL!' : '';
|
final critText = turn.isChallengerCritical ? ' CRITICAL!' : '';
|
||||||
final evadeText = turn.isOpponentEvaded ? ' (Evaded)' : '';
|
final skillText = turn.challengerSkillUsed != null ? '' : '';
|
||||||
final blockText = turn.isOpponentBlocked ? ' (Blocked)' : '';
|
_battleLog.add(CombatLogEntry(
|
||||||
_battleLog.add(
|
message: '${widget.match.challenger.characterName} deals '
|
||||||
'${widget.match.challenger.characterName} deals '
|
'${turn.challengerDamage}$critText$skillText',
|
||||||
'${turn.challengerDamage}$critText$evadeText$blockText',
|
timestamp: DateTime.now(),
|
||||||
);
|
type: type,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상대 회피/블록 이벤트
|
||||||
|
if (turn.isOpponentEvaded) {
|
||||||
|
_battleLog.add(CombatLogEntry(
|
||||||
|
message: '${widget.match.opponent.characterName} evaded!',
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
type: CombatLogType.evade,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (turn.isOpponentBlocked) {
|
||||||
|
_battleLog.add(CombatLogEntry(
|
||||||
|
message: '${widget.match.opponent.characterName} blocked!',
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
type: CombatLogType.block,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상대 스킬 사용 로그
|
||||||
|
if (turn.opponentSkillUsed != null) {
|
||||||
|
_battleLog.add(CombatLogEntry(
|
||||||
|
message: '${widget.match.opponent.characterName} uses '
|
||||||
|
'${turn.opponentSkillUsed}!',
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
type: CombatLogType.spell,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상대 회복 로그
|
||||||
|
if (turn.opponentHealAmount != null && turn.opponentHealAmount! > 0) {
|
||||||
|
_battleLog.add(CombatLogEntry(
|
||||||
|
message: '${widget.match.opponent.characterName} heals '
|
||||||
|
'${turn.opponentHealAmount} HP!',
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
type: CombatLogType.heal,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (turn.opponentDamage != null) {
|
if (turn.opponentDamage != null) {
|
||||||
|
final type = turn.isOpponentCritical
|
||||||
|
? CombatLogType.critical
|
||||||
|
: CombatLogType.monsterAttack;
|
||||||
final critText = turn.isOpponentCritical ? ' CRITICAL!' : '';
|
final critText = turn.isOpponentCritical ? ' CRITICAL!' : '';
|
||||||
final evadeText = turn.isChallengerEvaded ? ' (Evaded)' : '';
|
_battleLog.add(CombatLogEntry(
|
||||||
final blockText = turn.isChallengerBlocked ? ' (Blocked)' : '';
|
message: '${widget.match.opponent.characterName} deals '
|
||||||
_battleLog.add(
|
'${turn.opponentDamage}$critText',
|
||||||
'${widget.match.opponent.characterName} deals '
|
timestamp: DateTime.now(),
|
||||||
'${turn.opponentDamage}$critText$evadeText$blockText',
|
type: type,
|
||||||
);
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 도전자 회피/블록 이벤트
|
||||||
|
if (turn.isChallengerEvaded) {
|
||||||
|
_battleLog.add(CombatLogEntry(
|
||||||
|
message: '${widget.match.challenger.characterName} evaded!',
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
type: CombatLogType.evade,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (turn.isChallengerBlocked) {
|
||||||
|
_battleLog.add(CombatLogEntry(
|
||||||
|
message: '${widget.match.challenger.characterName} blocked!',
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
type: CombatLogType.block,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 전투 이벤트 생성 (테두리 이펙트용)
|
||||||
|
_latestCombatEvent = _createCombatEvent(turn);
|
||||||
|
|
||||||
|
// 전투 이벤트 아이콘 표시
|
||||||
|
_showEventIcon(turn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 전투 이벤트 아이콘 표시 (일정 시간 후 사라짐)
|
||||||
|
void _showEventIcon(ArenaCombatTurn turn) {
|
||||||
|
// 이전 타이머 취소
|
||||||
|
_eventIconTimer?.cancel();
|
||||||
|
|
||||||
|
// 스킬 이름 저장
|
||||||
|
_currentSkillName = turn.challengerSkillUsed ?? turn.opponentSkillUsed;
|
||||||
|
|
||||||
|
// 이벤트 타입 결정 (우선순위: 스킬 > 크리티컬 > 블록 > 회피 > 일반공격)
|
||||||
|
CombatEventType? eventType;
|
||||||
|
if (_currentSkillName != null) {
|
||||||
|
eventType = CombatEventType.playerSkill;
|
||||||
|
} else if (turn.isChallengerCritical || turn.isOpponentCritical) {
|
||||||
|
eventType = CombatEventType.playerAttack; // 크리티컬 표시용
|
||||||
|
} else if (turn.isChallengerBlocked || turn.isOpponentBlocked) {
|
||||||
|
eventType = CombatEventType.playerBlock;
|
||||||
|
} else if (turn.isChallengerEvaded || turn.isOpponentEvaded) {
|
||||||
|
eventType = CombatEventType.playerEvade;
|
||||||
|
} else if (turn.challengerDamage != null || turn.opponentDamage != null) {
|
||||||
|
eventType = CombatEventType.playerAttack;
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentEventIcon = eventType;
|
||||||
|
|
||||||
|
// 1초 후 아이콘 숨김
|
||||||
|
_eventIconTimer = Timer(const Duration(milliseconds: 800), () {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_currentEventIcon = null;
|
||||||
|
_currentSkillName = null;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ArenaCombatTurn에서 CombatEvent 생성 (테두리 이펙트용)
|
||||||
|
CombatEvent? _createCombatEvent(ArenaCombatTurn turn) {
|
||||||
|
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
|
||||||
|
// 도전자 스킬 사용 (보라색 테두리)
|
||||||
|
if (turn.challengerSkillUsed != null && turn.challengerDamage != null) {
|
||||||
|
return CombatEvent.playerSkill(
|
||||||
|
timestamp: timestamp,
|
||||||
|
skillName: turn.challengerSkillUsed!,
|
||||||
|
damage: turn.challengerDamage!,
|
||||||
|
targetName: widget.match.opponent.characterName,
|
||||||
|
isCritical: turn.isChallengerCritical,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 도전자 공격 이벤트 (우선순위: 크리티컬 > 일반 공격)
|
||||||
|
if (turn.challengerDamage != null) {
|
||||||
|
return CombatEvent.playerAttack(
|
||||||
|
timestamp: timestamp,
|
||||||
|
damage: turn.challengerDamage!,
|
||||||
|
targetName: widget.match.opponent.characterName,
|
||||||
|
isCritical: turn.isChallengerCritical,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 도전자 회복 이벤트
|
||||||
|
if (turn.challengerHealAmount != null && turn.challengerSkillUsed != null) {
|
||||||
|
return CombatEvent.playerHeal(
|
||||||
|
timestamp: timestamp,
|
||||||
|
healAmount: turn.challengerHealAmount!,
|
||||||
|
skillName: turn.challengerSkillUsed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 도전자 방어 이벤트 (회피/블록)
|
||||||
|
if (turn.isChallengerEvaded) {
|
||||||
|
return CombatEvent.playerEvade(
|
||||||
|
timestamp: timestamp,
|
||||||
|
attackerName: widget.match.opponent.characterName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (turn.isChallengerBlocked) {
|
||||||
|
return CombatEvent.playerBlock(
|
||||||
|
timestamp: timestamp,
|
||||||
|
reducedDamage: turn.opponentDamage ?? 0,
|
||||||
|
attackerName: widget.match.opponent.characterName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상대 공격 이벤트 (몬스터 공격으로 처리)
|
||||||
|
if (turn.opponentDamage != null) {
|
||||||
|
return CombatEvent.monsterAttack(
|
||||||
|
timestamp: timestamp,
|
||||||
|
damage: turn.opponentDamage!,
|
||||||
|
attackerName: widget.match.opponent.characterName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
void _endBattle() {
|
void _endBattle() {
|
||||||
// 최종 결과 계산
|
// 최종 결과 계산
|
||||||
_result = _arenaService.executeCombat(widget.match);
|
_result = _arenaService.executeCombat(widget.match);
|
||||||
@@ -205,6 +411,8 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
|
|||||||
_buildTurnIndicator(),
|
_buildTurnIndicator(),
|
||||||
// HP 바 (레트로 세그먼트 스타일)
|
// HP 바 (레트로 세그먼트 스타일)
|
||||||
_buildRetroHpBars(),
|
_buildRetroHpBars(),
|
||||||
|
// 전투 이벤트 아이콘 (HP 바와 애니메이션 사이)
|
||||||
|
_buildCombatEventIcons(),
|
||||||
// ASCII 애니메이션 (중앙) - 기존 AsciiAnimationCard 재사용
|
// ASCII 애니메이션 (중앙) - 기존 AsciiAnimationCard 재사용
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
@@ -216,6 +424,7 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
|
|||||||
shieldName: _hasShield(widget.match.challenger) ? 'shield' : null,
|
shieldName: _hasShield(widget.match.challenger) ? 'shield' : null,
|
||||||
opponentRaceId: widget.match.opponent.race,
|
opponentRaceId: widget.match.opponent.race,
|
||||||
opponentHasShield: _hasShield(widget.match.opponent),
|
opponentHasShield: _hasShield(widget.match.opponent),
|
||||||
|
latestCombatEvent: _latestCombatEvent,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -489,30 +698,98 @@ class _ArenaBattleScreenState extends State<ArenaBattleScreen>
|
|||||||
Widget _buildBattleLog() {
|
Widget _buildBattleLog() {
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.all(12),
|
margin: const EdgeInsets.all(12),
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: RetroColors.panelBgOf(context),
|
color: RetroColors.panelBgOf(context),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(color: RetroColors.borderOf(context)),
|
border: Border.all(color: RetroColors.borderOf(context)),
|
||||||
),
|
),
|
||||||
child: ListView.builder(
|
child: CombatLog(entries: _battleLog),
|
||||||
reverse: true,
|
);
|
||||||
itemCount: _battleLog.length,
|
}
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final reversedIndex = _battleLog.length - 1 - index;
|
/// 전투 이벤트 아이콘 영역 (HP 바와 애니메이션 사이)
|
||||||
return Padding(
|
///
|
||||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
/// 메인 게임의 _buildBuffIcons() 스타일을 따름
|
||||||
child: Text(
|
/// 스킬 사용, 크리티컬, 블록, 회피 표시
|
||||||
_battleLog[reversedIndex],
|
Widget _buildCombatEventIcons() {
|
||||||
style: TextStyle(
|
// 스킬 사용 또는 특수 액션만 표시
|
||||||
fontFamily: 'JetBrainsMono',
|
final hasSpecialEvent = _currentSkillName != null ||
|
||||||
fontSize: 7,
|
_latestCombatEvent?.isCritical == true ||
|
||||||
color: RetroColors.textSecondaryOf(context),
|
_currentEventIcon == CombatEventType.playerBlock ||
|
||||||
),
|
_currentEventIcon == CombatEventType.playerEvade ||
|
||||||
|
_currentEventIcon == CombatEventType.playerParry ||
|
||||||
|
_currentEventIcon == CombatEventType.playerSkill;
|
||||||
|
|
||||||
|
if (!hasSpecialEvent) {
|
||||||
|
return const SizedBox(height: 28);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이벤트 타입에 따른 아이콘/색상 결정
|
||||||
|
final (icon, color) = _getEventIconData();
|
||||||
|
|
||||||
|
return AnimatedOpacity(
|
||||||
|
opacity: _currentEventIcon != null ? 1.0 : 0.0,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 28,
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// 버프 아이콘 스타일 (CircularProgressIndicator)
|
||||||
|
Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
// 원형 진행률 표시 (펄스 효과용)
|
||||||
|
SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
value: 1.0,
|
||||||
|
strokeWidth: 2,
|
||||||
|
backgroundColor: Colors.grey.shade700,
|
||||||
|
valueColor: AlwaysStoppedAnimation(color),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 아이콘
|
||||||
|
Icon(icon, size: 12, color: color),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
// 스킬 이름 표시
|
||||||
},
|
if (_currentSkillName != null) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
_currentSkillName!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 6,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 이벤트 타입에 따른 아이콘, 색상 반환
|
||||||
|
(IconData, Color) _getEventIconData() {
|
||||||
|
// 스킬 사용
|
||||||
|
if (_currentSkillName != null ||
|
||||||
|
_currentEventIcon == CombatEventType.playerSkill) {
|
||||||
|
return (Icons.auto_fix_high, Colors.purple);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 크리티컬 체크 (latestCombatEvent에서)
|
||||||
|
if (_latestCombatEvent?.isCritical == true) {
|
||||||
|
return (Icons.flash_on, Colors.yellow.shade600);
|
||||||
|
}
|
||||||
|
|
||||||
|
return switch (_currentEventIcon) {
|
||||||
|
CombatEventType.playerBlock => (Icons.shield, Colors.blue),
|
||||||
|
CombatEventType.playerEvade => (Icons.directions_run, Colors.cyan),
|
||||||
|
CombatEventType.playerParry => (Icons.sports_kabaddi, Colors.purple),
|
||||||
|
_ => (Icons.trending_up, Colors.lightBlue),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import 'package:asciineverdie/src/shared/retro_colors.dart';
|
|||||||
// 임시 문자열 (추후 l10n으로 이동)
|
// 임시 문자열 (추후 l10n으로 이동)
|
||||||
const _myEquipmentTitle = 'MY EQUIPMENT';
|
const _myEquipmentTitle = 'MY EQUIPMENT';
|
||||||
const _enemyEquipmentTitle = 'ENEMY EQUIPMENT';
|
const _enemyEquipmentTitle = 'ENEMY EQUIPMENT';
|
||||||
const _selectSlotLabel = 'SELECT';
|
const _selectedLabel = 'SELECTED';
|
||||||
const _recommendedLabel = 'BEST';
|
const _recommendedLabel = 'BEST';
|
||||||
|
|
||||||
/// 좌우 대칭 장비 비교 리스트
|
/// 좌우 대칭 장비 비교 리스트
|
||||||
@@ -50,6 +50,41 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
|
|||||||
/// 현재 확장된 슬롯 (탭하여 비교 중인 슬롯)
|
/// 현재 확장된 슬롯 (탭하여 비교 중인 슬롯)
|
||||||
EquipmentSlot? _expandedSlot;
|
EquipmentSlot? _expandedSlot;
|
||||||
|
|
||||||
|
/// 스크롤 컨트롤러 (자동 스크롤용)
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
|
/// 슬롯별 행 높이 (대략적 계산용)
|
||||||
|
static const double _rowHeight = 40.0;
|
||||||
|
static const double _expandedHeight = 200.0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 선택된 슬롯으로 자동 스크롤
|
||||||
|
void _scrollToSlot(EquipmentSlot slot) {
|
||||||
|
final index = EquipmentSlot.values.indexOf(slot);
|
||||||
|
if (index < 0) return;
|
||||||
|
|
||||||
|
// 현재 확장된 슬롯까지의 높이 계산
|
||||||
|
double targetOffset = 0;
|
||||||
|
for (int i = 0; i < index; i++) {
|
||||||
|
targetOffset += _rowHeight;
|
||||||
|
if (_expandedSlot == EquipmentSlot.values[i]) {
|
||||||
|
targetOffset += _expandedHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 부드럽게 스크롤
|
||||||
|
_scrollController.animateTo(
|
||||||
|
targetOffset,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Column(
|
||||||
@@ -60,6 +95,7 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
|
|||||||
// 장비 리스트
|
// 장비 리스트
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
itemCount: EquipmentSlot.values.length,
|
itemCount: EquipmentSlot.values.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final slot = EquipmentSlot.values[index];
|
final slot = EquipmentSlot.values[index];
|
||||||
@@ -137,9 +173,17 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
|
|||||||
// 슬롯 행 (좌우 대칭)
|
// 슬롯 행 (좌우 대칭)
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
// 탭하면 즉시 선택 + 확장 + 자동 스크롤
|
||||||
|
widget.onSlotSelected(slot);
|
||||||
setState(() {
|
setState(() {
|
||||||
_expandedSlot = isExpanded ? null : slot;
|
_expandedSlot = isExpanded ? null : slot;
|
||||||
});
|
});
|
||||||
|
// 확장될 때만 스크롤 (다음 프레임에서 실행)
|
||||||
|
if (!isExpanded) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_scrollToSlot(slot);
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4),
|
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4),
|
||||||
@@ -393,30 +437,37 @@ class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
// 선택 버튼
|
// 선택됨 인디케이터 (SELECT 버튼 대신)
|
||||||
SizedBox(
|
Container(
|
||||||
width: double.infinity,
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
child: ElevatedButton(
|
decoration: BoxDecoration(
|
||||||
onPressed: () {
|
color: RetroColors.goldOf(context).withValues(alpha: 0.2),
|
||||||
widget.onSlotSelected(slot);
|
borderRadius: BorderRadius.circular(4),
|
||||||
setState(() => _expandedSlot = null);
|
border: Border.all(
|
||||||
},
|
color: RetroColors.goldOf(context),
|
||||||
style: ElevatedButton.styleFrom(
|
width: 2,
|
||||||
backgroundColor: RetroColors.goldOf(context),
|
|
||||||
foregroundColor: RetroColors.backgroundOf(context),
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Text(
|
),
|
||||||
_selectSlotLabel,
|
child: Row(
|
||||||
style: TextStyle(
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
fontFamily: 'PressStart2P',
|
mainAxisSize: MainAxisSize.min,
|
||||||
fontSize: 7,
|
children: [
|
||||||
color: RetroColors.backgroundOf(context),
|
Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: RetroColors.goldOf(context),
|
||||||
|
size: 16,
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
_selectedLabel,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'PressStart2P',
|
||||||
|
fontSize: 7,
|
||||||
|
color: RetroColors.goldOf(context),
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user