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-24 21:29:30 +08:00
using System.Diagnostics ;
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 ;
2023-05-17 14:22:48 +08:00
using osu.Game.Graphics.Sprites ;
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 partial 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 ;
2023-05-17 14:03:59 +08:00
private BufferedContainer bufferedGrid = null ! ;
private GridContainer pointGrid = null ! ;
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-25 17:44:04 +08:00
private const float line_thickness = 2 ;
2020-06-24 21:29:30 +08:00
/// <summary>
/// The highest count of any point currently being displayed.
/// </summary>
protected float PeakValue { get ; private set ; }
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 ( )
{
2023-05-23 15:21:56 +08:00
const float line_extension = 0.2f ;
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 Container
{
RelativeSizeAxes = Axes . Both ,
Children = new Drawable [ ]
2020-06-15 20:48:59 +08:00
{
2023-05-17 14:22:48 +08:00
new CircularContainer
{
Anchor = Anchor . Centre ,
Origin = Anchor . Centre ,
RelativeSizeAxes = Axes . Both ,
Size = new Vector2 ( inner_portion ) ,
Masking = true ,
BorderThickness = line_thickness ,
BorderColour = Color4 . White ,
Child = new Box
{
RelativeSizeAxes = Axes . Both ,
Colour = Color4Extensions . FromHex ( "#202624" )
}
} ,
2020-06-22 20:09:47 +08:00
new Container
2020-06-16 15:31:02 +08:00
{
2020-06-22 20:09:47 +08:00
RelativeSizeAxes = Axes . Both ,
Padding = new MarginPadding ( 1 ) ,
2023-05-17 14:22:48 +08:00
Anchor = Anchor . Centre ,
Origin = Anchor . Centre ,
Rotation = rotation ,
2020-06-22 20:09:47 +08:00
Child = new Container
{
RelativeSizeAxes = Axes . Both ,
Children = new Drawable [ ]
{
2023-05-23 15:21:56 +08:00
new Circle
2020-06-22 20:09:47 +08:00
{
Anchor = Anchor . Centre ,
Origin = Anchor . Centre ,
RelativeSizeAxes = Axes . Y ,
2023-05-23 15:21:56 +08:00
Width = line_thickness ,
Height = inner_portion + line_extension ,
2023-05-17 14:22:48 +08:00
Rotation = - rotation * 2 ,
2023-05-23 15:21:56 +08:00
Alpha = 0.6f ,
2020-06-22 20:09:47 +08:00
} ,
2023-05-23 15:21:56 +08:00
new Circle
2020-06-22 20:09:47 +08:00
{
Anchor = Anchor . Centre ,
Origin = Anchor . Centre ,
RelativeSizeAxes = Axes . Y ,
2023-05-23 15:21:56 +08:00
Width = line_thickness ,
Height = inner_portion + line_extension ,
2023-05-17 14:22:48 +08:00
} ,
new OsuSpriteText
{
2023-05-23 15:21:56 +08:00
Text = "Overshoot" ,
2023-05-17 14:22:48 +08:00
Anchor = Anchor . Centre ,
2023-05-23 15:21:56 +08:00
Origin = Anchor . BottomCentre ,
2023-05-17 14:22:48 +08:00
Padding = new MarginPadding ( 3 ) ,
RelativePositionAxes = Axes . Both ,
2023-05-23 15:21:56 +08:00
Y = - ( inner_portion + line_extension ) / 2 ,
2023-05-17 14:22:48 +08:00
} ,
new OsuSpriteText
{
2023-05-23 15:21:56 +08:00
Text = "Undershoot" ,
2023-05-17 14:22:48 +08:00
Anchor = Anchor . Centre ,
2023-05-23 15:21:56 +08:00
Origin = Anchor . TopCentre ,
2023-05-17 14:22:48 +08:00
Padding = new MarginPadding ( 3 ) ,
RelativePositionAxes = Axes . Both ,
2023-05-23 15:21:56 +08:00
Y = ( inner_portion + line_extension ) / 2 ,
2023-05-17 14:22:48 +08:00
} ,
2023-05-23 15:21:56 +08:00
new Circle
2023-05-17 14:22:48 +08:00
{
Anchor = Anchor . Centre ,
2023-05-23 15:21:56 +08:00
Origin = Anchor . TopCentre ,
2023-05-17 14:22:48 +08:00
RelativePositionAxes = Axes . Both ,
2023-05-23 15:21:56 +08:00
Y = - ( inner_portion + line_extension ) / 2 ,
Margin = new MarginPadding ( - line_thickness / 2 ) ,
Width = line_thickness ,
Height = 10 ,
Rotation = 45 ,
2023-05-17 14:22:48 +08:00
} ,
2023-05-23 15:21:56 +08:00
new Circle
2023-05-17 14:22:48 +08:00
{
Anchor = Anchor . Centre ,
2023-05-23 15:21:56 +08:00
Origin = Anchor . TopCentre ,
2023-05-17 14:22:48 +08:00
RelativePositionAxes = Axes . Both ,
2023-05-23 15:21:56 +08:00
Y = - ( inner_portion + line_extension ) / 2 ,
Margin = new MarginPadding ( - line_thickness / 2 ) ,
Width = line_thickness ,
Height = 10 ,
Rotation = - 45 ,
}
2020-06-22 20:09:47 +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
} ,
2021-11-05 14:54:27 +08:00
bufferedGrid = new BufferedContainer ( cachedFrameBuffer : true )
2020-06-16 15:31:02 +08:00
{
2020-06-22 20:00:13 +08:00
RelativeSizeAxes = Axes . Both ,
BackgroundColour = Color4Extensions . FromHex ( "#202624" ) . Opacity ( 0 ) ,
Child = pointGrid = new GridContainer
{
RelativeSizeAxes = Axes . Both
}
} ,
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-24 21:29:30 +08:00
var point = new HitPoint ( pointType , this )
2020-06-15 20:48:59 +08:00
{
2021-05-17 17:07:50 +08:00
BaseColour = 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 ;
2022-01-12 21:34:07 +08:00
if ( score . HitEvents . Count = = 0 )
2020-06-19 18:58:35 +08:00
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.
2021-10-02 11:34:29 +08:00
float radius = OsuHitObject . OBJECT_RADIUS * ( 1.0f - 0.7f * ( playableBeatmap . Difficulty . 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-09-10 17:09:03 +08:00
if ( pointGrid . Content . Count = = 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.
2021-10-27 12:04:41 +08:00
double rotatedAngle = finalAngle - MathUtils . DegreesToRadians ( rotation ) ;
2020-06-22 17:05:21 +08:00
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-24 21:29:30 +08:00
PeakValue = Math . Max ( PeakValue , ( ( HitPoint ) pointGrid . Content [ r ] [ c ] ) . Increment ( ) ) ;
2020-06-22 20:00:13 +08:00
bufferedGrid . ForceRedraw ( ) ;
2020-06-15 20:48:59 +08:00
}
private partial class HitPoint : Circle
{
2021-05-17 17:07:50 +08:00
/// <summary>
/// The base colour which will be lightened/darkened depending on the value of this <see cref="HitPoint"/>.
/// </summary>
public Color4 BaseColour ;
2020-06-15 20:48:59 +08:00
private readonly HitPointType pointType ;
2020-06-24 21:29:30 +08:00
private readonly AccuracyHeatmap heatmap ;
public override bool IsPresent = > count > 0 ;
2020-06-15 20:48:59 +08:00
2020-06-24 21:29:30 +08:00
public HitPoint ( HitPointType pointType , AccuracyHeatmap heatmap )
2020-06-15 20:48:59 +08:00
{
this . pointType = pointType ;
2020-06-24 21:29:30 +08:00
this . heatmap = heatmap ;
2020-06-15 20:48:59 +08:00
2020-06-19 18:08:36 +08:00
RelativeSizeAxes = Axes . Both ;
2020-06-24 21:29:30 +08:00
Alpha = 1 ;
}
private int count ;
/// <summary>
/// Increment the value of this point by one.
/// </summary>
/// <returns>The value after incrementing.</returns>
public int Increment ( )
{
return + + count ;
2020-06-15 20:48:59 +08:00
}
2020-06-24 21:29:30 +08:00
protected override void Update ( )
2020-06-15 20:48:59 +08:00
{
2020-06-24 21:29:30 +08:00
base . Update ( ) ;
// the point at which alpha is saturated and we begin to adjust colour lightness.
const float lighten_cutoff = 0.95f ;
// the amount of lightness to attribute regardless of relative value to peak point.
const float non_relative_portion = 0.2f ;
float amount = 0 ;
// give some amount of alpha regardless of relative count
amount + = non_relative_portion * Math . Min ( 1 , count / 10f ) ;
// add relative portion
amount + = ( 1 - non_relative_portion ) * ( count / heatmap . PeakValue ) ;
// apply easing
amount = ( float ) Interpolation . ApplyEasing ( Easing . OutQuint , Math . Min ( 1 , amount ) ) ;
Debug . Assert ( amount < = 1 ) ;
Alpha = Math . Min ( amount / lighten_cutoff , 1 ) ;
if ( pointType = = HitPointType . Hit )
2021-05-17 17:07:50 +08:00
Colour = BaseColour . Lighten ( Math . Max ( 0 , amount - lighten_cutoff ) ) ;
2020-06-15 20:48:59 +08:00
}
}
private enum HitPointType
{
Hit ,
Miss
}
}
}