feat(ui): 화면 및 공통 위젯 개선

- FrontScreen 개선
- GamePlayScreen, GameSessionController 업데이트
- ArenaBattleScreen, NewCharacterScreen 정리
- AsciiDisintegrateWidget 추가
This commit is contained in:
JiWoong Sul
2026-01-14 00:18:16 +09:00
parent f65bab6312
commit 1da377c127
7 changed files with 302 additions and 30 deletions

View File

@@ -50,6 +50,8 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
late final SettingsRepository _settingsRepository; late final SettingsRepository _settingsRepository;
late final AudioService _audioService; late final AudioService _audioService;
late final HallOfFameStorage _hallOfFameStorage; late final HallOfFameStorage _hallOfFameStorage;
final RouteObserver<ModalRoute<void>> _routeObserver =
RouteObserver<ModalRoute<void>>();
bool _isCheckingSave = true; bool _isCheckingSave = true;
bool _hasSave = false; bool _hasSave = false;
SavedGamePreview? _savedGamePreview; SavedGamePreview? _savedGamePreview;
@@ -437,6 +439,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
theme: _lightTheme, theme: _lightTheme,
darkTheme: _darkTheme, darkTheme: _darkTheme,
themeMode: _themeMode, themeMode: _themeMode,
navigatorObservers: [_routeObserver],
builder: (context, child) { builder: (context, child) {
// 현재 로케일을 게임 텍스트 l10n 시스템에 동기화 // 현재 로케일을 게임 텍스트 l10n 시스템에 동기화
final locale = Localizations.localeOf(context); final locale = Localizations.localeOf(context);
@@ -465,6 +468,11 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
hasSaveFile: _hasSave, hasSaveFile: _hasSave,
savedGamePreview: _savedGamePreview, savedGamePreview: _savedGamePreview,
hallOfFameCount: _hallOfFame.count, hallOfFameCount: _hallOfFame.count,
routeObserver: _routeObserver,
onRefresh: () {
_checkForExistingSave();
_loadHallOfFame();
},
); );
} }
@@ -480,8 +488,9 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
), ),
) )
.then((_) { .then((_) {
// 새 게임 후 돌아오면 세이브 정보 갱신 (BGM은 _checkForExistingSave에서 재생) // 새 게임 후 돌아오면 세이브 정보 및 명예의 전당 갱신
_checkForExistingSave(); _checkForExistingSave();
_loadHallOfFame();
}); });
} }
@@ -560,8 +569,9 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
), ),
) )
.then((_) { .then((_) {
// 게임에서 돌아오면 세이브 정보 갱신 (BGM은 _checkForExistingSave에서 재생) // 게임에서 돌아오면 세이브 정보 및 명예의 전당 갱신
_checkForExistingSave(); _checkForExistingSave();
_loadHallOfFame();
}); });
} }
@@ -574,7 +584,8 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
), ),
) )
.then((_) { .then((_) {
// 명예의 전당에서 돌아오면 타이틀 BGM 재생 // 명예의 전당에서 돌아오면 명예의 전당 갱신 및 타이틀 BGM 재생
_loadHallOfFame();
_audioService.playBgm('title'); _audioService.playBgm('title');
}); });
} }

View File

@@ -10,7 +10,7 @@ import 'package:asciineverdie/src/core/model/hall_of_fame.dart';
import 'package:asciineverdie/src/core/animation/race_character_frames.dart'; import 'package:asciineverdie/src/core/animation/race_character_frames.dart';
import 'package:asciineverdie/src/features/arena/widgets/arena_combat_log.dart'; import 'package:asciineverdie/src/features/arena/widgets/arena_combat_log.dart';
import 'package:asciineverdie/src/features/arena/widgets/arena_result_panel.dart'; import 'package:asciineverdie/src/features/arena/widgets/arena_result_panel.dart';
import 'package:asciineverdie/src/features/arena/widgets/ascii_disintegrate_widget.dart'; import 'package:asciineverdie/src/shared/widgets/ascii_disintegrate_widget.dart';
import 'package:asciineverdie/src/features/game/widgets/ascii_animation_card.dart'; import 'package:asciineverdie/src/features/game/widgets/ascii_animation_card.dart';
import 'package:asciineverdie/src/features/game/widgets/combat_log.dart'; import 'package:asciineverdie/src/features/game/widgets/combat_log.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart';

