1
0
mirror of https://github.com/ppy/osu.git synced 2025-01-12 11:42:54 +08:00

Merge branch 'master' into convert-path-string-new

This commit is contained in:
Salman Ahmed 2024-03-22 05:15:04 +03:00 committed by GitHub
commit 1efa08a1b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 1031 additions and 351 deletions

View File

@ -5,14 +5,21 @@ using System;
using System.Text;
using DiscordRPC;
using DiscordRPC.Message;
using Newtonsoft.Json;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Logging;
using osu.Framework.Threading;
using osu.Game;
using osu.Game.Configuration;
using osu.Game.Extensions;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Overlays;
using osu.Game.Rulesets;
using osu.Game.Users;
using LogLevel = osu.Framework.Logging.LogLevel;
@ -21,7 +28,7 @@ namespace osu.Desktop
{
internal partial class DiscordRichPresence : Component
{
private const string client_id = "367827983903490050";
private const string client_id = "1216669957799018608";
private DiscordRpcClient client = null!;
@ -33,6 +40,15 @@ namespace osu.Desktop
[Resolved]
private IAPIProvider api { get; set; } = null!;
[Resolved]
private OsuGame game { get; set; } = null!;
[Resolved]
private LoginOverlay? login { get; set; }
[Resolved]
private MultiplayerClient multiplayerClient { get; set; } = null!;
private readonly IBindable<UserStatus?> status = new Bindable<UserStatus?>();
private readonly IBindable<UserActivity> activity = new Bindable<UserActivity>();
@ -40,7 +56,12 @@ namespace osu.Desktop
private readonly RichPresence presence = new RichPresence
{
Assets = new Assets { LargeImageKey = "osu_logo_lazer", }
Assets = new Assets { LargeImageKey = "osu_logo_lazer" },
Secrets = new Secrets
{
JoinSecret = null,
SpectateSecret = null,
},
};
[BackgroundDependencyLoader]
@ -48,12 +69,18 @@ namespace osu.Desktop
{
client = new DiscordRpcClient(client_id)
{
SkipIdenticalPresence = false // handles better on discord IPC loss, see updateStatus call in onReady.
// SkipIdenticalPresence allows us to fire SetPresence at any point and leave it to the underlying implementation
// to check whether a difference has actually occurred before sending a command to Discord (with a minor caveat that's handled in onReady).
SkipIdenticalPresence = true
};
client.OnReady += onReady;
client.OnError += (_, e) => Logger.Log($"An error occurred with Discord RPC Client: {e.Code} {e.Message}", LoggingTarget.Network, LogLevel.Error);
client.OnError += (_, e) => Logger.Log($"An error occurred with Discord RPC Client: {e.Code} {e.Message}", LoggingTarget.Network);
// A URI scheme is required to support game invitations, as well as informing Discord of the game executable path to support launching the game when a user clicks on join/spectate.
client.RegisterUriScheme();
client.Subscribe(EventType.Join);
client.OnJoin += onJoin;
config.BindWith(OsuSetting.DiscordRichPresence, privacyMode);
@ -67,10 +94,11 @@ namespace osu.Desktop
activity.BindTo(u.NewValue.Activity);
}, true);
ruleset.BindValueChanged(_ => updateStatus());
status.BindValueChanged(_ => updateStatus());
activity.BindValueChanged(_ => updateStatus());
privacyMode.BindValueChanged(_ => updateStatus());
ruleset.BindValueChanged(_ => updatePresence());
status.BindValueChanged(_ => updatePresence());
activity.BindValueChanged(_ => updatePresence());
privacyMode.BindValueChanged(_ => updatePresence());
multiplayerClient.RoomUpdated += onRoomUpdated;
client.Initialize();
}
@ -78,24 +106,46 @@ namespace osu.Desktop
private void onReady(object _, ReadyMessage __)
{
Logger.Log("Discord RPC Client ready.", LoggingTarget.Network, LogLevel.Debug);
updateStatus();
// when RPC is lost and reconnected, we have to clear presence state for updatePresence to work (see DiscordRpcClient.SkipIdenticalPresence).
if (client.CurrentPresence != null)
client.SetPresence(null);
updatePresence();
}
private void updateStatus()
private void onRoomUpdated() => updatePresence();
private ScheduledDelegate? presenceUpdateDelegate;
private void updatePresence()
{
if (!client.IsInitialized)
return;
if (status.Value == UserStatus.Offline || privacyMode.Value == DiscordRichPresenceMode.Off)
presenceUpdateDelegate?.Cancel();
presenceUpdateDelegate = Scheduler.AddDelayed(() =>
{
client.ClearPresence();
return;
}
if (!client.IsInitialized)
return;
if (status.Value == UserStatus.Offline || privacyMode.Value == DiscordRichPresenceMode.Off)
{
client.ClearPresence();
return;
}
if (activity.Value != null)
{
bool hideIdentifiableInformation = privacyMode.Value == DiscordRichPresenceMode.Limited || status.Value == UserStatus.DoNotDisturb;
updatePresenceStatus(hideIdentifiableInformation);
updatePresenceParty(hideIdentifiableInformation);
updatePresenceAssets();
client.SetPresence(presence);
}, 200);
}
private void updatePresenceStatus(bool hideIdentifiableInformation)
{
if (activity.Value != null)
{
presence.State = truncate(activity.Value.GetStatus(hideIdentifiableInformation));
presence.Details = truncate(activity.Value.GetDetails(hideIdentifiableInformation) ?? string.Empty);
@ -120,7 +170,41 @@ namespace osu.Desktop
presence.State = "Idle";
presence.Details = string.Empty;
}
}
private void updatePresenceParty(bool hideIdentifiableInformation)
{
if (!hideIdentifiableInformation && multiplayerClient.Room != null)
{
MultiplayerRoom room = multiplayerClient.Room;
presence.Party = new Party
{
Privacy = string.IsNullOrEmpty(room.Settings.Password) ? Party.PrivacySetting.Public : Party.PrivacySetting.Private,
ID = room.RoomID.ToString(),
// technically lobbies can have infinite users, but Discord needs this to be set to something.
// to make party display sensible, assign a powers of two above participants count (8 at minimum).
Max = (int)Math.Max(8, Math.Pow(2, Math.Ceiling(Math.Log2(room.Users.Count)))),
Size = room.Users.Count,
};
RoomSecret roomSecret = new RoomSecret
{
RoomID = room.RoomID,
Password = room.Settings.Password,
};
presence.Secrets.JoinSecret = JsonConvert.SerializeObject(roomSecret);
}
else
{
presence.Party = null;
presence.Secrets.JoinSecret = null;
}
}
private void updatePresenceAssets()
{
// update user information
if (privacyMode.Value == DiscordRichPresenceMode.Limited)
presence.Assets.LargeImageText = string.Empty;
@ -135,13 +219,40 @@ namespace osu.Desktop
// update ruleset
presence.Assets.SmallImageKey = ruleset.Value.IsLegacyRuleset() ? $"mode_{ruleset.Value.OnlineID}" : "mode_custom";
presence.Assets.SmallImageText = ruleset.Value.Name;
client.SetPresence(presence);
}
private void onJoin(object sender, JoinMessage args) => Scheduler.AddOnce(() =>
{
game.Window?.Raise();
if (!api.IsLoggedIn)
{
login?.Show();
return;
}
Logger.Log($"Received room secret from Discord RPC Client: \"{args.Secret}\"", LoggingTarget.Network, LogLevel.Debug);
// Stable and lazer share the same Discord client ID, meaning they can accept join requests from each other.
// Since they aren't compatible in multi, see if stable's format is being used and log to avoid confusion.
if (args.Secret[0] != '{' || !tryParseRoomSecret(args.Secret, out long roomId, out string? password))
{
Logger.Log("Could not join multiplayer room, invitation is invalid or incompatible.", LoggingTarget.Network, LogLevel.Important);
return;
}
var request = new GetRoomRequest(roomId);
request.Success += room => Schedule(() =>
{
game.PresentMultiplayerMatch(room, password);
});
request.Failure += _ => Logger.Log($"Could not join multiplayer room, room could not be found (room ID: {roomId}).", LoggingTarget.Network, LogLevel.Important);
api.Queue(request);
});
private static readonly int ellipsis_length = Encoding.UTF8.GetByteCount(new[] { '…' });
private string truncate(string str)
private static string truncate(string str)
{
if (Encoding.UTF8.GetByteCount(str) <= 128)
return str;
@ -160,7 +271,31 @@ namespace osu.Desktop
});
}
private int? getBeatmapID(UserActivity activity)
private static bool tryParseRoomSecret(string secretJson, out long roomId, out string? password)
{
roomId = 0;
password = null;
RoomSecret? roomSecret;
try
{
roomSecret = JsonConvert.DeserializeObject<RoomSecret>(secretJson);
}
catch
{
return false;
}
if (roomSecret == null) return false;
roomId = roomSecret.RoomID;
password = roomSecret.Password;
return true;
}
private static int? getBeatmapID(UserActivity activity)
{
switch (activity)
{
@ -176,8 +311,20 @@ namespace osu.Desktop
protected override void Dispose(bool isDisposing)
{
if (multiplayerClient.IsNotNull())
multiplayerClient.RoomUpdated -= onRoomUpdated;
client.Dispose();
base.Dispose(isDisposing);
}
private class RoomSecret
{
[JsonProperty(@"roomId", Required = Required.Always)]
public long RoomID { get; set; }
[JsonProperty(@"password", Required = Required.AllowNull)]
public string? Password { get; set; }
}
}
}

