diff --git a/examples/assets/images/speech-bubble.png b/examples/assets/images/speech-bubble.png new file mode 100644 index 00000000000..aec41dac242 Binary files /dev/null and b/examples/assets/images/speech-bubble.png differ diff --git a/examples/lib/stories/rendering/nine_tile_box_custom_grid_example.dart b/examples/lib/stories/rendering/nine_tile_box_custom_grid_example.dart new file mode 100644 index 00000000000..f978fd45d16 --- /dev/null +++ b/examples/lib/stories/rendering/nine_tile_box_custom_grid_example.dart @@ -0,0 +1,46 @@ +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame/input.dart'; + +class NineTileBoxCustomGridExample extends FlameGame + with TapDetector, DoubleTapDetector { + static const String description = ''' + If you want to create a background for something that can stretch you can + use the `NineTileBox` which is showcased here. In this example a custom + grid is used.\n\n + Tap to make the box bigger and double tap to make it smaller. + '''; + + late NineTileBoxComponent nineTileBoxComponent; + + @override + Future onLoad() async { + final sprite = Sprite(await images.load('speech-bubble.png')); + final boxSize = Vector2.all(300); + final nineTileBox = NineTileBox.withGrid( + sprite, + leftWidth: 31, + rightWidth: 5, + topHeight: 5, + bottomHeight: 21, + ); + add( + nineTileBoxComponent = NineTileBoxComponent( + nineTileBox: nineTileBox, + position: size / 2, + size: boxSize, + anchor: Anchor.center, + ), + ); + } + + @override + void onTap() { + nineTileBoxComponent.scale.scale(1.2); + } + + @override + void onDoubleTap() { + nineTileBoxComponent.scale.scale(0.8); + } +} diff --git a/packages/flame/lib/src/nine_tile_box.dart b/packages/flame/lib/src/nine_tile_box.dart index 69186886e23..f121478292a 100644 --- a/packages/flame/lib/src/nine_tile_box.dart +++ b/packages/flame/lib/src/nine_tile_box.dart @@ -3,6 +3,7 @@ import 'dart:ui'; import 'package:flame/src/extensions/vector2.dart'; import 'package:flame/src/palette.dart'; import 'package:flame/src/sprite.dart'; +import 'package:meta/meta.dart'; /// This allows you to create a rectangle textured with a 9-sliced image. /// @@ -24,7 +25,9 @@ class NineTileBox { /// (optionally used to scale the src image). late int destTileSize; - late final Rect _center; + @visibleForTesting + late Rect center; + late final Rect _dst; /// Creates a nine-box instance. @@ -41,20 +44,125 @@ class NineTileBox { : tileSize = tileSize ?? sprite.src.width ~/ 3 { this.destTileSize = destTileSize ?? this.tileSize; final centerEdge = this.tileSize.toDouble(); - _center = Rect.fromLTWH(centerEdge, centerEdge, centerEdge, centerEdge); + center = Rect.fromLTWH(centerEdge, centerEdge, centerEdge, centerEdge); _dst = Rect.fromLTWH(0, 0, this.destTileSize * 3, this.destTileSize * 3); } + /// Creates a nine-box instance with the specified grid size + /// + /// A nine-box is a grid with 3 rows and 3 columns. The outer-most columns, + /// [leftWidth] and [rightWidth], are a fixed-width. As the nine-box is + /// resized, those columns remain fixed-width and the center column stretches + /// to take up the remaining space. In the same way, the outer-most rows, + /// [topHeight] and [bottomHeight], are a fixed-height. As the nine-box is + /// resized, those rows remain fixed-height and the center row stretches + /// to take up the remaining space. + NineTileBox.withGrid( + this.sprite, { + double leftWidth = 0.0, + double rightWidth = 0.0, + double topHeight = 0.0, + double bottomHeight = 0.0, + }) : tileSize = sprite.src.width ~/ 3 { + destTileSize = tileSize; + center = Rect.fromLTWH(0, 0, sprite.src.width, sprite.src.height); + _dst = Rect.fromLTWH(0, 0, sprite.src.width, sprite.src.height); + setGrid( + leftWidth: leftWidth, + rightWidth: rightWidth, + topHeight: topHeight, + bottomHeight: bottomHeight, + ); + } + + /// Set different sizes for each of the fixed size rows and columns + /// + /// A nine-box is a grid with 3 rows and 3 columns. The outer-most columns, + /// [leftWidth] and [rightWidth], are a fixed-width. As the nine-box is + /// resized, those columns remain fixed-width and the center column stretches + /// to take up the remaining space. In the same way, the outer-most rows, + /// [topHeight] and [bottomHeight], are a fixed-height. As the nine-box is + /// resized, those rows remain fixed-height and the center row stretches + /// to take up the remaining space. + /// + /// Any widths or heights that are not specified remain unchanged. + void setGrid({ + double? leftWidth, + double? rightWidth, + double? topHeight, + double? bottomHeight, + }) { + if (leftWidth != null && rightWidth != null) { + assert( + leftWidth + rightWidth <= sprite.src.width, + 'The left and right columns ($leftWidth + $rightWidth) do ' + 'not fit in the width of the sprite (${sprite.src.width})', + ); + } else if (leftWidth != null) { + assert( + leftWidth <= center.right, + 'The left column ($leftWidth) is too large ' + '(max ${center.right})', + ); + } else if (rightWidth != null) { + assert( + rightWidth + center.left <= sprite.src.width, + 'The right column ($rightWidth) is too large ' + '(max ${sprite.src.width - center.left})', + ); + } + if (topHeight != null && bottomHeight != null) { + assert( + topHeight + bottomHeight <= sprite.src.height, + 'The top and bottom rows ($topHeight + $bottomHeight) do not fit ' + 'in the height of the sprite (${sprite.src.height})', + ); + } else if (topHeight != null) { + assert( + topHeight <= center.bottom, + 'The top row ($topHeight) is too large ' + '(max ${center.bottom})', + ); + } else if (bottomHeight != null) { + assert( + bottomHeight + center.top <= sprite.src.height, + 'The bottom row ($bottomHeight) is too large ' + '(max ${sprite.src.height - center.top})', + ); + } + + final left = leftWidth ?? center.left; + final top = topHeight ?? center.top; + late final double right; + if (rightWidth == null) { + right = center.right; + } else { + right = sprite.src.width - rightWidth; + } + late final double bottom; + if (bottomHeight == null) { + bottom = center.bottom; + } else { + bottom = sprite.src.height - bottomHeight; + } + center = Rect.fromLTRB( + left, + top, + right, + bottom, + ); + } + /// Renders this nine box with the dimensions provided by [dst]. void drawRect(Canvas c, [Rect? dst]) { - c.drawImageNine(sprite.image, _center, dst ?? _dst, _whitePaint); + c.drawImageNine(sprite.image, center, dst ?? _dst, _whitePaint); } /// Renders this nine box as a rectangle at [position] with size [size]. void draw(Canvas c, Vector2 position, Vector2 size) { c.drawImageNine( sprite.image, - _center, + center, Rect.fromLTWH(position.x, position.y, size.x, size.y), _whitePaint, ); diff --git a/packages/flame/test/_goldens/nine_tile_box_test_1.png b/packages/flame/test/_goldens/nine_tile_box_test_1.png new file mode 100644 index 00000000000..03a8580f769 Binary files /dev/null and b/packages/flame/test/_goldens/nine_tile_box_test_1.png differ diff --git a/packages/flame/test/_goldens/nine_tile_box_test_2.png b/packages/flame/test/_goldens/nine_tile_box_test_2.png new file mode 100644 index 00000000000..a3f40af8fbd Binary files /dev/null and b/packages/flame/test/_goldens/nine_tile_box_test_2.png differ diff --git a/packages/flame/test/_resources/speech-bubble-1.png b/packages/flame/test/_resources/speech-bubble-1.png new file mode 100644 index 00000000000..1f0f808c933 Binary files /dev/null and b/packages/flame/test/_resources/speech-bubble-1.png differ diff --git a/packages/flame/test/_resources/speech-bubble-2.png b/packages/flame/test/_resources/speech-bubble-2.png new file mode 100644 index 00000000000..aec41dac242 Binary files /dev/null and b/packages/flame/test/_resources/speech-bubble-2.png differ diff --git a/packages/flame/test/nine_tile_box_test.dart b/packages/flame/test/nine_tile_box_test.dart new file mode 100644 index 00000000000..e025751a549 --- /dev/null +++ b/packages/flame/test/nine_tile_box_test.dart @@ -0,0 +1,112 @@ +import 'dart:ui'; + +import 'package:flame/components.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '_resources/load_image.dart'; + +void main() { + group('NineTileBox', () { + testGolden( + 'Render with default grid', + (game) async { + game.add(_MyComponent1()); + }, + size: Vector2(300, 200), + goldenFile: '_goldens/nine_tile_box_test_1.png', + ); + + testGolden( + 'Render with specified grid', + (game) async { + game.add(_MyComponent2()); + }, + size: Vector2(300, 200), + goldenFile: '_goldens/nine_tile_box_test_2.png', + ); + + test('default tile sizes calculated correctly', () async { + final sprite = Sprite(await loadImage('speech-bubble-1.png')); + final nineTileBox = NineTileBox(sprite); + + expect(nineTileBox.tileSize, equals(30)); + expect(nineTileBox.destTileSize, equals(30)); + }); + + test('tile sizes set correctly', () async { + final sprite = Sprite(await loadImage('speech-bubble-1.png')); + final nineTileBox = NineTileBox(sprite, tileSize: 20, destTileSize: 25); + + expect(nineTileBox.tileSize, equals(20)); + expect(nineTileBox.destTileSize, equals(25)); + }); + + test('grid sizes set correctly', () async { + final sprite = Sprite(await loadImage('speech-bubble-2.png')); + final nineTileBox = NineTileBox.withGrid( + sprite, + leftWidth: 31, + rightWidth: 5, + topHeight: 5, + bottomHeight: 21, + ); + + expect(nineTileBox.center.left, equals(31.0)); + expect(nineTileBox.center.right, equals(34.0)); + expect(nineTileBox.center.top, equals(5.0)); + expect(nineTileBox.center.bottom, equals(18.0)); + }); + }); +} + +class _MyComponent1 extends PositionComponent { + _MyComponent1() : super(size: Vector2(300, 200)); + late final Sprite sprite; + late final NineTileBox nineTileBox; + final bgPaint = Paint()..color = const Color.fromARGB(255, 57, 113, 158); + + @override + Future onLoad() async { + sprite = Sprite(await loadImage('speech-bubble-1.png')); + nineTileBox = NineTileBox(sprite); + } + + @override + void render(Canvas canvas) { + canvas.drawRect( + size.toRect(), + bgPaint, + ); + nineTileBox.draw(canvas, Vector2(25, 25), Vector2(250, 150)); + } +} + +class _MyComponent2 extends PositionComponent { + _MyComponent2() : super(size: Vector2(300, 200)); + late final Sprite sprite; + late final NineTileBox nineTileBox; + final bgPaint = Paint()..color = const Color.fromARGB(255, 57, 113, 158); + + @override + Future onLoad() async { + sprite = Sprite(await loadImage('speech-bubble-2.png')); + nineTileBox = NineTileBox.withGrid( + sprite, + leftWidth: 31, + rightWidth: 5, + topHeight: 5, + bottomHeight: 21, + ); + } + + @override + void render(Canvas canvas) { + canvas.drawRect( + size.toRect(), + bgPaint, + ); + nineTileBox.draw(canvas, Vector2(25, 25), Vector2(250, 150)); + } +}