1
0
mirror of https://github.com/ppy/osu.git synced 2024-09-21 17:27:24 +08:00

Merge branch 'master' into spinner-glow

This commit is contained in:
Bartłomiej Dach 2023-11-27 15:06:27 +09:00
commit 874a3706bc
No known key found for this signature in database
60 changed files with 1128 additions and 668 deletions

View File

@ -59,7 +59,7 @@ The [issue tracker](https://github.com/ppy/osu/issues) should provide plenty of
In the case of simple issues, a direct PR is okay. However, if you decide to work on an existing issue which doesn't seem trivial, **please ask us first**. This way we can try to estimate if it is a good fit for you and provide the correct direction on how to address it. In addition, note that while we do not rule out external contributors from working on roadmapped issues, we will generally prefer to handle them ourselves unless they're not very time sensitive.
If you'd like to propose a subjective change to one of the visual aspects of the game, or there is a bigger task you'd like to work on, but there is no corresponding issue or discussion thread yet for it, **please open a discussion or issue first** to avoid wasted effort. This in particular applies if you want to work on [one of the available designs from the osu! public Figma library](https://www.figma.com/file/6m10GiGEncVFWmgOoSyakH/osu!-Figma-Library).
If you'd like to propose a subjective change to one of the visual aspects of the game, or there is a bigger task you'd like to work on, but there is no corresponding issue or discussion thread yet for it, **please open a discussion or issue first** to avoid wasted effort. This in particular applies if you want to work on [one of the available designs from the osu! Figma master library](https://www.figma.com/file/VIkXMYNPMtQem2RJg9k2iQ/Master-Library).
Aside from the above, below is a brief checklist of things to watch out when you're preparing your code changes:
@ -85,4 +85,4 @@ If you're uncertain about some part of the codebase or some inner workings of th
- [Development roadmap](https://github.com/orgs/ppy/projects/7/views/6): What the core team is currently working on
- [`ppy/osu-framework` wiki](https://github.com/ppy/osu-framework/wiki): Contains introductory information about osu!framework, the bespoke 2D game framework we use for the game
- [`ppy/osu` wiki](https://github.com/ppy/osu/wiki): Contains articles about various technical aspects of the game
- [Public Figma library](https://www.figma.com/file/6m10GiGEncVFWmgOoSyakH/osu!-Figma-Library): Contains finished and draft designs for osu!
- [Figma master library](https://www.figma.com/file/VIkXMYNPMtQem2RJg9k2iQ/Master-Library): Contains finished and draft designs for osu!

View File

@ -39,6 +39,8 @@ namespace osu.Game.Rulesets.Catch
public override ScoreProcessor CreateScoreProcessor() => new CatchScoreProcessor();
public override HealthProcessor CreateHealthProcessor(double drainStartTime) => new CatchHealthProcessor(drainStartTime);
public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new CatchBeatmapConverter(beatmap, this);
public override IBeatmapProcessor CreateBeatmapProcessor(IBeatmap beatmap) => new CatchBeatmapProcessor(beatmap);

View File

@ -0,0 +1,57 @@
// 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.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Catch.Scoring
{
public partial class CatchHealthProcessor : LegacyDrainingHealthProcessor
{
public CatchHealthProcessor(double drainStartTime)
: base(drainStartTime)
{
}
protected override IEnumerable<HitObject> EnumerateTopLevelHitObjects() => EnumerateHitObjects(Beatmap).Where(h => h is Fruit || h is Droplet || h is Banana);
protected override IEnumerable<HitObject> EnumerateNestedHitObjects(HitObject hitObject) => Enumerable.Empty<HitObject>();
protected override double GetHealthIncreaseFor(HitObject hitObject, HitResult result)
{
double increase = 0;
switch (result)
{
case HitResult.SmallTickMiss:
return 0;
case HitResult.LargeTickMiss:
case HitResult.Miss:
return IBeatmapDifficultyInfo.DifficultyRange(Beatmap.Difficulty.DrainRate, -0.03, -0.125, -0.2);
case HitResult.SmallTickHit:
increase = 0.0015;
break;
case HitResult.LargeTickHit:
increase = 0.015;
break;
case HitResult.Great:
increase = 0.03;
break;
case HitResult.LargeBonus:
increase = 0.0025;
break;
}
return HpMultiplierNormal * increase;
}
}
}

View File

@ -0,0 +1,93 @@
// 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.Game.Beatmaps;
using osu.Game.Beatmaps.Timing;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Scoring;
namespace osu.Game.Rulesets.Osu.Tests
{
[TestFixture]
public class TestSceneOsuHealthProcessor
{
[Test]
public void TestNoBreak()
{
OsuHealthProcessor hp = new OsuHealthProcessor(-1000);
hp.ApplyBeatmap(new Beatmap<OsuHitObject>
{
HitObjects =
{
new HitCircle { StartTime = 0 },
new HitCircle { StartTime = 2000 }
}
});
Assert.That(hp.DrainRate, Is.EqualTo(1.4E-5).Within(0.1E-5));
}
[Test]
public void TestSingleBreak()
{
OsuHealthProcessor hp = new OsuHealthProcessor(-1000);
hp.ApplyBeatmap(new Beatmap<OsuHitObject>
{
HitObjects =
{
new HitCircle { StartTime = 0 },
new HitCircle { StartTime = 2000 }
},
Breaks =
{
new BreakPeriod(500, 1500)
}
});
Assert.That(hp.DrainRate, Is.EqualTo(4.3E-5).Within(0.1E-5));
}
[Test]
public void TestOverlappingBreak()
{
OsuHealthProcessor hp = new OsuHealthProcessor(-1000);
hp.ApplyBeatmap(new Beatmap<OsuHitObject>
{
HitObjects =
{
new HitCircle { StartTime = 0 },
new HitCircle { StartTime = 2000 }
},
Breaks =
{
new BreakPeriod(500, 1400),
new BreakPeriod(750, 1500),
}
});
Assert.That(hp.DrainRate, Is.EqualTo(4.3E-5).Within(0.1E-5));
}
[Test]
public void TestSequentialBreak()
{
OsuHealthProcessor hp = new OsuHealthProcessor(-1000);
hp.ApplyBeatmap(new Beatmap<OsuHitObject>
{
HitObjects =
{
new HitCircle { StartTime = 0 },
new HitCircle { StartTime = 2000 }
},
Breaks =
{
new BreakPeriod(500, 1000),
new BreakPeriod(1000, 1500),
}
});
Assert.That(hp.DrainRate, Is.EqualTo(4.3E-5).Within(0.1E-5));
}
}
}

View File

@ -175,6 +175,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
if (controlPointCount > 2 || (controlPointCount == 2 && HitObject.Path.ControlPoints.Last() != cursor))
return base.OnDragStart(e);
bSplineBuilder.AddLinearPoint(Vector2.Zero);
bSplineBuilder.AddLinearPoint(ToLocalSpace(e.ScreenSpaceMouseDownPosition) - HitObject.Position);
state = SliderPlacementState.Drawing;
return true;

View File

@ -1,215 +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 System;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Timing;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Osu.Scoring
{
/// <summary>
/// Reference implementation for osu!stable's HP drain.
/// Cannot be used for gameplay.
/// </summary>
public partial class LegacyOsuHealthProcessor : DrainingHealthProcessor
{
private const double hp_bar_maximum = 200;
private const double hp_combo_geki = 14;
private const double hp_hit_300 = 6;
private const double hp_slider_repeat = 4;
private const double hp_slider_tick = 3;
public Action<string>? OnIterationFail;
public Action<string>? OnIterationSuccess;
public bool ApplyComboEndBonus { get; set; } = true;
private double lowestHpEver;
private double lowestHpEnd;
private double lowestHpComboEnd;
private double hpRecoveryAvailable;
private double hpMultiplierNormal;
private double hpMultiplierComboEnd;
public LegacyOsuHealthProcessor(double drainStartTime)
: base(drainStartTime)
{
}
public override void ApplyBeatmap(IBeatmap beatmap)
{
lowestHpEver = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 195, 160, 60);
lowestHpComboEnd = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 198, 170, 80);
lowestHpEnd = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 198, 180, 80);
hpRecoveryAvailable = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 8, 4, 0);
base.ApplyBeatmap(beatmap);
}
protected override void ApplyResultInternal(JudgementResult result)
{
if (!IsSimulating)
throw new NotSupportedException("The legacy osu! health processor is not supported for gameplay.");
}
protected override void RevertResultInternal(JudgementResult result)
{
if (!IsSimulating)
throw new NotSupportedException("The legacy osu! health processor is not supported for gameplay.");
}
protected override void Reset(bool storeResults)
{
hpMultiplierNormal = 1;
hpMultiplierComboEnd = 1;
base.Reset(storeResults);
}
protected override double ComputeDrainRate()
{
double testDrop = 0.05;
double currentHp;
double currentHpUncapped;
do
{
currentHp = hp_bar_maximum;
currentHpUncapped = hp_bar_maximum;
double lowestHp = currentHp;
double lastTime = DrainStartTime;
int currentBreak = 0;
bool fail = false;
int comboTooLowCount = 0;
string failReason = string.Empty;
for (int i = 0; i < Beatmap.HitObjects.Count; i++)
{
HitObject h = Beatmap.HitObjects[i];
// Find active break (between current and lastTime)
double localLastTime = lastTime;
double breakTime = 0;
// Subtract any break time from the duration since the last object
if (Beatmap.Breaks.Count > 0 && currentBreak < Beatmap.Breaks.Count)
{
BreakPeriod e = Beatmap.Breaks[currentBreak];
if (e.StartTime >= localLastTime && e.EndTime <= h.StartTime)
{
// consider break start equal to object end time for version 8+ since drain stops during this time
breakTime = (Beatmap.BeatmapInfo.BeatmapVersion < 8) ? (e.EndTime - e.StartTime) : e.EndTime - localLastTime;
currentBreak++;
}
}
reduceHp(testDrop * (h.StartTime - lastTime - breakTime));
lastTime = h.GetEndTime();
if (currentHp < lowestHp)
lowestHp = currentHp;
if (currentHp <= lowestHpEver)
{
fail = true;
testDrop *= 0.96;
failReason = $"hp too low ({currentHp / hp_bar_maximum} < {lowestHpEver / hp_bar_maximum})";
break;
}
double hpReduction = testDrop * (h.GetEndTime() - h.StartTime);
double hpOverkill = Math.Max(0, hpReduction - currentHp);
reduceHp(hpReduction);
if (h is Slider slider)
{
for (int j = 0; j < slider.RepeatCount + 2; j++)
increaseHp(hpMultiplierNormal * hp_slider_repeat);
foreach (var _ in slider.NestedHitObjects.OfType<SliderTick>())
increaseHp(hpMultiplierNormal * hp_slider_tick);
}
else if (h is Spinner spinner)
{
foreach (var _ in spinner.NestedHitObjects.Where(t => t is not SpinnerBonusTick))
increaseHp(hpMultiplierNormal * 1.7);
}
if (hpOverkill > 0 && currentHp - hpOverkill <= lowestHpEver)
{
fail = true;
testDrop *= 0.96;
failReason = $"overkill ({currentHp / hp_bar_maximum} - {hpOverkill / hp_bar_maximum} <= {lowestHpEver / hp_bar_maximum})";
break;
}
if (ApplyComboEndBonus && (i == Beatmap.HitObjects.Count - 1 || ((OsuHitObject)Beatmap.HitObjects[i + 1]).NewCombo))
{
increaseHp(hpMultiplierComboEnd * hp_combo_geki + hpMultiplierNormal * hp_hit_300);
if (currentHp < lowestHpComboEnd)
{
if (++comboTooLowCount > 2)
{
hpMultiplierComboEnd *= 1.07;
hpMultiplierNormal *= 1.03;
fail = true;
failReason = $"combo end hp too low ({currentHp / hp_bar_maximum} < {lowestHpComboEnd / hp_bar_maximum})";
break;
}
}
}
else
increaseHp(hpMultiplierNormal * hp_hit_300);
}
if (!fail && currentHp < lowestHpEnd)
{
fail = true;
testDrop *= 0.94;
hpMultiplierComboEnd *= 1.01;
hpMultiplierNormal *= 1.01;
failReason = $"end hp too low ({currentHp / hp_bar_maximum} < {lowestHpEnd / hp_bar_maximum})";
}
double recovery = (currentHpUncapped - hp_bar_maximum) / Beatmap.HitObjects.Count;
if (!fail && recovery < hpRecoveryAvailable)
{
fail = true;
testDrop *= 0.96;
hpMultiplierComboEnd *= 1.02;
hpMultiplierNormal *= 1.01;
failReason = $"recovery too low ({recovery / hp_bar_maximum} < {hpRecoveryAvailable / hp_bar_maximum})";
}
if (fail)
{
OnIterationFail?.Invoke($"FAILED drop {testDrop / hp_bar_maximum}: {failReason}");
continue;
}
OnIterationSuccess?.Invoke($"PASSED drop {testDrop / hp_bar_maximum}");
return testDrop / hp_bar_maximum;
} while (true);
void reduceHp(double amount)
{
currentHpUncapped = Math.Max(0, currentHpUncapped - amount);
currentHp = Math.Max(0, currentHp - amount);
}
void increaseHp(double amount)
{
currentHpUncapped += amount;
currentHp = Math.Max(0, Math.Min(hp_bar_maximum, currentHp + amount));
}
}
}
}

View File

@ -1,177 +1,43 @@
// 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.Beatmaps.Timing;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Osu.Scoring
{
public partial class OsuHealthProcessor : DrainingHealthProcessor
public partial class OsuHealthProcessor : LegacyDrainingHealthProcessor
{
public Action<string>? OnIterationFail;
public Action<string>? OnIterationSuccess;
private double lowestHpEver;
private double lowestHpEnd;
private double hpRecoveryAvailable;
private double hpMultiplierNormal;
public OsuHealthProcessor(double drainStartTime)
: base(drainStartTime)
{
}
public override void ApplyBeatmap(IBeatmap beatmap)
protected override IEnumerable<HitObject> EnumerateTopLevelHitObjects() => Beatmap.HitObjects;
protected override IEnumerable<HitObject> EnumerateNestedHitObjects(HitObject hitObject)
{
lowestHpEver = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 0.975, 0.8, 0.3);
lowestHpEnd = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 0.99, 0.9, 0.4);
hpRecoveryAvailable = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 0.04, 0.02, 0);
base.ApplyBeatmap(beatmap);
}
protected override void Reset(bool storeResults)
{
hpMultiplierNormal = 1;
base.Reset(storeResults);
}
protected override double ComputeDrainRate()
{
double testDrop = 0.00025;
double currentHp;
double currentHpUncapped;
while (true)
{
currentHp = 1;
currentHpUncapped = 1;
double lowestHp = currentHp;
double lastTime = DrainStartTime;
int currentBreak = 0;
bool fail = false;
for (int i = 0; i < Beatmap.HitObjects.Count; i++)
{
HitObject h = Beatmap.HitObjects[i];
// Find active break (between current and lastTime)
double localLastTime = lastTime;
double breakTime = 0;
// TODO: This doesn't handle overlapping/sequential breaks correctly (/b/614).
// Subtract any break time from the duration since the last object
// Note that this method is a bit convoluted, but matches stable code for compatibility.
if (Beatmap.Breaks.Count > 0 && currentBreak < Beatmap.Breaks.Count)
{
BreakPeriod e = Beatmap.Breaks[currentBreak];
if (e.StartTime >= localLastTime && e.EndTime <= h.StartTime)
{
// consider break start equal to object end time for version 8+ since drain stops during this time
breakTime = (Beatmap.BeatmapInfo.BeatmapVersion < 8) ? (e.EndTime - e.StartTime) : e.EndTime - localLastTime;
currentBreak++;
}
}
reduceHp(testDrop * (h.StartTime - lastTime - breakTime));
lastTime = h.GetEndTime();
if (currentHp < lowestHp)
lowestHp = currentHp;
if (currentHp <= lowestHpEver)
{
fail = true;
testDrop *= 0.96;
OnIterationFail?.Invoke($"FAILED drop {testDrop}: hp too low ({currentHp} < {lowestHpEver})");
break;
}
double hpReduction = testDrop * (h.GetEndTime() - h.StartTime);
double hpOverkill = Math.Max(0, hpReduction - currentHp);
reduceHp(hpReduction);
switch (h)
switch (hitObject)
{
case Slider slider:
{
foreach (var nested in slider.NestedHitObjects)
increaseHp(nested);
yield return nested;
break;
}
case Spinner spinner:
{
foreach (var nested in spinner.NestedHitObjects.Where(t => t is not SpinnerBonusTick))
increaseHp(nested);
yield return nested;
break;
}
}
// Note: Because HP is capped during the above increases, long sliders (with many ticks) or spinners
// will appear to overkill at lower drain levels than they should. However, it is also not correct to simply use the uncapped version.
if (hpOverkill > 0 && currentHp - hpOverkill <= lowestHpEver)
{
fail = true;
testDrop *= 0.96;
OnIterationFail?.Invoke($"FAILED drop {testDrop}: overkill ({currentHp} - {hpOverkill} <= {lowestHpEver})");
break;
}
increaseHp(h);
}
if (!fail && currentHp < lowestHpEnd)
{
fail = true;
testDrop *= 0.94;
hpMultiplierNormal *= 1.01;
OnIterationFail?.Invoke($"FAILED drop {testDrop}: end hp too low ({currentHp} < {lowestHpEnd})");
}
double recovery = (currentHpUncapped - 1) / Beatmap.HitObjects.Count;
if (!fail && recovery < hpRecoveryAvailable)
{
fail = true;
testDrop *= 0.96;
hpMultiplierNormal *= 1.01;
OnIterationFail?.Invoke($"FAILED drop {testDrop}: recovery too low ({recovery} < {hpRecoveryAvailable})");
}
if (!fail)
{
OnIterationSuccess?.Invoke($"PASSED drop {testDrop}");
return testDrop;
}
}
void reduceHp(double amount)
{
currentHpUncapped = Math.Max(0, currentHpUncapped - amount);
currentHp = Math.Max(0, currentHp - amount);
}
void increaseHp(HitObject hitObject)
{
double amount = healthIncreaseFor(hitObject, hitObject.CreateJudgement().MaxResult);
currentHpUncapped += amount;
currentHp = Math.Max(0, Math.Min(1, currentHp + amount));
}
}
protected override double GetHealthIncreaseFor(JudgementResult result) => healthIncreaseFor(result.HitObject, result.Type);
private double healthIncreaseFor(HitObject hitObject, HitResult result)
protected override double GetHealthIncreaseFor(HitObject hitObject, HitResult result)
{
double increase = 0;
@ -217,7 +83,7 @@ namespace osu.Game.Rulesets.Osu.Scoring
break;
}
return hpMultiplierNormal * increase;
return HpMultiplierNormal * increase;
}
}
}