View File

@ -19,6 +19,7 @@ namespace osu.Game.Rulesets.Catch.Tests
[new Droplet(), 0.01, true],
[new TinyDroplet(), 0, false],
[new Banana(), 0, false],
[new BananaShower(), 0, false]
];
[TestCaseSource(nameof(test_cases))]

View File

@ -32,6 +32,10 @@ namespace osu.Game.Rulesets.Catch.Scoring
if (result.Type == HitResult.SmallTickMiss)
return false;
// on stable, banana showers don't exist as concrete objects themselves, so they can't cause a fail.
if (result.HitObject is BananaShower)
return false;
return base.CheckDefaultFailCondition(result);
}

View File

@ -16,6 +16,8 @@ using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.UI;
using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
using osuTK;
namespace osu.Game.Rulesets.Catch.UI
{
@ -52,5 +54,7 @@ namespace osu.Game.Rulesets.Catch.UI
protected override PassThroughInputManager CreateInputManager() => new CatchInputManager(Ruleset.RulesetInfo);
public override DrawableHitObject<CatchHitObject>? CreateDrawableRepresentation(CatchHitObject h) => null;
protected override ResumeOverlay CreateResumeOverlay() => new DelayedResumeOverlay { Scale = new Vector2(0.65f) };
}
}

View File

@ -26,6 +26,7 @@ using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.UI;
using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
using osu.Game.Skinning;
namespace osu.Game.Rulesets.Mania.UI
@ -164,6 +165,8 @@ namespace osu.Game.Rulesets.Mania.UI
protected override ReplayRecorder CreateReplayRecorder(Score score) => new ManiaReplayRecorder(score);
protected override ResumeOverlay CreateResumeOverlay() => new DelayedResumeOverlay();
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);

View File

@ -6,11 +6,11 @@ using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Testing;
using osu.Game.Configuration;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.Osu.UI.Cursor;
using osu.Game.Rulesets.UI;
using osu.Game.Screens.Play;
using osu.Game.Tests.Gameplay;
using osu.Game.Tests.Visual;
@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Osu.Tests
public partial class TestSceneResumeOverlay : OsuManualInputManagerTestScene
{
private ManualOsuInputManager osuInputManager = null!;
private CursorContainer cursor = null!;
private GameplayCursorContainer cursor = null!;
private ResumeOverlay resume = null!;
private bool resumeFired;
@ -99,7 +99,17 @@ namespace osu.Game.Rulesets.Osu.Tests
private void loadContent()
{
Child = osuInputManager = new ManualOsuInputManager(new OsuRuleset().RulesetInfo) { Children = new Drawable[] { cursor = new CursorContainer(), resume = new OsuResumeOverlay { GameplayCursor = cursor }, } };
Child = osuInputManager = new ManualOsuInputManager(new OsuRuleset().RulesetInfo)
{
Children = new Drawable[]
{
cursor = new GameplayCursorContainer(),
resume = new OsuResumeOverlay
{
GameplayCursor = cursor
},
}
};
resumeFired = false;
resume.ResumeAction = () => resumeFired = true;

View File

@ -91,11 +91,11 @@ namespace osu.Game.Rulesets.Osu.Tests
var skinnable = firstObject.ApproachCircle;
if (skin == null && skinnable?.Drawable is DefaultApproachCircle)
if (skin == null && skinnable.Drawable is DefaultApproachCircle)
// check for default skin provider
return true;
var text = skinnable?.Drawable as SpriteText;
var text = skinnable.Drawable as SpriteText;
return text?.Text == skin;
});

View File

@ -95,12 +95,7 @@ namespace osu.Game.Rulesets.Osu.Mods
/// </summary>
private static void blockInputToObjectsUnderSliderHead(DrawableSliderHead slider)
{
var oldHitAction = slider.HitArea.Hit;
slider.HitArea.Hit = () =>
{
oldHitAction?.Invoke();
return !slider.DrawableSlider.AllJudged;
};
slider.HitArea.CanBeHit = () => !slider.DrawableSlider.AllJudged;
}
private void applyEarlyFading(DrawableHitCircle circle)

View File

