first commit

This commit is contained in:
Your Name
2026-06-10 18:23:39 +08:00
commit 790110de8e
225 changed files with 10068 additions and 0 deletions

66
lib/game/engine.dart Normal file
View 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();
}

View 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
View 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+34行交错非对称记忆干扰提升
LevelConfig(
id: 2,
rowPattern: [5, 3, 5, 3],
poolSize: 8,
flipBackDelayMs: 580,
maxMistakes: 45,
timeSec: 85,
),
// 第3关18张3×66行紧凑布局行数增加+牌数提升
LevelConfig(
id: 3,
rowPattern: [3, 3, 3, 3, 3, 3],
poolSize: 9,
flipBackDelayMs: 550,
maxMistakes: 40,
timeSec: 80,
),
// 第4关18张6+4+4+44行非对称宽列+短列混合
LevelConfig(
id: 4,
rowPattern: [6, 4, 4, 4],
poolSize: 9,
flipBackDelayMs: 520,
maxMistakes: 38,
timeSec: 78,
),
// 第5关20张5×44行满列牌数再提升+列数增加
LevelConfig(
id: 5,
rowPattern: [5, 5, 5, 5],
poolSize: 10,
flipBackDelayMs: 500,
maxMistakes: 35,
timeSec: 75,
),
// 第6关20张4+6+4+64行交错宽列视觉复杂度升级
LevelConfig(
id: 6,
rowPattern: [4, 6, 4, 6],
poolSize: 10,
flipBackDelayMs: 480,
maxMistakes: 32,
timeSec: 70,
),
// 第7关24张4×66行满列牌数大幅提升+行数拉满6行
LevelConfig(
id: 7,
rowPattern: [4, 4, 4, 4, 4, 4],
poolSize: 12,
flipBackDelayMs: 450,
maxMistakes: 30,
timeSec: 68,
),
// 第8关24张7+5+7+54行极限交错宽列差提升记忆难度
LevelConfig(
id: 8,
rowPattern: [6, 5, 6, 5],
poolSize: 12,
flipBackDelayMs: 420,
maxMistakes: 28,
timeSec: 65,
),
// 第9关28张4×77行布局行数接近上限+牌数再提升
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+65行非对称不规则布局增加记忆压力
LevelConfig(
id: 10,
rowPattern: [6, 5, 6, 5, 6],
poolSize: 14,
flipBackDelayMs: 380,
maxMistakes: 22,
timeSec: 58,
),
// 第11关30张5×66行满列牌数达中高段峰值+列数5列
LevelConfig(
id: 11,
rowPattern: [5, 5, 5, 5, 5, 5],
poolSize: 15,
flipBackDelayMs: 350,
maxMistakes: 20,
timeSec: 55,
),
// 第12关30张7+8+7+84行超宽列列数拉满8列+极端非对称
LevelConfig(
id: 12,
rowPattern: [6, 6, 6, 6],
poolSize: 15,
flipBackDelayMs: 320,
maxMistakes: 18,
timeSec: 50,
),
// 第13关36张6×66行满列牌数大幅提升+高列数,记忆量拉满
LevelConfig(
id: 13,
rowPattern: [6, 6, 6, 6, 6, 6],
poolSize: 18,
flipBackDelayMs: 300,
maxMistakes: 15,
timeSec: 48,
),
// 第14关40张5×88行上限行数拉满+牌数峰值,布局压力拉满
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+76行极致交错全关难度顶峰
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
View 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
View 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,
);
}
}