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:
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