diff --git a/.github/.cspell/words_dictionary.txt b/.github/.cspell/words_dictionary.txt index 53f0e78a24c..f5acea2e007 100644 --- a/.github/.cspell/words_dictionary.txt +++ b/.github/.cspell/words_dictionary.txt @@ -8,6 +8,7 @@ hoverable Hoverables inactives layouting +NTSC orientable platformer positionable diff --git a/doc/flame/effects/color_effects.md b/doc/flame/effects/color_effects.md index efbd903bca2..37f77900fbf 100644 --- a/doc/flame/effects/color_effects.md +++ b/doc/flame/effects/color_effects.md @@ -136,3 +136,45 @@ final effect = GlowEffect( ``` Currently this effect can only be applied to components that have a `HasPaint` mixin. + + +## `HueToEffect` + +This effect will change the hue of the target over time to the specified angle in radians. +It can only be applied to components that implement the `HueProvider`. + +```dart +final effect = HueEffect.to( + pi / 2, + EffectController(duration: 3), +); +``` + + +## `HueByEffect` + +This effect will rotate the hue of the target relative by the specified angle in radians. +It can only be applied to components that implement the `HueProvider`. + +```{flutter-app} +:sources: ../flame/examples +:page: hue_effect +:show: widget code infobox +:width: 180 +:height: 160 +``` + +```dart +final effect = HueEffect.by( + 2 * pi, + EffectController(duration: 3), +); +``` + +Both effects can target any component implementing `HueProvider`. The `HasPaint` mixin +implements `HueProvider` and handles the necessary `ColorFilter` updates automatically. + +> [!TIP] +> **Performance Note**: `HueEffect` is extremely efficient because it modifies the `Paint`'s +> `colorFilter` directly. If you have many components, prefer this effect over the `HueDecorator`, +> which uses `saveLayer()` and has much higher overhead. diff --git a/doc/flame/effects/effects.md b/doc/flame/effects/effects.md index 6ef5ba4632b..459792b9a8d 100644 --- a/doc/flame/effects/effects.md +++ b/doc/flame/effects/effects.md @@ -105,6 +105,20 @@ that property to a fixed value. This way multiple effects would be able to act o without interfering with each other. +## Effects vs Decorators + +While effects and decorators can sometimes achieve similar visual results (like changing opacity +or color), they have different performance and visual characteristics: + +- **Effects** are fast and generally change a property on a single component. When applied to + a group, they affect each child individually. +- **Decorators** are more powerful but slower. They use `saveLayer` to flatten a whole + component subtree into a single layer before applying an effect. This is essential for + correctly rendering composite objects with transparency or complex filters. + +See the [Decorators documentation](../rendering/decorators.md) for a more detailed comparison. + + ## See also - [Examples of various effects](https://examples.flame-engine.org/). diff --git a/doc/flame/examples/lib/decorator_hue.dart b/doc/flame/examples/lib/decorator_hue.dart new file mode 100644 index 00000000000..94c8e4c7e78 --- /dev/null +++ b/doc/flame/examples/lib/decorator_hue.dart @@ -0,0 +1,32 @@ +import 'dart:math'; + +import 'package:doc_flame_examples/flower.dart'; +import 'package:flame/game.dart'; +import 'package:flame/rendering.dart'; + +class DecoratorHueGame extends FlameGame { + @override + Future onLoad() async { + var step = 0; + add( + Flower( + size: 100, + position: canvasSize / 2, + onTap: (flower) { + final decorator = flower.decorator; + step++; + if (step == 1) { + decorator.addLast(HueDecorator(hue: pi / 4)); + } else if (step == 2) { + decorator.replaceLast(HueDecorator(hue: pi / 2)); + } else if (step == 3) { + decorator.replaceLast(HueDecorator(hue: pi)); + } else { + decorator.replaceLast(null); + step = 0; + } + }, + )..onTapUp(), + ); + } +} diff --git a/doc/flame/examples/lib/hue_effect.dart b/doc/flame/examples/lib/hue_effect.dart new file mode 100644 index 00000000000..6b396f25ec4 --- /dev/null +++ b/doc/flame/examples/lib/hue_effect.dart @@ -0,0 +1,26 @@ +import 'dart:math'; + +import 'package:doc_flame_examples/ember.dart'; +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flame/game.dart'; + +class HueEffectExample extends FlameGame { + @override + Future onLoad() async { + final ember = EmberPlayer( + position: size / 2, + size: size / 4, + onTap: (ember) { + ember.add( + HueEffect.by( + 2 * pi, + EffectController(duration: 3), + ), + ); + }, + )..anchor = Anchor.center; + + add(ember); + } +} diff --git a/doc/flame/examples/lib/main.dart b/doc/flame/examples/lib/main.dart index 5bf3fef6b0f..6e8fa5f8584 100644 --- a/doc/flame/examples/lib/main.dart +++ b/doc/flame/examples/lib/main.dart @@ -5,11 +5,13 @@ import 'package:doc_flame_examples/collision_detection.dart'; import 'package:doc_flame_examples/color_effect.dart'; import 'package:doc_flame_examples/decorator_blur.dart'; import 'package:doc_flame_examples/decorator_grayscale.dart'; +import 'package:doc_flame_examples/decorator_hue.dart'; import 'package:doc_flame_examples/decorator_rotate3d.dart'; import 'package:doc_flame_examples/decorator_shadow3d.dart'; import 'package:doc_flame_examples/decorator_tint.dart'; import 'package:doc_flame_examples/drag_events.dart'; import 'package:doc_flame_examples/glow_effect.dart'; +import 'package:doc_flame_examples/hue_effect.dart'; import 'package:doc_flame_examples/move_along_path_effect.dart'; import 'package:doc_flame_examples/move_by_effect.dart'; import 'package:doc_flame_examples/move_to_effect.dart'; @@ -39,18 +41,20 @@ import 'package:flutter/widgets.dart'; import 'package:web/web.dart' as web; final routes = { + 'anchor': AnchorGame.new, 'anchor_by_effect': AnchorByEffectGame.new, 'anchor_to_effect': AnchorToEffectGame.new, - 'anchor': AnchorGame.new, 'collision_detection': CollisionDetectionGame.new, 'color_effect': ColorEffectExample.new, 'decorator_blur': DecoratorBlurGame.new, 'decorator_grayscale': DecoratorGrayscaleGame.new, + 'decorator_hue': DecoratorHueGame.new, 'decorator_rotate3d': DecoratorRotate3DGame.new, 'decorator_shadow3d': DecoratorShadowGame.new, 'decorator_tint': DecoratorTintGame.new, 'drag_events': DragEventsGame.new, 'glow_effect': GlowEffectExample.new, + 'hue_effect': HueEffectExample.new, 'move_along_path_effect': MoveAlongPathEffectGame.new, 'move_by_effect': MoveByEffectGame.new, 'move_to_effect': MoveToEffectGame.new, diff --git a/doc/flame/rendering/decorators.md b/doc/flame/rendering/decorators.md index fb15f8d557d..7828e6b8f10 100644 --- a/doc/flame/rendering/decorators.md +++ b/doc/flame/rendering/decorators.md @@ -10,6 +10,42 @@ necessary. We are planning to add shader-based decorators once Flutter fully sup web. +## Performance considerations + +Applying a Decorator to a component can have a significant performance overhead, especially when +it involves `canvas.saveLayer()`. + +- **Decorators**: Use `canvas.saveLayer()` by default to isolate rendering and apply + filters. This requires off-screen buffer allocation and GPU context switches. This is + computationally expensive but essential for correct visual composition of complex + objects (see below). +- **Effects** (e.g., `OpacityEffect`, `ColorEffect`): Modify the component's properties + or `Paint` directly. These are extremely fast and hardware-accelerated, but they apply + to each child individually. + + +### Decorators vs Effects: Visual Composition + +The key difference lies in how they handle composite objects (components with multiple +overlapping children): + +1. **Effects (Individual Blend)**: If you apply an `OpacityEffect` to a parent component, + Flame will render each child with that opacity. If children overlap, you will see + through them to the background and to other children, creating a "double-exposure" + look. +2. **Decorators (Group Blend)**: Because decorators use `saveLayer`, they render the + entire subtree into a flat buffer first, and then apply the effect to that + buffer. This results in a uniform appearance where overlaps are not visible, + making the group look like a single solid object. + +**Recommendation**: + +- Use **Effects** for simple property animations and high-performance color shifts on + large numbers of units. +- Use **Decorators** for advanced post-processing (blurs, tints) and when you need + to treat a group of components as a single visual unit. + + ## Flame built-in decorators @@ -157,6 +193,29 @@ limitation is that the shadows are flat and cannot interact with the environment decorator cannot handle shadows that fall onto walls or other vertical structures. +### HueDecorator + +```{flutter-app} +:sources: ../flame/examples +:page: decorator_hue +:show: widget code infobox +:width: 180 +:height: 160 +``` + +This decorator shifts the hue of the underlying component by the specified angle in radians. + +```dart +final decorator = HueDecorator(hue: tau / 4); +``` + +Possible uses: + +- alternative color schemes for enemies ("palette swapping"); +- environmental changes (e.g., world turning purple/surreal); +- power-up indicators. + + ## Using decorators @@ -175,7 +234,7 @@ components the `HasDecorator` mixin is not needed. In fact, the `PositionComponent` uses its decorator in order to properly position the component on the screen. Thus, any new decorators that you'd want to apply to the `PositionComponent` will need -to be chained (see the [](#multiple-decorators) section below). +to be chained (see the [Multiple decorators](#multiple-decorators) section below). It is also possible to replace the root decorator of the `PositionComponent`, if you want to create an alternative logic for how the component shall be positioned on the screen. @@ -196,5 +255,5 @@ from its root, which usually is `component.decorator`. [Component]: ../components/components.md#component -[Effect]: ../../flame/effects.md +[Effect]: ../effects/effects.md [HasDecorator]: #hasdecorator-mixin diff --git a/examples/lib/main.dart b/examples/lib/main.dart index f8037ef8d4d..c614f0c75d4 100644 --- a/examples/lib/main.dart +++ b/examples/lib/main.dart @@ -29,6 +29,7 @@ import 'package:examples/stories/image/image.dart'; import 'package:examples/stories/input/input.dart'; import 'package:examples/stories/layout/layout.dart'; import 'package:examples/stories/parallax/parallax.dart'; +import 'package:examples/stories/rendering/decorators.dart'; import 'package:examples/stories/rendering/rendering.dart'; import 'package:examples/stories/router/router.dart'; import 'package:examples/stories/sprites/sprites.dart'; @@ -83,6 +84,7 @@ void runAsDashbook() { addCameraAndViewportStories(dashbook); addCollisionDetectionStories(dashbook); addComponentsStories(dashbook); + addDecoratorStories(dashbook); addEffectsStories(dashbook); addExperimentalStories(dashbook); addInputStories(dashbook); diff --git a/examples/lib/stories/effects/effects.dart b/examples/lib/stories/effects/effects.dart index 97f1fdf6611..4d6dd242f2f 100644 --- a/examples/lib/stories/effects/effects.dart +++ b/examples/lib/stories/effects/effects.dart @@ -5,6 +5,7 @@ import 'package:examples/stories/effects/combined_effect_example.dart'; import 'package:examples/stories/effects/dual_effect_removal_example.dart'; import 'package:examples/stories/effects/effect_controllers_example.dart'; import 'package:examples/stories/effects/function_effect_example.dart'; +import 'package:examples/stories/effects/hue_effect_example.dart'; import 'package:examples/stories/effects/move_effect_example.dart'; import 'package:examples/stories/effects/opacity_effect_example.dart'; import 'package:examples/stories/effects/remove_effect_example.dart'; @@ -59,6 +60,12 @@ void addEffectsStories(Dashbook dashbook) { codeLink: baseLink('effects/opacity_effect_example.dart'), info: OpacityEffectExample.description, ) + ..add( + 'Hue Effect', + (_) => GameWidget(game: HueEffectExample()), + codeLink: baseLink('effects/hue_effect_example.dart'), + info: HueEffectExample.description, + ) ..add( 'Color Effect', (_) => GameWidget(game: ColorEffectExample()), diff --git a/examples/lib/stories/effects/hue_effect_example.dart b/examples/lib/stories/effects/hue_effect_example.dart new file mode 100644 index 00000000000..ccd7a91e019 --- /dev/null +++ b/examples/lib/stories/effects/hue_effect_example.dart @@ -0,0 +1,30 @@ +import 'dart:math'; + +import 'package:examples/commons/ember.dart'; +import 'package:flame/effects.dart'; +import 'package:flame/game.dart'; + +class HueEffectExample extends FlameGame { + static const String description = ''' +In this example we show how the `HueEffect` can be used. +Ember will shift its hue over time. +'''; + + @override + Future onLoad() async { + add( + Ember( + position: size / 2, + size: Vector2.all(100), + )..add( + HueEffect.by( + 2 * pi, + EffectController( + duration: 3, + infinite: true, + ), + ), + ), + ); + } +} diff --git a/examples/lib/stories/rendering/decorator_hue_example.dart b/examples/lib/stories/rendering/decorator_hue_example.dart new file mode 100644 index 00000000000..38bd71c8b92 --- /dev/null +++ b/examples/lib/stories/rendering/decorator_hue_example.dart @@ -0,0 +1,51 @@ +import 'dart:math'; + +import 'package:examples/commons/ember.dart'; +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flame/game.dart'; +import 'package:flame/rendering.dart'; + +class DecoratorHueExample extends FlameGame with TapCallbacks { + static const String description = ''' +This example demonstrates the usage of `HueDecorator` to shift the +colors of a component. + +Basic `HueDecorator` shifting the hue of an Ember component. + +Click to cycle through hue shifts. +'''; + + late final HueDecorator decorator; + int step = 0; + + @override + Future onLoad() async { + decorator = HueDecorator(); + world.add( + PositionComponent( + size: Vector2(150, 120), + anchor: Anchor.center, + children: [ + Ember( + size: Vector2.all(80), + position: Vector2(75, 40), + ), + TextComponent( + text: 'HueDecorator', + position: Vector2(75, 100), + anchor: Anchor.center, + ), + ], + )..decorator.addLast(decorator), + ); + } + + @override + void onTapDown(TapDownEvent event) { + step++; + final hues = [0.0, pi / 4, pi / 2, pi, 0.0]; + final hue = hues[step % hues.length]; + decorator.hue = hue; + } +} diff --git a/examples/lib/stories/rendering/decorator_vs_effect_example.dart b/examples/lib/stories/rendering/decorator_vs_effect_example.dart new file mode 100644 index 00000000000..ac2a6643d73 --- /dev/null +++ b/examples/lib/stories/rendering/decorator_vs_effect_example.dart @@ -0,0 +1,94 @@ +import 'package:examples/commons/ember.dart'; +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flame/experimental.dart'; +import 'package:flame/game.dart'; +import 'package:flame/rendering.dart'; +import 'package:flutter/widgets.dart'; + +class DecoratorVsEffectExample extends FlameGame { + static const String description = ''' +This example demonstrates the difference between using an `Effect` and a +`Decorator` for group transparency. + +1. Top (OpacityEffect): +Opacity is applied to EACH child individually. +Note the "double-exposure" where the sprites overlap. + +2. Bottom (Decorator): +The entire group is flattened into a layer first using `saveLayer`, +and then transparency is applied to the whole layer. +Note how the overlapping area is uniform. +'''; + + @override + Future onLoad() async { + final groupA = _buildItem( + 'OpacityEffect (Individual Blend)', + _buildCompositeObject() + ..children.forEach((child) { + if (child is OpacityProvider) { + child.add(OpacityEffect.to(0.5, EffectController(duration: 0))); + } + }), + ); + + final groupB = _buildItem( + 'Decorator (Group Blend)', + _buildCompositeObject(), + )..decorator.addLast(_GroupOpacityDecorator(0.5)); + + world.add( + ColumnComponent( + gap: 150, + anchor: Anchor.center, + children: [groupA, groupB], + ), + ); + } + + PositionComponent _buildItem(String title, Component object) { + return PositionComponent( + size: Vector2(150, 120), + children: [ + object, + TextComponent( + text: title, + position: Vector2(0, 80), + anchor: Anchor.center, + ), + ], + ); + } + + /// Builds an object consisting of two overlapping Embers. + Component _buildCompositeObject() { + return PositionComponent( + children: [ + Ember( + size: Vector2.all(100), + position: Vector2(-25, 0), + ), + Ember( + size: Vector2.all(100), + position: Vector2(25, 0), + ), + ], + ); + } +} + +/// A simple decorator that applies opacity to the entire decorated subtree. +class _GroupOpacityDecorator extends Decorator { + _GroupOpacityDecorator(double opacity) + : _paint = Paint()..color = Color.fromRGBO(255, 255, 255, opacity); + + final Paint _paint; + + @override + void apply(void Function(Canvas) draw, Canvas canvas) { + canvas.saveLayer(null, _paint); + draw(canvas); + canvas.restore(); + } +} diff --git a/examples/lib/stories/rendering/decorators.dart b/examples/lib/stories/rendering/decorators.dart new file mode 100644 index 00000000000..6e702aece6f --- /dev/null +++ b/examples/lib/stories/rendering/decorators.dart @@ -0,0 +1,21 @@ +import 'package:dashbook/dashbook.dart'; +import 'package:examples/commons/commons.dart'; +import 'package:examples/stories/rendering/decorator_hue_example.dart'; +import 'package:examples/stories/rendering/decorator_vs_effect_example.dart'; +import 'package:flame/game.dart'; + +void addDecoratorStories(Dashbook dashbook) { + dashbook.storiesOf('Decorators') + ..add( + 'Decorator Hue', + (_) => GameWidget(game: DecoratorHueExample()), + codeLink: baseLink('rendering/decorator_hue_example.dart'), + info: DecoratorHueExample.description, + ) + ..add( + 'Decorators vs Effects', + (_) => GameWidget(game: DecoratorVsEffectExample()), + codeLink: baseLink('rendering/decorator_vs_effect_example.dart'), + info: DecoratorVsEffectExample.description, + ); +} diff --git a/packages/flame/lib/effects.dart b/packages/flame/lib/effects.dart index 2f0ed154e1a..a7c46559a83 100644 --- a/packages/flame/lib/effects.dart +++ b/packages/flame/lib/effects.dart @@ -25,6 +25,9 @@ export 'src/effects/effect.dart'; export 'src/effects/effect_target.dart'; export 'src/effects/function_effect.dart'; export 'src/effects/glow_effect.dart'; +export 'src/effects/hue_by_effect.dart'; +export 'src/effects/hue_effect.dart'; +export 'src/effects/hue_to_effect.dart'; export 'src/effects/move_along_path_effect.dart'; export 'src/effects/move_by_effect.dart'; export 'src/effects/move_effect.dart'; @@ -41,7 +44,9 @@ export 'src/effects/provider_interfaces.dart' ReadOnlyPositionProvider, ReadOnlyScaleProvider, ReadOnlySizeProvider, - OpacityProvider; + OpacityProvider, + PaintProvider, + HueProvider; export 'src/effects/remove_effect.dart'; export 'src/effects/rotate_around_effect.dart'; export 'src/effects/rotate_effect.dart'; diff --git a/packages/flame/lib/rendering.dart b/packages/flame/lib/rendering.dart index 481d430d4c4..550ba85cfc8 100644 --- a/packages/flame/lib/rendering.dart +++ b/packages/flame/lib/rendering.dart @@ -1,4 +1,5 @@ export 'src/rendering/decorator.dart' show Decorator; +export 'src/rendering/hue_decorator.dart' show HueDecorator, hueRotationMatrix; export 'src/rendering/mutable_transform.dart' show MutableRSTransform; export 'src/rendering/paint_decorator.dart' show PaintDecorator; export 'src/rendering/rotate3d_decorator.dart' show Rotate3DDecorator; diff --git a/packages/flame/lib/src/components/mixins/has_paint.dart b/packages/flame/lib/src/components/mixins/has_paint.dart index 6b45e9d3bae..48a0ce81744 100644 --- a/packages/flame/lib/src/components/mixins/has_paint.dart +++ b/packages/flame/lib/src/components/mixins/has_paint.dart @@ -3,8 +3,8 @@ import 'dart:ui'; import 'package:flame/components.dart'; import 'package:flame/effects.dart'; -import 'package:flame/src/effects/provider_interfaces.dart'; import 'package:flame/src/palette.dart'; +import 'package:flame/src/rendering/hue_decorator.dart'; import 'package:meta/meta.dart'; /// Adds a collection of paints and paint layers to a component @@ -16,12 +16,14 @@ import 'package:meta/meta.dart'; /// [paintLayers] paints should be drawn in list order during the render. The /// main Paint is the first element. mixin HasPaint on Component - implements OpacityProvider, PaintProvider { + implements OpacityProvider, PaintProvider, HueProvider { late final Map _paints = {}; @override Paint paint = BasicPalette.white.paint(); + double _hue = 0.0; + @internal List? paintLayersInternal; @@ -131,6 +133,33 @@ mixin HasPaint on Component } } + @override + double get hue => _hue; + + @override + set hue(double value) { + if (_hue == value) { + return; + } + _hue = value; + _updateColorFilter(); + } + + void _updateColorFilter() { + final filter = _hue == 0 + ? null + : ColorFilter.matrix(hueRotationMatrix(_hue)); + paint.colorFilter = filter; + for (final paint in _paints.values) { + paint.colorFilter = filter; + } + if (paintLayersInternal != null) { + for (final layerPaint in paintLayersInternal!) { + layerPaint.colorFilter = filter; + } + } + } + /// Creates an [OpacityProvider] for given [paintId] and can be used as /// `target` for [OpacityEffect]. OpacityProvider opacityProviderOf(T paintId) { diff --git a/packages/flame/lib/src/effects/glow_effect.dart b/packages/flame/lib/src/effects/glow_effect.dart index 6788e9e55fb..22b19b6e2cd 100644 --- a/packages/flame/lib/src/effects/glow_effect.dart +++ b/packages/flame/lib/src/effects/glow_effect.dart @@ -1,7 +1,6 @@ import 'dart:ui'; import 'package:flame/effects.dart'; -import 'package:flame/src/effects/provider_interfaces.dart'; /// Change the MaskFilter on Paint of a component over time. /// diff --git a/packages/flame/lib/src/effects/hue_by_effect.dart b/packages/flame/lib/src/effects/hue_by_effect.dart new file mode 100644 index 00000000000..77c02e8aa02 --- /dev/null +++ b/packages/flame/lib/src/effects/hue_by_effect.dart @@ -0,0 +1,23 @@ +import 'package:flame/src/effects/hue_effect.dart'; +import 'package:flame/src/effects/provider_interfaces.dart'; + +/// An effect that changes the hue of a component by a specified angle. +class HueByEffect extends HueEffect { + HueByEffect( + double angle, + super.controller, { + HueProvider? target, + super.onComplete, + super.key, + }) : _angle = angle { + this.target = target; + } + + final double _angle; + + @override + void apply(double progress) { + final dProgress = progress - previousProgress; + target.hue += _angle * dProgress; + } +} diff --git a/packages/flame/lib/src/effects/hue_effect.dart b/packages/flame/lib/src/effects/hue_effect.dart new file mode 100644 index 00000000000..774ab40b48e --- /dev/null +++ b/packages/flame/lib/src/effects/hue_effect.dart @@ -0,0 +1,52 @@ +import 'package:flame/components.dart'; +import 'package:flame/src/effects/controllers/effect_controller.dart'; +import 'package:flame/src/effects/effect.dart'; +import 'package:flame/src/effects/effect_target.dart'; +import 'package:flame/src/effects/hue_by_effect.dart'; +import 'package:flame/src/effects/hue_to_effect.dart'; +import 'package:flame/src/effects/provider_interfaces.dart'; + +/// An effect that changes the hue of a component over time. +/// +/// This effect applies incremental changes to the hue property of the target, +/// and requires that any other effect or update logic applied to the same +/// target also used incremental updates. +abstract class HueEffect extends Effect with EffectTarget { + HueEffect( + super.controller, { + super.onComplete, + super.key, + }); + + factory HueEffect.by( + double angle, + EffectController controller, { + HueProvider? target, + void Function()? onComplete, + ComponentKey? key, + }) { + return HueByEffect( + angle, + controller, + target: target, + onComplete: onComplete, + key: key, + ); + } + + factory HueEffect.to( + double angle, + EffectController controller, { + HueProvider? target, + void Function()? onComplete, + ComponentKey? key, + }) { + return HueToEffect( + angle, + controller, + target: target, + onComplete: onComplete, + key: key, + ); + } +} diff --git a/packages/flame/lib/src/effects/hue_to_effect.dart b/packages/flame/lib/src/effects/hue_to_effect.dart new file mode 100644 index 00000000000..e2350da20f1 --- /dev/null +++ b/packages/flame/lib/src/effects/hue_to_effect.dart @@ -0,0 +1,30 @@ +import 'package:flame/src/effects/hue_effect.dart'; +import 'package:flame/src/effects/provider_interfaces.dart'; + +/// An effect that changes the hue of a component to a specified angle. +class HueToEffect extends HueEffect { + HueToEffect( + double angle, + super.controller, { + HueProvider? target, + super.onComplete, + super.key, + }) : _destinationAngle = angle, + _angle = 0.0 { + this.target = target; + } + + final double _destinationAngle; + double _angle; + + @override + void onStart() { + _angle = _destinationAngle - target.hue; + } + + @override + void apply(double progress) { + final dProgress = progress - previousProgress; + target.hue += _angle * dProgress; + } +} diff --git a/packages/flame/lib/src/effects/provider_interfaces.dart b/packages/flame/lib/src/effects/provider_interfaces.dart index f7a4e20aa88..0bc08988992 100644 --- a/packages/flame/lib/src/effects/provider_interfaces.dart +++ b/packages/flame/lib/src/effects/provider_interfaces.dart @@ -94,3 +94,9 @@ abstract class PaintProvider { Paint get paint; set paint(Paint value); } + +/// Interface for a component that can be affected by hue effects. +abstract class HueProvider { + double get hue; + set hue(double value); +} diff --git a/packages/flame/lib/src/rendering/hue_decorator.dart b/packages/flame/lib/src/rendering/hue_decorator.dart new file mode 100644 index 00000000000..0a6a7ce6b51 --- /dev/null +++ b/packages/flame/lib/src/rendering/hue_decorator.dart @@ -0,0 +1,85 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flame/src/rendering/decorator.dart'; + +/// Calculates the hue rotation matrix for a given angle in radians. +/// +/// Uses the standard NTSC luminance weights (R: 0.213, G: 0.715, B: 0.072) +/// to produce a 4x5 color matrix suitable for [ColorFilter.matrix]. +List hueRotationMatrix(double angle) { + final cosT = math.cos(angle); + final sinT = math.sin(angle); + + return [ + 0.213 + 0.787 * cosT - 0.213 * sinT, + 0.715 - 0.715 * cosT - 0.715 * sinT, + 0.072 - 0.072 * cosT + 0.928 * sinT, + 0, + 0, + 0.213 - 0.213 * cosT + 0.143 * sinT, + 0.715 + 0.285 * cosT + 0.140 * sinT, + 0.072 - 0.072 * cosT - 0.283 * sinT, + 0, + 0, + 0.213 - 0.213 * cosT - 0.787 * sinT, + 0.715 - 0.715 * cosT + 0.715 * sinT, + 0.072 + 0.928 * cosT + 0.072 * sinT, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + ]; +} + +/// [HueDecorator] is a [Decorator] that shifts the hue of the component. +/// +/// The [hue] value is in radians. +/// Standard range is from -pi to pi, or 0 to 2*pi. +/// +/// **Performance Note**: This decorator uses `canvas.saveLayer()` which has +/// significant overhead compared to direct [Paint] manipulation (like +/// `HueEffect`). Prefer `HueEffect` for high-density rendering. +class HueDecorator extends Decorator { + HueDecorator({double hue = 0.0}) : _hue = hue; + + final _paint = Paint(); + double _hue; + bool _isDirty = true; + + /// The hue shift in radians. + double get hue => _hue; + set hue(double value) { + if (_hue != value) { + _hue = value; + _isDirty = true; + } + } + + @override + void apply( + void Function(Canvas) draw, + Canvas canvas, + ) { + if (_hue == 0.0) { + draw(canvas); + return; + } + + if (_isDirty) { + _updatePaint(); + _isDirty = false; + } + + canvas.saveLayer(null, _paint); + draw(canvas); + canvas.restore(); + } + + void _updatePaint() { + _paint.colorFilter = ColorFilter.matrix(hueRotationMatrix(_hue)); + } +} diff --git a/packages/flame/test/effects/hue_effect_test.dart b/packages/flame/test/effects/hue_effect_test.dart new file mode 100644 index 00000000000..6f8ef4cbc1f --- /dev/null +++ b/packages/flame/test/effects/hue_effect_test.dart @@ -0,0 +1,104 @@ +import 'dart:math'; + +import 'package:flame/components.dart'; +import 'package:flame/effects.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('HueEffect', () { + testWithFlameGame('can apply to component having HasPaint', (game) async { + final component = _PaintComponent(); + await game.ensureAdd(component); + await component.add( + HueEffect.by(pi, EffectController(duration: 1)), + ); + + game.update(0); + + expect(component.children.length, 1); + // At progress 0, hue is 0, so colorFilter should be null + // due to optimization. + expect(component.paint.colorFilter, isNull); + + game.update(0.5); + final filter05 = component.paint.colorFilter; + expect(filter05, isNotNull); + + game.update(0.5); + final filter1 = component.paint.colorFilter; + expect(filter1, isNotNull); + expect(filter1, isNot(equals(filter05))); + }); + + testWithFlameGame('reset works correctly', (game) async { + final component = _PaintComponent(); + await game.ensureAdd(component); + final effect = HueEffect.by(pi, EffectController(duration: 1)); + await component.add(effect); + + game.update(0.5); + expect(component.paint.colorFilter, isNotNull); + + effect.reset(); + // Incremental effects don't usually clear the target property on reset. + // If we want to maintain the old behavior, + // we'd need to manually set hue to 0. + component.hue = 0; + expect(component.paint.colorFilter, isNull); + }); + }); + + group('HueToEffect', () { + testWithFlameGame('animates hue to target angle', (game) async { + final component = _PaintComponent(); + await game.ensureAdd(component); + await component.add( + HueEffect.to(pi, EffectController(duration: 1)), + ); + + game.update(0); + expect(component.hue, 0.0); + + game.update(0.5); + expect(component.hue, closeTo(pi / 2, 0.001)); + + game.update(0.5); + expect(component.hue, closeTo(pi, 0.001)); + }); + + testWithFlameGame('computes delta from current hue', (game) async { + final component = _PaintComponent(); + component.hue = pi / 4; + await game.ensureAdd(component); + await component.add( + HueEffect.to(pi, EffectController(duration: 1)), + ); + + game.update(0); + expect(component.hue, closeTo(pi / 4, 0.001)); + + game.update(1); + expect(component.hue, closeTo(pi, 0.001)); + }); + + testWithFlameGame('applies color filter', (game) async { + final component = _PaintComponent(); + await game.ensureAdd(component); + await component.add( + HueEffect.to(pi / 2, EffectController(duration: 1)), + ); + + game.update(0); + expect(component.paint.colorFilter, isNull); + + game.update(0.5); + expect(component.paint.colorFilter, isNotNull); + + game.update(0.5); + expect(component.paint.colorFilter, isNotNull); + }); + }); +} + +class _PaintComponent extends Component with HasPaint {} diff --git a/packages/flame/test/rendering/hue_decorator_test.dart b/packages/flame/test/rendering/hue_decorator_test.dart new file mode 100644 index 00000000000..ff9cd9d668b --- /dev/null +++ b/packages/flame/test/rendering/hue_decorator_test.dart @@ -0,0 +1,56 @@ +import 'dart:math' as math; +import 'dart:ui'; +import 'package:flame/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('HueDecorator', () { + test('can be instantiated', () { + final decorator = HueDecorator(); + expect(decorator.hue, 0.0); + }); + + test('hue property updates correctly', () { + final decorator = HueDecorator(); + decorator.hue = math.pi; + expect(decorator.hue, math.pi); + }); + + test('apply with hue 0 does not use saveLayer', () { + final decorator = HueDecorator(); + var drawCalled = false; + final canvas = _MockCanvas(); + + decorator.apply((c) => drawCalled = true, canvas); + + expect(drawCalled, isTrue); + expect(canvas.saveLayerCalled, isFalse); + }); + + test('apply with non-zero hue uses saveLayer', () { + final decorator = HueDecorator(hue: math.pi / 2); + var drawCalled = false; + final canvas = _MockCanvas(); + + decorator.apply((c) => drawCalled = true, canvas); + + expect(drawCalled, isTrue); + expect(canvas.saveLayerCalled, isTrue); + }); + }); +} + +class _MockCanvas extends Fake implements Canvas { + bool saveLayerCalled = false; + bool restoreCalled = false; + + @override + void saveLayer(Rect? bounds, Paint paint) { + saveLayerCalled = true; + } + + @override + void restore() { + restoreCalled = true; + } +}