Files
flame_flip/lib/flame/memory_match_flame_game.dart
2026-06-10 18:23:39 +08:00

503 lines
12 KiB
Dart

import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import '../audio/audio_manager.dart';
import '../game/game_controller.dart';
import '../game/levels.dart';
import '../game/music.dart';
import '../game/types.dart';
import 'memory_card_component.dart';
class MemoryMatchFlameGame extends FlameGame {
MemoryMatchFlameGame({
required this.level,
required this.onStateChanged,
required this.onResult,
});
LevelConfig level;
final VoidCallback onStateChanged;
final void Function(GameStatus status, int score) onResult;
late GameController controller;
bool _ready = false;
bool get isReady => _ready;
final Map<String, MemoryCardComponent> _cardViews = {};
double _countdownAccumulator = 0;
late final SpriteComponent _background;
late final PositionComponent _boardRoot;
bool pausedByUser = false;
bool showResult = false;
bool mutedMusic = false;
bool mutedSfx = false;
int timeLeft = 0;
int bestScore = 0;
String resultStatus = 'lost';
int finalScore = 0;
bool _boardEntryAnimating = false;
int _boardEntryToken = 0;
final math.Random _entryRandom = math.Random();
int _lastMismatchToken = 0;
int _lastStreak = 0;
GameStatus _lastStatus = GameStatus.playing;
String get timeText {
final m = (timeLeft ~/ 60).toString().padLeft(2, '0');
final s = (timeLeft % 60).toString().padLeft(2, '0');
return '$m:$s';
}
@override
Color backgroundColor() => const Color(0x00000000);
@override
Future<void> onLoad() async {
await super.onLoad();
_background = SpriteComponent(
sprite: await loadSprite('a5c6f3813d0d23b4042dd130542753cc.png'),
size: size,
position: Vector2.zero(),
priority: -100,
);
add(_background);
_boardRoot = PositionComponent(anchor: Anchor.topLeft);
add(_boardRoot);
controller = GameController(level: level, allFaceIds: allFaceIds)
..addListener(_onGameChanged);
timeLeft = level.timeSec ?? 120;
_lastStatus = controller.status;
_lastMismatchToken = controller.mismatch.token;
_lastStreak = controller.streak;
_ready = true;
await AudioManager.instance.playBgm();
_syncBoard(rebuildAll: true, animateEntry: true);
_startTicker();
}
@override
void onGameResize(Vector2 canvasSize) {
super.onGameResize(canvasSize);
if (!isLoaded) return;
_background.size = canvasSize;
_layoutBoard();
}
@override
void update(double dt) {
super.update(dt);
if (!_ready) return;
if (showResult || pausedByUser || controller.status != GameStatus.playing) {
return;
}
if (timeLeft <= 0) return;
_countdownAccumulator += dt;
while (_countdownAccumulator >= 1.0) {
_countdownAccumulator -= 1.0;
if (showResult ||
pausedByUser ||
controller.status != GameStatus.playing) {
return;
}
if (timeLeft <= 0) return;
timeLeft -= 1;
onStateChanged();
if (timeLeft == 0 &&
!showResult &&
controller.status == GameStatus.playing) {
_finishAsLose();
return;
}
}
}
void _startTicker() {
_countdownAccumulator = 0;
}
Future<void> onCardTapped(String cardId) async {
if (!_ready) return;
if (showResult ||
pausedByUser ||
_boardEntryAnimating ||
controller.status != GameStatus.playing) {
return;
}
await AudioManager.instance.playSfx(SfxKey.click);
controller.flip(cardId);
}
Future<void> _onGameChanged() async {
if (!_ready) return;
if (controller.mismatch.token > _lastMismatchToken) {
_lastMismatchToken = controller.mismatch.token;
await AudioManager.instance.playSfx(SfxKey.mismatch);
}
if (controller.streak > _lastStreak) {
_lastStreak = controller.streak;
await AudioManager.instance.playSfx(SfxKey.match);
}
if (controller.streak == 0) {
_lastStreak = 0;
}
if (controller.status != _lastStatus) {
_lastStatus = controller.status;
if (controller.status == GameStatus.won) {
await AudioManager.instance.playSfx(SfxKey.win);
} else if (controller.status == GameStatus.lost) {
await AudioManager.instance.playSfx(SfxKey.lose);
}
}
if (!showResult &&
!pausedByUser &&
controller.status == GameStatus.playing &&
level.maxMistakes != null &&
controller.mistakes >= level.maxMistakes!) {
_finishAsLose();
}
if (!showResult && controller.status == GameStatus.won) {
final score = controller.score + timeLeft * 10;
resultStatus = 'won';
finalScore = score;
bestScore = math.max(bestScore, score);
showResult = true;
paused = true;
onResult(GameStatus.won, score);
}
_syncBoard();
onStateChanged();
}
void _finishAsLose() {
resultStatus = 'lost';
finalScore = controller.score;
bestScore = math.max(bestScore, controller.score);
showResult = true;
paused = true;
onResult(GameStatus.lost, finalScore);
}
void _syncBoard({bool rebuildAll = false, bool animateEntry = false}) {
if (!_ready) return;
if (rebuildAll && animateEntry) {
_boardEntryAnimating = true;
}
final disablePlay =
showResult ||
controller.status != GameStatus.playing ||
pausedByUser ||
_boardEntryAnimating;
if (rebuildAll) {
_boardRoot.removeAll(_boardRoot.children.toList());
_cardViews.clear();
for (final card in controller.cards) {
final component = MemoryCardComponent(
model: card,
frontAsset: 'faces/b1c14934b04bf12e674abfffcb87cf6e.png',
backAsset: faceMap[card.faceId]!,
disabled: disablePlay || card.isMatched,
);
_cardViews[card.id] = component;
_boardRoot.add(component);
}
_layoutBoard(animateEntry: animateEntry);
return;
}
for (final card in controller.cards) {
_cardViews[card.id]?.updateFromModel(
card,
disablePlay: disablePlay,
shouldShake: controller.mismatch.ids.contains(card.id),
shakeToken: controller.mismatch.token,
);
}
}
void _layoutBoard({bool animateEntry = false}) {
if (_cardViews.isEmpty || size.x <= 0 || size.y <= 0) return;
final topReserve = size.y * 0.30;
final bottomReserve = size.y * 0.18;
final horizontalPadding = 18.0;
final rows = level.rowPattern.length;
final widest = level.rowPattern.reduce((a, b) => a > b ? a : b);
final isTabletWide = size.x >= 768;
final gap = isTabletWide ? 10.0 : 12.0;
final usableWidth = size.x - horizontalPadding * 2;
final usableHeight = size.y - topReserve - bottomReserve;
final cellByWidth = (usableWidth - gap * (widest - 1)) / widest;
final cellByHeight = (usableHeight - gap * (rows - 1)) / rows;
final cell = math
.min(cellByWidth, cellByHeight)
.clamp(38.0, isTabletWide ? 120.0 : 110.0);
final gridWidth = widest * cell + (widest - 1) * gap;
final gridHeight = rows * cell + (rows - 1) * gap;
final boardLeft = (size.x - gridWidth) / 2;
final boardTop = topReserve + (usableHeight - gridHeight) / 2;
_boardRoot.position = Vector2(boardLeft, boardTop);
_boardRoot.size = Vector2(gridWidth, gridHeight);
int cardIndex = 0;
final cardLayouts = <({
MemoryCardComponent view,
Vector2 target,
Vector2 start,
int row,
int indexInRow,
})>[];
for (int row = 0; row < rows; row++) {
final count = level.rowPattern[row];
final rowWidth = count * cell + (count - 1) * gap;
final startX = (gridWidth - rowWidth) / 2;
final y = row * (cell + gap);
for (int i = 0; i < count; i++) {
final card = controller.cards[cardIndex++];
final view = _cardViews[card.id]!;
final target = Vector2(startX + i * (cell + gap), y);
view.updateCardSize(Vector2.all(cell));
cardLayouts.add((
view: view,
target: target,
start: _randomEntryStartPosition(target, gridWidth, gridHeight, cell),
row: row,
indexInRow: i,
));
}
}
if (animateEntry) {
_playBoardEntryAnimation(cardLayouts);
return;
}
for (final layout in cardLayouts) {
layout.view.position = layout.target;
layout.view.resetEntranceVisual();
}
}
void _playBoardEntryAnimation(
List<
({
MemoryCardComponent view,
Vector2 target,
Vector2 start,
int row,
int indexInRow,
})
> cardLayouts,
) {
_boardEntryToken += 1;
final token = _boardEntryToken;
const rowDelayStep = 0.12;
const itemDelayStep = 0.035;
const duration = 0.42;
for (final layout in cardLayouts) {
layout.view.playEntranceEffect(
startPosition: layout.start,
targetPosition: layout.target,
delay: layout.row * rowDelayStep + layout.indexInRow * itemDelayStep,
);
}
onStateChanged();
final totalSeconds =
(level.rowPattern.length - 1) * rowDelayStep +
(widestRowCount() - 1) * itemDelayStep +
duration +
0.04;
Future.delayed(
Duration(milliseconds: (totalSeconds * 1000).round()),
() {
if (!_ready || token != _boardEntryToken) return;
_boardEntryAnimating = false;
_syncBoard();
onStateChanged();
}
);
}
int widestRowCount() {
return level.rowPattern.reduce((a, b) => a > b ? a : b);
}
Vector2 _randomEntryStartPosition(
Vector2 target,
double gridWidth,
double gridHeight,
double cell,
) {
final side = _entryRandom.nextInt(4);
final extraX = cell * (1.2 + _entryRandom.nextDouble() * 1.8);
final extraY = cell * (1.0 + _entryRandom.nextDouble() * 2.2);
switch (side) {
case 0:
return Vector2(
-extraX,
target.y - extraY - _entryRandom.nextDouble() * cell,
);
case 1:
return Vector2(
gridWidth + extraX,
target.y - extraY - _entryRandom.nextDouble() * cell,
);
case 2:
return Vector2(
target.x + (_entryRandom.nextDouble() - 0.5) * cell * 1.8,
-extraY,
);
default:
return Vector2(
target.x + (_entryRandom.nextDouble() - 0.5) * cell * 1.6,
gridHeight + extraY,
);
}
}
Future<void> restartSameLevel() async {
if (!_ready) return;
showResult = false;
paused = false;
pausedByUser = false;
timeLeft = level.timeSec ?? 120;
_countdownAccumulator = 0;
controller.reset();
_lastStatus = controller.status;
_lastMismatchToken = controller.mismatch.token;
_lastStreak = controller.streak;
_syncBoard(rebuildAll: true, animateEntry: true);
_startTicker();
onStateChanged();
}
Future<void> nextLevel(LevelConfig next) async {
if (!_ready) return;
level = next;
showResult = false;
paused = false;
pausedByUser = false;
_countdownAccumulator = 0;
controller.removeListener(_onGameChanged);
controller.dispose();
controller = GameController(level: level, allFaceIds: allFaceIds)
..addListener(_onGameChanged);
timeLeft = level.timeSec ?? 120;
_lastStatus = controller.status;
_lastMismatchToken = controller.mismatch.token;
_lastStreak = controller.streak;
_syncBoard(rebuildAll: true, animateEntry: true);
_startTicker();
onStateChanged();
}
Future<int> reviveWithReward() async {
if (!_ready) return 0;
final sec = controller.reviveWithReward(
baseSeconds: 30,
penaltyPerMove: 10,
rollbackMistakes: controller.mistakes,
);
if (sec > 0) {
timeLeft += sec;
_countdownAccumulator = 0;
showResult = false;
paused = false;
resultStatus = 'lost';
finalScore = 0;
_syncBoard();
onStateChanged();
}
return sec;
}
Future<void> togglePause() async {
if (!_ready) return;
pausedByUser = !pausedByUser;
paused = pausedByUser;
onStateChanged();
}
Future<void> toggleMusic() async {
mutedMusic = !mutedMusic;
await AudioManager.instance.setBgmMuted(mutedMusic);
onStateChanged();
}
void toggleSfx() {
mutedSfx = !mutedSfx;
AudioManager.instance.setSfxMuted(mutedSfx);
onStateChanged();
}
@override
void onRemove() {
if (_ready) {
controller.removeListener(_onGameChanged);
controller.dispose();
}
super.onRemove();
}
}