Compare commits
6 Commits
be56825ef9
...
cfa60f11d1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfa60f11d1 | ||
|
|
8cd09b9f86 | ||
|
|
687d04974e | ||
|
|
a2e93efc97 | ||
|
|
58cf4739fe | ||
|
|
4c68b3c7fb |
@@ -1216,6 +1216,12 @@ String get uiHallOfFame {
|
||||
return 'Hall of Fame';
|
||||
}
|
||||
|
||||
String get uiLocalArena {
|
||||
if (isKoreanLocale) return '로컬 아레나';
|
||||
if (isJapaneseLocale) return 'ローカルアリーナ';
|
||||
return 'Local Arena';
|
||||
}
|
||||
|
||||
String get frontDescription {
|
||||
if (isKoreanLocale) return '레트로 감성의 오프라인 싱글플레이어 RPG';
|
||||
if (isJapaneseLocale) return 'レトロ感のあるオフラインシングルプレイヤーRPG';
|
||||
|
||||
@@ -12,7 +12,10 @@ import 'package:asciineverdie/src/core/model/pq_config.dart';
|
||||
import 'package:asciineverdie/src/core/notification/notification_service.dart';
|
||||
import 'package:asciineverdie/src/core/storage/save_manager.dart';
|
||||
import 'package:asciineverdie/src/core/storage/save_repository.dart';
|
||||
import 'package:asciineverdie/src/core/storage/hall_of_fame_storage.dart';
|
||||
import 'package:asciineverdie/src/core/storage/settings_repository.dart';
|
||||
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
||||
import 'package:asciineverdie/src/features/arena/arena_screen.dart';
|
||||
import 'package:asciineverdie/src/features/front/front_screen.dart';
|
||||
import 'package:asciineverdie/src/features/front/save_picker_dialog.dart';
|
||||
import 'package:asciineverdie/src/features/game/game_play_screen.dart';
|
||||
@@ -46,10 +49,12 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
||||
late final NotificationService _notificationService;
|
||||
late final SettingsRepository _settingsRepository;
|
||||
late final AudioService _audioService;
|
||||
late final HallOfFameStorage _hallOfFameStorage;
|
||||
bool _isCheckingSave = true;
|
||||
bool _hasSave = false;
|
||||
SavedGamePreview? _savedGamePreview;
|
||||
ThemeMode _themeMode = ThemeMode.system;
|
||||
HallOfFame _hallOfFame = HallOfFame.empty();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -69,12 +74,25 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
||||
_notificationService = NotificationService();
|
||||
_settingsRepository = SettingsRepository();
|
||||
_audioService = AudioService(settingsRepository: _settingsRepository);
|
||||
_hallOfFameStorage = HallOfFameStorage();
|
||||
|
||||
// 초기 설정 및 오디오 서비스 로드
|
||||
_loadSettings();
|
||||
_audioService.init();
|
||||
// 세이브 파일 존재 여부 확인
|
||||
_checkForExistingSave();
|
||||
// 명예의 전당 로드
|
||||
_loadHallOfFame();
|
||||
}
|
||||
|
||||
/// 명예의 전당 로드
|
||||
Future<void> _loadHallOfFame() async {
|
||||
final hallOfFame = await _hallOfFameStorage.load();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_hallOfFame = hallOfFame;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 저장된 설정 불러오기
|
||||
@@ -443,8 +461,10 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
||||
onNewCharacter: _navigateToNewCharacter,
|
||||
onLoadSave: _loadSave,
|
||||
onHallOfFame: _navigateToHallOfFame,
|
||||
onLocalArena: _navigateToArena,
|
||||
hasSaveFile: _hasSave,
|
||||
savedGamePreview: _savedGamePreview,
|
||||
hallOfFameCount: _hallOfFame.count,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -542,6 +562,18 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
|
||||
MaterialPageRoute<void>(builder: (context) => const HallOfFameScreen()),
|
||||
);
|
||||
}
|
||||
|
||||
/// 로컬 아레나 화면으로 이동
|
||||
void _navigateToArena(BuildContext context) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (context) => const ArenaScreen(),
|
||||
),
|
||||
).then((_) {
|
||||
// 아레나에서 돌아오면 명예의 전당 다시 로드
|
||||
_loadHallOfFame();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 스플래시 화면 (세이브 파일 확인 중) - 레트로 스타일
|
||||
|
||||
@@ -14,6 +14,8 @@ import 'package:asciineverdie/src/core/model/item_stats.dart';
|
||||
///
|
||||
/// 기존 BattleComposer의 로직을 레이어 기반으로 변환.
|
||||
/// 출력: `List<AsciiLayer>` (z-order 정렬됨)
|
||||
///
|
||||
/// PvP 모드: [opponentRaceId]가 설정되면 몬스터 대신 상대 캐릭터(좌우 반전) 표시
|
||||
class CanvasBattleComposer {
|
||||
const CanvasBattleComposer({
|
||||
required this.weaponCategory,
|
||||
@@ -22,6 +24,8 @@ class CanvasBattleComposer {
|
||||
required this.monsterSize,
|
||||
this.raceId,
|
||||
this.weaponRarity,
|
||||
this.opponentRaceId,
|
||||
this.opponentHasShield = false,
|
||||
});
|
||||
|
||||
final WeaponCategory weaponCategory;
|
||||
@@ -35,6 +39,15 @@ class CanvasBattleComposer {
|
||||
/// 무기 희귀도 (Phase 9: 무기 등급별 이펙트 색상)
|
||||
final ItemRarity? weaponRarity;
|
||||
|
||||
/// 상대 종족 ID (PvP 모드: 설정 시 몬스터 대신 캐릭터 표시)
|
||||
final String? opponentRaceId;
|
||||
|
||||
/// 상대 방패 장착 여부 (PvP 모드)
|
||||
final bool opponentHasShield;
|
||||
|
||||
/// PvP 모드 여부
|
||||
bool get isPvP => opponentRaceId != null;
|
||||
|
||||
/// 프레임 상수
|
||||
static const int frameWidth = 60;
|
||||
static const int frameHeight = 8;
|
||||
@@ -59,6 +72,10 @@ class CanvasBattleComposer {
|
||||
final layers = <AsciiLayer>[
|
||||
_createBackgroundLayer(environment, globalTick),
|
||||
_createCharacterLayer(phase, subFrame, attacker),
|
||||
// PvP 모드: 몬스터 대신 상대 캐릭터(좌우 반전) 표시
|
||||
if (isPvP)
|
||||
_createOpponentCharacterLayer(phase, subFrame, attacker)
|
||||
else
|
||||
_createMonsterLayer(phase, subFrame, attacker),
|
||||
];
|
||||
|
||||
@@ -246,6 +263,91 @@ class CanvasBattleComposer {
|
||||
);
|
||||
}
|
||||
|
||||
/// 상대 캐릭터 레이어 생성 (PvP 모드, z=1)
|
||||
///
|
||||
/// 몬스터 대신 상대 캐릭터를 좌우 반전하여 표시
|
||||
AsciiLayer _createOpponentCharacterLayer(
|
||||
BattlePhase phase,
|
||||
int subFrame,
|
||||
AttackerType attacker,
|
||||
) {
|
||||
// 상대 종족별 프레임 조회
|
||||
CharacterFrame opponentFrame;
|
||||
if (opponentRaceId != null && opponentRaceId!.isNotEmpty) {
|
||||
final raceData = RaceCharacterFrames.get(opponentRaceId!);
|
||||
if (raceData != null) {
|
||||
final frames = raceData.getFrames(phase);
|
||||
opponentFrame = frames[subFrame % frames.length];
|
||||
} else {
|
||||
opponentFrame = getCharacterFrame(phase, subFrame);
|
||||
}
|
||||
} else {
|
||||
opponentFrame = getCharacterFrame(phase, subFrame);
|
||||
}
|
||||
|
||||
if (opponentHasShield) {
|
||||
opponentFrame = opponentFrame.withShield();
|
||||
}
|
||||
|
||||
// 좌우 반전
|
||||
final mirroredLines = _mirrorLines(opponentFrame.lines);
|
||||
|
||||
// 상대가 공격자인지 확인 (몬스터 역할)
|
||||
final isOpponentAttacking =
|
||||
attacker == AttackerType.monster || attacker == AttackerType.both;
|
||||
|
||||
// 페이즈별 X 위치 (몬스터와 동일하지만 캐릭터 너비 기준)
|
||||
const opponentWidth = 6;
|
||||
final opponentRightEdge = switch (phase) {
|
||||
BattlePhase.idle => 48,
|
||||
BattlePhase.prepare => isOpponentAttacking ? 45 : 48,
|
||||
BattlePhase.attack => isOpponentAttacking ? 42 : 48,
|
||||
BattlePhase.hit => isOpponentAttacking ? 42 : 48,
|
||||
BattlePhase.recover => isOpponentAttacking ? 45 : 48,
|
||||
};
|
||||
final opponentX = opponentRightEdge - opponentWidth;
|
||||
|
||||
final cells = _spriteToCells(mirroredLines);
|
||||
final opponentY = frameHeight - cells.length - 1;
|
||||
|
||||
return AsciiLayer(
|
||||
cells: cells,
|
||||
zIndex: 1,
|
||||
offsetX: opponentX,
|
||||
offsetY: opponentY,
|
||||
);
|
||||
}
|
||||
|
||||
/// 문자열 좌우 반전 (PvP 모드용)
|
||||
List<String> _mirrorLines(List<String> lines) {
|
||||
return lines.map((line) {
|
||||
final chars = line.split('');
|
||||
final mirrored = chars.reversed.map(_mirrorChar).toList();
|
||||
return mirrored.join();
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// 개별 문자 미러링
|
||||
String _mirrorChar(String char) {
|
||||
return switch (char) {
|
||||
'/' => r'\',
|
||||
r'\' => '/',
|
||||
'(' => ')',
|
||||
')' => '(',
|
||||
'[' => ']',
|
||||
']' => '[',
|
||||
'{' => '}',
|
||||
'}' => '{',
|
||||
'<' => '>',
|
||||
'>' => '<',
|
||||
'┘' => '└',
|
||||
'└' => '┘',
|
||||
'┐' => '┌',
|
||||
'┌' => '┐',
|
||||
_ => char,
|
||||
};
|
||||
}
|
||||
|
||||
/// 이펙트 레이어 생성 (z=3, 캐릭터/몬스터 위에 표시)
|
||||
///
|
||||
/// Phase 8: 공격자에 따라 이펙트 위치/모양 분리
|
||||
|
||||
314
lib/src/core/engine/arena_service.dart
Normal file
314
lib/src/core/engine/arena_service.dart
Normal file
@@ -0,0 +1,314 @@
|
||||
import 'package:asciineverdie/src/core/engine/combat_calculator.dart';
|
||||
import 'package:asciineverdie/src/core/model/arena_match.dart';
|
||||
import 'package:asciineverdie/src/core/model/equipment_item.dart';
|
||||
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
|
||||
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
||||
import 'package:asciineverdie/src/core/model/monster_combat_stats.dart';
|
||||
import 'package:asciineverdie/src/core/util/deterministic_random.dart';
|
||||
|
||||
/// 아레나 서비스
|
||||
///
|
||||
/// 로컬 아레나 대전 시스템의 핵심 로직 담당:
|
||||
/// - 순위 계산 및 상대 결정
|
||||
/// - 전투 실행
|
||||
/// - 장비 교환
|
||||
class ArenaService {
|
||||
ArenaService({DeterministicRandom? rng})
|
||||
: _rng = rng ?? DeterministicRandom(DateTime.now().millisecondsSinceEpoch);
|
||||
|
||||
final DeterministicRandom _rng;
|
||||
|
||||
// ============================================================================
|
||||
// 상대 결정
|
||||
// ============================================================================
|
||||
|
||||
/// 상대 결정 (바로 위 순위, 1위면 2위와 대결)
|
||||
///
|
||||
/// [hallOfFame] 명예의 전당
|
||||
/// [challengerId] 도전자 캐릭터 ID
|
||||
/// Returns: 상대 캐릭터 (없으면 null)
|
||||
HallOfFameEntry? findOpponent(HallOfFame hallOfFame, String challengerId) {
|
||||
final ranked = hallOfFame.rankedEntries;
|
||||
if (ranked.length < 2) return null;
|
||||
|
||||
final currentRank = hallOfFame.getRank(challengerId);
|
||||
if (currentRank <= 0) return null;
|
||||
|
||||
// 1위면 2위와 대결
|
||||
if (currentRank == 1) {
|
||||
return ranked[1];
|
||||
}
|
||||
|
||||
// 그 외는 바로 위 순위와 대결
|
||||
return ranked[currentRank - 2];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 전투 실행
|
||||
// ============================================================================
|
||||
|
||||
/// 아레나 전투 실행
|
||||
///
|
||||
/// [match] 대전 정보
|
||||
/// Returns: 대전 결과 (승패, 장비 교환 후 캐릭터)
|
||||
ArenaMatchResult executeCombat(ArenaMatch match) {
|
||||
final calculator = CombatCalculator(rng: _rng);
|
||||
|
||||
// 도전자 스탯 (풀 HP로 시작)
|
||||
final challengerStats = match.challenger.finalStats;
|
||||
final opponentStats = match.opponent.finalStats;
|
||||
|
||||
if (challengerStats == null || opponentStats == null) {
|
||||
// 스탯이 없으면 도전자 패배 처리
|
||||
return ArenaMatchResult(
|
||||
match: match,
|
||||
isVictory: false,
|
||||
turns: 0,
|
||||
updatedChallenger: match.challenger,
|
||||
updatedOpponent: match.opponent,
|
||||
);
|
||||
}
|
||||
|
||||
// 플레이어 스탯 (풀 HP로 초기화)
|
||||
var playerCombatStats = challengerStats.copyWith(
|
||||
hpCurrent: challengerStats.hpMax,
|
||||
mpCurrent: challengerStats.mpMax,
|
||||
);
|
||||
|
||||
// 상대를 몬스터 형태로 변환
|
||||
var opponentMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||
opponentStats,
|
||||
match.opponent.characterName,
|
||||
);
|
||||
|
||||
// 전투 시뮬레이션
|
||||
int turns = 0;
|
||||
int playerAccum = 0;
|
||||
int opponentAccum = 0;
|
||||
const tickMs = 200;
|
||||
|
||||
while (playerCombatStats.isAlive && opponentMonsterStats.isAlive) {
|
||||
playerAccum += tickMs;
|
||||
opponentAccum += tickMs;
|
||||
|
||||
// 플레이어 공격
|
||||
if (playerAccum >= playerCombatStats.attackDelayMs) {
|
||||
final result = calculator.playerAttackMonster(
|
||||
attacker: playerCombatStats,
|
||||
defender: opponentMonsterStats,
|
||||
);
|
||||
opponentMonsterStats = result.updatedDefender;
|
||||
playerAccum = 0;
|
||||
turns++;
|
||||
}
|
||||
|
||||
// 상대 공격 (살아있을 때만)
|
||||
if (opponentMonsterStats.isAlive &&
|
||||
opponentAccum >= opponentMonsterStats.attackDelayMs) {
|
||||
final result = calculator.monsterAttackPlayer(
|
||||
attacker: opponentMonsterStats,
|
||||
defender: playerCombatStats,
|
||||
);
|
||||
playerCombatStats = result.updatedDefender;
|
||||
opponentAccum = 0;
|
||||
}
|
||||
|
||||
// 무한 루프 방지
|
||||
if (turns > 1000) break;
|
||||
}
|
||||
|
||||
final isVictory = playerCombatStats.isAlive;
|
||||
|
||||
// 장비 교환
|
||||
final (updatedChallenger, updatedOpponent) = _exchangeEquipment(
|
||||
match: match,
|
||||
isVictory: isVictory,
|
||||
);
|
||||
|
||||
return ArenaMatchResult(
|
||||
match: match,
|
||||
isVictory: isVictory,
|
||||
turns: turns,
|
||||
updatedChallenger: updatedChallenger,
|
||||
updatedOpponent: updatedOpponent,
|
||||
);
|
||||
}
|
||||
|
||||
/// 전투 시뮬레이션 (애니메이션용 스트림)
|
||||
///
|
||||
/// [match] 대전 정보
|
||||
/// Returns: 턴별 전투 상황 스트림
|
||||
Stream<ArenaCombatTurn> simulateCombat(ArenaMatch match) async* {
|
||||
final calculator = CombatCalculator(rng: _rng);
|
||||
|
||||
final challengerStats = match.challenger.finalStats;
|
||||
final opponentStats = match.opponent.finalStats;
|
||||
|
||||
if (challengerStats == null || opponentStats == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
var playerCombatStats = challengerStats.copyWith(
|
||||
hpCurrent: challengerStats.hpMax,
|
||||
mpCurrent: challengerStats.mpMax,
|
||||
);
|
||||
|
||||
var opponentMonsterStats = MonsterCombatStats.fromCombatStats(
|
||||
opponentStats,
|
||||
match.opponent.characterName,
|
||||
);
|
||||
|
||||
int playerAccum = 0;
|
||||
int opponentAccum = 0;
|
||||
const tickMs = 200;
|
||||
int turns = 0;
|
||||
|
||||
// 초기 상태 전송
|
||||
yield ArenaCombatTurn(
|
||||
challengerHp: playerCombatStats.hpCurrent,
|
||||
opponentHp: opponentMonsterStats.hpCurrent,
|
||||
challengerHpMax: playerCombatStats.hpMax,
|
||||
opponentHpMax: opponentMonsterStats.hpMax,
|
||||
);
|
||||
|
||||
while (playerCombatStats.isAlive && opponentMonsterStats.isAlive) {
|
||||
playerAccum += tickMs;
|
||||
opponentAccum += tickMs;
|
||||
|
||||
int? challengerDamage;
|
||||
int? opponentDamage;
|
||||
bool isChallengerCritical = false;
|
||||
bool isOpponentCritical = false;
|
||||
bool isChallengerEvaded = false;
|
||||
bool isOpponentEvaded = false;
|
||||
bool isChallengerBlocked = false;
|
||||
bool isOpponentBlocked = false;
|
||||
|
||||
// 플레이어 공격
|
||||
if (playerAccum >= playerCombatStats.attackDelayMs) {
|
||||
final result = calculator.playerAttackMonster(
|
||||
attacker: playerCombatStats,
|
||||
defender: opponentMonsterStats,
|
||||
);
|
||||
opponentMonsterStats = result.updatedDefender;
|
||||
playerAccum = 0;
|
||||
|
||||
if (result.result.isHit) {
|
||||
challengerDamage = result.result.damage;
|
||||
isChallengerCritical = result.result.isCritical;
|
||||
} else {
|
||||
isOpponentEvaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 상대 공격
|
||||
if (opponentMonsterStats.isAlive &&
|
||||
opponentAccum >= opponentMonsterStats.attackDelayMs) {
|
||||
final result = calculator.monsterAttackPlayer(
|
||||
attacker: opponentMonsterStats,
|
||||
defender: playerCombatStats,
|
||||
);
|
||||
playerCombatStats = result.updatedDefender;
|
||||
opponentAccum = 0;
|
||||
|
||||
if (result.result.isHit) {
|
||||
opponentDamage = result.result.damage;
|
||||
isOpponentCritical = result.result.isCritical;
|
||||
isChallengerBlocked = result.result.isBlocked;
|
||||
} else {
|
||||
isChallengerEvaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 공격이 발생했을 때만 턴 전송
|
||||
if (challengerDamage != null || opponentDamage != null) {
|
||||
turns++;
|
||||
yield ArenaCombatTurn(
|
||||
challengerDamage: challengerDamage,
|
||||
opponentDamage: opponentDamage,
|
||||
challengerHp: playerCombatStats.hpCurrent,
|
||||
opponentHp: opponentMonsterStats.hpCurrent,
|
||||
challengerHpMax: playerCombatStats.hpMax,
|
||||
opponentHpMax: opponentMonsterStats.hpMax,
|
||||
isChallengerCritical: isChallengerCritical,
|
||||
isOpponentCritical: isOpponentCritical,
|
||||
isChallengerEvaded: isChallengerEvaded,
|
||||
isOpponentEvaded: isOpponentEvaded,
|
||||
isChallengerBlocked: isChallengerBlocked,
|
||||
isOpponentBlocked: isOpponentBlocked,
|
||||
);
|
||||
|
||||
// 애니메이션을 위한 딜레이
|
||||
await Future<void>.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
// 무한 루프 방지
|
||||
if (turns > 1000) break;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 장비 교환
|
||||
// ============================================================================
|
||||
|
||||
/// 장비 교환 (같은 슬롯끼리)
|
||||
///
|
||||
/// 승자가 선택한 슬롯의 장비를 서로 교환
|
||||
(HallOfFameEntry, HallOfFameEntry) _exchangeEquipment({
|
||||
required ArenaMatch match,
|
||||
required bool isVictory,
|
||||
}) {
|
||||
final slot = match.bettingSlot;
|
||||
|
||||
// 도전자 장비 목록 복사
|
||||
final challengerEquipment =
|
||||
List<EquipmentItem>.from(match.challenger.finalEquipment ?? []);
|
||||
|
||||
// 상대 장비 목록 복사
|
||||
final opponentEquipment =
|
||||
List<EquipmentItem>.from(match.opponent.finalEquipment ?? []);
|
||||
|
||||
// 해당 슬롯의 장비 찾기
|
||||
final challengerItem = _findItemBySlot(challengerEquipment, slot);
|
||||
final opponentItem = _findItemBySlot(opponentEquipment, slot);
|
||||
|
||||
// 장비 교환
|
||||
_replaceItemInList(challengerEquipment, slot, opponentItem);
|
||||
_replaceItemInList(opponentEquipment, slot, challengerItem);
|
||||
|
||||
// 업데이트된 엔트리 생성
|
||||
final updatedChallenger = match.challenger.copyWith(
|
||||
finalEquipment: challengerEquipment,
|
||||
);
|
||||
final updatedOpponent = match.opponent.copyWith(
|
||||
finalEquipment: opponentEquipment,
|
||||
);
|
||||
|
||||
return (updatedChallenger, updatedOpponent);
|
||||
}
|
||||
|
||||
/// 슬롯으로 장비 찾기
|
||||
EquipmentItem _findItemBySlot(
|
||||
List<EquipmentItem> equipment, EquipmentSlot slot) {
|
||||
for (final item in equipment) {
|
||||
if (item.slot == slot) return item;
|
||||
}
|
||||
return EquipmentItem.empty(slot);
|
||||
}
|
||||
|
||||
/// 장비 목록에서 특정 슬롯의 아이템 교체
|
||||
void _replaceItemInList(
|
||||
List<EquipmentItem> equipment,
|
||||
EquipmentSlot slot,
|
||||
EquipmentItem newItem,
|
||||
) {
|
||||
for (var i = 0; i < equipment.length; i++) {
|
||||
if (equipment[i].slot == slot) {
|
||||
equipment[i] = newItem;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 슬롯이 없으면 추가
|
||||
equipment.add(newItem);
|
||||
}
|
||||
}
|
||||
111
lib/src/core/model/arena_match.dart
Normal file
111
lib/src/core/model/arena_match.dart
Normal file
@@ -0,0 +1,111 @@
|
||||
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
|
||||
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
||||
|
||||
/// 아레나 대전 정보
|
||||
///
|
||||
/// 도전자와 상대의 정보, 베팅 슬롯을 포함
|
||||
class ArenaMatch {
|
||||
const ArenaMatch({
|
||||
required this.challenger,
|
||||
required this.opponent,
|
||||
required this.bettingSlot,
|
||||
});
|
||||
|
||||
/// 도전자 (내 캐릭터)
|
||||
final HallOfFameEntry challenger;
|
||||
|
||||
/// 상대 캐릭터
|
||||
final HallOfFameEntry opponent;
|
||||
|
||||
/// 베팅 슬롯 (같은 슬롯 교환)
|
||||
final EquipmentSlot bettingSlot;
|
||||
|
||||
/// 도전자 순위
|
||||
int get challengerRank => 0; // ArenaService에서 계산
|
||||
|
||||
/// 상대 순위
|
||||
int get opponentRank => 0; // ArenaService에서 계산
|
||||
}
|
||||
|
||||
/// 아레나 대전 결과
|
||||
class ArenaMatchResult {
|
||||
const ArenaMatchResult({
|
||||
required this.match,
|
||||
required this.isVictory,
|
||||
required this.turns,
|
||||
required this.updatedChallenger,
|
||||
required this.updatedOpponent,
|
||||
});
|
||||
|
||||
/// 대전 정보
|
||||
final ArenaMatch match;
|
||||
|
||||
/// 도전자 승리 여부
|
||||
final bool isVictory;
|
||||
|
||||
/// 전투 턴 수
|
||||
final int turns;
|
||||
|
||||
/// 장비 교환 후 업데이트된 도전자
|
||||
final HallOfFameEntry updatedChallenger;
|
||||
|
||||
/// 장비 교환 후 업데이트된 상대
|
||||
final HallOfFameEntry updatedOpponent;
|
||||
}
|
||||
|
||||
/// 아레나 전투 턴 (애니메이션용)
|
||||
class ArenaCombatTurn {
|
||||
ArenaCombatTurn({
|
||||
this.challengerDamage,
|
||||
this.opponentDamage,
|
||||
required this.challengerHp,
|
||||
required this.opponentHp,
|
||||
required this.challengerHpMax,
|
||||
required this.opponentHpMax,
|
||||
this.isChallengerCritical = false,
|
||||
this.isOpponentCritical = false,
|
||||
this.isChallengerEvaded = false,
|
||||
this.isOpponentEvaded = false,
|
||||
this.isChallengerBlocked = false,
|
||||
this.isOpponentBlocked = false,
|
||||
}) : timestamp = DateTime.now().microsecondsSinceEpoch;
|
||||
|
||||
/// 턴 식별용 타임스탬프
|
||||
final int timestamp;
|
||||
|
||||
/// 도전자가 입힌 데미지 (null이면 공격 안 함)
|
||||
final int? challengerDamage;
|
||||
|
||||
/// 상대가 입힌 데미지 (null이면 공격 안 함)
|
||||
final int? opponentDamage;
|
||||
|
||||
/// 도전자 현재 HP
|
||||
final int challengerHp;
|
||||
|
||||
/// 상대 현재 HP
|
||||
final int opponentHp;
|
||||
|
||||
/// 도전자 최대 HP
|
||||
final int challengerHpMax;
|
||||
|
||||
/// 상대 최대 HP
|
||||
final int opponentHpMax;
|
||||
|
||||
/// 도전자 크리티컬 여부
|
||||
final bool isChallengerCritical;
|
||||
|
||||
/// 상대 크리티컬 여부
|
||||
final bool isOpponentCritical;
|
||||
|
||||
/// 도전자 회피 여부
|
||||
final bool isChallengerEvaded;
|
||||
|
||||
/// 상대 회피 여부
|
||||
final bool isOpponentEvaded;
|
||||
|
||||
/// 도전자 블록 여부
|
||||
final bool isChallengerBlocked;
|
||||
|
||||
/// 상대 블록 여부
|
||||
final bool isOpponentBlocked;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:asciineverdie/src/core/engine/item_service.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_slot.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
|
||||
/// 명예의 전당 엔트리 (Phase 10: Hall of Fame Entry)
|
||||
@@ -80,6 +82,39 @@ class HallOfFameEntry {
|
||||
'${clearedAt.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// copyWith 메서드 (아레나 장비 교환용)
|
||||
HallOfFameEntry copyWith({
|
||||
String? id,
|
||||
String? characterName,
|
||||
String? race,
|
||||
String? klass,
|
||||
int? level,
|
||||
int? totalPlayTimeMs,
|
||||
int? totalDeaths,
|
||||
int? monstersKilled,
|
||||
int? questsCompleted,
|
||||
DateTime? clearedAt,
|
||||
CombatStats? finalStats,
|
||||
List<EquipmentItem>? finalEquipment,
|
||||
List<Map<String, String>>? finalSpells,
|
||||
}) {
|
||||
return HallOfFameEntry(
|
||||
id: id ?? this.id,
|
||||
characterName: characterName ?? this.characterName,
|
||||
race: race ?? this.race,
|
||||
klass: klass ?? this.klass,
|
||||
level: level ?? this.level,
|
||||
totalPlayTimeMs: totalPlayTimeMs ?? this.totalPlayTimeMs,
|
||||
totalDeaths: totalDeaths ?? this.totalDeaths,
|
||||
monstersKilled: monstersKilled ?? this.monstersKilled,
|
||||
questsCompleted: questsCompleted ?? this.questsCompleted,
|
||||
clearedAt: clearedAt ?? this.clearedAt,
|
||||
finalStats: finalStats ?? this.finalStats,
|
||||
finalEquipment: finalEquipment ?? this.finalEquipment,
|
||||
finalSpells: finalSpells ?? this.finalSpells,
|
||||
);
|
||||
}
|
||||
|
||||
/// GameState에서 HallOfFameEntry 생성
|
||||
factory HallOfFameEntry.fromGameState({
|
||||
required GameState state,
|
||||
@@ -181,6 +216,22 @@ class HallOfFame {
|
||||
/// 비어있는지 확인
|
||||
bool get isEmpty => entries.isEmpty;
|
||||
|
||||
/// ID로 엔트리 조회
|
||||
HallOfFameEntry? findById(String id) {
|
||||
for (final entry in entries) {
|
||||
if (entry.id == id) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 엔트리 업데이트 (아레나 장비 교환 후)
|
||||
HallOfFame updateEntry(HallOfFameEntry updated) {
|
||||
final newEntries = entries.map((e) {
|
||||
return e.id == updated.id ? updated : e;
|
||||
}).toList();
|
||||
return HallOfFame(entries: newEntries);
|
||||
}
|
||||
|
||||
/// JSON으로 직렬화
|
||||
Map<String, dynamic> toJson() {
|
||||
return {'entries': entries.map((e) => e.toJson()).toList()};
|
||||
@@ -196,3 +247,70 @@ class HallOfFame {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 아레나 순위 관련 extension
|
||||
extension HallOfFameArenaX on HallOfFame {
|
||||
/// 아레나 점수 계산
|
||||
///
|
||||
/// 점수 = (레벨 × 100) + 장비점수 + (전투력 / 10)
|
||||
static int calculateArenaScore(HallOfFameEntry entry) {
|
||||
// 1. 레벨 점수 (주요 지표)
|
||||
final levelScore = entry.level * 100;
|
||||
|
||||
// 2. 장비 점수 (전체 슬롯 합계)
|
||||
final equipScore = entry.finalEquipment?.fold<int>(
|
||||
0,
|
||||
(sum, item) => sum + ItemService.calculateEquipmentScore(item),
|
||||
) ??
|
||||
0;
|
||||
|
||||
// 3. 전투력 점수 (ATK + DEF + HP/10)
|
||||
final combatScore = entry.finalStats != null
|
||||
? (entry.finalStats!.atk +
|
||||
entry.finalStats!.def +
|
||||
entry.finalStats!.hpMax ~/ 10)
|
||||
: 0;
|
||||
|
||||
return levelScore + equipScore + (combatScore ~/ 10);
|
||||
}
|
||||
|
||||
/// 아레나 점수 기준 정렬된 엔트리 반환
|
||||
List<HallOfFameEntry> get rankedEntries {
|
||||
final sorted = List<HallOfFameEntry>.from(entries)
|
||||
..sort((a, b) {
|
||||
final scoreA = calculateArenaScore(a);
|
||||
final scoreB = calculateArenaScore(b);
|
||||
if (scoreA != scoreB) return scoreB.compareTo(scoreA);
|
||||
// 동점일 경우 먼저 클리어한 캐릭터가 상위
|
||||
return a.clearedAt.compareTo(b.clearedAt);
|
||||
});
|
||||
return sorted;
|
||||
}
|
||||
|
||||
/// 특정 캐릭터의 순위 반환 (1-based)
|
||||
int getRank(String characterId) {
|
||||
final ranked = rankedEntries;
|
||||
for (var i = 0; i < ranked.length; i++) {
|
||||
if (ranked[i].id == characterId) return i + 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/// 상대의 베팅 슬롯 결정 (최저 장비 점수 슬롯)
|
||||
static EquipmentSlot findWeakestSlot(HallOfFameEntry entry) {
|
||||
final equipment = entry.finalEquipment ?? [];
|
||||
if (equipment.isEmpty) return EquipmentSlot.weapon;
|
||||
|
||||
EquipmentItem weakest = equipment.first;
|
||||
int lowestScore = ItemService.calculateEquipmentScore(weakest);
|
||||
|
||||
for (final item in equipment) {
|
||||
final score = ItemService.calculateEquipmentScore(item);
|
||||
if (score < lowestScore) {
|
||||
lowestScore = score;
|
||||
weakest = item;
|
||||
}
|
||||
}
|
||||
return weakest.slot;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:asciineverdie/src/core/model/combat_stats.dart';
|
||||
import 'package:asciineverdie/src/core/util/balance_constants.dart';
|
||||
|
||||
/// 몬스터 공격 속도 타입
|
||||
@@ -238,4 +239,25 @@ class MonsterCombatStats {
|
||||
attackDelayMs: 1000,
|
||||
expReward: 15,
|
||||
);
|
||||
|
||||
/// CombatStats에서 MonsterCombatStats 생성 (아레나 PvP용)
|
||||
///
|
||||
/// 플레이어의 CombatStats를 몬스터 형태로 변환하여
|
||||
/// 기존 CombatCalculator를 재사용할 수 있게 함.
|
||||
factory MonsterCombatStats.fromCombatStats(CombatStats stats, String name) {
|
||||
return MonsterCombatStats(
|
||||
name: name,
|
||||
level: 0, // PvP에서는 레벨 페널티 없음
|
||||
atk: stats.atk,
|
||||
def: stats.def,
|
||||
hpMax: stats.hpMax,
|
||||
hpCurrent: stats.hpMax, // 풀 HP로 시작
|
||||
criRate: stats.criRate,
|
||||
criDamage: stats.criDamage,
|
||||
evasion: stats.evasion,
|
||||
accuracy: stats.accuracy,
|
||||
attackDelayMs: stats.attackDelayMs,
|
||||
expReward: 0, // PvP에서는 경험치 없음
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
518
lib/src/features/arena/arena_battle_screen.dart
Normal file
518
lib/src/features/arena/arena_battle_screen.dart
Normal file
@@ -0,0 +1,518 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/src/core/engine/arena_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/arena_match.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
||||
import 'package:asciineverdie/src/features/arena/widgets/arena_result_dialog.dart';
|
||||
import 'package:asciineverdie/src/features/game/widgets/ascii_animation_card.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
// 임시 문자열 (추후 l10n으로 이동)
|
||||
const _battleTitle = 'ARENA BATTLE';
|
||||
const _hpLabel = 'HP';
|
||||
const _turnLabel = 'TURN';
|
||||
|
||||
/// 아레나 전투 화면
|
||||
///
|
||||
/// ASCII 애니메이션 기반 턴제 전투 표시
|
||||
/// 레트로 RPG 스타일 HP 바 (세그먼트)
|
||||
class ArenaBattleScreen extends StatefulWidget {
|
||||
const ArenaBattleScreen({
|
||||
super.key,
|
||||
required this.match,
|
||||
required this.onBattleComplete,
|
||||
});
|
||||
|
||||
/// 대전 정보
|
||||
final ArenaMatch match;
|
||||
|
||||
/// 전투 완료 콜백
|
||||
final void Function(ArenaMatchResult) onBattleComplete;
|
||||
|
||||
@override
|
||||
State<ArenaBattleScreen> createState() => _ArenaBattleScreenState();
|
||||
}
|
||||
|
||||
class _ArenaBattleScreenState extends State<ArenaBattleScreen>
|
||||
with TickerProviderStateMixin {
|
||||
final ArenaService _arenaService = ArenaService();
|
||||
|
||||
/// 현재 턴
|
||||
int _currentTurn = 0;
|
||||
|
||||
/// 도전자 HP
|
||||
late int _challengerHp;
|
||||
late int _challengerHpMax;
|
||||
|
||||
/// 상대 HP
|
||||
late int _opponentHp;
|
||||
late int _opponentHpMax;
|
||||
|
||||
/// 전투 로그
|
||||
final List<String> _battleLog = [];
|
||||
|
||||
/// 전투 시뮬레이션 스트림 구독
|
||||
StreamSubscription<ArenaCombatTurn>? _combatSubscription;
|
||||
|
||||
/// 최종 결과
|
||||
ArenaMatchResult? _result;
|
||||
|
||||
// HP 변화 애니메이션
|
||||
late AnimationController _challengerFlashController;
|
||||
late AnimationController _opponentFlashController;
|
||||
late Animation<double> _challengerFlashAnimation;
|
||||
late Animation<double> _opponentFlashAnimation;
|
||||
|
||||
// 변화량 표시용
|
||||
int _challengerHpChange = 0;
|
||||
int _opponentHpChange = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// HP 초기화
|
||||
_challengerHpMax = widget.match.challenger.finalStats?.hpMax ?? 100;
|
||||
_challengerHp = _challengerHpMax;
|
||||
_opponentHpMax = widget.match.opponent.finalStats?.hpMax ?? 100;
|
||||
_opponentHp = _opponentHpMax;
|
||||
|
||||
// 플래시 애니메이션 초기화
|
||||
_challengerFlashController = AnimationController(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
vsync: this,
|
||||
);
|
||||
_challengerFlashAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
|
||||
CurvedAnimation(parent: _challengerFlashController, curve: Curves.easeOut),
|
||||
);
|
||||
|
||||
_opponentFlashController = AnimationController(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
vsync: this,
|
||||
);
|
||||
_opponentFlashAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
|
||||
CurvedAnimation(parent: _opponentFlashController, curve: Curves.easeOut),
|
||||
);
|
||||
|
||||
// 전투 시작 (딜레이 후)
|
||||
Future.delayed(const Duration(milliseconds: 500), _startBattle);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_combatSubscription?.cancel();
|
||||
_challengerFlashController.dispose();
|
||||
_opponentFlashController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startBattle() {
|
||||
_combatSubscription = _arenaService.simulateCombat(widget.match).listen(
|
||||
(turn) {
|
||||
_processTurn(turn);
|
||||
},
|
||||
onDone: () {
|
||||
_endBattle();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _processTurn(ArenaCombatTurn turn) {
|
||||
final oldChallengerHp = _challengerHp;
|
||||
final oldOpponentHp = _opponentHp;
|
||||
|
||||
setState(() {
|
||||
_currentTurn++;
|
||||
_challengerHp = turn.challengerHp;
|
||||
_opponentHp = turn.opponentHp;
|
||||
|
||||
// 도전자 HP 변화 감지
|
||||
if (oldChallengerHp != _challengerHp) {
|
||||
_challengerHpChange = _challengerHp - oldChallengerHp;
|
||||
_challengerFlashController.forward(from: 0.0);
|
||||
}
|
||||
|
||||
// 상대 HP 변화 감지
|
||||
if (oldOpponentHp != _opponentHp) {
|
||||
_opponentHpChange = _opponentHp - oldOpponentHp;
|
||||
_opponentFlashController.forward(from: 0.0);
|
||||
}
|
||||
|
||||
// 로그 추가
|
||||
if (turn.challengerDamage != null) {
|
||||
final critText = turn.isChallengerCritical ? ' CRITICAL!' : '';
|
||||
final evadeText = turn.isOpponentEvaded ? ' (Evaded)' : '';
|
||||
final blockText = turn.isOpponentBlocked ? ' (Blocked)' : '';
|
||||
_battleLog.add(
|
||||
'${widget.match.challenger.characterName} deals '
|
||||
'${turn.challengerDamage}$critText$evadeText$blockText',
|
||||
);
|
||||
}
|
||||
|
||||
if (turn.opponentDamage != null) {
|
||||
final critText = turn.isOpponentCritical ? ' CRITICAL!' : '';
|
||||
final evadeText = turn.isChallengerEvaded ? ' (Evaded)' : '';
|
||||
final blockText = turn.isChallengerBlocked ? ' (Blocked)' : '';
|
||||
_battleLog.add(
|
||||
'${widget.match.opponent.characterName} deals '
|
||||
'${turn.opponentDamage}$critText$evadeText$blockText',
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _endBattle() {
|
||||
// 최종 결과 계산
|
||||
_result = _arenaService.executeCombat(widget.match);
|
||||
|
||||
// 결과 다이얼로그 표시
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
if (mounted && _result != null) {
|
||||
showArenaResultDialog(
|
||||
context,
|
||||
result: _result!,
|
||||
onClose: () {
|
||||
widget.onBattleComplete(_result!);
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: RetroColors.backgroundOf(context),
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
_battleTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
backgroundColor: RetroColors.panelBgOf(context),
|
||||
automaticallyImplyLeading: false,
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// 턴 표시
|
||||
_buildTurnIndicator(),
|
||||
// HP 바 (레트로 세그먼트 스타일)
|
||||
_buildRetroHpBars(),
|
||||
// ASCII 애니메이션 (중앙) - 기존 AsciiAnimationCard 재사용
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: SizedBox(
|
||||
height: 120,
|
||||
child: AsciiAnimationCard(
|
||||
taskType: TaskType.kill,
|
||||
raceId: widget.match.challenger.race,
|
||||
shieldName: _hasShield(widget.match.challenger) ? 'shield' : null,
|
||||
opponentRaceId: widget.match.opponent.race,
|
||||
opponentHasShield: _hasShield(widget.match.opponent),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 로그 영역 (남은 공간 채움)
|
||||
Expanded(child: _buildBattleLog()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 방패 장착 여부 확인
|
||||
bool _hasShield(HallOfFameEntry entry) {
|
||||
final equipment = entry.finalEquipment;
|
||||
if (equipment == null) return false;
|
||||
return equipment.any((item) => item.slot.name == 'shield');
|
||||
}
|
||||
|
||||
Widget _buildTurnIndicator() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
color: RetroColors.panelBgOf(context).withValues(alpha: 0.5),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.sports_kabaddi,
|
||||
color: RetroColors.goldOf(context),
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$_turnLabel $_currentTurn',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 10,
|
||||
color: RetroColors.goldOf(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 레트로 스타일 HP 바 (좌우 대칭)
|
||||
Widget _buildRetroHpBars() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: RetroColors.panelBgOf(context),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: RetroColors.borderOf(context),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 도전자 HP (좌측, 파란색)
|
||||
Expanded(
|
||||
child: _buildRetroHpBar(
|
||||
name: widget.match.challenger.characterName,
|
||||
hp: _challengerHp,
|
||||
hpMax: _challengerHpMax,
|
||||
fillColor: RetroColors.mpBlue,
|
||||
accentColor: Colors.blue,
|
||||
flashAnimation: _challengerFlashAnimation,
|
||||
hpChange: _challengerHpChange,
|
||||
isReversed: false,
|
||||
),
|
||||
),
|
||||
// VS 구분자
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Text(
|
||||
'VS',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 8,
|
||||
color: RetroColors.goldOf(context),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 상대 HP (우측, 빨간색)
|
||||
Expanded(
|
||||
child: _buildRetroHpBar(
|
||||
name: widget.match.opponent.characterName,
|
||||
hp: _opponentHp,
|
||||
hpMax: _opponentHpMax,
|
||||
fillColor: RetroColors.hpRed,
|
||||
accentColor: Colors.red,
|
||||
flashAnimation: _opponentFlashAnimation,
|
||||
hpChange: _opponentHpChange,
|
||||
isReversed: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 레트로 세그먼트 HP 바
|
||||
Widget _buildRetroHpBar({
|
||||
required String name,
|
||||
required int hp,
|
||||
required int hpMax,
|
||||
required Color fillColor,
|
||||
required Color accentColor,
|
||||
required Animation<double> flashAnimation,
|
||||
required int hpChange,
|
||||
required bool isReversed,
|
||||
}) {
|
||||
final hpRatio = hpMax > 0 ? hp / hpMax : 0.0;
|
||||
final isLow = hpRatio < 0.2 && hpRatio > 0;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: flashAnimation,
|
||||
builder: (context, child) {
|
||||
// 플래시 색상 (데미지=빨강)
|
||||
final isDamage = hpChange < 0;
|
||||
final flashColor = isDamage
|
||||
? RetroColors.hpRed.withValues(alpha: flashAnimation.value * 0.4)
|
||||
: RetroColors.expGreen.withValues(alpha: flashAnimation.value * 0.4);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: flashAnimation.value > 0.1
|
||||
? flashColor
|
||||
: accentColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: accentColor, width: 2),
|
||||
),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment:
|
||||
isReversed ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 이름
|
||||
Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
color: RetroColors.textPrimaryOf(context),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// HP 세그먼트 바
|
||||
_buildSegmentBar(
|
||||
ratio: hpRatio,
|
||||
fillColor: fillColor,
|
||||
isLow: isLow,
|
||||
isReversed: isReversed,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// HP 수치
|
||||
Row(
|
||||
mainAxisAlignment:
|
||||
isReversed ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_hpLabel,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 5,
|
||||
color: accentColor.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'$hp/$hpMax',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
color: isLow ? RetroColors.hpRed : fillColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 플로팅 데미지 텍스트
|
||||
if (hpChange != 0 && flashAnimation.value > 0.05)
|
||||
Positioned(
|
||||
left: isReversed ? null : 0,
|
||||
right: isReversed ? 0 : null,
|
||||
top: -12,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, -12 * (1 - flashAnimation.value)),
|
||||
child: Opacity(
|
||||
opacity: flashAnimation.value,
|
||||
child: Text(
|
||||
hpChange > 0 ? '+$hpChange' : '$hpChange',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDamage ? RetroColors.hpRed : RetroColors.expGreen,
|
||||
shadows: const [
|
||||
Shadow(color: Colors.black, blurRadius: 3),
|
||||
Shadow(color: Colors.black, blurRadius: 6),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 세그먼트 바 (8-bit 스타일)
|
||||
Widget _buildSegmentBar({
|
||||
required double ratio,
|
||||
required Color fillColor,
|
||||
required bool isLow,
|
||||
required bool isReversed,
|
||||
}) {
|
||||
const segmentCount = 10;
|
||||
final filledSegments = (ratio.clamp(0.0, 1.0) * segmentCount).round();
|
||||
|
||||
final segments = List.generate(segmentCount, (index) {
|
||||
final isFilled = isReversed
|
||||
? index >= segmentCount - filledSegments
|
||||
: index < filledSegments;
|
||||
|
||||
return Expanded(
|
||||
child: Container(
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: isFilled
|
||||
? (isLow ? RetroColors.hpRed : fillColor)
|
||||
: fillColor.withValues(alpha: 0.2),
|
||||
border: Border(
|
||||
right: index < segmentCount - 1
|
||||
? BorderSide(
|
||||
color: RetroColors.borderOf(context).withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
)
|
||||
: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: RetroColors.borderOf(context),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: isReversed ? segments.reversed.toList() : segments,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBattleLog() {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: RetroColors.panelBgOf(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: RetroColors.borderOf(context)),
|
||||
),
|
||||
child: ListView.builder(
|
||||
reverse: true,
|
||||
itemCount: _battleLog.length,
|
||||
itemBuilder: (context, index) {
|
||||
final reversedIndex = _battleLog.length - 1 - index;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Text(
|
||||
_battleLog[reversedIndex],
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrainsMono',
|
||||
fontSize: 7,
|
||||
color: RetroColors.textSecondaryOf(context),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
168
lib/src/features/arena/arena_screen.dart
Normal file
168
lib/src/features/arena/arena_screen.dart
Normal file
@@ -0,0 +1,168 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
||||
import 'package:asciineverdie/src/core/storage/hall_of_fame_storage.dart';
|
||||
import 'package:asciineverdie/src/features/arena/arena_setup_screen.dart';
|
||||
import 'package:asciineverdie/src/features/arena/widgets/arena_rank_card.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
import 'package:asciineverdie/src/shared/widgets/retro_panel.dart';
|
||||
|
||||
// 임시 문자열 (추후 l10n으로 이동)
|
||||
const _arenaTitle = 'LOCAL ARENA';
|
||||
const _arenaSubtitle = 'SELECT YOUR FIGHTER';
|
||||
const _arenaEmpty = 'Not enough heroes';
|
||||
const _arenaEmptyHint = 'Clear the game with 2+ characters';
|
||||
|
||||
/// 로컬 아레나 메인 화면
|
||||
///
|
||||
/// 순위표 표시 및 도전하기 버튼
|
||||
class ArenaScreen extends StatefulWidget {
|
||||
const ArenaScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ArenaScreen> createState() => _ArenaScreenState();
|
||||
}
|
||||
|
||||
class _ArenaScreenState extends State<ArenaScreen> {
|
||||
final HallOfFameStorage _storage = HallOfFameStorage();
|
||||
HallOfFame? _hallOfFame;
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadHallOfFame();
|
||||
}
|
||||
|
||||
Future<void> _loadHallOfFame() async {
|
||||
final hallOfFame = await _storage.load();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_hallOfFame = hallOfFame;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 캐릭터 선택 시 바로 슬롯 선택 화면으로 이동
|
||||
void _selectChallenger(HallOfFameEntry challenger) {
|
||||
if (_hallOfFame == null || _hallOfFame!.count < 2) return;
|
||||
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (context) => ArenaSetupScreen(
|
||||
hallOfFame: _hallOfFame!,
|
||||
initialChallenger: challenger,
|
||||
onBattleComplete: _onBattleComplete,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onBattleComplete(HallOfFame updatedHallOfFame) {
|
||||
setState(() {
|
||||
_hallOfFame = updatedHallOfFame;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: RetroColors.backgroundOf(context),
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
_arenaTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
backgroundColor: RetroColors.panelBgOf(context),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _buildContent(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent() {
|
||||
final hallOfFame = _hallOfFame;
|
||||
if (hallOfFame == null || hallOfFame.count < 2) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 순위표 (캐릭터 선택)
|
||||
Expanded(child: _buildRankingList(hallOfFame)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: RetroPanel(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.sports_kabaddi,
|
||||
size: 64,
|
||||
color: RetroColors.textMutedOf(context),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_arenaEmpty,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 10,
|
||||
color: RetroColors.textSecondaryOf(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_arenaEmptyHint,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
color: RetroColors.textMutedOf(context),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRankingList(HallOfFame hallOfFame) {
|
||||
final rankedEntries = hallOfFame.rankedEntries;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: RetroGoldPanel(
|
||||
title: _arenaSubtitle,
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: ListView.builder(
|
||||
itemCount: rankedEntries.length,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = rankedEntries[index];
|
||||
final score = HallOfFameArenaX.calculateArenaScore(entry);
|
||||
return ArenaRankCard(
|
||||
entry: entry,
|
||||
rank: index + 1,
|
||||
score: score,
|
||||
onTap: () => _selectChallenger(entry),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
447
lib/src/features/arena/arena_setup_screen.dart
Normal file
447
lib/src/features/arena/arena_setup_screen.dart
Normal file
@@ -0,0 +1,447 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/src/core/engine/arena_service.dart';
|
||||
import 'package:asciineverdie/src/core/engine/item_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/arena_match.dart';
|
||||
import 'package:asciineverdie/src/core/model/equipment_item.dart';
|
||||
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
|
||||
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
||||
import 'package:asciineverdie/src/core/storage/hall_of_fame_storage.dart';
|
||||
import 'package:asciineverdie/src/features/arena/arena_battle_screen.dart';
|
||||
import 'package:asciineverdie/src/features/arena/widgets/arena_equipment_compare_list.dart';
|
||||
import 'package:asciineverdie/src/features/arena/widgets/arena_idle_preview.dart';
|
||||
import 'package:asciineverdie/src/features/arena/widgets/arena_rank_card.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
// 임시 문자열 (추후 l10n으로 이동)
|
||||
const _setupTitle = 'ARENA SETUP';
|
||||
const _selectCharacter = 'SELECT YOUR FIGHTER';
|
||||
const _startBattleLabel = 'START BATTLE';
|
||||
|
||||
/// 아레나 설정 화면
|
||||
///
|
||||
/// 캐릭터 선택 및 슬롯 선택
|
||||
class ArenaSetupScreen extends StatefulWidget {
|
||||
const ArenaSetupScreen({
|
||||
super.key,
|
||||
required this.hallOfFame,
|
||||
required this.onBattleComplete,
|
||||
this.initialChallenger,
|
||||
});
|
||||
|
||||
/// 명예의 전당
|
||||
final HallOfFame hallOfFame;
|
||||
|
||||
/// 전투 완료 콜백 (업데이트된 명예의 전당 전달)
|
||||
final void Function(HallOfFame) onBattleComplete;
|
||||
|
||||
/// 초기 도전자 (메인 화면에서 선택한 경우)
|
||||
final HallOfFameEntry? initialChallenger;
|
||||
|
||||
@override
|
||||
State<ArenaSetupScreen> createState() => _ArenaSetupScreenState();
|
||||
}
|
||||
|
||||
class _ArenaSetupScreenState extends State<ArenaSetupScreen> {
|
||||
final ArenaService _arenaService = ArenaService();
|
||||
final HallOfFameStorage _storage = HallOfFameStorage();
|
||||
|
||||
/// 현재 단계 (0: 캐릭터 선택, 1: 슬롯 선택)
|
||||
int _step = 0;
|
||||
|
||||
/// 선택된 도전자
|
||||
HallOfFameEntry? _challenger;
|
||||
|
||||
/// 자동 결정된 상대
|
||||
HallOfFameEntry? _opponent;
|
||||
|
||||
/// 선택된 베팅 슬롯
|
||||
EquipmentSlot? _selectedSlot;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// 초기 도전자가 있으면 바로 슬롯 선택 단계로 이동
|
||||
if (widget.initialChallenger != null) {
|
||||
_selectChallenger(widget.initialChallenger!);
|
||||
}
|
||||
}
|
||||
|
||||
void _selectChallenger(HallOfFameEntry entry) {
|
||||
final opponent = _arenaService.findOpponent(widget.hallOfFame, entry.id);
|
||||
|
||||
setState(() {
|
||||
_challenger = entry;
|
||||
_opponent = opponent;
|
||||
_step = 1;
|
||||
});
|
||||
}
|
||||
|
||||
void _startBattle() {
|
||||
if (_challenger == null ||
|
||||
_opponent == null ||
|
||||
_selectedSlot == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final match = ArenaMatch(
|
||||
challenger: _challenger!,
|
||||
opponent: _opponent!,
|
||||
bettingSlot: _selectedSlot!,
|
||||
);
|
||||
|
||||
final navigator = Navigator.of(context);
|
||||
|
||||
navigator.push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (ctx) => ArenaBattleScreen(
|
||||
match: match,
|
||||
onBattleComplete: (result) async {
|
||||
// 결과 저장
|
||||
var updatedHallOfFame = widget.hallOfFame
|
||||
.updateEntry(result.updatedChallenger)
|
||||
.updateEntry(result.updatedOpponent);
|
||||
|
||||
await _storage.save(updatedHallOfFame);
|
||||
|
||||
widget.onBattleComplete(updatedHallOfFame);
|
||||
|
||||
// 아레나 화면으로 돌아가기
|
||||
if (mounted) {
|
||||
navigator.popUntil((route) => route.isFirst);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: RetroColors.backgroundOf(context),
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
_setupTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
backgroundColor: RetroColors.panelBgOf(context),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: _step == 0 ? _buildCharacterSelection() : _buildSlotSelection(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCharacterSelection() {
|
||||
final rankedEntries = widget.hallOfFame.rankedEntries;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 헤더
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
_selectCharacter,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 10,
|
||||
color: RetroColors.goldOf(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 캐릭터 목록
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
itemCount: rankedEntries.length,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = rankedEntries[index];
|
||||
final score = HallOfFameArenaX.calculateArenaScore(entry);
|
||||
return ArenaRankCard(
|
||||
entry: entry,
|
||||
rank: index + 1,
|
||||
score: score,
|
||||
onTap: () => _selectChallenger(entry),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSlotSelection() {
|
||||
final recommendedSlot = _calculateRecommendedSlot();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// ASCII 캐릭터 미리보기 (좌: 도전자, 우: 상대 반전)
|
||||
ArenaIdlePreview(
|
||||
challengerRaceId: _challenger?.race,
|
||||
opponentRaceId: _opponent?.race,
|
||||
),
|
||||
// 상단 캐릭터 정보 (좌우 대칭)
|
||||
_buildCharacterHeaders(),
|
||||
// 장비 비교 리스트
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: ArenaEquipmentCompareList(
|
||||
myEquipment: _challenger?.finalEquipment,
|
||||
enemyEquipment: _opponent?.finalEquipment,
|
||||
selectedSlot: _selectedSlot,
|
||||
recommendedSlot: recommendedSlot,
|
||||
onSlotSelected: (slot) {
|
||||
setState(() => _selectedSlot = slot);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
// 하단 버튼
|
||||
_buildStartButton(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 추천 슬롯 계산 (점수 이득이 가장 큰 슬롯)
|
||||
EquipmentSlot? _calculateRecommendedSlot() {
|
||||
if (_challenger == null || _opponent == null) return null;
|
||||
|
||||
EquipmentSlot? bestSlot;
|
||||
int maxGain = 0;
|
||||
|
||||
for (final slot in EquipmentSlot.values) {
|
||||
final myItem = _findItem(slot, _challenger!.finalEquipment);
|
||||
final enemyItem = _findItem(slot, _opponent!.finalEquipment);
|
||||
|
||||
final myScore =
|
||||
myItem != null ? ItemService.calculateEquipmentScore(myItem) : 0;
|
||||
final enemyScore =
|
||||
enemyItem != null ? ItemService.calculateEquipmentScore(enemyItem) : 0;
|
||||
final gain = enemyScore - myScore;
|
||||
|
||||
if (gain > maxGain) {
|
||||
maxGain = gain;
|
||||
bestSlot = slot;
|
||||
}
|
||||
}
|
||||
|
||||
return bestSlot;
|
||||
}
|
||||
|
||||
/// 장비 찾기 헬퍼
|
||||
EquipmentItem? _findItem(EquipmentSlot slot, List<EquipmentItem>? items) {
|
||||
if (items == null) return null;
|
||||
for (final item in items) {
|
||||
if (item.slot == slot) return item;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 상단 캐릭터 정보 헤더 (좌우 대칭)
|
||||
Widget _buildCharacterHeaders() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 내 캐릭터
|
||||
Expanded(child: _buildChallengerInfo()),
|
||||
const SizedBox(width: 12),
|
||||
// 상대 캐릭터
|
||||
Expanded(child: _buildOpponentInfo()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 내 캐릭터 정보 카드
|
||||
Widget _buildChallengerInfo() {
|
||||
if (_challenger == null) return const SizedBox.shrink();
|
||||
|
||||
final score = HallOfFameArenaX.calculateArenaScore(_challenger!);
|
||||
final rank = widget.hallOfFame.getRank(_challenger!.id);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.blue, width: 2),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 순위 배지
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.blue, width: 2),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$rank',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 10,
|
||||
color: Colors.blue,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 정보
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_challenger!.characterName,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
color: RetroColors.textPrimaryOf(context),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Lv.${_challenger!.level} • $score pt',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 5,
|
||||
color: RetroColors.textSecondaryOf(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 시작 버튼
|
||||
Widget _buildStartButton() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: ElevatedButton(
|
||||
onPressed: _selectedSlot != null ? _startBattle : null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: RetroColors.goldOf(context),
|
||||
foregroundColor: RetroColors.backgroundOf(context),
|
||||
disabledBackgroundColor:
|
||||
RetroColors.borderOf(context).withValues(alpha: 0.5),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.sports_kabaddi,
|
||||
color: _selectedSlot != null
|
||||
? RetroColors.backgroundOf(context)
|
||||
: RetroColors.textMutedOf(context),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_startBattleLabel,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 10,
|
||||
color: _selectedSlot != null
|
||||
? RetroColors.backgroundOf(context)
|
||||
: RetroColors.textMutedOf(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 상대 캐릭터 정보 카드 (대칭 스타일)
|
||||
Widget _buildOpponentInfo() {
|
||||
if (_opponent == null) return const SizedBox.shrink();
|
||||
|
||||
final score = HallOfFameArenaX.calculateArenaScore(_opponent!);
|
||||
final rank = widget.hallOfFame.getRank(_opponent!.id);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.red, width: 2),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 순위 배지
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.red, width: 2),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$rank',
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 10,
|
||||
color: Colors.red,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 정보
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_opponent!.characterName,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
color: RetroColors.textPrimaryOf(context),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Lv.${_opponent!.level} • $score pt',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 5,
|
||||
color: RetroColors.textSecondaryOf(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
548
lib/src/features/arena/widgets/arena_equipment_compare_list.dart
Normal file
548
lib/src/features/arena/widgets/arena_equipment_compare_list.dart
Normal file
@@ -0,0 +1,548 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/src/core/engine/item_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/equipment_item.dart';
|
||||
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
|
||||
import 'package:asciineverdie/src/core/model/item_stats.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
// 임시 문자열 (추후 l10n으로 이동)
|
||||
const _myEquipmentTitle = 'MY EQUIPMENT';
|
||||
const _enemyEquipmentTitle = 'ENEMY EQUIPMENT';
|
||||
const _selectSlotLabel = 'SELECT';
|
||||
const _recommendedLabel = 'BEST';
|
||||
|
||||
/// 좌우 대칭 장비 비교 리스트
|
||||
///
|
||||
/// 내 장비와 상대 장비를 나란히 표시하고,
|
||||
/// 선택 시 인라인으로 비교 정보를 확장
|
||||
class ArenaEquipmentCompareList extends StatefulWidget {
|
||||
const ArenaEquipmentCompareList({
|
||||
super.key,
|
||||
required this.myEquipment,
|
||||
required this.enemyEquipment,
|
||||
required this.selectedSlot,
|
||||
required this.onSlotSelected,
|
||||
this.recommendedSlot,
|
||||
});
|
||||
|
||||
/// 내 장비 목록
|
||||
final List<EquipmentItem>? myEquipment;
|
||||
|
||||
/// 상대 장비 목록
|
||||
final List<EquipmentItem>? enemyEquipment;
|
||||
|
||||
/// 현재 선택된 슬롯
|
||||
final EquipmentSlot? selectedSlot;
|
||||
|
||||
/// 슬롯 선택 콜백
|
||||
final ValueChanged<EquipmentSlot> onSlotSelected;
|
||||
|
||||
/// 추천 슬롯 (점수 이득이 가장 큰 슬롯)
|
||||
final EquipmentSlot? recommendedSlot;
|
||||
|
||||
@override
|
||||
State<ArenaEquipmentCompareList> createState() =>
|
||||
_ArenaEquipmentCompareListState();
|
||||
}
|
||||
|
||||
class _ArenaEquipmentCompareListState extends State<ArenaEquipmentCompareList> {
|
||||
/// 현재 확장된 슬롯 (탭하여 비교 중인 슬롯)
|
||||
EquipmentSlot? _expandedSlot;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
// 헤더 (좌우 타이틀)
|
||||
_buildHeader(context),
|
||||
const SizedBox(height: 8),
|
||||
// 장비 리스트
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: EquipmentSlot.values.length,
|
||||
itemBuilder: (context, index) {
|
||||
final slot = EquipmentSlot.values[index];
|
||||
return _buildSlotRow(context, slot);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
// 내 장비 타이틀
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.blue.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Text(
|
||||
_myEquipmentTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
color: Colors.blue,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 상대 장비 타이틀
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.red.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Text(
|
||||
_enemyEquipmentTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
color: Colors.red,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSlotRow(BuildContext context, EquipmentSlot slot) {
|
||||
final myItem = _findItem(slot, widget.myEquipment);
|
||||
final enemyItem = _findItem(slot, widget.enemyEquipment);
|
||||
final isExpanded = _expandedSlot == slot;
|
||||
final isSelected = widget.selectedSlot == slot;
|
||||
final isRecommended = widget.recommendedSlot == slot;
|
||||
|
||||
final myScore =
|
||||
myItem != null ? ItemService.calculateEquipmentScore(myItem) : 0;
|
||||
final enemyScore =
|
||||
enemyItem != null ? ItemService.calculateEquipmentScore(enemyItem) : 0;
|
||||
final scoreDiff = enemyScore - myScore;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 슬롯 행 (좌우 대칭)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_expandedSlot = isExpanded ? null : slot;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? RetroColors.goldOf(context).withValues(alpha: 0.2)
|
||||
: isRecommended
|
||||
? Colors.green.withValues(alpha: 0.1)
|
||||
: isExpanded
|
||||
? RetroColors.panelBgOf(context)
|
||||
: Colors.transparent,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: RetroColors.borderOf(context).withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 내 장비
|
||||
Expanded(child: _buildEquipmentCell(context, myItem, myScore, Colors.blue)),
|
||||
// 슬롯 아이콘 (중앙)
|
||||
_buildSlotIndicator(context, slot, isSelected, isRecommended, scoreDiff),
|
||||
// 상대 장비
|
||||
Expanded(child: _buildEquipmentCell(context, enemyItem, enemyScore, Colors.red)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 확장된 비교 패널
|
||||
if (isExpanded)
|
||||
_buildExpandedPanel(context, slot, myItem, enemyItem, scoreDiff),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 장비 셀 (한쪽)
|
||||
Widget _buildEquipmentCell(
|
||||
BuildContext context,
|
||||
EquipmentItem? item,
|
||||
int score,
|
||||
Color accentColor,
|
||||
) {
|
||||
final hasItem = item != null && item.isNotEmpty;
|
||||
final rarityColor = hasItem ? _getRarityColor(item.rarity) : Colors.grey;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
// 아이템 이름
|
||||
Expanded(
|
||||
child: Text(
|
||||
hasItem ? item.name : '-',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 5,
|
||||
color: hasItem ? rarityColor : RetroColors.textMutedOf(context),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
// 점수
|
||||
Text(
|
||||
'$score',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 5,
|
||||
color: hasItem
|
||||
? RetroColors.textSecondaryOf(context)
|
||||
: RetroColors.textMutedOf(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 슬롯 인디케이터 (중앙)
|
||||
Widget _buildSlotIndicator(
|
||||
BuildContext context,
|
||||
EquipmentSlot slot,
|
||||
bool isSelected,
|
||||
bool isRecommended,
|
||||
int scoreDiff,
|
||||
) {
|
||||
final Color borderColor;
|
||||
final Color bgColor;
|
||||
|
||||
if (isSelected) {
|
||||
borderColor = RetroColors.goldOf(context);
|
||||
bgColor = RetroColors.goldOf(context).withValues(alpha: 0.3);
|
||||
} else if (isRecommended) {
|
||||
borderColor = Colors.green;
|
||||
bgColor = Colors.green.withValues(alpha: 0.2);
|
||||
} else {
|
||||
borderColor = RetroColors.borderOf(context);
|
||||
bgColor = RetroColors.panelBgOf(context);
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: 56,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: borderColor, width: isSelected ? 2 : 1),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 슬롯 아이콘
|
||||
Icon(
|
||||
_getSlotIcon(slot),
|
||||
size: 12,
|
||||
color: isSelected
|
||||
? RetroColors.goldOf(context)
|
||||
: RetroColors.textSecondaryOf(context),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
// 점수 변화
|
||||
_buildScoreDiffBadge(context, scoreDiff, isRecommended),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 점수 변화 뱃지
|
||||
Widget _buildScoreDiffBadge(
|
||||
BuildContext context,
|
||||
int scoreDiff,
|
||||
bool isRecommended,
|
||||
) {
|
||||
final Color diffColor;
|
||||
final String diffText;
|
||||
|
||||
if (scoreDiff > 0) {
|
||||
diffColor = Colors.green;
|
||||
diffText = '+$scoreDiff';
|
||||
} else if (scoreDiff < 0) {
|
||||
diffColor = Colors.red;
|
||||
diffText = '$scoreDiff';
|
||||
} else {
|
||||
diffColor = RetroColors.textMutedOf(context);
|
||||
diffText = '±0';
|
||||
}
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (isRecommended) ...[
|
||||
Text(
|
||||
_recommendedLabel,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 4,
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
],
|
||||
Text(
|
||||
diffText,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 5,
|
||||
color: diffColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 확장된 비교 패널
|
||||
Widget _buildExpandedPanel(
|
||||
BuildContext context,
|
||||
EquipmentSlot slot,
|
||||
EquipmentItem? myItem,
|
||||
EquipmentItem? enemyItem,
|
||||
int scoreDiff,
|
||||
) {
|
||||
final Color resultColor;
|
||||
final String resultText;
|
||||
final IconData resultIcon;
|
||||
|
||||
if (scoreDiff > 0) {
|
||||
resultColor = Colors.green;
|
||||
resultText = 'You will GAIN +$scoreDiff';
|
||||
resultIcon = Icons.arrow_upward;
|
||||
} else if (scoreDiff < 0) {
|
||||
resultColor = Colors.red;
|
||||
resultText = 'You will LOSE $scoreDiff';
|
||||
resultIcon = Icons.arrow_downward;
|
||||
} else {
|
||||
resultColor = RetroColors.textMutedOf(context);
|
||||
resultText = 'Even trade';
|
||||
resultIcon = Icons.swap_horiz;
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: resultColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: resultColor.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 상세 비교 (좌우)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 내 아이템 상세
|
||||
Expanded(child: _buildItemDetail(context, myItem, Colors.blue)),
|
||||
// VS 아이콘
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Icon(
|
||||
Icons.swap_horiz,
|
||||
color: RetroColors.textMutedOf(context),
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
// 상대 아이템 상세
|
||||
Expanded(child: _buildItemDetail(context, enemyItem, Colors.red)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// 교환 결과
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: resultColor.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(resultIcon, color: resultColor, size: 14),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
resultText,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
color: resultColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
// 선택 버튼
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
widget.onSlotSelected(slot);
|
||||
setState(() => _expandedSlot = null);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: RetroColors.goldOf(context),
|
||||
foregroundColor: RetroColors.backgroundOf(context),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_selectSlotLabel,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
color: RetroColors.backgroundOf(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 아이템 상세 정보
|
||||
Widget _buildItemDetail(
|
||||
BuildContext context,
|
||||
EquipmentItem? item,
|
||||
Color accentColor,
|
||||
) {
|
||||
final hasItem = item != null && item.isNotEmpty;
|
||||
final rarityColor = hasItem ? _getRarityColor(item.rarity) : Colors.grey;
|
||||
final score = hasItem ? ItemService.calculateEquipmentScore(item) : 0;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 아이템 이름
|
||||
Text(
|
||||
hasItem ? item.name : '(Empty)',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
color: hasItem ? rarityColor : RetroColors.textMutedOf(context),
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// 점수
|
||||
Text(
|
||||
'Score: $score',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 5,
|
||||
color: RetroColors.textSecondaryOf(context),
|
||||
),
|
||||
),
|
||||
// 스탯
|
||||
if (hasItem) ...[
|
||||
const SizedBox(height: 4),
|
||||
_buildItemStats(context, item),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 아이템 스탯 표시
|
||||
Widget _buildItemStats(BuildContext context, EquipmentItem item) {
|
||||
final stats = item.stats;
|
||||
final statWidgets = <Widget>[];
|
||||
|
||||
if (stats.atk > 0) {
|
||||
statWidgets.add(_buildStatChip('ATK', stats.atk, Colors.red));
|
||||
}
|
||||
if (stats.def > 0) {
|
||||
statWidgets.add(_buildStatChip('DEF', stats.def, Colors.blue));
|
||||
}
|
||||
if (stats.hpBonus > 0) {
|
||||
statWidgets.add(_buildStatChip('HP', stats.hpBonus, Colors.green));
|
||||
}
|
||||
if (stats.mpBonus > 0) {
|
||||
statWidgets.add(_buildStatChip('MP', stats.mpBonus, Colors.purple));
|
||||
}
|
||||
|
||||
if (statWidgets.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Wrap(
|
||||
spacing: 3,
|
||||
runSpacing: 3,
|
||||
children: statWidgets,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatChip(String label, int value, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
child: Text(
|
||||
'$label +$value',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 4,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
EquipmentItem? _findItem(EquipmentSlot slot, List<EquipmentItem>? items) {
|
||||
if (items == null) return null;
|
||||
for (final item in items) {
|
||||
if (item.slot == slot) return item;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
IconData _getSlotIcon(EquipmentSlot slot) {
|
||||
return switch (slot) {
|
||||
EquipmentSlot.weapon => Icons.gavel,
|
||||
EquipmentSlot.shield => Icons.shield,
|
||||
EquipmentSlot.helm => Icons.sports_mma,
|
||||
EquipmentSlot.hauberk => Icons.checkroom,
|
||||
EquipmentSlot.brassairts => Icons.front_hand,
|
||||
EquipmentSlot.vambraces => Icons.back_hand,
|
||||
EquipmentSlot.gauntlets => Icons.sports_handball,
|
||||
EquipmentSlot.gambeson => Icons.dry_cleaning,
|
||||
EquipmentSlot.cuisses => Icons.airline_seat_legroom_normal,
|
||||
EquipmentSlot.greaves => Icons.snowshoeing,
|
||||
EquipmentSlot.sollerets => Icons.do_not_step,
|
||||
};
|
||||
}
|
||||
|
||||
Color _getRarityColor(ItemRarity rarity) {
|
||||
return switch (rarity) {
|
||||
ItemRarity.common => Colors.grey.shade600,
|
||||
ItemRarity.uncommon => Colors.green.shade600,
|
||||
ItemRarity.rare => Colors.blue.shade600,
|
||||
ItemRarity.epic => Colors.purple.shade600,
|
||||
ItemRarity.legendary => Colors.orange.shade700,
|
||||
};
|
||||
}
|
||||
}
|
||||
181
lib/src/features/arena/widgets/arena_idle_preview.dart
Normal file
181
lib/src/features/arena/widgets/arena_idle_preview.dart
Normal file
@@ -0,0 +1,181 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:asciineverdie/src/core/animation/canvas/ascii_cell.dart';
|
||||
import 'package:asciineverdie/src/core/animation/canvas/ascii_canvas_widget.dart';
|
||||
import 'package:asciineverdie/src/core/animation/canvas/ascii_layer.dart';
|
||||
import 'package:asciineverdie/src/core/animation/character_frames.dart';
|
||||
import 'package:asciineverdie/src/core/animation/race_character_frames.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 아레나 idle 상태 캐릭터 미리보기 위젯
|
||||
///
|
||||
/// 좌측에 도전자, 우측에 상대(좌우 반전)를 idle 상태로 표시
|
||||
class ArenaIdlePreview extends StatefulWidget {
|
||||
const ArenaIdlePreview({
|
||||
super.key,
|
||||
required this.challengerRaceId,
|
||||
required this.opponentRaceId,
|
||||
});
|
||||
|
||||
/// 도전자 종족 ID
|
||||
final String? challengerRaceId;
|
||||
|
||||
/// 상대 종족 ID
|
||||
final String? opponentRaceId;
|
||||
|
||||
@override
|
||||
State<ArenaIdlePreview> createState() => _ArenaIdlePreviewState();
|
||||
}
|
||||
|
||||
class _ArenaIdlePreviewState extends State<ArenaIdlePreview> {
|
||||
/// 현재 idle 프레임 인덱스 (0~3)
|
||||
int _frameIndex = 0;
|
||||
|
||||
/// 애니메이션 타이머
|
||||
Timer? _timer;
|
||||
|
||||
/// 레이어 버전 (변경 감지용)
|
||||
int _layerVersion = 0;
|
||||
|
||||
/// 캔버스 크기
|
||||
static const int _gridWidth = 32;
|
||||
static const int _gridHeight = 5;
|
||||
|
||||
/// 캐릭터 위치
|
||||
static const int _leftCharX = 4;
|
||||
static const int _rightCharX = 22;
|
||||
static const int _charY = 1; // 상단 여백
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startAnimation();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _startAnimation() {
|
||||
// 200ms마다 프레임 업데이트 (원본 틱 속도)
|
||||
_timer = Timer.periodic(const Duration(milliseconds: 200), (_) {
|
||||
setState(() {
|
||||
_frameIndex = (_frameIndex + 1) % 4;
|
||||
_layerVersion++;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final layers = _composeLayers();
|
||||
|
||||
return SizedBox(
|
||||
height: 60,
|
||||
child: AsciiCanvasWidget(
|
||||
layers: layers,
|
||||
gridWidth: _gridWidth,
|
||||
gridHeight: _gridHeight,
|
||||
backgroundOpacity: 0.3,
|
||||
isAnimating: true,
|
||||
layerVersion: _layerVersion,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 레이어 합성
|
||||
List<AsciiLayer> _composeLayers() {
|
||||
final layers = <AsciiLayer>[];
|
||||
|
||||
// 도전자 캐릭터 (좌측, 정방향)
|
||||
final challengerLayer = _createCharacterLayer(
|
||||
widget.challengerRaceId,
|
||||
_leftCharX,
|
||||
mirrored: false,
|
||||
);
|
||||
layers.add(challengerLayer);
|
||||
|
||||
// 상대 캐릭터 (우측, 좌우 반전)
|
||||
final opponentLayer = _createCharacterLayer(
|
||||
widget.opponentRaceId,
|
||||
_rightCharX,
|
||||
mirrored: true,
|
||||
);
|
||||
layers.add(opponentLayer);
|
||||
|
||||
return layers;
|
||||
}
|
||||
|
||||
/// 캐릭터 레이어 생성
|
||||
AsciiLayer _createCharacterLayer(
|
||||
String? raceId,
|
||||
int xOffset, {
|
||||
required bool mirrored,
|
||||
}) {
|
||||
// 종족별 idle 프레임 조회
|
||||
CharacterFrame frame;
|
||||
if (raceId != null && raceId.isNotEmpty) {
|
||||
final raceData = RaceCharacterFrames.get(raceId);
|
||||
if (raceData != null) {
|
||||
frame = raceData.idle[_frameIndex % raceData.idle.length];
|
||||
} else {
|
||||
frame = getCharacterFrame(BattlePhase.idle, _frameIndex);
|
||||
}
|
||||
} else {
|
||||
frame = getCharacterFrame(BattlePhase.idle, _frameIndex);
|
||||
}
|
||||
|
||||
// 미러링 적용
|
||||
final lines = mirrored ? _mirrorLines(frame.lines) : frame.lines;
|
||||
|
||||
// 셀 변환
|
||||
final cells = _spriteToCells(lines);
|
||||
|
||||
return AsciiLayer(
|
||||
cells: cells,
|
||||
zIndex: 1,
|
||||
offsetX: xOffset,
|
||||
offsetY: _charY,
|
||||
);
|
||||
}
|
||||
|
||||
/// 문자열 좌우 반전
|
||||
List<String> _mirrorLines(List<String> lines) {
|
||||
return lines.map((line) {
|
||||
final chars = line.split('');
|
||||
final mirrored = chars.reversed.map(_mirrorChar).toList();
|
||||
return mirrored.join();
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// 개별 문자 미러링 (방향성 문자 변환)
|
||||
String _mirrorChar(String char) {
|
||||
return switch (char) {
|
||||
'/' => r'\',
|
||||
r'\' => '/',
|
||||
'(' => ')',
|
||||
')' => '(',
|
||||
'[' => ']',
|
||||
']' => '[',
|
||||
'{' => '}',
|
||||
'}' => '{',
|
||||
'<' => '>',
|
||||
'>' => '<',
|
||||
'┘' => '└',
|
||||
'└' => '┘',
|
||||
'┐' => '┌',
|
||||
'┌' => '┐',
|
||||
'λ' => 'λ', // 대칭
|
||||
_ => char,
|
||||
};
|
||||
}
|
||||
|
||||
/// 문자열 스프라이트를 AsciiCell 2D 배열로 변환
|
||||
List<List<AsciiCell>> _spriteToCells(List<String> lines) {
|
||||
return lines.map((line) {
|
||||
return line.split('').map(AsciiCell.fromChar).toList();
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
208
lib/src/features/arena/widgets/arena_rank_card.dart
Normal file
208
lib/src/features/arena/widgets/arena_rank_card.dart
Normal file
@@ -0,0 +1,208 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
|
||||
import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
/// 아레나 순위 카드 위젯
|
||||
///
|
||||
/// 명예의 전당 캐릭터를 순위와 함께 표시
|
||||
class ArenaRankCard extends StatelessWidget {
|
||||
const ArenaRankCard({
|
||||
super.key,
|
||||
required this.entry,
|
||||
required this.rank,
|
||||
required this.score,
|
||||
this.isSelected = false,
|
||||
this.isHighlighted = false,
|
||||
this.compact = false,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
/// 캐릭터 엔트리
|
||||
final HallOfFameEntry entry;
|
||||
|
||||
/// 순위 (1-based)
|
||||
final int rank;
|
||||
|
||||
/// 아레나 점수
|
||||
final int score;
|
||||
|
||||
/// 선택 상태 (상대로 선택됨)
|
||||
final bool isSelected;
|
||||
|
||||
/// 하이라이트 상태 (내 캐릭터 표시)
|
||||
final bool isHighlighted;
|
||||
|
||||
/// 컴팩트 모드 (작은 사이즈)
|
||||
final bool compact;
|
||||
|
||||
/// 탭 콜백
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rankColor = _getRankColor(rank);
|
||||
final rankIcon = _getRankIcon(rank);
|
||||
|
||||
// 배경색 결정
|
||||
Color bgColor;
|
||||
Color borderColor;
|
||||
if (isSelected) {
|
||||
bgColor = Colors.red.withValues(alpha: 0.15);
|
||||
borderColor = Colors.red;
|
||||
} else if (isHighlighted) {
|
||||
bgColor = Colors.blue.withValues(alpha: 0.15);
|
||||
borderColor = Colors.blue;
|
||||
} else {
|
||||
bgColor = RetroColors.panelBgOf(context);
|
||||
borderColor = RetroColors.borderOf(context);
|
||||
}
|
||||
|
||||
return Card(
|
||||
margin: EdgeInsets.symmetric(
|
||||
vertical: compact ? 2 : 4,
|
||||
horizontal: compact ? 0 : 8,
|
||||
),
|
||||
color: bgColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(compact ? 6 : 8),
|
||||
side: BorderSide(
|
||||
color: borderColor,
|
||||
width: (isSelected || isHighlighted) ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(compact ? 6 : 8),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(compact ? 8 : 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// 순위 배지
|
||||
_buildRankBadge(rankColor, rankIcon),
|
||||
SizedBox(width: compact ? 8 : 12),
|
||||
// 캐릭터 정보
|
||||
Expanded(child: _buildCharacterInfo(context)),
|
||||
// 점수
|
||||
if (!compact) _buildScoreColumn(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRankBadge(Color color, IconData? icon) {
|
||||
final size = compact ? 24.0 : 36.0;
|
||||
final iconSize = compact ? 12.0 : 18.0;
|
||||
final fontSize = compact ? 7.0 : 10.0;
|
||||
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.2),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: color, width: compact ? 1.5 : 2),
|
||||
),
|
||||
child: Center(
|
||||
child: icon != null
|
||||
? Icon(icon, color: color, size: iconSize)
|
||||
: Text(
|
||||
'$rank',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: fontSize,
|
||||
color: color,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCharacterInfo(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 이름
|
||||
Text(
|
||||
entry.characterName,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: compact ? 6 : 9,
|
||||
color: RetroColors.textPrimaryOf(context),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
SizedBox(height: compact ? 2 : 4),
|
||||
// 종족/클래스 + 레벨
|
||||
Text(
|
||||
compact
|
||||
? 'Lv.${entry.level}'
|
||||
: '${GameDataL10n.getRaceName(context, entry.race)} '
|
||||
'${GameDataL10n.getKlassName(context, entry.klass)} '
|
||||
'Lv.${entry.level}',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: compact ? 5 : 7,
|
||||
color: RetroColors.textSecondaryOf(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScoreColumn(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'$score',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 10,
|
||||
color: RetroColors.goldOf(context),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'SCORE',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
color: RetroColors.textMutedOf(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Color _getRankColor(int rank) {
|
||||
switch (rank) {
|
||||
case 1:
|
||||
return Colors.amber.shade700;
|
||||
case 2:
|
||||
return Colors.grey.shade500;
|
||||
case 3:
|
||||
return Colors.brown.shade400;
|
||||
default:
|
||||
return Colors.blue.shade400;
|
||||
}
|
||||
}
|
||||
|
||||
IconData? _getRankIcon(int rank) {
|
||||
switch (rank) {
|
||||
case 1:
|
||||
return Icons.emoji_events;
|
||||
case 2:
|
||||
return Icons.workspace_premium;
|
||||
case 3:
|
||||
return Icons.military_tech;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
437
lib/src/features/arena/widgets/arena_result_dialog.dart
Normal file
437
lib/src/features/arena/widgets/arena_result_dialog.dart
Normal file
@@ -0,0 +1,437 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
|
||||
import 'package:asciineverdie/src/core/engine/item_service.dart';
|
||||
import 'package:asciineverdie/src/core/model/arena_match.dart';
|
||||
import 'package:asciineverdie/src/core/model/equipment_item.dart';
|
||||
import 'package:asciineverdie/src/core/model/equipment_slot.dart';
|
||||
import 'package:asciineverdie/src/core/model/item_stats.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
// 아레나 관련 임시 문자열 (추후 l10n으로 이동)
|
||||
const _arenaVictory = 'VICTORY!';
|
||||
const _arenaDefeat = 'DEFEAT...';
|
||||
const _arenaExchange = 'EQUIPMENT EXCHANGE';
|
||||
|
||||
/// 아레나 결과 다이얼로그
|
||||
///
|
||||
/// 전투 승패 및 장비 교환 결과 표시
|
||||
class ArenaResultDialog extends StatelessWidget {
|
||||
const ArenaResultDialog({
|
||||
super.key,
|
||||
required this.result,
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
/// 대전 결과
|
||||
final ArenaMatchResult result;
|
||||
|
||||
/// 닫기 콜백
|
||||
final VoidCallback onClose;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isVictory = result.isVictory;
|
||||
final resultColor = isVictory ? Colors.amber : Colors.red;
|
||||
final slot = result.match.bettingSlot;
|
||||
|
||||
return AlertDialog(
|
||||
backgroundColor: RetroColors.panelBgOf(context),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: resultColor, width: 2),
|
||||
),
|
||||
title: _buildTitle(context, isVictory, resultColor),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 350),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 전투 정보
|
||||
_buildBattleInfo(context),
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
// 장비 교환 결과
|
||||
_buildExchangeResult(context, slot),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: onClose,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: resultColor,
|
||||
),
|
||||
child: Text(
|
||||
l10n.buttonConfirm,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTitle(BuildContext context, bool isVictory, Color color) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
isVictory ? Icons.emoji_events : Icons.sentiment_very_dissatisfied,
|
||||
color: color,
|
||||
size: 28,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
isVictory ? _arenaVictory : _arenaDefeat,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 12,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
isVictory ? Icons.emoji_events : Icons.sentiment_very_dissatisfied,
|
||||
color: color,
|
||||
size: 28,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBattleInfo(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
// 도전자 정보
|
||||
_buildFighterInfo(
|
||||
context,
|
||||
result.match.challenger.characterName,
|
||||
result.isVictory,
|
||||
),
|
||||
// VS
|
||||
Text(
|
||||
'VS',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 10,
|
||||
color: RetroColors.textMutedOf(context),
|
||||
),
|
||||
),
|
||||
// 상대 정보
|
||||
_buildFighterInfo(
|
||||
context,
|
||||
result.match.opponent.characterName,
|
||||
!result.isVictory,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFighterInfo(BuildContext context, String name, bool isWinner) {
|
||||
return Column(
|
||||
children: [
|
||||
Icon(
|
||||
isWinner ? Icons.emoji_events : Icons.close,
|
||||
color: isWinner ? Colors.amber : Colors.grey,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
color: isWinner
|
||||
? RetroColors.goldOf(context)
|
||||
: RetroColors.textMutedOf(context),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
isWinner ? 'WINNER' : 'LOSER',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
color: isWinner ? Colors.amber : Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExchangeResult(BuildContext context, EquipmentSlot slot) {
|
||||
// 교환된 장비 찾기
|
||||
final challengerOldItem = _findItem(
|
||||
result.match.challenger.finalEquipment,
|
||||
slot,
|
||||
);
|
||||
final opponentOldItem = _findItem(
|
||||
result.match.opponent.finalEquipment,
|
||||
slot,
|
||||
);
|
||||
|
||||
final challengerNewItem = _findItem(
|
||||
result.updatedChallenger.finalEquipment,
|
||||
slot,
|
||||
);
|
||||
final opponentNewItem = _findItem(
|
||||
result.updatedOpponent.finalEquipment,
|
||||
slot,
|
||||
);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 교환 타이틀
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.swap_horiz,
|
||||
color: RetroColors.goldOf(context),
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_arenaExchange,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 8,
|
||||
color: RetroColors.goldOf(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 슬롯 정보
|
||||
Text(
|
||||
_getSlotLabel(slot),
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
color: RetroColors.textSecondaryOf(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 내 캐릭터 장비 변경
|
||||
_buildExchangeRow(
|
||||
context,
|
||||
result.match.challenger.characterName,
|
||||
challengerOldItem,
|
||||
challengerNewItem,
|
||||
result.isVictory,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 상대 장비 변경
|
||||
_buildExchangeRow(
|
||||
context,
|
||||
result.match.opponent.characterName,
|
||||
opponentOldItem,
|
||||
opponentNewItem,
|
||||
!result.isVictory,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExchangeRow(
|
||||
BuildContext context,
|
||||
String name,
|
||||
EquipmentItem? oldItem,
|
||||
EquipmentItem? newItem,
|
||||
bool isWinner,
|
||||
) {
|
||||
final oldScore =
|
||||
oldItem != null ? ItemService.calculateEquipmentScore(oldItem) : 0;
|
||||
final newScore =
|
||||
newItem != null ? ItemService.calculateEquipmentScore(newItem) : 0;
|
||||
final scoreDiff = newScore - oldScore;
|
||||
final isGain = scoreDiff > 0;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: isWinner
|
||||
? Colors.green.withValues(alpha: 0.1)
|
||||
: Colors.red.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isWinner
|
||||
? Colors.green.withValues(alpha: 0.3)
|
||||
: Colors.red.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 이름
|
||||
Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
color: RetroColors.textPrimaryOf(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// 장비 변경
|
||||
Row(
|
||||
children: [
|
||||
// 이전 장비
|
||||
Expanded(
|
||||
child: _buildItemChip(
|
||||
context,
|
||||
oldItem,
|
||||
oldScore,
|
||||
isOld: true,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Icon(
|
||||
Icons.arrow_forward,
|
||||
size: 14,
|
||||
color: RetroColors.textMutedOf(context),
|
||||
),
|
||||
),
|
||||
// 새 장비
|
||||
Expanded(
|
||||
child: _buildItemChip(
|
||||
context,
|
||||
newItem,
|
||||
newScore,
|
||||
isOld: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// 점수 변화
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
'${isGain ? '+' : ''}$scoreDiff pt',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 7,
|
||||
color: isGain ? Colors.green : Colors.red,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildItemChip(
|
||||
BuildContext context,
|
||||
EquipmentItem? item,
|
||||
int score, {
|
||||
required bool isOld,
|
||||
}) {
|
||||
if (item == null || item.isEmpty) {
|
||||
return Text(
|
||||
'(empty)',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 6,
|
||||
color: RetroColors.textMutedOf(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final rarityColor = _getRarityColor(item.rarity);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: rarityColor.withValues(alpha: isOld ? 0.1 : 0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: rarityColor.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.name,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 5,
|
||||
color: rarityColor,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
'$score pt',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 5,
|
||||
color: RetroColors.textMutedOf(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
EquipmentItem? _findItem(List<EquipmentItem>? equipment, EquipmentSlot slot) {
|
||||
if (equipment == null) return null;
|
||||
for (final item in equipment) {
|
||||
if (item.slot == slot) return item;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _getSlotLabel(EquipmentSlot slot) {
|
||||
return switch (slot) {
|
||||
EquipmentSlot.weapon => l10n.slotWeapon,
|
||||
EquipmentSlot.shield => l10n.slotShield,
|
||||
EquipmentSlot.helm => l10n.slotHelm,
|
||||
EquipmentSlot.hauberk => l10n.slotHauberk,
|
||||
EquipmentSlot.brassairts => l10n.slotBrassairts,
|
||||
EquipmentSlot.vambraces => l10n.slotVambraces,
|
||||
EquipmentSlot.gauntlets => l10n.slotGauntlets,
|
||||
EquipmentSlot.gambeson => l10n.slotGambeson,
|
||||
EquipmentSlot.cuisses => l10n.slotCuisses,
|
||||
EquipmentSlot.greaves => l10n.slotGreaves,
|
||||
EquipmentSlot.sollerets => l10n.slotSollerets,
|
||||
};
|
||||
}
|
||||
|
||||
Color _getRarityColor(ItemRarity rarity) {
|
||||
return switch (rarity) {
|
||||
ItemRarity.common => Colors.grey.shade600,
|
||||
ItemRarity.uncommon => Colors.green.shade600,
|
||||
ItemRarity.rare => Colors.blue.shade600,
|
||||
ItemRarity.epic => Colors.purple.shade600,
|
||||
ItemRarity.legendary => Colors.orange.shade700,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 아레나 결과 다이얼로그 표시
|
||||
Future<void> showArenaResultDialog(
|
||||
BuildContext context, {
|
||||
required ArenaMatchResult result,
|
||||
required VoidCallback onClose,
|
||||
}) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => ArenaResultDialog(
|
||||
result: result,
|
||||
onClose: () {
|
||||
Navigator.of(context).pop();
|
||||
onClose();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -16,8 +16,10 @@ class FrontScreen extends StatelessWidget {
|
||||
this.onNewCharacter,
|
||||
this.onLoadSave,
|
||||
this.onHallOfFame,
|
||||
this.onLocalArena,
|
||||
this.hasSaveFile = false,
|
||||
this.savedGamePreview,
|
||||
this.hallOfFameCount = 0,
|
||||
});
|
||||
|
||||
/// "New character" 버튼 클릭 시 호출
|
||||
@@ -29,12 +31,18 @@ class FrontScreen extends StatelessWidget {
|
||||
/// "Hall of Fame" 버튼 클릭 시 호출
|
||||
final void Function(BuildContext context)? onHallOfFame;
|
||||
|
||||
/// "Local Arena" 버튼 클릭 시 호출
|
||||
final void Function(BuildContext context)? onLocalArena;
|
||||
|
||||
/// 세이브 파일 존재 여부 (새 캐릭터 시 경고용)
|
||||
final bool hasSaveFile;
|
||||
|
||||
/// 저장된 게임 미리보기 정보
|
||||
final SavedGamePreview? savedGamePreview;
|
||||
|
||||
/// 명예의 전당 캐릭터 수 (아레나 활성화 조건: 2명 이상)
|
||||
final int hallOfFameCount;
|
||||
|
||||
/// 새 캐릭터 생성 시 세이브 파일 존재하면 경고 표시
|
||||
void _handleNewCharacter(BuildContext context) {
|
||||
if (hasSaveFile) {
|
||||
@@ -99,7 +107,12 @@ class FrontScreen extends StatelessWidget {
|
||||
onHallOfFame: onHallOfFame != null
|
||||
? () => onHallOfFame!(context)
|
||||
: null,
|
||||
onLocalArena: onLocalArena != null &&
|
||||
hallOfFameCount >= 2
|
||||
? () => onLocalArena!(context)
|
||||
: null,
|
||||
savedGamePreview: savedGamePreview,
|
||||
hallOfFameCount: hallOfFameCount,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -199,13 +212,17 @@ class _ActionButtons extends StatelessWidget {
|
||||
this.onNewCharacter,
|
||||
this.onLoadSave,
|
||||
this.onHallOfFame,
|
||||
this.onLocalArena,
|
||||
this.savedGamePreview,
|
||||
this.hallOfFameCount = 0,
|
||||
});
|
||||
|
||||
final VoidCallback? onNewCharacter;
|
||||
final VoidCallback? onLoadSave;
|
||||
final VoidCallback? onHallOfFame;
|
||||
final VoidCallback? onLocalArena;
|
||||
final SavedGamePreview? savedGamePreview;
|
||||
final int hallOfFameCount;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -245,6 +262,16 @@ class _ActionButtons extends StatelessWidget {
|
||||
onPressed: onHallOfFame,
|
||||
isPrimary: false,
|
||||
),
|
||||
// 로컬 아레나 (2명 이상일 때만 활성화)
|
||||
if (hallOfFameCount >= 2) ...[
|
||||
const SizedBox(height: 12),
|
||||
RetroTextButton(
|
||||
text: game_l10n.uiLocalArena,
|
||||
icon: Icons.sports_kabaddi,
|
||||
onPressed: onLocalArena,
|
||||
isPrimary: false,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -50,6 +50,8 @@ class AsciiAnimationCard extends StatefulWidget {
|
||||
this.latestCombatEvent,
|
||||
this.raceId,
|
||||
this.weaponRarity,
|
||||
this.opponentRaceId,
|
||||
this.opponentHasShield = false,
|
||||
});
|
||||
|
||||
final TaskType taskType;
|
||||
@@ -89,6 +91,12 @@ class AsciiAnimationCard extends StatefulWidget {
|
||||
/// 무기 희귀도 (Phase 9: 무기 등급별 이펙트 색상)
|
||||
final ItemRarity? weaponRarity;
|
||||
|
||||
/// 상대 종족 ID (PvP 모드: 설정 시 몬스터 대신 캐릭터 표시)
|
||||
final String? opponentRaceId;
|
||||
|
||||
/// 상대 방패 장착 여부 (PvP 모드)
|
||||
final bool opponentHasShield;
|
||||
|
||||
@override
|
||||
State<AsciiAnimationCard> createState() => _AsciiAnimationCardState();
|
||||
}
|
||||
@@ -208,7 +216,9 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
oldWidget.shieldName != widget.shieldName ||
|
||||
oldWidget.monsterLevel != widget.monsterLevel ||
|
||||
oldWidget.raceId != widget.raceId ||
|
||||
oldWidget.weaponRarity != widget.weaponRarity) {
|
||||
oldWidget.weaponRarity != widget.weaponRarity ||
|
||||
oldWidget.opponentRaceId != widget.opponentRaceId ||
|
||||
oldWidget.opponentHasShield != widget.opponentHasShield) {
|
||||
_updateAnimation();
|
||||
}
|
||||
}
|
||||
@@ -445,6 +455,8 @@ class _AsciiAnimationCardState extends State<AsciiAnimationCard> {
|
||||
monsterSize: monsterSize,
|
||||
raceId: widget.raceId,
|
||||
weaponRarity: widget.weaponRarity,
|
||||
opponentRaceId: widget.opponentRaceId,
|
||||
opponentHasShield: widget.opponentHasShield,
|
||||
);
|
||||
|
||||
// 환경 타입 추론
|
||||
|
||||
Reference in New Issue
Block a user