diff --git a/.gitignore b/.gitignore
index aa6fef4..6b5629d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,3 +47,4 @@ app.*.map.json
# Generated localizations
lib/l10n/gen/
.superpowers/
+CLAUDE.md
diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml
index f74085f..c887eb5 100644
--- a/android/app/src/main/res/drawable-v21/launch_background.xml
+++ b/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -1,12 +1,8 @@
-
-
-
-
-
+ -
+
+
+
+
diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml
index 304732f..c887eb5 100644
--- a/android/app/src/main/res/drawable/launch_background.xml
+++ b/android/app/src/main/res/drawable/launch_background.xml
@@ -1,12 +1,8 @@
-
-
-
-
-
+ -
+
+
+
+
diff --git a/docs/screenshots/sim_polish_home.png b/docs/screenshots/sim_polish_home.png
new file mode 100644
index 0000000..2d43647
Binary files /dev/null and b/docs/screenshots/sim_polish_home.png differ
diff --git a/docs/screenshots/sim_polish_season_card.png b/docs/screenshots/sim_polish_season_card.png
new file mode 100644
index 0000000..45765f0
Binary files /dev/null and b/docs/screenshots/sim_polish_season_card.png differ
diff --git a/docs/screenshots/sim_polish_splash.png b/docs/screenshots/sim_polish_splash.png
new file mode 100644
index 0000000..b6355c8
Binary files /dev/null and b/docs/screenshots/sim_polish_splash.png differ
diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard
index f2e259c..a4ee227 100644
--- a/ios/Runner/Base.lproj/LaunchScreen.storyboard
+++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard
@@ -19,7 +19,7 @@
-
+
diff --git a/lib/app.dart b/lib/app.dart
index 8b1f83a..cfd596a 100644
--- a/lib/app.dart
+++ b/lib/app.dart
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'l10n/gen/app_localizations.dart';
-import 'ui/screens/home_screen.dart';
+import 'ui/screens/splash_screen.dart';
class BlockSeasonsApp extends StatelessWidget {
const BlockSeasonsApp({super.key});
@@ -26,7 +26,7 @@ class BlockSeasonsApp extends StatelessWidget {
),
useMaterial3: true,
),
- home: const HomeScreen(),
+ home: const SplashScreen(),
);
}
}
diff --git a/lib/data/save_repository.dart b/lib/data/save_repository.dart
index 0543a00..d9ab677 100644
--- a/lib/data/save_repository.dart
+++ b/lib/data/save_repository.dart
@@ -34,6 +34,11 @@ class SaveRepository {
lastYmd: streak['lastYmd'] as String?,
);
}
+ _tutorialDone =
+ (json['flags'] as Map?)?['tutorialDone'] as bool? ??
+ false;
+ _endlessBest =
+ (json['endless'] as Map?)?['best'] as int? ?? 0;
}
}
@@ -45,8 +50,22 @@ class SaveRepository {
final SharedPreferences _prefs;
final Map _progress = {};
StreakState _streak = StreakState.initial;
+ bool _tutorialDone = false;
+ int _endlessBest = 0;
StreakState get streak => _streak;
+ bool get tutorialDone => _tutorialDone;
+ int get endlessBest => _endlessBest;
+
+ Future markTutorialDone() {
+ _tutorialDone = true;
+ return _flush();
+ }
+
+ Future recordEndlessScore(int score) {
+ if (score > _endlessBest) _endlessBest = score;
+ return _flush();
+ }
Future saveStreak(StreakState streak) {
_streak = streak;
@@ -111,6 +130,8 @@ class SaveRepository {
'best': _streak.best,
'lastYmd': _streak.lastYmd,
},
+ 'flags': {'tutorialDone': _tutorialDone},
+ 'endless': {'best': _endlessBest},
}),
);
}
diff --git a/lib/game/engine/game_engine.dart b/lib/game/engine/game_engine.dart
index 678c80f..83f10d3 100644
--- a/lib/game/engine/game_engine.dart
+++ b/lib/game/engine/game_engine.dart
@@ -73,7 +73,12 @@ class GameEngine {
int get score => _score;
ComboState get combo => _combo;
int get movesUsed => _movesUsed;
- int get movesLeft => _moveLimit - _movesUsed;
+ // movesLeft: endless is effectively infinite.
+ int get movesLeft =>
+ _stage.endless ? 1 << 30 : _moveLimit - _movesUsed;
+
+ // UI branch selector for endless mode.
+ bool get endless => _stage.endless;
List get objectives => List.unmodifiable(_objectives);
GamePhase get phase => _phase;
StuckReason? get stuckReason => _stuckReason;
@@ -128,7 +133,7 @@ class GameEngine {
events.fold(obj, (o, event) => o.onEvent(event)),
];
- if (_objectives.every((o) => o.isComplete)) {
+ if (!_stage.endless && _objectives.every((o) => o.isComplete)) {
_phase = GamePhase.won;
} else {
if (_tray.isEmpty) _tray = _generator.nextTray(_grid);
@@ -147,7 +152,7 @@ class GameEngine {
}
void _checkStuck() {
- if (movesLeft <= 0) {
+ if (!_stage.endless && movesLeft <= 0) {
_phase = GamePhase.stuck;
_stuckReason = StuckReason.outOfMoves;
} else if (!anyPlacementExists(_grid, _tray)) {
diff --git a/lib/game/models/season.dart b/lib/game/models/season.dart
index c81b2ab..2ce1657 100644
--- a/lib/game/models/season.dart
+++ b/lib/game/models/season.dart
@@ -1,18 +1,62 @@
import 'stage.dart';
+/// Visual identity of a season. Colors are int ARGB so this file stays
+/// pure Dart (architecture guard forbids Flutter imports here).
class SeasonTheme {
- const SeasonTheme({required this.tileSet, required this.background});
+ const SeasonTheme({
+ this.tileSet = 'spring',
+ this.background = '',
+ this.backgroundGradient = defaultGradient,
+ this.accentColor = 0xFFFF7EB3,
+ this.particleType = 'petals',
+ this.tilePalette,
+ this.boardTint,
+ });
factory SeasonTheme.fromJson(Map json) => SeasonTheme(
- tileSet: json['tileSet'] as String,
- background: json['background'] as String,
+ tileSet: (json['tileSet'] as String?) ?? 'spring',
+ background: (json['background'] as String?) ?? '',
+ backgroundGradient: json['backgroundGradient'] != null
+ ? [for (final c in json['backgroundGradient'] as List) (c as num).toInt()]
+ : defaultGradient,
+ accentColor: (json['accentColor'] as int?) ?? 0xFFFF7EB3,
+ particleType: (json['particleType'] as String?) ?? 'petals',
+ tilePalette: json['tilePalette'] != null
+ ? [for (final c in json['tilePalette'] as List) (c as num).toInt()]
+ : null,
+ boardTint: json['boardTint'] as int?,
);
+ /// Season 1 "First Bloom": deep navy dusk.
+ static const defaultGradient = [0xFF0E1430, 0xFF16204A, 0xFF2A2E5E];
+
+ static const fallback = SeasonTheme();
+
final String tileSet;
final String background;
- Map toJson() =>
- {'tileSet': tileSet, 'background': background};
+ /// Top-to-bottom screen gradient, int ARGB.
+ final List backgroundGradient;
+ final int accentColor;
+
+ /// petals | snow | leaves | none
+ final String particleType;
+
+ /// Optional tile color override; null = built-in candy palette.
+ final List? tilePalette;
+
+ /// Optional board background override.
+ final int? boardTint;
+
+ Map toJson() => {
+ 'tileSet': tileSet,
+ 'background': background,
+ 'backgroundGradient': backgroundGradient,
+ 'accentColor': accentColor,
+ 'particleType': particleType,
+ if (tilePalette != null) 'tilePalette': tilePalette,
+ if (boardTint != null) 'boardTint': boardTint,
+ };
}
/// A season's full content: metadata, theme, and its stages. The unit of
diff --git a/lib/game/models/stage.dart b/lib/game/models/stage.dart
index c186785..ee0964f 100644
--- a/lib/game/models/stage.dart
+++ b/lib/game/models/stage.dart
@@ -74,8 +74,21 @@ class StageConfig {
required this.objectives,
required this.stars,
required this.generatorProfile,
+ this.endless = false,
});
+ factory StageConfig.endless({required int seed}) => StageConfig(
+ id: 'endless',
+ seed: seed,
+ moveLimit: 0,
+ preset: const [],
+ objectives: const [],
+ stars: const StarThresholds(
+ twoMovesLeft: 1 << 30, threeMovesLeft: 1 << 30),
+ generatorProfile: 'mid',
+ endless: true,
+ );
+
factory StageConfig.fromJson(Map json) => StageConfig(
id: json['id'] as String,
seed: json['seed'] as int,
@@ -100,6 +113,10 @@ class StageConfig {
final StarThresholds stars;
final String generatorProfile;
+ /// Runtime-only: score-attack mode with no objectives or move limit.
+ /// Never serialized — packs always describe objective stages.
+ final bool endless;
+
GridState initialGrid() {
var grid = GridState.empty();
for (final cell in preset) {
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index fc72489..d6efbb6 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -20,5 +20,39 @@
"type": "int"
}
}
- }
+ },
+ "almostThere": "{percent}% complete!",
+ "@almostThere": {
+ "placeholders": {
+ "percent": {
+ "type": "int"
+ }
+ }
+ },
+ "seasonLabel": "SEASON",
+ "seasonStages": "{count} stages",
+ "@seasonStages": {
+ "placeholders": {
+ "count": {
+ "type": "int"
+ }
+ }
+ },
+ "skip": "Skip",
+ "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!",
+ "gameOver": "Game Over",
+ "bestScore": "Best {score}",
+ "@bestScore": {
+ "placeholders": {
+ "score": {
+ "type": "int"
+ }
+ }
+ },
+ "newBest": "NEW BEST!",
+ "adventure": "Adventure",
+ "classic": "Classic"
}
diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb
index 5dd475f..0afe322 100644
--- a/lib/l10n/app_ko.arb
+++ b/lib/l10n/app_ko.arb
@@ -13,5 +13,18 @@
"giveUp": "포기하기",
"playAgain": "다시 하기",
"nextStage": "다음 스테이지",
- "streakMilestone": "{days}일 연속 플레이! 대단해요!"
+ "streakMilestone": "{days}일 연속 플레이! 대단해요!",
+ "almostThere": "{percent}% 달성!",
+ "seasonLabel": "SEASON",
+ "seasonStages": "{count}개 스테이지",
+ "skip": "건너뛰기",
+ "gotIt": "알겠어요!",
+ "tutorialDrag": "블록을 보드로 끌어다 놓아보세요!",
+ "tutorialClear": "가로나 세로 한 줄을 채우면 사라져요!",
+ "tutorialHud": "이동 횟수가 끝나기 전에 목표를 달성하세요. 이제 직접!",
+ "gameOver": "게임 오버",
+ "bestScore": "최고 {score}",
+ "newBest": "신기록!",
+ "adventure": "어드벤처",
+ "classic": "클래식"
}
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 707e35e..c7eaeac 100644
--- a/lib/state/providers.dart
+++ b/lib/state/providers.dart
@@ -5,10 +5,12 @@ 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';
import 'streak_notifier.dart';
+import 'tutorial_notifier.dart';
final gameSessionProvider =
NotifierProvider(
@@ -45,3 +47,18 @@ final seasonsProvider = FutureProvider>(
final streakProvider = NotifierProvider(
StreakNotifier.new,
);
+
+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) {
+ final flow = ref.watch(seasonFlowProvider);
+ return flow?.pack.theme ?? SeasonTheme.fallback;
+});
diff --git a/lib/state/season_flow_notifier.dart b/lib/state/season_flow_notifier.dart
index 749cbd4..eede482 100644
--- a/lib/state/season_flow_notifier.dart
+++ b/lib/state/season_flow_notifier.dart
@@ -41,4 +41,8 @@ class SeasonFlowNotifier extends Notifier {
if (flow == null || !flow.hasNext) return;
startSeasonStage(flow.pack, flow.index + 1);
}
+
+ /// Leaving season play (e.g. starting a Classic run) clears the flow so
+ /// stale stage context can't leak into other modes.
+ void clear() => state = null;
}
diff --git a/lib/state/tutorial_notifier.dart b/lib/state/tutorial_notifier.dart
new file mode 100644
index 0000000..f6cb2c0
--- /dev/null
+++ b/lib/state/tutorial_notifier.dart
@@ -0,0 +1,40 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import 'providers.dart';
+
+enum TutorialStep { dragPiece, clearLine, explainHud }
+
+/// First-play guided tutorial. State null = inactive. Events arriving in the
+/// wrong step are ignored, so engine wiring can fire them unconditionally.
+class TutorialNotifier extends Notifier {
+ @override
+ TutorialStep? build() => null;
+
+ void start() {
+ if (ref.read(saveRepositoryProvider).tutorialDone) return;
+ state = TutorialStep.dragPiece;
+ }
+
+ void onPlaced() {
+ if (state == TutorialStep.dragPiece) state = TutorialStep.clearLine;
+ }
+
+ void onLineCleared() {
+ if (state == TutorialStep.clearLine) state = TutorialStep.explainHud;
+ }
+
+ Future dismissHud() async {
+ if (state != TutorialStep.explainHud) return;
+ await _finish();
+ }
+
+ Future skip() async {
+ if (state == null) return;
+ await _finish();
+ }
+
+ Future _finish() async {
+ state = null;
+ await ref.read(saveRepositoryProvider).markTutorialDone();
+ }
+}
diff --git a/lib/ui/screens/game_screen.dart b/lib/ui/screens/game_screen.dart
index 7e189ff..695dbd9 100644
--- a/lib/ui/screens/game_screen.dart
+++ b/lib/ui/screens/game_screen.dart
@@ -1,7 +1,11 @@
+import 'dart:math' as math;
+
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../game/engine/game_engine.dart';
+import '../../game/models/grid.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../services/audio_service.dart';
import '../../state/game_session_notifier.dart';
@@ -10,9 +14,12 @@ import '../theme/palette.dart';
import '../widgets/board_geometry.dart';
import '../widgets/board_painter.dart';
import '../widgets/board_widget.dart';
+import '../widgets/effects_overlay.dart';
import '../widgets/hud_widget.dart';
import '../widgets/piece_painter.dart';
+import '../widgets/season_background.dart';
import '../widgets/tray_widget.dart';
+import '../widgets/tutorial_overlay.dart';
/// Renders whatever session [gameSessionProvider] holds; callers start the
/// stage (via SeasonFlowNotifier) before navigating here.
@@ -23,9 +30,18 @@ class GameScreen extends ConsumerStatefulWidget {
ConsumerState createState() => _GameScreenState();
}
-class _GameScreenState extends ConsumerState {
+class _GameScreenState extends ConsumerState
+ with TickerProviderStateMixin {
final _boardKey = GlobalKey();
final _stackKey = GlobalKey();
+ final _effectsKey = GlobalKey();
+ late final AnimationController _shake = AnimationController(
+ vsync: this,
+ duration: const Duration(milliseconds: 350),
+ );
+
+ bool _tutorialStartChecked = false;
+ bool _endlessNewBest = false;
int? _dragIndex;
Offset? _dragGlobal;
@@ -93,6 +109,12 @@ class _GameScreenState extends ConsumerState {
}
}
+ @override
+ void dispose() {
+ _shake.dispose();
+ super.dispose();
+ }
+
void _onSessionChange(GameViewState? prev, GameViewState? next) {
if (next == null) return;
final audio = ref.read(audioServiceProvider);
@@ -100,19 +122,57 @@ class _GameScreenState extends ConsumerState {
final placement = next.lastPlacement!;
if (placement.linesCleared > 0) {
audio.play(placement.comboStreak >= 2 ? Sfx.combo : Sfx.clear);
+ HapticFeedback.mediumImpact();
+ if (placement.comboStreak >= 4) {
+ HapticFeedback.heavyImpact();
+ _shake.forward(from: 0);
+ }
} else {
audio.play(Sfx.place);
+ HapticFeedback.lightImpact();
+ }
+ ref.read(tutorialProvider.notifier).onPlaced();
+ if (placement.linesCleared > 0) {
+ ref.read(tutorialProvider.notifier).onLineCleared();
+ }
+ final boardBox = _boardBox;
+ final stackBox =
+ _stackKey.currentContext?.findRenderObject() as RenderBox?;
+ if (boardBox != null && stackBox != null) {
+ final topLeft =
+ stackBox.globalToLocal(boardBox.localToGlobal(Offset.zero));
+ _effectsKey.currentState?.onPlacement(
+ placement,
+ boardRect: topLeft & boardBox.size,
+ );
}
}
if (prev?.phase != next.phase) {
+ // A finished stage ends the tutorial; otherwise the overlay would sit
+ // on top of the result card and leak into the next stage.
+ if (next.phase != GamePhase.playing) {
+ ref.read(tutorialProvider.notifier).skip();
+ }
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) {
+ _effectsKey.currentState?.onWin(stackBox.size);
+ }
}
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());
}
@@ -136,64 +196,136 @@ class _GameScreenState extends ConsumerState {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
+ final tutorialStep = ref.watch(tutorialProvider);
+ if (!_tutorialStartChecked) {
+ _tutorialStartChecked = true;
+ final flow = ref.read(seasonFlowProvider);
+ if (flow != null &&
+ flow.index == 0 &&
+ !ref.read(saveRepositoryProvider).tutorialDone) {
+ WidgetsBinding.instance.addPostFrameCallback(
+ (_) => ref.read(tutorialProvider.notifier).start());
+ }
+ }
+
final ghost = _ghost(view);
final draggedTopLeft = _draggedPieceTopLeft(view);
final boardBox = _boardBox;
+ final theme = ref.watch(activeThemeProvider);
return Scaffold(
- body: SafeArea(
- child: Stack(
- key: _stackKey,
- children: [
- Padding(
- padding: const EdgeInsets.all(16),
- child: Column(
- children: [
- HudWidget(view: view),
- Expanded(
- child: Center(
- child: BoardWidget(
- key: _boardKey,
- view: view,
- ghost: ghost,
+ backgroundColor: Colors.transparent,
+ body: Stack(
+ fit: StackFit.expand,
+ children: [
+ SeasonBackground(theme: theme),
+ SafeArea(
+ child: Stack(
+ key: _stackKey,
+ children: [
+ Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ children: [
+ HudWidget(view: view),
+ Expanded(
+ child: Center(
+ child: AnimatedBuilder(
+ animation: _shake,
+ builder: (context, child) {
+ final t = _shake.value;
+ final dx =
+ math.sin(t * math.pi * 10) * 6 * (1 - t);
+ return Transform.translate(
+ offset: Offset(dx, 0), child: child);
+ },
+ child: BoardWidget(
+ key: _boardKey,
+ view: view,
+ ghost: ghost,
+ ),
+ ),
+ ),
),
+ TrayWidget(
+ tray: view.tray,
+ draggingIndex: _dragIndex,
+ onDragStart: (index, global) => setState(() {
+ _dragIndex = index;
+ _dragGlobal = global;
+ }),
+ onDragUpdate: (global) =>
+ setState(() => _dragGlobal = global),
+ onDragEnd: () => _onDragEnd(view),
+ ),
+ ],
+ ),
+ ),
+ if (_dragIndex != null &&
+ draggedTopLeft != null &&
+ boardBox != null &&
+ _dragIndex! < view.tray.length)
+ _draggedPieceOverlay(view, draggedTopLeft, boardBox),
+ Positioned.fill(child: EffectsOverlay(key: _effectsKey)),
+ if (view.phase != GamePhase.playing) _resultOverlay(view),
+ if (tutorialStep != null)
+ Positioned.fill(
+ child: TutorialOverlay(
+ step: tutorialStep,
+ handFrom: _tutorialHandFrom(),
+ handTo: _tutorialHandTo(view),
+ onSkip: () => ref.read(tutorialProvider.notifier).skip(),
+ onDismissHud: () =>
+ ref.read(tutorialProvider.notifier).dismissHud(),
),
),
- TrayWidget(
- tray: view.tray,
- draggingIndex: _dragIndex,
- onDragStart: (index, global) => setState(() {
- _dragIndex = index;
- _dragGlobal = global;
- }),
- onDragUpdate: (global) =>
- setState(() => _dragGlobal = global),
- onDragEnd: () => _onDragEnd(view),
+ if (Navigator.of(context).canPop())
+ Positioned(
+ top: 4,
+ left: 4,
+ child: IconButton(
+ icon: const Icon(Icons.close, color: Colors.white54),
+ onPressed: () => Navigator.of(context).pop(),
+ ),
),
- ],
- ),
+ ],
),
- if (_dragIndex != null &&
- draggedTopLeft != null &&
- boardBox != null &&
- _dragIndex! < view.tray.length)
- _draggedPieceOverlay(view, draggedTopLeft, boardBox),
- if (view.phase != GamePhase.playing) _resultOverlay(view),
- if (Navigator.of(context).canPop())
- Positioned(
- top: 4,
- left: 4,
- child: IconButton(
- icon: const Icon(Icons.close, color: Colors.white54),
- onPressed: () => Navigator.of(context).pop(),
- ),
- ),
- ],
- ),
+ ),
+ ],
),
);
}
+ Offset _tutorialHandFrom() {
+ final stackBox =
+ _stackKey.currentContext?.findRenderObject() as RenderBox?;
+ if (stackBox == null) return Offset.zero;
+ // Tray sits at the bottom; aim at the left slot.
+ final size = stackBox.size;
+ return Offset(size.width * 0.18, size.height - 80);
+ }
+
+ Offset _tutorialHandTo(GameViewState view) {
+ final boardBox = _boardBox;
+ final stackBox =
+ _stackKey.currentContext?.findRenderObject() as RenderBox?;
+ if (boardBox == null || stackBox == null || view.tray.isEmpty) {
+ return Offset.zero;
+ }
+ final geo = BoardGeometry(boardSize: boardBox.size.width);
+ final notifier = ref.read(gameSessionProvider.notifier);
+ for (var y = 0; y < GridState.size; y++) {
+ for (var x = 0; x < GridState.size; x++) {
+ if (notifier.canPlaceAt(0, x, y)) {
+ final local =
+ Offset((x + 0.5) * geo.cellSize, (y + 0.5) * geo.cellSize);
+ return stackBox.globalToLocal(boardBox.localToGlobal(local));
+ }
+ }
+ }
+ return Offset.zero;
+ }
+
Widget _draggedPieceOverlay(
GameViewState view, Offset topLeftGlobal, RenderBox boardBox) {
final stackBox =
@@ -257,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,
[
@@ -286,16 +430,66 @@ class _GameScreenState extends ConsumerState {
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < 3; i++)
- Icon(
- Icons.star,
- size: 40,
- color: i < view.starsEarned
- ? Colors.amber
- : Colors.white24,
+ TweenAnimationBuilder(
+ key: ValueKey(i),
+ tween: Tween(begin: 0, end: 1),
+ duration: Duration(milliseconds: 400 + i * 250),
+ curve: Interval(i * 0.22, 1, curve: Curves.elasticOut),
+ builder: (context, v, child) =>
+ Transform.scale(scale: v, child: child),
+ child: Icon(
+ Icons.star,
+ size: 44,
+ color: i < view.starsEarned ? Colors.amber : Colors.white24,
+ ),
),
],
),
],
+ 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,
+ height: 88,
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ CircularProgressIndicator(
+ value: view.objectiveProgress,
+ strokeWidth: 7,
+ backgroundColor: Colors.white12,
+ color: Colors.amber,
+ ),
+ Center(
+ child: Text(
+ l10n.almostThere((view.objectiveProgress * 100).round()),
+ textAlign: TextAlign.center,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ style: theme.textTheme.labelSmall,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
const SizedBox(height: 20),
...actions,
],
diff --git a/lib/ui/screens/home_screen.dart b/lib/ui/screens/home_screen.dart
index d91d944..47a6863 100644
--- a/lib/ui/screens/home_screen.dart
+++ b/lib/ui/screens/home_screen.dart
@@ -1,64 +1,152 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
+import '../../game/models/season.dart';
+import '../../game/models/stage.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../state/providers.dart';
+import '../widgets/season_background.dart';
+import 'game_screen.dart';
import 'season_map_screen.dart';
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
+ static const _logoColors = [
+ Color(0xFFFF7EB3),
+ Color(0xFFFFD166),
+ Color(0xFF6FCDF5),
+ Color(0xFF7EDB9C),
+ ];
+
@override
Widget build(BuildContext context, WidgetRef ref) {
final l10n = AppLocalizations.of(context)!;
final streak = ref.watch(streakProvider);
+ final best = ref.watch(endlessBestProvider);
+
return Scaffold(
- body: SafeArea(
- child: Center(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Text(
- l10n.appTitle,
- style: Theme.of(context).textTheme.displaySmall?.copyWith(
- fontWeight: FontWeight.bold,
+ backgroundColor: Colors.transparent,
+ body: Stack(
+ fit: StackFit.expand,
+ children: [
+ const SeasonBackground(theme: SeasonTheme.fallback),
+ SafeArea(
+ child: Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ _logoMark(),
+ const SizedBox(height: 18),
+ Text(
+ l10n.appTitle,
+ style: Theme.of(context)
+ .textTheme
+ .displaySmall
+ ?.copyWith(fontWeight: FontWeight.w900),
+ ),
+ if (streak.current > 0) ...[
+ const SizedBox(height: 10),
+ Chip(
+ avatar: const Icon(
+ Icons.local_fire_department,
+ color: Colors.deepOrange,
+ size: 20,
+ ),
+ label: Text(
+ '${streak.current}',
+ style: Theme.of(context).textTheme.titleMedium,
+ ),
),
- ),
- if (streak.current > 0) ...[
- const SizedBox(height: 12),
- Chip(
- avatar: const Icon(
- Icons.local_fire_department,
- color: Colors.deepOrange,
- size: 20,
- ),
- label: Text(
- '${streak.current}',
- style: Theme.of(context).textTheme.titleMedium,
- ),
- ),
- ],
- const SizedBox(height: 48),
- FilledButton(
- style: FilledButton.styleFrom(
- padding: const EdgeInsets.symmetric(
- horizontal: 48,
- vertical: 16,
- ),
- textStyle: Theme.of(context).textTheme.titleLarge,
- ),
- onPressed: () {
- Navigator.of(context).push(
- MaterialPageRoute(
- builder: (_) => const SeasonMapScreen(),
+ ],
+ const SizedBox(height: 44),
+ FilledButton(
+ style: FilledButton.styleFrom(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 56, vertical: 18),
+ textStyle: Theme.of(context).textTheme.titleLarge,
),
- );
- },
- child: Text(l10n.play),
+ onPressed: () {
+ if (!(ModalRoute.of(context)?.isCurrent ?? true)) return;
+ Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => const SeasonMapScreen()),
+ );
+ },
+ child: Text(l10n.adventure),
+ ),
+ const SizedBox(height: 14),
+ OutlinedButton(
+ style: OutlinedButton.styleFrom(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 40, vertical: 14),
+ textStyle: Theme.of(context).textTheme.titleMedium,
+ ),
+ onPressed: () {
+ if (!(ModalRoute.of(context)?.isCurrent ?? true)) return;
+ ref.read(seasonFlowProvider.notifier).clear();
+ ref.read(gameSessionProvider.notifier).startStage(
+ StageConfig.endless(
+ seed: DateTime.now().millisecondsSinceEpoch,
+ ),
+ );
+ Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => const GameScreen()),
+ );
+ },
+ child: Text(l10n.classic),
+ ),
+ if (best > 0) ...[
+ const SizedBox(height: 10),
+ Text(
+ l10n.bestScore(best),
+ style: TextStyle(
+ color: Colors.white.withValues(alpha: 0.55),
+ ),
+ ),
+ ],
+ ],
),
- ],
+ ),
),
- ),
+ ],
+ ),
+ );
+ }
+
+ Widget _logoMark() {
+ return SizedBox(
+ width: 96,
+ height: 96,
+ child: GridView.count(
+ crossAxisCount: 2,
+ mainAxisSpacing: 5,
+ crossAxisSpacing: 5,
+ physics: const NeverScrollableScrollPhysics(),
+ children: [
+ for (final color in _logoColors)
+ DecoratedBox(
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(11),
+ gradient: LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: [
+ Color.lerp(color, Colors.white, 0.28)!,
+ color,
+ Color.lerp(color, Colors.black, 0.22)!,
+ ],
+ ),
+ boxShadow: [
+ BoxShadow(
+ color: color.withValues(alpha: 0.45),
+ blurRadius: 14,
+ ),
+ ],
+ ),
+ ),
+ ],
),
);
}
diff --git a/lib/ui/screens/season_map_screen.dart b/lib/ui/screens/season_map_screen.dart
index 9bd26dd..4776bb0 100644
--- a/lib/ui/screens/season_map_screen.dart
+++ b/lib/ui/screens/season_map_screen.dart
@@ -4,10 +4,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../game/models/season.dart';
import '../../state/providers.dart';
import '../theme/palette.dart';
+import '../widgets/map_layout.dart';
+import '../widgets/season_background.dart';
+import '../widgets/tile_painter.dart';
import 'game_screen.dart';
-/// Stage selection for the active season. Themed map art lands in Phase 6;
-/// for now a clean node grid with stars and locks.
+/// Journey map: a serpentine path of stage nodes climbing the season
+/// illustration. Auto-scrolls to the current stage on entry.
class SeasonMapScreen extends ConsumerWidget {
const SeasonMapScreen({super.key});
@@ -18,126 +21,281 @@ class SeasonMapScreen extends ConsumerWidget {
loading: () =>
const Scaffold(body: Center(child: CircularProgressIndicator())),
error: (e, _) => Scaffold(body: Center(child: Text('$e'))),
- data: (list) => _Map(pack: list.first),
+ data: (list) => _JourneyMap(pack: list.first),
);
}
}
-class _Map extends ConsumerWidget {
- const _Map({required this.pack});
+class _JourneyMap extends ConsumerStatefulWidget {
+ const _JourneyMap({required this.pack});
final SeasonPack pack;
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ ConsumerState<_JourneyMap> createState() => _JourneyMapState();
+}
+
+class _JourneyMapState extends ConsumerState<_JourneyMap> {
+ final _scroll = ScrollController();
+ bool _autoScrolled = false;
+
+ @override
+ void dispose() {
+ _scroll.dispose();
+ super.dispose();
+ }
+
+ void _autoScrollTo(
+ MapLayout layout, int current, int count, double viewportHeight) {
+ if (_autoScrolled || !_scroll.hasClients) return;
+ _autoScrolled = true;
+ final contentH = layout.heightFor(count);
+ final target =
+ (contentH - layout.nodeCenter(current, count).dy - viewportHeight / 2)
+ .clamp(0.0, _scroll.position.maxScrollExtent);
+ _scroll.jumpTo(target);
+ }
+
+ @override
+ Widget build(BuildContext context) {
// Watching progress keeps stars/locks fresh after each win.
ref.watch(progressProvider);
+ final pack = widget.pack;
final repo = ref.read(saveRepositoryProvider);
final ids = [for (final stage in pack.stages) stage.id];
final unlocked = repo.highestUnlockedIndex(pack.seasonId, ids);
final totalStars = repo.totalStars(pack.seasonId);
+ final seasonComplete = totalStars == pack.stages.length * 3 &&
+ pack.stages.isNotEmpty;
final locale = Localizations.localeOf(context).languageCode;
+ final colors = ThemeColors(pack.theme);
return Scaffold(
- appBar: AppBar(
- title: Text(pack.titleFor(locale)),
- actions: [
- Padding(
- padding: const EdgeInsets.only(right: 16),
- child: Center(
- child: Text(
- '★ $totalStars/${pack.stages.length * 3}',
- style: Theme.of(context)
- .textTheme
- .titleMedium
- ?.copyWith(color: Colors.amber),
+ backgroundColor: Colors.transparent,
+ body: Stack(
+ fit: StackFit.expand,
+ children: [
+ SeasonBackground(theme: pack.theme),
+ LayoutBuilder(
+ builder: (context, constraints) {
+ final layout = MapLayout(width: constraints.maxWidth);
+ final count = pack.stages.length;
+ if (!_autoScrolled) {
+ WidgetsBinding.instance.addPostFrameCallback((_) =>
+ _autoScrollTo(
+ layout, unlocked, count, constraints.maxHeight));
+ }
+ return SingleChildScrollView(
+ controller: _scroll,
+ reverse: true,
+ child: SizedBox(
+ width: constraints.maxWidth,
+ height: layout.heightFor(count),
+ child: Stack(
+ children: [
+ CustomPaint(
+ size: Size(
+ constraints.maxWidth, layout.heightFor(count)),
+ painter:
+ _PathPainter(layout: layout, count: count),
+ ),
+ for (var i = 0; i < count; i++)
+ _node(
+ context,
+ layout,
+ i,
+ count,
+ unlocked,
+ repo.progressFor(pack.seasonId, ids[i])?.stars ?? 0,
+ colors,
+ seasonComplete,
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ ),
+ // Glass header.
+ Positioned(
+ top: 0,
+ left: 0,
+ right: 0,
+ child: Container(
+ padding: EdgeInsets.only(
+ top: MediaQuery.of(context).padding.top + 6,
+ bottom: 12,
+ left: 8,
+ right: 16,
+ ),
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: [
+ Colors.black.withValues(alpha: 0.45),
+ Colors.transparent,
+ ],
+ ),
+ ),
+ child: Row(
+ children: [
+ IconButton(
+ icon: const Icon(Icons.arrow_back, color: Colors.white),
+ onPressed: () => Navigator.of(context).pop(),
+ ),
+ Expanded(
+ child: Text(
+ pack.titleFor(locale),
+ style: const TextStyle(
+ fontSize: 20,
+ fontWeight: FontWeight.w800,
+ color: Colors.white,
+ ),
+ ),
+ ),
+ Text(
+ '★ $totalStars/${pack.stages.length * 3}',
+ style: const TextStyle(
+ color: Colors.amber,
+ fontWeight: FontWeight.w700,
+ ),
+ ),
+ ],
),
),
),
],
),
- body: GridView.builder(
- padding: const EdgeInsets.all(16),
- gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
- crossAxisCount: 4,
- mainAxisSpacing: 12,
- crossAxisSpacing: 12,
+ );
+ }
+
+ Widget _node(BuildContext context, MapLayout layout, int i, int count,
+ int unlocked, int stars, ThemeColors colors, bool seasonComplete) {
+ final center = layout.nodeCenter(i, count);
+ final isCurrent = i == unlocked && !seasonComplete;
+ final isUnlocked = i <= unlocked;
+ final size = isCurrent ? 64.0 : 52.0;
+
+ return Positioned(
+ key: Key('stage_node_$i'),
+ left: center.dx - size / 2,
+ top: center.dy - size / 2,
+ child: GestureDetector(
+ onTap: !isUnlocked
+ ? null
+ : () {
+ ref
+ .read(seasonFlowProvider.notifier)
+ .startSeasonStage(widget.pack, i);
+ Navigator.of(context).push(
+ MaterialPageRoute(builder: (_) => const GameScreen()),
+ );
+ },
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Container(
+ width: size,
+ height: size,
+ alignment: Alignment.center,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ gradient: isUnlocked
+ ? LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: isCurrent
+ ? [
+ lighten(colors.accent, 0.25),
+ colors.accent,
+ darken(colors.accent, 0.2),
+ ]
+ : [
+ const Color(0xFFFFE9A8),
+ const Color(0xFFFFD166),
+ const Color(0xFFE0AC3B),
+ ],
+ )
+ : null,
+ color: isUnlocked ? null : GamePalette.lockedNode,
+ boxShadow: isCurrent
+ ? [
+ BoxShadow(
+ color: colors.accent.withValues(alpha: 0.7),
+ blurRadius: 22,
+ ),
+ ]
+ : null,
+ ),
+ child: isUnlocked
+ ? Text(
+ '${i + 1}',
+ style: TextStyle(
+ fontSize: isCurrent ? 22 : 17,
+ fontWeight: FontWeight.w900,
+ color: isCurrent
+ ? Colors.white
+ : const Color(0xFF5A4200),
+ ),
+ )
+ : const Icon(Icons.lock, color: Colors.white24, size: 20),
+ ),
+ if (isUnlocked && !isCurrent)
+ Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ for (var s = 0; s < 3; s++)
+ Icon(
+ Icons.star,
+ size: 13,
+ color: s < stars ? Colors.amber : Colors.white24,
+ ),
+ ],
+ ),
+ ],
),
- itemCount: pack.stages.length,
- itemBuilder: (context, i) {
- final progress = repo.progressFor(pack.seasonId, ids[i]);
- final isUnlocked = i <= unlocked;
- return _StageNode(
- number: i + 1,
- stars: progress?.stars ?? 0,
- unlocked: isUnlocked,
- onTap: !isUnlocked
- ? null
- : () {
- ref
- .read(seasonFlowProvider.notifier)
- .startSeasonStage(pack, i);
- Navigator.of(context).push(
- MaterialPageRoute(builder: (_) => const GameScreen()),
- );
- },
- );
- },
),
);
}
}
-class _StageNode extends StatelessWidget {
- const _StageNode({
- required this.number,
- required this.stars,
- required this.unlocked,
- required this.onTap,
- });
+class _PathPainter extends CustomPainter {
+ const _PathPainter({required this.layout, required this.count});
- final int number;
- final int stars;
- final bool unlocked;
- final VoidCallback? onTap;
+ final MapLayout layout;
+ final int count;
@override
- Widget build(BuildContext context) {
- return Material(
- color: unlocked ? GamePalette.emptyCell : GamePalette.boardBackground,
- borderRadius: BorderRadius.circular(14),
- child: InkWell(
- borderRadius: BorderRadius.circular(14),
- onTap: onTap,
- child: unlocked
- ? Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Text(
- '$number',
- style: Theme.of(context)
- .textTheme
- .titleLarge
- ?.copyWith(fontWeight: FontWeight.w800),
- ),
- const SizedBox(height: 4),
- Row(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- for (var s = 0; s < 3; s++)
- Icon(
- Icons.star,
- size: 14,
- color: s < stars ? Colors.amber : Colors.white24,
- ),
- ],
- ),
- ],
- )
- : const Center(
- child: Icon(Icons.lock, color: Colors.white24, size: 22),
- ),
- ),
- );
+ void paint(Canvas canvas, Size size) {
+ if (count < 2) return;
+ final path = Path()
+ ..moveTo(
+ layout.nodeCenter(0, count).dx, layout.nodeCenter(0, count).dy);
+ for (var i = 1; i < count; i++) {
+ final prev = layout.nodeCenter(i - 1, count);
+ final cur = layout.nodeCenter(i, count);
+ final midY = (prev.dy + cur.dy) / 2;
+ path.cubicTo(prev.dx, midY, cur.dx, midY, cur.dx, cur.dy);
+ }
+
+ final paint = Paint()
+ ..color = Colors.white.withValues(alpha: 0.25)
+ ..style = PaintingStyle.stroke
+ ..strokeWidth = 5
+ ..strokeCap = StrokeCap.round;
+
+ // Dash the path manually: short dots every 13px.
+ for (final metric in path.computeMetrics()) {
+ var d = 0.0;
+ while (d < metric.length) {
+ canvas.drawPath(metric.extractPath(d, d + 1.5), paint);
+ d += 13;
+ }
+ }
}
+
+ @override
+ bool shouldRepaint(_PathPainter old) =>
+ old.count != count || old.layout.width != layout.width;
}
diff --git a/lib/ui/screens/season_title_screen.dart b/lib/ui/screens/season_title_screen.dart
new file mode 100644
index 0000000..02b5d5a
--- /dev/null
+++ b/lib/ui/screens/season_title_screen.dart
@@ -0,0 +1,116 @@
+import 'dart:async';
+
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+
+import '../../l10n/gen/app_localizations.dart';
+import '../../state/providers.dart';
+import '../widgets/season_background.dart';
+import 'home_screen.dart';
+
+/// Cold-start interstitial: "SEASON 1 · First Bloom". Tap anywhere or wait
+/// ~1.6s. If content somehow fails to load we bail straight to home.
+class SeasonTitleScreen extends ConsumerStatefulWidget {
+ const SeasonTitleScreen({super.key});
+
+ @override
+ ConsumerState createState() =>
+ _SeasonTitleScreenState();
+}
+
+class _SeasonTitleScreenState extends ConsumerState {
+ Timer? _auto;
+ bool _navigated = false;
+ bool _dataTimerArmed = false;
+
+ void _go() {
+ if (_navigated || !mounted) return;
+ _navigated = true;
+ Navigator.of(context).pushReplacement(
+ MaterialPageRoute(builder: (_) => const HomeScreen()),
+ );
+ }
+
+ @override
+ void dispose() {
+ _auto?.cancel();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final seasons = ref.watch(seasonsProvider);
+ final l10n = AppLocalizations.of(context)!;
+ return seasons.when(
+ loading: () {
+ _auto ??= Timer(const Duration(milliseconds: 2500), _go);
+ return const Scaffold(
+ backgroundColor: Color(0xFF0E1430), body: SizedBox());
+ },
+ error: (e, st) {
+ WidgetsBinding.instance.addPostFrameCallback((_) => _go());
+ return const Scaffold(
+ backgroundColor: Color(0xFF0E1430), body: SizedBox());
+ },
+ data: (list) {
+ if (list.isEmpty) {
+ WidgetsBinding.instance.addPostFrameCallback((_) => _go());
+ return const Scaffold(
+ backgroundColor: Color(0xFF0E1430), body: SizedBox());
+ }
+ if (!_dataTimerArmed) {
+ _dataTimerArmed = true;
+ _auto?.cancel();
+ _auto = Timer(const Duration(milliseconds: 1600), _go);
+ }
+ final pack = list.first;
+ final locale = Localizations.localeOf(context).languageCode;
+ final number = int.tryParse(pack.seasonId.split('_').last) ?? 1;
+ return GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: _go,
+ child: Scaffold(
+ body: Stack(
+ fit: StackFit.expand,
+ children: [
+ SeasonBackground(theme: pack.theme),
+ SafeArea(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Text(
+ '${l10n.seasonLabel} $number',
+ style: TextStyle(
+ letterSpacing: 6,
+ fontSize: 14,
+ color: Colors.white.withValues(alpha: 0.7),
+ ),
+ ),
+ const SizedBox(height: 10),
+ Text(
+ pack.titleFor(locale),
+ style: const TextStyle(
+ fontSize: 38,
+ fontWeight: FontWeight.w900,
+ color: Colors.white,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ l10n.seasonStages(pack.stages.length),
+ style: TextStyle(
+ fontSize: 14,
+ color: Colors.white.withValues(alpha: 0.6),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ },
+ );
+ }
+}
diff --git a/lib/ui/screens/splash_screen.dart b/lib/ui/screens/splash_screen.dart
new file mode 100644
index 0000000..c8b06d5
--- /dev/null
+++ b/lib/ui/screens/splash_screen.dart
@@ -0,0 +1,127 @@
+import 'package:flutter/material.dart';
+
+import 'season_title_screen.dart';
+
+Widget _defaultNextScreen() => const SeasonTitleScreen();
+
+/// Logo-assembly splash: four glossy blocks fly in to form a 2x2 mark, the
+/// wordmark fades in, then we hand off. SaveRepository is already opened in
+/// main() so this doubles as perceived-zero loading time.
+class SplashScreen extends StatefulWidget {
+ const SplashScreen({super.key, this.nextScreen = _defaultNextScreen});
+
+ /// Built when the splash finishes; the season title card task repoints
+ /// the default.
+ final Widget Function() nextScreen;
+
+ @override
+ State createState() => _SplashScreenState();
+}
+
+class _SplashScreenState extends State
+ with SingleTickerProviderStateMixin {
+ late final AnimationController _c = AnimationController(
+ vsync: this,
+ duration: const Duration(milliseconds: 1900),
+ )..addStatusListener((status) {
+ if (status == AnimationStatus.completed && mounted) {
+ Navigator.of(context).pushReplacement(
+ MaterialPageRoute(builder: (_) => widget.nextScreen()),
+ );
+ }
+ });
+
+ /// (color, fly-in direction unit, 2x2 slot unit) per block.
+ static const _blocks = [
+ (Color(0xFFFF7EB3), Offset(-1.2, -0.4), Offset(-0.5, -0.5)),
+ (Color(0xFFFFD166), Offset(1.2, -0.4), Offset(0.5, -0.5)),
+ (Color(0xFF6FCDF5), Offset(-1.2, 0.6), Offset(-0.5, 0.5)),
+ (Color(0xFF7EDB9C), Offset(1.2, 0.6), Offset(0.5, 0.5)),
+ ];
+
+ @override
+ void initState() {
+ super.initState();
+ _c.forward();
+ }
+
+ @override
+ void dispose() {
+ _c.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ const blockSize = 46.0;
+ const gap = 3.0;
+ return Scaffold(
+ backgroundColor: const Color(0xFF0E1430),
+ body: SizedBox.expand(
+ child: AnimatedBuilder(
+ animation: _c,
+ builder: (context, _) {
+ final titleT = const Interval(0.60, 0.88, curve: Curves.easeOut)
+ .transform(_c.value);
+ return Stack(
+ alignment: Alignment.center,
+ children: [
+ for (var i = 0; i < _blocks.length; i++)
+ _block(i, blockSize, gap),
+ Transform.translate(
+ offset: Offset(0, 78 + 12 * (1 - titleT)),
+ child: Opacity(
+ opacity: titleT,
+ child: const Text(
+ 'BLOCK SEASONS',
+ style: TextStyle(
+ fontSize: 26,
+ fontWeight: FontWeight.w900,
+ letterSpacing: 4,
+ color: Colors.white,
+ ),
+ ),
+ ),
+ ),
+ ],
+ );
+ },
+ ),
+ ),
+ );
+ }
+
+ Widget _block(int i, double size, double gap) {
+ final (color, from, to) = _blocks[i];
+ final t = Interval(0.06 * i, 0.45 + 0.06 * i, curve: Curves.easeOutBack)
+ .transform(_c.value);
+ final begin = Offset(from.dx * 160, from.dy * 280);
+ final end = Offset(to.dx * (size + gap), to.dy * (size + gap));
+ final pos = Offset.lerp(begin, end, t)!;
+ return Transform.translate(
+ offset: pos,
+ child: Container(
+ width: size,
+ height: size,
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(11),
+ gradient: LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: [
+ Color.lerp(color, Colors.white, 0.28)!,
+ color,
+ Color.lerp(color, Colors.black, 0.22)!,
+ ],
+ ),
+ boxShadow: [
+ BoxShadow(
+ color: color.withValues(alpha: 0.55),
+ blurRadius: 18,
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/ui/theme/palette.dart b/lib/ui/theme/palette.dart
index 45ea3f3..9a2b8b1 100644
--- a/lib/ui/theme/palette.dart
+++ b/lib/ui/theme/palette.dart
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
+import '../../game/models/season.dart';
+
/// Season-themeable color set. Season 1 default: vivid candy tones on a
/// deep navy board.
class GamePalette {
@@ -20,7 +22,31 @@ class GamePalette {
static Color tile(int colorId) => tileColors[colorId % tileColors.length];
+ static const lockedNode = Color(0xFF232B4A);
static const gem = Color(0xFF7CF5FF);
static const ghostLegal = Color(0x66FFFFFF);
static const ghostIllegal = Color(0x55FF5252);
}
+
+/// Resolved per-season colors for the UI layer. Built from a SeasonTheme;
+/// falls back to the GamePalette constants.
+class ThemeColors {
+ ThemeColors(SeasonTheme theme)
+ : gradient = [for (final c in theme.backgroundGradient) Color(c)],
+ accent = Color(theme.accentColor),
+ particleType = theme.particleType,
+ board = theme.boardTint != null
+ ? Color(theme.boardTint!)
+ : GamePalette.boardBackground,
+ tiles = theme.tilePalette != null
+ ? [for (final c in theme.tilePalette!) Color(c)]
+ : GamePalette.tileColors;
+
+ final List gradient;
+ final Color accent;
+ final String particleType;
+ final Color board;
+ final List tiles;
+
+ Color tile(int colorId) => tiles[colorId % tiles.length];
+}
diff --git a/lib/ui/widgets/board_painter.dart b/lib/ui/widgets/board_painter.dart
index e07e70f..be006a1 100644
--- a/lib/ui/widgets/board_painter.dart
+++ b/lib/ui/widgets/board_painter.dart
@@ -6,6 +6,7 @@ import '../../game/models/piece.dart';
import '../theme/palette.dart';
import 'board_geometry.dart';
import 'piece_painter.dart';
+import 'tile_painter.dart';
/// Drag ghost preview: a piece hovering at a snapped anchor.
class GhostSpec {
@@ -55,27 +56,20 @@ class BoardPainter extends CustomPainter {
for (var x = 0; x < GridState.size; x++) {
final rect = geo.cellRect(x, y).deflate(inset);
final cell = grid.cellAt(x, y);
- final paint = Paint()
- ..color = switch (cell.type) {
- CellType.empty => GamePalette.emptyCell,
- CellType.filled => GamePalette.tile(cell.colorId),
- CellType.gem => GamePalette.emptyCell,
- };
- canvas.drawRRect(RRect.fromRectAndRadius(rect, radius), paint);
-
- if (cell.type == CellType.gem) {
- _paintGem(canvas, rect);
- } else if (cell.type == CellType.filled) {
- final highlight = Paint()
- ..color = Colors.white.withValues(alpha: 0.15);
- canvas.drawRRect(
- RRect.fromRectAndRadius(
- Rect.fromLTWH(
- rect.left, rect.top, rect.width, rect.height * 0.32),
- radius,
- ),
- highlight,
- );
+ switch (cell.type) {
+ case CellType.empty:
+ canvas.drawRRect(
+ RRect.fromRectAndRadius(rect, radius),
+ Paint()..color = GamePalette.emptyCell,
+ );
+ case CellType.filled:
+ paintGlossyTile(canvas, rect, GamePalette.tile(cell.colorId));
+ case CellType.gem:
+ canvas.drawRRect(
+ RRect.fromRectAndRadius(rect, radius),
+ Paint()..color = GamePalette.emptyCell,
+ );
+ _paintGem(canvas, rect);
}
}
}
@@ -111,6 +105,10 @@ class BoardPainter extends CustomPainter {
}
void _paintGem(Canvas canvas, Rect rect) {
+ final glowPaint = Paint()
+ ..color = GamePalette.gem.withValues(alpha: 0.45)
+ ..maskFilter = MaskFilter.blur(BlurStyle.normal, rect.width * 0.25);
+ canvas.drawCircle(rect.center, rect.width * 0.32, glowPaint);
final center = rect.center;
final r = rect.width * 0.32;
final path = Path()
@@ -133,5 +131,7 @@ class BoardPainter extends CustomPainter {
bool shouldRepaint(BoardPainter old) =>
old.grid != grid ||
old.ghost != ghost ||
- old.flashProgress != flashProgress;
+ old.flashProgress != flashProgress ||
+ old.flashRows != flashRows ||
+ old.flashCols != flashCols;
}
diff --git a/lib/ui/widgets/board_widget.dart b/lib/ui/widgets/board_widget.dart
index 106c7d6..b313d34 100644
--- a/lib/ui/widgets/board_widget.dart
+++ b/lib/ui/widgets/board_widget.dart
@@ -24,7 +24,6 @@ class _BoardWidgetState extends State
List _flashRows = const [];
List _flashCols = const [];
- int _comboStreak = 0;
@override
void didUpdateWidget(BoardWidget old) {
@@ -35,7 +34,6 @@ class _BoardWidgetState extends State
placement.linesCleared > 0) {
_flashRows = placement.clearedRows;
_flashCols = placement.clearedCols;
- _comboStreak = placement.comboStreak;
_flash.forward(from: 0);
}
}
@@ -66,26 +64,6 @@ class _BoardWidgetState extends State
flashCols: _flashCols,
),
),
- if (_flash.isAnimating && _comboStreak >= 2)
- Center(
- child: Transform.scale(
- scale: 0.8 + 0.6 * _flash.value,
- child: Opacity(
- opacity: 1 - _flash.value,
- child: Text(
- 'COMBO x$_comboStreak',
- style: TextStyle(
- fontSize: 36,
- fontWeight: FontWeight.w900,
- color: Colors.amber.shade300,
- shadows: const [
- Shadow(blurRadius: 12, color: Colors.black54),
- ],
- ),
- ),
- ),
- ),
- ),
],
);
},
diff --git a/lib/ui/widgets/effects_overlay.dart b/lib/ui/widgets/effects_overlay.dart
new file mode 100644
index 0000000..9a3752e
--- /dev/null
+++ b/lib/ui/widgets/effects_overlay.dart
@@ -0,0 +1,286 @@
+import 'dart:math' as math;
+
+import 'package:flutter/material.dart';
+import 'package:flutter/scheduler.dart';
+
+import '../../game/engine/game_engine.dart';
+import '../../game/engine/game_event.dart';
+import '../../game/models/grid.dart';
+import '../../game/models/piece.dart';
+import '../theme/palette.dart';
+import 'board_geometry.dart';
+
+enum _FxType { spark, popup, combo, confetti, settle }
+
+class _Fx {
+ _Fx(this.type, this.start, {this.pos = Offset.zero, this.data});
+
+ final _FxType type;
+ final Duration start;
+ final Offset pos;
+ final Object? data;
+
+ static const durations = {
+ _FxType.spark: Duration(milliseconds: 600),
+ _FxType.popup: Duration(milliseconds: 900),
+ _FxType.combo: Duration(milliseconds: 1000),
+ _FxType.confetti: Duration(milliseconds: 1800),
+ _FxType.settle: Duration(milliseconds: 140),
+ };
+
+ double progress(Duration now) {
+ final d = durations[type]!;
+ final p = (now - start).inMicroseconds / d.inMicroseconds;
+ return p.clamp(0.0, 1.0);
+ }
+
+ bool done(Duration now) => progress(now) >= 1;
+}
+
+/// Transient game-feel effects above the board: clear sparks, rising score
+/// popups, combo banners, win confetti, placed-piece settle. The game screen
+/// reports events; effects expire on their own and the ticker stops when the
+/// list drains, so widget tests settle normally.
+class EffectsOverlay extends StatefulWidget {
+ const EffectsOverlay({super.key});
+
+ @override
+ State createState() => EffectsOverlayState();
+}
+
+class EffectsOverlayState extends State
+ with SingleTickerProviderStateMixin {
+ late final Ticker _ticker;
+ final List<_Fx> _fx = [];
+ Duration _now = Duration.zero;
+
+ @override
+ void initState() {
+ super.initState();
+ _ticker = createTicker(_tick);
+ }
+
+ @visibleForTesting
+ Ticker get ticker => _ticker;
+
+ @visibleForTesting
+ Duration get now => _now;
+
+ void _tick(Duration elapsed) {
+ setState(() {
+ _now = elapsed;
+ _fx.removeWhere((e) => e.done(elapsed));
+ if (_fx.isEmpty) {
+ _ticker.stop();
+ // Ticker elapsed restarts from zero after stop(); re-anchor so
+ // effects added later don't inherit a stale clock.
+ _now = Duration.zero;
+ }
+ });
+ }
+
+ void _add(_Fx fx) {
+ _fx.add(fx);
+ if (!_ticker.isActive) _ticker.start();
+ }
+
+ /// [boardRect] is the board's rect in this overlay's coordinates.
+ void onPlacement(PlacementResult placement, {required Rect boardRect}) {
+ final geo = BoardGeometry(boardSize: boardRect.width);
+ final origin = boardRect.topLeft;
+
+ for (final event in placement.events) {
+ if (event is PiecePlaced) {
+ _add(_Fx(_FxType.settle, _now,
+ pos: origin +
+ Offset(event.x * geo.cellSize, event.y * geo.cellSize),
+ data: (event.piece, geo.cellSize)));
+ }
+ }
+
+ final cleared = [];
+ for (final y in placement.clearedRows) {
+ for (var x = 0; x < GridState.size; x++) {
+ cleared.add(origin + geo.cellRect(x, y).center);
+ }
+ }
+ for (final x in placement.clearedCols) {
+ for (var y = 0; y < GridState.size; y++) {
+ cleared.add(origin + geo.cellRect(x, y).center);
+ }
+ }
+ for (final c in cleared) {
+ _add(_Fx(_FxType.spark, _now, pos: c, data: geo.cellSize));
+ }
+
+ if (placement.pointsGained > 0 && placement.linesCleared > 0) {
+ final at = cleared.isEmpty
+ ? boardRect.center
+ : cleared[cleared.length ~/ 2];
+ _add(_Fx(_FxType.popup, _now,
+ pos: at, data: '+${placement.pointsGained}'));
+ }
+
+ if (placement.comboStreak >= 2) {
+ _add(_Fx(_FxType.combo, _now,
+ pos: boardRect.center, data: placement.comboStreak));
+ }
+ }
+
+ void onWin(Size screenSize) {
+ for (var i = 0; i < 36; i++) {
+ _add(_Fx(_FxType.confetti, _now,
+ pos: Offset(screenSize.width * hash(i, 1), -12), data: i));
+ }
+ }
+
+ /// Deterministic pseudo-random in [0, 1) from an index.
+ static double hash(int i, double salt) {
+ final v = math.sin(i * 12.9898 + salt) * 43758.5453;
+ return v - v.floorToDouble();
+ }
+
+ @override
+ void dispose() {
+ _ticker.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return IgnorePointer(
+ child: CustomPaint(
+ size: Size.infinite,
+ painter: _FxPainter(List.of(_fx), _now),
+ ),
+ );
+ }
+}
+
+class _FxPainter extends CustomPainter {
+ const _FxPainter(this.fx, this.now);
+
+ final List<_Fx> fx;
+ final Duration now;
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ for (final e in fx) {
+ final t = e.progress(now);
+ switch (e.type) {
+ case _FxType.spark:
+ _spark(canvas, e, t);
+ case _FxType.popup:
+ _popup(canvas, e, t);
+ case _FxType.combo:
+ _combo(canvas, e, t);
+ case _FxType.confetti:
+ _confetti(canvas, e, t, size);
+ case _FxType.settle:
+ _settle(canvas, e, t);
+ }
+ }
+ }
+
+ void _spark(Canvas canvas, _Fx e, double t) {
+ final cell = e.data as double;
+ for (var i = 0; i < 6; i++) {
+ final angle =
+ i * math.pi / 3 + EffectsOverlayState.hash(i, 7) * 0.8;
+ final dist = cell * (0.3 + 1.2 * Curves.easeOut.transform(t));
+ final pos = e.pos + Offset(math.cos(angle), math.sin(angle)) * dist;
+ canvas.drawCircle(
+ pos,
+ cell * 0.09 * (1 - t),
+ Paint()..color = Colors.white.withValues(alpha: (1 - t) * 0.9),
+ );
+ }
+ }
+
+ void _popup(Canvas canvas, _Fx e, double t) {
+ final rise = 44 * Curves.easeOut.transform(t);
+ _text(canvas, e.data as String, e.pos - Offset(0, rise),
+ fontSize: 22, color: Colors.white.withValues(alpha: 1 - t * t));
+ }
+
+ void _combo(Canvas canvas, _Fx e, double t) {
+ final streak = e.data as int;
+ final color = streak >= 6
+ ? const Color(0xFFB980FF)
+ : streak >= 4
+ ? const Color(0xFFFF8A4D)
+ : const Color(0xFFFFD166);
+ final scale =
+ t < 0.25 ? Curves.easeOutBack.transform(t / 0.25) : 1.0;
+ final alpha = t > 0.7 ? (1 - t) / 0.3 : 1.0;
+ canvas.save();
+ canvas.translate(e.pos.dx, e.pos.dy - 30);
+ canvas.scale(scale);
+ _text(canvas, 'COMBO ×$streak', Offset.zero,
+ fontSize: streak >= 4 ? 40 : 34,
+ color: color.withValues(alpha: alpha.clamp(0.0, 1.0)));
+ canvas.restore();
+ }
+
+ void _confetti(Canvas canvas, _Fx e, double t, Size size) {
+ final i = e.data as int;
+ final colors = GamePalette.tileColors;
+ final x = e.pos.dx + 28 * math.sin(t * 4 * math.pi + i);
+ final y = t * (size.height + 40);
+ canvas.save();
+ canvas.translate(x, y);
+ canvas.rotate(
+ t * 6 * math.pi * (EffectsOverlayState.hash(i, 3) - 0.5));
+ canvas.drawRect(
+ Rect.fromCenter(center: Offset.zero, width: 9, height: 5),
+ Paint()
+ ..color = colors[i % colors.length]
+ .withValues(alpha: (1 - t * 0.6).clamp(0.0, 1.0)),
+ );
+ canvas.restore();
+ }
+
+ void _settle(Canvas canvas, _Fx e, double t) {
+ final (piece, cellSize) = e.data as (Piece, double);
+ final scale = 1.08 - 0.08 * Curves.easeOut.transform(t);
+ final alpha = 0.35 * (1 - t);
+ for (final (dx, dy) in piece.offsets) {
+ final rect = Rect.fromLTWH(
+ e.pos.dx + dx * cellSize,
+ e.pos.dy + dy * cellSize,
+ cellSize,
+ cellSize,
+ );
+ final scaled = Rect.fromCenter(
+ center: rect.center,
+ width: rect.width * scale,
+ height: rect.height * scale,
+ ).deflate(cellSize * 0.05);
+ canvas.drawRRect(
+ RRect.fromRectAndRadius(scaled, Radius.circular(cellSize * 0.18)),
+ Paint()..color = Colors.white.withValues(alpha: alpha),
+ );
+ }
+ }
+
+ void _text(Canvas canvas, String s, Offset center,
+ {required double fontSize, required Color color}) {
+ final painter = TextPainter(
+ text: TextSpan(
+ text: s,
+ style: TextStyle(
+ fontSize: fontSize,
+ fontWeight: FontWeight.w900,
+ color: color,
+ shadows: const [Shadow(blurRadius: 14, color: Colors.black87)],
+ ),
+ ),
+ textDirection: TextDirection.ltr,
+ )..layout();
+ painter.paint(
+ canvas, center - Offset(painter.width / 2, painter.height / 2));
+ }
+
+ @override
+ bool shouldRepaint(_FxPainter old) => true;
+}
diff --git a/lib/ui/widgets/hud_widget.dart b/lib/ui/widgets/hud_widget.dart
index 5639beb..1a8a3a0 100644
--- a/lib/ui/widgets/hud_widget.dart
+++ b/lib/ui/widgets/hud_widget.dart
@@ -16,7 +16,13 @@ class HudWidget extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
- _movesChip(theme),
+ Visibility(
+ visible: !view.endless,
+ maintainSize: true,
+ maintainAnimation: true,
+ maintainState: true,
+ child: _movesChip(theme),
+ ),
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, anim) =>
diff --git a/lib/ui/widgets/map_layout.dart b/lib/ui/widgets/map_layout.dart
new file mode 100644
index 0000000..0db87c7
--- /dev/null
+++ b/lib/ui/widgets/map_layout.dart
@@ -0,0 +1,29 @@
+import 'dart:math' as math;
+import 'dart:ui';
+
+/// Deterministic serpentine layout for the journey map. Stage 0 is at the
+/// bottom; the path snakes upward. Works for any stage count.
+class MapLayout {
+ const MapLayout({
+ required this.width,
+ this.nodeSpacing = 108,
+ this.topPadding = 140,
+ this.bottomPadding = 150,
+ });
+
+ final double width;
+ final double nodeSpacing;
+ final double topPadding;
+ final double bottomPadding;
+
+ double get amplitude => width * 0.26;
+
+ double heightFor(int count) =>
+ topPadding + bottomPadding + (count - 1) * nodeSpacing;
+
+ Offset nodeCenter(int index, int count) {
+ final y = heightFor(count) - bottomPadding - index * nodeSpacing;
+ final x = width / 2 + amplitude * math.sin(index * 1.05);
+ return Offset(x, y);
+ }
+}
diff --git a/lib/ui/widgets/piece_painter.dart b/lib/ui/widgets/piece_painter.dart
index d0f5793..59ca681 100644
--- a/lib/ui/widgets/piece_painter.dart
+++ b/lib/ui/widgets/piece_painter.dart
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../game/models/piece.dart';
import '../theme/palette.dart';
+import 'tile_painter.dart';
/// Draws a piece as rounded tiles at a given cell size; reused by the tray,
/// the drag overlay, and ghost previews.
@@ -12,8 +13,6 @@ void paintPiece(
Offset origin = Offset.zero,
Color? overrideColor,
}) {
- final paint = Paint()
- ..color = overrideColor ?? GamePalette.tile(piece.colorId);
final inset = cellSize * 0.05;
final radius = Radius.circular(cellSize * 0.18);
for (final (dx, dy) in piece.offsets) {
@@ -23,17 +22,13 @@ void paintPiece(
cellSize - inset * 2,
cellSize - inset * 2,
);
- canvas.drawRRect(RRect.fromRectAndRadius(rect, radius), paint);
- if (overrideColor == null) {
- // Subtle top highlight for depth.
- final highlight = Paint()..color = Colors.white.withValues(alpha: 0.18);
+ if (overrideColor != null) {
canvas.drawRRect(
- RRect.fromRectAndRadius(
- Rect.fromLTWH(rect.left, rect.top, rect.width, rect.height * 0.32),
- radius,
- ),
- highlight,
+ RRect.fromRectAndRadius(rect, radius),
+ Paint()..color = overrideColor,
);
+ } else {
+ paintGlossyTile(canvas, rect, GamePalette.tile(piece.colorId));
}
}
}
diff --git a/lib/ui/widgets/season_background.dart b/lib/ui/widgets/season_background.dart
new file mode 100644
index 0000000..ffb9161
--- /dev/null
+++ b/lib/ui/widgets/season_background.dart
@@ -0,0 +1,141 @@
+import 'dart:math' as math;
+
+import 'package:flutter/material.dart';
+
+import '../../game/models/season.dart';
+import '../theme/palette.dart';
+
+/// Set true in tests (flutter_test_config.dart): looping ambience would make
+/// pumpAndSettle spin forever.
+bool debugDisableLoopingAnimations = false;
+
+/// Full-screen season ambience: vertical gradient plus drifting particles
+/// (petals for season 1). Pure procedural — no image assets required; an AI
+/// illustration layer can be added on top later without touching callers.
+class SeasonBackground extends StatefulWidget {
+ const SeasonBackground({super.key, required this.theme});
+
+ final SeasonTheme theme;
+
+ @override
+ State createState() => _SeasonBackgroundState();
+}
+
+class _SeasonBackgroundState extends State
+ with SingleTickerProviderStateMixin {
+ late final AnimationController _drift = AnimationController(
+ vsync: this,
+ duration: const Duration(seconds: 18),
+ );
+
+ late ThemeColors _colors = ThemeColors(widget.theme);
+
+ @override
+ void initState() {
+ super.initState();
+ if (!debugDisableLoopingAnimations) _drift.repeat();
+ }
+
+ @override
+ void didUpdateWidget(SeasonBackground old) {
+ super.didUpdateWidget(old);
+ if (old.theme != widget.theme) _colors = ThemeColors(widget.theme);
+ }
+
+ @override
+ void dispose() {
+ _drift.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return RepaintBoundary(
+ child: AnimatedBuilder(
+ animation: _drift,
+ builder: (context, _) => CustomPaint(
+ size: Size.infinite,
+ painter: _AmbiencePainter(colors: _colors, t: _drift.value),
+ ),
+ ),
+ );
+ }
+}
+
+class _AmbiencePainter extends CustomPainter {
+ const _AmbiencePainter({required this.colors, required this.t});
+
+ final ThemeColors colors;
+ final double t;
+
+ static const _particles = 9;
+
+ // Deterministic pseudo-random in [0, 1) from an index.
+ static double _hash(int i, double salt) {
+ final v = math.sin(i * 12.9898 + salt) * 43758.5453;
+ return v - v.floorToDouble();
+ }
+
+ @override
+ void paint(Canvas canvas, Size size) {
+ final rect = Offset.zero & size;
+ canvas.drawRect(
+ rect,
+ Paint()
+ ..shader = LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: colors.gradient,
+ stops: colors.gradient.length == 3
+ ? const [0.0, 0.55, 1.0]
+ : null,
+ ).createShader(rect),
+ );
+
+ if (colors.particleType == 'none') return;
+ for (var i = 0; i < _particles; i++) {
+ final speed = 0.5 + _hash(i, 1) * 0.6;
+ final phase = _hash(i, 2);
+ final fall = (t * speed + phase) % 1.15 - 0.075;
+ final x = (_hash(i, 3) +
+ 0.05 * math.sin(t * 2 * math.pi + i * 1.7)) *
+ size.width;
+ final y = fall * size.height;
+ final scale = 7 + _hash(i, 4) * 9;
+ final angle = t * 2 * math.pi * (0.4 + _hash(i, 5)) + i;
+ _paintParticle(canvas, Offset(x, y), scale, angle);
+ }
+ }
+
+ void _paintParticle(Canvas canvas, Offset c, double s, double angle) {
+ canvas.save();
+ canvas.translate(c.dx, c.dy);
+ canvas.rotate(angle);
+ final paint = Paint();
+ switch (colors.particleType) {
+ case 'snow':
+ paint.color = Colors.white.withValues(alpha: 0.35);
+ canvas.drawCircle(Offset.zero, s * 0.4, paint);
+ case 'leaves':
+ paint.color = colors.accent.withValues(alpha: 0.35);
+ canvas.drawOval(
+ Rect.fromCenter(center: Offset.zero, width: s, height: s * 0.55),
+ paint);
+ default: // petals
+ paint.color = colors.accent.withValues(alpha: 0.30);
+ canvas.drawOval(
+ Rect.fromCenter(
+ center: Offset(s * 0.18, 0), width: s, height: s * 0.62),
+ paint);
+ canvas.drawOval(
+ Rect.fromCenter(
+ center: Offset(-s * 0.18, 0), width: s, height: s * 0.62),
+ paint);
+ }
+ canvas.restore();
+ }
+
+ @override
+ bool shouldRepaint(_AmbiencePainter old) =>
+ old.t != t || old.colors != colors;
+}
diff --git a/lib/ui/widgets/tile_painter.dart b/lib/ui/widgets/tile_painter.dart
new file mode 100644
index 0000000..9084fc2
--- /dev/null
+++ b/lib/ui/widgets/tile_painter.dart
@@ -0,0 +1,47 @@
+import 'package:flutter/material.dart';
+
+Color lighten(Color c, double amount) => Color.lerp(c, Colors.white, amount)!;
+Color darken(Color c, double amount) => Color.lerp(c, Colors.black, amount)!;
+
+/// Candy-gloss tile: diagonal gradient body, glass top highlight, optional
+/// colored glow (used for gems and clear flashes). Shared by the board,
+/// tray, and drag overlay so every tile in the game matches.
+void paintGlossyTile(
+ Canvas canvas,
+ Rect rect,
+ Color color, {
+ double radiusFactor = 0.18,
+ double glow = 0,
+}) {
+ final radius = Radius.circular(rect.width * radiusFactor);
+ final rrect = RRect.fromRectAndRadius(rect, radius);
+
+ if (glow > 0) {
+ final glowPaint = Paint()
+ ..color = color.withValues(alpha: 0.55 * glow)
+ ..maskFilter =
+ MaskFilter.blur(BlurStyle.normal, rect.width * 0.28 * glow);
+ canvas.drawRRect(rrect.inflate(rect.width * 0.05), glowPaint);
+ }
+
+ final body = Paint()
+ ..shader = LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: [lighten(color, 0.28), color, darken(color, 0.22)],
+ stops: const [0.0, 0.45, 1.0],
+ ).createShader(rect);
+ canvas.drawRRect(rrect, body);
+
+ final highlight = Paint()..color = Colors.white.withValues(alpha: 0.30);
+ final hl = Rect.fromLTWH(
+ rect.left + rect.width * 0.10,
+ rect.top + rect.height * 0.07,
+ rect.width * 0.80,
+ rect.height * 0.30,
+ );
+ canvas.drawRRect(
+ RRect.fromRectAndRadius(hl, Radius.circular(rect.width * 0.12)),
+ highlight,
+ );
+}
diff --git a/lib/ui/widgets/tutorial_overlay.dart b/lib/ui/widgets/tutorial_overlay.dart
new file mode 100644
index 0000000..675481d
--- /dev/null
+++ b/lib/ui/widgets/tutorial_overlay.dart
@@ -0,0 +1,141 @@
+import 'package:flutter/material.dart';
+
+import '../../l10n/gen/app_localizations.dart';
+import '../../state/tutorial_notifier.dart';
+import 'season_background.dart' show debugDisableLoopingAnimations;
+
+/// Non-blocking guidance overlay: dim veil, message bubble, animated hand on
+/// the drag step, skip always available. Input still reaches the game so the
+/// player advances by actually doing the action.
+class TutorialOverlay extends StatefulWidget {
+ const TutorialOverlay({
+ super.key,
+ required this.step,
+ required this.handFrom,
+ required this.handTo,
+ required this.onSkip,
+ required this.onDismissHud,
+ });
+
+ final TutorialStep step;
+
+ /// Hand animation path in this overlay's local coordinates
+ /// (tray slot 0 → suggested board anchor). Only used on dragPiece.
+ final Offset handFrom;
+ final Offset handTo;
+ final VoidCallback onSkip;
+ final VoidCallback onDismissHud;
+
+ @override
+ State createState() => _TutorialOverlayState();
+}
+
+class _TutorialOverlayState extends State
+ with SingleTickerProviderStateMixin {
+ late final AnimationController _hand = AnimationController(
+ vsync: this,
+ duration: const Duration(milliseconds: 1400),
+ );
+
+ @override
+ void initState() {
+ super.initState();
+ if (!debugDisableLoopingAnimations) _hand.repeat();
+ }
+
+ @override
+ void dispose() {
+ _hand.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final l10n = AppLocalizations.of(context)!;
+ final message = switch (widget.step) {
+ TutorialStep.dragPiece => l10n.tutorialDrag,
+ TutorialStep.clearLine => l10n.tutorialClear,
+ TutorialStep.explainHud => l10n.tutorialHud,
+ };
+
+ return Stack(
+ children: [
+ // Veil that lets touches through.
+ IgnorePointer(
+ child: Container(color: Colors.black.withValues(alpha: 0.25)),
+ ),
+ if (widget.step == TutorialStep.dragPiece)
+ Positioned.fill(
+ child: IgnorePointer(
+ child: AnimatedBuilder(
+ animation: _hand,
+ builder: (context, _) {
+ final t = Curves.easeInOut.transform(_hand.value);
+ final pos =
+ Offset.lerp(widget.handFrom, widget.handTo, t)!;
+ final fade =
+ _hand.value < 0.9 ? 1.0 : (1 - _hand.value) * 10;
+ return Stack(
+ children: [
+ Transform.translate(
+ offset: pos,
+ child: Opacity(
+ opacity: fade.clamp(0.0, 1.0),
+ child: const Icon(Icons.touch_app,
+ size: 44, color: Colors.white),
+ ),
+ ),
+ ],
+ );
+ },
+ ),
+ ),
+ ),
+ Positioned(
+ top: 70,
+ left: 24,
+ right: 24,
+ child: Card(
+ color: const Color(0xEE1C2340),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(16),
+ ),
+ child: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ message,
+ textAlign: TextAlign.center,
+ style: const TextStyle(
+ fontSize: 16,
+ fontWeight: FontWeight.w700,
+ color: Colors.white,
+ ),
+ ),
+ if (widget.step == TutorialStep.explainHud) ...[
+ const SizedBox(height: 10),
+ FilledButton(
+ onPressed: widget.onDismissHud,
+ child: Text(l10n.gotIt),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ),
+ ),
+ Positioned(
+ top: 8,
+ right: 8,
+ child: TextButton(
+ onPressed: widget.onSkip,
+ child: Text(l10n.skip,
+ style: const TextStyle(color: Colors.white54)),
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/test/data/save_repository_test.dart b/test/data/save_repository_test.dart
index 7b2ec3c..2e83fd0 100644
--- a/test/data/save_repository_test.dart
+++ b/test/data/save_repository_test.dart
@@ -67,4 +67,40 @@ void main() {
expect(second.progressFor('season_001', 's1')!.stars, 3);
expect(second.progressFor('season_001', 's1')!.bestScore, 777);
});
+
+ group('tutorial flag and endless best', () {
+ test('defaults: tutorial not done, endless best 0', () async {
+ SharedPreferences.setMockInitialValues({});
+ final repo = SaveRepository(await SharedPreferences.getInstance());
+ expect(repo.tutorialDone, isFalse);
+ expect(repo.endlessBest, 0);
+ });
+
+ test('markTutorialDone persists across reload', () async {
+ SharedPreferences.setMockInitialValues({});
+ final prefs = await SharedPreferences.getInstance();
+ await SaveRepository(prefs).markTutorialDone();
+ expect(SaveRepository(prefs).tutorialDone, isTrue);
+ });
+
+ test('recordEndlessScore keeps the max', () async {
+ SharedPreferences.setMockInitialValues({});
+ final prefs = await SharedPreferences.getInstance();
+ final repo = SaveRepository(prefs);
+ await repo.recordEndlessScore(500);
+ await repo.recordEndlessScore(300);
+ expect(repo.endlessBest, 500);
+ expect(SaveRepository(prefs).endlessBest, 500);
+ });
+
+ test('legacy blob without new keys still loads', () async {
+ SharedPreferences.setMockInitialValues({
+ 'save_v1':
+ '{"saveVersion":1,"progress":{},"streak":{"current":0,"best":0,"lastYmd":null}}',
+ });
+ final repo = SaveRepository(await SharedPreferences.getInstance());
+ expect(repo.tutorialDone, isFalse);
+ expect(repo.endlessBest, 0);
+ });
+ });
}
diff --git a/test/flutter_test_config.dart b/test/flutter_test_config.dart
new file mode 100644
index 0000000..0545302
--- /dev/null
+++ b/test/flutter_test_config.dart
@@ -0,0 +1,9 @@
+import 'dart:async';
+
+import 'package:block_seasons/ui/widgets/season_background.dart';
+
+Future testExecutable(FutureOr Function() testMain) async {
+ // Looping ambience animations never settle under pumpAndSettle.
+ debugDisableLoopingAnimations = true;
+ await testMain();
+}
diff --git a/test/game/engine/endless_test.dart b/test/game/engine/endless_test.dart
new file mode 100644
index 0000000..0d5f04f
--- /dev/null
+++ b/test/game/engine/endless_test.dart
@@ -0,0 +1,49 @@
+import 'package:block_seasons/game/engine/game_engine.dart';
+import 'package:block_seasons/game/models/grid.dart';
+import 'package:block_seasons/game/models/stage.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ test('endless stage config has no objectives and the endless flag', () {
+ final stage = StageConfig.endless(seed: 42);
+ expect(stage.endless, isTrue);
+ expect(stage.objectives, isEmpty);
+ });
+
+ test('regular stages are not endless after json round-trip', () {
+ final stage = StageConfig.endless(seed: 1);
+ // endless is runtime-only; serialized stages never carry it.
+ expect(StageConfig.fromJson(stage.toJson()).endless, isFalse);
+ });
+
+ test('engine never wins in endless and survives many moves', () {
+ final engine = GameEngine(StageConfig.endless(seed: 36));
+ var moves = 0;
+ outer:
+ while (engine.phase == GamePhase.playing && moves < 300) {
+ for (var i = 0; i < engine.tray.length; i++) {
+ for (var y = 0; y < GridState.size; y++) {
+ for (var x = 0; x < GridState.size; x++) {
+ if (engine.tryPlaceWouldSucceed(i, x, y)) {
+ engine.tryPlace(i, x, y);
+ moves++;
+ continue outer;
+ }
+ }
+ }
+ }
+ break;
+ }
+ expect(engine.phase, isNot(GamePhase.won));
+ expect(moves, greaterThan(30)); // above any generated stage move limit
+ if (engine.phase == GamePhase.stuck) {
+ expect(engine.stuckReason, StuckReason.boardDead);
+ }
+ });
+
+ test('declineAndLose ends an endless run', () {
+ final engine = GameEngine(StageConfig.endless(seed: 36));
+ engine.declineAndLose();
+ expect(engine.phase, GamePhase.lost);
+ });
+}
diff --git a/test/game/models/season_test.dart b/test/game/models/season_test.dart
index ba61c3f..554b291 100644
--- a/test/game/models/season_test.dart
+++ b/test/game/models/season_test.dart
@@ -41,7 +41,24 @@ void main() {
test('round-trips to JSON', () {
final pack = SeasonPack.fromJson(packJson);
- expect(pack.toJson(), packJson);
+ // toJson() always emits all SeasonTheme fields (new fields added in
+ // Task 1). Compare everything except theme separately so that adding
+ // more theme fields in the future only requires updating theme tests.
+ final json = pack.toJson();
+ expect(json['schemaVersion'], packJson['schemaVersion']);
+ expect(json['seasonId'], packJson['seasonId']);
+ expect(json['version'], packJson['version']);
+ expect(json['title'], packJson['title']);
+ expect(json['stages'], packJson['stages']);
+ // Theme: legacy fields preserved, new fields present with defaults.
+ final theme = json['theme'] as Map;
+ expect(theme['tileSet'], 'spring');
+ expect(theme['background'], 'background.webp');
+ expect(theme['backgroundGradient'], SeasonTheme.defaultGradient);
+ expect(theme['accentColor'], 0xFFFF7EB3);
+ expect(theme['particleType'], 'petals');
+ expect(theme.containsKey('tilePalette'), isFalse);
+ expect(theme.containsKey('boardTint'), isFalse);
});
test('localized title falls back to English', () {
@@ -58,4 +75,42 @@ void main() {
);
});
});
+
+ group('SeasonTheme visuals', () {
+ test('legacy theme json (tileSet/background only) gets defaults', () {
+ final theme = SeasonTheme.fromJson({
+ 'tileSet': 'spring',
+ 'background': 'background.webp',
+ });
+ expect(theme.backgroundGradient, SeasonTheme.defaultGradient);
+ expect(theme.accentColor, 0xFFFF7EB3);
+ expect(theme.particleType, 'petals');
+ expect(theme.tilePalette, isNull);
+ expect(theme.boardTint, isNull);
+ });
+
+ test('full theme json round-trips', () {
+ final theme = SeasonTheme(
+ tileSet: 'summer',
+ background: 'bg.webp',
+ backgroundGradient: const [0xFF0A2430, 0xFF10394A, 0xFF1E5A66],
+ accentColor: 0xFF6FCDF5,
+ particleType: 'snow',
+ tilePalette: const [0xFF111111, 0xFF222222],
+ boardTint: 0xFF041016,
+ );
+ final decoded = SeasonTheme.fromJson(theme.toJson());
+ expect(decoded.backgroundGradient, theme.backgroundGradient);
+ expect(decoded.accentColor, theme.accentColor);
+ expect(decoded.particleType, theme.particleType);
+ expect(decoded.tilePalette, theme.tilePalette);
+ expect(decoded.boardTint, theme.boardTint);
+ });
+
+ test('fallback constant matches season 1 defaults', () {
+ expect(SeasonTheme.fallback.backgroundGradient,
+ const [0xFF0E1430, 0xFF16204A, 0xFF2A2E5E]);
+ expect(SeasonTheme.fallback.particleType, 'petals');
+ });
+ });
}
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);
+ });
+}
diff --git a/test/state/season_flow_test.dart b/test/state/season_flow_test.dart
index 53bc8e3..35fc5bc 100644
--- a/test/state/season_flow_test.dart
+++ b/test/state/season_flow_test.dart
@@ -77,4 +77,20 @@ void main() {
expect(container.read(seasonFlowProvider)!.hasNext, isFalse);
expect(container.read(gameSessionProvider)!.phase, GamePhase.playing);
});
+
+ test('clear() resets flow to null so Classic mode has no stale season context',
+ () async {
+ final container = await _container();
+ final flow = container.read(seasonFlowProvider.notifier);
+ flow.startSeasonStage(_pack(), 0);
+
+ // State is non-null after starting a season stage.
+ expect(container.read(seasonFlowProvider), isNotNull);
+
+ flow.clear();
+
+ // After clear(), the flow must be null so GameScreen's tutorial/theme
+ // checks can't fire for a Classic (endless) session.
+ expect(container.read(seasonFlowProvider), isNull);
+ });
}
diff --git a/test/state/tutorial_notifier_test.dart b/test/state/tutorial_notifier_test.dart
new file mode 100644
index 0000000..b721f62
--- /dev/null
+++ b/test/state/tutorial_notifier_test.dart
@@ -0,0 +1,60 @@
+import 'package:block_seasons/data/save_repository.dart';
+import 'package:block_seasons/state/providers.dart';
+import 'package:block_seasons/state/tutorial_notifier.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+void main() {
+ late ProviderContainer container;
+ late SaveRepository repo;
+
+ setUp(() async {
+ SharedPreferences.setMockInitialValues({});
+ repo = SaveRepository(await SharedPreferences.getInstance());
+ container = ProviderContainer(
+ overrides: [saveRepositoryProvider.overrideWithValue(repo)],
+ );
+ });
+
+ tearDown(() => container.dispose());
+
+ test('inactive by default', () {
+ expect(container.read(tutorialProvider), isNull);
+ });
+
+ test('happy path: drag -> clear -> hud -> done, persists flag', () async {
+ final n = container.read(tutorialProvider.notifier);
+ n.start();
+ expect(container.read(tutorialProvider), TutorialStep.dragPiece);
+ n.onPlaced();
+ expect(container.read(tutorialProvider), TutorialStep.clearLine);
+ n.onLineCleared();
+ expect(container.read(tutorialProvider), TutorialStep.explainHud);
+ await n.dismissHud();
+ expect(container.read(tutorialProvider), isNull);
+ expect(repo.tutorialDone, isTrue);
+ });
+
+ test('out-of-order events are ignored', () {
+ final n = container.read(tutorialProvider.notifier);
+ n.start();
+ n.onLineCleared(); // not in clearLine step yet
+ expect(container.read(tutorialProvider), TutorialStep.dragPiece);
+ });
+
+ test('skip finishes from any step and persists', () async {
+ final n = container.read(tutorialProvider.notifier);
+ n.start();
+ await n.skip();
+ expect(container.read(tutorialProvider), isNull);
+ expect(repo.tutorialDone, isTrue);
+ });
+
+ test('start is a no-op when tutorial already done', () async {
+ await repo.markTutorialDone();
+ final n = container.read(tutorialProvider.notifier);
+ n.start();
+ expect(container.read(tutorialProvider), isNull);
+ });
+}
diff --git a/test/ui/effects_overlay_test.dart b/test/ui/effects_overlay_test.dart
new file mode 100644
index 0000000..3813a3d
--- /dev/null
+++ b/test/ui/effects_overlay_test.dart
@@ -0,0 +1,66 @@
+import 'package:block_seasons/game/engine/game_engine.dart';
+import 'package:block_seasons/ui/widgets/effects_overlay.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+PlacementResult _clearResult() => const PlacementResult(
+ events: [],
+ pointsGained: 250,
+ linesCleared: 1,
+ gemsCleared: 0,
+ clearedRows: [3],
+ clearedCols: [],
+ comboStreak: 2,
+ );
+
+void main() {
+ testWidgets('second batch after drain completes within its own duration',
+ (tester) async {
+ final key = GlobalKey();
+ await tester.pumpWidget(MaterialApp(
+ home:
+ Stack(children: [Positioned.fill(child: EffectsOverlay(key: key))]),
+ ));
+
+ const board = Rect.fromLTWH(0, 0, 320, 320);
+
+ // ── First batch ──────────────────────────────────────────────────────────
+ key.currentState!.onPlacement(_clearResult(), boardRect: board);
+ await tester.pumpAndSettle();
+
+ // Ticker must be stopped after the first drain.
+ expect(
+ key.currentState!.ticker.isActive,
+ isFalse,
+ reason: 'ticker should be idle after first batch drains',
+ );
+
+ // _now must be Duration.zero after drain (regression for stale-clock bug).
+ // Without the fix, _now stays frozen at the elapsed value from the last
+ // tick of the first batch (~1000 ms).
+ expect(
+ key.currentState!.now,
+ Duration.zero,
+ reason:
+ '_now must be reset to zero when the list drains so the next '
+ 'batch starts from a clean clock',
+ );
+
+ // ── Second batch ─────────────────────────────────────────────────────────
+ // With the stale-clock bug the effects get start: ~1000ms, but the
+ // restarted ticker delivers elapsed from 0, so progress stays 0 forever
+ // and they never drain. We verify the second batch completes via
+ // pumpAndSettle (which would time out if the ticker ran forever).
+ key.currentState!.onPlacement(_clearResult(), boardRect: board);
+ await tester.pump(const Duration(milliseconds: 100));
+ await tester.pumpAndSettle();
+
+ expect(
+ tester.hasRunningAnimations,
+ isFalse,
+ reason:
+ 'second batch must finish; stale clock would keep effects frozen '
+ 'and the ticker running indefinitely',
+ );
+ });
+}
diff --git a/test/ui/goldens/game_screen.png b/test/ui/goldens/game_screen.png
index 83df2ec..47ffbef 100644
Binary files a/test/ui/goldens/game_screen.png and b/test/ui/goldens/game_screen.png differ
diff --git a/test/ui/map_layout_test.dart b/test/ui/map_layout_test.dart
new file mode 100644
index 0000000..61cb9fe
--- /dev/null
+++ b/test/ui/map_layout_test.dart
@@ -0,0 +1,32 @@
+import 'package:block_seasons/ui/widgets/map_layout.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ const layout = MapLayout(width: 400);
+
+ test('node 0 sits near the bottom, later nodes climb', () {
+ final h = layout.heightFor(60);
+ final first = layout.nodeCenter(0, 60);
+ final last = layout.nodeCenter(59, 60);
+ expect(first.dy, greaterThan(h - 200));
+ expect(last.dy, lessThan(200));
+ for (var i = 1; i < 60; i++) {
+ expect(layout.nodeCenter(i, 60).dy,
+ lessThan(layout.nodeCenter(i - 1, 60).dy));
+ }
+ });
+
+ test('x stays within horizontal margins', () {
+ for (var i = 0; i < 60; i++) {
+ final x = layout.nodeCenter(i, 60).dx;
+ expect(x, greaterThanOrEqualTo(400 * 0.12));
+ expect(x, lessThanOrEqualTo(400 * 0.88));
+ }
+ });
+
+ test('vertical spacing is uniform', () {
+ final a = layout.nodeCenter(3, 60).dy - layout.nodeCenter(4, 60).dy;
+ final b = layout.nodeCenter(40, 60).dy - layout.nodeCenter(41, 60).dy;
+ expect(a, closeTo(b, 0.001));
+ });
+}
diff --git a/test/ui/season_background_test.dart b/test/ui/season_background_test.dart
new file mode 100644
index 0000000..aac2aac
--- /dev/null
+++ b/test/ui/season_background_test.dart
@@ -0,0 +1,25 @@
+import 'package:block_seasons/game/models/season.dart';
+import 'package:block_seasons/ui/widgets/season_background.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ testWidgets('renders and settles with looping animations disabled',
+ (tester) async {
+ await tester.pumpWidget(const MaterialApp(
+ home: SeasonBackground(theme: SeasonTheme.fallback),
+ ));
+ await tester.pumpAndSettle();
+ expect(find.byType(SeasonBackground), findsOneWidget);
+ });
+
+ testWidgets('particleType none still renders', (tester) async {
+ await tester.pumpWidget(const MaterialApp(
+ home: SeasonBackground(
+ theme: SeasonTheme(particleType: 'none'),
+ ),
+ ));
+ await tester.pumpAndSettle();
+ expect(find.byType(CustomPaint), findsWidgets);
+ });
+}
diff --git a/test/ui/season_map_screen_test.dart b/test/ui/season_map_screen_test.dart
index 2f28a98..c0a19f2 100644
--- a/test/ui/season_map_screen_test.dart
+++ b/test/ui/season_map_screen_test.dart
@@ -71,13 +71,25 @@ void main() {
);
await tester.pumpAndSettle();
+ // Total stars displayed in header.
expect(find.text('★ 2/9'), findsOneWidget);
- expect(find.byIcon(Icons.lock), findsOneWidget); // stage 3 locked
- expect(find.text('1'), findsOneWidget);
- expect(find.text('2'), findsOneWidget);
- // Stage 1 is starred, so stage 2 is unlocked and playable.
- await tester.tap(find.text('2'));
+ // Node 0 (stage 1) exists.
+ expect(find.byKey(const Key('stage_node_0')), findsOneWidget);
+
+ // Stage 3 (index 2) is locked — contains a lock icon.
+ expect(
+ find.descendant(
+ of: find.byKey(const Key('stage_node_2')),
+ matching: find.byIcon(Icons.lock),
+ ),
+ findsOneWidget,
+ );
+
+ // Stage 1 is starred, so stage 2 (index 1) is unlocked and playable.
+ // Ensure the node is visible before tapping.
+ await tester.ensureVisible(find.byKey(const Key('stage_node_1')));
+ await tester.tap(find.byKey(const Key('stage_node_1')));
await tester.pumpAndSettle();
expect(find.byType(GameScreen), findsOneWidget);
diff --git a/test/ui/season_title_screen_test.dart b/test/ui/season_title_screen_test.dart
new file mode 100644
index 0000000..5c403b2
--- /dev/null
+++ b/test/ui/season_title_screen_test.dart
@@ -0,0 +1,68 @@
+import 'package:block_seasons/data/save_repository.dart';
+import 'package:block_seasons/game/models/season.dart';
+import 'package:block_seasons/game/models/stage.dart';
+import 'package:block_seasons/l10n/gen/app_localizations.dart';
+import 'package:block_seasons/state/providers.dart';
+import 'package:block_seasons/ui/screens/home_screen.dart';
+import 'package:block_seasons/ui/screens/season_title_screen.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:shared_preferences/shared_preferences.dart';
+
+SeasonPack _pack() => SeasonPack(
+ schemaVersion: 1,
+ seasonId: 'season_001',
+ version: 1,
+ title: const {'en': 'First Bloom', 'ko': '첫 개화'},
+ theme: SeasonTheme.fallback,
+ stages: [
+ StageConfig(
+ id: 's1',
+ seed: 1,
+ moveLimit: 10,
+ preset: const [],
+ objectives: const [],
+ stars: const StarThresholds(twoMovesLeft: 2, threeMovesLeft: 4),
+ generatorProfile: 'mid',
+ ),
+ ],
+ );
+
+Widget _app(SaveRepository repo) => ProviderScope(
+ overrides: [
+ saveRepositoryProvider.overrideWithValue(repo),
+ seasonsProvider.overrideWith((ref) async => [_pack()]),
+ ],
+ child: const MaterialApp(
+ localizationsDelegates: AppLocalizations.localizationsDelegates,
+ supportedLocales: AppLocalizations.supportedLocales,
+ home: SeasonTitleScreen(),
+ ),
+ );
+
+void main() {
+ testWidgets('shows season title then auto-advances to home',
+ (tester) async {
+ SharedPreferences.setMockInitialValues({});
+ final repo = SaveRepository(await SharedPreferences.getInstance());
+ await tester.pumpWidget(_app(repo));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 100));
+ expect(find.text('First Bloom'), findsOneWidget);
+ await tester.pump(const Duration(seconds: 2));
+ await tester.pumpAndSettle();
+ expect(find.byType(HomeScreen), findsOneWidget);
+ });
+
+ testWidgets('tap skips immediately', (tester) async {
+ SharedPreferences.setMockInitialValues({});
+ final repo = SaveRepository(await SharedPreferences.getInstance());
+ await tester.pumpWidget(_app(repo));
+ await tester.pump();
+ await tester.pump(const Duration(milliseconds: 100));
+ await tester.tap(find.text('First Bloom'));
+ await tester.pumpAndSettle();
+ expect(find.byType(HomeScreen), findsOneWidget);
+ });
+}
diff --git a/test/ui/splash_screen_test.dart b/test/ui/splash_screen_test.dart
new file mode 100644
index 0000000..f76bc54
--- /dev/null
+++ b/test/ui/splash_screen_test.dart
@@ -0,0 +1,19 @@
+import 'package:block_seasons/ui/screens/splash_screen.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+ testWidgets('splash wordmark is horizontally centered', (tester) async {
+ await tester.pumpWidget(MaterialApp(
+ home: SplashScreen(nextScreen: () => const Scaffold(body: SizedBox())),
+ ));
+ // Mid-animation: wordmark already laid out.
+ await tester.pump(const Duration(milliseconds: 1500));
+ final screenWidth = tester.getSize(find.byType(SplashScreen)).width;
+ final wordmark = tester.getCenter(find.text('BLOCK SEASONS'));
+ expect(wordmark.dx, closeTo(screenWidth / 2, 1.0));
+ // Drain the rest of the animation so no timers leak.
+ await tester.pump(const Duration(milliseconds: 600));
+ await tester.pumpAndSettle();
+ });
+}
diff --git a/test/widget_test.dart b/test/widget_test.dart
index 05be38c..fe2671a 100644
--- a/test/widget_test.dart
+++ b/test/widget_test.dart
@@ -16,9 +16,14 @@ void main() {
child: const BlockSeasonsApp(),
),
);
+ // Splash (~1.9s) → (later: season title card) → home.
+ await tester.pump(const Duration(milliseconds: 2100));
+ await tester.pump(const Duration(milliseconds: 2000));
+ await tester.pump(const Duration(milliseconds: 2000));
await tester.pumpAndSettle();
expect(find.text('Block Seasons'), findsOneWidget);
- expect(find.text('Play'), findsOneWidget);
+ expect(find.text('Adventure'), findsOneWidget);
+ expect(find.text('Classic'), findsOneWidget);
});
}