Aplicacions amb Flutter, Dart i Flame

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

Programa final per a aquesta aplicació

Atès que aquest programa té molts fitxers, fem un resum final de la versió més completa. Aquest és l'arbre de carpetes:

assets
  images                           - Imatges
    sheet.png                      - Full on hi ha totes les imatges del joc

lib
  main.dart                        - Programa principal
  config.dart                      - Configuració
  breakout.dart                    - Part principal del joc

  core                             - Dades de tipus general
    colors.dart                    - Definició dels colors que fem servir

  components                       - Elements de les vistes
    components.dart                - Exportació de components
    play_area.dart                 - Àrea de joc
    bola.dart                      - Bola
    pala.dart                      - Pala
    totxo.dart                     - Totxo

  widgets                          - Ginys
    game_app.dart                  - Estructura general del joc
    puntuacio.dart                 - Lloc on mostrar la puntuació
    sobreposat.dart                - Gestiona els textos sobreposats

Comencem pels fitxers de la carpeta lib.

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

config.dart
import 'package:vector_math/vector_math.dart';
const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2;
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05;
const numTotxosFilera = 10;
const juntaTotxos = gameWidth * 0.015;
final brickWidth =
    (gameWidth - (juntaTotxos * (numTotxosFilera + 1))) / numTotxosFilera;
const brickHeight = gameHeight * 0.03;
const factorDif = 1.03;
// Configuració per al full d'sprites
Vector2 posPalaImg = Vector2(0, 0);
Vector2 midaPalaImg = Vector2(512, 64);
// Definim les coordenades de cada totxo dins de la imatge
final List<Vector2> llistaCoordenades = [
  Vector2(0.0, 64.0),  // Totxo blau
  Vector2(256.0, 64.0),  // Totxo verd
  Vector2(0.0, 192.0),  // Totxo groc
  Vector2(256.0, 192.0),  // Totxo taronja
  Vector2(0.0, 320.0),  // Totxo roig
];
Vector2 midaTotxoImg = Vector2(256, 128);
Vector2 posBolaImg1 = Vector2(256, 320);
Vector2 posBolaImg2 = Vector2(320, 320);
Vector2 midaBolaImg = Vector2(64, 64);

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 ValueNotifier<int> punts = ValueNotifier(0);
  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();
    await images.load('sheet.png');
    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;
    punts.value = 0;
    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),
        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 < llistaImatgesTotxos.length; j++)
        for (var j = 0; j < llistaCoordenades.length; j++) 
          Totxo(
            position: Vector2(
              (i + 0.5) * brickWidth + (i + 1) * juntaTotxos,
              (j + 1 + 2.0) * brickHeight + (j + 1) * juntaTotxos,
            ),
            posicio: llistaCoordenades[j],
            colorParticules: ColorsApp.colorsTotxos[j],
          ),
        
    ]);
  }

  @override
  void onTapDown(TapDownEvent event) {
    super.onTapDown(event);
    //if(playState == PlayState.inici){
      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;
}

A continuació el de la carpeta core.

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

Ara la carpeta components.

components.dart
export 'bola.dart';
export 'pala.dart';
export 'totxo.dart';
export 'play_area.dart';

play_area.dart
import 'dart:async';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import 'package:flame/collisions.dart';
import 'package:joc_breakout/core/colors.dart';
import '../breakout.dart';
class PlayArea extends RectangleComponent with HasGameReference<Breakout> {
  PlayArea() : super(
    paint: Paint()
      ..color = ColorsApp.fons,
    children: [RectangleHitbox()], 
  );

  @override
  FutureOr<void> onLoad() async {
    super.onLoad();
    size = Vector2(game.width, game.height);
  }
}

