Support TexturePacker image atlases

This commit is contained in:
gem
2026-06-09 12:49:01 +08:00
parent e2a584d4dc
commit 38f6e0c0c9
16 changed files with 343 additions and 26 deletions

View File

@@ -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),

View File

@@ -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)) {

View File

@@ -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];
}
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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 {