Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Rendering;
using Avalonia.Threading;
using SnowflakesControlSample.Models;

Expand All @@ -17,18 +17,18 @@ namespace SnowflakesControlSample.Controls;
/// A control to render some <see cref="Snowflake">Snowflakes</see>. This control also adds the needed interaction logic
/// for the game to operate.
/// </summary>
public class SnowflakesControl : Control, ICustomHitTest
public class SnowflakesControl : Control
{
static SnowflakesControl()
{
// Make sure Render is updated whenever one of these properties changes
AffectsRender<SnowflakesControl>(IsRunningProperty, SnowflakesProperty, ScoreProperty);
}

// We use a stopwatch to measure elapsed time between two render loops as it has higher precision compared to
// other options. See: https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.stopwatch
private readonly Stopwatch _stopwatch = new();

// A collection which holds all current visible score infos to render.
private readonly ICollection<ScoreHint> _scoreHintsCollection = new Collection<ScoreHint>();

Expand Down Expand Up @@ -58,7 +58,7 @@ static SnowflakesControl()
AvaloniaProperty.Register<SnowflakesControl, bool>(nameof(IsRunning));

private IList<Snowflake> _snowflakes = [];

/// <summary>
/// Gets or sets a List of <see cref="Snowflake">Snowflakes</see> to render.
/// </summary>
Expand All @@ -69,7 +69,7 @@ public IList<Snowflake> Snowflakes
}

private int _score;

/// <summary>
/// Gets or sets the current user score.
/// </summary>
Expand All @@ -78,7 +78,7 @@ public int Score
get => _score;
set => SetAndRaise(ScoreProperty, ref _score, value);
}

/// <summary>
/// Gets or sets a bool indicating if the Game whether the game is currently running.
/// </summary>
Expand All @@ -87,7 +87,7 @@ public bool IsRunning
get { return GetValue(IsRunningProperty); }
set { SetValue(IsRunningProperty, value); }
}

/// <inheritdoc />
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
Expand All @@ -100,7 +100,7 @@ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs chang
{
// Resets and starts the stopwatch
_stopwatch.Restart();

// Clear any previous score hints
_scoreHintsCollection.Clear();
}
Expand Down Expand Up @@ -136,7 +136,7 @@ public override void Render(DrawingContext context)

// Bonus 1: Use a custom renderer using Skia-API to display the total score
context.Custom(new ScoreRenderer(Bounds, $"Your score: {Score:N0}"));

// Bonus 2: Render the score hint if any available.
foreach (var scoreHint in _scoreHintsCollection.ToArray())
{
Expand All @@ -145,7 +145,7 @@ public override void Render(DrawingContext context)
{
scoreHint.Update(elapsedMilliseconds);
}

// Use a formatted text to render the score hint.
var formattedText =
new FormattedText(
Expand All @@ -155,42 +155,60 @@ public override void Render(DrawingContext context)
Typeface.Default,
20,
new SolidColorBrush(Colors.Yellow, scoreHint.Opacity));

context.DrawText(formattedText, scoreHint.GetTopLeftForViewport(Bounds, new Size(formattedText.Width, formattedText.Height)));

context.DrawText(formattedText,
scoreHint.GetTopLeftForViewport(Bounds, new Size(formattedText.Width, formattedText.Height)));
}

base.Render(context);

// Request next frame as soon as possible, if the game is running. Remember to reset the stopwatch.
// Request the next frame as soon as possible if the game is running. Remember to reset the stopwatch.
if (IsRunning)
{
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Background);
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Render);
_stopwatch.Restart();
}
}

/// <inheritdoc />
protected override void OnPointerEntered(PointerEventArgs e)
{
HitTestSnowFlakes(e.GetPosition(this));

base.OnPointerEntered(e);
}

/// <inheritdoc />
protected override void OnPointerMoved(PointerEventArgs e)
{
HitTestSnowFlakes(e.GetPosition(this));

base.OnPointerMoved(e);
}

