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

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.

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.

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 RectangleComponent(
 58      position: game.size / 2,
 59      size: size,
 60      anchor: Anchor.center,
 61      paint: Paint()..color = const Color(0xee858585),
 62      children: [
 63        RoundedButton(
 64          text: 'Ok',
 65          action: () {
 66            completeWith(
 67              descendants().where((c) => c is Star && c.active).length,
 68            );
 69          },
 70          color: const Color(0xFFFFFFFF),
 71          borderColor: const Color(0xFF000000),
 72        )..position = Vector2(size.x / 2, 100),
 73        for (var i = 0; i < 5; i++)
 74          Star(
 75            value: i + 1,
 76            radius: radius,
 77            position: Vector2(starGap * (i + 1) + radius * (2 * i + 1), 40),
 78          ),
 79      ],
 80    );
 81  }
 82}
 83
 84class Star extends PositionComponent with TapCallbacks {
 85  Star({required this.value, required this.radius, super.position})
 86      : super(size: Vector2.all(2 * radius), anchor: Anchor.center);
 87
 88  final int value;
 89  final double radius;
 90  final Path path = Path();
 91  final Paint borderPaint = Paint()
 92    ..style = PaintingStyle.stroke
 93    ..color = const Color(0xffffe395)
 94    ..strokeWidth = 2;
 95  final Paint fillPaint = Paint()..color = const Color(0xffffe395);
 96  bool active = false;
 97
 98  @override
 99  Future<void> onLoad() async {
100    path.moveTo(radius, 0);
101    for (var i = 0; i < 5; i++) {
102      path.lineTo(
103        radius + 0.6 * radius * sin(tau / 5 * (i + 0.5)),
104        radius - 0.6 * radius * cos(tau / 5 * (i + 0.5)),
105      );
106      path.lineTo(
107        radius + radius * sin(tau / 5 * (i + 1)),
108        radius - radius * cos(tau / 5 * (i + 1)),
109      );
110    }
111    path.close();
112  }
113
114  @override
115  void render(Canvas canvas) {
116    if (active) {
117      canvas.drawPath(path, fillPaint);
118    }
119    canvas.drawPath(path, borderPaint);
120  }
121
122  @override
123  void onTapDown(TapDownEvent event) {
124    var on = true;
125    for (final star in parent!.children.whereType<Star>()) {
126      star.active = on;
127      if (star == this) {
128        on = false;
129      }
130    }
131  }
132}

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
      }
    }