news 2026/9/15 15:48:04

Forge2D 0.14 到 0.15 迁移完全指南:从 Dart 移植版到 Box2D v3 原生绑定(Flame 项目)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Forge2D 0.14 到 0.15 迁移完全指南:从 Dart 移植版到 Box2D v3 原生绑定(Flame 项目)

Forge2D 0.14 到 0.15 迁移完全指南:从 Dart 移植版到 Box2D v3 原生绑定(Flame 项目)

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

导读

Forge2D 0.15 是一次从底层推倒重来的大版本升级:它不再是 Box2D 2.x 的纯 Dart 移植,而是改为 Box2D v3 的 Dart 绑定,在移动端与桌面端以原生代码运行、在 Web 端以 WebAssembly 运行,因此整个公开 API 全部改变。本文以 Flame 仓库中的 Forge2D 迁移指南 为核心骨架,结合packages/flame_forge2d的源码实现,系统梳理 Fixture→Shape、接触监听→轮询事件、查询回调→返回值、关节创建方式、World/Body 属性重命名等全部破坏性变更,并给出可直接复制的迁移前后对照代码。阅读本文后,你将掌握从 forge2d 0.14 / flame_forge2d 0.19 平滑升级到 forge2d 0.15 / flame_forge2d 0.20 的完整步骤,以及踩坑点(默认摩擦系数变化、单向链条、世界尺度问题)的规避方法。

若你通过 Flame 使用 Forge2D,请同时阅读 flame_forge2d 迁移指南,它专门覆盖BodyComponentForge2DWorld与接触回调在桥接层上的变化,本文与它互为补充。

注意:粒子系统(LiquidFun)不属于 Box2D v3,已在 0.15 中被移除。如果你的游戏依赖粒子物理,请继续停留在 forge2d 0.14 / flame_forge2d 0.19。

初始化是强制要求:先initializeForge2D()再创建 World

0.14 中创建World无需任何前置调用;0.15 中必须在第一个World创建之前完成await initializeForge2D()

await initializeForge2D(); final world = World(gravity: Vector2(0, -10));

在原生平台上该方法立即返回;在 Web 上它负责加载并实例化 Box2D 的 WebAssembly 模块,未完成初始化就创建 world 会抛出StateError。因此无论目标平台是什么,都应在启动阶段统一await一次。

从源码看,Forge2DGame已经替你处理了这一步:forge2d_game.dart 的onLoad先执行await initializeForge2D(lengthUnitsPerMeter: lengthUnitsPerMeter)await super.onLoad(),而Forge2DWorld的物理世界是懒创建的(forge2d_world.dart 在首次使用时才new forge2d.World(...)),正是为了让初始化异步完成。所以基于Forge2DGame的游戏无需手动调用;但如果你自行创建Forge2DWorld或裸的World(包括测试代码),必须先await initializeForge2D(),否则 Web 端会抛异常。

另一个易踩的坑:Forge2DGame子类若重写onLoad必须在创建任何 body 之前await super.onLoad(),否则 Web 端直接崩溃。

平台要求

  • Dart SDK 下限:3.12(对应 Flutter 3.44)。可在 flame_forge2d/pubspec.yaml 中确认:sdk: ">=3.12.0 <4.0.0"flutter: ">=3.44.0",且依赖forge2d: ^0.15.1
  • 原生平台:随包分发的 Box2D 源码通过 Dart build hooks 编译,因此需要 C 工具链——iOS/macOS 用 Xcode,Android 用 NDK,Windows 用 Visual Studio Build Tools,Linux 用 clang 或 gcc。
  • Web 平台:随包分发的 WebAssembly 模块在常见托管场景下会被自动找到(Dart Web 工具链的服务路径、Flutter Web 自动打包的 asset、页面旁的box2d.wasm),除await initializeForge2D()外无需额外配置;若模块托管在别处,可用initializeForge2D(wasmUri: ...)指定。

Fixture 消失,Body 直接承载 Shape

