feat: endless mode UI - game over card, best score, HUD

This commit is contained in:
2026-06-12 07:06:30 +09:00
parent c5e9029cad
commit fea8336391
8 changed files with 110 additions and 8 deletions
+11 -1
View File
@@ -42,5 +42,15 @@
"gotIt": "Got it!", "gotIt": "Got it!",
"tutorialDrag": "Drag a block onto the board!", "tutorialDrag": "Drag a block onto the board!",
"tutorialClear": "Fill a row or column to clear it!", "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!"
} }
+4 -1
View File
@@ -21,5 +21,8 @@
"gotIt": "알겠어요!", "gotIt": "알겠어요!",
"tutorialDrag": "블록을 보드로 끌어다 놓아보세요!", "tutorialDrag": "블록을 보드로 끌어다 놓아보세요!",
"tutorialClear": "가로나 세로 한 줄을 채우면 사라져요!", "tutorialClear": "가로나 세로 한 줄을 채우면 사라져요!",
"tutorialHud": "이동 횟수가 끝나기 전에 목표를 달성하세요. 이제 직접!" "tutorialHud": "이동 횟수가 끝나기 전에 목표를 달성하세요. 이제 직접!",
"gameOver": "게임 오버",
"bestScore": "최고 {score}",
"newBest": "신기록!"
} }
+18
View File
@@ -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<int> {
@override
int build() => ref.read(saveRepositoryProvider).endlessBest;
/// Records the run; returns true when it set a new best.
Future<bool> record(int score) async {
final repo = ref.read(saveRepositoryProvider);
final isNewBest = score > state;
await repo.recordEndlessScore(score);
state = repo.endlessBest;
return isNewBest;
}
}
+5 -1
View File
@@ -23,6 +23,7 @@ class GameViewState {
required this.objectiveProgress, required this.objectiveProgress,
required this.lastPlacement, required this.lastPlacement,
required this.fxTick, required this.fxTick,
required this.endless,
}); });
final GridState grid; final GridState grid;
@@ -40,6 +41,8 @@ class GameViewState {
/// Increments on every accepted placement so animations can retrigger. /// Increments on every accepted placement so animations can retrigger.
final int fxTick; final int fxTick;
final bool endless;
} }
/// Bridges the pure-Dart [GameEngine] to the widget tree. The engine object /// Bridges the pure-Dart [GameEngine] to the widget tree. The engine object
@@ -107,7 +110,8 @@ class GameSessionNotifier extends Notifier<GameViewState?> {
score: engine.score, score: engine.score,
comboStreak: engine.combo.streak, comboStreak: engine.combo.streak,
movesLeft: engine.movesLeft, movesLeft: engine.movesLeft,
moveLimit: engine.movesLeft + engine.movesUsed, endless: engine.endless,
moveLimit: engine.endless ? 0 : engine.movesLeft + engine.movesUsed,
phase: engine.phase, phase: engine.phase,
stuckReason: engine.stuckReason, stuckReason: engine.stuckReason,
objectives: engine.objectives, objectives: engine.objectives,
+5
View File
@@ -5,6 +5,7 @@ import '../data/save_repository.dart';
import '../data/streak.dart'; import '../data/streak.dart';
import '../game/models/season.dart'; import '../game/models/season.dart';
import '../services/audio_service.dart'; import '../services/audio_service.dart';
import 'endless_best_notifier.dart';
import 'game_session_notifier.dart'; import 'game_session_notifier.dart';
import 'progress_notifier.dart'; import 'progress_notifier.dart';
import 'season_flow_notifier.dart'; import 'season_flow_notifier.dart';
@@ -51,6 +52,10 @@ final tutorialProvider = NotifierProvider<TutorialNotifier, TutorialStep?>(
TutorialNotifier.new, TutorialNotifier.new,
); );
final endlessBestProvider = NotifierProvider<EndlessBestNotifier, int>(
EndlessBestNotifier.new,
);
/// The visual theme of whatever season is in play; fallback outside seasons /// The visual theme of whatever season is in play; fallback outside seasons
/// (home, endless). Pure model — UI converts via ThemeColors. /// (home, endless). Pure model — UI converts via ThemeColors.
final activeThemeProvider = Provider<SeasonTheme>((ref) { final activeThemeProvider = Provider<SeasonTheme>((ref) {
+38 -1
View File
@@ -41,6 +41,7 @@ class _GameScreenState extends ConsumerState<GameScreen>
); );
bool _tutorialStartChecked = false; bool _tutorialStartChecked = false;
bool _endlessNewBest = false;
int? _dragIndex; int? _dragIndex;
Offset? _dragGlobal; Offset? _dragGlobal;
@@ -155,9 +156,11 @@ class _GameScreenState extends ConsumerState<GameScreen>
if (next.phase == GamePhase.won) { if (next.phase == GamePhase.won) {
audio.play(Sfx.win); audio.play(Sfx.win);
// recordResult keeps the best run, so re-entry is harmless. // recordResult keeps the best run, so re-entry is harmless.
if (!next.endless) {
ref ref
.read(seasonFlowProvider.notifier) .read(seasonFlowProvider.notifier)
.recordWin(stars: next.starsEarned, score: next.score); .recordWin(stars: next.starsEarned, score: next.score);
}
final stackBox = final stackBox =
_stackKey.currentContext?.findRenderObject() as RenderBox?; _stackKey.currentContext?.findRenderObject() as RenderBox?;
if (stackBox != null) { if (stackBox != null) {
@@ -165,6 +168,11 @@ class _GameScreenState extends ConsumerState<GameScreen>
} }
} }
if (next.phase == GamePhase.lost) audio.play(Sfx.lose); 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) { if (next.phase == GamePhase.won || next.phase == GamePhase.lost) {
ref.read(streakProvider.notifier).onStagePlayed(DateTime.now()); ref.read(streakProvider.notifier).onStagePlayed(DateTime.now());
} }
@@ -381,6 +389,18 @@ class _GameScreenState extends ConsumerState<GameScreen>
), ),
], ],
), ),
(GamePhase.lost, _) when view.endless => (
l10n.gameOver,
[
FilledButton(
onPressed: () {
setState(() => _endlessNewBest = false);
notifier.restart();
},
child: Text(l10n.playAgain),
),
],
),
(_, _) => ( (_, _) => (
l10n.stageFailed, l10n.stageFailed,
[ [
@@ -426,7 +446,24 @@ class _GameScreenState extends ConsumerState<GameScreen>
], ],
), ),
], ],
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), const SizedBox(height: 16),
SizedBox( SizedBox(
width: 88, width: 88,
+1 -1
View File
@@ -16,7 +16,7 @@ class HudWidget extends StatelessWidget {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
_movesChip(theme), view.endless ? const SizedBox(width: 48) : _movesChip(theme),
AnimatedSwitcher( AnimatedSwitcher(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
transitionBuilder: (child, anim) => transitionBuilder: (child, anim) =>
+25
View File
@@ -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);
});
}