diff --git a/doc/flame/components.md b/doc/flame/components.md index 1ac1fd7ac..c703ba56f 100644 --- a/doc/flame/components.md +++ b/doc/flame/components.md @@ -965,14 +965,46 @@ void main() { ## TiledComponent -Currently we have a very basic implementation of a Tiled component. This API uses the lib -[tiled.dart](https://github.com/flame-engine/tiled.dart) to parse map files and render visible -layers. +Tiled is a free and open source, full-featured level and map editor for your platformer or +RPG game. Currently we have an "in progress" implementation of a Tiled component. This API +uses the lib [tiled.dart](https://github.com/flame-engine/tiled.dart) to parse map files and +render visible layers using the performant `SpriteBatch` for each layer. + +Supported map types include: Orthogonal, Isometric, Hexagonal, and Staggered. + +Orthogonal | Hexagonal | Isomorphic +:--:|:-------------------------:|:-------------------------: +![An example of an orthogonal map](../images/orthogonal.png)|![An example of hexagonal map](../images/pointy_hex_even.png) | ![An example of isomorphic map](../images/tile_stack_single_move.png) An example of how to use the API can be found [here](https://github.com/flame-engine/flame_tiled/tree/main/example). +### TileStack + +Once a `TiledComponent` is loaded, you can select any column of (x,y) tiles in a `tileStack` to +then add animation. Removing the stack will not remove the tiles from the map. + +> **Note**: This currently only supports position based effects. + +```dart + final stack = map.tileMap.tileStack(4, 0, named: {'floor_under'}); + stack.add( + SequenceEffect( + [ + MoveEffect.by( + Vector2(5, 0), + NoiseEffectController(duration: 1, frequency: 20), + ), + MoveEffect.by(Vector2.zero(), LinearEffectController(2)), + ], + repeatCount: 3, + )..onComplete = () => stack.removeFromParent(), + ); + map.add(stack); +``` + + ## IsometricTileMapComponent This component allows you to render an isometric map based on a cartesian matrix of blocks and an diff --git a/doc/images/orthogonal.png b/doc/images/orthogonal.png new file mode 100644 index 000000000..92b1f52c6 Binary files /dev/null and b/doc/images/orthogonal.png differ diff --git a/doc/images/pointy_hex_even.png b/doc/images/pointy_hex_even.png new file mode 100644 index 000000000..d755002e8 Binary files /dev/null and b/doc/images/pointy_hex_even.png differ diff --git a/doc/images/tile_stack_single_move.png b/doc/images/tile_stack_single_move.png new file mode 100644 index 000000000..38e375405 Binary files /dev/null and b/doc/images/tile_stack_single_move.png differ diff --git a/packages/flame_tiled/lib/src/mutable_transform.dart b/packages/flame_tiled/lib/src/mutable_transform.dart new file mode 100644 index 000000000..d8f42c65a --- /dev/null +++ b/packages/flame_tiled/lib/src/mutable_transform.dart @@ -0,0 +1,67 @@ +import 'dart:typed_data'; +import 'dart:ui'; + +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; + +/// A mutable version of [RSTransform] for custom batch manipulation. +class MutableRSTransform implements RSTransform, PositionProvider { + final _values = Float32List(4); + + /// This is a cache of `-scos * anchorX + ssin * anchorY` + final double _anchorX; + + /// This is a cache of `-ssin * anchorX - scos * anchorY` + final double _anchorY; + + final Vector2 _position; + + MutableRSTransform( + double scos, + double ssin, + double tx, + double ty, + this._anchorX, + this._anchorY, + ) : _position = Vector2(tx, ty) { + _values[0] = scos; + _values[1] = ssin; + _values[2] = tx + _anchorX; + _values[3] = ty + _anchorY; + } + + /// The cosine of the rotation multiplied by the scale factor. + @override + double get scos => _values[0]; + set scos(double scos) => _values[0] = scos; + + /// The sine of the rotation multiplied by that same scale factor. + @override + double get ssin => _values[1]; + set ssin(double ssin) => _values[1] = ssin; + + /// The x coordinate of the translation, minus [scos] multiplied by the + /// x-coordinate of the rotation point, plus [ssin] multiplied by the + /// y-coordinate of the rotation point. + @override + double get tx => _values[2]; + set tx(double tx) => _values[2] = tx; + + /// The y coordinate of the translation, minus [ssin] multiplied by the + /// x-coordinate of the rotation point, minus [scos] multiplied by the + /// y-coordinate of the rotation point. + @override + double get ty => _values[3]; + set ty(double ty) => _values[3] = ty; + + @override + set position(Vector2 value) { + _values[2] = value.x + _anchorX; + _values[3] = value.y + _anchorY; + _position.x = value.x; + _position.y = value.y; + } + + @override + Vector2 get position => _position; +} diff --git a/packages/flame_tiled/lib/src/renderable_layers/tile_layer.dart b/packages/flame_tiled/lib/src/renderable_layers/tile_layer.dart index 78ce3f376..4d781751d 100644 --- a/packages/flame_tiled/lib/src/renderable_layers/tile_layer.dart +++ b/packages/flame_tiled/lib/src/renderable_layers/tile_layer.dart @@ -2,6 +2,7 @@ import 'package:flame/extensions.dart'; import 'package:flame/game.dart'; import 'package:flame/sprite.dart'; import 'package:flame_tiled/flame_tiled.dart'; +import 'package:flame_tiled/src/mutable_transform.dart'; import 'package:flame_tiled/src/renderable_layers/group_layer.dart'; import 'package:flame_tiled/src/renderable_layers/renderable_layer.dart'; import 'package:flame_tiled/src/tile_transform.dart'; @@ -13,6 +14,7 @@ import 'package:tiled/tiled.dart' as tiled; class TileLayer extends RenderableLayer { late final _layerPaint = Paint(); late final Map _cachedSpriteBatches; + late List> indexes; TileLayer( super.layer, @@ -26,6 +28,11 @@ class TileLayer extends RenderableLayer { @override void refreshCache() { + indexes = List.generate( + layer.width, + (index) => List.filled(layer.height, null), + ); + _cacheLayerTiles(); } @@ -95,14 +102,18 @@ class TileLayer extends RenderableLayer { final scos = flips.cos * scale; final ssin = flips.sin * scale; + indexes[tx][ty] = MutableRSTransform( + scos, + ssin, + offsetX, + offsetY, + -scos * anchorX + ssin * anchorY, + -ssin * anchorX - scos * anchorY, + ); + batch.addTransform( source: src, - transform: RSTransform( - scos, - ssin, - offsetX + -scos * anchorX + ssin * anchorY, - offsetY + -ssin * anchorX - scos * anchorY, - ), + transform: indexes[tx][ty], flip: flips.flip, ); } @@ -152,14 +163,18 @@ class TileLayer extends RenderableLayer { final scos = flips.cos * scale; final ssin = flips.sin * scale; + indexes[tx][ty] = MutableRSTransform( + scos, + ssin, + offsetX, + offsetY, + -scos * anchorX + ssin * anchorY, + -ssin * anchorX - scos * anchorY, + ); + batch.addTransform( source: src, - transform: RSTransform( - scos, - ssin, - offsetX + -scos * anchorX + ssin * anchorY, - offsetY + -ssin * anchorX - scos * anchorY, - ), + transform: indexes[tx][ty], flip: flips.flip, ); } @@ -257,11 +272,13 @@ class TileLayer extends RenderableLayer { final scos = flips.cos * scale; final ssin = flips.sin * scale; - final transform = RSTransform( + final transform = indexes[tx][ty] = MutableRSTransform( scos, ssin, - offsetX + -scos * anchorX + ssin * anchorY, - offsetY + -ssin * anchorX - scos * anchorY, + offsetX, + offsetY, + -scos * anchorX + ssin * anchorY, + -ssin * anchorX - scos * anchorY, ); // A second pass is only needed in the case of staggery. @@ -377,13 +394,14 @@ class TileLayer extends RenderableLayer { final scos = flips.cos * scale; final ssin = flips.sin * scale; - final transform = RSTransform( + final transform = indexes[tx][ty] = MutableRSTransform( scos, ssin, - offsetX + -scos * anchorX + ssin * anchorY, - offsetY + -ssin * anchorX - scos * anchorY, + offsetX, + offsetY, + -scos * anchorX + ssin * anchorY, + -ssin * anchorX - scos * anchorY, ); - // A second pass is only needed in the case of staggery. if (map.staggerAxis == tiled.StaggerAxis.x && staggerY > 0) { xSecondPass.add(TileTransform(src, transform, flips.flip, batch)); diff --git a/packages/flame_tiled/lib/src/renderable_tile_map.dart b/packages/flame_tiled/lib/src/renderable_tile_map.dart index 942702dc9..5187383fa 100644 --- a/packages/flame_tiled/lib/src/renderable_tile_map.dart +++ b/packages/flame_tiled/lib/src/renderable_tile_map.dart @@ -5,11 +5,13 @@ import 'package:flame/extensions.dart'; import 'package:flame/flame.dart'; import 'package:flame/game.dart'; import 'package:flame_tiled/src/flame_tsx_provider.dart'; +import 'package:flame_tiled/src/mutable_transform.dart'; import 'package:flame_tiled/src/renderable_layers/group_layer.dart'; import 'package:flame_tiled/src/renderable_layers/image_layer.dart'; import 'package:flame_tiled/src/renderable_layers/object_layer.dart'; import 'package:flame_tiled/src/renderable_layers/renderable_layer.dart'; import 'package:flame_tiled/src/renderable_layers/tile_layer.dart'; +import 'package:flame_tiled/src/tile_stack.dart'; import 'package:flutter/painting.dart'; import 'package:tiled/tiled.dart' as tiled; @@ -115,6 +117,74 @@ class RenderableTiledMap { return null; } + /// Select a group of tiles from the coordinates [x] and [y]. + /// + /// If [all] is set to true, every renderable tile from the map is collected. + /// + /// If the [named] or [ids] sets are not empty, any layer with matching + /// name or id will have their renderable tiles collected. If the matching + /// layer is a group layer, all layers in the group will have their tiles + /// collected. + TileStack tileStack( + int x, + int y, { + Set named = const {}, + Set ids = const {}, + bool all = false, + }) { + return TileStack( + _tileStack( + renderableLayers, + x, + y, + named: named, + ids: ids, + all: all, + ), + ); + } + + /// Recursive support for [tileStack] + List _tileStack( + List layers, + int x, + int y, { + Set named = const {}, + Set ids = const {}, + bool all = false, + }) { + final tiles = []; + for (final layer in layers) { + if (layer is GroupLayer) { + // if the group matches named or ids; grab every child. + // else descend and ask for named children. + tiles.addAll( + _tileStack( + layer.children, + x, + y, + named: named, + ids: ids, + all: all || + named.contains(layer.layer.name) || + ids.contains(layer.layer.id), + ), + ); + } else if (layer is TileLayer) { + if (!(all || + named.contains(layer.layer.name) || + ids.contains(layer.layer.id))) { + continue; + } + + if (layer.indexes[x][y] != null) { + tiles.add(layer.indexes[x][y]!); + } + } + } + return tiles; + } + /// Parses a file returning a [RenderableTiledMap]. /// /// NOTE: this method looks for files under the path "assets/tiles/". diff --git a/packages/flame_tiled/lib/src/tile_stack.dart b/packages/flame_tiled/lib/src/tile_stack.dart new file mode 100644 index 000000000..563fda0e2 --- /dev/null +++ b/packages/flame_tiled/lib/src/tile_stack.dart @@ -0,0 +1,27 @@ +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flame_tiled/src/mutable_transform.dart'; + +/// A select group of tiles from RenderableTiledMap that can be animated. +/// +/// Tiles are nothing more than an x/y coordinate in each layer. TileStack lets +/// you collect a certain group of tiles out of all the layers, and then +/// set their positions. This is typically done by using Flame's effects. +class TileStack extends Component implements PositionProvider { + final List _tiles; + + /// The number of tiles in this stack. + int get length => _tiles.length; + + TileStack(this._tiles); + + @override + Vector2 get position => _tiles.first.position; + + @override + set position(Vector2 position) { + for (final tile in _tiles) { + tile.position = position; + } + } +} diff --git a/packages/flame_tiled/lib/src/tile_transform.dart b/packages/flame_tiled/lib/src/tile_transform.dart index c9012cf3c..8d5a14a7b 100644 --- a/packages/flame_tiled/lib/src/tile_transform.dart +++ b/packages/flame_tiled/lib/src/tile_transform.dart @@ -1,13 +1,14 @@ import 'dart:ui'; import 'package:flame/sprite.dart'; +import 'package:flame_tiled/src/mutable_transform.dart'; import 'package:meta/meta.dart'; /// Caches transforms for staggered maps as the row/col are switched. @internal class TileTransform { final Rect source; - final RSTransform transform; + final MutableRSTransform transform; final bool flip; final SpriteBatch batch; diff --git a/packages/flame_tiled/test/assets/test_isometric.tmx b/packages/flame_tiled/test/assets/test_isometric.tmx index 1a7a8b735..42c50d9b7 100644 --- a/packages/flame_tiled/test/assets/test_isometric.tmx +++ b/packages/flame_tiled/test/assets/test_isometric.tmx @@ -1,5 +1,5 @@ - + @@ -8,9 +8,14 @@ AgAAAAIAAAACAAAAAgAAAAIAAAACAAAAAgAAAAQAAAACAAAAAwAAAAMAAAACAAAAAwAAAAIAAAADAAAAAgAAAAIAAAAEAACAAgAAAAMAAAACAAAAAwAAAAIAAAACAAAAAgAAAA== - + AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAACAAAAAAAAAAAAAAAAAAAAAAA== + + + AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA== + + diff --git a/packages/flame_tiled/test/goldens/tile_stack_all_move.png b/packages/flame_tiled/test/goldens/tile_stack_all_move.png new file mode 100644 index 000000000..9f25b485b Binary files /dev/null and b/packages/flame_tiled/test/goldens/tile_stack_all_move.png differ diff --git a/packages/flame_tiled/test/goldens/tile_stack_single_move.png b/packages/flame_tiled/test/goldens/tile_stack_single_move.png new file mode 100644 index 000000000..38e375405 Binary files /dev/null and b/packages/flame_tiled/test/goldens/tile_stack_single_move.png differ diff --git a/packages/flame_tiled/test/tiled_test.dart b/packages/flame_tiled/test/tiled_test.dart index faba7efaf..60324b59e 100644 --- a/packages/flame_tiled/test/tiled_test.dart +++ b/packages/flame_tiled/test/tiled_test.dart @@ -310,8 +310,8 @@ void main() { Future renderMapToPng( TiledComponent component, - int width, - int height, + num width, + num height, ) async { final canvasRecorder = PictureRecorder(); final canvas = Canvas(canvasRecorder); @@ -320,7 +320,7 @@ void main() { // Map size is now 320 wide, but it has 1 extra tile of height becusae // its actually double-height tiles. - final image = await picture.toImageSafe(width, height); + final image = await picture.toImageSafe(width.toInt(), height.toInt()); return (await image.toByteData(format: ImageByteFormat.png))! .buffer .asUint8List(); @@ -624,6 +624,65 @@ void main() { ); }); }); + + group('TileStack', () { + late TiledComponent component; + final size = Vector2(256 / 2, 128 / 2); + + setUp(() async { + Flame.bundle = TestAssetBundle( + imageNames: [ + 'isometric_spritesheet.png', + ], + mapPath: 'test/assets/test_isometric.tmx', + ); + component = await TiledComponent.load('test_isometric.tmx', size); + }); + test('from all layers', () { + var stack = component.tileMap.tileStack(0, 0, all: true); + expect(stack.length, 2); + + stack = component.tileMap.tileStack(1, 0, all: true); + expect(stack.length, 1); + }); + + test('from some layers', () { + var stack = component.tileMap.tileStack(0, 0, named: {'empty'}); + expect(stack.length, 0); + + stack = component.tileMap.tileStack(0, 0, named: {'item'}); + expect(stack.length, 1); + + stack = component.tileMap.tileStack(0, 0, ids: {1}); + expect(stack.length, 1); + + stack = component.tileMap.tileStack(0, 0, ids: {1, 2}); + expect(stack.length, 2); + }); + + test('can be positioned together', () async { + final stack = component.tileMap.tileStack(0, 0, all: true); + stack.position = stack.position + Vector2.all(20); + + final pngData = + await renderMapToPng(component, size.x * 5, size.y * 5 + size.y / 2); + expect( + pngData, + matchesGoldenFile('goldens/tile_stack_all_move.png'), + ); + }); + + test('can be positioned singularly', () async { + final stack = component.tileMap.tileStack(0, 0, named: {'item'}); + stack.position = stack.position + Vector2(-20, 20); + + final pngData = await renderMapToPng(component, size.x * 5, size.y * 5); + expect( + pngData, + matchesGoldenFile('goldens/tile_stack_single_move.png'), + ); + }); + }); } class TestAssetBundle extends CachingAssetBundle {