first commit
This commit is contained in:
335
lib/flame/memory_card_component.dart
Normal file
335
lib/flame/memory_card_component.dart
Normal file
@@ -0,0 +1,335 @@
|
||||
import 'package:flame/components.dart';
|
||||
import 'package:flame/effects.dart';
|
||||
import 'package:flame/events.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../game/types.dart';
|
||||
import 'memory_match_flame_game.dart';
|
||||
|
||||
class MemoryCardComponent extends PositionComponent
|
||||
with TapCallbacks, HasGameReference<MemoryMatchFlameGame> {
|
||||
MemoryCardComponent({
|
||||
required this.model,
|
||||
required this.frontAsset,
|
||||
required this.backAsset,
|
||||
required this.disabled,
|
||||
}) : super(anchor: Anchor.topLeft);
|
||||
|
||||
CardModel model;
|
||||
final String frontAsset;
|
||||
final String backAsset;
|
||||
bool disabled;
|
||||
|
||||
late final RectangleComponent _base;
|
||||
late final SpriteComponent _sprite;
|
||||
|
||||
late final Sprite _frontSprite;
|
||||
late final Sprite _backSprite;
|
||||
|
||||
bool _showBack = false;
|
||||
bool _isAnimatingFlip = false;
|
||||
bool _pendingMatchEffect = false;
|
||||
int _lastShakeToken = 0;
|
||||
bool _isEntering = false;
|
||||
double _entryElapsed = 0;
|
||||
double _entryDelay = 0;
|
||||
double _entryDuration = 0.42;
|
||||
double _entryStartAngle = 0;
|
||||
Vector2 _entryStartPosition = Vector2.zero();
|
||||
Vector2 _entryTargetPosition = Vector2.zero();
|
||||
|
||||
@override
|
||||
Future<void> onLoad() async {
|
||||
await super.onLoad();
|
||||
|
||||
_frontSprite = await game.loadSprite(frontAsset);
|
||||
_backSprite = await game.loadSprite(backAsset);
|
||||
|
||||
_base = RectangleComponent(
|
||||
anchor: Anchor.topLeft,
|
||||
position: Vector2.zero(),
|
||||
size: Vector2.all(size.x > 0 ? size.x : 80),
|
||||
paint: Paint()..color = const Color.fromRGBO(8, 16, 30, 0.28),
|
||||
);
|
||||
add(_base);
|
||||
|
||||
_sprite = SpriteComponent(
|
||||
sprite: _frontSprite,
|
||||
anchor: Anchor.topLeft,
|
||||
position: Vector2.zero(),
|
||||
size: Vector2.all((size.x > 0 ? size.x : 80) - 10),
|
||||
);
|
||||
add(_sprite);
|
||||
|
||||
_applyLayout();
|
||||
_applyImmediateVisual();
|
||||
}
|
||||
|
||||
@override
|
||||
void render(Canvas canvas) {
|
||||
final r = RRect.fromRectAndRadius(
|
||||
Rect.fromLTWH(0, 0, size.x, size.y),
|
||||
const Radius.circular(16),
|
||||
);
|
||||
|
||||
canvas.save();
|
||||
canvas.clipRRect(r);
|
||||
super.render(canvas);
|
||||
canvas.restore();
|
||||
|
||||
final borderPaint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2
|
||||
..color = const Color.fromRGBO(120, 255, 255, 0.9);
|
||||
|
||||
canvas.drawRRect(r, borderPaint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool containsLocalPoint(Vector2 point) {
|
||||
return point.x >= 0 &&
|
||||
point.y >= 0 &&
|
||||
point.x <= size.x &&
|
||||
point.y <= size.y;
|
||||
}
|
||||
|
||||
@override
|
||||
void onMount() {
|
||||
super.onMount();
|
||||
_applyLayout();
|
||||
}
|
||||
|
||||
@override
|
||||
void onGameResize(Vector2 gameSize) {
|
||||
super.onGameResize(gameSize);
|
||||
_applyLayout();
|
||||
}
|
||||
|
||||
void updateCardSize(Vector2 newSize) {
|
||||
size = newSize;
|
||||
_applyLayout();
|
||||
}
|
||||
|
||||
void resetEntranceVisual() {
|
||||
_isEntering = false;
|
||||
_entryElapsed = 0;
|
||||
position = position.clone();
|
||||
scale = Vector2.all(1.0);
|
||||
angle = 0;
|
||||
}
|
||||
|
||||
void _applyLayout() {
|
||||
if (!isLoaded) return;
|
||||
|
||||
_base.size = size.clone();
|
||||
_base.position = Vector2.zero();
|
||||
|
||||
final padding = 5.0;
|
||||
final innerW = (size.x - padding * 2).clamp(1.0, double.infinity);
|
||||
final innerH = (size.y - padding * 2).clamp(1.0, double.infinity);
|
||||
|
||||
_sprite.size = Vector2(innerW, innerH);
|
||||
_sprite.position = Vector2(padding, padding);
|
||||
}
|
||||
|
||||
void _setSpriteOpacity(double opacity) {
|
||||
_sprite.paint = Paint()
|
||||
..color = Color.fromRGBO(255, 255, 255, opacity.clamp(0.0, 1.0));
|
||||
}
|
||||
|
||||
void updateFromModel(
|
||||
CardModel nextModel, {
|
||||
required bool disablePlay,
|
||||
required bool shouldShake,
|
||||
required int shakeToken,
|
||||
}) {
|
||||
final wasFlippedOrMatched = model.isFlipped || model.isMatched;
|
||||
final wasMatched = model.isMatched;
|
||||
|
||||
model = nextModel;
|
||||
disabled = disablePlay || nextModel.isMatched;
|
||||
|
||||
if (!isLoaded) {
|
||||
_showBack = model.isFlipped || model.isMatched;
|
||||
return;
|
||||
}
|
||||
|
||||
final nowFlippedOrMatched = nextModel.isFlipped || nextModel.isMatched;
|
||||
final becameMatched = !wasMatched && nextModel.isMatched;
|
||||
|
||||
if (nowFlippedOrMatched != wasFlippedOrMatched) {
|
||||
if (becameMatched) {
|
||||
_pendingMatchEffect = true;
|
||||
}
|
||||
_runFlip(nowFlippedOrMatched);
|
||||
} else {
|
||||
_applyImmediateVisual();
|
||||
if (becameMatched) {
|
||||
_runMatchEffect();
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldShake && shakeToken > _lastShakeToken) {
|
||||
_lastShakeToken = shakeToken;
|
||||
_runShakeEffect();
|
||||
}
|
||||
}
|
||||
|
||||
void _applyImmediateVisual() {
|
||||
_showBack = model.isFlipped || model.isMatched;
|
||||
_updateSprite();
|
||||
_setSpriteOpacity(model.isMatched ? 0.68 : 1.0);
|
||||
scale = Vector2.all(1.0);
|
||||
angle = 0;
|
||||
}
|
||||
|
||||
void _updateSprite() {
|
||||
if (!isLoaded) return;
|
||||
_sprite.sprite = _showBack ? _backSprite : _frontSprite;
|
||||
}
|
||||
|
||||
void _runFlip(bool toBack) {
|
||||
if (_isAnimatingFlip) {
|
||||
_showBack = toBack;
|
||||
_applyImmediateVisual();
|
||||
return;
|
||||
}
|
||||
|
||||
_isAnimatingFlip = true;
|
||||
|
||||
final half = EffectController(duration: 0.11, curve: Curves.easeInOut);
|
||||
|
||||
add(
|
||||
SequenceEffect(
|
||||
[
|
||||
ScaleEffect.to(Vector2(0.04, 1.0), half),
|
||||
_SwapCardFaceEffect(
|
||||
onSwap: () {
|
||||
_showBack = toBack;
|
||||
_updateSprite();
|
||||
},
|
||||
),
|
||||
ScaleEffect.to(Vector2(1.0, 1.0), half),
|
||||
],
|
||||
onComplete: () {
|
||||
_isAnimatingFlip = false;
|
||||
scale = Vector2.all(1.0);
|
||||
_setSpriteOpacity(model.isMatched ? 0.68 : 1.0);
|
||||
|
||||
if (_pendingMatchEffect) {
|
||||
_pendingMatchEffect = false;
|
||||
_runMatchEffect();
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _runMatchEffect() {
|
||||
add(
|
||||
SequenceEffect(
|
||||
[
|
||||
ScaleEffect.to(
|
||||
Vector2.all(1.08),
|
||||
EffectController(duration: 0.09, curve: Curves.easeOut),
|
||||
),
|
||||
ScaleEffect.to(
|
||||
Vector2.all(1.0),
|
||||
EffectController(duration: 0.12, curve: Curves.easeIn),
|
||||
),
|
||||
],
|
||||
onComplete: () {
|
||||
scale = Vector2.all(1.0);
|
||||
_setSpriteOpacity(model.isMatched ? 0.68 : 1.0);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _runShakeEffect() {
|
||||
add(
|
||||
SequenceEffect([
|
||||
MoveByEffect(Vector2(8, 0), EffectController(duration: 0.045)),
|
||||
MoveByEffect(Vector2(-16, 0), EffectController(duration: 0.045)),
|
||||
MoveByEffect(Vector2(16, 0), EffectController(duration: 0.045)),
|
||||
MoveByEffect(Vector2(-8, 0), EffectController(duration: 0.045)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
void playEntranceEffect({
|
||||
required Vector2 startPosition,
|
||||
required Vector2 targetPosition,
|
||||
required double delay,
|
||||
}) {
|
||||
_isEntering = true;
|
||||
_entryElapsed = 0;
|
||||
_entryDelay = delay;
|
||||
_entryDuration = 0.42;
|
||||
_entryStartPosition = startPosition.clone();
|
||||
_entryTargetPosition = targetPosition.clone();
|
||||
_entryStartAngle = startPosition.x <= targetPosition.x ? -0.24 : 0.24;
|
||||
|
||||
position = startPosition.clone();
|
||||
scale = Vector2.all(0.72);
|
||||
angle = _entryStartAngle;
|
||||
}
|
||||
|
||||
@override
|
||||
void update(double dt) {
|
||||
super.update(dt);
|
||||
|
||||
if (!_isEntering) return;
|
||||
|
||||
_entryElapsed += dt;
|
||||
|
||||
if (_entryElapsed < _entryDelay) return;
|
||||
|
||||
final rawT = ((_entryElapsed - _entryDelay) / _entryDuration).clamp(0.0, 1.0);
|
||||
final moveT = Curves.easeOutCubic.transform(rawT);
|
||||
final scaleT = Curves.easeOutBack.transform(rawT);
|
||||
|
||||
position = Vector2(
|
||||
_lerp(_entryStartPosition.x, _entryTargetPosition.x, moveT),
|
||||
_lerp(_entryStartPosition.y, _entryTargetPosition.y, moveT),
|
||||
);
|
||||
final currentScale = _lerp(0.72, 1.0, scaleT);
|
||||
scale = Vector2.all(currentScale);
|
||||
angle = _lerp(_entryStartAngle, 0, moveT);
|
||||
|
||||
if (rawT >= 1.0) {
|
||||
_isEntering = false;
|
||||
position = _entryTargetPosition.clone();
|
||||
scale = Vector2.all(1.0);
|
||||
angle = 0;
|
||||
}
|
||||
}
|
||||
|
||||
double _lerp(double a, double b, double t) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
@override
|
||||
void onTapUp(TapUpEvent event) {
|
||||
super.onTapUp(event);
|
||||
if (disabled) return;
|
||||
game.onCardTapped(model.id);
|
||||
}
|
||||
}
|
||||
|
||||
class _SwapCardFaceEffect extends ComponentEffect<PositionComponent> {
|
||||
_SwapCardFaceEffect({required this.onSwap})
|
||||
: super(EffectController(duration: 0.0001));
|
||||
|
||||
final void Function() onSwap;
|
||||
bool _done = false;
|
||||
|
||||
@override
|
||||
void apply(double progress) {
|
||||
if (_done) return;
|
||||
_done = true;
|
||||
onSwap();
|
||||
}
|
||||
|
||||
double measure() => 0.0001;
|
||||
}
|
||||
502
lib/flame/memory_match_flame_game.dart
Normal file
502
lib/flame/memory_match_flame_game.dart
Normal file
@@ -0,0 +1,502 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user