View File

@ -192,7 +192,8 @@ namespace osu.Game.Tests.Gameplay
AddStep("apply perfect hit result", () => processor.ApplyResult(new JudgementResult(beatmap.HitObjects[0], new Judgement()) { Type = HitResult.Perfect }));
AddAssert("not failed", () => !processor.HasFailed);
AddStep($"apply {resultApplied.ToString().ToLowerInvariant()} hit result", () => processor.ApplyResult(new JudgementResult(beatmap.HitObjects[0], new Judgement()) { Type = resultApplied }));
AddStep($"apply {resultApplied.ToString().ToLowerInvariant()} hit result",
() => processor.ApplyResult(new JudgementResult(beatmap.HitObjects[0], new Judgement()) { Type = resultApplied }));
AddAssert("failed", () => processor.HasFailed);
}
@ -232,6 +233,84 @@ namespace osu.Game.Tests.Gameplay
assertHealthEqualTo(1);
}
[Test]
public void TestNoBreakDrainRate()
{
DrainingHealthProcessor hp = new DrainingHealthProcessor(-1000);
hp.ApplyBeatmap(new Beatmap<JudgeableHitObject>
{
HitObjects =
{
new JudgeableHitObject { StartTime = 0 },
new JudgeableHitObject { StartTime = 2000 }
}
});
Assert.That(hp.DrainRate, Is.EqualTo(4.5E-5).Within(0.1E-5));
}
[Test]
public void TestSingleBreakDrainRate()
{
DrainingHealthProcessor hp = new DrainingHealthProcessor(-1000);
hp.ApplyBeatmap(new Beatmap<JudgeableHitObject>
{
HitObjects =
{
new JudgeableHitObject { StartTime = 0 },
new JudgeableHitObject { StartTime = 2000 }
},
Breaks =
{
new BreakPeriod(500, 1500)
}
});
Assert.That(hp.DrainRate, Is.EqualTo(9.1E-5).Within(0.1E-5));
}
[Test]
public void TestOverlappingBreakDrainRate()
{
DrainingHealthProcessor hp = new DrainingHealthProcessor(-1000);
hp.ApplyBeatmap(new Beatmap<JudgeableHitObject>
{
HitObjects =
{
new JudgeableHitObject { StartTime = 0 },
new JudgeableHitObject { StartTime = 2000 }
},
Breaks =
{
new BreakPeriod(500, 1400),
new BreakPeriod(750, 1500),
}
});
Assert.That(hp.DrainRate, Is.EqualTo(9.1E-5).Within(0.1E-5));
}
[Test]
public void TestSequentialBreakDrainRate()
{
DrainingHealthProcessor hp = new DrainingHealthProcessor(-1000);
hp.ApplyBeatmap(new Beatmap<JudgeableHitObject>
{
HitObjects =
{
new JudgeableHitObject { StartTime = 0 },
new JudgeableHitObject { StartTime = 2000 }
},
Breaks =
{
new BreakPeriod(500, 1000),
new BreakPeriod(1000, 1500),
}
});
Assert.That(hp.DrainRate, Is.EqualTo(9.1E-5).Within(0.1E-5));
}
private Beatmap createBeatmap(double startTime, double endTime, params BreakPeriod[] breaks)
{
var beatmap = new Beatmap

View File

@ -35,7 +35,7 @@ namespace osu.Game.Tests.Visual.Editing
() => Game.Notifications.AllNotifications.Count(x => x.Text == EditorStrings.MustBeInEditorToHandleLinks),
() => Is.EqualTo(1));
AddStep("enter song select", () => Game.ChildrenOfType<ButtonSystem>().Single().OnSolo.Invoke());
AddStep("enter song select", () => Game.ChildrenOfType<ButtonSystem>().Single().OnSolo?.Invoke());
AddUntilStep("entered song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect);
addStepClickLink("00:00:000 (1)", waitForSeek: false);

View File

@ -179,7 +179,6 @@ namespace osu.Game.Tests.Visual.Gameplay
addFakeHit();
AddUntilStep("wait for fail", () => Player.GameplayState.HasFailed);
AddStep("exit", () => Player.Exit());
AddUntilStep("wait for submission", () => Player.SubmittedScore != null);
AddAssert("ensure failing submission", () => Player.SubmittedScore.ScoreInfo.Passed == false);

View File

@ -43,7 +43,7 @@ namespace osu.Game.Tests.Visual.Gameplay
private TestSpectatorClient spectatorClient => dependenciesScreen.SpectatorClient;
private DependenciesScreen dependenciesScreen;
private SoloSpectator spectatorScreen;
private SoloSpectatorScreen spectatorScreen;
private BeatmapSetInfo importedBeatmap;
private int importedBeatmapId;
@ -81,7 +81,7 @@ namespace osu.Game.Tests.Visual.Gameplay
start();
waitForPlayer();
waitForPlayerCurrent();
sendFrames(startTime: gameplay_start);
@ -100,23 +100,22 @@ namespace osu.Game.Tests.Visual.Gameplay
start();
AddUntilStep("wait for player loader", () => (Stack.CurrentScreen as PlayerLoader)?.IsLoaded == true);
AddUntilStep("wait for player loader", () => this.ChildrenOfType<PlayerLoader>().SingleOrDefault()?.IsLoaded == true);
AddUntilStep("queue send frames on player load", () =>
{
var loadingPlayer = (Stack.CurrentScreen as PlayerLoader)?.CurrentPlayer;
var loadingPlayer = this.ChildrenOfType<PlayerLoader>().SingleOrDefault()?.CurrentPlayer;
if (loadingPlayer == null)
return false;
loadingPlayer.OnLoadComplete += _ =>
{
spectatorClient.SendFramesFromUser(streamingUser.Id, 10, gameplay_start);
};
return true;
});
waitForPlayer();
waitForPlayerCurrent();
AddUntilStep("state is playing", () => spectatorClient.WatchedUserStates[streamingUser.Id].State == SpectatedUserState.Playing);
AddAssert("time is greater than seek target", () => currentFrameStableTime, () => Is.GreaterThan(gameplay_start));
@ -127,10 +126,10 @@ namespace osu.Game.Tests.Visual.Gameplay
{
loadSpectatingScreen();
AddAssert("screen hasn't changed", () => Stack.CurrentScreen is SoloSpectator);
AddAssert("screen hasn't changed", () => Stack.CurrentScreen is SoloSpectatorScreen);
start();
waitForPlayer();
waitForPlayerCurrent();
sendFrames();
AddAssert("ensure frames arrived", () => replayHandler.HasFrames);
@ -156,7 +155,7 @@ namespace osu.Game.Tests.Visual.Gameplay
loadSpectatingScreen();
start();
waitForPlayer();
waitForPlayerCurrent();
checkPaused(true);
// send enough frames to ensure play won't be paused
@ -172,7 +171,7 @@ namespace osu.Game.Tests.Visual.Gameplay
sendFrames(300);
loadSpectatingScreen();
waitForPlayer();
waitForPlayerCurrent();
sendFrames(300);
@ -187,7 +186,7 @@ namespace osu.Game.Tests.Visual.Gameplay
start();
sendFrames();
waitForPlayer();
waitForPlayerCurrent();
Player lastPlayer = null;
AddStep("store first player", () => lastPlayer = player);
@ -195,7 +194,7 @@ namespace osu.Game.Tests.Visual.Gameplay
start();
sendFrames();
waitForPlayer();
waitForPlayerCurrent();
AddAssert("player is different", () => lastPlayer != player);
}
@ -206,7 +205,7 @@ namespace osu.Game.Tests.Visual.Gameplay
start();
waitForPlayer();
waitForPlayerCurrent();
checkPaused(true);
sendFrames();
@ -224,7 +223,7 @@ namespace osu.Game.Tests.Visual.Gameplay
start();
sendFrames();
waitForPlayer();
waitForPlayerCurrent();
AddStep("stop spectating", () => (Stack.CurrentScreen as Player)?.Exit());
AddUntilStep("spectating stopped", () => spectatorScreen.GetChildScreen() == null);
@ -237,14 +236,14 @@ namespace osu.Game.Tests.Visual.Gameplay
start();
sendFrames();
waitForPlayer();
waitForPlayerCurrent();
AddStep("stop spectating", () => (Stack.CurrentScreen as Player)?.Exit());
AddUntilStep("spectating stopped", () => spectatorScreen.GetChildScreen() == null);
// host starts playing a new session
start();
waitForPlayer();
waitForPlayerCurrent();
}
[Test]
@ -255,7 +254,7 @@ namespace osu.Game.Tests.Visual.Gameplay
start(-1234);
sendFrames();
AddAssert("screen didn't change", () => Stack.CurrentScreen is SoloSpectator);
AddAssert("screen didn't change", () => Stack.CurrentScreen is SoloSpectatorScreen);
}
[Test]
@ -299,7 +298,7 @@ namespace osu.Game.Tests.Visual.Gameplay
start();
sendFrames();
waitForPlayer();
waitForPlayerCurrent();
AddUntilStep("state is playing", () => spectatorClient.WatchedUserStates[streamingUser.Id].State == SpectatedUserState.Playing);
}
@ -310,14 +309,14 @@ namespace osu.Game.Tests.Visual.Gameplay
start();
sendFrames();
waitForPlayer();
waitForPlayerCurrent();
AddStep("send passed", () => spectatorClient.SendEndPlay(streamingUser.Id, SpectatedUserState.Passed));
AddUntilStep("state is passed", () => spectatorClient.WatchedUserStates[streamingUser.Id].State == SpectatedUserState.Passed);
start();
sendFrames();
waitForPlayer();
waitForPlayerCurrent();
AddUntilStep("state is playing", () => spectatorClient.WatchedUserStates[streamingUser.Id].State == SpectatedUserState.Playing);
}
@ -328,44 +327,72 @@ namespace osu.Game.Tests.Visual.Gameplay
start();
sendFrames();
waitForPlayer();
waitForPlayerCurrent();
AddStep("send quit", () => spectatorClient.SendEndPlay(streamingUser.Id));
AddUntilStep("state is quit", () => spectatorClient.WatchedUserStates[streamingUser.Id].State == SpectatedUserState.Quit);
AddAssert("wait for player exit", () => Stack.CurrentScreen is SoloSpectatorScreen);
start();
sendFrames();
waitForPlayer();
waitForPlayerCurrent();
AddUntilStep("state is playing", () => spectatorClient.WatchedUserStates[streamingUser.Id].State == SpectatedUserState.Playing);
}
[Test]
public void TestFailedState()
public void TestFailedStateDuringPlay()
{
loadSpectatingScreen();
start();
sendFrames();
waitForPlayer();
waitForPlayerCurrent();
AddStep("send failed", () => spectatorClient.SendEndPlay(streamingUser.Id, SpectatedUserState.Failed));
AddUntilStep("state is failed", () => spectatorClient.WatchedUserStates[streamingUser.Id].State == SpectatedUserState.Failed);
AddUntilStep("wait for player to fail", () => player.GameplayState.HasFailed);
start();
sendFrames();
waitForPlayer();
waitForPlayerCurrent();
AddUntilStep("state is playing", () => spectatorClient.WatchedUserStates[streamingUser.Id].State == SpectatedUserState.Playing);
}
[Test]
public void TestFailedStateDuringLoading()
{
loadSpectatingScreen();
start();
sendFrames();
waitForPlayerLoader();
AddStep("send failed", () => spectatorClient.SendEndPlay(streamingUser.Id, SpectatedUserState.Failed));
AddUntilStep("state is failed", () => spectatorClient.WatchedUserStates[streamingUser.Id].State == SpectatedUserState.Failed);
AddAssert("wait for player exit", () => Stack.CurrentScreen is SoloSpectatorScreen);
start();
sendFrames();
waitForPlayerCurrent();
AddUntilStep("state is playing", () => spectatorClient.WatchedUserStates[streamingUser.Id].State == SpectatedUserState.Playing);
}
private OsuFramedReplayInputHandler replayHandler =>
(OsuFramedReplayInputHandler)Stack.ChildrenOfType<OsuInputManager>().First().ReplayInputHandler;
private Player player => Stack.CurrentScreen as Player;
private Player player => this.ChildrenOfType<Player>().Single();
private double currentFrameStableTime
=> player.ChildrenOfType<FrameStabilityContainer>().First().CurrentTime;
private void waitForPlayer() => AddUntilStep("wait for player", () => (Stack.CurrentScreen as Player)?.IsLoaded == true);
private void waitForPlayerLoader() => AddUntilStep("wait for loading", () => this.ChildrenOfType<SpectatorPlayerLoader>().SingleOrDefault()?.IsLoaded == true);
private void waitForPlayerCurrent() => AddUntilStep("wait for player current", () => this.ChildrenOfType<Player>().SingleOrDefault()?.IsCurrentScreen() == true);
private void start(int? beatmapId = null) => AddStep("start play", () => spectatorClient.SendStartPlay(streamingUser.Id, beatmapId ?? importedBeatmapId));
@ -381,7 +408,7 @@ namespace osu.Game.Tests.Visual.Gameplay
private void loadSpectatingScreen()
{
AddStep("load spectator", () => LoadScreen(spectatorScreen = new SoloSpectator(streamingUser)));
AddStep("load spectator", () => LoadScreen(spectatorScreen = new SoloSpectatorScreen(streamingUser)));
AddUntilStep("wait for screen load", () => spectatorScreen.LoadState == LoadState.Loaded);
}

