630 lines
20 KiB
Dart
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|