From abcb89d334f749d0a8f0c656a75abcc571f122b9 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Wed, 17 Dec 2025 18:38:08 +0900 Subject: [PATCH] =?UTF-8?q?feat(story):=20Phase=209=20=EC=8A=A4=ED=86=A0?= =?UTF-8?q?=EB=A6=AC/=EC=97=94=EB=94=A9=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - story_data.dart: 5개 Act 스토리 텍스트 및 ASCII 아트 - story_service.dart: Act 전환/보스 조우/엔딩 이벤트 관리 - cinematic_view.dart: 풀스크린 시네마틱 UI (페이드, 스킵) - game_play_screen.dart: 레벨 기반 Act 전환 시 시네마틱 재생 --- lib/data/story_data.dart | 395 ++++++++++++++++++ lib/src/core/engine/story_service.dart | 186 +++++++++ lib/src/features/game/game_play_screen.dart | 37 ++ .../features/game/widgets/cinematic_view.dart | 284 +++++++++++++ 4 files changed, 902 insertions(+) create mode 100644 lib/data/story_data.dart create mode 100644 lib/src/core/engine/story_service.dart create mode 100644 lib/src/features/game/widgets/cinematic_view.dart diff --git a/lib/data/story_data.dart b/lib/data/story_data.dart new file mode 100644 index 0000000..828c843 --- /dev/null +++ b/lib/data/story_data.dart @@ -0,0 +1,395 @@ +/// Phase 9: 스토리 데이터 (Story Data) +/// +/// 프롤로그부터 엔딩까지 일관된 스토리 텍스트 정의 +library; + +/// 스토리 Act 정의 +enum StoryAct { + prologue, // 프롤로그 + act1, // Act I: 각성 (레벨 1-20) + act2, // Act II: 성장 (레벨 21-40) + act3, // Act III: 시련 (레벨 41-60) + act4, // Act IV: 결전 (레벨 61-80) + act5, // Act V: 종말 (레벨 81-100) + ending, // 엔딩 +} + +/// Act별 레벨 범위 (Level Range) +const Map actLevelRange = { + StoryAct.prologue: (1, 1), + StoryAct.act1: (1, 20), + StoryAct.act2: (21, 40), + StoryAct.act3: (41, 60), + StoryAct.act4: (61, 80), + StoryAct.act5: (81, 100), + StoryAct.ending: (100, 999), +}; + +/// 레벨로 현재 Act 계산 (Calculate Current Act from Level) +StoryAct getActForLevel(int level) { + if (level <= 0) return StoryAct.prologue; + if (level <= 20) return StoryAct.act1; + if (level <= 40) return StoryAct.act2; + if (level <= 60) return StoryAct.act3; + if (level <= 80) return StoryAct.act4; + if (level < 100) return StoryAct.act5; + return StoryAct.ending; +} + +/// 시네마틱 단계 (Cinematic Step) +class CinematicStep { + const CinematicStep({ + required this.text, + this.asciiArt, + this.durationMs = 3000, + }); + + final String text; + final String? asciiArt; + final int durationMs; +} + +/// Act별 시네마틱 데이터 (Cinematic Data per Act) +const Map> cinematicData = { + // 프롤로그: 코드의 신으로부터 비전을 받음 + StoryAct.prologue: [ + CinematicStep( + text: 'In the beginning, there was only the Void...', + asciiArt: _asciiVoid, + durationMs: 4000, + ), + CinematicStep( + text: 'Then came the First Commit, and Light filled the Codebase.', + asciiArt: _asciiLight, + durationMs: 4000, + ), + CinematicStep( + text: 'The Code God spoke: "Let there be Functions."', + durationMs: 3500, + ), + CinematicStep( + text: 'And so the Digital Realm was born...', + durationMs: 3000, + ), + CinematicStep( + text: 'But from the shadows emerged the Glitch.', + asciiArt: _asciiGlitch, + durationMs: 4000, + ), + CinematicStep( + text: 'Now, a new hero awakens to defend the Code.', + durationMs: 3500, + ), + CinematicStep( + text: 'Your journey begins...', + durationMs: 2500, + ), + ], + + // Act I: 각성 (레벨 1-20) + StoryAct.act1: [ + CinematicStep( + text: '=== ACT I: AWAKENING ===', + durationMs: 3000, + ), + CinematicStep( + text: 'You have proven yourself against the lesser bugs.', + durationMs: 3000, + ), + CinematicStep( + text: 'The Debugger Knights take notice of your potential.', + durationMs: 3500, + ), + CinematicStep( + text: 'But a greater threat lurks in the Bug Nest...', + asciiArt: _asciiBugNest, + durationMs: 4000, + ), + CinematicStep( + text: 'The Syntax Error Dragon awaits.', + asciiArt: _asciiDragon, + durationMs: 4000, + ), + ], + + // Act II: 성장 (레벨 21-40) + StoryAct.act2: [ + CinematicStep( + text: '=== ACT II: GROWTH ===', + durationMs: 3000, + ), + CinematicStep( + text: 'With the Dragon slain, you join the Debugger Knights.', + durationMs: 3500, + ), + CinematicStep( + text: 'The Corrupted Network spreads its infection...', + asciiArt: _asciiNetwork, + durationMs: 4000, + ), + CinematicStep( + text: 'A traitor among the Knights is revealed!', + durationMs: 3500, + ), + CinematicStep( + text: 'The Memory Leak Hydra threatens all data.', + asciiArt: _asciiHydra, + durationMs: 4000, + ), + CinematicStep( + text: 'You must stop the corruption before it consumes everything.', + durationMs: 3500, + ), + ], + + // Act III: 시련 (레벨 41-60) + StoryAct.act3: [ + CinematicStep( + text: '=== ACT III: TRIALS ===', + durationMs: 3000, + ), + CinematicStep( + text: 'The path leads to the Null Kingdom...', + asciiArt: _asciiNullKingdom, + durationMs: 4000, + ), + CinematicStep( + text: 'The Ancient Compiler challenges you to its trials.', + durationMs: 3500, + ), + CinematicStep( + text: 'A companion falls... their sacrifice not in vain.', + durationMs: 4000, + ), + CinematicStep( + text: 'The Buffer Overflow Titan guards the gate.', + asciiArt: _asciiTitan, + durationMs: 4000, + ), + CinematicStep( + text: 'Only through great sacrifice can you proceed.', + durationMs: 3500, + ), + ], + + // Act IV: 결전 (레벨 61-80) + StoryAct.act4: [ + CinematicStep( + text: '=== ACT IV: CONFRONTATION ===', + durationMs: 3000, + ), + CinematicStep( + text: "The Glitch God's Citadel looms before you.", + asciiArt: _asciiCitadel, + durationMs: 4000, + ), + CinematicStep( + text: 'Former enemies unite against the common threat.', + durationMs: 3500, + ), + CinematicStep( + text: 'The Final Alliance is forged.', + durationMs: 3000, + ), + CinematicStep( + text: 'The Kernel Panic Archon blocks your path.', + asciiArt: _asciiArchon, + durationMs: 4000, + ), + CinematicStep( + text: 'One final battle before the end...', + durationMs: 3500, + ), + ], + + // Act V: 종말 (레벨 81-100) + StoryAct.act5: [ + CinematicStep( + text: '=== ACT V: ENDGAME ===', + durationMs: 3000, + ), + CinematicStep( + text: 'The Glitch God reveals its true form.', + asciiArt: _asciiGlitchGod, + durationMs: 4000, + ), + CinematicStep( + text: 'Reality itself begins to corrupt.', + durationMs: 3500, + ), + CinematicStep( + text: 'All hope rests upon your shoulders.', + durationMs: 3000, + ), + CinematicStep( + text: 'The final battle for the Codebase begins!', + durationMs: 3500, + ), + ], + + // 엔딩: 시스템 재부팅, 평화 회복 + StoryAct.ending: [ + CinematicStep( + text: '=== THE END ===', + durationMs: 3000, + ), + CinematicStep( + text: 'The Glitch God falls. The corruption fades.', + asciiArt: _asciiVictory, + durationMs: 4000, + ), + CinematicStep( + text: 'System Reboot initiated...', + durationMs: 3000, + ), + CinematicStep( + text: 'Peace returns to the Digital Realm.', + durationMs: 3500, + ), + CinematicStep( + text: 'Your legend will be compiled into the eternal logs.', + durationMs: 4000, + ), + CinematicStep( + text: 'THE END', + asciiArt: _asciiTheEnd, + durationMs: 5000, + ), + CinematicStep( + text: '...or is it?', + durationMs: 3000, + ), + ], +}; + +// ============================================================================ +// ASCII Art 상수 (ASCII Art Constants) +// ============================================================================ + +const _asciiVoid = ''' + . . . + . . . . . + . . . . . . + . . . . . + . . . +'''; + +const _asciiLight = ''' + \\|/ + -- * -- + /|\\ +'''; + +const _asciiGlitch = ''' + ####!!#### + # GLITCH # + ####!!#### +'''; + +const _asciiBugNest = ''' + /\\ /\\ + <( o.o )> + > ^^ < +'''; + +const _asciiDragon = ''' + /\\___/\\ + ( O O ) + \\ ^ / + \\~~~/ ~ +'''; + +const _asciiNetwork = ''' + [*]--[*]--[*] + | | | + [*]--[!]--[*] + | | | + [*]--[*]--[*] +'''; + +const _asciiHydra = ''' + /\\ /\\ /\\ + ( O O O ) + \\ \\|/ / + \\|||/ +'''; + +const _asciiNullKingdom = ''' + +--NULL--+ + | ???? | + | VOID | + +--------+ +'''; + +const _asciiTitan = ''' + [####] + /| |\\ + | | | | + / |__| \\ +'''; + +const _asciiCitadel = ''' + /\\ + / \\ + |GLITCH| + |======| + | | +'''; + +const _asciiArchon = ''' + ^^^^^ + (|O O|) + \\===/ + |X| +'''; + +const _asciiGlitchGod = ''' + ########## + # G L I # + # T C H # + # GOD # + ########## +'''; + +const _asciiVictory = ''' + \\O/ + | + / \\ + VICTORY! +'''; + +const _asciiTheEnd = ''' + +-----------+ + | THE END | + +-----------+ +'''; + +/// Act별 보스 몬스터 이름 (Boss Monster Names per Act) +const Map actBossNames = { + StoryAct.act1: 'BOSS: Stack Overflow Dragon', + StoryAct.act2: 'BOSS: Heap Corruption Hydra', + StoryAct.act3: 'BOSS: Kernel Panic Titan', + StoryAct.act4: 'BOSS: Zero Day Leviathan', + StoryAct.act5: 'BOSS: The Primordial Glitch', +}; + +/// Act별 시작 퀘스트 (Starting Quest per Act) +const Map actStartingQuests = { + StoryAct.prologue: 'Exterminate the Bug Infestation', + StoryAct.act1: 'Purge the Bug Nest', + StoryAct.act2: 'Cleanse the Corrupted Network', + StoryAct.act3: 'Pass the Trials of the Ancient Compiler', + StoryAct.act4: "Infiltrate the Glitch God's Citadel", + StoryAct.act5: 'Defeat the Glitch God', +}; + +/// Act 제목 (Act Titles) +const Map actTitles = { + StoryAct.prologue: 'Prologue', + StoryAct.act1: 'Act I: Awakening', + StoryAct.act2: 'Act II: Growth', + StoryAct.act3: 'Act III: Trials', + StoryAct.act4: 'Act IV: Confrontation', + StoryAct.act5: 'Act V: Endgame', + StoryAct.ending: 'The End', +}; diff --git a/lib/src/core/engine/story_service.dart b/lib/src/core/engine/story_service.dart new file mode 100644 index 0000000..e647691 --- /dev/null +++ b/lib/src/core/engine/story_service.dart @@ -0,0 +1,186 @@ +import 'dart:async'; + +import 'package:askiineverdie/data/story_data.dart'; + +/// 스토리 이벤트 타입 (Story Event Type) +enum StoryEventType { + actStart, // Act 시작 + actComplete, // Act 완료 + bossEncounter, // 보스 조우 + bossDefeat, // 보스 처치 + ending, // 엔딩 +} + +/// 스토리 이벤트 (Story Event) +class StoryEvent { + const StoryEvent({ + required this.type, + required this.act, + this.data, + }); + + final StoryEventType type; + final StoryAct act; + final Map? data; +} + +/// 스토리 서비스 (Phase 9: Story Progression Management) +/// +/// Act 전환, 시네마틱 트리거, 보스 조우 관리 +class StoryService { + StoryService(); + + final _eventController = StreamController.broadcast(); + + /// 스토리 이벤트 스트림 (Story Event Stream) + Stream get events => _eventController.stream; + + // 현재 Act 추적 + StoryAct _currentAct = StoryAct.prologue; + bool _hasSeenPrologue = false; + final Set _completedActs = {}; + + /// 현재 Act (Current Act) + StoryAct get currentAct => _currentAct; + + /// 프롤로그 시청 여부 + bool get hasSeenPrologue => _hasSeenPrologue; + + /// 완료된 Act 목록 + Set get completedActs => Set.unmodifiable(_completedActs); + + /// 레벨 변화 감지 및 Act 전환 처리 (Process Level Change) + /// + /// 레벨업 시 호출하여 Act 전환 이벤트 트리거 + StoryEvent? processLevelChange(int oldLevel, int newLevel) { + final oldAct = getActForLevel(oldLevel); + final newAct = getActForLevel(newLevel); + + // 새 게임 시작 (프롤로그) + if (oldLevel == 0 && newLevel == 1 && !_hasSeenPrologue) { + _hasSeenPrologue = true; + _currentAct = StoryAct.prologue; + final event = StoryEvent( + type: StoryEventType.actStart, + act: StoryAct.prologue, + ); + _eventController.add(event); + return event; + } + + // Act 전환 감지 + if (newAct != oldAct && newAct != _currentAct) { + // 이전 Act 완료 처리 + if (_currentAct != StoryAct.prologue) { + _completedActs.add(_currentAct); + _eventController.add(StoryEvent( + type: StoryEventType.actComplete, + act: _currentAct, + )); + } + + // 새 Act 시작 + _currentAct = newAct; + final event = StoryEvent( + type: StoryEventType.actStart, + act: newAct, + ); + _eventController.add(event); + return event; + } + + return null; + } + + /// 보스 조우 처리 (Process Boss Encounter) + void processBossEncounter(String monsterName) { + // BOSS: 접두사가 있는 몬스터인지 확인 + if (!monsterName.startsWith('BOSS:')) return; + + final event = StoryEvent( + type: StoryEventType.bossEncounter, + act: _currentAct, + data: {'bossName': monsterName}, + ); + _eventController.add(event); + } + + /// 보스 처치 처리 (Process Boss Defeat) + void processBossDefeat(String monsterName) { + if (!monsterName.startsWith('BOSS:')) return; + + final event = StoryEvent( + type: StoryEventType.bossDefeat, + act: _currentAct, + data: {'bossName': monsterName}, + ); + _eventController.add(event); + + // 최종 보스 처치 시 엔딩 + if (monsterName.contains('Primordial Glitch')) { + _triggerEnding(); + } + } + + /// 엔딩 트리거 (Trigger Ending) + void _triggerEnding() { + _completedActs.add(StoryAct.act5); + _currentAct = StoryAct.ending; + _eventController.add(StoryEvent( + type: StoryEventType.ending, + act: StoryAct.ending, + )); + } + + /// 시네마틱 데이터 가져오기 (Get Cinematic Data) + List getCinematicSteps(StoryAct act) { + return cinematicData[act] ?? []; + } + + /// Act 제목 가져오기 (Get Act Title) + String getActTitle(StoryAct act) { + return actTitles[act] ?? 'Unknown'; + } + + /// Act 보스 이름 가져오기 (Get Act Boss Name) + String? getActBossName(StoryAct act) { + return actBossNames[act]; + } + + /// 저장 데이터로 상태 복원 (Restore State from Save) + void restoreState({ + required StoryAct currentAct, + required bool hasSeenPrologue, + required Set completedActs, + }) { + _currentAct = currentAct; + _hasSeenPrologue = hasSeenPrologue; + _completedActs + ..clear() + ..addAll(completedActs); + } + + /// 현재 상태를 저장 데이터로 변환 (Convert to Save Data) + Map toSaveData() { + return { + 'currentAct': _currentAct.index, + 'hasSeenPrologue': _hasSeenPrologue, + 'completedActs': _completedActs.map((a) => a.index).toList(), + }; + } + + /// 저장 데이터에서 복원 (Restore from Save Data) + void fromSaveData(Map data) { + _currentAct = StoryAct.values[data['currentAct'] as int? ?? 0]; + _hasSeenPrologue = data['hasSeenPrologue'] as bool? ?? false; + final completedIndices = data['completedActs'] as List? ?? []; + _completedActs + ..clear() + ..addAll(completedIndices.map((i) => StoryAct.values[i as int])); + } + + /// 서비스 정리 (Dispose) + void dispose() { + _eventController.close(); + } +} diff --git a/lib/src/features/game/game_play_screen.dart b/lib/src/features/game/game_play_screen.dart index bf415e9..8a0ec74 100644 --- a/lib/src/features/game/game_play_screen.dart +++ b/lib/src/features/game/game_play_screen.dart @@ -1,12 +1,15 @@ import 'package:flutter/material.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/l10n/game_data_l10n.dart'; import 'package:askiineverdie/src/core/model/game_state.dart'; import 'package:askiineverdie/src/core/notification/notification_service.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/game/widgets/cinematic_view.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/task_progress_panel.dart'; @@ -30,6 +33,11 @@ class _GamePlayScreenState extends State // Phase 8: 알림 서비스 (Notification Service) late final NotificationService _notificationService; + // Phase 9: 스토리 서비스 (Story Service) + late final StoryService _storyService; + StoryAct _lastAct = StoryAct.prologue; + bool _showingCinematic = false; + // 이전 상태 추적 (레벨업/퀘스트/Act 완료 감지용) int _lastLevel = 0; int _lastQuestCount = 0; @@ -41,6 +49,13 @@ class _GamePlayScreenState extends State _specialAnimation = AsciiAnimationType.levelUp; _notificationService.showLevelUp(state.traits.level); _resetSpecialAnimationAfterFrame(); + + // Phase 9: Act 변경 감지 (레벨 기반) + final newAct = getActForLevel(state.traits.level); + if (newAct != _lastAct && !_showingCinematic) { + _lastAct = newAct; + _showCinematicForAct(newAct); + } } _lastLevel = state.traits.level; @@ -68,6 +83,25 @@ class _GamePlayScreenState extends State _lastPlotStageCount = state.progress.plotStageCount; } + /// Phase 9: Act 시네마틱 표시 (Show Act Cinematic) + Future _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; + } + void _resetSpecialAnimationAfterFrame() { // 다음 프레임에서 리셋 (AsciiAnimationCard가 값을 받은 후) WidgetsBinding.instance.addPostFrameCallback((_) { @@ -83,6 +117,7 @@ class _GamePlayScreenState extends State void initState() { super.initState(); _notificationService = NotificationService(); + _storyService = StoryService(); widget.controller.addListener(_onControllerChanged); WidgetsBinding.instance.addObserver(this); @@ -92,12 +127,14 @@ class _GamePlayScreenState extends State _lastLevel = state.traits.level; _lastQuestCount = state.progress.questCount; _lastPlotStageCount = state.progress.plotStageCount; + _lastAct = getActForLevel(state.traits.level); } } @override void dispose() { _notificationService.dispose(); + _storyService.dispose(); WidgetsBinding.instance.removeObserver(this); widget.controller.removeListener(_onControllerChanged); super.dispose(); diff --git a/lib/src/features/game/widgets/cinematic_view.dart b/lib/src/features/game/widgets/cinematic_view.dart new file mode 100644 index 0000000..626bf91 --- /dev/null +++ b/lib/src/features/game/widgets/cinematic_view.dart @@ -0,0 +1,284 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import 'package:askiineverdie/data/story_data.dart'; + +/// 시네마틱 뷰 위젯 (Phase 9: Cinematic UI) +/// +/// Act 전환 시 풀스크린 시네마틱 표시 +class CinematicView extends StatefulWidget { + const CinematicView({ + super.key, + required this.steps, + required this.onComplete, + this.canSkip = true, + }); + + final List steps; + final VoidCallback onComplete; + final bool canSkip; + + @override + State createState() => _CinematicViewState(); +} + +class _CinematicViewState extends State + with SingleTickerProviderStateMixin { + int _currentStep = 0; + Timer? _autoAdvanceTimer; + + late AnimationController _fadeController; + late Animation _fadeAnimation; + + @override + void initState() { + super.initState(); + + _fadeController = AnimationController( + duration: const Duration(milliseconds: 500), + vsync: this, + ); + + _fadeAnimation = CurvedAnimation( + parent: _fadeController, + curve: Curves.easeInOut, + ); + + _fadeController.forward(); + _scheduleAutoAdvance(); + } + + void _scheduleAutoAdvance() { + _autoAdvanceTimer?.cancel(); + if (_currentStep < widget.steps.length) { + final step = widget.steps[_currentStep]; + _autoAdvanceTimer = Timer( + Duration(milliseconds: step.durationMs), + _advanceStep, + ); + } + } + + void _advanceStep() { + if (_currentStep >= widget.steps.length - 1) { + _complete(); + return; + } + + _fadeController.reverse().then((_) { + if (mounted) { + setState(() { + _currentStep++; + }); + _fadeController.forward(); + _scheduleAutoAdvance(); + } + }); + } + + void _complete() { + _autoAdvanceTimer?.cancel(); + widget.onComplete(); + } + + void _skip() { + if (widget.canSkip) { + _complete(); + } + } + + @override + void dispose() { + _autoAdvanceTimer?.cancel(); + _fadeController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (widget.steps.isEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onComplete(); + }); + return const SizedBox.shrink(); + } + + final step = widget.steps[_currentStep]; + + return GestureDetector( + onTap: _advanceStep, + child: Material( + color: Colors.black, + child: SafeArea( + child: Stack( + children: [ + // 메인 콘텐츠 + Center( + child: FadeTransition( + opacity: _fadeAnimation, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // ASCII 아트 + if (step.asciiArt != null) ...[ + _AsciiArtDisplay(asciiArt: step.asciiArt!), + const SizedBox(height: 24), + ], + // 텍스트 + Text( + step.text, + style: const TextStyle( + color: Colors.white, + fontSize: 18, + fontFamily: 'monospace', + height: 1.5, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + + // 진행 표시 (Progress Indicator) + Positioned( + bottom: 40, + left: 0, + right: 0, + child: _ProgressDots( + total: widget.steps.length, + current: _currentStep, + ), + ), + + // 스킵 버튼 + if (widget.canSkip) + Positioned( + top: 16, + right: 16, + child: TextButton( + onPressed: _skip, + child: const Text( + 'SKIP', + style: TextStyle( + color: Colors.white54, + fontSize: 14, + ), + ), + ), + ), + + // 탭 힌트 + Positioned( + bottom: 16, + left: 0, + right: 0, + child: Text( + 'Tap to continue', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.3), + fontSize: 12, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ), + ); + } +} + +/// ASCII 아트 표시 위젯 +class _AsciiArtDisplay extends StatelessWidget { + const _AsciiArtDisplay({required this.asciiArt}); + + final String asciiArt; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: Colors.cyan.withValues(alpha: 0.5)), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + asciiArt, + style: const TextStyle( + color: Colors.cyan, + fontSize: 14, + fontFamily: 'monospace', + height: 1.2, + ), + textAlign: TextAlign.center, + ), + ); + } +} + +/// 진행 도트 표시 위젯 +class _ProgressDots extends StatelessWidget { + const _ProgressDots({required this.total, required this.current}); + + final int total; + final int current; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(total, (index) { + final isActive = index == current; + final isPast = index < current; + + return Container( + width: isActive ? 12 : 8, + height: isActive ? 12 : 8, + margin: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isActive + ? Colors.cyan + : isPast + ? Colors.cyan.withValues(alpha: 0.5) + : Colors.white.withValues(alpha: 0.2), + ), + ); + }), + ); + } +} + +/// 시네마틱 표시 다이얼로그 함수 (Show Cinematic Dialog) +Future showCinematic( + BuildContext context, { + required List steps, + bool canSkip = true, +}) async { + if (steps.isEmpty) return; + + return showDialog( + context: context, + barrierDismissible: false, + barrierColor: Colors.black, + builder: (context) => CinematicView( + steps: steps, + canSkip: canSkip, + onComplete: () => Navigator.of(context).pop(), + ), + ); +} + +/// Act 시네마틱 표시 함수 (Show Act Cinematic) +Future showActCinematic(BuildContext context, StoryAct act) async { + final steps = cinematicData[act]; + if (steps == null || steps.isEmpty) return; + + await showCinematic(context, steps: steps); +}