diff --git a/doc/flame/examples/lib/main.dart b/doc/flame/examples/lib/main.dart index ab221d53183..a9893fcd3d7 100644 --- a/doc/flame/examples/lib/main.dart +++ b/doc/flame/examples/lib/main.dart @@ -6,6 +6,7 @@ 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/router.dart'; import 'package:doc_flame_examples/tap_events.dart'; import 'package:flame/game.dart'; import 'package:flutter/widgets.dart'; @@ -22,6 +23,7 @@ void main() { 'decorator_shadow3d': DecoratorShadowGame.new, 'decorator_tint': DecoratorTintGame.new, 'drag_events': DragEventsGame.new, + 'router': RouterGame.new, 'tap_events': TapEventsGame.new, }; final game = routes[page]?.call(); diff --git a/doc/flame/examples/lib/router.dart b/doc/flame/examples/lib/router.dart new file mode 100644 index 00000000000..f1c7d953fd7 --- /dev/null +++ b/doc/flame/examples/lib/router.dart @@ -0,0 +1,411 @@ +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/rendering.dart'; + +class RouterGame extends FlameGame with HasTappableComponents { + late final RouterComponent router; + + @override + Future onLoad() async { + add( + router = RouterComponent( + routes: { + 'splash': Route(SplashScreenPage.new), + 'home': Route(StartPage.new), + 'level1': Route(Level1Page.new), + 'level2': Route(Level2Page.new), + 'pause': PauseRoute(), + }, + initialRoute: 'splash', + ), + ); + } +} + +class SplashScreenPage extends Component + with TapCallbacks, HasGameRef { + @override + Future onLoad() async { + addAll([ + Background(const Color(0xff282828)), + TextBoxComponent( + text: '[Router demo]', + textRenderer: TextPaint( + style: const TextStyle( + color: Color(0x66ffffff), + fontSize: 16, + ), + ), + align: Anchor.center, + size: gameRef.canvasSize, + ), + ]); + } + + @override + bool containsLocalPoint(Vector2 point) => true; + + @override + void onTapUp(TapUpEvent event) => gameRef.router.pushNamed('home'); +} + +class StartPage extends Component with HasGameRef { + StartPage() { + addAll([ + _logo = TextComponent( + text: 'Syzygy', + textRenderer: TextPaint( + style: const TextStyle( + fontSize: 64, + color: Color(0xFFC8FFF5), + fontWeight: FontWeight.w800, + ), + ), + anchor: Anchor.center, + ), + _button1 = RoundedButton( + text: 'Level 1', + action: () => gameRef.router.pushNamed('level1'), + color: const Color(0xffadde6c), + borderColor: const Color(0xffedffab), + ), + _button2 = RoundedButton( + text: 'Level 2', + action: () => gameRef.router.pushNamed('level2'), + color: const Color(0xffdebe6c), + borderColor: const Color(0xfffff4c7), + ), + ]); + } + + late final TextComponent _logo; + late final RoundedButton _button1; + late final RoundedButton _button2; + + @override + void onGameResize(Vector2 size) { + super.onGameResize(size); + _logo.position = Vector2(size.x / 2, size.y / 3); + _button1.position = Vector2(size.x / 2, _logo.y + 80); + _button2.position = Vector2(size.x / 2, _logo.y + 140); + } +} + +class Background extends Component { + Background(this.color); + final Color color; + + @override + void render(Canvas canvas) { + canvas.drawColor(color, BlendMode.srcATop); + } +} + +class RoundedButton extends PositionComponent with TapCallbacks { + RoundedButton({ + required this.text, + required this.action, + required Color color, + required Color borderColor, + super.anchor = Anchor.center, + }) : _textDrawable = TextPaint( + style: const TextStyle( + fontSize: 20, + color: Color(0xFF000000), + fontWeight: FontWeight.w800, + ), + ).toTextPainter(text) { + size = Vector2(150, 40); + _textOffset = Offset( + (size.x - _textDrawable.width) / 2, + (size.y - _textDrawable.height) / 2, + ); + _rrect = RRect.fromLTRBR(0, 0, size.x, size.y, Radius.circular(size.y / 2)); + _bgPaint = Paint()..color = color; + _borderPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..color = borderColor; + } + + final String text; + final void Function() action; + final TextPainter _textDrawable; + late final Offset _textOffset; + late final RRect _rrect; + late final Paint _borderPaint; + late final Paint _bgPaint; + + @override + void render(Canvas canvas) { + canvas.drawRRect(_rrect, _bgPaint); + canvas.drawRRect(_rrect, _borderPaint); + _textDrawable.paint(canvas, _textOffset); + } + + @override + void onTapDown(TapDownEvent event) { + scale = Vector2.all(1.05); + } + + @override + void onTapUp(TapUpEvent event) { + scale = Vector2.all(1.0); + action(); + } + + @override + void onTapCancel(TapCancelEvent event) { + scale = Vector2.all(1.0); + } +} + +abstract class SimpleButton extends PositionComponent with TapCallbacks { + SimpleButton(this._iconPath, {super.position}) : super(size: Vector2.all(40)); + + final Paint _borderPaint = Paint() + ..style = PaintingStyle.stroke + ..color = const Color(0x66ffffff); + final Paint _iconPaint = Paint() + ..style = PaintingStyle.stroke + ..color = const Color(0xffaaaaaa) + ..strokeWidth = 7; + final Path _iconPath; + + void action(); + + @override + void render(Canvas canvas) { + canvas.drawRRect( + RRect.fromRectAndRadius(size.toRect(), const Radius.circular(8)), + _borderPaint, + ); + canvas.drawPath(_iconPath, _iconPaint); + } + + @override + void onTapDown(TapDownEvent event) { + _iconPaint.color = const Color(0xffffffff); + } + + @override + void onTapUp(TapUpEvent event) { + _iconPaint.color = const Color(0xffaaaaaa); + action(); + } + + @override + void onTapCancel(TapCancelEvent event) { + _iconPaint.color = const Color(0xffaaaaaa); + } +} + +class BackButton extends SimpleButton with HasGameRef { + BackButton() + : super( + Path() + ..moveTo(22, 8) + ..lineTo(10, 20) + ..lineTo(22, 32) + ..moveTo(12, 20) + ..lineTo(34, 20), + position: Vector2.all(10), + ); + + @override + void action() => gameRef.router.pop(); +} + +class PauseButton extends SimpleButton with HasGameRef { + PauseButton() + : super( + Path() + ..moveTo(14, 10) + ..lineTo(14, 30) + ..moveTo(26, 10) + ..lineTo(26, 30), + position: Vector2(60, 10), + ); + @override + void action() => gameRef.router.pushNamed('pause'); +} + +class Level1Page extends Component { + @override + Future onLoad() async { + final game = findGame()!; + addAll([ + Background(const Color(0xbb2a074f)), + BackButton(), + PauseButton(), + Planet( + radius: 25, + color: const Color(0xfffff188), + position: game.size / 2, + children: [ + Orbit( + radius: 110, + revolutionPeriod: 6, + planet: Planet( + radius: 10, + color: const Color(0xff54d7b1), + children: [ + Orbit( + radius: 25, + revolutionPeriod: 5, + planet: Planet(radius: 3, color: const Color(0xFFcccccc)), + ), + ], + ), + ), + ], + ), + ]); + } +} + +class Level2Page extends Component { + @override + Future onLoad() async { + final game = findGame()!; + addAll([ + Background(const Color(0xff052b44)), + BackButton(), + PauseButton(), + Planet( + radius: 30, + color: const Color(0xFFFFFFff), + position: game.size / 2, + children: [ + Orbit( + radius: 60, + revolutionPeriod: 5, + planet: Planet(radius: 10, color: const Color(0xffc9ce0d)), + ), + Orbit( + radius: 110, + revolutionPeriod: 10, + planet: Planet( + radius: 14, + color: const Color(0xfff32727), + children: [ + Orbit( + radius: 26, + revolutionPeriod: 3, + planet: Planet(radius: 5, color: const Color(0xffffdb00)), + ), + Orbit( + radius: 35, + revolutionPeriod: 4, + planet: Planet(radius: 3, color: const Color(0xffdc00ff)), + ), + ], + ), + ), + ], + ), + ]); + } +} + +class Planet extends PositionComponent { + Planet({ + required this.radius, + required this.color, + super.position, + super.children, + }) : _paint = Paint()..color = color; + + final double radius; + final Color color; + final Paint _paint; + + @override + void render(Canvas canvas) { + canvas.drawCircle(Offset.zero, radius, _paint); + } +} + +class Orbit extends PositionComponent { + Orbit({ + required this.radius, + required this.planet, + required this.revolutionPeriod, + double initialAngle = 0, + }) : _paint = Paint() + ..style = PaintingStyle.stroke + ..color = const Color(0x888888aa), + _angle = initialAngle { + add(planet); + } + + final double radius; + final double revolutionPeriod; + final Planet planet; + final Paint _paint; + double _angle; + + @override + void render(Canvas canvas) { + canvas.drawCircle(Offset.zero, radius, _paint); + } + + @override + void update(double dt) { + _angle += dt / revolutionPeriod * Transform2D.tau; + planet.position = Vector2(radius, 0)..rotate(_angle); + } +} + +class PauseRoute extends Route { + PauseRoute() : super(PausePage.new, transparent: true); + + @override + void onPush(Route? previousRoute) { + previousRoute! + ..stopTime() + ..addRenderEffect( + PaintDecorator.grayscale(opacity: 0.5)..addBlur(3.0), + ); + } + + @override + void onPop(Route previousRoute) { + previousRoute + ..resumeTime() + ..removeRenderEffect(); + } +} + +class PausePage extends Component with TapCallbacks, HasGameRef { + @override + Future onLoad() async { + final game = findGame()!; + addAll([ + TextComponent( + text: 'PAUSED', + position: game.canvasSize / 2, + anchor: Anchor.center, + children: [ + ScaleEffect.to( + Vector2.all(1.1), + EffectController( + duration: 0.3, + alternate: true, + infinite: true, + ), + ) + ], + ), + ]); + } + + @override + bool containsLocalPoint(Vector2 point) => true; + + @override + void onTapUp(TapUpEvent event) => gameRef.router.pop(); +} diff --git a/doc/flame/flame.md b/doc/flame/flame.md index 066594ebb0d..0cf6d52721a 100644 --- a/doc/flame/flame.md +++ b/doc/flame/flame.md @@ -3,6 +3,7 @@ ```{toctree} File structure GameWidget +Router Game loop Components Platforms diff --git a/doc/flame/router.md b/doc/flame/router.md new file mode 100644 index 00000000000..bc600029889 --- /dev/null +++ b/doc/flame/router.md @@ -0,0 +1,64 @@ + +```{flutter-app} +:sources: ../flame/examples +:page: router +:show: widget code infobox + +This example app shows the use of the `RouterComponent` to move across multiple screens within +the game. In addition, the "pause" button stops time and applies visual effects to the content of +the page below it. +``` + + +# RouterComponent + +The **RouterComponent**'s job is to manage navigation across multiple screens within the game. It is +similar in spirit to Flutter's [Navigator][Flutter Navigator] class, except that it works with Flame +components instead of Flutter widgets. + +A typical game will usually consists of multiple pages: the splash screen, the starting menu page, +the settings page, credits, the main game page, several pop-ups, etc. The router will organize +all these destinations and allow you to transition between them. + +Internally, the `RouterComponent` contains a stack of routes. When you request it to show a route, +it will be placed on top of all other pages in the stack. Later you can `popPage()` to remove the +topmost page from the stack. The pages of the router are addressed by their unique names. + +Each page in the router can be either transparent or opaque. If a page is opaque, then the pages +below it in the stack are not rendered, and do not receive pointer events (such as taps or drags). +On the contrary, if a page is transparent, then the page below it will be rendered and receive +events normally. Such transparent pages are useful for implementing modal dialogs, inventory or +dialogue UIs, etc. + +Usage example: +```dart +class MyGame extends FlameGame { + late final RouterComponent router; + + @override + Future onLoad() async { + add( + router = RouterComponent( + routes: { + 'home': Route(HomePage.new), + 'level-selector': Route(LevelSelectorPage.new), + 'settings': Route(SettingsPage.new, transparent: true), + 'pause': PauseRoute(), + }, + initialRoute: 'home', + ), + ); + } +} +``` + +[Flutter Navigator]: https://api.flutter.dev/flutter/widgets/Navigator-class.html + + +## Route + +The **Route** component holds information about the content of a particular page. `Route`s are +mounted as children to the `RouterComponent`. + +The main property of a `Route` is its `builder` -- the function that creates the component with +the content of its page. diff --git a/packages/flame/.min_coverage b/packages/flame/.min_coverage index 9ddeb3cd0c7..c7a703d9234 100644 --- a/packages/flame/.min_coverage +++ b/packages/flame/.min_coverage @@ -1 +1 @@ -76.5 +78.0 diff --git a/packages/flame/lib/game.dart b/packages/flame/lib/game.dart index 8542e236b62..583831b0524 100644 --- a/packages/flame/lib/game.dart +++ b/packages/flame/lib/game.dart @@ -1,6 +1,8 @@ /// {@canonicalFor text.TextPaint} /// {@canonicalFor text.TextRenderer} export 'src/collisions/has_collision_detection.dart'; +export 'src/components/route.dart' show Route; +export 'src/components/router_component.dart' show RouterComponent; export 'src/extensions/vector2.dart'; export 'src/game/camera/camera.dart'; export 'src/game/camera/viewport.dart'; diff --git a/packages/flame/lib/src/components/route.dart b/packages/flame/lib/src/components/route.dart new file mode 100644 index 00000000000..9cb00a8b6a8 --- /dev/null +++ b/packages/flame/lib/src/components/route.dart @@ -0,0 +1,165 @@ +import 'dart:ui'; + +import 'package:flame/src/components/component.dart'; +import 'package:flame/src/components/mixins/parent_is_a.dart'; +import 'package:flame/src/components/position_component.dart'; +import 'package:flame/src/components/router_component.dart'; +import 'package:flame/src/effects/effect.dart'; +import 'package:flame/src/rendering/decorator.dart'; +import 'package:meta/meta.dart'; +import 'package:vector_math/vector_math_64.dart'; + +/// [Route] is a light-weight component that builds and manages a page. +/// +/// The "page" is a generic concept here: it is any component that comprises a +/// distinct UI arrangement. Pages are usually full-screen, for example: a +/// splash-screen page, a loading page, a game menu page, a level selection +/// page, a character creation page, and so on. Pages may also occupy less than +/// the full screen. These can be: a confirmation popup, an "enter name" dialog +/// box, a character inventory panel, a UI for dialogue with an NPC, etc. +/// +/// Most routes are created when the game is initialized, and thus they should +/// try to be as lightweight as possible. In particular, a [Route] should avoid +/// any potentially costly initialization operations. +/// +/// Routes are managed by the [RouterComponent] component. +class Route extends PositionComponent with ParentIsA { + Route( + Component Function()? builder, { + this.transparent = false, + }) : _builder = builder; + + /// If true, then the route below this one will continue to be rendered when + /// this route becomes active. If false, then this route is assumed to + /// completely obscure any route that would be underneath, and therefore the + /// route underneath doesn't need to be rendered. + final bool transparent; + + /// The name of the route (set by the [RouterComponent]). + late final String name; + + /// The function that will be invoked in order to build the page component + /// when this route first becomes active. This function may also be `null`, + /// in which case the user must override the [build] method. + final Component Function()? _builder; + + /// This method is invoked when the route is pushed on top of the + /// [RouterComponent]'s stack. + /// + /// The argument for this method is the route that was on top of the stack + /// before the push. It can be null if the current route becomes the first + /// element of the navigation stack. + void onPush(Route? previousRoute) {} + + /// This method is called when the route is popped off the top of the + /// [RouterComponent]'s stack. + /// + /// The argument for this method is the route that will become the next + /// top-most route on the stack. Thus, the argument in [onPop] will always be + /// the same as was given previously in [onPush]. + void onPop(Route nextRoute) {} + + /// Creates the page component managed by this page. + /// + /// Overriding this method is an alternative to supplying the explicit builder + /// function in the constructor. + @protected + Component build() { + assert( + _builder != null, + 'Either provide `builder` in the constructor, or override the build() ' + 'method', + ); + return _builder!(); + } + + /// The time "speed" factor. + /// + /// The value of 1 means that the time on this page runs normally. The value + /// less than 1 corresponds to time running slower than normal. The speed of + /// zero means the time on this page is stopped. + double timeSpeed = 1.0; + + /// Completely stops time for the managed page. + /// + /// When the time is stopped, the [updateTree] method of the page is not + /// called at all, which can save computational resources. However, this also + /// means that the lifecycle events on the page will not be processed, and + /// therefore no components will be able to be added or removed from the + /// page. + void stopTime() => timeSpeed = 0; + + /// Resumes normal time progression for the page, if it was previously slowed + /// down or stopped. + void resumeTime() => timeSpeed = 1.0; + + /// Applies the provided [Decorator] to the page. + /// + /// Render effects should not be confused with regular [Effect]s. Examples of + /// the render effects include: whole-page blur, convert into grayscale, + /// apply color tint, etc. + void addRenderEffect(Decorator effect) => _renderEffect = effect; + + /// Removes current [Decorator], is any. + void removeRenderEffect() => _renderEffect = null; + + //#region Implementation methods + + /// If true, the page must be rendered normally. If false, the page should + /// not be rendered, because it is completely obscured by another route which + /// is on top of it. This variable is set by the [RouterComponent]. + @internal + bool isRendered = true; + + /// The page that was built and is now owned by this route. This page will + /// also be added as a child component. + Component? _page; + + /// Additional visual effect that may be applied to the page during rendering. + Decorator? _renderEffect; + + /// Invoked by the [RouterComponent] when this route is pushed to the top + /// of the navigation stack. + @internal + void didPush(Route? previousRoute) { + _page ??= build()..addToParent(this); + onPush(previousRoute); + } + + /// Invoked by the [RouterComponent] when this route is popped off the top + /// of the navigation stack. + @internal + void didPop(Route previousRoute) => onPop(previousRoute); + + @override + void renderTree(Canvas canvas) { + if (isRendered) { + if (_renderEffect != null) { + _renderEffect!.apply(super.renderTree, canvas); + } else { + super.renderTree(canvas); + } + } + } + + @override + void updateTree(double dt) { + if (timeSpeed > 0) { + super.updateTree(dt * timeSpeed); + } + } + + @override + Iterable componentsAtPoint( + Vector2 point, [ + List? nestedPoints, + ]) { + if (isRendered) { + return super.componentsAtPoint(point, nestedPoints); + } else { + return const Iterable.empty(); + } + } + + //#endregion +} diff --git a/packages/flame/lib/src/components/router_component.dart b/packages/flame/lib/src/components/router_component.dart new file mode 100644 index 00000000000..c53e1870fbd --- /dev/null +++ b/packages/flame/lib/src/components/router_component.dart @@ -0,0 +1,205 @@ +import 'package:flame/src/components/component.dart'; +import 'package:flame/src/components/route.dart'; +import 'package:meta/meta.dart'; + +/// [RouterComponent] handles transitions between multiple pages of a game. +/// +/// The term **page** is used descriptively here: it is any full-screen (or +/// partial-screen) component. For example: a starting page, a settings page, +/// the main game world page, and so on. A page can also be any individual piece +/// of UI, such as a confirmation dialog box, or a user inventory pop-up. +/// +/// The router doesn't handle the pages directly -- instead, it operates a stack +/// of [Route]s. Each route, in turn, manages a single page component. However, +/// routes are lazy: they will only build their pages when they become active. +/// +/// Internally, the router maintains a stack of Routes. In the beginning, +/// the stack will contain the [initialRoute]. New routes can be added via the +/// [pushNamed] method, and removed with [pop]. However, the stack must be +/// kept non-empty: it is an error to attempt to remove the only remaining route +/// from the stack. +/// +/// Routes that are on the stack are mounted as components. When a route is +/// popped, it is removed from the stack and unmounted. Routes can be either +/// transparent or opaque. An opaque route prevents all routes below it from +/// rendering, and also stops pointer events. In addition, routes are able to +/// stop or slow down time for the pages that they control, or to apply visual +/// effects (via decorators) to those pages. +class RouterComponent extends Component { + RouterComponent({ + required this.initialRoute, + required Map routes, + Map? routeFactories, + this.onUnknownRoute, + }) : _routes = routes, + _routeFactories = routeFactories ?? {} { + routes.forEach((name, route) => route.name = name); + } + + /// Route that will be placed on the stack in the beginning. + final String initialRoute; + + /// The stack of all currently active routes. This stack must not be empty + /// (it will be populated with the [initialRoute] in the beginning). + /// + /// The routes in this list are also added to the Router as child + /// components. However, due to the fact that children are usually added or + /// removed with a delay, there could be temporary discrepancies between this + /// list and the list of children. + final List _routeStack = []; + @visibleForTesting + List get stack => _routeStack; + + /// The map of all routes known to the Router, each route will have a + /// unique name. This map is initialized in the constructor; in addition, any + /// routes produced by the [_routeFactories] will also be cached here. + Map get routes => _routes; + final Map _routes; + + /// Set of functions that are able to resolve routes dynamically. + /// + /// Route factories will be used to resolve pages with names like + /// "prefix/arg". For such a name, we will call the factory "prefix" with the + /// argument "arg". The produced route will be cached in the main [_routes] + /// map, and then built and mounted normally. + final Map _routeFactories; + + /// Function that will be called to resolve any route names that couldn't be + /// resolved via [_routes] or [_routeFactories]. Unlike with routeFactories, + /// the route returned by this function will not be cached. + final _RouteFactory? onUnknownRoute; + + /// Returns the route that is currently at the top of the stack. + Route get currentRoute => _routeStack.last; + + /// Returns the route that is below the current topmost route, if it exists. + Route? get previousRoute { + return _routeStack.length >= 2 ? _routeStack[_routeStack.length - 2] : null; + } + + /// Puts the route [name] on top of the navigation stack. + /// + /// If the route is already in the stack, it will be simply moved to the top. + /// Otherwise the route will be mounted and added at the top. We will also + /// initiate building the route's page if it hasn't been built before. If the + /// route is already on top of the stack, this method will do nothing. + /// + /// The method calls the [Route.didPush] callback for the newly activated + /// route. + void pushNamed(String name) { + final route = _resolveRoute(name); + if (route == currentRoute) { + return; + } + if (_routeStack.contains(route)) { + _routeStack.remove(route); + } else { + add(route); + } + _routeStack.add(route); + _adjustRoutesOrder(); + route.didPush(previousRoute); + _adjustRoutesVisibility(); + } + + /// Puts a new [route] on top of the navigation stack. + /// + /// The route may also be given a [name], in which case it will be cached in + /// the [routes] map under this name (if there was already a route with the + /// same name, it will be overwritten). + /// + /// The method calls [Route.didPush] for this new route after it is added. + void pushRoute(Route route, {String name = ''}) { + route.name = name; + if (name.isNotEmpty) { + _routes[name] = route; + } + add(route); + _routeStack.add(route); + _adjustRoutesOrder(); + route.didPush(previousRoute); + _adjustRoutesVisibility(); + } + + /// Removes the topmost route from the stack, and also removes it as a child + /// of the Router. + /// + /// The method calls [Route.didPop] for the route that was removed. + /// + /// It is an error to attempt to pop the last remaining route on the stack. + void pop() { + assert( + _routeStack.length > 1, + 'Cannot pop the last route from the Router', + ); + final route = _routeStack.removeLast(); + _adjustRoutesOrder(); + _adjustRoutesVisibility(); + route.didPop(_routeStack.last); + route.removeFromParent(); + } + + /// Removes routes from the top of the stack until reaches the route with the + /// given [name]. + /// + /// After this method, the route [name] will be at the top of the stack. An + /// error will occur if this method is run when there is no route with the + /// specified name on the stack. + void popUntilNamed(String name) { + while (currentRoute.name != name) { + pop(); + } + } + + /// Attempts to resolve the route with the given [name] by searching in the + /// [_routes] map, or invoking one of the [_routeFactories], or, lastly, + /// falling back to the [onUnknownRoute] function. If none of these methods + /// is able to produce a valid [Route], an exception will be raised. + Route _resolveRoute(String name) { + final existingRoute = _routes[name]; + if (existingRoute != null) { + return existingRoute; + } + if (name.contains('/')) { + final i = name.indexOf('/'); + final factoryName = name.substring(0, i); + final factory = _routeFactories[factoryName]; + if (factory != null) { + final argument = name.substring(i + 1); + final generatedRoute = factory(argument)..name = name; + _routes[name] = generatedRoute; + return generatedRoute; + } + } + if (onUnknownRoute != null) { + return onUnknownRoute!(name)..name = name; + } + throw ArgumentError('Route "$name" could not be resolved by the Router'); + } + + void _adjustRoutesOrder() { + for (var i = 0; i < _routeStack.length; i++) { + _routeStack[i].changePriorityWithoutResorting(i); + } + reorderChildren(); + } + + void _adjustRoutesVisibility() { + var render = true; + for (var i = _routeStack.length - 1; i >= 0; i--) { + _routeStack[i].isRendered = render; + render &= _routeStack[i].transparent; + } + } + + @override + void onMount() { + super.onMount(); + final route = _resolveRoute(initialRoute); + _routeStack.add(route); + add(route); + route.didPush(null); + } +} + +typedef _RouteFactory = Route Function(String parameter); diff --git a/packages/flame/test/_goldens/route_decorator_removed.png b/packages/flame/test/_goldens/route_decorator_removed.png new file mode 100644 index 00000000000..eb1a5a07a25 Binary files /dev/null and b/packages/flame/test/_goldens/route_decorator_removed.png differ diff --git a/packages/flame/test/_goldens/route_opaque.png b/packages/flame/test/_goldens/route_opaque.png new file mode 100644 index 00000000000..9b94842d2c5 Binary files /dev/null and b/packages/flame/test/_goldens/route_opaque.png differ diff --git a/packages/flame/test/_goldens/route_transparent.png b/packages/flame/test/_goldens/route_transparent.png new file mode 100644 index 00000000000..56c50e3dba0 Binary files /dev/null and b/packages/flame/test/_goldens/route_transparent.png differ diff --git a/packages/flame/test/_goldens/route_with_decorators.png b/packages/flame/test/_goldens/route_with_decorators.png new file mode 100644 index 00000000000..c77468c527b Binary files /dev/null and b/packages/flame/test/_goldens/route_with_decorators.png differ diff --git a/packages/flame/test/components/route_test.dart b/packages/flame/test/components/route_test.dart new file mode 100644 index 00000000000..f3903110f1c --- /dev/null +++ b/packages/flame/test/components/route_test.dart @@ -0,0 +1,337 @@ +import 'dart:ui'; + +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame/rendering.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Route', () { + testWithFlameGame('Route without a builder', (game) async { + final router = RouterComponent( + initialRoute: 'start', + routes: { + 'start': Route(Component.new), + 'new': Route(null), + }, + )..addToParent(game); + await game.ready(); + expect( + () => router.pushNamed('new'), + failsAssert( + 'Either provide `builder` in the constructor, or override the ' + 'build() method', + ), + ); + }); + + testWithFlameGame('onPush and onPop methods', (game) async { + var onPushCalled = 0; + var onPopCalled = 0; + var buildCalled = 0; + Route? previousRoute; + final router = RouterComponent( + initialRoute: 'start', + routes: { + 'start': Route(Component.new), + 'new': CustomRoute( + onPush: (self, prevRoute) { + onPushCalled++; + previousRoute = prevRoute; + }, + onPop: (self, prevRoute) { + onPopCalled++; + previousRoute = prevRoute; + }, + build: (self) { + buildCalled++; + return PositionComponent(); + }, + ), + }, + )..addToParent(game); + await game.ready(); + + router.pushNamed('new'); + expect(buildCalled, 1); + await game.ready(); + expect(router.currentRoute.name, 'new'); + expect(router.currentRoute.children.first, isA()); + expect(onPushCalled, 1); + expect(onPopCalled, 0); + expect(previousRoute!.name, 'start'); + + previousRoute = null; + router.pop(); + await game.ready(); + expect(onPushCalled, 1); + expect(onPopCalled, 1); + expect(previousRoute!.name, 'start'); + expect(router.currentRoute.name, 'start'); + }); + + testWithFlameGame('Stop and resume time', (game) async { + final router = RouterComponent( + initialRoute: 'start', + routes: { + 'start': Route(_TimerComponent.new), + 'pause': CustomRoute( + builder: Component.new, + onPush: (self, route) => route?.stopTime(), + onPop: (self, route) => route.resumeTime(), + ), + }, + )..addToParent(game); + await game.ready(); + + final timer = router.currentRoute.children.first as _TimerComponent; + expect(timer.elapsedTime, 0); + + game.update(1); + expect(timer.elapsedTime, 1); + + router.pushNamed('pause'); + await game.ready(); + expect(router.currentRoute.name, 'pause'); + expect(router.previousRoute!.timeSpeed, 0); + + game.update(10); + expect(timer.elapsedTime, 1); + + router.previousRoute!.timeSpeed = 0.1; + game.update(10); + expect(timer.elapsedTime, 2); + + router.pop(); + await game.ready(); + expect(router.currentRoute.name, 'start'); + expect(router.currentRoute.timeSpeed, 1); + + game.update(10); + expect(timer.elapsedTime, 12); + }); + + testGolden( + 'Rendering of opaque routes', + (game) async { + final router = RouterComponent( + initialRoute: 'initial', + routes: { + 'initial': Route( + () => _ColoredComponent( + color: const Color(0xFFFF0000), + size: Vector2.all(100), + ), + ), + 'green': Route( + () => _ColoredComponent( + color: const Color(0x8800FF00), + position: Vector2.all(10), + size: Vector2.all(80), + ), + ), + }, + )..addToParent(game); + await game.ready(); + router.pushNamed('green'); + }, + size: Vector2(100, 100), + goldenFile: '../_goldens/route_opaque.png', + ); + + testGolden( + 'Rendering of transparent routes', + (game) async { + final router = RouterComponent( + initialRoute: 'initial', + routes: { + 'initial': Route( + () => _ColoredComponent( + color: const Color(0xFFFF0000), + size: Vector2.all(100), + ), + ), + 'green': Route( + () => _ColoredComponent( + color: const Color(0x8800FF00), + position: Vector2.all(10), + size: Vector2.all(80), + ), + transparent: true, + ), + }, + )..addToParent(game); + await game.ready(); + router.pushNamed('green'); + }, + size: Vector2(100, 100), + goldenFile: '../_goldens/route_transparent.png', + ); + + testGolden( + 'Rendering of transparent routes with decorators', + (game) async { + final router = RouterComponent( + initialRoute: 'initial', + routes: { + 'initial': Route( + () => _ColoredComponent( + color: const Color(0xFFFF0000), + size: Vector2.all(100), + ), + ), + 'green': CustomRoute( + builder: () => _ColoredComponent( + color: const Color(0x8800FF00), + position: Vector2.all(10), + size: Vector2.all(80), + ), + transparent: true, + onPush: (self, route) => + route!.addRenderEffect(PaintDecorator.grayscale()), + onPop: (self, route) => route.removeRenderEffect(), + ), + }, + )..addToParent(game); + await game.ready(); + router.pushNamed('green'); + }, + size: Vector2(100, 100), + goldenFile: '../_goldens/route_with_decorators.png', + ); + + testGolden( + 'Rendering effect can be removed', + (game) async { + final router = RouterComponent( + initialRoute: 'initial', + routes: { + 'initial': Route( + () => _ColoredComponent( + color: const Color(0xFFFF0000), + size: Vector2.all(100), + ), + ), + 'green': CustomRoute( + builder: () => _ColoredComponent( + color: const Color(0x8800FF00), + position: Vector2.all(10), + size: Vector2.all(80), + ), + transparent: true, + onPush: (self, route) => + route!.addRenderEffect(PaintDecorator.grayscale()), + onPop: (self, route) => route.removeRenderEffect(), + ), + }, + )..addToParent(game); + await game.ready(); + router.pushNamed('green'); + await game.ready(); + router.pop(); + }, + size: Vector2(100, 100), + goldenFile: '../_goldens/route_decorator_removed.png', + ); + + testWithFlameGame('componentsAtPoint for opaque route', (game) async { + final router = RouterComponent( + initialRoute: 'initial', + routes: { + 'initial': Route( + () => PositionComponent(size: Vector2.all(100)), + ), + 'new': Route( + () => PositionComponent(size: Vector2.all(100)), + ), + }, + )..addToParent(game); + await game.ready(); + + router.pushNamed('new'); + await game.ready(); + expect( + game.componentsAtPoint(Vector2(50, 50)).toList(), + [router.currentRoute.children.first, game], + ); + }); + + testWithFlameGame('componentsAtPoint for transparent route', (game) async { + final router = RouterComponent( + initialRoute: 'initial', + routes: { + 'initial': Route( + () => PositionComponent(size: Vector2.all(100)), + ), + 'new': Route( + () => PositionComponent(size: Vector2.all(100)), + transparent: true, + ), + }, + )..addToParent(game); + await game.ready(); + + router.pushNamed('new'); + await game.ready(); + expect( + game.componentsAtPoint(Vector2(50, 50)).toList(), + [ + router.currentRoute.children.first, + router.previousRoute!.children.first, + game, + ], + ); + }); + }); +} + +class CustomRoute extends Route { + CustomRoute({ + Component Function()? builder, + super.transparent, + void Function(Route, Route?)? onPush, + void Function(Route, Route)? onPop, + Component Function(Route)? build, + }) : _onPush = onPush, + _onPop = onPop, + _build = build, + super(builder); + + final void Function(Route, Route?)? _onPush; + final void Function(Route, Route)? _onPop; + final Component Function(Route)? _build; + + @override + void onPush(Route? route) => _onPush?.call(this, route); + + @override + void onPop(Route route) => _onPop?.call(this, route); + + @override + Component build() => _build?.call(this) ?? super.build(); +} + +class _TimerComponent extends Component { + double elapsedTime = 0; + + @override + void update(double dt) { + elapsedTime += dt; + } +} + +class _ColoredComponent extends PositionComponent { + _ColoredComponent({ + required Color color, + super.position, + super.size, + }) : _paint = Paint()..color = color; + + final Paint _paint; + + @override + void render(Canvas canvas) { + canvas.drawRect(size.toRect(), _paint); + } +} diff --git a/packages/flame/test/components/router_component_test.dart b/packages/flame/test/components/router_component_test.dart new file mode 100644 index 00000000000..dd8a895dad3 --- /dev/null +++ b/packages/flame/test/components/router_component_test.dart @@ -0,0 +1,182 @@ +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame_test/flame_test.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('RouterComponent', () { + testWithFlameGame('normal route pushing/popping', (game) async { + final router = RouterComponent( + routes: { + 'A': Route(_ComponentA.new), + 'B': Route(_ComponentB.new), + 'C': Route(_ComponentC.new), + }, + initialRoute: 'A', + ); + game.add(router); + await game.ready(); + + expect(router.routes.length, 3); + expect(router.currentRoute.name, 'A'); + expect(router.currentRoute.children.length, 1); + expect(router.currentRoute.children.first, isA<_ComponentA>()); + + router.pushNamed('B'); + await game.ready(); + expect(router.currentRoute.name, 'B'); + expect(router.currentRoute.children.length, 1); + expect(router.currentRoute.children.first, isA<_ComponentB>()); + expect(router.stack.length, 2); + + router.pop(); + await game.ready(); + expect(router.currentRoute.name, 'A'); + expect(router.stack.length, 1); + + router.pushRoute(Route(_ComponentD.new), name: 'Dee'); + await game.ready(); + expect(router.routes.length, 4); + expect(router.currentRoute.name, 'Dee'); + expect(router.currentRoute.children.length, 1); + expect(router.currentRoute.children.first, isA<_ComponentD>()); + expect(router.stack.length, 2); + }); + + testWithFlameGame('Route factories', (game) async { + final router = RouterComponent( + initialRoute: 'initial', + routes: {'initial': Route(_ComponentD.new)}, + routeFactories: { + 'a': (arg) => Route(_ComponentA.new), + 'b': (arg) => Route(_ComponentB.new), + }, + ); + game.add(router); + await game.ready(); + + expect(router.currentRoute.name, 'initial'); + + router.pushNamed('a/101'); + await game.ready(); + expect(router.currentRoute.name, 'a/101'); + expect(router.currentRoute.children.first, isA<_ComponentA>()); + + router.pushNamed('b/something'); + await game.ready(); + expect(router.currentRoute.name, 'b/something'); + expect(router.currentRoute.children.first, isA<_ComponentB>()); + }); + + testWithFlameGame('push an existing route', (game) async { + final router = RouterComponent( + routes: { + 'A': Route(_ComponentA.new), + 'B': Route(_ComponentB.new), + 'C': Route(_ComponentC.new), + }, + initialRoute: 'A', + )..addToParent(game); + await game.ready(); + + router.pushNamed('A'); + await game.ready(); + expect(router.stack.length, 1); + + router.pushNamed('B'); + router.pushNamed('C'); + expect(router.stack.length, 3); + + router.pushNamed('B'); + expect(router.stack.length, 3); + router.pushNamed('A'); + expect(router.stack.length, 3); + + await game.ready(); + expect(router.children.length, 3); + expect((router.children.elementAt(0) as Route).name, 'C'); + expect((router.children.elementAt(1) as Route).name, 'B'); + expect((router.children.elementAt(2) as Route).name, 'A'); + }); + + testWithFlameGame('onUnknownRoute', (game) async { + final router = RouterComponent( + initialRoute: 'home', + routes: {'home': Route(_ComponentA.new)}, + onUnknownRoute: (name) => Route(_ComponentD.new), + )..addToParent(game); + await game.ready(); + + router.pushNamed('hello'); + await game.ready(); + expect(router.currentRoute.name, 'hello'); + expect(router.currentRoute.children.first, isA<_ComponentD>()); + }); + + testWithFlameGame('default unknown route handling', (game) async { + final router = RouterComponent( + initialRoute: 'home', + routes: {'home': Route(_ComponentA.new)}, + )..addToParent(game); + await game.ready(); + + expect( + () => router.pushNamed('hello'), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message == + 'Route "hello" could not be resolved by the Router', + ), + ), + ); + }); + + testWithFlameGame('cannot pop last remaining route', (game) async { + final router = RouterComponent( + initialRoute: 'home', + routes: {'home': Route(_ComponentA.new)}, + )..addToParent(game); + await game.ready(); + + expect( + router.pop, + failsAssert('Cannot pop the last route from the Router'), + ); + }); + + testWithFlameGame('popUntilNamed', (game) async { + final router = RouterComponent( + routes: { + 'A': Route(_ComponentA.new), + 'B': Route(_ComponentB.new), + 'C': Route(_ComponentC.new), + }, + initialRoute: 'A', + ); + game.add(router); + await game.ready(); + + router.pushNamed('B'); + router.pushNamed('C'); + await game.ready(); + expect(router.stack.length, 3); + expect(router.children.length, 3); + + router.popUntilNamed('A'); + await game.ready(); + expect(router.stack.length, 1); + expect(router.children.length, 1); + expect(router.currentRoute.name, 'A'); + }); + }); +} + +class _ComponentA extends Component {} + +class _ComponentB extends Component {} + +class _ComponentC extends Component {} + +class _ComponentD extends Component {}