2020-06-15 20:48:59 +08:00
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System ;
2020-06-19 19:31:52 +08:00
using System.Linq ;
2020-06-15 20:48:59 +08:00
using osu.Framework.Allocation ;
using osu.Framework.Extensions.Color4Extensions ;
using osu.Framework.Graphics ;
using osu.Framework.Graphics.Containers ;
using osu.Framework.Graphics.Shapes ;
2020-06-19 20:14:31 +08:00
using osu.Framework.Utils ;
2020-06-22 17:38:41 +08:00
using osu.Game.Beatmaps ;
2020-06-18 21:11:03 +08:00
using osu.Game.Rulesets.Osu.Objects ;
2020-06-19 18:58:35 +08:00
using osu.Game.Scoring ;
2020-06-15 20:48:59 +08:00
using osuTK ;
using osuTK.Graphics ;
namespace osu.Game.Rulesets.Osu.Statistics
{
2020-06-19 20:14:31 +08:00
public class AccuracyHeatmap : CompositeDrawable
2020-06-15 20:48:59 +08:00
{
/// <summary>
2020-06-19 20:14:31 +08:00
/// Size of the inner circle containing the "hit" points, relative to the size of this <see cref="AccuracyHeatmap"/>.
2020-06-15 20:48:59 +08:00
/// All other points outside of the inner circle are "miss" points.
/// </summary>
private const float inner_portion = 0.8f ;
2020-06-19 18:08:36 +08:00
/// <summary>
/// Number of rows/columns of points.
2020-06-22 19:44:39 +08:00
/// ~4px per point @ 128x128 size (the contents of the <see cref="AccuracyHeatmap"/> are always square). 1089 total points.
2020-06-19 18:08:36 +08:00
/// </summary>
2020-06-22 19:44:39 +08:00
private const int points_per_dimension = 33 ;
2020-06-19 18:08:36 +08:00
2020-06-15 20:48:59 +08:00
private const float rotation = 45 ;
2020-06-19 18:08:36 +08:00
private GridContainer pointGrid ;
2020-06-15 20:48:59 +08:00
2020-06-19 18:58:35 +08:00
private readonly ScoreInfo score ;
2020-06-22 17:38:41 +08:00
private readonly IBeatmap playableBeatmap ;
2020-06-16 15:31:02 +08:00
2020-06-22 17:38:41 +08:00
public AccuracyHeatmap ( ScoreInfo score , IBeatmap playableBeatmap )
2020-06-15 20:48:59 +08:00
{
2020-06-19 18:58:35 +08:00
this . score = score ;
2020-06-22 17:38:41 +08:00
this . playableBeatmap = playableBeatmap ;
2020-06-15 20:48:59 +08:00
}
[BackgroundDependencyLoader]
private void load ( )
{
2020-06-16 15:31:02 +08:00
InternalChild = new Container
2020-06-15 20:48:59 +08:00
{
2020-06-16 15:31:02 +08:00
Anchor = Anchor . Centre ,
Origin = Anchor . Centre ,
RelativeSizeAxes = Axes . Both ,
FillMode = FillMode . Fit ,
Children = new Drawable [ ]
2020-06-15 20:48:59 +08:00
{
2020-06-16 15:31:02 +08:00
new CircularContainer
2020-06-15 20:48:59 +08:00
{
2020-06-16 15:31:02 +08:00
Anchor = Anchor . Centre ,
Origin = Anchor . Centre ,
2020-06-15 20:48:59 +08:00
RelativeSizeAxes = Axes . Both ,
2020-06-16 15:31:02 +08:00
Size = new Vector2 ( inner_portion ) ,
Masking = true ,
BorderThickness = 2f ,
BorderColour = Color4 . White ,
Child = new Box
2020-06-15 20:48:59 +08:00
{
2020-06-16 15:31:02 +08:00
RelativeSizeAxes = Axes . Both ,
Colour = Color4Extensions . FromHex ( "#202624" )
}
} ,
new Container
{
RelativeSizeAxes = Axes . Both ,
Masking = true ,
Children = new Drawable [ ]
2020-06-15 20:48:59 +08:00
{
2020-06-16 15:31:02 +08:00
new Box
{
Anchor = Anchor . Centre ,
Origin = Anchor . Centre ,
RelativeSizeAxes = Axes . Y ,
Height = 2 , // We're rotating along a diagonal - we don't really care how big this is.
Width = 1f ,
Rotation = - rotation ,
Alpha = 0.3f ,
} ,
new Box
{
Anchor = Anchor . Centre ,
Origin = Anchor . Centre ,
RelativeSizeAxes = Axes . Y ,
Height = 2 , // We're rotating along a diagonal - we don't really care how big this is.
Width = 1f ,
Rotation = rotation
} ,
new Box
{
Anchor = Anchor . TopRight ,
Origin = Anchor . TopRight ,
Width = 10 ,
Height = 2f ,
} ,
new Box
{
Anchor = Anchor . TopRight ,
Origin = Anchor . TopRight ,
Y = - 1 ,
Width = 2f ,
Height = 10 ,
}
2020-06-15 20:48:59 +08:00
}
2020-06-16 15:31:02 +08:00
} ,
2020-06-19 18:08:36 +08:00
pointGrid = new GridContainer
2020-06-16 15:31:02 +08:00
{
RelativeSizeAxes = Axes . Both
2020-06-15 20:48:59 +08:00
}
2020-06-16 15:31:02 +08:00
}
2020-06-15 20:48:59 +08:00
} ;
2020-06-16 15:31:02 +08:00
2020-06-19 18:08:36 +08:00
Vector2 centre = new Vector2 ( points_per_dimension ) / 2 ;
float innerRadius = centre . X * inner_portion ;
2020-06-15 20:48:59 +08:00
2020-06-19 18:08:36 +08:00
Drawable [ ] [ ] points = new Drawable [ points_per_dimension ] [ ] ;
2020-06-15 20:48:59 +08:00
2020-06-19 18:08:36 +08:00
for ( int r = 0 ; r < points_per_dimension ; r + + )
2020-06-15 20:48:59 +08:00
{
2020-06-19 18:08:36 +08:00
points [ r ] = new Drawable [ points_per_dimension ] ;
2020-06-15 20:48:59 +08:00
2020-06-19 18:08:36 +08:00
for ( int c = 0 ; c < points_per_dimension ; c + + )
{
HitPointType pointType = Vector2 . Distance ( new Vector2 ( c , r ) , centre ) < = innerRadius
? HitPointType . Hit
: HitPointType . Miss ;
2020-06-15 20:48:59 +08:00
2020-06-19 18:08:36 +08:00
var point = new HitPoint ( pointType )
2020-06-15 20:48:59 +08:00
{
Colour = pointType = = HitPointType . Hit ? new Color4 ( 102 , 255 , 204 , 255 ) : new Color4 ( 255 , 102 , 102 , 255 )
2020-06-19 18:08:36 +08:00
} ;
points [ r ] [ c ] = point ;
2020-06-15 20:48:59 +08:00
}
}
2020-06-15 21:45:18 +08:00
2020-06-19 18:08:36 +08:00
pointGrid . Content = points ;
2020-06-19 18:58:35 +08:00
if ( score . HitEvents = = null | | score . HitEvents . Count = = 0 )
return ;
2020-06-18 21:11:03 +08:00
2020-06-19 18:58:35 +08:00
// Todo: This should probably not be done like this.
2020-06-22 17:38:41 +08:00
float radius = OsuHitObject . OBJECT_RADIUS * ( 1.0f - 0.7f * ( playableBeatmap . BeatmapInfo . BaseDifficulty . CircleSize - 5 ) / 5 ) / 2 ;
2020-06-18 21:11:03 +08:00
2020-06-22 18:05:41 +08:00
foreach ( var e in score . HitEvents . Where ( e = > e . HitObject is HitCircle & & ! ( e . HitObject is SliderTailCircle ) ) )
2020-06-19 18:58:35 +08:00
{
2020-06-22 18:04:51 +08:00
if ( e . LastHitObject = = null | | e . Position = = null )
2020-06-19 18:58:35 +08:00
continue ;
2020-06-22 18:04:51 +08:00
AddPoint ( ( ( OsuHitObject ) e . LastHitObject ) . StackedEndPosition , ( ( OsuHitObject ) e . HitObject ) . StackedEndPosition , e . Position . Value , radius ) ;
2020-06-16 16:20:38 +08:00
}
2020-06-15 20:48:59 +08:00
}
2020-06-16 15:31:02 +08:00
protected void AddPoint ( Vector2 start , Vector2 end , Vector2 hitPoint , float radius )
2020-06-15 20:48:59 +08:00
{
2020-06-19 18:08:36 +08:00
if ( pointGrid . Content . Length = = 0 )
2020-06-16 15:31:02 +08:00
return ;
2020-06-15 20:48:59 +08:00
double angle1 = Math . Atan2 ( end . Y - hitPoint . Y , hitPoint . X - end . X ) ; // Angle between the end point and the hit point.
double angle2 = Math . Atan2 ( end . Y - start . Y , start . X - end . X ) ; // Angle between the end point and the start point.
double finalAngle = angle2 - angle1 ; // Angle between start, end, and hit points.
float normalisedDistance = Vector2 . Distance ( hitPoint , end ) / radius ;
2020-06-22 17:05:21 +08:00
// Consider two objects placed horizontally, with the start on the left and the end on the right.
// The above calculated the angle between {end, start}, and the angle between {end, hitPoint}, in the form:
// +pi | 0
// O --------- O -----> Note: Math.Atan2 has a range (-pi <= theta <= +pi)
// -pi | 0
// E.g. If the hit point was directly above end, it would have an angle pi/2.
//
// It also calculated the angle separating hitPoint from the line joining {start, end}, that is anti-clockwise in the form:
// 0 | pi
// O --------- O ----->
// 2pi | pi
//
// However keep in mind that cos(0)=1 and cos(2pi)=1, whereas we actually want these values to appear on the left, so the x-coordinate needs to be inverted.
// Likewise sin(pi/2)=1 and sin(3pi/2)=-1, whereas we actually want these values to appear on the bottom/top respectively, so the y-coordinate also needs to be inverted.
//
// We also need to apply the anti-clockwise rotation.
var rotatedAngle = finalAngle - MathUtils . DegreesToRadians ( rotation ) ;
var rotatedCoordinate = - 1 * new Vector2 ( ( float ) Math . Cos ( rotatedAngle ) , ( float ) Math . Sin ( rotatedAngle ) ) ;
2020-06-22 19:45:44 +08:00
Vector2 localCentre = new Vector2 ( points_per_dimension - 1 ) / 2 ;
2020-06-19 18:08:36 +08:00
float localRadius = localCentre . X * inner_portion * normalisedDistance ; // The radius inside the inner portion which of the heatmap which the closest point lies.
2020-06-22 17:05:21 +08:00
Vector2 localPoint = localCentre + localRadius * rotatedCoordinate ;
2020-06-16 15:31:02 +08:00
2020-06-15 20:48:59 +08:00
// Find the most relevant hit point.
2020-06-22 14:48:42 +08:00
int r = Math . Clamp ( ( int ) Math . Round ( localPoint . Y ) , 0 , points_per_dimension - 1 ) ;
int c = Math . Clamp ( ( int ) Math . Round ( localPoint . X ) , 0 , points_per_dimension - 1 ) ;
2020-06-15 20:48:59 +08:00
2020-06-22 14:48:42 +08:00
( ( HitPoint ) pointGrid . Content [ r ] [ c ] ) . Increment ( ) ;
2020-06-15 20:48:59 +08:00
}
private class HitPoint : Circle
{
private readonly HitPointType pointType ;
2020-06-19 18:08:36 +08:00
public HitPoint ( HitPointType pointType )
2020-06-15 20:48:59 +08:00
{
this . pointType = pointType ;
2020-06-19 18:08:36 +08:00
RelativeSizeAxes = Axes . Both ;
2020-06-15 20:48:59 +08:00
Alpha = 0 ;
}
public void Increment ( )
{
if ( Alpha < 1 )
Alpha + = 0.1f ;
else if ( pointType = = HitPointType . Hit )
Colour = ( ( Color4 ) Colour ) . Lighten ( 0.1f ) ;
}
}
private enum HitPointType
{
Hit ,
Miss
}
}
}