From 064033d68ca9b4084caf6821eaa762d125afea4e Mon Sep 17 00:00:00 2001 From: Yasser-Ameur Date: Tue, 8 Sep 2026 18:46:18 +0200 Subject: [PATCH 1/2] fix: Fade TextComponent shadows and fill together under OpacityEffect TextPaint.copyWithPaint only swapped the style's foreground for the component's paint, so shadows kept their alpha while the glyphs faded, and the first paint change replaced the text color with the default white paint. The shadows now scale with the paint's alpha, the paint starts from the style's color, and every paint change is derived from the renderer the user gave, which textRenderer now returns; the copy with the paint applied is paintedTextRenderer, used for drawing by TextComponent and TextBoxComponent. Closes #4013 --- .../src/components/text_box_component.dart | 2 +- .../lib/src/components/text_component.dart | 30 +++++++- .../lib/src/text/renderers/text_paint.dart | 12 ++++ .../test/components/text_component_test.dart | 72 ++++++++++++++++++- 4 files changed, 111 insertions(+), 5 deletions(-) diff --git a/packages/flame/lib/src/components/text_box_component.dart b/packages/flame/lib/src/components/text_box_component.dart index 519f0003083..f098fb752ab 100644 --- a/packages/flame/lib/src/components/text_box_component.dart +++ b/packages/flame/lib/src/components/text_box_component.dart @@ -401,7 +401,7 @@ class TextBoxComponent extends TextComponent { line = line.substring(0, nChars); } - final textElement = textRenderer.format(line); + final textElement = paintedTextRenderer.format(line); final metrics = textElement.metrics; final position = Vector2( diff --git a/packages/flame/lib/src/components/text_component.dart b/packages/flame/lib/src/components/text_component.dart index 30a9bb5760f..0131524764c 100644 --- a/packages/flame/lib/src/components/text_component.dart +++ b/packages/flame/lib/src/components/text_component.dart @@ -20,6 +20,8 @@ class TextComponent extends PositionComponent super.key, }) : _text = text ?? '', _textRenderer = textRenderer ?? TextRendererFactory.createDefault() { + _paintedTextRenderer = _textRenderer; + _seedPaintColor(); updateBounds(); } @@ -32,17 +34,41 @@ class TextComponent extends PositionComponent } } + /// The renderer as given by the user. Setting it also starts [paint] from + /// the style's own color, see [paintedTextRenderer]. T get textRenderer => _textRenderer; T _textRenderer; set textRenderer(T textRenderer) { _textRenderer = textRenderer; + _paintedTextRenderer = textRenderer; + _seedPaintColor(); updateBounds(); } + /// [textRenderer] with the component's [paint] applied, which is what the + /// text is drawn with. It is derived from [textRenderer] again on every + /// paint change, so the paint never compounds across changes. + @internal + T get paintedTextRenderer => _paintedTextRenderer; + late T _paintedTextRenderer; + + /// Starts [paint] from the style's own color, so that the first paint change + /// (for example an `OpacityEffect`) fades the text in its color instead of + /// replacing it with the default white paint. + void _seedPaintColor() { + final renderer = _textRenderer; + if (renderer is TextPaint) { + final color = renderer.style.color; + if (color != null) { + paint.color = color; + } + } + } + late InlineTextElement _textElement; void _updateElement() { - _textElement = _textRenderer.format(_text); + _textElement = _paintedTextRenderer.format(_text); } @internal @@ -60,7 +86,7 @@ class TextComponent extends PositionComponent @override void onChanged() { - _textRenderer = _textRenderer.copyWithPaint(paint) as T; + _paintedTextRenderer = _textRenderer.copyWithPaint(paint) as T; _updateElement(); } } diff --git a/packages/flame/lib/src/text/renderers/text_paint.dart b/packages/flame/lib/src/text/renderers/text_paint.dart index 1202523cd83..afa75090018 100644 --- a/packages/flame/lib/src/text/renderers/text_paint.dart +++ b/packages/flame/lib/src/text/renderers/text_paint.dart @@ -58,8 +58,20 @@ class TextPaint extends TextRenderer { TextRenderer copyWithPaint(Paint paint) { return copyWith( (style) { + final shadows = style.shadows; return style.copyWith( foreground: paint, + shadows: shadows + ?.map( + (shadow) => Shadow( + color: shadow.color.withValues( + alpha: shadow.color.a * paint.color.a, + ), + offset: shadow.offset, + blurRadius: shadow.blurRadius, + ), + ) + .toList(), ); }, ); diff --git a/packages/flame/test/components/text_component_test.dart b/packages/flame/test/components/text_component_test.dart index d1c3cfd5806..6dc53c75a46 100644 --- a/packages/flame/test/components/text_component_test.dart +++ b/packages/flame/test/components/text_component_test.dart @@ -1,6 +1,9 @@ -import 'package:flame/src/components/text_component.dart'; +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flame/text.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter/rendering.dart'; import 'package:test/test.dart'; -import 'package:vector_math/vector_math.dart'; void main() { group('TextComponent', () { @@ -8,5 +11,70 @@ void main() { final t = TextComponent(text: 'foobar'); expect(t.size, isNot(equals(Vector2.zero()))); }); + + testWithFlameGame( + 'fades the text in its own color and the shadows with it under an ' + 'OpacityEffect', + (game) async { + const color = Color(0xFF2E9940); + const shadowColor = Color(0x99FFFFFF); + final component = TextComponent( + text: '+1 kr', + textRenderer: TextPaint( + style: const TextStyle( + color: color, + shadows: [Shadow(color: shadowColor, blurRadius: 4)], + ), + ), + ); + await game.ensureAdd(component); + component.add(OpacityEffect.fadeOut(EffectController(duration: 1))); + + game.update(0.5); + var style = component.paintedTextRenderer.style; + final fill = style.foreground!.color; + expect(fill.toARGB32() & 0xFFFFFF, color.toARGB32() & 0xFFFFFF); + expectDouble(fill.a, 0.5, epsilon: 0.05); + var shadow = style.shadows!.single; + expect( + shadow.color.toARGB32() & 0xFFFFFF, + shadowColor.toARGB32() & 0xFFFFFF, + ); + expectDouble(shadow.color.a, shadowColor.a * 0.5, epsilon: 0.05); + + game.update(0.5); + style = component.paintedTextRenderer.style; + expectDouble(style.foreground!.color.a, 0.0, epsilon: 0.05); + shadow = style.shadows!.single; + expectDouble(shadow.color.a, 0.0, epsilon: 0.05); + }, + ); + + test('applies the paint to the renderer the user set, not to a copy', () { + const color = Color(0xFF2E9940); + const shadowColor = Color(0x99FFFFFF); + final component = TextComponent( + text: '+1 kr', + textRenderer: TextPaint( + style: const TextStyle( + color: color, + shadows: [Shadow(color: shadowColor, blurRadius: 4)], + ), + ), + ); + + component.setOpacity(0.5); + component.textRenderer = component.textRenderer.copyWith( + (style) => style.copyWith(fontSize: 30), + ); + component.setOpacity(0.25); + + expect(component.textRenderer.style.color, color); + expect(component.textRenderer.style.shadows!.single.color, shadowColor); + final painted = component.paintedTextRenderer.style; + expect(painted.fontSize, 30); + expectDouble(painted.foreground!.color.a, 0.25); + expectDouble(painted.shadows!.single.color.a, shadowColor.a * 0.25); + }); }); } From d408a77480d4ecd840540443e03982a45c7b7d26 Mon Sep 17 00:00:00 2001 From: Yasser-Ameur Date: Tue, 8 Sep 2026 20:33:34 +0200 Subject: [PATCH 2/2] Close the round-trip test block lost in the merge --- packages/flame/test/components/text_component_test.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/flame/test/components/text_component_test.dart b/packages/flame/test/components/text_component_test.dart index 806b24568ab..03a51695aff 100644 --- a/packages/flame/test/components/text_component_test.dart +++ b/packages/flame/test/components/text_component_test.dart @@ -75,6 +75,7 @@ void main() { expect(painted.fontSize, 30); expectDouble(painted.foreground!.color.a, 0.25); expectDouble(painted.shadows!.single.color.a, shadowColor.a * 0.25); + }); test('keeps the glyphs translated by the ascent after a paint change', () { final elements = <_RecordingTextElement>[];