Aplicacions amb Flutter, Dart i Flame

Tutorial Flutter Flame Projectes   Recursos CITCEA
Exemples Dart Dades pràctiques     Inici

Controlem els estats del joc

El joc que tenim fins ara és funcional, però no està acabat. Per exemple, a l'acabar una partida cal reiniciar l'aplicació per poder tornar a jugar. Controlant els estats, podrem millorar això.

Un joc com aquest acostuma a tenir quatre estats: inicial (pantalla de benvinguda), jugant i dos finals, un per quan s'ha guanyat i un altre per quan no ha estat així.

Quan implementem els estat, afegirem text al nostre joc i farem servir fonts. Abans d'entrar en la definició dels estats, convindrà especificar que farem servir Google Fonts; per això cal modificar el fitxer pubspec.yaml.

dependencies:
  flutter:
    sdk: flutter
  flame: ^1.28.1
  flutter_animate: ^4.5.2
  google_fonts: ^8.0.2

En general, convé triar la versió més recent. En aquest cas hi ha la darrera versió en el moment d'escriure la primera versió d'aquesta pàgina.

Un cop afegit, cal picar al botó per baixar el paquet (baixar).

També cal donar permís d'accés a internet. Això ho farem anar al fitxer AndroidManifest.xml que es troba a la carpeta main de la carpeta src de la carpeta app de la carpeta android i afegir la línia del permís després de la de manifest i abans de la d'application:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
    <application
...

En la definició del joc haurem de fer uns quants canvis. Per un costat, definirem una enumeració amb els noms dels quatre estats (inici, jugant, fi i guanya). Atès que podrem picar sobre l'àrea de joc per iniciar la partida, cal afegir-hi la detecció d'aquest fet. També indiquem què passa (s'inicia el joc) quan es pica).

La informació es mostrarà al damunt de l'àrea de joc amb text sobreposat (overlay), afegim un gestor d'estats que ens mostrarà (add) el text corresponent a l'estat quan el joc no està en marxa (estats inici, fi i guanya) i els farà desaparèixer (remove) durant la partida.

Quan comença el joc, fem desaparèixer els elements (pilota, pala i totxos) de la partida anterior i els tornem a posar correctament.

En la gestió del teclat, afegim dues tecles que permetran iniciar el joc.

Per acabar, establim el color de fons sobrescrivint l'opció per defecte.

breakout.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart';
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:joc_breakout/core/colors.dart';
import 'components/components.dart';
import 'config.dart';
enum PlayState {inici, jugant, fi, guanya}

class Breakout extends FlameGame 
    with HasCollisionDetection, KeyboardEvents, TapCallbacks {
  Breakout()
    : super(
        camera: CameraComponent.withFixedResolution(
          width: gameWidth,
          height: gameHeight,
        ),
      );

  final rand = math.Random();  // Generador de valors aleatoris
  double get width => size.x;
  double get height => size.y;
  late PlayState _playState;
  PlayState get playState => _playState;
  set playState(PlayState playState) {
    _playState = playState;
    switch (playState) {
      case PlayState.inici:
      case PlayState.fi:
      case PlayState.guanya:
        overlays.add(playState.name);
      case PlayState.jugant:
        overlays.remove(PlayState.inici.name);
        overlays.remove(PlayState.fi.name);
        overlays.remove(PlayState.guanya.name);
    }
  }

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();
    camera.viewfinder.anchor = Anchor.topLeft;
    world.add(PlayArea());
    playState = PlayState.inici;
  }

  void startGame() {
    if (playState == PlayState.jugant) return;
    world.removeAll(world.children.query<Bola>());
    world.removeAll(world.children.query<Pala>());
    world.removeAll(world.children.query<Totxo>());
    playState = PlayState.jugant;
    world.add(Bola(
      factorDificultat: factorDif,
      radius: ballRadius,
      position: size / 2,  // Centre de l'àrea de joc
      velocity: Vector2(
        (rand.nextDouble() - 0.5) * width,
        height * 0.2,
      ).normalized()..scale(height / 4),
    ));
    world.add(
      Pala(
        size: Vector2(batWidth, batHeight),
        cornerRadius: const Radius.circular(ballRadius / 2),
        position: Vector2(width / 2, height * 0.95),
      ),
    );
    // Afegim els totxos amb dos bucles
    world.addAll([
      for (var i = 0; i < numTotxosFilera; i++)
        for (var j = 0; j < ColorsApp.colorsTotxos.length; j++)
          Totxo(
            position: Vector2(
              (i + 0.5) * brickWidth + (i + 1) * juntaTotxos,
              (j + 1 + 2.0) * brickHeight + (j + 1) * juntaTotxos,
            ),
            color: ColorsApp.colorsTotxos[j],
          ),
    ]);
  }

  @override
  void onTapDown(TapDownEvent event) {
    super.onTapDown(event);
    startGame();
  }

  @override
  KeyEventResult onKeyEvent(
    KeyEvent event,
    Set<LogicalKeyboardKey> keysPressed,
  ) {
    super.onKeyEvent(event, keysPressed);
    switch (event.logicalKey) {
      case LogicalKeyboardKey.arrowLeft:
        world.children.query<Pala>().first.moveBy(-batStep);
      case LogicalKeyboardKey.arrowRight:
        world.children.query<Pala>().first.moveBy(batStep);
      case LogicalKeyboardKey.space:
      case LogicalKeyboardKey.enter:
        startGame();
    }
    return KeyEventResult.handled;
  }

  @override
  Color backgroundColor() => ColorsApp.fons;
}

