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:
395
lib/data/story_data.dart
Normal file
395
lib/data/story_data.dart
Normal file
@@ -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<StoryAct, (int, int)> 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<StoryAct, List<CinematicStep>> 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<StoryAct, String> 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<StoryAct, String> 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<StoryAct, String> 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',
|
||||||
|
};
|
||||||
186
lib/src/core/engine/story_service.dart
Normal file
186
lib/src/core/engine/story_service.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:askiineverdie/data/story_data.dart';
|
||||||
import 'package:askiineverdie/l10n/app_localizations.dart';
|
import 'package:askiineverdie/l10n/app_localizations.dart';
|
||||||
import 'package:askiineverdie/src/core/animation/ascii_animation_type.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/l10n/game_data_l10n.dart';
|
||||||
import 'package:askiineverdie/src/core/model/game_state.dart';
|
import 'package:askiineverdie/src/core/model/game_state.dart';
|
||||||
import 'package:askiineverdie/src/core/notification/notification_service.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/core/util/pq_logic.dart' as pq_logic;
|
||||||
import 'package:askiineverdie/src/features/game/game_session_controller.dart';
|
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/notification_overlay.dart';
|
||||||
import 'package:askiineverdie/src/features/game/widgets/stats_panel.dart';
|
import 'package:askiineverdie/src/features/game/widgets/stats_panel.dart';
|
||||||
import 'package:askiineverdie/src/features/game/widgets/task_progress_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)
|
// Phase 8: 알림 서비스 (Notification Service)
|
||||||
late final NotificationService _notificationService;
|
late final NotificationService _notificationService;
|
||||||
|
|
||||||
|
// Phase 9: 스토리 서비스 (Story Service)
|
||||||
|
late final StoryService _storyService;
|
||||||
|
StoryAct _lastAct = StoryAct.prologue;
|
||||||
|
bool _showingCinematic = false;
|
||||||
|
|
||||||
// 이전 상태 추적 (레벨업/퀘스트/Act 완료 감지용)
|
// 이전 상태 추적 (레벨업/퀘스트/Act 완료 감지용)
|
||||||
int _lastLevel = 0;
|
int _lastLevel = 0;
|
||||||
int _lastQuestCount = 0;
|
int _lastQuestCount = 0;
|
||||||
@@ -41,6 +49,13 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
_specialAnimation = AsciiAnimationType.levelUp;
|
_specialAnimation = AsciiAnimationType.levelUp;
|
||||||
_notificationService.showLevelUp(state.traits.level);
|
_notificationService.showLevelUp(state.traits.level);
|
||||||
_resetSpecialAnimationAfterFrame();
|
_resetSpecialAnimationAfterFrame();
|
||||||
|
|
||||||
|
// Phase 9: Act 변경 감지 (레벨 기반)
|
||||||
|
final newAct = getActForLevel(state.traits.level);
|
||||||
|
if (newAct != _lastAct && !_showingCinematic) {
|
||||||
|
_lastAct = newAct;
|
||||||
|
_showCinematicForAct(newAct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_lastLevel = state.traits.level;
|
_lastLevel = state.traits.level;
|
||||||
|
|
||||||
@@ -68,6 +83,25 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
_lastPlotStageCount = state.progress.plotStageCount;
|
_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() {
|
void _resetSpecialAnimationAfterFrame() {
|
||||||
// 다음 프레임에서 리셋 (AsciiAnimationCard가 값을 받은 후)
|
// 다음 프레임에서 리셋 (AsciiAnimationCard가 값을 받은 후)
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
@@ -83,6 +117,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_notificationService = NotificationService();
|
_notificationService = NotificationService();
|
||||||
|
_storyService = StoryService();
|
||||||
widget.controller.addListener(_onControllerChanged);
|
widget.controller.addListener(_onControllerChanged);
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
|
||||||
@@ -92,12 +127,14 @@ class _GamePlayScreenState extends State<GamePlayScreen>
|
|||||||
_lastLevel = state.traits.level;
|
_lastLevel = state.traits.level;
|
||||||
_lastQuestCount = state.progress.questCount;
|
_lastQuestCount = state.progress.questCount;
|
||||||
_lastPlotStageCount = state.progress.plotStageCount;
|
_lastPlotStageCount = state.progress.plotStageCount;
|
||||||
|
_lastAct = getActForLevel(state.traits.level);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_notificationService.dispose();
|
_notificationService.dispose();
|
||||||
|
_storyService.dispose();
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
widget.controller.removeListener(_onControllerChanged);
|
widget.controller.removeListener(_onControllerChanged);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
|||||||
284
lib/src/features/game/widgets/cinematic_view.dart
Normal file
284
lib/src/features/game/widgets/cinematic_view.dart
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user