View File

@@ -10,7 +10,7 @@ import 'package:asciineverdie/src/features/front/widgets/hero_vs_boss_animation.
import 'package:asciineverdie/src/shared/retro_colors.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart';
import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart'; import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart';
class FrontScreen extends StatelessWidget { class FrontScreen extends StatefulWidget {
const FrontScreen({ const FrontScreen({
super.key, super.key,
this.onNewCharacter, this.onNewCharacter,
@@ -20,6 +20,8 @@ class FrontScreen extends StatelessWidget {
this.hasSaveFile = false, this.hasSaveFile = false,
this.savedGamePreview, this.savedGamePreview,
this.hallOfFameCount = 0, this.hallOfFameCount = 0,
this.routeObserver,
this.onRefresh,
}); });
/// "New character" 버튼 클릭 시 호출 /// "New character" 버튼 클릭 시 호출
@@ -43,12 +45,45 @@ class FrontScreen extends StatelessWidget {
/// 명예의 전당 캐릭터 수 (아레나 활성화 조건: 2명 이상) /// 명예의 전당 캐릭터 수 (아레나 활성화 조건: 2명 이상)
final int hallOfFameCount; final int hallOfFameCount;
/// RouteObserver (화면 복귀 시 갱신용)
final RouteObserver<ModalRoute<void>>? routeObserver;
/// 화면 복귀 시 호출할 콜백
final VoidCallback? onRefresh;
@override
State<FrontScreen> createState() => _FrontScreenState();
}
class _FrontScreenState extends State<FrontScreen> with RouteAware {
@override
void didChangeDependencies() {
super.didChangeDependencies();
// RouteObserver 구독
final route = ModalRoute.of(context);
if (route != null) {
widget.routeObserver?.subscribe(this, route);
}
}
@override
void dispose() {
widget.routeObserver?.unsubscribe(this);
super.dispose();
}
@override
void didPopNext() {
// 다른 화면에서 돌아왔을 때 갱신
widget.onRefresh?.call();
}
/// 새 캐릭터 생성 시 세이브 파일 존재하면 경고 표시 /// 새 캐릭터 생성 시 세이브 파일 존재하면 경고 표시
void _handleNewCharacter(BuildContext context) { void _handleNewCharacter(BuildContext context) {
if (hasSaveFile) { if (widget.hasSaveFile) {
_showDeleteWarningDialog(context); _showDeleteWarningDialog(context);
} else { } else {
onNewCharacter?.call(context); widget.onNewCharacter?.call(context);
} }
} }
@@ -67,7 +102,7 @@ class FrontScreen extends StatelessWidget {
FilledButton( FilledButton(
onPressed: () { onPressed: () {
Navigator.pop(dialogContext); Navigator.pop(dialogContext);
onNewCharacter?.call(context); widget.onNewCharacter?.call(context);
}, },
child: Text(game_l10n.buttonConfirm), child: Text(game_l10n.buttonConfirm),
), ),
@@ -98,21 +133,21 @@ class FrontScreen extends StatelessWidget {
const _AnimationPanel(), const _AnimationPanel(),
const SizedBox(height: 16), const SizedBox(height: 16),
_ActionButtons( _ActionButtons(
onNewCharacter: onNewCharacter != null onNewCharacter: widget.onNewCharacter != null
? () => _handleNewCharacter(context) ? () => _handleNewCharacter(context)
: null, : null,
onLoadSave: onLoadSave != null onLoadSave: widget.onLoadSave != null
? () => onLoadSave!(context) ? () => widget.onLoadSave!(context)
: null, : null,
onHallOfFame: onHallOfFame != null onHallOfFame: widget.onHallOfFame != null
? () => onHallOfFame!(context) ? () => widget.onHallOfFame!(context)
: null, : null,
onLocalArena: onLocalArena: widget.onLocalArena != null &&
onLocalArena != null && hallOfFameCount >= 2 widget.hallOfFameCount >= 2
? () => onLocalArena!(context) ? () => widget.onLocalArena!(context)
: null, : null,
savedGamePreview: savedGamePreview, savedGamePreview: widget.savedGamePreview,
hallOfFameCount: hallOfFameCount, hallOfFameCount: widget.hallOfFameCount,
), ),
], ],
), ),
@@ -262,16 +297,14 @@ class _ActionButtons extends StatelessWidget {
onPressed: onHallOfFame, onPressed: onHallOfFame,
isPrimary: false, isPrimary: false,
), ),
// 로컬 아레나 (2명 이상일 때만 활성화) // 로컬 아레나 (항상 표시, 2명 이상일 때만 활성화)
if (hallOfFameCount >= 2) ...[ const SizedBox(height: 12),
const SizedBox(height: 12), RetroTextButton(
RetroTextButton( text: game_l10n.uiLocalArena,
text: game_l10n.uiLocalArena, icon: Icons.sports_kabaddi,
icon: Icons.sports_kabaddi, onPressed: hallOfFameCount >= 2 ? onLocalArena : null,
onPressed: onLocalArena, isPrimary: false,
isPrimary: false, ),
),
],
], ],
), ),
); );

