Acerca de este codelab
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 los años 70, 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 cómo integrar Flame con el sistema de administración de estados de Flutter.
Cuando termines, el 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
de Flame Son similares a losWidget
de Flutter. - Cómo controlar las colisiones
- Cómo usar
Effect
para animarComponent
- Cómo superponer
Widget
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 termines, 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 funciones 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 las principales plataformas. 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 desees: Android Studio, otros IDE de IntelliJ, Emacs, Vim o Notepad++. Todos funcionan con Flutter.
Elige un segmento de desarrollo
Flutter produce apps para varias 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 el segmento de desarrollo. Este es el sistema operativo en el que se ejecuta tu app durante el desarrollo.
Por ejemplo, supongamos 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 el segmento de desarrollo, lo que ejecuta tu app en desarrollo como una app de Windows junto a tu editor.
Puede ser tentador elegir la Web como el segmento de desarrollo. Esto tiene una desventaja durante el desarrollo: pierdes la función de recarga en caliente con estado de Flutter. Actualmente, Flutter no puede hacer recargas en caliente de aplicaciones web.
Elige una opción antes de continuar. Podrás ejecutar tu app en otros sistemas operativos más adelante. Elegir un segmento de desarrollo facilita el siguiente paso.
Instala Flutter
Puedes 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 segmento 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 algún problema, es posible que algunas de estas preguntas y respuestas (de StackOverflow) te resulten ú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 app de Flutter en el 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. Los ejemplos incluyen tu directorio principal o
C:\src\
.
- Asigna el nombre
brick_breaker
a tu proyecto. En el resto de este codelab, se supone que le asignaste el nombrebrick_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 agregará a tu app el código de ejemplo que se proporciona en este codelab.
- En el panel izquierdo de VS Code, haz clic en Explorador 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. Se 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
Mide 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.
Existen varias formas de distribuir 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, en especial 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 positiva de X sigue siendo la misma, pero Y se invirtió. La dirección positiva de x era hacia la derecha y la de y hacia abajo. Para mantenerse 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 obtendrá 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 que se agregan a la pantalla se ajustan a esta altura y ancho.
Crea un área de juego
En el juego de Breakout, la pelota rebota en las paredes del área de juego. Para admitir 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
, Flame tiene Component
. 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 usarse y actualizar la capa RenderObject
persistente y mutable. Los componentes de Flame son persistentes y mutables, con la expectativa de que el desarrollador los use como parte de un sistema de simulación.
Los componentes de Flame están optimizados para expresar las mecánicas del juego. Este codelab comenzará con el bucle de juego, que se mostrará 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
tiene el rol inverso de import
. Declara qué funcionalidad expone este archivo cuando se importa a otro. Este archivo tendrá más entradas a medida que agregues componentes nuevos en los siguientes pasos.
Crea un juego de Flame
Para apagar las líneas rojas del paso anterior, deriva una subclase nueva 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 letterbox según sea necesario.
Expones el ancho y la altura del juego para que los componentes secundarios, como PlayArea
, puedan configurarse en el tamaño adecuado.
En el método anulado onLoad
, tu código realiza dos acciones.
- Configura la parte superior izquierda como el ancla del visor. De forma predeterminada,
viewfinder
usa el medio del área como el 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 partido 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 debe 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
Colocar una pelota en movimiento en la pantalla implica 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 nombradas como valores derivados aparecerá muchas veces en este codelab. Esto te permite modificar los gameWidth
y gameHeight
de nivel superior para explorar cómo cambia el aspecto 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;
}
}
Antes, definiste el PlayArea
con el RectangleComponent
, por lo que es lógico que existan más formas. CircleComponent
, al igual que RectangleComponent
, se 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 presenta el concepto de velocity
, o el cambio de posición con el tiempo. La velocidad es un objeto Vector2
, ya que la velocidad es la velocidad y la dirección. Para actualizar la posición, anula el método update
, al que el motor de juego llama para cada fotograma. dt
es la duración entre el fotograma anterior y este. Esto te permite adaptarte a factores como diferentes velocidades de fotogramas (60 Hz o 120 Hz) o fotogramas largos debido a un procesamiento excesivo.
Presta especial 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';
Cómo agregar 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
a 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.
Configurar el velocity
de la bola implica más complejidad. El objetivo es mover la bola hacia abajo de 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, independientemente de la dirección en la que se mueva. Luego, la velocidad de la pelota se ajusta para que sea 1/4 de la altura del juego.
Para obtener estos diversos valores correctos, se requiere cierta iteración, también conocida como pruebas de juego en la industria.
La última línea enciende la pantalla de depuración, que agrega información adicional a la pantalla para ayudar con la depuración.
Cuando ejecutes el juego, debería verse similar a la siguiente pantalla.
Tanto el componente PlayArea
como el componente Ball
tienen información de depuración, pero los mates de fondo recortan los números de PlayArea
. El motivo por el que todo muestra información de depuración es porque 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, 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 entran en contacto entre sí.
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 realiza un seguimiento de las hitboxes de los componentes y activa devoluciones de llamadas de colisión en cada tick del juego.
Para comenzar a propagar los 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);
}
}
Si agregas un componente RectangleHitbox
como elemento secundario de RectangleComponent
, se creará un cuadro de impacto para la detección de colisiones que coincida con el tamaño del componente superior. Hay un constructor de fábrica para RectangleHitbox
llamado relative
para las ocasiones en las que deseas un hitbox que sea más pequeño o más grande que el componente superior.
Haz rebotar la pelota.
Hasta el momento, agregar la detección de colisiones no hizo ninguna diferencia en el juego. Cambia una vez que modificas el componente Ball
. Es el comportamiento de la pelota el 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 ahora, 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 elementos que no son el bate. Este es un recordatorio para que implementes la lógica restante, si lo deseas.
Cuando la pelota choca con la pared inferior, desaparece de la superficie de juego, pero sigue siendo visible. Controlará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. Batea la pelota
Crea el archivo bat
Para agregar un bate que mantenga 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 explicativas. La constante batStep
, por otro lado, 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 la distancia que recorre el murciélago por cada vez que se presiona la tecla de flecha hacia la izquierda o la derecha.
- Define la clase de componentes
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 funciones 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 con atención la llamada canvas.drawRRect
(dibujar rectángulo redondeado), podrías preguntarte: "¿Dónde está el rectángulo?" Offset.zero & size.toSize()
aprovecha una sobrecarga de operator &
en la clase Offset
dart:ui
que crea Rect
. Al principio, esta abreviatura puede confundirte, 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 la 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 diga a este bate que se mueva hacia la izquierda o la derecha una cierta cantidad de píxeles virtuales. Esta función presenta una nueva función del motor de juego Flame: Effect
. Cuando se agrega el objeto MoveToEffect
como elemento secundario de este componente, el jugador ve el bate animado a una nueva posición. Hay una colección de Effect
disponibles en Flame para realizar una variedad de efectos.
Los argumentos del constructor de Effect incluyen una referencia al método get de game
. Por eso, incluyes la combinación HasGameReference
en esta clase. Esta combinación agrega un accesor game
seguro para 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 adición de la mixin KeyboardEvents
y el método onKeyEvent
anulado controlan la entrada del teclado. Recuerda el código que agregaste antes para mover el bate por la cantidad de pasos adecuada.
El resto del código agregado agrega el bate 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 de ajustar el tamaño relativo del bate y la pelota para obtener la sensación correcta del juego.
Si juegas el juego en este punto, verás que puedes mover el bate para interceptar la pelota, pero no obtienes 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 solucionarlo. 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 corrigen dos problemas independientes.
Primero, se corrige la bola que 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 permitir 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 muy bien 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 permisivo y quieres algo más realista, cambia este control para que se adapte mejor a la sensación que quieres que tenga el juego.
Vale la pena señalar la complejidad de la actualización de velocity
. No solo invierte el componente y
de la velocidad, como se hizo para las colisiones con las paredes. 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 cómo hacerlo, 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 ella.
8. Derribar 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, ya deberías estar familiarizado con la mayor parte de este código. Este código usa un RectangleComponent
, con detección de colisiones y una referencia segura para el tipo 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 elemento de su elemento superior.
El punto clave que debes comprender es que la eliminación de componentes es un comando en cola. Quita el bloque después de que se ejecuta este código, pero antes del siguiente tick del mundo del juego.
Para que BrickBreaker
pueda acceder al componente Brick
, 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 presenta el único aspecto nuevo, un modificador de dificultad que aumenta la velocidad de la pelota después de cada colisión con los ladrillos. Este parámetro ajustable debe probarse 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 tal como está actualmente, se mostrarán todas las mecánicas clave. Podrías desactivar la depuración y dar por terminado el proceso, pero parece que falta algo.
¿Qué tal una pantalla de bienvenida, una pantalla de fin de juego y, tal vez, una puntuación? Flutter puede agregar estas funciones al juego, y es a lo que te enfocarás a continuación.
9. Gana el partido
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, debes modificar los archivos del juego y los componentes para implementar un estado de juego que refleje si se debe mostrar una superposición y, de ser 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 una gran parte del juego BrickBreaker
. Agregar la enumeración playState
requiere mucho trabajo. Esto captura el momento en que el jugador ingresa al juego, lo juega y lo pierde o gana. En la parte superior del archivo, defines la enumeración y, luego, creas una instancia como un estado oculto con get y set coincidentes. Estos métodos get y set permiten modificar las superposiciones cuando las diferentes partes del juego activan transiciones de estado de juego.
A continuación, dividirás el código en onLoad
en onLoad y un nuevo método startGame
. Antes de este cambio, solo podías iniciar una partida nueva reiniciando el juego. Con estas nuevas incorporaciones, el jugador ahora puede comenzar un juego nuevo 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 de teclado para permitir que el usuario inicie un juego nuevo en varias modalidades. Con el estado de juego modelado, tendría sentido actualizar los componentes para activar las transiciones de estado de juego cuando el jugador gane o pierda.
- 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 escape 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, se mostrará una pantalla que indica que ganó el juego. ¡Buen trabajo, jugador!
Agrega el wrapper de Flutter
Para proporcionar un lugar donde incorporar el juego y agregar superposiciones de estado de juego, 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 de juego BrickBreaker
y el nuevo argumento overlayBuilderMap
para GameWidget
.
Las claves de este overlayBuilderMap
deben alinearse con las superposiciones que el set playState
en BrickBreaker
agregó o quitó. Si intentas establecer una superposición que no está en este mapa, se mostrarán caras tristes por todas partes.
- Para obtener esta nueva funcionalidad en 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 deseado se mostrará en el juego. Si tu segmentación es para macOS o Android, debes realizar un último ajuste para habilitar la visualización de google_fonts
.
Habilita el acceso a las fuentes
Agrega el 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>
Edita 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 lo ejecutas tal como está, debería mostrarse una pantalla de bienvenida y una pantalla de juego finalizado o ganado en todas las plataformas. Esas pantallas pueden ser un poco simplistas y sería bueno tener una puntuación. Adivína qué harás en el siguiente paso.
10. Mantén el registro
Agrega una puntuación al juego
En este paso, expones la puntuación del juego al contexto de Flutter circundante. En este paso, expones el estado del juego de Flame a la administración de estado de Flutter circundante. Esto permite que el código del juego actualice la puntuación cada vez que el jugador rompa 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);
}
Cuando 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 hora de armar 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 detalles a las superposiciones con el poder del paquete flutter_animate
para agregar movimiento y estilo a las pantallas superpuestas.
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 de la potencia de flutter_animate
, consulta el codelab Cómo compilar IUs de nueva 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
. La adición de la tarjeta de puntuación requiere la adición de 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! Compilaste 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 vea bien diseñado.
Próximos pasos
Consulta algunos codelabs sobre los siguientes temas:
- Cómo compilar IUs 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