
用 Flame 效果系统为 Klondike 纸牌游戏加入动画、重开机制与自定义 World【免费下载链接】flameA Flutter based game engine.项目地址: https://gitcode.com/GitHub_Trending/fl/flame本篇是 Flame 游戏引擎官方 Klondike纸牌接龙教程的第五章围绕让游戏更好玩、更好操作展开从支持 Klondike Draw 1 / Draw 3 两种变体到用Effect与EffectController实现卡牌移动与翻面动画再到通过FlameGameKlondikeWorld泛型与自定义 World 优雅地实现结束游戏—重新开局并加入按钮、随机数种子、胜利检测等完整功能。读完本篇你将掌握 Flame 中动画效果含MoveToEffect、ScaleEffect、EffectController回调链、HasGameRef共享游戏级状态、Model/View 分离思想以及用全新 World 替换旧世界的组件重建式重启方案并能直接对照 本仓库 step5 完整源码 进行验证与运行。本教程是 Klondike 系列的第五步前四步分别完成准备与脚手架、牌面与组件结构、卡牌组件、核心玩法。本章将在其基础上叠加动画、重开与胜利机制。一、Klondike 变体Draw 3 与 Draw 11. 两种变体的玩法差异Klondike 纸牌游戏有两种主要变体Draw 3发三张本章之前的版本默认是 Draw 3难度更高。虽然能看到 3 张牌但只能移动其中一张而且每次移动都会改变其他牌的相位导致可操作的牌不断变化较难上手Draw 1发一张每次从 Stock 发牌堆中抽出一张并展示牌堆中每一张都能被用到且和 Draw 3 一样可以无限次循环翻牌堆。因此本章第一个目标是让发牌Stock与弃牌Waste两个堆同时支持 1 或 3 两种发牌张数。显然这只需要在StockPile与WastePile两处代码中知道当前是 1 还是 3 即可——最直接的办法是给它们的构造函数加参数但 Flame 提供了另一种更通用的做法HasGameRefmixin。2. 用 HasGameRef 共享游戏级配置在类声明中混入HasGameRefMyGame组件内即可随时通过gameRef.xxx访问游戏实例上的任意字段。我们把配置值命名为klondikeDraw值为 1 或 3。对StockPile来说class StockPile extends PositionComponent with TapCallbacks, HasGameRefKlondikeGame implements Pile {override void onTapUp(TapUpEvent event) { final wastePile parent!.firstChildWastePile()!; 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 HasGameRefKlondikeGame 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 与 EffectController1. 概念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 ListCard 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会把它加回该堆的内容列表。同时注意附着牌列表被立即清空——因为每张移动中的牌自身都携带了MoveToEffect与EffectController里面包含把哪张牌在什么时间送到什么位置的全部数据提前清空列表不会丢失任何信息。此外默认情况下MoveToEffect与EffectController会在演出结束后由 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接近 0Y 方向不变但只允许占用一半时间此时用到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 版本用于渲染与动画即屏幕上看到的样子对应_isFaceUpViewModel 版本用于游戏逻辑、玩法与错误检查对应_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/restart的VoidCallback函数参数。回调被调用时借助 FlutterStatefulWidget的约定例如setState(() {});强制 widget 重建替换从而释放当前 Game 实例的全部引用、状态与内存还可以顺带运行菜单或其它启动画面。路线二游戏类内重新初始化只有当需要重做的操作少而简单时才适合否则编码错误容易引发隐蔽问题、内存泄漏与崩溃。对 Klondike以及 Ember 教程来说这几乎是最简单的做法清空所有 Pile 的卡牌引用重新洗牌或不洗并重新发牌必要时在 Draw 3 与 Draw 1 之间切换。但实践发现没那么简单重置 Pile 与每张 Card 都很容易难点在于——无论玩家获胜还是未获胜就重开52 张牌都散布在屏幕各堆上有的正面有的背面。我们想把牌收集成整齐的背面朝上的一叠放到左上角 Stock 区域暂不放进真正的 Stock因为它在发牌时才会创建。写一个简单循环让每张牌doMove独立飞回左上角会失败所有牌速度相同、到达时间却不同发牌后 tableau 堆乱七八糟整体动画也很丑。Tableau 堆错乱的问题尚可修补但此时教程审阅者提出了全新方案——不重置任何东西直接从头创建所有 Component这才是符合 Flutter/Flame 惯例的做法。六、新世界KlondikeWorld1. 所需动作清单我们希望在 Klondike 中提供首次开局任意次重新发新牌任意次用同一副牌重新开始在 Draw 1 与 Draw 3 之间切换后重新发新牌以及先玩开心再说留作惊喜。方案是新增一个KlondikeWorld类替换FlameGame提供的默认world。新世界包含玩这局游戏所需的几乎一切并在上述每个动作中创建或重新创建。2. 精简后的 KlondikeGameenum Action { newDeal, sameDeal, changeDraw, haveFun } class KlondikeGame extends FlameGameKlondikeWorld { 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. 随机数种子seedseed是各编程环境下通用的游戏编程技巧从已知点种子启动随机数生成器让开发与测试阶段的行为可复现。这里用它实现Same deal——玩家要求同一副牌时用与上一局完全相同的种子洗牌。KlondikeGame用FlameGameKlondikeWorld泛型声明强制本游戏的世界必须是KlondikeWorld类型。第一个世界实例在构造函数初始化序列中创建KlondikeGame() : super(world: KlondikeWorld());4. 按钮FlatButton用按钮激活各种重开方式。先扩展 Flame 的ButtonComponent创建FlatButton改编自 Flame 旧版 Examples 页的 Flat Button。ButtonComponent使用两个PositionComponent一个表示按钮常态up一个表示按下态down用户按下/释放按钮时二者交替mounted与rendered。按住即为按下。本按钮的两个组件是按钮的轮廓按下时buttonDown:轮廓变红作为警告——因为四个按钮动作都会结束当前游戏并开启新一局。这也解释了为何它们被放在画布顶部、所有卡牌之上避免误触。万一误按按住不放滑离即可取消。四个按钮对应前述四种重开动作标签分别为New deal、Same deal、Draw 1 ⇌ 3、Have fun。Flame 还有基于两个交替Sprite的SpriteButtonComponent、HudButtonComponent与AdvancedButtonComponent更复杂的按钮/控制器单选、下拉、滑块等则建议用 Flutter overlay、菜单或设置 widget。本教程的FlatButton够用。在 step5 的 flat_button.dart 中FlatButton由ButtonComponent加一个居中的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。一张Card的position表示其左上角的位置而一个FlatButton的position表示其中心的位置按钮的各部分围绕中心内部排布。这个对比恰好揭示了 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 Generators 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]); } }流程先落实本局Action首局时KlondikeGame默认Action.newDeal且klondikeDraw 1之后玩家按按钮会把动作保存到KlondikeGame或玩家获胜时自动保存Action.newDeal动作通常生成并保存新种子但Action.sameDeal会跳过用当前seed洗牌cards.shuffle(Random(gameRef.seed))改变种子即可让随机数生成器从新起点开始每张牌用card.doMove(动画发出目标位置递增、start:递增同时计数出发中的牌。循环刚结束时nMovingCards最大为 28即 1234567剩余 24 张进入正确构造的 Stock 堆顺序问题牌不一定按发出顺序到达。如果提前翻某列的最后一张牌可能翻错牌、打乱整个发牌。所以文档打印了发牌日志见下显示 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 发牌的完整动作Card的doMove()与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加上TapCallbacks与onTapUp()回调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会调用TableauPile的layoutCards()仓库中为layOutCards()每收进一张牌就立刻重排堆内所有牌动画被瞬间打断。解决办法颇费周折是给TableauPile新增dropCards方法在模拟原有行为的同时把多张牌的动画穿插进去。仓库实现中tableau_pile.dartvoid dropCards(Card firstCard, [ListCard 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. } }, ); } }它把整串牌一次性登记进堆pile、priority、_cards再逐张动画移动到各自的目标位置全部到达后统一用calculateHitArea()扩展命中区域。同时layOutCards()会为每张牌重算位置与优先级calculateHitArea()按cardHeight * 1.5 (最后一张.y - 第一张.y)动态调整堆的命中高度使较长的牌叠也能被准确投中。教训动画与时间相关的问题值得在游戏设计阶段就考虑——也就是 Klondike 教程第一步准备与第二步脚手架时就该想到。九、胜利检测与庆祝当所有花色的 A 到 K每堆 13 张都移入四个基桩即获胜。代码通过三处配合完成识别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 ListCard _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. } }每堆满后回调KlondikeWorld.checkWin()检查四个基桩是否全满void checkWin() { var nComplete 0; for (final f in foundations) { if (f.isFull) { nComplete; } } if (nComplete foundations.length) { letsCelebrate(); } }全满则调用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并把FlameGame的world指向一个新的KlondikeWorld从而替换当前世界——旧KlondikeWorld的存储交由垃圾回收Garbage Collect处置。FlameGame会继续触发新KlondikeWorld的onLoad()方法。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开启新局。两阶段都依赖doMove的start:延迟参数制造逐张接力的节奏感。至此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),仅供参考