336 lines
8.1 KiB
Dart
336 lines
8.1 KiB
Dart
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;
|
|
}
|