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 迁移指南,它专门覆盖
BodyComponent、Forge2DWorld与接触回调在桥接层上的变化,本文与它互为补充。
注意:粒子系统(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,而是持有Shape。Shape由不可变的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.fixtures→body.shapes;fixture.testPoint→shape.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),renderEdge→renderSegment,并新增renderCapsule;renderChain被移除,链条段统一走renderSegment渲染(详见 flame_forge2d 迁移指南)。
形状构造对照表
| 迁移前 | 迁移后 |
|---|---|
CircleShape()..radius = r | Circle(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 一旦越过一条边就会被困在内部。
接触监听器变成轮询事件
ContactListener与world.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:包含begin、end、hit三个列表。begin 事件携带接触法线与接触点,取代了旧的Manifold。world.sensorEvents:包含begin与end的传感器重叠事件,每个事件含sensor与visitor两个 shape。传感器与来访者双方都需要ShapeDef.enableSensorEvents。world.bodyMoveEvents:报告本步发生移动的 body。- end 事件可能引用已被销毁的 shape,使用前务必检查
Shape.isValid。
其他回调的替代:
preSolve→ 世界级回调world.preSolveCallback,返回布尔值决定本步是否求解该接触,要求相关 shape 设置ShapeDef.enablePreSolveEvents。postSolve与ContactImpulse不存在了:要测碰撞冲击强度,开启ShapeDef.enableHitEvents并读取world.contactEvents.hit,事件携带point、normal、approachSpeed。- 自定义碰撞对过滤(原来通过子类化 contact filter)→
world.customFilterCallback。 - 旧
Contact类整体消失,其方法无直接替代:contact.isTouching()不再需要(begin 事件本身就表示开始接触);contact.getWorldManifold(...)由 begin 事件上的normal与points取代。
在 Flame 桥接层,ContactCallbacksmixin 的形状保持不变,beginContact/endContact大体可继续使用;但Contact变成了 flame_forge2d 自己的小类,携带shapeA、shapeB、bodyA、bodyB、isSensorEvent(begin 事件还有normal、points),且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()实现里,当bodyDef或ShapeDef的userData是ContactCallbacks时,会自动为通过shapeSpecs创建的 shape 打开enableContactEvents与enableSensorEvents两个开关。但如果你重写了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携带shape、point、normal、fraction。 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,且都支持可选的QueryFilter;castRay的回调返回值语义为:-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 = 2、joint.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”的隐患。
句柄模型与显式销毁
World、Body、Shape、Chain及各类关节都是廉价的、值类似的句柄,指向原生引擎中的 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 属性重命名
| 迁移前 | 迁移后 |
|---|---|
worldCenter | worldCenterOfMass |
getLocalCenter() | localCenterOfMass |
setAwake(value) | isAwake = value |
getInertia() | rotationalInertia |
bodyType | type |
resetMassData() | applyMassFromShapes() |
setMassData(data) | massData = data |
worldVector(v) | rotation.rotate(v) |
localVector(v) | rotation.inverseRotate(v) |
BodyDef 重命名
allowSleep→enableSleep;bullet→isBullet;active→isEnabled。
(快速移动的弹体记得设isBullet = true,避免隧穿问题。)
每 body 重力
gravityScale从Vector2改为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 总结
- 升级依赖:
forge2d: ^0.15.1(Flame 侧flame_forge2d: ^0.20.0),确认 Dart ≥ 3.12 / Flutter ≥ 3.44,并按平台准备 C 工具链。 - 启动阶段(或测试 setUp)
await initializeForge2D();重写onLoad时先await super.onLoad()。 - 把所有
FixtureDef+createFixture改写为ShapeGeometry+ShapeDef+createShape;摩擦/弹性放入SurfaceMaterial,注意旧默认摩擦 0 需显式SurfaceMaterial(friction: 0)。 - 按形状对照表迁移 Circle/Segment/Polygon/Chain;检查链条点序(单向、至少 4 点、ghost 锚点语义),必要时换
Polygon。 - 把
ContactListener改为轮询world.contactEvents/sensorEvents/bodyMoveEvents,并为 shape 显式设置enableContactEvents/enableSensorEvents;重写createBody()的 Flame 用户需自行补开关。 - 把
raycast/queryAABB回调类改写为castRayClosest/castRay/castRayAll/overlapAabb(射线为 origin + translation,AABB→Aabb)。 - 关节改用类型化工厂方法 +
joint.destroy(),锚点用body.localPoint换算;弹簧参数frequencyHz→hertz,访问器改 getter/setter。 - 应用 World/Body 重命名表;旋转改用
Rot;gravityScale改为 double 乘数,自定义重力改手动applyForce。 - 关注世界尺度:不要再用“小于 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),仅供参考