bola.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.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/config.dart';
class Bola extends SpriteComponent
    with CollisionCallbacks, HasGameReference<Breakout> {
  Bola({
    required this.velocity,
    required super.position,
    required double radius,
    required this.factorDificultat,
  }) : super(
        // Posem el diàmetre (doble del radi) en les dues direccions
        size: Vector2.all(radius * 2),
        anchor: Anchor.center,
      );

  final Vector2 velocity;
  final double factorDificultat;

  // Declarem els dos sprites com a variables de la classe per no recrear-los constantment
  late final Sprite spriteDreta;
  late final Sprite spriteEsquerra;

  // En carregar l'sprite, definim el fitxer i el detector de col·lisions
  @override
  Future<void> onLoad() async {
    super.onLoad();
    // Carreguem la imatge des del fitxer
    spriteDreta = Sprite(
      game.images.fromCache('sheet.png'),
      srcPosition: posBolaImg1,   // Coordenades inici bola
      srcSize: midaBolaImg,     // Mida de la bola
    );
    spriteEsquerra = Sprite(
      game.images.fromCache('sheet.png'),
      srcPosition: posBolaImg2,   // Coordenades inici bola
      srcSize: midaBolaImg,     // Mida de la bola
    );

    // Definim l'sprite inicial segons la velocitat de partida
    sprite = velocity.x >= 0 ? spriteDreta : spriteEsquerra;

    // Detector de col·lisions circular ajustat a la mida de la imatge
    add(CircleHitbox());
  }

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

    // Actualitzem l'sprite dinàmicament a cada frame segons la direcció actual
    if (velocity.x >= 0) {
      sprite = spriteDreta;
    } else {
      sprite = spriteEsquerra;
    }
  }

  @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);
    }
  }
}

pala.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/events.dart';
import 'package:joc_breakout/breakout.dart';
import 'package:joc_breakout/config.dart';
class Pala extends SpriteComponent
    with DragCallbacks, HasGameReference<Breakout> {
  Pala({
    required super.position,
    required super.size,
  }) : super(
    anchor: Anchor.center, 
  );

  @override
  Future<void> onLoad() async {
    super.onLoad();
    // Carreguem la imatge des del fitxer
    sprite = Sprite(
      game.images.fromCache('sheet.png'),
      srcPosition: posPalaImg,   // Coordenades inici pala
      srcSize: midaPalaImg,     // Mida de la pala
    );
    // Afegim la caixa de col·lisions rectangular adaptada a la mida del component
    add(RectangleHitbox());
  }

  @override
  void onDragUpdate(DragUpdateEvent event) {
    super.onDragUpdate(event);
    position.x = (position.x + event.localDelta.x).clamp(size.x / 2, game.width - size.x / 2);
  }

  void moveBy(double dx) {
    add(
      MoveToEffect(
        Vector2((position.x + dx).clamp(size.x / 2, game.width - size.x / 2), position.y),
        EffectController(duration: 0.1),
      ),
    );
  }
}

totxo.dart
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/particles.dart';
import 'package:joc_breakout/breakout.dart';
import 'package:joc_breakout/config.dart';
import 'bola.dart';
import 'pala.dart';
class Totxo extends SpriteComponent
    with CollisionCallbacks, HasGameReference<Breakout> {
  // Afegim 'this.colorParticules' al constructor
  // Ara demanem la posició en la imatge
  Totxo({
    required super.position, 
    required this.posicio,
    required this.colorParticules,
    })
    : super(
        size: Vector2(brickWidth, brickHeight),
        anchor: Anchor.center,
      );

  final Vector2 posicio;  // Posició de cada totxo al fitxer
  final Color colorParticules;

  // En carregar l'sprite, definim el fitxer i el detector de col·lisions
  @override
  Future<void> onLoad() async {
    super.onLoad();
    sprite = Sprite(
      game.images.fromCache('sheet.png'),
      srcPosition: posicio, // Utilitzem la posició que ens passen
      srcSize: midaTotxoImg,  // Mida del dibuix del totxo
    );
    // Afegim la caixa de col·lisions
    add(RectangleHitbox());
  }

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);

    // Només generem l'efecte si el xoc és amb la bola
    if (other is Bola) {
      _creaExplosioParticules();
    }

    removeFromParent();
    game.punts.value++;
    // 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>());
    }
  }

  // Efecte de partícules en explosió
  void _creaExplosioParticules() {
    final random = Random();
    // Afegim el sistema de partícules directament al món del joc
    game.world.add(
      ParticleSystemComponent(
        position: position.clone(), // Sortiran des del centre del totxo actual
        particle: Particle.generate(
          count: 15, // Nombre de partícules de l'explosió
          lifespan: 0.4, // Durada de l'efecte en segons
          generator: (i) {
            // Calculem una direcció i velocitat aleatòria (360 graus)
            final angle = random.nextDouble() * 2 * pi;
            final velocitat = random.nextDouble() * 120 + 60;

            return AcceleratedParticle(
              acceleration: Vector2(0, 250), // Gravetat cap avall perquè caiguin
              speed: Vector2(cos(angle), sin(angle)) * velocitat,
              child: CircleParticle(
                radius: 2.0 + random.nextDouble() * 3.0, // Mida aleatòria de la partícula
                paint: Paint()..color = colorParticules, 
              ),
            );
          },
        ),
      ),
    );
  }
}

