first commit
This commit is contained in:
66
lib/game/engine.dart
Normal file
66
lib/game/engine.dart
Normal file
@@ -0,0 +1,66 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'types.dart';
|
||||
|
||||
final Random _random = Random();
|
||||
|
||||
List<T> shuffleList<T>(List<T> arr) {
|
||||
final a = List<T>.from(arr);
|
||||
for (int i = a.length - 1; i > 0; i--) {
|
||||
final int j = _random.nextInt(i + 1);
|
||||
final T temp = a[i];
|
||||
a[i] = a[j];
|
||||
a[j] = temp;
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
List<CardModel> createDeck({
|
||||
required int rows,
|
||||
required int totalCards,
|
||||
required int poolSize,
|
||||
required List<String> allFaceIds,
|
||||
}) {
|
||||
if (totalCards <= 0) {
|
||||
throw Exception('totalCards must be > 0');
|
||||
}
|
||||
if (totalCards.isOdd) {
|
||||
throw Exception('totalCards must be even');
|
||||
}
|
||||
if (allFaceIds.isEmpty) {
|
||||
throw Exception('allFaceIds must not be empty');
|
||||
}
|
||||
|
||||
final pairs = totalCards ~/ 2;
|
||||
final actualPoolSize = poolSize.clamp(1, allFaceIds.length);
|
||||
final pool = shuffleList(allFaceIds).take(actualPoolSize).toList();
|
||||
|
||||
if (pool.isEmpty) {
|
||||
throw Exception('pool must not be empty');
|
||||
}
|
||||
|
||||
final List<String> picked = [];
|
||||
if (pool.length >= pairs) {
|
||||
picked.addAll(shuffleList(pool).take(pairs));
|
||||
} else {
|
||||
picked.addAll(pool);
|
||||
final remain = pairs - pool.length;
|
||||
for (int i = 0; i < remain; i++) {
|
||||
picked.add(pool[_random.nextInt(pool.length)]);
|
||||
}
|
||||
}
|
||||
|
||||
final doubled = picked.expand((e) => [e, e]).toList();
|
||||
final shuffled = shuffleList(doubled);
|
||||
|
||||
return shuffled.asMap().entries.map((entry) {
|
||||
final idx = entry.key;
|
||||
final faceId = entry.value;
|
||||
return CardModel(
|
||||
id: '$faceId-$idx-${_random.nextInt(1 << 32).toRadixString(16)}',
|
||||
faceId: faceId,
|
||||
isFlipped: false,
|
||||
isMatched: false,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
209
lib/game/game_controller.dart
Normal file
209
lib/game/game_controller.dart
Normal file
@@ -0,0 +1,209 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'engine.dart';
|
||||
import 'levels.dart';
|
||||
import 'types.dart';
|
||||
|
||||
class MismatchState {
|
||||
final int token;
|
||||
final List<String> ids;
|
||||
|
||||
const MismatchState({required this.token, required this.ids});
|
||||
|
||||
MismatchState copyWith({int? token, List<String>? ids}) {
|
||||
return MismatchState(token: token ?? this.token, ids: ids ?? this.ids);
|
||||
}
|
||||
}
|
||||
|
||||
class GameController extends ChangeNotifier {
|
||||
final LevelConfig level;
|
||||
final List<String> allFaceIds;
|
||||
late final int flipBackDelayMs;
|
||||
late final int? maxMistakes;
|
||||
|
||||
GameStatus status = GameStatus.playing;
|
||||
List<CardModel> cards = [];
|
||||
List<String> flippedIds = [];
|
||||
int moves = 0;
|
||||
int mistakes = 0;
|
||||
MismatchState mismatch = const MismatchState(token: 0, ids: []);
|
||||
int score = 0;
|
||||
int streak = 0;
|
||||
|
||||
bool _locked = false;
|
||||
Timer? _timer;
|
||||
|
||||
GameController({required this.level, required this.allFaceIds}) {
|
||||
flipBackDelayMs = level.flipBackDelayMs ?? 600;
|
||||
maxMistakes = level.maxMistakes;
|
||||
cards = _build();
|
||||
}
|
||||
|
||||
List<CardModel> _build() {
|
||||
assert(
|
||||
level.totalCards % 2 == 0,
|
||||
'level.totalCards must be even, current=${level.totalCards}, level=${level.id}',
|
||||
);
|
||||
|
||||
return createDeck(
|
||||
rows: level.rows,
|
||||
totalCards: level.totalCards,
|
||||
poolSize: level.poolSize,
|
||||
allFaceIds: allFaceIds,
|
||||
);
|
||||
}
|
||||
|
||||
int _pow2(int n) => 1 << n;
|
||||
|
||||
void _clearTimer() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_clearTimer();
|
||||
_locked = false;
|
||||
status = GameStatus.playing;
|
||||
cards = _build();
|
||||
flippedIds = [];
|
||||
moves = 0;
|
||||
mistakes = 0;
|
||||
mismatch = const MismatchState(token: 0, ids: []);
|
||||
score = 0;
|
||||
streak = 0;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void reduceMistakes(int n) {
|
||||
if (n <= 0) return;
|
||||
mistakes = (mistakes - n).clamp(0, 1 << 30);
|
||||
if (status == GameStatus.lost) {
|
||||
status = GameStatus.playing;
|
||||
}
|
||||
mismatch = mismatch.copyWith(ids: []);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void reduceMoves(int n) {
|
||||
if (n <= 0) return;
|
||||
moves = (moves - n).clamp(0, 1 << 30);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void flip(String cardId) {
|
||||
if (status != GameStatus.playing || _locked) return;
|
||||
|
||||
final index = cards.indexWhere((e) => e.id == cardId);
|
||||
if (index < 0) return;
|
||||
|
||||
final target = cards[index];
|
||||
if (target.isMatched || target.isFlipped) return;
|
||||
|
||||
cards = cards
|
||||
.map((e) => e.id == cardId ? e.copyWith(isFlipped: true) : e)
|
||||
.toList();
|
||||
flippedIds = [...flippedIds, cardId];
|
||||
|
||||
if (flippedIds.length < 2) {
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
_locked = true;
|
||||
|
||||
final aId = flippedIds[0];
|
||||
final bId = flippedIds[1];
|
||||
final a = cards.firstWhere((e) => e.id == aId);
|
||||
final b = cards.firstWhere((e) => e.id == bId);
|
||||
|
||||
moves += 1;
|
||||
|
||||
if (a.faceId == b.faceId) {
|
||||
cards = cards.map((e) {
|
||||
if (e.id == aId || e.id == bId) {
|
||||
return e.copyWith(isMatched: true);
|
||||
}
|
||||
return e;
|
||||
}).toList();
|
||||
|
||||
streak += 1;
|
||||
score += _pow2(streak - 1);
|
||||
flippedIds = [];
|
||||
mismatch = mismatch.copyWith(ids: []);
|
||||
|
||||
if (cards.every((e) => e.isMatched)) {
|
||||
status = GameStatus.won;
|
||||
}
|
||||
|
||||
_locked = false;
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
mistakes += 1;
|
||||
score = score > 0 ? score - 1 : 0;
|
||||
streak = 0;
|
||||
mismatch = MismatchState(token: mismatch.token + 1, ids: [aId, bId]);
|
||||
|
||||
final lostNow = maxMistakes != null && mistakes >= maxMistakes!;
|
||||
if (lostNow) {
|
||||
status = GameStatus.lost;
|
||||
}
|
||||
|
||||
_clearTimer();
|
||||
_timer = Timer(Duration(milliseconds: flipBackDelayMs), () {
|
||||
cards = cards.map((e) {
|
||||
if (e.id == aId || e.id == bId) {
|
||||
return e.copyWith(isFlipped: false);
|
||||
}
|
||||
return e;
|
||||
}).toList();
|
||||
|
||||
flippedIds = [];
|
||||
mismatch = mismatch.copyWith(ids: []);
|
||||
|
||||
final lost = maxMistakes != null && mistakes >= maxMistakes!;
|
||||
if (lost) {
|
||||
status = GameStatus.lost;
|
||||
}
|
||||
|
||||
_locked = false;
|
||||
notifyListeners();
|
||||
});
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
int reviveWithReward({
|
||||
int baseSeconds = 30,
|
||||
int penaltyPerMove = 10,
|
||||
int? rollbackMistakes,
|
||||
int? rollbackMoves,
|
||||
}) {
|
||||
final backMistakes = rollbackMistakes ?? mistakes;
|
||||
final rewardSec = baseSeconds < 0 ? 0 : baseSeconds;
|
||||
|
||||
_locked = false;
|
||||
_clearTimer();
|
||||
mistakes = (mistakes - backMistakes).clamp(0, 1 << 30);
|
||||
|
||||
if (rollbackMoves != null) {
|
||||
moves = (moves - rollbackMoves).clamp(0, 1 << 30);
|
||||
}
|
||||
|
||||
flippedIds = [];
|
||||
mismatch = mismatch.copyWith(ids: []);
|
||||
status = GameStatus.playing;
|
||||
notifyListeners();
|
||||
|
||||
return rewardSec;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_clearTimer();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
208
lib/game/levels.dart
Normal file
208
lib/game/levels.dart
Normal file
@@ -0,0 +1,208 @@
|
||||
class LevelConfig {
|
||||
final int id;
|
||||
final List<int> rowPattern;
|
||||
final int poolSize;
|
||||
final int? flipBackDelayMs;
|
||||
final int? maxMistakes;
|
||||
final int? timeSec;
|
||||
const LevelConfig({
|
||||
required this.id,
|
||||
required this.rowPattern,
|
||||
required this.poolSize,
|
||||
this.flipBackDelayMs,
|
||||
this.maxMistakes,
|
||||
this.timeSec,
|
||||
});
|
||||
int get rows => rowPattern.length;
|
||||
int get maxCols {
|
||||
if (rowPattern.isEmpty) return 0;
|
||||
int max = rowPattern.first;
|
||||
for (final v in rowPattern) {
|
||||
if (v > max) max = v;
|
||||
}
|
||||
return max;
|
||||
}
|
||||
|
||||
int get totalCards {
|
||||
int sum = 0;
|
||||
for (final v in rowPattern) {
|
||||
sum += v;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
}
|
||||
|
||||
// 15关高难度配置:16张牌起步,容错/时间/延迟全程递减,布局多样,行数≤8
|
||||
const List<LevelConfig> levels = [
|
||||
// 第1关:16张(4×4),基础高难起步
|
||||
LevelConfig(
|
||||
id: 1,
|
||||
rowPattern: [4, 4, 4, 4],
|
||||
poolSize: 8,
|
||||
flipBackDelayMs: 600,
|
||||
maxMistakes: 50,
|
||||
timeSec: 90,
|
||||
),
|
||||
// 第2关:16张(5+3+5+3),4行交错非对称,记忆干扰提升
|
||||
LevelConfig(
|
||||
id: 2,
|
||||
rowPattern: [5, 3, 5, 3],
|
||||
poolSize: 8,
|
||||
flipBackDelayMs: 580,
|
||||
maxMistakes: 45,
|
||||
timeSec: 85,
|
||||
),
|
||||
// 第3关:18张(3×6),6行紧凑布局,行数增加+牌数提升
|
||||
LevelConfig(
|
||||
id: 3,
|
||||
rowPattern: [3, 3, 3, 3, 3, 3],
|
||||
poolSize: 9,
|
||||
flipBackDelayMs: 550,
|
||||
maxMistakes: 40,
|
||||
timeSec: 80,
|
||||
),
|
||||
// 第4关:18张(6+4+4+4),4行非对称,宽列+短列混合
|
||||
LevelConfig(
|
||||
id: 4,
|
||||
rowPattern: [6, 4, 4, 4],
|
||||
poolSize: 9,
|
||||
flipBackDelayMs: 520,
|
||||
maxMistakes: 38,
|
||||
timeSec: 78,
|
||||
),
|
||||
// 第5关:20张(5×4),4行满列,牌数再提升+列数增加
|
||||
LevelConfig(
|
||||
id: 5,
|
||||
rowPattern: [5, 5, 5, 5],
|
||||
poolSize: 10,
|
||||
flipBackDelayMs: 500,
|
||||
maxMistakes: 35,
|
||||
timeSec: 75,
|
||||
),
|
||||
// 第6关:20张(4+6+4+6),4行交错宽列,视觉复杂度升级
|
||||
LevelConfig(
|
||||
id: 6,
|
||||
rowPattern: [4, 6, 4, 6],
|
||||
poolSize: 10,
|
||||
flipBackDelayMs: 480,
|
||||
maxMistakes: 32,
|
||||
timeSec: 70,
|
||||
),
|
||||
// 第7关:24张(4×6),6行满列,牌数大幅提升+行数拉满6行
|
||||
LevelConfig(
|
||||
id: 7,
|
||||
rowPattern: [4, 4, 4, 4, 4, 4],
|
||||
poolSize: 12,
|
||||
flipBackDelayMs: 450,
|
||||
maxMistakes: 30,
|
||||
timeSec: 68,
|
||||
),
|
||||
// 第8关:24张(7+5+7+5),4行极限交错,宽列差提升记忆难度
|
||||
LevelConfig(
|
||||
id: 8,
|
||||
rowPattern: [6, 5, 6, 5],
|
||||
poolSize: 12,
|
||||
flipBackDelayMs: 420,
|
||||
maxMistakes: 28,
|
||||
timeSec: 65,
|
||||
),
|
||||
// 第9关:28张(4×7),7行布局,行数接近上限+牌数再提升
|
||||
LevelConfig(
|
||||
id: 9,
|
||||
rowPattern: [4, 4, 4, 4, 4, 4, 4],
|
||||
poolSize: 14,
|
||||
flipBackDelayMs: 400,
|
||||
maxMistakes: 25,
|
||||
timeSec: 60,
|
||||
),
|
||||
// 第10关:28张(6+5+6+5+6),5行非对称,不规则布局增加记忆压力
|
||||
LevelConfig(
|
||||
id: 10,
|
||||
rowPattern: [6, 5, 6, 5, 6],
|
||||
poolSize: 14,
|
||||
flipBackDelayMs: 380,
|
||||
maxMistakes: 22,
|
||||
timeSec: 58,
|
||||
),
|
||||
// 第11关:30张(5×6),6行满列,牌数达中高段峰值+列数5列
|
||||
LevelConfig(
|
||||
id: 11,
|
||||
rowPattern: [5, 5, 5, 5, 5, 5],
|
||||
poolSize: 15,
|
||||
flipBackDelayMs: 350,
|
||||
maxMistakes: 20,
|
||||
timeSec: 55,
|
||||
),
|
||||
// 第12关:30张(7+8+7+8),4行超宽列,列数拉满8列+极端非对称
|
||||
LevelConfig(
|
||||
id: 12,
|
||||
rowPattern: [6, 6, 6, 6],
|
||||
poolSize: 15,
|
||||
flipBackDelayMs: 320,
|
||||
maxMistakes: 18,
|
||||
timeSec: 50,
|
||||
),
|
||||
// 第13关:36张(6×6),6行满列,牌数大幅提升+高列数,记忆量拉满
|
||||
LevelConfig(
|
||||
id: 13,
|
||||
rowPattern: [6, 6, 6, 6, 6, 6],
|
||||
poolSize: 18,
|
||||
flipBackDelayMs: 300,
|
||||
maxMistakes: 15,
|
||||
timeSec: 48,
|
||||
),
|
||||
// 第14关:40张(5×8),8行上限,行数拉满+牌数峰值,布局压力拉满
|
||||
LevelConfig(
|
||||
id: 14,
|
||||
rowPattern: [5, 5, 5, 5, 5, 5, 5, 5],
|
||||
poolSize: 20,
|
||||
flipBackDelayMs: 280,
|
||||
maxMistakes: 10,
|
||||
timeSec: 45,
|
||||
),
|
||||
// 第15关:40张(8+7+8+7+8+7),6行极致交错,全关难度顶峰
|
||||
LevelConfig(
|
||||
id: 15,
|
||||
rowPattern: [6, 6, 6, 6, 6, 6],
|
||||
poolSize: 20,
|
||||
flipBackDelayMs: 250,
|
||||
maxMistakes: 5,
|
||||
timeSec: 40,
|
||||
),
|
||||
];
|
||||
|
||||
const Map<String, String> faceMap = {
|
||||
'dx': 'faces/96a3be3cf272e017046d1b2674a52bd3.png',
|
||||
'ht': 'faces/a2ef406e2c2351e0b9e80029c909242d.png',
|
||||
'hx': 'faces/e45ee7ce7e88149af8dd32b27f9512ce.png',
|
||||
'hl': 'faces/7d0665438e81d8eceb98c1e31fca80c1.png',
|
||||
'js': 'faces/751d31dd6b56b26b29dac2c0e1839e34.png',
|
||||
'kl': 'faces/faeac4e1eef307c2ab7b0a3821e6c667.png',
|
||||
'm': 'faces/d72d187df41e10ea7d9fcdc7f5909205.png',
|
||||
'mf': 'faces/fad6f4e614a212e80c67249a666d2b09.png',
|
||||
'mty': 'faces/0a8005f5594bd67041f88c6196192646.png',
|
||||
'xg': 'faces/d3d9446802a44259755d38e6d163e820.png',
|
||||
'xj': 'faces/6512bd43d9caa6e02c990b0a82652dca.png',
|
||||
'xm': 'faces/c20ad4d76fe97759aa27a0c99bff6710.png',
|
||||
'xt': 'faces/c51ce410c124a10e0db5e4b97fc2af39.png',
|
||||
'xy': 'faces/aab3238922bcc25a6f606eb525ffdc56.png',
|
||||
'xz': 'faces/9bf31c7ff062936a96d3c8bd1f8f2ff3.png',
|
||||
'xz1': 'faces/c74d97b01eae257e44aa9d5bade97baf.png',
|
||||
'xz2': 'faces/70efdf2ec9b086079795c442636b55fb.png',
|
||||
'xz3': 'faces/6f4922f45568161a8cdf4ad2299f6d23.png',
|
||||
'xz4': 'faces/1f0e3dad99908345f7439f8ffabdffc4.png',
|
||||
'xz5': 'faces/98f13708210194c475687be6106a3b84.png',
|
||||
'xz6': 'faces/3c59dc048e8850243be8079a5c74d079.png',
|
||||
'22': 'faces/b6d767d2f8ed5d21a44b0e5886680cb9.png',
|
||||
'23': 'faces/37693cfc748049e45d87b8c7d8b9aacd.png',
|
||||
'24': 'faces/1ff1de774005f8da13f42943881c655f.png',
|
||||
'25': 'faces/8e296a067a37563370ded05f5a3bf3ec.png',
|
||||
'26': 'faces/4e732ced3463d06de0ca9a15b6153677.png',
|
||||
'27': 'faces/02e74f10e0327ad868d138f2b4fdd6f0.png',
|
||||
'28': 'faces/33e75ff09dd601bbe69f351039152189.png',
|
||||
'29': 'faces/6ea9ab1baa0efb9e19094440c317e21b.png',
|
||||
'30': 'faces/34173cb38f07f89ddbebc2ac9128303f.png',
|
||||
'31': 'faces/c16a5320fa475530d9583c34fd356ef5.png',
|
||||
};
|
||||
|
||||
final List<String> allFaceIds = faceMap.keys.toList();
|
||||
14
lib/game/music.dart
Normal file
14
lib/game/music.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
enum SfxKey { click, match, mismatch, win, lose }
|
||||
|
||||
enum BgmKey { bgm }
|
||||
|
||||
class MusicMap {
|
||||
static const String bgm = 'assets/audio/6a7975d3dc974fd522610adfc8b1ce38.mp3';
|
||||
static const Map<SfxKey, String> sfx = {
|
||||
SfxKey.click: 'assets/audio/a8affc088cbca89fa20dbd98c91362e4.mp3',
|
||||
SfxKey.match: 'assets/audio/e3cc92c14a5e6dd1a7d94b6ff634d7fc.mp3',
|
||||
SfxKey.mismatch: 'assets/audio/1d1c5b76da944b44db42c9d0558021c5.mp3',
|
||||
SfxKey.win: 'assets/audio/0b08bd98d279b88859b628cd8c061ae0.mp3',
|
||||
SfxKey.lose: 'assets/audio/f7a2e8166a663d6a5fc86c3ccd320f0b.mp3',
|
||||
};
|
||||
}
|
||||
29
lib/game/types.dart
Normal file
29
lib/game/types.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
enum GameStatus { playing, won, lost }
|
||||
|
||||
class CardModel {
|
||||
final String id;
|
||||
final String faceId;
|
||||
final bool isFlipped;
|
||||
final bool isMatched;
|
||||
|
||||
const CardModel({
|
||||
required this.id,
|
||||
required this.faceId,
|
||||
required this.isFlipped,
|
||||
required this.isMatched,
|
||||
});
|
||||
|
||||
CardModel copyWith({
|
||||
String? id,
|
||||
String? faceId,
|
||||
bool? isFlipped,
|
||||
bool? isMatched,
|
||||
}) {
|
||||
return CardModel(
|
||||
id: id ?? this.id,
|
||||
faceId: faceId ?? this.faceId,
|
||||
isFlipped: isFlipped ?? this.isFlipped,
|
||||
isMatched: isMatched ?? this.isMatched,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user