2026/9/16 12:10:35

Flame 平台跳跃游戏教程(五):用 Ember Quest 实现键盘控制、碰撞检测与卷轴滚动

Flame 平台跳跃游戏教程(五):用 Ember Quest 实现键盘控制、碰撞检测与卷轴滚动 Flame 平台跳跃游戏教程五用 Ember Quest 实现键盘控制、碰撞检测与卷轴滚动【免费下载链接】flameA Flutter based game engine.项目地址: https://gitcode.com/GitHub_Trending/fl/flame导读本篇是 Flame 引擎官方教程《Ember Quest》系列的第五步核心任务是让玩家角色 Ember 真正活起来通过键盘控制左右移动与跳跃、接入 Flame 内置的碰撞检测系统实现落地与拾取判定、引入简单的重力与受击逻辑并最终实现横版卷轴scrolling效果。读完本文你将掌握HasKeyboardHandlerComponents/KeyboardHandler键盘输入链路、HasCollisionDetection/CollisionCallbacks碰撞回调体系、基于CircleHitbox的碰撞法线解析以及用共享objectSpeed驱动地图滚动与动态生成关卡片段的完整方案。本文对应的可运行工程位于 doc/tutorials/platformer/app全部改动围绕lib/ember_quest.dart游戏主类与lib/actors/ember.dart玩家组件展开。一、键盘控制让 Ember 响应方向键与 A/D 键1.1 接入键盘事件链路Flame 的键盘输入由两级 mixin 协作完成这在源码中有明确印证keyboard_handler.dart游戏层HasKeyboardHandlerComponents挂在FlameGame上负责收集按键状态并分发给组件树中的键盘监听者组件层KeyboardHandler挂在具体组件上通过覆写onKeyEvent(KeyEvent event, SetLogicalKeyboardKey keysPressed)接收事件。因此在lib/ember_quest.dart中为游戏类追加import package:flame/events.dart; class EmberQuestGame extends FlameGame with HasKeyboardHandlerComponents {在lib/actors/ember.dart中为玩家组件追加class EmberPlayer extends SpriteAnimationComponent with KeyboardHandler, HasGameRefEmberQuestGame {其中HasGameRefEmberQuestGame让 Ember 可以在任意生命周期阶段拿到游戏实例例如后面的gameRef.objectSpeed、gameRef.health这在上一系列步骤中已经引入。随后覆写按键回调override bool onKeyEvent(KeyEvent event, SetLogicalKeyboardKey keysPressed) { return true; }如果编辑器没有自动补全导入需要手动补充import package:flutter/services.dart;KeyEvent与LogicalKeyboardKey均来自 Flutter 的 services 库。1.2 用归一化方向向量驱动移动控制移动最直观的做法是维护一个归一化方向变量其取值被限制在 -1、0、1 三者之一。在EmberPlayer类顶部声明int horizontalDirection 0;然后在onKeyEvent中根据当前按下的按键集合计算方向override bool onKeyEvent(KeyEvent event, SetLogicalKeyboardKey keysPressed) { horizontalDirection 0; horizontalDirection (keysPressed.contains(LogicalKeyboardKey.keyA) || keysPressed.contains(LogicalKeyboardKey.arrowLeft)) ? -1 : 0; horizontalDirection (keysPressed.contains(LogicalKeyboardKey.keyD) || keysPressed.contains(LogicalKeyboardKey.arrowRight)) ? 1 : 0; return true; }这段逻辑同时支持A/D键与←/→方向键由于每一步都先把horizontalDirection归零再累加同时按住左右两键时方向会互相抵消不会出现卡死在某个方向的问题。1.3 速度变量与 update 中的位移在类顶部继续声明速度与移动速度常量final Vector2 velocity Vector2.zero(); final double moveSpeed 200;然后新增update方法将方向换算为位移override void update(double dt) { velocity.x horizontalDirection * moveSpeed; position velocity * dt; super.update(dt); }velocity.x horizontalDirection * moveSpeed得到每秒的像素速度单位像素/秒position velocity * dt按帧间隔dt累计位移。moveSpeed 200意味着 Ember 每秒最多横向移动 200 像素这个值可以按手感随时调整。1.4 面向移动方向flipHorizontally运行后你会发现 Ember 向左走时依然面向右。Flame 的SpriteAnimationComponent自带flipHorizontally()方法本质上是翻转组件的scale.x符号在update末尾追加if (horizontalDirection 0 scale.x 0) { flipHorizontally(); } else if (horizontalDirection 0 scale.x 0) { flipHorizontally(); }条件中的scale.x 0/scale.x 0判断用来避免重复翻转只有当前朝向与移动方向不一致时才翻转。在最终工程 ember.dart 中保留了这段逻辑并放置在速度/位置更新之后。二、碰撞检测落地、平台与基础物理2.1 激活全局碰撞系统Flame 的碰撞检测以 mixin 形式按需启用。官方文档 collision_detection.md 说明给游戏挂上HasCollisionDetection后所有添加进游戏、且带ShapeHitbox的组件都会被自动检测相交。为EmberQuestGame追加class EmberQuestGame extends FlameGame with HasCollisionDetection, HasKeyboardHandlerComponents {2.2 为 Ember 挂上 CollisionCallbacks 与 CircleHitbox玩家组件需要两个东西CollisionCallbacksmixin接收碰撞回调以及一个具体命中框让碰撞检测系统看得见它。修改类声明class EmberPlayer extends SpriteAnimationComponent with KeyboardHandler, CollisionCallbacks, HasGameRefEmberQuestGame {必要时补充import package:flame/collisions.dart;。然后在onLoad中为 Ember 添加圆形命中框add(CircleHitbox());不带参数的CircleHitbox()会尽量填满父组件Ember 是 64×64 的组件圆形形状与 Ember 的精灵轮廓更贴合。官方文档同时提醒Flame 的碰撞检测不会处理隧穿tunneling——当组件移动过快或单帧dt过大时两个命中框可能直接互相穿过而不触发碰撞见 collision_detection.md这也是后面要用terminalVelocity限制下落速度的原因之一。2.3 onCollision计算碰撞法线并解算穿透接下来覆写onCollision。Flame 的碰撞回调签名是void onCollision(ListVector2 intersectionPoints, PositionComponent other)其中intersectionPoints是两个命中框边缘相交的交点列表other是撞上的另一方详见 collision_detection.md。override void onCollision(ListVector2 intersectionPoints, PositionComponent other) { if (other is GroundBlock || other is PlatformBlock) { if (intersectionPoints.length 2) { // Calculate the collision normal and separation distance. final mid (intersectionPoints.elementAt(0) intersectionPoints.elementAt(1)) / 2; final collisionNormal absoluteCenter - mid; final separationDistance (size.x / 2) - collisionNormal.length; collisionNormal.normalize(); // If collision normal is almost upwards, // ember must be on ground. if (fromAbove.dot(collisionNormal) 0.9) { isOnGround true; } // Resolve collision by moving ember along // collision normal by separation distance. position collisionNormal.scaled(separationDistance); } } super.onCollision(intersectionPoints, other); }这段代码是轻量级物理的核心值得拆解判定对象只对GroundBlock地面块与PlatformBlock平台块做物理解算对星星、敌人则走另一套逻辑交点数量判断圆形命中框与矩形命中框相交时通常产生 2 个交点length 2保证只有真实相交才进入解算计算法线collisionNormal absoluteCenter - mid表示从碰撞中点指向 Ember 中心的向量归一化后就是碰撞法线方向分离距离separationDistance (size.x / 2) - collisionNormal.length即圆心到碰撞点的距离与圆半径之差——把 Ember 沿法线推开这个距离即可消除穿透地面判定预置向量fromAbove Vector2(0, -1)代表正上方若法线与其点积大于 0.9说明碰撞基本来自脚下夹角小于约 25°Ember 处于地面上置isOnGround true位置修正position collisionNormal.scaled(separationDistance)将 Ember 沿法线推出避免陷入地面。需要引入GroundBlock与PlatformBlock类型import ../objects/ground_block.dart; import ../objects/platform_block.dart;并声明两个类变量final Vector2 fromAbove Vector2(0, -1); bool isOnGround false;关于碰撞回调的调用时机官方文档明确指出当两个PositionComponent开始碰撞时onCollisionStart与onCollision都会被调用因此只处理持续碰撞时仅覆写onCollision即可collision_detection.md。2.4 重力、跳跃与终端速度有了碰撞解算落地判定才可能成立现在补上重力与跳跃。继续声明物理参数final double gravity 15; final double jumpSpeed 600; final double terminalVelocity 150; bool hasJumped false;在onKeyEvent中登记跳跃意图hasJumped keysPressed.contains(LogicalKeyboardKey.space);注意这里只记录空格是否按下真正的起跳判定放在update中——这样可以在同一帧内结合isOnGround状态做出决策。最后在update中把物理逻辑串起来// Apply basic gravity velocity.y gravity; // Determine if ember has jumped if (hasJumped) { if (isOnGround) { velocity.y -jumpSpeed; isOnGround false; } hasJumped false; } // Prevent ember from jumping to crazy fast as well as descending too fast and // crashing through the ground or a platform. velocity.y velocity.y.clamp(-jumpSpeed, terminalVelocity);三个参数的含义与作用如下参数值作用gravity15每帧向下累加的速度增量像素/帧²模拟重力加速度jumpSpeed600起跳瞬间的向上初速度velocity.y -jumpSpeed同时作为下落速度的下限clamp 上界terminalVelocity150终端速度限制最大下落速度防止高速下坠穿透地面隧穿clamp(-jumpSpeed, terminalVelocity)把velocity.y限制在 [-600, 150] 区间既防止跳得太高负方向被钳制也防止下落过快正方向被钳制。完整的物理更新顺序在最终工程 ember.dart 中可逐行对照。2.5 入场下落让 Ember 从空中掉进场景为了让重力与碰撞在开局立刻可见把 Ember 的初始位置从站在地面上改为悬在空中。在lib/ember_quest.dart的initializeGame方法中修改_ember EmberPlayer( position: Vector2(128, canvasSize.y - 128), );canvasSize.y - 128让 Ember 出生在屏幕底部略靠上方的空中运行后它会立刻下落、与地面块发生碰撞、被解算落地然后就可以按空格跳跃了。三、与对象碰撞吃星星、撞敌人与受击闪烁3.1 星星消失与敌人受击地面与平台之外场景中还有星星Star和水怪敌人WaterEnemy。在onCollision末尾追加if (other is Star) { other.removeFromParent(); } if (other is WaterEnemy) { hit(); }逻辑很直白碰到星星就把它从父节点移除拾取碰到敌人就调用hit()触发受击。在最终工程中碰撞星星还会同步累加分数gameRef.starsCollected见 ember.dart这部分属于后续步骤的内容。3.2 受击冷却与 OpacityEffect 闪烁声明受击冷却标记并实现hit方法bool hitByEnemy false;// This method runs an opacity effect on ember // to make it blink. void hit() { if (!hitByEnemy) { hitByEnemy true; } add( OpacityEffect.fadeOut( EffectController( alternate: true, duration: 0.1, repeatCount: 6, ), )..onComplete () { hitByEnemy false; }, ); }关键点冷却标记hitByEnemy保证一次受击动画期间不会反复扣血在完整工程中扣血逻辑gameRef.health--也放在if (!hitByEnemy)分支内见 ember.dart透明度动画OpacityEffect.fadeOut配合EffectController(alternate: true, duration: 0.1, repeatCount: 6)让 Ember 在 0.1 秒内淡出又淡入交替重复 6 次形成闪烁效果结束回调onComplete在动画结束后把hitByEnemy复位允许下一次受击。Flame 的 Effects 系统把这类一次性/循环动画封装为组件EffectComponent直接add到目标组件上即可自动播放无需手动管理生命周期。若未自动导入请补充import package:flame/effects.dart; import ../objects/star.dart; import water_enemy.dart;运行后可以自由移动、碰到星星它会消失碰到水怪 Ember 会闪烁——这就构成了拾取与受击两个最基础的游戏循环。四、卷轴滚动限制 Ember 出屏并让世界动起来4.1 屏幕边界与半屏限制目前 Ember 可以无限向右移动直到出屏而地图本身不会滚动。解决的思路是限制 Ember 的可移动范围把多余的移动量转嫁为世界的滚动。在update末尾追加gameRef.objectSpeed 0; // Prevent ember from going backwards at screen edge. if (position.x - 36 0 horizontalDirection 0) { velocity.x 0; } // Prevent ember from going beyond half screen. if (position.x 64 gameRef.size.x / 2 horizontalDirection 0) { velocity.x 0; gameRef.objectSpeed -moveSpeed; } position velocity * dt; super.update(dt);两个边界的物理含义左边界position.x - 36 0——Ember 的组件锚点在中心64×64左侧边缘中心减 36贴近屏幕左缘时禁止继续向左velocity.x 0Ember 无法出屏半屏边界position.x 64 gameRef.size.x / 2——Ember 中心到达屏幕中线偏右时同样钳制自身速度但同时把gameRef.objectSpeed设为-moveSpeed即每秒 200 像素的负向速度让整个世界反向移动。这里每帧先gameRef.objectSpeed 0再按需赋值保证不移动时世界静止。4.2 objectSpeed 如何驱动地图滚动objectSpeed是早在系列早期步骤就在EmberQuestGame中定义的世界级共享速度见 ember_quest.dart。所有场景对象——GroundBlockground_block.dart、PlatformBlockplatform_block.dart、Starstar.dart、WaterEnemywater_enemy.dart——的update中都执行着同一句代码velocity.x gameRef.objectSpeed; position velocity * dt;也就是说它们不关心自己该不该动只忠实地跟随gameRef.objectSpeed平移。Ember 越过半屏后objectSpeed变为负值所有地面、平台、星星、敌人整体向左移动视觉上就是Ember 在跑、世界在动。4.3 滚出屏幕的对象与关卡续接GroundBlock的update中还包含两个关键处理ground_block.dart出屏回收当position.x -size.x完全滚出左边界时调用removeFromParent()销毁自身避免无界累积续接生成当被移除的块是每段网格的第 0 列时调用gameRef.loadGameSegments(Random().nextInt(segments.length), gameRef.lastBlockXPosition)随机加载一个新关卡片段拼接到当前世界最右侧——这正是系列第三步随机片段生成机制与滚动系统的衔接点。lastBlockXPosition由每段第 9 列的块实时上报保证新片段无缝衔接。4.4 扩展思路如何改造成传统关卡制教程在末尾给出了一个延伸设计既然世界由随机片段segment拼接而成完全可以在 step_3.md 构建的片段体系中加入门或特殊方块片段每加载固定数量的普通片段后插入一个特殊片段Ember 到达该对象时重新加载关卡同时保留已收集的星星与生命值即可从无尽跑酷平滑改造成传统关卡制。这一思路直接复用现有的loadGameSegments与lastBlockXPosition接口不需要改动滚动核心。五、最终整合与运行验证将以上所有改动汇总后完整的EmberPlayer行为链为onKeyEvent每帧刷新horizontalDirection与hasJumpedupdate依次执行横向速度换算 → 边界钳制 → 重力累加 → 跳跃判定 → 终端速度钳制 → 位移 → 面向翻转onCollision按碰撞对象分流地面/平台做法线解算与落地判定星星被移除敌人触发受击闪烁世界对象地面、平台、星星、敌人统一消费gameRef.objectSpeed完成卷轴滚动。与教程代码对照最终工程 ember.dart 中还包含了本步骤未展开的后续内容health扣减、starsCollected累计、掉入坑中置零生命值等这些将在第六步《Adding the HUD》中通过 HUD 呈现给玩家。本步骤完成后运行游戏你将看到Ember 出生后下坠落地可用A/D或方向键左右移动并自动面向行进方向按空格跳跃越过半屏后世界开始滚动、新关卡片段持续生成撞到星星会拾取、撞到水怪会闪烁受击。延伸阅读碰撞检测体系的完整官方文档doc/flame/collision_detection.md含CollisionType主动/被动/无效三种类型、ScreenHitbox、宽相位与 QuadTree 等进阶内容本教程全部步骤platformer.md步骤一至七系列前序关卡片段与随机世界构建见 step_3.md、剩余组件见 step_4.md键盘处理 mixin 源码keyboard_handler.dart【免费下载链接】flameA Flutter based game engine.项目地址: https://gitcode.com/GitHub_Trending/fl/flame创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考