From 3aabf7d5064e71d09d235210499e3c401acd2ef9 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 20 Jul 2026 00:42:23 +0200 Subject: [PATCH 01/21] feat!: Migrate flame_forge2d to the Box2D v3 based forge2d --- doc/bridge_packages/flame_forge2d/forge2d.md | 65 ++- doc/bridge_packages/flame_forge2d/joints.md | 529 ++++++------------ examples/games/padracing/lib/ball.dart | 21 +- examples/games/padracing/lib/car.dart | 24 +- examples/games/padracing/lib/lap_line.dart | 8 +- .../games/padracing/lib/padracing_game.dart | 2 +- examples/games/padracing/lib/tire.dart | 40 +- examples/games/padracing/lib/wall.dart | 11 +- examples/lib/main.dart | 10 - .../flame_forge2d/animated_body_example.dart | 12 +- .../flame_forge2d/blob_example.dart | 124 ---- .../flame_forge2d/domino_example.dart | 16 +- .../flame_forge2d/flame_forge2d.dart | 42 -- .../joints/constant_volume_joint.dart | 62 -- .../flame_forge2d/joints/distance_joint.dart | 22 +- .../flame_forge2d/joints/friction_joint.dart | 52 -- .../flame_forge2d/joints/gear_joint.dart | 125 ----- .../flame_forge2d/joints/motor_joint.dart | 32 +- .../flame_forge2d/joints/mouse_joint.dart | 25 +- .../flame_forge2d/joints/prismatic_joint.dart | 48 +- .../flame_forge2d/joints/pulley_joint.dart | 98 ---- .../flame_forge2d/joints/revolute_joint.dart | 23 +- .../flame_forge2d/joints/rope_joint.dart | 88 --- .../flame_forge2d/joints/weld_joint.dart | 11 +- .../flame_forge2d/raycast_example.dart | 88 +-- .../revolute_joint_with_motor_example.dart | 59 +- .../flame_forge2d/sprite_body_example.dart | 13 +- .../flame_forge2d/utils/balls.dart | 13 +- .../flame_forge2d/utils/boundaries.dart | 9 +- .../flame_forge2d/utils/boxes.dart | 39 +- .../flame_forge2d/widget_example.dart | 9 +- .../dispatchers/multi_tap_dispatcher.dart | 2 +- packages/flame_forge2d/analysis_options.yaml | 6 + packages/flame_forge2d/example/lib/main.dart | 17 +- .../flame_forge2d/lib/body_component.dart | 149 ++--- packages/flame_forge2d/lib/contact.dart | 106 ++++ .../flame_forge2d/lib/contact_callbacks.dart | 39 +- .../lib/contact_events_dispatcher.dart | 92 +++ packages/flame_forge2d/lib/flame_forge2d.dart | 3 +- packages/flame_forge2d/lib/forge2d_game.dart | 6 +- packages/flame_forge2d/lib/forge2d_world.dart | 131 ++++- .../lib/world_contact_listener.dart | 78 --- packages/flame_forge2d/pubspec.yaml | 8 +- .../test/body_component_test.dart | 295 +++++----- .../test/contact_callbacks_test.dart | 43 +- .../test/contact_events_dispatcher_test.dart | 251 +++++++++ .../test/forge2d_world_test.dart | 53 +- .../goldens/body_component/capsule_shape.png | Bin 0 -> 26372 bytes .../goldens/body_component/chain_shape.png | Bin 22052 -> 0 bytes .../body_component/chain_shape_closed.png | Bin 22067 -> 21099 bytes .../body_component/chain_shape_open.png | Bin 22029 -> 22015 bytes .../goldens/body_component/circle_shape.png | Bin 25944 -> 25872 bytes .../{edge_shape.png => segment_shape.png} | Bin .../flame_forge2d/test/helpers/mocks.dart | 10 - .../test/world_contact_listener_test.dart | 353 ------------ scripts/customer_testing.dart | 2 +- 56 files changed, 1349 insertions(+), 2015 deletions(-) delete mode 100644 examples/lib/stories/bridge_libraries/flame_forge2d/blob_example.dart delete mode 100644 examples/lib/stories/bridge_libraries/flame_forge2d/joints/constant_volume_joint.dart delete mode 100644 examples/lib/stories/bridge_libraries/flame_forge2d/joints/friction_joint.dart delete mode 100644 examples/lib/stories/bridge_libraries/flame_forge2d/joints/gear_joint.dart delete mode 100644 examples/lib/stories/bridge_libraries/flame_forge2d/joints/pulley_joint.dart delete mode 100644 examples/lib/stories/bridge_libraries/flame_forge2d/joints/rope_joint.dart create mode 100644 packages/flame_forge2d/lib/contact.dart create mode 100644 packages/flame_forge2d/lib/contact_events_dispatcher.dart delete mode 100644 packages/flame_forge2d/lib/world_contact_listener.dart create mode 100644 packages/flame_forge2d/test/contact_events_dispatcher_test.dart create mode 100644 packages/flame_forge2d/test/goldens/body_component/capsule_shape.png delete mode 100644 packages/flame_forge2d/test/goldens/body_component/chain_shape.png rename packages/flame_forge2d/test/goldens/body_component/{edge_shape.png => segment_shape.png} (100%) delete mode 100644 packages/flame_forge2d/test/world_contact_listener_test.dart diff --git a/doc/bridge_packages/flame_forge2d/forge2d.md b/doc/bridge_packages/flame_forge2d/forge2d.md index c68c263b3b0..98ee07681d4 100644 --- a/doc/bridge_packages/flame_forge2d/forge2d.md +++ b/doc/bridge_packages/flame_forge2d/forge2d.md @@ -1,7 +1,7 @@ # Forge2D -Blue Fire maintains a ported version of the Box2D physics engine and our -version is called Forge2D. +Blue Fire maintains Forge2D, Dart bindings for the [Box2D](https://box2d.org/) physics engine +(native on mobile and desktop, WebAssembly on the web). If you want to use Forge2D specifically for Flame you should use our bridge library [flame_forge2d](https://github.com/flame-engine/flame/tree/main/packages/flame_forge2d) and if you @@ -14,6 +14,10 @@ To use it in your game you just need to add `flame_forge2d` to your and the pub. dev [installation instructions](https://pub.dev/packages/flame_forge2d)](). +Since Forge2D runs Box2D as native code, a C toolchain is required when building for native +platforms (Xcode on iOS/macOS, the NDK on Android, Visual Studio Build Tools on Windows and +clang or gcc on Linux). On the web a bundled WebAssembly build of Box2D is used automatically. + ## Forge2DGame @@ -59,19 +63,41 @@ to the `Forge2DGame` instance's `world` property, `game.world = Forge2DWorld()`. If you would like to re-use a world later and have it keep its physics state you have to make sure that the bodies aren't destroyed when the world is removed from the game. You can do this by -setting `world.destroyOnRemove` to false, like `game.world.destroyOnRemove = false;`. +setting `world.destroyBodiesOnRemove` to false, like `game.world.destroyBodiesOnRemove = false;`. + +The underlying Forge2D physics world is available as `world.physicsWorld`, which you can use to +access the parts of the Forge2D API that `Forge2DWorld` doesn't wrap, like creating joints or +polling the raw event streams. ## BodyComponent The `BodyComponent` is a wrapper for the `Forge2D` body, which is the body that the physics engine -is interacting with. To create a `BodyComponent` you can either: +is interacting with. A body carries one or more `Shape`s, which are created from a +`ShapeGeometry` (`Circle`, `Capsule`, `Segment` or `Polygon`, plus chains via +`body.createChain`) and an optional `ShapeDef` that holds the surface material (friction, +restitution), density, filter, and event flags. + +To create a `BodyComponent` you can either: - override `createBody()` and create and return your created body; - use the default `createBody()` implementation by passing a `BodyDef` instance (and optionally a -list of `FixtureDef` instances) to the BodyComponent's constructor; +list of `ShapeSpec` instances, which pair a `ShapeGeometry` with an optional `ShapeDef`) to the +BodyComponent's constructor; - use the default `createBody()` implementation and assign a `BodyDef` instance to `this.bodyDef`, -and optionally a list of `FixtureDef` instances to `this.fixtureDefs`. +and optionally a list of `ShapeSpec` instances to `this.shapeSpecs`. + +```dart +final ball = BodyComponent( + bodyDef: BodyDef(type: BodyType.dynamic), + shapeSpecs: [ + ShapeSpec( + Circle(radius: 5), + ShapeDef(material: SurfaceMaterial(restitution: 0.8)), + ), + ], +); +``` The `BodyComponent` is by default having `renderBody = true`, since otherwise, it wouldn't show anything after you have created a `Body` and added the `BodyComponent` to the game. If you want to @@ -114,9 +140,9 @@ to avoid some tunneling problems. `Forge2DGame` provides a simple out-of-the-box solution to propagate contact events. -Contact events occur whenever two `Fixture`s meet each other. These events allow listening when -these `Fixture`s begin to come in contact (`beginContact`) and cease being in contact -(`endContact`). +Contact events occur whenever two `Shape`s meet each other. These events allow listening when +these `Shape`s begin to come in contact (`beginContact`) and cease being in contact +(`endContact`). Sensor overlaps are delivered through the same callbacks. There are multiple ways to listen to these events. One common way is to use the `ContactCallbacks` class as a mixin in the `BodyComponent` where you are interested in these events. @@ -133,13 +159,18 @@ class Ball extends BodyComponent with ContactCallbacks { } ``` -For the above to work, the `Ball`'s `body.userData` or contacting `fixture.userData` must be +For the above to work, the `Ball`'s `body.userData` or contacting `shape.userData` must be set to a `ContactCallback`. And if `Wall` is a `BodyComponent` it's `body.userData` or contacting -`fixture.userData` must be set to `Wall`. +`shape.userData` must be set to `Wall`. If `userData` is `null` the contact events are ignored, it is `null` by default. -A convenient way of setting `userData` is to assign it when creating the body. For example: +Forge2D only generates events for shapes that have opted in to them, so the involved shapes also +need `ShapeDef.enableContactEvents` set to true (and `ShapeDef.enableSensorEvents` for sensors +and their visitors). The default `createBody()` implementation of `BodyComponent` enables these +flags automatically for shapes created through `shapeSpecs` when a `ContactCallbacks` is present +in the body's or shape's userData, but if you override `createBody()` you need to set them +yourself: ```dart class Ball extends BodyComponent with ContactCallbacks { @@ -151,6 +182,9 @@ class Ball extends BodyComponent with ContactCallbacks { final bodyDef = BodyDef( userData: this, ); + final shapeDef = ShapeDef( + enableContactEvents: true, + ); ... } @@ -158,7 +192,12 @@ class Ball extends BodyComponent with ContactCallbacks { ``` Every time `Ball` and `Wall` begin to come in contact `beginContact` will be called, and once the -fixtures cease being in contact, `endContact` will be called. +shapes cease being in contact, `endContact` will be called. + +The old `preSolve` and `postSolve` callbacks no longer exist. To disable a contact before it is +solved (for example for one-sided platforms), use `world.preSolveCallback` together with +`ShapeDef.enablePreSolveEvents`. To measure impact strength, enable `ShapeDef.enableHitEvents` +and poll `world.physicsWorld.contactEvents.hit`. An implementation example can be seen in the [Flame Forge2D example](https://github.com/flame-engine/flame/blob/main/examples/lib/stories/bridge_libraries/flame_forge2d/utils/balls.dart). diff --git a/doc/bridge_packages/flame_forge2d/joints.md b/doc/bridge_packages/flame_forge2d/joints.md index a2a94b90f22..7d54bf03da0 100644 --- a/doc/bridge_packages/flame_forge2d/joints.md +++ b/doc/bridge_packages/flame_forge2d/joints.md @@ -6,82 +6,48 @@ They help to simulate interactions between objects to create hinges, wheels, rop One `Body` in a joint may be of type `BodyType.static`. Joints between `BodyType.static` and/or `BodyType.kinematic` are allowed, but have no effect and use some processing time. -To construct a `Joint`, you need to create a corresponding subclass of `JointDef`and initialize it -with its parameters. - -To register a `Joint` use `world.createJoint`and later use `world.destroyJoint` when you want to -remove it. +To construct a `Joint`, you create the corresponding subclass of `JointDef` with its parameters, +and pass it to the typed creator method on the physics world, for example +`world.physicsWorld.createRevoluteJoint(revoluteJointDef)`. The creator returns the typed joint, +and when you want to remove a joint you call `joint.destroy()`. ## Built-in joints Currently, Forge2D supports the following joints: -- [`ConstantVolumeJoint`](#constantvolumejoint) - [`DistanceJoint`](#distancejoint) -- [`FrictionJoint`](#frictionjoint) -- [`GearJoint`](#gearjoint) +- [`FilterJoint`](#filterjoint) - [`MotorJoint`](#motorjoint) - [`MouseJoint`](#mousejoint) - [`PrismaticJoint`](#prismaticjoint) -- [`PulleyJoint`](#pulleyjoint) - [`RevoluteJoint`](#revolutejoint) -- [`RopeJoint`](#ropejoint) - [`WeldJoint`](#weldjoint) -- WheelJoint - - -### `ConstantVolumeJoint` - -This type of joint connects a group of bodies together and maintains a constant volume within them. -Essentially, it is a set of [`DistanceJoint`](#distancejoint)s, that connects all bodies one after -another. +- [`WheelJoint`](#wheeljoint) -It can for example be useful when simulating "soft-bodies". - -```dart - final constantVolumeJoint = ConstantVolumeJointDef() - ..frequencyHz = 10 - ..dampingRatio = 0.8; - - bodies.forEach((body) { - constantVolumeJoint.addBody(body); - }); - - world.createJoint(ConstantVolumeJoint(world, constantVolumeJoint)); -``` - -```{flutter-app} -:sources: ../../examples -:subfolder: stories/bridge_libraries/flame_forge2d/joints -:page: constant_volume_joint -:show: code popup -``` - -`ConstantVolumeJointDef` requires at least 3 bodies to be added using the `addBody` method. It also -has two optional parameters: - -- `frequencyHz`: This parameter sets the frequency of oscillation of the joint. If it is not set to -0, the higher the value, the less springy each of the compound `DistantJoint`s are. - -- `dampingRatio`: This parameter defines how quickly the oscillation comes to rest. It ranges from -0 to 1, where 0 means no damping and 1 indicates critical damping. +The gear, pulley, rope, friction, and constant-volume joints from older Forge2D versions do not +exist in Box2D v3, and therefore no longer exist in Forge2D. ### `DistanceJoint` -A `DistanceJoint` constrains two points on two bodies to remain at a fixed distance from each other. +A `DistanceJoint` constrains two points on two bodies to remain at a fixed distance from each +other. -You can view this as a massless, rigid rod. +You can view this as a massless, rigid rod, and by enabling its spring it can also act as a +spring/damper. ```dart -final distanceJointDef = DistanceJointDef() - ..initialize(firstBody, secondBody, firstBody.worldCenter, secondBody.worldCenter) - ..length = 10 - ..frequencyHz = 3 - ..dampingRatio = 0.2; - -world.createJoint(DistanceJoint(distanceJointDef)); +world.physicsWorld.createDistanceJoint( + DistanceJointDef( + bodyA: firstBody, + bodyB: secondBody, + length: 10, + enableSpring: true, + hertz: 3, + dampingRatio: 0.2, + ), +); ``` ```{flutter-app} @@ -91,121 +57,36 @@ world.createJoint(DistanceJoint(distanceJointDef)); :show: code popup ``` -To create a `DistanceJointDef`, you can use the `initialize` method, which requires two bodies and a -world anchor point on each body. The definition uses local anchor points, allowing for a slight -violation of the constraint in the initial configuration. This is useful when saving and -loading a game. +The most commonly used `DistanceJointDef` parameters: -The `DistanceJointDef` has three optional parameters that you can set: +- `localAnchorA`, `localAnchorB`: The anchor points relative to each body's origin. -- `length`: This parameter determines the distance between the two anchor points and must be greater -than 0. The default value is 1. +- `length`: This parameter determines the distance between the two anchor points and must be +greater than 0. The default value is 1. -- `frequencyHz`: This parameter sets the frequency of oscillation of the joint. If it is not set -to 0, the higher the value, the less springy the joint becomes. +- `enableSpring`, `hertz`, `dampingRatio`: When the spring is enabled the rod becomes soft; the +higher the `hertz` value the stiffer the spring, and the `dampingRatio` defines how quickly the +oscillation comes to rest, where 0 means no damping and 1 indicates critical damping. -- `dampingRatio`: This parameter defines how quickly the oscillation comes to rest. It ranges from -0 to 1, where 0 means no damping and 1 indicates critical damping. +- `enableLimit`, `minLength`, `maxLength`: Restricts the distance between the bodies to a range +when the spring is enabled. + +- `enableMotor`, `motorSpeed`, `maxMotorForce`: Drives the distance between the bodies. ```{warning} Do not use a zero or short length. ``` -### `FrictionJoint` - -A `FrictionJoint` is used for simulating friction in a top-down game. It provides 2D translational -friction and angular friction. - -The `FrictionJoint` isn't related to the friction that occurs when two shapes collide in the x-y plane -of the screen. Instead, it's designed to simulate friction along the z-axis, which is perpendicular -to the screen. The most common use-case for it is applying the friction force between a moving body -and the game floor. - -The `initialize` method of the `FrictionJointDef` method requires two bodies that will have friction -force applied to them, and an anchor. - -The third parameter is the `anchor` point in the world coordinates where the friction force will be -applied. In most cases, it would be the center of the first object. However, for more complex -physics interactions between bodies, you can set the `anchor` point to a specific location on one or -both of the bodies. - -```dart -final frictionJointDef = FrictionJointDef() - ..initialize(ballBody, floorBody, ballBody.worldCenter) - ..maxForce = 50 - ..maxTorque = 50; - - world.createJoint(FrictionJoint(frictionJointDef)); -``` - -```{flutter-app} -:sources: ../../examples -:page: friction_joint -:subfolder: stories/bridge_libraries/flame_forge2d/joints -:show: code popup -``` - -When creating a `FrictionJoint`, simulated friction can be applied via maximum force and torque -values: - -- `maxForce`: the maximum translational friction which applied to the joined body. A higher value -- simulates higher friction. - -- `maxTorque`: the maximum angular friction which may be applied to the joined body. A higher value -- simulates higher friction. +### `FilterJoint` -In other words, the former simulates the friction, when the body is sliding and the latter simulates -the friction when the body is spinning. - - -### `GearJoint` - -The `GearJoint` is used to connect two joints together. Joints are required to be a -[`RevoluteJoint`](#revolutejoint) or a [`PrismaticJoint`](#prismaticjoint) in any combination. - -```{warning} -The connected joints must attach a dynamic body to a static body. -The static body is expected to be a bodyA on those joints -``` +A `FilterJoint` doesn't constrain the bodies at all; its only purpose is to disable all collision +between the two connected bodies. ```dart -final gearJointDef = GearJointDef() - ..bodyA = firstJoint.bodyA - ..bodyB = secondJoint.bodyA - ..joint1 = firstJoint - ..joint2 = secondJoint - ..ratio = 1; - -world.createJoint(GearJoint(gearJointDef)); -``` - -```{flutter-app} -:sources: ../../examples -:page: gear_joint -:subfolder: stories/bridge_libraries/flame_forge2d/joints -:show: code popup -``` - -- `joint1`, `joint2`: Connected revolute or prismatic joints -- `bodyA`, `bodyB`: Any bodies form the connected joints, as long as they are not the same body. -- `ratio`: Gear ratio - -Similarly to [`PulleyJoint`](#pulleyjoint), you can specify a gear ratio to bind the motions -together: - -```text -coordinate1 + ratio * coordinate2 == constant -``` - -The ratio can be negative or positive. If one joint is a `RevoluteJoint` and the other joint is a -`PrismaticJoint`, then the ratio will have units of length or units of 1/length. - -Since the `GearJoint` depends on two other joints, if these are destroyed, the `GearJoint` needs to -be destroyed as well. - -```{warning} -Manually destroy the `GearJoint` if joint1 or joint2 is destroyed +world.physicsWorld.createFilterJoint( + FilterJointDef(bodyA: firstBody, bodyB: secondBody), +); ``` @@ -221,13 +102,15 @@ position and rotation. If the body is blocked, it will stop and the contact forc proportional the maximum motor force and torque. ```dart -final motorJointDef = MotorJointDef() - ..initialize(first, second) - ..maxTorque = 1000 - ..maxForce = 1000 - ..correctionFactor = 0.1; - - world.createJoint(MotorJoint(motorJointDef)); +final motorJoint = world.physicsWorld.createMotorJoint( + MotorJointDef( + bodyA: first, + bodyB: second, + maxForce: 1000, + maxTorque: 1000, + correctionFactor: 0.1, + ), +); ``` ```{flutter-app} @@ -249,12 +132,10 @@ target rotation. deviation from target position. A higher value makes the joint respond faster, while a lower value makes it respond slower. If the value is set too high, the joint may overcompensate and oscillate, becoming unstable. If set too low, it may respond too slowly. - + The linear and angular offsets are the target distance and angle that the bodies should achieve -relative to each other's position and rotation. By default, the linear target will be the distance -between the two body centers and the angular target will be the relative rotation of the bodies. -Use the `setLinearOffset(Vector2)` and `setLinearOffset(double)` methods of the `MotorJoint` to set -the desired relative translation and rotate between the bodies. +relative to each other's position and rotation. Use the `linearOffset` and `angularOffset` setters +of the `MotorJoint` to set the desired relative translation and rotation between the bodies. For example, this code increments the angular offset of the joint every update cycle, causing the body to rotate. @@ -263,9 +144,8 @@ body to rotate. @override void update(double dt) { super.update(dt); - - final angularOffset = joint.getAngularOffset() + motorSpeed * dt; - joint.setAngularOffset(angularOffset); + + joint.angularOffset = joint.angularOffset + motorSpeed * dt; } ``` @@ -275,10 +155,10 @@ void update(double dt) { The `MouseJoint` is used to manipulate bodies with the mouse. It attempts to drive a point on a body towards the current position of the cursor. There is no restriction on rotation. -The `MouseJoint` definition has a target point, maximum force, frequency, and damping ratio. The +The `MouseJoint` definition has a target point, maximum force, hertz, and damping ratio. The target point initially coincides with the body's anchor point. The maximum force is used to prevent violent reactions when multiple dynamic bodies interact. You can make this as large as you like. -The frequency and damping ratio are used to create a spring/damper effect similar to the distance +The hertz and damping ratio are used to create a spring/damper effect similar to the distance joint. ```{warning} @@ -289,18 +169,16 @@ kinematic bodies instead. ``` ```dart -final mouseJointDef = MouseJointDef() - ..maxForce = 3000 * ballBody.mass * 10 - ..dampingRatio = 1 - ..frequencyHz = 5 - ..target.setFrom(ballBody.position) - ..collideConnected = false - ..bodyA = groundBody - ..bodyB = ballBody; - - mouseJoint = MouseJoint(mouseJointDef); - world.createJoint(mouseJoint); -} +final mouseJoint = world.physicsWorld.createMouseJoint( + MouseJointDef( + bodyA: groundBody, + bodyB: ballBody, + target: ballBody.position, + maxForce: 3000 * ballBody.mass * 10, + dampingRatio: 1, + hertz: 5, + ), +); ``` ```{flutter-app} @@ -317,11 +195,12 @@ final mouseJointDef = MouseJointDef() - `dampingRatio`: This parameter defines how quickly the oscillation comes to rest. It ranges from 0 to 1, where 0 means no damping and 1 indicates critical damping. -- `frequencyHz`: This parameter defines the response speed of the body, i.e. how quickly it tries to +- `hertz`: This parameter defines the response speed of the body, i.e. how quickly it tries to reach the target position - `target`: The initial world target point. This is assumed to coincide with the body anchor - initially. + initially. While dragging you update it through the `target` setter, + `mouseJoint.target = newPosition;`. ### `PrismaticJoint` @@ -329,12 +208,11 @@ final mouseJointDef = MouseJointDef() The `PrismaticJoint` provides a single degree of freedom, allowing for a relative translation of two bodies along an axis fixed in bodyA. Relative rotation is prevented. -`PrismaticJointDef` requires defining a line of motion using an axis and an anchor point. +`PrismaticJointDef` requires defining a line of motion using a local axis and anchor points. The definition uses local anchor points and a local axis so that the initial configuration can violate the constraint slightly. The joint translation is zero when the local anchor points coincide in world space. -Using local anchors and a local axis helps when saving and loading a game. ```{warning} At least one body should by dynamic with a non-fixed rotation. @@ -344,13 +222,13 @@ The `PrismaticJoint` definition is similar to the [`RevoluteJoint`](#revolutejoi instead of rotation, it uses translation. ```dart -final prismaticJointDef = PrismaticJointDef() - ..initialize( - dynamicBody, - groundBody, - dynamicBody.worldCenter, - Vector2(1, 0), - ) +final prismaticJoint = world.physicsWorld.createPrismaticJoint( + PrismaticJointDef( + bodyA: dynamicBody, + bodyB: groundBody, + localAxisA: Vector2(1, 0), + ), +); ``` ```{flutter-app} @@ -360,23 +238,23 @@ final prismaticJointDef = PrismaticJointDef() :show: code popup ``` -- `b1`, `b2`: Bodies connected by the joint. -- `anchor`: World anchor point, to put the axis through. Usually the center of the first body. -- `axis`: World translation axis, along which the translation will be fixed. - -In some cases you might wish to control the range of motion. For this, the `PrismaticJointDef` has -optional parameters that allow you to simulate a joint limit and/or a motor. +- `bodyA`, `bodyB`: Bodies connected by the joint. +- `localAnchorA`, `localAnchorB`: The anchor points relative to each body's origin. +- `localAxisA`: The translation axis in bodyA's frame, along which the translation will be fixed. #### Prismatic Joint Limit -You can limit the relative rotation with a joint limit that specifies a lower and upper translation. +You can limit the relative translation with a joint limit that specifies a lower and upper +translation. ```dart -jointDef - ..enableLimit = true - ..lowerTranslation = -20 - ..upperTranslation = 20; +PrismaticJointDef( + ... + enableLimit: true, + lowerTranslation: -20, + upperTranslation: 20, +); ``` - `enableLimit`: Set to true to enable translation limits @@ -386,7 +264,7 @@ jointDef You change the limits after the joint was created with this method: ```dart -prismaticJoint.setLimits(-10, 10); +prismaticJoint.setLimits(lower: -10, upper: 10); ``` @@ -396,94 +274,30 @@ You can use a motor to drive the motion or to model joint friction. A maximum mo provided so that infinite forces are not generated. ```dart -jointDef - ..enableMotor = true - ..motorSpeed = 1 - ..maxMotorForce = 100; +PrismaticJointDef( + ... + enableMotor: true, + motorSpeed: 1, + maxMotorForce: 100, +); ``` - `enableMotor`: Set to true to enable the motor -- `motorSpeed`: The desired motor speed in radians per second -- `maxMotorForce`: The maximum motor torque used to achieve the desired motor speed in N-m. +- `motorSpeed`: The desired motor speed in meters per second +- `maxMotorForce`: The maximum motor force used to achieve the desired motor speed in N. -You change the motor's speed and force after the joint was created using these methods: +You change the motor's speed and force after the joint was created using these setters: ```dart -prismaticJoint.setMotorSpeed(2); -prismaticJoint.setMaxMotorForce(200); -``` - -Also, you can get the joint angle and speed using the following methods: - -```dart -prismaticJoint.getJointTranslation(); -prismaticJoint.getJointSpeed(); -``` - - -### `PulleyJoint` - -A `PulleyJoint` is used to create an idealized pulley. The pulley connects two bodies to the ground -and to each other. As one body goes up, the other goes down. The total length of the pulley rope is -conserved according to the initial configuration: - -```text -length1 + length2 == constant -``` - -You can supply a ratio that simulates a block and tackle. This causes one side of the pulley to -extend faster than the other. At the same time the constraint force is smaller on one side than the -other. You can use this to create a mechanical leverage. - -```text -length1 + ratio * length2 == constant -``` - -For example, if the ratio is 2, then `length1` will vary at twice the rate of `length2`. Also the -force in the rope attached to the first body will have half the constraint force as the rope -attached to the second body. - -```dart -final pulleyJointDef = PulleyJointDef() - ..initialize( - firstBody, - secondBody, - firstPulley.worldCenter, - secondPulley.worldCenter, - firstBody.worldCenter, - secondBody.worldCenter, - 1, - ); - -world.createJoint(PulleyJoint(pulleyJointDef)); -``` - -```{flutter-app} -:sources: ../../examples -:page: pulley_joint -:subfolder: stories/bridge_libraries/flame_forge2d/joints -:show: code popup +prismaticJoint.motorSpeed = 2; +prismaticJoint.maxMotorForce = 200; ``` -The `initialize` method of `PulleyJointDef` requires two ground anchors, two dynamic bodies and -their anchor points, and a pulley ratio. - -- `b1`, `b2`: Two dynamic bodies connected with the joint -- `ga1`, `ga2`: Two ground anchors -- `anchor1`, `anchor2`: Anchors on the dynamic bodies the joint will be attached to -- `r`: Pulley ratio to simulate a block and tackle - -`PulleyJoint` also provides the current lengths: +Also, you can get the joint translation and speed using the following getters: ```dart -joint.getCurrentLengthA() -joint.getCurrentLengthB() -``` - -```{warning} -`PulleyJoint` can get a bit troublesome by itself. They often work better when -combined with prismatic joints. You should also cover the anchor points -with static shapes to prevent one side from going to zero length. +prismaticJoint.translation; +prismaticJoint.speed; ``` @@ -492,14 +306,19 @@ with static shapes to prevent one side from going to zero length. A `RevoluteJoint` forces two bodies to share a common anchor point, often called a hinge point. The revolute joint has a single degree of freedom: the relative rotation of the two bodies. -To create a `RevoluteJoint`, provide two bodies and a common point to the `initialize` method. -The definition uses local anchor points so that the initial configuration can violate the -constraint slightly. +To create a `RevoluteJoint`, provide two bodies and the local anchor points that coincide at the +hinge point. The definition uses local anchor points so that the initial configuration can violate +the constraint slightly. ```dart -final jointDef = RevoluteJointDef() - ..initialize(firstBody, secondBody, firstBody.position); -world.createJoint(RevoluteJoint(jointDef)); +final revoluteJoint = world.physicsWorld.createRevoluteJoint( + RevoluteJointDef( + bodyA: firstBody, + bodyB: secondBody, + localAnchorA: firstBody.localPoint(anchor), + localAnchorB: secondBody.localPoint(anchor), + ), +); ``` ```{flutter-app} @@ -518,10 +337,12 @@ optional parameters that allow you to simulate a joint limit and/or a motor. You can limit the relative rotation with a joint limit that specifies a lower and upper angle. ```dart -jointDef - ..enableLimit = true - ..lowerAngle = 0 - ..upperAngle = pi / 2; +RevoluteJointDef( + ... + enableLimit: true, + lowerAngle: 0, + upperAngle: pi / 2, +); ``` - `enableLimit`: Set to true to enable angle limits @@ -531,7 +352,7 @@ jointDef You change the limits after the joint was created with this method: ```dart -revoluteJoint.setLimits(0, pi); +revoluteJoint.setLimits(lower: 0, upper: pi); ``` @@ -541,63 +362,30 @@ You can use a motor to drive the relative rotation about the shared point. A max provided so that infinite forces are not generated. ```dart -jointDef - ..enableMotor = true - ..motorSpeed = 5 - ..maxMotorTorque = 100; +RevoluteJointDef( + ... + enableMotor: true, + motorSpeed: 5, + maxMotorTorque: 100, +); ``` - `enableMotor`: Set to true to enable the motor - `motorSpeed`: The desired motor speed in radians per second - `maxMotorTorque`: The maximum motor torque used to achieve the desired motor speed in N-m. -You change the motor's speed and torque after the joint was created using these methods: +You change the motor's speed and torque after the joint was created using these setters: ```dart -revoluteJoint.setMotorSpeed(2); -revoluteJoint.setMaxMotorTorque(200); +revoluteJoint.motorSpeed = 2; +revoluteJoint.maxMotorTorque = 200; ``` -Also, you can get the joint angle and speed using the following methods: +Also, you can get the joint angle and speed using the following getters: ```dart -revoluteJoint.jointAngle(); -revoluteJoint.jointSpeed(); -``` - - -### `RopeJoint` - -A `RopeJoint` restricts the maximum distance between two points on two bodies. - -`RopeJointDef` requires two body anchor points and the maximum length. - -```dart -final ropeJointDef = RopeJointDef() - ..bodyA = firstBody - ..localAnchorA.setFrom(firstBody.getLocalCenter()) - ..bodyB = secondBody - ..localAnchorB.setFrom(secondBody.getLocalCenter()) - ..maxLength = (secondBody.worldCenter - firstBody.worldCenter).length; - -world.createJoint(RopeJoint(ropeJointDef)); -``` - -```{flutter-app} -:sources: ../../examples -:page: rope_joint -:subfolder: stories/bridge_libraries/flame_forge2d/joints -:show: code popup -``` - -- `bodyA`, `bodyB`: Connected bodies -- `localAnchorA`, `localAnchorB`: Optional parameter, anchor point relative to the body's origin. -- `maxLength`: The maximum length of the rope. This must be larger than `linearSlop`, or the joint -will have no effect. - -```{warning} -The joint assumes that the maximum length doesn't change during simulation. -See `DistanceJoint` if you want to dynamically control length. +revoluteJoint.angle; +revoluteJoint.motorSpeed; ``` @@ -606,13 +394,18 @@ See `DistanceJoint` if you want to dynamically control length. A `WeldJoint` is used to restrict all relative motion between two bodies, effectively joining them together. -`WeldJointDef` requires two bodies that will be connected, and a world anchor: +`WeldJointDef` requires two bodies that will be connected, and the local anchor points that +coincide at the weld point: ```dart -final weldJointDef = WeldJointDef() - ..initialize(bodyA, bodyB, anchor); - -world.createJoint(WeldJoint(weldJointDef)); +world.physicsWorld.createWeldJoint( + WeldJointDef( + bodyA: firstBody, + bodyB: secondBody, + localAnchorA: firstBody.localPoint(anchor), + localAnchorB: secondBody.localPoint(anchor), + ), +); ``` ```{flutter-app} @@ -624,13 +417,43 @@ world.createJoint(WeldJoint(weldJointDef)); - `bodyA`, `bodyB`: Two bodies that will be connected -- `anchor`: Anchor point in world coordinates, at which two bodies will be welded together - to 0, the higher the value, the less springy the joint becomes. +- `localAnchorA`, `localAnchorB`: Anchor points relative to each body's origin, at which the two + bodies will be welded together + +The weld can also be made springy with the `linearHertz`, `angularHertz`, `linearDampingRatio` +and `angularDampingRatio` parameters. #### Breakable Bodies and WeldJoint Since the Forge2D constraint solver is iterative, joints are somewhat flexible. This means that the bodies connected by a WeldJoint may bend slightly. If you want to simulate a breakable body, it's -better to create a single body with multiple fixtures. When the body breaks, you can destroy a -fixture and recreate it on a new body instead of relying on a `WeldJoint`. +better to create a single body with multiple shapes. When the body breaks, you can destroy a +shape and recreate it on a new body instead of relying on a `WeldJoint`. + + +### `WheelJoint` + +A `WheelJoint` provides two degrees of freedom: translation along a spring-loaded axis fixed in +bodyA, and rotation of bodyB. It is designed for vehicle suspensions. + +```dart +world.physicsWorld.createWheelJoint( + WheelJointDef( + bodyA: chassis, + bodyB: wheel, + localAnchorA: chassis.localPoint(wheel.position), + localAxisA: Vector2(0, 1), + hertz: 4, + dampingRatio: 0.7, + enableMotor: true, + maxMotorTorque: 30, + motorSpeed: -25, + ), +); +``` + +- `localAxisA`: The suspension axis in bodyA's frame. +- `enableSpring`, `hertz`, `dampingRatio`: The suspension spring configuration. +- `enableLimit`, `lowerTranslation`, `upperTranslation`: Limits the suspension travel. +- `enableMotor`, `motorSpeed`, `maxMotorTorque`: Drives the wheel's rotation. diff --git a/examples/games/padracing/lib/ball.dart b/examples/games/padracing/lib/ball.dart index 90f4c1d42d0..50ea9c1a680 100644 --- a/examples/games/padracing/lib/ball.dart +++ b/examples/games/padracing/lib/ball.dart @@ -3,7 +3,7 @@ import 'dart:ui'; import 'package:flame/extensions.dart'; import 'package:flame/palette.dart'; -import 'package:flame_forge2d/flame_forge2d.dart' hide Particle, World; +import 'package:flame_forge2d/flame_forge2d.dart' hide World; import 'package:padracing/car.dart'; import 'package:padracing/game_colors.dart'; import 'package:padracing/padracing_game.dart'; @@ -45,17 +45,18 @@ class Ball extends BodyComponent with ContactCallbacks { @override Body createBody() { - final def = BodyDef() - ..userData = this - ..type = isMovable ? BodyType.dynamic : BodyType.kinematic - ..position = initialPosition; + final def = BodyDef( + userData: this, + type: isMovable ? BodyType.dynamic : BodyType.kinematic, + position: initialPosition, + ); final body = world.createBody(def)..angularVelocity = rotation; - final shape = CircleShape()..radius = radius; - final fixtureDef = FixtureDef(shape) - ..restitution = 0.5 - ..friction = 0.5; - return body..createFixture(fixtureDef); + final shapeDef = ShapeDef( + material: SurfaceMaterial(restitution: 0.5, friction: 0.5), + enableContactEvents: true, + ); + return body..createShape(Circle(radius: radius), shapeDef); } @override diff --git a/examples/games/padracing/lib/car.dart b/examples/games/padracing/lib/car.dart index 25d300d775c..1e4f23a02b7 100644 --- a/examples/games/padracing/lib/car.dart +++ b/examples/games/padracing/lib/car.dart @@ -2,7 +2,7 @@ import 'dart:ui'; import 'package:flame/components.dart'; import 'package:flame/extensions.dart'; -import 'package:flame_forge2d/flame_forge2d.dart' hide Particle, World; +import 'package:flame_forge2d/flame_forge2d.dart' hide World; import 'package:flutter/material.dart' hide Image, Gradient; import 'package:padracing/game_colors.dart'; import 'package:padracing/lap_line.dart'; @@ -84,18 +84,15 @@ class Car extends BodyComponent { ..userData = this ..angularDamping = 3.0; - final shape = PolygonShape()..set(vertices); - final fixtureDef = FixtureDef(shape) - ..density = 0.2 - ..restitution = 2.0; - body.createFixture(fixtureDef); - - final jointDef = RevoluteJointDef() - ..bodyA = body - ..enableLimit = true - ..lowerAngle = 0.0 - ..upperAngle = 0.0 - ..localAnchorB.setZero(); + body.createShape( + Polygon(vertices), + ShapeDef( + density: 0.2, + material: SurfaceMaterial(restitution: 2.0), + // So that the lap line sensors can detect the car. + enableSensorEvents: true, + ), + ); tires = List.generate(4, (i) { final isFrontTire = i <= 1; @@ -105,7 +102,6 @@ class Car extends BodyComponent { pressedKeys: game.pressedKeySets[playerNumber], isFrontTire: isFrontTire, isLeftTire: isLeftTire, - jointDef: jointDef, isTurnableTire: isFrontTire, ); }); diff --git a/examples/games/padracing/lib/lap_line.dart b/examples/games/padracing/lib/lap_line.dart index 033b8f89779..dfb8d57047a 100644 --- a/examples/games/padracing/lib/lap_line.dart +++ b/examples/games/padracing/lib/lap_line.dart @@ -3,7 +3,7 @@ import 'dart:ui'; import 'package:flame/extensions.dart'; import 'package:flame/palette.dart'; -import 'package:flame_forge2d/flame_forge2d.dart' hide Particle, World; +import 'package:flame_forge2d/flame_forge2d.dart' hide World; import 'package:flutter/material.dart' hide Image, Gradient; import 'package:padracing/car.dart'; @@ -49,9 +49,9 @@ class LapLine extends BodyComponent with ContactCallbacks { userData: this, ), ); - final shape = PolygonShape()..setAsBoxXY(size.x / 2, size.y / 2); - final fixtureDef = FixtureDef(shape, isSensor: true); - return groundBody..createFixture(fixtureDef); + final shapeDef = ShapeDef(isSensor: true, enableSensorEvents: true); + return groundBody + ..createShape(Polygon.box(size.x / 2, size.y / 2), shapeDef); } late final Rect _scaledRect = (size * 10).toRect(); diff --git a/examples/games/padracing/lib/padracing_game.dart b/examples/games/padracing/lib/padracing_game.dart index 0f1d0cf58ce..a43723e8e97 100644 --- a/examples/games/padracing/lib/padracing_game.dart +++ b/examples/games/padracing/lib/padracing_game.dart @@ -6,7 +6,7 @@ import 'package:flame/components.dart'; import 'package:flame/effects.dart'; import 'package:flame/extensions.dart'; import 'package:flame/input.dart'; -import 'package:flame_forge2d/flame_forge2d.dart' hide Particle, World; +import 'package:flame_forge2d/flame_forge2d.dart' hide World; import 'package:flutter/material.dart' hide Image, Gradient; import 'package:flutter/services.dart'; import 'package:padracing/ball.dart'; diff --git a/examples/games/padracing/lib/tire.dart b/examples/games/padracing/lib/tire.dart index 11f0726cf95..7a168dec1c5 100644 --- a/examples/games/padracing/lib/tire.dart +++ b/examples/games/padracing/lib/tire.dart @@ -1,5 +1,5 @@ import 'package:flame/palette.dart'; -import 'package:flame_forge2d/flame_forge2d.dart' hide Particle, World; +import 'package:flame_forge2d/flame_forge2d.dart' hide World; import 'package:flutter/material.dart' hide Image, Gradient; import 'package:flutter/services.dart'; import 'package:padracing/car.dart'; @@ -12,7 +12,6 @@ class Tire extends BodyComponent { required this.pressedKeys, required this.isFrontTire, required this.isLeftTire, - required this.jointDef, this.isTurnableTire = false, }) : super( paint: Paint() @@ -52,7 +51,6 @@ class Tire extends BodyComponent { final double _maxForwardSpeed = 250.0; final double _maxBackwardSpeed = -40.0; - final RevoluteJointDef jointDef; late final RevoluteJoint joint; final bool isTurnableTire; final bool isFrontTire; @@ -76,18 +74,22 @@ class Tire extends BodyComponent { isFrontTire ? 3.5 : -4.25, ); - final def = BodyDef() - ..type = BodyType.dynamic - ..position = car.body.position + jointAnchor; + final def = BodyDef( + type: BodyType.dynamic, + position: car.body.position + jointAnchor, + ); final body = world.createBody(def)..userData = this; - final polygonShape = PolygonShape()..setAsBoxXY(0.5, 1.25); - body.createFixtureFromShape(polygonShape).userData = this; + body.createShape(Polygon.box(0.5, 1.25)).userData = this; - jointDef.bodyB = body; - jointDef.localAnchorA.setFrom(jointAnchor); - world.createJoint(joint = RevoluteJoint(jointDef)); - joint.setLimits(0, 0); + joint = world.physicsWorld.createRevoluteJoint( + RevoluteJointDef( + bodyA: car.body, + bodyB: body, + localAnchorA: jointAnchor, + enableLimit: true, + ), + ); return body; } @@ -115,7 +117,7 @@ class Tire extends BodyComponent { ..scale(_currentTraction); body.applyLinearImpulse(impulse); body.applyAngularImpulse( - 0.1 * _currentTraction * body.getInertia() * -body.angularVelocity, + 0.1 * _currentTraction * body.rotationalInertia * -body.angularVelocity, ); final currentForwardNormal = _forwardVelocity; @@ -136,7 +138,7 @@ class Tire extends BodyComponent { desiredSpeed += _maxBackwardSpeed; } - final currentForwardNormal = body.worldVector(Vector2(0.0, 1.0)); + final currentForwardNormal = body.rotation.rotate(Vector2(0.0, 1.0)); final currentSpeed = _forwardVelocity.dot(currentForwardNormal); var force = 0.0; if (desiredSpeed < currentSpeed) { @@ -166,15 +168,15 @@ class Tire extends BodyComponent { } if (isTurnableTire && isTurning) { final turnPerTimeStep = _turnSpeedPerSecond * dt; - final angleNow = joint.jointAngle(); + final angleNow = joint.angle; final angleToTurn = (desiredAngle - angleNow).clamp( -turnPerTimeStep, turnPerTimeStep, ); final angle = angleNow + angleToTurn; - joint.setLimits(angle, angle); + joint.setLimits(lower: angle, upper: angle); } else { - joint.setLimits(0, 0); + joint.setLimits(lower: 0, upper: 0); } body.applyTorque(desiredTorque); } @@ -184,13 +186,13 @@ class Tire extends BodyComponent { final Vector2 _worldUp = Vector2(0.0, -1.0); Vector2 get _lateralVelocity { - final currentRightNormal = body.worldVector(_worldLeft); + final currentRightNormal = body.rotation.rotate(_worldLeft); return currentRightNormal ..scale(currentRightNormal.dot(body.linearVelocity)); } Vector2 get _forwardVelocity { - final currentForwardNormal = body.worldVector(_worldUp); + final currentForwardNormal = body.rotation.rotate(_worldUp); return currentForwardNormal ..scale(currentForwardNormal.dot(body.linearVelocity)); } diff --git a/examples/games/padracing/lib/wall.dart b/examples/games/padracing/lib/wall.dart index 454d220ec8c..0c1dc863447 100644 --- a/examples/games/padracing/lib/wall.dart +++ b/examples/games/padracing/lib/wall.dart @@ -3,7 +3,7 @@ import 'dart:ui'; import 'package:flame/extensions.dart'; import 'package:flame/palette.dart'; -import 'package:flame_forge2d/flame_forge2d.dart' hide Particle, World; +import 'package:flame_forge2d/flame_forge2d.dart' hide World; import 'package:padracing/padracing_game.dart'; @@ -92,16 +92,13 @@ class Wall extends BodyComponent { @override Body createBody() { - final def = BodyDef() - ..type = BodyType.static - ..position = _position; + final def = BodyDef(position: _position); final body = world.createBody(def) ..userData = this ..angularDamping = 3.0; - final shape = PolygonShape()..setAsBoxXY(size.x / 2, size.y / 2); - final fixtureDef = FixtureDef(shape)..restitution = 0.5; - return body..createFixture(fixtureDef); + final shapeDef = ShapeDef(material: SurfaceMaterial(restitution: 0.5)); + return body..createShape(Polygon.box(size.x / 2, size.y / 2), shapeDef); } late Rect asRect = Rect.fromCenter( diff --git a/examples/lib/main.dart b/examples/lib/main.dart index c614f0c75d4..4cb28c5692a 100644 --- a/examples/lib/main.dart +++ b/examples/lib/main.dart @@ -4,16 +4,11 @@ import 'package:examples/platform/stub_provider.dart' import 'package:examples/stories/animations/animations.dart'; import 'package:examples/stories/bridge_libraries/audio/audio.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart'; -import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/constant_volume_joint.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/distance_joint.dart'; -import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/friction_joint.dart'; -import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/gear_joint.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/motor_joint.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/mouse_joint.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/prismatic_joint.dart'; -import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/pulley_joint.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/revolute_joint.dart'; -import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/rope_joint.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/weld_joint.dart'; import 'package:examples/stories/bridge_libraries/flame_isolate/isolate.dart'; import 'package:examples/stories/bridge_libraries/flame_jenny/jenny.dart'; @@ -46,16 +41,11 @@ void main() { final page = PageProviderImpl().getPage(); final routes = { - 'constant_volume_joint': ConstantVolumeJointExample.new, 'distance_joint': DistanceJointExample.new, - 'friction_joint': FrictionJointExample.new, - 'gear_joint': GearJointExample.new, 'motor_joint': MotorJointExample.new, 'mouse_joint': MouseJointExample.new, - 'pulley_joint': PulleyJointExample.new, 'prismatic_joint': PrismaticJointExample.new, 'revolute_joint': RevoluteJointExample.new, - 'rope_joint': RopeJointExample.new, 'weld_joint': WeldJointExample.new, }; final game = routes[page]?.call(); diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/animated_body_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/animated_body_example.dart index 07d310eb7f2..7e80b26d1f1 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/animated_body_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/animated_body_example.dart @@ -72,21 +72,19 @@ class ChopperBody extends BodyComponent { @override Body createBody() { - final shape = CircleShape()..radius = size.x / 4; - final fixtureDef = FixtureDef( - shape, + final shapeDef = ShapeDef( userData: this, // To be able to determine object in collision - restitution: 0.8, - friction: 0.2, + material: SurfaceMaterial(restitution: 0.8, friction: 0.2), ); final velocity = (Vector2.random() - Vector2.random()) * 200; final bodyDef = BodyDef( position: _position, - angle: velocity.angleTo(Vector2(1, 0)), + rotation: Rot.fromAngle(velocity.angleTo(Vector2(1, 0))), linearVelocity: velocity, type: BodyType.dynamic, ); - return world.createBody(bodyDef)..createFixture(fixtureDef); + return world.createBody(bodyDef) + ..createShape(Circle(radius: size.x / 4), shapeDef); } } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/blob_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/blob_example.dart deleted file mode 100644 index 6e5f177b78b..00000000000 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/blob_example.dart +++ /dev/null @@ -1,124 +0,0 @@ -import 'dart:math' as math; - -import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart'; -import 'package:flame/components.dart'; -import 'package:flame/events.dart'; -import 'package:flame_forge2d/flame_forge2d.dart'; - -class BlobExample extends Forge2DGame { - static const String description = ''' - In this example we show the power of joints by showing interactions between - bodies tied together. - - Tap the screen to add boxes that will bounce on the "blob" in the center. - '''; - BlobExample() : super(world: BlobWorld()); -} - -class BlobWorld extends Forge2DWorld - with TapCallbacks, HasGameReference { - @override - Future onLoad() async { - await super.onLoad(); - final blobCenter = Vector2(0, -30); - final blobRadius = Vector2.all(6.0); - addAll(createBoundaries(game)); - add(Ground(Vector2.zero())); - final jointDef = ConstantVolumeJointDef() - ..frequencyHz = 20.0 - ..dampingRatio = 1.0 - ..collideConnected = false; - - await addAll([ - for (var i = 0; i < 20; i++) - BlobPart(i, jointDef, blobRadius, blobCenter), - ]); - createJoint(ConstantVolumeJoint(physicsWorld, jointDef)); - } - - @override - void onTapDown(TapDownEvent info) { - super.onTapDown(info); - add(FallingBox(info.localPosition)); - } -} - -class Ground extends BodyComponent { - final Vector2 worldCenter; - - Ground(this.worldCenter); - - @override - Body createBody() { - final shape = PolygonShape(); - shape.setAsBoxXY(20.0, 0.4); - final fixtureDef = FixtureDef(shape, friction: 0.2); - - final bodyDef = BodyDef(position: worldCenter.clone()); - final ground = world.createBody(bodyDef); - ground.createFixture(fixtureDef); - - shape.setAsBox(0.4, 20.0, Vector2(-10.0, 0.0), 0.0); - ground.createFixture(fixtureDef); - shape.setAsBox(0.4, 20.0, Vector2(10.0, 0.0), 0.0); - ground.createFixture(fixtureDef); - return ground; - } -} - -class BlobPart extends BodyComponent { - final ConstantVolumeJointDef jointDef; - final int bodyNumber; - final Vector2 blobRadius; - final Vector2 blobCenter; - - BlobPart( - this.bodyNumber, - this.jointDef, - this.blobRadius, - this.blobCenter, - ); - - @override - Body createBody() { - const nBodies = 20.0; - const bodyRadius = 0.5; - final angle = (bodyNumber / nBodies) * math.pi * 2; - final x = blobCenter.x + blobRadius.x * math.sin(angle); - final y = blobCenter.y + blobRadius.y * math.cos(angle); - - final bodyDef = BodyDef( - fixedRotation: true, - position: Vector2(x, y), - type: BodyType.dynamic, - ); - final body = world.createBody(bodyDef); - - final shape = CircleShape()..radius = bodyRadius; - final fixtureDef = FixtureDef( - shape, - friction: 0.2, - ); - body.createFixture(fixtureDef); - jointDef.addBody(body); - return body; - } -} - -class FallingBox extends BodyComponent { - final Vector2 _position; - - FallingBox(this._position); - - @override - Body createBody() { - final bodyDef = BodyDef( - type: BodyType.dynamic, - position: _position, - ); - final shape = PolygonShape()..setAsBoxXY(2, 4); - final body = world.createBody(bodyDef); - body.createFixtureFromShape(shape); - return body; - } -} diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart index 1ecd29b8529..246e7ec4d56 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart @@ -58,12 +58,8 @@ class Platform extends BodyComponent { @override Body createBody() { - final shape = PolygonShape()..setAsBoxXY(14.8, 0.125); - final fixtureDef = FixtureDef(shape); - final bodyDef = BodyDef(position: _position); - final body = world.createBody(bodyDef); - return body..createFixture(fixtureDef); + return world.createBody(bodyDef)..createShape(Polygon.box(14.8, 0.125)); } } @@ -74,15 +70,13 @@ class DominoBrick extends BodyComponent { @override Body createBody() { - final shape = PolygonShape()..setAsBoxXY(0.125, 2.0); - final fixtureDef = FixtureDef( - shape, + final shapeDef = ShapeDef( density: 25.0, - restitution: 0.4, - friction: 0.5, + material: SurfaceMaterial(restitution: 0.4, friction: 0.5), ); final bodyDef = BodyDef(type: BodyType.dynamic, position: _position); - return world.createBody(bodyDef)..createFixture(fixtureDef); + return world.createBody(bodyDef) + ..createShape(Polygon.box(0.125, 2.0), shapeDef); } } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart index 01d35342388..fa3dd997050 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart @@ -1,22 +1,16 @@ import 'package:dashbook/dashbook.dart'; import 'package:examples/commons/commons.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/animated_body_example.dart'; -import 'package:examples/stories/bridge_libraries/flame_forge2d/blob_example.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/camera_example.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/composition_example.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/contact_callbacks_example.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/domino_example.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/drag_callbacks_example.dart'; -import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/constant_volume_joint.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/distance_joint.dart'; -import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/friction_joint.dart'; -import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/gear_joint.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/motor_joint.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/mouse_joint.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/prismatic_joint.dart'; -import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/pulley_joint.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/revolute_joint.dart'; -import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/rope_joint.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/weld_joint.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/raycast_example.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/revolute_joint_with_motor_example.dart'; @@ -30,12 +24,6 @@ String link(String example) => void addForge2DStories(Dashbook dashbook) { dashbook.storiesOf('flame_forge2d') - ..add( - 'Blob example', - (DashbookContext ctx) => GameWidget(game: BlobExample()), - codeLink: link('blob_example.dart'), - info: BlobExample.description, - ) ..add( 'Composition example', (DashbookContext ctx) => GameWidget(game: CompositionExample()), @@ -109,30 +97,12 @@ void addForge2DStories(Dashbook dashbook) { void addJointsStories(Dashbook dashbook) { dashbook .storiesOf('flame_forge2d/joints') - .add( - 'ConstantVolumeJoint', - (DashbookContext ctx) => GameWidget(game: ConstantVolumeJointExample()), - codeLink: link('joints/constant_volume_joint.dart'), - info: ConstantVolumeJointExample.description, - ) .add( 'DistanceJoint', (DashbookContext ctx) => GameWidget(game: DistanceJointExample()), codeLink: link('joints/distance_joint.dart'), info: DistanceJointExample.description, ) - .add( - 'FrictionJoint', - (DashbookContext ctx) => GameWidget(game: FrictionJointExample()), - codeLink: link('joints/friction_joint.dart'), - info: FrictionJointExample.description, - ) - .add( - 'GearJoint', - (DashbookContext ctx) => GameWidget(game: GearJointExample()), - codeLink: link('joints/gear_joint.dart'), - info: GearJointExample.description, - ) .add( 'MotorJoint', (DashbookContext ctx) => GameWidget(game: MotorJointExample()), @@ -151,24 +121,12 @@ void addJointsStories(Dashbook dashbook) { codeLink: link('joints/prismatic_joint.dart'), info: PrismaticJointExample.description, ) - .add( - 'PulleyJoint', - (DashbookContext ctx) => GameWidget(game: PulleyJointExample()), - codeLink: link('joints/pulley_joint.dart'), - info: PulleyJointExample.description, - ) .add( 'RevoluteJoint', (DashbookContext ctx) => GameWidget(game: RevoluteJointExample()), codeLink: link('joints/revolute_joint.dart'), info: RevoluteJointExample.description, ) - .add( - 'RopeJoint', - (DashbookContext ctx) => GameWidget(game: RopeJointExample()), - codeLink: link('joints/rope_joint.dart'), - info: RopeJointExample.description, - ) .add( 'WeldJoint', (DashbookContext ctx) => GameWidget(game: WeldJointExample()), diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/constant_volume_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/constant_volume_joint.dart deleted file mode 100644 index 53f0d6e9039..00000000000 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/constant_volume_joint.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'dart:math'; - -import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/balls.dart'; -import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart'; -import 'package:flame/components.dart'; -import 'package:flame/events.dart'; -import 'package:flame_forge2d/flame_forge2d.dart'; - -class ConstantVolumeJointExample extends Forge2DGame { - static const description = ''' - This example shows how to use a `ConstantVolumeJoint`. Tap the screen to add - a bunch off balls, that maintain a constant volume within them. - '''; - - ConstantVolumeJointExample() : super(world: SpriteBodyWorld()); -} - -class SpriteBodyWorld extends Forge2DWorld - with TapCallbacks, HasGameReference { - @override - Future onLoad() async { - super.onLoad(); - addAll(createBoundaries(game)); - } - - @override - Future onTapDown(TapDownEvent info) async { - super.onTapDown(info); - final center = info.localPosition; - - const numPieces = 20; - const radius = 5.0; - final balls = []; - - for (var i = 0; i < numPieces; i++) { - final x = radius * cos(2 * pi * (i / numPieces)); - final y = radius * sin(2 * pi * (i / numPieces)); - - final ball = Ball(Vector2(x + center.x, y + center.y), radius: 0.5); - - add(ball); - balls.add(ball); - } - - await Future.wait(balls.map((e) => e.loaded)); - - final constantVolumeJoint = ConstantVolumeJointDef() - ..frequencyHz = 10 - ..dampingRatio = 0.8; - - balls.forEach((ball) { - constantVolumeJoint.addBody(ball.body); - }); - - createJoint( - ConstantVolumeJoint( - physicsWorld, - constantVolumeJoint, - ), - ); - } -} diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/distance_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/distance_joint.dart index 15af34ba50f..86b4f12f9d0 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/distance_joint.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/distance_joint.dart @@ -32,17 +32,15 @@ class DistanceJointWorld extends Forge2DWorld await Future.wait([first.loaded, second.loaded]); - final distanceJointDef = DistanceJointDef() - ..initialize( - first.body, - second.body, - first.body.worldCenter, - second.center, - ) - ..length = 10 - ..frequencyHz = 3 - ..dampingRatio = 0.2; - - createJoint(DistanceJoint(distanceJointDef)); + physicsWorld.createDistanceJoint( + DistanceJointDef( + bodyA: first.body, + bodyB: second.body, + length: 10, + enableSpring: true, + hertz: 3, + dampingRatio: 0.2, + ), + ); } } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/friction_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/friction_joint.dart deleted file mode 100644 index bc79b376b3c..00000000000 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/friction_joint.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/balls.dart'; -import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart'; -import 'package:flame/components.dart'; -import 'package:flame/events.dart'; -import 'package:flame_forge2d/flame_forge2d.dart'; - -class FrictionJointExample extends Forge2DGame { - static const description = ''' - This example shows how to use a `FrictionJoint`. Tap the screen to move the - ball around and observe it slows down due to the friction force. - '''; - - FrictionJointExample() - : super(gravity: Vector2.all(0), world: FrictionJointWorld()); -} - -class FrictionJointWorld extends Forge2DWorld - with TapCallbacks, HasGameReference { - late Wall border; - late Ball ball; - - @override - Future onLoad() async { - super.onLoad(); - final boundaries = createBoundaries(game); - border = boundaries.first; - addAll(boundaries); - - ball = Ball(Vector2.zero(), radius: 3); - add(ball); - - await Future.wait([ball.loaded, border.loaded]); - - createFrictionJoint(ball.body, border.body); - } - - @override - Future onTapDown(TapDownEvent info) async { - super.onTapDown(info); - ball.body.applyLinearImpulse(Vector2.random() * 5000); - } - - void createFrictionJoint(Body first, Body second) { - final frictionJointDef = FrictionJointDef() - ..initialize(first, second, first.worldCenter) - ..collideConnected = true - ..maxForce = 500 - ..maxTorque = 500; - - createJoint(FrictionJoint(frictionJointDef)); - } -} diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/gear_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/gear_joint.dart deleted file mode 100644 index 8b83110b30f..00000000000 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/gear_joint.dart +++ /dev/null @@ -1,125 +0,0 @@ -import 'dart:ui'; - -import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/balls.dart'; -import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boxes.dart'; -import 'package:flame/components.dart'; -import 'package:flame_forge2d/flame_forge2d.dart'; - -class GearJointExample extends Forge2DGame { - static const description = ''' - This example shows how to use a `GearJoint`. - - Drag the box along the specified axis and observe gears respond to the - translation. - '''; - - GearJointExample() : super(world: GearJointWorld()); -} - -class GearJointWorld extends Forge2DWorld with HasGameReference { - late PrismaticJoint prismaticJoint; - Vector2 boxAnchor = Vector2.zero(); - - double boxWidth = 2; - double ball1Radius = 4; - double ball2Radius = 2; - - @override - Future onLoad() async { - super.onLoad(); - - final box = DraggableBox( - startPosition: boxAnchor, - width: boxWidth, - height: 20, - ); - add(box); - - final ball1Anchor = boxAnchor - Vector2(boxWidth / 2 + ball1Radius, 0); - final ball1 = Ball(ball1Anchor, radius: ball1Radius); - add(ball1); - - final ball2Anchor = ball1Anchor - Vector2(ball1Radius + ball2Radius, 0); - final ball2 = Ball(ball2Anchor, radius: ball2Radius); - add(ball2); - - await Future.wait([box.loaded, ball1.loaded, ball2.loaded]); - - prismaticJoint = createPrismaticJoint(box.body, boxAnchor); - final revoluteJoint1 = createRevoluteJoint(ball1.body, ball1Anchor); - final revoluteJoint2 = createRevoluteJoint(ball2.body, ball2Anchor); - - createGearJoint(prismaticJoint, revoluteJoint1, 1); - createGearJoint(revoluteJoint1, revoluteJoint2, 0.5); - add(JointRenderer(joint: prismaticJoint, anchor: boxAnchor)); - } - - PrismaticJoint createPrismaticJoint(Body box, Vector2 anchor) { - final groundBody = createBody(BodyDef()); - - final prismaticJointDef = PrismaticJointDef() - ..initialize( - groundBody, - box, - anchor, - Vector2(0, 1), - ) - ..enableLimit = true - ..lowerTranslation = -10 - ..upperTranslation = 10; - - final joint = PrismaticJoint(prismaticJointDef); - createJoint(joint); - return joint; - } - - RevoluteJoint createRevoluteJoint(Body ball, Vector2 anchor) { - final groundBody = createBody(BodyDef()); - - final revoluteJointDef = RevoluteJointDef() - ..initialize( - groundBody, - ball, - anchor, - ); - - final joint = RevoluteJoint(revoluteJointDef); - createJoint(joint); - return joint; - } - - void createGearJoint(Joint first, Joint second, double gearRatio) { - final gearJointDef = GearJointDef() - ..bodyA = first.bodyA - ..bodyB = second.bodyA - ..joint1 = first - ..joint2 = second - ..ratio = gearRatio; - - final joint = GearJoint(gearJointDef); - createJoint(joint); - } -} - -class JointRenderer extends Component { - JointRenderer({required this.joint, required this.anchor}); - - final PrismaticJoint joint; - final Vector2 anchor; - final Vector2 p1 = Vector2.zero(); - final Vector2 p2 = Vector2.zero(); - - @override - void render(Canvas canvas) { - p1 - ..setFrom(joint.getLocalAxisA()) - ..scale(joint.getLowerLimit()) - ..add(anchor); - p2 - ..setFrom(joint.getLocalAxisA()) - ..scale(joint.getUpperLimit()) - ..add(anchor); - - canvas.drawLine(p1.toOffset(), p2.toOffset(), debugPaint); - } -} diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/motor_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/motor_joint.dart index 75f19c89224..08ff1a1bcea 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/motor_joint.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/motor_joint.dart @@ -51,15 +51,15 @@ class MotorJointWorld extends Forge2DWorld with TapCallbacks { } MotorJoint createMotorJoint(Body first, Body second) { - final motorJointDef = MotorJointDef() - ..initialize(first, second) - ..maxForce = 1000 - ..maxTorque = 1000 - ..correctionFactor = 0.1; - - final joint = MotorJoint(motorJointDef); - createJoint(joint); - return joint; + return physicsWorld.createMotorJoint( + MotorJointDef( + bodyA: first, + bodyB: second, + maxForce: 1000, + maxTorque: 1000, + correctionFactor: 0.1, + ), + ); } final linearOffset = Vector2.zero(); @@ -73,13 +73,13 @@ class MotorJointWorld extends Forge2DWorld with TapCallbacks { deltaOffset = -deltaOffset; } - final linearOffsetX = joint.getLinearOffset().x + deltaOffset; - final linearOffsetY = joint.getLinearOffset().y + deltaOffset; + final linearOffsetX = joint.linearOffset.x + deltaOffset; + final linearOffsetY = joint.linearOffset.y + deltaOffset; linearOffset.setValues(linearOffsetX, linearOffsetY); - final angularOffset = joint.getAngularOffset() + deltaOffset; + final angularOffset = joint.angularOffset + deltaOffset; - joint.setLinearOffset(linearOffset); - joint.setAngularOffset(angularOffset); + joint.linearOffset = linearOffset; + joint.angularOffset = angularOffset; } } @@ -91,8 +91,8 @@ class JointRenderer extends Component { @override void render(Canvas canvas) { canvas.drawLine( - joint.anchorA.toOffset(), - joint.anchorB.toOffset(), + joint.bodyA.worldPoint(joint.localAnchorA).toOffset(), + joint.bodyB.worldPoint(joint.localAnchorB).toOffset(), debugPaint, ); } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/mouse_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/mouse_joint.dart index eee8816274d..2d33ca9155e 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/mouse_joint.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/mouse_joint.dart @@ -41,28 +41,27 @@ class MouseJointWorld extends Forge2DWorld if (mouseJoint != null) { return; } - final mouseJointDef = MouseJointDef() - ..maxForce = 3000 * ball.body.mass * 10 - ..dampingRatio = 0.1 - ..frequencyHz = 5 - ..target.setFrom(ball.body.position) - ..collideConnected = false - ..bodyA = groundBody - ..bodyB = ball.body; - - mouseJoint = MouseJoint(mouseJointDef); - createJoint(mouseJoint!); + mouseJoint = physicsWorld.createMouseJoint( + MouseJointDef( + bodyA: groundBody, + bodyB: ball.body, + target: ball.body.position, + maxForce: 3000 * ball.body.mass * 10, + dampingRatio: 0.1, + hertz: 5, + ), + ); } @override void onDragUpdate(DragUpdateEvent info) { - mouseJoint?.setTarget(info.localEndPosition); + mouseJoint?.target = info.localEndPosition; } @override void onDragEnd(DragEndEvent info) { super.onDragEnd(info); - destroyJoint(mouseJoint!); + mouseJoint?.destroy(); mouseJoint = null; } } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/prismatic_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/prismatic_joint.dart index 8232234545b..608b38bdb40 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/prismatic_joint.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/prismatic_joint.dart @@ -13,6 +13,7 @@ class PrismaticJointExample extends Forge2DGame { '''; final Vector2 anchor = Vector2.zero(); + final Vector2 axis = Vector2(1, 0); @override Future onLoad() async { @@ -23,49 +24,50 @@ class PrismaticJointExample extends Forge2DGame { await Future.wait([box.loaded]); final joint = createJoint(box.body, anchor); - world.add(JointRenderer(joint: joint, anchor: anchor)); + world.add(JointRenderer(joint: joint, anchor: anchor, axis: axis)); } PrismaticJoint createJoint(Body box, Vector2 anchor) { final groundBody = world.createBody(BodyDef()); - final prismaticJointDef = PrismaticJointDef() - ..initialize( - box, - groundBody, - anchor, - Vector2(1, 0), - ) - ..enableLimit = true - ..lowerTranslation = -20 - ..upperTranslation = 20 - ..enableMotor = true - ..motorSpeed = 1 - ..maxMotorForce = 100; - - final joint = PrismaticJoint(prismaticJointDef); - world.createJoint(joint); - return joint; + return world.physicsWorld.createPrismaticJoint( + PrismaticJointDef( + bodyA: box, + bodyB: groundBody, + localAxisA: axis, + enableLimit: true, + lowerTranslation: -20, + upperTranslation: 20, + enableMotor: true, + motorSpeed: 1, + maxMotorForce: 100, + ), + ); } } class JointRenderer extends Component { - JointRenderer({required this.joint, required this.anchor}); + JointRenderer({ + required this.joint, + required this.anchor, + required this.axis, + }); final PrismaticJoint joint; final Vector2 anchor; + final Vector2 axis; final Vector2 p1 = Vector2.zero(); final Vector2 p2 = Vector2.zero(); @override void render(Canvas canvas) { p1 - ..setFrom(joint.getLocalAxisA()) - ..scale(joint.getLowerLimit()) + ..setFrom(axis) + ..scale(joint.lowerLimit) ..add(anchor); p2 - ..setFrom(joint.getLocalAxisA()) - ..scale(joint.getUpperLimit()) + ..setFrom(axis) + ..scale(joint.upperLimit) ..add(anchor); canvas.drawLine(p1.toOffset(), p2.toOffset(), debugPaint); diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/pulley_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/pulley_joint.dart deleted file mode 100644 index 9108e640999..00000000000 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/pulley_joint.dart +++ /dev/null @@ -1,98 +0,0 @@ -import 'dart:ui'; - -import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/balls.dart'; -import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boxes.dart'; -import 'package:flame/components.dart'; -import 'package:flame_forge2d/flame_forge2d.dart'; - -class PulleyJointExample extends Forge2DGame { - static const description = ''' - This example shows how to use a `PulleyJoint`. Drag one of the boxes and see - how the other one gets moved by the pulley - '''; - - @override - Future onLoad() async { - super.onLoad(); - final distanceFromCenter = camera.visibleWorldRect.width / 5; - - final firstPulley = Ball( - Vector2(-distanceFromCenter, -10), - bodyType: BodyType.static, - ); - final secondPulley = Ball( - Vector2(distanceFromCenter, -10), - bodyType: BodyType.static, - ); - - final firstBox = DraggableBox( - startPosition: Vector2(-distanceFromCenter, 20), - width: 5, - height: 10, - ); - final secondBox = DraggableBox( - startPosition: Vector2(distanceFromCenter, 20), - width: 7, - height: 10, - ); - world.addAll([firstBox, secondBox, firstPulley, secondPulley]); - - await Future.wait([ - firstBox.loaded, - secondBox.loaded, - firstPulley.loaded, - secondPulley.loaded, - ]); - - final joint = createJoint(firstBox, secondBox, firstPulley, secondPulley); - world.add(PulleyRenderer(joint: joint)); - } - - PulleyJoint createJoint( - Box firstBox, - Box secondBox, - Ball firstPulley, - Ball secondPulley, - ) { - final pulleyJointDef = PulleyJointDef() - ..initialize( - firstBox.body, - secondBox.body, - firstPulley.center, - secondPulley.center, - firstBox.body.worldPoint(Vector2(0, -firstBox.height / 2)), - secondBox.body.worldPoint(Vector2(0, -secondBox.height / 2)), - 1, - ); - final joint = PulleyJoint(pulleyJointDef); - world.createJoint(joint); - return joint; - } -} - -class PulleyRenderer extends Component { - PulleyRenderer({required this.joint}); - - final PulleyJoint joint; - - @override - void render(Canvas canvas) { - canvas.drawLine( - joint.anchorA.toOffset(), - joint.getGroundAnchorA().toOffset(), - debugPaint, - ); - - canvas.drawLine( - joint.anchorB.toOffset(), - joint.getGroundAnchorB().toOffset(), - debugPaint, - ); - - canvas.drawLine( - joint.getGroundAnchorA().toOffset(), - joint.getGroundAnchorB().toOffset(), - debugPaint, - ); - } -} diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/revolute_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/revolute_joint.dart index 837fd76ba5c..0a2e0de1c24 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/revolute_joint.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/revolute_joint.dart @@ -54,23 +54,18 @@ class CircleShuffler extends BodyComponent { final xPos = radius * cos(2 * pi * (i / numPieces)); final yPos = radius * sin(2 * pi * (i / numPieces)); - final shape = CircleShape() - ..radius = 1.2 - ..position.setValues(xPos, yPos); - - final fixtureDef = FixtureDef( - shape, - density: 50.0, - friction: 0.1, - restitution: 0.9, + body.createShape( + Circle(radius: 1.2, center: Vector2(xPos, yPos)), + ShapeDef( + density: 50.0, + material: SurfaceMaterial(friction: 0.1, restitution: 0.9), + ), ); - - body.createFixture(fixtureDef); } - final jointDef = RevoluteJointDef() - ..initialize(body, ball.body, body.position); - world.createJoint(RevoluteJoint(jointDef)); + world.physicsWorld.createRevoluteJoint( + RevoluteJointDef(bodyA: body, bodyB: ball.body), + ); return body; } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/rope_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/rope_joint.dart deleted file mode 100644 index ab79b2a0b08..00000000000 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/rope_joint.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/balls.dart'; -import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boxes.dart'; -import 'package:flame/components.dart'; -import 'package:flame/events.dart'; -import 'package:flame_forge2d/flame_forge2d.dart'; -import 'package:flutter/material.dart'; - -class RopeJointExample extends Forge2DGame { - static const description = ''' - This example shows how to use a `RopeJoint`. - - Drag the box handle along the axis and observe the rope respond to the - movement. - '''; - - RopeJointExample() : super(world: RopeJointWorld()); -} - -class RopeJointWorld extends Forge2DWorld - with DragCallbacks, HasGameReference { - double handleWidth = 6; - - @override - Future onLoad() async { - super.onLoad(); - - final handleBody = await createHandle(); - createRope(handleBody); - } - - Future createHandle() async { - final anchor = game.screenToWorld(Vector2(0, 100))..x = 0; - - final box = DraggableBox( - startPosition: anchor, - width: handleWidth, - height: 3, - ); - await add(box); - - createPrismaticJoint(box.body, anchor); - return box.body; - } - - Future createRope(Body handle) async { - const length = 50; - var prevBody = handle; - - for (var i = 0; i < length; i++) { - final newPosition = prevBody.worldCenter + Vector2(0, 1); - final ball = Ball(newPosition, radius: 0.5, color: Colors.white); - await add(ball); - - createRopeJoint(ball.body, prevBody); - prevBody = ball.body; - } - } - - void createPrismaticJoint(Body box, Vector2 anchor) { - final groundBody = createBody(BodyDef()); - final halfWidth = game.screenToWorld(Vector2.zero()).x.abs(); - - final prismaticJointDef = PrismaticJointDef() - ..initialize( - box, - groundBody, - anchor, - Vector2(1, 0), - ) - ..enableLimit = true - ..lowerTranslation = -halfWidth + handleWidth / 2 - ..upperTranslation = halfWidth - handleWidth / 2; - - final joint = PrismaticJoint(prismaticJointDef); - createJoint(joint); - } - - void createRopeJoint(Body first, Body second) { - final ropeJointDef = RopeJointDef() - ..bodyA = first - ..localAnchorA.setFrom(first.getLocalCenter()) - ..bodyB = second - ..localAnchorB.setFrom(second.getLocalCenter()) - ..maxLength = (second.worldCenter - first.worldCenter).length; - - createJoint(RopeJoint(ropeJointDef)); - } -} diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/weld_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/weld_joint.dart index 50001153224..f4e1df03013 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/weld_joint.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/weld_joint.dart @@ -89,9 +89,14 @@ class WeldJointWorld extends Forge2DWorld } void createWeldJoint(Body first, Body second, Vector2 anchor) { - final weldJointDef = WeldJointDef()..initialize(first, second, anchor); - - createJoint(WeldJoint(weldJointDef)); + physicsWorld.createWeldJoint( + WeldJointDef( + bodyA: first, + bodyB: second, + localAnchorA: first.localPoint(anchor), + localAnchorB: second.localPoint(anchor), + ), + ); } @override diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/raycast_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/raycast_example.dart index 8ab66f23352..8774e8c2431 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/raycast_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/raycast_example.dart @@ -10,9 +10,9 @@ import 'package:flutter/material.dart' show Colors, Paint, Canvas; class RaycastExample extends Forge2DGame with MouseMovementDetector { static const String description = ''' - This example shows how raycasts can be used to find nearest and farthest - fixtures. - Red ray finds the nearest fixture and blue ray finds the farthest fixture. + This example shows how ray casts can be used to find the nearest and + farthest shapes. + The red ray finds the nearest shape and the blue ray the farthest shape. '''; final random = Random(); @@ -78,36 +78,36 @@ class RaycastExample extends Forge2DGame with MouseMovementDetector { bluePoints.clear(); bluePoints.add(rayStart); - final farthestCallback = FarthestBoxRayCastCallback(); - world.raycast(farthestCallback, rayStart, rayTarget); - - if (farthestCallback.farthestPoint != null) { - bluePoints.add(farthestCallback.farthestPoint!); + final hits = world.castRayAll(rayStart, rayTarget - rayStart); + if (hits.isNotEmpty) { + // The hits are sorted from nearest to farthest. + final farthestHit = hits.last; + bluePoints.add(farthestHit.point.clone()); + farthestBox = farthestHit.shape.userData as Box?; } else { bluePoints.add(rayTarget); + farthestBox = null; } - farthestBox = farthestCallback.box; } void fireRedRay(Vector2 rayStart, Vector2 rayTarget) { redPoints.clear(); redPoints.add(rayStart); - final nearestCallback = NearestBoxRayCastCallback(); - world.raycast(nearestCallback, rayStart, rayTarget); - - if (nearestCallback.nearestPoint != null) { - redPoints.add(nearestCallback.nearestPoint!); + final nearestHit = world.castRayClosest(rayStart, rayTarget - rayStart); + if (nearestHit != null) { + redPoints.add(nearestHit.point.clone()); + nearestBox = nearestHit.shape.userData as Box?; } else { redPoints.add(rayTarget); + nearestBox = null; } - nearestBox = nearestCallback.box; } @override void update(double dt) { super.update(dt); - children.whereType().forEach((component) { + world.children.whereType().forEach((component) { if ((component == nearestBox) && (component == farthestBox)) { component.paint.color = Colors.yellow; } else if (component == nearestBox) { @@ -157,60 +157,8 @@ class Box extends BodyComponent { @override Body createBody() { - final shape = PolygonShape()..setAsBoxXY(2.0, 4.0); - final fixtureDef = FixtureDef(shape, userData: this); final bodyDef = BodyDef(position: initialPosition); - return world.createBody(bodyDef)..createFixture(fixtureDef); - } -} - -class NearestBoxRayCastCallback extends RayCastCallback { - Box? box; - Vector2? nearestPoint; - Vector2? normalAtInter; - - @override - double reportFixture( - Fixture fixture, - Vector2 point, - Vector2 normal, - double fraction, - ) { - nearestPoint = point.clone(); - normalAtInter = normal.clone(); - box = fixture.userData as Box?; - - // Returning fraction implies that we care only about - // fixtures that are closer to ray start point than - // the current fixture - return fraction; - } -} - -class FarthestBoxRayCastCallback extends RayCastCallback { - Box? box; - Vector2? farthestPoint; - Vector2? normalAtInter; - double previousFraction = 0.0; - - @override - double reportFixture( - Fixture fixture, - Vector2 point, - Vector2 normal, - double fraction, - ) { - // Value of fraction is directly proportional to - // the distance of fixture from ray start point. - // So we are interested in the current fixture only if - // it has a higher fraction value than previousFraction. - if (previousFraction < fraction) { - farthestPoint = point.clone(); - normalAtInter = normal.clone(); - box = fixture.userData as Box?; - previousFraction = fraction; - } - - return 1; + return world.createBody(bodyDef) + ..createShape(Polygon.box(2.0, 4.0), ShapeDef(userData: this)); } } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/revolute_joint_with_motor_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/revolute_joint_with_motor_example.dart index 5c36d7c5250..377c3906019 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/revolute_joint_with_motor_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/revolute_joint_with_motor_example.dart @@ -64,29 +64,27 @@ class CircleShuffler extends BodyComponent { final xPos = radius * cos(2 * pi * (i / numPieces)); final yPos = radius * sin(2 * pi * (i / numPieces)); - final shape = CircleShape() - ..radius = 1.2 - ..position.setValues(xPos, yPos); - - final fixtureDef = FixtureDef( - shape, - density: 50.0, - friction: 0.1, - restitution: 0.9, + body.createShape( + Circle(radius: 1.2, center: Vector2(xPos, yPos)), + ShapeDef( + density: 50.0, + material: SurfaceMaterial(friction: 0.1, restitution: 0.9), + ), ); - - body.createFixture(fixtureDef); } // Create an empty ground body. final groundBody = world.createBody(BodyDef()); - final revoluteJointDef = RevoluteJointDef() - ..initialize(body, groundBody, body.position) - ..motorSpeed = pi - ..maxMotorTorque = 1000000.0 - ..enableMotor = true; - - world.createJoint(RevoluteJoint(revoluteJointDef)); + world.physicsWorld.createRevoluteJoint( + RevoluteJointDef( + bodyA: body, + bodyB: groundBody, + localAnchorB: body.position.clone(), + motorSpeed: pi, + maxMotorTorque: 1000000.0, + enableMotor: true, + ), + ); return body; } } @@ -99,21 +97,30 @@ class CornerRamp extends BodyComponent { @override Body createBody() { - final shape = ChainShape(); final mirrorFactor = isMirrored ? -1 : 1; final diff = 2.0 * mirrorFactor; - final vertices = [ + // Chains need at least four points, so a collinear point is added on the + // closing edge of the triangular loop. + final points = [ Vector2(diff, 0), Vector2(diff + 20.0 * mirrorFactor, -20.0), Vector2(diff + 35.0 * mirrorFactor, -30.0), + Vector2(diff + 17.5 * mirrorFactor, -15.0), ]; - shape.createLoop(vertices); + if (isMirrored) { + // Chains are one-sided, and mirroring the points flips the winding + // direction, so reverse the list to keep the solid side consistent. + points.setAll(0, points.reversed.toList()); + } - final fixtureDef = FixtureDef(shape, friction: 0.1); - final bodyDef = BodyDef() - ..position = _center - ..type = BodyType.static; + final bodyDef = BodyDef(position: _center); - return world.createBody(bodyDef)..createFixture(fixtureDef); + return world.createBody(bodyDef)..createChain( + ChainDef( + points: points, + materials: [SurfaceMaterial(friction: 0.1)], + isLoop: true, + ), + ); } } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart index b21ffab635c..a2f5872f742 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart @@ -59,27 +59,22 @@ class Pizza extends BodyComponent { @override Body createBody() { - final shape = PolygonShape(); - final vertices = [ Vector2(-size.x / 2, size.y / 2), Vector2(size.x / 2, size.y / 2), Vector2(0, -size.y / 2), ]; - shape.set(vertices); - final fixtureDef = FixtureDef( - shape, + final shapeDef = ShapeDef( userData: this, // To be able to determine object in collision - restitution: 0.4, - friction: 0.5, + material: SurfaceMaterial(restitution: 0.4, friction: 0.5), ); final bodyDef = BodyDef( position: initialPosition, - angle: (initialPosition.x + initialPosition.y) / 2 * pi, + rotation: Rot.fromAngle((initialPosition.x + initialPosition.y) / 2 * pi), type: BodyType.dynamic, ); - return world.createBody(bodyDef)..createFixture(fixtureDef); + return world.createBody(bodyDef)..createShape(Polygon(vertices), shapeDef); } } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/utils/balls.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/balls.dart index 4df873ef6c2..27b7b7930eb 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/utils/balls.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/balls.dart @@ -32,13 +32,9 @@ class Ball extends BodyComponent with ContactCallbacks { @override Body createBody() { - final shape = CircleShape(); - shape.radius = radius; - - final fixtureDef = FixtureDef( - shape, - restitution: 0.8, - friction: 0.4, + final shapeDef = ShapeDef( + material: SurfaceMaterial(restitution: 0.8, friction: 0.4), + enableContactEvents: true, ); final bodyDef = BodyDef( @@ -48,7 +44,8 @@ class Ball extends BodyComponent with ContactCallbacks { type: bodyType, ); - return world.createBody(bodyDef)..createFixture(fixtureDef); + return world.createBody(bodyDef) + ..createShape(Circle(radius: radius), shapeDef); } @override diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart index cdbd98f988d..a07c745de07 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart @@ -26,14 +26,17 @@ class Wall extends BodyComponent { @override Body createBody() { - final shape = EdgeShape()..set(start, end); - final fixtureDef = FixtureDef(shape, friction: 0.3); + final shapeDef = ShapeDef( + material: SurfaceMaterial(friction: 0.3), + enableContactEvents: true, + ); final bodyDef = BodyDef( userData: this, // To be able to determine object in collision position: Vector2.zero(), ); paint.strokeWidth = strokeWidth; - return world.createBody(bodyDef)..createFixture(fixtureDef); + return world.createBody(bodyDef) + ..createShape(Segment(point1: start, point2: end), shapeDef); } } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/utils/boxes.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/boxes.dart index ecdb7b05a4c..42b7705bccd 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/utils/boxes.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/boxes.dart @@ -29,13 +29,10 @@ class Box extends BodyComponent { @override Body createBody() { - final shape = PolygonShape() - ..setAsBox(width / 2, height / 2, Vector2.zero(), 0); - final fixtureDef = FixtureDef( - shape, - friction: 0.3, - restitution: 0.2, + final shapeDef = ShapeDef( + material: SurfaceMaterial(friction: 0.3, restitution: 0.2), density: 10, + enableContactEvents: true, ); final bodyDef = BodyDef( userData: this, // To be able to determine object in collision @@ -43,7 +40,8 @@ class Box extends BodyComponent { type: bodyType, ); - return world.createBody(bodyDef)..createFixture(fixtureDef); + return world.createBody(bodyDef) + ..createShape(Polygon.box(width / 2, height / 2), shapeDef); } } @@ -61,7 +59,7 @@ class DraggableBox extends Box with DragCallbacks { @override void update(double dt) { if (_destroyJoint && mouseJoint != null) { - world.destroyJoint(mouseJoint!); + mouseJoint!.destroy(); mouseJoint = null; _destroyJoint = false; } @@ -73,24 +71,21 @@ class DraggableBox extends Box with DragCallbacks { final target = game.screenToWorld(event.devicePosition); - final mouseJointDef = MouseJointDef() - ..maxForce = 5000 * body.mass - ..dampingRatio = 0.1 - ..frequencyHz = 50 - ..target.setFrom(target) - ..collideConnected = false - ..bodyA = groundBody - ..bodyB = body; - mouseJoint = MouseJoint(mouseJointDef); - - world.createJoint(mouseJoint!); + mouseJoint = world.physicsWorld.createMouseJoint( + MouseJointDef( + bodyA: groundBody, + bodyB: body, + target: target, + maxForce: 5000 * body.mass, + dampingRatio: 0.1, + hertz: 50, + ), + ); } @override bool onDragUpdate(DragUpdateEvent event) { - mouseJoint?.setTarget( - game.screenToWorld(event.deviceEndPosition), - ); + mouseJoint?.target = game.screenToWorld(event.deviceEndPosition); return false; } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart index 1c6b42d0a90..801bfc3d2d7 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart @@ -30,13 +30,10 @@ class WidgetExample extends Forge2DGame { ); final body = world.createBody(bodyDef); - final shape = PolygonShape()..setAsBoxXY(4.6, 0.8); - final fixtureDef = FixtureDef( - shape, - restitution: 0.8, - friction: 0.2, + body.createShape( + Polygon.box(4.6, 0.8), + ShapeDef(material: SurfaceMaterial(restitution: 0.8, friction: 0.2)), ); - body.createFixture(fixtureDef); return body; } diff --git a/packages/flame/lib/src/events/dispatchers/multi_tap_dispatcher.dart b/packages/flame/lib/src/events/dispatchers/multi_tap_dispatcher.dart index f82cb16693c..a4d320ede44 100644 --- a/packages/flame/lib/src/events/dispatchers/multi_tap_dispatcher.dart +++ b/packages/flame/lib/src/events/dispatchers/multi_tap_dispatcher.dart @@ -128,7 +128,7 @@ class MultiTapDispatcher extends Dispatcher onTapCancel(TapCancelEvent(pointerId)); } - @internal + @visibleForTesting @override void handleTapDown(int pointerId, TapDownDetails details) { onTapDown(TapDownEvent(pointerId, game, details)); diff --git a/packages/flame_forge2d/analysis_options.yaml b/packages/flame_forge2d/analysis_options.yaml index 92aae2f2499..81921288b3b 100644 --- a/packages/flame_forge2d/analysis_options.yaml +++ b/packages/flame_forge2d/analysis_options.yaml @@ -1 +1,7 @@ include: package:flame_lint/analysis_options_with_dcm.yaml + +analyzer: + errors: + # TODO(spydon): Remove once the Box2D v3 based forge2d is published and + # the temporary git dependency is replaced with a pub version. + invalid_dependency: ignore diff --git a/packages/flame_forge2d/example/lib/main.dart b/packages/flame_forge2d/example/lib/main.dart index 2c7aa8b03f8..a257a1ddaa6 100644 --- a/packages/flame_forge2d/example/lib/main.dart +++ b/packages/flame_forge2d/example/lib/main.dart @@ -38,11 +38,12 @@ class Forge2DExample extends Forge2DGame { class Ball extends BodyComponent with TapCallbacks { Ball({Vector2? initialPosition}) : super( - fixtureDefs: [ - FixtureDef( - CircleShape()..radius = 5, - restitution: 0.8, - friction: 0.4, + shapeSpecs: [ + ShapeSpec( + Circle(radius: 5), + ShapeDef( + material: SurfaceMaterial(restitution: 0.8, friction: 0.4), + ), ), ], bodyDef: BodyDef( @@ -66,12 +67,12 @@ class Wall extends BodyComponent { @override Body createBody() { - final shape = EdgeShape()..set(_start, _end); - final fixtureDef = FixtureDef(shape, friction: 0.3); + final shapeDef = ShapeDef(material: SurfaceMaterial(friction: 0.3)); final bodyDef = BodyDef( position: Vector2.zero(), ); - return world.createBody(bodyDef)..createFixture(fixtureDef); + return world.createBody(bodyDef) + ..createShape(Segment(point1: _start, point2: _end), shapeDef); } } diff --git a/packages/flame_forge2d/lib/body_component.dart b/packages/flame_forge2d/lib/body_component.dart index 8d7aafd2df4..1258ba2ac20 100644 --- a/packages/flame_forge2d/lib/body_component.dart +++ b/packages/flame_forge2d/lib/body_component.dart @@ -1,3 +1,4 @@ +import 'dart:math' as math; import 'dart:ui'; import 'package:flame/components.dart' hide World; @@ -7,11 +8,25 @@ import 'package:flame/game.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flutter/foundation.dart'; +/// A pairing of a [ShapeGeometry] with an optional [ShapeDef], used by +/// [BodyComponent.shapeSpecs] to describe the shapes that should be created +/// on the body. +class ShapeSpec { + const ShapeSpec(this.geometry, [this.definition]); + + /// The geometry of the shape, for example a [Circle] or a [Polygon]. + final ShapeGeometry geometry; + + /// The definition that the shape is created with, or the Forge2D defaults + /// when null. + final ShapeDef? definition; +} + /// Since a pure BodyComponent doesn't have anything drawn on top of it, /// it is a good idea to turn on [debugMode] for it so that the bodies can be /// seen /// -/// You can use the optional [bodyDef] and [fixtureDefs] arguments to create +/// You can use the optional [bodyDef] and [shapeSpecs] arguments to create /// the [BodyComponent]'s body without having to create the definitions within /// the component. class BodyComponent extends Component @@ -26,7 +41,7 @@ class BodyComponent extends Component super.priority, this.renderBody = true, this.bodyDef, - this.fixtureDefs, + this.shapeSpecs, super.key, }) { this.paint = paint ?? (Paint()..color = defaultColor); @@ -41,17 +56,17 @@ class BodyComponent extends Component /// If you do not provide a [BodyDef] here, you must override [createBody]. BodyDef? bodyDef; - /// The default implementation of [createBody] will add these fixtures to the - /// [Body] that it creates from [bodyDef]. - List? fixtureDefs; + /// The default implementation of [createBody] will create these shapes on + /// the [Body] that it creates from [bodyDef]. + List? shapeSpecs; @override Vector2 get position => body.position; - /// Specifies if the body's fixtures should be rendered. + /// Specifies if the body's shapes should be rendered. /// /// [renderBody] is true by default for [BodyComponent], if set to false - /// the body's fixtures wont be rendered. + /// the body's shapes wont be rendered. /// /// If you render something on top of the [BodyComponent], or doesn't want it /// to be seen, you probably want to set it to false. @@ -59,13 +74,28 @@ class BodyComponent extends Component /// You should create the Forge2D [Body] in this method when you extend /// the BodyComponent. + /// + /// The default implementation creates the body from [bodyDef] and the + /// shapes from [shapeSpecs]. When the userData of [bodyDef] or of a shape's + /// [ShapeDef] is a [ContactCallbacks], contact and sensor events are + /// automatically enabled for that shape, since Forge2D only generates + /// events for shapes that have opted in to them. If you override this + /// method you have to enable the event flags yourself. Body createBody() { assert( bodyDef != null, 'Ensure this.bodyDef is not null or override createBody', ); - final body = world.createBody(bodyDef!); - fixtureDefs?.forEach(body.createFixture); + final body = world.createBody(bodyDef); + final bodyHasCallbacks = bodyDef!.userData is ContactCallbacks; + for (final spec in shapeSpecs ?? const []) { + final definition = spec.definition ?? ShapeDef(); + if (bodyHasCallbacks || definition.userData is ContactCallbacks) { + definition.enableContactEvents = true; + definition.enableSensorEvents = true; + } + body.createShape(spec.geometry, definition); + } return body; } @@ -85,7 +115,7 @@ class BodyComponent extends Component late Forge2DWorld world; CameraComponent get camera => game.camera; - Vector2 get center => body.worldCenter; + Vector2 get center => body.worldCenterOfMass; @override double get angle => body.angle; @@ -116,71 +146,49 @@ class BodyComponent extends Component @override void render(Canvas canvas) { if (renderBody) { - for (final fixture in body.fixtures) { - renderFixture(canvas, fixture); + for (final shape in body.shapes) { + renderShape(canvas, shape); } } } @override void renderDebugMode(Canvas canvas) { - body.fixtures.forEach( - (fixture) => renderFixture(canvas, fixture), - ); + for (final shape in body.shapes) { + renderShape(canvas, shape); + } } - /// Renders a [Fixture] in a [Canvas]. + /// Renders a [Shape] in a [Canvas]. /// - /// Called for each fixture in [body] when [render]ing. Override this method - /// to customize how fixtures are rendered. For example, you can filter out - /// fixtures that you don't want to render. + /// Called for each shape in [body] when [render]ing. Override this method + /// to customize how shapes are rendered. For example, you can filter out + /// shapes that you don't want to render. /// - /// **NOTE**: If [renderBody] is false, no fixtures will be rendered. Hence, - /// [renderFixture] is not called when [render]ing. - void renderFixture(Canvas canvas, Fixture fixture) { + /// **NOTE**: If [renderBody] is false, no shapes will be rendered. Hence, + /// [renderShape] is not called when [render]ing. + void renderShape(Canvas canvas, Shape shape) { canvas.save(); - switch (fixture.type) { - case ShapeType.chain: - _renderChain(canvas, fixture); - case ShapeType.circle: - _renderCircle(canvas, fixture); - case ShapeType.edge: - _renderEdge(canvas, fixture); - case ShapeType.polygon: - _renderPolygon(canvas, fixture); + switch (shape.geometry) { + case Circle(:final center, :final radius): + renderCircle(canvas, center.toOffset(), radius); + case Capsule(:final center1, :final center2, :final radius): + renderCapsule(canvas, center1.toOffset(), center2.toOffset(), radius); + case Segment(:final point1, :final point2): + renderSegment(canvas, point1.toOffset(), point2.toOffset()); + case Polygon(:final points): + renderPolygon( + canvas, + points!.map((v) => v.toOffset()).toList(growable: false), + ); } canvas.restore(); } - void _renderChain(Canvas canvas, Fixture fixture) { - final chainShape = fixture.shape as ChainShape; - renderChain( - canvas, - chainShape.vertices.map((v) => v.toOffset()).toList(growable: false), - ); - } - - void renderChain(Canvas canvas, List points) { - canvas.drawPoints(PointMode.polygon, points, paint); - } - - void _renderCircle(Canvas canvas, Fixture fixture) { - final circle = fixture.shape as CircleShape; - renderCircle(canvas, circle.position.toOffset(), circle.radius); - } - void renderCircle(Canvas canvas, Offset center, double radius) { canvas.drawCircle(center, radius, paint); } - void _renderPolygon(Canvas canvas, Fixture fixture) { - final polygon = fixture.shape as PolygonShape; - renderPolygon( - canvas, - polygon.vertices.map((v) => v.toOffset()).toList(growable: false), - ); - } - late final Path _path = Path(); void renderPolygon(Canvas canvas, List points) { @@ -191,13 +199,28 @@ class BodyComponent extends Component canvas.drawPath(path, paint); } - void _renderEdge(Canvas canvas, Fixture fixture) { - final edge = fixture.shape as EdgeShape; - renderEdge(canvas, edge.vertex1.toOffset(), edge.vertex2.toOffset()); + void renderSegment(Canvas canvas, Offset p1, Offset p2) { + canvas.drawLine(p1, p2, paint); } - void renderEdge(Canvas canvas, Offset p1, Offset p2) { - canvas.drawLine(p1, p2, paint); + void renderCapsule(Canvas canvas, Offset p1, Offset p2, double radius) { + final delta = p2 - p1; + final angle = math.atan2(delta.dy, delta.dx); + final path = _path + ..reset() + ..addArc( + Rect.fromCircle(center: p1, radius: radius), + angle + math.pi / 2, + math.pi, + ) + ..arcTo( + Rect.fromCircle(center: p2, radius: radius), + angle - math.pi / 2, + math.pi, + false, + ) + ..close(); + canvas.drawPath(path, paint); } @override @@ -211,12 +234,12 @@ class BodyComponent extends Component @override bool containsLocalPoint(Vector2 point) { _transform.localToGlobal(point, output: _hitTestPoint); - return body.fixtures.any((fixture) => fixture.testPoint(_hitTestPoint)); + return body.shapes.any((shape) => shape.testPoint(_hitTestPoint)); } @override bool containsPoint(Vector2 point) { - return body.fixtures.any((fixture) => fixture.testPoint(point)); + return body.shapes.any((shape) => shape.testPoint(point)); } @override diff --git a/packages/flame_forge2d/lib/contact.dart b/packages/flame_forge2d/lib/contact.dart new file mode 100644 index 00000000000..c26fad4d8b6 --- /dev/null +++ b/packages/flame_forge2d/lib/contact.dart @@ -0,0 +1,106 @@ +import 'package:flame_forge2d/flame_forge2d.dart'; + +/// The contact data that is delivered to [ContactCallbacks] by the +/// [ContactEventsDispatcher]. +/// +/// Forge2D reports contacts as polled events after each step instead of +/// through listener callbacks, and sensor overlaps arrive through a separate +/// event stream. [Contact] wraps both streams in one type so that +/// [ContactCallbacks.beginContact] and [ContactCallbacks.endContact] can be +/// used for sensors and non-sensors alike. +class Contact { + /// Creates the contact for a begin-touch event. + Contact.begin({ + required this.shapeA, + required this.shapeB, + required Vector2 this.normal, + required List this.points, + }) : isSensorEvent = false; + + /// Creates the contact for an end-touch event. + /// + /// The shapes may already have been destroyed, check [Shape.isValid] + /// before using them. + Contact.end({required this.shapeA, required this.shapeB}) + : normal = null, + points = null, + isSensorEvent = false; + + /// Creates the contact for a sensor begin or end event. + Contact.sensor({required Shape sensor, required Shape visitor}) + : shapeA = sensor, + shapeB = visitor, + normal = null, + points = null, + isSensorEvent = true; + + /// The first shape, or the sensor shape for sensor events. + final Shape shapeA; + + /// The second shape, or the visiting shape for sensor events. + final Shape shapeB; + + /// The contact normal in world coordinates, pointing from [shapeA] to + /// [shapeB]. + /// + /// Only available for begin-touch events of non-sensor shapes. + final Vector2? normal; + + /// The initial contact points, recorded before the solver ran. + /// + /// Only available for begin-touch events of non-sensor shapes. + final List? points; + + /// Whether this contact comes from a sensor overlap event. + final bool isSensorEvent; + + /// The body of [shapeA]. + /// + /// Only use this when [shapeA] is valid. + Body get bodyA => shapeA.body; + + /// The body of [shapeB]. + /// + /// Only use this when [shapeB] is valid. + Body get bodyB => shapeB.body; + + /// The sensor shape of a sensor event. + Shape get sensor { + assert(isSensorEvent, 'Only sensor events have a sensor shape'); + return shapeA; + } + + /// The shape that entered or left the sensor of a sensor event. + Shape get visitor { + assert(isSensorEvent, 'Only sensor events have a visitor shape'); + return shapeB; + } + + /// Whether both shapes still exist in the world. + /// + /// End events can arrive after one of the shapes has been destroyed. + bool get isValid => shapeA.isValid && shapeB.isValid; + + /// The non-null userData of the bodies and shapes involved in this + /// contact, in the order body A, shape A, body B, shape B. + /// + /// Shapes that have already been destroyed are skipped, since their + /// userData is removed together with them. + Set get userDatas { + final userDatas = {}; + for (final shape in [shapeA, shapeB]) { + if (!shape.isValid) { + continue; + } + final bodyUserData = shape.body.userData; + if (bodyUserData != null) { + userDatas.add(bodyUserData); + } + final shapeUserData = shape.userData; + if (shapeUserData != null) { + userDatas.add(shapeUserData); + } + } + return userDatas; + } +} diff --git a/packages/flame_forge2d/lib/contact_callbacks.dart b/packages/flame_forge2d/lib/contact_callbacks.dart index 0503278431e..4c864df8ba6 100644 --- a/packages/flame_forge2d/lib/contact_callbacks.dart +++ b/packages/flame_forge2d/lib/contact_callbacks.dart @@ -2,50 +2,33 @@ import 'package:flame_forge2d/flame_forge2d.dart'; /// Used to listen to a [BodyComponent]'s contact events. /// -/// Contact events occur whenever two [Fixture] meet each other. +/// Contact events occur whenever two [Shape]s meet each other. /// /// To react to contacts you should assign [ContactCallbacks] to a [Body]'s -/// userData or/and to a [Fixture]'s userData. -/// {@macro flame_forge2d.world_contact_listener.algorithm} +/// userData or/and to a [Shape]'s userData. +/// {@macro flame_forge2d.contact_events_dispatcher.algorithm} +/// +/// The old `preSolve` and `postSolve` callbacks no longer exist in Forge2D. +/// To disable contacts before they are solved, set +/// [Forge2DWorld.preSolveCallback] (and [ShapeDef.enablePreSolveEvents] on +/// the involved shapes). To measure impact strength, enable +/// [ShapeDef.enableHitEvents] and poll the world's `contactEvents.hit`. mixin class ContactCallbacks { - /// Called when two [Fixture]s start being in contact. + /// Called when two [Shape]s start being in contact. /// /// It is called for sensors and non-sensors. void beginContact(Object other, Contact contact) { onBeginContact?.call(other, contact); } - /// Called when two [Fixture]s cease being in contact. + /// Called when two [Shape]s cease being in contact. /// /// It is called for sensors and non-sensors. void endContact(Object other, Contact contact) { onEndContact?.call(other, contact); } - /// Called after collision detection, but before collision resolution. - /// - /// This gives you a chance to disable the [Contact] based on the current - /// configuration. - /// Sensors do not create [Manifold]s. - void preSolve(Object other, Contact contact, Manifold oldManifold) { - onPreSolve?.call(other, contact, oldManifold); - } - - /// Called after collision resolution. - /// - /// Usually defined to gather collision impulse results. - /// If one of the colliding objects is a sensor, this will not be called. - void postSolve(Object other, Contact contact, ContactImpulse impulse) { - onPostSolve?.call(other, contact, impulse); - } - void Function(Object other, Contact contact)? onBeginContact; void Function(Object other, Contact contact)? onEndContact; - - void Function(Object other, Contact contact, Manifold oldManifold)? - onPreSolve; - - void Function(Object other, Contact contact, ContactImpulse impulse)? - onPostSolve; } diff --git a/packages/flame_forge2d/lib/contact_events_dispatcher.dart b/packages/flame_forge2d/lib/contact_events_dispatcher.dart new file mode 100644 index 00000000000..a1aa0256e71 --- /dev/null +++ b/packages/flame_forge2d/lib/contact_events_dispatcher.dart @@ -0,0 +1,92 @@ +import 'package:flame_forge2d/flame_forge2d.dart'; + +/// Dispatches the physics world's polled contact and sensor events to the +/// [ContactCallbacks] found in the userData of the involved bodies and +/// shapes. +/// +/// {@template flame_forge2d.contact_events_dispatcher.algorithm} +/// If the [Body] `userData` is set to a [ContactCallbacks] the contact events +/// of this will be called when any of the body's [Shape]s contacts another +/// [Shape]. +/// +/// If instead you wish to be more specific and only trigger contact events +/// when a specific [Shape] contacts another [Shape], you can set the shape's +/// `userData` to a [ContactCallbacks]. +/// +/// If the colliding [Shape] `userData` and [Body] `userData` are `null`, then +/// the contact events are not called. +/// +/// Forge2D only reports events for shapes that have opted in to them, so +/// remember to set [ShapeDef.enableContactEvents] (and +/// [ShapeDef.enableSensorEvents] for sensors and their visitors) on the +/// shapes that should generate events. The default +/// [BodyComponent.createBody] implementation enables them automatically when +/// a [ContactCallbacks] is present in the body's or shape's userData. +/// +/// The described behavior is a simple out of the box solution to propagate +/// contact events. If you wish to implement your own logic you can subclass +/// [ContactEventsDispatcher] and provide it to your [Forge2DGame] or +/// [Forge2DWorld]. +/// {@endtemplate} +class ContactEventsDispatcher { + /// Called by [Forge2DWorld.update] after each physics step, with the + /// events that were generated during that step. + void dispatch(ContactEvents contactEvents, SensorEvents sensorEvents) { + for (final event in contactEvents.begin) { + beginContact( + Contact.begin( + shapeA: event.shapeA, + shapeB: event.shapeB, + normal: event.normal, + points: event.points, + ), + ); + } + for (final event in sensorEvents.begin) { + beginContact( + Contact.sensor(sensor: event.sensor, visitor: event.visitor), + ); + } + for (final event in contactEvents.end) { + endContact(Contact.end(shapeA: event.shapeA, shapeB: event.shapeB)); + } + for (final event in sensorEvents.end) { + endContact(Contact.sensor(sensor: event.sensor, visitor: event.visitor)); + } + } + + /// Dispatches a begin contact to the [ContactCallbacks] of the involved + /// bodies and shapes. + void beginContact(Contact contact) { + _dispatch( + contact, + (contactCallbacks, other) => contactCallbacks.beginContact( + other, + contact, + ), + ); + } + + /// Dispatches an end contact to the [ContactCallbacks] of the involved + /// bodies and shapes. + void endContact(Contact contact) { + _dispatch( + contact, + (contactCallbacks, other) => contactCallbacks.endContact(other, contact), + ); + } + + void _dispatch( + Contact contact, + void Function(ContactCallbacks contactCallbacks, Object other) callback, + ) { + final userDatas = contact.userDatas; + for (final contactCallbacks in userDatas.whereType()) { + for (final other in userDatas) { + if (other != contactCallbacks) { + callback(contactCallbacks, other); + } + } + } + } +} diff --git a/packages/flame_forge2d/lib/flame_forge2d.dart b/packages/flame_forge2d/lib/flame_forge2d.dart index 56cc80b140f..52584426a46 100644 --- a/packages/flame_forge2d/lib/flame_forge2d.dart +++ b/packages/flame_forge2d/lib/flame_forge2d.dart @@ -3,7 +3,8 @@ library flame_forge2d; export 'package:forge2d/forge2d.dart'; export 'body_component.dart'; +export 'contact.dart'; export 'contact_callbacks.dart'; +export 'contact_events_dispatcher.dart'; export 'forge2d_game.dart'; export 'forge2d_world.dart'; -export 'world_contact_listener.dart'; diff --git a/packages/flame_forge2d/lib/forge2d_game.dart b/packages/flame_forge2d/lib/forge2d_game.dart index 6ac3b5bb98c..ad077fa1314 100644 --- a/packages/flame_forge2d/lib/forge2d_game.dart +++ b/packages/flame_forge2d/lib/forge2d_game.dart @@ -1,7 +1,7 @@ import 'package:flame/camera.dart'; import 'package:flame/game.dart'; +import 'package:flame_forge2d/contact_events_dispatcher.dart'; import 'package:flame_forge2d/forge2d_world.dart'; -import 'package:forge2d/forge2d.dart'; /// The base game class for creating games that uses the Forge2D physics engine. class Forge2DGame extends FlameGame { @@ -9,14 +9,14 @@ class Forge2DGame extends FlameGame { Forge2DWorld? world, CameraComponent? camera, Vector2? gravity, - ContactListener? contactListener, + ContactEventsDispatcher? contactEventsDispatcher, double zoom = 10, }) : super( world: ((world?..gravity = gravity ?? world.gravity) ?? Forge2DWorld( gravity: gravity, - contactListener: contactListener, + contactEventsDispatcher: contactEventsDispatcher, )) as T, camera: (camera ?? CameraComponent())..viewfinder.zoom = zoom, diff --git a/packages/flame_forge2d/lib/forge2d_world.dart b/packages/flame_forge2d/lib/forge2d_world.dart index 60933564a86..1685f1ebe9a 100644 --- a/packages/flame_forge2d/lib/forge2d_world.dart +++ b/packages/flame_forge2d/lib/forge2d_world.dart @@ -1,3 +1,5 @@ +import 'dart:collection'; + import 'package:flame/components.dart'; import 'package:flame_forge2d/flame_forge2d.dart' hide World; import 'package:forge2d/forge2d.dart' as forge2d; @@ -7,72 +9,151 @@ import 'package:forge2d/forge2d.dart' as forge2d; /// /// Wraps the world class that comes from Forge2D ([forge2d.World]). class Forge2DWorld extends World { + /// Creates a [Forge2DWorld] with the given [gravity], which defaults to + /// [defaultGravity]. + /// + /// A [definition] can be passed to configure the underlying physics world. + /// Be aware that [WorldDef.gravity] uses the y-up Box2D convention with a + /// default of (0, -10), while Flame's y-axis points down, so set its + /// gravity explicitly (or use the [gravity] argument, which takes + /// precedence) when you provide a definition. Forge2DWorld({ Vector2? gravity, - forge2d.ContactListener? contactListener, + forge2d.WorldDef? definition, + ContactEventsDispatcher? contactEventsDispatcher, super.children, - }) : physicsWorld = forge2d.World(gravity ?? defaultGravity) - ..setContactListener(contactListener ?? WorldContactListener()); + }) : physicsWorld = forge2d.World( + gravity: gravity ?? definition?.gravity ?? defaultGravity, + definition: definition, + ), + contactEventsDispatcher = + contactEventsDispatcher ?? ContactEventsDispatcher(); static final Vector2 defaultGravity = Vector2(0, 10.0); + /// The underlying Forge2D physics world. + /// + /// The world is never destroyed by the component, so that it can be + /// re-added to the component tree later. If you are permanently done with + /// it, call `physicsWorld.destroy()` to free its native resources. final forge2d.World physicsWorld; + /// Routes the contact and sensor events that the physics world generated + /// during a step to the [ContactCallbacks] in the involved userData. + final ContactEventsDispatcher contactEventsDispatcher; + + /// The number of sub-steps that the physics world performs for each + /// [update]. + int subStepCount = 4; + /// If true, all bodies will be destroyed when the world is removed from /// the component tree. /// Set this to false if you want to keep the bodies state for later, if /// you for example plan to add the world back to the component tree. bool destroyBodiesOnRemove = true; + final Set _bodies = {}; + + /// The bodies that have been created through [createBody] and not yet + /// destroyed. + /// + /// Bodies created directly on the [physicsWorld] are not included. + late final Set bodies = UnmodifiableSetView(_bodies); + @override void update(double dt) { - physicsWorld.stepDt(dt); + physicsWorld.step(dt, subStepCount: subStepCount); + contactEventsDispatcher.dispatch( + physicsWorld.contactEvents, + physicsWorld.sensorEvents, + ); } - Body createBody(BodyDef def) { - return physicsWorld.createBody(def); + Body createBody([BodyDef? def]) { + final body = physicsWorld.createBody(def); + _bodies.add(body); + return body; } void destroyBody(Body body) { - physicsWorld.destroyBody(body); + _bodies.remove(body); + if (body.isValid) { + body.destroy(); + } } - void createJoint(forge2d.Joint joint) { - physicsWorld.createJoint(joint); + /// Casts a ray from [origin] along [translation] and returns the closest + /// hit, or null when nothing is hit. + RayHit? castRayClosest( + Vector2 origin, + Vector2 translation, { + QueryFilter? filter, + }) { + return physicsWorld.castRayClosest(origin, translation, filter: filter); } - void destroyJoint(forge2d.Joint joint) { - physicsWorld.destroyJoint(joint); + /// Casts a ray from [origin] along [translation], invoking [callback] for + /// every candidate hit in an arbitrary order. + /// + /// The callback controls the rest of the cast with its return value: + /// -1 to ignore the hit, 0 to stop, the hit's fraction to clip the ray to + /// the hit, or 1 to continue looking without clipping. + void castRay( + Vector2 origin, + Vector2 translation, + double Function(RayHit hit) callback, { + QueryFilter? filter, + }) { + physicsWorld.castRay(origin, translation, callback, filter: filter); } - void raycast(RayCastCallback callback, Vector2 point1, Vector2 point2) { - physicsWorld.raycast(callback, point1, point2); + /// Casts a ray from [origin] along [translation] and returns every hit, + /// sorted from nearest to farthest. + List castRayAll( + Vector2 origin, + Vector2 translation, { + QueryFilter? filter, + }) { + return physicsWorld.castRayAll(origin, translation, filter: filter); } - void clearForces() { - physicsWorld.clearForces(); + /// Returns all shapes whose bounding boxes overlap [aabb]. + List overlapAabb(Aabb aabb, {QueryFilter? filter}) { + return physicsWorld.overlapAabb(aabb, filter: filter); } - void queryAABB(forge2d.QueryCallback callback, AABB aabb) { - physicsWorld.queryAABB(callback, aabb); + /// A callback that runs before contacts are solved, which can veto a + /// contact for the step by returning false. + /// + /// Only called for contacts between shapes that have + /// [ShapeDef.enablePreSolveEvents] set. The callback runs during the + /// physics step and must not access the world. + set preSolveCallback( + bool Function(Shape shapeA, Shape shapeB, Vector2 normal)? callback, + ) { + physicsWorld.preSolveCallback = callback; } - void raycastParticle( - forge2d.ParticleRaycastCallback callback, - Vector2 point1, - Vector2 point2, + /// A callback that decides whether two shapes that pass the regular + /// category filtering may collide. + /// + /// The callback runs during the physics step and must not access the + /// world. + set customFilterCallback( + bool Function(Shape shapeA, Shape shapeB)? callback, ) { - physicsWorld.particleSystem.raycast(callback, point1, point2); + physicsWorld.customFilterCallback = callback; } /// Don't change the gravity object directly, use the setter instead. Vector2 get gravity => physicsWorld.gravity; - /// Sets the gravity of the world and wakes up all bodies. + /// Sets the gravity of the world and wakes up all bodies that were created + /// through [createBody]. set gravity(Vector2? gravity) { physicsWorld.gravity = gravity ?? defaultGravity; - for (final body in physicsWorld.bodies) { - body.setAwake(true); + for (final body in _bodies) { + body.isAwake = true; } } } diff --git a/packages/flame_forge2d/lib/world_contact_listener.dart b/packages/flame_forge2d/lib/world_contact_listener.dart deleted file mode 100644 index 47477da5618..00000000000 --- a/packages/flame_forge2d/lib/world_contact_listener.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:flame_forge2d/flame_forge2d.dart'; - -/// Listens to the entire [World]'s contact events. -/// -/// It propagates the contact events ([beginContact], [endContact], [preSolve], -/// [postSolve]) to [ContactCallbacks]s when a [Body] or at least one of its -// fixtures' `userData` is set to a [ContactCallbacks]. -/// -/// {@template flame_forge2d.world_contact_listener.algorithm} -/// If the [Body] `userData` is set to a [ContactCallbacks] the contact events -/// of this will be called when any [Body]'s fixture contacts another [Fixture]. -/// -/// If instead you wish to be more specific and only trigger contact events -/// when a specific [Body]'s fixture contacts another [Fixture], you can -/// set the fixture `userData` to a [ContactCallbacks]. -/// -/// If the colliding [Fixture] `userData` and [Body] `userData` are `null`, then -/// the contact events are not called. -/// -/// The described behavior is a simple out of the box solution to propagate -/// contact events. If you wish to implement your own logic you can subclass -/// [ContactListener] and provide it to your [Forge2DGame]. -/// {@endtemplate} -class WorldContactListener extends ContactListener { - void _callback( - Contact contact, - void Function(ContactCallbacks contactCallback, Object other) callback, - ) { - final userData = { - contact.bodyA.userData, - contact.fixtureA.userData, - contact.bodyB.userData, - contact.fixtureB.userData, - }.whereType(); - - for (final contactCallback in userData.whereType()) { - for (final object in userData) { - if (object != contactCallback) { - callback(contactCallback, object); - } - } - } - } - - @override - void beginContact(Contact contact) { - _callback( - contact, - (contactCallback, other) => contactCallback.beginContact(other, contact), - ); - } - - @override - void endContact(Contact contact) { - _callback( - contact, - (contactCallback, other) => contactCallback.endContact(other, contact), - ); - } - - @override - void preSolve(Contact contact, Manifold oldManifold) { - _callback( - contact, - (contactCallback, other) => - contactCallback.preSolve(other, contact, oldManifold), - ); - } - - @override - void postSolve(Contact contact, ContactImpulse impulse) { - _callback( - contact, - (contactCallback, other) => - contactCallback.postSolve(other, contact, impulse), - ); - } -} diff --git a/packages/flame_forge2d/pubspec.yaml b/packages/flame_forge2d/pubspec.yaml index 16cec33702f..b066aa9d651 100644 --- a/packages/flame_forge2d/pubspec.yaml +++ b/packages/flame_forge2d/pubspec.yaml @@ -20,7 +20,13 @@ dependencies: flame: ^1.38.0 flutter: sdk: flutter - forge2d: ^0.14.2 + # TODO(spydon): Switch back to a pub version constraint once the Box2D v3 + # based forge2d has been published. + forge2d: + git: + url: https://github.com/flame-engine/forge2d.git + ref: box2d-wasm + path: packages/forge2d dev_dependencies: dartdoc: ^9.0.0 diff --git a/packages/flame_forge2d/test/body_component_test.dart b/packages/flame_forge2d/test/body_component_test.dart index 67423f81bc6..8e0e8db16d2 100644 --- a/packages/flame_forge2d/test/body_component_test.dart +++ b/packages/flame_forge2d/test/body_component_test.dart @@ -1,5 +1,3 @@ -// ignore_for_file: invalid_use_of_internal_member - import 'dart:math'; import 'package:flame/components.dart' @@ -12,8 +10,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; -import 'helpers/mocks.dart'; - class _TestBodyComponent extends BodyComponent with TapCallbacks { int tapCount = 0; @@ -55,11 +51,10 @@ void main() { final testPaint = Paint()..color = const Color(0xffff0000); flameTester.testGameWidget( - 'a CircleShape', + 'a Circle', setUp: (game, tester) async { final body = game.world.createBody(BodyDef()); - final shape = CircleShape()..radius = 5; - body.createFixture(FixtureDef(shape)); + body.createShape(Circle(radius: 5)); final component = _TestBodyComponent() ..body = body @@ -68,18 +63,21 @@ void main() { game.camera.follow(component); }, + verify: (game, tester) async { + await expectLater( + find.byGame(), + matchesGoldenFile(goldenPath('circle_shape')), + ); + }, ); flameTester.testGameWidget( - 'an EdgeShape', + 'a Segment', setUp: (game, tester) async { final body = game.world.createBody(BodyDef()); - final shape = EdgeShape() - ..set( - Vector2.zero(), - Vector2.all(10), - ); - body.createFixture(FixtureDef(shape)); + body.createShape( + Segment(point1: Vector2.zero(), point2: Vector2.all(10)), + ); final component = _TestBodyComponent() ..body = body @@ -91,24 +89,49 @@ void main() { verify: (game, tester) async { await expectLater( find.byGame(), - matchesGoldenFile(goldenPath('edge_shape')), + matchesGoldenFile(goldenPath('segment_shape')), ); }, ); flameTester.testGameWidget( - 'a PolygonShape', + 'a Capsule', setUp: (game, tester) async { final body = game.world.createBody(BodyDef()); - final shape = PolygonShape() - ..set( - [ - Vector2.zero(), - Vector2.all(10), - Vector2(0, 10), - ], - ); - body.createFixture(FixtureDef(shape)); + body.createShape( + Capsule( + center1: Vector2.zero(), + center2: Vector2.all(10), + radius: 3, + ), + ); + + final component = _TestBodyComponent() + ..body = body + ..paint = testPaint; + await game.world.add(component); + + game.camera.follow(component); + }, + verify: (game, tester) async { + await expectLater( + find.byGame(), + matchesGoldenFile(goldenPath('capsule_shape')), + ); + }, + ); + + flameTester.testGameWidget( + 'a Polygon', + setUp: (game, tester) async { + final body = game.world.createBody(BodyDef()); + body.createShape( + Polygon([ + Vector2.zero(), + Vector2.all(10), + Vector2(0, 10), + ]), + ); final component = _TestBodyComponent() ..body = body @@ -117,8 +140,8 @@ void main() { game.camera.follow(component); - // a PolygonShape contains point - expect(component.containsPoint(Vector2.all(10)), isTrue); + // a Polygon contains point + expect(component.containsPoint(Vector2(2, 8)), isTrue); }, verify: (game, tester) async { await expectLater( @@ -129,18 +152,21 @@ void main() { ); flameTester.testGameWidget( - 'an open ChainShape', + 'an open Chain', setUp: (game, tester) async { final body = game.world.createBody(BodyDef()); - final shape = ChainShape() - ..createChain( - [ + // The first and last points of an open chain are ghost anchors + // and are not part of the collidable (and rendered) segments. + body.createChain( + ChainDef( + points: [ + Vector2(-10, 0), Vector2.zero(), Vector2.all(10), Vector2(10, 0), ], - ); - body.createFixture(FixtureDef(shape)); + ), + ); final component = _TestBodyComponent() ..body = body @@ -158,18 +184,20 @@ void main() { ); flameTester.testGameWidget( - 'a closed ChainShape', + 'a closed Chain', setUp: (game, tester) async { final body = game.world.createBody(BodyDef()); - final shape = ChainShape() - ..createLoop( - [ + body.createChain( + ChainDef( + points: [ Vector2.zero(), - Vector2.all(10), Vector2(10, 0), + Vector2.all(10), + Vector2(0, 10), ], - ); - body.createFixture(FixtureDef(shape)); + isLoop: true, + ), + ); final component = _TestBodyComponent() ..body = body @@ -188,85 +216,96 @@ void main() { }); }); - group('renderFixture', () { + group('renderShape', () { group('returns normally', () { late Canvas canvas; + late World world; late Body body; setUp(() { canvas = _MockCanvas(); - final world = World(); + world = World(); body = world.createBody(BodyDef()); }); - test('when rendering a CircleShape', () { + tearDown(() { + world.destroy(); + }); + + test('when rendering a Circle', () { final component = _TestBodyComponent(); - final shape = CircleShape()..radius = 5; - final fixture = body.createFixture( - FixtureDef(shape), + final shape = body.createShape(Circle(radius: 5)); + + expect( + () => component.renderShape(canvas, shape), + returnsNormally, + ); + }); + + test('when rendering a Segment', () { + final component = _TestBodyComponent(); + final shape = body.createShape( + Segment(point1: Vector2.zero(), point2: Vector2.all(10)), ); expect( - () => component.renderFixture(canvas, fixture), + () => component.renderShape(canvas, shape), returnsNormally, ); }); - test('when rendering an EdgeShape', () { + test('when rendering a Capsule', () { final component = _TestBodyComponent(); - final shape = EdgeShape() - ..set( - Vector2.zero(), - Vector2.all(10), - ); - final fixture = body.createFixture( - FixtureDef(shape), + final shape = body.createShape( + Capsule( + center1: Vector2.zero(), + center2: Vector2.all(10), + radius: 2, + ), ); expect( - () => component.renderFixture(canvas, fixture), + () => component.renderShape(canvas, shape), returnsNormally, ); }); - test('when rendering a PolygonShape', () { + test('when rendering a Polygon', () { final component = _TestBodyComponent(); - final shape = PolygonShape() - ..set( - [ - Vector2.zero(), - Vector2.all(10), - Vector2(0, 10), - ], - ); - final fixture = body.createFixture( - FixtureDef(shape), + final shape = body.createShape( + Polygon([ + Vector2.zero(), + Vector2.all(10), + Vector2(0, 10), + ]), ); expect( - () => component.renderFixture(canvas, fixture), + () => component.renderShape(canvas, shape), returnsNormally, ); }); - test('when rendering a ChainShape', () { + test('when rendering a chain segment', () { final component = _TestBodyComponent(); - final shape = ChainShape() - ..createChain( - [ + final chain = body.createChain( + ChainDef( + points: [ + Vector2(-10, 0), Vector2.zero(), Vector2.all(10), Vector2(10, 0), ], - ); - final fixture = body.createFixture( - FixtureDef(shape), + ), ); - expect( - () => component.renderFixture(canvas, fixture), - returnsNormally, - ); + expect(chain.segments, isNotEmpty); + for (final shape in chain.segments) { + expect( + () => component.renderShape(canvas, shape), + returnsNormally, + ); + } }); }); }); @@ -280,15 +319,13 @@ void main() { setUp: (game, tester) async { final bodyDef = BodyDef(); final body = game.world.createBody(bodyDef); - final shape = PolygonShape() - ..set( - [ - Vector2.zero(), - Vector2.all(10), - Vector2(0, 10), - ], - ); - body.createFixture(FixtureDef(shape)); + body.createShape( + Polygon([ + Vector2.zero(), + Vector2.all(10), + Vector2(0, 10), + ]), + ); final component = _TestBodyComponent() ..body = body @@ -310,22 +347,6 @@ void main() { ); }); - group('BodyComponent contact events', () { - test('beginContact called', () { - final contactCallback = MockContactCallback(); - final contact = MockContact(); - final bodyA = MockBody()..angularDamping = 1.0; - final fixtureA = MockFixture(); - when(() => bodyA.userData).thenReturn(contactCallback); - when(() => fixtureA.userData).thenReturn(Object()); - contactCallback.beginContact(fixtureA.userData!, contact); - - verify( - () => contactCallback.beginContact(fixtureA.userData!, contact), - ).called(1); - }); - }); - group('PositionComponent parented by BodyComponent', () { final flameTester = FlameTester(Forge2DGame.new); @@ -333,13 +354,12 @@ void main() { 'absoluteAngle', setUp: (game, tester) async { // Creates a body with an angle of 2 radians - final body = game.world.createBody(BodyDef(angle: 2.0)); - final shape = EdgeShape() - ..set( - Vector2.zero(), - Vector2.all(10), - ); - body.createFixture(FixtureDef(shape)); + final body = game.world.createBody( + BodyDef(rotation: Rot.fromAngle(2.0)), + ); + body.createShape( + Segment(point1: Vector2.zero(), point2: Vector2.all(10)), + ); final bodyComponent = _TestBodyComponent()..body = body; // Creates a positional component with an angle of 1 radians @@ -358,8 +378,10 @@ void main() { expect(bodyComponent.children.length, 1); expect(positionComponent.children.length, 0); - // Expects the absolute angle to be (2 + 1) radians - expect(positionComponent.absoluteAngle, 3.0); + // Expects the absolute angle to be (2 + 1) radians, within the + // precision that survives the round trip through the physics + // engine's 32-bit floats. + expect(positionComponent.absoluteAngle, closeTo(3.0, 1e-6)); }, ); }); @@ -374,7 +396,7 @@ void main() { final flameTester = FlameTester(Forge2DGame.new); flameTester.testGameWidget( - 'with no fixtures', + 'with no shapes', setUp: (game, tester) async { final bodyComponent = BodyComponent( bodyDef: BodyDef(position: Vector2(33, 44)), @@ -391,14 +413,14 @@ void main() { ); flameTester.testGameWidget( - 'with a set of fixtures', + 'with a set of shapes', setUp: (game, tester) async { final bodyComponent = BodyComponent( bodyDef: BodyDef(), - fixtureDefs: [ - FixtureDef(CircleShape()..radius = 10), - FixtureDef(CircleShape()..radius = 20), - FixtureDef(CircleShape()..radius = 30), + shapeSpecs: [ + ShapeSpec(Circle(radius: 10)), + ShapeSpec(Circle(radius: 20)), + ShapeSpec(Circle(radius: 30)), ], key: ComponentKey.named('tested'), ); @@ -406,9 +428,10 @@ void main() { }, verify: (game, tester) async { final bodyComponent = game.findByKeyName('tested')!; - expect(bodyComponent.body.fixtures[0].shape.radius, 10); - expect(bodyComponent.body.fixtures[1].shape.radius, 20); - expect(bodyComponent.body.fixtures[2].shape.radius, 30); + final radii = bodyComponent.body.shapes + .map((shape) => (shape.geometry as Circle).radius) + .toSet(); + expect(radii, {10.0, 20.0, 30.0}); }, ); }); @@ -420,19 +443,11 @@ void main() { final zoom = game.camera.viewfinder.zoom; final position = Vector2.all(10); final body = game.world.createBody( - BodyDef(position: position, angle: pi / 2), + BodyDef(position: position, rotation: Rot.fromAngle(pi / 2)), ); - body.createFixtureFromShape( - CircleShape() - ..radius = 1 - ..position.setFrom(Vector2(3, 0)), - ); - body.createFixtureFromShape( - CircleShape() - ..radius = 1 - ..position.setFrom(Vector2(-3, 0)), - ); + body.createShape(Circle(radius: 1, center: Vector2(3, 0))); + body.createShape(Circle(radius: 1, center: Vector2(-3, 0))); final component = _TestBodyComponent()..body = body; await game.world.ensureAdd(component); @@ -485,12 +500,7 @@ void main() { 'BodyComponent.world consistency in onRemove', Forge2DGame.new, (game) async { - final bodyDef = BodyDef(); - final body = game.world.createBody(bodyDef); - final shape = CircleShape()..radius = 5; - body.createFixture(FixtureDef(shape)); - - final component = _ConsistentBodyComponent(bodyDef: bodyDef); + final component = _ConsistentBodyComponent(bodyDef: BodyDef()); await game.world.add(component); await game.ready(); component.removeFromParent(); @@ -505,12 +515,7 @@ void main() { 'BodyComponent.world consistency in onRemove with world change', Forge2DGame.new, (game) async { - final bodyDef = BodyDef(); - final body = game.world.createBody(bodyDef); - final shape = CircleShape()..radius = 5; - body.createFixture(FixtureDef(shape)); - - final component = _ConsistentBodyComponent(bodyDef: bodyDef); + final component = _ConsistentBodyComponent(bodyDef: BodyDef()); await game.world.add(component); await game.ready(); game.world = Forge2DWorld(); diff --git a/packages/flame_forge2d/test/contact_callbacks_test.dart b/packages/flame_forge2d/test/contact_callbacks_test.dart index 518bcf12462..808812a0071 100644 --- a/packages/flame_forge2d/test/contact_callbacks_test.dart +++ b/packages/flame_forge2d/test/contact_callbacks_test.dart @@ -2,26 +2,31 @@ import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:test/expect.dart'; import 'package:test/scaffolding.dart'; -import 'helpers/helpers.dart'; - void main() { group('ContactCallbacks', () { late Object other; + late World world; late Contact contact; - late Manifold manifold; - late ContactImpulse contactImpulse; setUp(() { other = Object(); - contact = MockContact(); - manifold = MockManifold(); - contactImpulse = MockContactImpulse(); + world = World(); + final bodyA = world.createBody(); + final bodyB = world.createBody(); + contact = Contact.end( + shapeA: bodyA.createShape(Circle(radius: 1)), + shapeB: bodyB.createShape(Circle(radius: 1)), + ); + }); + + tearDown(() { + world.destroy(); }); test('beginContact calls onBeginContact', () { final contactCallbacks = ContactCallbacks(); var called = 0; - contactCallbacks.onBeginContact = (_, __) => called++; + contactCallbacks.onBeginContact = (_, _) => called++; contactCallbacks.beginContact(other, contact); @@ -31,31 +36,11 @@ void main() { test('endContact calls onEndContact', () { final contactCallbacks = ContactCallbacks(); var called = 0; - contactCallbacks.onEndContact = (_, __) => called++; + contactCallbacks.onEndContact = (_, _) => called++; contactCallbacks.endContact(other, contact); expect(called, equals(1)); }); - - test('preSolve calls onPreSolve', () { - final contactCallbacks = ContactCallbacks(); - var called = 0; - contactCallbacks.onPreSolve = (_, __, ___) => called++; - - contactCallbacks.preSolve(other, contact, manifold); - - expect(called, equals(1)); - }); - - test('postSolve calls on postSolve', () { - final contactCallbacks = ContactCallbacks(); - var called = 0; - contactCallbacks.onPostSolve = (_, __, ___) => called++; - - contactCallbacks.postSolve(other, contact, contactImpulse); - - expect(called, equals(1)); - }); }); } diff --git a/packages/flame_forge2d/test/contact_events_dispatcher_test.dart b/packages/flame_forge2d/test/contact_events_dispatcher_test.dart new file mode 100644 index 00000000000..b4164b3715c --- /dev/null +++ b/packages/flame_forge2d/test/contact_events_dispatcher_test.dart @@ -0,0 +1,251 @@ +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:test/expect.dart'; +import 'package:test/scaffolding.dart'; + +import 'helpers/mocks.dart'; + +void main() { + group('ContactEventsDispatcher', () { + late ContactEventsDispatcher dispatcher; + late ContactCallbacks contactCallback; + late World world; + late Body bodyA; + late Body bodyB; + late Shape shapeA; + late Shape shapeB; + + setUp(() { + dispatcher = ContactEventsDispatcher(); + contactCallback = MockContactCallback(); + world = World(); + bodyA = world.createBody(); + bodyB = world.createBody(); + shapeA = bodyA.createShape(Circle(radius: 1)); + shapeB = bodyB.createShape(Circle(radius: 1)); + }); + + tearDown(() { + world.destroy(); + }); + + setUpAll(() { + registerFallbackValue(Object()); + }); + + Contact beginContact() => Contact.begin( + shapeA: shapeA, + shapeB: shapeB, + normal: Vector2(0, 1), + points: [], + ); + + group('beginContact', () { + test("doesn't callback if other userData are null", () { + bodyA.userData = contactCallback; + + final contact = beginContact(); + dispatcher.beginContact(contact); + + verifyNever( + () => contactCallback.beginContact(any(), contact), + ); + }); + + test('callbacks for userData when not null', () { + bodyA.userData = contactCallback; + bodyB.userData = Object(); + shapeA.userData = Object(); + shapeB.userData = Object(); + + final contact = beginContact(); + dispatcher.beginContact(contact); + + verify( + () => contactCallback.beginContact(bodyB.userData!, contact), + ).called(1); + verify( + () => contactCallback.beginContact(shapeA.userData!, contact), + ).called(1); + verify( + () => contactCallback.beginContact(shapeB.userData!, contact), + ).called(1); + }); + + test("doesn't callback itself", () { + bodyA.userData = contactCallback; + bodyB.userData = contactCallback; + + final contact = beginContact(); + dispatcher.beginContact(contact); + + verifyNever( + () => contactCallback.beginContact(any(), contact), + ); + }); + }); + + group('endContact', () { + test("doesn't callback if other userData are null", () { + bodyA.userData = contactCallback; + + final contact = Contact.end(shapeA: shapeA, shapeB: shapeB); + dispatcher.endContact(contact); + + verifyNever( + () => contactCallback.endContact(any(), contact), + ); + }); + + test('callbacks for userData when not null', () { + bodyA.userData = contactCallback; + bodyB.userData = Object(); + shapeA.userData = Object(); + shapeB.userData = Object(); + + final contact = Contact.end(shapeA: shapeA, shapeB: shapeB); + dispatcher.endContact(contact); + + verify( + () => contactCallback.endContact(bodyB.userData!, contact), + ).called(1); + verify( + () => contactCallback.endContact(shapeA.userData!, contact), + ).called(1); + verify( + () => contactCallback.endContact(shapeB.userData!, contact), + ).called(1); + }); + + test('skips shapes that have already been destroyed', () { + bodyA.userData = contactCallback; + shapeA.userData = Object(); + final otherUserData = Object(); + bodyB.userData = otherUserData; + bodyB.destroy(); + + final contact = Contact.end(shapeA: shapeA, shapeB: shapeB); + expect(() => dispatcher.endContact(contact), returnsNormally); + + verify( + () => contactCallback.endContact(shapeA.userData!, contact), + ).called(1); + verifyNever( + () => contactCallback.endContact(otherUserData, contact), + ); + }); + }); + + group('sensor contacts', () { + test('sensor and visitor are exposed through the contact', () { + final contact = Contact.sensor(sensor: shapeA, visitor: shapeB); + + expect(contact.isSensorEvent, isTrue); + expect(contact.sensor, shapeA); + expect(contact.visitor, shapeB); + expect(contact.normal, isNull); + expect(contact.points, isNull); + }); + + test('dispatches to the involved userData', () { + bodyA.userData = contactCallback; + bodyB.userData = Object(); + + final contact = Contact.sensor(sensor: shapeA, visitor: shapeB); + dispatcher.beginContact(contact); + + verify( + () => contactCallback.beginContact(bodyB.userData!, contact), + ).called(1); + }); + }); + }); + + group('ContactEventsDispatcher in a Forge2DGame', () { + testWithGame( + 'begin and end contacts are dispatched for contact events', + Forge2DGame.new, + (game) async { + final contactCallbacks = ContactCallbacks(); + var beginCount = 0; + var endCount = 0; + contactCallbacks.onBeginContact = (_, contact) { + expect(contact.isSensorEvent, isFalse); + beginCount++; + }; + contactCallbacks.onEndContact = (_, _) => endCount++; + + final world = game.world; + final ground = world.createBody( + BodyDef(position: Vector2(0, 2), userData: Object()), + ); + ground.createShape( + Polygon.box(10, 1), + ShapeDef(enableContactEvents: true), + ); + final ball = world.createBody( + BodyDef(type: BodyType.dynamic, userData: contactCallbacks), + ); + ball.createShape( + Circle(radius: 1), + ShapeDef(enableContactEvents: true), + ); + + await game.ready(); + for (var i = 0; i < 30; i++) { + game.update(1 / 60); + } + expect(beginCount, equals(1)); + expect(endCount, equals(0)); + + // The resting ball may have fallen asleep, and a sleeping body's + // contacts are not updated, so wake it up alongside the teleport. + ball.setTransform(Vector2(100, 0), const Rot.identity()); + ball.isAwake = true; + game.update(1 / 60); + expect(endCount, equals(1)); + }, + ); + + testWithGame( + 'begin and end contacts are dispatched for sensor events', + Forge2DGame.new, + (game) async { + final contactCallbacks = ContactCallbacks(); + var beginCount = 0; + var endCount = 0; + contactCallbacks.onBeginContact = (_, contact) { + expect(contact.isSensorEvent, isTrue); + beginCount++; + }; + contactCallbacks.onEndContact = (_, _) => endCount++; + + final world = game.world; + final sensorBody = world.createBody( + BodyDef(userData: contactCallbacks), + ); + sensorBody.createShape( + Circle(radius: 2), + ShapeDef(isSensor: true, enableSensorEvents: true), + ); + final visitor = world.createBody( + BodyDef(type: BodyType.dynamic, userData: Object()), + ); + visitor.createShape( + Circle(radius: 1), + ShapeDef(enableSensorEvents: true), + ); + + await game.ready(); + game.update(1 / 60); + expect(beginCount, equals(1)); + expect(endCount, equals(0)); + + visitor.setTransform(Vector2(100, 0), const Rot.identity()); + game.update(1 / 60); + expect(endCount, equals(1)); + }, + ); + }); +} diff --git a/packages/flame_forge2d/test/forge2d_world_test.dart b/packages/flame_forge2d/test/forge2d_world_test.dart index 7c269d7f624..34af542915b 100644 --- a/packages/flame_forge2d/test/forge2d_world_test.dart +++ b/packages/flame_forge2d/test/forge2d_world_test.dart @@ -13,14 +13,15 @@ void main() { () => Forge2DGame(world: _TestForge2DWorld()), (game) async { await game.ready(); - final bodyDef = BodyDef()..type = BodyType.dynamic; + final bodyDef = BodyDef(type: BodyType.dynamic); final component = BodyComponent(bodyDef: bodyDef); await game.world.ensureAdd(component); final body = component.body; game.world.removeFromParent(); await game.ready(); - expect(game.world.physicsWorld.bodies, isNot(contains(body))); + expect(game.world.bodies, isNot(contains(body))); + expect(body.isValid, isFalse); }, ); @@ -32,14 +33,58 @@ void main() { ), (game) async { await game.ready(); - final bodyDef = BodyDef()..type = BodyType.dynamic; + final bodyDef = BodyDef(type: BodyType.dynamic); final component = BodyComponent(bodyDef: bodyDef); await game.world.ensureAdd(component); final body = component.body; game.world.removeFromParent(); await game.ready(); - expect(game.world.physicsWorld.bodies, contains(body)); + expect(game.world.bodies, contains(body)); + expect(body.isValid, isTrue); + }, + ); + + testWithGame( + 'subStepCount defaults to 4 and can be changed', + Forge2DGame.new, + (game) async { + expect(game.world.subStepCount, 4); + game.world.subStepCount = 8; + expect(game.world.subStepCount, 8); + }, + ); + + testWithGame( + 'gravity setter wakes up bodies', + Forge2DGame.new, + (game) async { + final body = game.world.createBody(BodyDef(type: BodyType.dynamic)); + body.createShape(Circle(radius: 1)); + body.isAwake = false; + expect(body.isAwake, isFalse); + + game.world.gravity = Vector2(0, 5); + + expect(game.world.gravity, Vector2(0, 5)); + expect(body.isAwake, isTrue); + }, + ); + + testWithGame( + 'castRayClosest returns the closest hit', + Forge2DGame.new, + (game) async { + final near = game.world.createBody(BodyDef(position: Vector2(10, 0))); + near.createShape(Circle(radius: 1)); + final far = game.world.createBody(BodyDef(position: Vector2(15, 0))); + far.createShape(Circle(radius: 1)); + + final hit = game.world.castRayClosest(Vector2.zero(), Vector2(20, 0)); + + expect(hit, isNotNull); + expect(hit!.shape.body, near); + expect(hit.point.x, closeTo(9, 1e-4)); }, ); } diff --git a/packages/flame_forge2d/test/goldens/body_component/capsule_shape.png b/packages/flame_forge2d/test/goldens/body_component/capsule_shape.png new file mode 100644 index 0000000000000000000000000000000000000000..025f6bfc3c9343a3428f48d8a5f08b2eafd3d6cb GIT binary patch literal 26372 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 literal 0 HcmV?d00001 diff --git a/packages/flame_forge2d/test/goldens/body_component/chain_shape.png b/packages/flame_forge2d/test/goldens/body_component/chain_shape.png deleted file mode 100644 index ea1420b6372a741116d652496b2befd2f2af54b5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22052 zcmeI4ZETh08ON`d0;kf_0s<9kr7ci{yi}{M6RC&V(iTcRYS%U@`YdCWsVj$+)UG;u zYl@)}S`e&ASa%X5X^g?T32F9)Cu)W+&ea5KWxAcjsL0wwW#6>+XThNKHlh74xnTk@Tv{vb#i5J&~!Q(3He01Mgo*N&J=6d{;%O z9DX z@am(RE1vnNGP8VdGPlxVF3_`09#ZoucdQ%YQxi zQj<3a1+sSLkAtJ#!bwTG#7{f*o~-qHtQ-Cr^sM2+y;%58th97B77k&ho?Klx-b67E zVhu&8jW4IT5bsW>xJbp=4`9W*S!&eaUM*bnDU3Q*u15KD%G8ms7a$h%^E4Ls%u?t3 zXCoHNuos5AU~(NyexyoG4o`#Hqfi@P0=4%-ZFD=-jzVoER*Gk;O}l5P8b1SCOOOGx z0yaHfisSIV?$9t6uY;ID@pg!JKy0mmxPl_wlg$*@;N1d>WfWo5`5K7Vz^d_QvBgUm z*3IxQgnNsp!@oCSWJqm`Cu^YmzaTQvJsK$Q0o=JMtbyt;z@0ZDnZw;sGY>^GLW_67 zrgJDxGaYIk%hX7P3p7&ZEW#MehuV`+JNP`*Zh+c>Cm^n;*a>kJMcTNGBJaH#VirxF z0+Z8Va^D>Y)FjyW5URxY@`9oYWH`j+E@-&sxH%5M4pE8i&4paOy(@a z;smPER6);Y1zMb@lj35CZ&PH|BqLJI*k0ioFzW9}P%IOXx(_x+b|Mxz@b=nDHL7qb zY`hYcoJDW9L(lKI@D?n*1uOLn7S6&-4e&O;jp7c~6ThD#)W%CF?#8>cqG2B*bsMZ$ zTCGM6K8HxnDbQVgigi98wfZ&7WIc-WBr~!GvG{qaR;O2#rwMAI$+<|-d{{9Iweee^ z_7SL!?t#EZmBikM~ks ztM85hbuc-40C9W}4#o4~ZaZu?-N@giJeZZI?+S0l9vS`> z79K!#j#R_J`xa}=TTo$g05M;QLXbbIyXIqhe@J8Q*C5E@W>w?Og-x+(8uNaVVQ`mP z+(5Ob;QY%}yA?67quM4!xsFd8P1rJZjB*o-wT|93@m|!0JOz`lL;H|7VDf3$=ttDX z*bRt9;XyU3Z!Mg!29av1_7JRi2`yXho}nH5Svvm(7GA=I_h~G`li|E~4`R_m@gazf z6v1Dw4kGzmm=B}K-~B8{^7kD!aPqg2-jcsX^cMUz?=d6bueXp3gTGm9yX5aqbRHpp z$Ixn|2ci^AR7?IIW_6Ok^-K`>YxZF!^7ksJA%Ej{!wB-%FMx>x{+i!X1b{uLoAvB@OOy&b^!bx zVhs&|zeB8K^7mmn3;vo53>5eqyP4?&fBX1+ME;(jTJrZa_bvH*gwBJ%<`ymt{)W@( zJoxJoX5_Ee#CO48k8CsGZy_IY$lv{Z$RU525!^7lL+T*%+}3YrD}GSyM#ulbk@ zgTMZ}G#UK$>Uk&l+fOo(zr!Sh0e?qG2J*LsK_-7!QZ4vv-ltmd*Z%|6g1_F6s22S7 z%B`r)-@D0*+U>nXR#g6avnhhV{mG~c^0$xk2Kl>$nFoK(OSA&~4O_L~Z)_e{0)Hc& zOg;G9$BD@Ye``qw^0$$j!UKOBnJ&)19DjxU<@hV$uN{8{{I%n+fWLP974X-NzXJYp z{1wi>9DgOE{52eZ1^o3n{*u3T{3Uwh&2mW&W74ml(%LM#29DfD;wd1dVzjpi;@R#GS81R?luYkWCe}(+z_$%bE9e>GR zJN^pzTg-_`z+XH53i#W~KFj&HmE*5~zpdOOQqUR92X_1w@YjyNcrKFtCR0(tZtLIP2x6*+a#y~_c&_f;2td)3D+K7dvxv5 zwgtBv+-`8Y;Xipdghx-l8rbJAX#d*}N0xWV;)(y}(VMB?98&Y$%gCaO3F6@DgmL(n b7sMfnCf|Dc#MHG%0}WN>HD!lOy(j+(|F#;l diff --git a/packages/flame_forge2d/test/goldens/body_component/chain_shape_closed.png b/packages/flame_forge2d/test/goldens/body_component/chain_shape_closed.png index 0f2ec6783a624fe63a133999537be76d4b50941a..5b20862bbb13a8fb9e8e52d1e63bb359eebef698 100644 GIT binary patch literal 21099 zcmeHPOK1~O6upzCHE9#`p@^Sal2&LIU1;ONg<=yD+S$~!w8SEnfTbuwq(mfzR)@NY z%OFaFQkUJ>P#2Mko2(k8MXQkdL+D0|MTsbl;(tEx`GQLk#OBN*bLYc@Iq%$a@4N4k zOOc)~d;QLOBC^Zfp*|vOjA)C+Qm5XTFMc+wCvzew_gmCs+%lX}ucbs^SCFy~nqCum zNDc-1lh@xo57^1;%;#%=OqcdAJG`0Rh&1H?U{~Kajzak@H%W=^nEQ3nQnQWQox^z( zw^O#G+@!SBz->=QOyYK^uAQ68J*Af0P7KbW04!h&=K`Dv+QPU1$wjbWR6q7=-Xvc zMB9to2GxOyfddG^qz{AuAqd_8ZyR(;K9;P0Ti{7kRfa6X)XB zG7mF%vdyM<`LU6${>0!RwUJV}(56po>`AuuB$dy6Y0%~;dAMMVq>V<$uV9LGAk?1} zn9rerAVA6(7ZfPGXaGmx;|k$HKERL)mAO%D)t;_iU0j#w$or^jm*k2i_U@=dd#)eJ0i+`w3ouMD z4CzR#>ti3TMJleS4$W1Tm%_GtXRLlM`ZeFySL|ebfFIpgO)7Ux}?=#sTbh zav(Oj%E9O1^PK9)Y~T&>26%&t0Hgp?04ex~Qjm)m^kU=1ud{1&cMnX;qLFUOEV|@y j+n6I@V&I^F5Py>prd{ssk6+n*1KOZ+xF?hi#wLFPJZ4ia literal 22067 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 IeE;Tu0z^p4@&Et; diff --git a/packages/flame_forge2d/test/goldens/body_component/chain_shape_open.png b/packages/flame_forge2d/test/goldens/body_component/chain_shape_open.png index 3d13ca754967e0ef750308353c28fc94ebb72b14..6ff23f219e92df3d74a2e0ca553b80377ab63bdd 100644 GIT binary patch literal 22015 zcmeI4ZERF!7ROIZi&Jc6pbJ5%S{jN~3Kk?dAi@*|+5&cGg=iBOy{_n@OGsVN5GC@6 zMJfmpU$6qfiOB9ID>`Z<#Fud6ml`pq9|XI|u6H*ZMD6PMk}kp5ne+c2t6%Yh7@qmU zJe`^6ch7mweSY`cPI(~R+Eg**ydfe|(bAl{TBIxxIV%<$od3!GLnlk}|CFqp-Lg2A zf2@fu?aYshR$kpSTb_Np>O+xgX-PFMUcIyLNMnVJiHBk7ce_5F(ld0-6TeFrExGKA ziv0WW=6Wb3x3P>vS2BAhx$tkmV&?H@)x2k1%C;S50KX^tJ<;!peoxG2z;6Y9EAU&v zkG6v6S6^@+F5@6p0wujigz#b>I zAJKjZY!n0bE@0ak?OtFL#BKw&iqVb-b_=5|1-2X5_Qw(JZeaJ%LA0L%`!r^%);v{@ z)zIAycx}ymeXG3RsWG4E(23-pxMs2aF$^!@a6N{*FwAYi@Oln6V)!o(AH;AXhn*ba zh;xr%I1@(|U4eMkH0#=ivvA09eil{Yc4{36-g*?DOd#X30`u-%fOskp&)bZ49-{qV zm@ag3jxK1<2NU0cN)3uI@pjCVjp@Y6D=|}Br%s$1i2&;7Vd4## zcqHQa8@318gw=cNB5>BDj*nqG2#NiIc^d>Qmn&O_cvb`33v5si>~dfmSgKQj9fcD_ zb^!Y&v73QCN$fF1`wg&>0L$fSXS8<%n_#pPfUN?yhuCv~T}JE%V2=`eAF!XWO8yP( zG|Yr5k0Imkxl57b6sr8|1)9{{xp;4GG*WQ~jwu?7BmNWCCuG(CpTk9X^<54ZVE6%t z*J0SrVJ(KUcpL{)x_uw0gncg@rrX8WO*pJI3vpPN9ku#b?5eV)v#_JNN@7<4`w6hg zYZV*(5!lQXip`D(wgK2)Vn+enM{GH;cM^L)u&)ri2-x$9y%pGx80}(UZw5A)4lJWh zuhVGTh^?EWS$u}rM{6|NYlwXlN&P#qwOT`N1hFqDHpxQBOi@ny38b;^w>mN1E89?F zbgvx5o=f+-J&1?y4gZXZ>E574<1zPU>y<0apumhN@Cfu(!H+Yv3@8#N{v?%)QY#o!8vk<1`++SC+wK zbgxX&z0lk%=jrKV?v?X(0nELwRS(JBn`~07xi@o}V$Hn`z|y@k3Rt>V%7LYOWiYVZ z%%xJX=3dtVEZrNX6l?AcCM(w5n+BHdmAcuwwsf!jM$cMD_sT{+YeTwMN;RqGUiXw@ z&Al01AJDy0*QyiKy)shk*wMYRN$c3$>(*;L=HBoRn3(Pj8Z{nsZ?;b3p?jsROIMHX zm3m<5UYQCk-77m38`8aQGtx-+hPNoz+#A3@=HBeZiluv{7g)MimIF)o$~nN=a&17g zbg#P)(bB!)jfyq*M!4!S_qHQix>pjw(!EjzEZvK2z|wNd zm2Yvxbg#@n`O&>HLmlGiUU?O-(!KI3UZs2G_oyMdSN6lM=HA>e&7-+D?8afyy@FR` zdUY@R8w)jL?vr_dc;;StS+V9`nWI>9uft-Rd&9JruDLfzDAwHD04&`rUBJ@4vJhChSN;Ml-7B?< zHTSy7nniPO@?yoBdoxvvHTR|;)@bQosnd%xbFa)-j=7hOueq0vueq0vFWoC>d>!2@ zXnd{j6*Ru)UP0q)?saH<&An`V&AkB{Uvn=TU%FS&_&T~*(D+*4D`(fFEs^-MJPvhg+d zvhg+dvhg+d3L4*bx>wNnntKI}uen#y_?mkKjc<sx(P|My3KJ9@g@0xBHYX>&iyAB+}CM~T0oir%r1jx+Dxad<=D zk>?Acht+F8p4K;UOW}Di92W(c!rcG3kdXZ2)N(TR^Rc&yjM!TEe*yB7_-tw} zPc@r9gZ>=V0y?#m(U*@eA74KH+|e5FE=C%>i!BfS;*6Q_JA~gM{Pt+ufUVZ@Uyo9cjTvkZl3=xR3S!Zq6*ER3dc0EqyR(+Lt*K{c H2kX8E6h_Vy literal 22029 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 literal 25944 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 diff --git a/packages/flame_forge2d/test/goldens/body_component/edge_shape.png b/packages/flame_forge2d/test/goldens/body_component/segment_shape.png similarity index 100% rename from packages/flame_forge2d/test/goldens/body_component/edge_shape.png rename to packages/flame_forge2d/test/goldens/body_component/segment_shape.png diff --git a/packages/flame_forge2d/test/helpers/mocks.dart b/packages/flame_forge2d/test/helpers/mocks.dart index aa6c84b3d65..37ae84374ff 100644 --- a/packages/flame_forge2d/test/helpers/mocks.dart +++ b/packages/flame_forge2d/test/helpers/mocks.dart @@ -2,13 +2,3 @@ import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:mocktail/mocktail.dart'; class MockContactCallback extends Mock implements ContactCallbacks {} - -class MockContact extends Mock implements Contact {} - -class MockBody extends Mock implements Body {} - -class MockFixture extends Mock implements Fixture {} - -class MockManifold extends Mock implements Manifold {} - -class MockContactImpulse extends Mock implements ContactImpulse {} diff --git a/packages/flame_forge2d/test/world_contact_listener_test.dart b/packages/flame_forge2d/test/world_contact_listener_test.dart deleted file mode 100644 index 57067569352..00000000000 --- a/packages/flame_forge2d/test/world_contact_listener_test.dart +++ /dev/null @@ -1,353 +0,0 @@ -import 'package:flame_forge2d/flame_forge2d.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:test/scaffolding.dart'; - -import 'helpers/helpers.dart'; - -void main() { - group( - 'WorldContactListener', - () { - late ContactCallbacks contactCallback; - late Contact contact; - late Body bodyA; - late Body bodyB; - late Fixture fixtureA; - late Fixture fixtureB; - - setUp(() { - contactCallback = MockContactCallback(); - contact = MockContact(); - bodyA = MockBody(); - bodyB = MockBody(); - fixtureA = MockFixture(); - fixtureB = MockFixture(); - - when(() => contact.bodyA).thenReturn(bodyA); - when(() => contact.bodyB).thenReturn(bodyB); - when(() => contact.fixtureA).thenReturn(fixtureA); - when(() => contact.fixtureB).thenReturn(fixtureB); - }); - - setUpAll(() { - registerFallbackValue(Object()); - }); - - group( - 'beginContact', - () { - test( - "doesn't callback if userData are null", - () { - final contactListener = WorldContactListener(); - - when(() => bodyA.userData).thenReturn(contactCallback); - when(() => bodyB.userData).thenReturn(null); - when(() => fixtureA.userData).thenReturn(null); - when(() => fixtureB.userData).thenReturn(null); - - contactListener.beginContact(contact); - - verifyNever( - () => contactCallback.beginContact(any(), contact), - ); - }, - ); - - test( - 'callbacks for userData when not null', - () { - final contactListener = WorldContactListener(); - - when(() => bodyA.userData).thenReturn(contactCallback); - when(() => bodyB.userData).thenReturn(Object()); - when(() => fixtureA.userData).thenReturn(Object()); - when(() => fixtureB.userData).thenReturn(Object()); - - contactListener.beginContact(contact); - - verify( - () => contactCallback.beginContact(bodyB.userData!, contact), - ).called(1); - verify( - () => contactCallback.beginContact(fixtureA.userData!, contact), - ).called(1); - verify( - () => contactCallback.beginContact(fixtureB.userData!, contact), - ).called(1); - verify( - () => contactCallback.beginContact(any(), contact), - ).called(3); - }, - ); - - test( - "doesn't callback itself", - () { - final contactListener = WorldContactListener(); - - when(() => bodyA.userData).thenReturn(contactCallback); - when(() => bodyB.userData).thenReturn(contactCallback); - when(() => fixtureA.userData).thenReturn(null); - when(() => fixtureB.userData).thenReturn(null); - - contactListener.beginContact(contact); - - verifyNever( - () => contactCallback.beginContact(any(), contact), - ); - }, - ); - }, - ); - - group( - 'endContact', - () { - test( - "doesn't callback if userData are null", - () { - final contactListener = WorldContactListener(); - - when(() => bodyA.userData).thenReturn(contactCallback); - when(() => bodyB.userData).thenReturn(null); - when(() => fixtureA.userData).thenReturn(null); - when(() => fixtureB.userData).thenReturn(null); - - contactListener.endContact(contact); - - verifyNever( - () => contactCallback.endContact(any(), contact), - ); - }, - ); - - test( - 'callbacks for userData when not null', - () { - final contactListener = WorldContactListener(); - - when(() => bodyA.userData).thenReturn(contactCallback); - when(() => bodyB.userData).thenReturn(Object()); - when(() => fixtureA.userData).thenReturn(Object()); - when(() => fixtureB.userData).thenReturn(Object()); - - contactListener.endContact(contact); - - verify( - () => contactCallback.endContact(bodyB.userData!, contact), - ).called(1); - verify( - () => contactCallback.endContact(fixtureA.userData!, contact), - ).called(1); - verify( - () => contactCallback.endContact(fixtureB.userData!, contact), - ).called(1); - verify( - () => contactCallback.endContact(any(), contact), - ).called(3); - }, - ); - - test( - "doesn't callback itself", - () { - final contactListener = WorldContactListener(); - - when(() => bodyA.userData).thenReturn(contactCallback); - when(() => bodyB.userData).thenReturn(contactCallback); - when(() => fixtureA.userData).thenReturn(null); - when(() => fixtureB.userData).thenReturn(null); - - contactListener.endContact(contact); - - verifyNever( - () => contactCallback.endContact(any(), contact), - ); - }, - ); - }, - ); - - group( - 'preSolve', - () { - late Manifold manifold; - - setUp(() { - manifold = MockManifold(); - }); - - test( - "doesn't callback if userData are null", - () { - final contactListener = WorldContactListener(); - - when(() => bodyA.userData).thenReturn(contactCallback); - when(() => bodyB.userData).thenReturn(null); - when(() => fixtureA.userData).thenReturn(null); - when(() => fixtureB.userData).thenReturn(null); - - contactListener.preSolve(contact, manifold); - - verifyNever( - () => contactCallback.preSolve(any(), contact, manifold), - ); - }, - ); - - test( - 'callbacks for userData when not null', - () { - final contactListener = WorldContactListener(); - - when(() => bodyA.userData).thenReturn(contactCallback); - when(() => bodyB.userData).thenReturn(Object()); - when(() => fixtureA.userData).thenReturn(Object()); - when(() => fixtureB.userData).thenReturn(Object()); - - contactListener.preSolve(contact, manifold); - - verify( - () => contactCallback.preSolve( - bodyB.userData!, - contact, - manifold, - ), - ).called(1); - verify( - () => contactCallback.preSolve( - fixtureA.userData!, - contact, - manifold, - ), - ).called(1); - verify( - () => contactCallback.preSolve( - fixtureB.userData!, - contact, - manifold, - ), - ).called(1); - verify( - () => contactCallback.preSolve( - any(), - contact, - manifold, - ), - ).called(3); - }, - ); - - test( - "doesn't callback itself", - () { - final contactListener = WorldContactListener(); - - when(() => bodyA.userData).thenReturn(contactCallback); - when(() => bodyB.userData).thenReturn(contactCallback); - when(() => fixtureA.userData).thenReturn(null); - when(() => fixtureB.userData).thenReturn(null); - - contactListener.preSolve(contact, manifold); - - verifyNever( - () => contactCallback.preSolve(any(), contact, manifold), - ); - }, - ); - }, - ); - - group( - 'postSolve', - () { - late ContactImpulse contactImpulse; - - setUp(() { - contactImpulse = MockContactImpulse(); - }); - - test( - "doesn't callback if userData are null", - () { - final contactListener = WorldContactListener(); - - when(() => bodyA.userData).thenReturn(contactCallback); - when(() => bodyB.userData).thenReturn(null); - when(() => fixtureA.userData).thenReturn(null); - when(() => fixtureB.userData).thenReturn(null); - - contactListener.postSolve(contact, contactImpulse); - - verifyNever( - () => contactCallback.postSolve(any(), contact, contactImpulse), - ); - }, - ); - - test( - 'callbacks for userData when not null', - () { - final contactListener = WorldContactListener(); - - when(() => bodyA.userData).thenReturn(contactCallback); - when(() => bodyB.userData).thenReturn(Object()); - when(() => fixtureA.userData).thenReturn(Object()); - when(() => fixtureB.userData).thenReturn(Object()); - - contactListener.postSolve(contact, contactImpulse); - - verify( - () => contactCallback.postSolve( - bodyB.userData!, - contact, - contactImpulse, - ), - ).called(1); - verify( - () => contactCallback.postSolve( - fixtureA.userData!, - contact, - contactImpulse, - ), - ).called(1); - verify( - () => contactCallback.postSolve( - fixtureB.userData!, - contact, - contactImpulse, - ), - ).called(1); - verify( - () => contactCallback.postSolve( - any(), - contact, - contactImpulse, - ), - ).called(3); - }, - ); - - test( - "doesn't callback itself", - () { - final contactListener = WorldContactListener(); - - when(() => bodyA.userData).thenReturn(contactCallback); - when(() => bodyB.userData).thenReturn(contactCallback); - when(() => fixtureA.userData).thenReturn(null); - when(() => fixtureB.userData).thenReturn(null); - - contactListener.postSolve(contact, contactImpulse); - - verifyNever( - () => contactCallback.postSolve(any(), contact, contactImpulse), - ); - }, - ); - }, - ); - }, - ); -} diff --git a/scripts/customer_testing.dart b/scripts/customer_testing.dart index 1e377a68236..2929b3c7951 100644 --- a/scripts/customer_testing.dart +++ b/scripts/customer_testing.dart @@ -10,7 +10,7 @@ import 'dart:io'; // frequently in flutter/flutter's presubmit. Analyzing it here would block // framework changes on an unstable dependency that is not representative of // Flame, so it is excluded from this run. -const _excludedPackages = {'flame_3d'}; +const _excludedPackages = {'flame_3d', 'flame_forge2d'}; Future main() async { final packages = From af1b43519cd2fd0022e2bd72b31e8020227de825 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 20 Jul 2026 00:47:54 +0200 Subject: [PATCH 02/21] test: Full line coverage for flame_forge2d and forge2d module docs --- doc/other_modules/forge2d/forge2d.md | 88 +++++++++++ doc/other_modules/other_modules.md | 8 + .../test/body_component_test.dart | 140 ++++++++++++++++++ packages/flame_forge2d/test/contact_test.dart | 95 ++++++++++++ .../test/forge2d_world_test.dart | 121 +++++++++++++++ 5 files changed, 452 insertions(+) create mode 100644 doc/other_modules/forge2d/forge2d.md create mode 100644 packages/flame_forge2d/test/contact_test.dart diff --git a/doc/other_modules/forge2d/forge2d.md b/doc/other_modules/forge2d/forge2d.md new file mode 100644 index 00000000000..d30f815827b --- /dev/null +++ b/doc/other_modules/forge2d/forge2d.md @@ -0,0 +1,88 @@ +# Forge2D + +The **forge2d** library provides Dart bindings for the [Box2D](https://box2d.org/) physics engine, +running the engine as native code on mobile and desktop platforms and as WebAssembly on the web. +It can be used in any Dart project, with or without Flame. + +If you want to use Forge2D in a Flame game you should use the +[flame_forge2d](../../bridge_packages/flame_forge2d/flame_forge2d.md) bridge package instead, which +wraps the concepts described here in Flame components. + + +## Getting started + +Add `forge2d` to your `pubspec.yaml` and create a world: + +```dart +import 'package:forge2d/forge2d.dart'; + +void main() { + final world = World(gravity: Vector2(0, -10)); + + final ground = world.createBody(BodyDef(position: Vector2(0, -1))); + ground.createShape(Polygon.box(50, 1)); + + final ball = world.createBody( + BodyDef(type: BodyType.dynamic, position: Vector2(0, 10)), + ); + ball.createShape( + Circle(radius: 0.5), + ShapeDef(material: SurfaceMaterial(restitution: 0.8)), + ); + + for (var i = 0; i < 120; i++) { + world.step(1 / 60); + print(ball.position.y); + } + + world.destroy(); +} +``` + +Note that when forge2d is used standalone the world uses Box2D's y-up convention, so a downwards +gravity has a negative y value. The `flame_forge2d` bridge flips this for you to match Flame's +y-down coordinate system. + +`World`, `Body`, `Shape`, `Chain`, and the joints are cheap value-like handles over ids inside the +native engine. Destruction is explicit: call `destroy()` on the handle when you are done with it, +and `world.destroy()` frees the whole simulation. + + +## Shapes + +Bodies carry shapes, which are created from an immutable `ShapeGeometry` (`Circle`, `Capsule`, +`Segment`, or `Polygon`) and an optional `ShapeDef` holding the density, the collision filter, the +event flags, and the `SurfaceMaterial` (friction, restitution, and more). Chains of line segments +for static level geometry are created with `body.createChain(ChainDef(points: ...))`. + + +## Events and queries + +Contact, sensor, and body-move events are polled from the world after each step through +`world.contactEvents`, `world.sensorEvents`, and `world.bodyMoveEvents`. Events are only generated +for shapes that have opted in through the `ShapeDef` event flags, like `enableContactEvents` and +`enableSensorEvents`. + +Ray casts (`castRayClosest`, `castRay`, `castRayAll`), AABB overlap queries (`overlapAabb`), and +explosions (`explode`) are available directly on `World`, returning their results instead of using +callback classes. + + +## Joints + +Eight joint types are supported: distance, filter, motor, mouse, prismatic, revolute, weld, and +wheel. A joint is created by passing its def to the corresponding typed method on the world, for +example `world.createRevoluteJoint(RevoluteJointDef(bodyA: ..., bodyB: ...))`, and removed with +`joint.destroy()`. See the [flame_forge2d joints +documentation](../../bridge_packages/flame_forge2d/joints.md) for a description of each joint. + + +## Platform support + +Forge2D supports Android, iOS, macOS, Windows, Linux, and the web. On native platforms the bundled +Box2D sources are compiled by the Dart build hooks, which requires a C toolchain (Xcode on +iOS/macOS, the NDK on Android, Visual Studio Build Tools on Windows, and clang or gcc on Linux). +On the web a bundled WebAssembly build of Box2D is used, which is served automatically by the Dart +web tooling and bundled automatically into Flutter web builds. + +For more details, see the [forge2d repository](https://github.com/flame-engine/forge2d). diff --git a/doc/other_modules/other_modules.md b/doc/other_modules/other_modules.md index a326aa2bbc8..008d8490169 100644 --- a/doc/other_modules/other_modules.md +++ b/doc/other_modules/other_modules.md @@ -1,5 +1,12 @@ # Other Modules +:::{package} forge2d + +This module provides Dart bindings for the Box2D physics engine, running natively on mobile and +desktop and as WebAssembly on the web. It can be used in any Dart project; use bridge package +`flame_forge2d` in order to add it into a Flame game. +::: + :::{package} jenny This module lets you add interactive dialogue into your game. The module itself handles Yarn scripts @@ -16,6 +23,7 @@ Component System is as efficient in almost all cases. ```{toctree} :hidden: +forge2d jenny oxygen ``` diff --git a/packages/flame_forge2d/test/body_component_test.dart b/packages/flame_forge2d/test/body_component_test.dart index 8e0e8db16d2..76aab6d666f 100644 --- a/packages/flame_forge2d/test/body_component_test.dart +++ b/packages/flame_forge2d/test/body_component_test.dart @@ -216,6 +216,32 @@ void main() { }); }); + group('rendering toggles', () { + testWithGame( + 'render skips the shapes when renderBody is false and ' + 'renderDebugMode always renders them', + Forge2DGame.new, + (game) async { + final canvas = _MockCanvas(); + final component = _CountingBodyComponent( + bodyDef: BodyDef(), + shapeSpecs: [ShapeSpec(Circle(radius: 1))], + ); + await game.world.ensureAdd(component); + + component.render(canvas); + expect(component.renderedShapes, 1); + + component.renderBody = false; + component.render(canvas); + expect(component.renderedShapes, 1); + + component.renderDebugMode(canvas); + expect(component.renderedShapes, 2); + }, + ); + }); + group('renderShape', () { group('returns normally', () { late Canvas canvas; @@ -434,7 +460,109 @@ void main() { expect(radii, {10.0, 20.0, 30.0}); }, ); + + flameTester.testGameWidget( + 'without contact events enabled when no ContactCallbacks is used', + setUp: (game, tester) async { + final bodyComponent = BodyComponent( + bodyDef: BodyDef(), + shapeSpecs: [ShapeSpec(Circle(radius: 10))], + key: ComponentKey.named('tested'), + ); + game.world.add(bodyComponent); + }, + verify: (game, tester) async { + final bodyComponent = game.findByKeyName('tested')!; + final shape = bodyComponent.body.shapes.single; + expect(shape.contactEventsEnabled, isFalse); + expect(shape.sensorEventsEnabled, isFalse); + }, + ); + + flameTester.testGameWidget( + 'with contact events enabled when the bodyDef userData is a ' + 'ContactCallbacks', + setUp: (game, tester) async { + final bodyComponent = BodyComponent( + bodyDef: BodyDef(userData: ContactCallbacks()), + shapeSpecs: [ShapeSpec(Circle(radius: 10))], + key: ComponentKey.named('tested'), + ); + game.world.add(bodyComponent); + }, + verify: (game, tester) async { + final bodyComponent = game.findByKeyName('tested')!; + final shape = bodyComponent.body.shapes.single; + expect(shape.contactEventsEnabled, isTrue); + expect(shape.sensorEventsEnabled, isTrue); + }, + ); + + flameTester.testGameWidget( + 'with contact events enabled when a ShapeDef userData is a ' + 'ContactCallbacks', + setUp: (game, tester) async { + final bodyComponent = BodyComponent( + bodyDef: BodyDef(), + shapeSpecs: [ + ShapeSpec( + Circle(radius: 10), + ShapeDef(userData: ContactCallbacks()), + ), + ], + key: ComponentKey.named('tested'), + ); + game.world.add(bodyComponent); + }, + verify: (game, tester) async { + final bodyComponent = game.findByKeyName('tested')!; + final shape = bodyComponent.body.shapes.single; + expect(shape.contactEventsEnabled, isTrue); + expect(shape.sensorEventsEnabled, isTrue); + }, + ); + }); + }); + + group('accessors', () { + testWithGame('position, center and angle', Forge2DGame.new, (game) async { + final component = BodyComponent( + bodyDef: BodyDef( + position: Vector2(3, 4), + rotation: Rot.fromAngle(1), + ), + shapeSpecs: [ShapeSpec(Circle(radius: 2))], + ); + await game.world.ensureAdd(component); + + expect(component.position, Vector2(3, 4)); + expect(component.center, Vector2(3, 4)); + expect(component.angle, closeTo(1, 1e-6)); + expect(component.camera, game.camera); }); + + testWithGame( + 'parentToLocal and localToParent are inverses', + Forge2DGame.new, + (game) async { + final component = BodyComponent( + bodyDef: BodyDef( + position: Vector2(3, 4), + rotation: Rot.fromAngle(1), + ), + shapeSpecs: [ShapeSpec(Circle(radius: 2))], + ); + await game.world.ensureAdd(component); + game.update(0); + + final point = Vector2(1, 2); + final roundTrip = component.parentToLocal( + component.localToParent(point), + ); + expect(roundTrip.x, closeTo(point.x, 1e-6)); + expect(roundTrip.y, closeTo(point.y, 1e-6)); + }, + ); }); group('containsLocalPoint', () { @@ -528,6 +656,18 @@ void main() { }); } +class _CountingBodyComponent extends BodyComponent { + _CountingBodyComponent({super.bodyDef, super.shapeSpecs}); + + int renderedShapes = 0; + + @override + void renderShape(Canvas canvas, Shape shape) { + renderedShapes++; + super.renderShape(canvas, shape); + } +} + class _ConsistentBodyComponent extends BodyComponent { _ConsistentBodyComponent({super.bodyDef}); diff --git a/packages/flame_forge2d/test/contact_test.dart b/packages/flame_forge2d/test/contact_test.dart new file mode 100644 index 00000000000..270a610bfef --- /dev/null +++ b/packages/flame_forge2d/test/contact_test.dart @@ -0,0 +1,95 @@ +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:test/expect.dart'; +import 'package:test/scaffolding.dart'; + +void main() { + group('Contact', () { + late World world; + late Body bodyA; + late Body bodyB; + late Shape shapeA; + late Shape shapeB; + + setUp(() { + world = World(); + bodyA = world.createBody(); + bodyB = world.createBody(BodyDef(position: Vector2(10, 0))); + shapeA = bodyA.createShape(Circle(radius: 1)); + shapeB = bodyB.createShape(Circle(radius: 1)); + }); + + tearDown(() { + world.destroy(); + }); + + test('bodyA and bodyB return the owning bodies', () { + final contact = Contact.end(shapeA: shapeA, shapeB: shapeB); + + expect(contact.bodyA, bodyA); + expect(contact.bodyB, bodyB); + }); + + test('isValid reflects the validity of both shapes', () { + final contact = Contact.end(shapeA: shapeA, shapeB: shapeB); + expect(contact.isValid, isTrue); + + bodyB.destroy(); + expect(contact.isValid, isFalse); + }); + + test('begin contacts carry the normal and points', () { + final contact = Contact.begin( + shapeA: shapeA, + shapeB: shapeB, + normal: Vector2(0, 1), + points: [ContactPoint(point: Vector2(5, 0), separation: -0.1)], + ); + + expect(contact.isSensorEvent, isFalse); + expect(contact.normal, Vector2(0, 1)); + expect(contact.points, hasLength(1)); + }); + + test('userDatas keeps the body A, shape A, body B, shape B order', () { + final firstUserData = Object(); + final secondUserData = Object(); + final thirdUserData = Object(); + final fourthUserData = Object(); + bodyA.userData = firstUserData; + shapeA.userData = secondUserData; + bodyB.userData = thirdUserData; + shapeB.userData = fourthUserData; + + final contact = Contact.end(shapeA: shapeA, shapeB: shapeB); + + expect( + contact.userDatas.toList(), + [firstUserData, secondUserData, thirdUserData, fourthUserData], + ); + }); + + test('userDatas skips destroyed shapes', () { + bodyA.userData = Object(); + shapeA.userData = Object(); + bodyB.userData = Object(); + bodyB.destroy(); + + final contact = Contact.end(shapeA: shapeA, shapeB: shapeB); + + expect( + contact.userDatas.toList(), + [bodyA.userData, shapeA.userData], + ); + }); + + test('deduplicates userData shared between a body and its shape', () { + final sharedUserData = Object(); + bodyA.userData = sharedUserData; + shapeA.userData = sharedUserData; + + final contact = Contact.end(shapeA: shapeA, shapeB: shapeB); + + expect(contact.userDatas.toList(), [sharedUserData]); + }); + }); +} diff --git a/packages/flame_forge2d/test/forge2d_world_test.dart b/packages/flame_forge2d/test/forge2d_world_test.dart index 34af542915b..2885a5a0f04 100644 --- a/packages/flame_forge2d/test/forge2d_world_test.dart +++ b/packages/flame_forge2d/test/forge2d_world_test.dart @@ -87,4 +87,125 @@ void main() { expect(hit.point.x, closeTo(9, 1e-4)); }, ); + + testWithGame( + 'castRay reports every hit through the callback', + Forge2DGame.new, + (game) async { + game.world + .createBody(BodyDef(position: Vector2(10, 0))) + .createShape( + Circle(radius: 1), + ); + game.world + .createBody(BodyDef(position: Vector2(15, 0))) + .createShape( + Circle(radius: 1), + ); + + final hits = []; + game.world.castRay(Vector2.zero(), Vector2(20, 0), (hit) { + hits.add(hit); + return 1; + }); + + expect(hits, hasLength(2)); + }, + ); + + testWithGame( + 'castRayAll returns the hits sorted from nearest to farthest', + Forge2DGame.new, + (game) async { + final far = game.world.createBody(BodyDef(position: Vector2(15, 0))); + far.createShape(Circle(radius: 1)); + final near = game.world.createBody(BodyDef(position: Vector2(10, 0))); + near.createShape(Circle(radius: 1)); + + final hits = game.world.castRayAll(Vector2.zero(), Vector2(20, 0)); + + expect(hits, hasLength(2)); + expect(hits.first.shape.body, near); + expect(hits.last.shape.body, far); + }, + ); + + testWithGame( + 'overlapAabb returns the shapes overlapping the aabb', + Forge2DGame.new, + (game) async { + final inside = game.world.createBody(BodyDef(position: Vector2(5, 5))); + final insideShape = inside.createShape(Circle(radius: 1)); + final outside = game.world.createBody(BodyDef(position: Vector2(50, 50))); + outside.createShape(Circle(radius: 1)); + + final overlapping = game.world.overlapAabb( + Aabb(Vector2.zero(), Vector2.all(10)), + ); + + expect(overlapping, [insideShape]); + expect( + game.world.overlapAabb(Aabb(Vector2(20, 20), Vector2(30, 30))), + isEmpty, + ); + }, + ); + + testWithGame( + 'preSolveCallback can veto a contact', + Forge2DGame.new, + (game) async { + final platform = game.world.createBody(BodyDef(position: Vector2(0, 5))); + platform.createShape( + Polygon.box(10, 1), + ShapeDef(enablePreSolveEvents: true), + ); + final ball = game.world.createBody(BodyDef(type: BodyType.dynamic)); + ball.createShape( + Circle(radius: 1), + ShapeDef(enablePreSolveEvents: true), + ); + + var preSolveCalls = 0; + game.world.preSolveCallback = (shapeA, shapeB, normal) { + preSolveCalls++; + return false; + }; + + for (var i = 0; i < 120; i++) { + game.update(1 / 60); + } + + // The vetoed contact never stopped the falling ball, so it has dropped + // through the platform. + expect(preSolveCalls, greaterThan(0)); + expect(ball.position.y, greaterThan(6)); + }, + ); + + testWithGame( + 'customFilterCallback can disable a collision', + Forge2DGame.new, + (game) async { + final platform = game.world.createBody(BodyDef(position: Vector2(0, 5))); + platform.createShape(Polygon.box(10, 1)); + final ball = game.world.createBody(BodyDef(type: BodyType.dynamic)); + ball.createShape(Circle(radius: 1)); + + var filterCalls = 0; + game.world.customFilterCallback = (shapeA, shapeB) { + filterCalls++; + return false; + }; + + for (var i = 0; i < 120; i++) { + game.update(1 / 60); + } + + // The filtered pair never collided, so the ball has fallen through the + // platform. + expect(filterCalls, greaterThan(0)); + expect(ball.position.y, greaterThan(6)); + }, + ); } From decd2f45497d035b5252de87ef1386bca5b3167d Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 20 Jul 2026 21:24:36 +0200 Subject: [PATCH 03/21] docs: Add forge2d and flame_forge2d migration guides --- .../flame_forge2d/flame_forge2d.md | 1 + doc/bridge_packages/flame_forge2d/forge2d.md | 3 + .../flame_forge2d/migration.md | 155 ++++++++++++++ doc/other_modules/forge2d/forge2d.md | 2 + doc/other_modules/forge2d/migration.md | 199 ++++++++++++++++++ doc/other_modules/other_modules.md | 7 +- 6 files changed, 364 insertions(+), 3 deletions(-) create mode 100644 doc/bridge_packages/flame_forge2d/migration.md create mode 100644 doc/other_modules/forge2d/migration.md diff --git a/doc/bridge_packages/flame_forge2d/flame_forge2d.md b/doc/bridge_packages/flame_forge2d/flame_forge2d.md index c4e72f9ab72..1fde82ea103 100644 --- a/doc/bridge_packages/flame_forge2d/flame_forge2d.md +++ b/doc/bridge_packages/flame_forge2d/flame_forge2d.md @@ -3,4 +3,5 @@ ```{toctree} Overview Joints +Migration ``` diff --git a/doc/bridge_packages/flame_forge2d/forge2d.md b/doc/bridge_packages/flame_forge2d/forge2d.md index 98ee07681d4..eaa11cd0335 100644 --- a/doc/bridge_packages/flame_forge2d/forge2d.md +++ b/doc/bridge_packages/flame_forge2d/forge2d.md @@ -18,6 +18,9 @@ Since Forge2D runs Box2D as native code, a C toolchain is required when building platforms (Xcode on iOS/macOS, the NDK on Android, Visual Studio Build Tools on Windows and clang or gcc on Linux). On the web a bundled WebAssembly build of Box2D is used automatically. +If you are upgrading an existing game from flame_forge2d 0.19, see the +[migration guide](migration.md). + ## Forge2DGame diff --git a/doc/bridge_packages/flame_forge2d/migration.md b/doc/bridge_packages/flame_forge2d/migration.md new file mode 100644 index 00000000000..d1aa3277d7a --- /dev/null +++ b/doc/bridge_packages/flame_forge2d/migration.md @@ -0,0 +1,155 @@ +# Migrating from flame_forge2d 0.19 + +flame_forge2d 0.20 is built on Forge2D 0.15, which replaced the pure Dart port of Box2D 2.x with +bindings for [Box2D v3](https://box2d.org/). The whole underlying API changed, so this is a large +breaking change. + +This page covers the flame_forge2d side of the migration: `BodyComponent`, `Forge2DWorld`, +`Forge2DGame`, and the contact callbacks. Everything you do directly with the physics engine +(shapes, joints, queries, world stepping) is described in the +[Forge2D migration guide](../../other_modules/forge2d/migration.md), which is worth reading first. + +```{note} +The particle system (LiquidFun) no longer exists in Box2D v3 and has been +removed, together with `Forge2DWorld.raycastParticle`. If your game depends on +it, stay on flame_forge2d 0.19. +``` + + +## Platform requirements + +The SDK floor is now Dart 3.12 (Flutter 3.44), and building for native platforms requires a C +toolchain (Xcode on iOS and macOS, the NDK on Android, Visual Studio Build Tools on Windows, and +clang or gcc on Linux) because Box2D is compiled through the Dart build hooks. Flutter web builds +need no extra setup, the WebAssembly module is bundled automatically. + + +## BodyComponent + +Fixtures are gone; bodies now carry shapes created from a `ShapeGeometry` and a `ShapeDef`. The +`fixtureDefs` constructor argument is replaced by `shapeSpecs`, a list of `ShapeSpec`, which pairs +a geometry with an optional def: + +```dart +// Before +BodyComponent( + bodyDef: BodyDef(type: BodyType.dynamic), + fixtureDefs: [ + FixtureDef(CircleShape()..radius = 5, restitution: 0.8, friction: 0.4), + ], +); + +// After +BodyComponent( + bodyDef: BodyDef(type: BodyType.dynamic), + shapeSpecs: [ + ShapeSpec( + Circle(radius: 5), + ShapeDef(material: SurfaceMaterial(restitution: 0.8, friction: 0.4)), + ), + ], +); +``` + +If you override `createBody` instead, replace `createFixture`/`createFixtureFromShape` with +`createShape`: + +```dart +// Before +@override +Body createBody() { + final shape = EdgeShape()..set(start, end); + final fixtureDef = FixtureDef(shape, friction: 0.3); + return world.createBody(BodyDef())..createFixture(fixtureDef); +} + +// After +@override +Body createBody() { + final shapeDef = ShapeDef(material: SurfaceMaterial(friction: 0.3)); + return world.createBody(BodyDef()) + ..createShape(Segment(point1: start, point2: end), shapeDef); +} +``` + +The rendering hooks changed accordingly, and now read the geometry back from the shape: + +| Before | After | +|---|---| +| `renderFixture(Canvas, Fixture)` | `renderShape(Canvas, Shape)` | +| `renderEdge(Canvas, Offset, Offset)` | `renderSegment(Canvas, Offset, Offset)` | +| `renderChain(Canvas, List)` | removed, chain segments render through `renderSegment` | +| | `renderCapsule(Canvas, Offset, Offset, double)` is new | + +`renderCircle` and `renderPolygon` are unchanged. `BodyComponent.center` now returns +`body.worldCenterOfMass`, and `BodyDef(angle: a)` becomes `BodyDef(rotation: Rot.fromAngle(a))`. + + +## Contact callbacks + +`ContactCallbacks` keeps the same shape, so components that only implement `beginContact` and +`endContact` mostly keep working: + +```dart +class Ball extends BodyComponent with ContactCallbacks { + @override + void beginContact(Object other, Contact contact) { + if (other is Wall) { ... } + } +} +``` + +What changed: + +- `Contact` is now a small flame_forge2d class instead of the Forge2D one. It carries `shapeA`, + `shapeB`, `bodyA`, `bodyB`, `isSensorEvent`, and, for begin events, `normal` and `points`. Since + end events can arrive after a shape was destroyed, check `contact.isValid` before using the + bodies. `contact.fixtureA`/`fixtureB` become `contact.shapeA`/`shapeB`. +- **Contact events are opt-in per shape.** Box2D v3 only generates events for shapes that asked + for them, so the involved shapes need `ShapeDef(enableContactEvents: true)`, and sensors together + with their visitors need `enableSensorEvents: true`. The default `BodyComponent.createBody()` + sets both flags automatically for shapes created through `shapeSpecs` when the `bodyDef`'s or the + `ShapeDef`'s `userData` is a `ContactCallbacks`, but if you override `createBody` you have to set + them yourself. +- `preSolve` and `postSolve` are removed from `ContactCallbacks`. To veto a contact before it is + solved, set `world.preSolveCallback` and enable `ShapeDef.enablePreSolveEvents` on the shapes. + For impact strength, enable `ShapeDef.enableHitEvents` and read + `world.physicsWorld.contactEvents.hit`, whose events carry an `approachSpeed`. `Manifold` and + `ContactImpulse` no longer exist. +- `WorldContactListener` is replaced by `ContactEventsDispatcher`, which the world polls once per + update. Subclass it if you customized the dispatch algorithm, and pass it through the + `contactEventsDispatcher` argument of `Forge2DGame` or `Forge2DWorld`, which replaced the + `contactListener` argument. + +Also note that a body destroyed while it is touching something does not produce a final +`endContact` for that contact any more, because the userData needed to route the event is cleared +together with the body. + + +## Forge2DWorld + +- Joint helpers were removed. Create joints on the physics world with the typed methods and destroy + them on the joint: `world.physicsWorld.createRevoluteJoint(def)` and `joint.destroy()` replace + `world.createJoint(joint)` and `world.destroyJoint(joint)`. +- Queries follow the new Forge2D API: `raycast(callback, p1, p2)` becomes `castRayClosest`, + `castRay`, or `castRayAll` (taking an origin and a translation), and `queryAABB(callback, aabb)` + becomes `overlapAabb(aabb)`. `clearForces()` and `raycastParticle` are gone. +- `world.preSolveCallback` and `world.customFilterCallback` are new forwarding setters. +- `subStepCount` (default 4) controls how many sub-steps each update performs, replacing the old + velocity and position iteration counts. +- Forge2D no longer exposes a list of all bodies, so `world.physicsWorld.bodies` becomes + `world.bodies`, which tracks the bodies created through `world.createBody`. Bodies created + directly on `world.physicsWorld` are not included, and are not woken by the gravity setter. +- The physics world is never destroyed automatically, so that a world can be removed and added back + later. Call `world.physicsWorld.destroy()` yourself when you are permanently done with a world. + + +## Name collisions + +Forge2D now exports `Transform`, `Rot`, `Circle`, `Polygon`, and `Segment`, which can collide with +names from `flutter/material.dart` (`Transform`) and `flame/geometry.dart` (`Circle`, `Polygon`). +Resolve them per file with `hide` or a prefixed import, for example: + +```dart +import 'package:flame_forge2d/flame_forge2d.dart' hide Transform; +``` diff --git a/doc/other_modules/forge2d/forge2d.md b/doc/other_modules/forge2d/forge2d.md index d30f815827b..db7a66d67f9 100644 --- a/doc/other_modules/forge2d/forge2d.md +++ b/doc/other_modules/forge2d/forge2d.md @@ -8,6 +8,8 @@ If you want to use Forge2D in a Flame game you should use the [flame_forge2d](../../bridge_packages/flame_forge2d/flame_forge2d.md) bridge package instead, which wraps the concepts described here in Flame components. +If you are upgrading from forge2d 0.14, see the [migration guide](migration.md). + ## Getting started diff --git a/doc/other_modules/forge2d/migration.md b/doc/other_modules/forge2d/migration.md new file mode 100644 index 00000000000..aed797ccbc2 --- /dev/null +++ b/doc/other_modules/forge2d/migration.md @@ -0,0 +1,199 @@ +# Migrating from forge2d 0.14 + +Forge2D 0.15 is a ground-up rewrite: instead of a pure Dart port of Box2D 2.x it is now a set of +bindings for [Box2D v3](https://box2d.org/), running as native code on mobile and desktop and as +WebAssembly on the web. Because of that, the entire public API changed. + +If you use Forge2D through Flame, see the +[flame_forge2d migration guide](../../bridge_packages/flame_forge2d/migration.md) as well, which +covers the changes to `BodyComponent`, `Forge2DWorld`, and the contact callbacks on top of the +changes described here. + +```{note} +The particle system (LiquidFun) is not part of Box2D v3 and has been removed. +If your game depends on it, stay on forge2d 0.14. +``` + + +## Platform requirements + +The Dart SDK floor is now 3.12 (Flutter 3.44). On native platforms the bundled Box2D sources are +compiled through the Dart build hooks, so a C toolchain is needed: Xcode on iOS and macOS, the NDK +on Android, Visual Studio Build Tools on Windows, and clang or gcc on Linux. On the web nothing +extra is needed, the bundled WebAssembly module is loaded automatically. + + +## Fixtures are gone, bodies carry shapes + +A `Body` no longer holds `Fixture`s. It holds `Shape`s, which are created from an immutable +`ShapeGeometry` and an optional `ShapeDef`. Friction and restitution moved into the def's +`material`. + +```dart +// Before +final shape = CircleShape()..radius = 5; +body.createFixture(FixtureDef(shape, restitution: 0.8, friction: 0.4, density: 2)); + +// After +body.createShape( + Circle(radius: 5), + ShapeDef( + material: SurfaceMaterial(restitution: 0.8, friction: 0.4), + density: 2, + ), +); +``` + +`body.fixtures` becomes `body.shapes`, and `fixture.testPoint` becomes `shape.testPoint` (still in +world coordinates). The shape's geometry can be read back for rendering or inspection with +`shape.geometry`, which returns the sealed `ShapeGeometry` type: + +```dart +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): +} +``` + + +## Shape construction + +| Before | After | +|---|---| +| `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` is new; there is no 0.14 equivalent. + +Chains now require at least four points, and they are one-sided: the solid surface is to the right +of the winding direction, so wind loops counter-clockwise and list open ground chains from right to +left. For an open chain the first and last points are ghost anchors used for smooth collision and +are not part of the collidable segments, so a four-point open chain produces one segment. The +segments of a chain are available through `chain.segments`. + + +## Contact listeners become polled events + +`ContactListener` and `world.setContactListener` no longer exist. After each step the world exposes +the events that occurred during it, and each shape has to opt in to the events it should generate. + +```dart +// Before +class MyListener extends ContactListener { + @override + void beginContact(Contact contact) { ... } +} +world.setContactListener(MyListener()); + +// After +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` holds `begin`, `end`, and `hit` lists. Begin events carry the contact + normal and the contact points, which replace the old `Manifold`. +- `world.sensorEvents` holds the `begin` and `end` sensor overlaps, each with a `sensor` and a + `visitor` shape. Both the sensor and its visitors need `ShapeDef.enableSensorEvents`. +- `world.bodyMoveEvents` reports the bodies that moved during the step. +- End events can reference shapes that were destroyed in the meantime, so check `Shape.isValid` + before using them. + +`preSolve` becomes the world-level `world.preSolveCallback`, which returns whether the contact +should be solved this step and requires `ShapeDef.enablePreSolveEvents` on the shapes. There is no +`postSolve` and no `ContactImpulse`: for impact strength enable `ShapeDef.enableHitEvents` and read +`world.contactEvents.hit`, whose events carry a `point`, a `normal`, and an `approachSpeed`. +Custom pair filtering, previously done by subclassing the contact filter, is now +`world.customFilterCallback`. + + +## Queries return their results + +The ray cast and query callback classes are replaced by methods on `World` that return their +results. Note that the ray is now expressed as an origin and a translation, not two points. + +```dart +// Before +class MyCallback extends RayCastCallback { + @override + double reportFixture( + Fixture fixture, + Vector2 point, + Vector2 normal, + double fraction, + ) { ... } +} +world.raycast(MyCallback(), start, end); + +// After +final hit = world.castRayClosest(start, end - start); +final allHits = world.castRayAll(start, end - start); +world.castRay(start, end - start, (hit) => 1); +``` + +Each `RayHit` carries the `shape`, `point`, `normal`, and `fraction`. `world.queryAABB(callback, +aabb)` becomes `world.overlapAabb(aabb)`, returning the overlapping shapes. `world.clearForces()` +is gone, as forces are applied per step in Box2D v3. Explosions are available through +`world.explode(ExplosionDef(...))`. + + +## Joints + +Joints are created through typed methods on the world and destroyed on the joint itself. The +`initialize` helpers on the defs are gone; anchors are given as local points, which you can compute +with `body.localPoint(worldAnchor)`. + +```dart +// Before +final jointDef = RevoluteJointDef()..initialize(bodyA, bodyB, anchor); +final joint = RevoluteJoint(jointDef); +world.createJoint(joint); +world.destroyJoint(joint); + +// After +final joint = world.createRevoluteJoint( + RevoluteJointDef( + bodyA: bodyA, + bodyB: bodyB, + localAnchorA: bodyA.localPoint(anchor), + localAnchorB: bodyB.localPoint(anchor), + ), +); +joint.destroy(); +``` + +The available joints are distance, filter, motor, mouse, prismatic, revolute, weld, and wheel. The +gear, pulley, rope, friction, and constant-volume joints do not exist in Box2D v3. `FilterJoint` +(which only disables collision between two bodies) and `WheelJoint` are new. + +Spring parameters are named `hertz` instead of `frequencyHz`, and springs generally have to be +enabled explicitly with `enableSpring`. Joint accessors are now getters and setters rather than +`getX()`/`setX()` methods, for example `joint.motorSpeed = 2` and `joint.angle`, and the limit +setters take named arguments: `joint.setLimits(lower: 0, upper: pi)`. + + +## World and body changes + +- `world.stepDt(dt)` becomes `world.step(dt, subStepCount: 4)`. The velocity and position iteration + counts are replaced by the single `subStepCount`, which defaults to 4. +- There is no `world.bodies`. Track the bodies you create yourself, or use `world.bodyMoveEvents`. +- `World`, `Body`, `Shape`, `Chain`, and the joints are cheap value-like handles over ids in the + native engine. Destroy them explicitly with `destroy()`, and check `isValid` when a handle may + refer to something that has already been destroyed. +- Rotations are represented by `Rot` (a cosine/sine pair), so `BodyDef(angle: a)` becomes + `BodyDef(rotation: Rot.fromAngle(a))` and `body.setTransform(position, rotation)` takes a `Rot`. + `body.angle` still exists. +- Body renames: `worldCenter` is now `worldCenterOfMass`, `getLocalCenter()` is + `localCenterOfMass`, `setAwake(value)` is `isAwake = value`, `getInertia()` is + `rotationalInertia`, and `worldVector(v)` is `rotation.rotate(v)`. +- `userData` is stored on the Dart side in the world instead of a native pointer, and is cleared + when the owning handle is destroyed. diff --git a/doc/other_modules/other_modules.md b/doc/other_modules/other_modules.md index 008d8490169..4ce592374eb 100644 --- a/doc/other_modules/other_modules.md +++ b/doc/other_modules/other_modules.md @@ -23,7 +23,8 @@ Component System is as efficient in almost all cases. ```{toctree} :hidden: -forge2d -jenny -oxygen +forge2d +forge2d migration +jenny +oxygen ``` From e9f29843aa470e3daa09a68813b1f9def3db337a Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 20 Jul 2026 21:26:01 +0200 Subject: [PATCH 04/21] docs: Link the migration guides from the flame_forge2d README --- packages/flame_forge2d/README.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/flame_forge2d/README.md b/packages/flame_forge2d/README.md index 47c8ff96119..f83facf4fa9 100644 --- a/packages/flame_forge2d/README.md +++ b/packages/flame_forge2d/README.md @@ -23,8 +23,8 @@ Adds support for Forge2d, # flame_forge2d -This library acts as a bridge between [Forge2D](https://github.com/flame-engine/forge2d) (our port -of Box2D) and the Flame game engine. +This library acts as a bridge between [Forge2D](https://github.com/flame-engine/forge2d) (our +bindings for Box2D) and the Flame game engine. ## Installation @@ -47,3 +47,12 @@ and you can also find some examples in the Some more documentation can be found [here](https://docs.flame-engine.org/main/bridge_packages/flame_forge2d/flame_forge2d.html). + +## Migrating from 0.19 + +Version 0.20 is built on Forge2D 0.15, which replaced the pure Dart port of Box2D 2.x with +bindings for Box2D v3, so the whole API changed. See the [flame_forge2d migration +guide](https://docs.flame-engine.org/main/bridge_packages/flame_forge2d/migration.html) and the +[Forge2D migration +guide](https://docs.flame-engine.org/main/other_modules/forge2d/migration.html). + From 04b20945db9322758052587dcb60344df4e01c5b Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 20 Jul 2026 21:34:02 +0200 Subject: [PATCH 05/21] fix: Drop destroyed bodies from the Forge2DWorld body set --- .../flame_forge2d/lib/body_component.dart | 5 ++++ packages/flame_forge2d/lib/forge2d_world.dart | 15 +++++++++++- .../test/body_component_test.dart | 6 +++-- .../test/forge2d_world_test.dart | 22 ++++++++++++++++++ .../body_component/chain_shape_open.png | Bin 22015 -> 22038 bytes 5 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/flame_forge2d/lib/body_component.dart b/packages/flame_forge2d/lib/body_component.dart index 1258ba2ac20..474c082fac0 100644 --- a/packages/flame_forge2d/lib/body_component.dart +++ b/packages/flame_forge2d/lib/body_component.dart @@ -81,6 +81,11 @@ class BodyComponent extends Component /// automatically enabled for that shape, since Forge2D only generates /// events for shapes that have opted in to them. If you override this /// method you have to enable the event flags yourself. + /// + /// Note that the event flags are set on the [ShapeDef] of the [ShapeSpec] + /// itself, since Forge2D snapshots them when the shape is created. Don't + /// share a single [ShapeDef] instance between components if you don't want + /// them to share those flags. Body createBody() { assert( bodyDef != null, diff --git a/packages/flame_forge2d/lib/forge2d_world.dart b/packages/flame_forge2d/lib/forge2d_world.dart index 1685f1ebe9a..a39d26ef332 100644 --- a/packages/flame_forge2d/lib/forge2d_world.dart +++ b/packages/flame_forge2d/lib/forge2d_world.dart @@ -58,7 +58,19 @@ class Forge2DWorld extends World { /// destroyed. /// /// Bodies created directly on the [physicsWorld] are not included. - late final Set bodies = UnmodifiableSetView(_bodies); + Set get bodies { + _pruneDestroyedBodies(); + return UnmodifiableSetView(_bodies); + } + + /// Drops the bodies that have been destroyed without going through + /// [destroyBody], for example by calling [Body.destroy] directly. + /// + /// Their handles are not only stale but unusable, since Forge2D asserts on + /// destroyed bodies. + void _pruneDestroyedBodies() { + _bodies.removeWhere((body) => !body.isValid); + } @override void update(double dt) { @@ -152,6 +164,7 @@ class Forge2DWorld extends World { /// through [createBody]. set gravity(Vector2? gravity) { physicsWorld.gravity = gravity ?? defaultGravity; + _pruneDestroyedBodies(); for (final body in _bodies) { body.isAwake = true; } diff --git a/packages/flame_forge2d/test/body_component_test.dart b/packages/flame_forge2d/test/body_component_test.dart index 76aab6d666f..cbf3a524243 100644 --- a/packages/flame_forge2d/test/body_component_test.dart +++ b/packages/flame_forge2d/test/body_component_test.dart @@ -156,14 +156,16 @@ void main() { setUp: (game, tester) async { final body = game.world.createBody(BodyDef()); // The first and last points of an open chain are ghost anchors - // and are not part of the collidable (and rendered) segments. + // and are not part of the collidable (and rendered) segments, so + // these five points render as two connected segments. body.createChain( ChainDef( points: [ + Vector2(-10, 10), Vector2(-10, 0), Vector2.zero(), Vector2.all(10), - Vector2(10, 0), + Vector2(20, 10), ], ), ); diff --git a/packages/flame_forge2d/test/forge2d_world_test.dart b/packages/flame_forge2d/test/forge2d_world_test.dart index 2885a5a0f04..00f2e69df7a 100644 --- a/packages/flame_forge2d/test/forge2d_world_test.dart +++ b/packages/flame_forge2d/test/forge2d_world_test.dart @@ -71,6 +71,28 @@ void main() { }, ); + testWithGame( + 'bodies destroyed outside of destroyBody are dropped', + Forge2DGame.new, + (game) async { + final kept = game.world.createBody(BodyDef(type: BodyType.dynamic)); + kept.createShape(Circle(radius: 1)); + final destroyed = game.world.createBody(BodyDef(type: BodyType.dynamic)); + destroyed.createShape(Circle(radius: 1)); + expect(game.world.bodies, hasLength(2)); + + // Destroying the body directly bypasses Forge2DWorld.destroyBody, so + // the world is left holding a stale handle. + destroyed.destroy(); + + expect(game.world.bodies, {kept}); + // Waking the bodies must not touch the destroyed one, since Forge2D + // asserts when a destroyed body is used. + expect(() => game.world.gravity = Vector2(0, 5), returnsNormally); + expect(kept.isAwake, isTrue); + }, + ); + testWithGame( 'castRayClosest returns the closest hit', Forge2DGame.new, diff --git a/packages/flame_forge2d/test/goldens/body_component/chain_shape_open.png b/packages/flame_forge2d/test/goldens/body_component/chain_shape_open.png index 6ff23f219e92df3d74a2e0ca553b80377ab63bdd..52cea7420d0c595148d0790989315a92a01c8565 100644 GIT binary patch literal 22038 zcmeHP{Zmxe89sb30s%!3sE&Mu4;6JSDk39Y5K)xiszixsx@j~uLJTcdX-(Q|)D(i6 zL3H@R z@4NS&^E~hKew=gfU2J#O&QBPTF+wSoP`#jXsZy~nrDANh=-@ZU&J9Hd|A}m=sIIdG zKTp`!v<2<3mZkG6RPX7rmz2s>)s^LSt@|&(Q=Xu*l1ww|^8=qu_a$ciWQj9ub$ozy&^k2UG@`8I zBt5w$g_cEUiUPJZPf~lZ)TzVtQRM1;!eP_W`&^KKAK9s4T zZ_o8p3#Nj8u<-x-U}5m>-#i?8f@YN8KHuVBJ$72`aDUr7{1DBqzecKSO`wBL2(~_* z5IP~0t_EEVO6gXhTY+u`>;$?RD8${jHXPm95O4h!K=(x56LnA2Ju#?&ZUwp(=vMHh zR`9Eq$4k2Qxkgt_T5@sR+JC<_cWCMAC#PJW=^kI-7Zi<3qTKVRG)IsAk! zD{S~3<$~d-b^3Dk)hIL6cr6sNlY?8XDP!C1^f?m12a`h4Yx?lnzelKhhAtF`eqvx8 zzdg9=$MU-m^n1J!`w{sDVQs0J`MBRFq=7cKF#NF_BEOudxqv7oc#o5Qj$n9kR_5E z`{ks9Osg_QHUB<1H)WJudU+9AL;_U1j@TJhm6BgKkx-7gqMGprWb#ZC)yy)0`nC#; zi_z(?lwf(w0K4&CfvqR(Y~)uDVT(byry4AZ5z3zVG{Lb4p;W~jI~QjoK>0i(Ym|fX zRV0+@0Oj8ypuiN6QUT@~;r0}j|Hz@Jv&xZMar zAsnaytbI3NQvsVs2RZ=Tw-_?U!M#34q=P0y5Qh--Sz}>d%LnBh&|(PHWWEo|pOJC| zC^JpGu|nCg1(f}5LfJDJl$)wVDIY1%MAob$>^{J*BJ3`}_EM>ffGxpHaMp%+7|$jG zwGz(W!r58$?iK>yydOdhqQ(Phm}vl|7nnn{Qy2%>b_P&-0m2S+;p{YA+zj^uo8fJM z`Nt|Y(JGCDh^F}{l=_^cG1dXftlLfST}?rJN|EO3MUlc-&5?N`g}EE^I*MdoS20L< zkTl+8Q1qca_;7_9= zIJd$9HK}Tpjl7PXk~+3PF1^Wwnl4?*;O0kVNaC75 zgiIjTF|%j8(uw<;A6$}{L}G$`L9_qdsh+U&9 zz`Agiu%l&NFiHsfjO3T&N#YzA94EILo>?;SStgVlYyNtATZ4A@DQC))HQ!iC2lk3T zj&YngR{ZfqGYx(Ye-6L_*S91DXTU~jOG7DDqIBvlA+Z&33> zgy4oXcA7DZ`!|8{?;?pqLgF$vB90b;*@T_C@<5AH z!un(*Vw@vvzeK?ND`7Xvy5PE>up4CAbIr3%X~C`;mMQmlE-puE1IDuFs;5CYoRm)p zWw)i<%R>2-rE0TKeuVYpiIngaF?F4?E*NjKo(g1LFl$J;6Qoi}YN6DvJ%X^f+wEpB z9oP*`WQjQCrp!3ZnQCOscUayY66ZY`oN1ZVt+Sn)UlM=1E0|K9;!hoR?#i`N{HY?2 z9ruDc)Z7i&C4?;$Skp!ax&$`B2B*5Ddjxnpp}NG|z!d9ZX(2}h$HM#felO~Y=giybMt3h z5!eNsNfri&_4MdpqT(zz3w01Cx8~}bF!5kQsYIBRTrC!}mCapEg-P$!<1ktBDh4J? zM8ycFL2KrGIXlQ~RQWi|q)}m#jpOO;n{txXWJYBNOWSy(WXubV67{=vACb=yeA|=!DP- zq1&Twk3j`=E6}Y#w*uV?bSwCZ?xN53j+6g&DgHmdJ#gaV_id7j?nu0%3mQf>ekKt5 oUVkm>XiXr4P6$hfJ5wYRVwRVUKWP4WYiQc)d9{_j6|SfL2alOeBme*a literal 22015 zcmeI4ZERF!7ROIZi&Jc6pbJ5%S{jN~3Kk?dAi@*|+5&cGg=iBOy{_n@OGsVN5GC@6 zMJfmpU$6qfiOB9ID>`Z<#Fud6ml`pq9|XI|u6H*ZMD6PMk}kp5ne+c2t6%Yh7@qmU zJe`^6ch7mweSY`cPI(~R+Eg**ydfe|(bAl{TBIxxIV%<$od3!GLnlk}|CFqp-Lg2A zf2@fu?aYshR$kpSTb_Np>O+xgX-PFMUcIyLNMnVJiHBk7ce_5F(ld0-6TeFrExGKA ziv0WW=6Wb3x3P>vS2BAhx$tkmV&?H@)x2k1%C;S50KX^tJ<;!peoxG2z;6Y9EAU&v zkG6v6S6^@+F5@6p0wujigz#b>I zAJKjZY!n0bE@0ak?OtFL#BKw&iqVb-b_=5|1-2X5_Qw(JZeaJ%LA0L%`!r^%);v{@ z)zIAycx}ymeXG3RsWG4E(23-pxMs2aF$^!@a6N{*FwAYi@Oln6V)!o(AH;AXhn*ba zh;xr%I1@(|U4eMkH0#=ivvA09eil{Yc4{36-g*?DOd#X30`u-%fOskp&)bZ49-{qV zm@ag3jxK1<2NU0cN)3uI@pjCVjp@Y6D=|}Br%s$1i2&;7Vd4## zcqHQa8@318gw=cNB5>BDj*nqG2#NiIc^d>Qmn&O_cvb`33v5si>~dfmSgKQj9fcD_ zb^!Y&v73QCN$fF1`wg&>0L$fSXS8<%n_#pPfUN?yhuCv~T}JE%V2=`eAF!XWO8yP( zG|Yr5k0Imkxl57b6sr8|1)9{{xp;4GG*WQ~jwu?7BmNWCCuG(CpTk9X^<54ZVE6%t z*J0SrVJ(KUcpL{)x_uw0gncg@rrX8WO*pJI3vpPN9ku#b?5eV)v#_JNN@7<4`w6hg zYZV*(5!lQXip`D(wgK2)Vn+enM{GH;cM^L)u&)ri2-x$9y%pGx80}(UZw5A)4lJWh zuhVGTh^?EWS$u}rM{6|NYlwXlN&P#qwOT`N1hFqDHpxQBOi@ny38b;^w>mN1E89?F zbgvx5o=f+-J&1?y4gZXZ>E574<1zPU>y<0apumhN@Cfu(!H+Yv3@8#N{v?%)QY#o!8vk<1`++SC+wK zbgxX&z0lk%=jrKV?v?X(0nELwRS(JBn`~07xi@o}V$Hn`z|y@k3Rt>V%7LYOWiYVZ z%%xJX=3dtVEZrNX6l?AcCM(w5n+BHdmAcuwwsf!jM$cMD_sT{+YeTwMN;RqGUiXw@ z&Al01AJDy0*QyiKy)shk*wMYRN$c3$>(*;L=HBoRn3(Pj8Z{nsZ?;b3p?jsROIMHX zm3m<5UYQCk-77m38`8aQGtx-+hPNoz+#A3@=HBeZiluv{7g)MimIF)o$~nN=a&17g zbg#P)(bB!)jfyq*M!4!S_qHQix>pjw(!EjzEZvK2z|wNd zm2Yvxbg#@n`O&>HLmlGiUU?O-(!KI3UZs2G_oyMdSN6lM=HA>e&7-+D?8afyy@FR` zdUY@R8w)jL?vr_dc;;StS+V9`nWI>9uft-Rd&9JruDLfzDAwHD04&`rUBJ@4vJhChSN;Ml-7B?< zHTSy7nniPO@?yoBdoxvvHTR|;)@bQosnd%xbFa)-j=7hOueq0vueq0vFWoC>d>!2@ zXnd{j6*Ru)UP0q)?saH<&An`V&AkB{Uvn=TU%FS&_&T~*(D+*4D`(fFEs^-MJPvhg+d zvhg+dvhg+d3L4*bx>wNnntKI}uen#y_?mkKjc<sx(P|My3KJ9@g@0xBHYX>&iyAB+}CM~T0oir%r1jx+Dxad<=D zk>?Acht+F8p4K;UOW}Di92W(c!rcG3kdXZ2)N(TR^Rc&yjM!TEe*yB7_-tw} zPc@r9gZ>=V0y?#m(U*@eA74KH+|e5FE=C%>i!BfS;*6Q_JA~gM{Pt+ufUVZ@Uyo9cjTvkZl3=xR3S!Zq6*ER3dc0EqyR(+Lt*K{c H2kX8E6h_Vy From c9bdcf4236b0a9986da2581094f9930cef830674 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 20 Jul 2026 21:41:54 +0200 Subject: [PATCH 06/21] fix: Recreate destroyed bodies on mount and preserve zero friction defaults --- doc/bridge_packages/flame_forge2d/joints.md | 3 +- .../flame_forge2d/migration.md | 24 ++++++++++---- doc/other_modules/forge2d/migration.md | 33 ++++++++++++++++--- examples/games/padracing/lib/car.dart | 2 +- examples/games/padracing/lib/tire.dart | 11 ++++++- examples/games/padracing/lib/wall.dart | 4 ++- .../flame_forge2d/domino_example.dart | 7 +++- .../flame_forge2d/lib/body_component.dart | 7 ++++ packages/flame_forge2d/lib/forge2d_game.dart | 12 ++++++- packages/flame_forge2d/lib/forge2d_world.dart | 21 +++++++++--- .../test/body_component_test.dart | 26 +++++++++++++++ .../test/forge2d_world_test.dart | 5 +-- 12 files changed, 131 insertions(+), 24 deletions(-) diff --git a/doc/bridge_packages/flame_forge2d/joints.md b/doc/bridge_packages/flame_forge2d/joints.md index 7d54bf03da0..5001db1ae0d 100644 --- a/doc/bridge_packages/flame_forge2d/joints.md +++ b/doc/bridge_packages/flame_forge2d/joints.md @@ -381,11 +381,10 @@ revoluteJoint.motorSpeed = 2; revoluteJoint.maxMotorTorque = 200; ``` -Also, you can get the joint angle and speed using the following getters: +Also, you can get the current joint angle: ```dart revoluteJoint.angle; -revoluteJoint.motorSpeed; ``` diff --git a/doc/bridge_packages/flame_forge2d/migration.md b/doc/bridge_packages/flame_forge2d/migration.md index d1aa3277d7a..78e5ba56fa6 100644 --- a/doc/bridge_packages/flame_forge2d/migration.md +++ b/doc/bridge_packages/flame_forge2d/migration.md @@ -84,6 +84,13 @@ The rendering hooks changed accordingly, and now read the geometry back from the `renderCircle` and `renderPolygon` are unchanged. `BodyComponent.center` now returns `body.worldCenterOfMass`, and `BodyDef(angle: a)` becomes `BodyDef(rotation: Rot.fromAngle(a))`. +```{warning} +The default friction changed: `FixtureDef` defaulted to 0, while +`SurfaceMaterial` defaults to 0.6. Shapes that relied on the old default are +no longer frictionless, so pass `SurfaceMaterial(friction: 0)` explicitly +where you need the old behavior. +``` + ## Contact callbacks @@ -104,7 +111,9 @@ What changed: - `Contact` is now a small flame_forge2d class instead of the Forge2D one. It carries `shapeA`, `shapeB`, `bodyA`, `bodyB`, `isSensorEvent`, and, for begin events, `normal` and `points`. Since end events can arrive after a shape was destroyed, check `contact.isValid` before using the - bodies. `contact.fixtureA`/`fixtureB` become `contact.shapeA`/`shapeB`. + bodies. `contact.fixtureA`/`fixtureB` become `contact.shapeA`/`shapeB`. The old `isTouching()` + and `getWorldManifold()` methods are gone: a begin event already means the shapes started + touching, and the manifold data is on the event itself. - **Contact events are opt-in per shape.** Box2D v3 only generates events for shapes that asked for them, so the involved shapes need `ShapeDef(enableContactEvents: true)`, and sensors together with their visitors need `enableSensorEvents: true`. The default `BodyComponent.createBody()` @@ -133,7 +142,8 @@ together with the body. `world.createJoint(joint)` and `world.destroyJoint(joint)`. - Queries follow the new Forge2D API: `raycast(callback, p1, p2)` becomes `castRayClosest`, `castRay`, or `castRayAll` (taking an origin and a translation), and `queryAABB(callback, aabb)` - becomes `overlapAabb(aabb)`. `clearForces()` and `raycastParticle` are gone. + becomes `overlapAabb(aabb)`, whose bounding box type was renamed from `AABB` to `Aabb`. + `clearForces()` and `raycastParticle` are gone. - `world.preSolveCallback` and `world.customFilterCallback` are new forwarding setters. - `subStepCount` (default 4) controls how many sub-steps each update performs, replacing the old velocity and position iteration counts. @@ -146,10 +156,12 @@ together with the body. ## Name collisions -Forge2D now exports `Transform`, `Rot`, `Circle`, `Polygon`, and `Segment`, which can collide with -names from `flutter/material.dart` (`Transform`) and `flame/geometry.dart` (`Circle`, `Polygon`). -Resolve them per file with `hide` or a prefixed import, for example: +Forge2D exports a `World`, which collides with Flame's `World` component, so files that use both +need `import 'package:flame_forge2d/flame_forge2d.dart' hide World;`. That was already the case +before, but Forge2D now also exports `Transform`, `Rot`, `Circle`, `Polygon`, and `Segment`, which +can collide with `flutter/material.dart` (`Transform`) and `flame/geometry.dart` (`Circle`, +`Polygon`, `Shape`). Resolve them per file with `hide` or a prefixed import, for example: ```dart -import 'package:flame_forge2d/flame_forge2d.dart' hide Transform; +import 'package:flame_forge2d/flame_forge2d.dart' hide Transform, World; ``` diff --git a/doc/other_modules/forge2d/migration.md b/doc/other_modules/forge2d/migration.md index aed797ccbc2..6dcc84cfb95 100644 --- a/doc/other_modules/forge2d/migration.md +++ b/doc/other_modules/forge2d/migration.md @@ -44,6 +44,14 @@ body.createShape( ); ``` +```{warning} +The default friction changed. `FixtureDef` defaulted to a friction of 0, +while `SurfaceMaterial` defaults to 0.6. Box2D mixes the friction of a +contact as `sqrt(frictionA * frictionB)`, so a pair where one side relied on +the old default was frictionless and no longer is. Pass +`SurfaceMaterial(friction: 0)` explicitly wherever you relied on it. +``` + `body.fixtures` becomes `body.shapes`, and `fixture.testPoint` becomes `shape.testPoint` (still in world coordinates). The shape's geometry can be read back for rendering or inspection with `shape.geometry`, which returns the sealed `ShapeGeometry` type: @@ -115,6 +123,11 @@ should be solved this step and requires `ShapeDef.enablePreSolveEvents` on the s Custom pair filtering, previously done by subclassing the contact filter, is now `world.customFilterCallback`. +The old `Contact` class is gone entirely, so its methods have no direct replacement. In particular +`contact.isTouching()`, which was commonly used to guard callbacks, is no longer needed because a +begin event means the shapes started touching, and `contact.getWorldManifold(...)` is replaced by +the `normal` and `points` on the begin event. + ## Queries return their results @@ -141,9 +154,9 @@ world.castRay(start, end - start, (hit) => 1); ``` Each `RayHit` carries the `shape`, `point`, `normal`, and `fraction`. `world.queryAABB(callback, -aabb)` becomes `world.overlapAabb(aabb)`, returning the overlapping shapes. `world.clearForces()` -is gone, as forces are applied per step in Box2D v3. Explosions are available through -`world.explode(ExplosionDef(...))`. +aabb)` becomes `world.overlapAabb(aabb)`, returning the overlapping shapes, and the axis-aligned +bounding box class was renamed from `AABB` to `Aabb`. `world.clearForces()` is gone, as forces are +applied per step in Box2D v3. Explosions are available through `world.explode(ExplosionDef(...))`. ## Joints @@ -180,6 +193,14 @@ enabled explicitly with `enableSpring`. Joint accessors are now getters and sett `getX()`/`setX()` methods, for example `joint.motorSpeed = 2` and `joint.angle`, and the limit setters take named arguments: `joint.setLimits(lower: 0, upper: pi)`. +The world-space anchors `joint.anchorA` and `joint.anchorB` no longer exist; only the local +anchors do. Compute the world position when you need it, for example when rendering a joint: + +```dart +final anchorA = joint.bodyA.worldPoint(joint.localAnchorA); +final anchorB = joint.bodyB.worldPoint(joint.localAnchorB); +``` + ## World and body changes @@ -194,6 +215,10 @@ setters take named arguments: `joint.setLimits(lower: 0, upper: pi)`. `body.angle` still exists. - Body renames: `worldCenter` is now `worldCenterOfMass`, `getLocalCenter()` is `localCenterOfMass`, `setAwake(value)` is `isAwake = value`, `getInertia()` is - `rotationalInertia`, and `worldVector(v)` is `rotation.rotate(v)`. + `rotationalInertia`, `bodyType` is `type`, `resetMassData()` is `applyMassFromShapes()`, + `setMassData(data)` is `massData = data`, `worldVector(v)` is `rotation.rotate(v)`, and + `localVector(v)` is `rotation.inverseRotate(v)`. +- `BodyDef` renames: `allowSleep` is now `enableSleep`, `bullet` is `isBullet`, and `active` is + `isEnabled`. `gravityOverride` has no replacement; use `gravityScale` or apply your own forces. - `userData` is stored on the Dart side in the world instead of a native pointer, and is cleared when the owning handle is destroyed. diff --git a/examples/games/padracing/lib/car.dart b/examples/games/padracing/lib/car.dart index 1e4f23a02b7..3e807cc1ad5 100644 --- a/examples/games/padracing/lib/car.dart +++ b/examples/games/padracing/lib/car.dart @@ -88,7 +88,7 @@ class Car extends BodyComponent { Polygon(vertices), ShapeDef( density: 0.2, - material: SurfaceMaterial(restitution: 2.0), + material: SurfaceMaterial(friction: 0, restitution: 2.0), // So that the lap line sensors can detect the car. enableSensorEvents: true, ), diff --git a/examples/games/padracing/lib/tire.dart b/examples/games/padracing/lib/tire.dart index 7a168dec1c5..563477d50fa 100644 --- a/examples/games/padracing/lib/tire.dart +++ b/examples/games/padracing/lib/tire.dart @@ -80,7 +80,16 @@ class Tire extends BodyComponent { ); final body = world.createBody(def)..userData = this; - body.createShape(Polygon.box(0.5, 1.25)).userData = this; + // Tire friction against the track is simulated in _updateFriction, so + // the shape itself is frictionless, as it was before the Box2D v3 + // migration (SurfaceMaterial defaults to 0.6 friction). + body + .createShape( + Polygon.box(0.5, 1.25), + ShapeDef(material: SurfaceMaterial(friction: 0)), + ) + .userData = + this; joint = world.physicsWorld.createRevoluteJoint( RevoluteJointDef( diff --git a/examples/games/padracing/lib/wall.dart b/examples/games/padracing/lib/wall.dart index 0c1dc863447..2ed7703cae4 100644 --- a/examples/games/padracing/lib/wall.dart +++ b/examples/games/padracing/lib/wall.dart @@ -97,7 +97,9 @@ class Wall extends BodyComponent { ..userData = this ..angularDamping = 3.0; - final shapeDef = ShapeDef(material: SurfaceMaterial(restitution: 0.5)); + final shapeDef = ShapeDef( + material: SurfaceMaterial(friction: 0, restitution: 0.5), + ); return body..createShape(Polygon.box(size.x / 2, size.y / 2), shapeDef); } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart index 246e7ec4d56..96ead982d1a 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart @@ -59,7 +59,12 @@ class Platform extends BodyComponent { @override Body createBody() { final bodyDef = BodyDef(position: _position); - return world.createBody(bodyDef)..createShape(Polygon.box(14.8, 0.125)); + // The platform is frictionless, as it was before the Box2D v3 migration + // (SurfaceMaterial defaults to 0.6 friction), so that the bricks slide + // and topple the way this example intends. + final shapeDef = ShapeDef(material: SurfaceMaterial(friction: 0)); + return world.createBody(bodyDef) + ..createShape(Polygon.box(14.8, 0.125), shapeDef); } } diff --git a/packages/flame_forge2d/lib/body_component.dart b/packages/flame_forge2d/lib/body_component.dart index 474c082fac0..ee57a1e4d74 100644 --- a/packages/flame_forge2d/lib/body_component.dart +++ b/packages/flame_forge2d/lib/body_component.dart @@ -116,6 +116,13 @@ class BodyComponent extends Component void onMount() { super.onMount(); world = game.world; + if (!body.isValid) { + // The body was destroyed when the component was removed, and since + // onLoad only runs once it has to be recreated here for the component + // to be usable again. Reading a destroyed body is not just stale, it + // reads freed native memory. + body = createBody(); + } } late Forge2DWorld world; diff --git a/packages/flame_forge2d/lib/forge2d_game.dart b/packages/flame_forge2d/lib/forge2d_game.dart index ad077fa1314..6cc72305c5f 100644 --- a/packages/flame_forge2d/lib/forge2d_game.dart +++ b/packages/flame_forge2d/lib/forge2d_game.dart @@ -5,13 +5,23 @@ import 'package:flame_forge2d/forge2d_world.dart'; /// The base game class for creating games that uses the Forge2D physics engine. class Forge2DGame extends FlameGame { + /// Creates a game with a [Forge2DWorld]. + /// + /// [contactEventsDispatcher] is only used for the world that this + /// constructor creates, so pass it to the [Forge2DWorld] itself when you + /// provide a [world]. Forge2DGame({ Forge2DWorld? world, CameraComponent? camera, Vector2? gravity, ContactEventsDispatcher? contactEventsDispatcher, double zoom = 10, - }) : super( + }) : assert( + world == null || contactEventsDispatcher == null, + 'contactEventsDispatcher is ignored when a world is provided, pass ' + 'it to the Forge2DWorld constructor instead', + ), + super( world: ((world?..gravity = gravity ?? world.gravity) ?? Forge2DWorld( diff --git a/packages/flame_forge2d/lib/forge2d_world.dart b/packages/flame_forge2d/lib/forge2d_world.dart index a39d26ef332..d536a0d4be9 100644 --- a/packages/flame_forge2d/lib/forge2d_world.dart +++ b/packages/flame_forge2d/lib/forge2d_world.dart @@ -27,15 +27,25 @@ class Forge2DWorld extends World { definition: definition, ), contactEventsDispatcher = - contactEventsDispatcher ?? ContactEventsDispatcher(); + contactEventsDispatcher ?? ContactEventsDispatcher() { + assert( + physicsWorld.isValid, + 'The physics world could not be created. Box2D allows a limited number ' + 'of simultaneous worlds, and Forge2D worlds are not freed automatically, ' + 'so call physicsWorld.destroy() on the worlds that you are done with.', + ); + } static final Vector2 defaultGravity = Vector2(0, 10.0); /// The underlying Forge2D physics world. /// /// The world is never destroyed by the component, so that it can be - /// re-added to the component tree later. If you are permanently done with - /// it, call `physicsWorld.destroy()` to free its native resources. + /// re-added to the component tree later. Since it holds native resources + /// that are not garbage collected, and Box2D allows only a limited number + /// of simultaneous worlds, call `physicsWorld.destroy()` when you are + /// permanently done with it, for example when you tear down a game that + /// you don't intend to show again. final forge2d.World physicsWorld; /// Routes the contact and sensor events that the physics world generated @@ -66,8 +76,9 @@ class Forge2DWorld extends World { /// Drops the bodies that have been destroyed without going through /// [destroyBody], for example by calling [Body.destroy] directly. /// - /// Their handles are not only stale but unusable, since Forge2D asserts on - /// destroyed bodies. + /// Keeping them would be worse than a leak: Box2D reuses the slots of + /// destroyed bodies, so a stale handle silently reads and writes whichever + /// body took its place. void _pruneDestroyedBodies() { _bodies.removeWhere((body) => !body.isValid); } diff --git a/packages/flame_forge2d/test/body_component_test.dart b/packages/flame_forge2d/test/body_component_test.dart index cbf3a524243..de514b0e15f 100644 --- a/packages/flame_forge2d/test/body_component_test.dart +++ b/packages/flame_forge2d/test/body_component_test.dart @@ -526,6 +526,32 @@ void main() { }); }); + testWithGame( + 'the body is recreated when the component is added back', + Forge2DGame.new, + (game) async { + final component = BodyComponent( + bodyDef: BodyDef(type: BodyType.dynamic, position: Vector2(3, 4)), + shapeSpecs: [ShapeSpec(Circle(radius: 1))], + ); + await game.world.ensureAdd(component); + final firstBody = component.body; + + component.removeFromParent(); + await game.ready(); + expect(firstBody.isValid, isFalse); + + await game.world.ensureAdd(component); + + // Reading a destroyed body reads freed native memory, so the + // component must come back with a live one. + expect(component.body.isValid, isTrue); + expect(component.body.position, Vector2(3, 4)); + expect(component.body.shapes, hasLength(1)); + expect(() => game.update(0), returnsNormally); + }, + ); + group('accessors', () { testWithGame('position, center and angle', Forge2DGame.new, (game) async { final component = BodyComponent( diff --git a/packages/flame_forge2d/test/forge2d_world_test.dart b/packages/flame_forge2d/test/forge2d_world_test.dart index 00f2e69df7a..2a703635c4e 100644 --- a/packages/flame_forge2d/test/forge2d_world_test.dart +++ b/packages/flame_forge2d/test/forge2d_world_test.dart @@ -86,8 +86,9 @@ void main() { destroyed.destroy(); expect(game.world.bodies, {kept}); - // Waking the bodies must not touch the destroyed one, since Forge2D - // asserts when a destroyed body is used. + // Waking the bodies must not touch the destroyed one, since Box2D + // reuses its slot and the stale handle would write to whichever body + // took its place. expect(() => game.world.gravity = Vector2(0, 5), returnsNormally); expect(kept.isAwake, isTrue); }, From 16e771fed6e232b336f2ead52730ad0c9a1b8fc2 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 20 Jul 2026 22:12:25 +0200 Subject: [PATCH 07/21] feat: Depend on the published forge2d 0.15.0 --- doc/bridge_packages/flame_forge2d/forge2d.md | 8 ++- .../flame_forge2d/migration.md | 10 +++- doc/other_modules/forge2d/forge2d.md | 27 +++++++++- doc/other_modules/forge2d/migration.md | 18 ++++++- packages/flame_forge2d/analysis_options.yaml | 6 --- packages/flame_forge2d/lib/forge2d_game.dart | 11 ++++ packages/flame_forge2d/lib/forge2d_world.dart | 54 +++++++++++++------ packages/flame_forge2d/pubspec.yaml | 8 +-- .../test/contact_callbacks_test.dart | 4 ++ .../test/contact_events_dispatcher_test.dart | 4 ++ packages/flame_forge2d/test/contact_test.dart | 4 ++ scripts/customer_testing.dart | 5 ++ 12 files changed, 124 insertions(+), 35 deletions(-) diff --git a/doc/bridge_packages/flame_forge2d/forge2d.md b/doc/bridge_packages/flame_forge2d/forge2d.md index eaa11cd0335..76bf4dcd889 100644 --- a/doc/bridge_packages/flame_forge2d/forge2d.md +++ b/doc/bridge_packages/flame_forge2d/forge2d.md @@ -16,7 +16,13 @@ instructions](https://pub.dev/packages/flame_forge2d)]( main() async { + // Required before any world is created. See "Initialization" below. + await initializeForge2D(); + final world = World(gravity: Vector2(0, -10)); final ground = world.createBody(BodyDef(position: Vector2(0, -1))); @@ -45,6 +48,26 @@ Note that when forge2d is used standalone the world uses Box2D's y-up convention gravity has a negative y value. The `flame_forge2d` bridge flips this for you to match Flame's y-down coordinate system. + +## Initialization + +`await initializeForge2D()` has to complete before the first `World` is created. On native +platforms it returns immediately, but on the web it fetches and instantiates the Box2D +WebAssembly module, and creating a world before it has finished throws a `StateError`. Since the +same code should run on every platform, always await it once during startup: + +```dart +await initializeForge2D(); +``` + +The web module is looked up at the package asset path served by the Dart web tooling, at the +asset that Flutter web bundles automatically, and finally at a `box2d.wasm` next to the page, so +no extra setup is needed for the common cases. If you host the module somewhere else, point +Forge2D at it with `initializeForge2D(wasmUri: ...)`. + +`flame_forge2d` awaits this for you in `Forge2DGame.onLoad`, so games built on it don't need to +call it, unless they create a `World` outside of the game. + `World`, `Body`, `Shape`, `Chain`, and the joints are cheap value-like handles over ids inside the native engine. Destruction is explicit: call `destroy()` on the handle when you are done with it, and `world.destroy()` frees the whole simulation. diff --git a/doc/other_modules/forge2d/migration.md b/doc/other_modules/forge2d/migration.md index 6dcc84cfb95..98c8cec4f3d 100644 --- a/doc/other_modules/forge2d/migration.md +++ b/doc/other_modules/forge2d/migration.md @@ -15,12 +15,26 @@ If your game depends on it, stay on forge2d 0.14. ``` +## Initialization is required + +`await initializeForge2D()` has to complete before the first `World` is created. On native +platforms it returns immediately; on the web it loads the Box2D WebAssembly module, and creating +a world without it throws a `StateError`. There was no such call in 0.14, so add one during +startup: + +```dart +await initializeForge2D(); +final world = World(gravity: Vector2(0, -10)); +``` + + ## Platform requirements The Dart SDK floor is now 3.12 (Flutter 3.44). On native platforms the bundled Box2D sources are compiled through the Dart build hooks, so a C toolchain is needed: Xcode on iOS and macOS, the NDK -on Android, Visual Studio Build Tools on Windows, and clang or gcc on Linux. On the web nothing -extra is needed, the bundled WebAssembly module is loaded automatically. +on Android, Visual Studio Build Tools on Windows, and clang or gcc on Linux. On the web the +bundled WebAssembly module is found automatically in the common hosting setups, so beyond +awaiting `initializeForge2D()` no extra setup is needed. ## Fixtures are gone, bodies carry shapes diff --git a/packages/flame_forge2d/analysis_options.yaml b/packages/flame_forge2d/analysis_options.yaml index 81921288b3b..92aae2f2499 100644 --- a/packages/flame_forge2d/analysis_options.yaml +++ b/packages/flame_forge2d/analysis_options.yaml @@ -1,7 +1 @@ include: package:flame_lint/analysis_options_with_dcm.yaml - -analyzer: - errors: - # TODO(spydon): Remove once the Box2D v3 based forge2d is published and - # the temporary git dependency is replaced with a pub version. - invalid_dependency: ignore diff --git a/packages/flame_forge2d/lib/forge2d_game.dart b/packages/flame_forge2d/lib/forge2d_game.dart index 6cc72305c5f..9eda4a9b359 100644 --- a/packages/flame_forge2d/lib/forge2d_game.dart +++ b/packages/flame_forge2d/lib/forge2d_game.dart @@ -2,6 +2,7 @@ import 'package:flame/camera.dart'; import 'package:flame/game.dart'; import 'package:flame_forge2d/contact_events_dispatcher.dart'; import 'package:flame_forge2d/forge2d_world.dart'; +import 'package:forge2d/forge2d.dart' show initializeForge2D; /// The base game class for creating games that uses the Forge2D physics engine. class Forge2DGame extends FlameGame { @@ -32,6 +33,16 @@ class Forge2DGame extends FlameGame { camera: (camera ?? CameraComponent())..viewfinder.zoom = zoom, ); + @override + Future onLoad() async { + // Forge2D has to be initialized before any physics world is created, + // which on the web means loading the Box2D WebAssembly module. The + // world of a [Forge2DWorld] is created lazily so that this can happen + // first. + await initializeForge2D(); + await super.onLoad(); + } + /// Takes a point in world coordinates and returns it in screen coordinates. Vector2 worldToScreen(Vector2 position) { return camera.localToGlobal(position); diff --git a/packages/flame_forge2d/lib/forge2d_world.dart b/packages/flame_forge2d/lib/forge2d_world.dart index d536a0d4be9..a615594e571 100644 --- a/packages/flame_forge2d/lib/forge2d_world.dart +++ b/packages/flame_forge2d/lib/forge2d_world.dart @@ -22,31 +22,49 @@ class Forge2DWorld extends World { forge2d.WorldDef? definition, ContactEventsDispatcher? contactEventsDispatcher, super.children, - }) : physicsWorld = forge2d.World( - gravity: gravity ?? definition?.gravity ?? defaultGravity, - definition: definition, - ), + }) : _gravity = gravity ?? definition?.gravity ?? defaultGravity, + _definition = definition, contactEventsDispatcher = - contactEventsDispatcher ?? ContactEventsDispatcher() { - assert( - physicsWorld.isValid, - 'The physics world could not be created. Box2D allows a limited number ' - 'of simultaneous worlds, and Forge2D worlds are not freed automatically, ' - 'so call physicsWorld.destroy() on the worlds that you are done with.', - ); - } + contactEventsDispatcher ?? ContactEventsDispatcher(); static final Vector2 defaultGravity = Vector2(0, 10.0); + Vector2 _gravity; + final forge2d.WorldDef? _definition; + forge2d.World? _physicsWorld; + /// The underlying Forge2D physics world. /// + /// It is created the first time it is used rather than in the constructor, + /// because Forge2D has to be initialized before a world can be created, + /// and on the web that initialization is asynchronous. [Forge2DGame] awaits + /// it in its `onLoad`; if you use a [Forge2DWorld] outside of a + /// [Forge2DGame] you have to `await initializeForge2D()` yourself before + /// the world is used. + /// /// The world is never destroyed by the component, so that it can be /// re-added to the component tree later. Since it holds native resources /// that are not garbage collected, and Box2D allows only a limited number /// of simultaneous worlds, call `physicsWorld.destroy()` when you are /// permanently done with it, for example when you tear down a game that /// you don't intend to show again. - final forge2d.World physicsWorld; + forge2d.World get physicsWorld { + final existingWorld = _physicsWorld; + if (existingWorld != null) { + return existingWorld; + } + final createdWorld = forge2d.World( + gravity: _gravity, + definition: _definition, + ); + assert( + createdWorld.isValid, + 'The physics world could not be created. Box2D allows a limited number ' + 'of simultaneous worlds, and Forge2D worlds are not freed automatically, ' + 'so call physicsWorld.destroy() on the worlds that you are done with.', + ); + return _physicsWorld = createdWorld; + } /// Routes the contact and sensor events that the physics world generated /// during a step to the [ContactCallbacks] in the involved userData. @@ -169,12 +187,18 @@ class Forge2DWorld extends World { } /// Don't change the gravity object directly, use the setter instead. - Vector2 get gravity => physicsWorld.gravity; + Vector2 get gravity => _physicsWorld?.gravity ?? _gravity; /// Sets the gravity of the world and wakes up all bodies that were created /// through [createBody]. set gravity(Vector2? gravity) { - physicsWorld.gravity = gravity ?? defaultGravity; + _gravity = gravity ?? defaultGravity; + final existingWorld = _physicsWorld; + if (existingWorld == null) { + // The world picks the gravity up when it is created. + return; + } + existingWorld.gravity = _gravity; _pruneDestroyedBodies(); for (final body in _bodies) { body.isAwake = true; diff --git a/packages/flame_forge2d/pubspec.yaml b/packages/flame_forge2d/pubspec.yaml index b066aa9d651..7250013741a 100644 --- a/packages/flame_forge2d/pubspec.yaml +++ b/packages/flame_forge2d/pubspec.yaml @@ -20,13 +20,7 @@ dependencies: flame: ^1.38.0 flutter: sdk: flutter - # TODO(spydon): Switch back to a pub version constraint once the Box2D v3 - # based forge2d has been published. - forge2d: - git: - url: https://github.com/flame-engine/forge2d.git - ref: box2d-wasm - path: packages/forge2d + forge2d: ^0.15.0 dev_dependencies: dartdoc: ^9.0.0 diff --git a/packages/flame_forge2d/test/contact_callbacks_test.dart b/packages/flame_forge2d/test/contact_callbacks_test.dart index 808812a0071..543f3d84bc8 100644 --- a/packages/flame_forge2d/test/contact_callbacks_test.dart +++ b/packages/flame_forge2d/test/contact_callbacks_test.dart @@ -3,6 +3,10 @@ import 'package:test/expect.dart'; import 'package:test/scaffolding.dart'; void main() { + // Forge2D has to be initialized before a world can be created, which + // Forge2DGame does automatically but a bare World does not. + setUpAll(initializeForge2D); + group('ContactCallbacks', () { late Object other; late World world; diff --git a/packages/flame_forge2d/test/contact_events_dispatcher_test.dart b/packages/flame_forge2d/test/contact_events_dispatcher_test.dart index b4164b3715c..b81db3f708b 100644 --- a/packages/flame_forge2d/test/contact_events_dispatcher_test.dart +++ b/packages/flame_forge2d/test/contact_events_dispatcher_test.dart @@ -7,6 +7,10 @@ import 'package:test/scaffolding.dart'; import 'helpers/mocks.dart'; void main() { + // Forge2D has to be initialized before a world can be created, which + // Forge2DGame does automatically but a bare World does not. + setUpAll(initializeForge2D); + group('ContactEventsDispatcher', () { late ContactEventsDispatcher dispatcher; late ContactCallbacks contactCallback; diff --git a/packages/flame_forge2d/test/contact_test.dart b/packages/flame_forge2d/test/contact_test.dart index 270a610bfef..a1ff7528ace 100644 --- a/packages/flame_forge2d/test/contact_test.dart +++ b/packages/flame_forge2d/test/contact_test.dart @@ -3,6 +3,10 @@ import 'package:test/expect.dart'; import 'package:test/scaffolding.dart'; void main() { + // Forge2D has to be initialized before a world can be created, which + // Forge2DGame does automatically but a bare World does not. + setUpAll(initializeForge2D); + group('Contact', () { late World world; late Body bodyA; diff --git a/scripts/customer_testing.dart b/scripts/customer_testing.dart index 2929b3c7951..bfbf05b1470 100644 --- a/scripts/customer_testing.dart +++ b/scripts/customer_testing.dart @@ -10,6 +10,11 @@ import 'dart:io'; // frequently in flutter/flutter's presubmit. Analyzing it here would block // framework changes on an unstable dependency that is not representative of // Flame, so it is excluded from this run. +// +// flame_forge2d depends on forge2d, which compiles Box2D from source through +// the Dart build hooks and therefore needs a C toolchain and working native +// asset support on the runner. That is a property of the runner rather than +// of the framework change under test, so it is excluded as well. const _excludedPackages = {'flame_3d', 'flame_forge2d'}; Future main() async { From 5ce67f766a01752a593be0862b2fa0b1ca0c350a Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 20 Jul 2026 22:28:36 +0200 Subject: [PATCH 08/21] fix: Await super.onLoad in the Forge2D examples so web init completes --- doc/bridge_packages/flame_forge2d/forge2d.md | 18 +++++++++++++++--- .../flame_forge2d/animated_body_example.dart | 2 +- .../flame_forge2d/composition_example.dart | 4 ++-- .../contact_callbacks_example.dart | 2 +- .../flame_forge2d/domino_example.dart | 2 +- .../flame_forge2d/drag_callbacks_example.dart | 2 +- .../flame_forge2d/joints/distance_joint.dart | 2 +- .../flame_forge2d/joints/motor_joint.dart | 2 +- .../flame_forge2d/joints/mouse_joint.dart | 2 +- .../flame_forge2d/joints/prismatic_joint.dart | 2 +- .../flame_forge2d/joints/revolute_joint.dart | 2 +- .../flame_forge2d/joints/weld_joint.dart | 2 +- .../flame_forge2d/raycast_example.dart | 2 +- .../revolute_joint_with_motor_example.dart | 2 +- .../flame_forge2d/sprite_body_example.dart | 2 +- .../flame_forge2d/tap_callbacks_example.dart | 2 +- .../flame_forge2d/widget_example.dart | 2 +- packages/flame_forge2d/lib/forge2d_game.dart | 12 ++++++++---- 18 files changed, 40 insertions(+), 24 deletions(-) diff --git a/doc/bridge_packages/flame_forge2d/forge2d.md b/doc/bridge_packages/flame_forge2d/forge2d.md index 76bf4dcd889..59c7c37223c 100644 --- a/doc/bridge_packages/flame_forge2d/forge2d.md +++ b/doc/bridge_packages/flame_forge2d/forge2d.md @@ -20,9 +20,21 @@ clang or gcc on Linux). On the web a bundled WebAssembly build of Box2D is used Forge2D has to be initialized with `await initializeForge2D()` before any physics world is created, which on the web is what loads that WebAssembly module. [Forge2DGame] awaits this in its -`onLoad`, so games don't have to do anything. If you create a `Forge2DWorld` or a raw Forge2D -`World` outside of a `Forge2DGame`, await `initializeForge2D()` yourself first, or the world -creation will throw on the web. +`onLoad`, so games don't have to do anything, but that also means that a `Forge2DGame` subclass +which overrides `onLoad` has to await `super.onLoad()` before it creates any bodies: + +```dart +class MyGame extends Forge2DGame { + @override + Future onLoad() async { + await super.onLoad(); // Not awaiting this breaks the game on the web. + world.add(MyBody()); + } +} +``` + +If you create a `Forge2DWorld` or a raw Forge2D `World` outside of a `Forge2DGame`, await +`initializeForge2D()` yourself first, or the world creation will throw on the web. If you are upgrading an existing game from flame_forge2d 0.19, see the [migration guide](migration.md). diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/animated_body_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/animated_body_example.dart index 7e80b26d1f1..7cf7f7561bf 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/animated_body_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/animated_body_example.dart @@ -28,7 +28,7 @@ class AnimatedBodyWorld extends Forge2DWorld @override Future onLoad() async { - super.onLoad(); + await super.onLoad(); chopper = await Flame.images.load('animations/chopper.png'); animation = SpriteAnimation.fromFrameData( diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/composition_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/composition_example.dart index e8aa956d0a5..f2c93d97180 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/composition_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/composition_example.dart @@ -18,7 +18,7 @@ class CompositionExample extends Forge2DGame { @override Future onLoad() async { - super.onLoad(); + await super.onLoad(); final boundaries = createBoundaries(this); world.addAll(boundaries); world.add(TappableText(Vector2(0, 5))); @@ -74,7 +74,7 @@ class TappableBall extends Ball with TapCallbacks { @override Future onLoad() async { - super.onLoad(); + await super.onLoad(); _textPaint = TextPaint(style: _textStyle); textComponent = TextComponent( text: counter.toString(), diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/contact_callbacks_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/contact_callbacks_example.dart index 772e73b821b..f7734c70026 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/contact_callbacks_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/contact_callbacks_example.dart @@ -22,7 +22,7 @@ class ContactCallbackWorld extends Forge2DWorld with TapCallbacks, HasGameReference { @override Future onLoad() async { - super.onLoad(); + await super.onLoad(); final boundaries = createBoundaries(game); addAll(boundaries); } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart index 96ead982d1a..d639322c2ac 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart @@ -23,7 +23,7 @@ class DominoExampleWorld extends Forge2DWorld @override Future onLoad() async { - super.onLoad(); + await super.onLoad(); final boundaries = createBoundaries(game); addAll(boundaries); diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/drag_callbacks_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/drag_callbacks_example.dart index 090b6e8fc44..95c628450ca 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/drag_callbacks_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/drag_callbacks_example.dart @@ -15,7 +15,7 @@ class DragCallbacksExample extends Forge2DGame { @override Future onLoad() async { - super.onLoad(); + await super.onLoad(); final boundaries = createBoundaries(this); world.addAll(boundaries); world.add(DraggableBall(Vector2.zero())); diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/distance_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/distance_joint.dart index 86b4f12f9d0..cdafa593a1e 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/distance_joint.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/distance_joint.dart @@ -17,7 +17,7 @@ class DistanceJointWorld extends Forge2DWorld with TapCallbacks, HasGameReference { @override Future onLoad() async { - super.onLoad(); + await super.onLoad(); addAll(createBoundaries(game)); } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/motor_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/motor_joint.dart index 08ff1a1bcea..d97f49f2080 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/motor_joint.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/motor_joint.dart @@ -25,7 +25,7 @@ class MotorJointWorld extends Forge2DWorld with TapCallbacks { @override Future onLoad() async { - super.onLoad(); + await super.onLoad(); final box = Box( startPosition: Vector2.zero(), diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/mouse_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/mouse_joint.dart index 2d33ca9155e..80ea56de286 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/mouse_joint.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/mouse_joint.dart @@ -23,7 +23,7 @@ class MouseJointWorld extends Forge2DWorld @override Future onLoad() async { - super.onLoad(); + await super.onLoad(); final boundaries = createBoundaries(game); addAll(boundaries); diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/prismatic_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/prismatic_joint.dart index 608b38bdb40..a21a77c0310 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/prismatic_joint.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/prismatic_joint.dart @@ -17,7 +17,7 @@ class PrismaticJointExample extends Forge2DGame { @override Future onLoad() async { - super.onLoad(); + await super.onLoad(); final box = DraggableBox(startPosition: anchor, width: 6, height: 6); world.add(box); diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/revolute_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/revolute_joint.dart index 0a2e0de1c24..d3682ba688a 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/revolute_joint.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/revolute_joint.dart @@ -22,7 +22,7 @@ class RevoluteJointWorld extends Forge2DWorld with TapCallbacks, HasGameReference { @override Future onLoad() async { - super.onLoad(); + await super.onLoad(); addAll(createBoundaries(game)); } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/weld_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/weld_joint.dart index f4e1df03013..c5db11ceb29 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/weld_joint.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/weld_joint.dart @@ -21,7 +21,7 @@ class WeldJointWorld extends Forge2DWorld @override Future onLoad() async { - super.onLoad(); + await super.onLoad(); final leftPillar = Box( startPosition: game.screenToWorld(Vector2(50, game.size.y)) diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/raycast_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/raycast_example.dart index 8774e8c2431..ab609be8ae5 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/raycast_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/raycast_example.dart @@ -27,7 +27,7 @@ class RaycastExample extends Forge2DGame with MouseMovementDetector { @override Future onLoad() async { - super.onLoad(); + await super.onLoad(); world.addAll(createBoundaries(this)); const numberOfRows = 3; diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/revolute_joint_with_motor_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/revolute_joint_with_motor_example.dart index 377c3906019..ac388f63a55 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/revolute_joint_with_motor_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/revolute_joint_with_motor_example.dart @@ -25,7 +25,7 @@ class RevoluteJointWithMotorWorld extends Forge2DWorld @override Future onLoad() async { - super.onLoad(); + await super.onLoad(); final boundaries = createBoundaries(game); addAll(boundaries); final center = Vector2.zero(); diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart index a2f5872f742..1811c3c6e8c 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart @@ -22,7 +22,7 @@ class SpriteBodyWorld extends Forge2DWorld with TapCallbacks, HasGameReference { @override Future onLoad() async { - super.onLoad(); + await super.onLoad(); addAll(createBoundaries(game)); } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/tap_callbacks_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/tap_callbacks_example.dart index 96831ba761a..b85236d6ca7 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/tap_callbacks_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/tap_callbacks_example.dart @@ -15,7 +15,7 @@ class TapCallbacksExample extends Forge2DGame { @override Future onLoad() async { - super.onLoad(); + await super.onLoad(); final boundaries = createBoundaries(this); world.addAll(boundaries); world.add(TappableBall(Vector2.zero())); diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart index 801bfc3d2d7..f7126706bdf 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart @@ -17,7 +17,7 @@ class WidgetExample extends Forge2DGame { @override Future onLoad() async { - super.onLoad(); + await super.onLoad(); final boundaries = createBoundaries(this, strokeWidth: 0); world.addAll(boundaries); } diff --git a/packages/flame_forge2d/lib/forge2d_game.dart b/packages/flame_forge2d/lib/forge2d_game.dart index 9eda4a9b359..56dbc7de72c 100644 --- a/packages/flame_forge2d/lib/forge2d_game.dart +++ b/packages/flame_forge2d/lib/forge2d_game.dart @@ -33,12 +33,16 @@ class Forge2DGame extends FlameGame { camera: (camera ?? CameraComponent())..viewfinder.zoom = zoom, ); + /// Initializes Forge2D and then loads the game. + /// + /// On the web this is what loads the Box2D WebAssembly module, and no + /// physics world can be created before it has completed, which is why the + /// world of a [Forge2DWorld] is created lazily. + /// + /// Subclasses that override this **must** await `super.onLoad()` before + /// they create any bodies, or the game will throw on the web. @override Future onLoad() async { - // Forge2D has to be initialized before any physics world is created, - // which on the web means loading the Box2D WebAssembly module. The - // world of a [Forge2DWorld] is created lazily so that this can happen - // first. await initializeForge2D(); await super.onLoad(); } From f0ab6e3b07bd16515f1eb820680dba92f9a6f808 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 20 Jul 2026 22:54:51 +0200 Subject: [PATCH 09/21] feat: Restyle the Forge2D examples and render their joints --- .../flame_forge2d/migration.md | 7 + examples/lib/main.dart | 4 + .../flame_forge2d/animated_body_example.dart | 3 +- .../flame_forge2d/camera_example.dart | 4 +- .../flame_forge2d/composition_example.dart | 4 +- .../contact_callbacks_example.dart | 3 +- .../flame_forge2d/domino_example.dart | 3 +- .../flame_forge2d/drag_callbacks_example.dart | 3 +- .../flame_forge2d/flame_forge2d.dart | 14 ++ .../flame_forge2d/joints/distance_joint.dart | 7 +- .../flame_forge2d/joints/filter_joint.dart | 42 +++++ .../flame_forge2d/joints/motor_joint.dart | 25 +-- .../flame_forge2d/joints/mouse_joint.dart | 17 +- .../flame_forge2d/joints/prismatic_joint.dart | 50 ++---- .../flame_forge2d/joints/revolute_joint.dart | 7 +- .../flame_forge2d/joints/weld_joint.dart | 7 +- .../flame_forge2d/joints/wheel_joint.dart | 158 ++++++++++++++++++ .../flame_forge2d/raycast_example.dart | 3 +- .../revolute_joint_with_motor_example.dart | 11 +- .../flame_forge2d/sprite_body_example.dart | 3 +- .../flame_forge2d/tap_callbacks_example.dart | 3 +- .../flame_forge2d/utils/balls.dart | 26 ++- .../flame_forge2d/utils/boundaries.dart | 12 +- .../flame_forge2d/utils/boxes.dart | 31 ++-- .../flame_forge2d/utils/joint_renderer.dart | 126 ++++++++++++++ .../flame_forge2d/utils/style.dart | 108 ++++++++++++ .../flame_forge2d/widget_example.dart | 3 +- 27 files changed, 573 insertions(+), 111 deletions(-) create mode 100644 examples/lib/stories/bridge_libraries/flame_forge2d/joints/filter_joint.dart create mode 100644 examples/lib/stories/bridge_libraries/flame_forge2d/joints/wheel_joint.dart create mode 100644 examples/lib/stories/bridge_libraries/flame_forge2d/utils/joint_renderer.dart create mode 100644 examples/lib/stories/bridge_libraries/flame_forge2d/utils/style.dart diff --git a/doc/bridge_packages/flame_forge2d/migration.md b/doc/bridge_packages/flame_forge2d/migration.md index daf05241b63..fef0376e6bd 100644 --- a/doc/bridge_packages/flame_forge2d/migration.md +++ b/doc/bridge_packages/flame_forge2d/migration.md @@ -97,6 +97,13 @@ no longer frictionless, so pass `SurfaceMaterial(friction: 0)` explicitly where you need the old behavior. ``` +Chains changed from two-sided to one-sided, which is easy to miss because it +compiles fine and only shows up as bodies falling through your level geometry. +The solid surface is to the right of the winding direction, and since Flame's +y-axis points down, that is the opposite order from what Box2D's own +documentation describes: list ground chains from **left to right**, and wind +loops clockwise on screen. If bodies fall through a chain, reverse its points. + ## Contact callbacks diff --git a/examples/lib/main.dart b/examples/lib/main.dart index 4cb28c5692a..bbb486a7f66 100644 --- a/examples/lib/main.dart +++ b/examples/lib/main.dart @@ -5,11 +5,13 @@ import 'package:examples/stories/animations/animations.dart'; import 'package:examples/stories/bridge_libraries/audio/audio.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/distance_joint.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/filter_joint.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/motor_joint.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/mouse_joint.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/prismatic_joint.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/revolute_joint.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/weld_joint.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/wheel_joint.dart'; import 'package:examples/stories/bridge_libraries/flame_isolate/isolate.dart'; import 'package:examples/stories/bridge_libraries/flame_jenny/jenny.dart'; import 'package:examples/stories/bridge_libraries/flame_lottie/lottie.dart'; @@ -47,6 +49,8 @@ void main() { 'prismatic_joint': PrismaticJointExample.new, 'revolute_joint': RevoluteJointExample.new, 'weld_joint': WeldJointExample.new, + 'wheel_joint': WheelJointExample.new, + 'filter_joint': FilterJointExample.new, }; final game = routes[page]?.call(); if (game != null) { diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/animated_body_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/animated_body_example.dart index 7cf7f7561bf..6b31e5d4c3f 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/animated_body_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/animated_body_example.dart @@ -1,12 +1,13 @@ import 'dart:ui'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; import 'package:flame/components.dart'; import 'package:flame/events.dart'; import 'package:flame/flame.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; -class AnimatedBodyExample extends Forge2DGame { +class AnimatedBodyExample extends Forge2DExampleGame { static const String description = ''' In this example we show how to add an animated chopper, which is created with a SpriteAnimationComponent, on top of a BodyComponent. diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/camera_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/camera_example.dart index 941423dba56..36ecbf1ef7f 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/camera_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/camera_example.dart @@ -1,9 +1,9 @@ import 'package:examples/stories/bridge_libraries/flame_forge2d/domino_example.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; import 'package:flame/events.dart'; -import 'package:flame_forge2d/flame_forge2d.dart'; -class CameraExample extends Forge2DGame { +class CameraExample extends Forge2DExampleGame { static const String description = ''' This example showcases the possibility to follow BodyComponents with the camera. When the screen is tapped a pizza is added, which the camera will diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/composition_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/composition_example.dart index f2c93d97180..9a9c6bd422e 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/composition_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/composition_example.dart @@ -1,14 +1,14 @@ import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/balls.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; import 'package:flame/components.dart'; import 'package:flame/effects.dart'; import 'package:flame/events.dart'; -import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flutter/material.dart'; const TextStyle _textStyle = TextStyle(color: Colors.white, fontSize: 2); -class CompositionExample extends Forge2DGame { +class CompositionExample extends Forge2DExampleGame { static const description = ''' This example shows how to compose a `BodyComponent` together with a normal Flame component. Click the ball to see the number increment. diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/contact_callbacks_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/contact_callbacks_example.dart index f7734c70026..87b84dcbaee 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/contact_callbacks_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/contact_callbacks_example.dart @@ -2,11 +2,12 @@ import 'dart:math' as math; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/balls.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; import 'package:flame/components.dart'; import 'package:flame/events.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; -class ContactCallbacksExample extends Forge2DGame { +class ContactCallbacksExample extends Forge2DExampleGame { static const description = ''' This example shows how `BodyComponent`s can react to collisions with other bodies. diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart index d639322c2ac..a9cd1ac5e25 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart @@ -2,11 +2,12 @@ import 'dart:ui'; import 'package:examples/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; import 'package:flame/components.dart'; import 'package:flame/events.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; -class DominoExample extends Forge2DGame { +class DominoExample extends Forge2DExampleGame { static const description = ''' In this example we can see some domino tiles lined up. If you tap on the screen a pizza is added which can tip the tiles over and diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/drag_callbacks_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/drag_callbacks_example.dart index 95c628450ca..da6de0bbae3 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/drag_callbacks_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/drag_callbacks_example.dart @@ -1,10 +1,11 @@ import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/balls.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; import 'package:flame/events.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flutter/material.dart' hide Draggable; -class DragCallbacksExample extends Forge2DGame { +class DragCallbacksExample extends Forge2DExampleGame { static const description = ''' In this example we use Flame's normal `DragCallbacks` mixin to give impulses to a ball when we are dragging it around. If you are interested in dragging diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart index fa3dd997050..cd5ef9252de 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart @@ -7,11 +7,13 @@ import 'package:examples/stories/bridge_libraries/flame_forge2d/contact_callback import 'package:examples/stories/bridge_libraries/flame_forge2d/domino_example.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/drag_callbacks_example.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/distance_joint.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/filter_joint.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/motor_joint.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/mouse_joint.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/prismatic_joint.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/revolute_joint.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/weld_joint.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/joints/wheel_joint.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/raycast_example.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/revolute_joint_with_motor_example.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart'; @@ -97,6 +99,12 @@ void addForge2DStories(Dashbook dashbook) { void addJointsStories(Dashbook dashbook) { dashbook .storiesOf('flame_forge2d/joints') + .add( + 'FilterJoint', + (DashbookContext ctx) => GameWidget(game: FilterJointExample()), + codeLink: link('joints/filter_joint.dart'), + info: FilterJointExample.description, + ) .add( 'DistanceJoint', (DashbookContext ctx) => GameWidget(game: DistanceJointExample()), @@ -132,5 +140,11 @@ void addJointsStories(Dashbook dashbook) { (DashbookContext ctx) => GameWidget(game: WeldJointExample()), codeLink: link('joints/weld_joint.dart'), info: WeldJointExample.description, + ) + .add( + 'WheelJoint', + (DashbookContext ctx) => GameWidget(game: WheelJointExample()), + codeLink: link('joints/wheel_joint.dart'), + info: WheelJointExample.description, ); } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/distance_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/distance_joint.dart index cdafa593a1e..7f6986a78d0 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/distance_joint.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/distance_joint.dart @@ -1,10 +1,12 @@ import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/balls.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/joint_renderer.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; import 'package:flame/components.dart'; import 'package:flame/events.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; -class DistanceJointExample extends Forge2DGame { +class DistanceJointExample extends Forge2DExampleGame { static const description = ''' This example shows how to use a `DistanceJoint`. Tap the screen to add a pair of balls joined with a `DistanceJoint`. @@ -32,7 +34,7 @@ class DistanceJointWorld extends Forge2DWorld await Future.wait([first.loaded, second.loaded]); - physicsWorld.createDistanceJoint( + final joint = physicsWorld.createDistanceJoint( DistanceJointDef( bodyA: first.body, bodyB: second.body, @@ -42,5 +44,6 @@ class DistanceJointWorld extends Forge2DWorld dampingRatio: 0.2, ), ); + add(JointRenderer(joint: joint)); } } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/filter_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/filter_joint.dart new file mode 100644 index 00000000000..0df9d81a183 --- /dev/null +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/filter_joint.dart @@ -0,0 +1,42 @@ +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/balls.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/joint_renderer.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; +import 'package:flame/components.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; + +class FilterJointExample extends Forge2DExampleGame { + static const description = ''' + This example shows how to use a `FilterJoint`, which doesn't constrain the + bodies at all: its only purpose is to stop two specific bodies from + colliding with each other. + + The two balls on the left are connected by a `FilterJoint` and fall + through each other, while the pair on the right collides as usual. + '''; + + FilterJointExample() + : super(gravity: Vector2(0, 10.0), world: FilterJointWorld()); +} + +class FilterJointWorld extends Forge2DWorld with HasGameReference { + @override + Future onLoad() async { + await super.onLoad(); + addAll(createBoundaries(game)); + + // The filtered pair, which passes through itself. + final filteredTop = Ball(Vector2(-14, -12), color: ExampleColors.emerald); + final filteredBottom = Ball(Vector2(-14, 4), color: ExampleColors.emerald); + await addAll([filteredTop, filteredBottom]); + + final joint = physicsWorld.createFilterJoint( + FilterJointDef(bodyA: filteredTop.body, bodyB: filteredBottom.body), + ); + add(JointRenderer(joint: joint, color: ExampleColors.emerald)); + + // The unfiltered pair, which collides normally. + add(Ball(Vector2(14, -12), color: ExampleColors.rose)); + add(Ball(Vector2(14, 4), color: ExampleColors.rose)); + } +} diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/motor_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/motor_joint.dart index d97f49f2080..9c9f4b149f6 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/motor_joint.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/motor_joint.dart @@ -1,12 +1,11 @@ -import 'dart:ui'; - import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/balls.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boxes.dart'; -import 'package:flame/components.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/joint_renderer.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; import 'package:flame/events.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; -class MotorJointExample extends Forge2DGame { +class MotorJointExample extends Forge2DExampleGame { static const description = ''' This example shows how to use a `MotorJoint`. The ball spins around the center point. Tap the screen to change the direction. @@ -32,10 +31,11 @@ class MotorJointWorld extends Forge2DWorld with TapCallbacks { width: 2, height: 1, bodyType: BodyType.static, + color: ExampleColors.slate, ); add(box); - ball = Ball(Vector2(0, -5)); + ball = Ball(Vector2(0, -5), color: ExampleColors.violet); add(ball); await Future.wait([ball.loaded, box.loaded]); @@ -82,18 +82,3 @@ class MotorJointWorld extends Forge2DWorld with TapCallbacks { joint.angularOffset = angularOffset; } } - -class JointRenderer extends Component { - JointRenderer({required this.joint}); - - final MotorJoint joint; - - @override - void render(Canvas canvas) { - canvas.drawLine( - joint.bodyA.worldPoint(joint.localAnchorA).toOffset(), - joint.bodyB.worldPoint(joint.localAnchorB).toOffset(), - debugPaint, - ); - } -} diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/mouse_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/mouse_joint.dart index 80ea56de286..2b70a864be1 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/mouse_joint.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/mouse_joint.dart @@ -1,14 +1,17 @@ import 'package:examples/stories/bridge_libraries/flame_forge2d/revolute_joint_with_motor_example.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/balls.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/joint_renderer.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; import 'package:flame/components.dart'; import 'package:flame/events.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; -class MouseJointExample extends Forge2DGame { +class MouseJointExample extends Forge2DExampleGame { static const description = ''' In this example we use a `MouseJoint` to make the ball follow the mouse - when you drag it around. + when you drag it around. The line shows the joint pulling the ball + towards the pointer. '''; MouseJointExample() @@ -20,6 +23,7 @@ class MouseJointWorld extends Forge2DWorld late Ball ball; late Body groundBody; MouseJoint? mouseJoint; + MouseJointRenderer? _jointRenderer; @override Future onLoad() async { @@ -29,7 +33,7 @@ class MouseJointWorld extends Forge2DWorld final center = Vector2.zero(); groundBody = createBody(BodyDef()); - ball = Ball(center, radius: 5); + ball = Ball(center, radius: 5, color: ExampleColors.amber); add(ball); add(CornerRamp(center)); add(CornerRamp(center, isMirrored: true)); @@ -41,7 +45,7 @@ class MouseJointWorld extends Forge2DWorld if (mouseJoint != null) { return; } - mouseJoint = physicsWorld.createMouseJoint( + final joint = physicsWorld.createMouseJoint( MouseJointDef( bodyA: groundBody, bodyB: ball.body, @@ -51,6 +55,9 @@ class MouseJointWorld extends Forge2DWorld hertz: 5, ), ); + mouseJoint = joint; + _jointRenderer = MouseJointRenderer(joint: joint); + add(_jointRenderer!); } @override @@ -63,5 +70,7 @@ class MouseJointWorld extends Forge2DWorld super.onDragEnd(info); mouseJoint?.destroy(); mouseJoint = null; + _jointRenderer?.removeFromParent(); + _jointRenderer = null; } } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/prismatic_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/prismatic_joint.dart index a21a77c0310..0b85b41484e 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/prismatic_joint.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/prismatic_joint.dart @@ -1,15 +1,15 @@ -import 'dart:ui'; - import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boxes.dart'; -import 'package:flame/components.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/joint_renderer.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; -class PrismaticJointExample extends Forge2DGame { +class PrismaticJointExample extends Forge2DExampleGame { static const description = ''' - This example shows how to use a `PrismaticJoint`. - + This example shows how to use a `PrismaticJoint`. + Drag the box along the specified axis, bound between lower and upper limits. Also, there's a motor enabled that's pulling the box to the lower limit. + The line shows the axis between the two limits. '''; final Vector2 anchor = Vector2.zero(); @@ -19,12 +19,18 @@ class PrismaticJointExample extends Forge2DGame { Future onLoad() async { await super.onLoad(); - final box = DraggableBox(startPosition: anchor, width: 6, height: 6); + final box = DraggableBox( + startPosition: anchor, + width: 6, + height: 6, + ); world.add(box); await Future.wait([box.loaded]); final joint = createJoint(box.body, anchor); - world.add(JointRenderer(joint: joint, anchor: anchor, axis: axis)); + world.add( + PrismaticJointRenderer(joint: joint, anchor: anchor, axis: axis), + ); } PrismaticJoint createJoint(Body box, Vector2 anchor) { @@ -45,31 +51,3 @@ class PrismaticJointExample extends Forge2DGame { ); } } - -class JointRenderer extends Component { - JointRenderer({ - required this.joint, - required this.anchor, - required this.axis, - }); - - final PrismaticJoint joint; - final Vector2 anchor; - final Vector2 axis; - final Vector2 p1 = Vector2.zero(); - final Vector2 p2 = Vector2.zero(); - - @override - void render(Canvas canvas) { - p1 - ..setFrom(axis) - ..scale(joint.lowerLimit) - ..add(anchor); - p2 - ..setFrom(axis) - ..scale(joint.upperLimit) - ..add(anchor); - - canvas.drawLine(p1.toOffset(), p2.toOffset(), debugPaint); - } -} diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/revolute_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/revolute_joint.dart index d3682ba688a..aba813bd975 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/revolute_joint.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/revolute_joint.dart @@ -2,11 +2,13 @@ import 'dart:math'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/balls.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/joint_renderer.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; import 'package:flame/components.dart'; import 'package:flame/events.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; -class RevoluteJointExample extends Forge2DGame { +class RevoluteJointExample extends Forge2DExampleGame { static const description = ''' In this example we use a joint to keep a body with several fixtures stuck to another body. @@ -63,9 +65,10 @@ class CircleShuffler extends BodyComponent { ); } - world.physicsWorld.createRevoluteJoint( + final joint = world.physicsWorld.createRevoluteJoint( RevoluteJointDef(bodyA: body, bodyB: ball.body), ); + world.add(JointRenderer(joint: joint)); return body; } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/weld_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/weld_joint.dart index c5db11ceb29..960a83d995e 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/weld_joint.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/weld_joint.dart @@ -1,11 +1,13 @@ import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/balls.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boxes.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/joint_renderer.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; import 'package:flame/components.dart'; import 'package:flame/events.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flutter/material.dart'; -class WeldJointExample extends Forge2DGame { +class WeldJointExample extends Forge2DExampleGame { static const description = ''' This example shows how to use a `WeldJoint`. Tap the screen to add a ball to test the bridge built using a `WeldJoint` @@ -89,7 +91,7 @@ class WeldJointWorld extends Forge2DWorld } void createWeldJoint(Body first, Body second, Vector2 anchor) { - physicsWorld.createWeldJoint( + final joint = physicsWorld.createWeldJoint( WeldJointDef( bodyA: first, bodyB: second, @@ -97,6 +99,7 @@ class WeldJointWorld extends Forge2DWorld localAnchorB: second.localPoint(anchor), ), ); + add(JointRenderer(joint: joint)); } @override diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/wheel_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/wheel_joint.dart new file mode 100644 index 00000000000..b765f77883c --- /dev/null +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/wheel_joint.dart @@ -0,0 +1,158 @@ +import 'dart:math'; +import 'dart:ui'; + +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/joint_renderer.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; + +class WheelJointExample extends Forge2DExampleGame { + static const description = ''' + This example shows how to use a `WheelJoint`, which is the suspension of + a vehicle: the wheel can spin and travel along a spring-loaded axis. + + Tap the screen to change the direction that the car drives in. + '''; + + WheelJointExample() + : super(gravity: Vector2(0, 10.0), world: WheelJointWorld(), zoom: 14); +} + +class WheelJointWorld extends Forge2DWorld + with TapCallbacks, HasGameReference { + final joints = []; + static const _motorSpeed = 20.0; + bool drivingRight = true; + + @override + Future onLoad() async { + await super.onLoad(); + + add(Terrain()); + // Keeps the car on the track instead of driving off into the void. + add(TrackEnd(Terrain.startX)); + add(TrackEnd(Terrain.endX)); + + final chassis = Chassis(Vector2(-14, 1)); + await add(chassis); + // Follow the car so that it stays visible while it drives. + game.camera.follow(chassis); + + for (final offset in [Vector2(-1.6, 0.9), Vector2(1.6, 0.9)]) { + final wheel = Wheel(chassis.body.position + offset); + await add(wheel); + + // The wheel hangs under the chassis on a spring and is driven by the + // joint's motor. + final joint = physicsWorld.createWheelJoint( + WheelJointDef( + bodyA: chassis.body, + bodyB: wheel.body, + localAnchorA: offset, + localAxisA: Vector2(0, 1), + hertz: 4, + dampingRatio: 0.6, + enableLimit: true, + lowerTranslation: -0.3, + upperTranslation: 0.3, + enableMotor: true, + maxMotorTorque: 60, + motorSpeed: _motorSpeed, + ), + ); + joints.add(joint); + add(JointRenderer(joint: joint, color: ExampleColors.amber)); + } + } + + @override + void onTapDown(TapDownEvent event) { + super.onTapDown(event); + drivingRight = !drivingRight; + for (final joint in joints) { + joint.motorSpeed = drivingRight ? _motorSpeed : -_motorSpeed; + } + } +} + +class Chassis extends BodyComponent with GlowingBody { + Chassis(this._position) { + paint = Paint()..color = ExampleColors.indigo; + } + + final Vector2 _position; + + @override + Body createBody() { + final bodyDef = BodyDef(type: BodyType.dynamic, position: _position); + return world.createBody(bodyDef)..createShape( + Polygon.box(2.2, 0.5), + ShapeDef(material: SurfaceMaterial(friction: 0.3)), + ); + } +} + +class Wheel extends BodyComponent with GlowingBody { + Wheel(this._position) { + paint = Paint()..color = ExampleColors.emerald; + } + + final Vector2 _position; + + @override + Body createBody() { + final bodyDef = BodyDef(type: BodyType.dynamic, position: _position); + return world.createBody(bodyDef)..createShape( + Circle(radius: 0.7), + ShapeDef(density: 2, material: SurfaceMaterial(friction: 1.5)), + ); + } +} + +/// Rolling hills for the car to drive over, built as a one-sided chain. +class Terrain extends BodyComponent with GlowingBody { + Terrain() { + paint = Paint()..color = ExampleColors.slate; + } + + static const startX = -20.0; + static const endX = 100.0; + + /// The height of the ground at [x]. + static double heightAt(double x) => 6 - sin((x - startX) / 3) * 1.2; + + @override + Body createBody() { + // Chains are one-sided: the solid surface is to the right of the winding + // direction. Flame's y-axis points down, so listing the ground from left + // to right is what puts the drivable surface on top. + final points = [ + for (var x = startX; x <= endX; x++) Vector2(x, heightAt(x)), + ]; + + return world.createBody(BodyDef())..createChain( + ChainDef( + points: points, + materials: [SurfaceMaterial(friction: 0.8)], + ), + ); + } +} + +/// A wall at the end of the track that the car bumps into. +class TrackEnd extends BodyComponent with GlowingBody { + TrackEnd(this._x) { + paint = Paint()..color = ExampleColors.slate; + } + + final double _x; + + @override + Body createBody() { + final bodyDef = BodyDef( + position: Vector2(_x, Terrain.heightAt(_x) - 4), + ); + return world.createBody(bodyDef)..createShape(Polygon.box(0.5, 4)); + } +} diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/raycast_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/raycast_example.dart index ab609be8ae5..cbfb075c803 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/raycast_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/raycast_example.dart @@ -2,13 +2,14 @@ import 'dart:math'; import 'dart:ui'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; import 'package:flame/components.dart'; import 'package:flame/events.dart'; import 'package:flame/input.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flutter/material.dart' show Colors, Paint, Canvas; -class RaycastExample extends Forge2DGame with MouseMovementDetector { +class RaycastExample extends Forge2DExampleGame with MouseMovementDetector { static const String description = ''' This example shows how ray casts can be used to find the nearest and farthest shapes. diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/revolute_joint_with_motor_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/revolute_joint_with_motor_example.dart index ac388f63a55..fe8aa648f65 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/revolute_joint_with_motor_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/revolute_joint_with_motor_example.dart @@ -2,11 +2,12 @@ import 'dart:math'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/balls.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; import 'package:flame/components.dart'; import 'package:flame/events.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; -class RevoluteJointWithMotorExample extends Forge2DGame { +class RevoluteJointWithMotorExample extends Forge2DExampleGame { static const String description = ''' This example showcases a revolute joint, which is the spinning balls in the center. @@ -107,9 +108,11 @@ class CornerRamp extends BodyComponent { Vector2(diff + 35.0 * mirrorFactor, -30.0), Vector2(diff + 17.5 * mirrorFactor, -15.0), ]; - if (isMirrored) { - // Chains are one-sided, and mirroring the points flips the winding - // direction, so reverse the list to keep the solid side consistent. + if (!isMirrored) { + // Chains are one-sided: the solid surface is to the right of the + // winding direction, which with Flame's downwards y-axis means the + // points have to run the other way around than they would in Box2D's + // own coordinate system. Mirroring flips the winding again. points.setAll(0, points.reversed.toList()); } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart index 1811c3c6e8c..a2837c81ef4 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart @@ -1,11 +1,12 @@ import 'dart:math'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; import 'package:flame/components.dart'; import 'package:flame/events.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; -class SpriteBodyExample extends Forge2DGame { +class SpriteBodyExample extends Forge2DExampleGame { static const String description = ''' In this example we show how to add a sprite on top of a `BodyComponent`. Tap the screen to add more pizzas. diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/tap_callbacks_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/tap_callbacks_example.dart index b85236d6ca7..c8aa2ce0cd3 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/tap_callbacks_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/tap_callbacks_example.dart @@ -1,10 +1,11 @@ import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/balls.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; import 'package:flame/events.dart'; import 'package:flame/palette.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; -class TapCallbacksExample extends Forge2DGame { +class TapCallbacksExample extends Forge2DExampleGame { static const String description = ''' In this example we show how to use Flame's TapCallbacks mixin to react to taps on `BodyComponent`s. diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/utils/balls.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/balls.dart index 27b7b7930eb..d7e5a6c1fe5 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/utils/balls.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/balls.dart @@ -1,9 +1,10 @@ import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; import 'package:flame/palette.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; import 'package:flutter/material.dart'; -class Ball extends BodyComponent with ContactCallbacks { +class Ball extends BodyComponent with ContactCallbacks, GlowingBody { late Paint originalPaint; bool giveNudge = false; final double radius; @@ -12,23 +13,23 @@ class Ball extends BodyComponent with ContactCallbacks { double _timeSinceNudge = 0.0; static const double _minNudgeRest = 2.0; - final Paint _blue = BasicPalette.blue.paint(); - Ball( this._position, { this.radius = 2, this.bodyType = BodyType.dynamic, Color? color, }) { - if (color != null) { - originalPaint = PaletteEntry(color).paint(); - } else { - originalPaint = randomPaint(); - } + originalPaint = Paint()..color = color ?? randomColor(); paint = originalPaint; } - Paint randomPaint() => PaintExtension.random(withAlpha: 0.9, base: 100); + static int _colorIndex = 0; + + /// Cycles through the palette so that neighbouring balls stay readable. + Color randomColor() => + ExampleColors.dynamicColor(_colorIndex++ % ExampleColors.dynamics.length); + + Paint randomPaint() => Paint()..color = randomColor(); @override Body createBody() { @@ -48,13 +49,6 @@ class Ball extends BodyComponent with ContactCallbacks { ..createShape(Circle(radius: radius), shapeDef); } - @override - void renderCircle(Canvas canvas, Offset center, double radius) { - super.renderCircle(canvas, center, radius); - final lineRotation = Offset(0, radius); - canvas.drawLine(center, center + lineRotation, _blue); - } - final _impulseForce = Vector2(0, 1000); @override diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart index a07c745de07..b6ae98fef90 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart @@ -1,5 +1,7 @@ +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; import 'package:flame/extensions.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flutter/material.dart'; List createBoundaries(Forge2DGame game, {double? strokeWidth}) { final visibleRect = game.camera.visibleWorldRect; @@ -16,13 +18,18 @@ List createBoundaries(Forge2DGame game, {double? strokeWidth}) { ]; } -class Wall extends BodyComponent { +class Wall extends BodyComponent with GlowingBody { final Vector2 start; final Vector2 end; final double strokeWidth; Wall(this.start, this.end, {double? strokeWidth}) - : strokeWidth = strokeWidth ?? 1; + : strokeWidth = strokeWidth ?? 0.15 { + paint = Paint()..color = ExampleColors.slate; + } + + @override + double get outlineWidth => strokeWidth; @override Body createBody() { @@ -34,7 +41,6 @@ class Wall extends BodyComponent { userData: this, // To be able to determine object in collision position: Vector2.zero(), ); - paint.strokeWidth = strokeWidth; return world.createBody(bodyDef) ..createShape(Segment(point1: start, point2: end), shapeDef); diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/utils/boxes.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/boxes.dart index 42b7705bccd..e32ad2937f2 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/utils/boxes.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/boxes.dart @@ -1,11 +1,11 @@ import 'dart:ui'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/joint_renderer.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; import 'package:flame/events.dart'; -import 'package:flame/extensions.dart'; -import 'package:flame/palette.dart'; import 'package:flame_forge2d/flame_forge2d.dart'; -class Box extends BodyComponent { +class Box extends BodyComponent with GlowingBody { final Vector2 startPosition; final double width; final double height; @@ -18,14 +18,15 @@ class Box extends BodyComponent { this.bodyType = BodyType.dynamic, Color? color, }) { - if (color != null) { - paint = PaletteEntry(color).paint(); - } else { - paint = randomPaint(); - } + paint = Paint()..color = color ?? randomColor(); } - Paint randomPaint() => PaintExtension.random(withAlpha: 0.9, base: 100); + static int _colorIndex = 0; + + Color randomColor() => + ExampleColors.dynamicColor(_colorIndex++ % ExampleColors.dynamics.length); + + Paint randomPaint() => Paint()..color = randomColor(); @override Body createBody() { @@ -47,6 +48,7 @@ class Box extends BodyComponent { class DraggableBox extends Box with DragCallbacks { MouseJoint? mouseJoint; + MouseJointRenderer? _jointRenderer; late final groundBody = world.createBody(BodyDef()); bool _destroyJoint = false; @@ -61,6 +63,8 @@ class DraggableBox extends Box with DragCallbacks { if (_destroyJoint && mouseJoint != null) { mouseJoint!.destroy(); mouseJoint = null; + _jointRenderer?.removeFromParent(); + _jointRenderer = null; _destroyJoint = false; } } @@ -71,7 +75,7 @@ class DraggableBox extends Box with DragCallbacks { final target = game.screenToWorld(event.devicePosition); - mouseJoint = world.physicsWorld.createMouseJoint( + final joint = world.physicsWorld.createMouseJoint( MouseJointDef( bodyA: groundBody, bodyB: body, @@ -81,6 +85,13 @@ class DraggableBox extends Box with DragCallbacks { hertz: 50, ), ); + mouseJoint = joint; + // Show the spring that drags the box towards the pointer. + _jointRenderer = MouseJointRenderer( + joint: joint, + color: ExampleColors.amber, + ); + world.add(_jointRenderer!); } @override diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/utils/joint_renderer.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/joint_renderer.dart new file mode 100644 index 00000000000..1cefaac695e --- /dev/null +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/joint_renderer.dart @@ -0,0 +1,126 @@ +import 'dart:ui'; + +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; +import 'package:flame/components.dart'; +import 'package:flame/extensions.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; + +/// Draws a [Joint] so that the constraint itself is visible, not only the +/// bodies it connects. +/// +/// The line runs between the two anchor points, with a dot on each anchor. +/// The renderer removes itself once the joint is destroyed. +class JointRenderer extends Component { + JointRenderer({required this.joint, Color? color, super.priority = -1}) + : color = color ?? ExampleColors.slate; + + final Joint joint; + final Color color; + + static const _anchorRadius = 0.22; + + late final Paint linePaint = Paint() + ..color = color.withValues(alpha: 0.9) + ..style = PaintingStyle.stroke + ..strokeWidth = 0.1; + + late final Paint anchorPaint = Paint()..color = color.withValues(alpha: 0.95); + + /// The anchor of the first body in world coordinates. + Vector2 get anchorA => joint.bodyA.worldPoint(joint.localAnchorA); + + /// The anchor of the second body in world coordinates. + Vector2 get anchorB => joint.bodyB.worldPoint(joint.localAnchorB); + + @override + void update(double dt) { + if (!joint.isValid) { + removeFromParent(); + } + } + + @override + void render(Canvas canvas) { + if (!joint.isValid) { + return; + } + renderJoint(canvas, anchorA.toOffset(), anchorB.toOffset()); + } + + /// Override this to draw something else than a line between the anchors. + void renderJoint(Canvas canvas, Offset anchorA, Offset anchorB) { + canvas + ..drawLine(anchorA, anchorB, linePaint) + ..drawCircle(anchorA, _anchorRadius, anchorPaint) + ..drawCircle(anchorB, _anchorRadius, anchorPaint); + } +} + +/// Draws a [MouseJoint] from the body it drags to the target it is pulling +/// towards, which is what makes the joint's spring visible. +class MouseJointRenderer extends JointRenderer { + MouseJointRenderer({required MouseJoint super.joint, super.color}); + + MouseJoint get mouseJoint => joint as MouseJoint; + + @override + void render(Canvas canvas) { + if (!joint.isValid) { + return; + } + // Body B is the dragged body, and body A is only the anchor that the + // joint is attached to, so the interesting line is target to body. + final target = mouseJoint.target.toOffset(); + final body = anchorB.toOffset(); + canvas + ..drawLine(target, body, linePaint) + ..drawCircle(body, JointRenderer._anchorRadius, anchorPaint) + ..drawCircle(target, JointRenderer._anchorRadius, anchorPaint) + ..drawCircle( + target, + JointRenderer._anchorRadius * 2.5, + linePaint, + ); + } +} + +/// Draws the axis of a [PrismaticJoint] between its lower and upper limit, so +/// that the range the body can travel is visible. +class PrismaticJointRenderer extends JointRenderer { + PrismaticJointRenderer({ + required PrismaticJoint super.joint, + required this.axis, + required this.anchor, + super.color, + }); + + PrismaticJoint get prismaticJoint => joint as PrismaticJoint; + + /// The axis that the joint moves along, in world coordinates. + final Vector2 axis; + + /// The point that the axis passes through, in world coordinates. + final Vector2 anchor; + + final Vector2 _lower = Vector2.zero(); + final Vector2 _upper = Vector2.zero(); + + @override + void render(Canvas canvas) { + if (!joint.isValid) { + return; + } + _lower + ..setFrom(axis) + ..scale(prismaticJoint.lowerLimit) + ..add(anchor); + _upper + ..setFrom(axis) + ..scale(prismaticJoint.upperLimit) + ..add(anchor); + canvas + ..drawLine(_lower.toOffset(), _upper.toOffset(), linePaint) + ..drawCircle(_lower.toOffset(), JointRenderer._anchorRadius, anchorPaint) + ..drawCircle(_upper.toOffset(), JointRenderer._anchorRadius, anchorPaint); + } +} diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/utils/style.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/style.dart new file mode 100644 index 00000000000..e8745ce173c --- /dev/null +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/style.dart @@ -0,0 +1,108 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flame_forge2d/flame_forge2d.dart'; + +/// The palette that the Forge2D examples share, matching the one used by the +/// Forge2D package's own examples. +abstract final class ExampleColors { + static const background = Color(0xFF0B1020); + static const indigo = Color(0xFF7C9CFF); + static const sky = Color(0xFF38BDF8); + static const amber = Color(0xFFFBBF24); + static const rose = Color(0xFFFB7185); + static const emerald = Color(0xFF34D399); + static const violet = Color(0xFFA78BFA); + static const slate = Color(0xFF64748B); + static const text = Color(0xFFE5E9F5); + + /// The colors used for dynamic bodies. + static const dynamics = [indigo, sky, amber, rose, emerald, violet]; + + /// A stable palette color for the body number [index]. + static Color dynamicColor(int index) => dynamics[index % dynamics.length]; +} + +/// Renders a [BodyComponent] as a translucent fill with a bright outline, the +/// look that the Forge2D examples use. +/// +/// The color comes from the component's [BodyComponent.paint], so a body only +/// has to pick a color to fit in. +mixin GlowingBody on BodyComponent { + /// The width of the outline in world units. + double get outlineWidth => 0.12; + + Color? _styledColor; + late Paint _fillPaint; + late Paint _outlinePaint; + + void _syncPaints() { + final color = paint.color; + if (_styledColor == color) { + return; + } + _styledColor = color; + _fillPaint = Paint()..color = color.withValues(alpha: 0.28); + _outlinePaint = Paint() + ..color = color.withValues(alpha: 0.95) + ..style = PaintingStyle.stroke + ..strokeWidth = outlineWidth; + } + + @override + void renderCircle(Canvas canvas, Offset center, double radius) { + _syncPaints(); + canvas + ..drawCircle(center, radius, _fillPaint) + ..drawCircle(center, radius, _outlinePaint) + // A radius line makes the rotation of the body visible. + ..drawLine(center, center + Offset(radius, 0), _outlinePaint); + } + + @override + void renderPolygon(Canvas canvas, List points) { + _syncPaints(); + final path = Path()..addPolygon(points, true); + canvas + ..drawPath(path, _fillPaint) + ..drawPath(path, _outlinePaint); + } + + @override + void renderSegment(Canvas canvas, Offset p1, Offset p2) { + _syncPaints(); + canvas.drawLine(p1, p2, _outlinePaint); + } + + @override + void renderCapsule(Canvas canvas, Offset p1, Offset p2, double radius) { + _syncPaints(); + final delta = p2 - p1; + final angle = math.atan2(delta.dy, delta.dx); + final path = Path() + ..addArc( + Rect.fromCircle(center: p1, radius: radius), + angle + math.pi / 2, + math.pi, + ) + ..arcTo( + Rect.fromCircle(center: p2, radius: radius), + angle - math.pi / 2, + math.pi, + false, + ) + ..close(); + canvas + ..drawPath(path, _fillPaint) + ..drawPath(path, _outlinePaint); + } +} + +/// The base game for the Forge2D examples, which gives them the shared dark +/// background. +class Forge2DExampleGame extends Forge2DGame { + Forge2DExampleGame({super.world, super.camera, super.gravity, super.zoom}); + + @override + Color backgroundColor() => ExampleColors.background; +} diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart index f7126706bdf..a7d1e4f0599 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart @@ -1,9 +1,10 @@ import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart'; +import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; import 'package:flame/game.dart'; import 'package:flame_forge2d/flame_forge2d.dart' hide Transform; import 'package:flutter/material.dart'; -class WidgetExample extends Forge2DGame { +class WidgetExample extends Forge2DExampleGame { static const String description = ''' This examples shows how to render a widget on top of a Forge2D body outside of Flame. From 990fd453f3833e061e5e9feb5c3fab47116b68e4 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 20 Jul 2026 23:29:23 +0200 Subject: [PATCH 10/21] fix: Use the Forge2D domino tower and make the corner ramps solid --- .../flame_forge2d/migration.md | 6 + .../flame_forge2d/camera_example.dart | 3 +- .../flame_forge2d/domino_example.dart | 176 ++++++++++++++---- .../revolute_joint_with_motor_example.dart | 31 ++- 4 files changed, 156 insertions(+), 60 deletions(-) diff --git a/doc/bridge_packages/flame_forge2d/migration.md b/doc/bridge_packages/flame_forge2d/migration.md index fef0376e6bd..1531f497d87 100644 --- a/doc/bridge_packages/flame_forge2d/migration.md +++ b/doc/bridge_packages/flame_forge2d/migration.md @@ -104,6 +104,12 @@ y-axis points down, that is the opposite order from what Box2D's own documentation describes: list ground chains from **left to right**, and wind loops clockwise on screen. If bodies fall through a chain, reverse its points. +A one-sided chain is the right shape for ground and walls that are only ever +approached from one side. For solid level geometry that has to block from +every direction, such as a ramp or a platform that bodies can reach from +below, use a `Polygon` instead: a chain loop is hollow, so bodies that get +past one edge end up trapped inside it. + ## Contact callbacks diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/camera_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/camera_example.dart index 36ecbf1ef7f..844a267fcb4 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/camera_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/camera_example.dart @@ -9,7 +9,8 @@ class CameraExample extends Forge2DExampleGame { camera. When the screen is tapped a pizza is added, which the camera will follow. Other than that it is the same as the domino example. '''; - CameraExample() : super(world: CameraExampleWorld()); + // The same zoom as the domino example, since it shows the same tower. + CameraExample() : super(world: CameraExampleWorld(), zoom: 24); } class CameraExampleWorld extends DominoExampleWorld { diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart index a9cd1ac5e25..061e23186c2 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart @@ -1,7 +1,7 @@ +import 'dart:math'; import 'dart:ui'; import 'package:examples/stories/bridge_libraries/flame_forge2d/sprite_body_example.dart'; -import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; import 'package:flame/components.dart'; import 'package:flame/events.dart'; @@ -9,38 +9,118 @@ import 'package:flame_forge2d/flame_forge2d.dart'; class DominoExample extends Forge2DExampleGame { static const description = ''' - In this example we can see some domino tiles lined up. - If you tap on the screen a pizza is added which can tip the tiles over and - cause a chain reaction. + The classic domino tower: vertical dominoes carry horizontal ones as + planks, level by level, with braces at the edges. + + The tower stands on its own until you tap the screen, which drops a pizza + that topples it. '''; DominoExample() - : super(gravity: Vector2(0, 10.0), world: DominoExampleWorld()); + : super( + gravity: Vector2(0, 10.0), + world: DominoExampleWorld(), + zoom: 24, + ); } class DominoExampleWorld extends Forge2DWorld with TapCallbacks, HasGameReference { - late Image pizzaImage; + static const dominoWidth = 0.2; + static const dominoHeight = 1.0; + static const baseCount = 12; + + int _tint = 0; @override Future onLoad() async { await super.onLoad(); - final boundaries = createBoundaries(game); - addAll(boundaries); + add(Ground()); + _buildTower(); + + // Frame the tower, which is built upwards from the ground at y = 0. + game.camera.viewfinder.position = Vector2(0, -towerHeight / 2); + } + + /// How tall the finished tower is, in world units. + static double get towerHeight => + dominoHeight * 0.5 + (dominoHeight + 2 * dominoWidth) * (baseCount - 1); - const numberOfRows = 7; - for (var i = 0; i < numberOfRows - 2; i++) { - add(Platform(Vector2(0.0, 5.0 * i))); + /// Adds a domino [height] above the ground, standing up or lying down. + void _addDomino( + double x, + double height, { + required bool horizontal, + required double density, + }) { + add( + Domino( + // The tower is built upwards, which is the negative y direction. + initialPosition: Vector2(x, -height), + horizontal: horizontal, + density: density, + color: ExampleColors.dynamicColor(_tint++), + ), + ); + } + + void _buildTower() { + var density = 10.0; + + for (var i = 0; i < baseCount; i++) { + final x = i * 1.5 * dominoHeight - 1.5 * dominoHeight * baseCount / 2; + _addDomino(x, dominoHeight / 2, horizontal: false, density: density); + _addDomino( + x, + dominoHeight + dominoWidth / 2, + horizontal: true, + density: density, + ); } - const numberPerRow = 25; - for (var i = 0; i < numberOfRows; ++i) { - for (var j = 0; j < numberPerRow; j++) { - final position = Vector2( - -14.75 + j * (29.5 / (numberPerRow - 1)), - -12.7 + 5 * i, + for (var level = 1; level < baseCount; level++) { + if (level > 3) { + density *= 0.8; + } + final height = + dominoHeight * 0.5 + (dominoHeight + 2 * dominoWidth) * 0.99 * level; + final count = baseCount - level; + for (var i = 0; i < count; i++) { + final x = i * 1.5 * dominoHeight - 1.5 * dominoHeight * count / 2; + // The braces at both ends of a level are heavier, which is what + // keeps the tower standing. + density *= 2.5; + if (i == 0) { + _addDomino( + x - 1.25 * dominoHeight + 0.5 * dominoWidth, + height - dominoWidth, + horizontal: false, + density: density, + ); + } + if (i == count - 1) { + _addDomino( + x + 1.25 * dominoHeight - 0.5 * dominoWidth, + height - dominoWidth, + horizontal: false, + density: density, + ); + } + density /= 2.5; + + _addDomino(x, height, horizontal: false, density: density); + _addDomino( + x, + height + 0.5 * (dominoWidth + dominoHeight), + horizontal: true, + density: density, + ); + _addDomino( + x, + height - 0.5 * (dominoWidth + dominoHeight), + horizontal: true, + density: density, ); - add(DominoBrick(position)); } } } @@ -52,37 +132,53 @@ class DominoExampleWorld extends Forge2DWorld } } -class Platform extends BodyComponent { - final Vector2 _position; +class Domino extends BodyComponent with GlowingBody { + Domino({ + required this.initialPosition, + required this.horizontal, + required this.density, + required Color color, + }) { + paint = Paint()..color = color; + } - Platform(this._position); + /// Where the domino starts out; [position] tracks the live body position. + final Vector2 initialPosition; + final bool horizontal; + final double density; + + @override + double get outlineWidth => 0.04; @override Body createBody() { - final bodyDef = BodyDef(position: _position); - // The platform is frictionless, as it was before the Box2D v3 migration - // (SurfaceMaterial defaults to 0.6 friction), so that the bricks slide - // and topple the way this example intends. - final shapeDef = ShapeDef(material: SurfaceMaterial(friction: 0)); - return world.createBody(bodyDef) - ..createShape(Polygon.box(14.8, 0.125), shapeDef); + final bodyDef = BodyDef( + type: BodyType.dynamic, + position: initialPosition, + rotation: horizontal ? Rot.fromAngle(pi / 2) : const Rot.identity(), + ); + return world.createBody(bodyDef)..createShape( + Polygon.box( + DominoExampleWorld.dominoWidth / 2, + DominoExampleWorld.dominoHeight / 2, + ), + ShapeDef( + density: density, + material: SurfaceMaterial(friction: 0.1, restitution: 0.65), + ), + ); } } -class DominoBrick extends BodyComponent { - final Vector2 _position; - - DominoBrick(this._position); +class Ground extends BodyComponent with GlowingBody { + Ground() { + paint = Paint()..color = ExampleColors.slate; + } @override Body createBody() { - final shapeDef = ShapeDef( - density: 25.0, - material: SurfaceMaterial(restitution: 0.4, friction: 0.5), - ); - - final bodyDef = BodyDef(type: BodyType.dynamic, position: _position); - return world.createBody(bodyDef) - ..createShape(Polygon.box(0.125, 2.0), shapeDef); + // The top of the ground is at y = 0, which the tower is built up from. + final bodyDef = BodyDef(position: Vector2(0, 1)); + return world.createBody(bodyDef)..createShape(Polygon.box(24, 1)); } } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/revolute_joint_with_motor_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/revolute_joint_with_motor_example.dart index fe8aa648f65..35611e73d75 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/revolute_joint_with_motor_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/revolute_joint_with_motor_example.dart @@ -1,4 +1,5 @@ import 'dart:math'; +import 'dart:ui'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/balls.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart'; @@ -90,8 +91,10 @@ class CircleShuffler extends BodyComponent { } } -class CornerRamp extends BodyComponent { - CornerRamp(this._center, {this.isMirrored = false}); +class CornerRamp extends BodyComponent with GlowingBody { + CornerRamp(this._center, {this.isMirrored = false}) { + paint = Paint()..color = ExampleColors.slate; + } final bool isMirrored; final Vector2 _center; @@ -100,30 +103,20 @@ class CornerRamp extends BodyComponent { Body createBody() { final mirrorFactor = isMirrored ? -1 : 1; final diff = 2.0 * mirrorFactor; - // Chains need at least four points, so a collinear point is added on the - // closing edge of the triangular loop. - final points = [ + // A solid polygon rather than a chain loop: chains are one-sided in + // Box2D v3, so a chain ramp lets balls through from the other side and + // they end up trapped inside it. + final vertices = [ Vector2(diff, 0), Vector2(diff + 20.0 * mirrorFactor, -20.0), Vector2(diff + 35.0 * mirrorFactor, -30.0), - Vector2(diff + 17.5 * mirrorFactor, -15.0), ]; - if (!isMirrored) { - // Chains are one-sided: the solid surface is to the right of the - // winding direction, which with Flame's downwards y-axis means the - // points have to run the other way around than they would in Box2D's - // own coordinate system. Mirroring flips the winding again. - points.setAll(0, points.reversed.toList()); - } final bodyDef = BodyDef(position: _center); - return world.createBody(bodyDef)..createChain( - ChainDef( - points: points, - materials: [SurfaceMaterial(friction: 0.1)], - isLoop: true, - ), + return world.createBody(bodyDef)..createShape( + Polygon(vertices), + ShapeDef(material: SurfaceMaterial(friction: 0.1)), ); } } From 570b1c6f526011598541005cd076569dd40dc071 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 20 Jul 2026 23:38:18 +0200 Subject: [PATCH 11/21] feat: Fly a Flutter FAB in the widget example and free the mouse joint ball --- .../flame_forge2d/joints/mouse_joint.dart | 6 +-- .../flame_forge2d/widget_example.dart | 43 ++++++++++++------- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/mouse_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/mouse_joint.dart index 2b70a864be1..6cb978863c1 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/mouse_joint.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/mouse_joint.dart @@ -1,4 +1,3 @@ -import 'package:examples/stories/bridge_libraries/flame_forge2d/revolute_joint_with_motor_example.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/balls.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart'; import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/joint_renderer.dart'; @@ -31,12 +30,9 @@ class MouseJointWorld extends Forge2DWorld final boundaries = createBoundaries(game); addAll(boundaries); - final center = Vector2.zero(); groundBody = createBody(BodyDef()); - ball = Ball(center, radius: 5, color: ExampleColors.amber); + ball = Ball(Vector2.zero(), radius: 5, color: ExampleColors.amber); add(ball); - add(CornerRamp(center)); - add(CornerRamp(center, isMirrored: true)); } @override diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart index a7d1e4f0599..e3bb6f0da3f 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart @@ -6,15 +6,23 @@ import 'package:flutter/material.dart'; class WidgetExample extends Forge2DExampleGame { static const String description = ''' - This examples shows how to render a widget on top of a Forge2D body outside - of Flame. + This example shows how to render a widget on top of a Forge2D body, + outside of Flame. The Flutter buttons are real widgets in the Flutter + tree, driven by the physics bodies underneath them, so you can press them + while they tumble around. '''; + /// The size of the button widget in logical pixels. + static const buttonWidth = 190.0; + static const buttonHeight = 48.0; + final List updateStates = []; final Map bodyIdMap = {}; final List addLaterIds = []; - WidgetExample() : super(zoom: 20, gravity: Vector2(0, 10.0)); + static const zoom = 20.0; + + WidgetExample() : super(zoom: zoom, gravity: Vector2(0, 10.0)); @override Future onLoad() async { @@ -31,8 +39,9 @@ class WidgetExample extends Forge2DExampleGame { ); final body = world.createBody(bodyDef); + // The body matches the size of the button widget that is drawn on top. body.createShape( - Polygon.box(4.6, 0.8), + Polygon.box(buttonWidth / 2 / zoom, buttonHeight / 2 / zoom), ShapeDef(material: SurfaceMaterial(restitution: 0.8, friction: 0.2)), ); return body; @@ -113,19 +122,23 @@ class _BodyButtonState extends State { } else { final bodyPosition = _game.worldToScreen(body.position); return Positioned( - top: bodyPosition.y - 18, - left: bodyPosition.x - 90, + top: bodyPosition.y - WidgetExample.buttonHeight / 2, + left: bodyPosition.x - WidgetExample.buttonWidth / 2, child: Transform.rotate( angle: body.angle, - child: ElevatedButton( - onPressed: () { - setState( - () => body.applyLinearImpulse(Vector2(0.0, 1000)), - ); - }, - child: const Text( - 'Flying button!', - textScaler: TextScaler.linear(2.0), + child: SizedBox( + width: WidgetExample.buttonWidth, + height: WidgetExample.buttonHeight, + child: FloatingActionButton.extended( + // Every button needs its own tag, since there is more than one. + heroTag: 'flying_button_$_bodyId', + onPressed: () { + setState( + () => body.applyLinearImpulse(Vector2(0.0, 1000)), + ); + }, + icon: const FlutterLogo(size: 28), + label: const Text('Flutter'), ), ), ), From f7bbb0e1017723616827096c193509b82ffd2e97 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 20 Jul 2026 23:54:06 +0200 Subject: [PATCH 12/21] feat!: Render Forge2D worlds with a meters-to-pixels constant instead of zoom Closes #2613 --- doc/bridge_packages/flame_forge2d/forge2d.md | 18 +++- .../flame_forge2d/migration.md | 19 ++++ .../flame_forge2d/camera_example.dart | 4 +- .../flame_forge2d/composition_example.dart | 2 +- .../flame_forge2d/domino_example.dart | 2 +- .../flame_forge2d/joints/wheel_joint.dart | 6 +- .../flame_forge2d/tap_callbacks_example.dart | 2 +- .../flame_forge2d/utils/style.dart | 7 +- .../flame_forge2d/widget_example.dart | 10 +- packages/flame_forge2d/lib/flame_forge2d.dart | 1 + packages/flame_forge2d/lib/forge2d_game.dart | 36 ++++++- .../flame_forge2d/lib/forge2d_viewfinder.dart | 90 ++++++++++++++++ .../test/body_component_test.dart | 2 +- .../flame_forge2d/test/forge2d_game_test.dart | 11 +- .../test/forge2d_viewfinder_test.dart | 101 ++++++++++++++++++ 15 files changed, 287 insertions(+), 24 deletions(-) create mode 100644 packages/flame_forge2d/lib/forge2d_viewfinder.dart create mode 100644 packages/flame_forge2d/test/forge2d_viewfinder_test.dart diff --git a/doc/bridge_packages/flame_forge2d/forge2d.md b/doc/bridge_packages/flame_forge2d/forge2d.md index 59c7c37223c..a13fa6cd7dc 100644 --- a/doc/bridge_packages/flame_forge2d/forge2d.md +++ b/doc/bridge_packages/flame_forge2d/forge2d.md @@ -48,11 +48,19 @@ If you are going to use Forge2D in your project it can be a good idea to use the It is called `Forge2DGame` and supports both the special Forge2D components called `BodyComponents` as well as normal Flame components. -`Forge2DGame` has a built-in `CameraComponent` and has a zoom level set to 10 by default, so your -components will be a lot bigger than in a normal Flame game. This is due to the speed limit in the -`Forge2D` world, which you would hit very quickly if you are using it with `zoom = 1.0`. You can -easily change the zoom level either by calling `super(zoom: yourZoom)` in your constructor or -doing `game.cameraComponent.viewfinder.zoom = yourZoom;` at a later stage. +`Forge2DGame` has a built-in `CameraComponent` that uses a `Forge2DViewfinder`. The physics world +is measured in meters, and the viewfinder renders one meter as `metersToPixels` pixels, which is +10 by default. Your components will therefore be a lot bigger than in a normal Flame game, which +is what you want: there is a speed limit in the `Forge2D` world that you would hit very quickly if +one meter was one pixel. + +You can change the scale either by calling `super(metersToPixels: yourScale)` in your constructor +or by doing `game.metersToPixels = yourScale;` at a later stage. + +The `zoom` of the viewfinder is applied on top of `metersToPixels` and defaults to 1, so it is free +for what it is normally used for: zooming the camera in and out. Everything except the rendering +stays in meters, so body positions, `camera.viewfinder.position`, `camera.visibleWorldRect` and the +local positions that events report are all still expressed in meters. If you are previously familiar with Box2D it can be good to know that the whole concept of the Box2d world is mapped to `world` in the `Forge2DGame` component and every `Body` that you want to diff --git a/doc/bridge_packages/flame_forge2d/migration.md b/doc/bridge_packages/flame_forge2d/migration.md index 1531f497d87..01020c2c3ff 100644 --- a/doc/bridge_packages/flame_forge2d/migration.md +++ b/doc/bridge_packages/flame_forge2d/migration.md @@ -173,6 +173,25 @@ together with the body. later. Call `world.physicsWorld.destroy()` yourself when you are permanently done with a world. +## Forge2DGame + +The meters-to-pixels scaling no longer goes through the camera's zoom, so the zoom is free for +zooming the camera in and out. + +- `Forge2DGame(zoom: 24)` becomes `Forge2DGame(metersToPixels: 24)`, and + `game.camera.viewfinder.zoom = 24` becomes `game.metersToPixels = 24`. The default is still 10, + so games that did not set a zoom look the same as before. +- The camera of a `Forge2DGame` uses a `Forge2DViewfinder`, which renders one meter of the physics + world as `metersToPixels` pixels. Its `zoom` is applied on top of that and now starts at 1. If + you pass your own `camera`, its viewfinder is replaced with a `Forge2DViewfinder`, so pass one + yourself if you have a custom viewfinder. +- Nothing outside of the rendering changed units: body positions, + `camera.viewfinder.position`, `camera.viewfinder.visibleGameSize`, `camera.visibleWorldRect` and + the local positions that events report are all still in meters. +- Code that used the zoom to convert between meters and pixels, for example when positioning a + Flutter widget on top of a body, should use `game.metersToPixels` instead. + + ## Name collisions Forge2D exports a `World`, which collides with Flame's `World` component, so files that use both diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/camera_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/camera_example.dart index 844a267fcb4..af3d4afdb6e 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/camera_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/camera_example.dart @@ -9,8 +9,8 @@ class CameraExample extends Forge2DExampleGame { camera. When the screen is tapped a pizza is added, which the camera will follow. Other than that it is the same as the domino example. '''; - // The same zoom as the domino example, since it shows the same tower. - CameraExample() : super(world: CameraExampleWorld(), zoom: 24); + // The same scale as the domino example, since it shows the same tower. + CameraExample() : super(world: CameraExampleWorld(), metersToPixels: 24); } class CameraExampleWorld extends DominoExampleWorld { diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/composition_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/composition_example.dart index 9a9c6bd422e..f261c8a8c84 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/composition_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/composition_example.dart @@ -14,7 +14,7 @@ class CompositionExample extends Forge2DExampleGame { Flame component. Click the ball to see the number increment. '''; - CompositionExample() : super(zoom: 20, gravity: Vector2(0, 10.0)); + CompositionExample() : super(metersToPixels: 20, gravity: Vector2(0, 10.0)); @override Future onLoad() async { diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart index 061e23186c2..ea77c8a468a 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart @@ -20,7 +20,7 @@ class DominoExample extends Forge2DExampleGame { : super( gravity: Vector2(0, 10.0), world: DominoExampleWorld(), - zoom: 24, + metersToPixels: 24, ); } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/wheel_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/wheel_joint.dart index b765f77883c..8c758a1c502 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/wheel_joint.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/wheel_joint.dart @@ -16,7 +16,11 @@ class WheelJointExample extends Forge2DExampleGame { '''; WheelJointExample() - : super(gravity: Vector2(0, 10.0), world: WheelJointWorld(), zoom: 14); + : super( + gravity: Vector2(0, 10.0), + world: WheelJointWorld(), + metersToPixels: 14, + ); } class WheelJointWorld extends Forge2DWorld diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/tap_callbacks_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/tap_callbacks_example.dart index c8aa2ce0cd3..411314089e9 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/tap_callbacks_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/tap_callbacks_example.dart @@ -12,7 +12,7 @@ class TapCallbacksExample extends Forge2DExampleGame { Tap the ball to give it a random impulse, or the text to add an effect to it. '''; - TapCallbacksExample() : super(zoom: 20, gravity: Vector2(0, 10.0)); + TapCallbacksExample() : super(metersToPixels: 20, gravity: Vector2(0, 10.0)); @override Future onLoad() async { diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/utils/style.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/style.dart index e8745ce173c..2aaf761cd5b 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/utils/style.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/style.dart @@ -101,7 +101,12 @@ mixin GlowingBody on BodyComponent { /// The base game for the Forge2D examples, which gives them the shared dark /// background. class Forge2DExampleGame extends Forge2DGame { - Forge2DExampleGame({super.world, super.camera, super.gravity, super.zoom}); + Forge2DExampleGame({ + super.world, + super.camera, + super.gravity, + super.metersToPixels, + }); @override Color backgroundColor() => ExampleColors.background; diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart index e3bb6f0da3f..ce9a6527abc 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart @@ -20,9 +20,10 @@ class WidgetExample extends Forge2DExampleGame { final Map bodyIdMap = {}; final List addLaterIds = []; - static const zoom = 20.0; + /// How many pixels one meter of the physics world is rendered as. + static const scale = 20.0; - WidgetExample() : super(zoom: zoom, gravity: Vector2(0, 10.0)); + WidgetExample() : super(metersToPixels: scale, gravity: Vector2(0, 10.0)); @override Future onLoad() async { @@ -41,7 +42,10 @@ class WidgetExample extends Forge2DExampleGame { // The body matches the size of the button widget that is drawn on top. body.createShape( - Polygon.box(buttonWidth / 2 / zoom, buttonHeight / 2 / zoom), + Polygon.box( + buttonWidth / 2 / scale, + buttonHeight / 2 / scale, + ), ShapeDef(material: SurfaceMaterial(restitution: 0.8, friction: 0.2)), ); return body; diff --git a/packages/flame_forge2d/lib/flame_forge2d.dart b/packages/flame_forge2d/lib/flame_forge2d.dart index 52584426a46..c4ab7078b86 100644 --- a/packages/flame_forge2d/lib/flame_forge2d.dart +++ b/packages/flame_forge2d/lib/flame_forge2d.dart @@ -7,4 +7,5 @@ export 'contact.dart'; export 'contact_callbacks.dart'; export 'contact_events_dispatcher.dart'; export 'forge2d_game.dart'; +export 'forge2d_viewfinder.dart'; export 'forge2d_world.dart'; diff --git a/packages/flame_forge2d/lib/forge2d_game.dart b/packages/flame_forge2d/lib/forge2d_game.dart index 56dbc7de72c..e819185710b 100644 --- a/packages/flame_forge2d/lib/forge2d_game.dart +++ b/packages/flame_forge2d/lib/forge2d_game.dart @@ -1,6 +1,7 @@ import 'package:flame/camera.dart'; import 'package:flame/game.dart'; import 'package:flame_forge2d/contact_events_dispatcher.dart'; +import 'package:flame_forge2d/forge2d_viewfinder.dart'; import 'package:flame_forge2d/forge2d_world.dart'; import 'package:forge2d/forge2d.dart' show initializeForge2D; @@ -8,6 +9,11 @@ import 'package:forge2d/forge2d.dart' show initializeForge2D; class Forge2DGame extends FlameGame { /// Creates a game with a [Forge2DWorld]. /// + /// The world is measured in meters and rendered with [metersToPixels] + /// pixels per meter, see [Forge2DViewfinder]. If you pass your own [camera] + /// its viewfinder is replaced with a [Forge2DViewfinder], unless it already + /// is one, in which case [metersToPixels] is applied to it. + /// /// [contactEventsDispatcher] is only used for the world that this /// constructor creates, so pass it to the [Forge2DWorld] itself when you /// provide a [world]. @@ -16,7 +22,7 @@ class Forge2DGame extends FlameGame { CameraComponent? camera, Vector2? gravity, ContactEventsDispatcher? contactEventsDispatcher, - double zoom = 10, + double metersToPixels = Forge2DViewfinder.defaultMetersToPixels, }) : assert( world == null || contactEventsDispatcher == null, 'contactEventsDispatcher is ignored when a world is provided, pass ' @@ -30,9 +36,35 @@ class Forge2DGame extends FlameGame { contactEventsDispatcher: contactEventsDispatcher, )) as T, - camera: (camera ?? CameraComponent())..viewfinder.zoom = zoom, + camera: _prepareCamera(camera, metersToPixels), ); + static CameraComponent _prepareCamera( + CameraComponent? camera, + double metersToPixels, + ) { + if (camera == null) { + return CameraComponent( + viewfinder: Forge2DViewfinder(metersToPixels: metersToPixels), + ); + } + final viewfinder = camera.viewfinder; + if (viewfinder is Forge2DViewfinder) { + viewfinder.metersToPixels = metersToPixels; + } else { + camera.viewfinder = Forge2DViewfinder(metersToPixels: metersToPixels); + } + return camera; + } + + /// The number of pixels that one meter of the physics world is rendered as. + /// + /// See [Forge2DViewfinder.metersToPixels]. + double get metersToPixels => _viewfinder.metersToPixels; + set metersToPixels(double value) => _viewfinder.metersToPixels = value; + + Forge2DViewfinder get _viewfinder => camera.viewfinder as Forge2DViewfinder; + /// Initializes Forge2D and then loads the game. /// /// On the web this is what loads the Box2D WebAssembly module, and no diff --git a/packages/flame_forge2d/lib/forge2d_viewfinder.dart b/packages/flame_forge2d/lib/forge2d_viewfinder.dart new file mode 100644 index 00000000000..7a9c2ba61a9 --- /dev/null +++ b/packages/flame_forge2d/lib/forge2d_viewfinder.dart @@ -0,0 +1,90 @@ +import 'package:flame/camera.dart'; +import 'package:flame/game.dart'; + +/// A [Viewfinder] that renders a world measured in meters onto a screen +/// measured in pixels. +/// +/// Forge2D has a speed limit that a body reaches very quickly when one meter +/// is rendered as one pixel, so a physics world has to be laid out in meters +/// that are considerably smaller than a pixel. [metersToPixels] is the number +/// of pixels that one meter is rendered as, and keeping it separate from the +/// [zoom] leaves the zoom free for what it is meant for: zooming the camera +/// in and out. +/// +/// Only the rendering is affected, so body positions, the [position] of the +/// viewfinder, [visibleGameSize], [CameraComponent.visibleWorldRect] and the +/// local positions that events report are all still in meters. +class Forge2DViewfinder extends Viewfinder { + Forge2DViewfinder({double metersToPixels = defaultMetersToPixels, super.key}) + : assert(metersToPixels > 0, 'metersToPixels must be positive'), + _metersToPixels = metersToPixels { + zoom = 1; + } + + /// The number of pixels that one meter is rendered as when no other value + /// is given. + static const double defaultMetersToPixels = 10; + + double _metersToPixels; + + /// The number of pixels that one meter of the physics world is rendered as. + double get metersToPixels => _metersToPixels; + set metersToPixels(double value) { + assert(value > 0, 'metersToPixels must be positive: $value'); + if (value == _metersToPixels) { + return; + } + final currentZoom = zoom; + final currentVisibleGameSize = visibleGameSize; + _metersToPixels = value; + // Both of these are stored in the base class scaled by the old value, so + // they have to be written back through the overrides below. + zoom = currentZoom; + visibleGameSize = currentVisibleGameSize; + } + + /// The zoom level of the camera, on top of [metersToPixels]. + /// + /// A zoom of 1, which is the default, renders the world at its natural + /// size, where one meter covers [metersToPixels] pixels. + @override + double get zoom => super.zoom / _metersToPixels; + + @override + set zoom(double value) { + assert(value > 0, 'zoom level must be positive: $value'); + super.zoom = value * _metersToPixels; + } + + /// How much of the game world, in meters, ought to be visible through the + /// viewport. + /// + /// See [Viewfinder.visibleGameSize]. + @override + Vector2? get visibleGameSize { + final size = super.visibleGameSize; + return size == null ? null : size / _metersToPixels; + } + + @override + set visibleGameSize(Vector2? value) { + // The base class picks the zoom from this size, and that zoom goes back + // through the setter above, so the size has to be pre-scaled for the two + // factors of [metersToPixels] to cancel out. + super.visibleGameSize = value == null ? null : value * _metersToPixels; + } + + // The [ScaleProvider] API, which effects use to zoom the camera. It is + // routed through [zoom] so that it stays in the same units. + @override + Vector2 get scale => Vector2.all(zoom); + + @override + set scale(Vector2 value) { + assert( + value.x == value.y, + 'Non-uniform scale cannot be applied to a Viewfinder: $value', + ); + zoom = value.x; + } +} diff --git a/packages/flame_forge2d/test/body_component_test.dart b/packages/flame_forge2d/test/body_component_test.dart index de514b0e15f..8b82d7e28d2 100644 --- a/packages/flame_forge2d/test/body_component_test.dart +++ b/packages/flame_forge2d/test/body_component_test.dart @@ -596,7 +596,7 @@ void main() { group('containsLocalPoint', () { testWithGame('with rotation', Forge2DGame.new, (game) async { game.camera.viewfinder.anchor = Anchor.topLeft; - final zoom = game.camera.viewfinder.zoom; + final zoom = game.metersToPixels; final position = Vector2.all(10); final body = game.world.createBody( BodyDef(position: position, rotation: Rot.fromAngle(pi / 2)), diff --git a/packages/flame_forge2d/test/forge2d_game_test.dart b/packages/flame_forge2d/test/forge2d_game_test.dart index 197d7532cec..a735c4c1c84 100644 --- a/packages/flame_forge2d/test/forge2d_game_test.dart +++ b/packages/flame_forge2d/test/forge2d_game_test.dart @@ -3,7 +3,7 @@ import 'package:flame_test/flame_test.dart'; import 'package:test/test.dart'; class _TestForge2dGame extends Forge2DGame { - _TestForge2dGame() : super(zoom: 4.0, gravity: Vector2(0, -10.0)); + _TestForge2dGame() : super(metersToPixels: 4.0, gravity: Vector2(0, -10.0)); } void main() { @@ -32,7 +32,7 @@ void main() { game.onGameResize(size); expect( game.screenToWorld(Vector2.zero()), - -(size / 2) / game.camera.viewfinder.zoom, + -(size / 2) / game.metersToPixels, ); }, ); @@ -46,7 +46,7 @@ void main() { game.onGameResize(size); expect( game.screenToWorld(screenPosition), - (-size / 2 + screenPosition) / game.camera.viewfinder.zoom, + (-size / 2 + screenPosition) / game.metersToPixels, ); }, ); @@ -73,7 +73,7 @@ void main() { game.onGameResize(size); expect( game.worldToScreen(worldPosition), - (size / 2) + worldPosition * game.camera.viewfinder.zoom, + (size / 2) + worldPosition * game.metersToPixels, ); }, ); @@ -90,8 +90,7 @@ void main() { expect( game.worldToScreen(worldPosition), (size / 2) + - (worldPosition - viewfinderPosition) * - game.camera.viewfinder.zoom, + (worldPosition - viewfinderPosition) * game.metersToPixels, ); }, ); diff --git a/packages/flame_forge2d/test/forge2d_viewfinder_test.dart b/packages/flame_forge2d/test/forge2d_viewfinder_test.dart new file mode 100644 index 00000000000..2b6ae946364 --- /dev/null +++ b/packages/flame_forge2d/test/forge2d_viewfinder_test.dart @@ -0,0 +1,101 @@ +import 'package:flame/camera.dart'; +import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:test/test.dart'; + +void main() { + group('Forge2DViewfinder', () { + test('scales the transform by metersToPixels', () { + final viewfinder = Forge2DViewfinder(metersToPixels: 20); + expect(viewfinder.zoom, 1); + expect(viewfinder.transform.scale, Vector2.all(20)); + }); + + test('keeps the zoom when metersToPixels changes', () { + final viewfinder = Forge2DViewfinder(metersToPixels: 20)..zoom = 3; + expect(viewfinder.transform.scale, Vector2.all(60)); + + viewfinder.metersToPixels = 5; + expect(viewfinder.zoom, 3); + expect(viewfinder.transform.scale, Vector2.all(15)); + }); + + test('scale is expressed in zoom levels', () { + final viewfinder = Forge2DViewfinder(metersToPixels: 20)..zoom = 2; + expect(viewfinder.scale, Vector2.all(2)); + + viewfinder.scale = Vector2.all(4); + expect(viewfinder.zoom, 4); + expect(viewfinder.transform.scale, Vector2.all(80)); + }); + + test('rejects non-positive values', () { + final viewfinder = Forge2DViewfinder(); + expect(() => viewfinder.zoom = 0, throwsA(isA())); + expect( + () => viewfinder.metersToPixels = -1, + throwsA(isA()), + ); + expect( + () => viewfinder.scale = Vector2(1, 2), + throwsA(isA()), + ); + expect( + () => Forge2DViewfinder(metersToPixels: 0), + throwsA(isA()), + ); + }); + + testWithGame( + 'visibleGameSize and visibleWorldRect are in meters', + () => Forge2DGame(metersToPixels: 20), + (game) async { + game.onGameResize(Vector2.all(100)); + game.camera.viewfinder.visibleGameSize = Vector2.all(50); + + expect(game.camera.viewfinder.visibleGameSize, Vector2.all(50)); + expect(game.camera.viewfinder.zoom, 0.1); + expect(game.camera.visibleWorldRect.width, 50); + expect(game.camera.visibleWorldRect.height, 50); + + // Meters, not pixels: the size is unchanged by the rendering scale. + game.metersToPixels = 40; + expect(game.camera.viewfinder.visibleGameSize, Vector2.all(50)); + expect(game.camera.visibleWorldRect.width, 50); + }, + ); + + testWithGame( + 'a provided camera gets a Forge2DViewfinder', + () => Forge2DGame(camera: CameraComponent(), metersToPixels: 15), + (game) async { + expect(game.camera.viewfinder, isA()); + expect(game.metersToPixels, 15); + expect(game.camera.viewfinder.zoom, 1); + }, + ); + + testWithGame( + 'a provided Forge2DViewfinder is reused', + () => Forge2DGame( + camera: CameraComponent(viewfinder: Forge2DViewfinder()..zoom = 2), + metersToPixels: 15, + ), + (game) async { + expect(game.metersToPixels, 15); + expect(game.camera.viewfinder.zoom, 2); + }, + ); + + testWithGame( + 'the default renders one meter as ten pixels', + Forge2DGame.new, + (game) async { + game.onGameResize(Vector2.all(100)); + expect(game.metersToPixels, 10); + expect(game.camera.viewfinder.zoom, 1); + expect(game.worldToScreen(Vector2.all(1)), Vector2.all(60)); + }, + ); + }); +} From 2311467a9adae152544f13f61f20d1a47ceddc9d Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 20 Jul 2026 23:57:19 +0200 Subject: [PATCH 13/21] refactor: Set up the Forge2D viewfinder in the constructor body --- packages/flame_forge2d/lib/forge2d_game.dart | 21 ++++++-------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/packages/flame_forge2d/lib/forge2d_game.dart b/packages/flame_forge2d/lib/forge2d_game.dart index e819185710b..8f95e39b156 100644 --- a/packages/flame_forge2d/lib/forge2d_game.dart +++ b/packages/flame_forge2d/lib/forge2d_game.dart @@ -36,25 +36,16 @@ class Forge2DGame extends FlameGame { contactEventsDispatcher: contactEventsDispatcher, )) as T, - camera: _prepareCamera(camera, metersToPixels), - ); - - static CameraComponent _prepareCamera( - CameraComponent? camera, - double metersToPixels, - ) { - if (camera == null) { - return CameraComponent( - viewfinder: Forge2DViewfinder(metersToPixels: metersToPixels), - ); - } - final viewfinder = camera.viewfinder; + camera: camera ?? CameraComponent(), + ) { + final viewfinder = this.camera.viewfinder; if (viewfinder is Forge2DViewfinder) { viewfinder.metersToPixels = metersToPixels; } else { - camera.viewfinder = Forge2DViewfinder(metersToPixels: metersToPixels); + this.camera.viewfinder = Forge2DViewfinder( + metersToPixels: metersToPixels, + ); } - return camera; } /// The number of pixels that one meter of the physics world is rendered as. From 27d614686f38a65be55f72d2bf39cdcd2e783e4e Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Tue, 21 Jul 2026 00:10:54 +0200 Subject: [PATCH 14/21] fix: Use metersToPixels in PadRacing and cover the zoom composition --- examples/games/padracing/lib/padracing_game.dart | 4 +++- .../test/forge2d_viewfinder_test.dart | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/examples/games/padracing/lib/padracing_game.dart b/examples/games/padracing/lib/padracing_game.dart index a43723e8e97..1b4483728c8 100644 --- a/examples/games/padracing/lib/padracing_game.dart +++ b/examples/games/padracing/lib/padracing_game.dart @@ -39,7 +39,9 @@ class PadRacingGame extends Forge2DGame with KeyboardEvents { Watch out for the balls, they make your car spin. '''; - PadRacingGame() : super(gravity: Vector2.zero(), zoom: 1); + // The game replaces the built-in camera with its own cameras below, which + // apply the scaling through their zoom, so one meter is one world unit here. + PadRacingGame() : super(gravity: Vector2.zero(), metersToPixels: 1); @override Color backgroundColor() => Colors.black; diff --git a/packages/flame_forge2d/test/forge2d_viewfinder_test.dart b/packages/flame_forge2d/test/forge2d_viewfinder_test.dart index 2b6ae946364..3347ab19d10 100644 --- a/packages/flame_forge2d/test/forge2d_viewfinder_test.dart +++ b/packages/flame_forge2d/test/forge2d_viewfinder_test.dart @@ -87,6 +87,21 @@ void main() { }, ); + testWithGame( + 'the zoom is applied on top of metersToPixels', + () => Forge2DGame(metersToPixels: 20), + (game) async { + game.onGameResize(Vector2.all(100)); + game.camera.viewfinder.zoom = 2; + + // One meter covers 20 * 2 pixels, measured from the centre of the + // screen, and the position stays in meters both ways. + expect(game.worldToScreen(Vector2(1, 0)), Vector2(90, 50)); + expect(game.screenToWorld(Vector2(90, 50)), Vector2(1, 0)); + expect(game.camera.viewfinder.position, Vector2.zero()); + }, + ); + testWithGame( 'the default renders one meter as ten pixels', Forge2DGame.new, From 6823f14d50c621c6321fb7ab52ee59acc12a390d Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Tue, 21 Jul 2026 00:14:53 +0200 Subject: [PATCH 15/21] fix: Give the Forge2D examples enough friction to settle naturally --- .../flame_forge2d/animated_body_example.dart | 2 +- .../bridge_libraries/flame_forge2d/domino_example.dart | 8 ++++---- .../flame_forge2d/joints/revolute_joint.dart | 2 +- .../flame_forge2d/revolute_joint_with_motor_example.dart | 4 ++-- .../bridge_libraries/flame_forge2d/utils/balls.dart | 2 +- .../bridge_libraries/flame_forge2d/utils/boundaries.dart | 7 +++---- .../bridge_libraries/flame_forge2d/utils/boxes.dart | 2 +- .../bridge_libraries/flame_forge2d/widget_example.dart | 2 +- 8 files changed, 14 insertions(+), 15 deletions(-) diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/animated_body_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/animated_body_example.dart index 6b31e5d4c3f..f6578b5d03b 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/animated_body_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/animated_body_example.dart @@ -75,7 +75,7 @@ class ChopperBody extends BodyComponent { Body createBody() { final shapeDef = ShapeDef( userData: this, // To be able to determine object in collision - material: SurfaceMaterial(restitution: 0.8, friction: 0.2), + material: SurfaceMaterial(restitution: 0.6, friction: 0.5), ); final velocity = (Vector2.random() - Vector2.random()) * 200; diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart index ea77c8a468a..3714eb3cefb 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart @@ -162,10 +162,10 @@ class Domino extends BodyComponent with GlowingBody { DominoExampleWorld.dominoWidth / 2, DominoExampleWorld.dominoHeight / 2, ), - ShapeDef( - density: density, - material: SurfaceMaterial(friction: 0.1, restitution: 0.65), - ), + // The default material is what a domino wants: plenty of grip and no + // bounce. With less friction the tower shakes itself apart as soon as + // the frame rate dips. + ShapeDef(density: density), ); } } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/revolute_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/revolute_joint.dart index aba813bd975..c1ab1de09b5 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/revolute_joint.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/revolute_joint.dart @@ -60,7 +60,7 @@ class CircleShuffler extends BodyComponent { Circle(radius: 1.2, center: Vector2(xPos, yPos)), ShapeDef( density: 50.0, - material: SurfaceMaterial(friction: 0.1, restitution: 0.9), + material: SurfaceMaterial(friction: 0.5, restitution: 0.4), ), ); } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/revolute_joint_with_motor_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/revolute_joint_with_motor_example.dart index 35611e73d75..22e741d15d1 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/revolute_joint_with_motor_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/revolute_joint_with_motor_example.dart @@ -70,7 +70,7 @@ class CircleShuffler extends BodyComponent { Circle(radius: 1.2, center: Vector2(xPos, yPos)), ShapeDef( density: 50.0, - material: SurfaceMaterial(friction: 0.1, restitution: 0.9), + material: SurfaceMaterial(friction: 0.5, restitution: 0.4), ), ); } @@ -116,7 +116,7 @@ class CornerRamp extends BodyComponent with GlowingBody { return world.createBody(bodyDef)..createShape( Polygon(vertices), - ShapeDef(material: SurfaceMaterial(friction: 0.1)), + ShapeDef(material: SurfaceMaterial(friction: 0.5)), ); } } diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/utils/balls.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/balls.dart index d7e5a6c1fe5..76b09078ed0 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/utils/balls.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/balls.dart @@ -34,7 +34,7 @@ class Ball extends BodyComponent with ContactCallbacks, GlowingBody { @override Body createBody() { final shapeDef = ShapeDef( - material: SurfaceMaterial(restitution: 0.8, friction: 0.4), + material: SurfaceMaterial(restitution: 0.7), enableContactEvents: true, ); diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart index b6ae98fef90..6e0772ade0e 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart @@ -33,10 +33,9 @@ class Wall extends BodyComponent with GlowingBody { @override Body createBody() { - final shapeDef = ShapeDef( - material: SurfaceMaterial(friction: 0.3), - enableContactEvents: true, - ); + // The default material gives the walls enough grip for bodies to come + // to rest against them instead of sliding along. + final shapeDef = ShapeDef(enableContactEvents: true); final bodyDef = BodyDef( userData: this, // To be able to determine object in collision position: Vector2.zero(), diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/utils/boxes.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/boxes.dart index e32ad2937f2..6481caef290 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/utils/boxes.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/boxes.dart @@ -31,7 +31,7 @@ class Box extends BodyComponent with GlowingBody { @override Body createBody() { final shapeDef = ShapeDef( - material: SurfaceMaterial(friction: 0.3, restitution: 0.2), + material: SurfaceMaterial(restitution: 0.1), density: 10, enableContactEvents: true, ); diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart index ce9a6527abc..189d324d620 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart @@ -46,7 +46,7 @@ class WidgetExample extends Forge2DExampleGame { buttonWidth / 2 / scale, buttonHeight / 2 / scale, ), - ShapeDef(material: SurfaceMaterial(restitution: 0.8, friction: 0.2)), + ShapeDef(material: SurfaceMaterial(restitution: 0.6, friction: 0.5)), ); return body; } From b8f88f73d4b314fd27ea162b8d8355a9c456a743 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Tue, 21 Jul 2026 00:53:38 +0200 Subject: [PATCH 16/21] feat: Rebuild the Forge2D widget example as a falling counter app Every widget of the default flutter create counter app rests on a Forge2D body and drops to the floor, keeping its normal function. Pressing the button counts and launches it, and pressing any widget sends it flying. The bodies follow the shape Material draws: capsules for the text, a rounded box for the button. Every widget is given the same mass so the wide app bar does not crush the small counter, and the walls are solid boxes so nothing squeezes out through a corner. --- .../flame_forge2d/widget_example.dart | 396 ++++++++++++++---- 1 file changed, 304 insertions(+), 92 deletions(-) diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart index 189d324d620..75217482e5c 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart @@ -1,71 +1,203 @@ -import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/boundaries.dart'; +import 'dart:math'; + import 'package:examples/stories/bridge_libraries/flame_forge2d/utils/style.dart'; import 'package:flame/game.dart'; import 'package:flame_forge2d/flame_forge2d.dart' hide Transform; import 'package:flutter/material.dart'; +/// The body shape that a widget is given, which follows the shape that +/// Material draws it with. +enum WidgetShape { + /// A plain box, for the app bar. + box, + + /// A box with rounded corners, for the button. Material 3 draws a floating + /// action button as a rounded square rather than as a circle. + roundedBox, + + /// A capsule, for a line of text. + capsule, +} + +/// One of the widgets of the counter app, carried by a Forge2D body. +class FallingWidget { + FallingWidget(this.shape); + + /// The shape of the body underneath the widget. + final WidgetShape shape; + + Body? _body; + + /// The body that carries the widget, which only exists once the widget has + /// been measured, see [WidgetExample.fit]. + Body get body => _body!; + + bool get hasBody => _body != null; + + /// The size of the widget in logical pixels. + Size size = Size.zero; +} + class WidgetExample extends Forge2DExampleGame { static const String description = ''' - This example shows how to render a widget on top of a Forge2D body, - outside of Flame. The Flutter buttons are real widgets in the Flutter - tree, driven by the physics bodies underneath them, so you can press them - while they tumble around. - '''; + This is the app that `flutter create` gives you, except that every widget + rests on a Forge2D body and drops to the floor when the example starts. - /// The size of the button widget in logical pixels. - static const buttonWidth = 190.0; - static const buttonHeight = 48.0; - - final List updateStates = []; - final Map bodyIdMap = {}; - final List addLaterIds = []; + Press a widget to launch it across the screen. They are real widgets in + the Flutter tree the whole time, so the button keeps counting while it + tumbles, and the counter keeps showing the total. + '''; /// How many pixels one meter of the physics world is rendered as. static const scale = 20.0; WidgetExample() : super(metersToPixels: scale, gravity: Vector2(0, 10.0)); + final _random = Random(); + + /// The corner radius that Material 3 draws a floating action button with. + static const buttonCornerRadius = 16.0; + + /// The mass that every widget is given, so that the big app bar does not + /// crush the small counter. Used as `density = widgetMass / area`. + static const widgetMass = 1.0; + + final appBar = FallingWidget(WidgetShape.box); + final label = FallingWidget(WidgetShape.capsule); + final counter = FallingWidget(WidgetShape.capsule); + final button = FallingWidget(WidgetShape.roundedBox); + + /// Callbacks that run after every step, so that the overlay can follow the + /// bodies that it is drawn on. + final List onStep = []; + @override Future onLoad() async { await super.onLoad(); - final boundaries = createBoundaries(this, strokeWidth: 0); - world.addAll(boundaries); + _addWalls(); } - Body createBody() { - final bodyDef = BodyDef( - angularVelocity: 3, - position: Vector2.zero(), - type: BodyType.dynamic, - ); - final body = world.createBody(bodyDef); + /// Boxes just outside each edge of the screen that keep the widgets on + /// screen. + /// + /// They are solid boxes rather than the thin lines that the other examples + /// use, so that a small widget shoved into a corner by the falling app bar + /// cannot squeeze out between two walls. + void _addWalls() { + final rect = camera.visibleWorldRect; + const thickness = 5.0; + const half = thickness / 2; - // The body matches the size of the button widget that is drawn on top. - body.createShape( - Polygon.box( - buttonWidth / 2 / scale, - buttonHeight / 2 / scale, + void wall(double cx, double cy, double halfWidth, double halfHeight) { + world + .createBody(BodyDef(position: Vector2(cx, cy))) + .createShape(Polygon.box(halfWidth, halfHeight)); + } + + final halfWidth = rect.width / 2; + final halfHeight = rect.height / 2; + wall(rect.center.dx, rect.top - half, halfWidth, half); // top + wall(rect.center.dx, rect.bottom + half, halfWidth, half); // bottom + wall(rect.left - half, rect.center.dy, half, halfHeight); // left + wall(rect.right + half, rect.center.dy, half, halfHeight); // right + } + + /// Fits the body of [fallingWidget] to [size] logical pixels, creating it at + /// [layout] the first time. + /// + /// The overlay measures its widgets before it calls this, so that the shape + /// covers the widget itself and not the space around it. It is called again + /// whenever a widget changes size, which is what happens when the counter + /// gains a digit. + void fit( + FallingWidget fallingWidget, { + required Size size, + required Offset layout, + }) { + if (fallingWidget.size == size) { + return; + } + fallingWidget.size = size; + if (fallingWidget.hasBody) { + for (final shape in fallingWidget.body.shapes) { + shape.destroy(); + } + } else { + fallingWidget._body = world.createBody( + BodyDef( + type: BodyType.dynamic, + position: screenToWorld(Vector2(layout.dx, layout.dy)), + ), + ); + } + fallingWidget.body.createShape( + _geometryFor(fallingWidget.shape, size), + ShapeDef( + // Every widget weighs the same, whatever its size. With one density + // for all of them the app bar comes out orders of magnitude heavier + // than the counter, and squeezes it through the floor when it lands + // on top of it. + density: widgetMass / (size.width / scale * (size.height / scale)), + // The default friction of 0.6 keeps the widgets from sliding, and the + // rolling resistance is what stops the capsules from rolling on for + // ever once they have landed. + material: SurfaceMaterial(restitution: 0.2, rollingResistance: 0.1), ), - ShapeDef(material: SurfaceMaterial(restitution: 0.6, friction: 0.5)), ); - return body; } - int createBodyId(int id) { - addLaterIds.add(id); - return id; + /// The geometry, in meters, that covers a widget of [size] logical pixels. + ShapeGeometry _geometryFor(WidgetShape shape, Size size) { + final halfWidth = size.width / 2 / scale; + final halfHeight = size.height / 2 / scale; + switch (shape) { + case WidgetShape.box: + return Polygon.box(halfWidth, halfHeight); + case WidgetShape.roundedBox: + // The rounding radius grows the box outwards, so it has to be taken + // off the half extents for the shape to stay the size of the widget. + final radius = min( + buttonCornerRadius / scale, + min(halfWidth, halfHeight), + ); + return Polygon.box( + halfWidth - radius, + halfHeight - radius, + radius: radius, + ); + case WidgetShape.capsule: + final radius = min(halfWidth, halfHeight); + final offset = halfWidth >= halfHeight + ? Vector2(halfWidth - radius, 0) + : Vector2(0, halfHeight - radius); + return Capsule(center1: -offset, center2: offset, radius: radius); + } + } + + /// Sends a widget flying, which is what pressing one does. + void launch(FallingWidget fallingWidget) { + final body = fallingWidget.body; + final direction = Vector2(_random.nextDouble() * 2 - 1, -1.6)..normalize(); + body + ..applyLinearImpulse(direction * (body.mass * 14)) + ..applyAngularImpulse(body.mass * (_random.nextDouble() * 2 - 1)); + } + + /// Where the top left corner of [fallingWidget] currently is on screen. + Offset topLeftOf(FallingWidget fallingWidget) { + final center = worldToScreen(fallingWidget.body.position); + return Offset( + center.x - fallingWidget.size.width / 2, + center.y - fallingWidget.size.height / 2, + ); } @override void update(double dt) { super.update(dt); - addLaterIds.forEach((id) { - if (!bodyIdMap.containsKey(id)) { - bodyIdMap[id] = createBody(); - } - }); - addLaterIds.clear(); - updateStates.forEach((f) => f()); + for (final callback in onStep) { + callback(); + } } } @@ -77,76 +209,156 @@ class BodyWidgetExample extends StatelessWidget { return GameWidget( game: WidgetExample(), overlayBuilderMap: { - 'button1': (ctx, game) { - return BodyButtonWidget(game, game.createBodyId(1)); - }, - 'button2': (ctx, game) { - return BodyButtonWidget(game, game.createBodyId(2)); - }, + 'counterApp': (context, game) => CounterAppOverlay(game), }, - initialActiveOverlays: const ['button1', 'button2'], + initialActiveOverlays: const ['counterApp'], ); } } -class BodyButtonWidget extends StatefulWidget { - final WidgetExample _game; - final int _bodyId; +/// The widget tree of the counter app, with every widget positioned by the +/// body that carries it. +class CounterAppOverlay extends StatefulWidget { + const CounterAppOverlay(this.game, {super.key}); - const BodyButtonWidget( - this._game, - this._bodyId, { - super.key, - }); + final WidgetExample game; @override - State createState() { - return _BodyButtonState(_game, _bodyId); - } + State createState() => _CounterAppOverlayState(); } -class _BodyButtonState extends State { - final WidgetExample _game; - final int _bodyId; - Body? _body; +class _CounterAppOverlayState extends State { + static const _appBarHeight = 56.0; + + /// How much of the width the app bar covers. A bar that spans the whole + /// game is wedged between the two walls, so it is kept a little narrower to + /// leave it room to tumble and to be bounced up. + static const _appBarWidthFactor = 0.85; + static const _fabSize = 56.0; + static const _fabMargin = 16.0; + static const _labelText = 'You have pushed the button this many times:'; + + int _counter = 0; + + @override + void initState() { + super.initState(); + widget.game.onStep.add(_follow); + } + + @override + void dispose() { + widget.game.onStep.remove(_follow); + super.dispose(); + } + + void _follow() { + if (mounted) { + setState(() {}); + } + } + + /// The button's normal function, plus a shove. + void _incrementCounter() { + setState(() => _counter++); + widget.game.launch(widget.game.button); + } - _BodyButtonState(this._game, this._bodyId) { - _game.updateStates.add(() { - setState(() { - _body = _game.bodyIdMap[_bodyId]; - }); - }); + /// The size that [text] takes up, so that the body underneath it covers the + /// glyphs instead of a padded box around them. + Size _measure(String text, TextStyle? style) { + final painter = TextPainter( + text: TextSpan(text: text, style: style), + textDirection: TextDirection.ltr, + textScaler: MediaQuery.textScalerOf(context), + )..layout(); + return painter.size; } @override Widget build(BuildContext context) { - final body = _body; - if (body == null) { - return Container(); - } else { - final bodyPosition = _game.worldToScreen(body.position); - return Positioned( - top: bodyPosition.y - WidgetExample.buttonHeight / 2, - left: bodyPosition.x - WidgetExample.buttonWidth / 2, - child: Transform.rotate( - angle: body.angle, - child: SizedBox( - width: WidgetExample.buttonWidth, - height: WidgetExample.buttonHeight, - child: FloatingActionButton.extended( - // Every button needs its own tag, since there is more than one. - heroTag: 'flying_button_$_bodyId', - onPressed: () { - setState( - () => body.applyLinearImpulse(Vector2(0.0, 1000)), - ); - }, - icon: const FlutterLogo(size: 28), - label: const Text('Flutter'), + final game = widget.game; + final theme = Theme.of(context); + if (!game.isLoaded) { + return const SizedBox.shrink(); + } + + final gameSize = game.size; + final labelStyle = theme.textTheme.bodyMedium; + final counterStyle = theme.textTheme.headlineMedium; + game + ..fit( + game.appBar, + size: Size(gameSize.x * _appBarWidthFactor, _appBarHeight), + layout: Offset(gameSize.x / 2, _appBarHeight / 2), + ) + ..fit( + game.label, + size: _measure(_labelText, labelStyle), + layout: Offset(gameSize.x / 2, gameSize.y / 2 - 24), + ) + ..fit( + game.counter, + size: _measure('$_counter', counterStyle), + layout: Offset(gameSize.x / 2, gameSize.y / 2 + 20), + ) + ..fit( + game.button, + size: const Size(_fabSize, _fabSize), + layout: Offset( + gameSize.x - _fabMargin - _fabSize / 2, + gameSize.y - _fabMargin - _fabSize / 2, + ), + ); + + return Material( + color: theme.scaffoldBackgroundColor, + child: Stack( + children: [ + _positioned( + game.appBar, + AppBar( + // The game is not the whole window, so there is no status bar + // to leave room for. + primary: false, + backgroundColor: theme.colorScheme.inversePrimary, + title: const Text('Flutter Demo Home Page'), + ), + ), + _positioned(game.label, Text(_labelText, style: labelStyle)), + _positioned(game.counter, Text('$_counter', style: counterStyle)), + _positioned( + game.button, + FloatingActionButton( + onPressed: _incrementCounter, + tooltip: 'Increment', + child: const Icon(Icons.add), ), ), + ], + ), + ); + } + + /// Places [child] on the body of [fallingWidget], and launches it when it is + /// pressed. Widgets that handle their own taps, like the button, win the + /// gesture and launch themselves instead. + Widget _positioned(FallingWidget fallingWidget, Widget child) { + final topLeft = widget.game.topLeftOf(fallingWidget); + return Positioned( + left: topLeft.dx, + top: topLeft.dy, + child: Transform.rotate( + angle: fallingWidget.body.angle, + child: SizedBox( + width: fallingWidget.size.width, + height: fallingWidget.size.height, + child: GestureDetector( + onTap: () => widget.game.launch(fallingWidget), + child: child, + ), ), - ); - } + ), + ); } } From dec38c011e24d8c44e9ebc9622d8f8fb44d458f8 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Tue, 21 Jul 2026 01:05:33 +0200 Subject: [PATCH 17/21] fix: Satisfy the cspell and DCM pipeline checks Add a heroTag to the counter button, drop a redundant trailing zero in hue_decorator, teach cspell the word subclassing and use the en_US spelling of neighboring. --- .github/.cspell/words_dictionary.txt | 1 + .../lib/stories/bridge_libraries/flame_forge2d/utils/balls.dart | 2 +- .../stories/bridge_libraries/flame_forge2d/widget_example.dart | 1 + packages/flame/lib/src/rendering/hue_decorator.dart | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/.cspell/words_dictionary.txt b/.github/.cspell/words_dictionary.txt index 0132ba2a363..3e9355b3d69 100644 --- a/.github/.cspell/words_dictionary.txt +++ b/.github/.cspell/words_dictionary.txt @@ -24,6 +24,7 @@ renderable rerasterize rescan Roboto +subclassing tappable thumbstick trackpad diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/utils/balls.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/balls.dart index 76b09078ed0..2f5d1a617f7 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/utils/balls.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/balls.dart @@ -25,7 +25,7 @@ class Ball extends BodyComponent with ContactCallbacks, GlowingBody { static int _colorIndex = 0; - /// Cycles through the palette so that neighbouring balls stay readable. + /// Cycles through the palette so that neighboring balls stay readable. Color randomColor() => ExampleColors.dynamicColor(_colorIndex++ % ExampleColors.dynamics.length); diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart index 75217482e5c..6db6df9e0de 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart @@ -330,6 +330,7 @@ class _CounterAppOverlayState extends State { _positioned( game.button, FloatingActionButton( + heroTag: 'counterButton', onPressed: _incrementCounter, tooltip: 'Increment', child: const Icon(Icons.add), diff --git a/packages/flame/lib/src/rendering/hue_decorator.dart b/packages/flame/lib/src/rendering/hue_decorator.dart index 5ac82d187cf..7a0f63d6476 100644 --- a/packages/flame/lib/src/rendering/hue_decorator.dart +++ b/packages/flame/lib/src/rendering/hue_decorator.dart @@ -18,7 +18,7 @@ List hueRotationMatrix(double angle) { 0, 0, 0.213 - 0.213 * cosT + 0.143 * sinT, - 0.715 + 0.285 * cosT + 0.140 * sinT, + 0.715 + 0.285 * cosT + 0.14 * sinT, 0.072 - 0.072 * cosT - 0.283 * sinT, 0, 0, From 7a7dd1aa4f75f97ae4f78edbb2a61084a81e20fd Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Tue, 21 Jul 2026 14:19:02 +0200 Subject: [PATCH 18/21] fix: Keep the motor joint example's initial offset and correct two example nits --- .../stories/bridge_libraries/flame_forge2d/flame_forge2d.dart | 2 +- .../bridge_libraries/flame_forge2d/joints/motor_joint.dart | 4 ++++ .../flame_forge2d/revolute_joint_with_motor_example.dart | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart index cd5ef9252de..962c56f7743 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart @@ -49,7 +49,7 @@ void addForge2DStories(Dashbook dashbook) { (DashbookContext ctx) => GameWidget(game: RevoluteJointWithMotorExample()), codeLink: link('revolute_joint_with_motor_example.dart'), - info: RevoluteJointExample.description, + info: RevoluteJointWithMotorExample.description, ) ..add( 'Sprite Bodies', diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/motor_joint.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/motor_joint.dart index 9c9f4b149f6..9508f22616d 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/joints/motor_joint.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/motor_joint.dart @@ -55,6 +55,10 @@ class MotorJointWorld extends Forge2DWorld with TapCallbacks { MotorJointDef( bodyA: first, bodyB: second, + // The target offset of the box in the ball's frame. Starting with + // the current offset keeps the bodies where they are, and update + // below drifts it from there. + linearOffset: first.localPoint(second.position), maxForce: 1000, maxTorque: 1000, correctionFactor: 0.1, diff --git a/examples/lib/stories/bridge_libraries/flame_forge2d/revolute_joint_with_motor_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/revolute_joint_with_motor_example.dart index 22e741d15d1..eb3129f27c4 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/revolute_joint_with_motor_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/revolute_joint_with_motor_example.dart @@ -41,7 +41,7 @@ class RevoluteJointWithMotorWorld extends Forge2DWorld super.onTapDown(info); final tapPosition = info.localPosition; List.generate(15, (i) { - final randomVector = (Vector2.random() - Vector2.all(-0.5)).normalized(); + final randomVector = (Vector2.random() - Vector2.all(0.5)).normalized(); add(Ball(tapPosition + randomVector, radius: random.nextDouble())); }); } From 5d1c640bc927a7eae5c4803fe78193b3d2708cb7 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Tue, 21 Jul 2026 14:19:02 +0200 Subject: [PATCH 19/21] docs: Correct API facts and fix broken snippets in the Forge2D docs --- doc/bridge_packages/flame_forge2d/forge2d.md | 25 ++++++++++--------- doc/bridge_packages/flame_forge2d/joints.md | 9 ++++--- .../flame_forge2d/migration.md | 7 +++--- doc/other_modules/forge2d/forge2d.md | 6 ++--- 4 files changed, 25 insertions(+), 22 deletions(-) diff --git a/doc/bridge_packages/flame_forge2d/forge2d.md b/doc/bridge_packages/flame_forge2d/forge2d.md index a13fa6cd7dc..8683099c1d6 100644 --- a/doc/bridge_packages/flame_forge2d/forge2d.md +++ b/doc/bridge_packages/flame_forge2d/forge2d.md @@ -8,18 +8,17 @@ If you want to use Forge2D specifically for Flame you should use our bridge libr just want to use it in a Dart project you can use the [forge2d](https://github.com/flame-engine/forge2d) library directly. -To use it in your game you just need to add `flame_forge2d` to your -`pubspec.yaml`, as can be seen in the [Forge2D -[example](https://github.com/flame-engine/flame/tree/main/packages/flame_forge2d/example) -and the pub. dev [installation -instructions](https://pub.dev/packages/flame_forge2d)](). +To use it in your game you just need to add `flame_forge2d` to your `pubspec.yaml`, as can be +seen in the +[Forge2D example](https://github.com/flame-engine/flame/tree/main/packages/flame_forge2d/example) +and the pub.dev [installation instructions](https://pub.dev/packages/flame_forge2d). Since Forge2D runs Box2D as native code, a C toolchain is required when building for native platforms (Xcode on iOS/macOS, the NDK on Android, Visual Studio Build Tools on Windows and clang or gcc on Linux). On the web a bundled WebAssembly build of Box2D is used instead. Forge2D has to be initialized with `await initializeForge2D()` before any physics world is -created, which on the web is what loads that WebAssembly module. [Forge2DGame] awaits this in its +created, which on the web is what loads that WebAssembly module. `Forge2DGame` awaits this in its `onLoad`, so games don't have to do anything, but that also means that a `Forge2DGame` subclass which overrides `onLoad` has to await `super.onLoad()` before it creates any bodies: @@ -145,16 +144,18 @@ So instead of `add(Weapon()))`, `world.add(Weapon())` should be used (as below), should also of course initially be added to the world. ```dart -class Weapon extends BodyComponent { +class Weapon extends BodyComponent { @override - void onLoad() { - ... + Future onLoad() async { + await super.onLoad(); + // ... } } -class Player extends BodyComponent { +class Player extends BodyComponent { @override - void onLoad() { + Future onLoad() async { + await super.onLoad(); world.add(Weapon()); } } @@ -189,7 +190,7 @@ class Ball extends BodyComponent with ContactCallbacks { ``` For the above to work, the `Ball`'s `body.userData` or contacting `shape.userData` must be -set to a `ContactCallback`. And if `Wall` is a `BodyComponent` it's `body.userData` or contacting +set to a `ContactCallbacks`. And if `Wall` is a `BodyComponent` its `body.userData` or contacting `shape.userData` must be set to `Wall`. If `userData` is `null` the contact events are ignored, it is `null` by default. diff --git a/doc/bridge_packages/flame_forge2d/joints.md b/doc/bridge_packages/flame_forge2d/joints.md index 5001db1ae0d..1748d5c7965 100644 --- a/doc/bridge_packages/flame_forge2d/joints.md +++ b/doc/bridge_packages/flame_forge2d/joints.md @@ -120,7 +120,7 @@ final motorJoint = world.physicsWorld.createMotorJoint( :show: code popup ``` -A `MotorJointDef` has three optional parameters: +A `MotorJointDef` has these optional tuning parameters: - `maxForce`: the maximum translational force which will be applied to the joined body to reach the target position. @@ -134,8 +134,9 @@ makes it respond slower. If the value is set too high, the joint may overcompens becoming unstable. If set too low, it may respond too slowly. The linear and angular offsets are the target distance and angle that the bodies should achieve -relative to each other's position and rotation. Use the `linearOffset` and `angularOffset` setters -of the `MotorJoint` to set the desired relative translation and rotation between the bodies. +relative to each other's position and rotation. They can be passed to the `MotorJointDef` as +`linearOffset` and `angularOffset`, or changed later through the setters with the same names on +the `MotorJoint`. For example, this code increments the angular offset of the joint every update cycle, causing the body to rotate. @@ -215,7 +216,7 @@ can violate the constraint slightly. The joint translation is zero when the local anchor points coincide in world space. ```{warning} -At least one body should by dynamic with a non-fixed rotation. +At least one body should be dynamic with a non-fixed rotation. ``` The `PrismaticJoint` definition is similar to the [`RevoluteJoint`](#revolutejoint) definition, but diff --git a/doc/bridge_packages/flame_forge2d/migration.md b/doc/bridge_packages/flame_forge2d/migration.md index 01020c2c3ff..4c7661e522e 100644 --- a/doc/bridge_packages/flame_forge2d/migration.md +++ b/doc/bridge_packages/flame_forge2d/migration.md @@ -196,9 +196,10 @@ zooming the camera in and out. Forge2D exports a `World`, which collides with Flame's `World` component, so files that use both need `import 'package:flame_forge2d/flame_forge2d.dart' hide World;`. That was already the case -before, but Forge2D now also exports `Transform`, `Rot`, `Circle`, `Polygon`, and `Segment`, which -can collide with `flutter/material.dart` (`Transform`) and `flame/geometry.dart` (`Circle`, -`Polygon`, `Shape`). Resolve them per file with `hide` or a prefixed import, for example: +before, as was the collision between Forge2D's `Transform` and the one in +`flutter/material.dart`, but Forge2D now also exports `Circle`, `Polygon`, and `Segment`, which +can collide with `flame/geometry.dart`. Resolve them per file with `hide` or a prefixed import, +for example: ```dart import 'package:flame_forge2d/flame_forge2d.dart' hide Transform, World; diff --git a/doc/other_modules/forge2d/forge2d.md b/doc/other_modules/forge2d/forge2d.md index 274b0edc9e4..901340eaaf8 100644 --- a/doc/other_modules/forge2d/forge2d.md +++ b/doc/other_modules/forge2d/forge2d.md @@ -88,9 +88,9 @@ Contact, sensor, and body-move events are polled from the world after each step for shapes that have opted in through the `ShapeDef` event flags, like `enableContactEvents` and `enableSensorEvents`. -Ray casts (`castRayClosest`, `castRay`, `castRayAll`), AABB overlap queries (`overlapAabb`), and -explosions (`explode`) are available directly on `World`, returning their results instead of using -callback classes. +Ray casts (`castRayClosest`, `castRay`, `castRayAll`) and AABB overlap queries (`overlapAabb`) +are available directly on `World`, returning their results instead of using callback classes, +and explosions are applied with `World.explode`. ## Joints From 7cbec3a7b12392f0a4858c6d1a8fcaefbd831a3f Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Tue, 21 Jul 2026 14:19:02 +0200 Subject: [PATCH 20/21] test: Cover dispatcher wiring, gravity precedence, and contact auto-enabling --- .../test/body_component_test.dart | 3 - .../test/contact_callbacks_test.dart | 32 +++++++++ .../test/contact_events_dispatcher_test.dart | 66 ++++++++++++++++++- .../test/forge2d_world_test.dart | 38 +++++++++++ .../flame_forge2d/test/helpers/helpers.dart | 1 - 5 files changed, 133 insertions(+), 7 deletions(-) delete mode 100644 packages/flame_forge2d/test/helpers/helpers.dart diff --git a/packages/flame_forge2d/test/body_component_test.dart b/packages/flame_forge2d/test/body_component_test.dart index 8b82d7e28d2..07d3b8a119b 100644 --- a/packages/flame_forge2d/test/body_component_test.dart +++ b/packages/flame_forge2d/test/body_component_test.dart @@ -16,9 +16,6 @@ class _TestBodyComponent extends BodyComponent with TapCallbacks { @override Body createBody() => body; - @override - void noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); - @override void onTapDown(TapDownEvent _) { tapCount++; diff --git a/packages/flame_forge2d/test/contact_callbacks_test.dart b/packages/flame_forge2d/test/contact_callbacks_test.dart index 543f3d84bc8..ca6dcc94621 100644 --- a/packages/flame_forge2d/test/contact_callbacks_test.dart +++ b/packages/flame_forge2d/test/contact_callbacks_test.dart @@ -1,4 +1,5 @@ import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flame_test/flame_test.dart'; import 'package:test/expect.dart'; import 'package:test/scaffolding.dart'; @@ -47,4 +48,35 @@ void main() { expect(called, equals(1)); }); }); + + group('ContactCallbacks in a Forge2DGame', () { + testWithGame( + 'a BodyComponent with ContactCallbacks userData receives contact ' + 'events without enabling the event flags manually', + Forge2DGame.new, + (game) async { + final contactCallbacks = ContactCallbacks(); + Object? contactedWith; + contactCallbacks.onBeginContact = (other, _) => contactedWith = other; + + final groundUserData = Object(); + final ground = BodyComponent( + bodyDef: BodyDef(position: Vector2(0, 3), userData: groundUserData), + shapeSpecs: [ShapeSpec(Polygon.box(10, 1))], + ); + final ball = BodyComponent( + bodyDef: BodyDef(type: BodyType.dynamic, userData: contactCallbacks), + shapeSpecs: [ShapeSpec(Circle(radius: 1))], + ); + await game.world.ensureAdd(ground); + await game.world.ensureAdd(ball); + + for (var i = 0; i < 60 && contactedWith == null; i++) { + game.update(1 / 60); + } + + expect(contactedWith, equals(groundUserData)); + }, + ); + }); } diff --git a/packages/flame_forge2d/test/contact_events_dispatcher_test.dart b/packages/flame_forge2d/test/contact_events_dispatcher_test.dart index b81db3f708b..eaf33dc444d 100644 --- a/packages/flame_forge2d/test/contact_events_dispatcher_test.dart +++ b/packages/flame_forge2d/test/contact_events_dispatcher_test.dart @@ -6,6 +6,16 @@ import 'package:test/scaffolding.dart'; import 'helpers/mocks.dart'; +class _CountingDispatcher extends ContactEventsDispatcher { + int dispatchCount = 0; + + @override + void dispatch(ContactEvents contactEvents, SensorEvents sensorEvents) { + dispatchCount++; + super.dispatch(contactEvents, sensorEvents); + } +} + void main() { // Forge2D has to be initialized before a world can be created, which // Forge2DGame does automatically but a bare World does not. @@ -200,15 +210,18 @@ void main() { for (var i = 0; i < 30; i++) { game.update(1 / 60); } - expect(beginCount, equals(1)); - expect(endCount, equals(0)); + // The ball may bounce before it settles, so a begin can be followed + // by an end and a new begin, but the counts always end on a live + // contact. + expect(beginCount, greaterThanOrEqualTo(1)); + expect(endCount, beginCount - 1); // The resting ball may have fallen asleep, and a sleeping body's // contacts are not updated, so wake it up alongside the teleport. ball.setTransform(Vector2(100, 0), const Rot.identity()); ball.isAwake = true; game.update(1 / 60); - expect(endCount, equals(1)); + expect(endCount, equals(beginCount)); }, ); @@ -252,4 +265,51 @@ void main() { }, ); }); + + group('custom ContactEventsDispatcher', () { + testWithGame( + 'a dispatcher passed to Forge2DGame is used by the created world', + () => Forge2DGame(contactEventsDispatcher: _CountingDispatcher()), + (game) async { + final dispatcher = + game.world.contactEventsDispatcher as _CountingDispatcher; + + await game.ready(); + final countBefore = dispatcher.dispatchCount; + game.update(1 / 60); + + expect(dispatcher.dispatchCount, equals(countBefore + 1)); + }, + ); + + testWithGame( + 'a dispatcher passed to Forge2DWorld is used by that world', + () => Forge2DGame( + world: Forge2DWorld(contactEventsDispatcher: _CountingDispatcher()), + ), + (game) async { + final dispatcher = + game.world.contactEventsDispatcher as _CountingDispatcher; + + await game.ready(); + final countBefore = dispatcher.dispatchCount; + game.update(1 / 60); + + expect(dispatcher.dispatchCount, equals(countBefore + 1)); + }, + ); + + test( + 'Forge2DGame asserts when both a world and a dispatcher are given', + () { + expect( + () => Forge2DGame( + world: Forge2DWorld(), + contactEventsDispatcher: ContactEventsDispatcher(), + ), + throwsA(isA()), + ); + }, + ); + }); } diff --git a/packages/flame_forge2d/test/forge2d_world_test.dart b/packages/flame_forge2d/test/forge2d_world_test.dart index 2a703635c4e..a014b87e2ad 100644 --- a/packages/flame_forge2d/test/forge2d_world_test.dart +++ b/packages/flame_forge2d/test/forge2d_world_test.dart @@ -7,6 +7,44 @@ class _TestForge2DWorld extends Forge2DWorld { } void main() { + // Forge2D has to be initialized before a world can be created, which + // Forge2DGame does automatically but a bare Forge2DWorld does not. + setUpAll(initializeForge2D); + + group('gravity before the physics world is created', () { + test('the gravity argument takes precedence over the definition', () { + final world = Forge2DWorld( + gravity: Vector2(1, 2), + definition: WorldDef(gravity: Vector2(0, -10)), + ); + expect(world.gravity, Vector2(1, 2)); + expect(world.physicsWorld.gravity, Vector2(1, 2)); + world.physicsWorld.destroy(); + }); + + test('the definition gravity is used when no gravity is given', () { + final world = Forge2DWorld( + definition: WorldDef(gravity: Vector2(0, -10)), + ); + expect(world.physicsWorld.gravity, Vector2(0, -10)); + world.physicsWorld.destroy(); + }); + + test('a gravity set before creation is picked up by the created world', () { + final world = Forge2DWorld()..gravity = Vector2(3, 4); + expect(world.gravity, Vector2(3, 4)); + expect(world.physicsWorld.gravity, Vector2(3, 4)); + world.physicsWorld.destroy(); + }); + + test('setting gravity to null falls back to the default gravity', () { + final world = Forge2DWorld(gravity: Vector2(3, 4))..gravity = null; + expect(world.gravity, Forge2DWorld.defaultGravity); + expect(world.physicsWorld.gravity, Forge2DWorld.defaultGravity); + world.physicsWorld.destroy(); + }); + }); + testWithGame( 'Bodies are destroyed after world is removed when destroyBodiesOnRemove is ' 'true', diff --git a/packages/flame_forge2d/test/helpers/helpers.dart b/packages/flame_forge2d/test/helpers/helpers.dart deleted file mode 100644 index efe914f67da..00000000000 --- a/packages/flame_forge2d/test/helpers/helpers.dart +++ /dev/null @@ -1 +0,0 @@ -export 'mocks.dart'; From ffaa10d6c32acb44859c09e295790c1237bdb7fb Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Tue, 21 Jul 2026 23:15:14 +0200 Subject: [PATCH 21/21] fix: Throw instead of assert when the physics world cannot be created Asserts are stripped in release builds, so a Box2D world that fails to allocate (for example when the world limit is hit) would be cached and used by every later step, crashing far from the cause. Check it at runtime and throw a StateError. --- packages/flame_forge2d/lib/forge2d_world.dart | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/flame_forge2d/lib/forge2d_world.dart b/packages/flame_forge2d/lib/forge2d_world.dart index a615594e571..d8e45150ddd 100644 --- a/packages/flame_forge2d/lib/forge2d_world.dart +++ b/packages/flame_forge2d/lib/forge2d_world.dart @@ -57,12 +57,17 @@ class Forge2DWorld extends World { gravity: _gravity, definition: _definition, ); - assert( - createdWorld.isValid, - 'The physics world could not be created. Box2D allows a limited number ' - 'of simultaneous worlds, and Forge2D worlds are not freed automatically, ' - 'so call physicsWorld.destroy() on the worlds that you are done with.', - ); + if (!createdWorld.isValid) { + // Checked at runtime rather than with an assert, since an invalid world + // is cached and used by every later step and API call, which would crash + // far from the cause in a release build where asserts are stripped. + throw StateError( + 'The physics world could not be created. Box2D allows a limited number ' + 'of simultaneous worlds, and Forge2D worlds are not freed ' + 'automatically, so call physicsWorld.destroy() on the worlds that you ' + 'are done with.', + ); + } return _physicsWorld = createdWorld; }