@ -1,16 +1,12 @@
// 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.
#nullable disable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Game.Graphics.Containers;
@ -28,35 +24,30 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
{
public partial class DrawableHitCircle : DrawableOsuHitObject, IHasApproachCircle
{
public OsuAction? HitAction => HitArea?.HitAction;
public OsuAction? HitAction => HitArea.HitAction;
protected virtual OsuSkinComponents CirclePieceComponent => OsuSkinComponents.HitCircle;
public SkinnableDrawable ApproachCircle { get; private set; }
public HitReceptor HitArea { get; private set; }
public SkinnableDrawable CirclePiece { get; private set; }
public SkinnableDrawable ApproachCircle { get; private set; } = null!;
public HitReceptor HitArea { get; private set; } = null!;
public SkinnableDrawable CirclePiece { get; private set; } = null!;
protected override IEnumerable<Drawable> DimmablePieces => new[]
{
CirclePiece,
};
protected override IEnumerable<Drawable> DimmablePieces => new[] { CirclePiece };
Drawable IHasApproachCircle.ApproachCircle => ApproachCircle;
private Container scaleContainer;
private InputManager inputManager;
private Container scaleContainer = null!;
private ShakeContainer shakeContainer = null!;
public DrawableHitCircle()
: this(null)
{
}
public DrawableHitCircle([CanBeNull] HitCircle h = null)
public DrawableHitCircle(HitCircle? h = null)
: base(h)
{
}
private ShakeContainer shakeContainer;
[BackgroundDependencyLoader]
private void load()
{
@ -73,14 +64,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
{
HitArea = new HitReceptor
{
Hit = () =>
{
if (AllJudged)
return false;
UpdateResult(true);
return true;
},
CanBeHit = () => !AllJudged,
Hit = () => UpdateResult(true)
},
shakeContainer = new ShakeContainer
{
@ -114,13 +99,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
ScaleBindable.BindValueChanged(scale => scaleContainer.Scale = new Vector2(scale.NewValue));
}
protected override void LoadComplete()
{
base.LoadComplete();
inputManager = GetContainingInputManager();
}
public override double LifetimeStart
{
get => base.LifetimeStart;
@ -155,7 +133,15 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
if (!userTriggered)
{
if (!HitObject.HitWindows.CanBeHit(timeOffset))
ApplyMinResult();
{
ApplyResult((r, position) =>
{
var circleResult = (OsuHitCircleJudgementResult)r;
circleResult.Type = r.Judgement.MinResult;
circleResult.CursorPositionAtHit = position;
}, computeHitPosition());
}
return;
}
@ -169,22 +155,21 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
if (result == HitResult.None || clickAction != ClickAction.Hit)
return;
Vector2? hitPosition = null;
// Todo: This should also consider misses, but they're a little more interesting to handle, since we don't necessarily know the position at the time of a miss.
if (result.IsHit())
{
var localMousePosition = ToLocalSpace(inputManager.CurrentState.Mouse.Position);
hitPosition = HitObject.StackedPosition + (localMousePosition - DrawSize / 2);
}
ApplyResult<(HitResult result, Vector2? position)>((r, state) =>
{
var circleResult = (OsuHitCircleJudgementResult)r;
circleResult.Type = state.result;
circleResult.CursorPositionAtHit = state.position;
}, (result, hitPosition));
}, (result, computeHitPosition()));
}
private Vector2? computeHitPosition()
{
if (HitArea.ClosestPressPosition is Vector2 screenSpaceHitPosition)
return HitObject.StackedPosition + (ToLocalSpace(screenSpaceHitPosition) - DrawSize / 2);
return null;
}
/// <summary>
@ -227,7 +212,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
break;
case ArmedState.Idle:
HitArea.HitAction = null;
HitArea.Reset();
break;
case ArmedState.Miss:
@ -247,9 +232,25 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
// IsHovered is used
public override bool HandlePositionalInput => true;
public Func<bool> Hit;
/// <summary>
/// Whether the hitobject can still be hit at the current point in time.
/// </summary>
public required Func<bool> CanBeHit { get; set; }
public OsuAction? HitAction;
/// <summary>
/// An action that's invoked to perform the hit.
/// </summary>
public required Action Hit { get; set; }
/// <summary>
/// The <see cref="OsuAction"/> with which the hit was attempted.
/// </summary>
public OsuAction? HitAction { get; private set; }
/// <summary>
/// The closest position to the hit receptor at the point where the hit was attempted.
/// </summary>
public Vector2? ClosestPressPosition { get; private set; }
public HitReceptor()
{
@ -264,12 +265,27 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
public bool OnPressed(KeyBindingPressEvent<OsuAction> e)
{
if (!CanBeHit())
return false;
switch (e.Action)
{
case OsuAction.LeftButton:
case OsuAction.RightButton:
if (IsHovered && (Hit?.Invoke() ?? false))
if (ClosestPressPosition is Vector2 curClosest)
{
float oldDist = Vector2.DistanceSquared(curClosest, ScreenSpaceDrawQuad.Centre);
float newDist = Vector2.DistanceSquared(e.ScreenSpaceMousePosition, ScreenSpaceDrawQuad.Centre);
if (newDist < oldDist)
ClosestPressPosition = e.ScreenSpaceMousePosition;
}
else
ClosestPressPosition = e.ScreenSpaceMousePosition;
if (IsHovered)
{
Hit();
HitAction ??= e.Action;
return true;
}
@ -283,13 +299,22 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
public void OnReleased(KeyBindingReleaseEvent<OsuAction> e)
{
}
/// <summary>
/// Resets to a fresh state.
/// </summary>
public void Reset()
{
HitAction = null;
ClosestPressPosition = null;
}
}
private partial class ProxyableSkinnableDrawable : SkinnableDrawable
{
public override bool RemoveWhenNotAlive => false;
public ProxyableSkinnableDrawable(ISkinComponentLookup lookup, Func<ISkinComponentLookup, Drawable> defaultImplementation = null, ConfineMode confineMode = ConfineMode.NoScaling)
public ProxyableSkinnableDrawable(ISkinComponentLookup lookup, Func<ISkinComponentLookup, Drawable>? defaultImplementation = null, ConfineMode confineMode = ConfineMode.NoScaling)
: base(lookup, defaultImplementation, confineMode)
{
}

View File

@ -9,6 +9,7 @@ using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Graphics;
@ -191,16 +192,22 @@ namespace osu.Game.Rulesets.Osu.Statistics
for (int c = 0; c < points_per_dimension; c++)
{
HitPointType pointType = Vector2.Distance(new Vector2(c + 0.5f, r + 0.5f), centre) <= innerRadius
? HitPointType.Hit
: HitPointType.Miss;
bool isHit = Vector2.Distance(new Vector2(c + 0.5f, r + 0.5f), centre) <= innerRadius;
var point = new HitPoint(pointType, this)
if (isHit)
{
BaseColour = pointType == HitPointType.Hit ? new Color4(102, 255, 204, 255) : new Color4(255, 102, 102, 255)
};
points[r][c] = point;
points[r][c] = new HitPoint(this)
{
BaseColour = new Color4(102, 255, 204, 255)
};
}
else
{
points[r][c] = new MissPoint
{
BaseColour = new Color4(255, 102, 102, 255)
};
}
}
}
@ -250,40 +257,31 @@ namespace osu.Game.Rulesets.Osu.Statistics
var rotatedCoordinate = -1 * new Vector2((float)Math.Cos(rotatedAngle), (float)Math.Sin(rotatedAngle));
Vector2 localCentre = new Vector2(points_per_dimension - 1) / 2;
float localRadius = localCentre.X * inner_portion * normalisedDistance; // The radius inside the inner portion which of the heatmap which the closest point lies.
float localRadius = localCentre.X * inner_portion * normalisedDistance;
Vector2 localPoint = localCentre + localRadius * rotatedCoordinate;
// Find the most relevant hit point.
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);
int r = (int)Math.Round(localPoint.Y);
int c = (int)Math.Round(localPoint.X);
PeakValue = Math.Max(PeakValue, ((HitPoint)pointGrid.Content[r][c]).Increment());
if (r < 0 || r >= points_per_dimension || c < 0 || c >= points_per_dimension)
return;
PeakValue = Math.Max(PeakValue, ((GridPoint)pointGrid.Content[r][c]).Increment());
bufferedGrid.ForceRedraw();
}
private partial class HitPoint : Circle
private abstract partial class GridPoint : CompositeDrawable
{
/// <summary>
/// The base colour which will be lightened/darkened depending on the value of this <see cref="HitPoint"/>.
/// </summary>
public Color4 BaseColour;
private readonly HitPointType pointType;
private readonly AccuracyHeatmap heatmap;
public override bool IsPresent => Count > 0;
public override bool IsPresent => count > 0;
public HitPoint(HitPointType pointType, AccuracyHeatmap heatmap)
{
this.pointType = pointType;
this.heatmap = heatmap;
RelativeSizeAxes = Axes.Both;
Alpha = 1;
}
private int count;
protected int Count { get; private set; }
/// <summary>
/// Increment the value of this point by one.
@ -291,7 +289,41 @@ namespace osu.Game.Rulesets.Osu.Statistics
/// <returns>The value after incrementing.</returns>
public int Increment()
{
return ++count;
return ++Count;
}
}
private partial class MissPoint : GridPoint
{
public MissPoint()
{
RelativeSizeAxes = Axes.Both;
InternalChild = new SpriteIcon
{
RelativeSizeAxes = Axes.Both,
Icon = FontAwesome.Solid.Times
};
}
protected override void Update()
{
Alpha = 0.8f;
Colour = BaseColour;
}
}
private partial class HitPoint : GridPoint
{
private readonly AccuracyHeatmap heatmap;
public HitPoint(AccuracyHeatmap heatmap)
{
this.heatmap = heatmap;
RelativeSizeAxes = Axes.Both;
InternalChild = new Circle { RelativeSizeAxes = Axes.Both };
}
protected override void Update()
@ -307,10 +339,10 @@ namespace osu.Game.Rulesets.Osu.Statistics
float amount = 0;
// give some amount of alpha regardless of relative count
amount += non_relative_portion * Math.Min(1, count / 10f);
amount += non_relative_portion * Math.Min(1, Count / 10f);
// add relative portion
amount += (1 - non_relative_portion) * (count / heatmap.PeakValue);
amount += (1 - non_relative_portion) * (Count / heatmap.PeakValue);
// apply easing
amount = (float)Interpolation.ApplyEasing(Easing.OutQuint, Math.Min(1, amount));
@ -318,15 +350,8 @@ namespace osu.Game.Rulesets.Osu.Statistics
Debug.Assert(amount <= 1);
Alpha = Math.Min(amount / lighten_cutoff, 1);
if (pointType == HitPointType.Hit)
Colour = BaseColour.Lighten(Math.Max(0, amount - lighten_cutoff));
Colour = BaseColour.Lighten(Math.Max(0, amount - lighten_cutoff));
}
}
private enum HitPointType
{
Hit,
Miss
}
}
}