View File

@ -239,7 +239,7 @@ namespace osu.Game.Tests.Visual.Navigation
{
AddUntilStep("wait for dialog overlay", () => Game.ChildrenOfType<DialogOverlay>().SingleOrDefault() != null);
AddStep("open editor", () => Game.ChildrenOfType<ButtonSystem>().Single().OnEdit.Invoke());
AddStep("open editor", () => Game.ChildrenOfType<ButtonSystem>().Single().OnEditBeatmap?.Invoke());
AddUntilStep("wait for editor", () => Game.ScreenStack.CurrentScreen is Editor editor && editor.IsLoaded);
AddStep("click on file", () =>
{

View File

@ -97,7 +97,7 @@ namespace osu.Game.Tests.Visual.Navigation
[Test]
public void TestFromSongSelectWithFilter([Values] ScorePresentType type)
{
AddStep("enter song select", () => Game.ChildrenOfType<ButtonSystem>().Single().OnSolo.Invoke());
AddStep("enter song select", () => Game.ChildrenOfType<ButtonSystem>().Single().OnSolo?.Invoke());
AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect && songSelect.BeatmapSetsLoaded);
AddStep("filter to nothing", () => ((PlaySongSelect)Game.ScreenStack.CurrentScreen).FilterControl.CurrentTextSearch.Value = "fdsajkl;fgewq");
@ -110,7 +110,7 @@ namespace osu.Game.Tests.Visual.Navigation
[Test]
public void TestFromSongSelectWithConvertRulesetChange([Values] ScorePresentType type)
{
AddStep("enter song select", () => Game.ChildrenOfType<ButtonSystem>().Single().OnSolo.Invoke());
AddStep("enter song select", () => Game.ChildrenOfType<ButtonSystem>().Single().OnSolo?.Invoke());
AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect && songSelect.BeatmapSetsLoaded);
AddStep("set convert to false", () => Game.LocalConfig.SetValue(OsuSetting.ShowConvertedBeatmaps, false));
@ -122,7 +122,7 @@ namespace osu.Game.Tests.Visual.Navigation
[Test]
public void TestFromSongSelect([Values] ScorePresentType type)
{
AddStep("enter song select", () => Game.ChildrenOfType<ButtonSystem>().Single().OnSolo.Invoke());
AddStep("enter song select", () => Game.ChildrenOfType<ButtonSystem>().Single().OnSolo?.Invoke());
AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect && songSelect.BeatmapSetsLoaded);
var firstImport = importScore(1);
@ -135,7 +135,7 @@ namespace osu.Game.Tests.Visual.Navigation
[Test]
public void TestFromSongSelectDifferentRuleset([Values] ScorePresentType type)
{
AddStep("enter song select", () => Game.ChildrenOfType<ButtonSystem>().Single().OnSolo.Invoke());
AddStep("enter song select", () => Game.ChildrenOfType<ButtonSystem>().Single().OnSolo?.Invoke());
AddUntilStep("song select is current", () => Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect && songSelect.BeatmapSetsLoaded);
var firstImport = importScore(1);

View File

@ -180,11 +180,8 @@ namespace osu.Game.Tests.Visual.Online
});
AddStep("Show overlay", () => chatOverlay.Show());
AddAssert("Overlay uses config height", () => chatOverlay.Height == configChatHeight.Default);
AddStep("Click top bar", () =>
{
InputManager.MoveMouseTo(chatOverlayTopBar);
InputManager.PressButton(MouseButton.Left);
});
AddStep("Move mouse to drag bar", () => InputManager.MoveMouseTo(chatOverlayTopBar.DragBar));
AddStep("Click drag bar", () => InputManager.PressButton(MouseButton.Left));
AddStep("Drag overlay to new height", () => InputManager.MoveMouseTo(chatOverlayTopBar, new Vector2(0, -300)));
AddStep("Stop dragging", () => InputManager.ReleaseButton(MouseButton.Left));
AddStep("Store new height", () => newHeight = chatOverlay.Height);

View File

@ -67,14 +67,15 @@ namespace osu.Game.Tests.Visual.UserInterface
AddStep("Enter mode", performEnterMode);
}
[TestCase(Key.P, true)]
[TestCase(Key.M, true)]
[TestCase(Key.L, true)]
[TestCase(Key.E, false)]
[TestCase(Key.D, false)]
[TestCase(Key.Q, false)]
[TestCase(Key.O, false)]
public void TestShortcutKeys(Key key, bool entersPlay)
[TestCase(Key.P, Key.P)]
[TestCase(Key.M, Key.P)]
[TestCase(Key.L, Key.P)]
[TestCase(Key.B, Key.E)]
[TestCase(Key.S, Key.E)]
[TestCase(Key.D, null)]
[TestCase(Key.Q, null)]
[TestCase(Key.O, null)]
public void TestShortcutKeys(Key key, Key? subMenuEnterKey)
{
int activationCount = -1;
AddStep("set up action", () =>
@ -96,8 +97,12 @@ namespace osu.Game.Tests.Visual.UserInterface
buttons.OnPlaylists = action;
break;
case Key.E:
buttons.OnEdit = action;
case Key.B:
buttons.OnEditBeatmap = action;
break;
case Key.S:
buttons.OnEditSkin = action;
break;
case Key.D:
@ -117,10 +122,10 @@ namespace osu.Game.Tests.Visual.UserInterface
AddStep($"press {key}", () => InputManager.Key(key));
AddAssert("state is top level", () => buttons.State == ButtonSystemState.TopLevel);
if (entersPlay)
if (subMenuEnterKey != null)
{
AddStep("press P", () => InputManager.Key(Key.P));
AddAssert("state is play", () => buttons.State == ButtonSystemState.Play);
AddStep($"press {subMenuEnterKey}", () => InputManager.Key(subMenuEnterKey.Value));
AddAssert("state is not top menu", () => buttons.State != ButtonSystemState.TopLevel);
}
AddStep($"press {key}", () => InputManager.Key(key));

View File

@ -7,6 +7,7 @@ using osu.Framework.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Framework.Allocation;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osuTK.Graphics;
namespace osu.Game.Tests.Visual.UserInterface
@ -153,7 +154,7 @@ namespace osu.Game.Tests.Visual.UserInterface
public TestTitle()
{
Title = "title";
IconTexture = "Icons/changelog";
Icon = HexaconsIcons.Devtools;
}
}
}

View File

@ -0,0 +1,131 @@
// 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.IO;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Framework.Text;
namespace osu.Game.Graphics
{
public static class HexaconsIcons
{
public const string FONT_NAME = "Icons/Hexacons";
public static IconUsage BeatmapPacks => get(HexaconsMapping.beatmap_packs);
public static IconUsage Beatmap => get(HexaconsMapping.beatmap);
public static IconUsage Calendar => get(HexaconsMapping.calendar);
public static IconUsage Chart => get(HexaconsMapping.chart);
public static IconUsage Community => get(HexaconsMapping.community);
public static IconUsage Contests => get(HexaconsMapping.contests);
public static IconUsage Devtools => get(HexaconsMapping.devtools);
public static IconUsage Download => get(HexaconsMapping.download);
public static IconUsage Editor => get(HexaconsMapping.editor);
public static IconUsage FeaturedArtist => get(HexaconsMapping.featured_artist);
public static IconUsage Home => get(HexaconsMapping.home);
public static IconUsage Messaging => get(HexaconsMapping.messaging);
public static IconUsage Music => get(HexaconsMapping.music);
public static IconUsage News => get(HexaconsMapping.news);
public static IconUsage Notification => get(HexaconsMapping.notification);
public static IconUsage Profile => get(HexaconsMapping.profile);
public static IconUsage Rankings => get(HexaconsMapping.rankings);
public static IconUsage Search => get(HexaconsMapping.search);
public static IconUsage Settings => get(HexaconsMapping.settings);
public static IconUsage Social => get(HexaconsMapping.social);
public static IconUsage Store => get(HexaconsMapping.store);
public static IconUsage Tournament => get(HexaconsMapping.tournament);
public static IconUsage Wiki => get(HexaconsMapping.wiki);
private static IconUsage get(HexaconsMapping icon) => new IconUsage((char)icon, FONT_NAME);
// Basically just converting to something we can use in a `char` lookup for FontStore/GlyphStore compatibility.
// Names should match filenames in resources.
private enum HexaconsMapping
{
beatmap_packs,
beatmap,
calendar,
chart,
community,
contests,
devtools,
download,
editor,
featured_artist,
home,
messaging,
music,
news,
notification,
profile,
rankings,
search,
settings,
social,
store,
tournament,
wiki,
}
public class HexaconsStore : ITextureStore, ITexturedGlyphLookupStore
{
private readonly TextureStore textures;
public HexaconsStore(TextureStore textures)
{
this.textures = textures;
}
public void Dispose()
{
textures.Dispose();
}
public ITexturedCharacterGlyph? Get(string? fontName, char character)
{
if (fontName == FONT_NAME)
return new Glyph(textures.Get($"{fontName}/{((HexaconsMapping)character).ToString().Replace("_", "-")}"));
return null;
}
public Task<ITexturedCharacterGlyph?> GetAsync(string fontName, char character) => Task.Run(() => Get(fontName, character));
public Texture? Get(string name, WrapMode wrapModeS, WrapMode wrapModeT) => null;
public Texture Get(string name) => throw new NotImplementedException();
public Task<Texture> GetAsync(string name, CancellationToken cancellationToken = default) => throw new NotImplementedException();
public Stream GetStream(string name) => throw new NotImplementedException();
public IEnumerable<string> GetAvailableResources() => throw new NotImplementedException();
public Task<Texture?> GetAsync(string name, WrapMode wrapModeS, WrapMode wrapModeT, CancellationToken cancellationToken = default) => throw new NotImplementedException();
public class Glyph : ITexturedCharacterGlyph
{
public float XOffset => default;
public float YOffset => default;
public float XAdvance => default;
public float Baseline => default;
public char Character => default;
public float GetKerning<T>(T lastGlyph) where T : ICharacterGlyph => throw new NotImplementedException();
public Texture Texture { get; }
public float Width => Texture.Width;
public float Height => Texture.Height;
public Glyph(Texture texture)
{
Texture = texture;
}
}
}
}
}

View File

@ -9,6 +9,11 @@ namespace osu.Game.Localisation
{
private const string prefix = @"osu.Game.Resources.Localisation.Editor";
/// <summary>
/// "Beatmap editor"
/// </summary>
public static LocalisableString BeatmapEditor => new TranslatableString(getKey(@"beatmap_editor"), @"Beatmap editor");
/// <summary>
/// "Waveform opacity"
/// </summary>

View File

@ -48,7 +48,7 @@ namespace osu.Game.Online.Spectator
/// <summary>
/// Whether the local user is playing.
/// </summary>
protected internal bool IsPlaying { get; private set; }
private bool isPlaying { get; set; }
/// <summary>
/// Called whenever new frames arrive from the server.
@ -58,17 +58,17 @@ namespace osu.Game.Online.Spectator
/// <summary>
/// Called whenever a user starts a play session, or immediately if the user is being watched and currently in a play session.
/// </summary>
public virtual event Action<int, SpectatorState>? OnUserBeganPlaying;
public event Action<int, SpectatorState>? OnUserBeganPlaying;
/// <summary>
/// Called whenever a user finishes a play session.
/// </summary>
public virtual event Action<int, SpectatorState>? OnUserFinishedPlaying;
public event Action<int, SpectatorState>? OnUserFinishedPlaying;
/// <summary>
/// Called whenever a user-submitted score has been fully processed.
/// </summary>
public virtual event Action<int, long>? OnUserScoreProcessed;
public event Action<int, long>? OnUserScoreProcessed;
/// <summary>
/// A dictionary containing all users currently being watched, with the number of watching components for each user.
@ -114,7 +114,7 @@ namespace osu.Game.Online.Spectator
}
// re-send state in case it wasn't received
if (IsPlaying)
if (isPlaying)
// TODO: this is likely sent out of order after a reconnect scenario. needs further consideration.
BeginPlayingInternal(currentScoreToken, currentState);
}
@ -179,10 +179,10 @@ namespace osu.Game.Online.Spectator
// This schedule is only here to match the one below in `EndPlaying`.
Schedule(() =>
{
if (IsPlaying)
if (isPlaying)
throw new InvalidOperationException($"Cannot invoke {nameof(BeginPlaying)} when already playing");
IsPlaying = true;
isPlaying = true;
// transfer state at point of beginning play
currentState.BeatmapID = score.ScoreInfo.BeatmapInfo!.OnlineID;
@ -202,7 +202,7 @@ namespace osu.Game.Online.Spectator
public void HandleFrame(ReplayFrame frame) => Schedule(() =>
{
if (!IsPlaying)
if (!isPlaying)
{
Logger.Log($"Frames arrived at {nameof(SpectatorClient)} outside of gameplay scope and will be ignored.");
return;
@ -224,7 +224,7 @@ namespace osu.Game.Online.Spectator
// We probably need to find a better way to handle this...
Schedule(() =>
{
if (!IsPlaying)
if (!isPlaying)
return;
// Disposal can take some time, leading to EndPlaying potentially being called after a future play session.
@ -235,7 +235,7 @@ namespace osu.Game.Online.Spectator
if (pendingFrames.Count > 0)
purgePendingFrames();
IsPlaying = false;
isPlaying = false;
currentBeatmap = null;
if (state.HasPassed)

View File

@ -477,6 +477,8 @@ namespace osu.Game
AddFont(Resources, @"Fonts/Venera/Venera-Light");
AddFont(Resources, @"Fonts/Venera/Venera-Bold");
AddFont(Resources, @"Fonts/Venera/Venera-Black");
Fonts.AddStore(new HexaconsIcons.HexaconsStore(Textures));
}
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) =>

View File

@ -4,6 +4,7 @@
#nullable disable
using osu.Framework.Graphics;
using osu.Game.Graphics;
using osu.Game.Localisation;
using osu.Game.Resources.Localisation.Web;
@ -23,7 +24,7 @@ namespace osu.Game.Overlays.BeatmapListing
{
Title = PageTitleStrings.MainBeatmapsetsControllerIndex;
Description = NamedOverlayComponentStrings.BeatmapListingDescription;
IconTexture = "Icons/Hexacons/beatmap";
Icon = HexaconsIcons.Beatmap;
}
}
}

View File

@ -9,6 +9,7 @@ using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Effects;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Resources.Localisation.Web;
using osu.Game.Rulesets;
@ -59,7 +60,7 @@ namespace osu.Game.Overlays.BeatmapSet
public BeatmapHeaderTitle()
{
Title = PageTitleStrings.MainBeatmapsetsControllerShow;
IconTexture = "Icons/Hexacons/beatmap";
Icon = HexaconsIcons.Beatmap;
}
}
}

View File

@ -12,6 +12,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Localisation;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Resources.Localisation.Web;
@ -123,7 +124,7 @@ namespace osu.Game.Overlays.Changelog
{
Title = PageTitleStrings.MainChangelogControllerDefault;
Description = NamedOverlayComponentStrings.ChangelogDescription;
IconTexture = "Icons/Hexacons/devtools";
Icon = HexaconsIcons.Devtools;
}
}
}

