Files
asciinevrdie/lib/src/features/game/widgets/victory_overlay.dart
2026-01-08 20:11:03 +09:00

630 lines
20 KiB
Dart

import 'package:flutter/material.dart';
import 'package:asciineverdie/l10n/app_localizations.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;
// 스크롤이 완료(최하단 도달) 되었는지 여부
bool _isScrollComplete = false;
// 스크롤 지속 시간 (밀리초)
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) {
setState(() {
_isScrollComplete = true;
});
}
});
_scrollController.forward();
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
/// 탭 시 스크롤 최하단으로 즉시 이동
void _skipToEnd() {
_scrollController.stop();
_scrollController.value = 1.0;
setState(() {
_isScrollComplete = true;
});
}
@override
Widget build(BuildContext context) {
final gold = RetroColors.goldOf(context);
return GestureDetector(
onTap: _isScrollComplete ? null : _skipToEnd, // 스크롤 중에만 탭으로 스킵
child: Material(
color: Colors.black,
child: SafeArea(
child: Stack(
children: [
// 스크롤되는 크레딧
AnimatedBuilder(
animation: _scrollAnimation,
builder: (context, child) {
return _buildScrollingCredits(context);
},
),
// 스킵 버튼 (스크롤 중에만 표시)
if (!_isScrollComplete)
Positioned(
top: 16,
right: 16,
child: TextButton(
onPressed: _skipToEnd,
child: Text(
L10n.of(context).endingSkip,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: gold.withValues(alpha: 0.5),
),
),
),
),
// 하단 탭 힌트 (스크롤 중에만 표시)
if (!_isScrollComplete)
Positioned(
bottom: 16,
left: 0,
right: 0,
child: Text(
L10n.of(context).endingTapToSkip,
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 1600.0;
}
Widget _buildCreditContent(BuildContext context) {
final l10n = L10n.of(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(
l10n.endingCongratulations,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: gold,
letterSpacing: 2,
),
),
const SizedBox(height: 16),
Text(
l10n.endingGameComplete,
style: TextStyle(
fontFamily: 'JetBrainsMono',
fontSize: 14,
color: textPrimary,
),
),
const SizedBox(height: 80),
// ═══════════════════════════════════
// THE HERO
// ═══════════════════════════════════
_buildSectionTitle(l10n.endingTheHero, gold),
const SizedBox(height: 20),
_buildHeroInfo(context),
const SizedBox(height: 80),
// ═══════════════════════════════════
// JOURNEY STATISTICS
// ═══════════════════════════════════
_buildSectionTitle(l10n.endingJourneyStats, gold),
const SizedBox(height: 20),
_buildStatistics(context),
const SizedBox(height: 80),
// ═══════════════════════════════════
// FINAL STATS
// ═══════════════════════════════════
_buildSectionTitle(l10n.endingFinalStats, gold),
const SizedBox(height: 20),
_buildFinalStats(context),
const SizedBox(height: 100),
// ═══════════════════════════════════
// ASCII TROPHY
// ═══════════════════════════════════
_buildTrophyAsciiArt(gold),
const SizedBox(height: 60),
// ═══════════════════════════════════
// CREDITS
// ═══════════════════════════════════
_buildSectionTitle(l10n.endingCredits, gold),
const SizedBox(height: 20),
_buildCredits(context),
const SizedBox(height: 100),
// ═══════════════════════════════════
// THE END
// ═══════════════════════════════════
_buildTheEnd(context, gold),
const SizedBox(height: 60),
// ═══════════════════════════════════
// HALL OF FAME BUTTON
// ═══════════════════════════════════
_buildHallOfFameButton(context, gold),
const SizedBox(height: 100), // 여백 (스크롤 끝)
],
),
),
);
}
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 l10n = L10n.of(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(
l10n.endingLevelFormat(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 l10n = L10n.of(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(l10n.endingMonstersSlain,
'${widget.progress.monstersKilled}', textPrimary, exp),
const SizedBox(height: 8),
_buildStatLine(l10n.endingQuestsCompleted,
'${widget.progress.questCount}', textPrimary, exp),
const SizedBox(height: 8),
_buildStatLine(
l10n.endingPlayTime, 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 l10n = L10n.of(context);
final textPrimary = RetroColors.textPrimaryOf(context);
final textMuted = RetroColors.textMutedOf(context);
final gold = RetroColors.goldOf(context);
return Column(
children: [
Text(
l10n.appTitle,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,
color: gold,
),
),
const SizedBox(height: 16),
Text(
l10n.endingThankYou,
style: TextStyle(
fontFamily: 'JetBrainsMono',
fontSize: 12,
color: textPrimary,
),
),
const SizedBox(height: 8),
Text(
l10n.endingLegendLivesOn,
style: TextStyle(
fontFamily: 'JetBrainsMono',
fontSize: 10,
color: textMuted,
fontStyle: FontStyle.italic,
),
),
],
);
}
Widget _buildTheEnd(BuildContext context, Color gold) {
const theEnd = '''
████████╗██╗ ██╗███████╗ ███████╗███╗ ██╗██████╗
╚══██╔══╝██║ ██║██╔════╝ ██╔════╝████╗ ██║██╔══██╗
██║ ███████║█████╗ █████╗ ██╔██╗ ██║██║ ██║
██║ ██╔══██║██╔══╝ ██╔══╝ ██║╚██╗██║██║ ██║
██║ ██║ ██║███████╗ ███████╗██║ ╚████║██████╔╝
╚═╝ ╚═╝ ╚═╝╚══════╝ ╚══════╝╚═╝ ╚═══╝╚═════╝ ''';
return Column(
children: [
Text(
theEnd,
style: TextStyle(
fontFamily: 'JetBrainsMono',
fontSize: 8,
color: gold,
height: 1.0,
),
textAlign: TextAlign.center,
),
],
);
}
/// 명예의 전당 버튼 (최하단)
Widget _buildHallOfFameButton(BuildContext context, Color gold) {
final l10n = L10n.of(context);
return Column(
children: [
// 안내 텍스트
Text(
l10n.endingHallOfFameLine1,
style: TextStyle(
fontFamily: 'JetBrainsMono',
fontSize: 12,
color: gold.withValues(alpha: 0.7),
),
),
const SizedBox(height: 4),
Text(
l10n.endingHallOfFameLine2,
style: TextStyle(
fontFamily: 'JetBrainsMono',
fontSize: 12,
color: gold.withValues(alpha: 0.7),
),
),
const SizedBox(height: 24),
// 명예의 전당 버튼
SizedBox(
width: 280,
height: 56,
child: ElevatedButton(
onPressed: widget.onComplete,
style: ElevatedButton.styleFrom(
backgroundColor: gold,
foregroundColor: Colors.black,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
elevation: 8,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.emoji_events, size: 24),
const SizedBox(width: 12),
Text(
l10n.endingHallOfFameButton,
style: const TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
),
),
],
),
),
),
],
);
}
}