feat: endless mode UI - game over card, best score, HUD
This commit is contained in:
+11
-1
@@ -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
@@ -21,5 +21,8 @@
|
||||
"gotIt": "알겠어요!",
|
||||
"tutorialDrag": "블록을 보드로 끌어다 놓아보세요!",
|
||||
"tutorialClear": "가로나 세로 한 줄을 채우면 사라져요!",
|
||||
"tutorialHud": "이동 횟수가 끝나기 전에 목표를 달성하세요. 이제 직접!"
|
||||
"tutorialHud": "이동 횟수가 끝나기 전에 목표를 달성하세요. 이제 직접!",
|
||||
"gameOver": "게임 오버",
|
||||
"bestScore": "최고 {score}",
|
||||
"newBest": "신기록!"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,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,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,
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user