I, per acabar, la carpeta widgets.

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';
import 'sobreposat.dart';
import 'puntuacio.dart';
class GameApp extends StatefulWidget {
  const GameApp({super.key});

  @override
  State<GameApp> createState() => _GameAppState();
}

class _GameAppState extends State<GameApp> {
  late final Breakout game;

  @override
  void initState() {
    super.initState();
    game = Breakout();
  }

  @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: Column(
                  children: [
                    Puntuacio(punts: game.punts),
                    Expanded(
                      child: FittedBox(
                        child: SizedBox(
                          width: gameWidth,
                          height: gameHeight,
                          child: GameWidget(
                            game: game,
                            overlayBuilderMap: {
                              PlayState.inici.name: (context, game) =>
                                const Sobreposat(
                                  title: 'PICA PER COMENÇAR',
                                  subtitle: 'Empra les fletxes o fes lliscar',
                                ),
                              PlayState.fi.name: (context, game) =>
                                const Sobreposat(
                                  title: 'F I   D E L   J O C',
                                  subtitle: 'Pica per tornar a jugar',
                                ),
                              PlayState.guanya.name: (context, game) =>
                                const Sobreposat(
                                  title: 'H A S   G U A N Y A T !',
                                  subtitle: 'Pica per tornar a jugar',
                                ),
                            }
                          )
                        )
                      )
                    ),
                  ],
                ),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

puntuacio.dart
import 'package:flutter/material.dart';
class Puntuacio extends StatelessWidget {
  const Puntuacio({super.key, required this.punts});

  final ValueNotifier<int> punts;

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder<int>(
      valueListenable: punts,
      builder: (context, punts, child) {
        return Padding(
          padding: const EdgeInsets.fromLTRB(12, 6, 12, 18),
          child: Text(
            'Punts: $punts'.toUpperCase(),
            style: Theme.of(context).textTheme.titleLarge!,
          ),
        );
      },
    );
  }
}

sobreposat.dart
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
class Sobreposat extends StatelessWidget {
  const Sobreposat({super.key, required this.title, required this.subtitle});
  final String title;
  final String subtitle;

  @override
  Widget build(BuildContext context) {
    return Container(
      alignment: const Alignment(0, -0.15),
      child: IgnorePointer(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            Text(
              title,
              style: Theme.of(context).textTheme.headlineLarge,
            ).animate().slideY(duration: 750.ms, begin: -3, end: 0),
            const SizedBox(height: 16),
            Text(subtitle, style: Theme.of(context).textTheme.headlineSmall)
                .animate(onPlay: (controller) => controller.repeat())
                .fadeIn(duration: 1.seconds)
                .then()
                .fadeOut(duration: 1.seconds),
          ],
        ),
      ),
    );
  }
}

 

 

 

 

 

 

 

 

 

 

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