- MobileCarouselLayout에 onShowStatistics, onShowHelp 콜백 추가 - 옵션 메뉴에 통계, 도움말 메뉴 항목 추가 - SingleChildScrollView로 메뉴 오버플로우 방지 - isScrollControlled와 maxHeight 제약 조건 적용
1406 lines
46 KiB
Dart
1406 lines
46 KiB
Dart
import 'package:flutter/foundation.dart'
|
|
show kIsWeb, defaultTargetPlatform, TargetPlatform;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/scheduler.dart' show SchedulerBinding, SchedulerPhase;
|
|
|
|
import 'package:askiineverdie/data/game_text_l10n.dart' as game_l10n;
|
|
import 'package:askiineverdie/data/skill_data.dart';
|
|
import 'package:askiineverdie/data/story_data.dart';
|
|
import 'package:askiineverdie/l10n/app_localizations.dart';
|
|
import 'package:askiineverdie/src/core/animation/ascii_animation_type.dart';
|
|
import 'package:askiineverdie/src/core/engine/story_service.dart';
|
|
import 'package:askiineverdie/src/core/model/combat_event.dart';
|
|
import 'package:askiineverdie/src/core/l10n/game_data_l10n.dart';
|
|
import 'package:askiineverdie/src/core/model/combat_stats.dart';
|
|
import 'package:askiineverdie/src/core/model/game_state.dart';
|
|
import 'package:askiineverdie/src/core/model/hall_of_fame.dart';
|
|
import 'package:askiineverdie/src/core/model/skill.dart';
|
|
import 'package:askiineverdie/src/core/notification/notification_service.dart';
|
|
import 'package:askiineverdie/src/core/storage/hall_of_fame_storage.dart';
|
|
import 'package:askiineverdie/src/core/util/pq_logic.dart' as pq_logic;
|
|
import 'package:askiineverdie/src/features/game/game_session_controller.dart';
|
|
import 'package:askiineverdie/src/features/hall_of_fame/hall_of_fame_screen.dart';
|
|
import 'package:askiineverdie/src/features/game/widgets/cinematic_view.dart';
|
|
import 'package:askiineverdie/src/features/game/widgets/combat_log.dart';
|
|
import 'package:askiineverdie/src/features/game/widgets/death_overlay.dart';
|
|
import 'package:askiineverdie/src/features/game/widgets/hp_mp_bar.dart';
|
|
import 'package:askiineverdie/src/features/game/widgets/notification_overlay.dart';
|
|
import 'package:askiineverdie/src/features/game/widgets/stats_panel.dart';
|
|
import 'package:askiineverdie/src/features/game/widgets/equipment_stats_panel.dart';
|
|
import 'package:askiineverdie/src/features/game/widgets/potion_inventory_panel.dart';
|
|
import 'package:askiineverdie/src/features/game/widgets/task_progress_panel.dart';
|
|
import 'package:askiineverdie/src/features/game/widgets/active_buff_panel.dart';
|
|
import 'package:askiineverdie/src/features/game/layouts/mobile_carousel_layout.dart';
|
|
import 'package:askiineverdie/src/features/settings/settings_screen.dart';
|
|
import 'package:askiineverdie/src/features/game/widgets/statistics_dialog.dart';
|
|
import 'package:askiineverdie/src/features/game/widgets/help_dialog.dart';
|
|
import 'package:askiineverdie/src/core/storage/settings_repository.dart';
|
|
import 'package:askiineverdie/src/core/audio/audio_service.dart';
|
|
|
|
/// 게임 진행 화면 (Main.dfm 기반 3패널 레이아웃)
|
|
///
|
|
/// Phase 7: colorTheme 제거됨, 고정 4색 팔레트 사용
|
|
class GamePlayScreen extends StatefulWidget {
|
|
const GamePlayScreen({
|
|
super.key,
|
|
required this.controller,
|
|
this.audioService,
|
|
this.forceCarouselLayout = false,
|
|
this.forceDesktopLayout = false,
|
|
this.onThemeModeChange,
|
|
this.currentThemeMode = ThemeMode.system,
|
|
});
|
|
|
|
final GameSessionController controller;
|
|
|
|
/// 오디오 서비스 (BGM/SFX 재생)
|
|
final AudioService? audioService;
|
|
|
|
/// 테스트 모드: 웹에서도 모바일 캐로셀 레이아웃 강제 사용
|
|
final bool forceCarouselLayout;
|
|
|
|
/// 테스트 모드: 모바일에서도 데스크톱 3패널 레이아웃 강제 사용
|
|
final bool forceDesktopLayout;
|
|
|
|
/// 테마 모드 변경 콜백
|
|
final void Function(ThemeMode mode)? onThemeModeChange;
|
|
|
|
/// 현재 테마 모드
|
|
final ThemeMode currentThemeMode;
|
|
|
|
@override
|
|
State<GamePlayScreen> createState() => _GamePlayScreenState();
|
|
}
|
|
|
|
class _GamePlayScreenState extends State<GamePlayScreen>
|
|
with WidgetsBindingObserver {
|
|
AsciiAnimationType? _specialAnimation;
|
|
|
|
// Phase 8: 알림 서비스 (Notification Service)
|
|
late final NotificationService _notificationService;
|
|
|
|
// Phase 9: 스토리 서비스 (Story Service)
|
|
late final StoryService _storyService;
|
|
StoryAct _lastAct = StoryAct.prologue;
|
|
bool _showingCinematic = false;
|
|
|
|
// Phase 8: 전투 로그 (Combat Log)
|
|
final List<CombatLogEntry> _combatLogEntries = [];
|
|
String _lastTaskCaption = '';
|
|
|
|
// 이전 상태 추적 (레벨업/퀘스트/Act 완료 감지용)
|
|
int _lastLevel = 0;
|
|
int _lastQuestCount = 0;
|
|
int _lastPlotStageCount = 0;
|
|
|
|
// 전투 이벤트 추적 (마지막 처리된 이벤트 수)
|
|
int _lastProcessedEventCount = 0;
|
|
|
|
// 오디오 상태 추적
|
|
bool _wasInCombat = false;
|
|
|
|
// 사운드 볼륨 상태 (모바일 설정 UI용)
|
|
double _bgmVolume = 0.7;
|
|
double _sfxVolume = 0.8;
|
|
|
|
void _checkSpecialEvents(GameState state) {
|
|
// Phase 8: 태스크 변경 시 로그 추가
|
|
final currentCaption = state.progress.currentTask.caption;
|
|
if (currentCaption.isNotEmpty && currentCaption != _lastTaskCaption) {
|
|
_addCombatLog(currentCaption, CombatLogType.normal);
|
|
_lastTaskCaption = currentCaption;
|
|
// 새 태스크 시작 시 이벤트 카운터 리셋
|
|
_lastProcessedEventCount = 0;
|
|
}
|
|
|
|
// 전투 이벤트 처리 (Combat Events)
|
|
_processCombatEvents(state);
|
|
|
|
// 오디오: 전투 상태 변경 시 BGM 전환
|
|
_updateBgmForCombatState(state);
|
|
|
|
// 레벨업 감지
|
|
if (state.traits.level > _lastLevel && _lastLevel > 0) {
|
|
_specialAnimation = AsciiAnimationType.levelUp;
|
|
_notificationService.showLevelUp(state.traits.level);
|
|
_addCombatLog(
|
|
'${game_l10n.uiLevelUp} Lv.${state.traits.level}',
|
|
CombatLogType.levelUp,
|
|
);
|
|
// 오디오: 레벨업 SFX
|
|
widget.audioService?.playSfx('level_up');
|
|
_resetSpecialAnimationAfterFrame();
|
|
|
|
// Phase 9: Act 변경 감지 (레벨 기반)
|
|
final newAct = getActForLevel(state.traits.level);
|
|
if (newAct != _lastAct && !_showingCinematic) {
|
|
_lastAct = newAct;
|
|
|
|
// Phase 10: 엔딩 도달 시 클리어 처리 (시네마틱 대신 클리어 다이얼로그)
|
|
// 다음 프레임에서 실행 (리스너 콜백 중 showDialog 문제 방지)
|
|
if (newAct == StoryAct.ending && state.traits.level >= 100) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _handleGameClear(state);
|
|
});
|
|
} else {
|
|
// 일반 Act 전환 시 시네마틱 표시
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) _showCinematicForAct(newAct);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
_lastLevel = state.traits.level;
|
|
|
|
// 퀘스트 완료 감지
|
|
if (state.progress.questCount > _lastQuestCount && _lastQuestCount > 0) {
|
|
_specialAnimation = AsciiAnimationType.questComplete;
|
|
// 완료된 퀘스트 이름 가져오기
|
|
final completedQuest = state.progress.questHistory
|
|
.where((q) => q.isComplete)
|
|
.lastOrNull;
|
|
if (completedQuest != null) {
|
|
_notificationService.showQuestComplete(completedQuest.caption);
|
|
_addCombatLog(
|
|
game_l10n.uiQuestComplete(completedQuest.caption),
|
|
CombatLogType.questComplete,
|
|
);
|
|
}
|
|
// 오디오: 퀘스트 완료 SFX
|
|
widget.audioService?.playSfx('quest_complete');
|
|
_resetSpecialAnimationAfterFrame();
|
|
}
|
|
_lastQuestCount = state.progress.questCount;
|
|
|
|
// Act 완료 감지 (plotStageCount 증가)
|
|
// plotStageCount: 1=프롤로그 진행, 2=프롤로그 완료, 3=Act1 완료...
|
|
// 완료된 스테이지 인덱스 = plotStageCount - 2 (0=프롤로그, 1=Act1, ...)
|
|
if (state.progress.plotStageCount > _lastPlotStageCount &&
|
|
_lastPlotStageCount > 0) {
|
|
_specialAnimation = AsciiAnimationType.actComplete;
|
|
_notificationService.showActComplete(state.progress.plotStageCount - 2);
|
|
_resetSpecialAnimationAfterFrame();
|
|
}
|
|
_lastPlotStageCount = state.progress.plotStageCount;
|
|
}
|
|
|
|
/// Phase 8: 전투 로그 추가 (Add Combat Log Entry)
|
|
void _addCombatLog(String message, CombatLogType type) {
|
|
_combatLogEntries.add(
|
|
CombatLogEntry(message: message, timestamp: DateTime.now(), type: type),
|
|
);
|
|
// 최대 50개 유지
|
|
if (_combatLogEntries.length > 50) {
|
|
_combatLogEntries.removeAt(0);
|
|
}
|
|
}
|
|
|
|
/// 전투 이벤트를 로그로 변환 (Convert Combat Events to Log)
|
|
void _processCombatEvents(GameState state) {
|
|
final combat = state.progress.currentCombat;
|
|
if (combat == null || !combat.isActive) {
|
|
_lastProcessedEventCount = 0;
|
|
return;
|
|
}
|
|
|
|
final events = combat.recentEvents;
|
|
if (events.isEmpty || events.length <= _lastProcessedEventCount) {
|
|
return;
|
|
}
|
|
|
|
// 새 이벤트만 처리
|
|
final newEvents = events.skip(_lastProcessedEventCount);
|
|
for (final event in newEvents) {
|
|
final (message, type) = _formatCombatEvent(event);
|
|
_addCombatLog(message, type);
|
|
|
|
// 오디오: 전투 이벤트에 따른 SFX 재생
|
|
_playCombatEventSfx(event);
|
|
}
|
|
|
|
_lastProcessedEventCount = events.length;
|
|
}
|
|
|
|
/// 전투 상태에 따른 BGM 전환
|
|
void _updateBgmForCombatState(GameState state) {
|
|
final audio = widget.audioService;
|
|
if (audio == null) return;
|
|
|
|
final combat = state.progress.currentCombat;
|
|
final isInCombat = combat != null && combat.isActive;
|
|
|
|
if (isInCombat && !_wasInCombat) {
|
|
// 전투 시작: 보스 여부에 따라 BGM 선택
|
|
// 몬스터 레벨이 플레이어보다 5 이상 높으면 보스로 간주
|
|
final monsterLevel = state.progress.currentTask.monsterLevel ?? 0;
|
|
final playerLevel = state.traits.level;
|
|
final isBoss = monsterLevel >= playerLevel + 5;
|
|
|
|
if (isBoss) {
|
|
audio.playBgm('boss');
|
|
} else {
|
|
audio.playBgm('battle');
|
|
}
|
|
} else if (!isInCombat && _wasInCombat) {
|
|
// 전투 종료: 마을 BGM으로 복귀
|
|
audio.playBgm('town');
|
|
}
|
|
|
|
_wasInCombat = isInCombat;
|
|
}
|
|
|
|
/// 전투 이벤트에 따른 SFX 재생
|
|
void _playCombatEventSfx(CombatEvent event) {
|
|
final audio = widget.audioService;
|
|
if (audio == null) return;
|
|
|
|
switch (event.type) {
|
|
case CombatEventType.playerAttack:
|
|
audio.playSfx('attack');
|
|
case CombatEventType.monsterAttack:
|
|
audio.playSfx('hit');
|
|
case CombatEventType.playerSkill:
|
|
audio.playSfx('skill');
|
|
case CombatEventType.playerHeal:
|
|
case CombatEventType.playerPotion:
|
|
audio.playSfx('item');
|
|
case CombatEventType.potionDrop:
|
|
audio.playSfx('item');
|
|
case CombatEventType.playerBuff:
|
|
case CombatEventType.playerDebuff:
|
|
audio.playSfx('skill');
|
|
case CombatEventType.dotTick:
|
|
// DOT 틱은 SFX 없음 (너무 자주 발생)
|
|
break;
|
|
case CombatEventType.playerEvade:
|
|
case CombatEventType.monsterEvade:
|
|
case CombatEventType.playerBlock:
|
|
case CombatEventType.playerParry:
|
|
// 회피/방어는 별도 SFX 없음
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// 전투 이벤트를 메시지와 타입으로 변환
|
|
(String, CombatLogType) _formatCombatEvent(CombatEvent event) {
|
|
final target = event.targetName ?? '';
|
|
// 스킬/포션 이름 번역 (전역 로케일 사용)
|
|
final skillName = event.skillName != null
|
|
? game_l10n.translateSpell(event.skillName!)
|
|
: '';
|
|
return switch (event.type) {
|
|
CombatEventType.playerAttack =>
|
|
event.isCritical
|
|
? (
|
|
game_l10n.combatCritical(event.damage, target),
|
|
CombatLogType.critical,
|
|
)
|
|
: (
|
|
game_l10n.combatYouHit(target, event.damage),
|
|
CombatLogType.damage,
|
|
),
|
|
CombatEventType.monsterAttack => (
|
|
game_l10n.combatMonsterHitsYou(target, event.damage),
|
|
CombatLogType.monsterAttack,
|
|
),
|
|
CombatEventType.playerEvade => (
|
|
game_l10n.combatYouEvaded(target),
|
|
CombatLogType.evade,
|
|
),
|
|
CombatEventType.monsterEvade => (
|
|
game_l10n.combatMonsterEvaded(target),
|
|
CombatLogType.evade,
|
|
),
|
|
CombatEventType.playerBlock => (
|
|
game_l10n.combatBlocked(event.damage),
|
|
CombatLogType.block,
|
|
),
|
|
CombatEventType.playerParry => (
|
|
game_l10n.combatParried(event.damage),
|
|
CombatLogType.parry,
|
|
),
|
|
CombatEventType.playerSkill =>
|
|
event.isCritical
|
|
? (
|
|
game_l10n.combatSkillCritical(skillName, event.damage),
|
|
CombatLogType.critical,
|
|
)
|
|
: (
|
|
game_l10n.combatSkillDamage(skillName, event.damage),
|
|
CombatLogType.spell,
|
|
),
|
|
CombatEventType.playerHeal => (
|
|
game_l10n.combatSkillHeal(
|
|
skillName.isNotEmpty ? skillName : game_l10n.uiHeal,
|
|
event.healAmount,
|
|
),
|
|
CombatLogType.heal,
|
|
),
|
|
CombatEventType.playerBuff => (
|
|
game_l10n.combatBuffActivated(skillName),
|
|
CombatLogType.buff,
|
|
),
|
|
CombatEventType.playerDebuff => (
|
|
game_l10n.combatDebuffApplied(skillName, target),
|
|
CombatLogType.debuff,
|
|
),
|
|
CombatEventType.dotTick => (
|
|
game_l10n.combatDotTick(skillName, event.damage),
|
|
CombatLogType.dotTick,
|
|
),
|
|
CombatEventType.playerPotion => (
|
|
game_l10n.combatPotionUsed(skillName, event.healAmount, target),
|
|
CombatLogType.potion,
|
|
),
|
|
CombatEventType.potionDrop => (
|
|
game_l10n.combatPotionDrop(skillName),
|
|
CombatLogType.potionDrop,
|
|
),
|
|
};
|
|
}
|
|
|
|
/// Phase 9: Act 시네마틱 표시 (Show Act Cinematic)
|
|
Future<void> _showCinematicForAct(StoryAct act) async {
|
|
if (_showingCinematic) return;
|
|
|
|
_showingCinematic = true;
|
|
// 게임 일시 정지
|
|
await widget.controller.pause(saveOnStop: false);
|
|
|
|
if (mounted) {
|
|
await showActCinematic(context, act);
|
|
}
|
|
|
|
// 게임 재개
|
|
if (mounted) {
|
|
await widget.controller.resume();
|
|
}
|
|
_showingCinematic = false;
|
|
}
|
|
|
|
/// Phase 10: 게임 클리어 처리 (Handle Game Clear)
|
|
Future<void> _handleGameClear(GameState state) async {
|
|
// 게임 일시 정지
|
|
await widget.controller.pause(saveOnStop: true);
|
|
|
|
// 최종 전투 스탯 계산
|
|
final combatStats = CombatStats.fromStats(
|
|
stats: state.stats,
|
|
equipment: state.equipment,
|
|
level: state.traits.level,
|
|
);
|
|
|
|
// 명예의 전당 엔트리 생성
|
|
final entry = HallOfFameEntry.fromGameState(
|
|
state: state,
|
|
totalDeaths: state.progress.deathCount,
|
|
monstersKilled: state.progress.monstersKilled,
|
|
combatStats: combatStats,
|
|
);
|
|
|
|
// 명예의 전당에 저장
|
|
final storage = HallOfFameStorage();
|
|
await storage.addEntry(entry);
|
|
|
|
// 클리어 다이얼로그 표시
|
|
if (mounted) {
|
|
await showGameClearDialog(
|
|
context,
|
|
entry: entry,
|
|
onNewGame: () {
|
|
// 프론트 화면으로 돌아가기
|
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
|
},
|
|
onViewHallOfFame: () {
|
|
// 명예의 전당 화면으로 이동
|
|
Navigator.of(context).pushReplacement(
|
|
MaterialPageRoute<void>(
|
|
builder: (context) => const HallOfFameScreen(),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
void _resetSpecialAnimationAfterFrame() {
|
|
// 다음 프레임에서 리셋 (AsciiAnimationCard가 값을 받은 후)
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_specialAnimation = null;
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_notificationService = NotificationService();
|
|
_storyService = StoryService();
|
|
widget.controller.addListener(_onControllerChanged);
|
|
WidgetsBinding.instance.addObserver(this);
|
|
|
|
// 초기 상태 설정
|
|
final state = widget.controller.state;
|
|
if (state != null) {
|
|
_lastLevel = state.traits.level;
|
|
_lastQuestCount = state.progress.questCount;
|
|
_lastPlotStageCount = state.progress.plotStageCount;
|
|
_lastAct = getActForLevel(state.traits.level);
|
|
|
|
// 초기 전투 상태 확인
|
|
final combat = state.progress.currentCombat;
|
|
_wasInCombat = combat != null && combat.isActive;
|
|
}
|
|
|
|
// 누적 통계 로드
|
|
widget.controller.loadCumulativeStats();
|
|
|
|
// 초기 BGM 재생 (마을 테마)
|
|
widget.audioService?.playBgm('town');
|
|
|
|
// 오디오 볼륨 초기화
|
|
_initAudioVolumes();
|
|
}
|
|
|
|
/// 오디오 볼륨 초기화 (설정에서 로드)
|
|
Future<void> _initAudioVolumes() async {
|
|
final audio = widget.audioService;
|
|
if (audio != null) {
|
|
_bgmVolume = audio.bgmVolume;
|
|
_sfxVolume = audio.sfxVolume;
|
|
if (mounted) setState(() {});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_notificationService.dispose();
|
|
_storyService.dispose();
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
widget.controller.removeListener(_onControllerChanged);
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
super.didChangeAppLifecycleState(state);
|
|
|
|
// 앱이 백그라운드로 가거나 비활성화될 때 자동 저장
|
|
if (state == AppLifecycleState.paused ||
|
|
state == AppLifecycleState.inactive ||
|
|
state == AppLifecycleState.detached) {
|
|
_saveGameState();
|
|
}
|
|
}
|
|
|
|
Future<void> _saveGameState() async {
|
|
final currentState = widget.controller.state;
|
|
if (currentState == null || !widget.controller.isRunning) return;
|
|
|
|
await widget.controller.saveManager.saveState(currentState);
|
|
}
|
|
|
|
/// 뒤로가기 시 저장 확인 다이얼로그
|
|
Future<bool> _onPopInvoked() async {
|
|
final l10n = L10n.of(context);
|
|
final shouldPop = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(l10n.exitGame),
|
|
content: Text(l10n.saveProgressQuestion),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: Text(l10n.cancel),
|
|
),
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.of(context).pop(true);
|
|
},
|
|
child: Text(l10n.exitWithoutSaving),
|
|
),
|
|
FilledButton(
|
|
onPressed: () async {
|
|
await _saveGameState();
|
|
if (context.mounted) {
|
|
Navigator.of(context).pop(true);
|
|
}
|
|
},
|
|
child: Text(l10n.saveAndExit),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
return shouldPop ?? false;
|
|
}
|
|
|
|
void _onControllerChanged() {
|
|
final state = widget.controller.state;
|
|
if (state != null) {
|
|
_checkSpecialEvents(state);
|
|
}
|
|
|
|
// WASM 안정성: 프레임 빌드 중이면 다음 프레임까지 대기
|
|
if (SchedulerBinding.instance.schedulerPhase ==
|
|
SchedulerPhase.persistentCallbacks) {
|
|
SchedulerBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
setState(() {});
|
|
}
|
|
});
|
|
} else {
|
|
setState(() {});
|
|
}
|
|
}
|
|
|
|
/// 캐로셀 레이아웃 사용 여부 판단
|
|
///
|
|
/// - forceDesktopLayout (테스트 모드) 활성화 시 데스크톱 레이아웃 사용
|
|
/// - forceCarouselLayout (테스트 모드) 활성화 시 캐로셀 레이아웃 사용
|
|
/// - 실제 모바일 플랫폼 (iOS/Android) 시 캐로셀 사용
|
|
bool _shouldUseCarouselLayout(BuildContext context) {
|
|
// 테스트 모드: 데스크톱 레이아웃 강제
|
|
if (widget.forceDesktopLayout) return false;
|
|
|
|
// 테스트 모드: 캐로셀 레이아웃 강제
|
|
if (widget.forceCarouselLayout) return true;
|
|
|
|
// 웹에서는 3패널 레이아웃 사용 (테스트 모드가 아닌 경우)
|
|
if (kIsWeb) return false;
|
|
|
|
// 모바일 플랫폼(iOS/Android)에서는 캐로셀 사용
|
|
final platform = defaultTargetPlatform;
|
|
return platform == TargetPlatform.iOS || platform == TargetPlatform.android;
|
|
}
|
|
|
|
/// 통계 다이얼로그 표시
|
|
void _showStatisticsDialog(BuildContext context) {
|
|
StatisticsDialog.show(
|
|
context,
|
|
session: widget.controller.sessionStats,
|
|
cumulative: widget.controller.cumulativeStats,
|
|
);
|
|
}
|
|
|
|
/// 설정 화면 표시
|
|
void _showSettingsScreen(BuildContext context) {
|
|
final settingsRepo = SettingsRepository();
|
|
SettingsScreen.show(
|
|
context,
|
|
settingsRepository: settingsRepo,
|
|
currentThemeMode: widget.currentThemeMode,
|
|
onThemeModeChange: (mode) {
|
|
widget.onThemeModeChange?.call(mode);
|
|
},
|
|
onLocaleChange: (locale) async {
|
|
// 안전한 언어 변경: 전체 화면 재생성
|
|
final navigator = Navigator.of(this.context);
|
|
await widget.controller.pause(saveOnStop: true);
|
|
game_l10n.setGameLocale(locale);
|
|
if (mounted) {
|
|
await widget.controller.resume();
|
|
navigator.pushReplacement(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => GamePlayScreen(
|
|
controller: widget.controller,
|
|
currentThemeMode: widget.currentThemeMode,
|
|
onThemeModeChange: widget.onThemeModeChange,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
/// 부활 처리 핸들러
|
|
Future<void> _handleResurrect() async {
|
|
// 1. 부활 애니메이션 먼저 설정 (DeathOverlay 사라지기 전에)
|
|
setState(() {
|
|
_specialAnimation = AsciiAnimationType.resurrection;
|
|
});
|
|
|
|
// 2. 부활 처리 (HP/MP 회복, 장비 구매 - 게임 재개 없음)
|
|
// 이 시점에서 isDead가 false가 되고 DeathOverlay가 사라지지만,
|
|
// _specialAnimation이 이미 설정되어 있어 부활 애니메이션이 표시됨
|
|
await widget.controller.resurrect();
|
|
|
|
// 3. 애니메이션 종료 후 게임 재개
|
|
final duration = getSpecialAnimationDuration(
|
|
AsciiAnimationType.resurrection,
|
|
);
|
|
Future.delayed(Duration(milliseconds: duration), () async {
|
|
if (mounted) {
|
|
// 먼저 게임 재개 (status를 running으로 변경)
|
|
// 이렇게 해야 setState 시 UI가 '일시정지' 상태로 보이지 않음
|
|
await widget.controller.resumeAfterResurrection();
|
|
if (mounted) {
|
|
setState(() {
|
|
_specialAnimation = null;
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final state = widget.controller.state;
|
|
if (state == null) {
|
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
|
}
|
|
|
|
// 로케일 변경 시 전체 위젯 트리 강제 리빌드를 위한 Key
|
|
final localeKey = ValueKey(game_l10n.currentGameLocale);
|
|
|
|
// 캐로셀 레이아웃 사용 여부 확인
|
|
if (_shouldUseCarouselLayout(context)) {
|
|
return NotificationOverlay(
|
|
key: localeKey,
|
|
notificationService: _notificationService,
|
|
child: PopScope(
|
|
canPop: false,
|
|
onPopInvokedWithResult: (didPop, result) async {
|
|
if (didPop) return;
|
|
final shouldPop = await _onPopInvoked();
|
|
if (shouldPop && context.mounted) {
|
|
await widget.controller.pause(saveOnStop: false);
|
|
if (context.mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
}
|
|
},
|
|
child: Stack(
|
|
children: [
|
|
MobileCarouselLayout(
|
|
state: state,
|
|
combatLogEntries: _combatLogEntries,
|
|
speedMultiplier: widget.controller.loop?.speedMultiplier ?? 1,
|
|
onSpeedCycle: () {
|
|
widget.controller.loop?.cycleSpeed();
|
|
setState(() {});
|
|
},
|
|
// 특수 애니메이션 중에는 일시정지 상태로 표시하지 않음
|
|
isPaused: !widget.controller.isRunning && _specialAnimation == null,
|
|
onPauseToggle: () async {
|
|
await widget.controller.togglePause();
|
|
setState(() {});
|
|
},
|
|
onSave: _saveGameState,
|
|
onExit: () async {
|
|
final shouldExit = await _onPopInvoked();
|
|
if (shouldExit && context.mounted) {
|
|
await widget.controller.pause(saveOnStop: false);
|
|
if (context.mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
}
|
|
},
|
|
notificationService: _notificationService,
|
|
specialAnimation: _specialAnimation,
|
|
onLanguageChange: (locale) async {
|
|
// navigator 참조를 async gap 전에 저장
|
|
final navigator = Navigator.of(context);
|
|
// 1. 현재 상태 저장
|
|
await widget.controller.pause(saveOnStop: true);
|
|
// 2. 로케일 변경
|
|
game_l10n.setGameLocale(locale);
|
|
// 3. 화면 재생성 (전체 UI 재구성)
|
|
if (context.mounted) {
|
|
await widget.controller.resume();
|
|
navigator.pushReplacement(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => GamePlayScreen(
|
|
controller: widget.controller,
|
|
currentThemeMode: widget.currentThemeMode,
|
|
onThemeModeChange: widget.onThemeModeChange,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
onDeleteSaveAndNewGame: () async {
|
|
// 게임 루프 중지
|
|
await widget.controller.pause(saveOnStop: false);
|
|
// 세이브 파일 삭제
|
|
await widget.controller.saveManager.deleteSave();
|
|
// 캐릭터 생성 화면으로 돌아가기
|
|
if (context.mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
},
|
|
currentThemeMode: widget.currentThemeMode,
|
|
onThemeModeChange: widget.onThemeModeChange,
|
|
// 사운드 설정
|
|
bgmVolume: _bgmVolume,
|
|
sfxVolume: _sfxVolume,
|
|
onBgmVolumeChange: (volume) {
|
|
setState(() => _bgmVolume = volume);
|
|
widget.audioService?.setBgmVolume(volume);
|
|
},
|
|
onSfxVolumeChange: (volume) {
|
|
setState(() => _sfxVolume = volume);
|
|
widget.audioService?.setSfxVolume(volume);
|
|
},
|
|
// 통계 및 도움말
|
|
onShowStatistics: () => _showStatisticsDialog(context),
|
|
onShowHelp: () => HelpDialog.show(context),
|
|
),
|
|
// 사망 오버레이
|
|
if (state.isDead && state.deathInfo != null)
|
|
DeathOverlay(
|
|
deathInfo: state.deathInfo!,
|
|
traits: state.traits,
|
|
onResurrect: _handleResurrect,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// 기존 데스크톱 레이아웃
|
|
return NotificationOverlay(
|
|
key: localeKey,
|
|
notificationService: _notificationService,
|
|
child: PopScope(
|
|
canPop: false,
|
|
onPopInvokedWithResult: (didPop, result) async {
|
|
if (didPop) return;
|
|
final shouldPop = await _onPopInvoked();
|
|
if (shouldPop && context.mounted) {
|
|
await widget.controller.pause(saveOnStop: false);
|
|
if (context.mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
}
|
|
},
|
|
child: Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(L10n.of(context).progressQuestTitle(state.traits.name)),
|
|
actions: [
|
|
// 치트 버튼 (디버그용)
|
|
if (widget.controller.cheatsEnabled) ...[
|
|
IconButton(
|
|
icon: const Text('L+1'),
|
|
tooltip: L10n.of(context).levelUp,
|
|
onPressed: () => widget.controller.loop?.cheatCompleteTask(),
|
|
),
|
|
IconButton(
|
|
icon: const Text('Q!'),
|
|
tooltip: L10n.of(context).completeQuest,
|
|
onPressed: () => widget.controller.loop?.cheatCompleteQuest(),
|
|
),
|
|
IconButton(
|
|
icon: const Text('P!'),
|
|
tooltip: L10n.of(context).completePlot,
|
|
onPressed: () => widget.controller.loop?.cheatCompletePlot(),
|
|
),
|
|
],
|
|
// 통계 버튼
|
|
IconButton(
|
|
icon: const Icon(Icons.bar_chart),
|
|
tooltip: game_l10n.uiStatistics,
|
|
onPressed: () => _showStatisticsDialog(context),
|
|
),
|
|
// 도움말 버튼
|
|
IconButton(
|
|
icon: const Icon(Icons.help_outline),
|
|
tooltip: game_l10n.uiHelp,
|
|
onPressed: () => HelpDialog.show(context),
|
|
),
|
|
// 설정 버튼
|
|
IconButton(
|
|
icon: const Icon(Icons.settings),
|
|
tooltip: game_l10n.uiSettings,
|
|
onPressed: () => _showSettingsScreen(context),
|
|
),
|
|
],
|
|
),
|
|
body: Stack(
|
|
children: [
|
|
// 메인 게임 UI
|
|
Column(
|
|
children: [
|
|
// 상단: ASCII 애니메이션 + Task Progress (Phase 7: 고정 4색 팔레트)
|
|
TaskProgressPanel(
|
|
progress: state.progress,
|
|
speedMultiplier:
|
|
widget.controller.loop?.speedMultiplier ?? 1,
|
|
onSpeedCycle: () {
|
|
widget.controller.loop?.cycleSpeed();
|
|
setState(() {});
|
|
},
|
|
// 특수 애니메이션 중에는 일시정지 상태로 표시하지 않음
|
|
isPaused: !widget.controller.isRunning && _specialAnimation == null,
|
|
onPauseToggle: () async {
|
|
await widget.controller.togglePause();
|
|
setState(() {});
|
|
},
|
|
specialAnimation: _specialAnimation,
|
|
weaponName: state.equipment.weapon,
|
|
shieldName: state.equipment.shield,
|
|
characterLevel: state.traits.level,
|
|
monsterLevel: state.progress.currentTask.monsterLevel,
|
|
latestCombatEvent:
|
|
state.progress.currentCombat?.recentEvents.lastOrNull,
|
|
raceId: state.traits.raceId,
|
|
),
|
|
|
|
// 메인 3패널 영역
|
|
Expanded(
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// 좌측 패널: Character Sheet
|
|
Expanded(flex: 2, child: _buildCharacterPanel(state)),
|
|
|
|
// 중앙 패널: Equipment/Inventory
|
|
Expanded(flex: 3, child: _buildEquipmentPanel(state)),
|
|
|
|
// 우측 패널: Plot/Quest
|
|
Expanded(flex: 2, child: _buildQuestPanel(state)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
// Phase 4: 사망 오버레이 (Death Overlay)
|
|
if (state.isDead && state.deathInfo != null)
|
|
DeathOverlay(
|
|
deathInfo: state.deathInfo!,
|
|
traits: state.traits,
|
|
onResurrect: _handleResurrect,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 좌측 패널: Character Sheet (Traits, Stats, Experience, Spells)
|
|
Widget _buildCharacterPanel(GameState state) {
|
|
final l10n = L10n.of(context);
|
|
return Card(
|
|
margin: const EdgeInsets.all(4),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_buildPanelHeader(l10n.characterSheet),
|
|
|
|
// Traits 목록
|
|
_buildSectionHeader(l10n.traits),
|
|
_buildTraitsList(state),
|
|
|
|
// Stats 목록 (Phase 8: 애니메이션 변화 표시)
|
|
_buildSectionHeader(l10n.stats),
|
|
Expanded(flex: 2, child: StatsPanel(stats: state.stats)),
|
|
|
|
// Phase 8: HP/MP 바 (사망 위험 시 깜빡임, 전투 중 몬스터 HP 표시)
|
|
// 전투 중에는 전투 스탯의 HP/MP 사용, 비전투 시 기본 스탯 사용
|
|
HpMpBar(
|
|
hpCurrent:
|
|
state.progress.currentCombat?.playerStats.hpCurrent ??
|
|
state.stats.hp,
|
|
hpMax:
|
|
state.progress.currentCombat?.playerStats.hpMax ??
|
|
state.stats.hpMax,
|
|
mpCurrent:
|
|
state.progress.currentCombat?.playerStats.mpCurrent ??
|
|
state.stats.mp,
|
|
mpMax:
|
|
state.progress.currentCombat?.playerStats.mpMax ??
|
|
state.stats.mpMax,
|
|
// 전투 중일 때 몬스터 HP 정보 전달
|
|
monsterHpCurrent:
|
|
state.progress.currentCombat?.monsterStats.hpCurrent,
|
|
monsterHpMax: state.progress.currentCombat?.monsterStats.hpMax,
|
|
monsterName: state.progress.currentCombat?.monsterStats.name,
|
|
),
|
|
|
|
// Experience 바
|
|
_buildSectionHeader(l10n.experience),
|
|
_buildProgressBar(
|
|
state.progress.exp.position,
|
|
state.progress.exp.max,
|
|
Colors.blue,
|
|
tooltip:
|
|
'${state.progress.exp.max - state.progress.exp.position} '
|
|
'${l10n.xpNeededForNextLevel}',
|
|
),
|
|
|
|
// 스킬 (Skills - SpellBook 기반)
|
|
_buildSectionHeader(l10n.spellBook),
|
|
Expanded(flex: 3, child: _buildSkillsList(state)),
|
|
|
|
// 활성 버프 (Active Buffs)
|
|
_buildSectionHeader(game_l10n.uiBuffs),
|
|
Expanded(
|
|
child: ActiveBuffPanel(
|
|
activeBuffs: state.skillSystem.activeBuffs,
|
|
currentMs: state.skillSystem.elapsedMs,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 중앙 패널: Equipment/Inventory
|
|
Widget _buildEquipmentPanel(GameState state) {
|
|
final l10n = L10n.of(context);
|
|
return Card(
|
|
margin: const EdgeInsets.all(4),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_buildPanelHeader(l10n.equipment),
|
|
|
|
// Equipment 목록 (확장 가능 스탯 패널)
|
|
Expanded(
|
|
flex: 2,
|
|
child: EquipmentStatsPanel(equipment: state.equipment),
|
|
),
|
|
|
|
// Inventory
|
|
_buildPanelHeader(l10n.inventory),
|
|
Expanded(child: _buildInventoryList(state)),
|
|
|
|
// Potions (물약 인벤토리)
|
|
_buildSectionHeader(game_l10n.uiPotions),
|
|
Expanded(
|
|
child: PotionInventoryPanel(
|
|
inventory: state.potionInventory,
|
|
usedInBattle:
|
|
state.progress.currentCombat?.usedPotionTypes ?? const {},
|
|
),
|
|
),
|
|
|
|
// Encumbrance 바
|
|
_buildSectionHeader(l10n.encumbrance),
|
|
_buildProgressBar(
|
|
state.progress.encumbrance.position,
|
|
state.progress.encumbrance.max,
|
|
Colors.orange,
|
|
),
|
|
|
|
// Phase 8: 전투 로그 (Combat Log)
|
|
_buildPanelHeader(l10n.combatLog),
|
|
Expanded(flex: 2, child: CombatLog(entries: _combatLogEntries)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 우측 패널: Plot/Quest
|
|
Widget _buildQuestPanel(GameState state) {
|
|
final l10n = L10n.of(context);
|
|
return Card(
|
|
margin: const EdgeInsets.all(4),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_buildPanelHeader(l10n.plotDevelopment),
|
|
|
|
// Plot 목록
|
|
Expanded(child: _buildPlotList(state)),
|
|
|
|
// Plot 바
|
|
_buildProgressBar(
|
|
state.progress.plot.position,
|
|
state.progress.plot.max,
|
|
Colors.purple,
|
|
tooltip: state.progress.plot.max > 0
|
|
? '${pq_logic.roughTime(state.progress.plot.max - state.progress.plot.position)} remaining'
|
|
: null,
|
|
),
|
|
|
|
_buildPanelHeader(l10n.quests),
|
|
|
|
// Quest 목록
|
|
Expanded(child: _buildQuestList(state)),
|
|
|
|
// Quest 바
|
|
_buildProgressBar(
|
|
state.progress.quest.position,
|
|
state.progress.quest.max,
|
|
Colors.green,
|
|
tooltip: state.progress.quest.max > 0
|
|
? l10n.percentComplete(
|
|
100 *
|
|
state.progress.quest.position ~/
|
|
state.progress.quest.max,
|
|
)
|
|
: null,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPanelHeader(String title) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
color: Theme.of(context).colorScheme.primaryContainer,
|
|
child: Text(
|
|
title,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSectionHeader(String title) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|
child: Text(title, style: Theme.of(context).textTheme.labelSmall),
|
|
);
|
|
}
|
|
|
|
Widget _buildProgressBar(
|
|
int position,
|
|
int max,
|
|
Color color, {
|
|
String? tooltip,
|
|
}) {
|
|
final progress = max > 0 ? (position / max).clamp(0.0, 1.0) : 0.0;
|
|
final bar = Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
child: LinearProgressIndicator(
|
|
value: progress,
|
|
backgroundColor: color.withValues(alpha: 0.2),
|
|
valueColor: AlwaysStoppedAnimation<Color>(color),
|
|
minHeight: 12,
|
|
),
|
|
);
|
|
|
|
if (tooltip != null && tooltip.isNotEmpty) {
|
|
return Tooltip(message: tooltip, child: bar);
|
|
}
|
|
return bar;
|
|
}
|
|
|
|
Widget _buildTraitsList(GameState state) {
|
|
final l10n = L10n.of(context);
|
|
final traits = [
|
|
(l10n.traitName, state.traits.name),
|
|
(l10n.traitRace, GameDataL10n.getRaceName(context, state.traits.race)),
|
|
(l10n.traitClass, GameDataL10n.getKlassName(context, state.traits.klass)),
|
|
(l10n.traitLevel, '${state.traits.level}'),
|
|
];
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
child: Column(
|
|
children: traits.map((t) {
|
|
return Row(
|
|
children: [
|
|
SizedBox(
|
|
width: 50,
|
|
child: Text(t.$1, style: const TextStyle(fontSize: 11)),
|
|
),
|
|
Expanded(
|
|
child: Text(
|
|
t.$2,
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}).toList(),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 통합 스킬 목록 (SpellBook 기반)
|
|
///
|
|
/// 스펠 이름, 랭크, 스킬 타입, 쿨타임 표시
|
|
Widget _buildSkillsList(GameState state) {
|
|
if (state.spellBook.spells.isEmpty) {
|
|
return Center(
|
|
child: Text(
|
|
L10n.of(context).noSpellsYet,
|
|
style: const TextStyle(fontSize: 11),
|
|
),
|
|
);
|
|
}
|
|
|
|
return ListView.builder(
|
|
itemCount: state.spellBook.spells.length,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
itemBuilder: (context, index) {
|
|
final spell = state.spellBook.spells[index];
|
|
final skill = SkillData.getSkillBySpellName(spell.name);
|
|
final spellName = GameDataL10n.getSpellName(context, spell.name);
|
|
|
|
// 쿨타임 상태 확인
|
|
final skillState = skill != null
|
|
? state.skillSystem.getSkillState(skill.id)
|
|
: null;
|
|
final isOnCooldown =
|
|
skillState != null &&
|
|
!skillState.isReady(state.skillSystem.elapsedMs, skill!.cooldownMs);
|
|
|
|
return _SkillRow(
|
|
spellName: spellName,
|
|
rank: spell.rank,
|
|
skill: skill,
|
|
isOnCooldown: isOnCooldown,
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildInventoryList(GameState state) {
|
|
final l10n = L10n.of(context);
|
|
if (state.inventory.items.isEmpty) {
|
|
return Center(
|
|
child: Text(
|
|
l10n.goldAmount(state.inventory.gold),
|
|
style: const TextStyle(fontSize: 11),
|
|
),
|
|
);
|
|
}
|
|
|
|
return ListView.builder(
|
|
itemCount: state.inventory.items.length + 1, // +1 for gold
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
itemBuilder: (context, index) {
|
|
if (index == 0) {
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(l10n.gold, style: const TextStyle(fontSize: 11)),
|
|
),
|
|
Text(
|
|
'${state.inventory.gold}',
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
final item = state.inventory.items[index - 1];
|
|
// 아이템 이름 번역
|
|
final translatedName = GameDataL10n.translateItemString(
|
|
context,
|
|
item.name,
|
|
);
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
translatedName,
|
|
style: const TextStyle(fontSize: 11),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
Text(
|
|
'${item.count}',
|
|
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildPlotList(GameState state) {
|
|
// 플롯 단계를 표시 (Act I, Act II, ...)
|
|
final l10n = L10n.of(context);
|
|
final plotCount = state.progress.plotStageCount;
|
|
if (plotCount == 0) {
|
|
return Center(
|
|
child: Text(l10n.prologue, style: const TextStyle(fontSize: 11)),
|
|
);
|
|
}
|
|
|
|
return ListView.builder(
|
|
itemCount: plotCount,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
itemBuilder: (context, index) {
|
|
final isCompleted = index < plotCount - 1;
|
|
return Row(
|
|
children: [
|
|
Icon(
|
|
isCompleted ? Icons.check_box : Icons.check_box_outline_blank,
|
|
size: 14,
|
|
color: isCompleted ? Colors.green : Colors.grey,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
index == 0 ? l10n.prologue : l10n.actNumber(_toRoman(index)),
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
decoration: isCompleted
|
|
? TextDecoration.lineThrough
|
|
: TextDecoration.none,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildQuestList(GameState state) {
|
|
final l10n = L10n.of(context);
|
|
final questHistory = state.progress.questHistory;
|
|
|
|
if (questHistory.isEmpty) {
|
|
return Center(
|
|
child: Text(l10n.noActiveQuests, style: const TextStyle(fontSize: 11)),
|
|
);
|
|
}
|
|
|
|
// 원본처럼 퀘스트 히스토리를 리스트로 표시
|
|
// 완료된 퀘스트는 체크박스, 현재 퀘스트는 화살표
|
|
return ListView.builder(
|
|
itemCount: questHistory.length,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
itemBuilder: (context, index) {
|
|
final quest = questHistory[index];
|
|
final isCurrentQuest =
|
|
index == questHistory.length - 1 && !quest.isComplete;
|
|
|
|
return Row(
|
|
children: [
|
|
if (isCurrentQuest)
|
|
const Icon(Icons.arrow_right, size: 14)
|
|
else
|
|
Icon(
|
|
quest.isComplete
|
|
? Icons.check_box
|
|
: Icons.check_box_outline_blank,
|
|
size: 14,
|
|
color: quest.isComplete ? Colors.green : Colors.grey,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Expanded(
|
|
child: Text(
|
|
quest.caption,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
decoration: quest.isComplete
|
|
? TextDecoration.lineThrough
|
|
: TextDecoration.none,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// 로마 숫자 변환 (간단 버전)
|
|
String _toRoman(int number) {
|
|
const romanNumerals = [
|
|
(1000, 'M'),
|
|
(900, 'CM'),
|
|
(500, 'D'),
|
|
(400, 'CD'),
|
|
(100, 'C'),
|
|
(90, 'XC'),
|
|
(50, 'L'),
|
|
(40, 'XL'),
|
|
(10, 'X'),
|
|
(9, 'IX'),
|
|
(5, 'V'),
|
|
(4, 'IV'),
|
|
(1, 'I'),
|
|
];
|
|
|
|
var result = '';
|
|
var remaining = number;
|
|
for (final (value, numeral) in romanNumerals) {
|
|
while (remaining >= value) {
|
|
result += numeral;
|
|
remaining -= value;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
}
|
|
|
|
/// 스킬 행 위젯
|
|
///
|
|
/// 스펠 이름, 랭크, 스킬 타입 아이콘, 쿨타임 상태 표시
|
|
class _SkillRow extends StatelessWidget {
|
|
const _SkillRow({
|
|
required this.spellName,
|
|
required this.rank,
|
|
required this.skill,
|
|
required this.isOnCooldown,
|
|
});
|
|
|
|
final String spellName;
|
|
final String rank;
|
|
final Skill? skill;
|
|
final bool isOnCooldown;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 1),
|
|
child: Row(
|
|
children: [
|
|
// 스킬 타입 아이콘
|
|
_buildTypeIcon(),
|
|
const SizedBox(width: 4),
|
|
// 스킬 이름
|
|
Expanded(
|
|
child: Text(
|
|
spellName,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: isOnCooldown ? Colors.grey : null,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
// 쿨타임 표시
|
|
if (isOnCooldown)
|
|
const Icon(Icons.hourglass_empty, size: 10, color: Colors.orange),
|
|
const SizedBox(width: 4),
|
|
// 랭크
|
|
Text(
|
|
rank,
|
|
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 스킬 타입별 아이콘
|
|
Widget _buildTypeIcon() {
|
|
if (skill == null) {
|
|
return const SizedBox(width: 12);
|
|
}
|
|
|
|
final (IconData icon, Color color) = switch (skill!.type) {
|
|
SkillType.attack => (Icons.flash_on, Colors.red),
|
|
SkillType.heal => (Icons.favorite, Colors.green),
|
|
SkillType.buff => (Icons.arrow_upward, Colors.blue),
|
|
SkillType.debuff => (Icons.arrow_downward, Colors.purple),
|
|
};
|
|
|
|
return Icon(icon, size: 12, color: color);
|
|
}
|
|
}
|