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!",
"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!"
}
+4 -1
View File
@@ -21,5 +21,8 @@
"gotIt": "알겠어요!",
"tutorialDrag": "블록을 보드로 끌어다 놓아보세요!",
"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.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<GameViewState?> {
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,
+5
View File
@@ -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, TutorialStep?>(
TutorialNotifier.new,
);
final endlessBestProvider = NotifierProvider<EndlessBestNotifier, int>(
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<SeasonTheme>((ref) {
+41 -4
View File
@@ -41,6 +41,7 @@ class _GameScreenState extends ConsumerState<GameScreen>
);
bool _tutorialStartChecked = false;
bool _endlessNewBest = false;
int? _dragIndex;
Offset? _dragGlobal;
@@ -155,9 +156,11 @@ class _GameScreenState extends ConsumerState<GameScreen>
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<GameScreen>
}
}
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<GameScreen>
),
],
),
(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<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),
SizedBox(
width: 88,
+1 -1
View File
@@ -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) =>
+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);
});
}