feat: Added Decorator class and HasDecorator mixin (#1781)

The new class allows applying visual filters to canvas drawing operations. It is conceptually similar to the LayerProcessor class, except that it applies directly to a canvas instead of a Picture.

This functionality was extracted from PR #1755.

NB: the guitar image was taken from here, which is a public domain image, i.e. licensed under CC0 1.0.
This commit is contained in:
Pasha Stetsenko
2022-07-18 12:44:17 -07:00
committed by GitHub
parent 60daa196a8
commit 8d00847cfc
23 changed files with 561 additions and 7 deletions

View File

@ -127,6 +127,7 @@ button.flutter-app-button:after {
.flutter-app-iframe {
border: 1px solid #555;
display: block;
height: 350px;
width: 100%;
}
@ -138,7 +139,6 @@ button.flutter-app-button:after {
float: right;
margin-left: 6pt;
padding: 8px;
width: 280px;
}
.flutter-app-infobox button.flutter-app-iframe {
@ -148,8 +148,7 @@ button.flutter-app-button:after {
.flutter-app-infobox button.flutter-app-button {
float: right;
font-size: 0.85em;
margin-bottom: 0;
margin-right: 0;
margin: 6px 0 0 0;
min-height: 14pt;
min-width: 50pt;
}

View File

@ -52,6 +52,11 @@ class FlutterAppDirective(SphinxDirective):
compiled.
"infobox" - the content will be displayed as an infobox floating on
the right-hand side of the page.
:width: - override the default width of an iframe in widget/infobox modes.
:height: - override the default height of an iframe in widget/infobox
modes.
"""
has_content = True
required_arguments = 0
@ -60,6 +65,8 @@ class FlutterAppDirective(SphinxDirective):
'sources': directives.unchanged,
'page': directives.unchanged,
'show': directives.unchanged,
'width': directives.unchanged,
'height': directives.unchanged,
}
# Static list of targets that were already compiled during the build
COMPILED = []
@ -89,7 +96,21 @@ class FlutterAppDirective(SphinxDirective):
iframe_url = _doc_root() + self.html_dir + '/index.html?' + page
result = []
if 'widget' in self.modes:
result.append(IFrame(src=iframe_url, classes=['flutter-app-iframe']))
iframe = IFrame(src=iframe_url, classes=['flutter-app-iframe'])
result.append(iframe)
styles = []
if self.options.get('width'):
width = self.options.get('width')
if width.isdigit():
width += 'px'
styles.append("width: " + width)
if self.options.get('height'):
height = self.options.get('height')
if height.isdigit():
height += 'px'
styles.append("height: " + height)
if styles:
iframe.attributes['style'] = '; '.join(styles)
if 'popup' in self.modes:
result.append(Button(
'',
@ -237,8 +258,10 @@ def _doc_root():
class IFrame(nodes.Element, nodes.General):
def visit(self, node):
self.body.append(
self.starttag(node, 'iframe', src=node.attributes['src']))
attrs = {'src': node.attributes['src']}
if 'style' in node.attributes:
attrs['style'] = node.attributes['style']
self.body.append(self.starttag(node, 'iframe', **attrs).strip())
def depart(self, _):
self.body.append('</iframe>')

View File

@ -797,3 +797,12 @@ div.admonition.admonition-deprecated {
--admonition-icon-color: #555;
--admonition-title-background-color: #1c1c1c;
}
pre, div[class*="highlight-"] {
clear: none;
}
h1, h2, h3, h4, h5, h6 {
clear: both;
}

View File

@ -0,0 +1,30 @@
import 'package:doc_flame_examples/flower.dart';
import 'package:flame/experimental.dart';
import 'package:flame/game.dart';
import 'package:flame/rendering.dart';
class DecoratorBlurGame extends FlameGame with HasTappableComponents {
@override
Future<void> onLoad() async {
var step = 0;
add(
Flower(
size: 100,
position: canvasSize / 2,
onTap: (flower) {
step++;
if (step == 1) {
flower.decorator = PaintDecorator.blur(3.0);
} else if (step == 2) {
flower.decorator = PaintDecorator.blur(5.0);
} else if (step == 3) {
flower.decorator = PaintDecorator.blur(0.0, 20.0);
} else {
flower.decorator = null;
step = 0;
}
},
)..onTapUp(),
);
}
}

View File

@ -0,0 +1,32 @@
import 'package:doc_flame_examples/flower.dart';
import 'package:flame/experimental.dart';
import 'package:flame/game.dart';
import 'package:flame/rendering.dart';
class DecoratorGrayscaleGame extends FlameGame with HasTappableComponents {
@override
Future<void> onLoad() async {
var step = 0;
add(
Flower(
size: 100,
position: canvasSize / 2,
onTap: (flower) {
step++;
if (step == 1) {
flower.decorator = PaintDecorator.grayscale();
} else if (step == 2) {
flower.decorator = PaintDecorator.grayscale(opacity: 0.5);
} else if (step == 3) {
flower.decorator = PaintDecorator.grayscale(opacity: 0.2);
} else if (step == 4) {
flower.decorator = PaintDecorator.grayscale(opacity: 0.1);
} else {
flower.decorator = null;
step = 0;
}
},
)..onTapUp(),
);
}
}

View File

@ -0,0 +1,36 @@
import 'dart:ui';
import 'package:doc_flame_examples/flower.dart';
import 'package:flame/experimental.dart';
import 'package:flame/game.dart';
import 'package:flame/rendering.dart';
class DecoratorTintGame extends FlameGame with HasTappableComponents {
@override
Future<void> onLoad() async {
var step = 0;
add(
Flower(
size: 100,
position: canvasSize / 2,
onTap: (flower) {
step++;
if (step == 1) {
flower.decorator = PaintDecorator.tint(const Color(0x88FF0000));
} else if (step == 2) {
flower.decorator = PaintDecorator.tint(const Color(0x8800FF00));
} else if (step == 3) {
flower.decorator = PaintDecorator.tint(const Color(0x88000088));
} else if (step == 4) {
flower.decorator = PaintDecorator.tint(const Color(0x66FFFFFF));
} else if (step == 5) {
flower.decorator = PaintDecorator.tint(const Color(0xAA000000));
} else {
flower.decorator = null;
step = 0;
}
},
)..onTapUp(),
);
}
}

View File

@ -0,0 +1,53 @@
import 'dart:math';
import 'dart:ui';
import 'package:flame/components.dart';
import 'package:flame/experimental.dart';
const tau = 2 * pi;
class Flower extends PositionComponent with TapCallbacks, HasDecorator {
Flower({required double size, void Function(Flower)? onTap, super.position})
: _onTap = onTap,
super(size: Vector2.all(size), anchor: Anchor.center) {
final radius = size * 0.38;
_paths.add(_makePath(radius * 1.4, 6, -0.05, 0.8));
_paths.add(_makePath(radius, 6, 0.25, 1.5));
_paths.add(_makePath(radius * 0.8, 6, 0.3, 1.4));
_paths.add(_makePath(radius * 0.55, 6, 0.2, 1.5));
_paths.add(_makePath(radius * 0.1, 12, 0.1, 6));
_paints.add(Paint()..color = const Color(0xff255910));
_paints.add(Paint()..color = const Color(0xffee3f3f));
_paints.add(Paint()..color = const Color(0xffffbd66));
_paints.add(Paint()..color = const Color(0xfff6f370));
_paints.add(Paint()..color = const Color(0xfffffff0));
}
final List<Path> _paths = [];
final List<Paint> _paints = [];
final void Function(Flower)? _onTap;
Path _makePath(double radius, int n, double sharpness, double f) {
final radius2 = radius * f;
final p0 = Vector2(radius, 0)..rotate(0);
final path = Path()..moveTo(p0.x, p0.y);
for (var i = 0; i < n; i++) {
final p1 = Vector2(radius2, 0)..rotate(tau / n * (i + sharpness));
final p2 = Vector2(radius2, 0)..rotate(tau / n * (i + 1 - sharpness));
final p3 = Vector2(radius, 0)..rotate(tau / n * (i + 1));
path.cubicTo(p1.x, p1.y, p2.x, p2.y, p3.x, p3.y);
}
path.close();
return path.shift(Offset(width / 2, height / 2));
}
@override
void render(Canvas canvas) {
for (var i = 0; i < _paths.length; i++) {
canvas.drawPath(_paths[i], _paints[i]);
}
}
@override
void onTapUp([TapUpEvent? event]) => _onTap?.call(this);
}

View File

@ -1,5 +1,8 @@
import 'dart:html'; // ignore: avoid_web_libraries_in_flutter
import 'package:doc_flame_examples/decorator_blur.dart';
import 'package:doc_flame_examples/decorator_grayscale.dart';
import 'package:doc_flame_examples/decorator_tint.dart';
import 'package:doc_flame_examples/drag_events.dart';
import 'package:doc_flame_examples/tap_events.dart';
import 'package:flame/game.dart';
@ -18,6 +21,15 @@ void main() {
case 'drag_events':
game = DragEventsGame();
break;
case 'decorator_blur':
game = DecoratorBlurGame();
break;
case 'decorator_grayscale':
game = DecoratorGrayscaleGame();
break;
case 'decorator_tinted':
game = DecoratorTintGame();
break;
}
if (game != null) {
runApp(GameWidget(game: game));

View File

@ -0,0 +1,103 @@
# Decorators
**Decorators** are classes that can encapsulate certain visual effects and then apply those visual
effects to a sequence of canvas drawing operations. Decorators are not [Component]s, but they can
be applied to components either manually or via the [HasDecorator] mixin. Likewise, decorators are
not [Effect]s, although they can be used to implement certain `Effect`s.
There are a certain number of decorators available in Flame, and it is simple to add one's own if
necessary. We are planning to add shader-based decorators once Flutter fully supports them on the
web.
## Flame built-in decorators
### PaintDecorator.blur
```{flutter-app}
:sources: ../flame/examples
:page: decorator_blur
:show: widget code infobox
:width: 180
:height: 160
```
This decorator applies a Gaussian blur to the underlying component. The amount of blur can be
different in the X and Y direction, though this is not very common.
```dart
final decorator = PaintDecorator.blur(3.0);
```
Possible uses:
- soft shadows;
- "out-of-focus" objects in the distance or very close to the camera;
- motion blur effects;
- deemphasize/obscure content when showing a popup dialog;
- blurred vision when the character is drunk.
### PaintDecorator.grayscale
```{flutter-app}
:sources: ../flame/examples
:page: decorator_grayscale
:show: widget infobox
:width: 180
:height: 160
```
This decorator converts the underlying image into the shades of grey, as if it was a
black-and-white photograph. In addition, you can make the image semi-transparent to the desired
level of `opacity`.
```dart
final decorator = PaintDecorator.grayscale(opacity: 0.5);
```
Possible uses:
- apply to an NPC to turn them into stone, or into a ghost!
- apply to a scene to indicate that it is a memory of the past;
- black-and-white photos.
### PaintDecorator.tint
```{flutter-app}
:sources: ../flame/examples
:page: decorator_tint
:show: widget infobox
:width: 180
:height: 160
```
This decorator *tints* the underlying image with the specified color, as if watching it through a
colored glass. It is recommended that the `color` used by this decorator was semi-transparent, so
that you can see the details of the image below.
```dart
final decorator = PaintDecorator.tint(const Color(0xAAFF0000);
```
Possible uses:
- NPCs affected by certain types of magic;
- items/characters in the shadows can be tinted black;
- tint the scene red to show bloodlust, or that the character is low on health;
- tint green to show that the character is poisoned or sick;
- tint the scene deep blue during the night time;
## Using decorators
### HasDecorator mixin
This `Component` mixin adds the `decorator` property, which is initially `null`. If you set this
property to an actual `Decorator` object, then that decorator will apply its visual effect during
the rendering of the component. In order to remove this visual effect, simply set the `decorator`
property back to `null`.
[Component]: ../../flame/components.md#component
[Effect]: ../../flame/effects.md
[HasDecorator]: #hasdecorator-mixin

View File

@ -8,5 +8,6 @@
Text rendering <text.md>
Colors and palette <palette.md>
Particles <particles.md>
Decorators <decorators.md>
Layers <layers.md>
```

View File

@ -14,6 +14,7 @@ export 'src/components/mixins/component_viewport_margin.dart';
export 'src/components/mixins/draggable.dart';
export 'src/components/mixins/gesture_hitboxes.dart';
export 'src/components/mixins/has_ancestor.dart';
export 'src/components/mixins/has_decorator.dart' show HasDecorator;
export 'src/components/mixins/has_game_ref.dart';
export 'src/components/mixins/has_paint.dart';
export 'src/components/mixins/hoverable.dart';

View File

@ -0,0 +1,2 @@
export 'src/rendering/decorator.dart' show Decorator;
export 'src/rendering/paint_decorator.dart' show PaintDecorator;

View File

@ -0,0 +1,27 @@
import 'dart:ui';
import 'package:flame/src/components/component.dart';
import 'package:flame/src/rendering/decorator.dart';
/// [HasDecorator] mixin adds a nullable [decorator] field to a Component. If
/// this field is set, it will apply the visual effect encapsulated in this
/// [Decorator] to the component. If the field is not set, then the component
/// will be rendered normally.
///
/// Note that the decorator only affects visual rendering of a component, but
/// not its perceived size or shape from the point of view of tap events.
///
/// See also:
/// - [Decorator] class for the list of available decorators.
mixin HasDecorator on Component {
Decorator? decorator;
@override
void renderTree(Canvas canvas) {
if (decorator == null) {
super.renderTree(canvas);
} else {
decorator!.apply(super.renderTree, canvas);
}
}
}

View File

@ -0,0 +1,26 @@
import 'dart:ui';
import 'package:flame/src/rendering/paint_decorator.dart';
/// [Decorator] is an abstract class that encapsulates a particular visual
/// effect that should apply to drawing commands wrapped by this class.
///
/// The simplest way to apply a [Decorator] to a component is to override its
/// `renderTree` method like this:
/// ```dart
/// @override
/// void renderTree(Canvas canvas) {
/// decorator.apply(super.renderTree, canvas);
/// }
/// ```
///
/// The following implementations are available:
/// - [PaintDecorator]
abstract class Decorator {
/// Applies visual effect while [draw]ing on the [canvas].
///
/// A no-op decorator would simply call `draw(canvas)`. Any other non-trivial
/// decorator can transform the canvas before drawing, or perform any other
/// adjustment.
void apply(void Function(Canvas) draw, Canvas canvas);
}

View File

@ -0,0 +1,41 @@
import 'dart:ui';
import 'package:flame/src/rendering/decorator.dart';
/// [PaintDecorator] applies a paint filter to a group of drawing operations.
///
/// Specifically, the following filters are available:
/// - [PaintDecorator.blur] adds Gaussian blur to the image, as if your vision
/// became blurry and out of focus;
/// - [PaintDecorator.tint] tints the picture with the specified color, as if
/// looking through a colored glass;
/// - [PaintDecorator.grayscale] removes all color from the picture, as if it
/// was a black-and-white photo.
class PaintDecorator extends Decorator {
PaintDecorator.blur(double amount, [double? amountY]) {
addBlur(amount, amountY ?? amount);
}
PaintDecorator.tint(Color color) {
_paint.colorFilter = ColorFilter.mode(color, BlendMode.srcATop);
}
PaintDecorator.grayscale({double opacity = 1.0}) {
_paint.color = Color.fromARGB((255 * opacity).toInt(), 0, 0, 0);
_paint.blendMode = BlendMode.luminosity;
}
final _paint = Paint();
void addBlur(double amount, [double? amountY]) {
_paint.imageFilter =
ImageFilter.blur(sigmaX: amount, sigmaY: amountY ?? amount);
}
@override
void apply(void Function(Canvas) draw, Canvas canvas) {
canvas.saveLayer(null, _paint);
draw(canvas);
canvas.restore();
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View File

@ -0,0 +1,43 @@
import 'dart:ui';
import 'package:flame/components.dart';
import 'package:flame/rendering.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('HasDecorator', () {
testGolden(
'Component rendering with and without a Decorator',
(game) async {
await game.add(
_DecoratedComponent(
position: Vector2.all(25),
size: Vector2.all(40),
),
);
await game.add(
_DecoratedComponent(
position: Vector2(75, 25),
size: Vector2.all(40),
)..decorator = (PaintDecorator.grayscale()..addBlur(2)),
);
},
size: Vector2(100, 50),
goldenFile: '../../_goldens/has_decorator_1.png',
);
});
}
class _DecoratedComponent extends PositionComponent with HasDecorator {
_DecoratedComponent({super.position, super.size})
: super(anchor: Anchor.center);
final paint = Paint()..color = const Color(0xff30ccd2);
@override
void render(Canvas canvas) {
final radius = size.x / 2;
canvas.drawCircle(Offset(radius, radius), radius, paint);
}
}

View File

@ -0,0 +1,116 @@
import 'dart:ui';
import 'package:flame/components.dart';
import 'package:flame/rendering.dart';
import 'package:flame_test/flame_test.dart';
import 'package:flutter_test/flutter_test.dart';
import '../_resources/load_image.dart';
void main() {
group('PaintDecorator', () {
testGolden(
'blur effect',
(game) async {
final image = await loadImage('flame.png');
game.addAll([
SpriteComponent(sprite: Sprite(image)),
_DecoratedSprite(
sprite: Sprite(image),
decorator: PaintDecorator.blur(0, 10),
position: Vector2(150, 0),
),
]);
},
size: Vector2(300, 220),
goldenFile: '../_goldens/paint_decorator_blur.png',
);
testGolden(
'grayscale effect',
(game) async {
final image = await loadImage('flame.png');
game.addAll([
SpriteComponent(sprite: Sprite(image)),
_DecoratedSprite(
sprite: Sprite(image),
decorator: PaintDecorator.grayscale(),
position: Vector2(150, 0),
),
_DecoratedSprite(
sprite: Sprite(image),
decorator: PaintDecorator.grayscale(opacity: 0.5),
position: Vector2(300, 0),
),
_DecoratedSprite(
sprite: Sprite(image),
decorator: PaintDecorator.grayscale(opacity: 0.25),
position: Vector2(450, 0),
),
]);
},
size: Vector2(600, 220),
goldenFile: '../_goldens/paint_decorator_grayscale.png',
);
testGolden(
'tint effect',
(game) async {
final image = await loadImage('zz_guitarre.png');
game.addAll([
SpriteComponent(sprite: Sprite(image)),
_DecoratedSprite(
sprite: Sprite(image),
decorator: PaintDecorator.tint(const Color(0x8800FF00)),
position: Vector2(100, 0),
),
_DecoratedSprite(
sprite: Sprite(image),
decorator: PaintDecorator.tint(const Color(0x880000FF)),
position: Vector2(200, 0),
),
_DecoratedSprite(
sprite: Sprite(image),
decorator: PaintDecorator.tint(const Color(0xAAFFFFFF)),
position: Vector2(300, 0),
),
]);
},
size: Vector2(400, 300),
goldenFile: '../_goldens/paint_decorator_tinted.png',
);
testGolden(
'grayscale/tinted with blur',
(game) async {
final image = await loadImage('zz_guitarre.png');
const color = Color(0x88EBFF7F);
game.addAll([
SpriteComponent(sprite: Sprite(image)),
_DecoratedSprite(
sprite: Sprite(image),
decorator: PaintDecorator.grayscale()..addBlur(3),
position: Vector2(100, 0),
),
_DecoratedSprite(
sprite: Sprite(image),
decorator: PaintDecorator.tint(color)..addBlur(3),
position: Vector2(200, 0),
),
]);
},
size: Vector2(300, 300),
goldenFile: '../_goldens/paint_decorator_with_blur.png',
);
});
}
class _DecoratedSprite extends SpriteComponent {
_DecoratedSprite({super.sprite, super.position, required this.decorator});
final Decorator decorator;
@override
void renderTree(Canvas canvas) {
decorator.apply(super.renderTree, canvas);
}
}