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 createState() => _SettingsOverlayState(); } class _SettingsOverlayState extends State { 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 cards = []; } class MoveRecord { MoveRecord({ required this.cards, required this.fromPile, required this.toPile, }); final List cards; final CardPile fromPile; final CardPile toPile; } class HintMove { const HintMove({required this.cards, required this.target}); final List 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> tableaus; final List freeCells; final List foundations; _SolverState clone() { return _SolverState( tableaus: [for (final pile in tableaus) List.of(pile)], freeCells: List.of(freeCells), foundations: List.of(foundations), ); } int get foundationCount { return foundations.fold(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 = {}; 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 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 = []; 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 tableau) { if (tableau.isEmpty) return true; final top = tableau.last; return _isRed(card) != _isRed(top) && _rankOf(card) == _rankOf(top) - 1; } bool _isOrderedRun(List 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 freeCells = []; final List foundations = []; final List tableaus = []; final List history = []; CardComponent? draggingCard; List 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 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 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 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 _setupAudio() async { try { await AudioManager.instance.init(); await _applyBgmEnabled(bgmEnabled); await _applySfxEnabled(sfxEnabled); } catch (e) { debugPrint('FreeCell audio setup failed: $e'); } } Future _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 _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 _setOptionalInt( SharedPreferences prefs, String key, int? value, ) { if (value == null) { return prefs.remove(key); } return prefs.setInt(key, value); } Future _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 _applySfxEnabled(bool enabled) async { try { await AudioManager.instance.setSfxMuted(!enabled); } catch (e) { debugPrint('FreeCell sfx toggle failed: $e'); } } Future _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 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 _dealCards({required int generation}) async { if (generation != _dealGeneration) return; final deck = []; 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 = []; 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.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().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 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 _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 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 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 cards, CardPile target, { required bool record, bool autoCheck = true, bool animate = true, }) { if (isGameOver) return; if (cards.isEmpty) return; clearHint(); final movingCards = List.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 = [ 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 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( 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 = [...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(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.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 { @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 { 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 { 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 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); } }