用 Flame 效果系统为 Klondike 纸牌游戏加入动画、重开机制与自定义 World
2026/9/16 21:50:12 网站建设 项目流程

用 Flame 效果系统为 Klondike 纸牌游戏加入动画、重开机制与自定义 World

【免费下载链接】flameA Flutter based game engine.项目地址: https://gitcode.com/GitHub_Trending/fl/flame

本篇是 Flame 游戏引擎官方 Klondike(纸牌接龙)教程的第五章,围绕"让游戏更好玩、更好操作"展开:从支持 Klondike Draw 1 / Draw 3 两种变体,到用EffectEffectController实现卡牌移动与翻面动画,再到通过FlameGame<KlondikeWorld>泛型与自定义 World 优雅地实现"结束游戏—重新开局",并加入按钮、随机数种子、胜利检测等完整功能。读完本篇,你将掌握 Flame 中动画效果(含MoveToEffectScaleEffectEffectController回调链)、HasGameRef共享游戏级状态、Model/View 分离思想以及"用全新 World 替换旧世界"的组件重建式重启方案,并能直接对照 本仓库 step5 完整源码 进行验证与运行。

本教程是 Klondike 系列的第五步,前四步分别完成准备与脚手架、牌面与组件结构、卡牌组件、核心玩法。本章将在其基础上叠加动画、重开与胜利机制。

一、Klondike 变体:Draw 3 与 Draw 1

1. 两种变体的玩法差异

Klondike 纸牌游戏有两种主要变体:

  • Draw 3(发三张):本章之前的版本默认是 Draw 3,难度更高。虽然能看到 3 张牌,但只能移动其中一张,而且每次移动都会改变其他牌的"相位",导致可操作的牌不断变化,较难上手;
  • Draw 1(发一张):每次从 Stock 发牌堆中抽出一张并展示,牌堆中每一张都能被用到,且和 Draw 3 一样可以无限次循环翻牌堆。

因此本章第一个目标,是让发牌(Stock)与弃牌(Waste)两个堆同时支持 1 或 3 两种发牌张数。显然这只需要在StockPileWastePile两处代码中知道当前是 1 还是 3 即可——最直接的办法是给它们的构造函数加参数,但 Flame 提供了另一种更通用的做法:HasGameRefmixin

2. 用 HasGameRef 共享游戏级配置

在类声明中混入HasGameRef<MyGame>,组件内即可随时通过gameRef.xxx访问游戏实例上的任意字段。我们把配置值命名为klondikeDraw,值为 1 或 3。对StockPile来说:

