Merge Phase 3.5: commercial polish round

Glossy tiles + season theme system, juice kit, intro flow
(splash/season card/tutorial), serpentine journey map,
endless classic mode, redesigned home.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 07:39:27 +09:00
49 changed files with 2302 additions and 283 deletions
+1
View File
@@ -47,3 +47,4 @@ app.*.map.json
# Generated localizations
lib/l10n/gen/
.superpowers/
CLAUDE.md
@@ -1,12 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
<item>
<shape android:shape="rectangle">
<solid android:color="#FF0E1430"/>
</shape>
</item>
</layer-list>
@@ -1,12 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
<item>
<shape android:shape="rectangle">
<solid android:color="#FF0E1430"/>
</shape>
</item>
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

@@ -19,7 +19,7 @@
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
</imageView>
</subviews>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<color key="backgroundColor" red="0.054901960784" green="0.078431372549" blue="0.188235294118" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
<constraints>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
+2 -2
View File
@@ -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(),
);
}
}
+21
View File
@@ -34,6 +34,11 @@ class SaveRepository {
lastYmd: streak['lastYmd'] as String?,
);
}
_tutorialDone =
(json['flags'] as Map<String, dynamic>?)?['tutorialDone'] as bool? ??
false;
_endlessBest =
(json['endless'] as Map<String, dynamic>?)?['best'] as int? ?? 0;
}
}
@@ -45,8 +50,22 @@ class SaveRepository {
final SharedPreferences _prefs;
final Map<String, StageProgress> _progress = {};
StreakState _streak = StreakState.initial;
bool _tutorialDone = false;
int _endlessBest = 0;
StreakState get streak => _streak;
bool get tutorialDone => _tutorialDone;
int get endlessBest => _endlessBest;
Future<void> markTutorialDone() {
_tutorialDone = true;
return _flush();
}
Future<void> recordEndlessScore(int score) {
if (score > _endlessBest) _endlessBest = score;
return _flush();
}
Future<void> saveStreak(StreakState streak) {
_streak = streak;
@@ -111,6 +130,8 @@ class SaveRepository {
'best': _streak.best,
'lastYmd': _streak.lastYmd,
},
'flags': {'tutorialDone': _tutorialDone},
'endless': {'best': _endlessBest},
}),
);
}
+8 -3
View File
@@ -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<Objective> 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)) {
+49 -5
View File
@@ -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<String, dynamic> 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<String, dynamic> toJson() =>
{'tileSet': tileSet, 'background': background};
/// Top-to-bottom screen gradient, int ARGB.
final List<int> backgroundGradient;
final int accentColor;
/// petals | snow | leaves | none
final String particleType;
/// Optional tile color override; null = built-in candy palette.
final List<int>? tilePalette;
/// Optional board background override.
final int? boardTint;
Map<String, dynamic> 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
+17
View File
@@ -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<String, dynamic> 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) {
+35 -1
View File
@@ -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"
}
+14 -1
View File
@@ -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": "클래식"
}
+18
View File
@@ -0,0 +1,18 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'providers.dart';
/// Reactive view over SaveRepository's endless best score.
class EndlessBestNotifier extends Notifier<int> {
@override
int build() => ref.read(saveRepositoryProvider).endlessBest;
/// Records the run; returns true when it set a new best.
Future<bool> record(int score) async {
final repo = ref.read(saveRepositoryProvider);
final isNewBest = score > state;
await repo.recordEndlessScore(score);
state = repo.endlessBest;
return isNewBest;
}
}
+5 -1
View File
@@ -23,6 +23,7 @@ class GameViewState {
required this.objectiveProgress,
required this.lastPlacement,
required this.fxTick,
required this.endless,
});
final GridState grid;
@@ -40,6 +41,8 @@ class GameViewState {
/// Increments on every accepted placement so animations can retrigger.
final int fxTick;
final bool endless;
}
/// Bridges the pure-Dart [GameEngine] to the widget tree. The engine object
@@ -107,7 +110,8 @@ class GameSessionNotifier extends Notifier<GameViewState?> {
score: engine.score,
comboStreak: engine.combo.streak,
movesLeft: engine.movesLeft,
moveLimit: engine.movesLeft + engine.movesUsed,
endless: engine.endless,
moveLimit: engine.endless ? 0 : engine.movesLeft + engine.movesUsed,
phase: engine.phase,
stuckReason: engine.stuckReason,
objectives: engine.objectives,
+17
View File
@@ -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<GameSessionNotifier, GameViewState?>(
@@ -45,3 +47,18 @@ final seasonsProvider = FutureProvider<List<SeasonPack>>(
final streakProvider = NotifierProvider<StreakNotifier, StreakState>(
StreakNotifier.new,
);
final tutorialProvider = NotifierProvider<TutorialNotifier, TutorialStep?>(
TutorialNotifier.new,
);
final endlessBestProvider = NotifierProvider<EndlessBestNotifier, int>(
EndlessBestNotifier.new,
);
/// The visual theme of whatever season is in play; fallback outside seasons
/// (home, endless). Pure model — UI converts via ThemeColors.
final activeThemeProvider = Provider<SeasonTheme>((ref) {
final flow = ref.watch(seasonFlowProvider);
return flow?.pack.theme ?? SeasonTheme.fallback;
});
+4
View File
@@ -41,4 +41,8 @@ class SeasonFlowNotifier extends Notifier<SeasonFlow?> {
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;
}
+40
View File
@@ -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<TutorialStep?> {
@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<void> dismissHud() async {
if (state != TutorialStep.explainHud) return;
await _finish();
}
Future<void> skip() async {
if (state == null) return;
await _finish();
}
Future<void> _finish() async {
state = null;
await ref.read(saveRepositoryProvider).markTutorialDone();
}
}
+248 -54
View File
@@ -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<GameScreen> createState() => _GameScreenState();
}
class _GameScreenState extends ConsumerState<GameScreen> {
class _GameScreenState extends ConsumerState<GameScreen>
with TickerProviderStateMixin {
final _boardKey = GlobalKey();
final _stackKey = GlobalKey();
final _effectsKey = GlobalKey<EffectsOverlayState>();
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<GameScreen> {
}
}
@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<GameScreen> {
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<GameScreen> {
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<GameScreen> {
),
],
),
(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<GameScreen> {
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<double>(
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,
],
+130 -42
View File
@@ -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,
),
],
),
),
],
),
);
}
+250 -92
View File
@@ -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;
}
+116
View File
@@ -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<SeasonTitleScreen> createState() =>
_SeasonTitleScreenState();
}
class _SeasonTitleScreenState extends ConsumerState<SeasonTitleScreen> {
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),
),
),
],
),
),
],
),
),
);
},
);
}
}
+127
View File
@@ -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<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen>
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,
),
],
),
),
);
}
}
+26
View File
@@ -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<Color> gradient;
final Color accent;
final String particleType;
final Color board;
final List<Color> tiles;
Color tile(int colorId) => tiles[colorId % tiles.length];
}
+22 -22
View File
@@ -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;
}
-22
View File
@@ -24,7 +24,6 @@ class _BoardWidgetState extends State<BoardWidget>
List<int> _flashRows = const [];
List<int> _flashCols = const [];
int _comboStreak = 0;
@override
void didUpdateWidget(BoardWidget old) {
@@ -35,7 +34,6 @@ class _BoardWidgetState extends State<BoardWidget>
placement.linesCleared > 0) {
_flashRows = placement.clearedRows;
_flashCols = placement.clearedCols;
_comboStreak = placement.comboStreak;
_flash.forward(from: 0);
}
}
@@ -66,26 +64,6 @@ class _BoardWidgetState extends State<BoardWidget>
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),
],
),
),
),
),
),
],
);
},
+286
View File
@@ -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<EffectsOverlay> createState() => EffectsOverlayState();
}
class EffectsOverlayState extends State<EffectsOverlay>
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 = <Offset>[];
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;
}
+7 -1
View File
@@ -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) =>
+29
View File
@@ -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);
}
}
+6 -11
View File
@@ -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));
}
}
}
+141
View File
@@ -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<SeasonBackground> createState() => _SeasonBackgroundState();
}
class _SeasonBackgroundState extends State<SeasonBackground>
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;
}
+47
View File
@@ -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,
);
}
+141
View File
@@ -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<TutorialOverlay> createState() => _TutorialOverlayState();
}
class _TutorialOverlayState extends State<TutorialOverlay>
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)),
),
),
],
);
}
}
+36
View File
@@ -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);
});
});
}
+9
View File
@@ -0,0 +1,9 @@
import 'dart:async';
import 'package:block_seasons/ui/widgets/season_background.dart';
Future<void> testExecutable(FutureOr<void> Function() testMain) async {
// Looping ambience animations never settle under pumpAndSettle.
debugDisableLoopingAnimations = true;
await testMain();
}
+49
View File
@@ -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);
});
}
+56 -1
View File
@@ -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<String, dynamic>;
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');
});
});
}
+25
View File
@@ -0,0 +1,25 @@
import 'package:block_seasons/data/save_repository.dart';
import 'package:block_seasons/state/providers.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
test('exposes saved best and reports new records', () async {
SharedPreferences.setMockInitialValues({});
final repo = SaveRepository(await SharedPreferences.getInstance());
await repo.recordEndlessScore(400);
final container = ProviderContainer(
overrides: [saveRepositoryProvider.overrideWithValue(repo)],
);
addTearDown(container.dispose);
expect(container.read(endlessBestProvider), 400);
final n = container.read(endlessBestProvider.notifier);
expect(await n.record(300), isFalse);
expect(container.read(endlessBestProvider), 400);
expect(await n.record(900), isTrue);
expect(container.read(endlessBestProvider), 900);
});
}
+16
View File
@@ -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);
});
}
+60
View File
@@ -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);
});
}
+66
View File
@@ -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<EffectsOverlayState>();
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',
);
});
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 41 KiB

+32
View File
@@ -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));
});
}
+25
View File
@@ -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);
});
}
+17 -5
View File
@@ -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);
+68
View File
@@ -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);
});
}
+19
View File
@@ -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();
});
}
+6 -1
View File
@@ -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);
});
}