Files
freeCell/lib/screens/freecell_screen.dart
2026-06-02 19:01:08 +08:00

2339 lines
63 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:math';
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:freecell/audio/audio_manager.dart';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// 单独一个 Screen直接
/// Navigator.push(context, MaterialPageRoute(builder: (_) => const FreeCellScreen()));
class FreeCellScreen extends StatelessWidget {
const FreeCellScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: GameWidget(
game: FreeCellGame(),
overlayBuilderMap: {
'settings': (context, game) {
return SettingsOverlay(game: game as FreeCellGame);
},
'history': (context, game) {
return HistoryOverlay(game: game as FreeCellGame);
},
'win': (context, game) {
final freeCellGame = game as FreeCellGame;
return Center(
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(18),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'You Win!',
style: TextStyle(
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
_ResultStatText(
label: 'Time',
value: freeCellGame.stats.formatSeconds(
freeCellGame.elapsedSeconds.floor(),
),
),
_ResultStatText(
label: 'Moves',
value: '${freeCellGame.history.length}',
),
const SizedBox(height: 18),
ElevatedButton(
onPressed: freeCellGame.restartGame,
child: const Text('Restart Game'),
),
],
),
),
);
},
'lose': (context, game) {
final freeCellGame = game as FreeCellGame;
return Center(
child: Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(18),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'No Moves',
style: TextStyle(
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
_ResultStatText(
label: 'Time',
value: freeCellGame.stats.formatSeconds(
freeCellGame.elapsedSeconds.floor(),
),
),
_ResultStatText(
label: 'Moves',
value: '${freeCellGame.history.length}',
),
const SizedBox(height: 18),
ElevatedButton(
onPressed: freeCellGame.restartGame,
child: const Text('Restart Game'),
),
],
),
),
);
},
},
),
),
);
}
}
class _ResultStatText extends StatelessWidget {
const _ResultStatText({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text(
'$label: $value',
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
);
}
}
class SettingsOverlay extends StatefulWidget {
const SettingsOverlay({required this.game, super.key});
final FreeCellGame game;
@override
State<SettingsOverlay> createState() => _SettingsOverlayState();
}
class _SettingsOverlayState extends State<SettingsOverlay> {
late bool bgmEnabled;
late bool sfxEnabled;
late int selectedBackgroundIndex;
@override
void initState() {
super.initState();
bgmEnabled = widget.game.bgmEnabled;
sfxEnabled = widget.game.sfxEnabled;
selectedBackgroundIndex = widget.game.selectedBackgroundIndex;
}
@override
Widget build(BuildContext context) {
return Center(
child: Container(
width: min(MediaQuery.of(context).size.width - 28, 390),
padding: const EdgeInsets.fromLTRB(18, 16, 18, 18),
decoration: BoxDecoration(
color: const Color(0xFFF6EBCF),
border: Border.all(color: const Color(0xFFC9B98F), width: 2),
borderRadius: BorderRadius.circular(6),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.24),
blurRadius: 18,
offset: const Offset(0, 8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'SETTING',
style: TextStyle(
color: Color(0xFF595347),
fontSize: 18,
fontWeight: FontWeight.w900,
letterSpacing: 0,
),
),
const SizedBox(height: 14),
Row(
children: [
const Expanded(
child: Text(
'Background Music',
style: TextStyle(
color: Color(0xFF595347),
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
),
Switch(
value: bgmEnabled,
activeThumbColor: const Color(0xFF67B856),
activeTrackColor: const Color(0xFFB9E6A8),
onChanged: (value) {
widget.game.playSelectSound();
setState(() => bgmEnabled = value);
widget.game.setBgmEnabled(value);
},
),
],
),
Row(
children: [
const Expanded(
child: Text(
'Sound Effects',
style: TextStyle(
color: Color(0xFF595347),
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
),
Switch(
value: sfxEnabled,
activeThumbColor: const Color(0xFF67B856),
activeTrackColor: const Color(0xFFB9E6A8),
onChanged: (value) {
widget.game.playSelectSound();
setState(() => sfxEnabled = value);
widget.game.setSfxEnabled(value);
},
),
],
),
const SizedBox(height: 10),
const Align(
alignment: Alignment.centerLeft,
child: Text(
'Change Background',
style: TextStyle(
color: Color(0xFF595347),
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
),
const SizedBox(height: 10),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: widget.game.backgroundAssets.length,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 10,
crossAxisSpacing: 10,
childAspectRatio: 1.35,
),
itemBuilder: (context, index) {
final selected = selectedBackgroundIndex == index;
return GestureDetector(
onTap: () async {
widget.game.playSelectSound();
setState(() => selectedBackgroundIndex = index);
await widget.game.setBackgroundIndex(index);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5),
border: Border.all(
color: selected
? const Color(0xFF5FAE45)
: const Color(0xFFE1D1AA),
width: selected ? 3 : 1,
),
),
clipBehavior: Clip.antiAlias,
child: Image.asset(
'assets/images/${widget.game.backgroundAssets[index]}',
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return ColoredBox(
color: const Color(0xFF105A35),
child: Center(
child: Text(
'${index + 1}',
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
);
},
),
),
);
},
),
const SizedBox(height: 18),
SizedBox(
width: 188,
height: 44,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6DBA57),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w900,
),
),
onPressed: () {
widget.game.playSelectSound();
widget.game.closeSettings();
},
child: const Text('SAVE AND EXIT'),
),
),
],
),
),
);
}
}
class HistoryOverlay extends StatelessWidget {
const HistoryOverlay({required this.game, super.key});
final FreeCellGame game;
@override
Widget build(BuildContext context) {
final stats = game.stats;
return Center(
child: Container(
width: min(MediaQuery.of(context).size.width - 28, 390),
padding: const EdgeInsets.fromLTRB(20, 18, 20, 18),
decoration: BoxDecoration(
color: const Color(0xFFF6EBCF),
border: Border.all(color: const Color(0xFFC9B98F), width: 2),
borderRadius: BorderRadius.circular(6),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.24),
blurRadius: 18,
offset: const Offset(0, 8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'HISTORY',
style: TextStyle(
color: Color(0xFF595347),
fontSize: 18,
fontWeight: FontWeight.w900,
letterSpacing: 0,
),
),
const SizedBox(height: 14),
_HistoryRow(label: 'Wins', value: '${stats.winCount}'),
_HistoryRow(label: 'Losses', value: '${stats.lossCount}'),
_HistoryRow(label: 'Unfinished', value: '${stats.unfinishedCount}'),
const Divider(height: 22, color: Color(0xFFC9B98F)),
_HistoryRow(
label: 'Fastest Win',
value: stats.formatSeconds(stats.minWinSeconds),
),
_HistoryRow(
label: 'Slowest Win',
value: stats.formatSeconds(stats.maxWinSeconds),
),
_HistoryRow(
label: 'Fewest Win Moves',
value: stats.formatInt(stats.minWinMoves),
),
_HistoryRow(
label: 'Most Win Moves',
value: stats.formatInt(stats.maxWinMoves),
),
const SizedBox(height: 18),
SizedBox(
width: 150,
height: 42,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF6DBA57),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w900,
),
),
onPressed: () {
game.playSelectSound();
game.closeHistory();
},
child: const Text('CLOSE'),
),
),
],
),
),
);
}
}
class _HistoryRow extends StatelessWidget {
const _HistoryRow({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: Row(
children: [
Expanded(
child: Text(
label,
style: const TextStyle(
color: Color(0xFF595347),
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
),
Text(
value,
style: const TextStyle(
color: Color(0xFF3D372D),
fontSize: 15,
fontWeight: FontWeight.w900,
),
),
],
),
);
}
}
class FreeCellStats {
int winCount = 0;
int lossCount = 0;
int unfinishedCount = 0;
int? minWinSeconds;
int? maxWinSeconds;
int? minWinMoves;
int? maxWinMoves;
void recordWin({required int seconds, required int moves}) {
winCount++;
minWinSeconds = _minNullable(minWinSeconds, seconds);
maxWinSeconds = _maxNullable(maxWinSeconds, seconds);
minWinMoves = _minNullable(minWinMoves, moves);
maxWinMoves = _maxNullable(maxWinMoves, moves);
}
void recordLoss() {
lossCount++;
}
void recordUnfinished() {
unfinishedCount++;
}
String formatInt(int? value) => value == null ? '-' : '$value';
String formatSeconds(int? value) {
if (value == null) return '-';
final minutes = value ~/ 60;
final seconds = value % 60;
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
static int _minNullable(int? current, int value) {
return current == null ? value : min(current, value);
}
static int _maxNullable(int? current, int value) {
return current == null ? value : max(current, value);
}
}
enum Suit { spade, heart, diamond, club }
enum PileType { freeCell, foundation, tableau }
class CardModel {
final Suit suit;
final int rank;
const CardModel(this.suit, this.rank);
bool get isRed => suit == Suit.heart || suit == Suit.diamond;
String get rankText {
return switch (rank) {
1 => 'A',
11 => 'J',
12 => 'Q',
13 => 'K',
_ => '$rank',
};
}
String get suitText {
return switch (suit) {
Suit.spade => '',
Suit.heart => '',
Suit.diamond => '',
Suit.club => '',
};
}
/// 预留扑克图片路径assets/images/cards/AS.png、10H.png、KD.png 等。
String get imageName {
final s = switch (suit) {
Suit.spade => 'S',
Suit.heart => 'H',
Suit.diamond => 'D',
Suit.club => 'C',
};
return '$rankText$s.png';
}
}
class CardPile {
CardPile({required this.type, required this.index, required this.position});
final PileType type;
final int index;
Vector2 position;
final List<CardComponent> cards = [];
}
class MoveRecord {
MoveRecord({
required this.cards,
required this.fromPile,
required this.toPile,
});
final List<CardComponent> cards;
final CardPile fromPile;
final CardPile toPile;
}
class HintMove {
const HintMove({required this.cards, required this.target});
final List<CardComponent> cards;
final CardPile target;
}
enum _SolveStatus { solved, unsolved, unknown }
class _SolverMove {
const _SolverMove({
required this.fromType,
required this.fromIndex,
required this.startIndex,
required this.toType,
required this.toIndex,
});
final PileType fromType;
final int fromIndex;
final int startIndex;
final PileType toType;
final int toIndex;
}
class _SolverState {
_SolverState({
required this.tableaus,
required this.freeCells,
required this.foundations,
});
final List<List<int>> tableaus;
final List<int?> freeCells;
final List<int> foundations;
_SolverState clone() {
return _SolverState(
tableaus: [for (final pile in tableaus) List<int>.of(pile)],
freeCells: List<int?>.of(freeCells),
foundations: List<int>.of(foundations),
);
}
int get foundationCount {
return foundations.fold<int>(0, (sum, rank) => sum + rank);
}
int get emptyFreeCellCount {
return freeCells.where((card) => card == null).length;
}
int get emptyTableauCount {
return tableaus.where((pile) => pile.isEmpty).length;
}
bool get isWon => foundationCount == 52;
}
class _FreeCellSolver {
_FreeCellSolver({
required this.initial,
this.maxDepth = 80,
this.maxVisited = 50000,
});
final _SolverState initial;
final int maxDepth;
final int maxVisited;
int _visitedCount = 0;
bool _exhausted = false;
_SolveStatus solve() {
final visited = <String>{};
final solved = _dfs(initial, maxDepth, visited);
if (solved) return _SolveStatus.solved;
if (_exhausted) return _SolveStatus.unknown;
return _SolveStatus.unsolved;
}
bool _dfs(_SolverState state, int depthLeft, Set<String> visited) {
if (state.isWon) return true;
final moves = _moves(state);
if (moves.isEmpty) return false;
if (depthLeft <= 0 || _visitedCount >= maxVisited) {
_exhausted = true;
return false;
}
final key = _key(state);
if (!visited.add(key)) return false;
_visitedCount++;
for (final move in moves) {
final next = _apply(state, move);
if (next.foundationCount > state.foundationCount) return true;
if (next.emptyTableauCount > state.emptyTableauCount) return true;
if (_dfs(next, depthLeft - 1, visited)) return true;
}
return false;
}
List<_SolverMove> _moves(_SolverState state) {
final moves = <_SolverMove>[];
for (int i = 0; i < state.freeCells.length; i++) {
final card = state.freeCells[i];
if (card == null) continue;
if (_canMoveToFoundation(card, state)) {
moves.add(
_SolverMove(
fromType: PileType.freeCell,
fromIndex: i,
startIndex: 0,
toType: PileType.foundation,
toIndex: _suitOf(card),
),
);
}
for (int t = 0; t < state.tableaus.length; t++) {
if (_canMoveToTableau(card, state.tableaus[t])) {
moves.add(
_SolverMove(
fromType: PileType.freeCell,
fromIndex: i,
startIndex: 0,
toType: PileType.tableau,
toIndex: t,
),
);
}
}
}
final emptyFreeCell = state.freeCells.indexWhere((card) => card == null);
for (int from = 0; from < state.tableaus.length; from++) {
final tableau = state.tableaus[from];
if (tableau.isEmpty) continue;
final top = tableau.last;
if (_canMoveToFoundation(top, state)) {
moves.add(
_SolverMove(
fromType: PileType.tableau,
fromIndex: from,
startIndex: tableau.length - 1,
toType: PileType.foundation,
toIndex: _suitOf(top),
),
);
}
if (emptyFreeCell != -1) {
moves.add(
_SolverMove(
fromType: PileType.tableau,
fromIndex: from,
startIndex: tableau.length - 1,
toType: PileType.freeCell,
toIndex: emptyFreeCell,
),
);
}
final maxMovableStackSize = _maxMovableStackSize(state);
for (int start = tableau.length - 1; start >= 0; start--) {
final run = tableau.sublist(start);
if (!_isOrderedRun(run)) break;
if (run.length > maxMovableStackSize) continue;
final first = run.first;
for (int to = 0; to < state.tableaus.length; to++) {
if (to == from) continue;
final target = state.tableaus[to];
if (target.isEmpty && start == 0) continue;
if (!_canMoveToTableau(first, target)) continue;
moves.add(
_SolverMove(
fromType: PileType.tableau,
fromIndex: from,
startIndex: start,
toType: PileType.tableau,
toIndex: to,
),
);
}
}
}
moves.sort((a, b) => _moveScore(b).compareTo(_moveScore(a)));
return moves;
}
int _maxMovableStackSize(_SolverState state) {
return (state.emptyFreeCellCount + 1) * (1 << state.emptyTableauCount);
}
int _moveScore(_SolverMove move) {
if (move.toType == PileType.foundation) return 100;
if (move.fromType == PileType.freeCell && move.toType == PileType.tableau) {
return 70;
}
if (move.toType == PileType.tableau) return 50;
if (move.toType == PileType.freeCell) return 20;
return 0;
}
_SolverState _apply(_SolverState state, _SolverMove move) {
final next = state.clone();
final moving = <int>[];
switch (move.fromType) {
case PileType.freeCell:
final card = next.freeCells[move.fromIndex];
if (card != null) {
moving.add(card);
next.freeCells[move.fromIndex] = null;
}
break;
case PileType.tableau:
final from = next.tableaus[move.fromIndex];
moving.addAll(from.sublist(move.startIndex));
from.removeRange(move.startIndex, from.length);
break;
case PileType.foundation:
break;
}
if (moving.isEmpty) return next;
switch (move.toType) {
case PileType.freeCell:
next.freeCells[move.toIndex] = moving.single;
break;
case PileType.foundation:
final card = moving.single;
next.foundations[_suitOf(card)] = _rankOf(card);
break;
case PileType.tableau:
next.tableaus[move.toIndex].addAll(moving);
break;
}
return next;
}
String _key(_SolverState state) {
final foundation = state.foundations.join(',');
final free = [for (final card in state.freeCells) ?card]..sort();
final columns = [for (final tableau in state.tableaus) tableau.join('.')]
..sort();
return '$foundation|${free.join('.')}|${columns.join('|')}';
}
bool _canMoveToFoundation(int card, _SolverState state) {
return state.foundations[_suitOf(card)] + 1 == _rankOf(card);
}
bool _canMoveToTableau(int card, List<int> tableau) {
if (tableau.isEmpty) return true;
final top = tableau.last;
return _isRed(card) != _isRed(top) && _rankOf(card) == _rankOf(top) - 1;
}
bool _isOrderedRun(List<int> cards) {
if (cards.length <= 1) return true;
for (int i = 0; i < cards.length - 1; i++) {
final upper = cards[i];
final lower = cards[i + 1];
if (_isRed(upper) == _isRed(lower) ||
_rankOf(lower) != _rankOf(upper) - 1) {
return false;
}
}
return true;
}
static int encode(CardModel model) {
return model.suit.index * 13 + model.rank;
}
static int _suitOf(int card) => (card - 1) ~/ 13;
static int _rankOf(int card) => ((card - 1) % 13) + 1;
static bool _isRed(int card) {
final suit = _suitOf(card);
return suit == Suit.heart.index || suit == Suit.diamond.index;
}
}
class FreeCellGame extends FlameGame {
static const double cardWidth = 36;
static const double cardHeight = 52;
static const double gap = 8;
static const double horizontalPadding = 8;
static const double topY = 12;
static const double tableauY = 148;
static const double stackOffset = 27;
static const double moveAnimationDuration = 0.24;
static const String _bgmEnabledKey = 'freecell_bgm_enabled';
static const String _sfxEnabledKey = 'freecell_sfx_enabled';
static const String _backgroundIndexKey = 'freecell_background_index';
static const String _statsWinCountKey = 'freecell_stats_win_count';
static const String _statsLossCountKey = 'freecell_stats_loss_count';
static const String _statsUnfinishedCountKey =
'freecell_stats_unfinished_count';
static const String _statsMinWinSecondsKey = 'freecell_stats_min_win_seconds';
static const String _statsMaxWinSecondsKey = 'freecell_stats_max_win_seconds';
static const String _statsMinWinMovesKey = 'freecell_stats_min_win_moves';
static const String _statsMaxWinMovesKey = 'freecell_stats_max_win_moves';
final List<CardPile> freeCells = [];
final List<CardPile> foundations = [];
final List<CardPile> tableaus = [];
final List<MoveRecord> history = [];
CardComponent? draggingCard;
List<CardComponent> draggingCards = [];
CardPile? dragStartPile;
Vector2 dragStartPosition = Vector2.zero();
double elapsedSeconds = 0;
int _lastDisplayedSeconds = -1;
late TextComponent titleText;
late TextComponent timerText;
late TextComponent movesText;
late TextComponent hintText;
late HudButtonComponent undoButton;
late HudButtonComponent hintButton;
late HudButtonComponent restartButton;
late HudButtonComponent settingButton;
late HudButtonComponent historyButton;
late HintOverlayComponent hintOverlay;
late RectangleComponent backgroundFill;
SpriteComponent? backgroundSprite;
bool _hudReady = false;
List<CardComponent> hintedCards = [];
CardPile? hintedTarget;
double _hintSecondsLeft = 0;
bool bgmEnabled = true;
bool sfxEnabled = true;
int selectedBackgroundIndex = 0;
bool _winSfxPlayed = false;
bool _lossSfxPlayed = false;
bool _gameResultRecorded = false;
int _dealGeneration = 0;
final FreeCellStats stats = FreeCellStats();
final List<String> backgroundAssets = const [
'bg/freecell_bg.png',
'bg/freecell_bg1.png',
'bg/freecell_bg2.png',
'bg/freecell_bg3.png',
];
@override
Color backgroundColor() => const Color(0xFF105A35);
Future<void> setBackgroundIndex(int index) async {
if (index < 0 || index >= backgroundAssets.length) return;
selectedBackgroundIndex = index;
unawaited(_saveSettings());
try {
final bgImage = await images.load(backgroundAssets[index]);
final sprite = backgroundSprite;
if (sprite == null) {
backgroundSprite = SpriteComponent(
sprite: Sprite(bgImage),
size: size,
priority: -100,
);
add(backgroundSprite!);
} else {
sprite.sprite = Sprite(bgImage);
sprite.size = size;
}
} catch (_) {
backgroundSprite?.removeFromParent();
backgroundSprite = null;
}
}
void setBgmEnabled(bool value) {
bgmEnabled = value;
unawaited(_saveSettings());
unawaited(_applyBgmEnabled(value));
}
void setSfxEnabled(bool value) {
sfxEnabled = value;
unawaited(_saveSettings());
unawaited(_applySfxEnabled(value));
}
void playSelectSound() {
_playSfx(SfxKey.select);
}
void _playSfx(SfxKey key) {
unawaited(_safePlaySfx(key));
}
Future<void> _setupAudio() async {
try {
await AudioManager.instance.init();
await _applyBgmEnabled(bgmEnabled);
await _applySfxEnabled(sfxEnabled);
} catch (e) {
debugPrint('FreeCell audio setup failed: $e');
}
}
Future<void> _loadSettings() async {
try {
final prefs = await SharedPreferences.getInstance();
bgmEnabled = prefs.getBool(_bgmEnabledKey) ?? bgmEnabled;
sfxEnabled = prefs.getBool(_sfxEnabledKey) ?? sfxEnabled;
final savedBackgroundIndex = prefs.getInt(_backgroundIndexKey);
if (savedBackgroundIndex != null &&
savedBackgroundIndex >= 0 &&
savedBackgroundIndex < backgroundAssets.length) {
selectedBackgroundIndex = savedBackgroundIndex;
}
stats.winCount = prefs.getInt(_statsWinCountKey) ?? stats.winCount;
stats.lossCount = prefs.getInt(_statsLossCountKey) ?? stats.lossCount;
stats.unfinishedCount =
prefs.getInt(_statsUnfinishedCountKey) ?? stats.unfinishedCount;
stats.minWinSeconds = prefs.getInt(_statsMinWinSecondsKey);
stats.maxWinSeconds = prefs.getInt(_statsMaxWinSecondsKey);
stats.minWinMoves = prefs.getInt(_statsMinWinMovesKey);
stats.maxWinMoves = prefs.getInt(_statsMaxWinMovesKey);
} catch (e) {
debugPrint('FreeCell load settings failed: $e');
}
}
Future<void> _saveSettings() async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_bgmEnabledKey, bgmEnabled);
await prefs.setBool(_sfxEnabledKey, sfxEnabled);
await prefs.setInt(_backgroundIndexKey, selectedBackgroundIndex);
await prefs.setInt(_statsWinCountKey, stats.winCount);
await prefs.setInt(_statsLossCountKey, stats.lossCount);
await prefs.setInt(_statsUnfinishedCountKey, stats.unfinishedCount);
await _setOptionalInt(prefs, _statsMinWinSecondsKey, stats.minWinSeconds);
await _setOptionalInt(prefs, _statsMaxWinSecondsKey, stats.maxWinSeconds);
await _setOptionalInt(prefs, _statsMinWinMovesKey, stats.minWinMoves);
await _setOptionalInt(prefs, _statsMaxWinMovesKey, stats.maxWinMoves);
} catch (e) {
debugPrint('FreeCell save settings failed: $e');
}
}
Future<void> _setOptionalInt(
SharedPreferences prefs,
String key,
int? value,
) {
if (value == null) {
return prefs.remove(key);
}
return prefs.setInt(key, value);
}
Future<void> _applyBgmEnabled(bool enabled) async {
try {
await AudioManager.instance.setBgmMuted(!enabled);
if (enabled) {
await AudioManager.instance.playBgm();
}
} catch (e) {
debugPrint('FreeCell bgm toggle failed: $e');
}
}
Future<void> _applySfxEnabled(bool enabled) async {
try {
await AudioManager.instance.setSfxMuted(!enabled);
} catch (e) {
debugPrint('FreeCell sfx toggle failed: $e');
}
}
Future<void> _safePlaySfx(SfxKey key) async {
if (!sfxEnabled) return;
try {
await AudioManager.instance.playSfx(key);
} catch (e) {
debugPrint('FreeCell sfx failed: $e');
}
}
void openSettings() {
overlays.add('settings');
}
void closeSettings() {
overlays.remove('settings');
}
void openHistory() {
overlays.add('history');
}
void closeHistory() {
overlays.remove('history');
}
void _recordWinStats() {
if (_gameResultRecorded) return;
stats.recordWin(seconds: elapsedSeconds.floor(), moves: history.length);
_gameResultRecorded = true;
unawaited(_saveSettings());
}
void _recordLossStats() {
if (_gameResultRecorded) return;
stats.recordLoss();
_gameResultRecorded = true;
unawaited(_saveSettings());
}
void _recordUnfinishedStats() {
if (_gameResultRecorded) return;
stats.recordUnfinished();
_gameResultRecorded = true;
unawaited(_saveSettings());
}
@override
Future<void> onLoad() async {
await super.onLoad();
await _loadSettings();
unawaited(_setupAudio());
backgroundFill = RectangleComponent(
size: size,
paint: Paint()..color = const Color(0xFF105A35),
priority: -101,
);
add(backgroundFill);
await setBackgroundIndex(selectedBackgroundIndex);
titleText = TextComponent(
text: 'FREECELL',
position: Vector2(size.x / 2, 8),
anchor: Anchor.topCenter,
priority: 1000,
textRenderer: TextPaint(
style: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
);
timerText = TextComponent(
text: '00:00',
position: Vector2(10, 10),
anchor: Anchor.topLeft,
priority: 1000,
textRenderer: TextPaint(
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
);
movesText = TextComponent(
text: 'Moves 0',
position: Vector2(size.x - 10, 10),
anchor: Anchor.topRight,
priority: 1000,
textRenderer: TextPaint(
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
);
hintText = TextComponent(
text: 'Drag top cards. Double tap to auto move.',
position: Vector2(size.x / 2, size.y - 54),
anchor: Anchor.center,
priority: 1000,
textRenderer: TextPaint(
style: const TextStyle(color: Colors.white70, fontSize: 13),
),
);
add(titleText);
add(timerText);
add(movesText);
add(hintText);
undoButton = HudButtonComponent(
label: 'UNDO',
onPressed: (game) => game.undo(),
);
hintButton = HudButtonComponent(
label: 'HINT',
onPressed: (game) => game.showHint(),
);
restartButton = HudButtonComponent(
label: 'RESTART',
onPressed: (game) => game.restartGame(),
);
settingButton = HudButtonComponent(
label: 'SETTING',
onPressed: (game) => game.openSettings(),
);
historyButton = HudButtonComponent(
label: 'HISTORY',
onPressed: (game) => game.openHistory(),
);
hintOverlay = HintOverlayComponent();
add(undoButton);
add(hintButton);
add(restartButton);
add(settingButton);
add(historyButton);
add(hintOverlay);
_hudReady = true;
_positionHud();
_refreshHud(force: true);
_createPiles();
restartGame(recordUnfinished: false);
}
@override
void onRemove() {
unawaited(AudioManager.instance.stopBgm());
super.onRemove();
}
@override
void update(double dt) {
super.update(dt);
if (!_hudReady) return;
if (isWon || overlays.isActive('lose')) return;
elapsedSeconds += dt;
if (_hintSecondsLeft > 0) {
_hintSecondsLeft -= dt;
if (_hintSecondsLeft <= 0) {
clearHint();
}
}
_refreshHud();
}
@override
void onGameResize(Vector2 size) {
super.onGameResize(size);
if (!_hudReady) return;
backgroundFill.size = size;
backgroundSprite?.size = size;
_positionHud();
_positionPiles();
_layoutAll();
}
void _positionHud() {
if (!_hudReady) return;
titleText.position = Vector2(size.x / 2, 8);
timerText.position = Vector2(10, 10);
movesText.position = Vector2(size.x - 10, 10);
hintText.position = Vector2(size.x / 2, size.y - 58);
final buttonY = size.y - 30;
const buttonStep = 70.0;
undoButton.position = Vector2(size.x / 2 - buttonStep * 2, buttonY);
hintButton.position = Vector2(size.x / 2 - buttonStep, buttonY);
restartButton.position = Vector2(size.x / 2, buttonY);
settingButton.position = Vector2(size.x / 2 + buttonStep, buttonY);
historyButton.position = Vector2(size.x / 2 + buttonStep * 2, buttonY);
}
void _refreshHud({bool force = false}) {
if (!_hudReady) return;
final seconds = elapsedSeconds.floor();
if (force || seconds != _lastDisplayedSeconds) {
_lastDisplayedSeconds = seconds;
final minutes = seconds ~/ 60;
final restSeconds = seconds % 60;
timerText.text =
'${minutes.toString().padLeft(2, '0')}:${restSeconds.toString().padLeft(2, '0')}';
}
movesText.text = 'Moves ${history.length}';
undoButton.enabled = history.isNotEmpty && !isGameOver;
hintButton.enabled = _findLegalMoveForLoseCheck() != null && !isGameOver;
restartButton.enabled = true;
settingButton.enabled = true;
historyButton.enabled = true;
}
double _columnGap(double safeWidth) {
final availableGap =
(safeWidth - horizontalPadding * 2 - cardWidth * 8) / 7;
return availableGap.clamp(2.0, gap);
}
void _createPiles() {
freeCells.clear();
foundations.clear();
tableaus.clear();
for (int i = 0; i < 4; i++) {
freeCells.add(
CardPile(type: PileType.freeCell, index: i, position: Vector2.zero()),
);
}
for (int i = 0; i < 4; i++) {
foundations.add(
CardPile(type: PileType.foundation, index: i, position: Vector2.zero()),
);
}
for (int i = 0; i < 8; i++) {
tableaus.add(
CardPile(type: PileType.tableau, index: i, position: Vector2.zero()),
);
}
_positionPiles();
for (final pile in [...freeCells, ...foundations, ...tableaus]) {
add(PileSlotComponent(pile));
}
}
void _positionPiles() {
if (freeCells.isEmpty || foundations.isEmpty || tableaus.isEmpty) return;
final rowGap = _columnGap(size.x);
final step = cardWidth + rowGap;
final usedWidth = cardWidth * 8 + rowGap * 7;
final startX = max(horizontalPadding, (size.x - usedWidth) / 2);
final topPileY = topY + 24;
for (int i = 0; i < freeCells.length; i++) {
freeCells[i].position = Vector2(startX + i * step, topPileY);
}
for (int i = 0; i < foundations.length; i++) {
foundations[i].position = Vector2(startX + (i + 4) * step, topPileY);
}
for (int i = 0; i < tableaus.length; i++) {
tableaus[i].position = Vector2(startX + i * step, tableauY);
}
}
Future<void> _dealCards({required int generation}) async {
if (generation != _dealGeneration) return;
final deck = <CardModel>[];
for (final suit in Suit.values) {
for (int rank = 1; rank <= 13; rank++) {
deck.add(CardModel(suit, rank));
}
}
deck.shuffle(Random());
_playSfx(SfxKey.shuffle);
for (int i = 0; i < deck.length; i++) {
final pile = tableaus[i % tableaus.length];
final card = CardComponent(deck[i], pile);
pile.cards.add(card);
add(card);
}
final dealtCards = <CardComponent>[];
final dealOrigin = Vector2(size.x / 2 - cardWidth / 2, -cardHeight - 8);
for (final pile in tableaus) {
for (final card in pile.cards) {
dealtCards.add(card);
card.position = dealOrigin.clone();
}
}
_layoutAll(skip: dealtCards.toSet());
for (int i = 0; i < dealtCards.length; i++) {
final card = dealtCards[i];
card.animateTo(
_restPositionFor(card),
duration: 0.32,
delay: i * 0.025,
endPriority: _restPriorityFor(card),
);
}
await Future<void>.delayed(const Duration(milliseconds: 1700));
if (generation != _dealGeneration) return;
_autoMoveAvailableToFoundations(record: false);
}
void restartGame({bool recordUnfinished = true}) {
final generation = ++_dealGeneration;
if (recordUnfinished) {
_recordUnfinishedStats();
}
overlays.remove('win');
overlays.remove('lose');
overlays.remove('history');
clearHint();
_winSfxPlayed = false;
_lossSfxPlayed = false;
_gameResultRecorded = false;
for (final card in children.whereType<CardComponent>().toList()) {
card.removeFromParent();
}
for (final pile in [...freeCells, ...foundations, ...tableaus]) {
pile.cards.clear();
}
history.clear();
draggingCard = null;
draggingCards = [];
dragStartPile = null;
elapsedSeconds = 0;
_lastDisplayedSeconds = -1;
_refreshHud(force: true);
_dealCards(generation: generation);
}
bool isTopCard(CardComponent card) {
return card.pile.cards.isNotEmpty && card.pile.cards.last == card;
}
bool _isOrderedPair(CardModel upper, CardModel lower) {
return upper.isRed != lower.isRed && lower.rank == upper.rank - 1;
}
bool _isOrderedRun(List<CardComponent> cards) {
if (cards.length <= 1) return true;
for (int i = 0; i < cards.length - 1; i++) {
if (!_isOrderedPair(cards[i].model, cards[i + 1].model)) {
return false;
}
}
return true;
}
List<CardComponent> _movableRunFrom(CardComponent card) {
if (isGameOver) return const [];
final pile = card.pile;
final index = pile.cards.indexOf(card);
if (index == -1) return const [];
final run = pile.cards.sublist(index);
if (run.any((card) => card.isAnimating)) return const [];
if (pile.type != PileType.tableau) {
return run.length == 1 ? run : const [];
}
return _isOrderedRun(run) ? run : const [];
}
void startDrag(CardComponent card) {
if (isGameOver) return;
final run = _movableRunFrom(card);
if (run.isEmpty) return;
_playSfx(SfxKey.select);
draggingCard = card;
draggingCards = run;
dragStartPile = card.pile;
dragStartPosition = card.position.clone();
for (int i = 0; i < draggingCards.length; i++) {
draggingCards[i].priority = 999 + i;
}
}
void updateDrag(Vector2 delta) {
if (isGameOver) return;
for (final card in draggingCards) {
card.position.add(delta);
}
}
void endDrag() {
final card = draggingCard;
final fromPile = dragStartPile;
if (card == null || fromPile == null || draggingCards.isEmpty) return;
if (isGameOver) {
_layoutAll();
draggingCard = null;
draggingCards = [];
dragStartPile = null;
return;
}
final target = _findTargetPile(card);
if (target != null && canMoveCards(draggingCards, target)) {
moveCards(draggingCards, target, record: true);
} else {
_layoutAll();
}
draggingCard = null;
draggingCards = [];
dragStartPile = null;
checkGameState();
}
CardPile? _findTargetPile(CardComponent card) {
final center = card.position + Vector2(cardWidth / 2, cardHeight / 2);
final allPiles = [...freeCells, ...foundations, ...tableaus];
CardPile? best;
double bestDistance = double.infinity;
for (final pile in allPiles) {
if (pile == card.pile) continue;
final pos = pile.cards.isEmpty
? pile.position
: pile.cards.last.position.clone();
final rect = Rect.fromLTWH(pos.x, pos.y, cardWidth, cardHeight);
final expanded = rect.inflate(26);
if (expanded.contains(Offset(center.x, center.y))) {
final d = center.distanceTo(
pos + Vector2(cardWidth / 2, cardHeight / 2),
);
if (d < bestDistance) {
bestDistance = d;
best = pile;
}
}
}
return best;
}
bool canMove(CardComponent card, CardPile target) {
return canMoveCards([card], target);
}
bool canMoveCards(List<CardComponent> cards, CardPile target) {
if (cards.isEmpty) return false;
final firstCard = cards.first;
if (target == firstCard.pile) return false;
if (!_isOrderedRun(cards)) return false;
if (cards.length > 1 && target.type != PileType.tableau) {
return false;
}
switch (target.type) {
case PileType.freeCell:
return target.cards.isEmpty;
case PileType.foundation:
final card = cards.single;
if (target.cards.isEmpty) {
return card.model.rank == 1;
}
final top = target.cards.last.model;
return top.suit == card.model.suit && card.model.rank == top.rank + 1;
case PileType.tableau:
if (target.cards.isEmpty) return true;
final top = target.cards.last.model;
return top.isRed != firstCard.model.isRed &&
firstCard.model.rank == top.rank - 1;
}
}
CardPile? _findFoundationTarget(CardComponent card) {
for (final foundation in foundations) {
if (foundation.cards.isEmpty) continue;
final top = foundation.cards.last.model;
if (top.suit == card.model.suit && card.model.rank == top.rank + 1) {
return foundation;
}
}
if (card.model.rank != 1) return null;
for (final foundation in foundations) {
if (foundation.cards.isEmpty) {
return foundation;
}
}
return null;
}
bool _canMoveModelToTableau(CardModel model, CardPile target) {
if (target.cards.isEmpty) return true;
final top = target.cards.last.model;
return top.isRed != model.isRed && model.rank == top.rank - 1;
}
bool _wouldRevealUsefulCard(List<CardComponent> cards, CardPile target) {
if (cards.isEmpty) return false;
final source = cards.first.pile;
if (source.type != PileType.tableau) return true;
final index = source.cards.indexOf(cards.first);
if (index <= 0) return false;
final exposedCard = source.cards[index - 1];
if (_findFoundationTarget(exposedCard) != null) return true;
for (final tableau in tableaus) {
if (tableau == source || tableau == target) continue;
if (_canMoveModelToTableau(exposedCard.model, tableau)) {
return true;
}
}
return false;
}
void moveCards(
List<CardComponent> cards,
CardPile target, {
required bool record,
bool autoCheck = true,
bool animate = true,
}) {
if (isGameOver) return;
if (cards.isEmpty) return;
clearHint();
final movingCards = List<CardComponent>.of(cards);
final from = movingCards.first.pile;
for (final card in movingCards) {
from.cards.remove(card);
}
target.cards.addAll(movingCards);
for (final card in movingCards) {
card.pile = target;
}
if (record) {
history.add(
MoveRecord(cards: movingCards, fromPile: from, toPile: target),
);
_playSfx(SfxKey.drop);
}
_refreshHud(force: true);
if (animate) {
_layoutAll(skip: movingCards.toSet());
for (int i = 0; i < movingCards.length; i++) {
final card = movingCards[i];
card.animateTo(
_restPositionFor(card),
duration: moveAnimationDuration,
delay: i * 0.035,
endPriority: _restPriorityFor(card),
);
}
} else {
_layoutAll();
}
if (autoCheck) {
_autoMoveAvailableToFoundations(record: record);
}
}
void moveCard(
CardComponent card,
CardPile target, {
required bool record,
bool autoCheck = true,
bool animate = true,
}) {
moveCards(
[card],
target,
record: record,
autoCheck: autoCheck,
animate: animate,
);
}
void _autoMoveAvailableToFoundations({required bool record}) {
if (isGameOver) return;
var moved = true;
while (moved) {
moved = false;
final candidates = <CardComponent>[
for (final freeCell in freeCells)
if (freeCell.cards.isNotEmpty) freeCell.cards.last,
for (final tableau in tableaus)
if (tableau.cards.isNotEmpty) tableau.cards.last,
];
for (final card in candidates) {
if (!isTopCard(card)) continue;
final foundation = _findFoundationTarget(card);
if (foundation == null) continue;
moveCard(card, foundation, record: record, autoCheck: false);
moved = true;
break;
}
}
checkGameState();
}
void undo() {
if (isGameOver) return;
if (history.isEmpty) return;
clearHint();
final last = history.removeLast();
for (final card in last.cards) {
last.toPile.cards.remove(card);
}
last.fromPile.cards.addAll(last.cards);
for (final card in last.cards) {
card.pile = last.fromPile;
}
_refreshHud(force: true);
_layoutAll();
_playSfx(SfxKey.drop);
checkGameState();
}
Vector2 _restPositionFor(CardComponent card) {
if (card.pile.type == PileType.tableau) {
final index = card.pile.cards.indexOf(card);
return card.pile.position + Vector2(0, index * stackOffset);
}
return card.pile.position.clone();
}
Vector2 _dropPositionFor(CardPile pile) {
if (pile.type == PileType.tableau) {
return pile.position + Vector2(0, pile.cards.length * stackOffset);
}
return pile.position.clone();
}
int _restPriorityFor(CardComponent card) {
final index = card.pile.cards.indexOf(card);
return 10 + max(0, index);
}
void _layoutAll({Set<CardComponent> skip = const {}}) {
for (final pile in [...freeCells, ...foundations]) {
for (int i = 0; i < pile.cards.length; i++) {
final card = pile.cards[i];
if (skip.contains(card)) continue;
card.position = pile.position.clone();
card.priority = 10 + i;
}
}
for (final pile in tableaus) {
for (int i = 0; i < pile.cards.length; i++) {
final card = pile.cards[i];
if (skip.contains(card)) continue;
card.position = pile.position + Vector2(0, i * stackOffset);
card.priority = 10 + i;
}
}
}
void autoMove(CardComponent card) {
if (isGameOver) return;
clearHint();
if (!isTopCard(card)) return;
final foundation = _findFoundationTarget(card);
if (foundation != null) {
moveCard(card, foundation, record: true);
checkGameState();
return;
}
for (final freeCell in freeCells) {
if (canMove(card, freeCell)) {
moveCard(card, freeCell, record: true);
return;
}
}
}
bool get isWon {
final count = foundations.fold<int>(
0,
(sum, pile) => sum + pile.cards.length,
);
return count == 52;
}
bool get isGameOver {
return overlays.isActive('win') || overlays.isActive('lose');
}
bool get hasLegalMoves {
if (_findAnyLegalMove() != null) return true;
return _solveCurrentBoard() != _SolveStatus.unsolved;
}
HintMove? _findLegalMoveForLoseCheck() {
final hint = _findHintMove();
if (hint != null) return hint;
return _findAnyLegalMove();
}
HintMove? _findAnyLegalMove() {
final targets = <CardPile>[...freeCells, ...foundations, ...tableaus];
for (final freeCell in freeCells) {
if (freeCell.cards.isEmpty) continue;
final card = freeCell.cards.last;
for (final target in targets) {
if (target.type == PileType.freeCell) continue;
if (canMove(card, target)) {
return HintMove(cards: [card], target: target);
}
}
}
for (final tableau in tableaus) {
if (tableau.cards.isEmpty) continue;
for (int i = 0; i < tableau.cards.length; i++) {
final run = tableau.cards.sublist(i);
if (!_isOrderedRun(run)) continue;
for (final target in targets) {
if (canMoveCards(run, target)) {
return HintMove(cards: run, target: target);
}
}
}
}
return null;
}
HintMove? _findHintMove() {
final occupiedTableaus = [
for (final tableau in tableaus)
if (tableau.cards.isNotEmpty) tableau,
];
final emptyTableaus = [
for (final tableau in tableaus)
if (tableau.cards.isEmpty) tableau,
];
for (final freeCell in freeCells) {
if (freeCell.cards.isEmpty) continue;
final card = freeCell.cards.last;
final foundation = _findFoundationTarget(card);
if (foundation != null) {
return HintMove(cards: [card], target: foundation);
}
}
for (final tableau in tableaus) {
if (tableau.cards.isEmpty) continue;
final card = tableau.cards.last;
final foundation = _findFoundationTarget(card);
if (foundation != null) {
return HintMove(cards: [card], target: foundation);
}
}
for (final freeCell in freeCells) {
if (freeCell.cards.isEmpty) continue;
final card = freeCell.cards.last;
for (final target in occupiedTableaus) {
if (canMove(card, target)) {
return HintMove(cards: [card], target: target);
}
}
for (final target in emptyTableaus) {
if (canMove(card, target)) {
return HintMove(cards: [card], target: target);
}
}
}
for (final tableau in tableaus) {
for (int i = 0; i < tableau.cards.length; i++) {
final run = tableau.cards.sublist(i);
if (!_isOrderedRun(run)) continue;
for (final target in occupiedTableaus) {
if (canMoveCards(run, target) &&
_wouldRevealUsefulCard(run, target)) {
return HintMove(cards: run, target: target);
}
}
for (final target in emptyTableaus) {
if (canMoveCards(run, target) &&
_wouldRevealUsefulCard(run, target)) {
return HintMove(cards: run, target: target);
}
}
}
}
return null;
}
_SolverState _solverStateFromCurrentBoard() {
return _SolverState(
tableaus: [
for (final tableau in tableaus)
[
for (final card in tableau.cards)
_FreeCellSolver.encode(card.model),
],
],
freeCells: [
for (final freeCell in freeCells)
freeCell.cards.isEmpty
? null
: _FreeCellSolver.encode(freeCell.cards.last.model),
],
foundations: [
for (final suit in Suit.values)
foundations
.where((pile) => pile.cards.isNotEmpty)
.map((pile) => pile.cards.last.model)
.where((card) => card.suit == suit)
.fold<int>(0, (rank, card) => max(rank, card.rank)),
],
);
}
_SolveStatus _solveCurrentBoard() {
return _FreeCellSolver(
initial: _solverStateFromCurrentBoard(),
maxDepth: 80,
maxVisited: 50000,
).solve();
}
void showHint() {
if (isGameOver) return;
final hint = _findLegalMoveForLoseCheck();
if (hint == null) return;
hintedCards = List<CardComponent>.of(hint.cards);
hintedTarget = hint.target;
_hintSecondsLeft = 3;
}
void clearHint() {
hintedCards = [];
hintedTarget = null;
_hintSecondsLeft = 0;
}
void checkGameState() {
overlays.remove('win');
overlays.remove('lose');
if (isWon) {
_recordWinStats();
if (!_winSfxPlayed) {
_playSfx(SfxKey.win);
_winSfxPlayed = true;
}
overlays.add('win');
} else if (!hasLegalMoves) {
_recordLossStats();
if (!_lossSfxPlayed) {
_playSfx(SfxKey.loss);
_lossSfxPlayed = true;
}
overlays.add('lose');
}
}
}
class PileSlotComponent extends PositionComponent {
PileSlotComponent(this.pile)
: super(
position: pile.position,
size: Vector2(FreeCellGame.cardWidth, FreeCellGame.cardHeight),
priority: -1,
);
final CardPile pile;
@override
void update(double dt) {
super.update(dt);
position = pile.position;
}
@override
void render(Canvas canvas) {
final rect = Rect.fromLTWH(0, 0, size.x, size.y);
final paint = Paint()
..color = Colors.white.withValues(alpha: 0.15)
..style = PaintingStyle.fill;
final border = Paint()
..color = Colors.white.withValues(alpha: 0.45)
..style = PaintingStyle.stroke
..strokeWidth = 2;
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(8)),
paint,
);
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(8)),
border,
);
final label = switch (pile.type) {
PileType.freeCell => 'FREE',
PileType.foundation => 'A',
PileType.tableau => '',
};
if (label.isNotEmpty) {
final tp = TextPainter(
text: TextSpan(
text: label,
style: TextStyle(
color: Colors.white.withValues(alpha: 0.45),
fontSize: 9,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
)..layout();
tp.paint(
canvas,
Offset((size.x - tp.width) / 2, (size.y - tp.height) / 2),
);
}
}
}
class HintOverlayComponent extends Component
with HasGameReference<FreeCellGame> {
@override
int priority = 970;
@override
void render(Canvas canvas) {
final target = game.hintedTarget;
if (game.hintedCards.isEmpty || target == null) return;
final glow = Paint()
..color = const Color(0xFFFFD54F).withValues(alpha: 0.35)
..style = PaintingStyle.fill;
final stroke = Paint()
..color = const Color(0xFFFFD54F).withValues(alpha: 0.95)
..style = PaintingStyle.stroke
..strokeWidth = 2.5;
for (final card in game.hintedCards) {
final rect = Rect.fromLTWH(
card.position.x - 3,
card.position.y - 3,
FreeCellGame.cardWidth + 6,
FreeCellGame.cardHeight + 6,
);
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(9)),
glow,
);
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(9)),
stroke,
);
}
final targetPosition = game._dropPositionFor(target);
final rect = Rect.fromLTWH(
targetPosition.x - 5,
targetPosition.y - 5,
FreeCellGame.cardWidth + 10,
FreeCellGame.cardHeight + 10,
);
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(10)),
stroke,
);
}
}
class HudButtonComponent extends PositionComponent
with TapCallbacks, HasGameReference<FreeCellGame> {
HudButtonComponent({required this.label, required this.onPressed})
: super(size: Vector2(64, 32), anchor: Anchor.center, priority: 1000);
final String label;
final void Function(FreeCellGame game) onPressed;
bool enabled = false;
@override
void render(Canvas canvas) {
final rect = Rect.fromLTWH(0, 0, size.x, size.y);
final bg = Paint()
..color = Colors.black.withValues(alpha: enabled ? 0.34 : 0.16)
..style = PaintingStyle.fill;
final border = Paint()
..color = Colors.white.withValues(alpha: enabled ? 0.72 : 0.28)
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(8)),
bg,
);
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(8)),
border,
);
final tp = TextPainter(
text: TextSpan(
text: label,
style: TextStyle(
color: Colors.white.withValues(alpha: enabled ? 0.9 : 0.38),
fontSize: label.length > 6 ? 10.5 : 12,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
)..layout();
tp.paint(canvas, Offset((size.x - tp.width) / 2, (size.y - tp.height) / 2));
}
@override
void onTapDown(TapDownEvent event) {
super.onTapDown(event);
if (!enabled) return;
game.playSelectSound();
onPressed(game);
}
}
class CardComponent extends PositionComponent
with DragCallbacks, DoubleTapCallbacks, HasGameReference<FreeCellGame> {
CardComponent(this.model, this.pile)
: super(size: Vector2(FreeCellGame.cardWidth, FreeCellGame.cardHeight));
final CardModel model;
CardPile pile;
Sprite? cardSprite;
Vector2? _animationStart;
Vector2? _animationTarget;
double _animationElapsed = 0;
double _animationDuration = 0;
double _animationDelay = 0;
int? _animationEndPriority;
bool get isAnimating => _animationTarget != null;
void animateTo(
Vector2 target, {
required double duration,
double delay = 0,
int? endPriority,
}) {
_animationStart = position.clone();
_animationTarget = target.clone();
_animationElapsed = 0;
_animationDuration = max(0.01, duration);
_animationDelay = max(0, delay);
_animationEndPriority = endPriority;
priority = max(priority, 980);
}
@override
void update(double dt) {
super.update(dt);
final target = _animationTarget;
final start = _animationStart;
if (target == null || start == null) return;
_animationElapsed += dt;
if (_animationElapsed < _animationDelay) return;
final rawProgress =
((_animationElapsed - _animationDelay) / _animationDuration).clamp(
0.0,
1.0,
);
final progress = Curves.easeOutCubic.transform(rawProgress);
final offset = target - start;
offset.scale(progress);
position = start + offset;
if (rawProgress >= 1) {
position = target;
if (_animationEndPriority != null) {
priority = _animationEndPriority!;
}
_animationStart = null;
_animationTarget = null;
_animationEndPriority = null;
_animationElapsed = 0;
_animationDelay = 0;
}
}
@override
Future<void> onLoad() async {
await super.onLoad();
// 扑克图预留assets/images/cards/AS.png、2S.png ... KC.png
// 没放图片时,会自动使用代码绘制的简易扑克。
try {
final img = await game.images.load('cards/${model.imageName}');
cardSprite = Sprite(img);
} catch (_) {
cardSprite = null;
}
}
@override
void render(Canvas canvas) {
if (cardSprite != null) {
cardSprite!.render(canvas, size: size);
return;
}
final rect = Rect.fromLTWH(0, 0, size.x, size.y);
final bg = Paint()..color = Colors.white;
final border = Paint()
..color = Colors.black.withValues(alpha: 0.35)
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(8)),
bg,
);
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(8)),
border,
);
final color = model.isRed ? Colors.red : Colors.black;
_drawText(
canvas,
'${model.rankText}${model.suitText}',
8,
7,
18,
color,
FontWeight.bold,
);
_drawText(
canvas,
model.suitText,
size.x / 2 - 14,
size.y / 2 - 22,
38,
color,
FontWeight.bold,
);
}
void _drawText(
Canvas canvas,
String text,
double x,
double y,
double fontSize,
Color color,
FontWeight weight,
) {
final tp = TextPainter(
text: TextSpan(
text: text,
style: TextStyle(color: color, fontSize: fontSize, fontWeight: weight),
),
textDirection: TextDirection.ltr,
)..layout();
tp.paint(canvas, Offset(x, y));
}
@override
void onDragStart(DragStartEvent event) {
super.onDragStart(event);
game.startDrag(this);
}
@override
void onDragUpdate(DragUpdateEvent event) {
super.onDragUpdate(event);
if (game.draggingCard == this) {
game.updateDrag(event.canvasDelta);
}
}
@override
void onDragEnd(DragEndEvent event) {
super.onDragEnd(event);
if (game.draggingCard == this) {
game.endDrag();
}
}
@override
void onDoubleTapDown(DoubleTapDownEvent event) {
super.onDoubleTapDown(event);
game.autoMove(this);
}
}