In the previous part of the series we were implementing the SettingsPage
view, its corresponding view model and the PlayerSettings
view and view model. This is where we set the number of players, their names and initial money, as well as the ending condition. When this is all done correctly, the Ready button is enabled and if we hit it, we’ll navigate to the RacePage
. Well, this is what we want the button to do. For now it does nothing.
So, in this part we’ll implement the navigation to the RacePage
, and navigation in general. There are going to be several places in our app, where navigation between pages will be required, in particular:
– from SettingsPage
to RacePage
when the Ready button is clicked,
– from RacePage
to InstructionsPage
when the Instructions button is pressed, and back when a Back button in the InstructionsPage
is pressed (we haven’t created this page yet),
– from RacePage
to GameOverPage
when the game is over,
– from GameOverPage
to SettingsPage
when the Play Again button in the former is pressed.
Besides, only one of the Bets
and Results
content views can be visible at a time.
We’ll implement all this functionality in this part. We’ll also create the missing view models and a basic InstructionsPage
as we proceed.
To implement navigation to the GameOverPage
, we’ll create a basic game simulation.
To practice navigation, we’ll start by implementing a navigation system between the SettingsPage
and TestPage
. For now, when the Ready button is clicked, we’ll navigate to the TestPage
and we’ll be able to navigate back to the SettingsPage
from there. So, let’s begin.
Table of Contents
Navigation to Another Page
Let’s simplify the TestPage.xaml
file like so:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:viewmodels="clr-namespace:Slugrace.ViewModels"
x:DataType="viewmodels:TestViewModel"
x:Class="Slugrace.Views.TestPage"
Title="TestPage"
Padding="50">
<VerticalStackLayout WidthRequest="200">
<Label Text="Test Page"
FontSize="40" />
</VerticalStackLayout>
</ContentPage>
Also, we don’t need the properties we defined before in the TestViewModel
. Make sure this class looks for now like so:
using CommunityToolkit.Mvvm.ComponentModel;
namespace Slugrace.ViewModels;
public partial class TestViewModel : ObservableObject
{
}
There are different ways navigation can be implemented in .NET MAUI. We’re going to use URI Shell-based navigation, which looks like navigation to a website. The .NET MAUI Shell gives a structure to the application. We used it to set a starting page that is displayed when the app runs.
In order to be able to navigate to the TestPage
, we have to register this page with the routing system of .NET Shell. Open the AppShell.xaml.cs
file and add the following code:
using Slugrace.Views;
namespace Slugrace;
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
Routing.RegisterRoute(nameof(TestPage), typeof(TestPage));
}
}
The RegisterRoute
method takes two parameters. The first one is the route itself, the second one is the type that should be associated with this route. We’re using here the name of the page as the route, but you can use any name you like.
Now, we want to navigate to from SettingsPage
to TestPage
when the Ready button in the former is pressed. Let’s create a method in the SettingsViewModel
and add a RelayCommand
attribute to it:
...
public partial class SettingsViewModel : ObservableObject
{
...
[RelayCommand]
void SetEndingCondition(string condition)
...
[RelayCommand]
async Task StartGame()
{
await Shell.Current.GoToAsync("TestPage");
}
}
This is an asynchronous method used for Shell navigation. Here we passed the route as a string literal, but we could also do it using nameof
:
...
public partial class SettingsViewModel : ObservableObject
{
...
await Shell.Current.GoToAsync(nameof(TestPage));
...
Now go to the SettingsPage
and bind the button’s Command
property to the method:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage ...>
...
<!--the Ready button-->
<Button
Grid.Row="3"
Text="Ready"
IsEnabled="{Binding AllSettingsAreValid}"
Command="{Binding StartGameCommand}">
</Button>
...
Now run the app, fill in some valid data and hit the Ready button. This will take you to the TestPage
. Here’s what it looks like on Windows:
You can see a little arrow in the upper left corner. If you click it, you will navigate back to the SettingsPage
. Here’s what it looks like on Android:
In the actual game we shouldn’t be able to navigate from the RacePage
back to the SettingsPage
, so we have to take care of it. But first let’s see how to pass data from page to page.
Passing Data Between Pages
We can pass information from page to page. To do that, we can define query parameters or a dictionary. Let’s have a look at the first approach. It’s used for simple data types, like strings, ints, etc.
If you want to pass any query parameters in your web browser, they’re separated by a question mark. We also use a question mark in .NET MAUI.
So, suppose we want to pass the value of maxRaces
to the TestPage
. This is how we do it:
...
public partial class SettingsViewModel : ObservableObject
{
...
[RelayCommand]
async Task StartGame()
{
await Shell.Current.GoToAsync($"{nameof(TestPage)}?Limit={maxRaces}");
}
}
So, we’re using an interpolated string where the route is followed by ?
, then the query identifier (Limit
) and the value that we want to pass (maxRaces
).
Next, we have to receive the data in the page we navigate to. As we’re using the MVVM pattern, we’ll do it in the TestViewModel
:
using CommunityToolkit.Mvvm.ComponentModel;
namespace Slugrace.ViewModels;
[QueryProperty("MaxLimit", "Limit")]
public partial class TestViewModel : ObservableObject
{
}
We’re using the QueryProperty
attribute with two arguments. The first argument (MaxLimit
) is the name of the property defined in the view model that we will be able to bind to, and the second argument (Limit
) is the name of the query identifier that we used in the query inside the GoToAsync
method. We could use any name for the first parameter, also the same as for the second one. Anyway, then we have to define the property with the same name as the first argument in the class. In our case we’re using an observable property, so the actual property with the capitalized name will be generated for us. Here’s the code:
...
[QueryProperty("MaxLimit", "Limit")]
public partial class TestViewModel : ObservableObject
{
[ObservableProperty]
int maxLimit;
}
We can use nameof
for the first argument as well:
...
[QueryProperty(nameof(MaxLimit), "Limit")]
...
Let’s now bind to this property. We’ll create a new label in the TestPage
view and bind its Text
property to MaxLimit
:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage ...>
<VerticalStackLayout WidthRequest="600">
<Label Text="Test Page"
FontSize="40" />
<Label Text="{Binding MaxLimit, StringFormat='maximum number of races: {0}'}"
FontSize="30" />
</VerticalStackLayout>
</ContentPage>
Now you can see the value displayed correctly:
You can also pass multiple query parameters. Let’s modify the StartGame
method:
...
public partial class SettingsViewModel : ObservableObject
{
...
[RelayCommand]
async Task StartGame()
{
await Shell.Current.GoToAsync($"{nameof(TestPage)}?Limit={maxRaces}&Word={GameEndingCondition}");
}
}
Here we added a second parameter. Now, let’s receive both parameters in the TestViewModel
:
...
[QueryProperty(nameof(MaxLimit), "Limit")]
[QueryProperty(nameof(Word), "Word")]
public partial class TestViewModel : ObservableObject
{
[ObservableProperty]
int maxLimit;
[ObservableProperty]
string word;
}
Let’s bind to this property, too:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage ...>
...
<Label Text="{Binding MaxLimit, StringFormat='maximum number of races: {0}'}"
FontSize="30" />
<Label Text="{Binding Word, StringFormat='favorite word: {0}'}"
FontSize="20" />
...
Run the app and you’ll see this:
If you want to pass complex objects, you should add another argument to the GoToAsync
method, which is a Dictionary<string, object>
, like for example:
...
public partial class SettingsViewModel : ObservableObject
{
...
[RelayCommand]
async Task StartGame()
{
await Shell.Current.GoToAsync($"{nameof(TestPage)}",
new Dictionary<string, object>
{
{"Team", Players }
});
}
}
How do we receive navigation data in the TestViewModel
now? Exactly the same as before:
using CommunityToolkit.Mvvm.ComponentModel;
using System.Collections.ObjectModel;
namespace Slugrace.ViewModels;
[QueryProperty(nameof(Team), "Team")]
public partial class TestViewModel : ObservableObject
{
[ObservableProperty]
ObservableCollection<PlayerSettingsViewModel> team;
}
Let’s bind to it:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage ...>
<VerticalStackLayout WidthRequest="600">
<Label Text="Test Page"
FontSize="40" />
<Label Text="{Binding Team.Count, StringFormat='team size: {0} players'}"
FontSize="30" />
<Label Text="{Binding Team[0].PlayerInitialMoney, StringFormat='The captain has ${0}.'}"
FontSize="20" />
</VerticalStackLayout>
</ContentPage>
If we set the first player’s initial money to 2000 and hit the Ready button, we’ll see the following:
Fine. But what if we want to navigate back?
Navigating Back
Suppose we don’t want the user to navigate back. Then we have to remove the back button. The back button can be manipulated by setting the BackButtonBehavior
attached property to a BackButtonBehavior
object in the page you navigate to. There are a couple properties in the BackButtonBehavior
class, but the two that are of interest to us now are IsEnabled
and IsVisible
. We just have to set them to False
. Add the following code to the TextPage.xaml
file:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage ...>
<Shell.BackButtonBehavior>
<BackButtonBehavior IsEnabled="False" IsVisible="False" />
</Shell.BackButtonBehavior>
<VerticalStackLayout WidthRequest="600">
...
If you now run the app, you won’t see the back button anymore.
Let’s implement backward navigation programmatically, though. First let’s add a button in the TestPage
to navigate back to the SettingsPage
and bind its Command
property to a method that we have to create in the view model. Actually, let’s start with the method:
...
[QueryProperty(nameof(Team), "Team")]
public partial class TestViewModel : ObservableObject
{
[ObservableProperty]
ObservableCollection<PlayerSettingsViewModel> team;
[RelayCommand]
async Task NavigateBack()
{
await Shell.Current.GoToAsync("..");
}
}
We don’t have to specify the page we want to navigate to by its name. If we use the two dots, it just means we want to move one level up the stack, so to the page we navigated from.
And here’s the button in the TestPage
view:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage ...>
...
<Label Text="{Binding Team[0].PlayerInitialMoney, ...
<Button
Text="Back to settings"
Command="{Binding NavigateBackCommand}" />
</VerticalStackLayout>
</ContentPage>
If you run the app, you’ll see the button:
If you hit it, you’ll go back to where you came from.
It’s time to implement navigation in our app. But first let’s create the view model for the RacePage
.
GameViewModel
We’ll create a view model that will be used by the RacePage
and later also by the InstructionsPage
. This view model will end up pretty complex because it will include information about the entire game.
We’ll need a RaceStatus
enumeration to keep track of whether we’re waiting for a race to begin, or whether a race is currently going on, or whether it just finished. So, add a RaceStatus
enum to the root of the app and implement it like so:
namespace Slugrace;
public enum RaceStatus
{
NotYetStarted,
Started,
Finished
}
And now add a GameViewModel
class to the ViewModels
folder and implement it like so:
using CommunityToolkit.Mvvm.ComponentModel;
namespace Slugrace.ViewModels;
public partial class GameViewModel : ObservableObject
{
[ObservableProperty]
RaceStatus raceStatus;
}
Let’s register the page and the view model with the dependency service in MauiProgram.cs
:
...
public static class MauiProgram
{
...
builder.Services.AddTransient<SettingsViewModel>();
builder.Services.AddTransient<RacePage>();
builder.Services.AddTransient<GameViewModel>();
return builder.Build();
}
}
Next, in RacePage.xaml.cs
, let’s inject the view model in the constructor and set the binding context of the view to the view model:
...
public partial class RacePage : ContentPage
{
public RacePage(GameViewModel gameViewModel)
{
InitializeComponent();
BindingContext = gameViewModel;
}
}
We want to be able to navigate to RacePage
, so let’s register the route in AppShell.xaml.cs
:
...
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
Routing.RegisterRoute(nameof(RacePage), typeof(RacePage));
}
}
We removed the route to the TestPage because we don’t need it anymore.
The navigation should start when the Ready button is pressed. We’ll create a StartGame
method in the SettingsViewModel
and use it as a command.
In the method we’ll populate the Game
object with the slugs and players. To do that, let’s add some more properties to the Slug
model class. It now should look like this:
...
public class Slug
{
public string Name { get; set; }
public double Odds { get; set; }
public double PreviousOdds { get; set; }
public int WinNumber { get; set; }
public string ImageUrl { get; set; }
public string EyeImageUrl { get; set; }
public string BodyImageUrl { get; set; }
public double BaseOdds { get; set; }
public bool IsRaceWinner { get; set; }
}
The properties we want to set in the SettingsPage
are: Name
, BaseOdds
, ImageUrl
, EyeImageUrl
and BodyImageUrl
. Each slug will have a fixed name and BaseOdds
. Speedster will have the lowest BaseOdds
, because this slug will be the most probable to win. Similarly, as the least probable to win, Slowpoke will have the highest BaseOdds
.
The ImageUrl
refers to the silhouette image of the slug. The EyeImageUrl
and BodyImageUrl
properties will be used for the images in top view.
Before we move on, let’s also add some properties to the other model classes. We’re going to need them soon. So, here’s the Player
class:
...
public class Player
{
...
public int BetAmount { get; set; }
public bool IsInGame { get; set; }
public int PreviousMoney { get; set; }
public bool IsBankrupt { get; set; }
}
And here’s the Game
class:
...
public class Game
{
...
public int GameTimeSet { get; set; }
public string GameOverReason { get; set; }
public List<Player> Winners { get; set; }
public int RaceNumber { get; set; }
public int TimeElapsed { get; set; }
}
We’ll discuss all the new properties when we need them. With that in place, let’s create the StartGame
method in the SettingsViewModel
:
...
public partial class SettingsViewModel : ObservableObject
{
...
void SetEndingCondition(string condition)
...
[RelayCommand]
async Task StartGame()
{
// Populate the Game object
game.Slugs =
[
new Slug
{
Name = "Speedster",
BaseOdds = 1.33,
ImageUrl = "speedster.png",
EyeImageUrl = "speedster_eye.png",
BodyImageUrl = "speedster_body.png"
},
new Slug
{
Name = "Trusty",
BaseOdds = 1.59,
ImageUrl = "trusty.png",
EyeImageUrl = "trusty_eye.png",
BodyImageUrl = "trusty_body.png"
},
new Slug
{
Name = "Iffy",
BaseOdds = 2.5,
ImageUrl = "iffy.png",
EyeImageUrl = "iffy_eye.png",
BodyImageUrl = "iffy_body.png"
},
new Slug
{
Name = "Slowpoke",
BaseOdds = 2.89,
ImageUrl = "slowpoke.png",
EyeImageUrl = "slowpoke_eye.png",
BodyImageUrl = "slowpoke_body.png"
}
];
var playersInGame = Players.Where(p => p.PlayerIsInGame).ToList();
game.Players = [];
foreach (var player in playersInGame)
{
game.Players.Add(new Player
{
Id = player.PlayerId,
Name = string.IsNullOrEmpty(player.PlayerName) ? "Player " + player.PlayerId : player.PlayerName,
IsInGame = true,
InitialMoney = player.PlayerInitialMoney,
CurrentMoney = player.PlayerInitialMoney
});
}
// Navigate to RacePage
await Shell.Current.GoToAsync($"{nameof(RacePage)}",
new Dictionary<string, object>
{
{"Game", game }
});
}
}
Here we’re populating the Game
object with the data collected from the SettingsPage
. This object is then passed to the RacePage
.
We can now bind the method to the Command
property of the Ready button in the SettingsPage
:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage ...>
...
<!--the Ready button-->
<Button
Grid.Row="3"
Text="Ready"
IsEnabled="{Binding AllSettingsAreValid}"
Command="{Binding StartGameCommand}">
</Button>
...
Next, in the GameViewModel
, we have to add the QueryProperty
attribute and create a property to store the data passed from the SettingsPage
:
using CommunityToolkit.Mvvm.ComponentModel;
using Slugrace.Models;
...
[QueryProperty(nameof(Game), "Game")]
public partial class GameViewModel : ObservableObject
{
[ObservableProperty]
private Game game;
[ObservableProperty]
RaceStatus raceStatus;
...
If we now run the app, set the players (their names and initial money) and the ending condition, and then hit the Ready button, we’ll navigate to the RacePage
. We’ll take all the game information with us. Now we can consume it in the RacePage
.
There are several content views nested in the RacePage
that need information about the Game
object, the Player
objects and the Slug
objects. All this information will be contained in the GameViewModel
.
GameInfo
Let’s start with the GameInfo
view. As a child of the RacePage
, it will bind to the GameViewModel
, just like its parent. We have to add some properties to the view model that this content view will bind to. Here’s the GameViewModel
class:
...
[QueryProperty(nameof(Game), "Game")]
public partial class GameViewModel : ObservableObject
{
...
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(EndingConditionIsRaces))]
[NotifyPropertyChangedFor(nameof(EndingConditionIsTime))]
private EndingCondition gameEndingCondition;
public bool EndingConditionIsRaces => GameEndingCondition == EndingCondition.Races;
public bool EndingConditionIsTime => GameEndingCondition == EndingCondition.Time;
private int raceNumber;
public int RaceNumber
{
get => raceNumber;
set
{
if (raceNumber != value)
{
raceNumber = value;
OnPropertyChanged();
OnPropertyChanged(nameof(RacesFinished));
OnPropertyChanged(nameof(RacesToGo));
}
}
}
[ObservableProperty]
private int numberOfRacesSet;
public int RacesFinished => RaceNumber - 1;
public int RacesToGo => NumberOfRacesSet - RacesFinished;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(TimeRemaining))]
private TimeSpan gameTimeSet;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(TimeRemaining))]
private TimeSpan timeElapsed;
public TimeSpan TimeRemaining => GameTimeSet - TimeElapsed;
}
Here we have all the properties that our view needs. Now we can bind to them. The race number will be visible independent of the ending condition, but other information will be displayed depending on which ending condition is set. Here’s the GameInfo.xaml
file:
<?xml version="1.0" encoding="utf-8" ?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:viewmodels="clr-namespace:Slugrace.ViewModels"
x:DataType="viewmodels:GameViewModel"
x:Class="Slugrace.Controls.GameInfo">
<Grid
RowDefinitions="*, *, *, *, *"
ColumnDefinitions="3*, *">
<Grid.Resources>
<Style TargetType="Label"
BasedOn="{OnPlatform {StaticResource labelBaseStyle},
Android={StaticResource androidLabelBaseStyle}}" />
</Grid.Resources>
<Label
Text="Game Info"
Style="{OnPlatform {StaticResource labelSectionTitleStyle},
Android={StaticResource androidLabelSectionTitleStyle}}" />
<Label
Grid.Row="1"
Text="Race No:"/>
<Label
Grid.Row="1"
Grid.Column="1"
Text="{Binding RaceNumber}" />
<!--displayed if Races ending condition-->
<Label
IsVisible="{Binding EndingConditionIsRaces}"
Grid.Row="2">
<Label.Text>
<OnPlatform x:TypeArguments="x:String">
<On Platform="WinUI" Value="Number of races set:" />
<On Platform="Android" Value="Total races:" />
</OnPlatform>
</Label.Text>
</Label>
<Label
IsVisible="{Binding EndingConditionIsRaces}"
Grid.Row="2"
Grid.Column="1"
Text="{Binding NumberOfRacesSet}" />
<Label
IsVisible="{Binding EndingConditionIsRaces}"
Grid.Row="3"
Text="Races finished:" />
<Label
IsVisible="{Binding EndingConditionIsRaces}"
Grid.Row="3"
Grid.Column="1"
Text="{Binding RacesFinished}" />
<Label
IsVisible="{Binding EndingConditionIsRaces}"
Grid.Row="4"
Text="Races to go:" />
<Label
IsVisible="{Binding EndingConditionIsRaces}"
Grid.Row="4"
Grid.Column="1"
Text="{Binding RacesToGo}" />
<!--displayed if Time ending condition-->
<Label
IsVisible="{Binding EndingConditionIsTime}"
Grid.Row="2">
<Label.Text>
<OnPlatform x:TypeArguments="x:String">
<On Platform="WinUI" Value="Game time set:" />
<On Platform="Android" Value="Total time:" />
</OnPlatform>
</Label.Text>
</Label>
<Label
IsVisible="{Binding EndingConditionIsTime}"
Grid.Row="2"
Grid.Column="1"
Text="{Binding GameTimeSet}" />
<Label
IsVisible="{Binding EndingConditionIsTime}"
Grid.Row="3"
Text="Time elapsed:" />
<Label
IsVisible="{Binding EndingConditionIsTime}"
Grid.Row="3"
Grid.Column="1"
Text="{Binding TimeElapsed}" />
<Label
IsVisible="{Binding EndingConditionIsTime}"
Grid.Row="4"
Text="Time remaining:" />
<Label
IsVisible="{Binding EndingConditionIsTime}"
Grid.Row="4"
Grid.Column="1"
Text="{Binding TimeRemaining}" />
</Grid>
</ContentView>
We have to modify the GameViewModel
now because we need information from the Game
object passed from the SettingsPage
. However, we can’t do it in the constructor because there the Game
object isn’t available. That’s why we have to add a partial method OnGameChanged
and do it there:
...
public partial class GameViewModel : ObservableObject
{
...
partial void OnGameChanged(Game value)
{
GameEndingCondition = value.GameEndingCondition;
RaceStatus = RaceStatus.NotYetStarted;
RaceNumber = 1;
NumberOfRacesSet = value.NumberOfRacesSet;
GameTimeSet = TimeSpan.FromMinutes(value.GameTimeSet);
}
...
}
We’re done. If you now run the app, select the Money
ending condition and press the Ready button, you will only see the race number in the Game Info panel:
If you select the Races
ending condition and set the number of races to 20, you will see only races-related data:
Finally, if you select the Time
ending condition and set the time to 45 minutes, you will see only time-related data:
With this in place, let’s move on to the Slugs’ Stats and Players’ Stats panels. These panels need information about the slugs and players. There are more content views in the app that need information about the slugs and players, so let’s create separate view models to serve them all.
SlugViewModel
Let’s start with the slugs. So, add a new class to the ViewModels
folder and name it SlugViewModel
. Here’s the code:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Slugrace.Messages;
using Slugrace.Models;
namespace Slugrace.ViewModels;
public partial class SlugViewModel : ObservableObject
{
private Slug slug;
public string Name
{
get => slug.Name;
set
{
if (slug.Name != value)
{
slug.Name = value;
OnPropertyChanged();
}
}
}
public string ImageUrl
{
get => slug.ImageUrl;
set
{
if (slug.ImageUrl != value)
{
slug.ImageUrl = value;
OnPropertyChanged();
}
}
}
public string EyeImageUrl
{
get => slug.EyeImageUrl;
set
{
if (slug.EyeImageUrl != value)
{
slug.EyeImageUrl = value;
OnPropertyChanged();
}
}
}
public string BodyImageUrl
{
get => slug.BodyImageUrl;
set
{
if (slug.BodyImageUrl != value)
{
slug.BodyImageUrl = value;
OnPropertyChanged();
}
}
}
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(WinPercentage))]
private int currentRaceNumber;
public int WinNumber
{
get => slug.WinNumber;
set
{
if (slug.WinNumber != value)
{
slug.WinNumber = value;
OnPropertyChanged();
OnPropertyChanged(nameof(WinPercentage));
OnPropertyChanged(nameof(WinNumberText));
}
}
}
public string WinNumberText => WinNumber == 1 ? $"{WinNumber} win" : $"{WinNumber} wins";
[ObservableProperty]
private int winPercentage;
public bool IsRaceWinner
{
get => slug.IsRaceWinner;
set
{
if (slug.IsRaceWinner != value)
{
slug.IsRaceWinner = value;
OnPropertyChanged();
OnPropertyChanged(nameof(Odds));
}
}
}
public double Odds
{
get => slug.Odds;
set
{
if (slug.Odds != value)
{
slug.Odds = value;
OnPropertyChanged();
}
}
}
public double PreviousOdds
{
get => slug.PreviousOdds;
set
{
if (slug.PreviousOdds != value)
{
slug.PreviousOdds = value;
OnPropertyChanged();
}
}
}
public SlugViewModel()
{
slug = new Slug();
CurrentRaceNumber = 1;
WeakReferenceMessenger.Default.Register<RaceFinishedMessage>(this, (r, m) =>
OnRaceFinishedMessageReceived(m.Value));
}
private void OnRaceFinishedMessageReceived(RaceStatus value)
{
WinPercentage = (int)((double)WinNumber / CurrentRaceNumber * 100);
PreviousOdds = Odds;
Odds = IsRaceWinner
? Math.Round(Math.Max(1.01, Math.Min(Odds * .96, 20)), 2)
: Math.Round(Math.Max(1.01, Math.Min(Odds * 1.03, 20)), 2);
}
public void RecalculateStats(int raceNumber)
{
CurrentRaceNumber = raceNumber;
if (IsRaceWinner)
{
WinNumber++;
}
}
}
This is the complete code that is required by all the views where slug information is required. Let’s break it down.
So, we have the Name
property, followed by three properties related to the slug images. We also have a WinNumber
property that will increase by 1 every time the slug wins a race.
There are some more properties that are related to the winning position of the slug after each race. The IsRaceWinner
property will be set to true
if the slug wins. The WinNumberText
property will be used to display the number of wins correctly.
Another property is WinPercentage
. This property will tell us what part (expressed as a percentage) of the total number of races the slug has won. To calculate this property, we have to know how many races have already finished, hence the CurrentRaceNumber
property. We’ll use a message to notify the WinPercentage
property each time a race finishes. Let’s create the message first.
In the Messages
folder add a new class and name it RaceFinishedMessage
. Here’s the code:
using CommunityToolkit.Mvvm.Messaging.Messages;
namespace Slugrace.Messages;
public class RaceFinishedMessage : ValueChangedMessage<RaceStatus>
{
public RaceFinishedMessage(RaceStatus value) : base(value) { }
}
This message will be sent each time the race status changes to Finished
. Let’s modify the RaceStatus
property in the GameViewModel
:
...
public partial class GameViewModel : ObservableObject
{
...
private RaceStatus raceStatus;
public RaceStatus RaceStatus
{
get => raceStatus;
set
{
if (raceStatus != value)
{
raceStatus = value;
OnPropertyChanged();
if (raceStatus == RaceStatus.Finished)
{
WeakReferenceMessenger.Default.Send(new RaceFinishedMessage(value));
}
}
}
}
[ObservableProperty]
private bool gameIsOver;
...
The message is registered in the SlugViewModel
class’s constructor. In the OnRaceFinishedMessageReceived
method WinPercentage
is calculated after each race. Also the Odds
and PreviousOdds
are recalculated depending on whether the slug won the race or not. The odds decrease if the slug wins and increase otherwise.
We also have the RecalculateStats
method that we will call later from inside the GameViewModel
for each slug.
This is all about slugs. What about the players?
PlayerViewModel
Add a PlayerViewModel
class. It will contain all player-related data:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Slugrace.Messages;
using Slugrace.Models;
namespace Slugrace.ViewModels;
public partial class PlayerViewModel : ObservableObject
{
private Player player;
public string Name
{
get => player.Name;
set
{
if (player.Name != value)
{
player.Name = value;
OnPropertyChanged();
}
}
}
public int InitialMoney
{
get => player.InitialMoney;
set
{
if (player.InitialMoney != value)
{
player.InitialMoney = value;
OnPropertyChanged();
}
}
}
public int CurrentMoney
{
get => player.CurrentMoney;
set
{
if (player.CurrentMoney != value)
{
player.CurrentMoney = value;
OnPropertyChanged();
}
}
}
public int BetAmount
{
get => player.BetAmount;
set
{
if (player.BetAmount != value)
{
player.BetAmount = value;
OnPropertyChanged();
OnPropertyChanged(nameof(BetAmountIsValid));
OnPropertyChanged(nameof(PlayerIsValid));
WeakReferenceMessenger.Default.Send(new PlayerBetAmountChangedMessage(value));
}
}
}
public int PreviousMoney
{
get => player.PreviousMoney;
set
{
if (player.PreviousMoney != value)
{
player.PreviousMoney = value;
OnPropertyChanged();
}
}
}
public int WonOrLostMoney
{
get => player.WonOrLostMoney;
set
{
if (player.WonOrLostMoney != value)
{
player.WonOrLostMoney = value;
OnPropertyChanged();
}
}
}
public bool IsInGame
{
get => player.IsInGame;
set
{
if (player.IsInGame != value)
{
player.IsInGame = value;
OnPropertyChanged();
}
}
}
public bool IsBankrupt
{
get => player.IsBankrupt;
set
{
if (player.IsBankrupt != value)
{
player.IsBankrupt = value;
OnPropertyChanged();
}
}
}
[ObservableProperty]
private List<SlugViewModel> slugs;
private SlugViewModel selectedSlug;
public SlugViewModel SelectedSlug
{
get => selectedSlug;
set
{
if (selectedSlug != value)
{
selectedSlug = value;
OnPropertyChanged();
OnPropertyChanged(nameof(SelectedSlugIsValid));
OnPropertyChanged(nameof(PlayerIsValid));
WeakReferenceMessenger.Default.Send(new PlayerSelectedSlugChangedMessage(value));
}
}
}
public bool BetAmountIsValid => Helpers.ValueIsInRange(BetAmount,
1, CurrentMoney);
public bool SelectedSlugIsValid => SelectedSlug != null;
public bool PlayerIsValid => IsBankrupt || (BetAmountIsValid && SelectedSlugIsValid);
[ObservableProperty]
private string resultMessage;
public PlayerViewModel()
{
player = new Player();
}
[RelayCommand]
void SelectSlug(string name)
{
var slug = Slugs.Find(s => s.Name == name);
SelectedSlug = slug;
}
public void CalculateMoney(SlugViewModel raceWinnerSlug)
{
PreviousMoney = CurrentMoney;
bool wonRace = SelectedSlug == raceWinnerSlug;
WonOrLostMoney = (int)(wonRace
? BetAmount * (SelectedSlug.Odds - 1)
: -BetAmount);
CurrentMoney += WonOrLostMoney;
ResultMessage = wonRace
? (WonOrLostMoney == 0 ? $"- won less than $1" : $"- won ${WonOrLostMoney}")
: $"- lost ${Math.Abs(WonOrLostMoney)}";
}
}
Again, this is the complete code. Here we have some basic properties like Name
, InitialMoney
or CurrentMoney
. We have the BetAmount
property to store the amount of money a player bets on a slug. We store the current money in the PreviousMoney
property so that we can see how much money the player had before the race. After the race the CurrentMoney
property changes depending on the value of WonOrLostMoney
, which, in turn, depends on whether the player won or lost.
The player also holds a reference to the slugs and before each race a slug is selected and stored in the SelectedSlug
property.
When the BetAmount
and SelectedSlug
properties change, messages are sent. Let’s implement them. In the Messages
folder add the PlayerBetAmountChangedMessage
and PlayerSelectedSlugChangedMessage
. The former should be implemented like this:
using CommunityToolkit.Mvvm.Messaging.Messages;
namespace Slugrace.Messages;
public class PlayerBetAmountChangedMessage : ValueChangedMessage<int>
{
public PlayerBetAmountChangedMessage(int value) : base(value) { }
}
The latter should be implemented like this:
using CommunityToolkit.Mvvm.Messaging.Messages;
using Slugrace.ViewModels;
namespace Slugrace.Messages;
public class PlayerSelectedSlugChangedMessage : ValueChangedMessage<SlugViewModel>
{
public PlayerSelectedSlugChangedMessage(SlugViewModel value) : base(value) { }
}
These messages must be registered in the GameViewModel
’s constructor, but we’ll see to it in a moment. For now, let’s stay in the PlayerViewModel
class.
The player must be valid in order for the Go button in the Bets panel to be enabled. To check the validity, we have three properties: BetAmountIsValid
, SelectedSlugIsValid
and PlayerIsValid
.
We also have the SelectSlug
method that will be called when a radio button representing a slug is checked. Finally, after each race, the money properties are recalculated and a result message is created. For this we need the CalculateMoney
method and the ResultMessage
property.
And now let’s go to the GameViewModel
, add the slugs and players, and register the messages:
...
public partial class GameViewModel : ObservableObject
{
...
private EndingCondition gameEndingCondition;
[ObservableProperty]
private List<SlugViewModel> slugs;
[ObservableProperty]
private List<PlayerViewModel> players;
[ObservableProperty]
private ObservableCollection<PlayerViewModel> playersStillInGame;
...
[ObservableProperty]
private List<PlayerViewModel> winners;
[ObservableProperty]
private SlugViewModel raceWinnerSlug;
private int changedBetAmount;
public int ChangedBetAmount
{
get => changedBetAmount;
set
{
OnPropertyChanged();
OnPropertyChanged(nameof(AllPlayersAreValid));
if (changedBetAmount != value)
{
changedBetAmount = value;
}
}
}
private SlugViewModel changedSelectedSlug;
public SlugViewModel ChangedSelectedSlug
{
get => changedSelectedSlug;
set
{
OnPropertyChanged();
OnPropertyChanged(nameof(AllPlayersAreValid));
if (changedSelectedSlug != value)
{
changedSelectedSlug = value;
}
}
}
public bool? AllPlayersAreValid => Players?.All(p => p.PlayerIsValid);
...
public GameViewModel()
{
WeakReferenceMessenger.Default.Register<PlayerBetAmountChangedMessage>(this, (r, m) =>
OnBetAmountChangedMessageReceived(m.Value));
WeakReferenceMessenger.Default.Register<PlayerSelectedSlugChangedMessage>(this, (r, m) =>
OnSelectedSlugChangedMessageReceived(m.Value));
}
private void OnBetAmountChangedMessageReceived(int value)
{
ChangedBetAmount = value;
}
private void OnSelectedSlugChangedMessageReceived(SlugViewModel value)
{
ChangedSelectedSlug = value;
}
partial void OnGameChanged(Game value)
{
...
GameTimeSet = TimeSpan.FromMinutes(value.GameTimeSet);
Winners = [];
Slugs =
[
new()
{
Name = value.Slugs[0].Name,
ImageUrl = value.Slugs[0].ImageUrl,
EyeImageUrl = value.Slugs[0].EyeImageUrl,
BodyImageUrl = value.Slugs[0].BodyImageUrl,
WinNumber = 0,
Odds = Math.Round(value.Slugs[0].BaseOdds + new Random().Next(0, 10) / 100, 2),
PreviousOdds = value.Slugs[0].BaseOdds
},
new()
{
Name = value.Slugs[1].Name,
ImageUrl = value.Slugs[1].ImageUrl,
EyeImageUrl = value.Slugs[1].EyeImageUrl,
BodyImageUrl = value.Slugs[1].BodyImageUrl,
WinNumber = 0,
Odds = Math.Round(value.Slugs[1].BaseOdds + new Random().Next(0, 10) / 100, 2),
PreviousOdds = value.Slugs[1].BaseOdds
},
new()
{
Name = value.Slugs[2].Name,
ImageUrl = value.Slugs[2].ImageUrl,
EyeImageUrl = value.Slugs[2].EyeImageUrl,
BodyImageUrl = value.Slugs[2].BodyImageUrl,
WinNumber = 0,
Odds = Math.Round(value.Slugs[2].BaseOdds + new Random().Next(0, 10) / 100, 2),
PreviousOdds = value.Slugs[2].BaseOdds
},
new()
{
Name = value.Slugs[3].Name,
ImageUrl = value.Slugs[3].ImageUrl,
EyeImageUrl = value.Slugs[3].EyeImageUrl,
BodyImageUrl = value.Slugs[3].BodyImageUrl,
WinNumber = 0,
Odds = Math.Round(value.Slugs[3].BaseOdds + new Random().Next(0, 10) / 100, 2),
PreviousOdds = value.Slugs[3].BaseOdds
},
];
List<PlayerViewModel> players = [];
foreach (var player in value.Players)
{
players.Add
(
new()
{
Name = player.Name,
InitialMoney = player.InitialMoney,
CurrentMoney = player.CurrentMoney,
BetAmount = 0,
IsInGame = player.IsInGame,
PreviousMoney = player.CurrentMoney,
WonOrLostMoney = 0,
Slugs = Slugs,
SelectedSlug = null
}
);
}
Players = players;
PlayersStillInGame = Players.ToObservableCollection();
}
}
In the OnGameChanged
method we create the slugs and players. We’ll be talking about the PlayersStillInGame
, Winners
and RaceWinnerSlug
properties a bit later. In the constructor we register the two messages we just created. We also define the AllPlayersAreValid
property to check whether we can start the next race.
Now that we have the SlugViewModel
and PlayerViewModel
classes, let’s bind to them. Let’s start with the Slugs’ Stats content view.
Slugs’ Stats and Slug Stats
In the Slugs’ Stats panel we can view the achievements of all the slugs. The binding context for this content view is GameViewModel
. There we use the SlugStats
content views for each slug. Each SlugStats
control binds to one slug:
<?xml version="1.0" encoding="utf-8" ?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:controls="clr-namespace:Slugrace.Controls"
xmlns:viewmodels="clr-namespace:Slugrace.ViewModels"
x:DataType="viewmodels:GameViewModel"
x:Class="Slugrace.Controls.SlugsStats">
<ContentView.Resources>
<Style TargetType="Label" BasedOn="{StaticResource labelBaseStyle}" />
</ContentView.Resources>
<Grid
RowDefinitions="*, *, *, *, *"
ColumnDefinitions="*">
<Label
Text="Slugs' Stats"
Style="{OnPlatform {StaticResource labelSectionTitleStyle},
Android={StaticResource androidLabelSectionTitleStyle}}" />
<controls:SlugStats
BindingContext="{Binding Slugs[0]}"
Grid.Row="1" />
<controls:SlugStats
BindingContext="{Binding Slugs[1]}"
Grid.Row="2" />
<controls:SlugStats
BindingContext="{Binding Slugs[2]}"
Grid.Row="3" />
<controls:SlugStats
BindingContext="{Binding Slugs[3]}"
Grid.Row="4" />
</Grid>
</ContentView>
So, let’s have a look at a single slug now. The binding context for the SlugStats
view is SlugViewModel
. We bind to the properties we defined there:
<?xml version="1.0" encoding="utf-8" ?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:viewmodels="clr-namespace:Slugrace.ViewModels"
x:DataType="viewmodels:SlugViewModel"
x:Class="Slugrace.Controls.SlugStats">
<ContentView.BindingContext>
<viewmodels:SlugViewModel />
</ContentView.BindingContext>
<ContentView.Resources>
<Style TargetType="Label"
BasedOn="{OnPlatform {StaticResource labelBaseStyle},
Android={StaticResource androidLabelBaseStyle}}" />
</ContentView.Resources>
<Grid
RowDefinitions="*">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="{OnPlatform 2*, Android=1.8*}" />
<ColumnDefinition Width="{OnPlatform *, Android=1.4*}" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Label
Text="{Binding Name}" />
<Label
Grid.Column="1"
Text="{Binding WinNumberText}" />
<Label
Grid.Column="2"
Text="{Binding WinPercentage, StringFormat='{0}%'}" />
</Grid>
</ContentView>
The stats will be recalculated after each race. We’ll implement this in a moment when we create a game simulation. For now, though, let’s move on to the Players’ Stats panel.
Players’ Stats and Player Stats
The binding context for the PlayersStats
content view is GameViewModel
. For the slugs we added four SlugStats
controls to the SlugsStats
view because there are always four slugs. But the number of players may vary between one and four, so we’ll add a CollectionView
and set the Players
as its source. We’ll use the PlayerStats
control as the item template:
<?xml version="1.0" encoding="utf-8" ?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:controls="clr-namespace:Slugrace.Controls"
xmlns:viewmodels="clr-namespace:Slugrace.ViewModels"
x:DataType="viewmodels:GameViewModel"
x:Class="Slugrace.Controls.PlayersStats">
<ContentView.Resources>
<Style TargetType="Label" BasedOn="{StaticResource labelBaseStyle}" />
</ContentView.Resources>
<Grid
RowDefinitions="*, *, *, *, *"
ColumnDefinitions="*">
<Label
Text="Players' Stats"
Style="{OnPlatform {StaticResource labelSectionTitleStyle},
Android={StaticResource androidLabelSectionTitleStyle}}" />
<CollectionView
Grid.Row="1"
Grid.RowSpan="4"
ItemsSource="{Binding Players}">
<CollectionView.ItemTemplate>
<DataTemplate>
<controls:PlayerStats Margin="0, 2.5"/>
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</Grid>
</ContentView>
And now let’s have a look at a single player. The binding context of the PlayerStats
control is PlayerViewModel
:
<?xml version="1.0" encoding="utf-8" ?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
xmlns:viewmodels="clr-namespace:Slugrace.ViewModels"
x:DataType="viewmodels:PlayerViewModel"
x:Class="Slugrace.Controls.PlayerStats">
<ContentView.BindingContext>
<viewmodels:PlayerViewModel />
</ContentView.BindingContext>
<ContentView.Resources>
<Style TargetType="Label"
BasedOn="{OnPlatform {StaticResource labelBaseStyle},
Android={StaticResource androidLabelBaseStyle}}" />
<toolkit:InvertedBoolConverter x:Key="invertedBoolConverter" />
</ContentView.Resources>
<Grid
RowDefinitions="*">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="{OnPlatform 2.5*, Android=*}" />
<ColumnDefinition Width="{OnPlatform 1.5*, Android=*}" />
</Grid.ColumnDefinitions>
<Label
IsVisible="{Binding IsBankrupt, Converter={StaticResource invertedBoolConverter}}"
Text="{Binding Name}" />
<Label
IsVisible="{Binding IsBankrupt}"
Text="{Binding Name}"
TextColor="Red"
Opacity=".4" />
<Label
IsVisible="{Binding IsBankrupt, Converter={StaticResource invertedBoolConverter}}"
Grid.Column="1"
Text="{Binding CurrentMoney, StringFormat='has ${0}'}" />
<Label
IsVisible="{Binding IsBankrupt}"
Grid.Column="1"
Text="is bankrupt"
TextColor="Red"
Opacity=".4" />
</Grid>
</ContentView>
Here we use the InvertedBoolConverter
from the Toolkit.
Let’s move on to the racetrack now. There we have the slug images and some slug info.
Slug Image and Slug Info
The binding context for the SlugImage
and SlugInfo
content views is SlugViewModel
. In the SlugImage
control we bind to the top-view image properties:
<?xml version="1.0" encoding="utf-8" ?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:viewmodels="clr-namespace:Slugrace.ViewModels"
x:DataType="viewmodels:SlugViewModel"
x:Class="Slugrace.Controls.SlugImage">
<AbsoluteLayout>
<Image
Source="{Binding EyeImageUrl}"
AbsoluteLayout.LayoutBounds="1.2, .35, .25, .23"
AbsoluteLayout.LayoutFlags="All"
Rotation="-30"
AnchorX="0" />
<Image
Source="{Binding EyeImageUrl}"
AbsoluteLayout.LayoutBounds="1.2, .65, .25, .23"
AbsoluteLayout.LayoutFlags="All"
Rotation="30"
AnchorX="0" />
<Image
Source="{Binding BodyImageUrl}"
AbsoluteLayout.LayoutBounds="0, 0, 1, 1"
AbsoluteLayout.LayoutFlags="All"
Aspect="Fill"/>
</AbsoluteLayout>
</ContentView>
The SlugInfo
content view is used to display the slugs’ names, wins and odds:
<?xml version="1.0" encoding="utf-8" ?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:viewmodels="clr-namespace:Slugrace.ViewModels"
x:DataType="viewmodels:SlugViewModel"
x:Class="Slugrace.Controls.SlugInfo">
<ContentView.Resources>
<Color x:Key="infoTextColor">White</Color>
</ContentView.Resources>
<Grid
Padding="5, 2, 0, 2"
RowDefinitions="*, *"
ColumnDefinitions="6.8*, 2.2*">
<Label
Text="{Binding Name}"
FontSize="18"
FontAttributes="{StaticResource emphasized}"
TextColor="{StaticResource infoTextColor}"
Opacity="{OnPlatform 1, Android=0}" />
<Label
Grid.Row="1"
Text="{Binding WinNumberText}"
FontSize="14"
TextColor="{StaticResource infoTextColor}"
Opacity="{OnPlatform 1, Android=0}" />
<Label
Grid.Column="1"
Grid.RowSpan="2"
VerticalOptions="{StaticResource defaultLayoutOptions}"
Text="{Binding Odds, StringFormat='{0:F2}'}"
FontSize="{OnPlatform 40, Android=12}"
FontAttributes="{StaticResource emphasized}"
TextColor="{StaticResource infoTextColor}"/>
</Grid>
</ContentView>
Next, let’s move on to the Bets and Results panels.
Bets and Results
We’ll use GameViewModel
as the binding context for the Bets and Results views. For the PlayerBet
and PlayerResult
controls we’ll use PlayerViewModel
.
In the Bets
content view we’ll use a CollectionView
for the individual players:
<?xml version="1.0" encoding="utf-8" ?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:controls="clr-namespace:Slugrace.Controls"
xmlns:viewmodels="clr-namespace:Slugrace.ViewModels"
x:DataType="viewmodels:GameViewModel"
x:Class="Slugrace.Controls.Bets">
<ContentView.Resources>
<Style TargetType="Label" BasedOn="{StaticResource labelBaseStyle}" />
<Style TargetType="Slider">
<Setter Property="ThumbColor"
Value="{AppThemeBinding
Light={StaticResource mainButtonColor},
Dark={StaticResource buttonTextColor}}" />
<Setter Property="MinimumTrackColor"
Value="{AppThemeBinding
Light={StaticResource mainButtonColor},
Dark={StaticResource buttonTextColor}}" />
</Style>
</ContentView.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="{OnPlatform *, Android=.3*}" />
<RowDefinition Height="{OnPlatform 4*, Android=3*}" />
<RowDefinition Height="{OnPlatform *, Android=.5*}" />
</Grid.RowDefinitions>
<Label
Text="Bets"
Style="{OnPlatform {StaticResource labelSectionTitleStyle},
Android={StaticResource androidLabelSectionTitleStyle}}" />
<VerticalStackLayout
x:Name="betControls"
Grid.Row="1">
<CollectionView ItemsSource="{Binding Players}">
<CollectionView.ItemTemplate>
<DataTemplate>
<controls:PlayerBet />
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</VerticalStackLayout>
<Button
Grid.Row="2"
Text="Go"
Margin="0, 0, 0, 5"
IsEnabled="{Binding AllPlayersAreValid}"
Command="{Binding StartRaceCommand}" />
</Grid>
</ContentView>
You can see the Command
property on the button. We’re going to implement the StartRace
method in the view model in a moment.
The PlayerBet
control looks a bit complex:
<?xml version="1.0" encoding="utf-8" ?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
xmlns:viewmodels="clr-namespace:Slugrace.ViewModels"
xmlns:behaviors="clr-namespace:Slugrace.Behaviors"
xmlns:converters="clr-namespace:Slugrace.Converters"
x:DataType="viewmodels:PlayerViewModel"
IsVisible="{Binding IsInGame}"
x:Class="Slugrace.Controls.PlayerBet">
<ContentView.Resources>
<Color x:Key="backgroundColorLight">#FFF4E5</Color>
<Color x:Key="backgroundColorDark">Black</Color>
<Style TargetType="Label"
BasedOn="{OnPlatform {StaticResource labelBaseStyle},
Android={StaticResource androidLabelBaseStyle}}" />
<converters:ZeroToEmptyStringConverter x:Key="zeroToEmptyConverter" />
<converters:SelectedSlugToBoolConverter x:Key="slugToBoolConverter" />
<toolkit:InvertedBoolConverter x:Key="invertedBoolConverter" />
</ContentView.Resources>
<Grid>
<Grid
IsVisible="{Binding IsBankrupt, Converter={StaticResource invertedBoolConverter}}"
BackgroundColor="{OnPlatform
Android={AppThemeBinding
Light={StaticResource backgroundColorLight},
Dark={StaticResource backgroundColorDark}}}"
Margin="0, 0, 0, 2">
<Grid.RowDefinitions>
<RowDefinition Height="{OnPlatform *, Android=*}" />
<RowDefinition Height="{OnPlatform 0, Android=*}" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="{OnPlatform 150, Android=80}" />
<ColumnDefinition Width="{OnPlatform .3*, Android=.8*}" />
<ColumnDefinition Width="{OnPlatform .1*, Android=.15*}" />
<ColumnDefinition Width="{OnPlatform 1.5*, Android=1.4*}" />
<ColumnDefinition Width="{OnPlatform 1.5*, Android=1.9*}" />
<ColumnDefinition Width="{OnPlatform .3*, Android=.5*}" />
<ColumnDefinition Width="{OnPlatform 4*, Android=0}" />
</Grid.ColumnDefinitions>
<Label
Text="{Binding Name}" />
<Label
Grid.Column="1"
Text="bets" />
<Label
Grid.Column="2"
Text="$" />
<Entry
x:Name="betAmountEntry"
Grid.Column="3"
WidthRequest="{OnPlatform 200, Android=80}"
Placeholder="{Binding CurrentMoney, StringFormat='1 - {0}'}"
BackgroundColor="{OnPlatform
Android=Transparent}"
TextColor="{AppThemeBinding Dark=White}"
Keyboard="Numeric"
Text="{Binding BetAmount, Converter={StaticResource zeroToEmptyConverter}}"
TextChanged="OnBetAmountTextChanged">
<Entry.Behaviors>
<behaviors:NumericInputBehavior />
</Entry.Behaviors>
</Entry>
<Slider
Grid.Column="4"
Margin="0, 0, 30, 0"
Value="{Binding BetAmount}"
Minimum="0"
Maximum="{Binding CurrentMoney}"/>
<Label
Grid.Column="5"
Text="on" />
<Grid
Grid.Row="{OnPlatform 0, Android=1}"
Grid.Column="{OnPlatform 6, Android=0}"
Grid.ColumnSpan="{OnPlatform Android=6}"
RowDefinitions="*"
ColumnDefinitions="*, *, *, *">
<RadioButton
IsChecked="{Binding SelectedSlug, Mode=OneWay,
Converter={StaticResource slugToBoolConverter},
ConverterParameter=Speedster}"
GroupName="{Binding Name}">
<RadioButton.Content>
<Label
Text="Speedster"
Style="{OnPlatform {StaticResource labelBaseStyle},
Android={StaticResource androidLabelBaseStyle}}">
<Label.Margin>
<OnPlatform x:TypeArguments="Thickness">
<On Platform="WinUI" Value="0" />
<On Platform="Android" Value="-12, 0, 0, 0" />
</OnPlatform>
</Label.Margin>
</Label>
</RadioButton.Content>
<RadioButton.GestureRecognizers>
<TapGestureRecognizer Command="{Binding SelectSlugCommand}"
CommandParameter="Speedster" />
</RadioButton.GestureRecognizers>
</RadioButton>
<RadioButton
Grid.Column="1"
IsChecked="{Binding SelectedSlug, Mode=OneWay,
Converter={StaticResource slugToBoolConverter},
ConverterParameter=Trusty}"
GroupName="{Binding Name}">
<RadioButton.Content>
<Label
Text="Trusty"
Style="{OnPlatform {StaticResource labelBaseStyle},
Android={StaticResource androidLabelBaseStyle}}">
<Label.Margin>
<OnPlatform x:TypeArguments="Thickness">
<On Platform="WinUI" Value="0" />
<On Platform="Android" Value="-12, 0, 0, 0" />
</OnPlatform>
</Label.Margin>
</Label>
</RadioButton.Content>
<RadioButton.GestureRecognizers>
<TapGestureRecognizer Command="{Binding SelectSlugCommand}"
CommandParameter="Trusty" />
</RadioButton.GestureRecognizers>
</RadioButton>
<RadioButton
Grid.Column="2"
IsChecked="{Binding SelectedSlug, Mode=OneWay,
Converter={StaticResource slugToBoolConverter},
ConverterParameter=Iffy}"
GroupName="{Binding Name}">
<RadioButton.Content>
<Label
Text="Iffy"
Style="{OnPlatform {StaticResource labelBaseStyle},
Android={StaticResource androidLabelBaseStyle}}">
<Label.Margin>
<OnPlatform x:TypeArguments="Thickness">
<On Platform="WinUI" Value="0" />
<On Platform="Android" Value="-12, 0, 0, 0" />
</OnPlatform>
</Label.Margin>
</Label>
</RadioButton.Content>
<RadioButton.GestureRecognizers>
<TapGestureRecognizer Command="{Binding SelectSlugCommand}"
CommandParameter="Iffy" />
</RadioButton.GestureRecognizers>
</RadioButton>
<RadioButton
Grid.Column="3"
IsChecked="{Binding SelectedSlug, Mode=OneWay,
Converter={StaticResource slugToBoolConverter},
ConverterParameter=Slowpoke}"
GroupName="{Binding Name}">
<RadioButton.Content>
<Label
Text="Slowpoke"
Style="{OnPlatform {StaticResource labelBaseStyle},
Android={StaticResource androidLabelBaseStyle}}">
<Label.Margin>
<OnPlatform x:TypeArguments="Thickness">
<On Platform="WinUI" Value="0" />
<On Platform="Android" Value="-12, 0, 0, 0" />
</OnPlatform>
</Label.Margin>
</Label>
</RadioButton.Content>
<RadioButton.GestureRecognizers>
<TapGestureRecognizer Command="{Binding SelectSlugCommand}"
CommandParameter="Slowpoke" />
</RadioButton.GestureRecognizers>
</RadioButton>
</Grid>
</Grid>
<Grid
IsVisible="{Binding IsBankrupt}"
RowDefinitions="*">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="{OnPlatform 150, Android=80}" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Label
Text="{Binding Name}"
TextColor="Red"
TextDecorations="Strikethrough"
Opacity=".4" />
</Grid>
</Grid>
</ContentView>
It’s lengthy, but pretty straightforward. The entries where the bet amount is to be typed are empty at the beginning. We ensure this in the code-behind. Also in the code-behind we take care of the TextChanged
event that gets fired every time something is typed in the entries:
using Slugrace.ViewModels;
namespace Slugrace.Controls;
public partial class PlayerBet : ContentView
{
public PlayerBet()
{
InitializeComponent();
VisualStateManager.GoToState(betAmountEntry, "Empty");
}
private void OnBetAmountTextChanged(object sender, TextChangedEventArgs e)
{
if (BindingContext != null)
{
bool betAmountValid = (BindingContext as PlayerViewModel).BetAmountIsValid;
Helpers.HandleNumericEntryState(betAmountValid, betAmountEntry);
}
}
}
Now, back to the XAML file. We bind the radio buttons to the SelectedSlug
property of PlayerViewModel
using a converter we created. Add the SelectedSlugToBoolConverter
to the Converters
folder and implement it like so:
using Slugrace.ViewModels;
using System.Globalization;
namespace Slugrace.Converters;
internal class SelectedSlugToBoolConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if ((value as SlugViewModel) == null)
{
return false;
}
else
{
return (value as SlugViewModel).Name == parameter.ToString();
}
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
Now we can pass the names of the slugs as converter parameters.
As I mentioned before, the SelectSlug
method in the PlayerViewModel
is called when a radio button is checked. In order to avoid calling this method twice, the first time when a radio button is unchecked and the second time a radio button is checked, we use the TapGestureRecognizer
. This way the method is called only once, when the radio button is checked.
Next, let’s have a look at the Results
content view. It’s implemented in the same way as the Bets
view:
<?xml version="1.0" encoding="utf-8" ?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
xmlns:controls="clr-namespace:Slugrace.Controls"
xmlns:viewmodels="clr-namespace:Slugrace.ViewModels"
x:DataType="viewmodels:GameViewModel"
x:Class="Slugrace.Controls.Results">
<ContentView.Resources>
<Style TargetType="Label" BasedOn="{StaticResource labelBaseStyle}" />
<toolkit:InvertedBoolConverter x:Key="invertedBoolConverter" />
</ContentView.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="{OnPlatform *, Android=.3*}" />
<RowDefinition Height="{OnPlatform 4*, Android=3*}" />
<RowDefinition Height="{OnPlatform *, Android=.5*}" />
</Grid.RowDefinitions>
<Label
Text="Results"
Style="{OnPlatform {StaticResource labelSectionTitleStyle},
Android={StaticResource androidLabelSectionTitleStyle}}"/>
<VerticalStackLayout
Grid.Row="1"
Spacing="{OnPlatform 0, Android=15}">
<CollectionView ItemsSource="{Binding Players}">
<CollectionView.ItemTemplate>
<DataTemplate>
<controls:PlayerResult />
</DataTemplate>
</CollectionView.ItemTemplate>
</CollectionView>
</VerticalStackLayout>
<Button
Grid.Row="2"
Text="Next Race"
Margin="0, 0, 0, 5"
IsEnabled="{Binding IsShowingFinalResults,
Converter={StaticResource invertedBoolConverter}}"
Command="{Binding NextRaceCommand}" />
</Grid>
</ContentView>
The button is disabled after the last race and before the GameOverPage
appears. This is to avoid clicking on the Next Race button when there shouldn’t be a next race anymore. We’ll implement the IsShowingFinalResults
property in the GameViewModel
when we simulate a game.
A single PlayerResult
control is implemented like this:
<?xml version="1.0" encoding="utf-8" ?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:toolkit="http://schemas.microsoft.com/dotnet/2022/maui/toolkit"
xmlns:viewmodels="clr-namespace:Slugrace.ViewModels"
x:DataType="viewmodels:PlayerViewModel"
IsVisible="{Binding IsInGame}"
x:Class="Slugrace.Controls.PlayerResult">
<ContentView.Resources>
<Color x:Key="backgroundColorLight">#FFF4E5</Color>
<Color x:Key="backgroundColorDark">Black</Color>
<Style TargetType="Label"
BasedOn="{OnPlatform {StaticResource labelBaseStyle},
Android={StaticResource androidLabelBaseStyle}}" />
<toolkit:InvertedBoolConverter x:Key="invertedBoolConverter" />
</ContentView.Resources>
<Grid>
<Grid
IsVisible="{Binding IsBankrupt, Converter={StaticResource invertedBoolConverter}}"
BackgroundColor="{OnPlatform
Android={AppThemeBinding
Light={StaticResource backgroundColorLight},
Dark={StaticResource backgroundColorDark}}}"
RowSpacing="{OnPlatform 0, Android=10}"
Margin="0, 0, 0, 5">
<Grid.RowDefinitions>
<RowDefinition Height="{OnPlatform *, Android=*}" />
<RowDefinition Height="{OnPlatform 0, Android=*}" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="{OnPlatform *, Android=1.2*}" />
<ColumnDefinition Width="{OnPlatform *, Android=1.4*}" />
<ColumnDefinition Width="{OnPlatform *, Android=*}" />
<ColumnDefinition Width="{OnPlatform *, Android=1.2*}" />
<ColumnDefinition Width="{OnPlatform *, Android=0}" />
<ColumnDefinition Width="{OnPlatform *, Android=0}" />
<ColumnDefinition Width="{OnPlatform *, Android=0}" />
</Grid.ColumnDefinitions>
<Label
Text="{Binding Name}" />
<Label
Grid.Column="1"
Text="{Binding PreviousMoney, StringFormat='had ${0},'}" />
<Label
Grid.Column="2"
Text="{Binding BetAmount, StringFormat='bet ${0}'}" />
<Label
Grid.Column="3"
Text="{Binding SelectedSlug.Name, StringFormat='on {0},'}" />
<Label
Grid.Row="{OnPlatform 0, Android=1}"
Grid.Column="{OnPlatform 4, Android=0}"
Text="{Binding ResultMessage, StringFormat='{0},'}" />
<Label
Grid.Row="{OnPlatform 0, Android=1}"
Grid.Column="{OnPlatform 5, Android=1}"
Text="{OnPlatform {Binding CurrentMoney, StringFormat='now has ${0}.'},
Android={Binding CurrentMoney, StringFormat='has ${0}.'}}">
</Label>
<Label
Grid.Row="{OnPlatform 0, Android=2}"
Grid.Column="{OnPlatform 6, Android=2}"
Grid.ColumnSpan="{OnPlatform 1, Android=2}"
Text="{Binding SelectedSlug.PreviousOdds, StringFormat='The odds were {0:F2}'}" />
</Grid>
<Grid
IsVisible="{Binding IsBankrupt}"
RowDefinitions="*">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="{OnPlatform *, Android=1.2*}" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Label
Text="{Binding Name}"
TextColor="Red"
TextDecorations="Strikethrough"
Opacity=".4" />
</Grid>
</Grid>
</ContentView>
I think this code is pretty straightforward. Now that we have most of the pieces of the puzzle, let’s start a game simulation.
Game Simulation
Before we discuss animation, we can only simulate the races. So, for now, the winners will be picked randomly right after a race begins. In the final version each race will be animated and it will take some time to complete.
The game begins when all players have placed valid bets on the slugs and the Go button is pressed. This is when the first race begins. Depending on which ending condition was chosen, there may be a different number of races. Anyway, the Go button in the Bets
content view binds to the StartRace
command in the GameViewModel
.
Let’s add this method to GameViewModel
:
...
public partial class GameViewModel : ObservableObject
{
...
[RelayCommand]
async Task StartRace()
{
// Start race
RaceStatus = RaceStatus.Started;
if (RaceNumber == 1 && GameEndingCondition == EndingCondition.Time)
{
gameTimer.Start();
}
await RunRace();
}
...
}
This method just sets the RaceStatus
to Started
and calls the RunRace
method. Additionally, if we chose the Time
ending condition, it starts the gameTimer
before the first race. Speaking of which…
Yes, we need a timer to count down the time of the game. When the time is up, the game will be over. We’ll add a timer of the type IDispatcherTimer
like so:
...
public partial class GameViewModel : ObservableObject
{
...
readonly IDispatcherTimer gameTimer;
...
public GameViewModel()
{
gameTimer = Application.Current.Dispatcher.CreateTimer();
gameTimer.Interval = TimeSpan.FromSeconds(1);
gameTimer.Tick += async (sender, e) =>
{
if (TimeRemaining > TimeSpan.Zero)
{
TimeElapsed += TimeSpan.FromSeconds(1);
}
if (TimeRemaining == TimeSpan.Zero && RaceStatus == RaceStatus.Finished)
{
await CheckForGameOver();
}
};
...
WeakReferenceMessenger.Default.Register<PlayerBetAmountChangedMessage>(this, (r, m) =>
OnBetAmountChangedMessageReceived(m.Value));
...
}
...
[RelayCommand]
async Task StartRace()
...
}
The timer object is created in the constructor. The Tick
event will fire every second (we set the Interval
property to this amount of time) and will increase the TimeElapsed
property, which will automatically decrease the TimeRemaining
property.
When the time is up, so when the TimeRemaining
property equals zero and the RaceStatus
is Finished
, the CheckForGameOver
method is called. We’ll have a look at this method in a moment. For now, let’s see what happens next.
Next, the RunRace
method is called. Here it is:
...
public partial class GameViewModel : ObservableObject
{
...
readonly IDispatcherTimer gameTimer;
...
async Task StartRace()
...
private async Task RunRace()
{
// random winner slug
var random = new Random();
RaceWinnerSlug = Slugs[random.Next(Slugs.Count)];
RaceWinnerSlug.IsRaceWinner = true;
HandleSlugsAfterRace();
HandlePlayersAfterRace();
await FinishRace();
}
}
In this method, naturally only for now, a random slug is selected to be the winner. The IsRaceWinner
property of the RaceWinnerSlug
property is set to true
. Then three methods are called to handle the slugs and the players after the race and to finish the race. Let’s have a look at how they are implemented:
...
public partial class GameViewModel : ObservableObject
{
...
private async Task RunRace()
...
private void HandleSlugsAfterRace()
{
foreach (var slug in Slugs)
{
if (slug != RaceWinnerSlug)
{
slug.IsRaceWinner = false;
}
slug.RecalculateStats(RaceNumber);
}
}
private void HandlePlayersAfterRace()
{
foreach (var player in Players)
{
player.CalculateMoney(RaceWinnerSlug);
if (player.CurrentMoney == 0)
{
player.IsBankrupt = true;
}
PlayersStillInGame = PlayersStillInGame.Where(p => !p.IsBankrupt).ToObservableCollection();
}
}
private async Task FinishRace()
{
RaceStatus = RaceStatus.Finished;
await CheckForGameOver();
}
}
The slug stats are recalculated. The players’ money is calculated and if all money is lost, the IsBankrupt
property is set to true
. The ObservableCollection
of PlayersStillInGame
is populated with just the players who did not go bankrupt.
In the FinishRace
method, the RaceStatus
is set to Finished
. When this happens, the WinnerInfo
content view is displayed. Here’s the WinnerInfo
view:
<?xml version="1.0" encoding="utf-8" ?>
<ContentView ...>
<ContentView.Resources>
...
<toolkit:EnumToBoolConverter x:Key="raceStatusConverter" />
</ContentView.Resources>
<Grid
IsVisible="{Binding RaceStatus,
Converter={StaticResource raceStatusConverter},
ConverterParameter={x:Static local:RaceStatus.Finished}}"
RowDefinitions=".6*, *, 3*">
<Label
Text="The winner is"
FontSize="{OnPlatform 28, Android=10}" />
<Label
Grid.Row="1"
Text="{Binding RaceWinnerSlug.Name}"
FontSize="{OnPlatform 36, Android=12}" />
<Image
Grid.Row="2"
Source="{Binding RaceWinnerSlug.ImageUrl}" />
</Grid>
</ContentView>
Then a method is called to check whether, by any chance, the conditions are met to end the game. And these conditions will be different for each ending condition. Here’s how the CheckForGameOver
method is implemented:
...
public partial class GameViewModel : ObservableObject
{
...
private async Task CheckForGameOver(bool endedManually = false)
{
// This is for the case when the game is ended manually
if (endedManually)
{
GetWinners();
GameOverReason = "You ended the game manually.";
await EndGame();
}
// This works the same for each ending condition.
// scenario 1: there's only 1 player with money (except one-player mode) - it's the winner
else if (Players.Count > 1 && PlayersStillInGame.Count == 1)
{
Winners.Add(PlayersStillInGame[0]);
GameOverReason = "There's only one player with any money left.";
await EndGame();
}
// scenario 2: all players go bankrupt simultaneously - no winner
else if (PlayersStillInGame.Count == 0)
{
GameOverReason = Players.Count == 1 ? "You are bankrupt." : "All players are bankrupt.";
await EndGame();
}
// This works for the Races ending condition
else if (GameEndingCondition == EndingCondition.Races && RaceNumber == NumberOfRacesSet)
{
GetWinners();
GameOverReason = "The number of races you set has been reached.";
await EndGame();
}
// This works for the Time ending condition
else if (GameEndingCondition == EndingCondition.Time && TimeRemaining == TimeSpan.Zero)
{
GetWinners();
GameOverReason = "The game time you set is up.";
await EndGame();
}
}
}
Here we have a GameOverReason
property that will store the text that will be displayed in the GameOverPage
. This text will tell us why the game is over. Naturally, there may be different reasons.
The endedManually
parameter is for the case when we end the game manually by clicking the End Game button in the race screen. We’ll see to this soon.
We also have two methods, GetWinners
and EndGame
. Let’s implement the GameOverReason
property and the two methods:
...
public partial class GameViewModel : ObservableObject
{
...
[ObservableProperty]
private string gameOverReason;
...
[ObservableProperty]
private bool isShowingFinalResults;
...
private async Task CheckForGameOver(bool endedManually = false)
...
private void GetWinners()
{
int maxMoney = PlayersStillInGame.Max(p => p.CurrentMoney);
foreach (var player in PlayersStillInGame)
{
if (player.CurrentMoney == maxMoney)
{
Winners.Add(player);
}
}
}
async Task EndGame()
{
gameTimer.Stop();
IsShowingFinalResults = true;
gameOverPageDelayTimer.Start();
gameOverPageDelayTimer.Tick += async (sender, e) =>
{
gameOverPageDelayTimer.Stop();
IsShowingFinalResults = false;
// Navigate to GameOverPage
await Shell.Current.GoToAsync($"{nameof(GameOverPage)}",
new Dictionary<string, object>
{
{"Game", this }
});
};
}
}
The GetWinners
method just picks the player or players who won the most money and adds them to the Winners
list.
The EndGame
method seems more interesting. Here the gameTimer
is stopped and the IsShowingFinalResults
property is set to true
. Then another timer is started (we’ll implement it in a minute) and stopped after a couple seconds. Finally, we navigate to the GameOverPage
.
In order for the navigation to work, let’s register the route in the AppShell.xaml.cs
file:
...
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
Routing.RegisterRoute(nameof(RacePage), typeof(RacePage));
Routing.RegisterRoute(nameof(GameOverPage), typeof(GameOverPage));
}
}
For the GameOverPage
we’ll create a GameOverViewModel
. Here’s the code:
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Slugrace.Views;
using System.Text;
namespace Slugrace.ViewModels;
[QueryProperty(nameof(Game), "Game")]
public partial class GameOverViewModel : ObservableObject
{
[ObservableProperty]
private GameViewModel game;
[ObservableProperty]
private string gameOverReason;
[ObservableProperty]
private int originalNumberOfPlayers;
[ObservableProperty]
private List<PlayerViewModel> winners;
[ObservableProperty]
public string winnerInfo;
partial void OnGameChanged(GameViewModel value)
{
OriginalNumberOfPlayers = value.Players.Count;
GameOverReason = value.GameOverReason;
Winners = value.Winners;
WinnerInfo = Winners.Count switch
{
0 => OriginalNumberOfPlayers == 1
? "There are no winners in 1-player mode."
: "There is no winner!",
1 => OriginalNumberOfPlayers == 1
? $"You were playing in 1-player mode.\n"
+ $"You started with ${Winners[0].InitialMoney}, "
+ $"and you're leaving with ${Winners[0].CurrentMoney}."
: $"The winner is {Winners[0].Name}, "
+ $"having started with ${Winners[0].InitialMoney}, "
+ $"leaving with ${Winners[0].CurrentMoney}.",
_ => $"There's a tie. The joint winners are:\n\n"
+ $"{DisplayWinners()}"
};
}
private string DisplayWinners()
{
StringBuilder displayMessage = new StringBuilder();
foreach (var winner in Winners)
{
displayMessage.Append($"{winner.Name}, "
+ $"having started with ${winner.InitialMoney}, "
+ $"leaving with ${winner.CurrentMoney}.\n");
}
return displayMessage.ToString();
}
[RelayCommand]
async Task RestartGame()
{
// Navigate to SettingsPage
await Shell.Current.GoToAsync($"//{nameof(SettingsPage)}");
}
}
This is the page we’re navigating to from the RacePage
. We pass the GameViewModel
object, so we need to add the QueryProperty
attribute, just like before. As the GameViewModel
object is not available in the constructor, we’re using it in the OnGameChanged
method. We also define the DisplayWinners
method and the RestartGame
method that will be bound to from the Play Again button.
We set the binding context in the code-behind file of the GameOverPage
:
...
public partial class GameOverPage : ContentPage
{
public GameOverPage(GameOverViewModel gameOverViewModel)
{
InitializeComponent();
BindingContext = gameOverViewModel;
}
}
The GameOverPage
looks like so:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
xmlns:viewmodels="clr-namespace:Slugrace.ViewModels"
x:DataType="viewmodels:GameOverViewModel"
x:Class="Slugrace.Views.GameOverPage">
<Shell.BackButtonBehavior>
<BackButtonBehavior IsEnabled="False" IsVisible="False" />
</Shell.BackButtonBehavior>
<ContentPage.Resources>
<Style TargetType="Label">
<Setter Property="FontAttributes"
Value="{StaticResource emphasized}" />
<Setter Property="HorizontalOptions"
Value="{StaticResource defaultLayoutOptions}" />
<Setter Property="FontSize"
Value="{OnPlatform 40, Android=30}" />
</Style>
</ContentPage.Resources>
<Grid
RowDefinitions="1.5*, *, 2.5*, *">
<Grid.Margin>
<OnPlatform x:TypeArguments="Thickness">
<On Platform="WinUI" Value="0" />
<On Platform="Android" Value="20" />
</OnPlatform>
</Grid.Margin>
<Label
Text="Game Over"
FontSize="{OnPlatform 120, Android=65}" />
<Label
Grid.Row="1"
Text="{Binding GameOverReason}" />
<Label
Grid.Row="2"
Text="{Binding WinnerInfo}"
HorizontalTextAlignment="Center"
FontSize="30"/>
<FlexLayout
Grid.Row="3"
JustifyContent="SpaceEvenly">
<Button
Text="Play Again"
Command="{Binding RestartGameCommand}"/>
<Button
Text="Quit" />
</FlexLayout>
</Grid>
</ContentPage>
Let’s register the the GameOverPage
and the GameOverViewModel
with the dependency service in the MauiProgram.cs
file:
...
public static class MauiProgram
{
...
builder.Services.AddTransient<RacePage>();
builder.Services.AddTransient<GameViewModel>();
builder.Services.AddTransient<GameOverPage>();
builder.Services.AddTransient<GameOverViewModel>();
return builder.Build();
}
}
About the other timer. It’s used to delay the navigation to the GameOverPage
so that you can view the results of the last race. Without it, you wouldn’t be able to see the results because the GameOverPage
wound be navigated to immediately.
Fine, let’s implement the timer then:
...
public partial class GameViewModel : ObservableObject
{
...
readonly IDispatcherTimer gameTimer;
readonly IDispatcherTimer gameOverPageDelayTimer;
...
public GameViewModel()
{
...
gameOverPageDelayTimer = Application.Current.Dispatcher.CreateTimer();
gameOverPageDelayTimer.Interval = TimeSpan.FromSeconds(3);
WeakReferenceMessenger.Default.Register<PlayerBetAmountChangedMessage>(this, (r, m) =>
OnBetAmountChangedMessageReceived(m.Value));
...
}
...
OK, we know what happens if the ending conditions are met. But if they’re not, we can click the Next Race button in the Results
view to call the NextRace
method in GameViewModel
. Here it is:
...
public partial class GameViewModel : ObservableObject
{
...
[RelayCommand]
void NextRace()
{
RaceStatus = RaceStatus.NotYetStarted;
RaceNumber++;
foreach (var player in Players)
{
player.BetAmount = 0;
player.SelectedSlug = null;
}
}
}
Here the race number is increased, the race status is reset to NotYetStarted
and each player is reset.
Let’s now run the app and see if everything works as expected. First on Windows, then on Android. Let’s say we’ll have three players and the game should end not later than after 1 minute. Here’s the SettingsPage
:
If you now hit the Ready button, you’ll navigate to the RacePage
:
The End Game and Instructions buttons are disabled because they’re only enabled when the RaceStatus
is Finished
. The Go button is disabled because we have to set the bet amount for each player and select the slugs, for example like this:
If we now hit the Go button, we’ll immediately see the slug that won and the stats will be updated. Also, the timer will start counting down. We can run as many races as we can within one minute:
When the time is up, we automatically navigate to the GameOverPage
and see the results:
If we now click the Play Again button, we’ll navigate to the SettingsPage
and all the settings there will be exactly the same as before. This is good if you want to save some time. Usually the same people are going to play again, so they may want to keep their names and other settings. But it’s totally up to you. If you want, you can change the settings.
Try it out with other numbers of players and ending conditions. It should work fine.
And now let’s run the app on Android. Here’s the SettingsPage
with the same settings as before:
Let’s hit the Ready button:
And now let’s place some bets:
Let’s press the Go button. Now we have one minute to play. Here’s what we can expect to see during the game:
The GameOverPage
looks like this:
Our game simulation works fine. Before we implement the actual game flow, though, let’s take care of the End Game and Instructions buttons in RacePage
.
Ending the Game Manually
Sometimes you may want to end the game earlier than set at the beginning. All you have to do is hit the End Game button in the RacePage
.
Let’s create an EndGameManually
method in the GameViewModel
:
...
public partial class GameViewModel : ObservableObject
{
...
[ObservableProperty]
private bool gameEndedManually;
...
[RelayCommand]
async Task EndGameManually()
{
await CheckForGameOver(true);
}
...
}
This method just calls the CheckForGameOver
method with the endedManually
argument set to true
. Now we have to bind the Command
parameter of the button in RacePage
to this method:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage ...>
...
<!--the buttons-->
<VerticalStackLayout
...
<Button
Text="End Game"
Command="{Binding EndGameManuallyCommand}"
IsEnabled="{Binding RaceStatus,
Converter={StaticResource raceStatusConverter},
ConverterParameter={x:Static local:RaceStatus.Finished}}" />
<Button
...
The button will be enabled after each race, so there must be at least one race. Now if we run the app and press the button, the game will end before the ending condition we decided on is actually met.
Finally, let’s handle the Instructions button.
InstructionsPage
We haven’t created the InstructionsPage
yet. Let’s create a very basic one for now so that we can navigate to it. Add a new class to the Views
folder, name it InstructionsPage
, and implement it like so:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Slugrace.Views.InstructionsPage"
xmlns:viewmodels="clr-namespace:Slugrace.ViewModels"
x:DataType="viewmodels:GameViewModel">
<Shell.BackButtonBehavior>
<BackButtonBehavior IsEnabled="False" IsVisible="False" />
</Shell.BackButtonBehavior>
<VerticalStackLayout>
<Label
Text="INSTRUCTIONS"
VerticalOptions="Center"
HorizontalOptions="Center" />
<Button
Text="Back"
Command="{Binding NavigateBackCommand}" />
</VerticalStackLayout>
</ContentPage>
In the code-behind let’s set the binding context to the GameViewModel
:
using Slugrace.ViewModels;
namespace Slugrace.Views;
public partial class InstructionsPage : ContentPage
{
public InstructionsPage(GameViewModel gameViewModel)
{
InitializeComponent();
BindingContext = gameViewModel;
}
}
Let’s also register the page with the dependency service in MauiProgram.cs
:
...
public static class MauiProgram
{
...
builder.Services.AddTransient<GameOverViewModel>();
builder.Services.AddTransient<InstructionsPage>();
return builder.Build();
...
In the GameViewModel
, let’s define two methods, one for navigating to the InstructionsPage
and one for navigating back:
...
public partial class GameViewModel : ObservableObject
{
...
[RelayCommand]
async Task SeeInstructions()
{
// Navigate to InstructionsPage
await Shell.Current.GoToAsync($"{nameof(InstructionsPage)}");
}
[RelayCommand]
async Task NavigateBack()
{
await Shell.Current.GoToAsync("..");
}
}
We mustn’t forget to add the route in the AppShell.xaml.cs
file:
using Slugrace.Views;
namespace Slugrace;
public partial class AppShell : Shell
{
public AppShell()
{
InitializeComponent();
Routing.RegisterRoute(nameof(RacePage), typeof(RacePage));
Routing.RegisterRoute(nameof(GameOverPage), typeof(GameOverPage));
Routing.RegisterRoute(nameof(InstructionsPage), typeof(InstructionsPage));
}
}
The last piece of the puzzle is to bind the Instructions button in RacePage
to the SeeInstructions
method:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage ...>
...
<!--the buttons-->
...
<Button
Text="Instructions"
IsEnabled="{Binding RaceStatus,
Converter={StaticResource raceStatusConverter},
ConverterParameter={x:Static local:RaceStatus.Finished}}"
Command="{Binding SeeInstructionsCommand}" />
<Button
...
The button will be enabled after each race. Let’s run the app and hit the Instructions button. This will take us to the new page:
If we click the Back button, we’ll go back to RacePage
.
So, our game simulation works. But it’s just a simulation. In the real game the slugs will be animated. In order to implement this, we need to learn about animations in .NET MAUI. This is the topic of the next part of the series.