View File

@ -1,8 +1,8 @@
// 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 osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
@ -13,25 +13,22 @@ using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Resources.Localisation.Web;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Overlays.Chat
{
public partial class ChatOverlayTopBar : Container
{
private Box background = null!;
private Color4 backgroundColour;
public Drawable DragBar { get; private set; } = null!;
[BackgroundDependencyLoader]
private void load(OverlayColourProvider colourProvider, TextureStore textures)
{
Children = new Drawable[]
Children = new[]
{
background = new Box
new Box
{
RelativeSizeAxes = Axes.Both,
Colour = backgroundColour = colourProvider.Background3,
Colour = colourProvider.Background3,
},
new GridContainer
{
@ -45,12 +42,12 @@ namespace osu.Game.Overlays.Chat
{
new Drawable[]
{
new Sprite
new SpriteIcon
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Texture = textures.Get("Icons/Hexacons/messaging"),
Size = new Vector2(18),
Icon = HexaconsIcons.Social,
Size = new Vector2(24),
},
// Placeholder text
new OsuSpriteText
@ -64,19 +61,92 @@ namespace osu.Game.Overlays.Chat
},
},
},
DragBar = new DragArea
{
Alpha = RuntimeInfo.IsMobile ? 1 : 0,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Colour = colourProvider.Background4,
}
};
}
protected override bool OnHover(HoverEvent e)
{
background.FadeColour(backgroundColour.Lighten(0.1f), 300, Easing.OutQuint);
if (!RuntimeInfo.IsMobile)
DragBar.FadeIn(100);
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
background.FadeColour(backgroundColour, 300, Easing.OutQuint);
if (!RuntimeInfo.IsMobile)
DragBar.FadeOut(100);
base.OnHoverLost(e);
}
private partial class DragArea : CompositeDrawable
{
private readonly Circle circle;
public DragArea()
{
AutoSizeAxes = Axes.Both;
InternalChildren = new Drawable[]
{
circle = new Circle
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(150, 7),
Margin = new MarginPadding(12),
}
};
}
protected override bool OnHover(HoverEvent e)
{
updateScale();
return base.OnHover(e);
}
protected override void OnHoverLost(HoverLostEvent e)
{
updateScale();
base.OnHoverLost(e);
}
private bool dragging;
protected override bool OnMouseDown(MouseDownEvent e)
{
dragging = true;
updateScale();
return base.OnMouseDown(e);
}
protected override void OnMouseUp(MouseUpEvent e)
{
dragging = false;
updateScale();
base.OnMouseUp(e);
}
private void updateScale()
{
if (dragging || IsHovered)
circle.FadeIn(100);
else
circle.FadeTo(0.6f, 100);
if (dragging)
circle.ScaleTo(1f, 400, Easing.OutQuint);
else if (IsHovered)
circle.ScaleTo(1.05f, 400, Easing.OutElasticHalf);
else
circle.ScaleTo(1f, 500, Easing.OutQuint);
}
}
}
}

