diff --git a/CHANGELOG.md b/CHANGELOG.md index a5c3d3e..87d49f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Added atlas source-region and nine-slice image rendering fields for image-capable nodes. - Added Runtime text shadow fields for text-capable nodes. - Fixed Runtime node color alpha composition so `#AARRGGBB` alpha now multiplies with node/runtime alpha instead of being overwritten. diff --git a/docs/protocol.md b/docs/protocol.md index f0640fb..daee08c 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -54,6 +54,21 @@ Text-capable nodes may use flat shadow fields: The shadow color alpha is multiplied by `RuntimeNode.alpha` and any runtime animation alpha. +### Image regions and nine-slice + +Image-capable nodes (`image`, `sprite`, and image-backed `button`) may draw only part of an asset by setting source region fields in image pixels: + +- `sourceX` / `sourceY`: top-left source position inside the loaded image. +- `sourceWidth` / `sourceHeight`: source region size. + +When source fields are omitted, the full image is used. This supports atlas-style usage where multiple sprites are packed into one image and nodes reference different source rectangles. + +Image-capable nodes may also use nine-slice scaling with source-pixel insets: + +- `sliceLeft` / `sliceTop` / `sliceRight` / `sliceBottom`. + +Nine-slice keeps corners unscaled, stretches edges on one axis, and stretches the center on both axes. Insets are clamped to the selected source region and destination size. + ## RuntimeCommand Runtime commands request generic side effects owned by Dart/Flame. diff --git a/example/assets/games/flight/scripts/runtime_defs.lua b/example/assets/games/flight/scripts/runtime_defs.lua index 994f9dc..8ddf3d5 100644 --- a/example/assets/games/flight/scripts/runtime_defs.lua +++ b/example/assets/games/flight/scripts/runtime_defs.lua @@ -86,6 +86,14 @@ ---@field type RuntimeNodeType ---@field parent? string ---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image. +---@field sourceX? number Source atlas region x in image pixels. +---@field sourceY? number Source atlas region y in image pixels. +---@field sourceWidth? number Source atlas region width in image pixels. +---@field sourceHeight? number Source atlas region height in image pixels. +---@field sliceLeft? number Left nine-slice inset in source pixels. +---@field sliceTop? number Top nine-slice inset in source pixels. +---@field sliceRight? number Right nine-slice inset in source pixels. +---@field sliceBottom? number Bottom nine-slice inset in source pixels. ---@field pressedAsset? string Button pressed-state image asset key. ---@field disabledAsset? string Button disabled-state image asset key. ---@field animation? string @@ -147,6 +155,14 @@ ---@field type? RuntimeNodeType ---@field parent? string ---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image. +---@field sourceX? number Source atlas region x in image pixels. +---@field sourceY? number Source atlas region y in image pixels. +---@field sourceWidth? number Source atlas region width in image pixels. +---@field sourceHeight? number Source atlas region height in image pixels. +---@field sliceLeft? number Left nine-slice inset in source pixels. +---@field sliceTop? number Top nine-slice inset in source pixels. +---@field sliceRight? number Right nine-slice inset in source pixels. +---@field sliceBottom? number Bottom nine-slice inset in source pixels. ---@field pressedAsset? string Button pressed-state image asset key. ---@field disabledAsset? string Button disabled-state image asset key. ---@field animation? string diff --git a/example/assets/games/ludo/scripts/runtime_defs.lua b/example/assets/games/ludo/scripts/runtime_defs.lua index 443b779..d5be9db 100644 --- a/example/assets/games/ludo/scripts/runtime_defs.lua +++ b/example/assets/games/ludo/scripts/runtime_defs.lua @@ -86,6 +86,14 @@ ---@field type RuntimeNodeType ---@field parent? string ---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image. +---@field sourceX? number Source atlas region x in image pixels. +---@field sourceY? number Source atlas region y in image pixels. +---@field sourceWidth? number Source atlas region width in image pixels. +---@field sourceHeight? number Source atlas region height in image pixels. +---@field sliceLeft? number Left nine-slice inset in source pixels. +---@field sliceTop? number Top nine-slice inset in source pixels. +---@field sliceRight? number Right nine-slice inset in source pixels. +---@field sliceBottom? number Bottom nine-slice inset in source pixels. ---@field pressedAsset? string Button pressed-state image asset key. ---@field disabledAsset? string Button disabled-state image asset key. ---@field animation? string @@ -147,6 +155,14 @@ ---@field type? RuntimeNodeType ---@field parent? string ---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image. +---@field sourceX? number Source atlas region x in image pixels. +---@field sourceY? number Source atlas region y in image pixels. +---@field sourceWidth? number Source atlas region width in image pixels. +---@field sourceHeight? number Source atlas region height in image pixels. +---@field sliceLeft? number Left nine-slice inset in source pixels. +---@field sliceTop? number Top nine-slice inset in source pixels. +---@field sliceRight? number Right nine-slice inset in source pixels. +---@field sliceBottom? number Bottom nine-slice inset in source pixels. ---@field pressedAsset? string Button pressed-state image asset key. ---@field disabledAsset? string Button disabled-state image asset key. ---@field animation? string diff --git a/example/assets/games/showcase/scripts/runtime_defs.lua b/example/assets/games/showcase/scripts/runtime_defs.lua index 35016f4..5bec283 100644 --- a/example/assets/games/showcase/scripts/runtime_defs.lua +++ b/example/assets/games/showcase/scripts/runtime_defs.lua @@ -86,6 +86,14 @@ ---@field type RuntimeNodeType ---@field parent? string ---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image. +---@field sourceX? number Source atlas region x in image pixels. +---@field sourceY? number Source atlas region y in image pixels. +---@field sourceWidth? number Source atlas region width in image pixels. +---@field sourceHeight? number Source atlas region height in image pixels. +---@field sliceLeft? number Left nine-slice inset in source pixels. +---@field sliceTop? number Top nine-slice inset in source pixels. +---@field sliceRight? number Right nine-slice inset in source pixels. +---@field sliceBottom? number Bottom nine-slice inset in source pixels. ---@field pressedAsset? string Button pressed-state image asset key. ---@field disabledAsset? string Button disabled-state image asset key. ---@field animation? string @@ -147,6 +155,14 @@ ---@field type? RuntimeNodeType ---@field parent? string ---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image. +---@field sourceX? number Source atlas region x in image pixels. +---@field sourceY? number Source atlas region y in image pixels. +---@field sourceWidth? number Source atlas region width in image pixels. +---@field sourceHeight? number Source atlas region height in image pixels. +---@field sliceLeft? number Left nine-slice inset in source pixels. +---@field sliceTop? number Top nine-slice inset in source pixels. +---@field sliceRight? number Right nine-slice inset in source pixels. +---@field sliceBottom? number Bottom nine-slice inset in source pixels. ---@field pressedAsset? string Button pressed-state image asset key. ---@field disabledAsset? string Button disabled-state image asset key. ---@field animation? string diff --git a/example/assets/games/template/scripts/runtime_defs.lua b/example/assets/games/template/scripts/runtime_defs.lua index e2a852c..49d89fd 100644 --- a/example/assets/games/template/scripts/runtime_defs.lua +++ b/example/assets/games/template/scripts/runtime_defs.lua @@ -86,6 +86,14 @@ ---@field type RuntimeNodeType ---@field parent? string ---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image. +---@field sourceX? number Source atlas region x in image pixels. +---@field sourceY? number Source atlas region y in image pixels. +---@field sourceWidth? number Source atlas region width in image pixels. +---@field sourceHeight? number Source atlas region height in image pixels. +---@field sliceLeft? number Left nine-slice inset in source pixels. +---@field sliceTop? number Top nine-slice inset in source pixels. +---@field sliceRight? number Right nine-slice inset in source pixels. +---@field sliceBottom? number Bottom nine-slice inset in source pixels. ---@field pressedAsset? string Button pressed-state image asset key. ---@field disabledAsset? string Button disabled-state image asset key. ---@field animation? string @@ -147,6 +155,14 @@ ---@field type? RuntimeNodeType ---@field parent? string ---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image. +---@field sourceX? number Source atlas region x in image pixels. +---@field sourceY? number Source atlas region y in image pixels. +---@field sourceWidth? number Source atlas region width in image pixels. +---@field sourceHeight? number Source atlas region height in image pixels. +---@field sliceLeft? number Left nine-slice inset in source pixels. +---@field sliceTop? number Top nine-slice inset in source pixels. +---@field sliceRight? number Right nine-slice inset in source pixels. +---@field sliceBottom? number Bottom nine-slice inset in source pixels. ---@field pressedAsset? string Button pressed-state image asset key. ---@field disabledAsset? string Button disabled-state image asset key. ---@field animation? string diff --git a/lib/runtime/models/runtime_node.dart b/lib/runtime/models/runtime_node.dart index 4bded4f..0e7a91e 100644 --- a/lib/runtime/models/runtime_node.dart +++ b/lib/runtime/models/runtime_node.dart @@ -8,6 +8,14 @@ class RuntimeNode { required this.type, this.parent, this.asset, + this.sourceX, + this.sourceY, + this.sourceWidth, + this.sourceHeight, + this.sliceLeft, + this.sliceTop, + this.sliceRight, + this.sliceBottom, this.pressedAsset, this.disabledAsset, this.animation, @@ -70,6 +78,14 @@ class RuntimeNode { final String type; final String? parent; final String? asset; + final double? sourceX; + final double? sourceY; + final double? sourceWidth; + final double? sourceHeight; + final double? sliceLeft; + final double? sliceTop; + final double? sliceRight; + final double? sliceBottom; final String? pressedAsset; final String? disabledAsset; final String? animation; @@ -213,6 +229,26 @@ class RuntimeNode { type: nextType, parent: _parentProp(props, currentParent: parent, nodeId: id), asset: _stringProp(props, RuntimeProtocolField.asset) ?? asset, + sourceX: _nonNegativeDoubleProp(props, RuntimeProtocolField.sourceX) ?? sourceX, + sourceY: _nonNegativeDoubleProp(props, RuntimeProtocolField.sourceY) ?? sourceY, + sourceWidth: + _positiveDoubleProp(props, RuntimeProtocolField.sourceWidth) ?? + sourceWidth, + sourceHeight: + _positiveDoubleProp(props, RuntimeProtocolField.sourceHeight) ?? + sourceHeight, + sliceLeft: + _nonNegativeDoubleProp(props, RuntimeProtocolField.sliceLeft) ?? + sliceLeft, + sliceTop: + _nonNegativeDoubleProp(props, RuntimeProtocolField.sliceTop) ?? + sliceTop, + sliceRight: + _nonNegativeDoubleProp(props, RuntimeProtocolField.sliceRight) ?? + sliceRight, + sliceBottom: + _nonNegativeDoubleProp(props, RuntimeProtocolField.sliceBottom) ?? + sliceBottom, pressedAsset: _stringProp(props, RuntimeProtocolField.pressedAsset) ?? pressedAsset, disabledAsset: @@ -345,6 +381,20 @@ class RuntimeNode { nodeId: _requiredString(map, RuntimeProtocolField.id), ), asset: _stringProp(map, RuntimeProtocolField.asset), + sourceX: _nonNegativeDoubleProp(map, RuntimeProtocolField.sourceX), + sourceY: _nonNegativeDoubleProp(map, RuntimeProtocolField.sourceY), + sourceWidth: _positiveDoubleProp(map, RuntimeProtocolField.sourceWidth), + sourceHeight: _positiveDoubleProp( + map, + RuntimeProtocolField.sourceHeight, + ), + sliceLeft: _nonNegativeDoubleProp(map, RuntimeProtocolField.sliceLeft), + sliceTop: _nonNegativeDoubleProp(map, RuntimeProtocolField.sliceTop), + sliceRight: _nonNegativeDoubleProp(map, RuntimeProtocolField.sliceRight), + sliceBottom: _nonNegativeDoubleProp( + map, + RuntimeProtocolField.sliceBottom, + ), pressedAsset: _stringProp(map, RuntimeProtocolField.pressedAsset), disabledAsset: _stringProp(map, RuntimeProtocolField.disabledAsset), animation: _stringProp(map, RuntimeProtocolField.animation), @@ -530,6 +580,17 @@ class RuntimeNode { return value; } + static double? _positiveDoubleProp(Map map, String key) { + final value = _doubleProp(map, key); + if (value == null) { + return null; + } + if (value <= 0) { + throw FormatException('RuntimeNode.$key must be > 0'); + } + return value; + } + static double? _scrollProp( Map map, String key, { diff --git a/lib/runtime/protocol/runtime_protocol.dart b/lib/runtime/protocol/runtime_protocol.dart index 112360e..6f7102c 100644 --- a/lib/runtime/protocol/runtime_protocol.dart +++ b/lib/runtime/protocol/runtime_protocol.dart @@ -138,6 +138,14 @@ class RuntimeProtocolField { static const target = 'target'; static const parent = 'parent'; static const asset = 'asset'; + static const sourceX = 'sourceX'; + static const sourceY = 'sourceY'; + static const sourceWidth = 'sourceWidth'; + static const sourceHeight = 'sourceHeight'; + static const sliceLeft = 'sliceLeft'; + static const sliceTop = 'sliceTop'; + static const sliceRight = 'sliceRight'; + static const sliceBottom = 'sliceBottom'; static const pressedAsset = 'pressedAsset'; static const disabledAsset = 'disabledAsset'; static const animation = 'animation'; @@ -225,6 +233,14 @@ class RuntimeProtocolSchema { RuntimeProtocolField.type, RuntimeProtocolField.parent, RuntimeProtocolField.asset, + RuntimeProtocolField.sourceX, + RuntimeProtocolField.sourceY, + RuntimeProtocolField.sourceWidth, + RuntimeProtocolField.sourceHeight, + RuntimeProtocolField.sliceLeft, + RuntimeProtocolField.sliceTop, + RuntimeProtocolField.sliceRight, + RuntimeProtocolField.sliceBottom, RuntimeProtocolField.pressedAsset, RuntimeProtocolField.disabledAsset, RuntimeProtocolField.animation, @@ -294,6 +310,14 @@ class RuntimeProtocolSchema { RuntimeProtocolField.type, RuntimeProtocolField.parent, RuntimeProtocolField.asset, + RuntimeProtocolField.sourceX, + RuntimeProtocolField.sourceY, + RuntimeProtocolField.sourceWidth, + RuntimeProtocolField.sourceHeight, + RuntimeProtocolField.sliceLeft, + RuntimeProtocolField.sliceTop, + RuntimeProtocolField.sliceRight, + RuntimeProtocolField.sliceBottom, RuntimeProtocolField.pressedAsset, RuntimeProtocolField.disabledAsset, RuntimeProtocolField.animation, diff --git a/lib/runtime/rendering/runtime_component.dart b/lib/runtime/rendering/runtime_component.dart index aaef7a8..fcb5ec8 100644 --- a/lib/runtime/rendering/runtime_component.dart +++ b/lib/runtime/rendering/runtime_component.dart @@ -17,6 +17,102 @@ Color composeRuntimeColorAlpha(Color color, double alpha) { return color.withValues(alpha: color.a * alpha.clamp(0.0, 1.0)); } +@visibleForTesting +Rect runtimeImageSourceRect({ + required double imageWidth, + required double imageHeight, + double? sourceX, + double? sourceY, + double? sourceWidth, + double? sourceHeight, +}) { + final x = (sourceX ?? 0).clamp(0.0, imageWidth).toDouble(); + final y = (sourceY ?? 0).clamp(0.0, imageHeight).toDouble(); + final maxWidth = imageWidth - x; + final maxHeight = imageHeight - y; + final width = (sourceWidth ?? maxWidth).clamp(0.0, maxWidth).toDouble(); + final height = (sourceHeight ?? maxHeight).clamp(0.0, maxHeight).toDouble(); + return Rect.fromLTWH(x, y, width, height); +} + +@visibleForTesting +List<({Rect source, Rect destination})> runtimeNineSliceRects({ + required Rect source, + required Rect destination, + double sliceLeft = 0, + double sliceTop = 0, + double sliceRight = 0, + double sliceBottom = 0, +}) { + if (source.width <= 0 || + source.height <= 0 || + destination.width <= 0 || + destination.height <= 0) { + return const []; + } + final left = sliceLeft.clamp(0.0, source.width).toDouble(); + final top = sliceTop.clamp(0.0, source.height).toDouble(); + final right = sliceRight.clamp(0.0, source.width - left).toDouble(); + final bottom = sliceBottom.clamp(0.0, source.height - top).toDouble(); + final destLeft = left.clamp(0.0, destination.width).toDouble(); + final destTop = top.clamp(0.0, destination.height).toDouble(); + final destRight = right.clamp(0.0, destination.width - destLeft).toDouble(); + final destBottom = bottom + .clamp(0.0, destination.height - destTop) + .toDouble(); + + final sourceXs = [ + source.left, + source.left + left, + source.right - right, + source.right, + ]; + final sourceYs = [ + source.top, + source.top + top, + source.bottom - bottom, + source.bottom, + ]; + final destXs = [ + destination.left, + destination.left + destLeft, + destination.right - destRight, + destination.right, + ]; + final destYs = [ + destination.top, + destination.top + destTop, + destination.bottom - destBottom, + destination.bottom, + ]; + + final parts = <({Rect source, Rect destination})>[]; + for (var y = 0; y < 3; y++) { + for (var x = 0; x < 3; x++) { + final sourcePart = Rect.fromLTRB( + sourceXs[x], + sourceYs[y], + sourceXs[x + 1], + sourceYs[y + 1], + ); + final destPart = Rect.fromLTRB( + destXs[x], + destYs[y], + destXs[x + 1], + destYs[y + 1], + ); + if (sourcePart.width <= 0 || + sourcePart.height <= 0 || + destPart.width <= 0 || + destPart.height <= 0) { + continue; + } + parts.add((source: sourcePart, destination: destPart)); + } + } + return parts; +} + class RuntimeComponent extends PositionComponent with HasVisibility, TapCallbacks { RuntimeComponent({ @@ -431,12 +527,14 @@ class RuntimeComponent extends PositionComponent (_node.type == RuntimeNodeType.sprite || _node.type == RuntimeNodeType.image || _node.type == RuntimeNodeType.button)) { - canvas.drawImageRect( - image, - Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()), - rect, - Paint()..color = composeRuntimeColorAlpha(Colors.white, renderAlpha), - ); + final imagePaint = Paint() + ..color = composeRuntimeColorAlpha(Colors.white, renderAlpha); + final source = _imageSourceRect(image); + if (_usesNineSlice(source, rect)) { + _drawNineSliceImage(canvas, image, source, rect, imagePaint); + } else { + canvas.drawImageRect(image, source, rect, imagePaint); + } return; } @@ -448,6 +546,50 @@ class RuntimeComponent extends PositionComponent ); } + Rect _imageSourceRect(ui.Image image) { + return runtimeImageSourceRect( + imageWidth: image.width.toDouble(), + imageHeight: image.height.toDouble(), + sourceX: _node.sourceX, + sourceY: _node.sourceY, + sourceWidth: _node.sourceWidth, + sourceHeight: _node.sourceHeight, + ); + } + + bool _usesNineSlice(Rect source, Rect destination) { + if (source.width <= 0 || + source.height <= 0 || + destination.width <= 0 || + destination.height <= 0) { + return false; + } + return (_node.sliceLeft ?? 0) > 0 || + (_node.sliceTop ?? 0) > 0 || + (_node.sliceRight ?? 0) > 0 || + (_node.sliceBottom ?? 0) > 0; + } + + void _drawNineSliceImage( + Canvas canvas, + ui.Image image, + Rect source, + Rect destination, + Paint paint, + ) { + final parts = runtimeNineSliceRects( + source: source, + destination: destination, + sliceLeft: _node.sliceLeft ?? 0, + sliceTop: _node.sliceTop ?? 0, + sliceRight: _node.sliceRight ?? 0, + sliceBottom: _node.sliceBottom ?? 0, + ); + for (final part in parts) { + canvas.drawImageRect(image, part.source, part.destination, paint); + } + } + void _applyBase(RuntimeNode node) { _syncVisibility(); size = Vector2(node.width ?? 40, node.height ?? 40); diff --git a/test/runtime/models/runtime_node_test.dart b/test/runtime/models/runtime_node_test.dart index 7670d52..aadc777 100644 --- a/test/runtime/models/runtime_node_test.dart +++ b/test/runtime/models/runtime_node_test.dart @@ -10,6 +10,14 @@ void main() { 'type': 'button', 'parent': 'top_bar', 'asset': 'dice_normal', + 'sourceX': 4, + 'sourceY': 5, + 'sourceWidth': 64, + 'sourceHeight': 32, + 'sliceLeft': 6, + 'sliceTop': 7, + 'sliceRight': 8, + 'sliceBottom': 9, 'pressedAsset': 'dice_pressed', 'disabledAsset': 'dice_disabled', 'animation': 'idle', @@ -72,6 +80,14 @@ void main() { expect(node.type, 'button'); expect(node.parent, 'top_bar'); expect(node.asset, 'dice_normal'); + expect(node.sourceX, 4); + expect(node.sourceY, 5); + expect(node.sourceWidth, 64); + expect(node.sourceHeight, 32); + expect(node.sliceLeft, 6); + expect(node.sliceTop, 7); + expect(node.sliceRight, 8); + expect(node.sliceBottom, 9); expect(node.pressedAsset, 'dice_pressed'); expect(node.disabledAsset, 'dice_disabled'); expect(node.animation, 'idle'); @@ -147,6 +163,14 @@ void main() { expect(node.textShadowOffsetX, isNull); expect(node.textShadowOffsetY, isNull); expect(node.textShadowBlur, isNull); + expect(node.sourceX, isNull); + expect(node.sourceY, isNull); + expect(node.sourceWidth, isNull); + expect(node.sourceHeight, isNull); + expect(node.sliceLeft, isNull); + expect(node.sliceTop, isNull); + expect(node.sliceRight, isNull); + expect(node.sliceBottom, isNull); expect(node.scrollbarVisible, isTrue); expect(node.paddingLeft, 0); expect(node.paddingTop, 0); @@ -182,6 +206,14 @@ void main() { 'paddingBottom': 11, 'contentWidth': 120, 'contentHeight': 100, + 'sourceX': 3, + 'sourceY': 4, + 'sourceWidth': 40, + 'sourceHeight': 41, + 'sliceLeft': 5, + 'sliceTop': 6, + 'sliceRight': 7, + 'sliceBottom': 8, 'pressedAsset': 'button_pressed', 'disabledAsset': 'button_disabled', 'scrollX': 90, @@ -213,6 +245,14 @@ void main() { expect(updated.paddingBottom, 11); expect(updated.contentWidth, 120); expect(updated.contentHeight, 100); + expect(updated.sourceX, 3); + expect(updated.sourceY, 4); + expect(updated.sourceWidth, 40); + expect(updated.sourceHeight, 41); + expect(updated.sliceLeft, 5); + expect(updated.sliceTop, 6); + expect(updated.sliceRight, 7); + expect(updated.sliceBottom, 8); expect(updated.pressedAsset, 'button_pressed'); expect(updated.disabledAsset, 'button_disabled'); expect(updated.scrollX, 68); @@ -255,6 +295,22 @@ void main() { () => RuntimeNode.fromMap({'id': 'a', 'type': 'progress', 'value': 2}), throwsFormatException, ); + expect( + () => RuntimeNode.fromMap({ + 'id': 'a', + 'type': 'image', + 'sourceWidth': 0, + }), + throwsFormatException, + ); + expect( + () => RuntimeNode.fromMap({ + 'id': 'a', + 'type': 'image', + 'sliceLeft': -1, + }), + throwsFormatException, + ); expect( () => RuntimeNode.fromMap({ 'id': 'a', diff --git a/test/runtime/rendering/runtime_component_test.dart b/test/runtime/rendering/runtime_component_test.dart index cb29170..734385a 100644 --- a/test/runtime/rendering/runtime_component_test.dart +++ b/test/runtime/rendering/runtime_component_test.dart @@ -4,6 +4,7 @@ import 'package:flame_lua_runtime/runtime/protocol/runtime_protocol.dart'; import 'package:flame_lua_runtime/runtime/rendering/runtime_component.dart'; import 'package:flame_lua_runtime/runtime/resources/game_resource_manager.dart'; import 'package:flutter/material.dart' show Color; +import 'package:flutter/rendering.dart' show Rect; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -138,6 +139,50 @@ void main() { ); }); + test('computes atlas source region and clamps to image bounds', () { + expect( + runtimeImageSourceRect( + imageWidth: 100, + imageHeight: 80, + sourceX: 10, + sourceY: 12, + sourceWidth: 30, + sourceHeight: 20, + ), + const Rect.fromLTWH(10, 12, 30, 20), + ); + expect( + runtimeImageSourceRect( + imageWidth: 100, + imageHeight: 80, + sourceX: 90, + sourceY: 70, + sourceWidth: 30, + sourceHeight: 20, + ), + const Rect.fromLTWH(90, 70, 10, 10), + ); + }); + + test('computes nine-slice source and destination rects', () { + final parts = runtimeNineSliceRects( + source: const Rect.fromLTWH(10, 20, 30, 40), + destination: const Rect.fromLTWH(0, 0, 90, 120), + sliceLeft: 5, + sliceTop: 6, + sliceRight: 7, + sliceBottom: 8, + ); + + expect(parts, hasLength(9)); + expect(parts.first.source, const Rect.fromLTRB(10, 20, 15, 26)); + expect(parts.first.destination, const Rect.fromLTRB(0, 0, 5, 6)); + expect(parts[4].source, const Rect.fromLTRB(15, 26, 33, 52)); + expect(parts[4].destination, const Rect.fromLTRB(5, 6, 83, 112)); + expect(parts.last.source, const Rect.fromLTRB(33, 52, 40, 60)); + expect(parts.last.destination, const Rect.fromLTRB(83, 112, 90, 120)); + }); + test('updates text alpha style without rebuilding text component', () { final component = RuntimeComponent( node: const RuntimeNode( @@ -161,27 +206,32 @@ void main() { expect(((updatedText.textRenderer as TextPaint).style.color!).a, 1); }); - test('updates button text alpha style without rebuilding text component', () { - final component = RuntimeComponent( - node: const RuntimeNode( - id: 'button', - type: RuntimeNodeType.button, - text: 'Fade me', - alpha: 0, - ), - resources: GameResourceManager(), - onNodeTap: (_, __) {}, - ); + test( + 'updates button text alpha style without rebuilding text component', + () { + final component = RuntimeComponent( + node: const RuntimeNode( + id: 'button', + type: RuntimeNodeType.button, + text: 'Fade me', + alpha: 0, + ), + resources: GameResourceManager(), + onNodeTap: (_, __) {}, + ); - final text = component.children.whereType().single; - expect(((text.textRenderer as TextPaint).style.color!).a, 0); + final text = component.children.whereType().single; + expect(((text.textRenderer as TextPaint).style.color!).a, 0); - component.setRuntimeAlpha(1); + component.setRuntimeAlpha(1); - final updatedText = component.children.whereType().single; - expect(identical(updatedText, text), isTrue); - expect(((updatedText.textRenderer as TextPaint).style.color!).a, 1); - }); + final updatedText = component.children + .whereType() + .single; + expect(identical(updatedText, text), isTrue); + expect(((updatedText.textRenderer as TextPaint).style.color!).a, 1); + }, + ); test('applies text shadow style', () { final component = RuntimeComponent( diff --git a/tool/lua_runtime_defs_common.lua b/tool/lua_runtime_defs_common.lua index e2a852c..49d89fd 100644 --- a/tool/lua_runtime_defs_common.lua +++ b/tool/lua_runtime_defs_common.lua @@ -86,6 +86,14 @@ ---@field type RuntimeNodeType ---@field parent? string ---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image. +---@field sourceX? number Source atlas region x in image pixels. +---@field sourceY? number Source atlas region y in image pixels. +---@field sourceWidth? number Source atlas region width in image pixels. +---@field sourceHeight? number Source atlas region height in image pixels. +---@field sliceLeft? number Left nine-slice inset in source pixels. +---@field sliceTop? number Top nine-slice inset in source pixels. +---@field sliceRight? number Right nine-slice inset in source pixels. +---@field sliceBottom? number Bottom nine-slice inset in source pixels. ---@field pressedAsset? string Button pressed-state image asset key. ---@field disabledAsset? string Button disabled-state image asset key. ---@field animation? string @@ -147,6 +155,14 @@ ---@field type? RuntimeNodeType ---@field parent? string ---@field asset? string Normal image/sprite/spine asset key. For button nodes this is the normal-state image. +---@field sourceX? number Source atlas region x in image pixels. +---@field sourceY? number Source atlas region y in image pixels. +---@field sourceWidth? number Source atlas region width in image pixels. +---@field sourceHeight? number Source atlas region height in image pixels. +---@field sliceLeft? number Left nine-slice inset in source pixels. +---@field sliceTop? number Top nine-slice inset in source pixels. +---@field sliceRight? number Right nine-slice inset in source pixels. +---@field sliceBottom? number Bottom nine-slice inset in source pixels. ---@field pressedAsset? string Button pressed-state image asset key. ---@field disabledAsset? string Button disabled-state image asset key. ---@field animation? string