router.dart
  1import 'package:flame/components.dart';
  2import 'package:flame/effects.dart';
  3import 'package:flame/events.dart';
  4import 'package:flame/game.dart';
  5import 'package:flame/geometry.dart';
  6import 'package:flame/rendering.dart';
  7import 'package:flutter/rendering.dart';
  8
  9class RouterGame extends FlameGame {
 10  late final RouterComponent router;
 11
 12  @override
 13  Future<void> onLoad() async {
 14    add(
 15      router = RouterComponent(
 16        routes: {
 17          'home': Route(StartPage.new),
 18          'level1': WorldRoute(Level1Page.new),
 19          'level2': WorldRoute(Level2Page.new, maintainState: false),
 20          'pause': PauseRoute(),
 21        },
 22        initialRoute: 'home',
 23      ),
 24    );
 25  }
 26}
 27
 28class StartPage extends Component with HasGameReference<RouterGame> {
 29  StartPage() {
 30    addAll([
 31      _logo = TextComponent(
 32        text: 'Your Game',
 33        textRenderer: TextPaint(
 34          style: const TextStyle(
 35            fontSize: 64,
 36            color: Color(0xFFC8FFF5),
 37            fontWeight: FontWeight.w800,
 38          ),
 39        ),
 40        anchor: Anchor.center,
 41      ),
 42      _button1 = RoundedButton(
 43        text: 'Level 1',
 44        action: () => game.router.pushNamed('level1'),
 45        color: const Color(0xffadde6c),
 46        borderColor: const Color(0xffedffab),
 47      ),
 48      _button2 = RoundedButton(
 49        text: 'Level 2',
 50        action: () => game.router.pushNamed('level2'),
 51        color: const Color(0xffdebe6c),
 52        borderColor: const Color(0xfffff4c7),
 53      ),
 54    ]);
 55  }
 56
 57  late final TextComponent _logo;
 58  late final RoundedButton _button1;
 59  late final RoundedButton _button2;
 60
 61  @override
 62  void onGameResize(Vector2 size) {
 63    super.onGameResize(size);
 64    _logo.position = Vector2(size.x / 2, size.y / 3);
 65    _button1.position = Vector2(size.x / 2, _logo.y + 80);
 66    _button2.position = Vector2(size.x / 2, _logo.y + 140);
 67  }
 68}
 69
 70class Background extends Component {
 71  Background(this.color);
 72  final Color color;
 73
 74  @override
 75  void render(Canvas canvas) {
 76    canvas.drawColor(color, BlendMode.srcATop);
 77  }
 78}
 79
 80class RoundedButton extends PositionComponent with TapCallbacks {
 81  RoundedButton({
 82    required this.text,
 83    required this.action,
 84    required Color color,
 85    required Color borderColor,
 86    super.position,
 87    super.anchor = Anchor.center,
 88  }) : _textDrawable = TextPaint(
 89          style: const TextStyle(
 90            fontSize: 20,
 91            color: Color(0xFF000000),
 92            fontWeight: FontWeight.w800,
 93          ),
 94        ).toTextPainter(text) {
 95    size = Vector2(150, 40);
 96    _textOffset = Offset(
 97      (size.x - _textDrawable.width) / 2,
 98      (size.y - _textDrawable.height) / 2,
 99    );
100    _rrect = RRect.fromLTRBR(0, 0, size.x, size.y, Radius.circular(size.y / 2));
101    _bgPaint = Paint()..color = color;
102    _borderPaint = Paint()
103      ..style = PaintingStyle.stroke
104      ..strokeWidth = 2
105      ..color = borderColor;
106  }
107
108  final String text;
109  final void Function() action;
110  final TextPainter _textDrawable;
111  late final Offset _textOffset;
112  late final RRect _rrect;
113  late final Paint _borderPaint;
114  late final Paint _bgPaint;
115
116  @override
117  void render(Canvas canvas) {
118    canvas.drawRRect(_rrect, _bgPaint);
119    canvas.drawRRect(_rrect, _borderPaint);
120    _textDrawable.paint(canvas, _textOffset);
121  }
122
123  @override
124  void onTapDown(TapDownEvent event) {
125    scale = Vector2.all(1.05);
126  }
127
128  @override
129  void onTapUp(TapUpEvent event) {
130    scale = Vector2.all(1.0);
131    action();
132  }
133
134  @override
135  void onTapCancel(TapCancelEvent event) {
136    scale = Vector2.all(1.0);
137  }
138}
139
140abstract class SimpleButton extends PositionComponent with TapCallbacks {
141  SimpleButton(this._iconPath, {super.position}) : super(size: Vector2.all(40));
142
143  final Paint _borderPaint = Paint()
144    ..style = PaintingStyle.stroke
145    ..color = const Color(0x66ffffff);
146  final Paint _iconPaint = Paint()
147    ..style = PaintingStyle.stroke
148    ..color = const Color(0xffaaaaaa)
149    ..strokeWidth = 7;
150  final Path _iconPath;
151
152  void action();
153
154  @override
155  void render(Canvas canvas) {
156    canvas.drawRRect(
157      RRect.fromRectAndRadius(size.toRect(), const Radius.circular(8)),
158      _borderPaint,
159    );
160    canvas.drawPath(_iconPath, _iconPaint);
161  }
162
163  @override
164  void onTapDown(TapDownEvent event) {
165    _iconPaint.color = const Color(0xffffffff);
166  }
167
168  @override
169  void onTapUp(TapUpEvent event) {
170    _iconPaint.color = const Color(0xffaaaaaa);
171    action();
172  }
173
174  @override
175  void onTapCancel(TapCancelEvent event) {
176    _iconPaint.color = const Color(0xffaaaaaa);
177  }
178}
179
180class BackButton extends SimpleButton with HasGameReference<RouterGame> {
181  BackButton()
182      : super(
183          Path()
184            ..moveTo(22, 8)
185            ..lineTo(10, 20)
186            ..lineTo(22, 32)
187            ..moveTo(12, 20)
188            ..lineTo(34, 20),
189          position: Vector2.all(10),
190        );
191
192  @override
193  void action() => game.router.pop();
194}
195
196class PauseButton extends SimpleButton with HasGameReference<RouterGame> {
197  PauseButton()
198      : super(
199          Path()
200            ..moveTo(14, 10)
201            ..lineTo(14, 30)
202            ..moveTo(26, 10)
203            ..lineTo(26, 30),
204          position: Vector2(60, 10),
205        );
206
207  bool isPaused = false;
208
209  @override
210  void action() {
211    if (isPaused) {
212      game.router.pop();
213    } else {
214      game.router.pushNamed('pause');
215    }
216    isPaused = !isPaused;
217  }
218}
219
220class Level1Page extends DecoratedWorld with HasGameReference {
221  @override
222  Future<void> onLoad() async {
223    addAll([
224      Background(const Color(0xbb2a074f)),
225      Planet(
226        radius: 25,
227        color: const Color(0xfffff188),
228        children: [
229          Orbit(
230            radius: 110,
231            revolutionPeriod: 6,
232            planet: Planet(
233              radius: 10,
234              color: const Color(0xff54d7b1),
235              children: [
236                Orbit(
237                  radius: 25,
238                  revolutionPeriod: 5,
239                  planet: Planet(radius: 3, color: const Color(0xFFcccccc)),
240                ),
241              ],
242            ),
243          ),
244        ],
245      ),
246    ]);
247  }
248
249  final hudComponents = <Component>[];
250
251  @override
252  void onMount() {
253    hudComponents.addAll([
254      BackButton(),
255      PauseButton(),
256    ]);
257    game.camera.viewport.addAll(hudComponents);
258  }
259
260  @override
261  void onRemove() {
262    game.camera.viewport.removeAll(hudComponents);
263    super.onRemove();
264  }
265}
266
267class Level2Page extends DecoratedWorld with HasGameReference {
268  @override
269  Future<void> onLoad() async {
270    addAll([
271      Background(const Color(0xff052b44)),
272      Planet(
273        radius: 30,
274        color: const Color(0xFFFFFFff),
275        children: [
276          Orbit(
277            radius: 60,
278            revolutionPeriod: 5,
279            planet: Planet(radius: 10, color: const Color(0xffc9ce0d)),
280          ),
281          Orbit(
282            radius: 110,
283            revolutionPeriod: 10,
284            planet: Planet(
285              radius: 14,
286              color: const Color(0xfff32727),
287              children: [
288                Orbit(
289                  radius: 26,
290                  revolutionPeriod: 3,
291                  planet: Planet(radius: 5, color: const Color(0xffffdb00)),
292                ),
293                Orbit(
294                  radius: 35,
295                  revolutionPeriod: 4,
296                  planet: Planet(radius: 3, color: const Color(0xffdc00ff)),
297                ),
298              ],
299            ),
300          ),
301        ],
302      ),
303    ]);
304  }
305
306  final hudComponents = <Component>[];
307
308  @override
309  void onMount() {
310    hudComponents.addAll([
311      BackButton(),
312      PauseButton(),
313    ]);
314    game.camera.viewport.addAll(hudComponents);
315  }
316
317  @override
318  void onRemove() {
319    game.camera.viewport.removeAll(hudComponents);
320    super.onRemove();
321  }
322}
323
324class Planet extends PositionComponent {
325  Planet({
326    required this.radius,
327    required this.color,
328    super.position,
329    super.children,
330  }) : _paint = Paint()..color = color;
331
332  final double radius;
333  final Color color;
334  final Paint _paint;
335
336  @override
337  void render(Canvas canvas) {
338    canvas.drawCircle(Offset.zero, radius, _paint);
339  }
340}
341
342class Orbit extends PositionComponent {
343  Orbit({
344    required this.radius,
345    required this.planet,
346    required this.revolutionPeriod,
347    double initialAngle = 0,
348  })  : _paint = Paint()
349          ..style = PaintingStyle.stroke
350          ..color = const Color(0x888888aa),
351        _angle = initialAngle {
352    add(planet);
353  }
354
355  final double radius;
356  final double revolutionPeriod;
357  final Planet planet;
358  final Paint _paint;
359  double _angle;
360
361  @override
362  void render(Canvas canvas) {
363    canvas.drawCircle(Offset.zero, radius, _paint);
364  }
365
366  @override
367  void update(double dt) {
368    _angle += dt / revolutionPeriod * tau;
369    planet.position = Vector2(radius, 0)..rotate(_angle);
370  }
371}
372
373class PauseRoute extends Route {
374  PauseRoute() : super(PausePage.new, transparent: true);
375
376  @override
377  void onPush(Route? previousRoute) {
378    if (previousRoute is WorldRoute && previousRoute.world is DecoratedWorld) {
379      (previousRoute.world! as DecoratedWorld).timeScale = 0;
380      (previousRoute.world! as DecoratedWorld).decorator =
381          PaintDecorator.grayscale(opacity: 0.5)..addBlur(3.0);
382    }
383  }
384
385  @override
386  void onPop(Route nextRoute) {
387    if (nextRoute is WorldRoute && nextRoute.world is DecoratedWorld) {
388      (nextRoute.world! as DecoratedWorld).timeScale = 1;
389      (nextRoute.world! as DecoratedWorld).decorator = null;
390    }
391  }
392}
393
394class PausePage extends Component
395    with TapCallbacks, HasGameReference<RouterGame> {
396  @override
397  Future<void> onLoad() async {
398    final game = findGame()!;
399    addAll([
400      TextComponent(
401        text: 'PAUSED',
402        position: game.canvasSize / 2,
403        anchor: Anchor.center,
404        children: [
405          ScaleEffect.to(
406            Vector2.all(1.1),
407            EffectController(
408              duration: 0.3,
409              alternate: true,
410              infinite: true,
411            ),
412          ),
413        ],
414      ),
415    ]);
416  }
417
418  @override
419  bool containsLocalPoint(Vector2 point) => true;
420
421  @override
422  void onTapUp(TapUpEvent event) => game.router.pop();
423}
424
425class DecoratedWorld extends World with HasTimeScale {
426  PaintDecorator? decorator;
427
428  @override
429  void renderFromCamera(Canvas canvas) {
430    if (decorator == null) {
431      super.renderFromCamera(canvas);
432    } else {
433      decorator!.applyChain(super.renderFromCamera, canvas);
434    }
435  }
436}

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 class, except that it works with Flame components instead of Flutter widgets.