View File

@ -39,6 +39,13 @@ namespace osu.Game.Rulesets.Osu.UI
protected override void PopIn()
{
// Can't display if the cursor is outside the window.
if (GameplayCursor.LastFrameState == Visibility.Hidden || !Contains(GameplayCursor.ActiveCursor.ScreenSpaceDrawQuad.Centre))
{
Resume();
return;
}
base.PopIn();
GameplayCursor.ActiveCursor.Hide();

View File

@ -4,7 +4,6 @@
<OutputType>Library</OutputType>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Description>click the circles. to the beat.</Description>
<LangVersion>10</LangVersion>
</PropertyGroup>
<PropertyGroup Label="Nuget">

View File

@ -116,5 +116,7 @@ namespace osu.Game.Rulesets.Taiko.UI
protected override ReplayInputHandler CreateReplayInputHandler(Replay replay) => new TaikoFramedReplayInputHandler(replay);
protected override ReplayRecorder CreateReplayRecorder(Score score) => new TaikoReplayRecorder(score);
protected override ResumeOverlay CreateResumeOverlay() => new DelayedResumeOverlay();
}
}

View File

@ -5,7 +5,9 @@ using System.Collections.Generic;
using System.IO;
using NUnit.Framework;
using osu.Game.Beatmaps.Formats;
using osu.Game.IO.Legacy;
using osu.Game.Rulesets.Catch;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Scoring.Legacy;
@ -21,9 +23,9 @@ namespace osu.Game.Tests.Beatmaps.Formats
public void CatchMergesFruitAndDropletMisses(int missCount, int largeTickMissCount)
{
var ruleset = new CatchRuleset().RulesetInfo;
var scoreInfo = TestResources.CreateTestScoreInfo(ruleset);
var beatmap = new TestBeatmap(ruleset);
scoreInfo.Statistics = new Dictionary<HitResult, int>
{
[HitResult.Great] = 50,
@ -31,13 +33,63 @@ namespace osu.Game.Tests.Beatmaps.Formats
[HitResult.Miss] = missCount,
[HitResult.LargeTickMiss] = largeTickMissCount
};
var score = new Score { ScoreInfo = scoreInfo };
var score = new Score { ScoreInfo = scoreInfo };
var decodedAfterEncode = encodeThenDecode(LegacyBeatmapDecoder.LATEST_VERSION, score, beatmap);
Assert.That(decodedAfterEncode.ScoreInfo.GetCountMiss(), Is.EqualTo(missCount + largeTickMissCount));
}
[Test]
public void ScoreWithMissIsNotPerfect()
{
var ruleset = new OsuRuleset().RulesetInfo;
var scoreInfo = TestResources.CreateTestScoreInfo(ruleset);
var beatmap = new TestBeatmap(ruleset);
scoreInfo.Statistics = new Dictionary<HitResult, int>
{
[HitResult.Great] = 2,
[HitResult.Miss] = 1,
};
scoreInfo.MaximumStatistics = new Dictionary<HitResult, int>
{
[HitResult.Great] = 3
};
// Hit -> Miss -> Hit
scoreInfo.Combo = 1;
scoreInfo.MaxCombo = 1;
using (var ms = new MemoryStream())
{
new LegacyScoreEncoder(new Score { ScoreInfo = scoreInfo }, beatmap).Encode(ms, true);
ms.Seek(0, SeekOrigin.Begin);
using (var sr = new SerializationReader(ms))
{
sr.ReadByte(); // ruleset id
sr.ReadInt32(); // version
sr.ReadString(); // beatmap hash
sr.ReadString(); // username
sr.ReadString(); // score hash
sr.ReadInt16(); // count300
sr.ReadInt16(); // count100
sr.ReadInt16(); // count50
sr.ReadInt16(); // countGeki
sr.ReadInt16(); // countKatu
sr.ReadInt16(); // countMiss
sr.ReadInt32(); // total score
sr.ReadInt16(); // max combo
bool isPerfect = sr.ReadBoolean(); // full combo
Assert.That(isPerfect, Is.False);
}
}
}
private static Score encodeThenDecode(int beatmapVersion, Score score, TestBeatmap beatmap)
{
var encodeStream = new MemoryStream();

View File

@ -0,0 +1,145 @@
// 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.Linq;
using Moq;
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Timing;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Checks;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Storyboards;
using osuTK;
using static osu.Game.Tests.Visual.OsuTestScene.ClockBackedTestWorkingBeatmap;
namespace osu.Game.Tests.Editing.Checks
{
public class CheckUnusedAudioAtEndTest
{
private CheckUnusedAudioAtEnd check = null!;
private IBeatmap beatmapNotFullyMapped = null!;
private IBeatmap beatmapFullyMapped = null!;
[SetUp]
public void Setup()
{
check = new CheckUnusedAudioAtEnd();
beatmapNotFullyMapped = new Beatmap<HitObject>
{
HitObjects =
{
new HitCircle { StartTime = 0 },
new HitCircle { StartTime = 1_298 },
},
BeatmapInfo = new BeatmapInfo
{
Metadata = new BeatmapMetadata { AudioFile = "abc123.jpg" }
}
};
beatmapFullyMapped = new Beatmap<HitObject>
{
HitObjects =
{
new HitCircle { StartTime = 0 },
new HitCircle { StartTime = 9000 },
},
BeatmapInfo = new BeatmapInfo
{
Metadata = new BeatmapMetadata { AudioFile = "abc123.jpg" },
}
};
}
[Test]
public void TestEmptyBeatmap()
{
var context = getContext(new Beatmap<HitObject>());
var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(1));
Assert.That(issues.Single().Template is CheckUnusedAudioAtEnd.IssueTemplateUnusedAudioAtEnd);
}
[Test]
public void TestAudioNotFullyUsed()
{
var context = getContext(beatmapNotFullyMapped);
var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(1));
Assert.That(issues.Single().Template is CheckUnusedAudioAtEnd.IssueTemplateUnusedAudioAtEnd);
}
[Test]
public void TestAudioNotFullyUsedWithVideo()
{
var storyboard = new Storyboard();
var video = new StoryboardVideo("abc123.mp4", 0);
storyboard.GetLayer("Video").Add(video);
var mockWorkingBeatmap = getMockWorkingBeatmap(beatmapNotFullyMapped, storyboard);
var context = getContext(beatmapNotFullyMapped, mockWorkingBeatmap);
var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(1));
Assert.That(issues.Single().Template is CheckUnusedAudioAtEnd.IssueTemplateUnusedAudioAtEndStoryboardOrVideo);
}
[Test]
public void TestAudioNotFullyUsedWithStoryboardElement()
{
var storyboard = new Storyboard();
var sprite = new StoryboardSprite("unknown", Anchor.TopLeft, Vector2.Zero);
storyboard.GetLayer("Background").Add(sprite);
var mockWorkingBeatmap = getMockWorkingBeatmap(beatmapNotFullyMapped, storyboard);
var context = getContext(beatmapNotFullyMapped, mockWorkingBeatmap);
var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(1));
Assert.That(issues.Single().Template is CheckUnusedAudioAtEnd.IssueTemplateUnusedAudioAtEndStoryboardOrVideo);
}
[Test]
public void TestAudioFullyUsed()
{
var context = getContext(beatmapFullyMapped);
var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(0));
}
private BeatmapVerifierContext getContext(IBeatmap beatmap)
{
return new BeatmapVerifierContext(beatmap, getMockWorkingBeatmap(beatmap, new Storyboard()).Object);
}
private BeatmapVerifierContext getContext(IBeatmap beatmap, Mock<IWorkingBeatmap> workingBeatmap)
{
return new BeatmapVerifierContext(beatmap, workingBeatmap.Object);
}
private Mock<IWorkingBeatmap> getMockWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard)
{
var mockTrack = new TrackVirtualStore(new FramedClock()).GetVirtual(10000, "virtual");
var mockWorkingBeatmap = new Mock<IWorkingBeatmap>();
mockWorkingBeatmap.SetupGet(w => w.Beatmap).Returns(beatmap);
mockWorkingBeatmap.SetupGet(w => w.Track).Returns(mockTrack);
mockWorkingBeatmap.SetupGet(w => w.Storyboard).Returns(storyboard);
return mockWorkingBeatmap;
}
}
}

