Add image atlas and nine-slice support

This commit is contained in:
gem
2026-06-09 12:30:44 +08:00
parent 409942b4af
commit e2a584d4dc
12 changed files with 453 additions and 24 deletions

View File

@@ -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<String, Object?> 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<String, Object?> map,
String key, {

View File

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

View File

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