1. Introducción
Flame es un motor de juego 2D basado en Flutter. En este codelab, compilarás un juego inspirado en uno de los clásicos de los videojuegos de la década de 1970, Breakout de Steve Wozniak. Usarás los componentes de Flame para dibujar el bate, la pelota y los ladrillos. Usarás los efectos de Flame para animar el movimiento del murciélago y verás cómo integrar Flame con el sistema de administración de estados de Flutter.
Cuando termines, tu juego debería verse como este GIF animado, aunque un poco más lento.
Qué aprenderás
- Cómo funcionan los conceptos básicos de Flame, comenzando con
GameWidget
. - Cómo usar un bucle de juego
- Cómo funcionan los
Component
s de Flame Son similares a losWidget
de Flutter. - Cómo controlar las colisiones.
- Cómo usar
Effect
s para animarComponent
s - Cómo superponer
Widget
s de Flutter sobre un juego de Flame - Cómo integrar Flame con la administración de estados de Flutter
Qué compilarás
En este codelab, compilarás un juego en 2D con Flutter y Flame. Cuando esté completo, tu juego debe cumplir con los siguientes requisitos:
- Funciona en las seis plataformas que admite Flutter: Android, iOS, Linux, macOS, Windows y la Web.
- Mantén al menos 60 FPS con el bucle de juego de Flame.
- Usa las capacidades de Flutter, como el paquete
google_fonts
yflutter_animate
, para recrear la sensación de los juegos de arcade de los años 80.
2. Configura tu entorno de Flutter
Editor
Para simplificar este codelab, se supone que Visual Studio Code (VS Code) es tu entorno de desarrollo. VS Code es gratuito y funciona en todas las plataformas principales. Usamos VS Code para este codelab porque las instrucciones predeterminadas indican combinaciones de teclas específicas de VS Code. Las tareas se vuelven más sencillas: "haz clic en este botón" o "presiona esta tecla para hacer X" en lugar de "realiza la acción apropiada en tu editor para hacer X".
Puedes usar cualquier editor que quieras: Android Studio, otros IDE de IntelliJ, Emacs, Vim o Notepad++. Todos funcionan con Flutter.
Elige un segmento de desarrollo
Flutter produce apps para múltiples plataformas. Tu app puede ejecutarse en cualquiera de los siguientes sistemas operativos:
- iOS
- Android
- Windows
- macOS
- Linux
- web
Es una práctica común elegir un sistema operativo como tu segmento de desarrollo. Este es el sistema operativo en el que se ejecuta tu app durante el desarrollo.
Por ejemplo, digamos que usas una laptop con Windows para desarrollar tu app de Flutter. Luego, eliges Android como tu segmento de desarrollo. Para obtener una vista previa de tu app, conecta un dispositivo Android a tu laptop con Windows con un cable USB, y tu app en desarrollo se ejecutará en ese dispositivo Android conectado o en un emulador de Android. Podrías haber elegido Windows como segmento de desarrollo, lo que ejecuta tu app en desarrollo como una app de Windows junto a tu editor.
Realiza tu elección antes de continuar. Podrás ejecutar tu app en otros sistemas operativos más adelante. Elegir un segmento de desarrollo simplifica los próximos pasos.
Instala Flutter
Podrás encontrar las instrucciones más actualizadas para instalar el SDK de Flutter en docs.flutter.dev.
Las instrucciones del sitio web de Flutter abarcan la instalación del SDK y también los complementos y las herramientas relacionadas con el segmento de desarrollo. Para este codelab, instala el siguiente software:
- El SDK de Flutter
- Visual Studio Code con el complemento de Flutter
- Software del compilador para el destino de desarrollo que elegiste (Necesitas Visual Studio para segmentar a Windows o Xcode para segmentar a macOS o iOS).
En la siguiente sección, crearás tu primer proyecto de Flutter.
Si necesitas solucionar problemas, consulta estas preguntas y respuestas (de StackOverflow), que te resultarán útiles.
Preguntas frecuentes
- ¿Cómo encuentro la ruta de acceso al SDK de Flutter?
- ¿Qué debo hacer si no encuentro el comando de Flutter?
- ¿Cómo soluciono el problema que indica "Waiting for another flutter command to release the startup lock"?
- ¿Cómo le indico a Flutter la ubicación de mi instalación del SDK de Android?
- ¿Cómo corrijo el error de Java cuando ejecuto
flutter doctor --android-licenses
? - ¿Cómo corrijo el error que indica que no se encontró la herramienta
sdkmanager
de Android? - ¿Cómo corrijo el error que indica "
cmdline-tools
component is missing"? - ¿Cómo ejecuto CocoaPods en Apple Silicon (M1)?
- ¿Cómo puedo inhabilitar la aplicación automática del formato en el momento de guardar en VS Code?
3. Crea un proyecto
Crea tu primer proyecto de Flutter
Esto implica abrir VS Code y crear la plantilla de la app de Flutter en un directorio que elijas.
- Inicia Visual Studio Code.
- Abre la paleta de comandos (
F1
,Ctrl+Shift+P
oShift+Cmd+P
) y, luego, escribe "flutter new". Cuando aparezca, selecciona el comando Flutter: New Project.
- Selecciona Empty Application. Elige un directorio en el que crear tu proyecto. Debe ser cualquier directorio que no requiera privilegios elevados ni tenga un espacio en su ruta. Por ejemplo, tu directorio principal o
C:\src\
.
- Asigna el nombre
brick_breaker
a tu proyecto. En el resto de este codelab, se supone que le pusistebrick_breaker
a tu app.
Flutter ahora creará la carpeta del proyecto y VS Code lo abrirá. Ahora reemplazarás el contenido de dos archivos con un andamiaje básico de la app.
Copia y pega la app inicial
Esto agrega el código de ejemplo proporcionado en este codelab a tu app.
- En el panel izquierdo de VS Code, haz clic en Explorer y abre el archivo
pubspec.yaml
.
- Reemplaza el contenido de este archivo con lo siguiente:
pubspec.yaml
name: brick_breaker
description: "Re-implementing Woz's Breakout"
publish_to: "none"
version: 0.1.0
environment:
sdk: ^3.8.0
dependencies:
flutter:
sdk: flutter
flame: ^1.28.1
flutter_animate: ^4.5.2
google_fonts: ^6.2.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
flutter:
uses-material-design: true
El archivo pubspec.yaml
especifica la información básica de tu app, como la versión actual, las dependencias y los recursos con los que se enviará.
- Abre el archivo
main.dart
en el directoriolib/
.
- Reemplaza el contenido de este archivo con lo siguiente:
lib/main.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
void main() {
final game = FlameGame();
runApp(GameWidget(game: game));
}
- Ejecuta este código para verificar que todo funcione correctamente. Debería mostrar una ventana nueva con solo un fondo negro en blanco. El peor videojuego del mundo ahora se renderiza a 60 FPS.
4. Crea el juego
Analiza el juego
Un juego que se juega en dos dimensiones (2D) necesita un área de juego. Construirás un área de dimensiones específicas y, luego, usarás estas dimensiones para determinar el tamaño de otros aspectos del juego.
Hay varias formas de establecer las coordenadas en el área de juego. Según una convención, puedes medir la dirección desde el centro de la pantalla con el origen (0,0)
en el centro de la pantalla. Los valores positivos mueven los elementos hacia la derecha a lo largo del eje X y hacia arriba a lo largo del eje Y. Este estándar se aplica a la mayoría de los juegos actuales, especialmente a los que involucran tres dimensiones.
La convención cuando se creó el juego original de Breakout era establecer el origen en la esquina superior izquierda. La dirección X positiva permaneció igual, pero la dirección Y se invirtió. La dirección positiva de X era hacia la derecha y la de Y, hacia abajo. Para ser fiel a la época, este juego establece el origen en la esquina superior izquierda.
Crea un archivo llamado config.dart
en un directorio nuevo llamado lib/src
. Este archivo contendrá más constantes en los siguientes pasos.
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
Este juego tendrá 820 píxeles de ancho y 1,600 píxeles de alto. El área de juego se ajusta para adaptarse a la ventana en la que se muestra, pero todos los componentes agregados a la pantalla se ajustan a esta altura y ancho.
Crea un PlayArea
En el juego Breakout, la pelota rebota en las paredes del área de juego. Para tener en cuenta las colisiones, primero necesitas un componente PlayArea
.
- Crea un archivo llamado
play_area.dart
en un directorio nuevo llamadolib/src/components
. - Agrega lo siguiente a este archivo.
lib/src/components/play_area.dart
import 'dart:async';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
PlayArea() : super(paint: Paint()..color = const Color(0xfff2e8cf));
@override
FutureOr<void> onLoad() async {
super.onLoad();
size = Vector2(game.width, game.height);
}
}
Mientras que Flutter tiene Widget
s, Flame tiene Component
s. Mientras que las apps de Flutter consisten en crear árboles de widgets, los juegos de Flame consisten en mantener árboles de componentes.
Ahí radica una diferencia interesante entre Flutter y Flame. El árbol de widgets de Flutter es una descripción efímera que se compila para actualizar la capa RenderObject
persistente y mutable. Los componentes de Flame son persistentes y mutables, y se espera que el desarrollador los use como parte de un sistema de simulación.
Los componentes de Flame están optimizados para expresar la mecánica del juego. Este codelab comenzará con el bucle de juego, que se muestra en el siguiente paso.
- Para controlar el desorden, agrega un archivo que contenga todos los componentes de este proyecto. Crea un archivo
components.dart
enlib/src/components
y agrega el siguiente contenido.
lib/src/components/components.dart
export 'play_area.dart';
La directiva export
desempeña el rol inverso de import
. Declara qué funcionalidad expone este archivo cuando se importa a otro archivo. Este archivo tendrá más entradas a medida que agregues componentes nuevos en los siguientes pasos.
Crea un juego con Flame
Para quitar las líneas onduladas rojas del paso anterior, deriva una nueva subclase para FlameGame
de Flame.
- Crea un archivo llamado
brick_breaker.dart
enlib/src
y agrega el siguiente código.
lib/src/brick_breaker.dart
import 'dart:async';
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
}
}
Este archivo coordina las acciones del juego. Durante la construcción de la instancia del juego, este código configura el juego para que use la renderización de resolución fija. El juego cambia de tamaño para llenar la pantalla que lo contiene y agrega letterboxing según sea necesario.
Expones el ancho y el alto del juego para que los componentes secundarios, como PlayArea
, puedan establecerse en el tamaño adecuado.
En el método anulado onLoad
, tu código realiza dos acciones.
- Configura la esquina superior izquierda como ancla del visor. De forma predeterminada,
viewfinder
usa el centro del área como ancla para(0,0)
. - Agrega
PlayArea
aworld
. El mundo representa el mundo del juego. Proyecta todos sus elementos secundarios a través de la transformación de vista deCameraComponent
.
Cómo mostrar el juego en la pantalla
Para ver todos los cambios que realizaste en este paso, actualiza tu archivo lib/main.dart
con los siguientes cambios.
lib/main.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'src/brick_breaker.dart'; // Add this import
void main() {
final game = BrickBreaker(); // Modify this line
runApp(GameWidget(game: game));
}
Después de realizar estos cambios, reinicia el juego. El juego debería parecerse a la siguiente imagen.
En el siguiente paso, agregarás una pelota al mundo y la harás moverse.
5. Cómo mostrar la pelota
Crea el componente de la pelota
Para poner una pelota en movimiento en la pantalla, debes crear otro componente y agregarlo al mundo del juego.
- Edita el contenido del archivo
lib/src/config.dart
de la siguiente manera.
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02; // Add this constant
El patrón de diseño de definir constantes con nombre como valores derivados se repetirá muchas veces en este codelab. Esto te permite modificar los elementos gameWidth
y gameHeight
de nivel superior para explorar cómo cambia la apariencia del juego como resultado.
- Crea el componente
Ball
en un archivo llamadoball.dart
enlib/src/components
.
lib/src/components/ball.dart
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
class Ball extends CircleComponent {
Ball({
required this.velocity,
required super.position,
required double radius,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
);
final Vector2 velocity;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
}
Anteriormente, definiste el PlayArea
con el RectangleComponent
, por lo que es lógico que existan más formas. CircleComponent
, al igual que RectangleComponent
, deriva de PositionedComponent
, por lo que puedes posicionar la pelota en la pantalla. Lo más importante es que se puede actualizar su posición.
Este componente introduce el concepto de velocity
, o cambio de posición con el tiempo. La velocidad es un objeto Vector2
, ya que la velocidad incluye tanto la rapidez como la dirección. Para actualizar la posición, anula el método update
, al que llama el motor del juego para cada fotograma. El valor dt
es la duración entre el cuadro anterior y este. Esto te permite adaptarte a factores como diferentes frecuencias de actualización (60 Hz o 120 Hz) o fotogramas largos debido a un exceso de procesamiento.
Presta mucha atención a la actualización de position += velocity * dt
. Así es como se implementa la actualización de una simulación discreta del movimiento a lo largo del tiempo.
- Para incluir el componente
Ball
en la lista de componentes, edita el archivolib/src/components/components.dart
de la siguiente manera.
lib/src/components/components.dart
export 'ball.dart'; // Add this export
export 'play_area.dart';
Agrega la pelota al mundo
Tienes una pelota. Colócalo en el mundo y configúralo para que se mueva por el área de juego.
Edita el archivo lib/src/brick_breaker.dart
de la siguiente manera.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math; // Add this import
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random(); // Add this variable
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(
Ball( // Add from here...
radius: ballRadius,
position: size / 2,
velocity: Vector2(
(rand.nextDouble() - 0.5) * width,
height * 0.2,
).normalized()..scale(height / 4),
),
);
debugMode = true; // To here.
}
}
Este cambio agrega el componente Ball
al world
. Para establecer el position
de la pelota en el centro del área de visualización, el código primero reduce a la mitad el tamaño del juego, ya que Vector2
tiene sobrecargas de operadores (*
y /
) para escalar un Vector2
por un valor escalar.
Establecer el velocity
de la pelota implica más complejidad. La intención es mover la pelota hacia abajo en la pantalla en una dirección aleatoria a una velocidad razonable. La llamada al método normalized
crea un objeto Vector2
establecido en la misma dirección que el Vector2
original, pero reducido a una distancia de 1. Esto mantiene la velocidad de la pelota constante, sin importar la dirección en la que vaya. Luego, la velocidad de la pelota se incrementa hasta alcanzar 1/4 de la altura del juego.
Obtener estos diversos valores correctamente implica cierta iteración, también conocida como prueba de juego en la industria.
La última línea activa la pantalla de depuración, que agrega información adicional para ayudar con la depuración.
Cuando ejecutes el juego, debería verse como la siguiente pantalla.
Tanto el componente PlayArea
como el componente Ball
tienen información de depuración, pero los fondos mate recortan los números del componente PlayArea
. El motivo por el que se muestra información de depuración para todo es que activaste debugMode
para todo el árbol de componentes. También puedes activar la depuración solo para los componentes seleccionados, si eso te resulta más útil.
Si reinicias el juego varias veces, es posible que notes que la pelota no interactúa con las paredes como se espera. Para lograr ese efecto, debes agregar la detección de colisiones, lo que harás en el siguiente paso.
6. Rebotar
Cómo agregar detección de colisiones
La detección de colisiones agrega un comportamiento en el que el juego reconoce cuando dos objetos entraron en contacto.
Para agregar la detección de colisiones al juego, agrega la combinación HasCollisionDetection
al juego BrickBreaker
, como se muestra en el siguiente código.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/game.dart';
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame with HasCollisionDetection { // Modify this line
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(
Ball(
radius: ballRadius,
position: size / 2,
velocity: Vector2(
(rand.nextDouble() - 0.5) * width,
height * 0.2,
).normalized()..scale(height / 4),
),
);
debugMode = true;
}
}
Esto hace un seguimiento de las cajas de colisiones de los componentes y activa devoluciones de llamadas de colisión en cada tic del juego.
Para comenzar a completar las hitboxes del juego, modifica el componente PlayArea
como se muestra a continuación:
lib/src/components/play_area.dart
import 'dart:async';
import 'package:flame/collisions.dart'; // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
class PlayArea extends RectangleComponent with HasGameReference<BrickBreaker> {
PlayArea()
: super(
paint: Paint()..color = const Color(0xfff2e8cf),
children: [RectangleHitbox()], // Add this parameter
);
@override
FutureOr<void> onLoad() async {
super.onLoad();
size = Vector2(game.width, game.height);
}
}
Agregar un componente RectangleHitbox
como elemento secundario del componente RectangleComponent
creará una caja de impacto para la detección de colisiones que coincida con el tamaño del componente principal. Hay un constructor de fábrica para RectangleHitbox
llamado relative
para los casos en los que quieras una hitbox más pequeña o más grande que el componente principal.
Rebota la pelota
Hasta el momento, agregar la detección de colisiones no hizo ninguna diferencia en la jugabilidad. Sin embargo, cambia una vez que modificas el componente Ball
. El comportamiento de la pelota es lo que debe cambiar cuando choca con el PlayArea
.
Modifica el componente Ball
de la siguiente manera.
lib/src/components/ball.dart
import 'package:flame/collisions.dart'; // Add this import
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart'; // And this import
import 'play_area.dart'; // And this one too
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> { // Add these mixins
Ball({
required this.velocity,
required super.position,
required double radius,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()], // Add this parameter
);
final Vector2 velocity;
@override
void update(double dt) {
super.update(dt);
position += velocity * dt;
}
@override // Add from here...
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) {
removeFromParent();
}
} else {
debugPrint('collision with $other');
}
} // To here.
}
En este ejemplo, se realiza un cambio importante con la adición de la devolución de llamada onCollisionStart
. El sistema de detección de colisiones que se agregó a BrickBreaker
en el ejemplo anterior llama a esta devolución de llamada.
Primero, el código prueba si Ball
chocó con PlayArea
. Por el momento, esto parece redundante, ya que no hay otros componentes en el mundo del juego. Eso cambiará en el siguiente paso, cuando agregues un murciélago al mundo. Luego, también agrega una condición else
para controlar cuando la pelota choca con objetos que no son el bate. Es un recordatorio para implementar la lógica restante, si lo deseas.
Cuando la pelota choca con la pared inferior, simplemente desaparece de la superficie de juego, aunque sigue siendo muy visible. Manejarás este artefacto en un paso posterior con el poder de los efectos de Flame.
Ahora que la pelota choca con las paredes del juego, sería útil darle al jugador un bate para golpearla…
7. Conectar la pelota con el bate
Crea el murciélago
Para agregar un bate y mantener la pelota en juego, haz lo siguiente:
- Inserta algunas constantes en el archivo
lib/src/config.dart
de la siguiente manera.
lib/src/config.dart
const gameWidth = 820.0;
const gameHeight = 1600.0;
const ballRadius = gameWidth * 0.02;
const batWidth = gameWidth * 0.2; // Add from here...
const batHeight = ballRadius * 2;
const batStep = gameWidth * 0.05; // To here.
Las constantes batHeight
y batWidth
son evidentes. Por otro lado, la constante batStep
necesita una explicación. Para interactuar con la pelota en este juego, el jugador puede arrastrar el bate con el mouse o el dedo, según la plataforma, o usar el teclado. La constante batStep
configura qué tan lejos se desplaza el murciélago con cada presión de la tecla de flecha hacia la izquierda o la derecha.
- Define la clase de componente
Bat
de la siguiente manera.
lib/src/components/bat.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flame/events.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
class Bat extends PositionComponent
with DragCallbacks, HasGameReference<BrickBreaker> {
Bat({
required this.cornerRadius,
required super.position,
required super.size,
}) : super(anchor: Anchor.center, children: [RectangleHitbox()]);
final Radius cornerRadius;
final _paint = Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill;
@override
void render(Canvas canvas) {
super.render(canvas);
canvas.drawRRect(
RRect.fromRectAndRadius(Offset.zero & size.toSize(), cornerRadius),
_paint,
);
}
@override
void onDragUpdate(DragUpdateEvent event) {
super.onDragUpdate(event);
position.x = (position.x + event.localDelta.x).clamp(0, game.width);
}
void moveBy(double dx) {
add(
MoveToEffect(
Vector2((position.x + dx).clamp(0, game.width), position.y),
EffectController(duration: 0.1),
),
);
}
}
Este componente presenta algunas capacidades nuevas.
En primer lugar, el componente Bat es un PositionComponent
, no un RectangleComponent
ni un CircleComponent
. Esto significa que este código debe renderizar el Bat
en la pantalla. Para ello, anula la devolución de llamada render
.
Si observas de cerca la llamada canvas.drawRRect
(dibuja un rectángulo redondeado), tal vez te preguntes: "¿Dónde está el rectángulo?". El Offset.zero & size.toSize()
aprovecha una sobrecarga de operator &
en la clase dart:ui
Offset
que crea Rect
s. Es posible que esta abreviatura te confunda al principio, pero la verás con frecuencia en el código de Flutter y Flame de nivel inferior.
En segundo lugar, este componente Bat
se puede arrastrar con el dedo o el mouse, según la plataforma. Para implementar esta funcionalidad, agrega el mixin DragCallbacks
y anula el evento onDragUpdate
.
Por último, el componente Bat
debe responder al control del teclado. La función moveBy
permite que otro código le indique a este murciélago que se mueva hacia la izquierda o la derecha una cierta cantidad de píxeles virtuales. Esta función presenta una nueva capacidad del motor de juegos de Flame: los Effect
. Si agregas el objeto MoveToEffect
como secundario de este componente, el jugador verá el bate animado en una nueva posición. En Flame, hay una colección de Effect
s disponibles para realizar una variedad de efectos.
Los argumentos del constructor de Effect incluyen una referencia al getter game
. Por eso, incluyes la combinación HasGameReference
en esta clase. Esta combinación agrega un accesor game
con seguridad de tipos a este componente para acceder a la instancia de BrickBreaker
en la parte superior del árbol de componentes.
- Para que
Bat
esté disponible paraBrickBreaker
, actualiza el archivolib/src/components/components.dart
de la siguiente manera.
lib/src/components/components.dart
export 'ball.dart';
export 'bat.dart'; // Add this export
export 'play_area.dart';
Agrega el murciélago al mundo
Para agregar el componente Bat
al mundo del juego, actualiza BrickBreaker
de la siguiente manera.
lib/src/brick_breaker.dart
import 'dart:async';
import 'dart:math' as math;
import 'package:flame/components.dart';
import 'package:flame/events.dart'; // Add this import
import 'package:flame/game.dart';
import 'package:flutter/material.dart'; // And this import
import 'package:flutter/services.dart'; // And this
import 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents { // Modify this line
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(
Ball(
radius: ballRadius,
position: size / 2,
velocity: Vector2(
(rand.nextDouble() - 0.5) * width,
height * 0.2,
).normalized()..scale(height / 4),
),
);
world.add( // Add from here...
Bat(
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95),
),
); // To here.
debugMode = true;
}
@override // Add from here...
KeyEventResult onKeyEvent(
KeyEvent event,
Set<LogicalKeyboardKey> keysPressed,
) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
}
return KeyEventResult.handled;
} // To here.
}
La incorporación del mixin KeyboardEvents
y el método onKeyEvent
anulado controlan la entrada del teclado. Recuerda el código que agregaste antes para mover el murciélago según la cantidad de pasos adecuada.
El resto del código agregado añade el murciélago al mundo del juego en la posición adecuada y con las proporciones correctas. Tener todos estos parámetros de configuración expuestos en este archivo simplifica tu capacidad para ajustar el tamaño relativo del bate y la pelota para obtener la sensación correcta del juego.
Si juegas en este punto, verás que puedes mover el bate para interceptar la pelota, pero no obtendrás ninguna respuesta visible, aparte del registro de depuración que dejaste en el código de detección de colisiones de Ball
.
Es hora de corregirlo. Edita el componente Ball
de la siguiente manera.
lib/src/components/ball.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart'; // Add this import
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import 'bat.dart'; // And this import
import 'play_area.dart';
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Ball({
required this.velocity,
required super.position,
required double radius,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()],
);
final Vector2 velocity;
@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)); // Modify from here...
}
} else if (other is Bat) {
velocity.y = -velocity.y;
velocity.x =
velocity.x +
(position.x - other.position.x) / other.size.x * game.width * 0.3;
} else { // To here.
debugPrint('collision with $other');
}
}
}
Estos cambios de código solucionan dos problemas distintos.
Primero, corrige el problema de que la pelota desaparece en el momento en que toca la parte inferior de la pantalla. Para solucionar este problema, reemplaza la llamada a removeFromParent
por RemoveEffect
. El RemoveEffect
quita la pelota del mundo del juego después de dejar que salga del área de juego visible.
En segundo lugar, estos cambios corrigen el manejo de la colisión entre el bate y la pelota. Este código de control funciona mucho a favor del jugador. Mientras el jugador toque la pelota con el bate, esta volverá a la parte superior de la pantalla. Si esto te parece demasiado indulgente y quieres algo más realista, cambia este manejo para que se ajuste mejor a cómo quieres que se sienta tu juego.
Vale la pena destacar la complejidad de la actualización de velocity
. No solo invierte el componente y
de la velocidad, como se hizo para las colisiones con la pared. También actualiza el componente x
de una manera que depende de la posición relativa del bate y la pelota en el momento del contacto. Esto le da al jugador más control sobre lo que hace la pelota, pero no se le comunica de ninguna manera, excepto a través del juego.
Ahora que tienes un bate con el que golpear la pelota, sería bueno tener algunos ladrillos para romper con la pelota.
8. Derriba el muro
Crea los ladrillos
Para agregar ladrillos al juego, haz lo siguiente:
- Inserta algunas constantes en el archivo
lib/src/config.dart
de la siguiente manera.
lib/src/config.dart
import 'package:flutter/material.dart'; // Add this import
const brickColors = [ // Add this const
Color(0xfff94144),
Color(0xfff3722c),
Color(0xfff8961e),
Color(0xfff9844a),
Color(0xfff9c74f),
Color(0xff90be6d),
Color(0xff43aa8b),
Color(0xff4d908e),
Color(0xff277da1),
Color(0xff577590),
];
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 brickGutter = gameWidth * 0.015; // Add from here...
final brickWidth =
(gameWidth - (brickGutter * (brickColors.length + 1))) / brickColors.length;
const brickHeight = gameHeight * 0.03;
const difficultyModifier = 1.03; // To here.
- Inserta el componente
Brick
de la siguiente manera.
lib/src/components/brick.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';
class Brick extends RectangleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Brick({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();
if (game.world.children.query<Brick>().length == 1) {
game.world.removeAll(game.world.children.query<Ball>());
game.world.removeAll(game.world.children.query<Bat>());
}
}
}
A estas alturas, la mayor parte de este código debería resultarte familiar. Este código usa un RectangleComponent
, con detección de colisiones y una referencia con seguridad de tipos al juego BrickBreaker
en la parte superior del árbol de componentes.
El concepto nuevo más importante que presenta este código es cómo el jugador logra la condición de victoria. La verificación de la condición de victoria consulta el mundo en busca de ladrillos y confirma que solo queda uno. Esto puede ser un poco confuso, ya que la línea anterior quita este bloque de su elemento superior.
El punto clave que debes comprender es que la eliminación de componentes es un comando en cola. Quita el ladrillo después de que se ejecuta este código, pero antes del siguiente ciclo del mundo del juego.
Para que el componente Brick
sea accesible para BrickBreaker
, edita lib/src/components/components.dart
de la siguiente manera.
lib/src/components/components.dart
export 'ball.dart';
export 'bat.dart';
export 'brick.dart'; // Add this export
export 'play_area.dart';
Agrega ladrillos al mundo
Actualiza el componente Ball
de la siguiente manera.
lib/src/components/ball.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart'; // Add this import
import 'play_area.dart';
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Ball({
required this.velocity,
required super.position,
required double radius,
required this.difficultyModifier, // Add this parameter
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()],
);
final Vector2 velocity;
final double difficultyModifier; // Add this member
@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));
}
} else if (other is Bat) {
velocity.y = -velocity.y;
velocity.x =
velocity.x +
(position.x - other.position.x) / other.size.x * game.width * 0.3;
} else if (other is Brick) { // Modify from here...
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 * difficultyModifier); // To here.
}
}
}
Esto introduce el único aspecto nuevo, un modificador de dificultad que aumenta la velocidad de la pelota después de cada colisión con un ladrillo. Este parámetro ajustable debe someterse a pruebas de juego para encontrar la curva de dificultad adecuada para tu juego.
Edita el juego BrickBreaker
de la siguiente manera.
lib/src/brick_breaker.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 'components/components.dart';
import 'config.dart';
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
world.add(
Ball(
difficultyModifier: difficultyModifier, // Add this argument
radius: ballRadius,
position: size / 2,
velocity: Vector2(
(rand.nextDouble() - 0.5) * width,
height * 0.2,
).normalized()..scale(height / 4),
),
);
world.add(
Bat(
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95),
),
);
await world.addAll([ // Add from here...
for (var i = 0; i < brickColors.length; i++)
for (var j = 1; j <= 5; j++)
Brick(
position: Vector2(
(i + 0.5) * brickWidth + (i + 1) * brickGutter,
(j + 2.0) * brickHeight + j * brickGutter,
),
color: brickColors[i],
),
]); // To here.
debugMode = true;
}
@override
KeyEventResult onKeyEvent(
KeyEvent event,
Set<LogicalKeyboardKey> keysPressed,
) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
}
return KeyEventResult.handled;
}
}
Si ejecutas el juego, se mostrarán todas las mecánicas clave. Podrías desactivar la depuración y dar el trabajo por terminado, pero sentirías que falta algo.
¿Qué tal una pantalla de bienvenida, una pantalla de fin del juego y, tal vez, una puntuación? Flutter puede agregar estas funciones al juego, y es a lo que le prestarás atención a continuación.
9. Cómo ganar el juego
Agrega estados de reproducción
En este paso, incorporarás el juego de Flame dentro de un wrapper de Flutter y, luego, agregarás superposiciones de Flutter para las pantallas de bienvenida, fin del juego y victoria.
Primero, modifica los archivos del juego y del componente para implementar un estado de reproducción que refleje si se debe mostrar una superposición y, si es así, cuál.
- Modifica el juego
BrickBreaker
de la siguiente manera.
lib/src/brick_breaker.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 'components/components.dart';
import 'config.dart';
enum PlayState { welcome, playing, gameOver, won } // Add this enumeration
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents, TapDetector { // Modify this line
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final rand = math.Random();
double get width => size.x;
double get height => size.y;
late PlayState _playState; // Add from here...
PlayState get playState => _playState;
set playState(PlayState playState) {
_playState = playState;
switch (playState) {
case PlayState.welcome:
case PlayState.gameOver:
case PlayState.won:
overlays.add(playState.name);
case PlayState.playing:
overlays.remove(PlayState.welcome.name);
overlays.remove(PlayState.gameOver.name);
overlays.remove(PlayState.won.name);
}
} // To here.
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
playState = PlayState.welcome; // Add from here...
}
void startGame() {
if (playState == PlayState.playing) return;
world.removeAll(world.children.query<Ball>());
world.removeAll(world.children.query<Bat>());
world.removeAll(world.children.query<Brick>());
playState = PlayState.playing; // To here.
world.add(
Ball(
difficultyModifier: difficultyModifier,
radius: ballRadius,
position: size / 2,
velocity: Vector2(
(rand.nextDouble() - 0.5) * width,
height * 0.2,
).normalized()..scale(height / 4),
),
);
world.add(
Bat(
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95),
),
);
world.addAll([ // Drop the await
for (var i = 0; i < brickColors.length; i++)
for (var j = 1; j <= 5; j++)
Brick(
position: Vector2(
(i + 0.5) * brickWidth + (i + 1) * brickGutter,
(j + 2.0) * brickHeight + j * brickGutter,
),
color: brickColors[i],
),
]);
} // Drop the debugMode
@override // Add from here...
void onTap() {
super.onTap();
startGame();
} // To here.
@override
KeyEventResult onKeyEvent(
KeyEvent event,
Set<LogicalKeyboardKey> keysPressed,
) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
case LogicalKeyboardKey.space: // Add from here...
case LogicalKeyboardKey.enter:
startGame(); // To here.
}
return KeyEventResult.handled;
}
@override
Color backgroundColor() => const Color(0xfff2e8cf); // Add this override
}
Este código cambia gran parte del juego BrickBreaker
. Agregar la enumeración playState
requiere mucho trabajo. Esto captura en qué punto se encuentra el jugador para ingresar, jugar y perder o ganar el juego. En la parte superior del archivo, defines la enumeración y, luego, la instancias como un estado oculto con los métodos get y set correspondientes. Estos métodos de obtención y configuración permiten modificar las superposiciones cuando las distintas partes del juego activan transiciones de estado de reproducción.
A continuación, divide el código en onLoad
en onLoad y un nuevo método startGame
. Antes de este cambio, solo podías iniciar un juego nuevo reiniciándolo. Con estas nuevas incorporaciones, el jugador ahora puede comenzar un nuevo juego sin medidas tan drásticas.
Para permitir que el jugador inicie un juego nuevo, configuraste dos controladores nuevos para el juego. Agregaste un controlador de toques y extendiste el controlador del teclado para permitir que el usuario inicie un juego nuevo en varias modalidades. Con el estado de reproducción modelado, tendría sentido actualizar los componentes para activar las transiciones de estado de reproducción cuando el jugador gana o pierde.
- Modifica el componente
Ball
de la siguiente manera.
lib/src/components/ball.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flame/effects.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import 'bat.dart';
import 'brick.dart';
import 'play_area.dart';
class Ball extends CircleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Ball({
required this.velocity,
required super.position,
required double radius,
required this.difficultyModifier,
}) : super(
radius: radius,
anchor: Anchor.center,
paint: Paint()
..color = const Color(0xff1e6091)
..style = PaintingStyle.fill,
children: [CircleHitbox()],
);
final Vector2 velocity;
final double difficultyModifier;
@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: () { // Modify from here
game.playState = PlayState.gameOver;
},
),
); // To here.
}
} else if (other is Bat) {
velocity.y = -velocity.y;
velocity.x =
velocity.x +
(position.x - other.position.x) / other.size.x * game.width * 0.3;
} else if (other is Brick) {
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 * difficultyModifier);
}
}
}
Este pequeño cambio agrega una devolución de llamada onComplete
a RemoveEffect
que activa el estado de reproducción gameOver
. Esto debería ser correcto si el jugador permite que la pelota se salga de la parte inferior de la pantalla.
- Edita el componente
Brick
de la siguiente manera.
lib/src/components/brick.dart
impimport 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';
class Brick extends RectangleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Brick({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();
if (game.world.children.query<Brick>().length == 1) {
game.playState = PlayState.won; // Add this line
game.world.removeAll(game.world.children.query<Ball>());
game.world.removeAll(game.world.children.query<Bat>());
}
}
}
Por otro lado, si el jugador puede romper todos los ladrillos, aparecerá la pantalla de "juego ganado". ¡Bien hecho, jugador!
Agrega el wrapper de Flutter
Para proporcionar un lugar donde incorporar el juego y agregar superposiciones de estado de reproducción, agrega el shell de Flutter.
- Crea un directorio
widgets
enlib/src
. - Agrega un archivo
game_app.dart
y, luego, inserta el siguiente contenido en él.
lib/src/widgets/game_app.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../brick_breaker.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: const Color(0xff184e77),
displayColor: const Color(0xff184e77),
),
),
home: Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xffa9d6e5), Color(0xfff2e8cf)],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: FittedBox(
child: SizedBox(
width: gameWidth,
height: gameHeight,
child: GameWidget.controlled(
gameFactory: BrickBreaker.new,
overlayBuilderMap: {
PlayState.welcome.name: (context, game) => Center(
child: Text(
'TAP TO PLAY',
style: Theme.of(context).textTheme.headlineLarge,
),
),
PlayState.gameOver.name: (context, game) => Center(
child: Text(
'G A M E O V E R',
style: Theme.of(context).textTheme.headlineLarge,
),
),
PlayState.won.name: (context, game) => Center(
child: Text(
'Y O U W O N ! ! !',
style: Theme.of(context).textTheme.headlineLarge,
),
),
},
),
),
),
),
),
),
),
),
);
}
}
La mayor parte del contenido de este archivo sigue una compilación estándar del árbol de widgets de Flutter. Las partes específicas de Flame incluyen el uso de GameWidget.controlled
para construir y administrar la instancia del juego BrickBreaker
y el nuevo argumento overlayBuilderMap
para GameWidget
.
Las claves de este overlayBuilderMap
deben alinearse con las superposiciones que el configurador de playState
en BrickBreaker
agregó o quitó. Si intentas establecer una superposición que no se encuentra en este mapa, se mostrarán caras tristes por todas partes.
- Para que esta nueva funcionalidad aparezca en la pantalla, reemplaza el archivo
lib/main.dart
por el siguiente contenido.
lib/main.dart
import 'package:flutter/material.dart';
import 'src/widgets/game_app.dart';
void main() {
runApp(const GameApp());
}
Si ejecutas este código en iOS, Linux, Windows o la Web, el resultado previsto se muestra en el juego. Si segmentas a macOS o Android, debes realizar un último ajuste para habilitar la visualización de google_fonts
.
Habilita el acceso a las fuentes
Cómo agregar permiso de Internet para Android
En Android, debes agregar el permiso de Internet. Edita tu AndroidManifest.xml
de la siguiente manera.
android/app/src/main/AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Add the following line -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:label="brick_breaker"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
Cómo editar archivos de derechos para macOS
En macOS, tienes dos archivos para editar.
- Edita el archivo
DebugProfile.entitlements
para que coincida con el siguiente código.
macos/Runner/DebugProfile.entitlements
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<!-- Add from here... -->
<key>com.apple.security.network.client</key>
<true/>
<!-- to here. -->
</dict>
</plist>
- Edita el archivo
Release.entitlements
para que coincida con el siguiente código
macos/Runner/Release.entitlements
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<!-- Add from here... -->
<key>com.apple.security.network.client</key>
<true/>
<!-- to here. -->
</dict>
</plist>
Si ejecutas el código tal como está, se mostrará una pantalla de bienvenida y una pantalla de fin de partida o de victoria en todas las plataformas. Esas pantallas pueden ser un poco simplistas, y sería bueno tener una puntuación. Así que adivina qué harás en el siguiente paso.
10. Cómo llevar el puntaje
Cómo agregar una puntuación al juego
En este paso, expones la puntuación del juego al contexto de Flutter circundante. En este paso, expondrás el estado del juego de Flame a la administración de estados de Flutter circundante. Esto permite que el código del juego actualice la puntuación cada vez que el jugador rompe un ladrillo.
- Modifica el juego
BrickBreaker
de la siguiente manera.
lib/src/brick_breaker.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 'components/components.dart';
import 'config.dart';
enum PlayState { welcome, playing, gameOver, won }
class BrickBreaker extends FlameGame
with HasCollisionDetection, KeyboardEvents, TapDetector {
BrickBreaker()
: super(
camera: CameraComponent.withFixedResolution(
width: gameWidth,
height: gameHeight,
),
);
final ValueNotifier<int> score = ValueNotifier(0); // Add this line
final rand = math.Random();
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.welcome:
case PlayState.gameOver:
case PlayState.won:
overlays.add(playState.name);
case PlayState.playing:
overlays.remove(PlayState.welcome.name);
overlays.remove(PlayState.gameOver.name);
overlays.remove(PlayState.won.name);
}
}
@override
FutureOr<void> onLoad() async {
super.onLoad();
camera.viewfinder.anchor = Anchor.topLeft;
world.add(PlayArea());
playState = PlayState.welcome;
}
void startGame() {
if (playState == PlayState.playing) return;
world.removeAll(world.children.query<Ball>());
world.removeAll(world.children.query<Bat>());
world.removeAll(world.children.query<Brick>());
playState = PlayState.playing;
score.value = 0; // Add this line
world.add(
Ball(
difficultyModifier: difficultyModifier,
radius: ballRadius,
position: size / 2,
velocity: Vector2(
(rand.nextDouble() - 0.5) * width,
height * 0.2,
).normalized()..scale(height / 4),
),
);
world.add(
Bat(
size: Vector2(batWidth, batHeight),
cornerRadius: const Radius.circular(ballRadius / 2),
position: Vector2(width / 2, height * 0.95),
),
);
world.addAll([
for (var i = 0; i < brickColors.length; i++)
for (var j = 1; j <= 5; j++)
Brick(
position: Vector2(
(i + 0.5) * brickWidth + (i + 1) * brickGutter,
(j + 2.0) * brickHeight + j * brickGutter,
),
color: brickColors[i],
),
]);
}
@override
void onTap() {
super.onTap();
startGame();
}
@override
KeyEventResult onKeyEvent(
KeyEvent event,
Set<LogicalKeyboardKey> keysPressed,
) {
super.onKeyEvent(event, keysPressed);
switch (event.logicalKey) {
case LogicalKeyboardKey.arrowLeft:
world.children.query<Bat>().first.moveBy(-batStep);
case LogicalKeyboardKey.arrowRight:
world.children.query<Bat>().first.moveBy(batStep);
case LogicalKeyboardKey.space:
case LogicalKeyboardKey.enter:
startGame();
}
return KeyEventResult.handled;
}
@override
Color backgroundColor() => const Color(0xfff2e8cf);
}
Si agregas score
al juego, vinculas el estado del juego a la administración de estado de Flutter.
- Modifica la clase
Brick
para agregar un punto a la puntuación cuando el jugador rompa ladrillos.
lib/src/components/brick.dart
import 'package:flame/collisions.dart';
import 'package:flame/components.dart';
import 'package:flutter/material.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'ball.dart';
import 'bat.dart';
class Brick extends RectangleComponent
with CollisionCallbacks, HasGameReference<BrickBreaker> {
Brick({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();
game.score.value++; // Add this line
if (game.world.children.query<Brick>().length == 1) {
game.playState = PlayState.won;
game.world.removeAll(game.world.children.query<Ball>());
game.world.removeAll(game.world.children.query<Bat>());
}
}
}
Crea un juego atractivo
Ahora que puedes llevar el registro de la puntuación en Flutter, es momento de unir los widgets para que se vea bien.
- Crea
score_card.dart
enlib/src/widgets
y agrega lo siguiente.
lib/src/widgets/score_card.dart
import 'package:flutter/material.dart';
class ScoreCard extends StatelessWidget {
const ScoreCard({super.key, required this.score});
final ValueNotifier<int> score;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<int>(
valueListenable: score,
builder: (context, score, child) {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 12, 18),
child: Text(
'Score: $score'.toUpperCase(),
style: Theme.of(context).textTheme.titleLarge!,
),
);
},
);
}
}
- Crea
overlay_screen.dart
enlib/src/widgets
y agrega el siguiente código.
Esto agrega más pulido a las superposiciones con la potencia del paquete flutter_animate
para agregar movimiento y estilo a las pantallas de superposición.
lib/src/widgets/overlay_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
class OverlayScreen extends StatelessWidget {
const OverlayScreen({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: 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),
],
),
);
}
}
Para obtener una visión más detallada del poder de flutter_animate
, consulta el codelab Cómo compilar IU de próxima generación en Flutter.
Este código cambió mucho en el componente GameApp
. Primero, para permitir que ScoreCard
acceda a score
, debes convertirlo de StatelessWidget
a StatefulWidget
. Para agregar la tarjeta de puntuación, se debe agregar un Column
para apilar la puntuación sobre el juego.
En segundo lugar, para mejorar las experiencias de bienvenida, fin del juego y victoria, agregaste el nuevo widget OverlayScreen
.
lib/src/widgets/game_app.dart
import 'package:flame/game.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import '../brick_breaker.dart';
import '../config.dart';
import 'overlay_screen.dart'; // Add this import
import 'score_card.dart'; // And this one too
class GameApp extends StatefulWidget { // Modify this line
const GameApp({super.key});
@override // Add from here...
State<GameApp> createState() => _GameAppState();
}
class _GameAppState extends State<GameApp> {
late final BrickBreaker game;
@override
void initState() {
super.initState();
game = BrickBreaker();
} // To here.
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
textTheme: GoogleFonts.pressStart2pTextTheme().apply(
bodyColor: const Color(0xff184e77),
displayColor: const Color(0xff184e77),
),
),
home: Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xffa9d6e5), Color(0xfff2e8cf)],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Column( // Modify from here...
children: [
ScoreCard(score: game.score),
Expanded(
child: FittedBox(
child: SizedBox(
width: gameWidth,
height: gameHeight,
child: GameWidget(
game: game,
overlayBuilderMap: {
PlayState.welcome.name: (context, game) =>
const OverlayScreen(
title: 'TAP TO PLAY',
subtitle: 'Use arrow keys or swipe',
),
PlayState.gameOver.name: (context, game) =>
const OverlayScreen(
title: 'G A M E O V E R',
subtitle: 'Tap to Play Again',
),
PlayState.won.name: (context, game) =>
const OverlayScreen(
title: 'Y O U W O N ! ! !',
subtitle: 'Tap to Play Again',
),
},
),
),
),
),
],
), // To here.
),
),
),
),
),
);
}
}
Con todo esto en su lugar, ahora deberías poder ejecutar este juego en cualquiera de las seis plataformas de destino de Flutter. El juego debería parecerse al siguiente.
11. Felicitaciones
¡Felicitaciones! Lograste compilar un juego con Flutter y Flame.
Compilaste un juego con el motor de juego 2D de Flame y lo incorporaste en un wrapper de Flutter. Usaste los efectos de Flame para animar y quitar componentes. Usaste los paquetes de Google Fonts y Flutter Animate para que todo el juego se viera bien diseñado.
Próximos pasos
Consulta algunos codelabs sobre los siguientes temas:
- Cómo compilar IU de nueva generación en Flutter
- Cómo hacer que tu app de Flutter pase de aburrida a atractiva
- Cómo agregar compras directas desde la aplicación a tu app de Flutter