From 477221998a272bf659cd86d2bf145adf0f277e65 Mon Sep 17 00:00:00 2001 From: Jochum van der Ploeg Date: Thu, 21 Sep 2023 11:34:02 +0200 Subject: [PATCH] feat(flame): Add helper methods to create frame data on `SpriteSheet` (#2754) Add two methods to SpriteSheet to create frame data for SpriteAnimation --- doc/flame/rendering/images.md | 26 +++++-- doc/flame/rendering/particles.md | 4 +- doc/flame/rendering/text_rendering.md | 4 +- doc/tutorials/klondike/step1.md | 6 +- doc/tutorials/klondike/step2.md | 6 +- doc/tutorials/klondike/step3.md | 4 +- doc/tutorials/platformer/step_1.md | 4 +- .../{spritesheet.png => sprite_sheet.png} | Bin .../stories/rendering/particles_example.dart | 4 +- ...example.dart => sprite_sheet_example.dart} | 33 ++++++--- examples/lib/stories/sprites/sprites.dart | 10 +-- packages/flame/lib/sprite.dart | 2 +- .../isometric_tile_map_component.dart | 2 +- .../{spritesheet.dart => sprite_sheet.dart} | 28 ++++++++ .../input/sprite_button_component_test.dart | 2 +- packages/flame/test/spritesheet_test.dart | 64 ++++++++++++++++++ 16 files changed, 160 insertions(+), 39 deletions(-) rename examples/assets/images/{spritesheet.png => sprite_sheet.png} (100%) rename examples/lib/stories/sprites/{spritesheet_example.dart => sprite_sheet_example.dart} (68%) rename packages/flame/lib/src/{spritesheet.dart => sprite_sheet.dart} (82%) diff --git a/doc/flame/rendering/images.md b/doc/flame/rendering/images.md index 4ff999566..760445851 100644 --- a/doc/flame/rendering/images.md +++ b/doc/flame/rendering/images.md @@ -331,21 +331,37 @@ a very simple example of how to use it: ```dart import 'package:flame/sprite.dart'; -final spritesheet = SpriteSheet( +final spriteSheet = SpriteSheet( image: imageInstance, srcSize: Vector2.all(16.0), ); -final animation = spritesheet.createAnimation(0, stepTime: 0.1); +final animation = spriteSheet.createAnimation(0, stepTime: 0.1); ``` Now you can use the animation directly or use it in an animation component. -You can also get a single frame of the sprite sheet using the `getSprite` method: +You can also create a custom animation by retrieving individual `SpriteAnimationFrameData` using +either `SpriteSheet.createFrameData` or `SpriteSheet.createFrameDataFromId`: ```dart -spritesheet.getSprite(0, 0) // row, column; +final animation = SpriteAnimation.fromFrameData( + imageInstance, + SpriteAnimationData([ + spriteSheet.createFrameDataFromId(1, stepTime: 0.1), // by id + spriteSheet.createFrameData(2, 3, stepTime: 0.3), // row, column + spriteSheet.createFrameDataFromId(4, stepTime: 0.1), // by id + ]), +); +``` + +If you don't need any kind of animation and instead only want an instance of a `Sprite` on the +`SpriteSheet` you can use the `getSprite` or `getSpriteById` methods: + +```dart +spriteSheet.getSpriteById(2); // by id +spriteSheet.getSprite(0, 0); // row, column ``` You can see a full example of the `SpriteSheet` class -[here](https://github.com/flame-engine/flame/blob/main/examples/lib/stories/sprites/spritesheet_example.dart). +[here](https://github.com/flame-engine/flame/blob/main/examples/lib/stories/sprites/sprite_sheet_example.dart). diff --git a/doc/flame/rendering/particles.md b/doc/flame/rendering/particles.md index ec93b27a4..a87cc8813 100644 --- a/doc/flame/rendering/particles.md +++ b/doc/flame/rendering/particles.md @@ -308,7 +308,7 @@ it's fully played during the `Particle` lifespan. It's possible to override this `alignAnimationTime` argument. ```dart -final spritesheet = SpriteSheet( +final spriteSheet = SpriteSheet( image: yourSpriteSheetImage, srcSize: Vector2.all(16.0), ); @@ -316,7 +316,7 @@ final spritesheet = SpriteSheet( game.add( ParticleSystemComponent( particle: SpriteAnimationParticle( - animation: spritesheet.createAnimation(0, stepTime: 0.1), + animation: spriteSheet.createAnimation(0, stepTime: 0.1), ); ), ); diff --git a/doc/flame/rendering/text_rendering.md b/doc/flame/rendering/text_rendering.md index 6fb8d6aaf..b40de3f25 100644 --- a/doc/flame/rendering/text_rendering.md +++ b/doc/flame/rendering/text_rendering.md @@ -261,7 +261,7 @@ generate a `TextElement`. Flame provides two concrete implementations: - `TextPaint`: most used, uses Flutter `TextPainter` to render regular text -- `SpriteFontRenderer`: uses a `SpriteFont` (a spritesheet-based font) to render bitmap text +- `SpriteFontRenderer`: uses a `SpriteFont` (a sprite sheet-based font) to render bitmap text - `DebugTextRenderer`: only intended to be used for Golden Tests But you can also provide your own if you want to extend to other customized forms of text rendering. @@ -340,7 +340,7 @@ Palette](palette.md) guide. #### SpriteFontRenderer The other renderer option provided out of the box is `SpriteFontRenderer`, which allows you to -provide a `SpriteFont` based off of a spritesheet. TODO +provide a `SpriteFont` based off of a sprite sheet. TODO #### DebugTextRenderer diff --git a/doc/tutorials/klondike/step1.md b/doc/tutorials/klondike/step1.md index 3cfb95564..5b4cc30d9 100644 --- a/doc/tutorials/klondike/step1.md +++ b/doc/tutorials/klondike/step1.md @@ -75,14 +75,14 @@ klondike/ └─pubspec.yaml ``` -By the way, this kind of file is called the **spritesheet**: it's just a +By the way, this kind of file is called the **sprite sheet**: it's just a collection of multiple independent images in a single file. We are using a -spritesheet here for the simple reason that loading a single large image is +sprite sheet here for the simple reason that loading a single large image is faster than many small images. In addition, rendering sprites that were extracted from a single source image can be faster too, since Flutter will optimize multiple such drawing commands into a single `drawAtlas` command. -Here are the contents of my spritesheet: +Here are the contents of my sprite sheet: - Numerals 2, 3, 4, ..., K, A. In theory, we could have rendered these in the game as text strings, but then we would need to also include a font as an diff --git a/doc/tutorials/klondike/step2.md b/doc/tutorials/klondike/step2.md index b5c013c93..bda08bada 100644 --- a/doc/tutorials/klondike/step2.md +++ b/doc/tutorials/klondike/step2.md @@ -39,9 +39,9 @@ have been more difficult to access that image from other classes. Also note that I am `await`ing the image to finish loading before initializing anything else in the game. This is for convenience: it means that by the time -all other components are initialized, they can assume the spritesheet is already +all other components are initialized, they can assume the sprite sheet is already loaded. We can even add a helper function to extract a sprite from the common -spritesheet: +sprite sheet: ```dart Sprite klondikeSprite(double x, double y, double width, double height) { @@ -193,7 +193,7 @@ not planning to change these values during the game: Next, we will create a `Stock` component, the `Waste`, four `Foundation`s and seven `Pile`s, setting their sizes and positions in the world. The positions are calculated using simple arithmetics. This should all happen inside the -`onLoad` method, after loading the spritesheet: +`onLoad` method, after loading the sprite sheet: ```dart final stock = Stock() diff --git a/doc/tutorials/klondike/step3.md b/doc/tutorials/klondike/step3.md index 3669ad6aa..33ea04193 100644 --- a/doc/tutorials/klondike/step3.md +++ b/doc/tutorials/klondike/step3.md @@ -71,9 +71,9 @@ after the image is loaded into the cache. ``` The last four numbers in the constructor are the coordinates of the sprite -image within the spritesheet `klondike-sprites.png`. If you're wondering how I +image within the sprite sheet `klondike-sprites.png`. If you're wondering how I obtained these numbers, the answer is that I used a free online service -[spritecow.com] -- it's a handy tool for locating sprites within a spritesheet. +[spritecow.com] -- it's a handy tool for locating sprites within a sprite sheet. Lastly, I have simple getters to determine the "color" of a suit. This will be needed later when we need to enforce the rule that cards can only be placed diff --git a/doc/tutorials/platformer/step_1.md b/doc/tutorials/platformer/step_1.md index 21f79fc67..a8afc90ce 100644 --- a/doc/tutorials/platformer/step_1.md +++ b/doc/tutorials/platformer/step_1.md @@ -52,10 +52,10 @@ provide free pixel art that can be used in games, but please check and comply wi always provide valid creator attribution. For this game though, I am going to take a chance and make my artwork using an online pixel art tool. If you decide to use this tool, multiple online tutorials will assist you with the basic operations as well as exporting the assets. Now normally, -most games will utilize spritesheets. These combine many images into one larger image that can be +most games will utilize sprite sheets. These combine many images into one larger image that can be sectioned and used as individual images. For this tutorial though, I specifically will save the images individually as I want to demonstrate the Flame engine's caching abilities. Ember and the -water enemy are spritesheets though as they contain multiple images to create animations. +water enemy are sprite sheets though as they contain multiple images to create animations. Right-click the images below, choose "Save as...", and store them in the `assets/images` folder of the project. At this point our project's structure looks like this: diff --git a/examples/assets/images/spritesheet.png b/examples/assets/images/sprite_sheet.png similarity index 100% rename from examples/assets/images/spritesheet.png rename to examples/assets/images/sprite_sheet.png diff --git a/examples/lib/stories/rendering/particles_example.dart b/examples/lib/stories/rendering/particles_example.dart index f3dd15f66..9f86685bd 100644 --- a/examples/lib/stories/rendering/particles_example.dart +++ b/examples/lib/stories/rendering/particles_example.dart @@ -501,12 +501,12 @@ class ParticlesExample extends FlameGame { const rows = 8; const frames = columns * rows; final spriteImage = images.fromCache('boom.png'); - final spritesheet = SpriteSheet.fromColumnsAndRows( + final spriteSheet = SpriteSheet.fromColumnsAndRows( image: spriteImage, columns: columns, rows: rows, ); - final sprites = List.generate(frames, spritesheet.getSpriteById); + final sprites = List.generate(frames, spriteSheet.getSpriteById); return SpriteAnimation.spriteList(sprites, stepTime: 0.1); } } diff --git a/examples/lib/stories/sprites/spritesheet_example.dart b/examples/lib/stories/sprites/sprite_sheet_example.dart similarity index 68% rename from examples/lib/stories/sprites/spritesheet_example.dart rename to examples/lib/stories/sprites/sprite_sheet_example.dart index 640805f05..86c592acc 100644 --- a/examples/lib/stories/sprites/spritesheet_example.dart +++ b/examples/lib/stories/sprites/sprite_sheet_example.dart @@ -2,7 +2,7 @@ import 'package:flame/components.dart'; import 'package:flame/game.dart'; import 'package:flame/sprite.dart'; -class SpritesheetExample extends FlameGame { +class SpriteSheetExample extends FlameGame { static const String description = ''' In this example we show how to load images and how to create animations from sprite sheets. @@ -11,7 +11,7 @@ class SpritesheetExample extends FlameGame { @override Future onLoad() async { final spriteSheet = SpriteSheet( - image: await images.load('spritesheet.png'), + image: await images.load('sprite_sheet.png'), srcSize: Vector2(16.0, 18.0), ); @@ -28,6 +28,19 @@ class SpritesheetExample extends FlameGame { stepTimes: [0.1, 0.1, 0.3, 0.3, 0.5, 0.3, 0.1], ); + final customVampireAnimation = SpriteAnimation.fromFrameData( + spriteSheet.image, + SpriteAnimationData([ + spriteSheet.createFrameData(0, 0, stepTime: 0.1), + spriteSheet.createFrameData(0, 1, stepTime: 0.1), + spriteSheet.createFrameData(0, 2, stepTime: 0.3), + spriteSheet.createFrameDataFromId(4, stepTime: 0.3), + spriteSheet.createFrameDataFromId(5, stepTime: 0.5), + spriteSheet.createFrameDataFromId(6, stepTime: 0.3), + spriteSheet.createFrameDataFromId(7, stepTime: 0.1), + ]), + ); + final spriteSize = Vector2(80.0, 90.0); final vampireComponent = SpriteAnimationComponent( @@ -44,13 +57,20 @@ class SpritesheetExample extends FlameGame { final ghostAnimationVariableStepTimesComponent = SpriteAnimationComponent( animation: ghostAnimationVariableStepTimes, - position: Vector2(150, 340), + position: Vector2(250, 220), + size: spriteSize, + ); + + final customVampireComponent = SpriteAnimationComponent( + animation: customVampireAnimation, + position: Vector2(250, 100), size: spriteSize, ); add(vampireComponent); add(ghostComponent); add(ghostAnimationVariableStepTimesComponent); + add(customVampireComponent); // Some plain sprites final vampireSpriteComponent = SpriteComponent( @@ -65,14 +85,7 @@ class SpritesheetExample extends FlameGame { position: Vector2(50, 220), ); - final ghostVariableSpriteComponent = SpriteComponent( - sprite: spriteSheet.getSprite(1, 0), - size: spriteSize, - position: Vector2(50, 340), - ); - add(vampireSpriteComponent); add(ghostSpriteComponent); - add(ghostVariableSpriteComponent); } } diff --git a/examples/lib/stories/sprites/sprites.dart b/examples/lib/stories/sprites/sprites.dart index 365f4500f..e97027a16 100644 --- a/examples/lib/stories/sprites/sprites.dart +++ b/examples/lib/stories/sprites/sprites.dart @@ -5,7 +5,7 @@ import 'package:examples/stories/sprites/basic_sprite_example.dart'; import 'package:examples/stories/sprites/sprite_batch_example.dart'; import 'package:examples/stories/sprites/sprite_batch_load_example.dart'; import 'package:examples/stories/sprites/sprite_group_example.dart'; -import 'package:examples/stories/sprites/spritesheet_example.dart'; +import 'package:examples/stories/sprites/sprite_sheet_example.dart'; import 'package:flame/game.dart'; void addSpritesStories(Dashbook dashbook) { @@ -23,10 +23,10 @@ void addSpritesStories(Dashbook dashbook) { info: Base64SpriteExample.description, ) ..add( - 'Spritesheet', - (_) => GameWidget(game: SpritesheetExample()), - codeLink: baseLink('sprites/spritesheet_example.dart'), - info: SpritesheetExample.description, + 'SpriteSheet', + (_) => GameWidget(game: SpriteSheetExample()), + codeLink: baseLink('sprites/sprite_sheet_example.dart'), + info: SpriteSheetExample.description, ) ..add( 'SpriteBatch', diff --git a/packages/flame/lib/sprite.dart b/packages/flame/lib/sprite.dart index 0012ca397..9bb914b13 100644 --- a/packages/flame/lib/sprite.dart +++ b/packages/flame/lib/sprite.dart @@ -7,4 +7,4 @@ export 'src/sprite.dart'; export 'src/sprite_animation.dart'; export 'src/sprite_animation_ticker.dart'; export 'src/sprite_batch.dart' hide FlippedAtlasStatus; -export 'src/spritesheet.dart'; +export 'src/sprite_sheet.dart'; diff --git a/packages/flame/lib/src/components/isometric_tile_map_component.dart b/packages/flame/lib/src/components/isometric_tile_map_component.dart index 51ea6c47b..4c1a3cbae 100644 --- a/packages/flame/lib/src/components/isometric_tile_map_component.dart +++ b/packages/flame/lib/src/components/isometric_tile_map_component.dart @@ -1,7 +1,7 @@ import 'dart:ui'; import 'package:flame/components.dart'; -import 'package:flame/src/spritesheet.dart'; +import 'package:flame/src/sprite_sheet.dart'; import 'package:meta/meta.dart'; /// This is just a pair of . diff --git a/packages/flame/lib/src/spritesheet.dart b/packages/flame/lib/src/sprite_sheet.dart similarity index 82% rename from packages/flame/lib/src/spritesheet.dart rename to packages/flame/lib/src/sprite_sheet.dart index 0dd89880d..c1f2d1200 100644 --- a/packages/flame/lib/src/spritesheet.dart +++ b/packages/flame/lib/src/sprite_sheet.dart @@ -68,6 +68,34 @@ class SpriteSheet { return _spriteCache[spriteId] ??= _computeSprite(spriteId); } + /// Create a [SpriteAnimationFrameData] for the sprite in the position + /// (row, column) on the sprite sheet grid. + SpriteAnimationFrameData createFrameData( + int row, + int column, { + required double stepTime, + }) { + return createFrameDataFromId(row * columns + column, stepTime: stepTime); + } + + /// Create a [SpriteAnimationFrameData] for the sprite with id [spriteId] + /// from the grid. + /// + /// The ids are defined as starting at 0 on the top left and going + /// sequentially on each row. + SpriteAnimationFrameData createFrameDataFromId( + int spriteId, { + required double stepTime, + }) { + final i = spriteId % columns; + final j = spriteId ~/ columns; + return SpriteAnimationFrameData( + srcPosition: Vector2Extension.fromInts(i, j)..multiply(srcSize), + srcSize: srcSize, + stepTime: stepTime, + ); + } + Sprite _computeSprite(int spriteId) { final i = spriteId % columns; final j = spriteId ~/ columns; diff --git a/packages/flame/test/components/input/sprite_button_component_test.dart b/packages/flame/test/components/input/sprite_button_component_test.dart index 96b7b1f69..134279a4a 100644 --- a/packages/flame/test/components/input/sprite_button_component_test.dart +++ b/packages/flame/test/components/input/sprite_button_component_test.dart @@ -5,7 +5,7 @@ import 'package:flame/input.dart'; import 'package:flame/src/anchor.dart'; import 'package:flame/src/components/sprite_group_component.dart'; import 'package:flame/src/events/flame_game_mixins/multi_tap_dispatcher.dart'; -import 'package:flame/src/spritesheet.dart'; +import 'package:flame/src/sprite_sheet.dart'; import 'package:flame_test/flame_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/packages/flame/test/spritesheet_test.dart b/packages/flame/test/spritesheet_test.dart index 09407ad1c..fa9e3424d 100644 --- a/packages/flame/test/spritesheet_test.dart +++ b/packages/flame/test/spritesheet_test.dart @@ -54,5 +54,69 @@ void main() { ); }, ); + + test('return sprite based on row and column', () { + final spriteSheet = SpriteSheet( + image: image, + srcSize: Vector2(50, 50), + ); + + expect( + spriteSheet.getSprite(1, 1), + isA().having( + (sprite) => sprite.srcPosition, + 'srcPosition', + equals(Vector2(50, 50)), + ), + ); + }); + + test('return sprite based on id', () { + final spriteSheet = SpriteSheet( + image: image, + srcSize: Vector2(50, 50), + ); + + expect( + spriteSheet.getSpriteById(3), + isA().having( + (sprite) => sprite.srcPosition, + 'srcPosition', + equals(Vector2(50, 50)), + ), + ); + }); + + test('create sprite animation frame data based on row and column', () { + final spriteSheet = SpriteSheet( + image: image, + srcSize: Vector2(50, 50), + ); + + expect( + spriteSheet.createFrameData(1, 1, stepTime: 0.1), + isA().having( + (frame) => frame.srcPosition, + 'srcPosition', + equals(Vector2(50, 50)), + ), + ); + }); + + test('create sprite animation frame data based on id', () { + final spriteSheet = SpriteSheet( + image: image, + srcSize: Vector2(50, 50), + ); + + expect( + spriteSheet.createFrameDataFromId(3, stepTime: 0.1), + isA().having( + (frame) => frame.srcPosition, + 'srcPosition', + equals(Vector2(50, 50)), + ), + ); + }); }); }