View File

@ -0,0 +1,44 @@
// 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 NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Osu;
using osu.Game.Screens.Play;
using osu.Game.Tests.Gameplay;
namespace osu.Game.Tests.Visual.Gameplay
{
public partial class TestSceneDelayedResumeOverlay : OsuTestScene
{
private ResumeOverlay resume = null!;
private bool resumeFired;
[Cached]
private GameplayState gameplayState;
public TestSceneDelayedResumeOverlay()
{
gameplayState = TestGameplayState.Create(new OsuRuleset());
}
[SetUp]
public void SetUp() => Schedule(loadContent);
[Test]
public void TestResume()
{
AddStep("show", () => resume.Show());
AddUntilStep("dismissed", () => resumeFired && resume.State.Value == Visibility.Hidden);
}
private void loadContent()
{
Child = resume = new DelayedResumeOverlay();
resumeFired = false;
resume.ResumeAction = () => resumeFired = true;
}
}
}

View File

@ -25,9 +25,11 @@ using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Screens.Menu;
using osu.Game.Screens.Play;
using osu.Game.Screens.Play.PlayerSettings;
using osu.Game.Utils;
using osuTK;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Gameplay
@ -55,6 +57,9 @@ namespace osu.Game.Tests.Visual.Gameplay
[Cached]
private readonly VolumeOverlay volumeOverlay;
[Cached]
private readonly OsuLogo logo;
[Cached(typeof(BatteryInfo))]
private readonly LocalBatteryInfo batteryInfo = new LocalBatteryInfo();
@ -78,7 +83,14 @@ namespace osu.Game.Tests.Visual.Gameplay
Anchor = Anchor.TopLeft,
Origin = Anchor.TopLeft,
},
changelogOverlay = new ChangelogOverlay()
changelogOverlay = new ChangelogOverlay(),
logo = new OsuLogo
{
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
Scale = new Vector2(0.5f),
Position = new Vector2(128f),
},
});
}
@ -212,6 +224,36 @@ namespace osu.Game.Tests.Visual.Gameplay
AddUntilStep("loads after idle", () => !loader.IsCurrentScreen());
}
[Test]
public void TestLoadNotBlockedOnOsuLogo()
{
AddStep("load dummy beatmap", () => resetPlayer(false));
AddUntilStep("wait for current", () => loader.IsCurrentScreen());
AddUntilStep("wait for load ready", () =>
{
moveMouse();
return player?.LoadState == LoadState.Ready;
});
// move mouse in logo while waiting for load to still proceed (it shouldn't be blocked when hovering logo).
AddUntilStep("move mouse in logo", () =>
{
moveMouse();
return !loader.IsCurrentScreen();
});
void moveMouse()
{
notificationOverlay.State.Value = Visibility.Hidden;
InputManager.MoveMouseTo(
logo.ScreenSpaceDrawQuad.TopLeft
+ (logo.ScreenSpaceDrawQuad.BottomRight - logo.ScreenSpaceDrawQuad.TopLeft)
* RNG.NextSingle(0.3f, 0.7f));
}
}
[Test]
public void TestLoadContinuation()
{

View File

@ -1,138 +0,0 @@
// 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 NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Screens;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Screens;
using osuTK;
namespace osu.Game.Tests.Visual.UserInterface
{
[TestFixture]
public partial class TestSceneScreenBreadcrumbControl : OsuTestScene
{
private readonly ScreenBreadcrumbControl breadcrumbs;
private readonly OsuScreenStack screenStack;
public TestSceneScreenBreadcrumbControl()
{
OsuSpriteText titleText;
IScreen startScreen = new TestScreenOne();
screenStack = new OsuScreenStack { RelativeSizeAxes = Axes.Both };
screenStack.Push(startScreen);
Children = new Drawable[]
{
screenStack,
new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Spacing = new Vector2(10),
Children = new Drawable[]
{
breadcrumbs = new ScreenBreadcrumbControl(screenStack)
{
RelativeSizeAxes = Axes.X,
},
titleText = new OsuSpriteText(),
},
},
};
breadcrumbs.Current.ValueChanged += screen => titleText.Text = $"Changed to {screen.NewValue}";
breadcrumbs.Current.TriggerChange();
waitForCurrent();
pushNext();
waitForCurrent();
pushNext();
waitForCurrent();
AddStep(@"make start current", () => startScreen.MakeCurrent());
waitForCurrent();
pushNext();
waitForCurrent();
AddAssert(@"only 2 items", () => breadcrumbs.Items.Count == 2);
AddStep(@"exit current", () => screenStack.CurrentScreen.Exit());
AddAssert(@"current screen is first", () => startScreen == screenStack.CurrentScreen);
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
breadcrumbs.StripColour = colours.Blue;
}
private void pushNext() => AddStep(@"push next screen", () => ((TestScreen)screenStack.CurrentScreen).PushNext());
private void waitForCurrent() => AddUntilStep("current screen", () => screenStack.CurrentScreen.IsCurrentScreen());
private abstract partial class TestScreen : OsuScreen
{
protected abstract string NextTitle { get; }
protected abstract TestScreen CreateNextScreen();
public TestScreen PushNext()
{
TestScreen screen = CreateNextScreen();
this.Push(screen);
return screen;
}
protected TestScreen()
{
InternalChild = new FillFlowContainer
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
AutoSizeAxes = Axes.Both,
Direction = FillDirection.Vertical,
Spacing = new Vector2(10),
Children = new Drawable[]
{
new OsuSpriteText
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Text = Title,
},
new RoundedButton
{
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Width = 100,
Text = $"Push {NextTitle}",
Action = () => PushNext(),
},
},
};
}
}
private partial class TestScreenOne : TestScreen
{
public override string Title => @"Screen One";
protected override string NextTitle => @"Two";
protected override TestScreen CreateNextScreen() => new TestScreenTwo();
}
private partial class TestScreenTwo : TestScreen
{
public override string Title => @"Screen Two";
protected override string NextTitle => @"One";
protected override TestScreen CreateNextScreen() => new TestScreenOne();
}
}
}