View File

@@ -1181,6 +1181,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
state.progress.currentCombat?.monsterStats.hpCurrent, state.progress.currentCombat?.monsterStats.hpCurrent,
monsterHpMax: state.progress.currentCombat?.monsterStats.hpMax, monsterHpMax: state.progress.currentCombat?.monsterStats.hpMax,
monsterName: state.progress.currentCombat?.monsterStats.name, monsterName: state.progress.currentCombat?.monsterStats.name,
monsterLevel: state.progress.currentCombat?.monsterStats.level,
), ),
// Experience 바 // Experience 바

View File

@@ -107,6 +107,14 @@ class GameSessionController extends ChangeNotifier {
if (isNewGame) { if (isNewGame) {
_sessionStats = SessionStatistics.empty(); _sessionStats = SessionStatistics.empty();
await _statisticsStorage.recordGameStart(); await _statisticsStorage.recordGameStart();
} else {
// 게임 로드 시 저장된 사망 횟수 복원
_sessionStats = _sessionStats.copyWith(
deathCount: state.progress.deathCount,
questsCompleted: state.progress.questCount,
monstersKilled: state.progress.monstersKilled,
playTimeMs: state.skillSystem.elapsedMs,
);
} }
_initPreviousValues(state); _initPreviousValues(state);
@@ -341,7 +349,7 @@ class GameSessionController extends ChangeNotifier {
final entry = HallOfFameEntry.fromGameState( final entry = HallOfFameEntry.fromGameState(
state: _state!, state: _state!,
totalDeaths: _sessionStats.deathCount, totalDeaths: _state!.progress.deathCount, // GameState에 저장된 값 사용
monstersKilled: _state!.progress.monstersKilled, monstersKilled: _state!.progress.monstersKilled,
combatStats: combatStats, combatStats: combatStats,
); );

View File

@@ -259,7 +259,7 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
seed: gameSeed, seed: gameSeed,
traits: traits, traits: traits,
stats: finalStats, stats: finalStats,
inventory: const Inventory(gold: 0, items: []), inventory: Inventory.empty(),
equipment: Equipment.empty(), equipment: Equipment.empty(),
skillBook: SkillBook.empty(), skillBook: SkillBook.empty(),
progress: ProgressState.empty(), progress: ProgressState.empty(),

View File

@@ -0,0 +1,219 @@
import 'dart:math';
import 'package:flutter/material.dart';
/// ASCII 문자 분해 파티클
class AsciiParticle {
AsciiParticle({
required this.char,
required this.initialX,
required this.initialY,
required this.vx,
required this.vy,
required this.delay,
}) : x = initialX,
y = initialY,
opacity = 1.0;
final String char;
final double initialX;
final double initialY;
final double vx; // X 속도
final double vy; // Y 속도
final double delay; // 분해 시작 지연 (0.0 ~ 0.3)
double x;
double y;
double opacity;
/// 진행도에 따라 파티클 상태 업데이트
void update(double progress) {
// 지연 적용
final adjustedProgress = ((progress - delay) / (1.0 - delay)).clamp(
0.0,
1.0,
);
if (adjustedProgress <= 0) {
// 아직 분해 시작 전
x = initialX;
y = initialY;
opacity = 1.0;
return;
}
// 이징 적용 (가속)
final easedProgress = Curves.easeOutQuad.transform(adjustedProgress);
// 위치 업데이트 (초기 위치에서 이동)
x = initialX + vx * easedProgress * 3.0;
y = initialY + vy * easedProgress * 3.0;
// 중력 효과
y += easedProgress * easedProgress * 2.0;
// 페이드 아웃 (후반부에 급격히)
opacity = (1.0 - easedProgress * easedProgress).clamp(0.0, 1.0);
}
}
/// ASCII 캐릭터 분해 애니메이션 위젯
///
/// 캐릭터의 각 ASCII 문자가 파티클로 분해되어 흩어지는 효과
class AsciiDisintegrateWidget extends StatefulWidget {
const AsciiDisintegrateWidget({
super.key,
required this.characterLines,
this.charWidth = 8.0,
this.charHeight = 12.0,
this.duration = const Duration(milliseconds: 1500),
this.textColor,
this.onComplete,
});
/// ASCII 캐릭터 문자열 (줄 단위)
final List<String> characterLines;
/// 문자 너비 (픽셀)
final double charWidth;
/// 문자 높이 (픽셀)
final double charHeight;
/// 애니메이션 지속 시간
final Duration duration;
/// 텍스트 색상 (null이면 테마 색상)
final Color? textColor;
/// 완료 콜백
final VoidCallback? onComplete;
@override
State<AsciiDisintegrateWidget> createState() =>
_AsciiDisintegrateWidgetState();
}
class _AsciiDisintegrateWidgetState extends State<AsciiDisintegrateWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late List<AsciiParticle> _particles;
final Random _random = Random();
@override
void initState() {
super.initState();
_initParticles();
_controller = AnimationController(duration: widget.duration, vsync: this)
..addListener(() => setState(() {}))
..addStatusListener((status) {
if (status == AnimationStatus.completed) {
widget.onComplete?.call();
}
})
..forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _initParticles() {
_particles = [];
for (int y = 0; y < widget.characterLines.length; y++) {
final line = widget.characterLines[y];
for (int x = 0; x < line.length; x++) {
final char = line[x];
// 공백은 파티클로 변환하지 않음
if (char != ' ') {
_particles.add(
AsciiParticle(
char: char,
initialX: x.toDouble(),
initialY: y.toDouble(),
// 랜덤 속도 (위쪽 + 좌우로 퍼짐)
vx: (_random.nextDouble() - 0.5) * 4.0,
vy: -_random.nextDouble() * 2.0 - 0.5, // 위쪽으로
// 랜덤 지연 (안쪽에서 바깥쪽으로 분해)
delay: _random.nextDouble() * 0.3,
),
);
}
}
}
}
@override
Widget build(BuildContext context) {
// 파티클 상태 업데이트
for (final particle in _particles) {
particle.update(_controller.value);
}
final textColor =
widget.textColor ?? Theme.of(context).textTheme.bodyMedium?.color;
return CustomPaint(
size: Size(
widget.characterLines.isNotEmpty
? widget.characterLines
.map((l) => l.length)
.reduce((a, b) => a > b ? a : b) *
widget.charWidth
: 0,
widget.characterLines.length * widget.charHeight,
),
painter: _DisintegratePainter(
particles: _particles,
charWidth: widget.charWidth,
charHeight: widget.charHeight,
textColor: textColor ?? Colors.white,
),
);
}
}
/// 분해 파티클 페인터
class _DisintegratePainter extends CustomPainter {
_DisintegratePainter({
required this.particles,
required this.charWidth,
required this.charHeight,
required this.textColor,
});
final List<AsciiParticle> particles;
final double charWidth;
final double charHeight;
final Color textColor;
@override
void paint(Canvas canvas, Size size) {
for (final particle in particles) {
if (particle.opacity <= 0) continue;
final textPainter = TextPainter(
text: TextSpan(
text: particle.char,
style: TextStyle(
fontFamily: 'JetBrainsMono',
fontSize: charHeight * 0.9,
color: textColor.withValues(alpha: particle.opacity),
),
),
textDirection: TextDirection.ltr,
)..layout();
final x = particle.x * charWidth;
final y = particle.y * charHeight;
textPainter.paint(canvas, Offset(x, y));
}
}
@override
bool shouldRepaint(covariant _DisintegratePainter oldDelegate) => true;
}