A typical game will usually consist 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 pop() 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. If you want your route to be visually transparent but for the routes below it to not receive events, make sure to add a background component to your route that captures the events by using one of the event capturing mixins.

Usage example:

class MyGame extends FlameGame {
  late final RouterComponent router;

  @override
  void onLoad() {
    add(
      router = RouterComponent(
        routes: {
          'home': Route(HomePage.new),
          'level-selector': Route(LevelSelectorPage.new),
          'settings': Route(SettingsPage.new, transparent: true),
          'pause': PauseRoute(),
          'confirm-dialog': OverlayRoute.existing(),
        },
        initialRoute: 'home',
      ),
    );
  }
}

class PauseRoute extends Route { ... }

Note

Use hide Route if any of your imported packages export another class called Route

eg: import 'package:flutter/material.dart' hide Route;

Route

The Route component holds information about the content of a particular page. Routes 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.

In addition, the routes can be either transparent or opaque (default). An opaque prevents the route below it from rendering or receiving pointer events, a transparent route doesn’t. As a rule of thumb, declare the route opaque if it is full-screen, and transparent if it is supposed to cover only a part of the screen.

By default, routes maintain the state of the page component after being popped from the stack and the builder function is only called the first time a route is activated. Setting maintainState to false drops the page component after the route is popped from the route stack and the builder function is called each time the route is activated.

