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); }); }