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:
@@ -47,3 +47,4 @@ app.*.map.json
|
|||||||
# Generated localizations
|
# Generated localizations
|
||||||
lib/l10n/gen/
|
lib/l10n/gen/
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
CLAUDE.md
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-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">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="?android:colorBackground" />
|
<item>
|
||||||
|
<shape android:shape="rectangle">
|
||||||
<!-- You can insert your own image assets here -->
|
<solid android:color="#FF0E1430"/>
|
||||||
<!-- <item>
|
</shape>
|
||||||
<bitmap
|
</item>
|
||||||
android:gravity="center"
|
|
||||||
android:src="@mipmap/launch_image" />
|
|
||||||
</item> -->
|
|
||||||
</layer-list>
|
</layer-list>
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-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">
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<item android:drawable="@android:color/white" />
|
<item>
|
||||||
|
<shape android:shape="rectangle">
|
||||||
<!-- You can insert your own image assets here -->
|
<solid android:color="#FF0E1430"/>
|
||||||
<!-- <item>
|
</shape>
|
||||||
<bitmap
|
</item>
|
||||||
android:gravity="center"
|
|
||||||
android:src="@mipmap/launch_image" />
|
|
||||||
</item> -->
|
|
||||||
</layer-list>
|
</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 opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||||
</imageView>
|
</imageView>
|
||||||
</subviews>
|
</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>
|
<constraints>
|
||||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
<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"/>
|
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||||
|
|||||||
+2
-2
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
|
|
||||||
import 'l10n/gen/app_localizations.dart';
|
import 'l10n/gen/app_localizations.dart';
|
||||||
import 'ui/screens/home_screen.dart';
|
import 'ui/screens/splash_screen.dart';
|
||||||
|
|
||||||
class BlockSeasonsApp extends StatelessWidget {
|
class BlockSeasonsApp extends StatelessWidget {
|
||||||
const BlockSeasonsApp({super.key});
|
const BlockSeasonsApp({super.key});
|
||||||
@@ -26,7 +26,7 @@ class BlockSeasonsApp extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
),
|
),
|
||||||
home: const HomeScreen(),
|
home: const SplashScreen(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ class SaveRepository {
|
|||||||
lastYmd: streak['lastYmd'] as String?,
|
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 SharedPreferences _prefs;
|
||||||
final Map<String, StageProgress> _progress = {};
|
final Map<String, StageProgress> _progress = {};
|
||||||
StreakState _streak = StreakState.initial;
|
StreakState _streak = StreakState.initial;
|
||||||
|
bool _tutorialDone = false;
|
||||||
|
int _endlessBest = 0;
|
||||||
|
|
||||||
StreakState get streak => _streak;
|
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) {
|
Future<void> saveStreak(StreakState streak) {
|
||||||
_streak = streak;
|
_streak = streak;
|
||||||
@@ -111,6 +130,8 @@ class SaveRepository {
|
|||||||
'best': _streak.best,
|
'best': _streak.best,
|
||||||
'lastYmd': _streak.lastYmd,
|
'lastYmd': _streak.lastYmd,
|
||||||
},
|
},
|
||||||
|
'flags': {'tutorialDone': _tutorialDone},
|
||||||
|
'endless': {'best': _endlessBest},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,12 @@ class GameEngine {
|
|||||||
int get score => _score;
|
int get score => _score;
|
||||||
ComboState get combo => _combo;
|
ComboState get combo => _combo;
|
||||||
int get movesUsed => _movesUsed;
|
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);
|
List<Objective> get objectives => List.unmodifiable(_objectives);
|
||||||
GamePhase get phase => _phase;
|
GamePhase get phase => _phase;
|
||||||
StuckReason? get stuckReason => _stuckReason;
|
StuckReason? get stuckReason => _stuckReason;
|
||||||
@@ -128,7 +133,7 @@ class GameEngine {
|
|||||||
events.fold(obj, (o, event) => o.onEvent(event)),
|
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;
|
_phase = GamePhase.won;
|
||||||
} else {
|
} else {
|
||||||
if (_tray.isEmpty) _tray = _generator.nextTray(_grid);
|
if (_tray.isEmpty) _tray = _generator.nextTray(_grid);
|
||||||
@@ -147,7 +152,7 @@ class GameEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _checkStuck() {
|
void _checkStuck() {
|
||||||
if (movesLeft <= 0) {
|
if (!_stage.endless && movesLeft <= 0) {
|
||||||
_phase = GamePhase.stuck;
|
_phase = GamePhase.stuck;
|
||||||
_stuckReason = StuckReason.outOfMoves;
|
_stuckReason = StuckReason.outOfMoves;
|
||||||
} else if (!anyPlacementExists(_grid, _tray)) {
|
} else if (!anyPlacementExists(_grid, _tray)) {
|
||||||
|
|||||||
@@ -1,18 +1,62 @@
|
|||||||
import 'stage.dart';
|
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 {
|
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(
|
factory SeasonTheme.fromJson(Map<String, dynamic> json) => SeasonTheme(
|
||||||
tileSet: json['tileSet'] as String,
|
tileSet: (json['tileSet'] as String?) ?? 'spring',
|
||||||
background: json['background'] as String,
|
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 tileSet;
|
||||||
final String background;
|
final String background;
|
||||||
|
|
||||||
Map<String, dynamic> toJson() =>
|
/// Top-to-bottom screen gradient, int ARGB.
|
||||||
{'tileSet': tileSet, 'background': background};
|
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
|
/// A season's full content: metadata, theme, and its stages. The unit of
|
||||||
|
|||||||
@@ -74,8 +74,21 @@ class StageConfig {
|
|||||||
required this.objectives,
|
required this.objectives,
|
||||||
required this.stars,
|
required this.stars,
|
||||||
required this.generatorProfile,
|
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(
|
factory StageConfig.fromJson(Map<String, dynamic> json) => StageConfig(
|
||||||
id: json['id'] as String,
|
id: json['id'] as String,
|
||||||
seed: json['seed'] as int,
|
seed: json['seed'] as int,
|
||||||
@@ -100,6 +113,10 @@ class StageConfig {
|
|||||||
final StarThresholds stars;
|
final StarThresholds stars;
|
||||||
final String generatorProfile;
|
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() {
|
GridState initialGrid() {
|
||||||
var grid = GridState.empty();
|
var grid = GridState.empty();
|
||||||
for (final cell in preset) {
|
for (final cell in preset) {
|
||||||
|
|||||||
@@ -20,5 +20,39 @@
|
|||||||
"type": "int"
|
"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
@@ -13,5 +13,18 @@
|
|||||||
"giveUp": "포기하기",
|
"giveUp": "포기하기",
|
||||||
"playAgain": "다시 하기",
|
"playAgain": "다시 하기",
|
||||||
"nextStage": "다음 스테이지",
|
"nextStage": "다음 스테이지",
|
||||||
"streakMilestone": "{days}일 연속 플레이! 대단해요!"
|
"streakMilestone": "{days}일 연속 플레이! 대단해요!",
|
||||||
|
"almostThere": "{percent}% 달성!",
|
||||||
|
"seasonLabel": "SEASON",
|
||||||
|
"seasonStages": "{count}개 스테이지",
|
||||||
|
"skip": "건너뛰기",
|
||||||
|
"gotIt": "알겠어요!",
|
||||||
|
"tutorialDrag": "블록을 보드로 끌어다 놓아보세요!",
|
||||||
|
"tutorialClear": "가로나 세로 한 줄을 채우면 사라져요!",
|
||||||
|
"tutorialHud": "이동 횟수가 끝나기 전에 목표를 달성하세요. 이제 직접!",
|
||||||
|
"gameOver": "게임 오버",
|
||||||
|
"bestScore": "최고 {score}",
|
||||||
|
"newBest": "신기록!",
|
||||||
|
"adventure": "어드벤처",
|
||||||
|
"classic": "클래식"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'providers.dart';
|
||||||
|
|
||||||
|
/// Reactive view over SaveRepository's endless best score.
|
||||||
|
class EndlessBestNotifier extends Notifier<int> {
|
||||||
|
@override
|
||||||
|
int build() => ref.read(saveRepositoryProvider).endlessBest;
|
||||||
|
|
||||||
|
/// Records the run; returns true when it set a new best.
|
||||||
|
Future<bool> record(int score) async {
|
||||||
|
final repo = ref.read(saveRepositoryProvider);
|
||||||
|
final isNewBest = score > state;
|
||||||
|
await repo.recordEndlessScore(score);
|
||||||
|
state = repo.endlessBest;
|
||||||
|
return isNewBest;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ class GameViewState {
|
|||||||
required this.objectiveProgress,
|
required this.objectiveProgress,
|
||||||
required this.lastPlacement,
|
required this.lastPlacement,
|
||||||
required this.fxTick,
|
required this.fxTick,
|
||||||
|
required this.endless,
|
||||||
});
|
});
|
||||||
|
|
||||||
final GridState grid;
|
final GridState grid;
|
||||||
@@ -40,6 +41,8 @@ class GameViewState {
|
|||||||
|
|
||||||
/// Increments on every accepted placement so animations can retrigger.
|
/// Increments on every accepted placement so animations can retrigger.
|
||||||
final int fxTick;
|
final int fxTick;
|
||||||
|
|
||||||
|
final bool endless;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bridges the pure-Dart [GameEngine] to the widget tree. The engine object
|
/// Bridges the pure-Dart [GameEngine] to the widget tree. The engine object
|
||||||
@@ -107,7 +110,8 @@ class GameSessionNotifier extends Notifier<GameViewState?> {
|
|||||||
score: engine.score,
|
score: engine.score,
|
||||||
comboStreak: engine.combo.streak,
|
comboStreak: engine.combo.streak,
|
||||||
movesLeft: engine.movesLeft,
|
movesLeft: engine.movesLeft,
|
||||||
moveLimit: engine.movesLeft + engine.movesUsed,
|
endless: engine.endless,
|
||||||
|
moveLimit: engine.endless ? 0 : engine.movesLeft + engine.movesUsed,
|
||||||
phase: engine.phase,
|
phase: engine.phase,
|
||||||
stuckReason: engine.stuckReason,
|
stuckReason: engine.stuckReason,
|
||||||
objectives: engine.objectives,
|
objectives: engine.objectives,
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import '../data/save_repository.dart';
|
|||||||
import '../data/streak.dart';
|
import '../data/streak.dart';
|
||||||
import '../game/models/season.dart';
|
import '../game/models/season.dart';
|
||||||
import '../services/audio_service.dart';
|
import '../services/audio_service.dart';
|
||||||
|
import 'endless_best_notifier.dart';
|
||||||
import 'game_session_notifier.dart';
|
import 'game_session_notifier.dart';
|
||||||
import 'progress_notifier.dart';
|
import 'progress_notifier.dart';
|
||||||
import 'season_flow_notifier.dart';
|
import 'season_flow_notifier.dart';
|
||||||
import 'streak_notifier.dart';
|
import 'streak_notifier.dart';
|
||||||
|
import 'tutorial_notifier.dart';
|
||||||
|
|
||||||
final gameSessionProvider =
|
final gameSessionProvider =
|
||||||
NotifierProvider<GameSessionNotifier, GameViewState?>(
|
NotifierProvider<GameSessionNotifier, GameViewState?>(
|
||||||
@@ -45,3 +47,18 @@ final seasonsProvider = FutureProvider<List<SeasonPack>>(
|
|||||||
final streakProvider = NotifierProvider<StreakNotifier, StreakState>(
|
final streakProvider = NotifierProvider<StreakNotifier, StreakState>(
|
||||||
StreakNotifier.new,
|
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;
|
||||||
|
});
|
||||||
|
|||||||
@@ -41,4 +41,8 @@ class SeasonFlowNotifier extends Notifier<SeasonFlow?> {
|
|||||||
if (flow == null || !flow.hasNext) return;
|
if (flow == null || !flow.hasNext) return;
|
||||||
startSeasonStage(flow.pack, flow.index + 1);
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../game/engine/game_engine.dart';
|
import '../../game/engine/game_engine.dart';
|
||||||
|
import '../../game/models/grid.dart';
|
||||||
import '../../l10n/gen/app_localizations.dart';
|
import '../../l10n/gen/app_localizations.dart';
|
||||||
import '../../services/audio_service.dart';
|
import '../../services/audio_service.dart';
|
||||||
import '../../state/game_session_notifier.dart';
|
import '../../state/game_session_notifier.dart';
|
||||||
@@ -10,9 +14,12 @@ import '../theme/palette.dart';
|
|||||||
import '../widgets/board_geometry.dart';
|
import '../widgets/board_geometry.dart';
|
||||||
import '../widgets/board_painter.dart';
|
import '../widgets/board_painter.dart';
|
||||||
import '../widgets/board_widget.dart';
|
import '../widgets/board_widget.dart';
|
||||||
|
import '../widgets/effects_overlay.dart';
|
||||||
import '../widgets/hud_widget.dart';
|
import '../widgets/hud_widget.dart';
|
||||||
import '../widgets/piece_painter.dart';
|
import '../widgets/piece_painter.dart';
|
||||||
|
import '../widgets/season_background.dart';
|
||||||
import '../widgets/tray_widget.dart';
|
import '../widgets/tray_widget.dart';
|
||||||
|
import '../widgets/tutorial_overlay.dart';
|
||||||
|
|
||||||
/// Renders whatever session [gameSessionProvider] holds; callers start the
|
/// Renders whatever session [gameSessionProvider] holds; callers start the
|
||||||
/// stage (via SeasonFlowNotifier) before navigating here.
|
/// stage (via SeasonFlowNotifier) before navigating here.
|
||||||
@@ -23,9 +30,18 @@ class GameScreen extends ConsumerStatefulWidget {
|
|||||||
ConsumerState<GameScreen> createState() => _GameScreenState();
|
ConsumerState<GameScreen> createState() => _GameScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _GameScreenState extends ConsumerState<GameScreen> {
|
class _GameScreenState extends ConsumerState<GameScreen>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
final _boardKey = GlobalKey();
|
final _boardKey = GlobalKey();
|
||||||
final _stackKey = 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;
|
int? _dragIndex;
|
||||||
Offset? _dragGlobal;
|
Offset? _dragGlobal;
|
||||||
@@ -93,6 +109,12 @@ class _GameScreenState extends ConsumerState<GameScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_shake.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
void _onSessionChange(GameViewState? prev, GameViewState? next) {
|
void _onSessionChange(GameViewState? prev, GameViewState? next) {
|
||||||
if (next == null) return;
|
if (next == null) return;
|
||||||
final audio = ref.read(audioServiceProvider);
|
final audio = ref.read(audioServiceProvider);
|
||||||
@@ -100,19 +122,57 @@ class _GameScreenState extends ConsumerState<GameScreen> {
|
|||||||
final placement = next.lastPlacement!;
|
final placement = next.lastPlacement!;
|
||||||
if (placement.linesCleared > 0) {
|
if (placement.linesCleared > 0) {
|
||||||
audio.play(placement.comboStreak >= 2 ? Sfx.combo : Sfx.clear);
|
audio.play(placement.comboStreak >= 2 ? Sfx.combo : Sfx.clear);
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
if (placement.comboStreak >= 4) {
|
||||||
|
HapticFeedback.heavyImpact();
|
||||||
|
_shake.forward(from: 0);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
audio.play(Sfx.place);
|
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) {
|
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) {
|
if (next.phase == GamePhase.won) {
|
||||||
audio.play(Sfx.win);
|
audio.play(Sfx.win);
|
||||||
// recordResult keeps the best run, so re-entry is harmless.
|
// recordResult keeps the best run, so re-entry is harmless.
|
||||||
|
if (!next.endless) {
|
||||||
ref
|
ref
|
||||||
.read(seasonFlowProvider.notifier)
|
.read(seasonFlowProvider.notifier)
|
||||||
.recordWin(stars: next.starsEarned, score: next.score);
|
.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) 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) {
|
if (next.phase == GamePhase.won || next.phase == GamePhase.lost) {
|
||||||
ref.read(streakProvider.notifier).onStagePlayed(DateTime.now());
|
ref.read(streakProvider.notifier).onStagePlayed(DateTime.now());
|
||||||
}
|
}
|
||||||
@@ -136,12 +196,30 @@ class _GameScreenState extends ConsumerState<GameScreen> {
|
|||||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
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 ghost = _ghost(view);
|
||||||
final draggedTopLeft = _draggedPieceTopLeft(view);
|
final draggedTopLeft = _draggedPieceTopLeft(view);
|
||||||
final boardBox = _boardBox;
|
final boardBox = _boardBox;
|
||||||
|
|
||||||
|
final theme = ref.watch(activeThemeProvider);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: SafeArea(
|
backgroundColor: Colors.transparent,
|
||||||
|
body: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
SeasonBackground(theme: theme),
|
||||||
|
SafeArea(
|
||||||
child: Stack(
|
child: Stack(
|
||||||
key: _stackKey,
|
key: _stackKey,
|
||||||
children: [
|
children: [
|
||||||
@@ -152,6 +230,15 @@ class _GameScreenState extends ConsumerState<GameScreen> {
|
|||||||
HudWidget(view: view),
|
HudWidget(view: view),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Center(
|
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(
|
child: BoardWidget(
|
||||||
key: _boardKey,
|
key: _boardKey,
|
||||||
view: view,
|
view: view,
|
||||||
@@ -159,6 +246,7 @@ class _GameScreenState extends ConsumerState<GameScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
TrayWidget(
|
TrayWidget(
|
||||||
tray: view.tray,
|
tray: view.tray,
|
||||||
draggingIndex: _dragIndex,
|
draggingIndex: _dragIndex,
|
||||||
@@ -178,7 +266,19 @@ class _GameScreenState extends ConsumerState<GameScreen> {
|
|||||||
boardBox != null &&
|
boardBox != null &&
|
||||||
_dragIndex! < view.tray.length)
|
_dragIndex! < view.tray.length)
|
||||||
_draggedPieceOverlay(view, draggedTopLeft, boardBox),
|
_draggedPieceOverlay(view, draggedTopLeft, boardBox),
|
||||||
|
Positioned.fill(child: EffectsOverlay(key: _effectsKey)),
|
||||||
if (view.phase != GamePhase.playing) _resultOverlay(view),
|
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(),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (Navigator.of(context).canPop())
|
if (Navigator.of(context).canPop())
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 4,
|
top: 4,
|
||||||
@@ -191,9 +291,41 @@ class _GameScreenState extends ConsumerState<GameScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
Widget _draggedPieceOverlay(
|
||||||
GameViewState view, Offset topLeftGlobal, RenderBox boardBox) {
|
GameViewState view, Offset topLeftGlobal, RenderBox boardBox) {
|
||||||
final stackBox =
|
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,
|
l10n.stageFailed,
|
||||||
[
|
[
|
||||||
@@ -286,16 +430,66 @@ class _GameScreenState extends ConsumerState<GameScreen> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
for (var i = 0; i < 3; i++)
|
for (var i = 0; i < 3; i++)
|
||||||
Icon(
|
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,
|
Icons.star,
|
||||||
size: 40,
|
size: 44,
|
||||||
color: i < view.starsEarned
|
color: i < view.starsEarned ? Colors.amber : Colors.white24,
|
||||||
? 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),
|
const SizedBox(height: 20),
|
||||||
...actions,
|
...actions,
|
||||||
],
|
],
|
||||||
|
|||||||
+100
-12
@@ -1,31 +1,52 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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 '../../l10n/gen/app_localizations.dart';
|
||||||
import '../../state/providers.dart';
|
import '../../state/providers.dart';
|
||||||
|
import '../widgets/season_background.dart';
|
||||||
|
import 'game_screen.dart';
|
||||||
import 'season_map_screen.dart';
|
import 'season_map_screen.dart';
|
||||||
|
|
||||||
class HomeScreen extends ConsumerWidget {
|
class HomeScreen extends ConsumerWidget {
|
||||||
const HomeScreen({super.key});
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
|
static const _logoColors = [
|
||||||
|
Color(0xFFFF7EB3),
|
||||||
|
Color(0xFFFFD166),
|
||||||
|
Color(0xFF6FCDF5),
|
||||||
|
Color(0xFF7EDB9C),
|
||||||
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final streak = ref.watch(streakProvider);
|
final streak = ref.watch(streakProvider);
|
||||||
|
final best = ref.watch(endlessBestProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: SafeArea(
|
backgroundColor: Colors.transparent,
|
||||||
|
body: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
const SeasonBackground(theme: SeasonTheme.fallback),
|
||||||
|
SafeArea(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
|
_logoMark(),
|
||||||
|
const SizedBox(height: 18),
|
||||||
Text(
|
Text(
|
||||||
l10n.appTitle,
|
l10n.appTitle,
|
||||||
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
style: Theme.of(context)
|
||||||
fontWeight: FontWeight.bold,
|
.textTheme
|
||||||
),
|
.displaySmall
|
||||||
|
?.copyWith(fontWeight: FontWeight.w900),
|
||||||
),
|
),
|
||||||
if (streak.current > 0) ...[
|
if (streak.current > 0) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 10),
|
||||||
Chip(
|
Chip(
|
||||||
avatar: const Icon(
|
avatar: const Icon(
|
||||||
Icons.local_fire_department,
|
Icons.local_fire_department,
|
||||||
@@ -38,28 +59,95 @@ class HomeScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 48),
|
const SizedBox(height: 44),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 48,
|
horizontal: 56, vertical: 18),
|
||||||
vertical: 16,
|
|
||||||
),
|
|
||||||
textStyle: Theme.of(context).textTheme.titleLarge,
|
textStyle: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
if (!(ModalRoute.of(context)?.isCurrent ?? true)) return;
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => const SeasonMapScreen(),
|
builder: (_) => const SeasonMapScreen()),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: Text(l10n.play),
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../game/models/season.dart';
|
import '../../game/models/season.dart';
|
||||||
import '../../state/providers.dart';
|
import '../../state/providers.dart';
|
||||||
import '../theme/palette.dart';
|
import '../theme/palette.dart';
|
||||||
|
import '../widgets/map_layout.dart';
|
||||||
|
import '../widgets/season_background.dart';
|
||||||
|
import '../widgets/tile_painter.dart';
|
||||||
import 'game_screen.dart';
|
import 'game_screen.dart';
|
||||||
|
|
||||||
/// Stage selection for the active season. Themed map art lands in Phase 6;
|
/// Journey map: a serpentine path of stage nodes climbing the season
|
||||||
/// for now a clean node grid with stars and locks.
|
/// illustration. Auto-scrolls to the current stage on entry.
|
||||||
class SeasonMapScreen extends ConsumerWidget {
|
class SeasonMapScreen extends ConsumerWidget {
|
||||||
const SeasonMapScreen({super.key});
|
const SeasonMapScreen({super.key});
|
||||||
|
|
||||||
@@ -18,126 +21,281 @@ class SeasonMapScreen extends ConsumerWidget {
|
|||||||
loading: () =>
|
loading: () =>
|
||||||
const Scaffold(body: Center(child: CircularProgressIndicator())),
|
const Scaffold(body: Center(child: CircularProgressIndicator())),
|
||||||
error: (e, _) => Scaffold(body: Center(child: Text('$e'))),
|
error: (e, _) => Scaffold(body: Center(child: Text('$e'))),
|
||||||
data: (list) => _Map(pack: list.first),
|
data: (list) => _JourneyMap(pack: list.first),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Map extends ConsumerWidget {
|
class _JourneyMap extends ConsumerStatefulWidget {
|
||||||
const _Map({required this.pack});
|
const _JourneyMap({required this.pack});
|
||||||
|
|
||||||
final SeasonPack pack;
|
final SeasonPack pack;
|
||||||
|
|
||||||
@override
|
@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.
|
// Watching progress keeps stars/locks fresh after each win.
|
||||||
ref.watch(progressProvider);
|
ref.watch(progressProvider);
|
||||||
|
final pack = widget.pack;
|
||||||
final repo = ref.read(saveRepositoryProvider);
|
final repo = ref.read(saveRepositoryProvider);
|
||||||
final ids = [for (final stage in pack.stages) stage.id];
|
final ids = [for (final stage in pack.stages) stage.id];
|
||||||
final unlocked = repo.highestUnlockedIndex(pack.seasonId, ids);
|
final unlocked = repo.highestUnlockedIndex(pack.seasonId, ids);
|
||||||
final totalStars = repo.totalStars(pack.seasonId);
|
final totalStars = repo.totalStars(pack.seasonId);
|
||||||
|
final seasonComplete = totalStars == pack.stages.length * 3 &&
|
||||||
|
pack.stages.isNotEmpty;
|
||||||
final locale = Localizations.localeOf(context).languageCode;
|
final locale = Localizations.localeOf(context).languageCode;
|
||||||
|
final colors = ThemeColors(pack.theme);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
backgroundColor: Colors.transparent,
|
||||||
title: Text(pack.titleFor(locale)),
|
body: Stack(
|
||||||
actions: [
|
fit: StackFit.expand,
|
||||||
Padding(
|
children: [
|
||||||
padding: const EdgeInsets.only(right: 16),
|
SeasonBackground(theme: pack.theme),
|
||||||
child: Center(
|
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(
|
child: Text(
|
||||||
|
pack.titleFor(locale),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
'★ $totalStars/${pack.stages.length * 3}',
|
'★ $totalStars/${pack.stages.length * 3}',
|
||||||
style: Theme.of(context)
|
style: const TextStyle(
|
||||||
.textTheme
|
color: Colors.amber,
|
||||||
.titleMedium
|
fontWeight: FontWeight.w700,
|
||||||
?.copyWith(color: Colors.amber),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: GridView.builder(
|
);
|
||||||
padding: const EdgeInsets.all(16),
|
}
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
|
||||||
crossAxisCount: 4,
|
Widget _node(BuildContext context, MapLayout layout, int i, int count,
|
||||||
mainAxisSpacing: 12,
|
int unlocked, int stars, ThemeColors colors, bool seasonComplete) {
|
||||||
crossAxisSpacing: 12,
|
final center = layout.nodeCenter(i, count);
|
||||||
),
|
final isCurrent = i == unlocked && !seasonComplete;
|
||||||
itemCount: pack.stages.length,
|
|
||||||
itemBuilder: (context, i) {
|
|
||||||
final progress = repo.progressFor(pack.seasonId, ids[i]);
|
|
||||||
final isUnlocked = i <= unlocked;
|
final isUnlocked = i <= unlocked;
|
||||||
return _StageNode(
|
final size = isCurrent ? 64.0 : 52.0;
|
||||||
number: i + 1,
|
|
||||||
stars: progress?.stars ?? 0,
|
return Positioned(
|
||||||
unlocked: isUnlocked,
|
key: Key('stage_node_$i'),
|
||||||
|
left: center.dx - size / 2,
|
||||||
|
top: center.dy - size / 2,
|
||||||
|
child: GestureDetector(
|
||||||
onTap: !isUnlocked
|
onTap: !isUnlocked
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
ref
|
ref
|
||||||
.read(seasonFlowProvider.notifier)
|
.read(seasonFlowProvider.notifier)
|
||||||
.startSeasonStage(pack, i);
|
.startSeasonStage(widget.pack, i);
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute(builder: (_) => const GameScreen()),
|
MaterialPageRoute(builder: (_) => const GameScreen()),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
child: Column(
|
||||||
},
|
mainAxisSize: MainAxisSize.min,
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _StageNode extends StatelessWidget {
|
|
||||||
const _StageNode({
|
|
||||||
required this.number,
|
|
||||||
required this.stars,
|
|
||||||
required this.unlocked,
|
|
||||||
required this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
final int number;
|
|
||||||
final int stars;
|
|
||||||
final bool unlocked;
|
|
||||||
final VoidCallback? onTap;
|
|
||||||
|
|
||||||
@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: [
|
children: [
|
||||||
Text(
|
Container(
|
||||||
'$number',
|
width: size,
|
||||||
style: Theme.of(context)
|
height: size,
|
||||||
.textTheme
|
alignment: Alignment.center,
|
||||||
.titleLarge
|
decoration: BoxDecoration(
|
||||||
?.copyWith(fontWeight: FontWeight.w800),
|
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,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
]
|
||||||
|
: 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(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
for (var s = 0; s < 3; s++)
|
for (var s = 0; s < 3; s++)
|
||||||
Icon(
|
Icon(
|
||||||
Icons.star,
|
Icons.star,
|
||||||
size: 14,
|
size: 13,
|
||||||
color: s < stars ? Colors.amber : Colors.white24,
|
color: s < stars ? Colors.amber : Colors.white24,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
)
|
|
||||||
: const Center(
|
|
||||||
child: Icon(Icons.lock, color: Colors.white24, size: 22),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _PathPainter extends CustomPainter {
|
||||||
|
const _PathPainter({required this.layout, required this.count});
|
||||||
|
|
||||||
|
final MapLayout layout;
|
||||||
|
final int count;
|
||||||
|
|
||||||
|
@override
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../game/models/season.dart';
|
||||||
|
|
||||||
/// Season-themeable color set. Season 1 default: vivid candy tones on a
|
/// Season-themeable color set. Season 1 default: vivid candy tones on a
|
||||||
/// deep navy board.
|
/// deep navy board.
|
||||||
class GamePalette {
|
class GamePalette {
|
||||||
@@ -20,7 +22,31 @@ class GamePalette {
|
|||||||
|
|
||||||
static Color tile(int colorId) => tileColors[colorId % tileColors.length];
|
static Color tile(int colorId) => tileColors[colorId % tileColors.length];
|
||||||
|
|
||||||
|
static const lockedNode = Color(0xFF232B4A);
|
||||||
static const gem = Color(0xFF7CF5FF);
|
static const gem = Color(0xFF7CF5FF);
|
||||||
static const ghostLegal = Color(0x66FFFFFF);
|
static const ghostLegal = Color(0x66FFFFFF);
|
||||||
static const ghostIllegal = Color(0x55FF5252);
|
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];
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../../game/models/piece.dart';
|
|||||||
import '../theme/palette.dart';
|
import '../theme/palette.dart';
|
||||||
import 'board_geometry.dart';
|
import 'board_geometry.dart';
|
||||||
import 'piece_painter.dart';
|
import 'piece_painter.dart';
|
||||||
|
import 'tile_painter.dart';
|
||||||
|
|
||||||
/// Drag ghost preview: a piece hovering at a snapped anchor.
|
/// Drag ghost preview: a piece hovering at a snapped anchor.
|
||||||
class GhostSpec {
|
class GhostSpec {
|
||||||
@@ -55,27 +56,20 @@ class BoardPainter extends CustomPainter {
|
|||||||
for (var x = 0; x < GridState.size; x++) {
|
for (var x = 0; x < GridState.size; x++) {
|
||||||
final rect = geo.cellRect(x, y).deflate(inset);
|
final rect = geo.cellRect(x, y).deflate(inset);
|
||||||
final cell = grid.cellAt(x, y);
|
final cell = grid.cellAt(x, y);
|
||||||
final paint = Paint()
|
switch (cell.type) {
|
||||||
..color = switch (cell.type) {
|
case CellType.empty:
|
||||||
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(
|
canvas.drawRRect(
|
||||||
RRect.fromRectAndRadius(
|
RRect.fromRectAndRadius(rect, radius),
|
||||||
Rect.fromLTWH(
|
Paint()..color = GamePalette.emptyCell,
|
||||||
rect.left, rect.top, rect.width, rect.height * 0.32),
|
|
||||||
radius,
|
|
||||||
),
|
|
||||||
highlight,
|
|
||||||
);
|
);
|
||||||
|
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) {
|
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 center = rect.center;
|
||||||
final r = rect.width * 0.32;
|
final r = rect.width * 0.32;
|
||||||
final path = Path()
|
final path = Path()
|
||||||
@@ -133,5 +131,7 @@ class BoardPainter extends CustomPainter {
|
|||||||
bool shouldRepaint(BoardPainter old) =>
|
bool shouldRepaint(BoardPainter old) =>
|
||||||
old.grid != grid ||
|
old.grid != grid ||
|
||||||
old.ghost != ghost ||
|
old.ghost != ghost ||
|
||||||
old.flashProgress != flashProgress;
|
old.flashProgress != flashProgress ||
|
||||||
|
old.flashRows != flashRows ||
|
||||||
|
old.flashCols != flashCols;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ class _BoardWidgetState extends State<BoardWidget>
|
|||||||
|
|
||||||
List<int> _flashRows = const [];
|
List<int> _flashRows = const [];
|
||||||
List<int> _flashCols = const [];
|
List<int> _flashCols = const [];
|
||||||
int _comboStreak = 0;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(BoardWidget old) {
|
void didUpdateWidget(BoardWidget old) {
|
||||||
@@ -35,7 +34,6 @@ class _BoardWidgetState extends State<BoardWidget>
|
|||||||
placement.linesCleared > 0) {
|
placement.linesCleared > 0) {
|
||||||
_flashRows = placement.clearedRows;
|
_flashRows = placement.clearedRows;
|
||||||
_flashCols = placement.clearedCols;
|
_flashCols = placement.clearedCols;
|
||||||
_comboStreak = placement.comboStreak;
|
|
||||||
_flash.forward(from: 0);
|
_flash.forward(from: 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,26 +64,6 @@ class _BoardWidgetState extends State<BoardWidget>
|
|||||||
flashCols: _flashCols,
|
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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -16,7 +16,13 @@ class HudWidget extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
_movesChip(theme),
|
Visibility(
|
||||||
|
visible: !view.endless,
|
||||||
|
maintainSize: true,
|
||||||
|
maintainAnimation: true,
|
||||||
|
maintainState: true,
|
||||||
|
child: _movesChip(theme),
|
||||||
|
),
|
||||||
AnimatedSwitcher(
|
AnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
transitionBuilder: (child, anim) =>
|
transitionBuilder: (child, anim) =>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../../game/models/piece.dart';
|
import '../../game/models/piece.dart';
|
||||||
import '../theme/palette.dart';
|
import '../theme/palette.dart';
|
||||||
|
import 'tile_painter.dart';
|
||||||
|
|
||||||
/// Draws a piece as rounded tiles at a given cell size; reused by the tray,
|
/// Draws a piece as rounded tiles at a given cell size; reused by the tray,
|
||||||
/// the drag overlay, and ghost previews.
|
/// the drag overlay, and ghost previews.
|
||||||
@@ -12,8 +13,6 @@ void paintPiece(
|
|||||||
Offset origin = Offset.zero,
|
Offset origin = Offset.zero,
|
||||||
Color? overrideColor,
|
Color? overrideColor,
|
||||||
}) {
|
}) {
|
||||||
final paint = Paint()
|
|
||||||
..color = overrideColor ?? GamePalette.tile(piece.colorId);
|
|
||||||
final inset = cellSize * 0.05;
|
final inset = cellSize * 0.05;
|
||||||
final radius = Radius.circular(cellSize * 0.18);
|
final radius = Radius.circular(cellSize * 0.18);
|
||||||
for (final (dx, dy) in piece.offsets) {
|
for (final (dx, dy) in piece.offsets) {
|
||||||
@@ -23,17 +22,13 @@ void paintPiece(
|
|||||||
cellSize - inset * 2,
|
cellSize - inset * 2,
|
||||||
cellSize - inset * 2,
|
cellSize - inset * 2,
|
||||||
);
|
);
|
||||||
canvas.drawRRect(RRect.fromRectAndRadius(rect, radius), paint);
|
if (overrideColor != null) {
|
||||||
if (overrideColor == null) {
|
|
||||||
// Subtle top highlight for depth.
|
|
||||||
final highlight = Paint()..color = Colors.white.withValues(alpha: 0.18);
|
|
||||||
canvas.drawRRect(
|
canvas.drawRRect(
|
||||||
RRect.fromRectAndRadius(
|
RRect.fromRectAndRadius(rect, radius),
|
||||||
Rect.fromLTWH(rect.left, rect.top, rect.width, rect.height * 0.32),
|
Paint()..color = overrideColor,
|
||||||
radius,
|
|
||||||
),
|
|
||||||
highlight,
|
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
paintGlossyTile(canvas, rect, GamePalette.tile(piece.colorId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,4 +67,40 @@ void main() {
|
|||||||
expect(second.progressFor('season_001', 's1')!.stars, 3);
|
expect(second.progressFor('season_001', 's1')!.stars, 3);
|
||||||
expect(second.progressFor('season_001', 's1')!.bestScore, 777);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -41,7 +41,24 @@ void main() {
|
|||||||
|
|
||||||
test('round-trips to JSON', () {
|
test('round-trips to JSON', () {
|
||||||
final pack = SeasonPack.fromJson(packJson);
|
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', () {
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -77,4 +77,20 @@ void main() {
|
|||||||
expect(container.read(seasonFlowProvider)!.hasNext, isFalse);
|
expect(container.read(seasonFlowProvider)!.hasNext, isFalse);
|
||||||
expect(container.read(gameSessionProvider)!.phase, GamePhase.playing);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -71,13 +71,25 @@ void main() {
|
|||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Total stars displayed in header.
|
||||||
expect(find.text('★ 2/9'), findsOneWidget);
|
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.
|
// Node 0 (stage 1) exists.
|
||||||
await tester.tap(find.text('2'));
|
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();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.byType(GameScreen), findsOneWidget);
|
expect(find.byType(GameScreen), findsOneWidget);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -16,9 +16,14 @@ void main() {
|
|||||||
child: const BlockSeasonsApp(),
|
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();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
expect(find.text('Block Seasons'), findsOneWidget);
|
expect(find.text('Block Seasons'), findsOneWidget);
|
||||||
expect(find.text('Play'), findsOneWidget);
|
expect(find.text('Adventure'), findsOneWidget);
|
||||||
|
expect(find.text('Classic'), findsOneWidget);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user