1. Introducción
Descubre cómo compilar un juego de plataformas con Flutter y Flame. En el juego Doodle Dash, inspirado por Doodle Jump, jugarás como Dash (la mascota de Flutter) o su mejor amigo, Sparky (la mascota de Firebase), e intentarás saltar lo más alto posible sobre plataformas.
Qué aprenderás
- Cómo compilar un juego multiplataforma en Flutter
- Cómo crear componentes reutilizables de juego que se puedan renderizar y actualizar como parte del bucle de juego de Flame
- Cómo controlar y animar los movimientos de tu personaje (lo que se conoce como objeto) mediante la física del juego
- Cómo agregar y gestionar la detección de colisiones
- Cómo agregar el teclado y la entrada táctil como controles del juego
Requisitos previos
En este codelab, se presupone que tienes experiencia en Flutter. Si no es así, puedes aprender los conceptos básicos con el codelab Tu primera app de Flutter.
Qué compilarás
En este codelab, te ayudaremos a compilar un juego llamado Doodle Dash: un juego de plataformas con Dash, la mascota de Flutter, o Sparky, la mascota de Firebase (el resto del codelab hace referencia a Dash, pero los pasos también aplican a Sparky). Tu juego tendrá los siguientes elementos:
- Un objeto que se puede mover horizontal y verticalmente
- Plataformas generadas de forma aleatoria
- Un efecto gravitatorio que empuje tu objeto hacia abajo
- Menús de juego
- Controles integrados en el juego, como los de pausa y volver a reproducir
- La habilidad de llevar la puntuación
Juego
Doodle Dash se juega moviendo a Dash hacia la izquierda y la derecha, saltando sobre plataformas y usando potenciadores para aumentar su habilidad a lo largo del juego. Comenzarás el juego eligiendo el nivel de dificultad (de 1 a 5) y haciendo clic en Start.
Niveles
El juego tiene 5 niveles. Cada uno (luego del nivel 1) desbloquea nuevas funciones.
- Nivel 1 (predeterminado): Este nivel genera las plataformas
NormalPlatform
ySpringBoard
. Cuando se crea, hay un 20% de probabilidades de que esa plataforma sea móvil. - Nivel 2 (puntuación >= 20): Agrega
BrokenPlatform
, en la que solo se puede saltar una vez. - Nivel 3 (puntuación >= 40): Desbloquea el potenciador
NooglerHat
. Esta plataforma especial dura 5 segundos y aumenta la habilidad de salto de Dash a 2.5 veces su velocidad normal. También lleva un divertido gorro de empleado de Google nuevo durante esos 5 segundos. - Nivel 4 (puntuación >= 80): Desbloquea el potenciador
Rocket
. Esta plataforma especial, representada por un cohete, hace que Dash sea invencible. También aumenta su habilidad de salto a 3.5 veces su velocidad normal. - Nivel 5 (puntuación >= 100): Desbloquea las plataformas
Enemy
. Si Dash choca con un enemigo, perderás automáticamente el juego.
Tipos de plataforma según el nivel
Nivel 1 (predeterminado)
|
|
Nivel 2 (puntuación >= 20) | Nivel 3 (puntuación >= 40) | Nivel 4 (puntuación >= 80) | Nivel 5 (puntuación >= 100) |
|
|
|
|
Cómo perder en el juego
Hay dos maneras de perder en el juego:
- Si Dash cae por debajo de la parte inferior de la pantalla
- Si Dash choca con un enemigo (los enemigos se generan en el nivel 5)
Potenciadores
Los potenciadores aumentan la habilidad de juego del personaje; por ejemplo, aumentan su velocidad de salto, le permiten volverse "invencible" frente a los enemigos, o ambas cosas. Doodle Dash tiene dos opciones de potenciación. Solo se activan de a una por vez.
- El potenciador del gorro de empleado de Google nuevo aumenta la habilidad de salto de Dash a 2.5 veces su altura de salto normal. Además, llevará el gorro durante la potenciación.
- El potenciador de cohete hace que Dash sea invencible ante plataformas enemigas (chocar con un enemigo no tiene ningún efecto) y aumenta su habilidad de salto a 3.5 veces su altura de salto normal. Dash volará en un cohete hasta que la gravedad supere su velocidad y aterrizará en una plataforma.
2. Obtén el código de partida del codelab
Descarga la versión inicial de tu proyecto desde GitHub:
- Desde la línea de comandos, clona el repositorio de GitHub en un directorio
flutter-codelabs
:
git clone https://github.com/flutter/codelabs.git flutter-codelabs
El código de este codelab se encuentra en el directorio flutter-codelabs/flame-building-doodle-dash
. El directorio contiene el código completo del proyecto para cada paso del codelab.
Importa la app de partida
- Importa el directorio
flutter-codelabs/flame-building-doodle-dash/step_02
al IDE que prefieras.
Instala los paquetes:
- Todos los paquetes requeridos, como Flame, ya se encuentran en el archivo
pubspec.yaml
del proyecto. Si tu IDE no instala automáticamente las dependencias del proyecto, abre una terminal de la línea de comandos y, desde la raíz del proyecto de Flutter, ejecuta el siguiente comando para recuperarlas:
flutter pub get
Configura tu entorno de desarrollo de Flutter
Para completar este codelab, necesitarás lo siguiente:
3. Explora el código
A continuación, explora el código.
Revisa el archivo lib/game/doodle_dash.dart
, que contiene el juego Doodle Dash que extiende FlameGame
. Registrarás los componentes con la instancia FlameGame
, el componente más básico de Flame (similar a Scaffold
de Flutter). Durante el juego, se renderizarán y actualizarán todos los componentes registrados. Es como el sistema nervioso central de tu juego.
¿Qué son los componentes? Así como una app de Flutter se compone de Widgets
, un FlameGame
contiene Components
, que son los componentes básicos que conforman el juego. De forma muy parecida a los widgets de Flutter, los componentes también pueden tener componentes secundarios. El objeto de un personaje, el fondo del juego y el objeto responsable de generar nuevos componentes del juego (como los enemigos) son todos ejemplos de componentes. De hecho, el FlameGame
en sí mismo es un Component
; Flame llama a esto el sistema de componentes de Flame.
Los componentes se heredan de una clase abstracta Component
. Implementa los métodos abstractos de Component
para crear la mecánica de la clase FlameGame
. Por ejemplo, con frecuencia, verás los siguientes métodos implementados en Doodle Dash:
onLoad
: Inicializa de forma asíncrona un componente (es similar al métodoinitState
de Flutter).update
: Actualiza un componente con cada marca del bucle de juego (es similar al métodobuild
de Flutter).
Además, el método add
registra los componentes en el motor de Flame.
Por ejemplo, el archivo lib/game/world.dart
contiene la clase World
, que extiende ParallaxComponent
para renderizar el fondo del juego. Esta clase toma una lista de recursos de imagen y los renderiza en capas, lo que hace que cada capa se mueva a una velocidad diferente para darle un aspecto más realista. La clase DoodleDash
contiene una instancia de ParallaxComponent
y la agrega al juego en el método onLoad
de Doodle Dash:
lib/game/world.dart
class World extends ParallaxComponent<DoodleDash> {
@override
Future<void> onLoad() async {
parallax = await gameRef.loadParallax(
[
ParallaxImageData('game/background/06_Background_Solid.png'),
ParallaxImageData('game/background/05_Background_Small_Stars.png'),
ParallaxImageData('game/background/04_Background_Big_Stars.png'),
ParallaxImageData('game/background/02_Background_Orbs.png'),
ParallaxImageData('game/background/03_Background_Block_Shapes.png'),
ParallaxImageData('game/background/01_Background_Squiggles.png'),
],
fill: LayerFill.width,
repeat: ImageRepeat.repeat,
baseVelocity: Vector2(0, -5),
velocityMultiplierDelta: Vector2(0, 1.2),
);
}
}
lib/game/doodle_dash.dart
class DoodleDash extends FlameGame
with HasKeyboardHandlerComponents, HasCollisionDetection {
...
final World _world = World();
...
@override
Future<void> onLoad() async {
await add(_world);
...
}
}
Gestión del estado
El directorio lib/game/managers
contiene tres archivos que controlan la gestión del estado para Doodle Dash: game_manager.dart
, object_manager.dart
y level_manager.dart
.
La clase GameManager
(en game_manager.dart
) hace un seguimiento del estado general del juego y de la puntuación.
La clase ObjectManager
(en object_manager.dart
) administra el lugar y el momento en el que las plataformas se generan y se quitan. Más adelante, agregarás contenido a esta clase.
Y, por último, la clase LevelManager
(en level_manager.dart
) administra el nivel de dificultad del juego junto con cualquier configuración relevante para cuando los jugadores suben de nivel. El juego ofrece cinco niveles de dificultad, y el jugador avanzará al próximo nivel cuando alcance el hito de puntuación correspondiente. Cada avance de nivel aumenta la dificultad y la distancia que Dash debe saltar. Dado que la gravedad es constante a lo largo del juego, la velocidad de salto aumenta de forma gradual para tener en cuenta la mayor distancia.
La puntuación del jugador aumentará cuando este pase una plataforma. Cuando el jugador alcanza ciertos umbrales de puntuación, el juego sube de nivel y desbloquea plataformas nuevas y especiales que hacen que el juego resulte más divertido y desafiante.
4. Agrega un jugador al juego
En este paso, agregarás un personaje al juego (en este caso, Dash). El jugador controlará el personaje, y toda la lógica residirá en la clase Player
(en el archivo player.dart
). La clase Player
extiende la clase SpriteGroupComponent
de Flame, que contiene métodos abstractos que anularás para implementar la lógica personalizada. Esto incluye cargar recursos y objetos, posicionar al jugador (horizontal y verticalmente), configurar la detección de colisiones y aceptar la entrada del usuario.
Cómo cargar recursos
Dash se muestra mediante diferentes objetos, que representan distintas versiones del personaje y de los potenciadores. Por ejemplo, los siguientes íconos muestran a Dash y a Sparky mirando al centro, a la izquierda y a la derecha.
El elemento SpriteGroupComponent
de Flame te permite gestionar varios estados de objeto con la propiedad sprites
, como verás en el método _loadCharacterSprites
.
En la clase Player
, agrega las siguientes líneas al método onLoad
para cargar los recursos de objeto y establece el estado de objeto de Player
de modo que mire hacia adelante:
lib/game/sprites/player.dart
@override
Future<void> onLoad() async {
await super.onLoad();
await _loadCharacterSprites(); // Add this line
current = PlayerState.center; // Add this line
}
Revisa el código para cargar los objetos y recursos en _loadCharacterSprites
. Este código podría implementarse de forma directa en el método onLoad
, pero ubicarlo en uno independiente organizará el código fuente y lo hará más legible. Este método asigna un mapa a la propiedad sprites
que vincula el estado de cada personaje a un recurso de objeto cargado, como se muestra a continuación:
lib/game/sprites/player.dart
Future<void> _loadCharacterSprites() async {
final left = await gameRef.loadSprite('game/${character.name}_left.png');
final right = await gameRef.loadSprite('game/${character.name}_right.png');
final center =
await gameRef.loadSprite('game/${character.name}_center.png');
final rocket = await gameRef.loadSprite('game/rocket_4.png');
final nooglerCenter =
await gameRef.loadSprite('game/${character.name}_hat_center.png');
final nooglerLeft =
await gameRef.loadSprite('game/${character.name}_hat_left.png');
final nooglerRight =
await gameRef.loadSprite('game/${character.name}_hat_right.png');
sprites = <PlayerState, Sprite>{
PlayerState.left: left,
PlayerState.right: right,
PlayerState.center: center,
PlayerState.rocket: rocket,
PlayerState.nooglerCenter: nooglerCenter,
PlayerState.nooglerLeft: nooglerLeft,
PlayerState.nooglerRight: nooglerRight,
};
}
Cómo actualizar el componente del jugador
Flame llama al método update
de un componente una vez por cada marca (o fotograma) del bucle de evento para volver a dibujar cada componente del juego que haya cambiado (es similar al método build
de Flutter). A continuación, agrega lógica en el método update
de la clase Player
para posicionar el personaje en pantalla.
Agrega el siguiente código al método update
de la clase Player
para calcular la velocidad y la posición actuales del personaje:
lib/game/sprites/player.dart
void update(double dt) {
// Add lines from here...
if (gameRef.gameManager.isIntro || gameRef.gameManager.isGameOver) return;
_velocity.x = _hAxisInput * jumpSpeed; // ... to here.
final double dashHorizontalCenter = size.x / 2;
if (position.x < dashHorizontalCenter) { // Add lines from here...
position.x = gameRef.size.x - (dashHorizontalCenter);
}
if (position.x > gameRef.size.x - (dashHorizontalCenter)) {
position.x = dashHorizontalCenter;
} // ... to here.
// Core gameplay: Add gravity
position += _velocity * dt; // Add this line
super.update(dt);
}
Antes de mover el jugador, el método update
hace una verificación para asegurarse de que el juego no esté en un estado no jugable en el que el jugador no debería moverse, por ejemplo, durante el estado inicial (cuando el juego se carga por primera vez) o en el estado de fin del juego.
Si el juego está en un estado jugable, la posición de Dash se calcula usando la ecuación new_position = current_position + (velocity * time-elapsed-since-last-game-loop-tick)
o como se ve en el código siguiente:
position += _velocity * dt
Otro aspecto fundamental a la hora de compilar Doodle Dash es asegurarse de incluir límites laterales infinitos. De esta manera, Dash podrá saltar hacia afuera del borde izquierdo de la pantalla y volver a ingresar desde el derecho, y viceversa.
Esto se implementa verificando si la posición de Dash sobrepasó el borde izquierdo o derecho de la pantalla y, de ser así, reposicionándola en el borde opuesto.
Eventos de teclas
Inicialmente, Doodle Dash se ejecutará en la Web y en computadoras de escritorio, por lo que necesita ser compatible con la entrada del teclado de modo que los jugadores puedan controlar el movimiento del personaje. El método onKeyEvent
le permite al componente Player
reconocer las presiones de las teclas de flecha para determinar si Dash debería orientarse o caminar hacia la izquierda o la derecha.
Dash se orienta a la izquierda cuando se mueve en esa dirección | Dash se orienta a la derecha cuando se mueve en esa dirección |
A continuación, implementa la habilidad de Dash para moverse horizontalmente (como se definió en la variable _hAxisInput
). También harás que Dash se oriente en la dirección en la que se esté moviendo.
Modifica los métodos moveLeft
y moveRight
de la clase Player
para definir la dirección actual de Dash:
lib/game/sprites/player.dart
void moveLeft() {
_hAxisInput = 0;
current = PlayerState.left; // Add this line
_hAxisInput += movingLeftInput; // Add this line
}
void moveRight() {
_hAxisInput = 0;
current = PlayerState.right; // Add this line
_hAxisInput += movingRightInput; // Add this line
}
Modifica el método onKeyEvent
de la clase Player
para llamar a los métodos moveLeft
o moveRight
, respectivamente, cuando se presionen las teclas de flecha izquierda o derecha:
lib/game/sprites/player.dart
@override
bool onKeyEvent(RawKeyEvent event, Set<LogicalKeyboardKey> keysPressed) {
_hAxisInput = 0;
// Add lines from here...
if (keysPressed.contains(LogicalKeyboardKey.arrowLeft)) {
moveLeft();
}
if (keysPressed.contains(LogicalKeyboardKey.arrowRight)) {
moveRight();
} // ... to here.
// During development, it's useful to "cheat"
if (keysPressed.contains(LogicalKeyboardKey.arrowUp)) {
// jump();
}
return true;
}
Ahora que la clase Player
es funcional, el juego de Doodle Dash puede usarla.
En el archivo de Doodle Dash, importa sprites.dart
, que hará que esté disponible la clase Player
:
lib/game/doodle_dash.dart
import 'sprites/sprites.dart'; // Add this line
Crea una instancia de Player
en la clase DoodleDash
:
lib/game/doodle_dash.dart
class DoodleDash extends FlameGame
with HasKeyboardHandlerComponents, HasCollisionDetection {
DoodleDash({super.children});
final World _world = World();
LevelManager levelManager = LevelManager();
GameManager gameManager = GameManager();
int screenBufferSpace = 300;
ObjectManager objectManager = ObjectManager();
late Player player; // Add this line
...
}
A continuación, inicializa y configura la velocidad de salto de Player
con base en el nivel de dificultad seleccionado por el jugador, y agrega el componente Player
a FlameGame
. Completa el método setCharacter
con el siguiente código:
lib/game/doodle_dash.dart
void setCharacter() {
player = Player( // Add lines from here...
character: gameManager.character,
jumpSpeed: levelManager.startingJumpSpeed,
);
add(player); // ... to here.
}
Llama al método setCharacter
al comienzo de initializeGameStart
.
lib/game/doodle_dash.dart
void initializeGameStart() {
setCharacter(); // Add this line
...
}
Además, en initializeGameStart
, llama a resetPosition
en el jugador de modo que este se mueva a la posición inicial cada vez que comience un juego.
lib/game/doodle_dash.dart
void initializeGameStart() {
...
levelManager.reset();
player.resetPosition(); // Add this line
objectManager = ObjectManager(
minVerticalDistanceToNextPlatform: levelManager.minDistance,
maxVerticalDistanceToNextPlatform: levelManager.maxDistance);
...
}
Ejecuta la app. Comienza un juego, y Dash aparecerá en pantalla.
¿Tienes problemas?
Si la app no se ejecuta correctamente, comprueba que no haya errores ortográficos. De ser necesario, usa el código que aparece en los siguientes vínculos para volver a empezar.
5. Agrega plataformas
En este paso, agregarás plataformas (Dash aterrizará en ellas y las usará para saltar) y la lógica de detección de colisiones para determinar cuándo debe saltar Dash.
En primer lugar, revisa la clase abstracta Platform
:
lib/game/sprites/platform.dart
abstract class Platform<T> extends SpriteGroupComponent<T>
with HasGameRef<DoodleDash>, CollisionCallbacks {
final hitbox = RectangleHitbox();
bool isMoving = false;
Platform({
super.position,
}) : super(
size: Vector2.all(100),
priority: 2,
);
@override
Future<void>? onLoad() async {
await super.onLoad();
await add(hitbox);
}
}
¿Qué es una caja de colisiones?
Cada componente de plataforma presentado en Doodle Dash extiende la clase abstracta Platform<T>
, que es un SpriteComponent
con una caja de colisiones. Esta caja le permite a un componente de objeto detectar cuándo colisiona con otros objetos con cajas de colisiones. Flame admite una variedad de formas de cajas de colisiones, como rectángulos, círculos y polígonos. Por ejemplo, Doodle Dash usa una caja de colisiones rectangular para una plataforma y una circular para Dash. Flame controla la matemática que calcula la colisión.
La clase Platform
agrega una caja de colisiones y devoluciones de llamadas de colisión a todos los subtipos.
Agrega una plataforma estándar
La clase Platform
agrega plataformas al juego. Una plataforma normal está representada por una de 4 imágenes elegidas aleatoriamente: un monitor, un teléfono, una terminal o una laptop. La elección de la imagen no afecta el comportamiento de la plataforma.
|
Agrega una plataforma estática y regular agregando un elemento NormalPlatformState
de tipo enum y una clase NormalPlatform
:
lib/game/sprites/platform.dart
enum NormalPlatformState { only } // Add lines from here...
class NormalPlatform extends Platform<NormalPlatformState> {
NormalPlatform({super.position});
final Map<String, Vector2> spriteOptions = {
'platform_monitor': Vector2(115, 84),
'platform_phone_center': Vector2(100, 55),
'platform_terminal': Vector2(110, 83),
'platform_laptop': Vector2(100, 63),
};
@override
Future<void>? onLoad() async {
var randSpriteIndex = Random().nextInt(spriteOptions.length);
String randSprite = spriteOptions.keys.elementAt(randSpriteIndex);
sprites = {
NormalPlatformState.only: await gameRef.loadSprite('game/$randSprite.png')
};
current = NormalPlatformState.only;
size = spriteOptions[randSprite]!;
await super.onLoad();
}
} // ... to here.
A continuación, genera plataformas para que el personaje interactúe con ellas.
La clase ObjectManager
extiende la clase Component
de Flame y genera objetos Platform
a lo largo del juego. Implementa la habilidad de generar plataformas en los métodos update
y onMount
de ObjectManager
.
Genera plataformas en la clase ObjectManager
creando un método nuevo llamado _semiRandomPlatform
. Más adelante, actualizarás este método para mostrar diferentes tipos de plataformas, pero, por ahora, solo muestra una NormalPlatform
:
lib/game/managers/object_manager.dart
Platform _semiRandomPlatform(Vector2 position) { // Add lines from here...
return NormalPlatform(position: position);
} // ... to here.
Anula el método update
de ObjectManager
y usa el método _semiRandomPlatform
para generar una plataforma y agregarla al juego:
lib/game/managers/object_manager.dart
@override // Add lines from here...
void update(double dt) {
final topOfLowestPlatform =
_platforms.first.position.y + _tallestPlatformHeight;
final screenBottom = gameRef.player.position.y +
(gameRef.size.x / 2) +
gameRef.screenBufferSpace;
if (topOfLowestPlatform > screenBottom) {
var newPlatY = _generateNextY();
var newPlatX = _generateNextX(100);
final nextPlat = _semiRandomPlatform(Vector2(newPlatX, newPlatY));
add(nextPlat);
_platforms.add(nextPlat);
gameRef.gameManager.increaseScore();
_cleanupPlatforms();
// Losing the game: Add call to _maybeAddEnemy()
// Powerups: Add call to _maybeAddPowerup();
}
super.update(dt);
} // ... to here.
Haz lo mismo en el método onMount
de ObjectManager
de modo que, cuando el juego se ejecute por primera vez, el método _semiRandomPlatform
genere una plataforma de inicio y la agregue al juego.
Agrega el método onMount
con el siguiente código:
lib/game/managers/object_manager.dart
@override // Add lines from here...
void onMount() {
super.onMount();
var currentX = (gameRef.size.x.floor() / 2).toDouble() - 50;
var currentY =
gameRef.size.y - (_rand.nextInt(gameRef.size.y.floor()) / 3) - 50;
for (var i = 0; i < 9; i++) {
if (i != 0) {
currentX = _generateNextX(100);
currentY = _generateNextY();
}
_platforms.add(
_semiRandomPlatform(
Vector2(
currentX,
currentY,
),
),
);
add(_platforms[i]);
}
} // ... to here.
Por ejemplo, como se muestra en el código siguiente, el método configure
permite que el juego de Doodle Dash vuelva a configurar las distancias mínima y máxima entre las plataformas, y habilita las plataformas especiales cuando el nivel de dificultad aumenta:
lib/game/managers/object_manager.dart
void configure(int nextLevel, Difficulty config) {
minVerticalDistanceToNextPlatform = gameRef.levelManager.minDistance;
maxVerticalDistanceToNextPlatform = gameRef.levelManager.maxDistance;
for (int i = 1; i <= nextLevel; i++) {
enableLevelSpecialty(i);
}
}
La instancia de DoodleDash
(en el método initializeGameStart
) crea un ObjectManager
que se inicializa, se configura con base en el nivel de dificultad y se agrega al juego de Flame:
lib/game/doodle_dash.dart
void initializeGameStart() {
gameManager.reset();
if (children.contains(objectManager)) objectManager.removeFromParent();
levelManager.reset();
player.resetPosition();
objectManager = ObjectManager(
minVerticalDistanceToNextPlatform: levelManager.minDistance,
maxVerticalDistanceToNextPlatform: levelManager.maxDistance);
add(objectManager);
objectManager.configure(levelManager.level, levelManager.difficulty);
}
El elemento ObjectManager
aparece otra vez en el método checkLevelUp
. Cuando el jugador sube de nivel, ObjectManager
vuelve a configurar sus parámetros de generación de plataformas con base en el nivel de dificultad.
lib/game/doodle_dash.dart
void checkLevelUp() {
if (levelManager.shouldLevelUp(gameManager.score.value)) {
levelManager.increaseLevel();
objectManager.configure(levelManager.level, levelManager.difficulty);
}
}
Haz una recarga en caliente (o reinicia si estás probando en la Web) para activar los cambios. Guarda el archivo; usa el botón de tu IDE; o, desde la línea de comandos, ingresa r
para hacer la recarga en caliente. Comienza un juego, y Dash y algunas plataformas aparecerán en pantalla:
¿Tienes problemas?
Si la app no se ejecuta correctamente, comprueba que no haya errores ortográficos. De ser necesario, usa el código que aparece en los siguientes vínculos para volver a empezar.
6. Juego principal
Ahora que implementaste los widgets individuales Player
y Platform
, puedes empezar a consolidar todo. En este paso, se implementará la funcionalidad principal, la detección de colisiones y el movimiento de la cámara.
Gravedad
Para que el juego resulte más realista, Dash necesita verse afectada por la gravedad, una fuerza que la empuja hacia abajo cuando ella salta. En nuestra versión de Doodle Dash, la gravedad es un valor constante y positivo que siempre empuja a Dash hacia abajo. En el futuro, sin embargo, podrías optar por cambiar la gravedad para crear otros efectos.
En la clase Player
, agrega una propiedad _gravity
con un valor de 9:
lib/game/sprites/player.dart
class Player extends SpriteGroupComponent<PlayerState>
with HasGameRef<DoodleDash>, KeyboardHandler, CollisionCallbacks {
...
Character character;
double jumpSpeed;
final double _gravity = 9; // Add this line
@override
Future<void> onLoad() async {
...
}
...
}
Modifica el método update
de Player
para agregar la variable _gravity
y modificar la velocidad vertical de Dash:
lib/game/sprites/player.dart
void update(double dt) {
if (gameRef.gameManager.isIntro || gameRef.gameManager.isGameOver) return;
_velocity.x = _hAxisInput * jumpSpeed;
final double dashHorizontalCenter = size.x / 2;
if (position.x < dashHorizontalCenter) {
position.x = gameRef.size.x - (dashHorizontalCenter);
}
if (position.x > gameRef.size.x - (dashHorizontalCenter)) {
position.x = dashHorizontalCenter;
}
_velocity.y += _gravity; // Add this line
position += _velocity * dt;
super.update(dt);
}
Detección de colisiones
Flame tiene compatibilidad de fábrica con la detección de colisiones. Para habilitarla en tu juego de Flame, agrega la combinación HasCollisionDetection
. Si revisas la clase DoodleDash
, verás que esta combinación ya se había agregado:
lib/game/doodle_dash.dart
class DoodleDash extends FlameGame
with HasKeyboardHandlerComponents, HasCollisionDetection {
...
}
A continuación, agrega la detección de colisiones a los componentes independientes del juego usando la combinación CollisionCallbacks
. Esta combinación le da al componente acceso a la devolución de llamada onCollision
. Una colisión de dos objetos con cajas de colisiones activa la devolución de llamada onCollision
y pasa una referencia al objeto con el que está colisionando, de modo que puedes implementar lógica para la forma en que debería reaccionar tu objeto.
Recuerda que, en el paso anterior, vimos que la clase abstracta Platform
ya tiene la combinación CollisionCallbacks
y una caja de colisiones. La clase Player
también tiene la combinación CollisionCallbacks
, de modo que solo tienes que agregar una CircleHitbox
a la clase Player
. La caja de colisiones de Dash es un círculo, dado que Dash tiene una forma más circular que rectangular.
En la clase Player
, importa sprites.dart
de modo que tenga acceso a las distintas clases Platform
:
lib/game/sprites/player.dart
import 'sprites.dart';
Agrega una CircleHitbox
al método onLoad
de la clase Player
:
lib/game/sprites/player.dart
@override
Future<void> onLoad() async {
await super.onLoad();
await add(CircleHitbox()); // Add this line
await _loadCharacterSprites();
current = PlayerState.center;
}
Dash necesita un método jump para poder saltar cuando colisione con una plataforma.
Agrega un método jump
que tome un elemento opcional specialJumpSpeed
:
lib/game/sprites/player.dart
void jump({double? specialJumpSpeed}) {
_velocity.y = specialJumpSpeed != null ? -specialJumpSpeed : -jumpSpeed;
}
Anula el método onCollision
de Player
agregando el siguiente código:
lib/game/sprites/player.dart
@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollision(intersectionPoints, other);
bool isCollidingVertically =
(intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;
if (isMovingDown && isCollidingVertically) {
current = PlayerState.center;
if (other is NormalPlatform) {
jump();
return;
}
}
}
Esta devolución de llamada llama al método jump
de Dash cuando esta se cae y colisiona con la parte superior de una NormalPlatform
. La sentencia isMovingDown && isCollidingVertically
garantiza que Dash se mueva hacia arriba por las plataformas sin activar un salto.
Movimiento de la cámara
La cámara debería seguir a Dash a medida que se mueva hacia arriba en el juego, pero debería quedarse estática cuando Dash se caiga.
En Flame, si el "mundo" es más grande que la pantalla, usa el elemento worldBounds
de la cámara para agregar límites que le indiquen a Flame qué parte del mundo se debe mostrar. Para dar la apariencia de que la cámara se está moviendo hacia arriba mientras se mantiene horizontalmente fija, ajusta los límites de arriba y abajo del mundo en cada actualización basada en la posición del jugador, pero mantén los límites izquierdo y derecho sin cambios.
En la clase DoodleDash
, agrega el siguiente código al método update
para permitir que la cámara siga a Dash durante el juego:
lib/game/doodle_dash.dart
@override
void update(double dt) {
super.update(dt);
if (gameManager.isIntro) {
overlays.add('mainMenuOverlay');
return;
}
if (gameManager.isPlaying) {
checkLevelUp();
// Add lines from here...
final Rect worldBounds = Rect.fromLTRB(
0,
camera.position.y - screenBufferSpace,
camera.gameSize.x,
camera.position.y + _world.size.y,
);
camera.worldBounds = worldBounds;
if (player.isMovingDown) {
camera.worldBounds = worldBounds;
}
var isInTopHalfOfScreen = player.position.y <= (_world.size.y / 2);
if (!player.isMovingDown && isInTopHalfOfScreen) {
camera.followComponent(player);
} // ... to here.
}
}
A continuación, se deben restablecer la posición de Player
y los límites de la cámara a sus valores iniciales siempre que se reinicie un juego.
Agrega el siguiente código en el método initializeGameStart
:
lib/game/doodle_dash.dart
void initializeGameStart() {
...
levelManager.reset();
// Add the lines from here...
player.reset();
camera.worldBounds = Rect.fromLTRB(
0,
-_world.size.y,
camera.gameSize.x,
_world.size.y +
screenBufferSpace,
);
camera.followComponent(player);
// ... to here.
player.resetPosition();
...
}
Aumenta la velocidad de salto cuando se suba de nivel
La última pieza del juego principal requiere que la velocidad de salto de Dash aumente cuando el nivel de dificultad lo haga y que se generen plataformas a distancias cada vez más grandes entre ellas.
Agrega una llamada al método setJumpSpeed
y proporciona la velocidad de salto asociada al nivel actual:
lib/game/doodle_dash.dart
void checkLevelUp() {
if (levelManager.shouldLevelUp(gameManager.score.value)) {
levelManager.increaseLevel();
objectManager.configure(levelManager.level, levelManager.difficulty);
player.setJumpSpeed(levelManager.jumpSpeed); // Add this line
}
}
Haz una recarga en caliente (o reinicia si estás en la Web) para activar los cambios. Guarda el archivo; usa el botón de tu IDE; o, desde la línea de comandos, ingresa r
para hacer la recarga en caliente:
¿Tienes problemas?
Si la app no se ejecuta correctamente, comprueba que no haya errores ortográficos. De ser necesario, usa el código que aparece en los siguientes vínculos para volver a empezar.
7. Más información sobre plataformas
Ahora que el elemento ObjectManager
genera plataformas sobre las que Dash puede saltar, puedes crear para ella algunas plataformas especiales y emocionantes.
A continuación, agrega las clases BrokenPlatform
y SpringBoard
. Como el nombre lo indica, una BrokenPlatform
se rompe luego de un salto, y una SpringBoard
ofrece un trampolín que permite que Dash rebote más alto y más rápido.
|
|
Como la clase Player
, cada una de estas clases de plataformas dependen de enums
para representar su estado actual.
lib/game/sprites/platform.dart
enum BrokenPlatformState { cracked, broken }
Un cambio en el estado current
de la plataforma también cambia el objeto que aparece dentro del juego. Define la asignación entre el State
de tipo enum y los recursos de imagen en la propiedad sprites
para correlacionar cuál objeto se asigna a cada estado.
Agrega un BrokenPlatformState
de tipo enum y la clase BrokenPlatform
:
lib/game/sprites/platform.dart
enum BrokenPlatformState { cracked, broken } // Add lines from here...
class BrokenPlatform extends Platform<BrokenPlatformState> {
BrokenPlatform({super.position});
@override
Future<void>? onLoad() async {
await super.onLoad();
sprites = <BrokenPlatformState, Sprite>{
BrokenPlatformState.cracked:
await gameRef.loadSprite('game/platform_cracked_monitor.png'),
BrokenPlatformState.broken:
await gameRef.loadSprite('game/platform_monitor_broken.png'),
};
current = BrokenPlatformState.cracked;
size = Vector2(115, 84);
}
void breakPlatform() {
current = BrokenPlatformState.broken;
}
} // ... to here.
Agrega un SpringState
de tipo enum y la clase SpringBoard
:
lib/game/sprites/platform.dart
enum SpringState { down, up } // Add lines from here...
class SpringBoard extends Platform<SpringState> {
SpringBoard({
super.position,
});
@override
Future<void>? onLoad() async {
await super.onLoad();
sprites = <SpringState, Sprite>{
SpringState.down:
await gameRef.loadSprite('game/platform_trampoline_down.png'),
SpringState.up:
await gameRef.loadSprite('game/platform_trampoline_up.png'),
};
current = SpringState.up;
size = Vector2(100, 45);
}
@override
void onCollisionStart(
Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollisionStart(intersectionPoints, other);
bool isCollidingVertically =
(intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;
if (isCollidingVertically) {
current = SpringState.down;
}
}
@override
void onCollisionEnd(PositionComponent other) {
super.onCollisionEnd(other);
current = SpringState.up;
}
} // ... to here.
A continuación, habilita estas plataformas especiales en ObjectManager
. Como son especiales, no querrás que estas plataformas aparezcan en el juego todo el tiempo, así que genéralas con base en la probabilidad: 15% para SpringBoard
y 10% para BrokenPlatform
.
En ObjectManager
, dentro del método _semiRandomPlatform
, antes de la sentencia que muestra una NormalPlatform
, agrega el siguiente código para mostrar de forma condicional una plataforma especial:
lib/game/managers/object_manager.dart
Platform _semiRandomPlatform(Vector2 position) {
if (specialPlatforms['spring'] == true && // Add lines from here...
probGen.generateWithProbability(15)) {
return SpringBoard(position: position);
}
if (specialPlatforms['broken'] == true &&
probGen.generateWithProbability(10)) {
return BrokenPlatform(position: position);
} // ... to here.
return NormalPlatform(position: position);
}
Parte de la diversión de jugar un juego consiste en desbloquear nuevos desafíos y funciones a medida que subes de nivel.
Deberías completar el trampolín desde el principio del nivel 1; pero, una vez que Dash alcance el nivel 2, desbloqueará la BrokenPlatform
, lo que hará un poco más difícil el juego.
En la clase ObjectManager
, modifica el método enableLevelSpecialty
(que actualmente es un stub) agregando una sentencia switch
que habilite las plataformas SpringBoard
para el nivel 1 y las BrokenPlatform
para el nivel 2:
lib/game/managers/object_manager.dart
void enableLevelSpecialty(int level) {
switch (level) { // Add lines from here...
case 1:
enableSpecialty('spring');
break;
case 2:
enableSpecialty('broken');
break;
} // ... to here.
}
A continuación, permite que las plataformas se puedan mover de forma horizontal hacia adelante y hacia atrás. En la clase abstracta Platform
**,** agrega el siguiente método _move
:
lib/game/sprites/platform.dart
void _move(double dt) {
if (!isMoving) return;
final double gameWidth = gameRef.size.x;
if (position.x <= 0) {
direction = 1;
} else if (position.x >= gameWidth - size.x) {
direction = -1;
}
_velocity.x = direction * speed;
position += _velocity * dt;
}
Si la plataforma se está moviendo, cambiará su movimiento a la dirección opuesta cuando alcance el borde de la pantalla de juego. Como Dash, la posición de la plataforma se determina multiplicando _direction
por la speed
de la plataforma para obtener la velocidad. Luego, multiplica la velocidad por time-elapsed
y agrega la distancia resultante a la position
actual de la plataforma.
Anula el método update
de la clase Platform
para llamar al método _move
:
lib/game/sprites/platform.dart
@override
void update(double dt) {
_move(dt);
super.update(dt);
}
Para activar el movimiento de Platform
, en el método onLoad
, establece de forma aleatoria el elemento booleano isMoving
en true
el 20% del tiempo.
lib/game/sprites/platform.dart
@override
Future<void>? onLoad() async {
await super.onLoad();
await add(hitbox);
final int rand = Random().nextInt(100); // Add this line
if (rand > 80) isMoving = true; // Add this line
}
Finalmente, en Player
, modifica el método onCollision
de la clase Player
para detectar una colisión con una Springboard
o una BrokenPlatform
. Observa que una SpringBoard
llama a jump
con un duplicador de velocidad, y BrokenPlatform
solo llama a jump
si su estado es .cracked
y no .broken
(que significa que Dash ya saltó en ella):
lib/game/sprites/player.dart
@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollision(intersectionPoints, other);
bool isCollidingVertically =
(intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;
if (isMovingDown && isCollidingVertically) {
current = PlayerState.center;
if (other is NormalPlatform) {
jump();
return;
} else if (other is SpringBoard) { // Add lines from here...
jump(specialJumpSpeed: jumpSpeed * 2);
return;
} else if (other is BrokenPlatform &&
other.current == BrokenPlatformState.cracked) {
jump();
other.breakPlatform();
return;
} // ... to here.
}
}
Reinicia la app. Comienza un juego para ver las plataformas que se mueven, SpringBoard
y BrokenPlatform
.
¿Tienes problemas?
Si la app no se ejecuta correctamente, comprueba que no haya errores ortográficos. De ser necesario, usa el código que aparece en los siguientes vínculos para volver a empezar.
8. Cómo perder en el juego
En este paso, se agregarán condiciones a Doodle Dash que, cuando se cumplan, harán que el jugador pierda el juego. Existen dos maneras de perder el juego:
- Si Dash no le acierta a una plataforma y se cae por debajo de la parte inferior de la pantalla
- Si Dash colisiona con una plataforma
Enemy
Antes de que implementes la condición de "fin del juego", debes agregar lógica que establezca el estado del juego de Doodle Dash en gameOver
.
En la clase DoodleDash
**,** agrega un método onLose
que se llame cada vez que debería terminar el juego. Establecerá el estado del juego, quitará el jugador de la pantalla y activará el menú o una superposición de **Fin del juego**.
lib/game/sprites/doodle_dash.dart
void onLose() { // Add lines from here...
gameManager.state = GameState.gameOver;
player.removeFromParent();
overlays.add('gameOverOverlay');
} // ... to here.
Menú de Fin del juego:
En la parte superior del método update
de DoodleDash
, agrega el siguiente código para que el juego no se actualice cuando su estado sea GameOver
:
lib/game/sprites/doodle_dash.dart
@override
void update(double dt) {
super.update(dt);
if (gameManager.isGameOver) { // Add lines from here...
return;
} // ... to here.
...
}
Además, en el método update
, llama a onLose
cuando el jugador haya caído por debajo de la parte inferior de la pantalla.
lib/game/sprites/doodle_dash.dart
@override
void update(double dt) {
...
if (gameManager.isPlaying) {
checkLevelUp();
final Rect worldBounds = Rect.fromLTRB(
0,
camera.position.y - screenBufferSpace,
camera.gameSize.x,
camera.position.y + _world.size.y,
);
camera.worldBounds = worldBounds;
if (player.isMovingDown) {
camera.worldBounds = worldBounds;
}
var isInTopHalfOfScreen = player.position.y <= (_world.size.y / 2);
if (!player.isMovingDown && isInTopHalfOfScreen) {
camera.followComponent(player);
}
// Add lines from here...
if (player.position.y >
camera.position.y +
_world.size.y +
player.size.y +
screenBufferSpace) {
onLose();
} // ... to here.
}
}
Los enemigos pueden tener varias formas y tamaños. En Doodle Dash, se indican con un ícono de papelera o de carpeta con error. Los jugadores deben evitar colisionar con ellos, ya que eso provocaría un fin del juego inmediato.
|
Crea un tipo de plataforma enemiga agregando un EnemyPlatformState
de tipo enum y la clase EnemyPlatform
:
lib/game/sprites/platform.dart
enum EnemyPlatformState { only } // Add lines from here...
class EnemyPlatform extends Platform<EnemyPlatformState> {
EnemyPlatform({super.position});
@override
Future<void>? onLoad() async {
var randBool = Random().nextBool();
var enemySprite = randBool ? 'enemy_trash_can' : 'enemy_error';
sprites = <EnemyPlatformState, Sprite>{
EnemyPlatformState.only:
await gameRef.loadSprite('game/$enemySprite.png'),
};
current = EnemyPlatformState.only;
return super.onLoad();
}
} // ... to here.
La clase EnemyPlatform
extiende el supertipo Platform
. El elemento ObjectManager
genera y administra las plataformas enemigas tal como lo hace para el resto de las plataformas.
En ObjectManager
, agrega el siguiente código para generar y administrar las plataformas enemigas:
lib/game/managers/object_manager.dart
final List<EnemyPlatform> _enemies = []; // Add lines from here...
void _maybeAddEnemy() {
if (specialPlatforms['enemy'] != true) {
return;
}
if (probGen.generateWithProbability(20)) {
var enemy = EnemyPlatform(
position: Vector2(_generateNextX(100), _generateNextY()),
);
add(enemy);
_enemies.add(enemy);
_cleanupEnemies();
}
}
void _cleanupEnemies() {
final screenBottom = gameRef.player.position.y +
(gameRef.size.x / 2) +
gameRef.screenBufferSpace;
while (_enemies.isNotEmpty && _enemies.first.position.y > screenBottom) {
remove(_enemies.first);
_enemies.removeAt(0);
}
} // ... to here.
ObjectManager
mantiene una lista de objetos enemigos, _enemies
. El elemento _maybeAddEnemy
genera enemigos con un 20 por ciento de probabilidad y agrega el objeto a la lista de enemigos. El método _cleanupEnemies()
quita los objetos EnemyPlatform
inactivos que ya no son visibles.
En ObjectManager
, genera plataformas enemigas llamando a _maybeAddEnemy()
en el método update
:
lib/game/managers/object_manager.dart
@override
void update(double dt) {
final topOfLowestPlatform =
_platforms.first.position.y + _tallestPlatformHeight;
final screenBottom = gameRef.player.position.y +
(gameRef.size.x / 2) +
gameRef.screenBufferSpace;
if (topOfLowestPlatform > screenBottom) {
var newPlatY = _generateNextY();
var newPlatX = _generateNextX(100);
final nextPlat = _semiRandomPlatform(Vector2(newPlatX, newPlatY));
add(nextPlat);
_platforms.add(nextPlat);
gameRef.gameManager.increaseScore();
_cleanupPlatforms();
_maybeAddEnemy(); // Add this line
}
super.update(dt);
}
Haz agregados al método onCollision
de Player
para verificar si el jugador está colisionando con una EnemyPlatform
. De ser así, llama al método onLose()
.
lib/game/sprites/player.dart
@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollision(intersectionPoints, other);
if (other is EnemyPlatform) { // Add lines from here...
gameRef.onLose();
return;
} // ... to here.
bool isCollidingVertically =
(intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;
if (isMovingDown && isCollidingVertically) {
current = PlayerState.center;
if (other is NormalPlatform) {
jump();
return;
} else if (other is SpringBoard) {
jump(specialJumpSpeed: jumpSpeed * 2);
return;
} else if (other is BrokenPlatform &&
other.current == BrokenPlatformState.cracked) {
jump();
other.breakPlatform();
return;
}
}
}
Finalmente, modifica el método enableLevelSpecialty
de ObjectManager
para agregar el nivel 5 a la sentencia switch
:
lib/game/managers/object_manager.dart
void enableLevelSpecialty(int level) {
switch (level) {
case 1:
enableSpecialty('spring');
break;
case 2:
enableSpecialty('broken');
break;
case 5: // Add lines from here...
enableSpecialty('enemy');
break; // ... to here.
}
}
Ahora que hiciste que el juego resulte más desafiante, haz una recarga en caliente para activar los cambios. Guarda los archivos; usa el botón de tu IDE; o, desde la línea de comandos, ingresa r
para hacer la recarga en caliente:
Cuídate de los enemigos con forma de carpeta rota. Son engañosos. ¡Se mezclan con el fondo!
¿Tienes problemas?
Si la app no se ejecuta correctamente, comprueba que no haya errores ortográficos. De ser necesario, usa el código que aparece en los siguientes vínculos para volver a empezar.
9. Potenciadores
En este paso, agregarás funciones mejoradas para potenciar a Dash a lo largo del juego. Doodle Dash tiene dos opciones de potenciación: un gorro de empleado de Google nuevo y un cohete. Puedes considerar que estos potenciadores son otro tipo de plataforma especial. A medida que Dash salte durante el juego, su velocidad aumentará cuando colisione con un potenciador de gorro de empleado de Google nuevo o con uno de cohete, y cuando posea uno de ellos.
|
|
El gorro de empleado de Google nuevo se genera en el nivel 3, una vez que el jugador alcanza una puntuación >= 40. Cuando Dash colisiona con el gorro, comienza a llevarlo puesto y recibe un aumento de aceleración de 2.5 veces su velocidad normal. Esto dura 5 segundos.
El cohete se genera en el nivel 4, cuando el jugador alcanza una puntuación >= 80. Cuando Dash colisiona con el cohete, su objeto se reemplaza por un cohete y ella recibe un aumento de aceleración de 3.5 veces su velocidad normal hasta que aterriza en una plataforma. Adicionalmente, también será invencible ante enemigos cuando cuente con el potenciador de cohete.
Los objetos de gorro de empleado de Google nuevo y de cohete extienden la clase abstracta PowerUp
. Como la clase abstracta Platform
, PowerUp
, que mostramos a continuación, también incluye el cambio de tamaño y una caja de colisiones.
lib/game/sprites/powerup.dart
abstract class PowerUp extends SpriteComponent
with HasGameRef<DoodleDash>, CollisionCallbacks {
final hitbox = RectangleHitbox();
double get jumpSpeedMultiplier;
PowerUp({
super.position,
}) : super(
size: Vector2.all(50),
priority: 2,
);
@override
Future<void>? onLoad() async {
await super.onLoad();
await add(hitbox);
}
}
Crea una clase Rocket
que extienda la clase abstracta PowerUp
. Cuando Dash colisiona con el cohete, recibe un aumento de aceleración de 3.5 veces su velocidad normal.
lib/game/sprites/powerup.dart
class Rocket extends PowerUp { // Add lines from here...
@override
double get jumpSpeedMultiplier => 3.5;
Rocket({
super.position,
});
@override
Future<void>? onLoad() async {
await super.onLoad();
sprite = await gameRef.loadSprite('game/rocket_1.png');
size = Vector2(50, 70);
}
} // ... to here.
Crea una clase NooglerHat
que extienda la clase abstracta PowerUp
. Cuando Dash colisiona con el NooglerHat
, recibe un aumento de aceleración de 2.5 veces su velocidad normal. Esta aceleración aumentada dura 5 segundos.
lib/game/sprites/powerup.dart
class NooglerHat extends PowerUp { // Add lines from here...
@override
double get jumpSpeedMultiplier => 2.5;
NooglerHat({
super.position,
});
final int activeLengthInMS = 5000;
@override
Future<void>? onLoad() async {
await super.onLoad();
sprite = await gameRef.loadSprite('game/noogler_hat.png');
size = Vector2(75, 50);
}
} // ... to here.
Ahora que implementaste los potenciadores NooglerHat
y Rocket
, actualiza ObjectManager
para generarlos en el juego.
Modifica la clase ObjectManger
para agregar una lista que realice un seguimiento de los potenciadores generados, junto con dos métodos nuevos, _maybePowerup
y _cleanupPowerups
, para generar y quitar las plataformas potenciadoras nuevas.
lib/game/managers/object_manager.dart
final List<PowerUp> _powerups = []; // Add lines from here...
void _maybeAddPowerup() {
if (specialPlatforms['noogler'] == true &&
probGen.generateWithProbability(20)) {
var nooglerHat = NooglerHat(
position: Vector2(_generateNextX(75), _generateNextY()),
);
add(nooglerHat);
_powerups.add(nooglerHat);
} else if (specialPlatforms['rocket'] == true &&
probGen.generateWithProbability(15)) {
var rocket = Rocket(
position: Vector2(_generateNextX(50), _generateNextY()),
);
add(rocket);
_powerups.add(rocket);
}
_cleanupPowerups();
}
void _cleanupPowerups() {
final screenBottom = gameRef.player.position.y +
(gameRef.size.x / 2) +
gameRef.screenBufferSpace;
while (_powerups.isNotEmpty && _powerups.first.position.y > screenBottom) {
if (_powerups.first.parent != null) {
remove(_powerups.first);
}
_powerups.removeAt(0);
}
} // ... to here.
El método _maybeAddPowerup
genera un gorro de empleado de Google nuevo el 20% de las veces y un cohete el 15%. El método _cleanupPowerups
se llama para quitar los potenciadores que estén debajo de los límites inferiores de la pantalla.
Modifica el método update
de ObjectManager
para llamar a _maybePowerup
en cada marca del bucle de juego.
lib/game/managers/object_manager.dart
@override
void update(double dt) {
final topOfLowestPlatform =
_platforms.first.position.y + _tallestPlatformHeight;
final screenBottom = gameRef.player.position.y +
(gameRef.size.x / 2) +
gameRef.screenBufferSpace;
if (topOfLowestPlatform > screenBottom) {
var newPlatY = _generateNextY();
var newPlatX = _generateNextX(100);
final nextPlat = _semiRandomPlatform(Vector2(newPlatX, newPlatY));
add(nextPlat);
_platforms.add(nextPlat);
gameRef.gameManager.increaseScore();
_cleanupPlatforms();
_maybeAddEnemy();
_maybeAddPowerup(); // Add this line
}
super.update(dt);
}
Modifica el método enableLevelSpecialty
para agregar dos casos nuevos en la sentencia switch: uno para habilitar NooglerHat
en el nivel 3 y otro para habilitar Rocket
en el nivel 4:
lib/game/managers/object_manager.dart
void enableLevelSpecialty(int level) {
switch (level) {
case 1:
enableSpecialty('spring');
break;
case 2:
enableSpecialty('broken');
break;
case 3: // Add lines from here...
enableSpecialty('noogler');
break;
case 4:
enableSpecialty('rocket');
break; // ... to here.
case 5:
enableSpecialty('enemy');
break;
}
}
Agrega los siguientes métodos get booleanos a la clase Player
. Si Dash tiene un potenciador activo, esto se puede representar con varios estados distintos. Estos métodos get facilitan la verificación del potenciador activo.
lib/game/sprites/player.dart
bool get hasPowerup => // Add lines from here...
current == PlayerState.rocket ||
current == PlayerState.nooglerLeft ||
current == PlayerState.nooglerRight ||
current == PlayerState.nooglerCenter;
bool get isInvincible => current == PlayerState.rocket;
bool get isWearingHat =>
current == PlayerState.nooglerLeft ||
current == PlayerState.nooglerRight ||
current == PlayerState.nooglerCenter; // ... to here.
Modifica el método onCollision
de Player
para reaccionar a una colisión con NooglerHat
o Rocket
. Con este código, también te aseguras de que Dash solo active un potenciador nuevo si no tiene ya uno activo.
lib/game/sprites/player.dart
@override
void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
super.onCollision(intersectionPoints, other);
if (other is EnemyPlatform && !isInvincible) {
gameRef.onLose();
return;
}
bool isCollidingVertically =
(intersectionPoints.first.y - intersectionPoints.last.y).abs() < 5;
if (isMovingDown && isCollidingVertically) {
current = PlayerState.center;
if (other is NormalPlatform) {
jump();
return;
} else if (other is SpringBoard) {
jump(specialJumpSpeed: jumpSpeed * 2);
return;
} else if (other is BrokenPlatform &&
other.current == BrokenPlatformState.cracked) {
jump();
other.breakPlatform();
return;
}
}
if (!hasPowerup && other is Rocket) { // Add lines from here...
current = PlayerState.rocket;
other.removeFromParent();
jump(specialJumpSpeed: jumpSpeed * other.jumpSpeedMultiplier);
return;
} else if (!hasPowerup && other is NooglerHat) {
if (current == PlayerState.center) current = PlayerState.nooglerCenter;
if (current == PlayerState.left) current = PlayerState.nooglerLeft;
if (current == PlayerState.right) current = PlayerState.nooglerRight;
other.removeFromParent();
_removePowerupAfterTime(other.activeLengthInMS);
jump(specialJumpSpeed: jumpSpeed * other.jumpSpeedMultiplier);
return;
} // ... to here.
}
Si Dash colisiona con un cohete, el PlayerState
cambia a Rocket
y le permite a Dash saltar con un jumpSpeedMultiplier
de 3.5 veces.
Si Dash colisiona con un gorro de empleado de Google nuevo, según la dirección actual de PlayerState
(.center
, .left
o .right
), el PlayerState
cambia al PlayerState
del gorro correspondiente y Dash comienza a llevar el gorro y recibe un jumpSpeedMultiplier
de 2.5 veces. El método _removePowerupAfterTime
quita el potenciador al cabo de 5 segundos y cambia el PlayerState
de los estados del potenciador de nuevo a center
.
La llamada a other.removeFromParent
quita de la pantalla las plataformas de objeto de cohete o de gorro de empleado de Google nuevo para mostrar que Dash adquirió el potenciador.
Modifica los métodos moveLeft
y moveRight
de la clase Player para incluir el objeto NooglerHat
. No necesitas tener en cuenta el potenciador Rocket
, ya que ese objeto apunta en la misma dirección independientemente de la dirección de movimiento.
lib/game/sprites/player.dart
void moveLeft() {
_hAxisInput = 0;
if (isWearingHat) { // Add lines from here...
current = PlayerState.nooglerLeft;
} else if (!hasPowerup) { // ... to here.
current = PlayerState.left;
} // Add this line
_hAxisInput += movingLeftInput;
}
void moveRight() {
_hAxisInput = 0;
if (isWearingHat) { // Add lines from here...
current = PlayerState.nooglerRight;
} else if (!hasPowerup) { //... to here.
current = PlayerState.right;
} // Add this line
_hAxisInput += movingRightInput;
}
Dash es invencible frente a enemigos cuando tiene el potenciador Rocket
, de modo que no debes finalizar el juego durante este período.
Modifica la devolución de llamada a onCollision
para verificar si Dash cumple con isInvincible
antes de activar un fin del juego tras colisionar con una EnemyPlatform
:
lib/game/sprites/player.dart
if (other is EnemyPlatform && !isInvincible) { // Modify this line
gameRef.onLose();
return;
}
Reinicia la app y juega para ver los potenciadores en acción.
¿Tienes problemas?
Si la app no se ejecuta correctamente, comprueba que no haya errores ortográficos. De ser necesario, usa el código que aparece en los siguientes vínculos para volver a empezar.
10. Superposiciones
Un juego de Flame se puede unir en un widget, lo que facilita su integración junto con otros widgets en una app de Flutter. También puedes mostrar los widgets de Flutter como superposiciones sobre tu juego de Flame. Esto resulta conveniente para los componentes que no son del juego y que no dependen del bucle de juego, como los menús, la pantalla de pausa, los botones y los controles deslizantes.
La pantalla de la puntuación que se ve en el juego y todos los menús de Doodle Dash son widgets normales de Flutter, no componentes de Flame. Todo el código de los widgets se ubica en lib/game/widgets
, por ejemplo, el menú de Fin del juego es una columna que contiene otros widgets como Text
y ElevatedButton
, como se muestra en el siguiente código:
lib/game/widgets/game_over_overlay.dart
class GameOverOverlay extends StatelessWidget {
const GameOverOverlay(this.game, {super.key});
final Game game;
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).colorScheme.background,
child: Center(
child: Padding(
padding: const EdgeInsets.all(48.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'Game Over',
style: Theme.of(context).textTheme.displayMedium!.copyWith(),
),
const WhiteSpace(height: 50),
ScoreDisplay(
game: game,
isLight: true,
),
const WhiteSpace(
height: 50,
),
ElevatedButton(
onPressed: () {
(game as DoodleDash).resetGame();
},
style: ButtonStyle(
minimumSize: MaterialStateProperty.all(
const Size(200, 75),
),
textStyle: MaterialStateProperty.all(
Theme.of(context).textTheme.titleLarge),
),
child: const Text('Play Again'),
),
],
),
),
),
);
}
}
Para usar un widget como una superposición en un juego de Flame, define una propiedad overlayBuilderMap
en GameWidget
con una key
que represente la superposición (como una String
) y el value
de una función de widgets que muestre un widget, como se ve en el siguiente código:
lib/main.dart
GameWidget(
game: game,
overlayBuilderMap: <String, Widget Function(BuildContext, Game)>{
'gameOverlay': (context, game) => GameOverlay(game),
'mainMenuOverlay': (context, game) => MainMenuOverlay(game),
'gameOverOverlay': (context, game) => GameOverOverlay(game),
},
)
Una vez que se agrega, se puede usar la superposición en cualquier parte del juego. Muestra una superposición usando overlays.add
y ocúltala usando overlays.remove
, como se muestra en el siguiente código:
lib/game/doodle_dash.dart
void resetGame() {
startGame();
overlays.remove('gameOverOverlay');
}
void onLose() {
gameManager.state = GameState.gameOver;
player.removeFromParent();
overlays.add('gameOverOverlay');
}
11. Compatibilidad con dispositivos móviles
Doodle Dash se compila en Flutter y Flame, de modo que ya se ejecuta en todas las plataformas compatibles con Flutter. Sin embargo, hasta el momento, Doodle Dash solo es compatible con la entrada basada en teclados. Para dispositivos que no cuenten con un teclado, como los teléfonos celulares, podremos agregar fácilmente los botones de controles de tacto en pantalla a la superposición.
Agrega una variable de estado booleano a la GameOverlay
que determina si el juego se ejecuta en una plataforma móvil:
lib/game/widgets/game_overlay.dart
class GameOverlayState extends State<GameOverlay> {
bool isPaused = false;
// Add this line
final bool isMobile = !kIsWeb && (Platform.isAndroid || Platform.isIOS);
@override
Widget build(BuildContext context) {
...
}
}
Ahora, muestra botones direccionales de izquierda y derecha en la superposición cuando el juego se ejecute en dispositivos móviles. De manera similar a la lógica de los "eventos de teclas" del paso 4, presionar el botón izquierdo mueve a Dash en esa dirección. Presionar el botón derecho la moverá en esa dirección también.
En el método build
de GameOverlay
, agrega una sección isMobile
que siga el mismo comportamiento descrito en el paso 4: presionar el botón izquierdo invoca a moveLeft
y presionar el derecho invoca a moveRight
. Dejar de presionar cualquiera de los dos botones llama a resetDirection
y hace que Dash quede horizontalmente inactiva.
lib/game/widgets/game_overlay.dart
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: Stack(
children: [
Positioned(... child: ScoreDisplay(...)),
Positioned(... child: ElevatedButton(...)),
if (isMobile) // Add lines from here...
Positioned(
bottom: MediaQuery.of(context).size.height / 4,
child: SizedBox(
width: MediaQuery.of(context).size.width,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.only(left: 24),
child: GestureDetector(
onTapDown: (details) {
(widget.game as DoodleDash).player.moveLeft();
},
onTapUp: (details) {
(widget.game as DoodleDash).player.resetDirection();
},
child: Material(
color: Colors.transparent,
elevation: 3.0,
shadowColor: Theme.of(context).colorScheme.background,
child: const Icon(Icons.arrow_left, size: 64),
),
),
),
Padding(
padding: const EdgeInsets.only(right: 24),
child: GestureDetector(
onTapDown: (details) {
(widget.game as DoodleDash).player.moveRight();
},
onTapUp: (details) {
(widget.game as DoodleDash).player.resetDirection();
},
child: Material(
color: Colors.transparent,
elevation: 3.0,
shadowColor: Theme.of(context).colorScheme.background,
child: const Icon(Icons.arrow_right, size: 64),
),
),
),
],
),
),
), // ... to here.
if (isPaused)
...
],
),
);
}
Eso es todo. Ahora, la app de Doodle Dash detecta automáticamente en qué tipo de plataforma se está ejecutando y cambia las entradas en consecuencia.
Ejecuta la app en iOS o Android para ver los botones direccionales en acción.
¿Tienes problemas?
Si la app no se ejecuta correctamente, comprueba que no haya errores ortográficos. De ser necesario, usa el código que aparece en los siguientes vínculos para volver a empezar.
12. Próximos pasos
¡Felicitaciones!
Completaste este codelab y aprendiste a compilar un juego en Flutter usando el motor de juego de Flame.
Temas abordados:
- Cómo usar el paquete de Flame para crear un juego de plataformas, que incluye lo siguiente:
- Cómo agregar un personaje
- Cómo agregar varios tipos de plataformas
- Cómo implementar la detección de colisiones
- Cómo agregar un componente gravitatorio
- Cómo definir el movimiento de la cámara
- Cómo crear enemigos
- Cómo crear potenciadores
- Cómo detectar la plataforma en la que se ejecuta el juego
- Cómo usar la información anterior para cambiar entre controles de entrada por teclado o táctil
Recursos
Esperamos que hayas aprendido más sobre la creación de juegos en Flutter.
Los siguientes recursos también pueden resultarte útiles e incluso inspiradores:
- Documentación de Flame y el paquete de Flame en pub.dev
- Video de YouTube de Lukas Klingsbo sobre los conceptos básicos del motor de juego de Flame
- Juegos simples de plataformas, la serie de juegos de Flame + Flutter de DevKage
- Dino Run, la serie de desarrollo de juegos de Flutter de DevKage
- Spacescape, la serie de desarrollo de juegos de Flutter de DevKage
- Juegos de Flutter
- Página del kit de herramientas de Juegos casuales de Flutter y su correspondiente plantilla con los primeros pasos para ese kit (el kit de herramientas de Juegos casuales no usa el motor de Flame, pero está diseñado para admitir anuncios para dispositivos móviles y compras integradas en la app del juego)
- Cómo compilar tu propio juego en Flutter, un video del kit de herramientas de Juegos casuales
- Página de Flutter Puzzle Hack (una competencia que se realizó en enero de 2022) y el video de los ganadores