0.15 中Body不再持有Fixture,而是持有ShapeShape由不可变的ShapeGeometry与可选的ShapeDef创建,摩擦与弹性恢复系数(restitution)移入ShapeDef.material(即SurfaceMaterial

// 迁移前 final shape = CircleShape()..radius = 5; body.createFixture(FixtureDef(shape, restitution: 0.8, friction: 0.4, density: 2)); // 迁移后 body.createShape( Circle(radius: 5), ShapeDef( material: SurfaceMaterial(restitution: 0.8, friction: 0.4), density: 2, ), );

警告:默认摩擦系数变了

  • FixtureDef的默认摩擦系数是0(无摩擦);
  • SurfaceMaterial的默认摩擦系数是0.6

Box2D 按sqrt(frictionA * frictionB)混合接触双方的摩擦系数,所以原来依赖旧默认值(一侧为 0)而表现为无摩擦的接触对,现在不再无摩擦。凡是你依赖旧默认行为的地方,请显式传入SurfaceMaterial(friction: 0)。flame_forge2d 的迁移文档(migration.md)也专门用警告强调了这一点。

属性与读取方式

  • body.fixturesbody.shapes
  • fixture.testPointshape.testPoint(参数仍为世界坐标);
  • 渲染或检查形状时用shape.geometry读回几何数据,返回 sealed 类型ShapeGeometry
switch (shape.geometry) { case Circle(:final center, :final radius): case Capsule(:final center1, :final center2, :final radius): case Segment(:final point1, :final point2): case Polygon(:final points, :final radius): }

在桥接层,BodyComponent的渲染钩子同样从“Fixture 时代”改为“Shape 时代”:renderFixture(Canvas, Fixture)renderShape(Canvas, Shape)renderEdgerenderSegment,并新增renderCapsulerenderChain被移除,链条段统一走renderSegment渲染(详见 flame_forge2d 迁移指南)。

形状构造对照表

迁移前迁移后
CircleShape()..radius = rCircle(radius: r, center: c)
EdgeShape()..set(a, b)Segment(point1: a, point2: b)
PolygonShape()..set(vertices)Polygon(vertices)
PolygonShape()..setAsBoxXY(w, h)Polygon.box(w, h)
ChainShape()..createChain(points)body.createChain(ChainDef(points: points))
ChainShape()..createLoop(points)body.createChain(ChainDef(points: points, isLoop: true))
  • Capsule是新增形状,0.14 没有对应物。
  • 链条的规则变化最隐蔽:链条现在至少需要 4 个点,并且是单向的——实体面在绕行方向的右侧。因此:环路按逆时针绕行,开放地面链条从右向左列出
  • 对于开放链条,首尾两个点是用于平滑碰撞的 ghost 锚点,不参与可碰撞线段,所以一条 4 点开放链条只产生 1 条线段。
  • 链条的线段可通过chain.segments获取。

⚠️ 链条从双向变为单向是“编译能过、运行才炸”的典型:body 会直接穿地而过。由于 Flame 的 y 轴向下,屏幕上的绕行顺序与 Box2D 官方文档相反——地面链条应从左到右列出,环路在屏幕上顺时针绕行。若 body 穿过链条,把点序反转即可。此外,对于需要从所有方向阻挡的实体关卡几何(如斜坡、可被从下方抵达的平台),应改用Polygon:链条环是空心的,body 一旦越过一条边就会被困在内部。

接触监听器变成轮询事件

ContactListenerworld.setContactListener已被移除。现在每步(step)之后从 world 上轮询该步发生的事件,且每个 shape 必须显式选择(opt in)要生成的事件

// 迁移前 class MyListener extends ContactListener { @override void beginContact(Contact contact) { ... } } world.setContactListener(MyListener()); // 迁移后 body.createShape(Circle(radius: 1), ShapeDef(enableContactEvents: true)); world.step(1 / 60); for (final event in world.contactEvents.begin) { // event.shapeA, event.shapeB, event.normal, event.points }

事件流全貌:

  • world.contactEvents:包含beginendhit三个列表。begin 事件携带接触法线与接触点,取代了旧的Manifold
  • world.sensorEvents:包含beginend的传感器重叠事件,每个事件含sensorvisitor两个 shape。传感器与来访者双方都需要ShapeDef.enableSensorEvents
  • world.bodyMoveEvents:报告本步发生移动的 body。
  • end 事件可能引用已被销毁的 shape,使用前务必检查Shape.isValid

其他回调的替代:

  • preSolve→ 世界级回调world.preSolveCallback,返回布尔值决定本步是否求解该接触,要求相关 shape 设置ShapeDef.enablePreSolveEvents
  • postSolveContactImpulse不存在了:要测碰撞冲击强度,开启ShapeDef.enableHitEvents并读取world.contactEvents.hit,事件携带pointnormalapproachSpeed
  • 自定义碰撞对过滤(原来通过子类化 contact filter)→world.customFilterCallback
  • Contact类整体消失,其方法无直接替代:contact.isTouching()不再需要(begin 事件本身就表示开始接触);contact.getWorldManifold(...)由 begin 事件上的normalpoints取代。

在 Flame 桥接层,ContactCallbacksmixin 的形状保持不变,beginContact/endContact大体可继续使用;但Contact变成了 flame_forge2d 自己的小类,携带shapeAshapeBbodyAbodyBisSensorEvent(begin 事件还有normalpoints),且contact.fixtureA/fixtureB改为contact.shapeA/shapeB。对应源码可参考 contact.dart:end 事件的构造器注释明确写道“shapes may already have been destroyed, checkShape.isValid”,并提供contact.isValid便捷判断;preSolve/postSolve已从ContactCallbacks移除。另外注意:一个 body 在接触期间被销毁,不再产生对应的endContact,因为路由事件所需的 userData 随 body 一起被清除了。

事件 opt-in 的自动处理

body_component.dart 的默认createBody()实现里,当bodyDefShapeDefuserDataContactCallbacks时,会自动为通过shapeSpecs创建的 shape 打开enableContactEventsenableSensorEvents两个开关。但如果你重写了createBody(),就必须自己设置这些标志,否则接触回调永远不触发。

查询改为直接返回结果

射线与 AABB 查询的 callback 类被World上直接返回结果的方法取代。注意射线现在用“起点 + 平移量”表示,而不是两个点

// 迁移前 class MyCallback extends RayCastCallback { @override double reportFixture( Fixture fixture, Vector2 point, Vector2 normal, double fraction, ) { ... } } world.raycast(MyCallback(), start, end); // 迁移后 final hit = world.castRayClosest(start, end - start); final allHits = world.castRayAll(start, end - start); world.castRay(start, end - start, (hit) => 1);
  • 每个RayHit携带shapepointnormalfraction
  • world.queryAABB(callback, aabb)world.overlapAabb(aabb),返回重叠的 shape 列表。
  • 包围盒类由AABB更名为Aabb
  • world.clearForces()已移除——Box2D v3 中作用力按步结算。
  • 爆炸效果通过world.explode(ExplosionDef(...))提供。

Forge2DWorld中,这些方法以转发形式暴露(forge2d_world.dart):castRayClosest/castRay/castRayAll(均为 origin + translation 语义)与overlapAabb,且都支持可选的QueryFiltercastRay的回调返回值语义为:-1忽略该命中、0停止、命中的fraction将射线裁剪到该命中、1继续且不裁剪。

关节:类型化工厂方法 + 本地锚点

关节通过 world 上的类型化方法创建,并在关节自身上销毁。def 上的initialize辅助方法已移除;锚点以本地点形式给出,可用body.localPoint(worldAnchor)换算:

// 迁移前 final jointDef = RevoluteJointDef()..initialize(bodyA, bodyB, anchor); final joint = RevoluteJoint(jointDef); world.createJoint(joint); world.destroyJoint(joint); // 迁移后 final joint = world.createRevoluteJoint( RevoluteJointDef( bodyA: bodyA, bodyB: bodyB, localAnchorA: bodyA.localPoint(anchor), localAnchorB: bodyB.localPoint(anchor), ), ); joint.destroy();
  • 可用关节:distance、filter、motor、mouse、prismatic、revolute、weld、wheel共 8 种。
  • Box2D v3 中不存在:gear、pulley、rope、friction、constant-volume关节。
  • FilterJoint(仅用于禁用两个 body 之间的碰撞)与WheelJoint是新增的
  • 弹簧参数改名为hertz(原frequencyHz),且弹簧一般需显式enableSpring开启。
  • 关节访问器改为 getter/setter(joint.motorSpeed = 2joint.angle),limit setter 采用命名参数:joint.setLimits(lower: 0, upper: pi)
  • 世界空间锚点joint.anchorA/joint.anchorB已不存在,只剩本地锚点。需要世界位置时自行计算,例如渲染关节:
final anchorA = joint.bodyA.worldPoint(joint.localAnchorA); final anchorB = joint.bodyB.worldPoint(joint.localAnchorB);

在 flame_forge2d 中,旧Forge2DWorld上的createJoint/destroyJoint辅助方法被移除,统一改用world.physicsWorld.createRevoluteJoint(def)+joint.destroy()

World 与 Body 的变化清单

步进与迭代

  • world.stepDt(dt)world.step(dt, subStepCount: 4)。原来的速度迭代数与位置迭代数合并为单个subStepCount默认 4。对应到 Flame 侧,Forge2DWorld.subStepCount(forge2d_world.dart)默认也是 4,update(dt)内先physicsWorld.step(dt, subStepCount: subStepCount)再派发接触事件(contactEventsDispatcher.dispatch(...))。

body 列表

  • world.bodies不存在了。自己跟踪创建的 body,或用world.bodyMoveEvents。Flame 侧Forge2DWorld提供了自己的bodies集合(forge2d_world.dart),只跟踪经由world.createBody创建的 body,且会自动剔除已失效的句柄——直接调用Body.destroy()销毁的 body 也会被清扫,避免“Box2D 复用已销毁 body 的槽位导致陈旧句柄读写到别的 body”的隐患。

句柄模型与显式销毁

WorldBodyShapeChain及各类关节都是廉价的、值类似的句柄,指向原生引擎中的 id。必须显式调用destroy()释放;当句柄可能指向已销毁对象时,用isValid校验。world.destroy()释放整个模拟。在 Flame 侧,Forge2DWorld不会自动销毁物理世界(以便 world 移除后可重新加回组件树),但你确定不再使用时必须自己调用world.physicsWorld.destroy();若无限不释放,Box2D 对同时存在的 world 数量有限制,创建时会抛StateError(forge2d_world.dart)。

旋转表示:Rot

旋转现在用Rot(余弦/正弦对)表示:

  • BodyDef(angle: a)BodyDef(rotation: Rot.fromAngle(a))
  • body.setTransform(position, rotation)接收Rot
  • body.angle仍然存在。

Body 属性重命名

迁移前迁移后
worldCenterworldCenterOfMass
getLocalCenter()localCenterOfMass
setAwake(value)isAwake = value
getInertia()rotationalInertia
bodyTypetype
resetMassData()applyMassFromShapes()
setMassData(data)massData = data
worldVector(v)rotation.rotate(v)
localVector(v)rotation.inverseRotate(v)

BodyDef 重命名

  • allowSleepenableSleep
  • bulletisBullet
  • activeisEnabled

(快速移动的弹体记得设isBullet = true,避免隧穿问题。)

每 body 重力

  • gravityScaleVector2改为double,且它是世界重力的乘数——在零重力世界中它不会产生任何效果。
  • gravityOverride是旧 Dart 移植版的扩展,Box2D v3 没有对应物。要给某个 body 独立的重力向量,可设gravityScale: 0(或保持世界重力为零),然后在每次 update 中自行施加作用力,例如在BodyComponent中:
@override void update(double dt) { super.update(dt); body.applyForce(customGravity * body.mass); }

由于作用力在每步之后都会被清除,这必须每次 update 都施加,而不是只做一次。若希望静止的 body 保持休眠,传入wake: false——这正是常规重力的行为方式。

userData 存于 Dart 侧

userData现在存在 Dart 侧(world 内部),而不是原生指针;当所属句柄被销毁时,userData 一并清除

迁移 checklist 总结

  1. 升级依赖:forge2d: ^0.15.1(Flame 侧flame_forge2d: ^0.20.0),确认 Dart ≥ 3.12 / Flutter ≥ 3.44,并按平台准备 C 工具链。
  2. 启动阶段(或测试 setUp)await initializeForge2D();重写onLoad时先await super.onLoad()
  3. 把所有FixtureDef+createFixture改写为ShapeGeometry+ShapeDef+createShape;摩擦/弹性放入SurfaceMaterial,注意旧默认摩擦 0 需显式SurfaceMaterial(friction: 0)
  4. 按形状对照表迁移 Circle/Segment/Polygon/Chain;检查链条点序(单向、至少 4 点、ghost 锚点语义),必要时换Polygon
  5. ContactListener改为轮询world.contactEvents/sensorEvents/bodyMoveEvents,并为 shape 显式设置enableContactEvents/enableSensorEvents;重写createBody()的 Flame 用户需自行补开关。
  6. raycast/queryAABB回调类改写为castRayClosest/castRay/castRayAll/overlapAabb(射线为 origin + translation,AABBAabb)。
  7. 关节改用类型化工厂方法 +joint.destroy(),锚点用body.localPoint换算;弹簧参数frequencyHzhertz,访问器改 getter/setter。
  8. 应用 World/Body 重命名表;旋转改用RotgravityScale改为 double 乘数,自定义重力改手动applyForce
  9. 关注世界尺度:不要再用“小于 1 米”的布局(详见下文),并记得显式destroy()原生句柄。

附:为什么世界尺度问题最值得警惕

flame_forge2d 的迁移文档将“世界尺度”列为最可能让一个能编译能运行的游戏坏掉的变化:Box2D v2 将每个 body 限制在每步 2 米(约 120 m/s),旧文档因此建议把世界布局得远小于 1 米;Box2D v3 改为WorldDef.maximumLinearSpeed(默认 400 m/s,可逐 world 设置),并引入 speculative contacts——两个形状相距Tolerances.speculativeDistance(0.02 米)内即报告接触。结果是:刻意做成亚米尺度的世界会出现“隔空报接触、永不反弹、body 还在动就被催眠”等怪象。

因此迁移时应优先把世界放大到移动 body 约为 0.1~10 米:长度与重力乘以同一系数 S(时间不受影响),再把metersToPixels除以 S 以保持屏幕尺寸不变(各物理量的缩放关系见 forge2d.md 的尺度章节)。若无法改布局,可向Forge2DGame构造函数传入lengthUnitsPerMeter(例如人物高 0.04 个单位就传super(lengthUnitsPerMeter: 0.04)),Box2D 的绝对长度容差会随之缩放;该设置是进程级、不可在物理世界创建后修改的,同一时刻运行的游戏必须一致,否则抛StateError。源码层面,Forge2DGame.lengthUnitsPerMeter正是被转发给initializeForge2D的(forge2d_game.dart)。调试模式下 flame_forge2d 在创建过小的移动 body 时会打印一次警告。

相关文档与源码入口

  • Forge2D 迁移指南(本文主文档)
  • flame_forge2d 迁移指南(桥接层)
  • Forge2D 入门与初始化
  • flame_forge2d 总览:Forge2DGame / Forge2DWorld / BodyComponent / 单位与尺度
  • flame_forge2d 关节文档
  • 源码:Forge2DGame、Forge2DWorld、BodyComponent 与 ShapeSpec、Contact 事件类型、Forge2DViewfinder 与 metersToPixels、依赖与 SDK 约束

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

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 15:47:25

CUDA HyperQ 并发内核执行深度解析:simpleHyperQ 示例实战指南

CUDA HyperQ 并发内核执行深度解析&#xff1a;simpleHyperQ 示例实战指南 【免费下载链接】cuda-samples Samples for CUDA Developers which demonstrates features in CUDA Toolkit 项目地址: https://gitcode.com/GitHub_Trending/cu/cuda-samples simpleHyperQ 是 …

作者头像 李华
网站建设 2026/9/15 15:47:19

HarmonyOS与Flutter结合实现应用内URL跳转方案

1. 项目概述今天要分享的是在HarmonyOS环境下使用Flutter实现应用内URL跳转的完整方案。作为一名同时接触过Flutter和HarmonyOS开发的工程师&#xff0c;我发现这两个平台的结合确实能碰撞出不少有意思的技术点。特别是在应用内跳转这个看似基础但实际藏着不少坑的功能上&#…

作者头像 李华
网站建设 2026/9/15 15:46:35

5位数字验证码识别:多标签分类与OneHot+CNN实战

简介&#xff1a;本资源是一套完整的5位数字验证码识别实战项目&#xff0c;面向计算机相关专业在校学生、教师及初级AI开发者&#xff0c;聚焦深度学习基础应用——利用One-Hot编码与CNN网络实现端到端验证码识别任务。项目包含可直接运行的Python源码、2000张真实风格验证码图…

作者头像 李华
网站建设 2026/9/15 15:45:07

gfast-ui v3.2 实战:Vue3+Vite+Pinia 后台开发与Nginx部署指南

简介&#xff1a;gfast-ui v3.2 是一套面向 Web 前端的 UI 框架源码压缩包&#xff0c;定位于希望快速搭建网站界面、学习前端工程化实践或完成毕业设计项目的开发人群。它经过多次版本迭代&#xff0c;既可作为建站模板直接套用&#xff0c;也能作为计算机教学案例与系统软件工…

作者头像 李华
网站建设 2026/9/15 15:42:13

基于SAM的遥感影像语义分割实战指南:从掩码到类别标签

有段时间我一直在跟遥感影像标注较劲。几百张高分影像等着打标签&#xff0c;每张图动辄上亿像素&#xff0c;房区、水体、耕地、道路一类的要素密密麻麻&#xff0c;标注团队的人换了一茬又一茬&#xff0c;进度还是慢得像蜗牛。后来我把Meta开源的SAM&#xff08;Segment Any…

作者头像 李华