diff --git a/doc/flame/examples/lib/decorator_shadow3d.dart b/doc/flame/examples/lib/decorator_shadow3d.dart new file mode 100644 index 00000000000..359ee310922 --- /dev/null +++ b/doc/flame/examples/lib/decorator_shadow3d.dart @@ -0,0 +1,70 @@ +import 'dart:ui'; + +import 'package:doc_flame_examples/flower.dart'; +import 'package:flame/components.dart'; +import 'package:flame/experimental.dart'; +import 'package:flame/game.dart'; +import 'package:flame/rendering.dart'; + +class DecoratorShadowGame extends FlameGame with HasTappableComponents { + @override + Color backgroundColor() => const Color(0xFFC7C7C7); + + @override + Future onLoad() async { + var step = 0; + add(Grid()); + add( + Flower( + size: 100, + position: canvasSize / 2, + decorator: Shadow3DDecorator( + base: canvasSize / 2 + Vector2(0, 50), + ), + onTap: (flower) { + step++; + final decorator = flower.decorator! as Shadow3DDecorator; + if (step == 1) { + decorator.xShift = 200; + decorator.opacity = 0.5; + } else if (step == 2) { + decorator.xShift = 400; + decorator.yScale = 3; + decorator.blur = 1; + } else if (step == 3) { + decorator.angle = 1.7; + decorator.blur = 2; + } else if (step == 4) { + decorator.ascent = 20; + decorator.angle = 1.7; + decorator.blur = 2; + flower.y -= 20; + } else { + decorator.ascent = 0; + decorator.xShift = 0; + decorator.yScale = 1; + decorator.angle = -1.4; + decorator.opacity = 0.8; + decorator.blur = 0; + flower.y += 20; + step = 0; + } + }, + )..onTapUp(), + ); + } +} + +class Grid extends Component { + final paint = Paint() + ..color = const Color(0xffa9a9a9) + ..style = PaintingStyle.stroke + ..strokeWidth = 1; + @override + void render(Canvas canvas) { + for (var i = 0; i < 50; i++) { + canvas.drawLine(Offset(0, i * 25), Offset(500, i * 25), paint); + canvas.drawLine(Offset(i * 25, 0), Offset(i * 25, 500), paint); + } + } +} diff --git a/doc/flame/examples/lib/main.dart b/doc/flame/examples/lib/main.dart index 8f31b07859d..ab221d53183 100644 --- a/doc/flame/examples/lib/main.dart +++ b/doc/flame/examples/lib/main.dart @@ -3,6 +3,7 @@ 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_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/tap_events.dart'; @@ -14,27 +15,16 @@ void main() { if (page.startsWith('?')) { page = page.substring(1); } - Game? game; - switch (page) { - case 'tap_events': - game = TapEventsGame(); - break; - case 'drag_events': - game = DragEventsGame(); - break; - case 'decorator_blur': - game = DecoratorBlurGame(); - break; - case 'decorator_grayscale': - game = DecoratorGrayscaleGame(); - break; - case 'decorator_rotate3d': - game = DecoratorRotate3DGame(); - break; - case 'decorator_tint': - game = DecoratorTintGame(); - break; - } + final routes = { + 'decorator_blur': DecoratorBlurGame.new, + 'decorator_grayscale': DecoratorGrayscaleGame.new, + 'decorator_rotate3d': DecoratorRotate3DGame.new, + 'decorator_shadow3d': DecoratorShadowGame.new, + 'decorator_tint': DecoratorTintGame.new, + 'drag_events': DragEventsGame.new, + 'tap_events': TapEventsGame.new, + }; + final game = routes[page]?.call(); if (game != null) { runApp(GameWidget(game: game)); } else { diff --git a/doc/flame/rendering/decorators.md b/doc/flame/rendering/decorators.md index 64619a2692a..363f262e6d9 100644 --- a/doc/flame/rendering/decorators.md +++ b/doc/flame/rendering/decorators.md @@ -119,6 +119,39 @@ Possible uses: - 3d falling particles such as snowflakes or leaves. +### Shadow3DDecorator + +```{flutter-app} +:sources: ../flame/examples +:page: decorator_shadow3d +:show: widget code infobox +:width: 180 +:height: 160 +``` + +This decorator renders a shadow underneath the component, as if the component was a 3D object +standing on a plane. This effect works best for games that use isometric camera projection. + +The shadow produced by this generator is quite flexible: you can control its angle, length, opacity, +blur, etc. For a full description of what properties this decorator has and their meaning, see the +class documentation. + +```dart +final decorator = Shadow3DDecorator( + base: Vector2(100, 150), + angle: -1.4, + xShift: 200, + yScale: 1.5, + opacity: 0.5, + blur: 1.5, +); +``` + +The primary purpose of this decorator is to add shadows on the ground to your components. The main +limitation is that the shadows are flat and cannot interact with the environment. For example, this +decorator cannot handle shadows that fall onto walls or other vertical structures. + + ## Using decorators ### HasDecorator mixin diff --git a/packages/flame/lib/rendering.dart b/packages/flame/lib/rendering.dart index 95bafa01afd..e33108617e0 100644 --- a/packages/flame/lib/rendering.dart +++ b/packages/flame/lib/rendering.dart @@ -1,3 +1,4 @@ export 'src/rendering/decorator.dart' show Decorator; export 'src/rendering/paint_decorator.dart' show PaintDecorator; export 'src/rendering/rotate3d_decorator.dart' show Rotate3DDecorator; +export 'src/rendering/shadow3d_decorator.dart' show Shadow3DDecorator; diff --git a/packages/flame/lib/src/rendering/shadow3d_decorator.dart b/packages/flame/lib/src/rendering/shadow3d_decorator.dart new file mode 100644 index 00000000000..3287e33fe9b --- /dev/null +++ b/packages/flame/lib/src/rendering/shadow3d_decorator.dart @@ -0,0 +1,153 @@ +import 'dart:ui'; + +import 'package:flame/src/rendering/decorator.dart'; +import 'package:vector_math/vector_math_64.dart'; + +/// [Shadow3DDecorator] casts a realistic-looking shadow from the component +/// onto the ground. +/// +/// This decorator is suitable for games that use an isometric projection. +/// +/// The shadows are very flexible, allowing for different positions of sun in +/// the sky, and even supporting airborne objects. +/// +/// Still, these are not real 3D shadows cast by real 3D objects on a real 3D +/// terrain, so many limitations apply. For example, the shadow must fall on +/// the flat ground, having the sun too high in the sky is undesirable as it +/// would betray the fact that the component is really flat, etc. +class Shadow3DDecorator extends Decorator { + Shadow3DDecorator({ + Vector2? base, + double? ascent, + double? angle, + double? xShift, + double? yScale, + double? blur, + double? opacity, + }) : _base = base?.clone() ?? Vector2.zero(), + _ascent = ascent ?? 0, + _angle = angle ?? -1.4, + _shift = xShift ?? 100.0, + _scale = yScale ?? 1.0, + _blur = blur ?? 0, + _opacity = opacity ?? 0.6; + + /// Coordinates of the point where the component "touches the ground". If the + /// component is airborne (i.e. [ascent] is non-zero), then this should be the + /// coordinate of the point where the component would have touched the ground + /// if it landed. + /// + /// This point is in the parent's coordinate space. + Vector2 get base => _base; + final Vector2 _base; + set base(Vector2 value) { + _base.setFrom(value); + _transformMatrix = null; + } + + /// How high is the component above the ground. + double get ascent => _ascent; + double _ascent; + set ascent(double value) { + _ascent = value; + _transformMatrix = null; + } + + /// The amount of skew the shadow is experiencing. The value of 0 corresponds + /// to the shadow being right behind (or in front of) the object. Positive + /// shift skews the shadow to the right if it's behind the object, or to the + /// left if the shadow is in front of the object. Negative shift skews in the + /// opposite direction. + /// + /// This property should be determined by the meridian position of the sun. + double get xShift => _shift; + double _shift; + set xShift(double value) { + _shift = value; + _transformMatrix = null; + } + + /// The length of the shadow relative to the height of the object. If the sun + /// is 45º above the horizon, this scale will be 1. When the sun is higher in + /// the sky, the scale factor should be less than 1, and when the sun is + /// lower, the scale factor ought to be greater than 1. + double get yScale => _scale; + double _scale; + set yScale(double value) { + _scale = value; + _transformMatrix = null; + } + + /// Visual angle between a vertically standing component and the ground. This + /// angle is determined by your isometric projection. Use negative values + /// smaller than τ/4 (1.57) in magnitude to create shadows that are behind the + /// objects. Use positive angles that are slightly above τ/4 to make shadows + /// that are in front of the objects. + double get angle => _angle; + double _angle; + set angle(double value) { + _angle = value; + _transformMatrix = null; + } + + /// The amount of blur to apply to the shadow. The value of 0 produces crisp + /// shadows with sharp edges, whereas positive [blur] produces softer-looking + /// shadows. + /// + /// Strictly speaking, the parts of the object that are closer to the ground + /// ought to experience less blur than those that are higher up. However, this + /// is not supported by this decorator. Still, you can try setting the amount + /// of blur proportional to the height of the object, or dependent on its + /// ascent above the ground. + double get blur => _blur; + double _blur; + set blur(double value) { + _blur = value; + _paint = null; + } + + /// Shadow's intensity. The value of 1 will create a hard pitch-black shadow, + /// which can only happen when there are no ambient sources of light (e.g. in + /// a cave). Values close to 0 will make the shadow barely visible, such as + /// on a cloudy day. + double get opacity => _opacity; + double _opacity; + set opacity(double value) { + _opacity = value; + _paint = null; + } + + Paint? _paint; + Paint _makePaint() { + final paint = Paint(); + final color = Color.fromRGBO(0, 0, 0, opacity); + paint.colorFilter = ColorFilter.mode(color, BlendMode.srcIn); + if (_blur > 0) { + paint.imageFilter = ImageFilter.blur(sigmaX: blur, sigmaY: blur / _scale); + } + return paint; + } + + Matrix4? _transformMatrix; + Matrix4 _makeTransform() { + return Matrix4.identity() + ..translate(0.0, 0.0, _scale * _ascent) + ..setEntry(3, 2, 0.001) + ..rotateX(_angle) + ..scale(1.0, _scale) + ..translate(-base.x - _shift, -base.y); + } + + @override + void apply(void Function(Canvas) draw, Canvas canvas) { + _transformMatrix ??= _makeTransform(); + _paint ??= _makePaint(); + + canvas.saveLayer(null, _paint!); + canvas.translate(base.x + _shift, base.y); + canvas.transform(_transformMatrix!.storage); + draw(canvas); + canvas.restore(); + draw(canvas); + } +} diff --git a/packages/flame/test/_goldens/shadow3d_decorator_1.png b/packages/flame/test/_goldens/shadow3d_decorator_1.png new file mode 100644 index 00000000000..de9111bc71d Binary files /dev/null and b/packages/flame/test/_goldens/shadow3d_decorator_1.png differ diff --git a/packages/flame/test/_goldens/shadow3d_decorator_2.png b/packages/flame/test/_goldens/shadow3d_decorator_2.png new file mode 100644 index 00000000000..66703677626 Binary files /dev/null and b/packages/flame/test/_goldens/shadow3d_decorator_2.png differ diff --git a/packages/flame/test/_goldens/shadow3d_decorator_3.png b/packages/flame/test/_goldens/shadow3d_decorator_3.png new file mode 100644 index 00000000000..ae39b5f80ce Binary files /dev/null and b/packages/flame/test/_goldens/shadow3d_decorator_3.png differ diff --git a/packages/flame/test/rendering/shadow3d_decorator_test.dart b/packages/flame/test/rendering/shadow3d_decorator_test.dart new file mode 100644 index 00000000000..a89541a14b3 --- /dev/null +++ b/packages/flame/test/rendering/shadow3d_decorator_test.dart @@ -0,0 +1,110 @@ +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('Shadow3DDecorator', () { + test('shadow default properties', () { + final decorator = Shadow3DDecorator(); + expect(decorator.base, Vector2(0, 0)); + expect(decorator.ascent, 0.0); + expect(decorator.angle, -1.4); + expect(decorator.xShift, 100.0); + expect(decorator.yScale, 1.0); + expect(decorator.blur, 0.0); + expect(decorator.opacity, 0.6); + }); + + testGolden( + 'shadow behind object', + (game) async { + game.addAll([ + Background(const Color(0xffc9c9c9)), + DecoratedRectangle( + position: Vector2(20, 30), + size: Vector2(60, 100), + paint: Paint()..color = const Color(0xcc199f2b), + decorator: Shadow3DDecorator( + base: Vector2(50, 130), + xShift: 200, + yScale: 2, + ), + ), + ]); + }, + size: Vector2(120, 150), + goldenFile: '../_goldens/shadow3d_decorator_1.png', + ); + + testGolden( + 'shadow in front object', + (game) async { + game.addAll([ + Background(const Color(0xffc9c9c9)), + DecoratedRectangle( + position: Vector2(60, 20), + size: Vector2(60, 100), + paint: Paint()..color = const Color(0xcc199f2b), + decorator: Shadow3DDecorator( + base: Vector2(90, 120), + angle: 1.7, + xShift: 200, + yScale: 2, + opacity: 0.5, + blur: 2.0, + ), + ), + ]); + }, + size: Vector2(140, 180), + goldenFile: '../_goldens/shadow3d_decorator_2.png', + ); + + testGolden( + 'dynamically change shadow properties', + (game) async { + game.addAll([ + Background(const Color(0xffc9c9c9)), + DecoratedRectangle( + position: Vector2(60, 20), + size: Vector2(60, 100), + paint: Paint()..color = const Color(0xcc199f2b), + decorator: Shadow3DDecorator() + ..base = Vector2(90, 120) + ..ascent = 20 + ..angle = 1.8 + ..xShift = 250.0 + ..yScale = 1.5 + ..opacity = 0.4 + ..blur = 1.0, + ), + ]); + }, + size: Vector2(140, 180), + goldenFile: '../_goldens/shadow3d_decorator_3.png', + ); + }); +} + +class DecoratedRectangle extends RectangleComponent with HasDecorator { + DecoratedRectangle({ + super.position, + super.size, + super.paint, + Decorator? decorator, + }) { + this.decorator = decorator; + } +} + +class Background extends Component { + Background(this.color); + final Color color; + @override + void render(Canvas canvas) { + canvas.drawColor(color, BlendMode.src); + } +}