The current route can be replaced using pushReplacementNamed or pushReplacement. Each method simply executes pop on the current route and then pushNamed or pushRoute.

WorldRoute

The WorldRoute is a special route that allows setting active game worlds via the router. These Such routes can be used, for example, for swapping levels implemented as separate worlds in your game.

By default, the WorldRoute will replace the current world with the new one and by default it will keep the state of the world after being popped from the stack. If you want the world to be recreated each time the route is activated, set maintainState to false.

If you are not using the built-in CameraComponent you can pass in the camera that you want to use explicitly in the constructor.

final router = RouterComponent(
  routes: {
    'level1': WorldRoute(MyWorld1.new),
    'level2': WorldRoute(MyWorld2.new, maintainState: false),
  },
);

class MyWorld1 extends World {
  @override
  Future<void> onLoad() async {
    add(BackgroundComponent());
    add(PlayerComponent());
  }
}

class MyWorld2 extends World {
   @override
   Future<void> onLoad() async {
      add(BackgroundComponent());
      add(PlayerComponent());
      add(EnemyComponent());
   }
}

OverlayRoute

The OverlayRoute is a special route that allows adding game overlays via the router. These routes are transparent by default.

There are two constructors for the OverlayRoute. The first constructor requires a builder function that describes how the overlay’s widget is to be built. The second constructor can be used when the builder function was already specified within the GameWidget:

final router = RouterComponent(
  routes: {
    'ok-dialog': OverlayRoute(
      (context, game) {
        return Center(
          child: DecoratedContainer(...),
        );
      },
    ),  // OverlayRoute
    'confirm-dialog': OverlayRoute.existing(),
  },
);

Overlays that were defined within the GameWidget don’t even need to be declared within the routes map beforehand: the RouterComponent.pushOverlay() method can do it for you. Once an overlay route was registered, it can be activated either via the regular .pushNamed() method, or via the .pushOverlay() – the two methods will do exactly the same, though you can use the second one to make it more clear in your code that an overlay is being added instead of a regular route.

The current overlay can be replaced using pushReplacementOverlay. This method executes pushReplacementNamed or pushReplacement based on the status of the overlay being pushed.

ValueRoute

value_route.dart
  1import 'dart:math';
  2import 'dart:ui';
  3
  4import 'package:doc_flame_examples/router.dart';
  5import 'package:flame/components.dart';
  6import 'package:flame/events.dart';
  7import 'package:flame/game.dart';
  8import 'package:flame/geometry.dart';
  9
 10class ValueRouteExample extends FlameGame {
 11  late final RouterComponent router;
 12
 13  @override
 14  Future<void> onLoad() async {
 15    router = RouterComponent(
 16      routes: {'home': Route(HomePage.new)},
 17      initialRoute: 'home',
 18    );
 19    add(router);
 20  }
 21}
 22
 23class HomePage extends Component with HasGameReference<ValueRouteExample> {
 24  @override
 25  Future<void> onLoad() async {
 26    add(
 27      RoundedButton(
 28        text: 'Rate me',
 29        action: () async {
 30          final score = await game.router.pushAndWait(RateRoute());
 31          firstChild<TextComponent>()!.text = 'Score: $score';
 32        },
 33        color: const Color(0xff758f9a),
 34        borderColor: const Color(0xff60d5ff),
 35      )..position = game.size / 2,
 36    );
 37    add(
 38      TextComponent(
 39        text: 'Score: –',
 40        anchor: Anchor.topCenter,
 41        position: game.size / 2 + Vector2(0, 30),
 42        scale: Vector2.all(0.7),
 43      ),
 44    );
 45  }
 46}
 47
 48class RateRoute extends ValueRoute<int>
 49    with HasGameReference<ValueRouteExample> {
 50  RateRoute() : super(value: -1, transparent: true);
 51
 52  @override
 53  Component build() {
 54    final size = Vector2(250, 130);
 55    const radius = 18.0;
 56    final starGap = (size.x - 5 * 2 * radius) / 6;
 57    return DialogBackground(
 58      position: game.size / 2,
 59      size: size,
 60      children: [
 61        RoundedButton(
 62          text: 'Ok',
 63          position: position = Vector2(size.x / 2, 100),
 64          action: () {
 65            completeWith(
 66              descendants().where((c) => c is Star && c.active).length,
 67            );
 68          },
 69          color: const Color(0xFFFFFFFF),
 70          borderColor: const Color(0xFF000000),
 71        ),
 72        for (var i = 0; i < 5; i++)
 73          Star(
 74            value: i + 1,
 75            radius: radius,
 76            position: Vector2(starGap * (i + 1) + radius * (2 * i + 1), 40),
 77          ),
 78      ],
 79    );
 80  }
 81}
 82
 83class DialogBackground extends RectangleComponent with TapCallbacks {
 84  DialogBackground({super.position, super.size, super.children})
 85      : super(
 86          anchor: Anchor.center,
 87          paint: Paint()..color = const Color(0xee858585),
 88        );
 89}
 90
 91class Star extends PositionComponent with TapCallbacks {
 92  Star({required this.value, required this.radius, super.position})
 93      : super(size: Vector2.all(2 * radius), anchor: Anchor.center);
 94
 95  final int value;
 96  final double radius;
 97  final Path path = Path();
 98  final Paint borderPaint = Paint()
 99    ..style = PaintingStyle.stroke
100    ..color = const Color(0xffffe395)
101    ..strokeWidth = 2;
102  final Paint fillPaint = Paint()..color = const Color(0xffffe395);
103  bool active = false;
104
105  @override
106  Future<void> onLoad() async {
107    path.moveTo(radius, 0);
108    for (var i = 0; i < 5; i++) {
109      path.lineTo(
110        radius + 0.6 * radius * sin(tau / 5 * (i + 0.5)),
111        radius - 0.6 * radius * cos(tau / 5 * (i + 0.5)),
112      );
113      path.lineTo(
114        radius + radius * sin(tau / 5 * (i + 1)),
115        radius - radius * cos(tau / 5 * (i + 1)),
116      );
117    }
118    path.close();
119  }
120
121  @override
122  void render(Canvas canvas) {
123    if (active) {
124      canvas.drawPath(path, fillPaint);
125    }
126    canvas.drawPath(path, borderPaint);
127  }
128
129  @override
130  void onTapDown(TapDownEvent event) {
131    var on = true;
132    for (final star in parent!.children.whereType<Star>()) {
133      star.active = on;
134      if (star == this) {
135        on = false;
136      }
137    }
138  }
139}

A ValueRoute is a route that will return a value when it is eventually popped from the stack. Such routes can be used, for example, for dialog boxes that ask for some feedback from the user.

In order to use ValueRoutes, two steps are required:

  1. Create a route derived from the ValueRoute<T> class, where T is the type of the value that your route will return. Inside that class override the build() method to construct the component that will be displayed. The component should use the completeWith(value) method to pop the route and return the specified value.

    class YesNoDialog extends ValueRoute<bool> {
      YesNoDialog(this.text) : super(value: false);
      final String text;
    
      @override
      Component build() {
        return PositionComponent(
          children: [
            RectangleComponent(),
            TextComponent(text: text),
            Button(
              text: 'Yes',
              action: () => completeWith(true),
            ),
            Button(
              text: 'No',
              action: () => completeWith(false),
            ),
          ],
        );
      }
    }
    
  2. Display the route using Router.pushAndWait(), which returns a future that resolves with the value returned from the route.

    Future<void> foo() async {
      final result = await game.router.pushAndWait(YesNoDialog('Are you sure?'));
      if (result) {
        // ... the user is sure
      } else {
        // ... the user was not so sure
      }
    }