From d0d5d97cfea8b4664c0110ca384d57c3ef44aec5 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 11 Mar 2026 19:08:26 +0900 Subject: [PATCH] Add replay / spectator mode scrolling text back (#36911) As mentioned in https://github.com/ppy/osu/discussions/36883. This has caught me off-guard a few times. Was a quick one to make this work like it does on stable. It doesn't fit as well as stable because we have a lot of elements at the top of the screen, but I think it's better than nothing, as it lets you know you're in a replay quick obviously. I don't think we can easily localise strings with formatting in them yet. Maybe using a `MarkdownContainer` or something? --- .../TestSceneExpandedPanelMiddleContent.cs | 4 +- osu.Game/Rulesets/Mods/ModExtensions.cs | 2 + osu.Game/Screens/Play/Player.cs | 10 +++- osu.Game/Screens/Play/ReplayPlayer.cs | 25 +++++++++ osu.Game/Screens/Play/ScrollingMessage.cs | 44 +++++++++++++++ osu.Game/Screens/Play/SpectatorPlayer.cs | 20 ++++--- .../Expanded/ExpandedPanelMiddleContent.cs | 40 +------------- .../Screens/Ranking/Expanded/PlayedOnText.cs | 55 +++++++++++++++++++ 8 files changed, 150 insertions(+), 50 deletions(-) create mode 100644 osu.Game/Screens/Play/ScrollingMessage.cs create mode 100644 osu.Game/Screens/Ranking/Expanded/PlayedOnText.cs diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs index eade5aaf5d..df9dbf90ee 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs @@ -67,7 +67,7 @@ namespace osu.Game.Tests.Visual.Ranking AddAssert("mapped by text not present", () => this.ChildrenOfType().All(spriteText => !containsAny(spriteText.Text.ToString(), "mapped", "by"))); - AddAssert("play time displayed", () => this.ChildrenOfType().Any()); + AddAssert("play time displayed", () => this.ChildrenOfType().Any()); } [Test] @@ -137,7 +137,7 @@ namespace osu.Game.Tests.Visual.Ranking showPanel(score); }); - AddAssert("play time not displayed", () => !this.ChildrenOfType().Any()); + AddAssert("play time not displayed", () => !this.ChildrenOfType().Any()); } [Test] diff --git a/osu.Game/Rulesets/Mods/ModExtensions.cs b/osu.Game/Rulesets/Mods/ModExtensions.cs index bd2d42f3eb..b9f723e88e 100644 --- a/osu.Game/Rulesets/Mods/ModExtensions.cs +++ b/osu.Game/Rulesets/Mods/ModExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . 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; @@ -20,6 +21,7 @@ namespace osu.Game.Rulesets.Mods Replay = replayData.Replay, ScoreInfo = { + Date = DateTimeOffset.Now, User = new APIUser { Id = replayData.User.OnlineID, diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index ba543db996..97c0a0b769 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -311,7 +311,7 @@ namespace osu.Game.Screens.Play { // underlay and gameplay should have access to the skinning sources. createUnderlayComponents(Beatmap.Value), - createGameplayComponents(Beatmap.Value) + createGameplayComponents() } }, FailOverlay = new FailOverlay @@ -426,6 +426,11 @@ namespace osu.Game.Screens.Play IsBreakTime.BindValueChanged(onBreakTimeChanged, true); } + /// + /// Implement to add any components which should exist above gameplay but below the HUD. + /// + protected virtual Drawable CreateOverlayComponents() => Empty(); + protected virtual GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) => new MasterGameplayClockContainer(beatmap, gameplayStart); private Drawable createUnderlayComponents(WorkingBeatmap working) @@ -451,7 +456,7 @@ namespace osu.Game.Screens.Play return container; } - private Drawable createGameplayComponents(IWorkingBeatmap working) => new ScalingContainer(ScalingMode.Gameplay) + private Drawable createGameplayComponents() => new ScalingContainer(ScalingMode.Gameplay) { Children = new Drawable[] { @@ -474,6 +479,7 @@ namespace osu.Game.Screens.Play Children = new[] { DimmableStoryboard.OverlayLayerContainer.CreateProxy(), + CreateOverlayComponents(), HUDOverlay = new HUDOverlay(DrawableRuleset, GameplayState.Mods, Configuration) { HoldToQuit = diff --git a/osu.Game/Screens/Play/ReplayPlayer.cs b/osu.Game/Screens/Play/ReplayPlayer.cs index cd769d7615..e3c0361052 100644 --- a/osu.Game/Screens/Play/ReplayPlayer.cs +++ b/osu.Game/Screens/Play/ReplayPlayer.cs @@ -12,12 +12,15 @@ using osu.Framework.Input.Events; using osu.Framework.Screens; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; using osu.Game.Input.Bindings; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Screens.Play.Leaderboards; using osu.Game.Screens.Play.PlayerSettings; using osu.Game.Screens.Ranking; +using osu.Game.Screens.Ranking.Expanded; using osu.Game.Skinning; using osu.Game.Users; @@ -97,6 +100,7 @@ namespace osu.Game.Screens.Play playbackSettings.UserPlaybackRate.BindTo(master.UserPlaybackRate); HUDOverlay.PlayerSettingsOverlay.AddAtStart(playbackSettings); + AddInternal(new RulesetSkinProvidingContainer(GameplayState.Ruleset, GameplayState.Beatmap, Beatmap.Value.Skin) { Child = failIndicator = new ReplayFailIndicator(GameplayClockContainer) @@ -113,6 +117,27 @@ namespace osu.Game.Screens.Play }); } + protected override Drawable CreateOverlayComponents() + { + OsuTextFlowContainer message = new OsuTextFlowContainer(cp => cp.Font = OsuFont.Style.Body) { AutoSizeAxes = Axes.Both }; + message.AddText("Watching "); + message.AddText(Score.ScoreInfo.User.Username, s => s.Font = s.Font.With(weight: FontWeight.SemiBold)); + message.AddText(" play "); + message.AddText(Beatmap.Value.BeatmapInfo.GetDisplayTitleRomanisable(), s => s.Font = s.Font.With(weight: FontWeight.SemiBold)); + message.AddText(" on "); + message.AddArbitraryDrawable(new PlayedOnText(Score.ScoreInfo.Date, false) + { + Font = OsuFont.Style.Body.With(weight: FontWeight.SemiBold), + }); + + return new ScrollingMessage(message) + { + Y = 100, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }; + } + protected override void PrepareReplay() { DrawableRuleset?.SetReplayScore(Score); diff --git a/osu.Game/Screens/Play/ScrollingMessage.cs b/osu.Game/Screens/Play/ScrollingMessage.cs new file mode 100644 index 0000000000..9dcd303fe1 --- /dev/null +++ b/osu.Game/Screens/Play/ScrollingMessage.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; + +namespace osu.Game.Screens.Play +{ + public partial class ScrollingMessage : CompositeDrawable + { + private readonly Drawable messageContent; + + public ScrollingMessage(Drawable messageContent) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChild = this.messageContent = messageContent; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + this.FadeInFromZero(2000, Easing.OutQuint); + resetMessagePosition(); + } + + protected override void Update() + { + base.Update(); + + if (messageContent.X + messageContent.DrawWidth > 0) + messageContent.X -= (float)Clock.ElapsedFrameTime * 0.05f; + else + resetMessagePosition(); + } + + private void resetMessagePosition() + { + messageContent.X = DrawWidth + 10; + } + } +} diff --git a/osu.Game/Screens/Play/SpectatorPlayer.cs b/osu.Game/Screens/Play/SpectatorPlayer.cs index 22c966e0af..6d008447bb 100644 --- a/osu.Game/Screens/Play/SpectatorPlayer.cs +++ b/osu.Game/Screens/Play/SpectatorPlayer.cs @@ -7,7 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Screens; using osu.Game.Beatmaps; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.Containers; using osu.Game.Online.Spectator; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Replays.Types; @@ -29,17 +29,23 @@ namespace osu.Game.Screens.Play this.score = score; } - [BackgroundDependencyLoader] - private void load() + protected override Drawable CreateOverlayComponents() { - AddInternal(new OsuSpriteText + // TODO: This should be customised for `MultiplayerSpectatorPlayer` to be static and only show the player name. + // Or maybe we should completely redesign this to show the user avatar and other things if that happens. + OsuTextFlowContainer message = new OsuTextFlowContainer(cp => cp.Font = OsuFont.Style.Body) { AutoSizeAxes = Axes.Both }; + message.AddText("Watching "); + message.AddText(Score.ScoreInfo.User.Username, s => s.Font = s.Font.With(weight: FontWeight.SemiBold)); + message.AddText(" play "); + message.AddText(Beatmap.Value.BeatmapInfo.GetDisplayTitleRomanisable(), s => s.Font = s.Font.With(weight: FontWeight.SemiBold)); + message.AddText(" live", s => s.Font = s.Font.With(weight: FontWeight.Bold)); + + return new ScrollingMessage(message) { - Text = $"Watching {score.ScoreInfo.User.Username} playing live!", - Font = OsuFont.Default.With(size: 30), Y = 100, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - }); + }; } protected override void LoadComplete() diff --git a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs index 445d219c7f..0f11b01dde 100644 --- a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs +++ b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs @@ -1,19 +1,15 @@ // Copyright (c) ppy Pty Ltd . 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.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Extensions; -using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; -using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -237,7 +233,7 @@ namespace osu.Game.Screens.Ranking.Expanded }); if (score.Date != default) - AddInternal(new PlayedOnText(score.Date)); + AddInternal(new PlayedOnText(score.Date, true)); } protected override void LoadComplete() @@ -268,40 +264,6 @@ namespace osu.Game.Screens.Ranking.Expanded }); } - public partial class PlayedOnText : OsuSpriteText - { - private readonly DateTimeOffset time; - private readonly Bindable prefer24HourTime = new Bindable(); - - public PlayedOnText(DateTimeOffset time) - { - this.time = time; - - Anchor = Anchor.BottomCentre; - Origin = Anchor.BottomCentre; - Font = OsuFont.GetFont(size: 10, weight: FontWeight.SemiBold); - } - - [BackgroundDependencyLoader] - private void load(OsuConfigManager configManager) - { - configManager.BindWith(OsuSetting.Prefer24HourTime, prefer24HourTime); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - prefer24HourTime.BindValueChanged(_ => updateDisplay(), true); - } - - private void updateDisplay() - { - Text = LocalisableString.Format("Played on {0}", - time.ToLocalTime().ToLocalisableString(prefer24HourTime.Value ? @"d MMMM yyyy HH:mm" : @"d MMMM yyyy h:mm tt")); - } - } - internal partial class ClickableMetadata : OsuHoverContainer { [Resolved] diff --git a/osu.Game/Screens/Ranking/Expanded/PlayedOnText.cs b/osu.Game/Screens/Ranking/Expanded/PlayedOnText.cs new file mode 100644 index 0000000000..9ac453bfb7 --- /dev/null +++ b/osu.Game/Screens/Ranking/Expanded/PlayedOnText.cs @@ -0,0 +1,55 @@ +// Copyright (c) ppy Pty Ltd . 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.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Screens.Ranking.Expanded +{ + public partial class PlayedOnText : OsuSpriteText + { + private readonly DateTimeOffset time; + private readonly bool withPrefix; + private readonly Bindable prefer24HourTime = new Bindable(); + + public PlayedOnText(DateTimeOffset time, bool withPrefix) + { + this.time = time; + this.withPrefix = withPrefix; + + Anchor = Anchor.BottomCentre; + Origin = Anchor.BottomCentre; + Font = OsuFont.GetFont(size: 10, weight: FontWeight.SemiBold); + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager configManager) + { + configManager.BindWith(OsuSetting.Prefer24HourTime, prefer24HourTime); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + prefer24HourTime.BindValueChanged(_ => updateDisplay(), true); + } + + private void updateDisplay() + { + var timeText = time.ToLocalTime().ToLocalisableString(prefer24HourTime.Value ? @"d MMMM yyyy HH:mm" : @"d MMMM yyyy h:mm tt"); + + if (withPrefix) + Text = LocalisableString.Format("Played on {0}", timeText); + else + Text = timeText; + } + } +}