Last updated:
0 purchases
leap
Leap #
An opinionated toolkit for creating 2D platformers on top of the
Flame engine.
Join the
#leap channel in the Flame Discord to discuss.
WARNING library under development #
Be aware that this is still under development and is likely to change
frequently, every release could introduce breaking changes up until a v1.0.0
release (which may never happen as this is a solo endeavour currently).
Features #
Level creation via Tiled #
Leap uses Tiled tile maps not just for visually rendering the level, but also
for imbuing behavior and terrain in the level by creating corresponding Flame
components automatically from the map's layers.
Physics #
The crux of this physics engine is based on this post The guide to implementing
2D platformers:
http://higherorderfun.com/blog/2012/05/20/the-guide-to-implementing-2d-platformers/
The "Type #2: Tile Based (Smooth)" section outlines the overall algorithm.
Note that Leap doesn't use Flame's collision detection system in favor of one
that is more specialized and efficient for tile based platformers where every
hitbox is an axis-aligned bounding box, and special handling can be done for
tile grid aligned components (such as ground terrain).
Efficient collision detection
Essentially all physical objects (PhsyicalComponent) in the game have
axis-aligned bounding boxes (AABBs) for hitboxes, determined by their size and
position. The hitbox doesn't necessarily need to match the visual size of the
component.
✅ Supported tile platformer features:
Ground terrain
One way platforms
Slopes
Moving platforms
Ladders
🚧 Future tile platformer features:
Friction control for ground tiles (for ice, etc.)
Interactive ground tiles
Removable ground tiles
One way walls
Simple physics designed for 2D platformers
Long story short: physics engines like box2d are great for emulating realistic
physics and terrible for implementing retro-style 2d platformers which are not
remotely realistic. In order to get the snappy jumps and controls required for a
responsive platformer a much more rudimentary physics engine is required.
In Leap, physical entities have a velocity attribute for storing the current
x and y velocity, which will automatically update the entity's position. A
moving entity colliding with the level terrain will automatically have its
velocity set to 0 and position updated to be kept outside the terrain to
prevent overlap. There is also a global gravity rate applied to the y
velocity every game tick. Static entities will never be moved by velocity or
gravity.
Getting started #
Before using Leap, you should be familiar with the following Flame components:
FlameGame
CameraComponent
PositionComponent
TiledComponent
Usage #
LeapGame #
To use Leap, your game instance must extend LeapGame (which in turn extends
FlameGame). It's recommended to use game.loadWorldAndMap to initialize the
game's world and map.
LeapWorld #
Accessible via LeapGame.world, this component manages any global logic
necessary for the physics engine.
LeapMap #
Accessible via LeapGame.map, this component manages the Tiled map and
automatically constructs the tiles with proper collision detection for the
ground terrain. See [Tiled map integration](#Tiled map integration) below
Game code snippet #
See the standard_platformer example for complete
game code.
void main() {
runApp(GameWidget(game: MyLeapGame()));
}
class MyLeapGame extends LeapGame with HasTappables, HasKeyboardHandlerComponents {
late final Player player;
@override
Future<void> onLoad() async {
await super.onLoad();
// "map.tmx" should be a Tiled map that meets the Leap requirements defined below
await loadWorldAndMap('map.tmx', 16);
setFixedViewportInTiles(32, 16);
player = Player();
add(player);
camera.followComponent(player);
}
}
copied to clipboard
PhyiscalEntity physics system #
The physics system for Leap requires that every Component that interacts with
the game's phyical world extend PhysicalEntity and be added to the LeapWorld
component which is accessible via LeapGame.world.
Characters
Character is a PhysicalEntity which is intended to be used as the parent
class for players, enemies, and possibly even objects in your game. It has the
concept of health and an onDeath function that can be overridden for when
health reaches zero (or negative). It's also possible to set
removeOnDeath = true to automatically remove the character from the game when
it dies.
Entity animations and behaviors
Entities are typically rendered visually as a SpriteAnimation, however most
likely there is a different animation for different states of the character.
That's where AnchoredAnimationGroup comes in.
A AnchoredAnimationGroup is a specialized SpriteAnimationGroupComponent, so
you can set all the animations as map on the component and then update the
current animation with the key in the map for the correct animation.
Typically you want to make use of this by making a subclass of
AnchoredAnimationGroup so all the logic relevant to picking the current
animation is self contained.
For example:
class Player extends JumperCharacter with HasAnimationGroup {
Player() {
// Behaviors that run before physics
...
// Physics
add(GravityAccelerationBehavior());
add(CollisionDetectionBehavior());
// Behaviors that run after physics
...
add(ApplyVelocityBehavior());
// Rendering related behaviors
add(AnimationVelocityFlipBehavior());
animationGroup = PlayerAnimation();
}
}
enum _AnimationState { walk, jump }
class PlayerAnimation extends AnchoredAnimationGroup<_AnimationState, Player> {
@override
Future<void> onLoad() async {
animations = {
_AnimationState.walk: SpriteAnimation(...),
_AnimationState.jump: SpriteAnimation(...),
};
return super.onLoad();
}
@override
void update(double dt) {
if (character.isWalking) {
current = _AnimationState.walking;
} else if (character.isJumping) {
current = _AnimationState.jumping;
}
super.update(dt);
}
}
copied to clipboard
AnchoredAnimationGroup also automatically handles positioning the animation to
be centered on the parent's hitbox. The positioning can be changed with the
hitboxAnchor property.
AnchoredAnimationGroup must be added via the HasAnimationGroup mixin in a
PhysicalEntity component, and typically is set to the animationGroup
property as well.
Death animations
The recommended way to handle death animations is to add the
RemoveOnDeathBehavior or the RemoveAfterDeathAnimationBehavior. If you
depend on the death animation finishing, you will also need to:
Have a AnchoredAnimationGroup set on the character, and make sure it sets
the current animation to whichever death animation you need.
Make sure the death animation has loop = false so it doesn't play forever.
Make sure the rest of your game doesn't interact with it as if it is still
alive. The recommened approach for this is to add a custom Status to it,
possibly with the IgnoredByWorld mixin on it. Or you can other entities
interacting with it can check isDead on it.
Status effect system
PhysicalEntity components can have statuses (EntityStatus) which modify
their behavior. Statuses affect the component they are added to. For example,
you could implement a StarPowerStatus which when added to your player
component makes them flash colors become invincible.
Since statuses are themselves components, they can maintain their own state and
handle updating themselves or their parent PhysicalEntity components. See
OnLadderStatus for an example of this.
There are mixins on EntityStatus which affect the Leap engine's handling of
the parent PhysicalEntity. See:
IgnoredByWorld
IgnoresVelocity
IgnoresGravity
IgnoredByCollisions
IgnoresAllCollisions
IgnoresNonSolidCollisions
IgnoresSolidCollisions
You can implement your own mixins on EntityStatus which control pieces of
logic in your own game.
Resetting the map on player death #
It's pretty common want to reset the map when the player dies. The recommended
patter is to reload the map in the Game class and add:
class MyGame {
...
Future<void> reloadLevel() async {
await loadWorldAndMap(
tiledMapPath: 'map.tmx',
tiledObjectHandlers: tiledObjectHandlers,
);
// Don't let the camera move outside the bounds of the map, inset
// by half the viewport size to the edge of the camera if flush with the
// edge of the map.
final inset = camera.viewport.virtualSize;
camera.setBounds(
Rectangle.fromLTWH(
inset.x / 2,
inset.y / 2,
leapMap.width - inset.x,
leapMap.height - inset.y,
),
);
}
@override
void onMapUnload(LeapMap map) {
player?.removeFromParent();
}
@override
void onMapLoaded(LeapMap map) {
if (player != null) {
world.add(player!);
player!.resetPosition();
}
}
}
copied to clipboard
And make sure to call game.reloadLevel() from the Player when the player
dies.
Tiled map integration #
Leap automatically parses specific features out of specific Tiled layers.
Ground layer
Layer must be a Tile Layer named "Ground", by default all tiles placed in this
layer are assumed to be ground terrain in the physics of the game. This means
these tiles will be statically positioned and have a hitbox that matches the
width and height of the tile.
Specialized ground tiles:
Slopes for terrain the physical can walk up/down like a hill. These tiles
must have two custom int properties LeftTop and RightTop. For example, a
16x16 pixel tile with LeftTop = 0 and RightTop = 8 indicates slope that is
ascending when moving from left-to-right. Alternatively the tile can have
LeftBottom and RightBottom for pitched (sloped on the bottom) tile.
One Way Platforms for terrain the physical entities can move up (e.g.
jump) through from all but one direction. These are implemented via
GroundTileHandler classes, and can therefore use and class you want via
passing in a map of custom groundTileHandlers when loading the map (see
below). The most used is OneWayTopPlatformHandler which modifies the tile to
be phased through from below and the sides, but solid from the top.
Custom ground tile handling
To have complete control over individual tiles in the ground layer, you can use
the class property in the Tiled editor tileset to hook into the
groundTileHandlers you pass in when loading your map.
In your LeapGame:
await loadWorldAndMap(
camera: camera,
tiledMapPath: 'map.tmx',
groundTileHandlers: {
'OneWayTopPlatform': OneWayTopPlatformHandler(),
'MyCustomTile': MyCustomTileHandler(),
},
);
copied to clipboard
And your MyCustomTileHandler:
class MyCustomTileHandler implements GroundTileHandler {
@override
LeapMapGroundTile handleGroundTile(LeapMapGroundTile groundTile, LeapMap map) {
tile.tags.add('PowerUpTile');
// Add some extra rendering on top of your special tile.
map.add(PowerUpTileAnimationComponent(x: groundTile.x, y: groundTile.y));
// use the provided tile instance in the map
return tile;
}
}
// OR
class MyCustomTileHandler implements GroundTileHandler {
@override
LeapMapGroundTile handleGroundTile(LeapMapGroundTile groundTile, LeapMap map) {
// MyCustomTile constructor must call the super constructor to initialize
// the the LeapMapGroundTile properties
return MyCustomTile(
groundTile,
myCustomProperty: groundTile.tile.properties.getValue<int>('PowerValue'),
);
}
}
copied to clipboard
Note that the class property is always added to each tile's
PhysicalEntity.tags. So, you can check if your player is walking into a
special type of wall with something like this:
class Player extends PhysicalEntity {
@override
void update(double dt) {
super.update(dt);
if (collisionInfo.right && // hitting solid entity on the right
input.actionButtonPressed && // custom input handling
collisionInfo.rightCollision!.tags.contains('MySpecialTile')) {
// right collision has a tag we set from Tiled's tileset `class` property
// (tag could also be added by your own custom handling)
specialInteraction(collisionInfo.rightCollision!);
}
}
}
copied to clipboard
Metadata layer
Layer must be an Object Group named "Metadata", used to place any objects to be
used in your game like level start/end, enemy spawn points, anything you want.
Object Group layers
Any Object Group layer (including the Metadata layer) can include arbitrary
objects in them, if you wish to automatically create Flame Components for some
of those objects you can do so based on the Class string in Tiled. All that is
required is implementing the TiledObjectFactory interface and mapping each
Class string you care about to a factory instance when loading the LeapMap,
for example...
In your LeapGame:
await loadWorldAndMap(
camera: camera,
tiledMapPath: 'map.tmx',
tiledObjectHandlers: {
'Coin': await CoinFactory.createFactory(),
},
);
copied to clipboard
Your custom factory:
class CoinFactory implements TiledObjectFactory<Coin> {
late final SpriteAnimation spriteAnimation;
CoinFactory(this.spriteAnimation);
@override
void handleObject(TiledObject object, Layer layer, LeapMap map) {
final coin = Coin(object, spriteAnimation);
map.add(coin);
}
static Future<CoinFactory> createFactory() async {
final tileset = await Flame.images.load('my_animated_coin.png');
final spriteAnimation = SpriteAnimation.fromFrameData(
tileset,
SpriteAnimationData.sequenced(...),
);
return CoinFactory(spriteAnimation);
}
}
class Coin extends PhysicalEntity {
Coin(TiledObject object, this.animation)
: super(static: true) {
anchor = Anchor.center;
// Use the position from your Tiled map
position = Vector2(object.x, object.y);
// Use custom properties from your Tiled object
value = tiledObject.properties.getValue<int>('CoinValue');
}
...
}
copied to clipboard
Other layers
Any other layers will be rendered visually but have no impact on the game
automatically. You can add additional custom behavior by accessing the layers
via LeapGame.map.tiledMap and integrating your own special behavior for tiles
or objects.
Moving platforms
To create a moving platform, you need to implement your own component which
extends MovingPlatform and provides a Sprite (or some other rendering).
If you choose to implement this component with a Tiled object (recommended),
many of the fields can be directly read from the object's custom properties:
MoveSpeedX (double), speed in tiles per second on the X axis
MoveSpeedY (double), speed in tiles per second on the y axis
LoopMode (string), one of resetAndLoop, reverseAndLoop, none
TilePath (string), a list of grid offsets to define the platforms path of
movement. For example, 0,-3;2,0 means the platform will move up 3 tiles and
then move right 2 tiles.
Ladders
To create a ladder, you need to implement your own component which extends
Ladder and provides a Sprite (or some other rendering).
Ladders also require custom integration with your components which are able to
climb the ladder. This is accomplished by adding an OnLadderStatus as a child
component, removing the child component will remove the component from the
ladder.
For example:
class Player extends PhyiscalEntity {
void update(double dt) {
// These booleans are fabricated for this example,
// implement what makes senes for your own system.
if (isNearLadder && actionButton.isPressed) {
add(OnLadderStatus(ladder));
} else if (hasStatus<OnLadderStatus>() && jumpButton.isPressed) {
remove(getStatus<OnLadderStatus>());
}
}
}
copied to clipboard
To see a fully working example, see Player in examples/standard_platformer.
Customizing layer names and classes
Even though the structure explained above should always be followed, the
developer can ask Leap to use different classes, types, names.
In order to do so, a custom LeapConfiguration can be passed to the game.
Example:
class MyLeapGame extends LeapGame {
MyLeapGame() : super(
configuration: LeapConfiguration(
tiled: const TiledOptions(
groundLayerName: 'Ground',
metadataLayerName: 'Metadata',
playerSpawnClass: 'PlayerSpawn',
damageProperty: 'Damage',
platformClass: 'Platform',
slopeType: 'Slope',
slopeRightTopProperty: 'RightTop',
slopeLeftTopProperty: 'LeftTop',
),
),
);
}
copied to clipboard
Debugging #
Slow motion #
LeapWorld includes
HasTimeScale,
so you can set world.timeScale = 0.5 to slow your whole game down to 50% speed
to make it easier to play test nuanced bugs. (You can use this as slow motion
for your game too.)
Render hitbox #
PhysicalEntity includes a debugHitbox property you can set to automatically
draw a box indicating the exact hitbox the collision detection system is using
for your entity.
class MyPlayer extends PhysicalEntity {
@override
void update(double dt) {
// Draw entity's hitbox
debugHitbox = true;
}
}
copied to clipboard
Render collisions #
PhysicalEntity includes a debugCollisions property you can set to
automatically draw a box indicating the hitbox of all other entities it is
currently colliding with.
class MyPlayer extends PhysicalEntity {
@override
void update(double dt) {
// Draw entity's collisions
debugCollisions = true;
}
}
copied to clipboard
Roadmap 🚧 #
Improved collision detection API.
The current API is fairly awkward, see CollisionInfo.
There is no great way to detect collision start or collision end.
Improved API for PhysicalEntity, addImpulse etc.
Lots of code clean-up to make usage of Leap more ergonomic and configurable.
Contributing #
Ensure any changes pass:
melos format
melos analyze
melos test
Start your PR title with a
conventional commit type
(feat:, fix: etc).
For personal and professional use. You cannot resell or redistribute these repositories in their original state.
There are no reviews.