View File

@ -5,6 +5,7 @@ using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Online.API;
@ -85,10 +86,16 @@ namespace osu.Game.Beatmaps
private bool shouldDiscardLookupResult(OnlineBeatmapMetadata result, BeatmapInfo beatmapInfo)
{
if (beatmapInfo.OnlineID > 0 && result.BeatmapID != beatmapInfo.OnlineID)
{
Logger.Log($"Discarding metadata lookup result due to mismatching online ID (expected: {beatmapInfo.OnlineID} actual: {result.BeatmapID})", LoggingTarget.Database);
return true;
}
if (beatmapInfo.OnlineID == -1 && result.MD5Hash != beatmapInfo.MD5Hash)
{
Logger.Log($"Discarding metadata lookup result due to mismatching hash (expected: {beatmapInfo.MD5Hash} actual: {result.MD5Hash})", LoggingTarget.Database);
return true;
}
return false;
}

View File

@ -1,6 +1,7 @@
// 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;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps.ControlPoints;
@ -129,6 +130,7 @@ namespace osu.Game.Beatmaps
///
/// It's not super efficient so calls should be kept to a minimum.
/// </remarks>
/// <exception cref="InvalidOperationException">If <paramref name="beatmap"/> has no objects.</exception>
public static double GetLastObjectTime(this IBeatmap beatmap) => beatmap.HitObjects.Max(h => h.GetEndTime());
#region Helper methods

View File

@ -1,48 +0,0 @@
// 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.
#nullable disable
using System.Linq;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Screens;
namespace osu.Game.Graphics.UserInterface
{
/// <summary>
/// A <see cref="BreadcrumbControl{IScreen}"/> which follows the active screen (and allows navigation) in a <see cref="Screen"/> stack.
/// </summary>
public partial class ScreenBreadcrumbControl : BreadcrumbControl<IScreen>
{
public ScreenBreadcrumbControl(ScreenStack stack)
{
stack.ScreenPushed += onPushed;
stack.ScreenExited += onExited;
if (stack.CurrentScreen != null)
onPushed(null, stack.CurrentScreen);
}
protected override void SelectTab(TabItem<IScreen> tab)
{
// override base method to prevent current item from being changed on click.
// depend on screen push/exit to change current item instead.
tab.Value.MakeCurrent();
}
private void onPushed(IScreen lastScreen, IScreen newScreen)
{
AddItem(newScreen);
Current.Value = newScreen;
}
private void onExited(IScreen lastScreen, IScreen newScreen)
{
if (newScreen != null)
Current.Value = newScreen;
Items.ToList().SkipWhile(s => s != Current.Value).Skip(1).ForEach(RemoveItem);
}
}
}

View File

@ -125,6 +125,11 @@ Click to see what's new!", version);
/// </summary>
public static LocalisableString UpdateReadyToInstall => new TranslatableString(getKey(@"update_ready_to_install"), @"Update ready to install. Click to restart!");
/// <summary>
/// "This is not an official build of the game. Scores will not be submitted and other online systems may not work as intended."
/// </summary>
public static LocalisableString NotOfficialBuild => new TranslatableString(getKey(@"not_official_build"), @"This is not an official build of the game. Scores will not be submitted and other online systems may not work as intended.");
/// <summary>
/// "Downloading update..."
/// </summary>

View File

@ -36,6 +36,7 @@ namespace osu.Game.Rulesets.Edit
new CheckConcurrentObjects(),
new CheckZeroLengthObjects(),
new CheckDrainLength(),
new CheckUnusedAudioAtEnd(),
// Timing
new CheckPreviewTime(),

View File

