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/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 c68c263b3b0..8683099c1d6 100644 --- a/doc/bridge_packages/flame_forge2d/forge2d.md +++ b/doc/bridge_packages/flame_forge2d/forge2d.md @@ -1,18 +1,42 @@ # 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 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 +`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). ## Forge2DGame @@ -23,11 +47,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 @@ -59,19 +91,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 @@ -90,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()); } } @@ -114,9 +170,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 +189,18 @@ class Ball extends BodyComponent with ContactCallbacks { } ``` -For the above to work, the `Ball`'s `body.userData` or contacting `fixture.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`. +For the above to work, the `Ball`'s `body.userData` or contacting `shape.userData` must be +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. -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 +212,9 @@ class Ball extends BodyComponent with ContactCallbacks { final bodyDef = BodyDef( userData: this, ); + final shapeDef = ShapeDef( + enableContactEvents: true, + ); ... } @@ -158,7 +222,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..1748d5c7965 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} @@ -237,7 +120,7 @@ final motorJointDef = MotorJointDef() :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. @@ -249,12 +132,11 @@ 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. 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. @@ -263,9 +145,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 +156,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 +170,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 +196,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,28 +209,27 @@ 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. +At least one body should be dynamic with a non-fixed rotation. ``` The `PrismaticJoint` definition is similar to the [`RevoluteJoint`](#revolutejoint) definition, but 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 +239,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 +265,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 +275,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 +307,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 +338,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 +353,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 +363,29 @@ 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 current joint angle: ```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; ``` @@ -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/doc/bridge_packages/flame_forge2d/migration.md b/doc/bridge_packages/flame_forge2d/migration.md new file mode 100644 index 00000000000..4c7661e522e --- /dev/null +++ b/doc/bridge_packages/flame_forge2d/migration.md @@ -0,0 +1,206 @@ +# 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. On the web the +WebAssembly module is bundled into the app automatically, so no build setup is needed there +either. + +Forge2D now needs `await initializeForge2D()` before a physics world is created, which is what +loads that module on the web. `Forge2DGame` awaits it in its `onLoad` and creates the physics +world lazily, so games need no change. Code that creates a `Forge2DWorld` or a raw Forge2D +`World` on its own, including tests, has to await it first. + + +## 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))`. + +```{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. +``` + +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. + +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 + +`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`. 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()` + 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)`, 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. +- 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. + + +## 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 +need `import 'package:flame_forge2d/flame_forge2d.dart' hide World;`. That was already the case +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 new file mode 100644 index 00000000000..901340eaaf8 --- /dev/null +++ b/doc/other_modules/forge2d/forge2d.md @@ -0,0 +1,113 @@ +# 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. + +If you are upgrading from forge2d 0.14, see the [migration guide](migration.md). + + +## Getting started + +Add `forge2d` to your `pubspec.yaml`, initialize Forge2D, and create a world: + +```dart +import 'package:forge2d/forge2d.dart'; + +Future 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))); + 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. + + +## 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. + + +## 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`) 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 + +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/forge2d/migration.md b/doc/other_modules/forge2d/migration.md new file mode 100644 index 00000000000..98c8cec4f3d --- /dev/null +++ b/doc/other_modules/forge2d/migration.md @@ -0,0 +1,238 @@ +# 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. +``` + + +## 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 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 + +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, + ), +); +``` + +```{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: + +```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`. + +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 + +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, 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 + +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)`. + +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 + +- `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`, `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/doc/other_modules/other_modules.md b/doc/other_modules/other_modules.md index a326aa2bbc8..4ce592374eb 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,8 @@ Component System is as efficient in almost all cases. ```{toctree} :hidden: -jenny -oxygen +forge2d +forge2d migration +jenny +oxygen ``` 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..3e807cc1ad5 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(friction: 0, 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..1b4483728c8 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'; @@ -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/examples/games/padracing/lib/tire.dart b/examples/games/padracing/lib/tire.dart index 11f0726cf95..563477d50fa 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,31 @@ 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; - - jointDef.bodyB = body; - jointDef.localAnchorA.setFrom(jointAnchor); - world.createJoint(joint = RevoluteJoint(jointDef)); - joint.setLimits(0, 0); + // 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( + bodyA: car.body, + bodyB: body, + localAnchorA: jointAnchor, + enableLimit: true, + ), + ); return body; } @@ -115,7 +126,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 +147,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 +177,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 +195,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..2ed7703cae4 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,15 @@ 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(friction: 0, 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..bbb486a7f66 100644 --- a/examples/lib/main.dart +++ b/examples/lib/main.dart @@ -4,17 +4,14 @@ 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/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/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/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'; @@ -46,17 +43,14 @@ 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, + '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 07d310eb7f2..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 @@ -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. @@ -28,7 +29,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( @@ -72,21 +73,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.6, friction: 0.5), ); 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/camera_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/camera_example.dart index 941423dba56..af3d4afdb6e 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/camera_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/camera_example.dart @@ -1,15 +1,16 @@ 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 follow. Other than that it is the same as the domino example. '''; - CameraExample() : super(world: CameraExampleWorld()); + // 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 e8aa956d0a5..f261c8a8c84 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/composition_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/composition_example.dart @@ -1,24 +1,24 @@ 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. '''; - CompositionExample() : super(zoom: 20, gravity: Vector2(0, 10.0)); + CompositionExample() : super(metersToPixels: 20, gravity: Vector2(0, 10.0)); @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..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. @@ -22,7 +23,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 1ecd29b8529..3714eb3cefb 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/domino_example.dart @@ -1,45 +1,126 @@ +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'; 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 - 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(), + metersToPixels: 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 { - super.onLoad(); - final boundaries = createBoundaries(game); - addAll(boundaries); + await super.onLoad(); + 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); + + /// 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++), + ), + ); + } - const numberOfRows = 7; - for (var i = 0; i < numberOfRows - 2; i++) { - add(Platform(Vector2(0.0, 5.0 * i))); + 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)); } } } @@ -51,38 +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 - Body createBody() { - final shape = PolygonShape()..setAsBoxXY(14.8, 0.125); - final fixtureDef = FixtureDef(shape); + double get outlineWidth => 0.04; - final bodyDef = BodyDef(position: _position); - final body = world.createBody(bodyDef); - return body..createFixture(fixtureDef); + @override + Body createBody() { + 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, + ), + // 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), + ); } } -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 shape = PolygonShape()..setAsBoxXY(0.125, 2.0); - final fixtureDef = FixtureDef( - shape, - density: 25.0, - restitution: 0.4, - friction: 0.5, - ); - - final bodyDef = BodyDef(type: BodyType.dynamic, position: _position); - return world.createBody(bodyDef)..createFixture(fixtureDef); + // 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/drag_callbacks_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/drag_callbacks_example.dart index 090b6e8fc44..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 @@ -15,7 +16,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/flame_forge2d.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart index 01d35342388..962c56f7743 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/flame_forge2d.dart @@ -1,23 +1,19 @@ 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/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/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/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'; @@ -30,12 +26,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()), @@ -59,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', @@ -110,10 +100,10 @@ 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, + 'FilterJoint', + (DashbookContext ctx) => GameWidget(game: FilterJointExample()), + codeLink: link('joints/filter_joint.dart'), + info: FilterJointExample.description, ) .add( 'DistanceJoint', @@ -121,18 +111,6 @@ void addJointsStories(Dashbook dashbook) { 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,28 +129,22 @@ 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()), 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/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..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`. @@ -17,7 +19,7 @@ class DistanceJointWorld extends Forge2DWorld with TapCallbacks, HasGameReference { @override Future onLoad() async { - super.onLoad(); + await super.onLoad(); addAll(createBoundaries(game)); } @@ -32,17 +34,16 @@ 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)); + final joint = physicsWorld.createDistanceJoint( + DistanceJointDef( + bodyA: first.body, + bodyB: second.body, + length: 10, + enableSpring: true, + hertz: 3, + 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/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..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 @@ -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. @@ -25,17 +24,18 @@ class MotorJointWorld extends Forge2DWorld with TapCallbacks { @override Future onLoad() async { - super.onLoad(); + await super.onLoad(); final box = Box( startPosition: Vector2.zero(), 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]); @@ -51,15 +51,19 @@ 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, + // 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, + ), + ); } final linearOffset = Vector2.zero(); @@ -73,27 +77,12 @@ 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; - - joint.setLinearOffset(linearOffset); - joint.setAngularOffset(angularOffset); - } -} + final angularOffset = joint.angularOffset + deltaOffset; -class JointRenderer extends Component { - JointRenderer({required this.joint}); - - final MotorJoint joint; - - @override - void render(Canvas canvas) { - canvas.drawLine( - joint.anchorA.toOffset(), - joint.anchorB.toOffset(), - debugPaint, - ); + joint.linearOffset = linearOffset; + joint.angularOffset = angularOffset; } } 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..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,14 +1,16 @@ -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,19 +22,17 @@ class MouseJointWorld extends Forge2DWorld late Ball ball; late Body groundBody; MouseJoint? mouseJoint; + MouseJointRenderer? _jointRenderer; @override Future onLoad() async { - super.onLoad(); + await super.onLoad(); final boundaries = createBoundaries(game); addAll(boundaries); - final center = Vector2.zero(); groundBody = createBody(BodyDef()); - ball = Ball(center, radius: 5); + ball = Ball(Vector2.zero(), radius: 5, color: ExampleColors.amber); add(ball); - add(CornerRamp(center)); - add(CornerRamp(center, isMirrored: true)); } @override @@ -41,28 +41,32 @@ 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!); + final joint = physicsWorld.createMouseJoint( + MouseJointDef( + bodyA: groundBody, + bodyB: ball.body, + target: ball.body.position, + maxForce: 3000 * ball.body.mass * 10, + dampingRatio: 0.1, + hertz: 5, + ), + ); + mouseJoint = joint; + _jointRenderer = MouseJointRenderer(joint: joint); + add(_jointRenderer!); } @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; + _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 8232234545b..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,73 +1,53 @@ -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(); + final Vector2 axis = Vector2(1, 0); @override Future onLoad() async { - super.onLoad(); + 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)); + world.add( + PrismaticJointRenderer(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; - } -} - -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); + return world.physicsWorld.createPrismaticJoint( + PrismaticJointDef( + bodyA: box, + bodyB: groundBody, + localAxisA: axis, + enableLimit: true, + lowerTranslation: -20, + upperTranslation: 20, + enableMotor: true, + motorSpeed: 1, + maxMotorForce: 100, + ), + ); } } 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..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 @@ -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. @@ -22,7 +24,7 @@ class RevoluteJointWorld extends Forge2DWorld with TapCallbacks, HasGameReference { @override Future onLoad() async { - super.onLoad(); + await super.onLoad(); addAll(createBoundaries(game)); } @@ -54,23 +56,19 @@ 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.5, restitution: 0.4), + ), ); - - body.createFixture(fixtureDef); } - final jointDef = RevoluteJointDef() - ..initialize(body, ball.body, body.position); - world.createJoint(RevoluteJoint(jointDef)); + 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/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..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` @@ -21,7 +23,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)) @@ -89,9 +91,15 @@ class WeldJointWorld extends Forge2DWorld } void createWeldJoint(Body first, Body second, Vector2 anchor) { - final weldJointDef = WeldJointDef()..initialize(first, second, anchor); - - createJoint(WeldJoint(weldJointDef)); + final joint = physicsWorld.createWeldJoint( + WeldJointDef( + bodyA: first, + bodyB: second, + localAnchorA: first.localPoint(anchor), + 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..8c758a1c502 --- /dev/null +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/joints/wheel_joint.dart @@ -0,0 +1,162 @@ +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(), + metersToPixels: 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 8ab66f23352..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,17 +2,18 @@ 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 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(); @@ -27,7 +28,7 @@ class RaycastExample extends Forge2DGame with MouseMovementDetector { @override Future onLoad() async { - super.onLoad(); + await super.onLoad(); world.addAll(createBoundaries(this)); const numberOfRows = 3; @@ -78,36 +79,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 +158,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..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 @@ -1,12 +1,14 @@ 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'; +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. @@ -25,7 +27,7 @@ class RevoluteJointWithMotorWorld extends Forge2DWorld @override Future onLoad() async { - super.onLoad(); + await super.onLoad(); final boundaries = createBoundaries(game); addAll(boundaries); final center = Vector2.zero(); @@ -39,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())); }); } @@ -64,56 +66,57 @@ 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.5, restitution: 0.4), + ), ); - - 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; } } -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; @override Body createBody() { - final shape = ChainShape(); final mirrorFactor = isMirrored ? -1 : 1; final diff = 2.0 * mirrorFactor; + // 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), ]; - shape.createLoop(vertices); - 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)..createShape( + Polygon(vertices), + ShapeDef(material: SurfaceMaterial(friction: 0.5)), + ); } } 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..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. @@ -22,7 +23,7 @@ class SpriteBodyWorld extends Forge2DWorld with TapCallbacks, HasGameReference { @override Future onLoad() async { - super.onLoad(); + await super.onLoad(); addAll(createBoundaries(game)); } @@ -59,27 +60,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/tap_callbacks_example.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/tap_callbacks_example.dart index 96831ba761a..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 @@ -1,21 +1,22 @@ 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. 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 { - 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/utils/balls.dart b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/balls.dart index 4df873ef6c2..2f5d1a617f7 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,33 +13,29 @@ 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 neighboring balls stay readable. + Color randomColor() => + ExampleColors.dynamicColor(_colorIndex++ % ExampleColors.dynamics.length); + + Paint randomPaint() => Paint()..color = randomColor(); @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.7), + enableContactEvents: true, ); final bodyDef = BodyDef( @@ -48,14 +45,8 @@ class Ball extends BodyComponent with ContactCallbacks { type: bodyType, ); - return world.createBody(bodyDef)..createFixture(fixtureDef); - } - - @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); + return world.createBody(bodyDef) + ..createShape(Circle(radius: radius), shapeDef); } final _impulseForce = Vector2(0, 1000); 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..6e0772ade0e 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,24 +18,30 @@ 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() { - final shape = EdgeShape()..set(start, end); - final fixtureDef = FixtureDef(shape, friction: 0.3); + // 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(), ); - 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..6481caef290 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,24 +18,22 @@ 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() { - 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(restitution: 0.1), density: 10, + enableContactEvents: true, ); final bodyDef = BodyDef( userData: this, // To be able to determine object in collision @@ -43,12 +41,14 @@ class Box extends BodyComponent { type: bodyType, ); - return world.createBody(bodyDef)..createFixture(fixtureDef); + return world.createBody(bodyDef) + ..createShape(Polygon.box(width / 2, height / 2), shapeDef); } } class DraggableBox extends Box with DragCallbacks { MouseJoint? mouseJoint; + MouseJointRenderer? _jointRenderer; late final groundBody = world.createBody(BodyDef()); bool _destroyJoint = false; @@ -61,8 +61,10 @@ class DraggableBox extends Box with DragCallbacks { @override void update(double dt) { if (_destroyJoint && mouseJoint != null) { - world.destroyJoint(mouseJoint!); + mouseJoint!.destroy(); mouseJoint = null; + _jointRenderer?.removeFromParent(); + _jointRenderer = null; _destroyJoint = false; } } @@ -73,24 +75,28 @@ 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!); + final joint = world.physicsWorld.createMouseJoint( + MouseJointDef( + bodyA: groundBody, + bodyB: body, + target: target, + maxForce: 5000 * body.mass, + dampingRatio: 0.1, + 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 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/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..2aaf761cd5b --- /dev/null +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/utils/style.dart @@ -0,0 +1,113 @@ +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.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 1c6b42d0a90..6db6df9e0de 100644 --- a/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart +++ b/examples/lib/stories/bridge_libraries/flame_forge2d/widget_example.dart @@ -1,60 +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'; -class WidgetExample extends Forge2DGame { +/// 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 examples shows how to render a widget on top of a Forge2D body outside - of Flame. + 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. + + 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. '''; - final List updateStates = []; - final Map bodyIdMap = {}; - final List addLaterIds = []; + /// 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; - WidgetExample() : super(zoom: 20, gravity: Vector2(0, 10.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 { - super.onLoad(); - final boundaries = createBoundaries(this, strokeWidth: 0); - world.addAll(boundaries); + await super.onLoad(); + _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; + + void wall(double cx, double cy, double halfWidth, double halfHeight) { + world + .createBody(BodyDef(position: Vector2(cx, cy))) + .createShape(Polygon.box(halfWidth, halfHeight)); + } - final shape = PolygonShape()..setAsBoxXY(4.6, 0.8); - final fixtureDef = FixtureDef( - shape, - restitution: 0.8, - friction: 0.2, + 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), + ), ); - body.createFixture(fixtureDef); - 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(); + } } } @@ -66,72 +209,157 @@ 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(() {}); + } + } - _BodyButtonState(this._game, this._bodyId) { - _game.updateStates.add(() { - setState(() { - _body = _game.bodyIdMap[_bodyId]; - }); - }); + /// The button's normal function, plus a shove. + void _incrementCounter() { + setState(() => _counter++); + widget.game.launch(widget.game.button); + } + + /// 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 - 18, - left: bodyPosition.x - 90, - 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), + 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( + heroTag: 'counterButton', + 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, + ), ), - ); - } + ), + ); } } 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/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, 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). + 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..ee57a1e4d74 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,33 @@ 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. + /// + /// 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, '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; } @@ -81,11 +116,18 @@ 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; CameraComponent get camera => game.camera; - Vector2 get center => body.worldCenter; + Vector2 get center => body.worldCenterOfMass; @override double get angle => body.angle; @@ -116,71 +158,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 +211,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 +246,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..c4ab7078b86 100644 --- a/packages/flame_forge2d/lib/flame_forge2d.dart +++ b/packages/flame_forge2d/lib/flame_forge2d.dart @@ -3,7 +3,9 @@ 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_viewfinder.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..8f95e39b156 100644 --- a/packages/flame_forge2d/lib/forge2d_game.dart +++ b/packages/flame_forge2d/lib/forge2d_game.dart @@ -1,26 +1,74 @@ 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'; +import 'package:forge2d/forge2d.dart' show initializeForge2D; /// The base game class for creating games that uses the Forge2D physics engine. 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]. Forge2DGame({ Forge2DWorld? world, CameraComponent? camera, Vector2? gravity, - ContactListener? contactListener, - double zoom = 10, - }) : super( + ContactEventsDispatcher? contactEventsDispatcher, + double metersToPixels = Forge2DViewfinder.defaultMetersToPixels, + }) : 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( gravity: gravity, - contactListener: contactListener, + contactEventsDispatcher: contactEventsDispatcher, )) as T, - camera: (camera ?? CameraComponent())..viewfinder.zoom = zoom, - ); + camera: camera ?? CameraComponent(), + ) { + final viewfinder = this.camera.viewfinder; + if (viewfinder is Forge2DViewfinder) { + viewfinder.metersToPixels = metersToPixels; + } else { + this.camera.viewfinder = Forge2DViewfinder( + metersToPixels: metersToPixels, + ); + } + } + + /// 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 + /// 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 { + await initializeForge2D(); + await super.onLoad(); + } /// Takes a point in world coordinates and returns it in screen coordinates. Vector2 worldToScreen(Vector2 position) { 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/lib/forge2d_world.dart b/packages/flame_forge2d/lib/forge2d_world.dart index 60933564a86..d8e45150ddd 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,16 +9,75 @@ 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()); + }) : _gravity = gravity ?? definition?.gravity ?? defaultGravity, + _definition = definition, + contactEventsDispatcher = + contactEventsDispatcher ?? ContactEventsDispatcher(); static final Vector2 defaultGravity = Vector2(0, 10.0); - final forge2d.World physicsWorld; + 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. + forge2d.World get physicsWorld { + final existingWorld = _physicsWorld; + if (existingWorld != null) { + return existingWorld; + } + final createdWorld = forge2d.World( + gravity: _gravity, + definition: _definition, + ); + 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; + } + + /// 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. @@ -24,55 +85,128 @@ class Forge2DWorld extends World { /// 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. + 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. + /// + /// 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); + } + @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; + Vector2 get gravity => _physicsWorld?.gravity ?? _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); + _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/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..7250013741a 100644 --- a/packages/flame_forge2d/pubspec.yaml +++ b/packages/flame_forge2d/pubspec.yaml @@ -20,7 +20,7 @@ dependencies: flame: ^1.38.0 flutter: sdk: flutter - forge2d: ^0.14.2 + forge2d: ^0.15.0 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..07d3b8a119b 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,17 +10,12 @@ 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; @override Body createBody() => body; - @override - void noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); - @override void onTapDown(TapDownEvent _) { tapCount++; @@ -55,11 +48,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 +60,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 +86,22 @@ 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 @@ -116,9 +109,36 @@ void main() { await game.world.add(component); game.camera.follow(component); + }, + verify: (game, tester) async { + await expectLater( + find.byGame(), + matchesGoldenFile(goldenPath('capsule_shape')), + ); + }, + ); - // a PolygonShape contains point - expect(component.containsPoint(Vector2.all(10)), isTrue); + 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 + ..paint = testPaint; + await game.world.add(component); + + game.camera.follow(component); + + // a Polygon contains point + expect(component.containsPoint(Vector2(2, 8)), isTrue); }, verify: (game, tester) async { await expectLater( @@ -129,18 +149,23 @@ 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, 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), ], - ); - body.createFixture(FixtureDef(shape)); + ), + ); final component = _TestBodyComponent() ..body = body @@ -158,18 +183,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 +215,122 @@ void main() { }); }); - group('renderFixture', () { + 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; + 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 = body.createShape(Circle(radius: 5)); + + expect( + () => component.renderShape(canvas, shape), + returnsNormally, + ); + }); + + test('when rendering a Segment', () { final component = _TestBodyComponent(); - final shape = CircleShape()..radius = 5; - final fixture = body.createFixture( - FixtureDef(shape), + 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 +344,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 +372,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 +379,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 +403,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 +421,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 +438,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,33 +453,154 @@ 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}); + }, + ); + + 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); + }, + ); + }); + }); + + 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( + 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', () { 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, 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 +653,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 +668,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(); @@ -523,6 +681,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_callbacks_test.dart b/packages/flame_forge2d/test/contact_callbacks_test.dart index 518bcf12462..ca6dcc94621 100644 --- a/packages/flame_forge2d/test/contact_callbacks_test.dart +++ b/packages/flame_forge2d/test/contact_callbacks_test.dart @@ -1,27 +1,37 @@ import 'package:flame_forge2d/flame_forge2d.dart'; +import 'package:flame_test/flame_test.dart'; import 'package:test/expect.dart'; import 'package:test/scaffolding.dart'; -import 'helpers/helpers.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; 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 +41,42 @@ 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)); - }); + 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; - test('postSolve calls on postSolve', () { - final contactCallbacks = ContactCallbacks(); - var called = 0; - contactCallbacks.onPostSolve = (_, __, ___) => called++; + 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); - contactCallbacks.postSolve(other, contact, contactImpulse); + for (var i = 0; i < 60 && contactedWith == null; i++) { + game.update(1 / 60); + } - expect(called, equals(1)); - }); + 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 new file mode 100644 index 00000000000..eaf33dc444d --- /dev/null +++ b/packages/flame_forge2d/test/contact_events_dispatcher_test.dart @@ -0,0 +1,315 @@ +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'; + +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. + setUpAll(initializeForge2D); + + 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); + } + // 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(beginCount)); + }, + ); + + 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)); + }, + ); + }); + + 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/contact_test.dart b/packages/flame_forge2d/test/contact_test.dart new file mode 100644 index 00000000000..a1ff7528ace --- /dev/null +++ b/packages/flame_forge2d/test/contact_test.dart @@ -0,0 +1,99 @@ +import 'package:flame_forge2d/flame_forge2d.dart'; +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; + 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_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..3347ab19d10 --- /dev/null +++ b/packages/flame_forge2d/test/forge2d_viewfinder_test.dart @@ -0,0 +1,116 @@ +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 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, + (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)); + }, + ); + }); +} diff --git a/packages/flame_forge2d/test/forge2d_world_test.dart b/packages/flame_forge2d/test/forge2d_world_test.dart index 7c269d7f624..a014b87e2ad 100644 --- a/packages/flame_forge2d/test/forge2d_world_test.dart +++ b/packages/flame_forge2d/test/forge2d_world_test.dart @@ -7,20 +7,59 @@ 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', () => 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 +71,202 @@ 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( + '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 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); + }, + ); + + 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)); + }, + ); + + 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)); }, ); } 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 00000000000..025f6bfc3c9 Binary files /dev/null and b/packages/flame_forge2d/test/goldens/body_component/capsule_shape.png differ 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 ea1420b6372..00000000000 Binary files a/packages/flame_forge2d/test/goldens/body_component/chain_shape.png and /dev/null differ 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 0f2ec6783a6..5b20862bbb1 100644 Binary files a/packages/flame_forge2d/test/goldens/body_component/chain_shape_closed.png and b/packages/flame_forge2d/test/goldens/body_component/chain_shape_closed.png differ 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 3d13ca75496..52cea7420d0 100644 Binary files a/packages/flame_forge2d/test/goldens/body_component/chain_shape_open.png and b/packages/flame_forge2d/test/goldens/body_component/chain_shape_open.png differ diff --git a/packages/flame_forge2d/test/goldens/body_component/circle_shape.png b/packages/flame_forge2d/test/goldens/body_component/circle_shape.png index de84575b03a..97da127ea9a 100644 Binary files a/packages/flame_forge2d/test/goldens/body_component/circle_shape.png and b/packages/flame_forge2d/test/goldens/body_component/circle_shape.png differ 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/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'; 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..bfbf05b1470 100644 --- a/scripts/customer_testing.dart +++ b/scripts/customer_testing.dart @@ -10,7 +10,12 @@ 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'}; +// +// 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 { final packages =