Hem suprimit la indicació await quan s'afegeixen els totxos, perquè d'això ja se n'encarrega el gestor d'estats; atès que ja no estem dins del mètode onLoad.

En la classe de la bola, hem de fer un petit canvi. Quan la bola surti fora de l'àrea de joc, cal canviar l'estat, per indicar que la partida ha acabat i l'usuari no ha guanyat.

bola.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';
import 'package:joc_breakout/breakout.dart';
import 'package:joc_breakout/components/pala.dart';
import 'package:joc_breakout/components/totxo.dart';
import 'package:joc_breakout/components/play_area.dart';
import 'package:joc_breakout/core/colors.dart';
class Bola extends CircleComponent 
    with CollisionCallbacks, HasGameReference<Breakout> {
  Bola({
    required this.velocity,
    required super.position,
    required double radius,
    required this.factorDificultat,
  }) : super(
        radius: radius,
        anchor: Anchor.center,
        paint: Paint()
          ..color = ColorsApp.bola
          ..style = PaintingStyle.fill,
        children: [CircleHitbox()],
      );

  final Vector2 velocity;
  final double factorDificultat;

  @override
  void update(double dt) {
    super.update(dt);
    position += velocity * dt;
  }

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is PlayArea) {
      if (intersectionPoints.first.y <= 0) {
        velocity.y = -velocity.y;
      } else if (intersectionPoints.first.x <= 0) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.x >= game.width) {
        velocity.x = -velocity.x;
      } else if (intersectionPoints.first.y >= game.height) {
        add(
          RemoveEffect(
            delay: 0.35,
            onComplete: () {
              game.playState = PlayState.fi;
            },
          ),
        );
      }
    } else if (other is Pala) {
      velocity.y = -velocity.y;
      velocity.x =
          velocity.x +
          (position.x - other.position.x) / other.size.x * game.width * 0.3;
    } else if (other is Totxo) {
      if (position.y < other.position.y - other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.y > other.position.y + other.size.y / 2) {
        velocity.y = -velocity.y;
      } else if (position.x < other.position.x) {
        velocity.x = -velocity.x;
      } else if (position.x > other.position.x) {
        velocity.x = -velocity.x;
      }
      velocity.setFrom(velocity * factorDificultat);
    }
  }
}

Una cosa similar hem de fer amb la classe dels totxos.

totxo.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../breakout.dart';
import '../config.dart';
import 'bola.dart';
import 'pala.dart';
class Totxo extends RectangleComponent
    with CollisionCallbacks, HasGameReference<Breakout> {
  Totxo({required super.position, required Color color})
    : super(
        size: Vector2(brickWidth, brickHeight),
        anchor: Anchor.center,
        paint: Paint()
          ..color = color
          ..style = PaintingStyle.fill,
        children: [RectangleHitbox()],
      );

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    removeFromParent();
    // Mira si era el darrer totxo
    if (game.world.children.query<Totxo>().length == 1) {
      game.playState = PlayState.guanya;
      game.world.removeAll(game.world.children.query<Bola>());
      game.world.removeAll(game.world.children.query<Pala>());
    }
  }
}

Ates que volem gestionar els estats, definirem el joc com una classe. Aquesta classe contindrà el giny GameWidget on controlarem els diferents estats i cada estat tindrà el text que li correspon. Aquest giny va encapsulat en una complexa estructura de ginys que tenen per objecte adaptar-se a tots els casos possibles de sistemes operatius i maquinaris.

En el MaterialApp hem definit un tema, en el qual s'especifica una font i uns colors. Fem servir la font pressStart2p, que té un aspecte similar a com es mostraven els textos en una pantalla a finals de la dècada de 1970. En l'esquelet hi hem posat un contenidor amb un gradient, això farà que les vores del joc tinguin un degradat des d'un color a la part superior fins a un altre a la inferior.

