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 { 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 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 { _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; }