Compare commits
32 Commits
1397746845
...
41b9a14c0c
| Author | SHA1 | Date | |
|---|---|---|---|
| 41b9a14c0c | |||
| f9eaa3ae59 | |||
| c59454aa5f | |||
| bf7720ebd3 | |||
| 26adf98d73 | |||
| 94e62d3e41 | |||
| 5a84a47cd4 | |||
| fea8336391 | |||
| c5e9029cad | |||
| 6c76837ab6 | |||
| 2b44dcd812 | |||
| 78eb5c0639 | |||
| 96304cc8a7 | |||
| ee364cc2e2 | |||
| 963d0d5dd6 | |||
| 3d1f3b30c7 | |||
| f97b4faad7 | |||
| 9fe1910d12 | |||
| 3f34358137 | |||
| 189ab469af | |||
| 944c5733c9 | |||
| 677a09f8cb | |||
| f1b8052f77 | |||
| 8c3c2ae9a9 | |||
| d985d40f09 | |||
| 6b796b6d1d | |||
| 6e4d3b60df | |||
| d283bf6959 | |||
| 8739fc0e26 | |||
| 6bb1eac28c | |||
| e866de189d | |||
| a69120e46b |
@@ -47,3 +47,4 @@ app.*.map.json
|
||||
# Generated localizations
|
||||
lib/l10n/gen/
|
||||
.superpowers/
|
||||
CLAUDE.md
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#FF0E1430"/>
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#FF0E1430"/>
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 370 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 211 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,7 @@
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<color key="backgroundColor" red="0.054901960784" green="0.078431372549" blue="0.188235294118" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
|
||||
import 'l10n/gen/app_localizations.dart';
|
||||
import 'ui/screens/home_screen.dart';
|
||||
import 'ui/screens/splash_screen.dart';
|
||||
|
||||
class BlockSeasonsApp extends StatelessWidget {
|
||||
const BlockSeasonsApp({super.key});
|
||||
@@ -26,7 +26,7 @@ class BlockSeasonsApp extends StatelessWidget {
|
||||
),
|
||||
useMaterial3: true,
|
||||
),
|
||||
home: const HomeScreen(),
|
||||
home: const SplashScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,11 @@ class SaveRepository {
|
||||
lastYmd: streak['lastYmd'] as String?,
|
||||
);
|
||||
}
|
||||
_tutorialDone =
|
||||
(json['flags'] as Map<String, dynamic>?)?['tutorialDone'] as bool? ??
|
||||
false;
|
||||
_endlessBest =
|
||||
(json['endless'] as Map<String, dynamic>?)?['best'] as int? ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,8 +50,22 @@ class SaveRepository {
|
||||
final SharedPreferences _prefs;
|
||||
final Map<String, StageProgress> _progress = {};
|
||||
StreakState _streak = StreakState.initial;
|
||||
bool _tutorialDone = false;
|
||||
int _endlessBest = 0;
|
||||
|
||||
StreakState get streak => _streak;
|
||||
bool get tutorialDone => _tutorialDone;
|
||||
int get endlessBest => _endlessBest;
|
||||
|
||||
Future<void> markTutorialDone() {
|
||||
_tutorialDone = true;
|
||||
return _flush();
|
||||
}
|
||||
|
||||
Future<void> recordEndlessScore(int score) {
|
||||
if (score > _endlessBest) _endlessBest = score;
|
||||
return _flush();
|
||||
}
|
||||
|
||||
Future<void> saveStreak(StreakState streak) {
|
||||
_streak = streak;
|
||||
@@ -111,6 +130,8 @@ class SaveRepository {
|
||||
'best': _streak.best,
|
||||
'lastYmd': _streak.lastYmd,
|
||||
},
|
||||
'flags': {'tutorialDone': _tutorialDone},
|
||||
'endless': {'best': _endlessBest},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,7 +73,12 @@ class GameEngine {
|
||||
int get score => _score;
|
||||
ComboState get combo => _combo;
|
||||
int get movesUsed => _movesUsed;
|
||||
int get movesLeft => _moveLimit - _movesUsed;
|
||||
// movesLeft: endless is effectively infinite.
|
||||
int get movesLeft =>
|
||||
_stage.endless ? 1 << 30 : _moveLimit - _movesUsed;
|
||||
|
||||
// UI branch selector for endless mode.
|
||||
bool get endless => _stage.endless;
|
||||
List<Objective> get objectives => List.unmodifiable(_objectives);
|
||||
GamePhase get phase => _phase;
|
||||
StuckReason? get stuckReason => _stuckReason;
|
||||
@@ -128,7 +133,7 @@ class GameEngine {
|
||||
events.fold(obj, (o, event) => o.onEvent(event)),
|
||||
];
|
||||
|
||||
if (_objectives.every((o) => o.isComplete)) {
|
||||
if (!_stage.endless && _objectives.every((o) => o.isComplete)) {
|
||||
_phase = GamePhase.won;
|
||||
} else {
|
||||
if (_tray.isEmpty) _tray = _generator.nextTray(_grid);
|
||||
@@ -147,7 +152,7 @@ class GameEngine {
|
||||
}
|
||||
|
||||
void _checkStuck() {
|
||||
if (movesLeft <= 0) {
|
||||
if (!_stage.endless && movesLeft <= 0) {
|
||||
_phase = GamePhase.stuck;
|
||||
_stuckReason = StuckReason.outOfMoves;
|
||||
} else if (!anyPlacementExists(_grid, _tray)) {
|
||||
|
||||
@@ -1,18 +1,62 @@
|
||||
import 'stage.dart';
|
||||
|
||||
/// Visual identity of a season. Colors are int ARGB so this file stays
|
||||
/// pure Dart (architecture guard forbids Flutter imports here).
|
||||
class SeasonTheme {
|
||||
const SeasonTheme({required this.tileSet, required this.background});
|
||||
const SeasonTheme({
|
||||
this.tileSet = 'spring',
|
||||
this.background = '',
|
||||
this.backgroundGradient = defaultGradient,
|
||||
this.accentColor = 0xFFFF7EB3,
|
||||
this.particleType = 'petals',
|
||||
this.tilePalette,
|
||||
this.boardTint,
|
||||
});
|
||||
|
||||
factory SeasonTheme.fromJson(Map<String, dynamic> json) => SeasonTheme(
|
||||
tileSet: json['tileSet'] as String,
|
||||
background: json['background'] as String,
|
||||
tileSet: (json['tileSet'] as String?) ?? 'spring',
|
||||
background: (json['background'] as String?) ?? '',
|
||||
backgroundGradient: json['backgroundGradient'] != null
|
||||
? [for (final c in json['backgroundGradient'] as List) (c as num).toInt()]
|
||||
: defaultGradient,
|
||||
accentColor: (json['accentColor'] as int?) ?? 0xFFFF7EB3,
|
||||
particleType: (json['particleType'] as String?) ?? 'petals',
|
||||
tilePalette: json['tilePalette'] != null
|
||||
? [for (final c in json['tilePalette'] as List) (c as num).toInt()]
|
||||
: null,
|
||||
boardTint: json['boardTint'] as int?,
|
||||
);
|
||||
|
||||
/// Season 1 "First Bloom": deep navy dusk.
|
||||
static const defaultGradient = [0xFF0E1430, 0xFF16204A, 0xFF2A2E5E];
|
||||
|
||||
static const fallback = SeasonTheme();
|
||||
|
||||
final String tileSet;
|
||||
final String background;
|
||||
|
||||
Map<String, dynamic> toJson() =>
|
||||
{'tileSet': tileSet, 'background': background};
|
||||
/// Top-to-bottom screen gradient, int ARGB.
|
||||
final List<int> backgroundGradient;
|
||||
final int accentColor;
|
||||
|
||||
/// petals | snow | leaves | none
|
||||
final String particleType;
|
||||
|
||||
/// Optional tile color override; null = built-in candy palette.
|
||||
final List<int>? tilePalette;
|
||||
|
||||
/// Optional board background override.
|
||||
final int? boardTint;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'tileSet': tileSet,
|
||||
'background': background,
|
||||
'backgroundGradient': backgroundGradient,
|
||||
'accentColor': accentColor,
|
||||
'particleType': particleType,
|
||||
if (tilePalette != null) 'tilePalette': tilePalette,
|
||||
if (boardTint != null) 'boardTint': boardTint,
|
||||
};
|
||||
}
|
||||
|
||||
/// A season's full content: metadata, theme, and its stages. The unit of
|
||||
|
||||
@@ -74,8 +74,21 @@ class StageConfig {
|
||||
required this.objectives,
|
||||
required this.stars,
|
||||
required this.generatorProfile,
|
||||
this.endless = false,
|
||||
});
|
||||
|
||||
factory StageConfig.endless({required int seed}) => StageConfig(
|
||||
id: 'endless',
|
||||
seed: seed,
|
||||
moveLimit: 0,
|
||||
preset: const [],
|
||||
objectives: const [],
|
||||
stars: const StarThresholds(
|
||||
twoMovesLeft: 1 << 30, threeMovesLeft: 1 << 30),
|
||||
generatorProfile: 'mid',
|
||||
endless: true,
|
||||
);
|
||||
|
||||
factory StageConfig.fromJson(Map<String, dynamic> json) => StageConfig(
|
||||
id: json['id'] as String,
|
||||
seed: json['seed'] as int,
|
||||
@@ -100,6 +113,10 @@ class StageConfig {
|
||||
final StarThresholds stars;
|
||||
final String generatorProfile;
|
||||
|
||||
/// Runtime-only: score-attack mode with no objectives or move limit.
|
||||
/// Never serialized — packs always describe objective stages.
|
||||
final bool endless;
|
||||
|
||||
GridState initialGrid() {
|
||||
var grid = GridState.empty();
|
||||
for (final cell in preset) {
|
||||
|
||||
+35
-1
@@ -20,5 +20,39 @@
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"almostThere": "{percent}% complete!",
|
||||
"@almostThere": {
|
||||
"placeholders": {
|
||||
"percent": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"seasonLabel": "SEASON",
|
||||
"seasonStages": "{count} stages",
|
||||
"@seasonStages": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"skip": "Skip",
|
||||
"gotIt": "Got it!",
|
||||
"tutorialDrag": "Drag a block onto the board!",
|
||||
"tutorialClear": "Fill a row or column to clear it!",
|
||||
"tutorialHud": "Hit the goal before you run out of moves. Your turn!",
|
||||
"gameOver": "Game Over",
|
||||
"bestScore": "Best {score}",
|
||||
"@bestScore": {
|
||||
"placeholders": {
|
||||
"score": {
|
||||
"type": "int"
|
||||
}
|
||||
}
|
||||
},
|
||||
"newBest": "NEW BEST!",
|
||||
"adventure": "Adventure",
|
||||
"classic": "Classic"
|
||||
}
|
||||
|
||||
+14
-1
@@ -13,5 +13,18 @@
|
||||
"giveUp": "포기하기",
|
||||
"playAgain": "다시 하기",
|
||||
"nextStage": "다음 스테이지",
|
||||
"streakMilestone": "{days}일 연속 플레이! 대단해요!"
|
||||
"streakMilestone": "{days}일 연속 플레이! 대단해요!",
|
||||
"almostThere": "{percent}% 달성!",
|
||||
"seasonLabel": "SEASON",
|
||||
"seasonStages": "{count}개 스테이지",
|
||||
"skip": "건너뛰기",
|
||||
"gotIt": "알겠어요!",
|
||||
"tutorialDrag": "블록을 보드로 끌어다 놓아보세요!",
|
||||
"tutorialClear": "가로나 세로 한 줄을 채우면 사라져요!",
|
||||
"tutorialHud": "이동 횟수가 끝나기 전에 목표를 달성하세요. 이제 직접!",
|
||||
"gameOver": "게임 오버",
|
||||
"bestScore": "최고 {score}",
|
||||
"newBest": "신기록!",
|
||||
"adventure": "어드벤처",
|
||||
"classic": "클래식"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'providers.dart';
|
||||
|
||||
/// Reactive view over SaveRepository's endless best score.
|
||||
class EndlessBestNotifier extends Notifier<int> {
|
||||
@override
|
||||
int build() => ref.read(saveRepositoryProvider).endlessBest;
|
||||
|
||||
/// Records the run; returns true when it set a new best.
|
||||
Future<bool> record(int score) async {
|
||||
final repo = ref.read(saveRepositoryProvider);
|
||||
final isNewBest = score > state;
|
||||
await repo.recordEndlessScore(score);
|
||||
state = repo.endlessBest;
|
||||
return isNewBest;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ class GameViewState {
|
||||
required this.objectiveProgress,
|
||||
required this.lastPlacement,
|
||||
required this.fxTick,
|
||||
required this.endless,
|
||||
});
|
||||
|
||||
final GridState grid;
|
||||
@@ -40,6 +41,8 @@ class GameViewState {
|
||||
|
||||
/// Increments on every accepted placement so animations can retrigger.
|
||||
final int fxTick;
|
||||
|
||||
final bool endless;
|
||||
}
|
||||
|
||||
/// Bridges the pure-Dart [GameEngine] to the widget tree. The engine object
|
||||
@@ -107,7 +110,8 @@ class GameSessionNotifier extends Notifier<GameViewState?> {
|
||||
score: engine.score,
|
||||
comboStreak: engine.combo.streak,
|
||||
movesLeft: engine.movesLeft,
|
||||
moveLimit: engine.movesLeft + engine.movesUsed,
|
||||
endless: engine.endless,
|
||||
moveLimit: engine.endless ? 0 : engine.movesLeft + engine.movesUsed,
|
||||
phase: engine.phase,
|
||||
stuckReason: engine.stuckReason,
|
||||
objectives: engine.objectives,
|
||||
|
||||
@@ -5,10 +5,12 @@ import '../data/save_repository.dart';
|
||||
import '../data/streak.dart';
|
||||
import '../game/models/season.dart';
|
||||
import '../services/audio_service.dart';
|
||||
import 'endless_best_notifier.dart';
|
||||
import 'game_session_notifier.dart';
|
||||
import 'progress_notifier.dart';
|
||||
import 'season_flow_notifier.dart';
|
||||
import 'streak_notifier.dart';
|
||||
import 'tutorial_notifier.dart';
|
||||
|
||||
final gameSessionProvider =
|
||||
NotifierProvider<GameSessionNotifier, GameViewState?>(
|
||||
@@ -45,3 +47,18 @@ final seasonsProvider = FutureProvider<List<SeasonPack>>(
|
||||
final streakProvider = NotifierProvider<StreakNotifier, StreakState>(
|
||||
StreakNotifier.new,
|
||||
);
|
||||
|
||||
final tutorialProvider = NotifierProvider<TutorialNotifier, TutorialStep?>(
|
||||
TutorialNotifier.new,
|
||||
);
|
||||
|
||||
final endlessBestProvider = NotifierProvider<EndlessBestNotifier, int>(
|
||||
EndlessBestNotifier.new,
|
||||
);
|
||||
|
||||
/// The visual theme of whatever season is in play; fallback outside seasons
|
||||
/// (home, endless). Pure model — UI converts via ThemeColors.
|
||||
final activeThemeProvider = Provider<SeasonTheme>((ref) {
|
||||
final flow = ref.watch(seasonFlowProvider);
|
||||
return flow?.pack.theme ?? SeasonTheme.fallback;
|
||||
});
|
||||
|
||||
@@ -41,4 +41,8 @@ class SeasonFlowNotifier extends Notifier<SeasonFlow?> {
|
||||
if (flow == null || !flow.hasNext) return;
|
||||
startSeasonStage(flow.pack, flow.index + 1);
|
||||
}
|
||||
|
||||
/// Leaving season play (e.g. starting a Classic run) clears the flow so
|
||||
/// stale stage context can't leak into other modes.
|
||||
void clear() => state = null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'providers.dart';
|
||||
|
||||
enum TutorialStep { dragPiece, clearLine, explainHud }
|
||||
|
||||
/// First-play guided tutorial. State null = inactive. Events arriving in the
|
||||
/// wrong step are ignored, so engine wiring can fire them unconditionally.
|
||||
class TutorialNotifier extends Notifier<TutorialStep?> {
|
||||
@override
|
||||
TutorialStep? build() => null;
|
||||
|
||||
void start() {
|
||||
if (ref.read(saveRepositoryProvider).tutorialDone) return;
|
||||
state = TutorialStep.dragPiece;
|
||||
}
|
||||
|
||||
void onPlaced() {
|
||||
if (state == TutorialStep.dragPiece) state = TutorialStep.clearLine;
|
||||
}
|
||||
|
||||
void onLineCleared() {
|
||||
if (state == TutorialStep.clearLine) state = TutorialStep.explainHud;
|
||||
}
|
||||
|
||||
Future<void> dismissHud() async {
|
||||
if (state != TutorialStep.explainHud) return;
|
||||
await _finish();
|
||||
}
|
||||
|
||||
Future<void> skip() async {
|
||||
if (state == null) return;
|
||||
await _finish();
|
||||
}
|
||||
|
||||
Future<void> _finish() async {
|
||||
state = null;
|
||||
await ref.read(saveRepositoryProvider).markTutorialDone();
|
||||
}
|
||||
}
|
||||
+248
-54
@@ -1,7 +1,11 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../game/engine/game_engine.dart';
|
||||
import '../../game/models/grid.dart';
|
||||
import '../../l10n/gen/app_localizations.dart';
|
||||
import '../../services/audio_service.dart';
|
||||
import '../../state/game_session_notifier.dart';
|
||||
@@ -10,9 +14,12 @@ import '../theme/palette.dart';
|
||||
import '../widgets/board_geometry.dart';
|
||||
import '../widgets/board_painter.dart';
|
||||
import '../widgets/board_widget.dart';
|
||||
import '../widgets/effects_overlay.dart';
|
||||
import '../widgets/hud_widget.dart';
|
||||
import '../widgets/piece_painter.dart';
|
||||
import '../widgets/season_background.dart';
|
||||
import '../widgets/tray_widget.dart';
|
||||
import '../widgets/tutorial_overlay.dart';
|
||||
|
||||
/// Renders whatever session [gameSessionProvider] holds; callers start the
|
||||
/// stage (via SeasonFlowNotifier) before navigating here.
|
||||
@@ -23,9 +30,18 @@ class GameScreen extends ConsumerStatefulWidget {
|
||||
ConsumerState<GameScreen> createState() => _GameScreenState();
|
||||
}
|
||||
|
||||
class _GameScreenState extends ConsumerState<GameScreen> {
|
||||
class _GameScreenState extends ConsumerState<GameScreen>
|
||||
with TickerProviderStateMixin {
|
||||
final _boardKey = GlobalKey();
|
||||
final _stackKey = GlobalKey();
|
||||
final _effectsKey = GlobalKey<EffectsOverlayState>();
|
||||
late final AnimationController _shake = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 350),
|
||||
);
|
||||
|
||||
bool _tutorialStartChecked = false;
|
||||
bool _endlessNewBest = false;
|
||||
|
||||
int? _dragIndex;
|
||||
Offset? _dragGlobal;
|
||||
@@ -93,6 +109,12 @@ class _GameScreenState extends ConsumerState<GameScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_shake.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onSessionChange(GameViewState? prev, GameViewState? next) {
|
||||
if (next == null) return;
|
||||
final audio = ref.read(audioServiceProvider);
|
||||
@@ -100,19 +122,57 @@ class _GameScreenState extends ConsumerState<GameScreen> {
|
||||
final placement = next.lastPlacement!;
|
||||
if (placement.linesCleared > 0) {
|
||||
audio.play(placement.comboStreak >= 2 ? Sfx.combo : Sfx.clear);
|
||||
HapticFeedback.mediumImpact();
|
||||
if (placement.comboStreak >= 4) {
|
||||
HapticFeedback.heavyImpact();
|
||||
_shake.forward(from: 0);
|
||||
}
|
||||
} else {
|
||||
audio.play(Sfx.place);
|
||||
HapticFeedback.lightImpact();
|
||||
}
|
||||
ref.read(tutorialProvider.notifier).onPlaced();
|
||||
if (placement.linesCleared > 0) {
|
||||
ref.read(tutorialProvider.notifier).onLineCleared();
|
||||
}
|
||||
final boardBox = _boardBox;
|
||||
final stackBox =
|
||||
_stackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (boardBox != null && stackBox != null) {
|
||||
final topLeft =
|
||||
stackBox.globalToLocal(boardBox.localToGlobal(Offset.zero));
|
||||
_effectsKey.currentState?.onPlacement(
|
||||
placement,
|
||||
boardRect: topLeft & boardBox.size,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (prev?.phase != next.phase) {
|
||||
// A finished stage ends the tutorial; otherwise the overlay would sit
|
||||
// on top of the result card and leak into the next stage.
|
||||
if (next.phase != GamePhase.playing) {
|
||||
ref.read(tutorialProvider.notifier).skip();
|
||||
}
|
||||
if (next.phase == GamePhase.won) {
|
||||
audio.play(Sfx.win);
|
||||
// recordResult keeps the best run, so re-entry is harmless.
|
||||
ref
|
||||
.read(seasonFlowProvider.notifier)
|
||||
.recordWin(stars: next.starsEarned, score: next.score);
|
||||
if (!next.endless) {
|
||||
ref
|
||||
.read(seasonFlowProvider.notifier)
|
||||
.recordWin(stars: next.starsEarned, score: next.score);
|
||||
}
|
||||
final stackBox =
|
||||
_stackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (stackBox != null) {
|
||||
_effectsKey.currentState?.onWin(stackBox.size);
|
||||
}
|
||||
}
|
||||
if (next.phase == GamePhase.lost) audio.play(Sfx.lose);
|
||||
if (next.phase == GamePhase.lost && next.endless) {
|
||||
ref.read(endlessBestProvider.notifier).record(next.score).then((isNew) {
|
||||
if (mounted) setState(() => _endlessNewBest = isNew);
|
||||
});
|
||||
}
|
||||
if (next.phase == GamePhase.won || next.phase == GamePhase.lost) {
|
||||
ref.read(streakProvider.notifier).onStagePlayed(DateTime.now());
|
||||
}
|
||||
@@ -136,64 +196,136 @@ class _GameScreenState extends ConsumerState<GameScreen> {
|
||||
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
final tutorialStep = ref.watch(tutorialProvider);
|
||||
if (!_tutorialStartChecked) {
|
||||
_tutorialStartChecked = true;
|
||||
final flow = ref.read(seasonFlowProvider);
|
||||
if (flow != null &&
|
||||
flow.index == 0 &&
|
||||
!ref.read(saveRepositoryProvider).tutorialDone) {
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => ref.read(tutorialProvider.notifier).start());
|
||||
}
|
||||
}
|
||||
|
||||
final ghost = _ghost(view);
|
||||
final draggedTopLeft = _draggedPieceTopLeft(view);
|
||||
final boardBox = _boardBox;
|
||||
|
||||
final theme = ref.watch(activeThemeProvider);
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Stack(
|
||||
key: _stackKey,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
HudWidget(view: view),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: BoardWidget(
|
||||
key: _boardKey,
|
||||
view: view,
|
||||
ghost: ghost,
|
||||
backgroundColor: Colors.transparent,
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
SeasonBackground(theme: theme),
|
||||
SafeArea(
|
||||
child: Stack(
|
||||
key: _stackKey,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
HudWidget(view: view),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: AnimatedBuilder(
|
||||
animation: _shake,
|
||||
builder: (context, child) {
|
||||
final t = _shake.value;
|
||||
final dx =
|
||||
math.sin(t * math.pi * 10) * 6 * (1 - t);
|
||||
return Transform.translate(
|
||||
offset: Offset(dx, 0), child: child);
|
||||
},
|
||||
child: BoardWidget(
|
||||
key: _boardKey,
|
||||
view: view,
|
||||
ghost: ghost,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
TrayWidget(
|
||||
tray: view.tray,
|
||||
draggingIndex: _dragIndex,
|
||||
onDragStart: (index, global) => setState(() {
|
||||
_dragIndex = index;
|
||||
_dragGlobal = global;
|
||||
}),
|
||||
onDragUpdate: (global) =>
|
||||
setState(() => _dragGlobal = global),
|
||||
onDragEnd: () => _onDragEnd(view),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_dragIndex != null &&
|
||||
draggedTopLeft != null &&
|
||||
boardBox != null &&
|
||||
_dragIndex! < view.tray.length)
|
||||
_draggedPieceOverlay(view, draggedTopLeft, boardBox),
|
||||
Positioned.fill(child: EffectsOverlay(key: _effectsKey)),
|
||||
if (view.phase != GamePhase.playing) _resultOverlay(view),
|
||||
if (tutorialStep != null)
|
||||
Positioned.fill(
|
||||
child: TutorialOverlay(
|
||||
step: tutorialStep,
|
||||
handFrom: _tutorialHandFrom(),
|
||||
handTo: _tutorialHandTo(view),
|
||||
onSkip: () => ref.read(tutorialProvider.notifier).skip(),
|
||||
onDismissHud: () =>
|
||||
ref.read(tutorialProvider.notifier).dismissHud(),
|
||||
),
|
||||
),
|
||||
TrayWidget(
|
||||
tray: view.tray,
|
||||
draggingIndex: _dragIndex,
|
||||
onDragStart: (index, global) => setState(() {
|
||||
_dragIndex = index;
|
||||
_dragGlobal = global;
|
||||
}),
|
||||
onDragUpdate: (global) =>
|
||||
setState(() => _dragGlobal = global),
|
||||
onDragEnd: () => _onDragEnd(view),
|
||||
if (Navigator.of(context).canPop())
|
||||
Positioned(
|
||||
top: 4,
|
||||
left: 4,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white54),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_dragIndex != null &&
|
||||
draggedTopLeft != null &&
|
||||
boardBox != null &&
|
||||
_dragIndex! < view.tray.length)
|
||||
_draggedPieceOverlay(view, draggedTopLeft, boardBox),
|
||||
if (view.phase != GamePhase.playing) _resultOverlay(view),
|
||||
if (Navigator.of(context).canPop())
|
||||
Positioned(
|
||||
top: 4,
|
||||
left: 4,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close, color: Colors.white54),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Offset _tutorialHandFrom() {
|
||||
final stackBox =
|
||||
_stackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (stackBox == null) return Offset.zero;
|
||||
// Tray sits at the bottom; aim at the left slot.
|
||||
final size = stackBox.size;
|
||||
return Offset(size.width * 0.18, size.height - 80);
|
||||
}
|
||||
|
||||
Offset _tutorialHandTo(GameViewState view) {
|
||||
final boardBox = _boardBox;
|
||||
final stackBox =
|
||||
_stackKey.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (boardBox == null || stackBox == null || view.tray.isEmpty) {
|
||||
return Offset.zero;
|
||||
}
|
||||
final geo = BoardGeometry(boardSize: boardBox.size.width);
|
||||
final notifier = ref.read(gameSessionProvider.notifier);
|
||||
for (var y = 0; y < GridState.size; y++) {
|
||||
for (var x = 0; x < GridState.size; x++) {
|
||||
if (notifier.canPlaceAt(0, x, y)) {
|
||||
final local =
|
||||
Offset((x + 0.5) * geo.cellSize, (y + 0.5) * geo.cellSize);
|
||||
return stackBox.globalToLocal(boardBox.localToGlobal(local));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Offset.zero;
|
||||
}
|
||||
|
||||
Widget _draggedPieceOverlay(
|
||||
GameViewState view, Offset topLeftGlobal, RenderBox boardBox) {
|
||||
final stackBox =
|
||||
@@ -257,6 +389,18 @@ class _GameScreenState extends ConsumerState<GameScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
(GamePhase.lost, _) when view.endless => (
|
||||
l10n.gameOver,
|
||||
[
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
setState(() => _endlessNewBest = false);
|
||||
notifier.restart();
|
||||
},
|
||||
child: Text(l10n.playAgain),
|
||||
),
|
||||
],
|
||||
),
|
||||
(_, _) => (
|
||||
l10n.stageFailed,
|
||||
[
|
||||
@@ -286,16 +430,66 @@ class _GameScreenState extends ConsumerState<GameScreen> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var i = 0; i < 3; i++)
|
||||
Icon(
|
||||
Icons.star,
|
||||
size: 40,
|
||||
color: i < view.starsEarned
|
||||
? Colors.amber
|
||||
: Colors.white24,
|
||||
TweenAnimationBuilder<double>(
|
||||
key: ValueKey(i),
|
||||
tween: Tween(begin: 0, end: 1),
|
||||
duration: Duration(milliseconds: 400 + i * 250),
|
||||
curve: Interval(i * 0.22, 1, curve: Curves.elasticOut),
|
||||
builder: (context, v, child) =>
|
||||
Transform.scale(scale: v, child: child),
|
||||
child: Icon(
|
||||
Icons.star,
|
||||
size: 44,
|
||||
color: i < view.starsEarned ? Colors.amber : Colors.white24,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (view.phase == GamePhase.lost && view.endless) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text('${view.score}',
|
||||
style: theme.textTheme.displaySmall
|
||||
?.copyWith(fontWeight: FontWeight.w900)),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_endlessNewBest
|
||||
? l10n.newBest
|
||||
: l10n.bestScore(ref.read(endlessBestProvider)),
|
||||
style: TextStyle(
|
||||
color:
|
||||
_endlessNewBest ? Colors.amber : Colors.white60,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (view.phase == GamePhase.lost && !view.endless && view.objectiveProgress > 0) ...[
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: 88,
|
||||
height: 88,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
value: view.objectiveProgress,
|
||||
strokeWidth: 7,
|
||||
backgroundColor: Colors.white12,
|
||||
color: Colors.amber,
|
||||
),
|
||||
Center(
|
||||
child: Text(
|
||||
l10n.almostThere((view.objectiveProgress * 100).round()),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.labelSmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
...actions,
|
||||
],
|
||||
|
||||
+130
-42
@@ -1,64 +1,152 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../game/models/season.dart';
|
||||
import '../../game/models/stage.dart';
|
||||
import '../../l10n/gen/app_localizations.dart';
|
||||
import '../../state/providers.dart';
|
||||
import '../widgets/season_background.dart';
|
||||
import 'game_screen.dart';
|
||||
import 'season_map_screen.dart';
|
||||
|
||||
class HomeScreen extends ConsumerWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
static const _logoColors = [
|
||||
Color(0xFFFF7EB3),
|
||||
Color(0xFFFFD166),
|
||||
Color(0xFF6FCDF5),
|
||||
Color(0xFF7EDB9C),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final streak = ref.watch(streakProvider);
|
||||
final best = ref.watch(endlessBestProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
l10n.appTitle,
|
||||
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
backgroundColor: Colors.transparent,
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
const SeasonBackground(theme: SeasonTheme.fallback),
|
||||
SafeArea(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_logoMark(),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
l10n.appTitle,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.displaySmall
|
||||
?.copyWith(fontWeight: FontWeight.w900),
|
||||
),
|
||||
if (streak.current > 0) ...[
|
||||
const SizedBox(height: 10),
|
||||
Chip(
|
||||
avatar: const Icon(
|
||||
Icons.local_fire_department,
|
||||
color: Colors.deepOrange,
|
||||
size: 20,
|
||||
),
|
||||
label: Text(
|
||||
'${streak.current}',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (streak.current > 0) ...[
|
||||
const SizedBox(height: 12),
|
||||
Chip(
|
||||
avatar: const Icon(
|
||||
Icons.local_fire_department,
|
||||
color: Colors.deepOrange,
|
||||
size: 20,
|
||||
),
|
||||
label: Text(
|
||||
'${streak.current}',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 48),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 48,
|
||||
vertical: 16,
|
||||
),
|
||||
textStyle: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const SeasonMapScreen(),
|
||||
],
|
||||
const SizedBox(height: 44),
|
||||
FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 56, vertical: 18),
|
||||
textStyle: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Text(l10n.play),
|
||||
onPressed: () {
|
||||
if (!(ModalRoute.of(context)?.isCurrent ?? true)) return;
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const SeasonMapScreen()),
|
||||
);
|
||||
},
|
||||
child: Text(l10n.adventure),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 40, vertical: 14),
|
||||
textStyle: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
onPressed: () {
|
||||
if (!(ModalRoute.of(context)?.isCurrent ?? true)) return;
|
||||
ref.read(seasonFlowProvider.notifier).clear();
|
||||
ref.read(gameSessionProvider.notifier).startStage(
|
||||
StageConfig.endless(
|
||||
seed: DateTime.now().millisecondsSinceEpoch,
|
||||
),
|
||||
);
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const GameScreen()),
|
||||
);
|
||||
},
|
||||
child: Text(l10n.classic),
|
||||
),
|
||||
if (best > 0) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
l10n.bestScore(best),
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.55),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _logoMark() {
|
||||
return SizedBox(
|
||||
width: 96,
|
||||
height: 96,
|
||||
child: GridView.count(
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 5,
|
||||
crossAxisSpacing: 5,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: [
|
||||
for (final color in _logoColors)
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Color.lerp(color, Colors.white, 0.28)!,
|
||||
color,
|
||||
Color.lerp(color, Colors.black, 0.22)!,
|
||||
],
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: color.withValues(alpha: 0.45),
|
||||
blurRadius: 14,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../game/models/season.dart';
|
||||
import '../../state/providers.dart';
|
||||
import '../theme/palette.dart';
|
||||
import '../widgets/map_layout.dart';
|
||||
import '../widgets/season_background.dart';
|
||||
import '../widgets/tile_painter.dart';
|
||||
import 'game_screen.dart';
|
||||
|
||||
/// Stage selection for the active season. Themed map art lands in Phase 6;
|
||||
/// for now a clean node grid with stars and locks.
|
||||
/// Journey map: a serpentine path of stage nodes climbing the season
|
||||
/// illustration. Auto-scrolls to the current stage on entry.
|
||||
class SeasonMapScreen extends ConsumerWidget {
|
||||
const SeasonMapScreen({super.key});
|
||||
|
||||
@@ -18,126 +21,281 @@ class SeasonMapScreen extends ConsumerWidget {
|
||||
loading: () =>
|
||||
const Scaffold(body: Center(child: CircularProgressIndicator())),
|
||||
error: (e, _) => Scaffold(body: Center(child: Text('$e'))),
|
||||
data: (list) => _Map(pack: list.first),
|
||||
data: (list) => _JourneyMap(pack: list.first),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Map extends ConsumerWidget {
|
||||
const _Map({required this.pack});
|
||||
class _JourneyMap extends ConsumerStatefulWidget {
|
||||
const _JourneyMap({required this.pack});
|
||||
|
||||
final SeasonPack pack;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<_JourneyMap> createState() => _JourneyMapState();
|
||||
}
|
||||
|
||||
class _JourneyMapState extends ConsumerState<_JourneyMap> {
|
||||
final _scroll = ScrollController();
|
||||
bool _autoScrolled = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _autoScrollTo(
|
||||
MapLayout layout, int current, int count, double viewportHeight) {
|
||||
if (_autoScrolled || !_scroll.hasClients) return;
|
||||
_autoScrolled = true;
|
||||
final contentH = layout.heightFor(count);
|
||||
final target =
|
||||
(contentH - layout.nodeCenter(current, count).dy - viewportHeight / 2)
|
||||
.clamp(0.0, _scroll.position.maxScrollExtent);
|
||||
_scroll.jumpTo(target);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Watching progress keeps stars/locks fresh after each win.
|
||||
ref.watch(progressProvider);
|
||||
final pack = widget.pack;
|
||||
final repo = ref.read(saveRepositoryProvider);
|
||||
final ids = [for (final stage in pack.stages) stage.id];
|
||||
final unlocked = repo.highestUnlockedIndex(pack.seasonId, ids);
|
||||
final totalStars = repo.totalStars(pack.seasonId);
|
||||
final seasonComplete = totalStars == pack.stages.length * 3 &&
|
||||
pack.stages.isNotEmpty;
|
||||
final locale = Localizations.localeOf(context).languageCode;
|
||||
final colors = ThemeColors(pack.theme);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(pack.titleFor(locale)),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'★ $totalStars/${pack.stages.length * 3}',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium
|
||||
?.copyWith(color: Colors.amber),
|
||||
backgroundColor: Colors.transparent,
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
SeasonBackground(theme: pack.theme),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final layout = MapLayout(width: constraints.maxWidth);
|
||||
final count = pack.stages.length;
|
||||
if (!_autoScrolled) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) =>
|
||||
_autoScrollTo(
|
||||
layout, unlocked, count, constraints.maxHeight));
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
controller: _scroll,
|
||||
reverse: true,
|
||||
child: SizedBox(
|
||||
width: constraints.maxWidth,
|
||||
height: layout.heightFor(count),
|
||||
child: Stack(
|
||||
children: [
|
||||
CustomPaint(
|
||||
size: Size(
|
||||
constraints.maxWidth, layout.heightFor(count)),
|
||||
painter:
|
||||
_PathPainter(layout: layout, count: count),
|
||||
),
|
||||
for (var i = 0; i < count; i++)
|
||||
_node(
|
||||
context,
|
||||
layout,
|
||||
i,
|
||||
count,
|
||||
unlocked,
|
||||
repo.progressFor(pack.seasonId, ids[i])?.stars ?? 0,
|
||||
colors,
|
||||
seasonComplete,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// Glass header.
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top + 6,
|
||||
bottom: 12,
|
||||
left: 8,
|
||||
right: 16,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.black.withValues(alpha: 0.45),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: Colors.white),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
pack.titleFor(locale),
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'★ $totalStars/${pack.stages.length * 3}',
|
||||
style: const TextStyle(
|
||||
color: Colors.amber,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: GridView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _node(BuildContext context, MapLayout layout, int i, int count,
|
||||
int unlocked, int stars, ThemeColors colors, bool seasonComplete) {
|
||||
final center = layout.nodeCenter(i, count);
|
||||
final isCurrent = i == unlocked && !seasonComplete;
|
||||
final isUnlocked = i <= unlocked;
|
||||
final size = isCurrent ? 64.0 : 52.0;
|
||||
|
||||
return Positioned(
|
||||
key: Key('stage_node_$i'),
|
||||
left: center.dx - size / 2,
|
||||
top: center.dy - size / 2,
|
||||
child: GestureDetector(
|
||||
onTap: !isUnlocked
|
||||
? null
|
||||
: () {
|
||||
ref
|
||||
.read(seasonFlowProvider.notifier)
|
||||
.startSeasonStage(widget.pack, i);
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const GameScreen()),
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: size,
|
||||
height: size,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: isUnlocked
|
||||
? LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: isCurrent
|
||||
? [
|
||||
lighten(colors.accent, 0.25),
|
||||
colors.accent,
|
||||
darken(colors.accent, 0.2),
|
||||
]
|
||||
: [
|
||||
const Color(0xFFFFE9A8),
|
||||
const Color(0xFFFFD166),
|
||||
const Color(0xFFE0AC3B),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
color: isUnlocked ? null : GamePalette.lockedNode,
|
||||
boxShadow: isCurrent
|
||||
? [
|
||||
BoxShadow(
|
||||
color: colors.accent.withValues(alpha: 0.7),
|
||||
blurRadius: 22,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: isUnlocked
|
||||
? Text(
|
||||
'${i + 1}',
|
||||
style: TextStyle(
|
||||
fontSize: isCurrent ? 22 : 17,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: isCurrent
|
||||
? Colors.white
|
||||
: const Color(0xFF5A4200),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.lock, color: Colors.white24, size: 20),
|
||||
),
|
||||
if (isUnlocked && !isCurrent)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var s = 0; s < 3; s++)
|
||||
Icon(
|
||||
Icons.star,
|
||||
size: 13,
|
||||
color: s < stars ? Colors.amber : Colors.white24,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
itemCount: pack.stages.length,
|
||||
itemBuilder: (context, i) {
|
||||
final progress = repo.progressFor(pack.seasonId, ids[i]);
|
||||
final isUnlocked = i <= unlocked;
|
||||
return _StageNode(
|
||||
number: i + 1,
|
||||
stars: progress?.stars ?? 0,
|
||||
unlocked: isUnlocked,
|
||||
onTap: !isUnlocked
|
||||
? null
|
||||
: () {
|
||||
ref
|
||||
.read(seasonFlowProvider.notifier)
|
||||
.startSeasonStage(pack, i);
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (_) => const GameScreen()),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StageNode extends StatelessWidget {
|
||||
const _StageNode({
|
||||
required this.number,
|
||||
required this.stars,
|
||||
required this.unlocked,
|
||||
required this.onTap,
|
||||
});
|
||||
class _PathPainter extends CustomPainter {
|
||||
const _PathPainter({required this.layout, required this.count});
|
||||
|
||||
final int number;
|
||||
final int stars;
|
||||
final bool unlocked;
|
||||
final VoidCallback? onTap;
|
||||
final MapLayout layout;
|
||||
final int count;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: unlocked ? GamePalette.emptyCell : GamePalette.boardBackground,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onTap: onTap,
|
||||
child: unlocked
|
||||
? Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'$number',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleLarge
|
||||
?.copyWith(fontWeight: FontWeight.w800),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
for (var s = 0; s < 3; s++)
|
||||
Icon(
|
||||
Icons.star,
|
||||
size: 14,
|
||||
color: s < stars ? Colors.amber : Colors.white24,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
: const Center(
|
||||
child: Icon(Icons.lock, color: Colors.white24, size: 22),
|
||||
),
|
||||
),
|
||||
);
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (count < 2) return;
|
||||
final path = Path()
|
||||
..moveTo(
|
||||
layout.nodeCenter(0, count).dx, layout.nodeCenter(0, count).dy);
|
||||
for (var i = 1; i < count; i++) {
|
||||
final prev = layout.nodeCenter(i - 1, count);
|
||||
final cur = layout.nodeCenter(i, count);
|
||||
final midY = (prev.dy + cur.dy) / 2;
|
||||
path.cubicTo(prev.dx, midY, cur.dx, midY, cur.dx, cur.dy);
|
||||
}
|
||||
|
||||
final paint = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.25)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 5
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
// Dash the path manually: short dots every 13px.
|
||||
for (final metric in path.computeMetrics()) {
|
||||
var d = 0.0;
|
||||
while (d < metric.length) {
|
||||
canvas.drawPath(metric.extractPath(d, d + 1.5), paint);
|
||||
d += 13;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_PathPainter old) =>
|
||||
old.count != count || old.layout.width != layout.width;
|
||||
}
|
||||
|
||||
@@ -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 '../../game/models/season.dart';
|
||||
|
||||
/// Season-themeable color set. Season 1 default: vivid candy tones on a
|
||||
/// deep navy board.
|
||||
class GamePalette {
|
||||
@@ -20,7 +22,31 @@ class GamePalette {
|
||||
|
||||
static Color tile(int colorId) => tileColors[colorId % tileColors.length];
|
||||
|
||||
static const lockedNode = Color(0xFF232B4A);
|
||||
static const gem = Color(0xFF7CF5FF);
|
||||
static const ghostLegal = Color(0x66FFFFFF);
|
||||
static const ghostIllegal = Color(0x55FF5252);
|
||||
}
|
||||
|
||||
/// Resolved per-season colors for the UI layer. Built from a SeasonTheme;
|
||||
/// falls back to the GamePalette constants.
|
||||
class ThemeColors {
|
||||
ThemeColors(SeasonTheme theme)
|
||||
: gradient = [for (final c in theme.backgroundGradient) Color(c)],
|
||||
accent = Color(theme.accentColor),
|
||||
particleType = theme.particleType,
|
||||
board = theme.boardTint != null
|
||||
? Color(theme.boardTint!)
|
||||
: GamePalette.boardBackground,
|
||||
tiles = theme.tilePalette != null
|
||||
? [for (final c in theme.tilePalette!) Color(c)]
|
||||
: GamePalette.tileColors;
|
||||
|
||||
final List<Color> gradient;
|
||||
final Color accent;
|
||||
final String particleType;
|
||||
final Color board;
|
||||
final List<Color> tiles;
|
||||
|
||||
Color tile(int colorId) => tiles[colorId % tiles.length];
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../../game/models/piece.dart';
|
||||
import '../theme/palette.dart';
|
||||
import 'board_geometry.dart';
|
||||
import 'piece_painter.dart';
|
||||
import 'tile_painter.dart';
|
||||
|
||||
/// Drag ghost preview: a piece hovering at a snapped anchor.
|
||||
class GhostSpec {
|
||||
@@ -55,27 +56,20 @@ class BoardPainter extends CustomPainter {
|
||||
for (var x = 0; x < GridState.size; x++) {
|
||||
final rect = geo.cellRect(x, y).deflate(inset);
|
||||
final cell = grid.cellAt(x, y);
|
||||
final paint = Paint()
|
||||
..color = switch (cell.type) {
|
||||
CellType.empty => GamePalette.emptyCell,
|
||||
CellType.filled => GamePalette.tile(cell.colorId),
|
||||
CellType.gem => GamePalette.emptyCell,
|
||||
};
|
||||
canvas.drawRRect(RRect.fromRectAndRadius(rect, radius), paint);
|
||||
|
||||
if (cell.type == CellType.gem) {
|
||||
_paintGem(canvas, rect);
|
||||
} else if (cell.type == CellType.filled) {
|
||||
final highlight = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.15);
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
Rect.fromLTWH(
|
||||
rect.left, rect.top, rect.width, rect.height * 0.32),
|
||||
radius,
|
||||
),
|
||||
highlight,
|
||||
);
|
||||
switch (cell.type) {
|
||||
case CellType.empty:
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(rect, radius),
|
||||
Paint()..color = GamePalette.emptyCell,
|
||||
);
|
||||
case CellType.filled:
|
||||
paintGlossyTile(canvas, rect, GamePalette.tile(cell.colorId));
|
||||
case CellType.gem:
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(rect, radius),
|
||||
Paint()..color = GamePalette.emptyCell,
|
||||
);
|
||||
_paintGem(canvas, rect);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,6 +105,10 @@ class BoardPainter extends CustomPainter {
|
||||
}
|
||||
|
||||
void _paintGem(Canvas canvas, Rect rect) {
|
||||
final glowPaint = Paint()
|
||||
..color = GamePalette.gem.withValues(alpha: 0.45)
|
||||
..maskFilter = MaskFilter.blur(BlurStyle.normal, rect.width * 0.25);
|
||||
canvas.drawCircle(rect.center, rect.width * 0.32, glowPaint);
|
||||
final center = rect.center;
|
||||
final r = rect.width * 0.32;
|
||||
final path = Path()
|
||||
@@ -133,5 +131,7 @@ class BoardPainter extends CustomPainter {
|
||||
bool shouldRepaint(BoardPainter old) =>
|
||||
old.grid != grid ||
|
||||
old.ghost != ghost ||
|
||||
old.flashProgress != flashProgress;
|
||||
old.flashProgress != flashProgress ||
|
||||
old.flashRows != flashRows ||
|
||||
old.flashCols != flashCols;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ class _BoardWidgetState extends State<BoardWidget>
|
||||
|
||||
List<int> _flashRows = const [];
|
||||
List<int> _flashCols = const [];
|
||||
int _comboStreak = 0;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(BoardWidget old) {
|
||||
@@ -35,7 +34,6 @@ class _BoardWidgetState extends State<BoardWidget>
|
||||
placement.linesCleared > 0) {
|
||||
_flashRows = placement.clearedRows;
|
||||
_flashCols = placement.clearedCols;
|
||||
_comboStreak = placement.comboStreak;
|
||||
_flash.forward(from: 0);
|
||||
}
|
||||
}
|
||||
@@ -66,26 +64,6 @@ class _BoardWidgetState extends State<BoardWidget>
|
||||
flashCols: _flashCols,
|
||||
),
|
||||
),
|
||||
if (_flash.isAnimating && _comboStreak >= 2)
|
||||
Center(
|
||||
child: Transform.scale(
|
||||
scale: 0.8 + 0.6 * _flash.value,
|
||||
child: Opacity(
|
||||
opacity: 1 - _flash.value,
|
||||
child: Text(
|
||||
'COMBO x$_comboStreak',
|
||||
style: TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.amber.shade300,
|
||||
shadows: const [
|
||||
Shadow(blurRadius: 12, color: Colors.black54),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_movesChip(theme),
|
||||
Visibility(
|
||||
visible: !view.endless,
|
||||
maintainSize: true,
|
||||
maintainAnimation: true,
|
||||
maintainState: true,
|
||||
child: _movesChip(theme),
|
||||
),
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
transitionBuilder: (child, anim) =>
|
||||
|
||||
@@ -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 '../theme/palette.dart';
|
||||
import 'tile_painter.dart';
|
||||
|
||||
/// Draws a piece as rounded tiles at a given cell size; reused by the tray,
|
||||
/// the drag overlay, and ghost previews.
|
||||
@@ -12,8 +13,6 @@ void paintPiece(
|
||||
Offset origin = Offset.zero,
|
||||
Color? overrideColor,
|
||||
}) {
|
||||
final paint = Paint()
|
||||
..color = overrideColor ?? GamePalette.tile(piece.colorId);
|
||||
final inset = cellSize * 0.05;
|
||||
final radius = Radius.circular(cellSize * 0.18);
|
||||
for (final (dx, dy) in piece.offsets) {
|
||||
@@ -23,17 +22,13 @@ void paintPiece(
|
||||
cellSize - inset * 2,
|
||||
cellSize - inset * 2,
|
||||
);
|
||||
canvas.drawRRect(RRect.fromRectAndRadius(rect, radius), paint);
|
||||
if (overrideColor == null) {
|
||||
// Subtle top highlight for depth.
|
||||
final highlight = Paint()..color = Colors.white.withValues(alpha: 0.18);
|
||||
if (overrideColor != null) {
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
Rect.fromLTWH(rect.left, rect.top, rect.width, rect.height * 0.32),
|
||||
radius,
|
||||
),
|
||||
highlight,
|
||||
RRect.fromRectAndRadius(rect, radius),
|
||||
Paint()..color = overrideColor,
|
||||
);
|
||||
} else {
|
||||
paintGlossyTile(canvas, rect, GamePalette.tile(piece.colorId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')!.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', () {
|
||||
final pack = SeasonPack.fromJson(packJson);
|
||||
expect(pack.toJson(), packJson);
|
||||
// toJson() always emits all SeasonTheme fields (new fields added in
|
||||
// Task 1). Compare everything except theme separately so that adding
|
||||
// more theme fields in the future only requires updating theme tests.
|
||||
final json = pack.toJson();
|
||||
expect(json['schemaVersion'], packJson['schemaVersion']);
|
||||
expect(json['seasonId'], packJson['seasonId']);
|
||||
expect(json['version'], packJson['version']);
|
||||
expect(json['title'], packJson['title']);
|
||||
expect(json['stages'], packJson['stages']);
|
||||
// Theme: legacy fields preserved, new fields present with defaults.
|
||||
final theme = json['theme'] as Map<String, dynamic>;
|
||||
expect(theme['tileSet'], 'spring');
|
||||
expect(theme['background'], 'background.webp');
|
||||
expect(theme['backgroundGradient'], SeasonTheme.defaultGradient);
|
||||
expect(theme['accentColor'], 0xFFFF7EB3);
|
||||
expect(theme['particleType'], 'petals');
|
||||
expect(theme.containsKey('tilePalette'), isFalse);
|
||||
expect(theme.containsKey('boardTint'), isFalse);
|
||||
});
|
||||
|
||||
test('localized title falls back to English', () {
|
||||
@@ -58,4 +75,42 @@ void main() {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('SeasonTheme visuals', () {
|
||||
test('legacy theme json (tileSet/background only) gets defaults', () {
|
||||
final theme = SeasonTheme.fromJson({
|
||||
'tileSet': 'spring',
|
||||
'background': 'background.webp',
|
||||
});
|
||||
expect(theme.backgroundGradient, SeasonTheme.defaultGradient);
|
||||
expect(theme.accentColor, 0xFFFF7EB3);
|
||||
expect(theme.particleType, 'petals');
|
||||
expect(theme.tilePalette, isNull);
|
||||
expect(theme.boardTint, isNull);
|
||||
});
|
||||
|
||||
test('full theme json round-trips', () {
|
||||
final theme = SeasonTheme(
|
||||
tileSet: 'summer',
|
||||
background: 'bg.webp',
|
||||
backgroundGradient: const [0xFF0A2430, 0xFF10394A, 0xFF1E5A66],
|
||||
accentColor: 0xFF6FCDF5,
|
||||
particleType: 'snow',
|
||||
tilePalette: const [0xFF111111, 0xFF222222],
|
||||
boardTint: 0xFF041016,
|
||||
);
|
||||
final decoded = SeasonTheme.fromJson(theme.toJson());
|
||||
expect(decoded.backgroundGradient, theme.backgroundGradient);
|
||||
expect(decoded.accentColor, theme.accentColor);
|
||||
expect(decoded.particleType, theme.particleType);
|
||||
expect(decoded.tilePalette, theme.tilePalette);
|
||||
expect(decoded.boardTint, theme.boardTint);
|
||||
});
|
||||
|
||||
test('fallback constant matches season 1 defaults', () {
|
||||
expect(SeasonTheme.fallback.backgroundGradient,
|
||||
const [0xFF0E1430, 0xFF16204A, 0xFF2A2E5E]);
|
||||
expect(SeasonTheme.fallback.particleType, 'petals');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(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();
|
||||
|
||||
// Total stars displayed in header.
|
||||
expect(find.text('★ 2/9'), findsOneWidget);
|
||||
expect(find.byIcon(Icons.lock), findsOneWidget); // stage 3 locked
|
||||
expect(find.text('1'), findsOneWidget);
|
||||
expect(find.text('2'), findsOneWidget);
|
||||
|
||||
// Stage 1 is starred, so stage 2 is unlocked and playable.
|
||||
await tester.tap(find.text('2'));
|
||||
// Node 0 (stage 1) exists.
|
||||
expect(find.byKey(const Key('stage_node_0')), findsOneWidget);
|
||||
|
||||
// Stage 3 (index 2) is locked — contains a lock icon.
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('stage_node_2')),
|
||||
matching: find.byIcon(Icons.lock),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
// Stage 1 is starred, so stage 2 (index 1) is unlocked and playable.
|
||||
// Ensure the node is visible before tapping.
|
||||
await tester.ensureVisible(find.byKey(const Key('stage_node_1')));
|
||||
await tester.tap(find.byKey(const Key('stage_node_1')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(GameScreen), findsOneWidget);
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
// Splash (~1.9s) → (later: season title card) → home.
|
||||
await tester.pump(const Duration(milliseconds: 2100));
|
||||
await tester.pump(const Duration(milliseconds: 2000));
|
||||
await tester.pump(const Duration(milliseconds: 2000));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Block Seasons'), findsOneWidget);
|
||||
expect(find.text('Play'), findsOneWidget);
|
||||
expect(find.text('Adventure'), findsOneWidget);
|
||||
expect(find.text('Classic'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user