@ -0,0 +1,80 @@
// 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;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit.Checks.Components;
using osu.Game.Storyboards;
namespace osu.Game.Rulesets.Edit.Checks
{
public class CheckUnusedAudioAtEnd : ICheck
{
public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Compose, "More than 20% unused audio at the end");
public IEnumerable<IssueTemplate> PossibleTemplates => new IssueTemplate[]
{
new IssueTemplateUnusedAudioAtEnd(this),
new IssueTemplateUnusedAudioAtEndStoryboardOrVideo(this),
};
public IEnumerable<Issue> Run(BeatmapVerifierContext context)
{
double mappedLength = context.Beatmap.HitObjects.Any() ? context.Beatmap.GetLastObjectTime() : 0;
double trackLength = context.WorkingBeatmap.Track.Length;
double mappedPercentage = Math.Round(mappedLength / trackLength * 100);
if (mappedPercentage < 80)
{
double percentageLeft = Math.Abs(mappedPercentage - 100);
bool storyboardIsPresent = isAnyStoryboardElementPresent(context.WorkingBeatmap.Storyboard);
if (storyboardIsPresent)
{
yield return new IssueTemplateUnusedAudioAtEndStoryboardOrVideo(this).Create(percentageLeft);
}
else
{
yield return new IssueTemplateUnusedAudioAtEnd(this).Create(percentageLeft);
}
}
}
private bool isAnyStoryboardElementPresent(Storyboard storyboard)
{
foreach (var layer in storyboard.Layers)
{
foreach (var _ in layer.Elements)
{
return true;
}
}
return false;
}
public class IssueTemplateUnusedAudioAtEnd : IssueTemplate
{
public IssueTemplateUnusedAudioAtEnd(ICheck check)
: base(check, IssueType.Warning, "Currently there is {0}% unused audio at the end. Ensure the outro significantly contributes to the song, otherwise cut the outro.")
{
}
public Issue Create(double percentageLeft) => new Issue(this, percentageLeft);
}
public class IssueTemplateUnusedAudioAtEndStoryboardOrVideo : IssueTemplate
{
public IssueTemplateUnusedAudioAtEndStoryboardOrVideo(ICheck check)
: base(check, IssueType.Warning, "Currently there is {0}% unused audio at the end. Ensure the outro significantly contributes to the song, or is being occupied by the video or storyboard, otherwise cut the outro.")
{
}
public Issue Create(double percentageLeft) => new Issue(this, percentageLeft);
}
}
}

View File

@ -108,6 +108,9 @@ namespace osu.Game.Rulesets.Scoring
increaseHp(h);
}
if (topLevelObjectCount == 0)
return testDrop;
if (!fail && currentHp < lowestHpEnd)
{
fail = true;

View File

@ -240,7 +240,7 @@ namespace osu.Game.Rulesets.UI
public override void RequestResume(Action continueResume)
{
if (ResumeOverlay != null && UseResumeOverlay && (Cursor == null || (Cursor.LastFrameState == Visibility.Visible && Contains(Cursor.ActiveCursor.ScreenSpaceDrawQuad.Centre))))
if (ResumeOverlay != null && UseResumeOverlay)
{
ResumeOverlay.GameplayCursor = Cursor;
ResumeOverlay.ResumeAction = continueResume;

View File

@ -93,7 +93,7 @@ namespace osu.Game.Scoring.Legacy
sw.Write((ushort)(score.ScoreInfo.GetCountMiss() ?? 0));
sw.Write((int)(score.ScoreInfo.TotalScore));
sw.Write((ushort)score.ScoreInfo.MaxCombo);
sw.Write(score.ScoreInfo.Combo == score.ScoreInfo.MaxCombo);
sw.Write(score.ScoreInfo.MaxCombo == score.ScoreInfo.GetMaximumAchievableCombo());
sw.Write((int)score.ScoreInfo.Ruleset.CreateInstance().ConvertToLegacyMods(score.ScoreInfo.Mods));
sw.Write(getHpGraphFormatted());

View File

@ -1,10 +1,9 @@
// 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.Collections.Specialized;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Caching;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts;
@ -15,6 +14,16 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
/// </summary>
public partial class TimelineControlPointDisplay : TimelinePart<TimelineControlPointGroup>
{
[Resolved]
private Timeline timeline { get; set; } = null!;
/// <summary>
/// The visible time/position range of the timeline.
/// </summary>
private (float min, float max) visibleRange = (float.MinValue, float.MaxValue);
private readonly Cached groupCache = new Cached();
private readonly IBindableList<ControlPointGroup> controlPointGroups = new BindableList<ControlPointGroup>();
protected override void LoadBeatmap(EditorBeatmap beatmap)
@ -23,34 +32,67 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
controlPointGroups.UnbindAll();
controlPointGroups.BindTo(beatmap.ControlPointInfo.Groups);
controlPointGroups.BindCollectionChanged((_, args) =>
{
switch (args.Action)
{
case NotifyCollectionChangedAction.Reset:
Clear();
break;
case NotifyCollectionChangedAction.Add:
Debug.Assert(args.NewItems != null);
foreach (var group in args.NewItems.OfType<ControlPointGroup>())
Add(new TimelineControlPointGroup(group));
break;
case NotifyCollectionChangedAction.Remove:
Debug.Assert(args.OldItems != null);
foreach (var group in args.OldItems.OfType<ControlPointGroup>())
{
var matching = Children.SingleOrDefault(gv => ReferenceEquals(gv.Group, group));
matching?.Expire();
}
break;
}
}, true);
controlPointGroups.BindCollectionChanged((_, _) => groupCache.Invalidate(), true);
}
protected override void Update()
{
base.Update();
if (DrawWidth <= 0) return;
(float, float) newRange = (
(ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopLeft).X - TopPointPiece.WIDTH) / DrawWidth * Content.RelativeChildSize.X,
(ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopRight).X) / DrawWidth * Content.RelativeChildSize.X);
if (visibleRange != newRange)
{
visibleRange = newRange;
groupCache.Invalidate();
}
if (!groupCache.IsValid)
{
recreateDrawableGroups();
groupCache.Validate();
}
}
private void recreateDrawableGroups()
{
// Remove groups outside the visible range
foreach (TimelineControlPointGroup drawableGroup in this)
{
if (!shouldBeVisible(drawableGroup.Group))
drawableGroup.Expire();
}
// Add remaining ones
for (int i = 0; i < controlPointGroups.Count; i++)
{
var group = controlPointGroups[i];
if (!shouldBeVisible(group))
continue;
bool alreadyVisible = false;
foreach (var g in this)
{
if (ReferenceEquals(g.Group, group))
{
alreadyVisible = true;
break;
}
}
if (alreadyVisible)
continue;
Add(new TimelineControlPointGroup(group));
}
}
private bool shouldBeVisible(ControlPointGroup group) => group.Time >= visibleRange.min && group.Time <= visibleRange.max;
}
}

View File

