feat(story): Phase 9 스토리/엔딩 시스템 구현

- story_data.dart: 5개 Act 스토리 텍스트 및 ASCII 아트
- story_service.dart: Act 전환/보스 조우/엔딩 이벤트 관리
- cinematic_view.dart: 풀스크린 시네마틱 UI (페이드, 스킵)
- game_play_screen.dart: 레벨 기반 Act 전환 시 시네마틱 재생
This commit is contained in:
JiWoong Sul
2025-12-17 18:38:08 +09:00
parent 8cbef3475b
commit abcb89d334
4 changed files with 902 additions and 0 deletions

View File

@@ -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<String, dynamic>? data;
}
/// 스토리 서비스 (Phase 9: Story Progression Management)
///
/// Act 전환, 시네마틱 트리거, 보스 조우 관리
class StoryService {
StoryService();
final _eventController = StreamController<StoryEvent>.broadcast();
/// 스토리 이벤트 스트림 (Story Event Stream)
Stream<StoryEvent> get events => _eventController.stream;
// 현재 Act 추적
StoryAct _currentAct = StoryAct.prologue;
bool _hasSeenPrologue = false;
final Set<StoryAct> _completedActs = {};
/// 현재 Act (Current Act)
StoryAct get currentAct => _currentAct;
/// 프롤로그 시청 여부
bool get hasSeenPrologue => _hasSeenPrologue;
/// 완료된 Act 목록
Set<StoryAct> 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<CinematicStep> 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<StoryAct> completedActs,
}) {
_currentAct = currentAct;
_hasSeenPrologue = hasSeenPrologue;
_completedActs
..clear()
..addAll(completedActs);
}
/// 현재 상태를 저장 데이터로 변환 (Convert to Save Data)
Map<String, dynamic> toSaveData() {
return {
'currentAct': _currentAct.index,
'hasSeenPrologue': _hasSeenPrologue,
'completedActs': _completedActs.map((a) => a.index).toList(),
};
}
/// 저장 데이터에서 복원 (Restore from Save Data)
void fromSaveData(Map<String, dynamic> data) {
_currentAct = StoryAct.values[data['currentAct'] as int? ?? 0];
_hasSeenPrologue = data['hasSeenPrologue'] as bool? ?? false;
final completedIndices = data['completedActs'] as List<dynamic>? ?? [];
_completedActs
..clear()
..addAll(completedIndices.map((i) => StoryAct.values[i as int]));
}
/// 서비스 정리 (Dispose)
void dispose() {
_eventController.close();
}
}

View File

@@ -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<GamePlayScreen>
// 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<GamePlayScreen>
_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<GamePlayScreen>
_lastPlotStageCount = state.progress.plotStageCount;
}
/// 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;
}
void _resetSpecialAnimationAfterFrame() {
// 다음 프레임에서 리셋 (AsciiAnimationCard가 값을 받은 후)
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -83,6 +117,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
void initState() {
super.initState();
_notificationService = NotificationService();
_storyService = StoryService();
widget.controller.addListener(_onControllerChanged);
WidgetsBinding.instance.addObserver(this);
@@ -92,12 +127,14 @@ class _GamePlayScreenState extends State<GamePlayScreen>
_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();

View File

@@ -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<CinematicStep> steps;
final VoidCallback onComplete;
final bool canSkip;
@override
State<CinematicView> createState() => _CinematicViewState();
}
class _CinematicViewState extends State<CinematicView>
with SingleTickerProviderStateMixin {
int _currentStep = 0;
Timer? _autoAdvanceTimer;
late AnimationController _fadeController;
late Animation<double> _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<void> showCinematic(
BuildContext context, {
required List<CinematicStep> steps,
bool canSkip = true,
}) async {
if (steps.isEmpty) return;
return showDialog<void>(
context: context,
barrierDismissible: false,
barrierColor: Colors.black,
builder: (context) => CinematicView(
steps: steps,
canSkip: canSkip,
onComplete: () => Navigator.of(context).pop(),
),
);
}
/// Act 시네마틱 표시 함수 (Show Act Cinematic)
Future<void> showActCinematic(BuildContext context, StoryAct act) async {
final steps = cinematicData[act];
if (steps == null || steps.isEmpty) return;
await showCinematic(context, steps: steps);
}