From fea83363914b289bde80bfa8b2792948bf2a23c4 Mon Sep 17 00:00:00 2001 From: airkjw Date: Fri, 12 Jun 2026 07:06:30 +0900 Subject: [PATCH] feat: endless mode UI - game over card, best score, HUD --- lib/l10n/app_en.arb | 12 +++++++- lib/l10n/app_ko.arb | 5 +++- lib/state/endless_best_notifier.dart | 18 +++++++++++ lib/state/game_session_notifier.dart | 6 +++- lib/state/providers.dart | 5 ++++ lib/ui/screens/game_screen.dart | 45 +++++++++++++++++++++++++--- lib/ui/widgets/hud_widget.dart | 2 +- test/state/endless_best_test.dart | 25 ++++++++++++++++ 8 files changed, 110 insertions(+), 8 deletions(-) create mode 100644 lib/state/endless_best_notifier.dart create mode 100644 test/state/endless_best_test.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index eee50b0..75a77bd 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -42,5 +42,15 @@ "gotIt": "Got it!", "tutorialDrag": "Drag a block onto the board!", "tutorialClear": "Fill a row or column to clear it!", - "tutorialHud": "Hit the goal before you run out of moves. Your turn!" + "tutorialHud": "Hit the goal before you run out of moves. Your turn!", + "gameOver": "Game Over", + "bestScore": "Best {score}", + "@bestScore": { + "placeholders": { + "score": { + "type": "int" + } + } + }, + "newBest": "NEW BEST!" } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index a4bce51..2cb4038 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -21,5 +21,8 @@ "gotIt": "알겠어요!", "tutorialDrag": "블록을 보드로 끌어다 놓아보세요!", "tutorialClear": "가로나 세로 한 줄을 채우면 사라져요!", - "tutorialHud": "이동 횟수가 끝나기 전에 목표를 달성하세요. 이제 직접!" + "tutorialHud": "이동 횟수가 끝나기 전에 목표를 달성하세요. 이제 직접!", + "gameOver": "게임 오버", + "bestScore": "최고 {score}", + "newBest": "신기록!" } diff --git a/lib/state/endless_best_notifier.dart b/lib/state/endless_best_notifier.dart new file mode 100644 index 0000000..92d4944 --- /dev/null +++ b/lib/state/endless_best_notifier.dart @@ -0,0 +1,18 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'providers.dart'; + +/// Reactive view over SaveRepository's endless best score. +class EndlessBestNotifier extends Notifier { + @override + int build() => ref.read(saveRepositoryProvider).endlessBest; + + /// Records the run; returns true when it set a new best. + Future record(int score) async { + final repo = ref.read(saveRepositoryProvider); + final isNewBest = score > state; + await repo.recordEndlessScore(score); + state = repo.endlessBest; + return isNewBest; + } +} diff --git a/lib/state/game_session_notifier.dart b/lib/state/game_session_notifier.dart index ff8dde5..b0d8858 100644 --- a/lib/state/game_session_notifier.dart +++ b/lib/state/game_session_notifier.dart @@ -23,6 +23,7 @@ class GameViewState { required this.objectiveProgress, required this.lastPlacement, required this.fxTick, + required this.endless, }); final GridState grid; @@ -40,6 +41,8 @@ class GameViewState { /// Increments on every accepted placement so animations can retrigger. final int fxTick; + + final bool endless; } /// Bridges the pure-Dart [GameEngine] to the widget tree. The engine object @@ -107,7 +110,8 @@ class GameSessionNotifier extends Notifier { score: engine.score, comboStreak: engine.combo.streak, movesLeft: engine.movesLeft, - moveLimit: engine.movesLeft + engine.movesUsed, + endless: engine.endless, + moveLimit: engine.endless ? 0 : engine.movesLeft + engine.movesUsed, phase: engine.phase, stuckReason: engine.stuckReason, objectives: engine.objectives, diff --git a/lib/state/providers.dart b/lib/state/providers.dart index 66f7790..c7eaeac 100644 --- a/lib/state/providers.dart +++ b/lib/state/providers.dart @@ -5,6 +5,7 @@ import '../data/save_repository.dart'; import '../data/streak.dart'; import '../game/models/season.dart'; import '../services/audio_service.dart'; +import 'endless_best_notifier.dart'; import 'game_session_notifier.dart'; import 'progress_notifier.dart'; import 'season_flow_notifier.dart'; @@ -51,6 +52,10 @@ final tutorialProvider = NotifierProvider( TutorialNotifier.new, ); +final endlessBestProvider = NotifierProvider( + EndlessBestNotifier.new, +); + /// The visual theme of whatever season is in play; fallback outside seasons /// (home, endless). Pure model — UI converts via ThemeColors. final activeThemeProvider = Provider((ref) { diff --git a/lib/ui/screens/game_screen.dart b/lib/ui/screens/game_screen.dart index 23daf71..ea4fc00 100644 --- a/lib/ui/screens/game_screen.dart +++ b/lib/ui/screens/game_screen.dart @@ -41,6 +41,7 @@ class _GameScreenState extends ConsumerState ); bool _tutorialStartChecked = false; + bool _endlessNewBest = false; int? _dragIndex; Offset? _dragGlobal; @@ -155,9 +156,11 @@ class _GameScreenState extends ConsumerState if (next.phase == GamePhase.won) { audio.play(Sfx.win); // recordResult keeps the best run, so re-entry is harmless. - ref - .read(seasonFlowProvider.notifier) - .recordWin(stars: next.starsEarned, score: next.score); + if (!next.endless) { + ref + .read(seasonFlowProvider.notifier) + .recordWin(stars: next.starsEarned, score: next.score); + } final stackBox = _stackKey.currentContext?.findRenderObject() as RenderBox?; if (stackBox != null) { @@ -165,6 +168,11 @@ class _GameScreenState extends ConsumerState } } if (next.phase == GamePhase.lost) audio.play(Sfx.lose); + if (next.phase == GamePhase.lost && next.endless) { + ref.read(endlessBestProvider.notifier).record(next.score).then((isNew) { + if (mounted) setState(() => _endlessNewBest = isNew); + }); + } if (next.phase == GamePhase.won || next.phase == GamePhase.lost) { ref.read(streakProvider.notifier).onStagePlayed(DateTime.now()); } @@ -381,6 +389,18 @@ class _GameScreenState extends ConsumerState ), ], ), + (GamePhase.lost, _) when view.endless => ( + l10n.gameOver, + [ + FilledButton( + onPressed: () { + setState(() => _endlessNewBest = false); + notifier.restart(); + }, + child: Text(l10n.playAgain), + ), + ], + ), (_, _) => ( l10n.stageFailed, [ @@ -426,7 +446,24 @@ class _GameScreenState extends ConsumerState ], ), ], - if (view.phase == GamePhase.lost && view.objectiveProgress > 0) ...[ + if (view.phase == GamePhase.lost && view.endless) ...[ + const SizedBox(height: 10), + Text('${view.score}', + style: theme.textTheme.displaySmall + ?.copyWith(fontWeight: FontWeight.w900)), + const SizedBox(height: 4), + Text( + _endlessNewBest + ? l10n.newBest + : l10n.bestScore(ref.read(endlessBestProvider)), + style: TextStyle( + color: + _endlessNewBest ? Colors.amber : Colors.white60, + fontWeight: FontWeight.w800, + ), + ), + ], + if (view.phase == GamePhase.lost && !view.endless && view.objectiveProgress > 0) ...[ const SizedBox(height: 16), SizedBox( width: 88, diff --git a/lib/ui/widgets/hud_widget.dart b/lib/ui/widgets/hud_widget.dart index 5639beb..ae75122 100644 --- a/lib/ui/widgets/hud_widget.dart +++ b/lib/ui/widgets/hud_widget.dart @@ -16,7 +16,7 @@ class HudWidget extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - _movesChip(theme), + view.endless ? const SizedBox(width: 48) : _movesChip(theme), AnimatedSwitcher( duration: const Duration(milliseconds: 200), transitionBuilder: (child, anim) => diff --git a/test/state/endless_best_test.dart b/test/state/endless_best_test.dart new file mode 100644 index 0000000..25eba33 --- /dev/null +++ b/test/state/endless_best_test.dart @@ -0,0 +1,25 @@ +import 'package:block_seasons/data/save_repository.dart'; +import 'package:block_seasons/state/providers.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + test('exposes saved best and reports new records', () async { + SharedPreferences.setMockInitialValues({}); + final repo = SaveRepository(await SharedPreferences.getInstance()); + await repo.recordEndlessScore(400); + final container = ProviderContainer( + overrides: [saveRepositoryProvider.overrideWithValue(repo)], + ); + addTearDown(container.dispose); + + expect(container.read(endlessBestProvider), 400); + + final n = container.read(endlessBestProvider.notifier); + expect(await n.record(300), isFalse); + expect(container.read(endlessBestProvider), 400); + expect(await n.record(900), isTrue); + expect(container.read(endlessBestProvider), 900); + }); +}