You may sometimes want to show a popup message to the user. This can be just a simple message, or you can add a button or two to let the user do something, like confirm, cancel, etc.
In our app a popup will be displayed when the user clicks the Quit button in the GameOverPage
. It will ask the user if they are sure they want to quit the game. There will be two buttons. One of them will be used to cancel and close the popup. The other will actually quit the app.
We’re also going to show a popup when an accident happens. We haven’t implemented accidents yet, so we’ll take care of it first. Then we’ll implement the popups.
There are a couple ways to create a popup. We’ll be using the Community Toolkit Popup
class. As our popups are going to be rather simple, we won’t create view models for them. Instead we’ll implement the logic in the code-behind.
So, let’s start with the first popup.
Table of Contents
Quit Popup
We’re going to create a couple popups in our app, so let’s put them in a folder. Create a new folder in the app root and name it Popups. In it, create a new ContentView
and name it QuitPopup
. Here’s the code:
<?xml version="1.0" encoding="utf-8" ?>
<toolkit:Popup 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"
x:Class="Slugrace.Popups.QuitPopup"
CanBeDismissedByTappingOutsideOfPopup="False"
Color="Coral">
<VerticalStackLayout
HorizontalOptions="Center"
VerticalOptions="Center">
<Label
Text="Are you sure you want to quit?"
Margin="5, 20, 5, 40"
FontSize="30"
VerticalOptions="Center"
HorizontalOptions="Center" />
<FlexLayout
Direction="Column"
JustifyContent="Center"
AlignItems="Center">
<Button
Text="No, I was wrong. Cancel."
WidthRequest="300"
Margin="20"
Clicked="CancelButtonClicked"/>
<Button
Text="Yes, I'm sure. Quit."
WidthRequest="300"
Margin="20"
Clicked="QuitButtonClicked"/>
</FlexLayout>
</VerticalStackLayout>
</toolkit:Popup>
So, first of all, we changed the type from ContentView
to toolkit:Popup
.
We also set the CanBeDismissedByTappingOutsideOfPopup
property to false. This way we won’t be able to close the popup by clicking anywhere outside it. We want to close it only when a button is clicked.
Speaking of which… There are two buttons. We define the Clicked
event for each of them. Let’s now have a look at the code-behind to see what should happen when the buttons are clicked:
using CommunityToolkit.Maui.Views;
namespace Slugrace.Popups;
public partial class QuitPopup : Popup
{
public QuitPopup()
{
InitializeComponent();
}
private async void CancelButtonClicked(object sender, EventArgs e)
{
await CloseAsync();
}
private void QuitButtonClicked(object sender, EventArgs e)
{
Application.Current.Quit();
}
}
As you can see, the class now inherits from Popup
. The first button just closes the popup. The second button quits the app.
Our popup is ready to use. We now have to make the Quit button in the GameOverPage
open it:
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage ...
x:Class="Slugrace.Views.GameOverPage">
...
<Button
Text="Play Again"
...
<Button
Text="Quit"
Clicked="QuitButtonClicked"/>
</FlexLayout>
...
And here’s the code-behind:
using CommunityToolkit.Maui.Views;
using Slugrace.Popups;
using Slugrace.ViewModels;
namespace Slugrace.Views;
public partial class GameOverPage : ContentPage
{
public GameOverPage(GameOverViewModel gameOverViewModel)
...
private void QuitButtonClicked(object sender, EventArgs e)
{
this.ShowPopup(new QuitPopup());
}
}
This is how we display a popup. Let’s run the app, finish one race and hit the End Game button to navigate to the GameOverPage
. Now let’s click the Quit button. We should see the popup:
This is a modal popup. If you now click the first button, it will close the popup and you’ll see the GameOverPage
again. If you click the second button, you will actually quit the app.
Let’s now run the app on Android. Here it looks like so:
We’re done with this one. Now let’s move on to implementing accidents.
Accidents
Accidents happen. Also to slugs. During a race an accident may happen to one of the slugs. It’s not going to be very frequent, but still, it may influence the result of the race when it happens. I’m using the name ‘accident’ here to keep things simple, but some of them may be convenient for a slug and even help him win.
Anyway, whatever happens, it will only have consequences in the race in which it happens. In the next race the affected slug will be up and running again.
Each accident will have a graphical representation, which you can see below. Later, will also add sound effects to the accidents.
So, here are the accidents that may happen to the slugs:
Broken Leg
If this accident happens, the slug stops moving and doesn’t even make it to the finish line. This race is lost.
Overheating
Overheating means stopping and not being able to continue the race either.
Heart Attack
If a slug suffers from a heart attack, it’s definitely not convenient either. The race is over for that slug. He needs a rest. His heart is beating like crazy, which you can see and hear. You will see a small heart image on top of the affected slug.
Power Grass
This is the first accident that may be very convenient actually. First, some magic grass appears on the racetrack, just in front of the slug, and he stops to eat it, which takes a while. But the grass powers him up and then he starts racing much faster than before.
Falling Asleep
Falling asleep during a race is never a good idea. The slug stops and starts breathing slowly. You can hear him snore and see some changes in his size when the sleeping animation is played.
Going Blind
If a slug goes blind, he doesn’t stop running, but without his eyes he starts staggering, so winning the race becomes pretty difficult.
Drowning in a Puddle
Drowning sucks. When this accident happens, a puddle of water appears on the racetrack and when the slug enters it, he drown. The slug becomes less and less visible under the water until he disappears completely.
Electroshock
An electroshock is good, at least for a slug. Not only doesn’t it kill him, but it even speeds him up considerably, which often results in a vistory. You’ll see a pulsating bolt on the slug’s back.
Turning Back
Sometimes a slug forgets something and turns back. By doing so he looses his chances of winning.
Slug Monster
Finally, a slug may be eaten by the horrifying slug monster. Being devoured is never good.
So, these are all the accidents that may happen in the game. Now we’ll implement some classes that we will need for the accidents.
Assets
As you can see, we’ll need some graphical assets for the accidents. You can grab them from the Github repository. Create an Accidents folder inside the Images folder and add them there:
Most of the images will be shared by all the slugs. The broken leg images, however, will be different for each slug.
And now let’s start coding the accidents.
Accident Model Class
Let’s start by adding a new class to the Models folder and naming it Accident
. Here’s the code:
namespace Slugrace.Models;
public enum AccidentType
{
BrokenLeg,
Overheat,
HeartAttack,
Grass,
Asleep,
Blind,
Puddle,
Electroshock,
TurningBack,
Devoured
}
class Accident
{
public AccidentType Type { get; set; }
public string Name { get; set; }
public string Headline { get; set; }
public string Sound { get; set; }
public uint TimePosition { get; set; }
}
We also defined an enumaration that will be used by this class. There are ten accident types that may happen. The Name
property will be used to set a name for each accident. The Headline
property will be set to a succinct information about the accident. The Sound
property will be set to the path to the sound file associated with a particular accident. The TimePosition
property will store information about the time when the accident is supposed to happen, counting from the race start.
Next, let’s create a view model.
AccidentViewModel
Let’s create a new file in the ViewModels folder and name it AccidentViewModel
. Here’s the code:
using CommunityToolkit.Mvvm.ComponentModel;
using Slugrace.Models;
namespace Slugrace.ViewModels;
public partial class AccidentViewModel : ObservableObject
{
private Accident accident;
private readonly Dictionary<AccidentType, string> AccidentNames = new()
{
{ AccidentType.BrokenLeg, "Broken Leg" },
{ AccidentType.Overheat, "Overheat" },
{ AccidentType.HeartAttack, "Heart Attack" },
{ AccidentType.Grass, "Grass" },
{ AccidentType.Asleep, "Asleep" },
{ AccidentType.Blind, "Blind" },
{ AccidentType.Puddle, "Puddle" },
{ AccidentType.Electroshock, "Electroshock" },
{ AccidentType.TurningBack, "Turning Back" },
{ AccidentType.Devoured, "Devoured" }
};
private readonly Dictionary<AccidentType, string[]> Headlines = new()
{
{ AccidentType.BrokenLeg, [
"just broke his leg and is grounded!",
"broke his leg, which is practically all he consists of!",
"suffered from an open fracture. All he can do now is watch the others win!",
"broke his only leg and now looks pretty helpless!",
"tripped over a root and broke his leg!"
] },
{ AccidentType.Overheat, [
"has been running faster than he should have. He burned of overheat!",
"burned by friction. Needs to cool down a bit before the next race!",
"roasted on the track from overheat. He's been running way too fast!",
"looks like he has been running faster than his body cooling system can handle!",
"shouldn't have been speeding like that. Overheating can be dangerous!"
] },
{ AccidentType.HeartAttack, [
"had a heart attack. Definitely needs a rest!",
"has a poor heart condition. Hadn't he stopped now, it could have killed him!",
"beaten by cardiac infarction. He'd better go to hospital asap!",
"almost killed by heart attack. He had a really narrow escape!",
"beaten by his weak heart. He'd better get some rest!"
] },
{ AccidentType.Grass, [
"just found magic grass. It's famous for powering slugs up!",
"just about to speed up after eating magic grass!",
"powered up by magic grass found unexpectedly on the track!",
"seems to be full of beans after having eaten the magic grass on his way!",
"heading perhaps even for victory after his magic grass meal!"
] },
{ AccidentType.Asleep, [
"just fell asleep for a while after the long and wearisome running!",
"having a nap. He again has chosen just the perfect time for that!",
"sleeping instead of running. It's getting one of his bad habits!",
"always takes a short nap at this time of the day, no matter what he's doing!",
"knows how important sleep is. Even if it's not the best time for that!"
] },
{ AccidentType.Blind, [
"gone blind. Now staggering to find his way!",
"shouldn't have been reading in dark. Now it's hard to find the way!",
"temporarily lost his eyesight. Now it's difficult for him to follow the track!",
"trying hard to find his way after going blind on track!",
"staggering to finish the race after going blind because of an infection!"
] },
{ AccidentType.Puddle, [
"drowning in a puddle of water!",
"beaten by yesterday's heavy rainfalls. Just drowning in a puddle!",
"shouldn't have skipped his swimming lessons. Drowning in a puddle now!",
"has always neglected his swimming lessons. How wrong he’s been!",
"disappearing in a puddle of water formed afted heavy rainfall!"
] },
{ AccidentType.Electroshock, [
"speeding up after being struck by lightning!",
"powered up by lightning. Now running really fast!",
"hit by electric discharge. Seems to have been powered up by it!",
"accelerated by a series of electric discharges!",
"now running much faster after being struck by lightning!"
] },
{ AccidentType.TurningBack, [
"has forgotten to turn off the gas. Must hurry home before it's too late!",
"just received a phone call. His house is on fire. No time to lose!",
"seems to have more interesting stuff to do than racing.",
"seems to have lost orientation. Well, how these little brains work!",
"has left his snack in the kitchen. He won't race when he's hungry!"
] },
{ AccidentType.Devoured, [
"devoured by the infamous slug monster. Bad luck!",
"just swallowed by the terrible slug monster!",
"next on the long list of the slug monster's victims!",
"has never suspected he's gonna end up as a snack!",
"devoured by the legendary slug monster from the nearby swamps!"
] }
};
private readonly Dictionary<AccidentType, string> Sounds = new()
{
{ AccidentType.BrokenLeg, "Broken Leg.mp3" },
{ AccidentType.Overheat, "Overheat.mp3" },
{ AccidentType.HeartAttack, "Heart Attack.mp3" },
{ AccidentType.Grass, "Grass.mp3" },
{ AccidentType.Asleep, "Asleep.mp3" },
{ AccidentType.Blind, "Blind.mp3" },
{ AccidentType.Puddle, "Drown.mp3" },
{ AccidentType.Electroshock, "Electroshock.mp3" },
{ AccidentType.TurningBack, "Turning Back.mp3" },
{ AccidentType.Devoured, "Devoured.mp3" }
};
private readonly Dictionary<AccidentType, uint> AccidentDurations = new()
{
{ AccidentType.BrokenLeg, 0 },
{ AccidentType.Overheat, 0 },
{ AccidentType.HeartAttack, 0 },
{ AccidentType.Grass, 2000 },
{ AccidentType.Asleep, 0 },
{ AccidentType.Blind, 10000 },
{ AccidentType.Puddle, 0 },
{ AccidentType.Electroshock, 2000 },
{ AccidentType.TurningBack, 0 },
{ AccidentType.Devoured, 0 }
};
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Name))]
[NotifyPropertyChangedFor(nameof(Headline))]
[NotifyPropertyChangedFor(nameof(Sound))]
private AccidentType accidentType;
public string Name => AccidentNames[AccidentType];
public string Headline
{
get
{
var availableHeadlines = Headlines[AccidentType];
return availableHeadlines[new Random().Next(0, availableHeadlines.Length)];
}
}
public string Sound => Sounds[AccidentType];
public uint TimePosition
{
get => accident.TimePosition;
set
{
if (accident.TimePosition != value)
{
accident.TimePosition = value;
OnPropertyChanged();
}
}
}
public uint Duration => AccidentDurations[AccidentType];
[ObservableProperty]
private SlugViewModel affectedSlug;
public bool Expected => new Random().Next(0, 4) == 0;
public AccidentViewModel(AccidentType accidentType)
{
accident = new Accident();
AccidentType = accidentType;
}
}
In the constructor we create a new instance of Accident
of a specified type. We use a couple dictionaries with the accident type as the key. They contain accident names, headlines, sounds and accident durations. Then we can easily define the properties. These are readonly properties where a value from a dictionary is returned by key. The Headline
property is set to a random string from the Headlines
dictionary, so that we don’t see the same message over and over again.
An interesting property is Expected
. It returns a boolean value by comparing a random integer between 0 and 3 to 0. If it’s true, an accident is expected to happen. Otherwise, no accident will happen in a given race.
We also define a couple properties that are not readonly. One of them is TimePosition
. Another is AffectedSlug
. The latter will store a SlugViewModel
instance representing the slug to which the accident is going to happen.
We’re going to use the AccidentViewModel
in the GameViewModel
to control accidents. Let’s do it next.
Controlling Accidents
We’ll need access to AccidentViewModel
and some other stuff in the GameViewModel
, so let’s modify it:
...
public partial class GameViewModel : ObservableObject
{
SoundViewModel soundViewModel;
private readonly IPopupService popupService;
public AccidentViewModel AccidentViewModel;
[ObservableProperty]
private Game game;
...
public int RaceNumber
...
[ObservableProperty]
private uint raceTime;
[ObservableProperty]
private uint minTime;
[ObservableProperty]
private uint finishTime;
[ObservableProperty]
private uint secondTime;
[ObservableProperty]
private bool isShowingFinalResults;
...
private bool muted;
[ObservableProperty]
private bool accidentShouldHappen;
[ObservableProperty]
private IAudioPlayer accidentSoundPlayer;
[ObservableProperty]
private uint afterAccidentTime = 0;
public GameViewModel(SoundViewModel soundViewModel, IPopupService popupService)
{
...
this.soundViewModel = soundViewModel;
this.popupService = popupService;
...
}
...
}
Here we added a couple properties we’re going to need. Some of them are related to different times associated with accidents. We also have properties that will be used for accident sounds and popups. We’ll create a special popup to be used with accidents in a moment.
We’ll discuss all these properties in more detail when we need them.
Let’s have a look at the StartRace
method first. I doubled the slugs’ running times to make them run slower. Now it’s time to implement the accident logic:
...
public partial class GameViewModel : ObservableObject
{
...
async Task StartRace()
{
Slugs[0].RunningTime = (uint)new Random().Next(6000, 14000);
Slugs[1].RunningTime = (uint)new Random().Next(8000, 14000);
Slugs[2].RunningTime = (uint)new Random().Next(8000, 14000);
Slugs[3].RunningTime = (uint)new Random().Next(10000, 16000);
// Check for accident.
bool thereIsAnAccident;
// Should there be an accident?
if (RaceNumber > 5 && AccidentViewModel.Expected)
//if (2 + 2 == 4)
{
// If so, then...
thereIsAnAccident = true;
// Which one?
AccidentType[] accidentTypes = (AccidentType[])Enum.GetValues(typeof(AccidentType));
var type = accidentTypes[new Random().Next(0, accidentTypes.Length)];
AccidentViewModel = new AccidentViewModel(type);
// Which slug should be affected?
AccidentViewModel.AffectedSlug = Slugs[new Random().Next(0, Slugs.Count)];
// When should it happen?
AccidentViewModel.TimePosition = (uint)new Random().Next((int)(AccidentViewModel.AffectedSlug.RunningTime * .2),
(int)(AccidentViewModel.AffectedSlug.RunningTime * .4));
// Modify affected slug's running time
if (AccidentViewModel.Duration > 0)
{
if (AccidentViewModel.AccidentType == AccidentType.Grass)
{
AfterAccidentTime = AccidentViewModel.AffectedSlug.RunningTime / 4;
AccidentViewModel.AffectedSlug.RunningTime = AccidentViewModel.TimePosition
+ AccidentViewModel.Duration + AfterAccidentTime;
}
if (AccidentViewModel.AccidentType == AccidentType.Electroshock)
{
AfterAccidentTime = AccidentViewModel.AffectedSlug.RunningTime / 4;
AccidentViewModel.AffectedSlug.RunningTime = AccidentViewModel.TimePosition
+ AccidentViewModel.Duration + AfterAccidentTime;
}
}
}
else
{
thereIsAnAccident = false;
}
// Set race-related times
uint[] runningTimes = [
Slugs[0].RunningTime,
Slugs[1].RunningTime,
Slugs[2].RunningTime,
Slugs[3].RunningTime
];
RaceTime = runningTimes.Max();
MinTime = runningTimes.Min();
FinishTime = (uint)(.79 * MinTime);
SecondTime = runningTimes.Order().ToArray()[1];
AccidentShouldHappen = thereIsAnAccident;
// Start race
RaceStatus = RaceStatus.Started;
if (RaceNumber == 1 && GameEndingCondition == EndingCondition.Time)
{
gameTimer.Start();
}
_ = soundViewModel.PlaySound("Game", "Go.mp3", .2);
await RunRace();
}
private async Task RunRace()
...
}
The particular parts of the code above are commented in a clear way, so that you know what they’re for.
So, first of all, we must decide whether there should be an accident at all. We don’t want any accidents to happen in the first couple races so that the user can get used to the game. After the fifth race, an accident will happen if the Expected
property is set to true
. As you remember, this property is set randomly.
If an accident is to happen, we must decide which one. This is also set randomly. And then the AccidentViewModel
object of the specified type is created.
Next, a random slug is picked to be affected by the accident.
The time when the accident should happen is also set randomly, within a certain range.
For some accidents, the running time of the affected slug is adjusted.
Then we gather all the running times of the slugs and set the RaceTime
property to the max running time. We also set the MinTime
property, the FinishTime
property and the SecondTime
property. The SecondTime
property will be needed when the fastest slug is stopped by an accident and can’t continue the race.
After all these properties are set, the RunRace
method is called. Let’s have a look at it next:
...
public partial class GameViewModel : ObservableObject
{
...
private async Task RunRace()
{
_ = soundViewModel.PlaySound("Game", "Slugs Running.mp3", .5, true);
// Modify finish time if the fastest slug has an accident.
if (AccidentViewModel.AffectedSlug.RunningTime == MinTime)
{
if (AccidentViewModel.Duration == 0)
{
FinishTime = (uint)(.79 * SecondTime);
}
else
{
uint secondFinishTime = (uint)(SecondTime * .79);
if (secondFinishTime < FinishTime)
{
FinishTime = secondFinishTime;
MinTime = SecondTime;
}
}
}
await Task.Delay((int)FinishTime);
RaceWinnerSlug = Slugs.Where(s => s.RunningTime == MinTime).FirstOrDefault();
if (AccidentShouldHappen
&& RaceWinnerSlug == AccidentViewModel.AffectedSlug
&& AccidentViewModel.Duration == 0)
{
RaceWinnerSlug = Slugs.Where(s => s.RunningTime == SecondTime).FirstOrDefault();
}
_ = soundViewModel.PlaySound("Slugs Winning", RaceWinnerSlug.WinSound);
await Task.Delay((int)(RaceTime - FinishTime));
RaceWinnerSlug.IsRaceWinner = true;
soundViewModel.Clean();
HandleSlugsAfterRace();
HandlePlayersAfterRace();
await FinishRace();
}
private void HandleSlugsAfterRace()
...
}
First, we modify the finish time if the fastest slug has an accident, and in particular, if it’s an accident where the Duration
property is set to zero. These are accidents in which the slug stops running and never reaches the finish line, so the winner is the slug with the second time.
Fine, but where are the accidents handled actually? Well, let’s see.
Handling the Accidents
The accidents will be implemented as animations, so we’ll write the code in the code-behind. Let’s start by opening the TrackImage.xaml file and making sure the layout and racetrack elements are given names so that we can reference them in code:
<?xml version="1.0" encoding="utf-8" ?>
<ContentView ...>
<AbsoluteLayout x:Name="layout">
<!--Racetrack-->
<Image
x:Name="track"
.../>
<!--Speedster-->
...
And now let’s open the code-behind. This is where the actual accident animations will be handled.
We’ll need some variables for accident handling. Let’s define and initialize them now:
...
public partial class TrackImage : ContentView
{
GameViewModel vm;
double trackLength;
Animation speedsterMovement;
Animation trustyMovement;
Animation iffyMovement;
Animation slowpokeMovement;
// accident handling
SlugImage slugImage;
string runningAnimationName;
string brokenLegImage;
string overheatBodyImage;
string overheatEyeImage;
string heartImage;
string grassImage;
string puddleImage;
string boltImage;
string monsterImage;
Image accidentImage;
Animation accidentAnimation;
public TrackImage()
{
InitializeComponent();
overheatBodyImage = "overheat_body.png";
overheatEyeImage = "overheat_eye.png";
heartImage = "heart_attack.png";
grassImage = "grass.png";
puddleImage = "puddle.png";
boltImage = "electroshock.png";
monsterImage = "slug_monster.png";
}
protected override void LayoutChildren(double x, double y, double width, double height)
...
In the constructor we assign images to the particular variables.
Next, let’s modify the Vm_PropertyChanged
method to also take accidents into account. We’ll do it by defining a method, HandleAccident
, and calling it inside Vm_PropertyChanged
whenever the AccidentShouldHappen
property in the GameViewModel
changes. Besides, we’ll make some changes in the HandleRunning
method to reset before each race the properties modified by an accident, like ZIndex
, Opacity
or ScaleX
and also remove the accident image:
...
public partial class TrackImage : ContentView
{
...
private void Vm_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(vm.RaceStatus))
{
HandleRunning();
}
else if (e.PropertyName == nameof(vm.AccidentShouldHappen))
{
HandleAccident();
}
}
private void HandleRunning()
{
if (vm.RaceStatus == RaceStatus.Started)
...
else if (vm.RaceStatus == RaceStatus.NotYetStarted)
{
speedster.TranslationX = 0;
trusty.TranslationX = 0;
iffy.TranslationX = 0;
slowpoke.TranslationX = 0;
if (layout.Contains(accidentImage))
{
if (accidentImage.ZIndex != 0)
{
accidentImage.ZIndex = 0;
}
layout.Remove(accidentImage);
}
if (accidentAnimation != null)
{
this.AbortAnimation("accidentAnimation");
}
slugImage.StartEyeRotation();
if (slugImage.ZIndex != 0)
{
slugImage.ZIndex = 0;
}
if (slugImage.Opacity != 1)
{
slugImage.Opacity = 1;
}
if (slugImage.ScaleX != 1)
{
slugImage.ScaleX = 1;
}
}
else
{
this.AbortAnimation("moveSpeedster");
this.AbortAnimation("moveTrusty");
this.AbortAnimation("moveIffy");
this.AbortAnimation("moveSlowpoke");
}
}
private async Task HandleAccident()
{
if (vm.AccidentShouldHappen)
{
// slug data
if (vm.AccidentViewModel.AffectedSlug == vm.Slugs[0])
{
slugImage = speedster;
runningAnimationName = "moveSpeedster";
brokenLegImage = "broken_leg_speedster.png";
}
else if (vm.AccidentViewModel.AffectedSlug == vm.Slugs[1])
{
slugImage = trusty;
runningAnimationName = "moveTrusty";
brokenLegImage = "broken_leg_trusty.png";
}
else if (vm.AccidentViewModel.AffectedSlug == vm.Slugs[2])
{
slugImage = iffy;
runningAnimationName = "moveIffy";
brokenLegImage = "broken_leg_iffy.png";
}
else if (vm.AccidentViewModel.AffectedSlug == vm.Slugs[3])
{
slugImage = slowpoke;
runningAnimationName = "moveSlowpoke";
brokenLegImage = "broken_leg_slowpoke.png";
}
// accident type
switch (vm.AccidentViewModel.AccidentType)
{
case AccidentType.BrokenLeg:
await HandleBrokenLeg();
break;
case AccidentType.Overheat:
await HandleOverheat();
break;
case AccidentType.HeartAttack:
await HandleHeartAttack();
break;
case AccidentType.Grass:
await HandleGrass();
break;
case AccidentType.Asleep:
await HandleAsleep();
break;
case AccidentType.Blind:
await HandleBlind();
break;
case AccidentType.Puddle:
await HandlePuddle();
break;
case AccidentType.Electroshock:
await HandleElectroshock();
break;
case AccidentType.TurningBack:
await HandleTurningBack();
break;
case AccidentType.Devoured:
await HandleDevoured();
break;
default:
break;
}
}
}
}
As you can see, in the HandleAccident
method we assign images and animations and call a method to handle the accident, depending on which accident type is to happen.
Before we implement the particular methods to handle the accidents, we’ll have to create a popup that will show up whenever an accident happens.
Accident Popup
We’ll create a small popup that will appear if an accident happens. It will contain the image of the slug affected by the accident and a headline. We’ll also create a separate view model for the popup. Actually, let’s start with that. In the ViewModels folder add a new class and name it AccidentPopupViewModel
. Here’s the code:
using CommunityToolkit.Mvvm.ComponentModel;
namespace Slugrace.ViewModels;
public partial class AccidentPopupViewModel : ObservableObject
{
[ObservableProperty]
private string affectedSlugImageUrl;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HeadlineMessage))]
private string affectedSlugName;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HeadlineMessage))]
private string accidentHeadline;
public string HeadlineMessage => $"{AffectedSlugName} {AccidentHeadline}";
public void ShowAccidentInfo(AccidentViewModel accidentViewModel)
{
AffectedSlugImageUrl = accidentViewModel.AffectedSlug.ImageUrl;
AffectedSlugName = accidentViewModel.AffectedSlug.Name;
AccidentHeadline = accidentViewModel.Headline;
}
}
Here we define a couple properties that will be required by the popup. The HeadlineMessage
property combines the name of the affected slug with the actual headline.
Next, let’s create the popup itself. Add a new class to the Popups folder and name it AccidentPopup
. Here’s the XAML file:
<?xml version="1.0" encoding="utf-8" ?>
<toolkit:Popup 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"
x:Class="Slugrace.Popups.AccidentPopup"
HorizontalOptions="Center"
VerticalOptions="Start"
Color="Black">
<VerticalStackLayout
Padding="10"
WidthRequest="500"
HeightRequest="240">
<Label
Text="BREAKING NEWS"
FontSize="30"
TextColor="Red" />
<Line
Stroke="CadetBlue"
StrokeThickness="1"
X1="10"
Y1="10"
X2="460"
Y2="10" />
<Grid
Margin="10"
ColumnDefinitions="1.5*, 3.5*">
<Image
Source="{Binding AffectedSlugImageUrl}" />
<Label
Grid.Column="1"
HorizontalTextAlignment="Start"
VerticalTextAlignment="Center"
Text="{Binding HeadlineMessage}"
FontSize="25"
FontAttributes="Bold,Italic"
TextColor="Red" />
</Grid>
</VerticalStackLayout>
</toolkit:Popup>
The popup will be centered horizontally and it will appear at the top of the window. There are a couple elements: a label, a horizontal line and a grid with the image of the slug and the headline message.
Here’s the code-behind:
using CommunityToolkit.Maui.Views;
using Slugrace.ViewModels;
namespace Slugrace.Popups;
public partial class AccidentPopup : Popup
{
public AccidentPopup(AccidentPopupViewModel accidentPopupViewModel)
{
InitializeComponent();
BindingContext = accidentPopupViewModel;
}
}
All we do here is set the binding context. Let’s also register the popup and the view model with the dependency service in MauiProgram.cs:
...
public static class MauiProgram
{
...
builder.Services.AddSingleton(AudioManager.Current);
builder.Services.AddTransientPopup<AccidentPopup, AccidentPopupViewModel>();
return builder.Build();
}
}
We need a way to display the popup. To this end, we’ll add a DisplayAccidentPopup
method to the GaveViewModel
:
...
public partial class GameViewModel : ObservableObject
{
...
void NextRace()
...
public void DisplayAccidentPopup()
{
popupService.ShowPopup<AccidentPopupViewModel>(
onPresenting: viewModel => viewModel.ShowAccidentInfo(AccidentViewModel));
}
[RelayCommand]
async Task SeeInstructions()
...
}
We’re using an IPopupService
here, which has the ShowPopup
method that we can use. We have to specify the view model we want to use. As we also want to pass data to the popup view model, we have to use the onPresenting
parameter, which is of the Action<TViewModel>
delegate type. Here the ShowAccidentInfo
method is called that we defined in the view model.
If you click anywhere outside the popup, it will be dismissed.
Besides displaying the accident popup when an accident happens, we also should hear a sound associated with the accident. Let’s take care of it next.
Accident Sound
As far as sound is concerned in our app, it’s the responsibility of the SoundViewModel
, so let’s start right there. We’ll modify the code slightly:
...
public partial class SoundViewModel : ObservableObject
{
...
private List<IAudioPlayer> loopingAccidentPlayers;
...
public SoundViewModel(IAudioManager audioManager)
{
this.audioManager = audioManager;
effectPlayers = [];
loopingAccidentPlayers = [];
Volume = .3;
}
...
public async Task PlaySound(string folderName, string fileName, double volume = 1, bool loop = false, bool loopingAccidentSound = false)
{
...
var player = audioManager.CreatePlayer(await FileSystem.OpenAppPackageFileAsync(path));
if (!loopingAccidentSound)
{
effectPlayers.Add(player);
}
else
{
loopingAccidentPlayers.Add(player);
}
player.Volume = volume;
...
}
...
public void Clean(bool loopingAccidentSound = false)
{
if (!loopingAccidentSound)
{
foreach (var player in effectPlayers)
{
if (player.IsPlaying)
{
player.Stop();
}
player.Dispose();
}
effectPlayers.Clear();
}
else
{
foreach (var player in loopingAccidentPlayers)
{
if (player.IsPlaying)
{
player.Stop();
}
player.Dispose();
}
loopingAccidentPlayers.Clear();
}
}
public async Task Attenuate()
...
}
First, we add a list of IAudioPlayer
s called loopingAccidentPlayers
to store sounds that should be played continuously in a loop when an accident happens. This will be the case, for example, with the HeartAttack accident when we will hear the heart beating until we hit the Next Race button. The list is instantiated in the constructor.
Next, we modify the PlaySound
method. It now takes an additional loopingAccidentSound
parameter of type bool. If it’s set to false
, the IAudioPlayer
is added to the effectPlayers
list. Otherwise, it’s added to the loopingAccidentPlayers
list.
We also modify the Clean
method. It now also takes a loopingAccidentSound
parameter. Depending on its value, either the effectPlayers
or the loopingAccidentPlayers
list is cleared.
With that in place, let’s add two methods to the GameViewModel
: one to play and the other to stop the accident sound:
...
public partial class GameViewModel : ObservableObject
{
...
public void PlayAccidentSound(bool loop = false, bool loopingAccidentSound = false)
{
_ = soundViewModel.PlaySound("Accidents", AccidentViewModel.Sound, loop: loop,
loopingAccidentSound: loopingAccidentSound);
}
public void StopAccidentSound()
{
soundViewModel.Clean(true);
}
private void HandleSlugsAfterRace()
...
void NextRace()
{
soundViewModel.Clean();
soundViewModel.Clean(true);
RaceStatus = RaceStatus.NotYetStarted;
...
}
...
}
We also call the Clean
method in the NextRace
method twice to clear both IAudioPlayer
lists.
And now we’re ready to implement the particular accidents. However, accidents are supposed to happen randomly and rather not too frequently, which makes it difficult and time-consuming to test them. This is why we have to temporarily modify the accident-related code in the GameViewModel
.
Testing Accidents
We want to ensure two things. First, the accident should happen in each race. Secondly, we should be able to decide which accident happens.
To do the former, let’s comment out the line of code where the condition is checked whether an accident should happen and use a condition that is always true, like 2 + 2 == 4.
To do the latter, let’s manually pass the index from the accidentTypes
list. Let’s start with index 0, which corresponds the Broken Leg accident:
...
public partial class GameViewModel : ObservableObject
{
...
async Task StartRace()
{
...
// Should there be an accident?
//if (RaceNumber > 5 && AccidentViewModel.Expected)
if (2 + 2 == 4)
{
// If so, then...
thereIsAnAccident = true;
// Which one?
AccidentType[] accidentTypes = (AccidentType[])Enum.GetValues(typeof(AccidentType));
//var type = accidentTypes[new Random().Next(0, accidentTypes.Length)];
var type = accidentTypes[0];
AccidentViewModel = new AccidentViewModel(type);
// Which slug should be affected?
...
And now let’s implement the accidents one by one.
Accidents Implementation
Each accident will be implemented in a separate asynchronous method in the TrackImage
class. Let’s start with the Broken Leg accident.
Broken Leg Accident
This accident will be implemented inside the HandleBrokenLeg
method. When the race begins, nothing happens until the accident’s TimePosition
is reached, which is a randomized value and may differ from race to race within a certain range.
When this time has elapsed, the running animation is canceled and the slug stops moving.
The BodyImageUrl
property is set to brokenLegImage
, which is different for each slug.
We also hear the accident sound and see the accident popup.
Here’s the code:
...
public partial class TrackImage : ContentView
{
...
private async Task HandleAccident()
...
private async Task HandleBrokenLeg()
{
await Task.Delay((int)vm.AccidentViewModel.TimePosition);
this.AbortAnimation(runningAnimationName);
vm.AccidentViewModel.AffectedSlug.BodyImageUrl = brokenLegImage;
vm.PlayAccidentSound();
vm.DisplayAccidentPopup();
}
}
Let’s now run the app and start a race. After a while, one of the slugs will break his leg. Here’s what it looks like with the popup on Windows and Android:
And here’s what it looks like when you dismiss the popup on Windows:
With the other accidents, I’ll show you what it looks like without the popup on Windows. On Android it looks pretty much the same.
If we now hit the Next Race button, Iffy will start the next race with a broken leg, which isn’t what we want. Remember: Whatever happens to the slugs in a race, they don’t suffer and they always start the next race fully healed.
So, let’s take care of the healing process.
As the body image and the eye images of the slugs will be replaced in some accidents, like the body image here, let’s add two properties to the Slug
model to store the default values, so the ones the slugs should always start with. Here’s the Slug
class:
namespace Slugrace.Models;
public class Slug
{
...
public string EyeImageUrl { get; set; }
public string BodyImageUrl { get; set; }
public string DefaultEyeImageUrl { get; set; }
public string DefaultBodyImageUrl { get; set; }
public double BaseOdds { get; set; }
...
}
We also need to implement the default image properties in the SlugViewModel
:
...
public partial class SlugViewModel : ObservableObject
{
...
public string BodyImageUrl
...
public string DefaultEyeImageUrl
{
get => slug.DefaultEyeImageUrl;
set
{
if (slug.DefaultEyeImageUrl != value)
{
slug.DefaultEyeImageUrl = value;
OnPropertyChanged();
}
}
}
public string DefaultBodyImageUrl
{
get => slug.DefaultBodyImageUrl;
set
{
if (slug.DefaultBodyImageUrl != value)
{
slug.DefaultBodyImageUrl = value;
OnPropertyChanged();
}
}
}
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(WinPercentage))]
private int currentRaceNumber;
...
Next, let’s go to the SettingsViewModel
and set the properties for each slug there:
...
public partial class SettingsViewModel : ObservableObject
{
...
async Task StartGame()
{
// Populate the Game object
game.Slugs =
[
new Slug
{
...
EyeImageUrl = "speedster_eye.png",
BodyImageUrl = "speedster_body.png",
DefaultEyeImageUrl = "speedster_eye.png",
DefaultBodyImageUrl = "speedster_body.png",
WinSound = "Speedster Win.mp3"
},
new Slug
{
...
EyeImageUrl = "trusty_eye.png",
BodyImageUrl = "trusty_body.png",
DefaultEyeImageUrl = "trusty_eye.png",
DefaultBodyImageUrl = "trusty_body.png",
WinSound = "Trusty Win.mp3"
},
new Slug
{
...
EyeImageUrl = "iffy_eye.png",
BodyImageUrl = "iffy_body.png",
DefaultEyeImageUrl = "iffy_eye.png",
DefaultBodyImageUrl = "iffy_body.png",
WinSound = "Iffy Win.mp3"
},
new Slug
{
...
EyeImageUrl = "slowpoke_eye.png",
BodyImageUrl = "slowpoke_body.png",
DefaultEyeImageUrl = "slowpoke_eye.png",
DefaultBodyImageUrl = "slowpoke_body.png",
WinSound = "Slowpoke Win.mp3"
}
];
var playersInGame = Players.Where(p => p.PlayerIsInGame).ToList();
...
Finally, let’s restore the images in the NextRace
method in the GameViewModel
to the default values so that the default images are used in the next race. In case of the Broken Leg accident, we didn’t replace the eye images, but in some other accidents they will be replaced, that’s why we reset both images here. We also set AccidentViewModel
to null
because if there should be an accident in the next race, a new instance will be created anyway. By default there is no accident in a race, so we set AccidentShouldHappen
to false
:
...
public partial class GameViewModel : ObservableObject
{
...
void NextRace()
{
...
RaceWinnerSlug = null;
AccidentViewModel = null;
AccidentShouldHappen = false;
foreach (var player in Players)
...
foreach (var slug in Slugs)
{
if (slug.BodyImageUrl != slug.DefaultBodyImageUrl)
{
slug.BodyImageUrl = slug.DefaultBodyImageUrl;
}
if (slug.EyeImageUrl != slug.DefaultEyeImageUrl)
{
slug.EyeImageUrl = slug.DefaultEyeImageUrl;
}
}
}
...
}
Now we’re done. The slug is healed. We can now move on to the next accident.
Overheat Accident
First of all, remember to change the accident type index in GameViewModel
, so that the Overheat accident is selected. Then, in the TrackImage
class add the HandleOverheat
method:
...
public partial class TrackImage : ContentView
{
...
private async Task HandleBrokenLeg()
...
private async Task HandleOverheat()
{
await Task.Delay((int)vm.AccidentViewModel.TimePosition);
this.AbortAnimation(runningAnimationName);
slugImage.StopEyeRotation();
vm.AccidentViewModel.AffectedSlug.BodyImageUrl = overheatBodyImage;
vm.AccidentViewModel.AffectedSlug.EyeImageUrl = overheatEyeImage;
vm.PlayAccidentSound();
vm.DisplayAccidentPopup();
}
}
Just like before, when the time position is reached, the running animation is canceled. But this time, we also want the eye rotation animation to stop. To this end, we define a StopEyeRotation
method in the SlugImage
class:
...
public partial class SlugImage : ContentView
{
...
public void StartEyeRotation()
...
public void StopEyeRotation()
{
this.AbortAnimation("eyeRotation");
}
}
Then the body and eye images are replaced so that the slug really looks as if he was burned. Naturally, we also hear the sound and see the popup.
If we now run the app and start a race, we should see the accident in action:
Naturally, the images will be reset in the next race. And now let’s move on to a more complicated accident.
Heart Attack Accident
In this accident we’ll use an accident image. It will be set differently for the Windows and Android platforms so that it looks similar on both platforms. Here’s the code:
...
public partial class TrackImage : ContentView
{
...
private async Task HandleOverheat()
...
private async Task HandleHeartAttack()
{
await Task.Delay((int)vm.AccidentViewModel.TimePosition);
this.AbortAnimation(runningAnimationName);
accidentImage = new Image { Source = heartImage };
layout.Add(accidentImage);
#if ANDROID
layout.SetLayoutBounds(accidentImage, new Rect(
slugImage.X + slugImage.TranslationX - .2 * slugImage.Width,
slugImage.Y - slugImage.Height,
accidentImage.Width,
accidentImage.Height));
accidentAnimation = new Animation()
{
{0, .24, new Animation(v => accidentImage.Scale = v, .4, .3) },
{.24, 1, new Animation(v => accidentImage.Scale = v, .3, .4) }
};
#endif
#if WINDOWS
layout.SetLayoutBounds(accidentImage, new Rect(
slugImage.X + slugImage.TranslationX + .4 * slugImage.Width,
slugImage.Y,
accidentImage.Width,
accidentImage.Height));
accidentAnimation = new Animation()
{
{0, .24, new Animation(v => accidentImage.Scale = v, .8, .6) },
{.24, 1, new Animation(v => accidentImage.Scale = v, .6, .8) }
};
#endif
accidentAnimation.Commit(this, "accidentAnimation", 16, 820, Easing.CubicInOut, null, () => true);
vm.PlayAccidentSound(true, true);
vm.DisplayAccidentPopup();
}
}
So, again, when the time position is reached, the running animation is canceled. Then the accident image representing a heart is added and positioned. Also, a heart beating animation is created. The positioning of the image and the animation are different for each platform. But then the animation is started the same way for both platforms. As always, there’s a sound (a looping one this time) and we can see the popup.
If the accident happens, we should see something like this:
Fine, we’re done with this accident. The next one is going to be even more complicated.
Grass Accident
The Grass accident consists of a couple parts. First the slug notices some grass and stops to eat. He spends some time eating. The grass gives him more strength, so after the meal he starts running faster then before. Here we have the grass image, which will be scaled and positioned differently for each platform. Anyway, here’s the code:
...
public partial class TrackImage : ContentView
{
...
private async Task HandleHeartAttack()
...
private async Task HandleGrass()
{
await Task.Delay((int)vm.AccidentViewModel.TimePosition);
this.AbortAnimation(runningAnimationName);
accidentImage = new Image { Source = grassImage };
layout.Add(accidentImage);
#if ANDROID
accidentImage.ScaleX = .5;
accidentImage.ScaleY = .25;
layout.SetLayoutBounds(accidentImage, new Rect(
slugImage.X + slugImage.TranslationX + .5 * slugImage.Width,
slugImage.Y - 2 * slugImage.Height,
accidentImage.Width,
accidentImage.Height));
#endif
#if WINDOWS
layout.SetLayoutBounds(accidentImage, new Rect(
slugImage.X + slugImage.TranslationX + slugImage.Width,
.9 * slugImage.Y,
accidentImage.Width,
accidentImage.Height));
#endif
accidentAnimation = new Animation()
{
{0, .24, new Animation(v => slugImage.ScaleX = v, 1, .9) },
{.24, 1, new Animation(v => slugImage.ScaleX = v, .9, 1) }
};
accidentAnimation.Commit(this, "accidentAnimation", 16, 500, Easing.CubicInOut, null, () => true);
vm.PlayAccidentSound(true, true);
vm.DisplayAccidentPopup();
await Task.Delay((int)vm.AccidentViewModel.Duration);
this.AbortAnimation("accidentAnimation");
if (layout.Contains(accidentImage))
{
layout.Remove(accidentImage);
}
vm.StopAccidentSound();
accidentAnimation = new Animation(v => slugImage.TranslationX = v, slugImage.X + slugImage.TranslationX, trackLength);
accidentAnimation.Commit(this, "accidentAnimation", 16, vm.AccidentViewModel.AffectedSlug.RunningTime / 4, Easing.Linear, null, () => false);
}
}
So, after the running animation is canceled, the grass image appears, the eating sound starts playing, the popup appears, and a new animation is started. This animation scales the slug image horizontally up and down so that it looks like the slug is eating.
Next, the grass image is removed and the eating sound is stopped. A running animation is started so that the slug can finish the race. He now runs faster, but still isn’t sure (although more probable) to win.
Here’s what the accident looks like halfway, when the grass image is visible:
Here the slug speeds up after a nice meal. But sometimes he just falls asleep…
Asleep Accident
Here’s the Asleep accident implementation:
...
public partial class TrackImage : ContentView
{
...
private async Task HandleGrass()
...
private async Task HandleAsleep()
{
await Task.Delay((int)vm.AccidentViewModel.TimePosition);
this.AbortAnimation(runningAnimationName);
slugImage.StopEyeRotation();
accidentAnimation = new Animation()
{
{0, .46, new Animation(v => slugImage.Scale = v, 1, 1.05) },
{.46, 1, new Animation(v => slugImage.Scale = v, 1.05, 1) }
};
accidentAnimation.Commit(this, "accidentAnimation", 16, 5600, Easing.CubicInOut, null, () => true);
vm.PlayAccidentSound(true, true);
vm.DisplayAccidentPopup();
}
}
When this accident happens, the slug stops running and stops moving his tentacles. A new animation is started in which the slug is scaled up and down, which looks like he’s breathing steadily in his sleep. We can also hear a looping snoring sound.
This accident isn’t very spectacular. Here you can see the affected slug (Trusty) is slightly bigger than the other slugs, but it’s hard to notice in a static image like below:
In the next accident, the slug will go blind.
Blind Accident
From time to time, a slug may lose his tentacles and go blind. Here’s how this accident is implemented:
...
public partial class TrackImage : ContentView
{
...
private async Task HandleAsleep()
...
private async Task HandleBlind()
{
await Task.Delay((int)vm.AccidentViewModel.TimePosition);
vm.AccidentViewModel.AffectedSlug.EyeImageUrl = null;
accidentAnimation = new Animation()
{
{0, .25, new Animation(v => slugImage.Rotation = v, 0, 15) },
{.25, .5, new Animation(v => slugImage.Rotation = v, 15, 0) },
{.5, .75, new Animation(v => slugImage.Rotation = v, 0, -15) },
{.75, 1, new Animation(v => slugImage.Rotation = v, -15, 0) }
};
accidentAnimation.Commit(this, "accidentAnimation", 16, 1000, Easing.Linear, null, () => true);
vm.PlayAccidentSound();
vm.DisplayAccidentPopup();
await Task.Delay((int)vm.AccidentViewModel.Duration);
slugImage.Rotation = 0;
this.AbortAnimation("accidentAnimation");
}
}
The slug doesn’t stop running when the accident happens. He just loses his eyes (the eye image is set to null) and starts moving in a seemingly chaotic way, just like Slowpoke in the image below:
Unfortunately, a slug may also fall into a puddle and drown. Let’s have a look at this accident next.
Puddle Accident
Let’s now implement the next accident. In this accident the slug drowns in a puddle of water:
...
public partial class TrackImage : ContentView
{
...
private async Task HandleBlind()
...
private async Task HandlePuddle()
{
await Task.Delay((int)vm.AccidentViewModel.TimePosition);
this.AbortAnimation(runningAnimationName);
accidentImage = new Image { Source = puddleImage };
accidentImage.ZIndex = 1;
slugImage.ZIndex = 2;
layout.Add(accidentImage);
#if ANDROID
accidentImage.ScaleX = .25;
accidentImage.ScaleY = .25;
layout.SetLayoutBounds(accidentImage, new Rect(
slugImage.X + slugImage.TranslationX - 3 * slugImage.Width,
slugImage.Y - 2 * slugImage.Height,
accidentImage.Width,
accidentImage.Height));
#endif
#if WINDOWS
layout.SetLayoutBounds(accidentImage, new Rect(
slugImage.X + slugImage.TranslationX - slugImage.Width / 3,
slugImage.Y - .3 * slugImage.Height,
accidentImage.Width,
accidentImage.Height));
#endif
accidentAnimation = new Animation()
{
{0, .1, new Animation(v => slugImage.Opacity = v, .6, 0) },
{.1, .8, new Animation(v => slugImage.Opacity = v, 0, .3) },
{.8, 1, new Animation(v => slugImage.Opacity = v, .3, .6) }
};
accidentAnimation.Commit(this, "accidentAnimation", 16, vm.RaceTime, Easing.CubicInOut, null, () => true);
vm.PlayAccidentSound(true, true);
vm.DisplayAccidentPopup();
}
}
The image is scaled and positioned per platform. We set the ZIndex
property so that the puddle image appears under the slug image instead of on top of it. We then animate the Opacity
property so that the slug disappears in the water, then reappears, and so on. Here’s what it looks like:
Let’s move on to the next accident.
Electroshock Accident
The next accident, Electroshock, is implemented like so:
...
public partial class TrackImage : ContentView
{
...
private async Task HandlePuddle()
...
private async Task HandleElectroshock()
{
await Task.Delay((int)vm.AccidentViewModel.TimePosition);
this.AbortAnimation(runningAnimationName);
accidentImage = new Image { Source = boltImage };
layout.Add(accidentImage);
accidentImage.Rotation = -15;
#if ANDROID
accidentImage.Scale = .2;
layout.SetLayoutBounds(accidentImage, new Rect(
slugImage.X + slugImage.TranslationX - slugImage.Width,
slugImage.Y - 2 * slugImage.Height,
accidentImage.Width,
accidentImage.Height));
#endif
#if WINDOWS
accidentImage.Scale = .7;
layout.SetLayoutBounds(accidentImage, new Rect(
slugImage.X + slugImage.TranslationX,
.9 * slugImage.Y,
accidentImage.Width,
accidentImage.Height));
#endif
accidentAnimation = new Animation()
{
{0, .5, new Animation(v => accidentImage.Opacity = v, 0, 1) },
{.5, 1, new Animation(v => accidentImage.Opacity = v, 1, 0) }
};
accidentAnimation.Commit(this, "accidentAnimation", 16, 500, Easing.Linear, null, () => true);
vm.PlayAccidentSound(true, true);
vm.DisplayAccidentPopup();
await Task.Delay((int)vm.AccidentViewModel.Duration);
this.AbortAnimation("accidentAnimation");
if (layout.Contains(accidentImage))
{
layout.Remove(accidentImage);
}
vm.StopAccidentSound();
accidentAnimation = new Animation(v => slugImage.TranslationX = v, slugImage.TranslationX, trackLength);
accidentAnimation.Commit(this, "accidentAnimation", 16, vm.AccidentViewModel.AffectedSlug.RunningTime / 4, Easing.Linear, null, () => false);
}
}
The running animation is canceled and a bolt image appears. We animate its Opacity
to imitate lightning. After that, there’s another running animation, but this time the slug runs faster.
Here’s a slug just being struck by lightning:
Sometimes nothing special happens to a slug, but they just turn back. Let’s implement this.
Turning Back Accident
Here’s the Turning Back accident:
...
public partial class TrackImage : ContentView
{
...
private async Task HandleElectroshock()
...
private async Task HandleTurningBack()
{
await Task.Delay((int)vm.AccidentViewModel.TimePosition);
this.AbortAnimation(runningAnimationName);
accidentAnimation = new Animation()
{
{0, .2, new Animation(v => slugImage.ScaleX = v, 1, -1) },
{.2, 1, new Animation(v => slugImage.TranslationX = v, slugImage.TranslationX, -400) }
};
accidentAnimation.Commit(this, "accidentAnimation", 16, 5000, Easing.Linear, null, () => false);
vm.PlayAccidentSound();
vm.DisplayAccidentPopup();
}
}
The running animation is stopped and a new animation is started that consists of two simple animations: one where the ScaleX
property changes from 1 to -1, which flips the slug horizontally, and one where the slug runs from his current position to the left.
Here’s the effect:
And there is one more accident, the most terrible one…
Devoured Accident
The worst thing, at least theoretically, that may happen to a slug, is being devoured by the terrible slug monster that occasionally haunts the area where the racetrack is. The slug catches the slug and eats it. Here’s how this is implemented in our app:
...
public partial class TrackImage : ContentView
{
...
private async Task HandleTurningBack()
...
private async Task HandleDevoured()
{
await Task.Delay((int)vm.AccidentViewModel.TimePosition);
this.AbortAnimation(runningAnimationName);
accidentImage = new Image { Source = monsterImage };
layout.Add(accidentImage);
#if ANDROID
accidentImage.ScaleX = .25;
accidentImage.ScaleY = .25;
layout.SetLayoutBounds(accidentImage, new Rect(
-100,
slugImage.Y - 2 * slugImage.Height,
accidentImage.Width,
accidentImage.Height));
accidentAnimation = new Animation(v => accidentImage.TranslationX = v, 0, slugImage.TranslationX);
#endif
#if WINDOWS
layout.SetLayoutBounds(accidentImage, new Rect(
-accidentImage.Width,
slugImage.Y - .3 * slugImage.Height,
accidentImage.Width,
accidentImage.Height));
accidentAnimation = new Animation(v => accidentImage.TranslationX = v, 0, slugImage.X + slugImage.TranslationX + accidentImage.Width);
#endif
accidentAnimation.Commit(this, "accidentAnimation", 16, 1000, Easing.CubicInOut, null, () => false);
vm.PlayAccidentSound();
vm.DisplayAccidentPopup();
}
}
The slug monster comes from the left-hand side of the window. Here’s what it looks like:
Poor Speedster. But don’t worry, he’ll be up and running in the next race.
And that’s it. We’ve covered all the accidents that may happen to a slug. But we don’t want them to happen too frequently, and for sure not in every race, so restore the original code in GameViewModel
that is responsible for deciding whether an accident should happen and picking a type.
Great, looks like our app is almost finished. There are just a couple final touches I’d like to take care of before we deploy our app. They are the topic of the next part of the series.