Support TexturePacker image atlases
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String, _ImageResourceRecord> _images = {};
|
||||
final Map<String, RuntimeTextureAtlas> _textureAtlases = {};
|
||||
int _cacheBytes = 0;
|
||||
int _accessCounter = 0;
|
||||
|
||||
@@ -49,8 +51,10 @@ class GameResourceManager {
|
||||
|
||||
Future<void> 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<SpineComponent?> createSpineComponent(String? keyOrPath) {
|
||||
return _createSpineComponent(keyOrPath);
|
||||
}
|
||||
@@ -223,6 +239,24 @@ class GameResourceManager {
|
||||
return count;
|
||||
}
|
||||
|
||||
Future<void> 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<void> preloadDeclaredImages(GamePackageManifest manifest) async {
|
||||
final futures = <Future<void>>[];
|
||||
for (final entry in manifest.resources.entries) {
|
||||
@@ -254,6 +288,100 @@ class GameResourceManager {
|
||||
}
|
||||
}
|
||||
|
||||
class RuntimeTextureAtlas {
|
||||
const RuntimeTextureAtlas({required this.frames});
|
||||
|
||||
final Map<String, RuntimeTextureFrame> 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 = <String, RuntimeTextureFrame>{};
|
||||
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<String, Object?>.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<String, Object?>.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<String, Object?> 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<String, Object?>.from(frame);
|
||||
return RuntimeTextureFrame(
|
||||
x: _number(frameMap, 'x'),
|
||||
y: _number(frameMap, 'y'),
|
||||
width: _positiveNumber(frameMap, 'w'),
|
||||
height: _positiveNumber(frameMap, 'h'),
|
||||
);
|
||||
}
|
||||
|
||||
static double _number(Map<String, Object?> 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<String, Object?> 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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<GamePackage> _createPackage(
|
||||
);
|
||||
}
|
||||
|
||||
Future<GamePackage> _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<GamePackage> _createMultiImagePackage(String name) async {
|
||||
final root = await Directory.systemTemp.createTemp('resource_${name}_');
|
||||
Directory('${root.path}/assets').createSync(recursive: true);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user