Fix nine-slice seams and inherited alpha

This commit is contained in:
gem
2026-06-09 13:52:07 +08:00
parent 638ea22562
commit 4ce5fe1ae7
4 changed files with 156 additions and 4 deletions

View File

@@ -3,6 +3,8 @@
## Unreleased
- Added TexturePacker frame, manual source-region, and nine-slice image rendering fields for image-capable nodes.
- Fixed nine-slice image seams by overlapping destination slices during runtime rendering.
- Fixed Runtime alpha inheritance so parent fade commands apply to the full child subtree.
- 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.

View File

@@ -327,6 +327,7 @@ class RenderTreeController {
contentOffsetY: parentContentOffset.y,
);
component.setViewportCulled(_isCulledByParentListView(component, parent));
component.setInheritedAlpha(parent?.renderAlpha ?? 1);
if (component.parent == target) {
return;
}

View File

@@ -43,6 +43,7 @@ List<({Rect source, Rect destination})> runtimeNineSliceRects({
double sliceTop = 0,
double sliceRight = 0,
double sliceBottom = 0,
double destinationOverlap = 0,
}) {
if (source.width <= 0 ||
source.height <= 0 ||
@@ -93,7 +94,7 @@ List<({Rect source, Rect destination})> runtimeNineSliceRects({
sourceXs[x + 1],
sourceYs[y + 1],
);
final destPart = Rect.fromLTRB(
final rawDestPart = Rect.fromLTRB(
destXs[x],
destYs[y],
destXs[x + 1],
@@ -101,16 +102,41 @@ List<({Rect source, Rect destination})> runtimeNineSliceRects({
);
if (sourcePart.width <= 0 ||
sourcePart.height <= 0 ||
destPart.width <= 0 ||
destPart.height <= 0) {
rawDestPart.width <= 0 ||
rawDestPart.height <= 0) {
continue;
}
final destPart = _overlapNineSliceDestinationRect(
rawDestPart,
x: x,
y: y,
bounds: destination,
overlap: destinationOverlap,
);
parts.add((source: sourcePart, destination: destPart));
}
}
return parts;
}
Rect _overlapNineSliceDestinationRect(
Rect rect, {
required int x,
required int y,
required Rect bounds,
required double overlap,
}) {
if (overlap <= 0) {
return rect;
}
return Rect.fromLTRB(
x == 0 ? rect.left : math.max(bounds.left, rect.left - overlap),
y == 0 ? rect.top : math.max(bounds.top, rect.top - overlap),
x == 2 ? rect.right : math.min(bounds.right, rect.right + overlap),
y == 2 ? rect.bottom : math.min(bounds.bottom, rect.bottom + overlap),
);
}
class RuntimeComponent extends PositionComponent
with HasVisibility, TapCallbacks {
RuntimeComponent({
@@ -157,8 +183,11 @@ class RuntimeComponent extends PositionComponent
double _parentContentOffsetY = 0;
bool _viewportCulled = false;
bool _pressed = false;
double _inheritedAlpha = 1;
double get renderAlpha => _runtimeAlpha ?? _node.alpha;
double get localAlpha => _runtimeAlpha ?? _node.alpha;
double get renderAlpha => (_inheritedAlpha * localAlpha).clamp(0.0, 1.0);
void setRuntimeAlpha(double value) {
final next = value.clamp(0, 1).toDouble();
@@ -166,7 +195,23 @@ class RuntimeComponent extends PositionComponent
return;
}
_runtimeAlpha = next;
_refreshInheritedAlphaSubtree();
}
void setInheritedAlpha(double value) {
final next = value.clamp(0, 1).toDouble();
if (_inheritedAlpha == next) {
return;
}
_inheritedAlpha = next;
_refreshInheritedAlphaSubtree();
}
void _refreshInheritedAlphaSubtree() {
_syncTextStyle(_node);
for (final child in children.whereType<RuntimeComponent>()) {
child.setInheritedAlpha(renderAlpha);
}
}
void setParentScroll({
@@ -194,6 +239,7 @@ class RuntimeComponent extends PositionComponent
_syncImage(node);
_syncSpine(node);
_syncParticle(node);
_refreshInheritedAlphaSubtree();
}
bool containsVisualPoint(Vector2 point) {
@@ -584,6 +630,7 @@ class RuntimeComponent extends PositionComponent
final parts = runtimeNineSliceRects(
source: source,
destination: destination,
destinationOverlap: 0.5,
sliceLeft: _node.sliceLeft ?? 0,
sliceTop: _node.sliceTop ?? 0,
sliceRight: _node.sliceRight ?? 0,
@@ -1174,6 +1221,7 @@ class RuntimeComponent extends PositionComponent
_releaseLoadedSpine();
_releaseParticle();
_runtimeAlpha = null;
_inheritedAlpha = 1;
_pressed = false;
_textComponent = null;
super.onRemove();

View File

@@ -116,6 +116,90 @@ void main() {
expect(component.renderAlpha, 1);
});
test('inherits parent alpha for prefab-like subtrees', () async {
final parent = RuntimeComponent(
node: const RuntimeNode(
id: 'parent',
type: RuntimeNodeType.panel,
alpha: 0.8,
),
resources: GameResourceManager(),
onNodeTap: (_, __) {},
);
final child = RuntimeComponent(
node: const RuntimeNode(
id: 'child',
type: RuntimeNodeType.text,
text: 'Child',
alpha: 0.5,
color: Color(0xffffffff),
),
resources: GameResourceManager(),
onNodeTap: (_, __) {},
);
parent.add(child);
parent.updateTree(0);
child.setInheritedAlpha(parent.renderAlpha);
expect(child.renderAlpha, closeTo(0.4, 0.001));
final text = child.children.whereType<TextComponent>().single;
expect(
((text.textRenderer as TextPaint).style.color!).a,
closeTo(0.4, 0.003),
);
parent.setRuntimeAlpha(0.25);
expect(child.renderAlpha, closeTo(0.125, 0.001));
final updatedText = child.children.whereType<TextComponent>().single;
expect(identical(updatedText, text), isTrue);
expect(
((updatedText.textRenderer as TextPaint).style.color!).a,
closeTo(0.125, 0.003),
);
});
test('propagates parent node alpha updates to child subtree', () {
final parent = RuntimeComponent(
node: const RuntimeNode(
id: 'parent',
type: RuntimeNodeType.panel,
alpha: 0.8,
),
resources: GameResourceManager(),
onNodeTap: (_, __) {},
);
final child = RuntimeComponent(
node: const RuntimeNode(
id: 'child',
type: RuntimeNodeType.text,
text: 'Child',
alpha: 0.5,
color: Color(0xffffffff),
),
resources: GameResourceManager(),
onNodeTap: (_, __) {},
);
parent.add(child);
parent.updateTree(0);
child.setInheritedAlpha(parent.renderAlpha);
parent.updateNode(
const RuntimeNode(
id: 'parent',
type: RuntimeNodeType.panel,
alpha: 0.6,
),
);
final text = child.children.whereType<TextComponent>().single;
expect(child.renderAlpha, closeTo(0.3, 0.001));
expect(
((text.textRenderer as TextPaint).style.color!).a,
closeTo(0.3, 0.003),
);
});
test('multiplies color alpha with node and runtime alpha', () {
expect(
composeRuntimeColorAlpha(const Color(0xffffffff), 1).a,
@@ -183,6 +267,23 @@ void main() {
expect(parts.last.destination, const Rect.fromLTRB(83, 112, 90, 120));
});
test('overlaps nine-slice destination seams without changing bounds', () {
final parts = runtimeNineSliceRects(
source: const Rect.fromLTWH(0, 0, 30, 30),
destination: const Rect.fromLTWH(0, 0, 90, 90),
sliceLeft: 10,
sliceTop: 10,
sliceRight: 10,
sliceBottom: 10,
destinationOverlap: 0.5,
);
expect(parts.first.destination, const Rect.fromLTRB(0, 0, 10.5, 10.5));
expect(parts[4].destination, const Rect.fromLTRB(9.5, 9.5, 80.5, 80.5));
expect(parts.last.destination, const Rect.fromLTRB(79.5, 79.5, 90, 90));
expect(parts[4].source, const Rect.fromLTRB(10, 10, 20, 20));
});
test('updates text alpha style without rebuilding text component', () {
final component = RuntimeComponent(
node: const RuntimeNode(