From 4ce5fe1ae746f7f5ab9cfbfbdb10d0f909fb8423 Mon Sep 17 00:00:00 2001 From: gem Date: Tue, 9 Jun 2026 13:52:07 +0800 Subject: [PATCH] Fix nine-slice seams and inherited alpha --- CHANGELOG.md | 2 + .../rendering/render_tree_controller.dart | 1 + lib/runtime/rendering/runtime_component.dart | 56 +++++++++- .../rendering/runtime_component_test.dart | 101 ++++++++++++++++++ 4 files changed, 156 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a788ef..6adcc61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/lib/runtime/rendering/render_tree_controller.dart b/lib/runtime/rendering/render_tree_controller.dart index 31f3cb8..28cb5e6 100644 --- a/lib/runtime/rendering/render_tree_controller.dart +++ b/lib/runtime/rendering/render_tree_controller.dart @@ -327,6 +327,7 @@ class RenderTreeController { contentOffsetY: parentContentOffset.y, ); component.setViewportCulled(_isCulledByParentListView(component, parent)); + component.setInheritedAlpha(parent?.renderAlpha ?? 1); if (component.parent == target) { return; } diff --git a/lib/runtime/rendering/runtime_component.dart b/lib/runtime/rendering/runtime_component.dart index a68d703..42484f5 100644 --- a/lib/runtime/rendering/runtime_component.dart +++ b/lib/runtime/rendering/runtime_component.dart @@ -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()) { + 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(); diff --git a/test/runtime/rendering/runtime_component_test.dart b/test/runtime/rendering/runtime_component_test.dart index 734385a..c2b3848 100644 --- a/test/runtime/rendering/runtime_component_test.dart +++ b/test/runtime/rendering/runtime_component_test.dart @@ -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().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().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().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(