Refactor ParallaxComponent (#613)

* Simplified loading of ParallaxComponent

* Loading helpers for the different Parallax parts

And refactor how the delta velocity works

* Fix formatting

* Break out Parallax out of ParallaxComponent

* Fix docs

* Add extension for loading different parallax things on game

* Fix formatting

* Add loadParallaxComponent extension

* Fix formatting
This commit is contained in:
Lukas Klingsbo
2021-01-06 21:07:31 +01:00
committed by GitHub
parent 0b6efdf170
commit af53438cd4
6 changed files with 497 additions and 212 deletions

View File

@ -8,6 +8,7 @@
- Add test for child removal
- Fix bug where `Timer` callback doesn't fire for non-repeating timers, also fixing bug with `Particle` lifespan
- Adding shortcut for loading Sprites and SpriteAnimation from the global cache
- Adding loading methods for the different `ParallaxComponent` parts and refactor how the delta velocity works
## 1.0.0-rc5
- Option for overlays to be already visible on the GameWidget

View File

@ -186,57 +186,85 @@ For a working example, check this [source file](https://github.com/flame-engine/
## ParallaxComponent
This Component can be used to render pretty backgrounds by drawing several transparent images on top of each other, each dislocated by a tiny amount.
This Component can be used to render pretty backgrounds by drawing several transparent images on top
of each other, where each image is moving with a different velocity.
The rationale is that when you look at the horizon and moving, closer objects seem to move faster than distant ones.
The rationale is that when you look at the horizon and moving, closer objects seem to move faster
than distant ones.
This component simulates this effect, making a more realistic background with a feeling of depth.
Create it like this:
The simplest `ParallaxComponent` is created like this:
```dart
final images = [
ParallaxImage('mountains.jpg'),
ParallaxImage('forest.jpg'),
ParallaxImage('city.jpg'),
];
this.bg = ParallaxComponent(images);
@override
Future<void> onLoad() async {
final parallaxComponent = await loadParallaxComponent([bg.png, trees.png]);
add(parallax);
}
```
This creates a static background, if you want it to move you have to set the named optional parameters `baseSpeed` and `layerDelta`. For example if you want to move your background images along the X-axis and have the images further away you would do the following:
This creates a static background, if you want a moving parallax (which is the whole point of a
parallax), you can do it in a few different ways depending on how fine grained you want to set the
settings for each layer.
They simplest way set is to set the named optional parameters `baseVelocity` and
`velocityMultiplierDelta` in the `load` helper function.
For example if you want to move your background images along the X-axis with a faster speed the
"closer" the image is:
```dart
this.bg = ParallaxComponent(images, baseSpeed: Offset(50, 0), layerDelta: Offset(20, 0));
final parallaxComponent = await loadParalladComponent(
_paths,
baseVelocity: Vector2(20, 0),
velocityMultiplierDelta: Vector2(1.8, 1.0),
);
```
You can set the baseSpeed and layerDelta at any time, for example if your character jumps or your game speeds up.
You can set the baseSpeed and layerDelta at any time, for example if your character jumps or your
game speeds up.
```dart
this.bg.baseSpeed = Vector2(100, 0);
this.bg.layerDelta = Vector2(40, 0);
final parallax = parallaxComponen.parallax;
parallax.baseSpeed = Vector2(100, 0);
parallax.velocityMultiplierDelta = Vector2(2.0, 1.0);
```
By default the images are aligned to the bottom left, repeated along the X-axis and scaled proportionally so that the image covers the height of the screen. If you want to change this behaviour, for example if you are not making a side scrolling game, you can set the `repeat`, `alignment` and `fill` parameters for each ParallaxImage.
By default the images are aligned to the bottom left, repeated along the X-axis and scaled
proportionally so that the image covers the height of the screen. If you want to change this
behaviour, for example if you are not making a side scrolling game, you can set the `repeat`,
`alignment` and `fill` parameters for each `ParallaxImage` and add them to `ParallaxLayer`s that you
then pass in to the `ParallaxComponent`'s constructor.
Advanced example:
```dart
final images = [
ParallaxImage('stars.jpg', repeat: ImageRepeat.repeat, alignment: Alignment.center, fill: LayerFill.width),
ParallaxImage('planets.jpg', repeat: ImageRepeat.repeatY, alignment: Alignment.bottomLeft, fill: LayerFill.none),
ParallaxImage('dust.jpg', repeat: ImageRepeat.repeatX, alignment: Alignment.topRight, fill: LayerFill.height),
loadParallaxImage('stars.jpg', repeat: ImageRepeat.repeat, alignment: Alignment.center, fill: LayerFill.width),
loadParallaxImage('planets.jpg', repeat: ImageRepeat.repeatY, alignment: Alignment.bottomLeft, fill: LayerFill.none),
loadParallaxImage('dust.jpg', repeat: ImageRepeat.repeatX, alignment: Alignment.topRight, fill: LayerFill.height),
];
this.bg = ParallaxComponent(images, baseSpeed: Vector2(50, 0), layerDelta: Vector2(20, 0));
final layers = images.map((image) => ParallaxLayer(await image, velocityMulitplier: images.indexOf(image) * 2.0));
final parallaxComponent = ParallaxComponent(
Parallax(
await Future.wait(layers),
baseVelocity: Vector2(50, 0),
),
);
```
* The stars image in this example will be repeatedly drawn in both axis, align in the center and be scaled to fill the screen width.
* The planets image will be repeated in Y-axis, aligned to the bottom left of the screen and not be scaled.
* The dust image will be repeated in X-axis, aligned to the top right and scaled to fill the screen height.
Once you are done with setting the parameters to your needs, render the ParallaxComponent as any other component.
Like the SpriteAnimationComponent, even if your parallax is static, you must call update on this component, so it runs its animation.
Once you are done setting up your `ParallaxComponent`, add it to the game like with any other
component (`game.add(parallaxComponent`).
Also, don't forget to add you images to the `pubspec.yaml` file as assets or they wont be found.
An example implementation can be found in the [examples directory](https://github.com/flame-engine/flame/tree/master/doc/examples/parallax).
The `Parallax` file contains an extension of the game which adds `loadParallax`, `loadParallaxLayer`
and `loadParallaxImage` so that it automatically uses your game's image cache instead of the global
one. The same for the `ParallaxComponent` file, but that provides `loadParallaxComponent`.
Two examples implementation can be found in the
[examples directory](https://github.com/flame-engine/flame/tree/master/doc/examples/parallax).
## SpriteBodyComponent

View File

@ -15,21 +15,22 @@ void main() async {
}
class MyGame extends BaseGame {
MyGame() {
final images = [
ParallaxImage('bg.png'),
ParallaxImage('mountain-far.png'),
ParallaxImage('mountains.png'),
ParallaxImage('trees.png'),
ParallaxImage('foreground-trees.png'),
];
final _imageNames = [
'bg.png',
'mountain-far.png',
'mountains.png',
'trees.png',
'foreground-trees.png',
];
final parallaxComponent = ParallaxComponent(
images,
baseSpeed: Vector2(20, 0),
layerDelta: Vector2(30, 0),
@override
Future<void> onLoad() async {
final parallax = await ParallaxComponent.load(
_imageNames,
baseVelocity: Vector2(20, 0),
velocityMultiplierDelta: Vector2(1.8, 1.0),
images: images,
);
add(parallaxComponent);
add(parallax);
}
}

View File

@ -0,0 +1,43 @@
import 'package:flame/flame.dart';
import 'package:flame/parallax.dart';
import 'package:flame/components/parallax_component.dart';
import 'package:flame/extensions/vector2.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Flame.util.fullScreen();
runApp(
GameWidget(
game: MyGame(),
),
);
}
class MyGame extends BaseGame {
final _layersMeta = {
'bg.png': 1.0,
'mountain-far.png': 1.5,
'mountains.png': 2.3,
'trees.png': 3.8,
'foreground-trees.png': 6.6,
};
@override
Future<void> onLoad() async {
final layers = _layersMeta.entries.map(
(e) => loadParallaxLayer(
e.key,
velocityMultiplier: Vector2(e.value, 1.0),
),
);
final parallax = ParallaxComponent(
Parallax(
await Future.wait(layers),
baseVelocity: Vector2(20, 0),
),
);
add(parallax);
}
}

View File

@ -4,197 +4,52 @@ import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/painting.dart';
import '../extensions/rect.dart';
import '../assets/images.dart';
import '../extensions/vector2.dart';
import '../flame.dart';
import '../game.dart';
import '../parallax.dart';
import 'position_component.dart';
/// Specifications with a path to an image and how it should be drawn in
/// relation to the device screen
class ParallaxImage {
/// The filename of the image
final String filename;
/// If and how the image should be repeated on the canvas
final ImageRepeat repeat;
/// How to align the image in relation to the screen
final Alignment alignment;
/// How to fill the screen with the image, always proportionally scaled.
final LayerFill fill;
ParallaxImage(
this.filename, {
this.repeat = ImageRepeat.repeatX,
this.alignment = Alignment.bottomLeft,
this.fill = LayerFill.height,
});
}
/// Represents one layer in the parallax, draws out an image on a canvas in the
/// manner specified by the parallaxImage
class ParallaxLayer {
final ParallaxImage parallaxImage;
Future<Image> future;
Image _image;
Rect _paintArea;
Vector2 _screenSize;
Vector2 _scroll;
Vector2 _imageSize;
double _scale = 1.0;
ParallaxLayer(this.parallaxImage) {
future = _load(parallaxImage.filename);
}
bool loaded() => _image != null;
Vector2 currentOffset() => _scroll;
void resize(Vector2 size) {
if (!loaded()) {
_screenSize = size;
return;
}
double scale(LayerFill fill) {
switch (fill) {
case LayerFill.height:
return _image.height / size.y;
case LayerFill.width:
return _image.width / size.x;
default:
return _scale;
}
}
_scale = scale(parallaxImage.fill);
// The image size so that it fulfills the LayerFill parameter
_imageSize =
Vector2Extension.fromInts(_image.width, _image.height) / _scale;
// Number of images that can fit on the canvas plus one
// to have something to scroll to without leaving canvas empty
final Vector2 count = Vector2.all(1) + (size.clone()..divide(_imageSize));
// Percentage of the image size that will overflow
final Vector2 overflow = ((_imageSize.clone()..multiply(count)) - size)
..divide(_imageSize);
// Align image to correct side of the screen
final alignment = parallaxImage.alignment;
final marginX = alignment.x == 0 ? overflow.x / 2 : alignment.x;
final marginY = alignment.y == 0 ? overflow.y / 2 : alignment.y;
_scroll ??= Vector2(marginX, marginY);
// Size of the area to paint the images on
final Vector2 paintSize = count..multiply(_imageSize);
_paintArea = paintSize.toRect();
}
void update(Vector2 delta) {
if (!loaded()) {
return;
}
// Scale the delta so that images that are larger don't scroll faster
_scroll += delta.clone()..divide(_imageSize);
switch (parallaxImage.repeat) {
case ImageRepeat.repeat:
_scroll = Vector2(_scroll.x % 1, _scroll.y % 1);
break;
case ImageRepeat.repeatX:
_scroll = Vector2(_scroll.x % 1, _scroll.y);
break;
case ImageRepeat.repeatY:
_scroll = Vector2(_scroll.x, _scroll.y % 1);
break;
case ImageRepeat.noRepeat:
break;
}
final Vector2 scrollPosition = _scroll.clone()..multiply(_imageSize);
_paintArea = Rect.fromLTWH(
-scrollPosition.x,
-scrollPosition.y,
_paintArea.width,
_paintArea.height,
extension ParallaxComponentExtension on Game {
Future<ParallaxComponent> loadParallaxComponent(
List<String> paths, {
Vector2 baseVelocity,
Vector2 velocityMultiplierDelta,
ImageRepeat repeat = ImageRepeat.repeatX,
Alignment alignment = Alignment.bottomLeft,
LayerFill fill = LayerFill.height,
}) {
return ParallaxComponent.load(
paths,
baseVelocity: baseVelocity,
velocityMultiplierDelta: velocityMultiplierDelta,
repeat: repeat,
alignment: alignment,
fill: fill,
images: images,
);
}
void render(Canvas canvas) {
if (!loaded()) {
return;
}
paintImage(
canvas: canvas,
image: _image,
rect: _paintArea,
repeat: parallaxImage.repeat,
scale: _scale,
alignment: parallaxImage.alignment,
);
}
Future<Image> _load(String filename) {
return Flame.images.load(filename).then((image) {
_image = image;
if (_screenSize != null) {
resize(_screenSize);
}
return _image;
});
}
}
/// How to fill the screen with the image, always proportionally scaled.
enum LayerFill { height, width, none }
/// A full parallax, several layers of images drawn out on the screen and each
/// layer moves with different speeds to give an effect of depth.
/// layer moves with different velocities to give an effect of depth.
class ParallaxComponent extends PositionComponent {
Vector2 baseSpeed;
Vector2 layerDelta;
List<ParallaxLayer> _layers;
final List<ParallaxImage> _images;
final Parallax parallax;
ParallaxComponent(
this._images, {
this.baseSpeed,
this.layerDelta,
}) {
baseSpeed ??= Vector2.zero();
layerDelta ??= Vector2.zero();
}
@override
Future<void> onLoad() async {
await _load(_images);
_layers?.forEach((layer) => layer.resize(size));
}
/// The base offset of the parallax, can be used in an outer update loop
/// if you want to transition the parallax to a certain position.
Vector2 currentOffset() => _layers[0].currentOffset();
ParallaxComponent(this.parallax);
@mustCallSuper
@override
void onGameResize(Vector2 size) {
this.size = size;
super.onGameResize(size);
_layers?.forEach((layer) => layer.resize(size));
this.size = size;
parallax.resize(size);
}
@override
void update(double t) {
super.update(t);
_layers.forEach((layer) {
layer.update(baseSpeed * t + layerDelta * (_layers.indexOf(layer) * t));
});
parallax.update(t);
}
@mustCallSuper
@ -202,12 +57,50 @@ class ParallaxComponent extends PositionComponent {
void render(Canvas canvas) {
super.render(canvas);
canvas.save();
_layers.forEach((layer) => layer.render(canvas));
parallax.layers.forEach((layer) {
canvas.save();
layer.render(canvas);
canvas.restore();
});
canvas.restore();
}
Future<void> _load(List<ParallaxImage> images) async {
_layers = images.map((image) => ParallaxLayer(image)).toList();
await Future.wait(_layers.map((layer) => layer.future));
/// Note that this method only should be used if all of your layers should
/// have the same layer arguments (how the images should be repeated, aligned
/// and filled), otherwise load the [ParallaxLayer]s individually and use the
/// normal constructor.
///
/// [load] takes a list of paths to all the images that you want to use in the
/// parallax.
/// Optionally arguments for the [baseVelocity] and [layerDelta] can be passed
/// in, [baseVelocity] defines what the base velocity of the layers should be
/// and [velocityMultiplierDelta] defines how the velocity should change the
/// closer the layer is ([velocityMultiplierDelta ^ n], where n is the
/// layer index).
/// Arguments for how all the images should repeat ([repeat]),
/// which edge it should align with ([alignment]), which axis it should fill
/// the image on ([fill]) and [images] which is the image cache that should be
/// used can also be passed in.
/// If no image cache is set, the global flame cache is used.
static Future<ParallaxComponent> load(
List<String> paths, {
Vector2 baseVelocity,
Vector2 velocityMultiplierDelta,
ImageRepeat repeat = ImageRepeat.repeatX,
Alignment alignment = Alignment.bottomLeft,
LayerFill fill = LayerFill.height,
Images images,
}) async {
return ParallaxComponent(
await Parallax.load(
paths,
baseVelocity: baseVelocity,
velocityMultiplierDelta: velocityMultiplierDelta,
repeat: repeat,
alignment: alignment,
fill: fill,
images: images,
),
);
}
}

319
lib/parallax.dart Normal file
View File

@ -0,0 +1,319 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/painting.dart';
import 'assets/images.dart';
import 'extensions/rect.dart';
import 'extensions/vector2.dart';
import 'flame.dart';
import 'game/game.dart';
extension ParallaxExtension on Game {
Future<Parallax> loadParallax(
List<String> paths, {
Vector2 baseVelocity,
Vector2 velocityMultiplierDelta,
ImageRepeat repeat = ImageRepeat.repeatX,
Alignment alignment = Alignment.bottomLeft,
LayerFill fill = LayerFill.height,
}) {
return Parallax.load(
paths,
baseVelocity: baseVelocity,
velocityMultiplierDelta: velocityMultiplierDelta,
repeat: repeat,
alignment: alignment,
fill: fill,
images: images,
);
}
Future<ParallaxImage> loadParallaxImage(
String path, {
ImageRepeat repeat = ImageRepeat.repeatX,
Alignment alignment = Alignment.bottomLeft,
LayerFill fill = LayerFill.height,
}) {
return ParallaxImage.load(
path,
repeat: repeat,
alignment: alignment,
fill: fill,
images: images,
);
}
Future<ParallaxLayer> loadParallaxLayer(
String path, {
ImageRepeat repeat = ImageRepeat.repeatX,
Alignment alignment = Alignment.bottomLeft,
LayerFill fill = LayerFill.height,
Vector2 velocityMultiplier,
}) {
return ParallaxLayer.load(
path,
velocityMultiplier: velocityMultiplier,
repeat: repeat,
alignment: alignment,
fill: fill,
images: images,
);
}
}
/// Specifications with a path to an image and how it should be drawn in
/// relation to the device screen
class ParallaxImage {
/// The image
final Image image;
/// If and how the image should be repeated on the canvas
final ImageRepeat repeat;
/// How to align the image in relation to the screen
final Alignment alignment;
/// How to fill the screen with the image, always proportionally scaled.
final LayerFill fill;
ParallaxImage(
this.image, {
this.repeat = ImageRepeat.repeatX,
this.alignment = Alignment.bottomLeft,
this.fill = LayerFill.height,
});
/// Takes a path of an image, and optionally arguments for how the image should
/// repeat ([repeat]), which edge it should align with ([alignment]), which axis
/// it should fill the image on ([fill]) and [images] which is the image cache
/// that should be used. If no image cache is set, the global flame cache is used.
static Future<ParallaxImage> load(
String path, {
ImageRepeat repeat = ImageRepeat.repeatX,
Alignment alignment = Alignment.bottomLeft,
LayerFill fill = LayerFill.height,
Images images,
}) async {
images ??= Flame.images;
return ParallaxImage(
await images.load(path),
repeat: repeat,
alignment: alignment,
fill: fill,
);
}
}
/// Represents one layer in the parallax, draws out an image on a canvas in the
/// manner specified by the parallaxImage
class ParallaxLayer {
final ParallaxImage parallaxImage;
Vector2 velocityMultiplier;
Rect _paintArea;
Vector2 _scroll;
Vector2 _imageSize;
double _scale = 1.0;
/// [parallaxImage] is the representation of the image with data of how the
/// image should behave.
/// [velocityMultiplier] will be used to determine the velocity of the layer by
/// multiplying the [baseVelocity] with the [velocityMultiplier].
ParallaxLayer(
this.parallaxImage, {
this.velocityMultiplier,
}) {
velocityMultiplier ??= Vector2.all(1.0);
}
Vector2 currentOffset() => _scroll;
void resize(Vector2 size) {
double scale(LayerFill fill) {
switch (fill) {
case LayerFill.height:
return parallaxImage.image.height / size.y;
case LayerFill.width:
return parallaxImage.image.width / size.x;
default:
return _scale;
}
}
_scale = scale(parallaxImage.fill);
// The image size so that it fulfills the LayerFill parameter
_imageSize = Vector2Extension.fromInts(
parallaxImage.image.width,
parallaxImage.image.height,
) /
_scale;
// Number of images that can fit on the canvas plus one
// to have something to scroll to without leaving canvas empty
final Vector2 count = Vector2.all(1) + (size.clone()..divide(_imageSize));
// Percentage of the image size that will overflow
final Vector2 overflow = ((_imageSize.clone()..multiply(count)) - size)
..divide(_imageSize);
// Align image to correct side of the screen
final alignment = parallaxImage.alignment;
final marginX = alignment.x == 0 ? overflow.x / 2 : alignment.x;
final marginY = alignment.y == 0 ? overflow.y / 2 : alignment.y;
_scroll ??= Vector2(marginX, marginY);
// Size of the area to paint the images on
final Vector2 paintSize = count..multiply(_imageSize);
_paintArea = paintSize.toRect();
}
void update(Vector2 delta) {
// Scale the delta so that images that are larger don't scroll faster
_scroll += delta.clone()..divide(_imageSize);
switch (parallaxImage.repeat) {
case ImageRepeat.repeat:
_scroll = Vector2(_scroll.x % 1, _scroll.y % 1);
break;
case ImageRepeat.repeatX:
_scroll = Vector2(_scroll.x % 1, _scroll.y);
break;
case ImageRepeat.repeatY:
_scroll = Vector2(_scroll.x, _scroll.y % 1);
break;
case ImageRepeat.noRepeat:
break;
}
final Vector2 scrollPosition = _scroll.clone()..multiply(_imageSize);
_paintArea = Rect.fromLTWH(
-scrollPosition.x,
-scrollPosition.y,
_paintArea.width,
_paintArea.height,
);
}
void render(Canvas canvas) {
paintImage(
canvas: canvas,
image: parallaxImage.image,
rect: _paintArea,
repeat: parallaxImage.repeat,
scale: _scale,
alignment: parallaxImage.alignment,
);
}
/// Takes a path of an image, and optionally arguments for how the image should
/// repeat ([repeat]), which edge it should align with ([alignment]), which axis
/// it should fill the image on ([fill]) and [images] which is the image cache
/// that should be used. If no image cache is set, the global flame cache is used.
static Future<ParallaxLayer> load(
String path, {
Vector2 velocityMultiplier,
ImageRepeat repeat = ImageRepeat.repeatX,
Alignment alignment = Alignment.bottomLeft,
LayerFill fill = LayerFill.height,
Images images,
}) async {
return ParallaxLayer(
await ParallaxImage.load(
path,
repeat: repeat,
alignment: alignment,
fill: fill,
images: images,
),
velocityMultiplier: velocityMultiplier,
);
}
}
/// How to fill the screen with the image, always proportionally scaled.
enum LayerFill { height, width, none }
/// A full parallax, several layers of images drawn out on the screen and each
/// layer moves with different velocities to give an effect of depth.
class Parallax {
Vector2 baseVelocity;
final List<ParallaxLayer> layers;
Parallax(
this.layers, {
this.baseVelocity,
}) {
baseVelocity ??= Vector2.zero();
}
/// The base offset of the parallax, can be used in an outer update loop
/// if you want to transition the parallax to a certain position.
Vector2 currentOffset() => layers[0].currentOffset();
/// If the `ParallaxComponent` isn't used your own wrapper needs to call this
/// on creation.
void resize(Vector2 size) => layers.forEach((layer) => layer.resize(size));
void update(double t) {
layers.forEach((layer) {
layer.update(
(baseVelocity.clone()..multiply(layer.velocityMultiplier)) * t,
);
});
}
/// Note that this method only should be used if all of your layers should
/// have the same layer arguments (how the images should be repeated, aligned
/// and filled), otherwise load the [ParallaxLayer]s individually and use the
/// normal constructor.
///
/// [load] takes a list of paths to all the images that you want to use in the
/// parallax.
/// Optionally arguments for the [baseVelocity] and [layerDelta] can be passed
/// in, [baseVelocity] defines what the base velocity of the layers should be
/// and [velocityMultiplierDelta] defines how the velocity should change the
/// closer the layer is ([velocityMultiplierDelta ^ n], where n is the
/// layer index).
/// Arguments for how all the images should repeat ([repeat]),
/// which edge it should align with ([alignment]), which axis it should fill
/// the image on ([fill]) and [images] which is the image cache that should be
/// used can also be passed in.
/// If no image cache is set, the global flame cache is used.
static Future<Parallax> load(
List<String> paths, {
Vector2 baseVelocity,
Vector2 velocityMultiplierDelta,
ImageRepeat repeat = ImageRepeat.repeatX,
Alignment alignment = Alignment.bottomLeft,
LayerFill fill = LayerFill.height,
Images images,
}) async {
velocityMultiplierDelta ??= Vector2.all(1.0);
int depth = 0;
final layers = await Future.wait<ParallaxLayer>(
paths.map((path) async {
final image = ParallaxImage.load(
path,
repeat: repeat,
alignment: alignment,
fill: fill,
images: images,
);
final velocityMultiplier =
List.filled(depth, velocityMultiplierDelta).fold<Vector2>(
velocityMultiplierDelta,
(previousValue, delta) => previousValue.clone()..multiply(delta),
);
++depth;
return ParallaxLayer(
await image,
velocityMultiplier: velocityMultiplier,
);
}),
);
return Parallax(
layers,
baseVelocity: baseVelocity,
);
}
}