Files
freeCell/lib/screens/freecell_screen.dart

2339 lines
63 KiB
Dart
Raw Normal View History

2026-06-02 19:01:08 +08:00
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);
}
}