class StockPile extends PositionComponent with TapCallbacks, HasGameRef<KlondikeGame> implements Pile {
@override void onTapUp(TapUpEvent event) { final wastePile = parent!.firstChild<WastePile>()!; if (_cards.isEmpty) { wastePile.removeAllCards().reversed.forEach((card) { card.flip(); acquireCard(card); }); } else { for (var i = 0; i < gameRef.klondikeDraw; i++) { if (_cards.isNotEmpty) { final card = _cards.removeLast(); card.flip(); wastePile.acquireCard(card); } } } }

WastePile则需要根据变体决定是否展开顶部牌(Draw 1 只有一张牌,无需扇形展开):

class WastePile extends PositionComponent with HasGameRef<KlondikeGame> implements Pile {
void _fanOutTopCards() { if (gameRef.klondikeDraw == 1) { // No fan-out in Klondike Draw 1. return; } final n = _cards.length; for (var i = 0; i < n; i++) { _cards[i].position = position; } if (n == 2) { _cards[1].position.add(_fanOffset); } else if (n >= 3) { _cards[n - 2].position.add(_fanOffset); _cards[n - 1].position.addScaled(_fanOffset, 2); } }

在仓库的 step5 的 stock_pile.dart 中,发牌循环已升级为调用card.doMoveAndFlip(...)的动画版本(见后文),waste_pile.dart_fanOutTopCards()则与文档完全一致。

3. 从占位常量到玩家输入

如何告诉游戏当前是哪种变体?教程先在KlondikeGame中放一个占位常量,通过注释切换、重新构建来测试:

// final int klondikeDraw = 3; final int klondikeDraw = 1;

这只是临时手段,最终必须提供玩家输入——菜单界面、设置界面或按钮。Flame 可以把 Flutter widget 嵌入游戏,后续的 Ember 教程会在最后一步演示如何添加菜单 widget。

二、让卡牌动起来:Effect 与 EffectController

1. 概念:Effect 是什么

在 Flame 中,想让一个组件"做什么",就给它挂一个Effect——一种特殊组件,可以附着到另一个组件(如一张卡牌)上并修改其属性,包括任意形式的运动(即position的改变)。配套的还有EffectController,它负责提供效果的时间控制:何时开始、持续多久、遵循哪条Curve。这里的 Curve 不是空间曲线,而是时间曲线,指定效果期间的速度变化(加速/减速),例如"卡牌起步快、临近终点时减速"。

2. doMove():可复用的移动方法

Card类新增doMove()方法。必选参数是目标位置to,可选参数有:

参数默认值说明
speed:10.0移动速度,单位是"卡牌宽度/秒"
start:0.0延迟多少秒后开始移动
curve:Curves.easeOutQuad时间曲线,默认提供"快进慢出",接近真人操作手感
onComplete:null移动完成后的回调;通常都会提供,因为很多玩法逻辑必须等动画结束后再执行
void doMove( Vector2 to, { double speed = 10.0, double start = 0.0, Curve curve = Curves.easeOutQuad, VoidCallback? onComplete, }) { assert(speed > 0.0, 'Speed must be > 0 widths per second'); final dt = (to - position).length / (speed * size.x); assert(dt > 0.0, 'Distance to move must be > 0'); priority = 100; add( MoveToEffect( to, EffectController(duration: dt, startDelay: start, curve: curve), onComplete: () { onComplete?.call(); }, ), ); }

移动时长由距离、速度和卡牌宽度共同算出:dt = 距离 / (速度 × 牌宽),因此speed的单位确实是"牌宽/秒"。需要编译通过,须在components/card.dart文件头部导入:

import 'package:flame/effects.dart'; import 'package:flutter/animation.dart';

仓库实现中,doMove()被封装为 card.dart 中的 CardMoveEffect,它在MoveToEffect之上增加了一个transitPriority(移动期间卡牌的渲染优先级),并通过onStart()在效果开始时把父组件优先级提升到该值,保证移动中的牌浮在其它牌之上。

3. 无效落点:优雅地把牌送回去

拖拽(drag-and-drop)把牌放到无效位置后,不再瞬间跳回,而是沿原路动画返回。为此需要两个新字段:

bool _isDragging = false; Vector2 _whereCardStarted = Vector2(0, 0); final List<Card> attachedCards = [];

并在拖拽开始时记录起点:

_isDragging = true; priority = 100; // Copy each co-ord, else _whereCardStarted changes as the position does. _whereCardStarted = Vector2(position.x, position.y); if (pile is TableauPile) {

关键陷阱:这里绝不能写_whereCardStarted = position;。在 Dart 中那只是拷贝了一个引用,拖拽期间position数据变化时_whereCardStarted也会跟着变。必须把卡牌当前的 X、Y 坐标复制进一个新的Vector2对象。

然后把onDragEnd()方法末尾的五行替换为动画返回逻辑:

// Invalid drop (middle of nowhere, invalid pile or invalid card for pile). doMove( _whereCardStarted, onComplete: () { pile!.returnCard(this); }, ); if (attachedCards.isNotEmpty) { attachedCards.forEach((card) { final offset = card.position - position; card.doMove( _whereCardStarted + offset, onComplete: () { pile!.returnCard(card); }, ); }); attachedCards.clear(); }

这里全部使用默认速度(10 牌宽/秒)。注意onComplete:回调负责把每张牌交还给它出发时的堆(returnCard会把它加回该堆的内容列表)。同时注意:附着牌列表被立即清空——因为每张移动中的牌自身都携带了MoveToEffectEffectController,里面包含"把哪张牌在什么时间送到什么位置"的全部数据,提前清空列表不会丢失任何信息。此外,默认情况下MoveToEffectEffectController会在演出结束后由 Flame 自动从卡牌上卸载并删除。

除"无效回弹"外,还有很多移动场景可以自动化、动画化:发牌、从 Stock 翻到 Waste、tableau 堆自动翻顶牌、有效落点后的"归位"等。接下来先看翻牌动画。

三、翻牌动画:用 ScaleEffect 模拟 3D 翻转

截至教程写作时间(2023 年 10 月),Flutter 与 Flame 尚不支持真正的 3D 效果,但可以模拟:先把牌背视图宽度压缩到几乎为 0(变成一条竖线),切换到牌面视图,再恢复全宽。整个过程大量使用了 Effects 与 EffectControllers 的特性:

void turnFaceUp({ double time = 0.3, double start = 0.0, VoidCallback? onComplete, }) { assert(!_isFaceUpView, 'Card must be face-down before turning face-up.'); assert(time > 0.0, 'Time to turn card over must be > 0'); _isAnimatedFlip = true; anchor = Anchor.topCenter; position += Vector2(width / 2, 0); priority = 100; add( ScaleEffect.to( Vector2(scale.x / 100, scale.y), EffectController( startDelay: start, curve: Curves.easeOutSine, duration: time / 2, onMax: () { _isFaceUpView = true; }, reverseDuration: time / 2, onMin: () { _isAnimatedFlip = false; _faceUp = true; anchor = Anchor.topLeft; position -= Vector2(width / 2, 0); }, ), onComplete: () { onComplete?.call(); }, ), ); }

工作原理拆解

  • 默认翻转耗时 0.3 秒,支持start延迟与onComplete完成回调;
  • ScaleEffect.to把 X 方向缩放压到scale.x / 100(接近 0),Y 方向不变,但只允许占用一半时间
  • 此时用到EffectController更高级的参数:duration: time / 2之后触发onMax:回调——效果达到"最大"状态(牌被压成细线),在回调里切换到正面视图;
  • 随后EffectController让效果进入反向,时长reverseDuration: time / 2,牌面从细线重新展开,时间曲线按逆序应用;
  • 整体时间遵循从 0 到 π 的正弦曲线,动画平滑,牌宽始终是它在 3D 空间位置的 2D 投影。

为什么还需要调整 Anchor 和坐标

如果只执行add()部分,画面会很丑:卡牌会从左边缩成一条线。原因在于本游戏中所有牌默认Anchor.topLeft,而position指向的就是这个锚点。要让牌绕垂直中心线翻转,先设anchor = Anchor.topCenter——这样翻转真实了,但牌会先向左跳半个牌宽。

解法就是assert(...)add(...)之间、以及onMin:回调中那几行代码:开始时把锚点移到topCenter并把position向右补偿width / 2,翻完后在onMin:中(效果完成、最终onComplete:触发之前)恢复topLeft锚点并左移回width / 2

此外,翻牌开始时把渲染priority设为 100,让它在周围所有牌之上;该值不总是能保存恢复(接收它的 Pile 未必知道正确优先级),所以代码保证接收方总是在onComplete:中被调用,用专门的方法统一调整堆内各牌的位置与优先级。

两个关键布尔量

_isAnimatedFlip_isFaceUpView在 card.dart 类开头声明并初始化为false,与原有的_faceUp = false并列。它们的作用巨大,因为render()现在依据_isFaceUpView决定画正面还是背面:

@override void render(Canvas canvas) { if (_isFaceUpView) { _renderFront(canvas); } else { _renderBack(canvas); } }

第四步结束时,这个判断是if (_faceUp) {。当时所有移动都是瞬时的(拖拽除外),状态的改变能在引擎下一个tick及时渲染,所以没问题。但一旦加入动画(且不涉及翻面)也还能凑合;而当点击非空 Stock 堆时:

final card = _cards.removeLast(); card.flip; wastePile.acquireCard(card);

wastePile.acquireCard()的第一件事就是assert(card.isFaceUp);——如果翻牌动画还在进行(前半个动画期间牌仍处于背面状态),这个断言就会失败。这正是 Model 与 View 分离的由来。

四、Model 与 View 分离

卡牌不可能同时处于两个状态(它不是薛定谔的猫!)。解决办法是用两个"正面"定义

  • View 版本:用于渲染与动画,即屏幕上看到的样子,对应_isFaceUpView
  • Model 版本:用于游戏逻辑、玩法与错误检查,对应_faceUp

这样无需为了动画而修改所有 Pile 的逻辑。更复杂的游戏或许应在设计早期就把 Model 与 View 拆成独立类;本游戏只做轻量分离。_isAnimatedFlip在翻牌动画进行中为true,否则为falseCard.flip()相应扩展为:

void flip() { if (_isAnimatedFlip) { // Let the animation determine the FaceUp/FaceDown state. _faceUp = _isFaceUpView; } else { // No animation: flip and render the card immediately. _faceUp = !_faceUp; _isFaceUpView = _faceUp; } }

教程还指出一个可优化点:目前翻牌动画结束时仍要在onComplete:回调里触发一次 Model 更新。对于手速快的玩家,本可以在 Model 中把牌从 Stock 瞬时转移到 Waste,让 View 中的动画慢慢追上、无需onComplete:回调,从而快速连点翻牌堆。这超出了本教程范围,留作思考。

五、结束与重开:两条技术路线

现状下,游戏没有简单的方式结束并重开——即使赢了也只能关掉 App 重来,且没有任何"赢的奖励"。处理方式取决于游戏的复杂度与onLoad()耗时:

路线一:自定义 GameWidget

给 Game 传入名为reset/restartVoidCallback函数参数。回调被调用时,借助 FlutterStatefulWidget的约定(例如setState(() {});)强制 widget 重建替换,从而释放当前 Game 实例的全部引用、状态与内存;还可以顺带运行菜单或其它启动画面。

路线二:游戏类内重新初始化

只有当需要重做的操作"少而简单"时才适合,否则编码错误容易引发隐蔽问题、内存泄漏与崩溃。对 Klondike(以及 Ember 教程)来说这几乎是最简单的做法:清空所有 Pile 的卡牌引用,重新洗牌(或不洗)并重新发牌,必要时在 Draw 3 与 Draw 1 之间切换。

但实践发现"没那么简单":重置 Pile 与每张 Card 都很容易,难点在于——无论玩家获胜还是未获胜就重开,52 张牌都散布在屏幕各堆上,有的正面有的背面。我们想把牌收集成整齐的背面朝上的一叠,放到左上角 Stock 区域(暂不放进真正的 Stock,因为它在发牌时才会创建)。

写一个简单循环让每张牌doMove独立飞回左上角,会失败:所有牌速度相同、到达时间却不同,发牌后 tableau 堆乱七八糟;整体动画也很丑。Tableau 堆错乱的问题尚可修补,但此时教程审阅者提出了全新方案——不重置任何东西,直接从头创建所有 Component,这才是符合 Flutter/Flame 惯例的做法。

六、新世界:KlondikeWorld

1. 所需动作清单

我们希望在 Klondike 中提供:

  • 首次开局;
  • 任意次"重新发新牌";
  • 任意次"用同一副牌重新开始";
  • 在 Draw 1 与 Draw 3 之间切换后重新发新牌;
  • 以及"先玩开心再说"(留作惊喜)。

方案是新增一个KlondikeWorld类,替换FlameGame提供的默认world。新世界包含玩这局游戏所需的(几乎)一切,并在上述每个动作中创建或重新创建。

2. 精简后的 KlondikeGame

enum Action { newDeal, sameDeal, changeDraw, haveFun } class KlondikeGame extends FlameGame<KlondikeWorld> { static const double cardGap = 175.0; static const double topGap = 500.0; static const double cardWidth = 1000.0; static const double cardHeight = 1400.0; static const double cardRadius = 100.0; static const double cardSpaceWidth = cardWidth + cardGap; static const double cardSpaceHeight = cardHeight + cardGap; static final Vector2 cardSize = Vector2(cardWidth, cardHeight); static final cardRRect = RRect.fromRectAndRadius( const Rect.fromLTWH(0, 0, cardWidth, cardHeight), const Radius.circular(cardRadius), ); // Constant used when creating Random seed. static const int maxInt = 0xFFFFFFFE; // = (2 to the power 32) - 1 // This KlondikeGame constructor also initiates the first KlondikeWorld. KlondikeGame() : super(world: KlondikeWorld()); // These three values persist between games and are starting conditions // for the next game to be played in KlondikeWorld. The actual seed is // computed in KlondikeWorld but is held here in case the player chooses // to replay a game by selecting Action.sameDeal. int klondikeDraw = 1; int seed = 1; Action action = Action.newDeal; }

onLoad()去哪了?它现在属于KlondikeWorldWorld的子类,而World是一种Component,所以任何组件类型都可以有onLoad())。方法内容与之前几乎一致,只是原来的world.add(变成了add(,并且引入了若干addButton()调用。

对应源码见 step5 的 klondike_game.dart,其中还额外定义了dragTolerance(判断短拖拽视为点击的距离阈值)与从雪碧图切片生成Sprite的辅助函数。

3. 随机数种子(seed)

seed是各编程环境下通用的游戏编程技巧:从已知点(种子)启动随机数生成器,让开发与测试阶段的行为可复现。这里用它实现"Same deal"——玩家要求同一副牌时,用与上一局完全相同的种子洗牌。

KlondikeGameFlameGame<KlondikeWorld>泛型声明,强制本游戏的世界必须是KlondikeWorld类型。第一个世界实例在构造函数初始化序列中创建:

KlondikeGame() : super(world: KlondikeWorld());

4. 按钮:FlatButton

用按钮激活各种重开方式。先扩展 Flame 的ButtonComponent创建FlatButton(改编自 Flame 旧版 Examples 页的 Flat Button)。ButtonComponent使用两个PositionComponent:一个表示按钮常态(up),一个表示按下态(down);用户按下/释放按钮时二者交替mountedrendered。按住即为按下。

本按钮的两个组件是按钮的轮廓:按下时buttonDown:轮廓变红,作为警告——因为四个按钮动作都会结束当前游戏并开启新一局。这也解释了为何它们被放在画布顶部、所有卡牌之上,避免误触。万一误按,按住不放滑离即可取消。

四个按钮对应前述四种重开动作,标签分别为New dealSame dealDraw 1 ⇌ 3Have fun。Flame 还有基于两个交替SpriteSpriteButtonComponentHudButtonComponentAdvancedButtonComponent;更复杂的按钮/控制器(单选、下拉、滑块等)则建议用 Flutter overlay、菜单或设置 widget。本教程的FlatButton够用。

在 step5 的 flat_button.dart 中,FlatButtonButtonComponent加一个居中的TextComponent组成,ButtonBackground负责绘制圆角描边背景(圆角半径0.3 * size.y、描边宽0.05 * size.y)。

世界onLoad()中用addButton()布置四个按钮:

playAreaSize = Vector2(7 * cardSpaceWidth + cardGap, 4 * cardSpaceHeight + topGap); final gameMidX = playAreaSize.x / 2; addButton('New deal', gameMidX, Action.newDeal); addButton('Same deal', gameMidX + cardSpaceWidth, Action.sameDeal); addButton('Draw 1 or 3', gameMidX + 2 * cardSpaceWidth, Action.changeDraw); addButton('Have fun', gameMidX + 3 * cardSpaceWidth, Action.haveFun);

它们恰好位于四个 Foundation(基桩)堆正上方、与其中心对齐——第一个基桩堆大致在屏幕顶部中央,所以第一个按钮居中于它上方。

5. 锚点与坐标

这里的表达式初看有些奇怪:卡牌与各堆都是Anchor.topLeft,按钮却是Anchor.center。一张Cardposition表示其左上角的位置;而一个FlatButtonposition表示其中心的位置,按钮的各部分围绕中心(内部)排布。这个对比恰好揭示了 Flame 坐标系统的工作方式。另外 klondike_world.dart 中还通过 camera 的viewfinder设置可视游戏区域尺寸、锚点与位置,保证四种屏幕比例下布局正确。

6. deal():动画化发牌

KlondikeWorld.onLoad()最后一件事是调用deal()洗牌并发牌;checkWin()letsCelebrate()也移入KlondikeWorld。发牌逻辑与第四步相同,但现在带上了动画:

void deal() { assert(cards.length == 52, 'There are ${cards.length} cards: should be 52'); if (gameRef.action != Action.sameDeal) { // New deal: change the Random Number Generator's seed. gameRef.seed = Random().nextInt(KlondikeGame.maxInt); if (gameRef.action == Action.changeDraw) { gameRef.klondikeDraw = (gameRef.klondikeDraw == 3) ? 1 : 3; } } // For the "Same deal" option, re-use the previous seed, else use a new one. cards.shuffle(Random(gameRef.seed)); var cardToDeal = cards.length - 1; var nMovingCards = 0; for (var i = 0; i < 7; i++) { for (var j = i; j < 7; j++) { final card = cards[cardToDeal--]; card.doMove( tableauPiles[j].position, start: nMovingCards * 0.15, onComplete: () { tableauPiles[j].acquireCard(card); nMovingCards--; if (nMovingCards == 0) { var delayFactor = 0; for (final tableauPile in tableauPiles) { delayFactor++; tableauPile.flipTopCard(start: delayFactor * 0.15); } } }, ); nMovingCards++; } } for (var n = 0; n <= cardToDeal; n++) { stock.acquireCard(cards[n]); } }

流程:

  1. 先落实本局Action:首局时KlondikeGame默认Action.newDealklondikeDraw = 1;之后玩家按按钮会把动作保存到KlondikeGame,或玩家获胜时自动保存Action.newDeal
  2. 动作通常生成并保存新种子,但Action.sameDeal会跳过;
  3. 用当前seed洗牌(cards.shuffle(Random(gameRef.seed))),改变种子即可让随机数生成器从新起点开始;
  4. 每张牌用card.doMove(动画发出,目标位置递增、start:递增,同时计数出发中的牌。循环刚结束时nMovingCards最大为 28(即 1+2+3+4+5+6+7),剩余 24 张进入正确构造的 Stock 堆;
  5. 顺序问题:牌不一定按发出顺序到达。如果提前翻某列的最后一张牌,可能翻错牌、打乱整个发牌。所以文档打印了发牌日志(见下),显示 6 号列的 K♥ 比 5 号列末尾的 Q♣ 还早到:
flutter: Move done, i 3, j 6, 6♠ 5 moving cards. flutter: Move done, i 4, j 5, 9♥ 4 moving cards. flutter: Move done, i 4, j 6, K♥ 3 moving cards. flutter: Move done, i 5, j 5, Q♣ 2 moving cards. flutter: Move done, i 5, j 6, 2♠ 1 moving cards. flutter: Move done, i 6, j 6, 10♠ 0 moving cards. flutter: Pile 0 [Q♦] flutter: Pile 1 [J♣, Q♥] flutter: Pile 2 [5♥, 5♦, J♦] flutter: Pile 3 [A♠, Q♠, A♥, 5♠] flutter: Pile 4 [8♦, 10♣, 7♥, 3♥, 4♥] flutter: Pile 5 [4♠, 8♣, 5♣, 2♥, 9♥, Q♣] flutter: Pile 6 [4♣, 3♦, K♦, 6♠, K♥, 2♠, 10♠]

解法:在onComplete()回调里统计到达数,只有当 28 张全部到达后,才开始翻开每列的最后一张牌(flipTopCard(start: delayFactor * 0.15),逐列错开 0.15 秒)。发牌完成后KlondikeWorld才算就绪、可以开玩。

七、更多移动动画与自动移动

1. doMoveAndFlip:从 Stock 发牌的完整动作

CarddoMove()turnFaceUp()被组合成doMoveAndFlip(),用于从 Stock 发牌——先移动到目标位置,到达后再翻面:

void doMoveAndFlip( Vector2 to, { double speed = 10.0, double start = 0.0, Curve curve = Curves.easeOutQuad, VoidCallback? whenDone, }) { assert(speed > 0.0, 'Speed must be > 0 widths per second'); final dt = (to - position).length / (speed * size.x); assert(dt > 0, 'Distance to move must be > 0'); priority = 100; add( MoveToEffect( to, EffectController(duration: dt, startDelay: start, curve: curve), onComplete: () { turnFaceUp( onComplete: whenDone, ); }, ), ); }

在 stock_pile.dart 的handleTapUp中,发牌循环即为card.doMoveAndFlip(wastePile.position, whenDone: () { wastePile.acquireCard(card); })——牌到达 Waste 后才被正式收入(此时assert(card.isFaceUp)必定通过,因为翻面动画已在onComplete里先行完成)。

2. 拖拽落点归位与点击自动进基桩

拖拽放下牌时也用doMove()平滑"归位";此外,若某张牌已经可以进基桩(Foundation),只需点一下它就会自动飞过去。为此给Card加上TapCallbacksonTapUp()回调:

onTapUp(TapUpEvent event) { if (isFaceUp) { final suitIndex = suit.value; if (game.foundations[suitIndex].canAcceptCard(this)) { pile!.removeCard(this); doMove( game.foundations[suitIndex].position, onComplete: () { game.foundations[suitIndex].acquireCard(this); }, ); } } else if (pile is StockPile) { game.stock.onTapUp(event); } }

快要赢时,这个"点击自动出牌"能省下大量拖拽操作。代码唯一的新意在于:当点击的是 Stock 顶牌时,Card对象先收到点击事件,然后转发stock对象处理。在仓库实现中,这一逻辑被抽为handleTapUp(),同时支持点击与"短拖拽后松手"两种触发途径(见 card.dart)。

八、图形瑕疵的修复:TableauPile.dropCards

如果从某个 tableau 堆一次拖多张牌到另一个堆,第四步的内部代码会在拖拽结束瞬间粗暴地把牌就位;第五步想改成"一系列动画移动、每张到达即acquireCard"。但这引发了丑陋的图形故障——原因是acquireCard会调用TableauPilelayoutCards()(仓库中为layOutCards()),每收进一张牌就立刻重排堆内所有牌,动画被瞬间打断。

解决办法(颇费周折)是给TableauPile新增dropCards方法:在模拟原有行为的同时,把多张牌的动画穿插进去。仓库实现中(tableau_pile.dart):

void dropCards(Card firstCard, [List<Card> attachedCards = const []]) { final cardList = [firstCard]; cardList.addAll(attachedCards); Vector2 nextPosition = _cards.isEmpty ? position : _cards.last.position; var nCardsToMove = cardList.length; for (final card in cardList) { card.pile = this; card.priority = _cards.length; if (_cards.isNotEmpty) { nextPosition = nextPosition + (card.isFaceDown ? _fanOffset1 : _fanOffset2); } _cards.add(card); card.doMove( nextPosition, startPriority: card.priority, onComplete: () { nCardsToMove--; if (nCardsToMove == 0) { calculateHitArea(); // Expand the hit-area. } }, ); } }

它把整串牌一次性登记进堆(pilepriority_cards),再逐张动画移动到各自的目标位置;全部到达后统一用calculateHitArea()扩展命中区域。同时layOutCards()会为每张牌重算位置与优先级,calculateHitArea()cardHeight * 1.5 + (最后一张.y - 第一张.y)动态调整堆的命中高度,使较长的牌叠也能被准确投中。

教训:动画与时间相关的问题值得在游戏设计阶段就考虑——也就是 Klondike 教程第一步(准备)与第二步(脚手架)时就该想到。

九、胜利检测与庆祝

当所有花色的 A 到 K(每堆 13 张)都移入四个基桩即获胜。代码通过三处配合完成识别:

  1. FoundationPile.acquireCard()中新增isFull测试:
class FoundationPile extends PositionComponent implements Pile { FoundationPile(int intSuit, this.checkWin, {super.position}) : suit = Suit.fromInt(intSuit), super(size: KlondikeGame.cardSize); final VoidCallback checkWin; final Suit suit; final List<Card> _cards = []; //#region Pile API bool get isFull => _cards.length == 13;
void acquireCard(Card card) { assert(card.isFaceUp); card.position = position; card.priority = _cards.length; card.pile = this; _cards.add(card); if (isFull) { checkWin(); // Get KlondikeWorld to check all FoundationPiles. } }
  1. 每堆满后回调KlondikeWorld.checkWin(),检查四个基桩是否全满:
void checkWin() { var nComplete = 0; for (final f in foundations) { if (f.isFull) { nComplete++; } } if (nComplete == foundations.length) { letsCelebrate(); } }
  1. 全满则调用letsCelebrate()播放胜利动画。

在仓库实现中,FoundationPile的构造函数接收this.checkWin(即KlondikeWorld.checkWin),见 foundation_pile.dart。

顺便一提:从某局牌的牌面位置常常能算出"能否获胜"或"原本能赢却错过关键一步",初始牌局能否解开也常可计算(部分 Klondike 牌局天然无解),但这远超本教程范围,胜负判断交给玩家——继续打还是按按钮重开。

十、结束一局并重新开始

一局游戏在玩家获胜或按下按钮时结束。此时KlondikeGame必须持有开启新局所需的全部数据:一个Action值、一个klondikeDraw值(1 或 3)以及上一局的seed。每个按钮的onReleased:回调由KlondikeWorld.addButton()提供:

onReleased: () { if (action == Action.haveFun) { // Shortcut to the "win" sequence, for Tutorial purposes only. letsCelebrate(); } else { // Restart with a new deal or the same deal as before. gameRef.action = action; gameRef.world = KlondikeWorld(); } },

letsCelebrate()通常只在玩家获胜时触发。另外三个按钮的功能是:把Action值写入KlondikeGame,并把FlameGameworld指向一个新的KlondikeWorld,从而替换当前世界——旧KlondikeWorld的存储交由垃圾回收(Garbage Collect)处置。FlameGame会继续触发新KlondikeWorldonLoad()方法。

letsCelebrate()方法结尾有类似代码,但强制开启新牌局:

gameRef.action = Action.newDeal; gameRef.world = KlondikeWorld();

十一、Have fun 按钮与本章小结

获胜时letsCelebrate()会播放一段小演出。为了让读者不必真的打完一整局才能看到(同时也用于测试该方法),教程提供了Have fun按钮——当然,真正的游戏里不会有这种按钮。

在仓库实现中,letsCelebrate()是两阶段动画(klondike_world.dart):第一阶段把所有牌以约 15 牌宽/秒、每张间隔 0.02 秒的速度聚拢到屏幕中央(营造"洗牌"效果);第二阶段再以每张间隔 0.04 秒、约 5 牌宽/秒的速度,让牌按矩形四边等间距散落到屏幕外;全部结束后以Action.newDeal开启新局。两阶段都依赖doMovestart:延迟参数制造"逐张接力"的节奏感。

至此,Klondike 游戏变得更加可玩:支持 Draw 1 / Draw 3 切换,所有移动都有动画(发牌、翻牌、回弹、归位、自动出牌),可以随时重开(新牌、同牌、换变体),获胜有庆祝演出。本游戏终究首先是一个教程,做得更多固然可以,但更值得做的,是继续下一步——Ember 教程(其最终步演示了如何用菜单 widget 整合 Flutter 与 Flame)。

延伸阅读

  • 本教程完整源码:step5 源码目录,含 klondike_game.dart、klondike_world.dart 及 components 下的 card.dart、stock_pile.dart、waste_pile.dart、tableau_pile.dart、foundation_pile.dart、flat_button.dart;示例工程配置见 pubspec.yaml;
  • 前序步骤:第一步 准备、第二步 脚手架、第三步 卡牌、第四步 玩法、教程总览;
  • Flame 效果系统:Effects 总览、EffectControllers、移动效果 MoveToEffect、缩放效果 ScaleEffect;
  • 输入系统:点击事件、拖拽事件;
  • 游戏与组件体系:FlameGame 与 World、组件体系。

【免费下载链接】flameA Flutter based game engine.项目地址: https://gitcode.com/GitHub_Trending/fl/flame

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询