feat(game): 게임 클리어 시 VictoryOverlay 추가
- VictoryOverlay 위젯 신규 생성 - GameSessionController에 isComplete 상태 추가 - 레벨 100 도달 시 승리 오버레이 표시 - 승리 후 명예의 전당 화면으로 이동
This commit is contained in:
572
lib/src/features/game/widgets/victory_overlay.dart
Normal file
572
lib/src/features/game/widgets/victory_overlay.dart
Normal file
@@ -0,0 +1,572 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:asciineverdie/src/core/l10n/game_data_l10n.dart';
|
||||
import 'package:asciineverdie/src/core/model/game_state.dart';
|
||||
import 'package:asciineverdie/src/shared/retro_colors.dart';
|
||||
|
||||
/// 게임 클리어 엔딩 오버레이 (Act V 완료 시)
|
||||
///
|
||||
/// 영화 엔딩 크레딧 스타일로 텍스트가 아래에서 위로 스크롤됨
|
||||
class VictoryOverlay extends StatefulWidget {
|
||||
const VictoryOverlay({
|
||||
super.key,
|
||||
required this.traits,
|
||||
required this.stats,
|
||||
required this.progress,
|
||||
required this.elapsedMs,
|
||||
required this.onComplete,
|
||||
});
|
||||
|
||||
final Traits traits;
|
||||
final Stats stats;
|
||||
final ProgressState progress;
|
||||
final int elapsedMs;
|
||||
|
||||
/// 엔딩 완료 콜백 (명예의 전당으로 이동)
|
||||
final VoidCallback onComplete;
|
||||
|
||||
@override
|
||||
State<VictoryOverlay> createState() => _VictoryOverlayState();
|
||||
}
|
||||
|
||||
class _VictoryOverlayState extends State<VictoryOverlay>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _scrollController;
|
||||
late Animation<double> _scrollAnimation;
|
||||
|
||||
// 스크롤 지속 시간 (밀리초)
|
||||
static const _scrollDurationMs = 25000; // 25초
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_scrollController = AnimationController(
|
||||
duration: const Duration(milliseconds: _scrollDurationMs),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_scrollAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(parent: _scrollController, curve: Curves.linear),
|
||||
);
|
||||
|
||||
// 스크롤 완료 시 자동 종료
|
||||
_scrollController.addStatusListener((status) {
|
||||
if (status == AnimationStatus.completed) {
|
||||
widget.onComplete();
|
||||
}
|
||||
});
|
||||
|
||||
_scrollController.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final gold = RetroColors.goldOf(context);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: widget.onComplete, // 탭으로 스킵
|
||||
child: Material(
|
||||
color: Colors.black,
|
||||
child: SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
// 스크롤되는 크레딧
|
||||
AnimatedBuilder(
|
||||
animation: _scrollAnimation,
|
||||
builder: (context, child) {
|
||||
return _buildScrollingCredits(context);
|
||||
},
|
||||
),
|
||||
|
||||
// 스킵 버튼
|
||||
Positioned(
|
||||
top: 16,
|
||||
right: 16,
|
||||
child: TextButton(
|
||||
onPressed: widget.onComplete,
|
||||
child: Text(
|
||||
'SKIP',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 10,
|
||||
color: gold.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 하단 탭 힌트
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Text(
|
||||
'TAP TO SKIP',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 8,
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScrollingCredits(BuildContext context) {
|
||||
final screenHeight = MediaQuery.of(context).size.height;
|
||||
final contentHeight = _estimateContentHeight();
|
||||
|
||||
// 스크롤 오프셋: 화면 아래에서 시작 → 화면 위로 사라짐
|
||||
final totalScrollDistance = screenHeight + contentHeight;
|
||||
final currentOffset =
|
||||
screenHeight - (_scrollAnimation.value * totalScrollDistance);
|
||||
|
||||
return SingleChildScrollView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, currentOffset),
|
||||
child: _buildCreditContent(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double _estimateContentHeight() {
|
||||
// 대략적인 콘텐츠 높이 추정 (스크롤 계산용)
|
||||
return 1500.0;
|
||||
}
|
||||
|
||||
Widget _buildCreditContent(BuildContext context) {
|
||||
final gold = RetroColors.goldOf(context);
|
||||
final textPrimary = RetroColors.textPrimaryOf(context);
|
||||
|
||||
return Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 500),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// ═══════════════════════════════════
|
||||
// VICTORY ASCII ART
|
||||
// ═══════════════════════════════════
|
||||
_buildVictoryAsciiArt(gold),
|
||||
const SizedBox(height: 60),
|
||||
|
||||
// ═══════════════════════════════════
|
||||
// CONGRATULATIONS
|
||||
// ═══════════════════════════════════
|
||||
Text(
|
||||
'★ CONGRATULATIONS ★',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 14,
|
||||
color: gold,
|
||||
letterSpacing: 2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'You have completed the game!',
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrainsMono',
|
||||
fontSize: 14,
|
||||
color: textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 80),
|
||||
|
||||
// ═══════════════════════════════════
|
||||
// THE HERO
|
||||
// ═══════════════════════════════════
|
||||
_buildSectionTitle('THE HERO', gold),
|
||||
const SizedBox(height: 20),
|
||||
_buildHeroInfo(context),
|
||||
const SizedBox(height: 80),
|
||||
|
||||
// ═══════════════════════════════════
|
||||
// JOURNEY STATISTICS
|
||||
// ═══════════════════════════════════
|
||||
_buildSectionTitle('JOURNEY STATISTICS', gold),
|
||||
const SizedBox(height: 20),
|
||||
_buildStatistics(context),
|
||||
const SizedBox(height: 80),
|
||||
|
||||
// ═══════════════════════════════════
|
||||
// FINAL STATS
|
||||
// ═══════════════════════════════════
|
||||
_buildSectionTitle('FINAL STATS', gold),
|
||||
const SizedBox(height: 20),
|
||||
_buildFinalStats(context),
|
||||
const SizedBox(height: 100),
|
||||
|
||||
// ═══════════════════════════════════
|
||||
// ASCII TROPHY
|
||||
// ═══════════════════════════════════
|
||||
_buildTrophyAsciiArt(gold),
|
||||
const SizedBox(height: 60),
|
||||
|
||||
// ═══════════════════════════════════
|
||||
// CREDITS
|
||||
// ═══════════════════════════════════
|
||||
_buildSectionTitle('CREDITS', gold),
|
||||
const SizedBox(height: 20),
|
||||
_buildCredits(context),
|
||||
const SizedBox(height: 100),
|
||||
|
||||
// ═══════════════════════════════════
|
||||
// THE END
|
||||
// ═══════════════════════════════════
|
||||
_buildTheEnd(gold),
|
||||
const SizedBox(height: 200), // 여백 (스크롤 끝)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildVictoryAsciiArt(Color gold) {
|
||||
const asciiArt = '''
|
||||
╔═══════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ ██╗ ██╗██╗ ██████╗████████╗ ██████╗ ║
|
||||
║ ██║ ██║██║██╔════╝╚══██╔══╝██╔═══██╗ ║
|
||||
║ ██║ ██║██║██║ ██║ ██║ ██║ ║
|
||||
║ ╚██╗ ██╔╝██║██║ ██║ ██║ ██║ ║
|
||||
║ ╚████╔╝ ██║╚██████╗ ██║ ╚██████╔╝ ║
|
||||
║ ╚═══╝ ╚═╝ ╚═════╝ ╚═╝ ╚═════╝ ║
|
||||
║ ║
|
||||
║ ██████╗ ██╗ ██╗ ║
|
||||
║ ██╔══██╗╚██╗ ██╔╝ ║
|
||||
║ ██████╔╝ ╚████╔╝ ║
|
||||
║ ██╔══██╗ ╚██╔╝ ║
|
||||
║ ██║ ██║ ██║ ║
|
||||
║ ╚═╝ ╚═╝ ╚═╝ ║
|
||||
║ ║
|
||||
╚═══════════════════════════════════════════╝''';
|
||||
|
||||
return Text(
|
||||
asciiArt,
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrainsMono',
|
||||
fontSize: 8,
|
||||
color: gold,
|
||||
height: 1.0,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSectionTitle(String title, Color gold) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
'═══════════════════',
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrainsMono',
|
||||
fontSize: 12,
|
||||
color: gold.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 12,
|
||||
color: gold,
|
||||
letterSpacing: 2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'═══════════════════',
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrainsMono',
|
||||
fontSize: 12,
|
||||
color: gold.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeroInfo(BuildContext context) {
|
||||
final gold = RetroColors.goldOf(context);
|
||||
final textPrimary = RetroColors.textPrimaryOf(context);
|
||||
final textMuted = RetroColors.textMutedOf(context);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 캐릭터 이름
|
||||
Text(
|
||||
widget.traits.name,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 16,
|
||||
color: gold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 레벨, 종족, 직업
|
||||
Text(
|
||||
'Level ${widget.traits.level}',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 10,
|
||||
color: textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${GameDataL10n.getRaceName(context, widget.traits.race)} '
|
||||
'${GameDataL10n.getKlassName(context, widget.traits.klass)}',
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrainsMono',
|
||||
fontSize: 12,
|
||||
color: textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatistics(BuildContext context) {
|
||||
final textPrimary = RetroColors.textPrimaryOf(context);
|
||||
final exp = RetroColors.expOf(context);
|
||||
|
||||
// 플레이 시간 포맷
|
||||
final playTime = Duration(milliseconds: widget.elapsedMs);
|
||||
final hours = playTime.inHours;
|
||||
final minutes = playTime.inMinutes % 60;
|
||||
final seconds = playTime.inSeconds % 60;
|
||||
final playTimeStr = '${hours.toString().padLeft(2, '0')}:'
|
||||
'${minutes.toString().padLeft(2, '0')}:'
|
||||
'${seconds.toString().padLeft(2, '0')}';
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
_buildStatLine('Monsters Slain', '${widget.progress.monstersKilled}',
|
||||
textPrimary, exp),
|
||||
const SizedBox(height: 8),
|
||||
_buildStatLine('Quests Completed', '${widget.progress.questCount}',
|
||||
textPrimary, exp),
|
||||
const SizedBox(height: 8),
|
||||
_buildStatLine('Play Time', playTimeStr, textPrimary, textPrimary),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatLine(
|
||||
String label, String value, Color labelColor, Color valueColor) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'$label: ',
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrainsMono',
|
||||
fontSize: 12,
|
||||
color: labelColor.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 10,
|
||||
color: valueColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFinalStats(BuildContext context) {
|
||||
final textPrimary = RetroColors.textPrimaryOf(context);
|
||||
final stats = widget.stats;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildStatBox('STR', '${stats.str}', textPrimary),
|
||||
const SizedBox(width: 16),
|
||||
_buildStatBox('CON', '${stats.con}', textPrimary),
|
||||
const SizedBox(width: 16),
|
||||
_buildStatBox('DEX', '${stats.dex}', textPrimary),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildStatBox('INT', '${stats.intelligence}', textPrimary),
|
||||
const SizedBox(width: 16),
|
||||
_buildStatBox('WIS', '${stats.wis}', textPrimary),
|
||||
const SizedBox(width: 16),
|
||||
_buildStatBox('CHA', '${stats.cha}', textPrimary),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatBox(String label, String value, Color color) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrainsMono',
|
||||
fontSize: 10,
|
||||
color: color.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 12,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrophyAsciiArt(Color gold) {
|
||||
const trophy = '''
|
||||
___________
|
||||
'._==_==_=_.'
|
||||
.-\\: /-.
|
||||
| (|:. |) |
|
||||
'-|:. |-'
|
||||
\\::. /
|
||||
'::. .'
|
||||
) (
|
||||
_.' '._
|
||||
'-------' ''';
|
||||
|
||||
return Text(
|
||||
trophy,
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrainsMono',
|
||||
fontSize: 12,
|
||||
color: gold,
|
||||
height: 1.0,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCredits(BuildContext context) {
|
||||
final textPrimary = RetroColors.textPrimaryOf(context);
|
||||
final textMuted = RetroColors.textMutedOf(context);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
'Based on',
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrainsMono',
|
||||
fontSize: 10,
|
||||
color: textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Progress Quest',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 10,
|
||||
color: textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'by Eric Fredricksen',
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrainsMono',
|
||||
fontSize: 10,
|
||||
color: textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Flutter Port',
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrainsMono',
|
||||
fontSize: 10,
|
||||
color: textMuted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'ASCII Never Die',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PressStart2P',
|
||||
fontSize: 10,
|
||||
color: textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTheEnd(Color gold) {
|
||||
const theEnd = '''
|
||||
████████╗██╗ ██╗███████╗ ███████╗███╗ ██╗██████╗
|
||||
╚══██╔══╝██║ ██║██╔════╝ ██╔════╝████╗ ██║██╔══██╗
|
||||
██║ ███████║█████╗ █████╗ ██╔██╗ ██║██║ ██║
|
||||
██║ ██╔══██║██╔══╝ ██╔══╝ ██║╚██╗██║██║ ██║
|
||||
██║ ██║ ██║███████╗ ███████╗██║ ╚████║██████╔╝
|
||||
╚═╝ ╚═╝ ╚═╝╚══════╝ ╚══════╝╚═╝ ╚═══╝╚═════╝ ''';
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
theEnd,
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrainsMono',
|
||||
fontSize: 6,
|
||||
color: gold,
|
||||
height: 1.0,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Your heroic deeds will be',
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrainsMono',
|
||||
fontSize: 12,
|
||||
color: gold.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'remembered in the Hall of Fame',
|
||||
style: TextStyle(
|
||||
fontFamily: 'JetBrainsMono',
|
||||
fontSize: 12,
|
||||
color: gold.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user