/// <summary>
/// This method is needed to customize hit-testing. In our case we want the pointer hit only in case
/// the game is running and the pointer is directly over one of our snowflakes.
/// This method will check if the pointer has hit any snowflake. If so, it will remove the snowflake from the list
/// and update the score.
/// </summary>
/// <param name="point">the pointer point to test.</param>
/// <returns>true if the pointer hit the control, otherwise false</returns>
/// <remarks>Used by the <see cref="ICustomHitTest"/>-interface, which this control implements.</remarks>
public bool HitTest(Point point)
private void HitTestSnowFlakes(Point point)
{
if (!IsRunning) return false;
// if the game is not running, we don't need to do anything.
if (!IsRunning) return;

var snowFlake = Snowflakes.FirstOrDefault(x => x.IsHit(point, Bounds));
if (snowFlake != null)
// loop through all snowflakes and check if the pointer is inside one of them.
// Copy the list to avoid concurrent modification exceptions.
foreach (var snowFlake in Snowflakes.ToArray())
{
Snowflakes.Remove(snowFlake);
Score += snowFlake.GetHitScore();

// Add a text hint about the earned score. We also hand over the containing collection,
// so it can auto-remove itself after 1 second.
_scoreHintsCollection.Add(new ScoreHint(snowFlake, _scoreHintsCollection));
}
if (snowFlake.IsHit(point, Bounds))
{
Snowflakes.Remove(snowFlake);
Score += snowFlake.GetHitScore();

return snowFlake != null;
// Add a text hint about the earned score. We also hand over the containing collection,
// so it can auto-remove itself after 1 second.
_scoreHintsCollection.Add(new ScoreHint(snowFlake, _scoreHintsCollection));
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ Here is the final class: xref:ViewModels/SnowflakeGameViewModel.cs[]

=== Step 3: Add the SnowflakesControl

Now it's time to add the needed `Control` to render our game. Therefore, we add a new folder `Controls` and inside we add a new class `SnowflakesControl.cs`. This class must inherit `Control`. In addition, we want to implement the interface `ICustomHitTest` in order to control hit-testing on our own.
Now it's time to add the needed `Control` to render our game. Therefore, we add a new folder `Controls` and inside we add a new class `SnowflakesControl.cs`. This class must inherit `Control`.

The control needs some https://docs.avaloniaui.net/docs/guides/custom-controls/how-to-create-advanced-custom-controls[[AvaloniaProperties\]] to allow us to bind to it.

Expand Down Expand Up @@ -374,7 +374,7 @@ public override void Render(DrawingContext context)
// Request next frame as soon as possible, if the game is running. Remember to reset the stopwatch.
if (IsRunning)
{
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Background);
Dispatcher.UIThread.Post(InvalidateVisual, DispatcherPriority.Render);
_stopwatch.Restart();
}
}
Expand All @@ -387,31 +387,56 @@ This is how a single render frame would look like:
.Result of a single render loop
image:_docs/single_render_frame.png[]

As we wanted to implement `ICustomHitTest`, we will add the needed interface members, which is just the following method here:
As we want to check if our pointer is over any `Snowflake`, we will override the `OnPointerEntered` and `OnPointerMoved` methods. From the `PointerEventArgs` we can read the pointer position and forward it to a method that tests for hits,

[source,csharp]
----
public bool HitTest(Point point)
/// <inheritdoc />
protected override void OnPointerEntered(PointerEventArgs e)
{
if (!IsRunning) return false;
HitTestSnowFlakes(e.GetPosition(this));
base.OnPointerEntered(e);
}

/// <inheritdoc />
protected override void OnPointerMoved(PointerEventArgs e)
{
HitTestSnowFlakes(e.GetPosition(this));
base.OnPointerMoved(e);
}

/// <summary>
/// This method will check if the pointer has hit any snowflake. If so, it will remove the snowflake from the list
/// and update the score.
/// </summary>
/// <param name="point">the pointer point to test.</param>
private void HitTestSnowFlakes(Point point)
{
// if the game is not running, we don't need to do anything.
if (!IsRunning) return;

var snowFlake = Snowflakes.FirstOrDefault(x => x.IsHit(point, Bounds));
if (snowFlake != null)
// loop through all snowflakes and check if the pointer is inside one of them.
// Copy the list to avoid concurrent modification exceptions.
foreach (var snowFlake in Snowflakes.ToArray())
{
Snowflakes.Remove(snowFlake);
Score += snowFlake.GetHitScore();
}
if (snowFlake.IsHit(point, Bounds))
{
Snowflakes.Remove(snowFlake);
Score += snowFlake.GetHitScore();

return snowFlake != null;
// Add a text hint about the earned score. We also hand over the containing collection,
// so it can auto-remove itself after 1 second.
_scoreHintsCollection.Add(new ScoreHint(snowFlake, _scoreHintsCollection));
}
}
}
----

What this method does is:

* If the game is not running, the control shouldn't receive any hit
* If the game is running, search for any snowflake hit by the pointer
** If one is found, remove it from the collection
** If one is found, add the score to the total score
* If the game is running, search for all snowflakes hit by the pointer
** If any is found, remove it from the collection
** If any is found, add the score to the total score

Here is the final class xref:Controls/SnowflakesControl.cs[]

Expand Down