From 38f6e0c0c9147a5f6b9bf63f72dca10ec501aefc Mon Sep 17 00:00:00 2001 From: gem Date: Tue, 9 Jun 2026 12:49:01 +0800 Subject: [PATCH] Support TexturePacker image atlases --- CHANGELOG.md | 2 +- docs/protocol.md | 8 +- .../games/flight/scripts/runtime_defs.lua | 6 + .../games/ludo/scripts/runtime_defs.lua | 6 + .../games/showcase/scripts/runtime_defs.lua | 6 + .../games/template/scripts/runtime_defs.lua | 6 + lib/runtime/models/runtime_node.dart | 29 +++- .../packages/game_package_manifest.dart | 11 +- lib/runtime/packages/package_verifier.dart | 3 + lib/runtime/protocol/runtime_protocol.dart | 9 ++ lib/runtime/rendering/runtime_component.dart | 27 +++- .../resources/game_resource_manager.dart | 128 ++++++++++++++++++ test/runtime/models/runtime_node_test.dart | 29 ++-- .../packages/game_package_manifest_test.dart | 2 + .../resources/game_resource_manager_test.dart | 91 +++++++++++++ tool/lua_runtime_defs_common.lua | 6 + 16 files changed, 343 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87d49f5..8a788ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- Added atlas source-region and nine-slice image rendering fields for image-capable nodes. +- Added TexturePacker frame, manual 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 daee08c..89844eb 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -56,12 +56,16 @@ The shadow color alpha is multiplied by `RuntimeNode.alpha` and any runtime anim ### 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: +Image-capable nodes (`image`, `sprite`, and image-backed `button`) may draw only part of an asset. + +For manual atlas regions, set source 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. +For TexturePacker JSON atlases, declare `atlas` on an image resource and set `frame` on the node. Image-backed buttons may also use `pressedFrame` and `disabledFrame`. The runtime supports TexturePacker JSON Hash and JSON Array `frames` formats with non-rotated frames. `rotated: true` frames are rejected. + +When frame/source fields are omitted, the full image is used. Image-capable nodes may also use nine-slice scaling with source-pixel insets: diff --git a/example/assets/games/flight/scripts/runtime_defs.lua b/example/assets/games/flight/scripts/runtime_defs.lua index 8ddf3d5..1b56637 100644 --- a/example/assets/games/flight/scripts/runtime_defs.lua +++ b/example/assets/games/flight/scripts/runtime_defs.lua @@ -86,6 +86,9 @@ ---@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 frame? string TexturePacker frame name within the asset atlas. +---@field pressedFrame? string Button pressed-state TexturePacker frame name. +---@field disabledFrame? string Button disabled-state TexturePacker frame name. ---@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. @@ -155,6 +158,9 @@ ---@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 frame? string TexturePacker frame name within the asset atlas. +---@field pressedFrame? string Button pressed-state TexturePacker frame name. +---@field disabledFrame? string Button disabled-state TexturePacker frame name. ---@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. diff --git a/example/assets/games/ludo/scripts/runtime_defs.lua b/example/assets/games/ludo/scripts/runtime_defs.lua index d5be9db..d6847be 100644 --- a/example/assets/games/ludo/scripts/runtime_defs.lua +++ b/example/assets/games/ludo/scripts/runtime_defs.lua @@ -86,6 +86,9 @@ ---@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 frame? string TexturePacker frame name within the asset atlas. +---@field pressedFrame? string Button pressed-state TexturePacker frame name. +---@field disabledFrame? string Button disabled-state TexturePacker frame name. ---@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. @@ -155,6 +158,9 @@ ---@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 frame? string TexturePacker frame name within the asset atlas. +---@field pressedFrame? string Button pressed-state TexturePacker frame name. +---@field disabledFrame? string Button disabled-state TexturePacker frame name. ---@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. diff --git a/example/assets/games/showcase/scripts/runtime_defs.lua b/example/assets/games/showcase/scripts/runtime_defs.lua index 5bec283..92fc29d 100644 --- a/example/assets/games/showcase/scripts/runtime_defs.lua +++ b/example/assets/games/showcase/scripts/runtime_defs.lua @@ -86,6 +86,9 @@ ---@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 frame? string TexturePacker frame name within the asset atlas. +---@field pressedFrame? string Button pressed-state TexturePacker frame name. +---@field disabledFrame? string Button disabled-state TexturePacker frame name. ---@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. @@ -155,6 +158,9 @@ ---@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 frame? string TexturePacker frame name within the asset atlas. +---@field pressedFrame? string Button pressed-state TexturePacker frame name. +---@field disabledFrame? string Button disabled-state TexturePacker frame name. ---@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. diff --git a/example/assets/games/template/scripts/runtime_defs.lua b/example/assets/games/template/scripts/runtime_defs.lua index 49d89fd..d227e95 100644 --- a/example/assets/games/template/scripts/runtime_defs.lua +++ b/example/assets/games/template/scripts/runtime_defs.lua @@ -86,6 +86,9 @@ ---@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 frame? string TexturePacker frame name within the asset atlas. +---@field pressedFrame? string Button pressed-state TexturePacker frame name. +---@field disabledFrame? string Button disabled-state TexturePacker frame name. ---@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. @@ -155,6 +158,9 @@ ---@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 frame? string TexturePacker frame name within the asset atlas. +---@field pressedFrame? string Button pressed-state TexturePacker frame name. +---@field disabledFrame? string Button disabled-state TexturePacker frame name. ---@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. diff --git a/lib/runtime/models/runtime_node.dart b/lib/runtime/models/runtime_node.dart index 0e7a91e..b7aa782 100644 --- a/lib/runtime/models/runtime_node.dart +++ b/lib/runtime/models/runtime_node.dart @@ -8,6 +8,9 @@ class RuntimeNode { required this.type, this.parent, this.asset, + this.frame, + this.pressedFrame, + this.disabledFrame, this.sourceX, this.sourceY, this.sourceWidth, @@ -78,6 +81,9 @@ class RuntimeNode { final String type; final String? parent; final String? asset; + final String? frame; + final String? pressedFrame; + final String? disabledFrame; final double? sourceX; final double? sourceY; final double? sourceWidth; @@ -229,8 +235,19 @@ 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, + frame: _stringProp(props, RuntimeProtocolField.frame) ?? frame, + pressedFrame: + _stringProp(props, RuntimeProtocolField.pressedFrame) ?? + pressedFrame, + disabledFrame: + _stringProp(props, RuntimeProtocolField.disabledFrame) ?? + disabledFrame, + sourceX: + _nonNegativeDoubleProp(props, RuntimeProtocolField.sourceX) ?? + sourceX, + sourceY: + _nonNegativeDoubleProp(props, RuntimeProtocolField.sourceY) ?? + sourceY, sourceWidth: _positiveDoubleProp(props, RuntimeProtocolField.sourceWidth) ?? sourceWidth, @@ -381,13 +398,13 @@ class RuntimeNode { nodeId: _requiredString(map, RuntimeProtocolField.id), ), asset: _stringProp(map, RuntimeProtocolField.asset), + frame: _stringProp(map, RuntimeProtocolField.frame), + pressedFrame: _stringProp(map, RuntimeProtocolField.pressedFrame), + disabledFrame: _stringProp(map, RuntimeProtocolField.disabledFrame), sourceX: _nonNegativeDoubleProp(map, RuntimeProtocolField.sourceX), sourceY: _nonNegativeDoubleProp(map, RuntimeProtocolField.sourceY), sourceWidth: _positiveDoubleProp(map, RuntimeProtocolField.sourceWidth), - sourceHeight: _positiveDoubleProp( - map, - RuntimeProtocolField.sourceHeight, - ), + sourceHeight: _positiveDoubleProp(map, RuntimeProtocolField.sourceHeight), sliceLeft: _nonNegativeDoubleProp(map, RuntimeProtocolField.sliceLeft), sliceTop: _nonNegativeDoubleProp(map, RuntimeProtocolField.sliceTop), sliceRight: _nonNegativeDoubleProp(map, RuntimeProtocolField.sliceRight), diff --git a/lib/runtime/packages/game_package_manifest.dart b/lib/runtime/packages/game_package_manifest.dart index e3ad011..ca07fce 100644 --- a/lib/runtime/packages/game_package_manifest.dart +++ b/lib/runtime/packages/game_package_manifest.dart @@ -222,8 +222,15 @@ class GameResource { 'spine resource.skeleton must be a non-empty string', ); } - } else if (path is! String || path.isEmpty) { - throw const FormatException('resource.path must be a non-empty string'); + } else { + if (path is! String || path.isEmpty) { + throw const FormatException('resource.path must be a non-empty string'); + } + if (atlas != null && (atlas is! String || atlas.isEmpty)) { + throw const FormatException( + 'image resource.atlas must be a non-empty string', + ); + } } final preload = map['preload'] as String? ?? GameResourcePreload.required; if (!GameResourcePreload.isSupported(preload)) { diff --git a/lib/runtime/packages/package_verifier.dart b/lib/runtime/packages/package_verifier.dart index 51b8848..d02245d 100644 --- a/lib/runtime/packages/package_verifier.dart +++ b/lib/runtime/packages/package_verifier.dart @@ -112,6 +112,9 @@ class PackageVerifier { if (resource.type == GameResourceType.spine) { return [resource.atlas!, resource.skeleton!]; } + if (resource.type == GameResourceType.image && resource.atlas != null) { + return [resource.path, resource.atlas!]; + } return [resource.path]; } } diff --git a/lib/runtime/protocol/runtime_protocol.dart b/lib/runtime/protocol/runtime_protocol.dart index 6f7102c..e3de82e 100644 --- a/lib/runtime/protocol/runtime_protocol.dart +++ b/lib/runtime/protocol/runtime_protocol.dart @@ -138,6 +138,9 @@ class RuntimeProtocolField { static const target = 'target'; static const parent = 'parent'; static const asset = 'asset'; + static const frame = 'frame'; + static const pressedFrame = 'pressedFrame'; + static const disabledFrame = 'disabledFrame'; static const sourceX = 'sourceX'; static const sourceY = 'sourceY'; static const sourceWidth = 'sourceWidth'; @@ -233,6 +236,9 @@ class RuntimeProtocolSchema { RuntimeProtocolField.type, RuntimeProtocolField.parent, RuntimeProtocolField.asset, + RuntimeProtocolField.frame, + RuntimeProtocolField.pressedFrame, + RuntimeProtocolField.disabledFrame, RuntimeProtocolField.sourceX, RuntimeProtocolField.sourceY, RuntimeProtocolField.sourceWidth, @@ -310,6 +316,9 @@ class RuntimeProtocolSchema { RuntimeProtocolField.type, RuntimeProtocolField.parent, RuntimeProtocolField.asset, + RuntimeProtocolField.frame, + RuntimeProtocolField.pressedFrame, + RuntimeProtocolField.disabledFrame, RuntimeProtocolField.sourceX, RuntimeProtocolField.sourceY, RuntimeProtocolField.sourceWidth, diff --git a/lib/runtime/rendering/runtime_component.dart b/lib/runtime/rendering/runtime_component.dart index fcb5ec8..a68d703 100644 --- a/lib/runtime/rendering/runtime_component.dart +++ b/lib/runtime/rendering/runtime_component.dart @@ -57,9 +57,7 @@ List<({Rect source, Rect destination})> runtimeNineSliceRects({ 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 destBottom = bottom.clamp(0.0, destination.height - destTop).toDouble(); final sourceXs = [ source.left, @@ -529,7 +527,7 @@ class RuntimeComponent extends PositionComponent _node.type == RuntimeNodeType.button)) { final imagePaint = Paint() ..color = composeRuntimeColorAlpha(Colors.white, renderAlpha); - final source = _imageSourceRect(image); + final source = _imageSourceRect(image, _currentImageFrame(_node)); if (_usesNineSlice(source, rect)) { _drawNineSliceImage(canvas, image, source, rect, imagePaint); } else { @@ -546,7 +544,13 @@ class RuntimeComponent extends PositionComponent ); } - Rect _imageSourceRect(ui.Image image) { + Rect _imageSourceRect(ui.Image image, String? frameName) { + final frame = _loadedAsset == null + ? null + : _resources.textureFrame(_loadedAsset!, frameName); + if (frame != null) { + return frame.rect; + } return runtimeImageSourceRect( imageWidth: image.width.toDouble(), imageHeight: image.height.toDouble(), @@ -666,6 +670,19 @@ class RuntimeComponent extends PositionComponent return node.asset; } + String? _currentImageFrame(RuntimeNode node) { + if (node.type != RuntimeNodeType.button) { + return node.frame; + } + if (!node.interactive && node.disabledFrame != null) { + return node.disabledFrame; + } + if (_pressed && node.pressedFrame != null) { + return node.pressedFrame; + } + return node.frame; + } + void _releaseRetainedImage(String asset, int generation, ui.Image? image) { if (image == null) { return; diff --git a/lib/runtime/resources/game_resource_manager.dart b/lib/runtime/resources/game_resource_manager.dart index 2f6ce1b..cb74a1b 100644 --- a/lib/runtime/resources/game_resource_manager.dart +++ b/lib/runtime/resources/game_resource_manager.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:ui' as ui; import 'package:flame_spine/flame_spine.dart'; @@ -32,6 +33,7 @@ class GameResourceManager { final RuntimeAsyncGate _asyncGate = RuntimeAsyncGate(initiallyClosed: true); GamePackage? _package; final Map _images = {}; + final Map _textureAtlases = {}; int _cacheBytes = 0; int _accessCounter = 0; @@ -49,8 +51,10 @@ class GameResourceManager { Future mount(GamePackage package) async { _releaseCachedImages(); + _textureAtlases.clear(); _asyncGate.activate(); _package = package; + await loadDeclaredTextureAtlases(package.manifest); await preloadDeclaredImages(package.manifest); await preloadDeclaredSpines(package.manifest); } @@ -58,6 +62,7 @@ class GameResourceManager { void dispose() { _asyncGate.close(); _releaseCachedImages(); + _textureAtlases.clear(); _package = null; } @@ -146,6 +151,17 @@ class GameResourceManager { return _loadImage(keyOrPath, failOnError: false, retain: retain); } + RuntimeTextureFrame? textureFrame(String keyOrPath, String? frameName) { + if (frameName == null || frameName.isEmpty) { + return null; + } + final path = _tryResolve(keyOrPath); + if (path == null) { + return null; + } + return _textureAtlases[path]?.frames[frameName]; + } + Future createSpineComponent(String? keyOrPath) { return _createSpineComponent(keyOrPath); } @@ -223,6 +239,24 @@ class GameResourceManager { return count; } + Future loadDeclaredTextureAtlases(GamePackageManifest manifest) async { + final activePackage = _package; + if (activePackage == null) { + return; + } + for (final entry in manifest.resources.entries) { + final resource = entry.value; + final atlas = resource.atlas; + if (resource.type != GameResourceType.image || atlas == null) { + continue; + } + final imagePath = activePackage.resolveResourcePath(entry.key); + final atlasPath = activePackage.resolveResourcePath(atlas); + final source = await activePackage.readText(atlasPath); + _textureAtlases[imagePath] = RuntimeTextureAtlas.fromJsonString(source); + } + } + Future preloadDeclaredImages(GamePackageManifest manifest) async { final futures = >[]; for (final entry in manifest.resources.entries) { @@ -254,6 +288,100 @@ class GameResourceManager { } } +class RuntimeTextureAtlas { + const RuntimeTextureAtlas({required this.frames}); + + final Map frames; + + factory RuntimeTextureAtlas.fromJsonString(String source) { + final value = jsonDecode(source); + if (value is! Map) { + throw const FormatException('Texture atlas JSON must be an object'); + } + final framesValue = value['frames']; + final frames = {}; + if (framesValue is Map) { + for (final entry in framesValue.entries) { + if (entry.key is! String || entry.value is! Map) { + throw const FormatException('Texture atlas frames must be objects'); + } + frames[entry.key as String] = RuntimeTextureFrame.fromMap( + Map.from(entry.value as Map), + ); + } + } else if (framesValue is List) { + for (final item in framesValue) { + if (item is! Map) { + throw const FormatException('Texture atlas frames must be objects'); + } + final map = Map.from(item); + final filename = map['filename']; + if (filename is! String || filename.isEmpty) { + throw const FormatException( + 'Texture atlas array frames require filename', + ); + } + frames[filename] = RuntimeTextureFrame.fromMap(map); + } + } else { + throw const FormatException('Texture atlas frames must be a map or list'); + } + return RuntimeTextureAtlas(frames: frames); + } +} + +class RuntimeTextureFrame { + const RuntimeTextureFrame({ + required this.x, + required this.y, + required this.width, + required this.height, + }); + + final double x; + final double y; + final double width; + final double height; + + ui.Rect get rect => ui.Rect.fromLTWH(x, y, width, height); + + factory RuntimeTextureFrame.fromMap(Map map) { + final rotated = map['rotated']; + if (rotated == true) { + throw const FormatException( + 'Rotated TexturePacker frames are unsupported', + ); + } + final frame = map['frame']; + if (frame is! Map) { + throw const FormatException('TexturePacker frame must be an object'); + } + final frameMap = Map.from(frame); + return RuntimeTextureFrame( + x: _number(frameMap, 'x'), + y: _number(frameMap, 'y'), + width: _positiveNumber(frameMap, 'w'), + height: _positiveNumber(frameMap, 'h'), + ); + } + + static double _number(Map map, String key) { + final value = map[key]; + if (value is num) { + return value.toDouble(); + } + throw FormatException('TexturePacker frame.$key must be a number'); + } + + static double _positiveNumber(Map map, String key) { + final value = _number(map, key); + if (value <= 0) { + throw FormatException('TexturePacker frame.$key must be > 0'); + } + return value; + } +} + enum GameResourceState { idle, loading, ready, failed, disposed } class ResourceLoadException implements Exception { diff --git a/test/runtime/models/runtime_node_test.dart b/test/runtime/models/runtime_node_test.dart index aadc777..ebc2434 100644 --- a/test/runtime/models/runtime_node_test.dart +++ b/test/runtime/models/runtime_node_test.dart @@ -10,6 +10,9 @@ void main() { 'type': 'button', 'parent': 'top_bar', 'asset': 'dice_normal', + 'frame': 'dice_idle.png', + 'pressedFrame': 'dice_pressed.png', + 'disabledFrame': 'dice_disabled.png', 'sourceX': 4, 'sourceY': 5, 'sourceWidth': 64, @@ -80,6 +83,9 @@ void main() { expect(node.type, 'button'); expect(node.parent, 'top_bar'); expect(node.asset, 'dice_normal'); + expect(node.frame, 'dice_idle.png'); + expect(node.pressedFrame, 'dice_pressed.png'); + expect(node.disabledFrame, 'dice_disabled.png'); expect(node.sourceX, 4); expect(node.sourceY, 5); expect(node.sourceWidth, 64); @@ -163,6 +169,9 @@ void main() { expect(node.textShadowOffsetX, isNull); expect(node.textShadowOffsetY, isNull); expect(node.textShadowBlur, isNull); + expect(node.frame, isNull); + expect(node.pressedFrame, isNull); + expect(node.disabledFrame, isNull); expect(node.sourceX, isNull); expect(node.sourceY, isNull); expect(node.sourceWidth, isNull); @@ -210,6 +219,9 @@ void main() { 'sourceY': 4, 'sourceWidth': 40, 'sourceHeight': 41, + 'frame': 'piece.png', + 'pressedFrame': 'piece_down.png', + 'disabledFrame': 'piece_disabled.png', 'sliceLeft': 5, 'sliceTop': 6, 'sliceRight': 7, @@ -249,6 +261,9 @@ void main() { expect(updated.sourceY, 4); expect(updated.sourceWidth, 40); expect(updated.sourceHeight, 41); + expect(updated.frame, 'piece.png'); + expect(updated.pressedFrame, 'piece_down.png'); + expect(updated.disabledFrame, 'piece_disabled.png'); expect(updated.sliceLeft, 5); expect(updated.sliceTop, 6); expect(updated.sliceRight, 7); @@ -296,19 +311,13 @@ void main() { throwsFormatException, ); expect( - () => RuntimeNode.fromMap({ - 'id': 'a', - 'type': 'image', - 'sourceWidth': 0, - }), + () => + RuntimeNode.fromMap({'id': 'a', 'type': 'image', 'sourceWidth': 0}), throwsFormatException, ); expect( - () => RuntimeNode.fromMap({ - 'id': 'a', - 'type': 'image', - 'sliceLeft': -1, - }), + () => + RuntimeNode.fromMap({'id': 'a', 'type': 'image', 'sliceLeft': -1}), throwsFormatException, ); expect( diff --git a/test/runtime/packages/game_package_manifest_test.dart b/test/runtime/packages/game_package_manifest_test.dart index d9bfbac..41ef0ae 100644 --- a/test/runtime/packages/game_package_manifest_test.dart +++ b/test/runtime/packages/game_package_manifest_test.dart @@ -26,6 +26,7 @@ void main() { 'board': { 'type': 'image', 'path': 'assets/board.png', + 'atlas': 'assets/board.json', 'preload': 'lazy', 'group': 'board', }, @@ -53,6 +54,7 @@ void main() { expect(manifest.display.scaleMode, 'fit'); expect(manifest.resources['board']?.type, 'image'); expect(manifest.resources['board']?.path, 'assets/board.png'); + expect(manifest.resources['board']?.atlas, 'assets/board.json'); expect(manifest.resources['board']?.preload, GameResourcePreload.lazy); expect(manifest.resources['board']?.group, 'board'); expect(manifest.resources['roll']?.type, GameResourceType.audio); diff --git a/test/runtime/resources/game_resource_manager_test.dart b/test/runtime/resources/game_resource_manager_test.dart index 5d1cadc..8479ac3 100644 --- a/test/runtime/resources/game_resource_manager_test.dart +++ b/test/runtime/resources/game_resource_manager_test.dart @@ -1,6 +1,7 @@ import 'dart:async' as async; import 'dart:io'; import 'dart:typed_data'; +import 'dart:ui' show Rect; import 'package:flame_lua_runtime/runtime/diagnostics/runtime_diagnostics.dart'; import 'package:flame_lua_runtime/runtime/packages/game_package.dart'; @@ -61,6 +62,19 @@ void main() { }, ); + test('loads TexturePacker atlas frames for image resources', () async { + final resources = GameResourceManager(); + final package = await _createTextureAtlasPackage('texture_atlas'); + + await resources.mount(package); + + final idle = resources.textureFrame('ui', 'button_idle.png'); + final pressed = resources.textureFrame('ui', 'button_pressed.png'); + expect(idle?.rect, Rect.fromLTWH(2, 3, 40, 20)); + expect(pressed?.rect, Rect.fromLTWH(44, 3, 40, 20)); + expect(resources.textureFrame('ui', 'missing.png'), isNull); + }); + test('exports image debug json and evicts failed records', () async { final resources = GameResourceManager(); final package = await _createPackage('debug_json'); @@ -332,6 +346,83 @@ Future _createPackage( ); } +Future _createTextureAtlasPackage(String name) async { + final root = await Directory.systemTemp.createTemp('resource_${name}_'); + Directory('${root.path}/assets').createSync(recursive: true); + File('${root.path}/assets/ui.png').writeAsBytesSync(_pngBytes); + File('${root.path}/assets/ui.json').writeAsStringSync(''' +{ + "frames": { + "button_idle.png": { + "frame": { "x": 2, "y": 3, "w": 40, "h": 20 }, + "rotated": false, + "trimmed": false + } + } +} +'''); + + addTearDown(() { + if (root.existsSync()) { + root.deleteSync(recursive: true); + } + }); + + final hashAtlas = RuntimeTextureAtlas.fromJsonString( + File('${root.path}/assets/ui.json').readAsStringSync(), + ); + expect(hashAtlas.frames['button_idle.png']?.rect, Rect.fromLTWH(2, 3, 40, 20)); + final arrayAtlas = RuntimeTextureAtlas.fromJsonString(''' +{ + "frames": [ + { + "filename": "button_pressed.png", + "frame": { "x": 44, "y": 3, "w": 40, "h": 20 }, + "rotated": false, + "trimmed": false + } + ] +} +'''); + final mergedAtlas = ''' +{ + "frames": { + "button_idle.png": { + "frame": { "x": 2, "y": 3, "w": 40, "h": 20 }, + "rotated": false, + "trimmed": false + }, + "button_pressed.png": { + "frame": { "x": ${arrayAtlas.frames['button_pressed.png']!.x}, "y": 3, "w": 40, "h": 20 }, + "rotated": false, + "trimmed": false + } + } +} +'''; + File('${root.path}/assets/ui.json').writeAsStringSync(mergedAtlas); + + return GamePackage.file( + rootPath: root.path, + manifest: GamePackageManifest( + gameId: 'test', + name: 'Test', + version: '0.1.0', + runtimeApiVersion: 1, + entry: 'scripts/main.lua', + assetsBase: 'assets', + resources: const { + 'ui': GameResource( + type: GameResourceType.image, + path: 'assets/ui.png', + atlas: 'assets/ui.json', + preload: GameResourcePreload.lazy, + ), + }, + ), + ); +} + Future _createMultiImagePackage(String name) async { final root = await Directory.systemTemp.createTemp('resource_${name}_'); Directory('${root.path}/assets').createSync(recursive: true); diff --git a/tool/lua_runtime_defs_common.lua b/tool/lua_runtime_defs_common.lua index 49d89fd..d227e95 100644 --- a/tool/lua_runtime_defs_common.lua +++ b/tool/lua_runtime_defs_common.lua @@ -86,6 +86,9 @@ ---@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 frame? string TexturePacker frame name within the asset atlas. +---@field pressedFrame? string Button pressed-state TexturePacker frame name. +---@field disabledFrame? string Button disabled-state TexturePacker frame name. ---@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. @@ -155,6 +158,9 @@ ---@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 frame? string TexturePacker frame name within the asset atlas. +---@field pressedFrame? string Button pressed-state TexturePacker frame name. +---@field disabledFrame? string Button disabled-state TexturePacker frame name. ---@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.