View File

@ -11,11 +11,13 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Localisation;
@ -29,7 +31,7 @@ namespace osu.Game.Overlays
{
public partial class ChatOverlay : OsuFocusedOverlayContainer, INamedOverlayComponent, IKeyBindingHandler<PlatformAction>
{
public string IconTexture => "Icons/Hexacons/messaging";
public IconUsage Icon => HexaconsIcons.Messaging;
public LocalisableString Title => ChatStrings.HeaderTitle;
public LocalisableString Description => ChatStrings.HeaderDescription;
@ -251,10 +253,14 @@ namespace osu.Game.Overlays
{
}
protected override bool OnMouseDown(MouseDownEvent e)
{
isDraggingTopBar = topBar.DragBar.IsHovered;
return base.OnMouseDown(e);
}
protected override bool OnDragStart(DragStartEvent e)
{
isDraggingTopBar = topBar.IsHovered;
if (!isDraggingTopBar)
return base.OnDragStart(e);

View File

@ -212,7 +212,7 @@ namespace osu.Game.Overlays.Dashboard
Text = "Spectate",
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
Action = () => performer?.PerformFromScreen(s => s.Push(new SoloSpectator(User))),
Action = () => performer?.PerformFromScreen(s => s.Push(new SoloSpectatorScreen(User))),
Enabled = { Value = User.Id != api.LocalUser.Value.Id }
}
}

View File

@ -3,6 +3,7 @@
using System.ComponentModel;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Localisation;
using osu.Game.Resources.Localisation.Web;
@ -18,7 +19,7 @@ namespace osu.Game.Overlays.Dashboard
{
Title = PageTitleStrings.MainHomeControllerIndex;
Description = NamedOverlayComponentStrings.DashboardDescription;
IconTexture = "Icons/Hexacons/social";
Icon = HexaconsIcons.Social;
}
}
}

View File

@ -7,6 +7,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Graphics.Containers;
using osu.Game.Online.API;
@ -17,7 +18,7 @@ namespace osu.Game.Overlays
public abstract partial class FullscreenOverlay<T> : WaveOverlayContainer, INamedOverlayComponent
where T : OverlayHeader
{
public virtual string IconTexture => Header.Title.IconTexture;
public virtual IconUsage Icon => Header.Title.Icon;
public virtual LocalisableString Title => Header.Title.Title;
public virtual LocalisableString Description => Header.Title.Description;

View File

@ -1,13 +1,14 @@
// 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 osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
namespace osu.Game.Overlays
{
public interface INamedOverlayComponent
{
string IconTexture { get; }
IconUsage Icon { get; }
LocalisableString Title { get; }

View File

@ -7,6 +7,7 @@ using System;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Localisation;
using osu.Game.Resources.Localisation.Web;
@ -68,7 +69,7 @@ namespace osu.Game.Overlays.News
{
Title = PageTitleStrings.MainNewsControllerDefault;
Description = NamedOverlayComponentStrings.NewsDescription;
IconTexture = "Icons/Hexacons/news";
Icon = HexaconsIcons.News;
}
}
}

View File

@ -13,9 +13,11 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Framework.Logging;
using osu.Framework.Threading;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Overlays.Notifications;
using osu.Game.Resources.Localisation.Web;
@ -27,7 +29,7 @@ namespace osu.Game.Overlays
{
public partial class NotificationOverlay : OsuFocusedOverlayContainer, INamedOverlayComponent, INotificationOverlay
{
public string IconTexture => "Icons/Hexacons/notification";
public IconUsage Icon => HexaconsIcons.Notification;
public LocalisableString Title => NotificationsStrings.HeaderTitle;
public LocalisableString Description => NotificationsStrings.HeaderDescription;

View File

@ -29,7 +29,7 @@ namespace osu.Game.Overlays
{
public partial class NowPlayingOverlay : OsuFocusedOverlayContainer, INamedOverlayComponent
{
public string IconTexture => "Icons/Hexacons/music";
public IconUsage Icon => HexaconsIcons.Music;
public LocalisableString Title => NowPlayingStrings.HeaderTitle;
public LocalisableString Description => NowPlayingStrings.HeaderDescription;

View File

@ -1,13 +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.
#nullable disable
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
@ -20,7 +16,7 @@ namespace osu.Game.Overlays
public const float ICON_SIZE = 30;
private readonly OsuSpriteText titleText;
private readonly Container icon;
private readonly Container iconContainer;
private LocalisableString title;
@ -32,12 +28,20 @@ namespace osu.Game.Overlays
public LocalisableString Description { get; protected set; }
private string iconTexture;
private IconUsage icon;
public string IconTexture
public IconUsage Icon
{
get => iconTexture;
protected set => icon.Child = new OverlayTitleIcon(iconTexture = value);
get => icon;
protected set => iconContainer.Child = new SpriteIcon
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
FillMode = FillMode.Fit,
Icon = icon = value,
};
}
protected OverlayTitle()
@ -51,7 +55,7 @@ namespace osu.Game.Overlays
Direction = FillDirection.Horizontal,
Children = new Drawable[]
{
icon = new Container
iconContainer = new Container
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
@ -68,26 +72,5 @@ namespace osu.Game.Overlays
}
};
}
private partial class OverlayTitleIcon : Sprite
{
private readonly string textureName;
public OverlayTitleIcon(string textureName)
{
this.textureName = textureName;
RelativeSizeAxes = Axes.Both;
Anchor = Anchor.Centre;
Origin = Anchor.Centre;
FillMode = FillMode.Fit;
}
[BackgroundDependencyLoader]
private void load(TextureStore textures)
{
Texture = textures.Get(textureName);
}
}
}
}

View File

@ -6,6 +6,7 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Overlays.Profile.Header;
using osu.Game.Overlays.Profile.Header.Components;
using osu.Game.Resources.Localisation.Web;
@ -86,7 +87,7 @@ namespace osu.Game.Overlays.Profile
public ProfileHeaderTitle()
{
Title = PageTitleStrings.MainUsersControllerDefault;
IconTexture = "Icons/Hexacons/profile";
Icon = HexaconsIcons.Profile;
}
}
}

View File

@ -7,6 +7,7 @@ using osu.Framework.Bindables;
using osu.Game.Localisation;
using osu.Game.Resources.Localisation.Web;
using osu.Framework.Graphics;
using osu.Game.Graphics;
using osu.Game.Rulesets;
using osu.Game.Users;
@ -35,7 +36,7 @@ namespace osu.Game.Overlays.Rankings
{
Title = PageTitleStrings.MainRankingControllerDefault;
Description = NamedOverlayComponentStrings.RankingsDescription;
IconTexture = "Icons/Hexacons/rankings";
Icon = HexaconsIcons.Rankings;
}
}
}

View File

@ -12,14 +12,16 @@ using osu.Game.Overlays.Settings.Sections.Input;
using osuTK.Graphics;
using System.Collections.Generic;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Localisation;
namespace osu.Game.Overlays
{
public partial class SettingsOverlay : SettingsPanel, INamedOverlayComponent
{
public string IconTexture => "Icons/Hexacons/settings";
public IconUsage Icon => HexaconsIcons.Settings;
public LocalisableString Title => SettingsStrings.HeaderTitle;
public LocalisableString Description => SettingsStrings.HeaderDescription;

View File

@ -1,7 +1,10 @@
// 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.Diagnostics;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
@ -9,12 +12,21 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Framework.Screens;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Graphics.Containers;
using osu.Game.Input.Bindings;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Scoring;
using osu.Game.Screens;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Components;
using osu.Game.Screens.Menu;
using osu.Game.Screens.Play;
using osu.Game.Screens.Select;
using osu.Game.Utils;
using osuTK;
namespace osu.Game.Overlays.SkinEditor
@ -31,12 +43,27 @@ namespace osu.Game.Overlays.SkinEditor
private SkinEditor? skinEditor;
[Resolved]
private IPerformFromScreenRunner? performer { get; set; }
[Cached]
public readonly EditorClipboard Clipboard = new EditorClipboard();
[Resolved]
private OsuGame game { get; set; } = null!;
[Resolved]
private MusicController music { get; set; } = null!;
[Resolved]
private IBindable<RulesetInfo> ruleset { get; set; } = null!;
[Resolved]
private Bindable<IReadOnlyList<Mod>> mods { get; set; } = null!;
[Resolved]
private IBindable<WorkingBeatmap> beatmap { get; set; } = null!;
private OsuScreen? lastTargetScreen;
private Vector2 lastDrawSize;
@ -72,6 +99,9 @@ namespace osu.Game.Overlays.SkinEditor
{
globallyDisableBeatmapSkinSetting();
if (lastTargetScreen is MainMenu)
PresentGameplay();
if (skinEditor != null)
{
skinEditor.Show();
@ -105,6 +135,36 @@ namespace osu.Game.Overlays.SkinEditor
globallyReenableBeatmapSkinSetting();
}
public void PresentGameplay()
{
performer?.PerformFromScreen(screen =>
{
// If we're playing the intro, switch away to another beatmap.
if (beatmap.Value.BeatmapSetInfo.Protected)
{
music.NextTrack();
Schedule(PresentGameplay);
return;
}
if (screen is Player)
return;
var replayGeneratingMod = ruleset.Value.CreateInstance().GetAutoplayMod();
IReadOnlyList<Mod> usableMods = mods.Value;
if (replayGeneratingMod != null)
usableMods = usableMods.Append(replayGeneratingMod).ToArray();
if (!ModUtils.CheckCompatibleSet(usableMods, out var invalid))
mods.Value = mods.Value.Except(invalid).ToArray();
if (replayGeneratingMod != null)
screen.Push(new EndlessPlayer((beatmap, mods) => replayGeneratingMod.CreateScoreFromReplayData(beatmap, mods)));
}, new[] { typeof(Player), typeof(PlaySongSelect) });
}
protected override void Update()
{
base.Update();
@ -222,5 +282,25 @@ namespace osu.Game.Overlays.SkinEditor
leasedBeatmapSkins?.Return();
leasedBeatmapSkins = null;
}
private partial class EndlessPlayer : ReplayPlayer
{
public EndlessPlayer(Func<IBeatmap, IReadOnlyList<Mod>, Score> createScore)
: base(createScore, new PlayerConfiguration
{
ShowResults = false,
AutomaticallySkipIntro = true,
})
{
}
protected override void Update()
{
base.Update();
if (GameplayState.HasPassed)
GameplayClockContainer.Seek(0);
}
}
}
}

View File

@ -1,10 +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.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
@ -14,12 +11,8 @@ using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Localisation;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Screens;
using osu.Game.Screens.Play;
using osu.Game.Screens.Select;
using osu.Game.Utils;
using osuTK;
namespace osu.Game.Overlays.SkinEditor
@ -36,10 +29,7 @@ namespace osu.Game.Overlays.SkinEditor
private IPerformFromScreenRunner? performer { get; set; }
[Resolved]
private IBindable<RulesetInfo> ruleset { get; set; } = null!;
[Resolved]
private Bindable<IReadOnlyList<Mod>> mods { get; set; } = null!;
private SkinEditorOverlay? skinEditorOverlay { get; set; }
public SkinEditorSceneLibrary()
{
@ -96,24 +86,7 @@ namespace osu.Game.Overlays.SkinEditor
Text = SkinEditorStrings.Gameplay,
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Action = () => performer?.PerformFromScreen(screen =>
{
if (screen is Player)
return;
var replayGeneratingMod = ruleset.Value.CreateInstance().GetAutoplayMod();
IReadOnlyList<Mod> usableMods = mods.Value;
if (replayGeneratingMod != null)
usableMods = usableMods.Append(replayGeneratingMod).ToArray();
if (!ModUtils.CheckCompatibleSet(usableMods, out var invalid))
mods.Value = mods.Value.Except(invalid).ToArray();
if (replayGeneratingMod != null)
screen.Push(new PlayerLoader(() => new ReplayPlayer((beatmap, mods) => replayGeneratingMod.CreateScoreFromReplayData(beatmap, mods))));
}, new[] { typeof(Player), typeof(PlaySongSelect) })
Action = () => skinEditorOverlay?.PresentGameplay(),
},
}
},

