Saltar al contenido
Ricardo RetamalSoftware y cosas que vale la pena construir
/
Conversemos
← Ver todos los escritos
Building3 min read

Making a Game with .NET MAUI: Part 2 — Voice Commands

In Part 1, we created a basic game that included using sensors to move a spaceship on the screen, and we added a couple of effects to give…

Publicado originalmente en Medium ↗

In Part 1, we created a basic game that included using sensors to move a spaceship on the screen, and we added a couple of effects to give the impression of movement.

In this next version, I set out to capture voice commands to make the spaceship fire. To achieve this, we will set the following objectives:

  • Convert voice to text
  • Make the spaceship fire
  • Display some enemies
  • Detect when any of the spaceships gets hit

To convert voice to text, I didn’t want to reinvent the wheel, and I had already seen that Gerald Versluis had a great article on this, so we will base our implementation on his example. If you want to see the implementation, you can find it here:

https://github.com/jfversluis/MauiSpeechToTextSample/tree/main

Once the voice service is implemented, we will need to create a new class called Shot that will represent a shot, and we will create a method called FireShip that will be responsible for generating a new shot.

The Shot class will look like this:

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

GitHub Gist ↗

The method should look like this:

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

GitHub Gist ↗

The painting of the shots will be done again in the PaintSurface event, so we will add the following portion of code:

// 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");
        }
    }
}

GitHub Gist ↗

The code basically takes from a collection of active shots and draws a line that moves successively across the screen until it disappears. Once it goes out of view, it is removed from the collection. We will also add a small indicator that will show at the top of the screen how many shots are active, mainly for debugging purposes:

// Draw an indicator
using (var textPaint = new SKPaint
{
    Color = SKColors.White,
    TextSize = 30,
    IsAntialias = true
})
{
    canvas.DrawText($"Active shoots: {activeShots.Count}", 10, 30, textPaint);
}

GitHub Gist ↗

If you run the project now, nothing will happen, haha. So, we will subscribe to the voice detected event to make our super shot. This will be done in the OnAppearing event of the project:

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

GitHub Gist ↗

In this method, we will receive a string that can contain several words depending on what is identified, so we will try to read some common words to trigger the shot. In this case, I tried to detect “pew,” “pum,” and “boom” as some common words to execute a shot. If any of these words is detected, a new shot is queued in the collection.

If you run the project now, the spaceship will be able to shoot! But I know, what’s the fun in shooting if I don’t have anything to destroy! So now we’ll create a method to draw the enemy spaceships, and we’ll base it on the same design as the player spaceship but change the colors a bit to differentiate between good and bad guys. But first, let’s create a new entity to represent the enemies:

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

GitHub Gist ↗

Now, let’s draw the enemies:

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

GitHub Gist ↗

This method will also be called from the PaintSurface event.

 // Draw enemy ships
 foreach (var enemy in enemies)
 {
     DrawEnemyShip(canvas, enemy, info);
 }

GitHub Gist ↗

If you run the project now, what you will see is that the line moves up the screen but passes through the enemies. What we need to detect now is when one of the shots hits any enemy spaceship, so we will create a method that will handle detecting these types of collisions. We’ll call it 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));
            }
        }
    }
}

GitHub Gist ↗

But since we not only want to detect the collision but also show an effect similar to an explosion, we will create a couple of new classes called Explosion and ExplosionParticle that will be responsible for this effect:

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

GitHub Gist ↗

Collision detection will be implemented through a timer that we will run when the screen is displayed, and from there, we will process the shot queue and detect the collisions to make the necessary drawings:

Dispatcher.StartTimer(TimeSpan.FromMilliseconds(16), () =>
{
    MainThread.BeginInvokeOnMainThread(() =>
    {
        UpdateGame();
        ProcessPewQueue();
        canvasView.InvalidateSurface();
    });
    return true; // Return true to keep the timer running
});

GitHub Gist ↗

The final result should look like this:

GitHub - rretamal/MauiGyras
Contribute to rretamal/MauiGyras development by creating an account on GitHub.github.com

Check here Part 2!: https://medium.com/@rretamal.dev/using-sensors-in-net-maui-4c0996f852ed

Happy Coding!