
English version: https://medium.com/@rretamal.dev/making-a-game-with-net-maui-part-2-voice-commands-4a2d1272e8f3
En la parte 1 hicimos un juego básico que incluía el uso de sensores para mover una nave en la pantalla y añadimos un par de efectos para dar la sensación de movimiento.
En esta siguiente versión me propuse capturar comandos de voz para hacer disparar la nave, para ello nos estableceremos los siguientes objetivos:
- Convertir voz a texto
- Hacer que la nave dispare
- Mostrar algunos enemigos
- Detectar cuando alguna de las naves recibe un disparo
Para convertir la voz a texto no quería reinventar la rueda y ya había visto que Gerald Versluis Tenía un artículo genial al respecto por lo que nos basaremos en dicho ejemplo para poder hacer la implementación en el juego, si quieres ver la implementación la puedes encontrar aquí:
https://github.com/jfversluis/MauiSpeechToTextSample/tree/main
Así que una vez implementado el servicio de voz necesitaremos crear una clase nueva llamada Shot que representará un disparo y nos crearemos un método llamado FireShip que sera el encargado de generar un nuevo disparo.
La clase Shot lucirá de la siguiente manera:
private class Shot
{
public float X, Y;
public const float Speed = 10f;
public Shot(float x, float y)
{
X = x;
Y = y;
}
public void Move(bool down = false)
{
Y += down ? Speed : -Speed;
}
}El método deberá quedar de la siguiente manera:
private async Task FireShip()
{
float centerX = canvasView.CanvasSize.Width / 2;
float centerY = canvasView.CanvasSize.Height / 2;
activeShots.Add(new Shot(centerX, centerY));
Console.WriteLine($"Shoot added on: ({centerX}, {centerY})");
MainThread.BeginInvokeOnMainThread(() => canvasView.InvalidateSurface());
}El pintado de por sí de los disparos se hará nuevamente en el evento PaintSurface por lo que agregaremos la siguiente porción de código:
// Draw shots
using (var shotPaint = new SKPaint { Color = SKColors.Red, StrokeWidth = 10 })
{
for (int i = activeShots.Count - 1; i >= 0; i--)
{
var shot = activeShots[i];
canvas.DrawLine(shot.X, shot.Y, shot.X, shot.Y - 30, shotPaint);
shot.Move();
if (shot.Y < 0)
{
activeShots.RemoveAt(i);
Console.WriteLine("Shoot deleted");
}
}
}El código básicamente toma de una colección de disparos activos y dibuja una línea que se va moviendo sucesivamente a través de la pantalla hasta que desaparece, una vez que sale del rango de visión se borra de la colección.
Igualmente agregaremos un pequeño indicador que mostrará en la parte superior de la pantalla cuántos disparos hay activos, esto mas que todo a modo de Debug:
// Draw an indicator
using (var textPaint = new SKPaint
{
Color = SKColors.White,
TextSize = 30,
IsAntialias = true
})
{
canvas.DrawText($"Active shoots: {activeShots.Count}", 10, 30, textPaint);
}Si ejecutas el proyecto no pasará nada haha, así que nos subscribiremos al evento de voz detectada para hacer nuestro super disparo, esto lo tendremos que hacer en el evento OnAppearing del proyecto:
override protected async void OnAppearing()
{
base.OnAppearing();
await _voiceRecognitionService.RequestPermissions();
_= _voiceRecognitionService.Listen(CultureInfo.GetCultureInfo("en-us"), new Progress<string>(partialText =>
{
listenedText = partialText;
if (!string.IsNullOrEmpty(listenedText))
{
_voiceRecognitionService_OnSpeechRecognized(this, partialText);
OnPropertyChanged(nameof(listenedText));
}
}), tokenSource.Token);
}
private void _voiceRecognitionService_OnSpeechRecognized(object? sender, string e)
{
var words = e.ToLower().Split(' ');
foreach (var word in words)
{
if (word.Contains("pew") || word.Contains("pure") || word.Contains("pum") || word.Contains("boom"))
{
pewTimes.Enqueue(DateTime.Now);
}
float x = (float)random.NextDouble() * canvasView.CanvasSize.Width;
float y = canvasView.CanvasSize.Height - 50;
recognizedWords.Add(new RecognizedWord(word, x, y));
}
}En este método recibiremos un string que puede contener varias palabras dependiendo de lo que se va identificando por lo que trataremos de leer algunas palabras comunes para hacer el disparo, en este caso traté de detectar “pew”, “pum” y “boom” como algunas palabras comunes para ejecutar un disparo, si alguna de esas palabras es detectada se encola un nuevo disparo en la colección.
Si ejecutas el proyecto ahora ya la nave podrá disparar!
Pero lo se, que gracia tiene disparar solamente si no tengo nada que destruir! así que ahora haremos un método para poder dibujar las naves enemigas y nos basaremos en el mismo diseño de la nave jugadora pero cambiando un poco los colores para establecer la diferencia de buenos contra malos pero primero creemos una nueva entidad que represente a los enemigos:
private class EnemyShip
{
public float X, Y;
public List<Shot> Shots = new List<Shot>();
private Random random = new Random();
public EnemyShip(float x, float y)
{
X = x;
Y = y;
}
public void Move()
{
// Simple random movement
X += (float)(random.NextDouble() * 4 - 2);
Y += (float)(random.NextDouble() * 4 - 2);
}
public void Fire()
{
Shots.Add(new Shot(X, Y));
}
public void UpdateShots()
{
for (int i = Shots.Count - 1; i >= 0; i--)
{
Shots[i].Move(true); // true for moving down
if (Shots[i].Y > 1000) // Adjust based on your game's scale
{
Shots.RemoveAt(i);
}
}
}
}Ahora sí, dibujemos a los enemigos:
private void DrawEnemyShip(SKCanvas canvas, EnemyShip enemy, SKImageInfo info)
{
float x = enemy.X - shipPosition.X;
float y = enemy.Y - shipPosition.Y;
// Rotate the camera and apply 3D effect (similar to stars)
x += cameraRotationY * 20;
y += cameraRotationX * 20;
float z = 1000 + (x * x + y * y) / 20000;
x = x * 1000 / z + info.Width / 2;
y = y * 1000 / z + info.Height / 2;
// Check if the enemy is visible on screen
if (x >= 0 && x <= info.Width && y >= 0 && y <= info.Height)
{
canvas.Save();
canvas.Translate(x, y);
canvas.Scale(1000 / z);
using (var paint = new SKPaint { IsAntialias = true })
{
// Enemy ship body
paint.Color = new SKColor(192, 57, 43); // Dark Red
var bodyPath = new SKPath();
bodyPath.MoveTo(-20, 15);
bodyPath.LineTo(20, 15);
bodyPath.LineTo(0, -30);
bodyPath.Close();
canvas.DrawPath(bodyPath, paint);
// Enemy wings
paint.Color = new SKColor(231, 76, 60); // Red
var leftWing = new SKPath();
leftWing.MoveTo(-20, 15);
leftWing.LineTo(-40, 30);
leftWing.LineTo(-15, 0);
leftWing.Close();
canvas.DrawPath(leftWing, paint);
var rightWing = new SKPath();
rightWing.MoveTo(20, 15);
rightWing.LineTo(40, 30);
rightWing.LineTo(15, 0);
rightWing.Close();
canvas.DrawPath(rightWing, paint);
// Enemy cabin
paint.Color = new SKColor(52, 152, 219); // Blue
canvas.DrawCircle(0, -5, 10, paint);
}
canvas.Restore();
}
}Este método será llamado desde el PaintSurface igualmente
// Draw enemy ships
foreach (var enemy in enemies)
{
DrawEnemyShip(canvas, enemy, info);
}Si ejecutas el proyecto ahora lo que verás es que la línea sube a través de la pantalla pero atraviesa a los enemigos, lo que tenemos que detectar ahora es cuando uno de los disparos alcanza a cualquier nave enemiga por lo que crearemos un método que se encargará de detectar este tipo de colisiones en este caso lo llamaremos CheckCollisions:
private void CheckCollisions()
{
float screenCenterX = canvasView.CanvasSize.Width / 2;
float screenCenterY = canvasView.CanvasSize.Height / 2;
// Check player shots against enemies
for (int i = activeShots.Count - 1; i >= 0; i--)
{
var shot = activeShots[i];
for (int j = enemies.Count - 1; j >= 0; j--)
{
var enemy = enemies[j];
// Calculate screen positions
float enemyScreenX = screenCenterX + (enemy.X - shipPosition.X);
float enemyScreenY = screenCenterY + (enemy.Y - shipPosition.Y);
float shotScreenX = shot.X;//screenCenterX + (shot.X - shipPosition.X);
float shotScreenY = shot.Y; // screenCenterY + (shot.Y - shipPosition.Y);
float distance = (float)Math.Sqrt(
Math.Pow(shotScreenX - enemyScreenX, 2) +
Math.Pow(shotScreenY - enemyScreenY, 2));
Console.WriteLine($"Shot ({shotScreenX},{shotScreenY}) Enemy ({enemyScreenX},{enemyScreenY}) Distance: {distance}");
if (distance < 50) // Adjust this value as needed
{
explosions.Add(new Explosion(enemy.X, enemy.Y));
activeShots.RemoveAt(i);
enemies.RemoveAt(j);
Console.WriteLine("Enemy hit!");
break;
}
}
}
// Check enemy shots against player (simplified collision)
foreach (var enemy in enemies)
{
for (int i = enemy.Shots.Count - 1; i >= 0; i--)
{
var shot = enemy.Shots[i];
if (Math.Abs(shot.X - shipPosition.X) < 30 &&
Math.Abs(shot.Y - shipPosition.Y) < 30)
{
// Player hit! Implement game over logic here
enemy.Shots.RemoveAt(i);
Console.WriteLine("Player hit!"); // Debug output
explosions.Add(new Explosion(shipPosition.X, shipPosition.Y));
}
}
}
}Pero como no solamente queremos detectar la colisión sino poder mostrar un efecto similar a una explosión crearemos un par de clases nuevas llamadas Explosion y ExplosionParticle que serán las encargadas de realizar este efecto:
private class Explosion
{
public float X { get; private set; }
public float Y { get; private set; }
private const int ParticleCount = 50;
private List<ExplosionParticle> particles;
private const int Duration = 60; // frames
private int currentFrame = 0;
public bool IsFinished => currentFrame >= Duration;
public Explosion(float x, float y)
{
X = x;
Y = y;
particles = new List<ExplosionParticle>();
for (int i = 0; i < ParticleCount; i++)
{
particles.Add(new ExplosionParticle(x, y));
}
}
public void Update()
{
currentFrame++;
foreach (var particle in particles)
{
particle.Update();
}
}
public void Draw(SKCanvas canvas, SKPoint shipPosition, SKImageInfo info,float cameraRotationX, float cameraRotationY)
{
float x = X - shipPosition.X;
float y = Y - shipPosition.Y;
// Apply the rotation of the camera
x += cameraRotationY * 20;
y += cameraRotationX * 20;
float z = 1000 + (x * x + y * y) / 20000;
x = x * 1000 / z + info.Width / 2;
y = y * 1000 / z + info.Height / 2;
using (var paint = new SKPaint { IsAntialias = true })
{
foreach (var particle in particles)
{
float alpha = 1 - (float)currentFrame / Duration;
paint.Color = particle.Color.WithAlpha((byte)(255 * alpha));
canvas.DrawCircle(
x + particle.OffsetX * (1000 / z),
y + particle.OffsetY * (1000 / z),
particle.Size * (1000 / z),
paint
);
}
}
}
}
private class ExplosionParticle
{
public float OffsetX { get; private set; }
public float OffsetY { get; private set; }
public float VelocityX { get; private set; }
public float VelocityY { get; private set; }
public float Size { get; private set; }
public SKColor Color { get; private set; }
private static Random random = new Random();
public ExplosionParticle(float x, float y)
{
OffsetX = 0;
OffsetY = 0;
float angle = (float)(random.NextDouble() * Math.PI * 2);
// Increase the speed
float speed = (float)(random.NextDouble() * 4 + 2);
VelocityX = (float)Math.Cos(angle) * speed;
VelocityY = (float)Math.Sin(angle) * speed;
// Increase the size
Size = (float)(random.NextDouble() * 5 + 2);
Color = new SKColor(
(byte)random.Next(200, 256),
(byte)random.Next(100, 200),
(byte)random.Next(50)
);
}
public void Update()
{
OffsetX += VelocityX;
OffsetY += VelocityY;
VelocityY += 0.1f;
Size *= 0.98f;
}
}La detección de colisiones será implementada a través de un timer que ejecutaremos cuando la pantalla se ha mostrado y ahí iremos procesando la cola de disparos e iremos detectando las colisiones para hacer los pintados necesarios:
Dispatcher.StartTimer(TimeSpan.FromMilliseconds(16), () =>
{
MainThread.BeginInvokeOnMainThread(() =>
{
UpdateGame();
ProcessPewQueue();
canvasView.InvalidateSurface();
});
return true; // Return true to keep the timer running
});El resultado final se debe ver de la siguiente manera:

https://github.com/rretamal/MauiGyras
Happy Coding!