@ -19,12 +19,12 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
protected OsuSpriteText Label { get; private set; } = null!;
private const float width = 80;
public const float WIDTH = 80;
public TopPointPiece(ControlPoint point)
{
Point = point;
Width = width;
Width = WIDTH;
Height = 16;
Margin = new MarginPadding { Vertical = 4 };
@ -65,7 +65,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline
new Container
{
RelativeSizeAxes = Axes.Y,
Width = width - triangle_portion,
Width = WIDTH - triangle_portion,
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Colour = Point.GetRepresentingColour(colours),

View File

@ -0,0 +1,196 @@
// 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;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Localisation;
using osu.Framework.Threading;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Overlays;
using osuTK;
namespace osu.Game.Screens.Play
{
/// <summary>
/// Simple <see cref="ResumeOverlay"/> that resumes after a short delay.
/// </summary>
public partial class DelayedResumeOverlay : ResumeOverlay
{
// todo: this shouldn't define its own colour provider, but nothing in Player screen does, so let's do that for now.
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue);
private const float outer_size = 200;
private const float inner_size = 150;
private const float progress_stroke_width = 7;
private const float progress_size = inner_size + progress_stroke_width / 2f;
private const double countdown_time = 2000;
protected override LocalisableString Message => string.Empty;
private ScheduledDelegate? scheduledResume;
private int? countdownCount;
private double countdownStartTime;
private bool countdownComplete;
private Drawable outerContent = null!;
private Container innerContent = null!;
private Container countdownComponents = null!;
private Drawable countdownBackground = null!;
private SpriteText countdownText = null!;
private CircularProgress countdownProgress = null!;
private Sample? sampleCountdown;
public DelayedResumeOverlay()
{
Anchor = Anchor.Centre;
Origin = Anchor.Centre;
}
[BackgroundDependencyLoader]
private void load(AudioManager audio)
{
Add(outerContent = new Circle
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(outer_size),
Colour = colourProvider.Background6,
});
Add(innerContent = new Container
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
Children = new[]
{
countdownBackground = new Circle
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(inner_size),
Colour = colourProvider.Background4,
},
countdownComponents = new Container
{
RelativeSizeAxes = Axes.Both,
Children = new Drawable[]
{
countdownProgress = new CircularProgress
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(progress_size),
InnerRadius = progress_stroke_width / progress_size,
RoundedCaps = true
},
countdownText = new OsuSpriteText
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
UseFullGlyphHeight = false,
AlwaysPresent = true,
Font = OsuFont.Torus.With(size: 70, weight: FontWeight.Light)
}
}
}
}
});
sampleCountdown = audio.Samples.Get(@"Gameplay/resume-countdown");
}
protected override void PopIn()
{
this.FadeIn();
// The transition effects.
outerContent.FadeIn().ScaleTo(Vector2.Zero).Then().ScaleTo(Vector2.One, 200, Easing.OutQuint);
innerContent.FadeIn().ScaleTo(Vector2.Zero).Then().ScaleTo(Vector2.One, 400, Easing.OutElasticHalf);
countdownComponents.FadeOut().Delay(50).FadeTo(1, 100);
// Reset states for various components.
countdownBackground.FadeIn();
countdownText.FadeIn();
countdownProgress.FadeIn().ScaleTo(1);
countdownComplete = false;
countdownCount = null;
countdownStartTime = Time.Current;
scheduledResume?.Cancel();
scheduledResume = Scheduler.AddDelayed(() =>
{
countdownComplete = true;
Resume();
}, countdown_time);
}
protected override void PopOut()
{
this.Delay(300).FadeOut();
outerContent.FadeOut();
countdownBackground.FadeOut();
countdownText.FadeOut();
if (countdownComplete)
{
countdownProgress.ScaleTo(2f, 300, Easing.OutQuint);
countdownProgress.FadeOut(300, Easing.OutQuint);
}
else
countdownProgress.FadeOut();
scheduledResume?.Cancel();
}
protected override void Update()
{
base.Update();
updateCountdown();
}
private void updateCountdown()
{
double amountTimePassed = Math.Min(countdown_time, Time.Current - countdownStartTime) / countdown_time;
int newCount = 3 - (int)Math.Floor(amountTimePassed * 3);
countdownProgress.Progress = amountTimePassed;
countdownProgress.InnerRadius = progress_stroke_width / progress_size / countdownProgress.Scale.X;
if (countdownCount != newCount)
{
if (newCount > 0)
{
countdownText.Text = Math.Max(1, newCount).ToString();
countdownText.ScaleTo(0.25f).Then().ScaleTo(1, 200, Easing.OutQuint);
outerContent.Delay(25).Then().ScaleTo(1.05f, 100).Then().ScaleTo(1f, 200, Easing.Out);
countdownBackground.FlashColour(colourProvider.Background3, 400, Easing.Out);
}
var chan = sampleCountdown?.GetChannel();
if (chan != null)
{
chan.Frequency.Value = newCount == 0 ? 0.5f : 1;
chan.Play();
}
}
countdownCount = newCount;
}
}
}

View File

@ -110,8 +110,8 @@ namespace osu.Game.Screens.Play
&& ReadyForGameplay;
protected virtual bool ReadyForGameplay =>
// not ready if the user is hovering one of the panes, unless they are idle.
(IsHovered || idleTracker.IsIdle.Value)
// not ready if the user is hovering one of the panes (logo is excluded), unless they are idle.
(IsHovered || osuLogo?.IsHovered == true || idleTracker.IsIdle.Value)
// not ready if the user is dragging a slider or otherwise.
&& inputManager.DraggedDrawable == null
// not ready if a focused overlay is visible, like settings.
@ -335,10 +335,14 @@ namespace osu.Game.Screens.Play
return base.OnExiting(e);
}
private OsuLogo? osuLogo;
protected override void LogoArriving(OsuLogo logo, bool resuming)
{
base.LogoArriving(logo, resuming);
osuLogo = logo;
const double duration = 300;
if (!resuming) logo.MoveTo(new Vector2(0.5f), duration, Easing.OutQuint);
@ -357,6 +361,7 @@ namespace osu.Game.Screens.Play
{
base.LogoExiting(logo);
content.StopTracking();
osuLogo = null;
}
protected override void LogoSuspending(OsuLogo logo)
@ -367,6 +372,8 @@ namespace osu.Game.Screens.Play
logo
.FadeOut(CONTENT_OUT_DURATION / 2, Easing.OutQuint)
.ScaleTo(logo.Scale * 0.8f, CONTENT_OUT_DURATION * 2, Easing.OutQuint);
osuLogo = null;
}
#endregion

View File

@ -11,6 +11,7 @@ using osu.Framework.Graphics.Cursor;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Rulesets.UI;
using osuTK;
using osuTK.Graphics;
@ -21,7 +22,7 @@ namespace osu.Game.Screens.Play
/// </summary>
public abstract partial class ResumeOverlay : VisibilityContainer
{
public CursorContainer GameplayCursor { get; set; }
public GameplayCursorContainer GameplayCursor { get; set; }
/// <summary>
/// The action to be performed to complete resuming.

View File

@ -1,7 +1,9 @@
// 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.Reflection;
using System.Threading.Tasks;
using osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
@ -11,6 +13,7 @@ using osu.Game.Graphics;
using osu.Game.Localisation;
using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
using osu.Game.Utils;
using osuTK;
namespace osu.Game.Updater
@ -51,6 +54,9 @@ namespace osu.Game.Updater
// only show a notification if we've previously saved a version to the config file (ie. not the first run).
if (!string.IsNullOrEmpty(lastVersion))
Notifications.Post(new UpdateCompleteNotification(version));
if (RuntimeInfo.EntryAssembly.GetCustomAttribute<OfficialBuildAttribute>() == null)
Notifications.Post(new SimpleNotification { Text = NotificationsStrings.NotOfficialBuild });
}
// debug / local compilations will reset to a non-release string.

View File

@ -0,0 +1,12 @@
// 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;
using JetBrains.Annotations;
namespace osu.Game.Utils
{
[UsedImplicitly]
[AttributeUsage(AttributeTargets.Assembly)]
public class OfficialBuildAttribute : Attribute;
}

View File

@ -3,7 +3,6 @@
<TargetFramework>net8.0</TargetFramework>
<OutputType>Library</OutputType>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<LangVersion>10</LangVersion>
</PropertyGroup>
<PropertyGroup Label="Nuget">
<Title>osu!</Title>
@ -37,7 +36,7 @@
</PackageReference>
<PackageReference Include="Realm" Version="11.5.0" />
<PackageReference Include="ppy.osu.Framework" Version="2024.306.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2024.309.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2024.321.0" />
<PackageReference Include="Sentry" Version="3.41.3" />
<!-- Held back due to 0.34.0 failing AOT compilation on ZstdSharp.dll dependency. -->
<PackageReference Include="SharpCompress" Version="0.36.0" />