View File

@ -10,12 +10,11 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Framework.Input;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Game.Database;
using osu.Framework.Localisation;
using osu.Game.Database;
using osu.Game.Graphics;
using osu.Game.Graphics.Backgrounds;
using osu.Game.Graphics.Containers;
@ -36,16 +35,13 @@ namespace osu.Game.Overlays.Toolbar
IconContainer.Show();
}
[Resolved]
private TextureStore textures { get; set; } = null!;
[Resolved]
private ReadableKeyCombinationProvider keyCombinationProvider { get; set; } = null!;
public void SetIcon(string texture) =>
SetIcon(new Sprite
public void SetIcon(IconUsage icon) =>
SetIcon(new SpriteIcon
{
Texture = textures.Get(texture),
Icon = icon,
});
public LocalisableString Text

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Game.Graphics;
using osu.Game.Input.Bindings;
using osu.Game.Localisation;
@ -20,7 +21,7 @@ namespace osu.Game.Overlays.Toolbar
{
TooltipMain = ToolbarStrings.HomeHeaderTitle;
TooltipSub = ToolbarStrings.HomeHeaderDescription;
SetIcon("Icons/Hexacons/home");
SetIcon(HexaconsIcons.Home);
}
}
}

View File

@ -39,7 +39,7 @@ namespace osu.Game.Overlays.Toolbar
{
TooltipMain = named.Title;
TooltipSub = named.Description;
SetIcon(named.IconTexture);
SetIcon(named.Icon);
}
}
}

View File

@ -8,6 +8,7 @@ using System.Linq;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Localisation;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Resources.Localisation.Web;
@ -81,7 +82,7 @@ namespace osu.Game.Overlays.Wiki
{
Title = PageTitleStrings.MainWikiControllerDefault;
Description = NamedOverlayComponentStrings.WikiDescription;
IconTexture = "Icons/Hexacons/wiki";
Icon = HexaconsIcons.Wiki;
}
}
}

View File

@ -103,7 +103,9 @@ namespace osu.Game.Rulesets.Scoring
if (beatmap.HitObjects.Count > 0)
gameplayEndTime = beatmap.HitObjects[^1].GetEndTime();
noDrainPeriodTracker = new PeriodTracker(beatmap.Breaks.Select(breakPeriod => new Period(
noDrainPeriodTracker = new PeriodTracker(
beatmap.Breaks.Select(breakPeriod =>
new Period(
beatmap.HitObjects
.Select(hitObject => hitObject.GetEndTime())
.Where(endTime => endTime <= breakPeriod.StartTime)
@ -159,26 +161,24 @@ namespace osu.Game.Rulesets.Scoring
{
double currentHealth = 1;
double lowestHealth = 1;
int currentBreak = -1;
int currentBreak = 0;
for (int i = 0; i < healthIncreases.Count; i++)
{
double currentTime = healthIncreases[i].time;
double lastTime = i > 0 ? healthIncreases[i - 1].time : DrainStartTime;
// Subtract any break time from the duration since the last object
if (Beatmap.Breaks.Count > 0)
while (currentBreak < Beatmap.Breaks.Count && Beatmap.Breaks[currentBreak].EndTime <= currentTime)
{
// Advance the last break occuring before the current time
while (currentBreak + 1 < Beatmap.Breaks.Count && Beatmap.Breaks[currentBreak + 1].EndTime < currentTime)
// If two hitobjects are separated by a break period, there is no drain for the full duration between the hitobjects.
// This differs from legacy (version < 8) beatmaps which continue draining until the break section is entered,
// but this shouldn't have a noticeable impact in practice.
lastTime = currentTime;
currentBreak++;
if (currentBreak >= 0)
lastTime = Math.Max(lastTime, Beatmap.Breaks[currentBreak].EndTime);
}
// Apply health adjustments
currentHealth -= (healthIncreases[i].time - lastTime) * result;
currentHealth -= (currentTime - lastTime) * result;
lowestHealth = Math.Min(lowestHealth, currentHealth);
currentHealth = Math.Min(1, currentHealth + healthIncreases[i].health);

View File

@ -0,0 +1,158 @@
// 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 osu.Game.Beatmaps;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects;
namespace osu.Game.Rulesets.Scoring
{
/// <summary>
/// A <see cref="DrainingHealthProcessor"/> that matches legacy drain rate calculations as best as possible.
/// </summary>
public abstract partial class LegacyDrainingHealthProcessor : DrainingHealthProcessor
{
public Action<string>? OnIterationFail;
public Action<string>? OnIterationSuccess;
protected double HpMultiplierNormal { get; private set; }
private double lowestHpEver;
private double lowestHpEnd;
private double hpRecoveryAvailable;
protected LegacyDrainingHealthProcessor(double drainStartTime)
: base(drainStartTime)
{
}
public override void ApplyBeatmap(IBeatmap beatmap)
{
lowestHpEver = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 0.975, 0.8, 0.3);
lowestHpEnd = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 0.99, 0.9, 0.4);
hpRecoveryAvailable = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.DrainRate, 0.04, 0.02, 0);
base.ApplyBeatmap(beatmap);
}
protected override void Reset(bool storeResults)
{
HpMultiplierNormal = 1;
base.Reset(storeResults);
}
protected override double ComputeDrainRate()
{
double testDrop = 0.00025;
double currentHp;
double currentHpUncapped;
while (true)
{
currentHp = 1;
currentHpUncapped = 1;
double lowestHp = currentHp;
double lastTime = DrainStartTime;
int currentBreak = 0;
bool fail = false;
int topLevelObjectCount = 0;
foreach (var h in EnumerateTopLevelHitObjects())
{
topLevelObjectCount++;
while (currentBreak < Beatmap.Breaks.Count && Beatmap.Breaks[currentBreak].EndTime <= h.StartTime)
{
// If two hitobjects are separated by a break period, there is no drain for the full duration between the hitobjects.
// This differs from legacy (version < 8) beatmaps which continue draining until the break section is entered,
// but this shouldn't have a noticeable impact in practice.
lastTime = h.StartTime;
currentBreak++;
}
reduceHp(testDrop * (h.StartTime - lastTime));
lastTime = h.GetEndTime();
if (currentHp < lowestHp)
lowestHp = currentHp;
if (currentHp <= lowestHpEver)
{
fail = true;
testDrop *= 0.96;
OnIterationFail?.Invoke($"FAILED drop {testDrop}: hp too low ({currentHp} < {lowestHpEver})");
break;
}
double hpReduction = testDrop * (h.GetEndTime() - h.StartTime);
double hpOverkill = Math.Max(0, hpReduction - currentHp);
reduceHp(hpReduction);
foreach (var nested in EnumerateNestedHitObjects(h))
increaseHp(nested);
// Note: Because HP is capped during the above increases, long sliders (with many ticks) or spinners
// will appear to overkill at lower drain levels than they should. However, it is also not correct to simply use the uncapped version.
if (hpOverkill > 0 && currentHp - hpOverkill <= lowestHpEver)
{
fail = true;
testDrop *= 0.96;
OnIterationFail?.Invoke($"FAILED drop {testDrop}: overkill ({currentHp} - {hpOverkill} <= {lowestHpEver})");
break;
}
increaseHp(h);
}
if (!fail && currentHp < lowestHpEnd)
{
fail = true;
testDrop *= 0.94;
HpMultiplierNormal *= 1.01;
OnIterationFail?.Invoke($"FAILED drop {testDrop}: end hp too low ({currentHp} < {lowestHpEnd})");
}
double recovery = (currentHpUncapped - 1) / Math.Max(1, topLevelObjectCount);
if (!fail && recovery < hpRecoveryAvailable)
{
fail = true;
testDrop *= 0.96;
HpMultiplierNormal *= 1.01;
OnIterationFail?.Invoke($"FAILED drop {testDrop}: recovery too low ({recovery} < {hpRecoveryAvailable})");
}
if (!fail)
{
OnIterationSuccess?.Invoke($"PASSED drop {testDrop}");
return testDrop;
}
}
void reduceHp(double amount)
{
currentHpUncapped = Math.Max(0, currentHpUncapped - amount);
currentHp = Math.Max(0, currentHp - amount);
}
void increaseHp(HitObject hitObject)
{
double amount = GetHealthIncreaseFor(hitObject, hitObject.CreateJudgement().MaxResult);
currentHpUncapped += amount;
currentHp = Math.Max(0, Math.Min(1, currentHp + amount));
}
}
protected sealed override double GetHealthIncreaseFor(JudgementResult result) => GetHealthIncreaseFor(result.HitObject, result.Type);
protected abstract IEnumerable<HitObject> EnumerateTopLevelHitObjects();
protected abstract IEnumerable<HitObject> EnumerateNestedHitObjects(HitObject hitObject);
protected abstract double GetHealthIncreaseFor(HitObject hitObject, HitResult result);
}
}

View File

@ -46,12 +46,12 @@ namespace osu.Game.Screens.Edit.Components.Menus
Padding = new MarginPadding(8),
Children = new Drawable[]
{
new Sprite
new SpriteIcon
{
Size = new Vector2(26),
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Texture = textures.Get("Icons/Hexacons/editor"),
Icon = HexaconsIcons.Editor,
},
text = new TextFlowContainer
{

View File

@ -7,6 +7,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.UserInterface;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Overlays;
using osuTK.Graphics;
@ -79,7 +80,7 @@ namespace osu.Game.Screens.Edit.Setup
{
Title = EditorSetupStrings.BeatmapSetup.ToLower();
Description = EditorSetupStrings.BeatmapSetupDescription;
IconTexture = "Icons/Hexacons/social";
Icon = HexaconsIcons.Social;
}
}

View File

@ -1,18 +1,16 @@
// 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.Linq;
using JetBrains.Annotations;
using osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
@ -36,29 +34,29 @@ namespace osu.Game.Screens.Menu
{
public partial class ButtonSystem : Container, IStateful<ButtonSystemState>, IKeyBindingHandler<GlobalAction>
{
public event Action<ButtonSystemState> StateChanged;
private readonly IBindable<bool> isIdle = new BindableBool();
public Action OnEdit;
public Action OnExit;
public Action OnBeatmapListing;
public Action OnSolo;
public Action OnSettings;
public Action OnMultiplayer;
public Action OnPlaylists;
public const float BUTTON_WIDTH = 140f;
public const float WEDGE_WIDTH = 20;
[CanBeNull]
private OsuLogo logo;
public event Action<ButtonSystemState>? StateChanged;
public Action? OnEditBeatmap;
public Action? OnEditSkin;
public Action? OnExit;
public Action? OnBeatmapListing;
public Action? OnSolo;
public Action? OnSettings;
public Action? OnMultiplayer;
public Action? OnPlaylists;
private readonly IBindable<bool> isIdle = new BindableBool();
private OsuLogo? logo;
/// <summary>
/// Assign the <see cref="OsuLogo"/> that this ButtonSystem should manage the position of.
/// </summary>
/// <param name="logo">The instance of the logo to be assigned. If null, we are suspending from the screen that uses this ButtonSystem.</param>
public void SetOsuLogo(OsuLogo logo)
public void SetOsuLogo(OsuLogo? logo)
{
this.logo = logo;
@ -84,9 +82,10 @@ namespace osu.Game.Screens.Menu
private readonly List<MainMenuButton> buttonsTopLevel = new List<MainMenuButton>();
private readonly List<MainMenuButton> buttonsPlay = new List<MainMenuButton>();
private readonly List<MainMenuButton> buttonsEdit = new List<MainMenuButton>();
private Sample sampleBackToLogo;
private Sample sampleLogoSwoosh;
private Sample? sampleBackToLogo;
private Sample? sampleLogoSwoosh;
private readonly LogoTrackingContainer logoTrackingContainer;
@ -108,7 +107,8 @@ namespace osu.Game.Screens.Menu
backButton = new MainMenuButton(ButtonSystemStrings.Back, @"back-to-top", OsuIcon.LeftCircle, new Color4(51, 58, 94, 255), () => State = ButtonSystemState.TopLevel,
-WEDGE_WIDTH)
{
VisibleState = ButtonSystemState.Play,
VisibleStateMin = ButtonSystemState.Play,
VisibleStateMax = ButtonSystemState.Edit,
},
logoTrackingContainer.LogoFacade.With(d => d.Scale = new Vector2(0.74f))
});
@ -116,31 +116,36 @@ namespace osu.Game.Screens.Menu
buttonArea.Flow.CentreTarget = logoTrackingContainer.LogoFacade;
}
[Resolved(CanBeNull = true)]
private OsuGame game { get; set; }
[Resolved]
private IAPIProvider api { get; set; } = null!;
[Resolved]
private IAPIProvider api { get; set; }
private OsuGame? game { get; set; }
[Resolved(CanBeNull = true)]
private LoginOverlay loginOverlay { get; set; }
[Resolved]
private LoginOverlay? loginOverlay { get; set; }
[BackgroundDependencyLoader(true)]
private void load(AudioManager audio, IdleTracker idleTracker, GameHost host)
[BackgroundDependencyLoader]
private void load(AudioManager audio, IdleTracker? idleTracker, GameHost host)
{
buttonsPlay.Add(new MainMenuButton(ButtonSystemStrings.Solo, @"button-default-select", FontAwesome.Solid.User, new Color4(102, 68, 204, 255), () => OnSolo?.Invoke(), WEDGE_WIDTH, Key.P));
buttonsPlay.Add(new MainMenuButton(ButtonSystemStrings.Multi, @"button-default-select", FontAwesome.Solid.Users, new Color4(94, 63, 186, 255), onMultiplayer, 0, Key.M));
buttonsPlay.Add(new MainMenuButton(ButtonSystemStrings.Playlists, @"button-default-select", OsuIcon.Charts, new Color4(94, 63, 186, 255), onPlaylists, 0, Key.L));
buttonsPlay.ForEach(b => b.VisibleState = ButtonSystemState.Play);
buttonsEdit.Add(new MainMenuButton(EditorStrings.BeatmapEditor.ToLower(), @"button-default-select", HexaconsIcons.Beatmap, new Color4(238, 170, 0, 255), () => OnEditBeatmap?.Invoke(), WEDGE_WIDTH, Key.B));
buttonsEdit.Add(new MainMenuButton(SkinEditorStrings.SkinEditor.ToLower(), @"button-default-select", HexaconsIcons.Editor, new Color4(220, 160, 0, 255), () => OnEditSkin?.Invoke(), 0, Key.S));
buttonsEdit.ForEach(b => b.VisibleState = ButtonSystemState.Edit);
buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Play, @"button-play-select", OsuIcon.Logo, new Color4(102, 68, 204, 255), () => State = ButtonSystemState.Play, WEDGE_WIDTH, Key.P));
buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Edit, @"button-default-select", OsuIcon.EditCircle, new Color4(238, 170, 0, 255), () => OnEdit?.Invoke(), 0, Key.E));
buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Edit, @"button-play-select", OsuIcon.EditCircle, new Color4(238, 170, 0, 255), () => State = ButtonSystemState.Edit, 0, Key.E));
buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Browse, @"button-default-select", OsuIcon.ChevronDownCircle, new Color4(165, 204, 0, 255), () => OnBeatmapListing?.Invoke(), 0, Key.B, Key.D));
if (host.CanExit)
buttonsTopLevel.Add(new MainMenuButton(ButtonSystemStrings.Exit, string.Empty, OsuIcon.CrossCircle, new Color4(238, 51, 153, 255), () => OnExit?.Invoke(), 0, Key.Q));
buttonArea.AddRange(buttonsPlay);
buttonArea.AddRange(buttonsEdit);
buttonArea.AddRange(buttonsTopLevel);
buttonArea.ForEach(b =>
@ -270,6 +275,7 @@ namespace osu.Game.Screens.Menu
return true;
case ButtonSystemState.Edit:
case ButtonSystemState.Play:
StopSamplePlayback();
backButton.TriggerClick();
@ -305,6 +311,10 @@ namespace osu.Game.Screens.Menu
case ButtonSystemState.Play:
buttonsPlay.First().TriggerClick();
return false;
case ButtonSystemState.Edit:
buttonsEdit.First().TriggerClick();
return false;
}
}
@ -328,6 +338,8 @@ namespace osu.Game.Screens.Menu
Logger.Log($"{nameof(ButtonSystem)}'s state changed from {lastState} to {state}");
buttonArea.FinishTransforms(true);
using (buttonArea.BeginDelayedSequence(lastState == ButtonSystemState.Initial ? 150 : 0))
{
buttonArea.ButtonSystemState = state;
@ -340,7 +352,7 @@ namespace osu.Game.Screens.Menu
}
}
private ScheduledDelegate logoDelayedAction;
private ScheduledDelegate? logoDelayedAction;
private void updateLogoState(ButtonSystemState lastState = ButtonSystemState.Initial)
{
@ -414,6 +426,7 @@ namespace osu.Game.Screens.Menu
Initial,
TopLevel,
Play,
Edit,
EnteringMode,
}
}

