From 8d2c97269abd45649c39368f1962e578262d2a5d Mon Sep 17 00:00:00 2001 From: gem Date: Tue, 9 Jun 2026 11:26:51 +0800 Subject: [PATCH] Fix runtime color alpha composition --- CHANGELOG.md | 4 +++ docs/protocol.md | 10 ++++++ lib/runtime/rendering/runtime_component.dart | 33 ++++++++++++++----- .../rendering/runtime_component_test.dart | 24 ++++++++++++++ 4 files changed, 62 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 220f564..c0cebf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Fixed Runtime node color alpha composition so `#AARRGGBB` alpha now multiplies with node/runtime alpha instead of being overwritten. + ## 0.1.0 - Initial extracted package skeleton for `flame_lua_runtime`. diff --git a/docs/protocol.md b/docs/protocol.md index 5fd501e..1293aa0 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -34,6 +34,16 @@ Supported node concepts include: Lua may compose higher-level widgets, but those widgets must normalize into supported runtime nodes. +### Color and alpha + +`RuntimeNode.color` supports `#RRGGBB` and `#AARRGGBB`. When the alpha channel is present in the color, it is multiplied with `RuntimeNode.alpha` and any runtime animation alpha. + +```text +Final opacity = color alpha × node alpha × runtime animation alpha +``` + +`#RRGGBB` colors behave as fully opaque colors. `#00000000` is fully transparent. + ## RuntimeCommand Runtime commands request generic side effects owned by Dart/Flame. diff --git a/lib/runtime/rendering/runtime_component.dart b/lib/runtime/rendering/runtime_component.dart index 6404075..cd7e2c4 100644 --- a/lib/runtime/rendering/runtime_component.dart +++ b/lib/runtime/rendering/runtime_component.dart @@ -12,6 +12,11 @@ import '../models/runtime_node.dart'; import '../protocol/runtime_protocol.dart'; import '../resources/game_resource_manager.dart'; +@visibleForTesting +Color composeRuntimeColorAlpha(Color color, double alpha) { + return color.withValues(alpha: color.a * alpha.clamp(0.0, 1.0)); +} + class RuntimeComponent extends PositionComponent with HasVisibility, TapCallbacks { RuntimeComponent({ @@ -156,7 +161,10 @@ class RuntimeComponent extends PositionComponent } final paint = Paint() - ..color = (_node.color ?? _defaultColor()).withValues(alpha: renderAlpha); + ..color = composeRuntimeColorAlpha( + _node.color ?? _defaultColor(), + renderAlpha, + ); canvas.save(); canvas.transform(Float64List.fromList(transform.transformMatrix.storage)); @@ -177,7 +185,10 @@ class RuntimeComponent extends PositionComponent @override void render(Canvas canvas) { final paint = Paint() - ..color = (_node.color ?? _defaultColor()).withValues(alpha: renderAlpha); + ..color = composeRuntimeColorAlpha( + _node.color ?? _defaultColor(), + renderAlpha, + ); switch (_node.type) { case RuntimeNodeType.circle: @@ -224,7 +235,7 @@ class RuntimeComponent extends PositionComponent final rect = Rect.fromLTWH(0, 0, size.x, size.y); final radius = Radius.circular(_node.radius ?? 4); final backgroundPaint = Paint() - ..color = const Color(0x33475569).withValues(alpha: renderAlpha); + ..color = composeRuntimeColorAlpha(const Color(0x33475569), renderAlpha); canvas.drawRRect(RRect.fromRectAndRadius(rect, radius), backgroundPaint); final value = _node.value ?? 0; @@ -327,11 +338,15 @@ class RuntimeComponent extends PositionComponent final contentRect = _listViewContentRect(); final scrollbars = _listViewScrollbarVisibility(); final trackPaint = Paint() - ..color = (_node.scrollbarTrackColor ?? const Color(0x33475569)) - .withValues(alpha: renderAlpha); + ..color = composeRuntimeColorAlpha( + _node.scrollbarTrackColor ?? const Color(0x33475569), + renderAlpha, + ); final thumbPaint = Paint() - ..color = (_node.scrollbarThumbColor ?? const Color(0xaa94a3b8)) - .withValues(alpha: renderAlpha); + ..color = composeRuntimeColorAlpha( + _node.scrollbarThumbColor ?? const Color(0xaa94a3b8), + renderAlpha, + ); final contentHeight = _node.contentHeight; if (scrollbars.vertical && @@ -415,7 +430,7 @@ class RuntimeComponent extends PositionComponent image, Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()), rect, - Paint()..color = Colors.white.withValues(alpha: renderAlpha), + Paint()..color = composeRuntimeColorAlpha(Colors.white, renderAlpha), ); return; } @@ -948,7 +963,7 @@ class RuntimeComponent extends PositionComponent final text = label ?? ''; final color = _textColor(node); final style = TextStyle( - color: color.withValues(alpha: renderAlpha), + color: composeRuntimeColorAlpha(color, renderAlpha), fontSize: node.fontSize ?? 18, fontWeight: node.type == RuntimeNodeType.button ? FontWeight.w600 diff --git a/test/runtime/rendering/runtime_component_test.dart b/test/runtime/rendering/runtime_component_test.dart index 6fea432..0b48c41 100644 --- a/test/runtime/rendering/runtime_component_test.dart +++ b/test/runtime/rendering/runtime_component_test.dart @@ -3,6 +3,7 @@ import 'package:flame_lua_runtime/runtime/models/runtime_node.dart'; import 'package:flame_lua_runtime/runtime/protocol/runtime_protocol.dart'; import 'package:flame_lua_runtime/runtime/rendering/runtime_component.dart'; import 'package:flame_lua_runtime/runtime/resources/game_resource_manager.dart'; +import 'package:flutter/material.dart' show Color; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -114,6 +115,29 @@ void main() { expect(component.renderAlpha, 1); }); + test('multiplies color alpha with node and runtime alpha', () { + expect( + composeRuntimeColorAlpha(const Color(0xffffffff), 1).a, + closeTo(1, 0.001), + ); + expect( + composeRuntimeColorAlpha(const Color(0x80ffffff), 1).a, + closeTo(0.5, 0.003), + ); + expect( + composeRuntimeColorAlpha(const Color(0x80ffffff), 0.5).a, + closeTo(0.25, 0.003), + ); + expect( + composeRuntimeColorAlpha(const Color(0x00ffffff), 1).a, + closeTo(0, 0.001), + ); + expect( + composeRuntimeColorAlpha(const Color(0x80ffffff), 0.25).a, + closeTo(0.125, 0.003), + ); + }); + test('multi-line non-button text is top aligned', () { final component = RuntimeComponent( node: const RuntimeNode(