game_app.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:joc_breakout/core/colors.dart';
import '../breakout.dart';
import '../config.dart';
class GameApp extends StatelessWidget {
  const GameApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        textTheme: GoogleFonts.pressStart2pTextTheme().apply(
          bodyColor: ColorsApp.tema,
          displayColor: ColorsApp.tema,  // Color del text
        ),
      ),
      home: Scaffold(
        body: Container(
          decoration: const BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: ColorsApp.gradient,  // Gradient del marc
            ),
          ),
          child: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Center(
                child: FittedBox(
                  child: SizedBox(
                    width: gameWidth,
                    height: gameHeight,
                    child: GameWidget.controlled(
                      gameFactory: Breakout.new,
                      overlayBuilderMap: {
                        PlayState.inici.name: (context, game) => Center(
                          child: Text(
                            'PICA PER COMENÇAR',
                            style: Theme.of(context).textTheme.headlineLarge,
                          ),
                        ),
                        PlayState.fi.name: (context, game) => Center(
                          child: Text(
                            'F I   D E L   J O C',
                            style: Theme.of(context).textTheme.headlineLarge,
                          ),
                        ),
                        PlayState.guanya.name: (context, game) => Center(
                          child: Text(
                            'H A S   G U A N Y A T !',
                            style: Theme.of(context).textTheme.headlineLarge,
                          ),
                        ),
                      },
                    ),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

Els colors els definim en la classe corresponent.

colors.dart
import 'dart:ui';
class ColorsApp {
  static const Color fons = Color(0xFFFFEECC);
  static const Color bola = Color(0xFFFF6699);
  static const Color pala = Color(0xFF116699);
  static const colorsTotxos = [
    Color(0xFF2277AA),
    Color(0xFF99BB66),
    Color(0xFFFFCC44),
    Color(0xFFFF9911),
    Color(0xFFFF4444),
  ];
  static const Color tema = Color(0xFF114477);
  static const gradient = [  // Gradient del marc
    Color(0xFFAADDEE), 
    Color(0xFFFFEECC)
  ];
}

Per acabar, ates que hem definit el joc com una classe, cal adequar el programa principal:

main.dart
import 'package:flutter/material.dart';
import 'widgets/game_app.dart';
void main() {
  runApp(const GameApp());
}

Ignorant les accions sobre el text

Si heu provat l'aplicació, potser us ha semblat que no funciona correctament. Si, quan surt algun dels textos, piquem sobre el fons, el joc s'inicia. Però no és així si piquem sobre el text. Això passa perquè onTapDown es troba a la classe Breakout però els textos estan a GameApp i es visualitzen a sobre.

Per solucionar-ho (i que es pugui picar a qualsevol lloc) podem posar els textos dins d'un ignorador d'accions per tal que les accions que es facin sobre el text (picar, en el nostre cas) siguin ignorades i es transfereixin a l'element que hi ha sota (l'àrea de joc).

game_app.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:joc_breakout/core/colors.dart';
import '../breakout.dart';
import '../config.dart';
class GameApp extends StatelessWidget {
  const GameApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        textTheme: GoogleFonts.pressStart2pTextTheme().apply(
          bodyColor: ColorsApp.tema,
          displayColor: ColorsApp.tema,  // Color del text
        ),
      ),
      home: Scaffold(
        body: Container(
          decoration: const BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
              colors: ColorsApp.gradient,  // Gradient del marc
            ),
          ),
          child: SafeArea(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Center(
                child: FittedBox(
                  child: SizedBox(
                    width: gameWidth,
                    height: gameHeight,
                    child: GameWidget.controlled(
                      gameFactory: Breakout.new,
                      overlayBuilderMap: {
                        PlayState.inici.name: (context, game) => Center(
                          child: IgnorePointer(
                            ignoring: true,
                            child: Text(
                              'PICA PER COMENÇAR',
                              style: Theme.of(context).textTheme.headlineLarge,
                            ),
                          ),
                        ),
                        PlayState.fi.name: (context, game) => Center(
                          child: IgnorePointer(
                            ignoring: true,
                            child: Text(
                              'F I   D E L   J O C',
                              style: Theme.of(context).textTheme.headlineLarge,
                            ),
                          ),
                        ),
                        PlayState.guanya.name: (context, game) => Center(
                          child: IgnorePointer(
                            ignoring: true,
                            child: Text(
                              'H A S   G U A N Y A T !',
                              style: Theme.of(context).textTheme.headlineLarge,
                            ),
                          ),
                        ),
                      },
                    ),
                  ),
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

 

 

 

 

 

 

 

 

 

 

Llicència de Creative Commons
Aquesta obra d'Oriol Boix està llicenciada sota una llicència no importada Reconeixement-NoComercial-SenseObraDerivada 3.0.