View File

@ -25,6 +25,7 @@ using osu.Game.Input.Bindings;
using osu.Game.IO;
using osu.Game.Online.API;
using osu.Game.Overlays;
using osu.Game.Overlays.SkinEditor;
using osu.Game.Rulesets;
using osu.Game.Screens.Backgrounds;
using osu.Game.Screens.Edit;
@ -93,6 +94,9 @@ namespace osu.Game.Screens.Menu
private Sample reappearSampleSwoosh;
[Resolved(canBeNull: true)]
private SkinEditorOverlay skinEditor { get; set; }
[BackgroundDependencyLoader(true)]
private void load(BeatmapListingOverlay beatmapListing, SettingsOverlay settings, OsuConfigManager config, SessionStatics statics, AudioManager audio)
{
@ -120,11 +124,15 @@ namespace osu.Game.Screens.Menu
{
Buttons = new ButtonSystem
{
OnEdit = delegate
OnEditBeatmap = () =>
{
Beatmap.SetDefault();
this.Push(new EditorLoader());
},
OnEditSkin = () =>
{
skinEditor?.Show();
},
OnSolo = loadSoloSongSelect,
OnMultiplayer = () => this.Push(new Multiplayer()),
OnPlaylists = () => this.Push(new Playlists()),

View File

@ -1,8 +1,6 @@
// 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.Linq;
using osu.Framework;
@ -33,7 +31,7 @@ namespace osu.Game.Screens.Menu
/// </summary>
public partial class MainMenuButton : BeatSyncedContainer, IStateful<ButtonState>
{
public event Action<ButtonState> StateChanged;
public event Action<ButtonState>? StateChanged;
public readonly Key[] TriggerKeys;
@ -44,18 +42,28 @@ namespace osu.Game.Screens.Menu
private readonly string sampleName;
/// <summary>
/// The menu state for which we are visible for.
/// The menu state for which we are visible for (assuming only one).
/// </summary>
public ButtonSystemState VisibleState = ButtonSystemState.TopLevel;
public ButtonSystemState VisibleState
{
set
{
VisibleStateMin = value;
VisibleStateMax = value;
}
}
private readonly Action clickAction;
private Sample sampleClick;
private Sample sampleHover;
private SampleChannel sampleChannel;
public ButtonSystemState VisibleStateMin = ButtonSystemState.TopLevel;
public ButtonSystemState VisibleStateMax = ButtonSystemState.TopLevel;
private readonly Action? clickAction;
private Sample? sampleClick;
private Sample? sampleHover;
private SampleChannel? sampleChannel;
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => box.ReceivePositionalInputAt(screenSpacePos);
public MainMenuButton(LocalisableString text, string sampleName, IconUsage symbol, Color4 colour, Action clickAction = null, float extraWidth = 0, params Key[] triggerKeys)
public MainMenuButton(LocalisableString text, string sampleName, IconUsage symbol, Color4 colour, Action? clickAction = null, float extraWidth = 0, params Key[] triggerKeys)
{
this.sampleName = sampleName;
this.clickAction = clickAction;
@ -315,9 +323,9 @@ namespace osu.Game.Screens.Menu
break;
default:
if (value == VisibleState)
if (value <= VisibleStateMax && value >= VisibleStateMin)
State = ButtonState.Expanded;
else if (value < VisibleState)
else if (value < VisibleStateMin)
State = ButtonState.Contracted;
else
State = ButtonState.Exploded;

View File

@ -228,7 +228,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
{
}
protected override void StartGameplay(int userId, SpectatorGameplayState spectatorGameplayState)
protected override void StartGameplay(int userId, SpectatorGameplayState spectatorGameplayState) => Schedule(() =>
{
var playerArea = instances.Single(i => i.UserId == userId);
@ -242,9 +242,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
return;
playerArea.LoadScore(spectatorGameplayState.Score);
});
protected override void FailGameplay(int userId)
{
// We probably want to visualise this in the future.
}
protected override void QuitGameplay(int userId)
protected override void QuitGameplay(int userId) => Schedule(() =>
{
RemoveUser(userId);
@ -252,7 +257,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
instance.FadeColour(colours.Gray4, 400, Easing.OutQuint);
syncManager.RemoveManagedClock(instance.SpectatorPlayerClock);
}
});
public override bool OnBackButton()
{

View File

@ -26,11 +26,22 @@ namespace osu.Game.Screens.Play
public override LocalisableString Header => GameplayMenuOverlayStrings.FailedHeader;
private readonly bool showButtons;
public FailOverlay(bool showButtons = true)
{
this.showButtons = showButtons;
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
if (showButtons)
{
AddButton(GameplayMenuOverlayStrings.Retry, colours.YellowDark, () => OnRetry?.Invoke());
AddButton(GameplayMenuOverlayStrings.Quit, new Color4(170, 27, 39, 255), () => OnQuit?.Invoke());
}
// from #10339 maybe this is a better visual effect
Add(new Container
{

View File

@ -155,7 +155,7 @@ namespace osu.Game.Screens.Play.HUD
this.getLookup = getLookup;
}
public ITexturedCharacterGlyph? Get(string fontName, char character)
public ITexturedCharacterGlyph? Get(string? fontName, char character)
{
string lookup = getLookup(character);
var texture = textures.Get($"Gameplay/Fonts/{fontName}-{lookup}");

View File

@ -267,7 +267,7 @@ namespace osu.Game.Screens.Play
createGameplayComponents(Beatmap.Value)
}
},
FailOverlay = new FailOverlay
FailOverlay = new FailOverlay(Configuration.AllowUserInteraction)
{
SaveReplay = async () => await prepareAndImportScoreAsync(true).ConfigureAwait(false),
OnRetry = () => Restart(),
@ -894,6 +894,13 @@ namespace osu.Game.Screens.Play
#region Fail Logic
/// <summary>
/// Invoked when gameplay has permanently failed.
/// </summary>
protected virtual void OnFail()
{
}
protected FailOverlay FailOverlay { get; private set; }
private FailAnimationContainer failAnimationContainer;
@ -923,8 +930,21 @@ namespace osu.Game.Screens.Play
failAnimationContainer.Start();
// Failures can be triggered either by a judgement, or by a mod.
//
// For the case of a judgement, due to ordering considerations, ScoreProcessor will not have received
// the final judgement which triggered the failure yet (see DrawableRuleset.NewResult handling above).
//
// A schedule here ensures that any lingering judgements from the current frame are applied before we
// finalise the score as "failed".
Schedule(() =>
{
ScoreProcessor.FailScore(Score.ScoreInfo);
OnFail();
if (GameplayState.Mods.OfType<IApplicableFailOverride>().Any(m => m.RestartOnFail))
Restart(true);
});
return true;
}
@ -934,11 +954,6 @@ namespace osu.Game.Screens.Play
/// </summary>
private void onFailComplete()
{
// fail completion is a good point to mark a score as failed,
// since the last judgement that caused the fail only applies to score processor after onFail.
// todo: this should probably be handled better.
ScoreProcessor.FailScore(Score.ScoreInfo);
GameplayClockContainer.Stop();
FailOverlay.Retries = RestartCount;

View File

@ -17,8 +17,8 @@ namespace osu.Game.Screens.Play
protected override UserActivity InitialActivity => new UserActivity.SpectatingUser(Score.ScoreInfo);
public SoloSpectatorPlayer(Score score, PlayerConfiguration configuration = null)
: base(score, configuration)
public SoloSpectatorPlayer(Score score)
: base(score, new PlayerConfiguration { AllowUserInteraction = false })
{
this.score = score;
}

View File

@ -1,10 +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.
#nullable disable
using System.Diagnostics;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
@ -33,41 +30,40 @@ using osuTK;
namespace osu.Game.Screens.Play
{
[Cached(typeof(IPreviewTrackOwner))]
public partial class SoloSpectator : SpectatorScreen, IPreviewTrackOwner
public partial class SoloSpectatorScreen : SpectatorScreen, IPreviewTrackOwner
{
[NotNull]
private readonly APIUser targetUser;
[Resolved]
private IAPIProvider api { get; set; } = null!;
[Resolved]
private IAPIProvider api { get; set; }
private PreviewTrackManager previewTrackManager { get; set; } = null!;
[Resolved]
private PreviewTrackManager previewTrackManager { get; set; }
private BeatmapManager beatmaps { get; set; } = null!;
[Resolved]
private BeatmapManager beatmaps { get; set; }
[Resolved]
private BeatmapModelDownloader beatmapDownloader { get; set; }
private BeatmapModelDownloader beatmapDownloader { get; set; } = null!;
[Cached]
private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple);
private Container beatmapPanelContainer;
private RoundedButton watchButton;
private SettingsCheckbox automaticDownload;
private Container beatmapPanelContainer = null!;
private RoundedButton watchButton = null!;
private SettingsCheckbox automaticDownload = null!;
private readonly APIUser targetUser;
/// <summary>
/// The player's immediate online gameplay state.
/// This doesn't always reflect the gameplay state being watched.
/// </summary>
private SpectatorGameplayState immediateSpectatorGameplayState;
private SpectatorGameplayState? immediateSpectatorGameplayState;
private GetBeatmapSetRequest onlineBeatmapRequest;
private GetBeatmapSetRequest? onlineBeatmapRequest;
private APIBeatmapSet beatmapSet;
private APIBeatmapSet? beatmapSet;
public SoloSpectator([NotNull] APIUser targetUser)
public SoloSpectatorScreen(APIUser targetUser)
: base(targetUser.Id)
{
this.targetUser = targetUser;
@ -168,28 +164,47 @@ namespace osu.Game.Screens.Play
automaticDownload.Current.BindValueChanged(_ => checkForAutomaticDownload());
}
protected override void OnNewPlayingUserState(int userId, SpectatorState spectatorState)
protected override void OnNewPlayingUserState(int userId, SpectatorState spectatorState) => Schedule(() =>
{
clearDisplay();
showBeatmapPanel(spectatorState);
}
});
protected override void StartGameplay(int userId, SpectatorGameplayState spectatorGameplayState)
protected override void StartGameplay(int userId, SpectatorGameplayState spectatorGameplayState) => Schedule(() =>
{
immediateSpectatorGameplayState = spectatorGameplayState;
watchButton.Enabled.Value = true;
scheduleStart(spectatorGameplayState);
});
protected override void FailGameplay(int userId)
{
if (this.GetChildScreen() is SpectatorPlayerLoader loader)
{
if (loader.GetChildScreen() is SpectatorPlayer player)
{
player.AllowFail();
resetStartState();
}
else
QuitGameplay(userId);
}
}
protected override void QuitGameplay(int userId)
{
// Importantly, don't schedule this call, as a child screen may be present (and will cause the schedule to not be run as expected).
this.MakeCurrent();
resetStartState();
}
private void resetStartState() => Schedule(() =>
{
scheduledStart?.Cancel();
immediateSpectatorGameplayState = null;
watchButton.Enabled.Value = false;
clearDisplay();
}
});
private void clearDisplay()
{
@ -199,10 +214,12 @@ namespace osu.Game.Screens.Play
previewTrackManager.StopAnyPlaying(this);
}
private ScheduledDelegate scheduledStart;
private ScheduledDelegate? scheduledStart;
private void scheduleStart(SpectatorGameplayState spectatorGameplayState)
private void scheduleStart(SpectatorGameplayState? spectatorGameplayState)
{
Debug.Assert(spectatorGameplayState != null);
// This function may be called multiple times in quick succession once the screen becomes current again.
scheduledStart?.Cancel();
scheduledStart = Schedule(() =>

View File

@ -25,7 +25,15 @@ namespace osu.Game.Screens.Play
private readonly Score score;
protected override bool CheckModsAllowFailure() => false; // todo: better support starting mid-way through beatmap
protected override bool CheckModsAllowFailure()
{
if (!allowFail)
return false;
return base.CheckModsAllowFailure();
}
private bool allowFail;
protected SpectatorPlayer(Score score, PlayerConfiguration configuration = null)
: base(configuration)
@ -60,6 +68,12 @@ namespace osu.Game.Screens.Play
}, true);
}
/// <summary>
/// Should be called when it is apparent that the player being spectated has failed.
/// This will subsequently stop blocking the fail screen from displaying (usually done out of safety).
/// </summary>
public void AllowFail() => allowFail = true;
protected override void StartGameplay()
{
base.StartGameplay();

View File

@ -165,10 +165,22 @@ namespace osu.Game.Screens.Play
spectatorClient.BeginPlaying(token, GameplayState, Score);
}
protected override void OnFail()
{
base.OnFail();
submitFromFailOrQuit();
}
public override bool OnExiting(ScreenExitEvent e)
{
bool exiting = base.OnExiting(e);
submitFromFailOrQuit();
return exiting;
}
private void submitFromFailOrQuit()
{
if (LoadedBeatmapSuccessfully)
{
Task.Run(async () =>
@ -177,8 +189,6 @@ namespace osu.Game.Screens.Play
spectatorClient.EndPlaying(GameplayState);
}).FireAndForget();
}
return exiting;
}
/// <summary>

View File

@ -4,6 +4,7 @@
#nullable disable
using System;
using JetBrains.Annotations;
using osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
@ -82,6 +83,7 @@ namespace osu.Game.Screens.Ranking
private static readonly Color4 contracted_top_layer_colour = Color4Extensions.FromHex("#353535");
private static readonly Color4 contracted_middle_layer_colour = Color4Extensions.FromHex("#353535");
[CanBeNull]
public event Action<PanelState> StateChanged;
/// <summary>

View File

@ -1,13 +1,10 @@
// 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 System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
@ -33,22 +30,27 @@ namespace osu.Game.Screens.Spectate
private readonly List<int> users = new List<int>();
[Resolved]
private BeatmapManager beatmaps { get; set; }
private BeatmapManager beatmaps { get; set; } = null!;
[Resolved]
private RulesetStore rulesets { get; set; }
private RulesetStore rulesets { get; set; } = null!;
[Resolved]
private SpectatorClient spectatorClient { get; set; }
private SpectatorClient spectatorClient { get; set; } = null!;
[Resolved]
private UserLookupCache userLookupCache { get; set; }
private UserLookupCache userLookupCache { get; set; } = null!;
[Resolved]
private RealmAccess realm { get; set; } = null!;
private readonly IBindableDictionary<int, SpectatorState> userStates = new BindableDictionary<int, SpectatorState>();
private readonly Dictionary<int, APIUser> userMap = new Dictionary<int, APIUser>();
private readonly Dictionary<int, SpectatorGameplayState> gameplayStates = new Dictionary<int, SpectatorGameplayState>();
private IDisposable? realmSubscription;
/// <summary>
/// Creates a new <see cref="SpectatorScreen"/>.
/// </summary>
@ -58,11 +60,6 @@ namespace osu.Game.Screens.Spectate
this.users.AddRange(users);
}
[Resolved]
private RealmAccess realm { get; set; }
private IDisposable realmSubscription;
protected override void LoadComplete()
{
base.LoadComplete();
@ -90,7 +87,7 @@ namespace osu.Game.Screens.Spectate
}));
}
private void beatmapsChanged(IRealmCollection<BeatmapSetInfo> items, ChangeSet changes)
private void beatmapsChanged(IRealmCollection<BeatmapSetInfo> items, ChangeSet? changes)
{
if (changes?.InsertedIndices == null) return;
@ -109,7 +106,7 @@ namespace osu.Game.Screens.Spectate
}
}
private void onUserStatesChanged(object sender, NotifyDictionaryChangedEventArgs<int, SpectatorState> e)
private void onUserStatesChanged(object? sender, NotifyDictionaryChangedEventArgs<int, SpectatorState> e)
{
switch (e.Action)
{
@ -132,7 +129,7 @@ namespace osu.Game.Screens.Spectate
switch (newState.State)
{
case SpectatedUserState.Playing:
Schedule(() => OnNewPlayingUserState(userId, newState));
OnNewPlayingUserState(userId, newState);
startGameplay(userId);
break;
@ -140,6 +137,10 @@ namespace osu.Game.Screens.Spectate
markReceivedAllFrames(userId);
break;
case SpectatedUserState.Failed:
failGameplay(userId);
break;
case SpectatedUserState.Quit:
quitGameplay(userId);
break;
@ -176,7 +177,7 @@ namespace osu.Game.Screens.Spectate
var gameplayState = new SpectatorGameplayState(score, resolvedRuleset, beatmaps.GetWorkingBeatmap(resolvedBeatmap));
gameplayStates[userId] = gameplayState;
Schedule(() => StartGameplay(userId, gameplayState));
StartGameplay(userId, gameplayState);
}
/// <summary>
@ -188,6 +189,20 @@ namespace osu.Game.Screens.Spectate
gameplayState.Score.Replay.HasReceivedAllFrames = true;
}
private void failGameplay(int userId)
{
if (!userMap.ContainsKey(userId))
return;
if (!gameplayStates.ContainsKey(userId))
return;
markReceivedAllFrames(userId);
gameplayStates.Remove(userId);
FailGameplay(userId);
}
private void quitGameplay(int userId)
{
if (!userMap.ContainsKey(userId))
@ -199,29 +214,39 @@ namespace osu.Game.Screens.Spectate
markReceivedAllFrames(userId);
gameplayStates.Remove(userId);
Schedule(() => QuitGameplay(userId));
QuitGameplay(userId);
}
/// <summary>
/// Invoked when a spectated user's state has changed to a new state indicating the player is currently playing.
/// Thread safety is not guaranteed should be scheduled as required.
/// </summary>
/// <param name="userId">The user whose state has changed.</param>
/// <param name="spectatorState">The new state.</param>
protected abstract void OnNewPlayingUserState(int userId, [NotNull] SpectatorState spectatorState);
protected abstract void OnNewPlayingUserState(int userId, SpectatorState spectatorState);
/// <summary>
/// Starts gameplay for a user.
/// Thread safety is not guaranteed should be scheduled as required.
/// </summary>
/// <param name="userId">The user to start gameplay for.</param>
/// <param name="spectatorGameplayState">The gameplay state.</param>
protected abstract void StartGameplay(int userId, [NotNull] SpectatorGameplayState spectatorGameplayState);
protected abstract void StartGameplay(int userId, SpectatorGameplayState spectatorGameplayState);
/// <summary>
/// Quits gameplay for a user.
/// Thread safety is not guaranteed should be scheduled as required.
/// </summary>
/// <param name="userId">The user to quit gameplay for.</param>
protected abstract void QuitGameplay(int userId);
/// <summary>
/// Fails gameplay for a user.
/// Thread safety is not guaranteed should be scheduled as required.
/// </summary>
/// <param name="userId">The user to fail gameplay for.</param>
protected abstract void FailGameplay(int userId);
/// <summary>
/// Stops spectating a user.
/// </summary>
@ -243,7 +268,7 @@ namespace osu.Game.Screens.Spectate
{
base.Dispose(isDisposing);
if (spectatorClient != null)
if (spectatorClient.IsNotNull())
{
foreach ((int userId, var _) in userMap)
spectatorClient.StopWatchingUser(userId);

View File

@ -63,7 +63,7 @@ namespace osu.Game.Skinning
this.maxSize = maxSize;
}
public ITexturedCharacterGlyph? Get(string fontName, char character)
public ITexturedCharacterGlyph? Get(string? fontName, char character)
{
string lookup = getLookupName(character);