2339 lines
63 KiB
Dart
2339 lines
63 KiB
Dart
|
|
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);
|
|||
|
|
}
|
|||
|
|
}
|