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

Merge branch 'master' into mods-stable-ordering

This commit is contained in:
Bartłomiej Dach 2023-09-28 18:30:56 +02:00
commit 8215c4cb0e
No known key found for this signature in database
16 changed files with 273 additions and 91 deletions

View File

@ -45,9 +45,9 @@ namespace osu.Game.Tests.Rulesets.Scoring
[TestCase(ScoringMode.Standardised, HitResult.Meh, 116_667)]
[TestCase(ScoringMode.Standardised, HitResult.Ok, 233_338)]
[TestCase(ScoringMode.Standardised, HitResult.Great, 1_000_000)]
[TestCase(ScoringMode.Classic, HitResult.Meh, 0)]
[TestCase(ScoringMode.Classic, HitResult.Ok, 2)]
[TestCase(ScoringMode.Classic, HitResult.Great, 36)]
[TestCase(ScoringMode.Classic, HitResult.Meh, 11_670)]
[TestCase(ScoringMode.Classic, HitResult.Ok, 23_341)]
[TestCase(ScoringMode.Classic, HitResult.Great, 100_033)]
public void TestSingleOsuHit(ScoringMode scoringMode, HitResult hitResult, int expectedScore)
{
scoreProcessor.ApplyBeatmap(beatmap);
@ -84,17 +84,17 @@ namespace osu.Game.Tests.Rulesets.Scoring
[TestCase(ScoringMode.Standardised, HitResult.SmallBonus, HitResult.SmallBonus, 1_000_030)]
[TestCase(ScoringMode.Standardised, HitResult.LargeBonus, HitResult.LargeBonus, 1_000_150)]
[TestCase(ScoringMode.Classic, HitResult.Miss, HitResult.Great, 0)]
[TestCase(ScoringMode.Classic, HitResult.Meh, HitResult.Great, 4)]
[TestCase(ScoringMode.Classic, HitResult.Ok, HitResult.Great, 15)]
[TestCase(ScoringMode.Classic, HitResult.Good, HitResult.Perfect, 53)]
[TestCase(ScoringMode.Classic, HitResult.Great, HitResult.Great, 140)]
[TestCase(ScoringMode.Classic, HitResult.Perfect, HitResult.Perfect, 140)]
[TestCase(ScoringMode.Classic, HitResult.Meh, HitResult.Great, 7_975)]
[TestCase(ScoringMode.Classic, HitResult.Ok, HitResult.Great, 15_949)]
[TestCase(ScoringMode.Classic, HitResult.Good, HitResult.Perfect, 30_398)]
[TestCase(ScoringMode.Classic, HitResult.Great, HitResult.Great, 49_546)]
[TestCase(ScoringMode.Classic, HitResult.Perfect, HitResult.Perfect, 49_546)]
[TestCase(ScoringMode.Classic, HitResult.SmallTickMiss, HitResult.SmallTickHit, 0)]
[TestCase(ScoringMode.Classic, HitResult.SmallTickHit, HitResult.SmallTickHit, 11)]
[TestCase(ScoringMode.Classic, HitResult.SmallTickHit, HitResult.SmallTickHit, 54_189)]
[TestCase(ScoringMode.Classic, HitResult.LargeTickMiss, HitResult.LargeTickHit, 0)]
[TestCase(ScoringMode.Classic, HitResult.LargeTickHit, HitResult.LargeTickHit, 9)]
[TestCase(ScoringMode.Classic, HitResult.SmallBonus, HitResult.SmallBonus, 36)]
[TestCase(ScoringMode.Classic, HitResult.LargeBonus, HitResult.LargeBonus, 36)]
[TestCase(ScoringMode.Classic, HitResult.LargeTickHit, HitResult.LargeTickHit, 49_289)]
[TestCase(ScoringMode.Classic, HitResult.SmallBonus, HitResult.SmallBonus, 100_003)]
[TestCase(ScoringMode.Classic, HitResult.LargeBonus, HitResult.LargeBonus, 100_015)]
public void TestFourVariousResultsOneMiss(ScoringMode scoringMode, HitResult hitResult, HitResult maxResult, int expectedScore)
{
var minResult = new TestJudgement(hitResult).MinResult;

View File

@ -312,7 +312,9 @@ namespace osu.Game.Tests.Visual.SongSelect
{
createSongSelect();
addRulesetImportStep(0);
// We need to use one real beatmap to trigger the "same-track-transfer" logic that we're looking to test here.
// See `SongSelect.ensurePlayingSelected` and `WorkingBeatmap.TryTransferTrack`.
AddStep("import test beatmap", () => manager.Import(new ImportTask(TestResources.GetTestBeatmapForImport())).WaitSafely());
addRulesetImportStep(0);
checkMusicPlaying(true);
@ -321,6 +323,8 @@ namespace osu.Game.Tests.Visual.SongSelect
AddStep("manual pause", () => music.TogglePause());
checkMusicPlaying(false);
// Track should not have changed, so music should still not be playing.
AddStep("select next difficulty", () => songSelect!.Carousel.SelectNext(skipDifficulties: false));
checkMusicPlaying(false);

View File

@ -1,10 +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 System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.UI;
@ -25,6 +29,53 @@ namespace osu.Game.Tests.Visual.UserInterface
ChildrenEnumerable = Ruleset.Value.CreateInstance().CreateAllMods().Select(m => new ModIcon(m)),
};
});
AddStep("toggle selected", () =>
{
foreach (var icon in this.ChildrenOfType<ModIcon>())
icon.Selected.Toggle();
});
}
[Test]
public void TestShowRateAdjusts()
{
AddStep("create mod icons", () =>
{
Child = new FillFlowContainer
{
RelativeSizeAxes = Axes.Both,
Direction = FillDirection.Full,
ChildrenEnumerable = Ruleset.Value.CreateInstance().CreateAllMods()
.OfType<ModRateAdjust>()
.SelectMany(m =>
{
List<ModIcon> icons = new List<ModIcon> { new ModIcon(m) };
for (double i = m.SpeedChange.MinValue; i < m.SpeedChange.MaxValue; i += m.SpeedChange.Precision * 10)
{
m = (ModRateAdjust)m.DeepClone();
m.SpeedChange.Value = i;
icons.Add(new ModIcon(m));
}
return icons;
}),
};
});
AddStep("adjust rates", () =>
{
foreach (var icon in this.ChildrenOfType<ModIcon>())
{
if (icon.Mod is ModRateAdjust rateAdjust)
{
rateAdjust.SpeedChange.Value = RNG.NextDouble() > 0.9
? rateAdjust.SpeedChange.Default
: RNG.NextDouble(rateAdjust.SpeedChange.MinValue, rateAdjust.SpeedChange.MaxValue);
}
}
});
}
[Test]

View File

@ -89,6 +89,8 @@ namespace osu.Game.Graphics
public static IconUsage ModSpunOut => Get(0xe046);
public static IconUsage ModSuddenDeath => Get(0xe047);
public static IconUsage ModTarget => Get(0xe048);
public static IconUsage ModBg => Get(0xe04a);
// Use "Icons/BeatmapDetails/mod-icon" instead
// public static IconUsage ModBg => Get(0xe04a);
}
}

View File

@ -210,7 +210,7 @@ namespace osu.Game.Online.Leaderboards
Spacing = new Vector2(2f, 0f),
Children = new Drawable[]
{
new ModIcon(mod, showTooltip: false).With(icon =>
new ModIcon(mod, showTooltip: false, showExtendedInformation: false).With(icon =>
{
icon.Origin = Anchor.CentreLeft;
icon.Anchor = Anchor.CentreLeft;

View File

@ -3,6 +3,9 @@
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
@ -28,6 +31,9 @@ namespace osu.Game.Overlays.Dialog
private readonly Vector2 ringMinifiedSize = new Vector2(20f);
private readonly Vector2 buttonsEnterSpacing = new Vector2(0f, 50f);
private readonly Box flashLayer;
private Sample flashSample = null!;
private readonly Container content;
private readonly Container ring;
private readonly FillFlowContainer<PopupDialogButton> buttonsContainer;
@ -208,6 +214,13 @@ namespace osu.Game.Overlays.Dialog
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
},
flashLayer = new Box
{
Alpha = 0,
RelativeSizeAxes = Axes.Both,
Blending = BlendingParameters.Additive,
Colour = Color4Extensions.FromHex(@"221a21"),
},
},
},
};
@ -217,6 +230,12 @@ namespace osu.Game.Overlays.Dialog
Show();
}
[BackgroundDependencyLoader]
private void load(AudioManager audio)
{
flashSample = audio.Samples.Get(@"UI/default-select-disabled");
}
/// <summary>
/// Programmatically clicks the first <see cref="PopupDialogOkButton"/>.
/// </summary>
@ -232,6 +251,14 @@ namespace osu.Game.Overlays.Dialog
Scheduler.AddOnce(() => Buttons.OfType<T>().FirstOrDefault()?.TriggerClick());
}
public void Flash()
{
flashLayer.FadeInFromZero(80, Easing.OutQuint)
.Then()
.FadeOutFromOne(1500, Easing.OutQuint);
flashSample.Play();
}
protected override bool OnKeyDown(KeyDownEvent e)
{
if (e.Repeat) return false;

View File

@ -19,6 +19,13 @@ namespace osu.Game.Rulesets.Mods
/// </summary>
string Name { get; }
/// <summary>
/// Short important information to display on the mod icon. For example, a rate adjust mod's rate
/// or similarly important setting.
/// Use <see cref="string.Empty"/> if the icon should not display any additional info.
/// </summary>
string ExtendedIconInformation { get; }
/// <summary>
/// The user readable description of this mod.
/// </summary>

View File

@ -27,6 +27,9 @@ namespace osu.Game.Rulesets.Mods
public abstract string Acronym { get; }
[JsonIgnore]
public virtual string ExtendedIconInformation => string.Empty;
[JsonIgnore]
public virtual IconUsage? Icon => null;

View File

@ -28,5 +28,7 @@ namespace osu.Game.Rulesets.Mods
public override Type[] IncompatibleMods => new[] { typeof(ModTimeRamp), typeof(ModAdaptiveSpeed), typeof(ModRateAdjust) };
public override string SettingDescription => SpeedChange.IsDefault ? string.Empty : $"{SpeedChange.Value:N2}x";
public override string ExtendedIconInformation => SettingDescription;
}
}

View File

@ -1,22 +1,22 @@
// 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 osuTK.Graphics;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Framework.Localisation;
using osu.Game.Configuration;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Rulesets.Mods;
using osuTK;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Localisation;
using osuTK.Graphics;
namespace osu.Game.Rulesets.UI
{
@ -27,22 +27,27 @@ namespace osu.Game.Rulesets.UI
{
public readonly BindableBool Selected = new BindableBool();
private readonly SpriteIcon modIcon;
private readonly SpriteText modAcronym;
private readonly SpriteIcon background;
private SpriteIcon modIcon = null!;
private SpriteText modAcronym = null!;
private Sprite background = null!;
private const float size = 80;
public static readonly Vector2 MOD_ICON_SIZE = new Vector2(80);
public virtual LocalisableString TooltipText => showTooltip ? ((mod as Mod)?.IconTooltip ?? mod.Name) : null;
public virtual LocalisableString TooltipText => showTooltip ? ((mod as Mod)?.IconTooltip ?? mod.Name) : string.Empty;
private IMod mod;
private readonly bool showTooltip;
private readonly bool showExtendedInformation;
public IMod Mod
{
get => mod;
set
{
if (mod == value)
return;
mod = value;
if (IsLoaded)
@ -51,49 +56,103 @@ namespace osu.Game.Rulesets.UI
}
[Resolved]
private OsuColour colours { get; set; }
private OsuColour colours { get; set; } = null!;
private Color4 backgroundColour;
private Sprite extendedBackground = null!;
private OsuSpriteText extendedText = null!;
private Container extendedContent = null!;
private ModSettingChangeTracker? modSettingsChangeTracker;
/// <summary>
/// Construct a new instance.
/// </summary>
/// <param name="mod">The mod to be displayed</param>
/// <param name="showTooltip">Whether a tooltip describing the mod should display on hover.</param>
public ModIcon(IMod mod, bool showTooltip = true)
/// <param name="showExtendedInformation">Whether to display a mod's extended information, if available.</param>
public ModIcon(IMod mod, bool showTooltip = true, bool showExtendedInformation = true)
{
// May expand due to expanded content, so autosize here.
AutoSizeAxes = Axes.X;
Height = MOD_ICON_SIZE.Y;
this.mod = mod ?? throw new ArgumentNullException(nameof(mod));
this.showTooltip = showTooltip;
this.showExtendedInformation = showExtendedInformation;
}
Size = new Vector2(size);
[BackgroundDependencyLoader]
private void load(TextureStore textures)
{
Children = new Drawable[]
{
background = new SpriteIcon
extendedContent = new Container
{
Origin = Anchor.Centre,
Anchor = Anchor.Centre,
Size = new Vector2(size),
Icon = OsuIcon.ModBg,
Shadow = true,
Name = "extended content",
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Size = new Vector2(116, MOD_ICON_SIZE.Y),
X = MOD_ICON_SIZE.X - 22,
Children = new Drawable[]
{
extendedBackground = new Sprite
{
RelativeSizeAxes = Axes.Both,
FillMode = FillMode.Fit,
Texture = textures.Get("Icons/BeatmapDetails/mod-icon-extender"),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
extendedText = new OsuSpriteText
{
Font = OsuFont.Default.With(size: 34f, weight: FontWeight.Bold),
UseFullGlyphHeight = false,
Text = mod.ExtendedIconInformation,
X = 6,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
}
},
modAcronym = new OsuSpriteText
new Container
{
Origin = Anchor.Centre,
Anchor = Anchor.Centre,
Colour = OsuColour.Gray(84),
Alpha = 0,
Font = OsuFont.Numeric.With(null, 22f),
UseFullGlyphHeight = false,
Text = mod.Acronym
},
modIcon = new SpriteIcon
{
Origin = Anchor.Centre,
Anchor = Anchor.Centre,
Colour = OsuColour.Gray(84),
Size = new Vector2(45),
Icon = FontAwesome.Solid.Question
Anchor = Anchor.CentreLeft,
Origin = Anchor.CentreLeft,
Name = "main content",
Size = MOD_ICON_SIZE,
Children = new Drawable[]
{
background = new Sprite
{
RelativeSizeAxes = Axes.Both,
FillMode = FillMode.Fit,
Texture = textures.Get("Icons/BeatmapDetails/mod-icon"),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
modAcronym = new OsuSpriteText
{
Origin = Anchor.Centre,
Anchor = Anchor.Centre,
Colour = OsuColour.Gray(84),
Alpha = 0,
Font = OsuFont.Numeric.With(null, 22f),
UseFullGlyphHeight = false,
Text = mod.Acronym
},
modIcon = new SpriteIcon
{
Origin = Anchor.Centre,
Anchor = Anchor.Centre,
Colour = OsuColour.Gray(84),
Size = new Vector2(45),
Icon = FontAwesome.Solid.Question
},
}
},
};
}
@ -109,6 +168,14 @@ namespace osu.Game.Rulesets.UI
private void updateMod(IMod value)
{
modSettingsChangeTracker?.Dispose();
if (value is Mod actualMod)
{
modSettingsChangeTracker = new ModSettingChangeTracker(new[] { actualMod });
modSettingsChangeTracker.SettingChanged = _ => updateExtendedInformation();
}
modAcronym.Text = value.Acronym;
modIcon.Icon = value.Icon ?? FontAwesome.Solid.Question;
@ -125,11 +192,28 @@ namespace osu.Game.Rulesets.UI
backgroundColour = colours.ForModType(value.Type);
updateColour();
updateExtendedInformation();
}
private void updateExtendedInformation()
{
bool showExtended = showExtendedInformation && !string.IsNullOrEmpty(mod.ExtendedIconInformation);
extendedContent.Alpha = showExtended ? 1 : 0;
extendedText.Text = mod.ExtendedIconInformation;
}
private void updateColour()
{
background.Colour = Selected.Value ? backgroundColour.Lighten(0.2f) : backgroundColour;
extendedText.Colour = background.Colour = Selected.Value ? backgroundColour.Lighten(0.2f) : backgroundColour;
extendedBackground.Colour = Selected.Value ? backgroundColour.Darken(2.4f) : backgroundColour.Darken(2.8f);
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
modSettingsChangeTracker?.Dispose();
}
}
}

View File

@ -6,6 +6,7 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Framework.Utils;
using osu.Game.Graphics;
using osu.Game.Overlays;
@ -23,8 +24,8 @@ namespace osu.Game.Rulesets.UI
private readonly IMod mod;
private readonly SpriteIcon background;
private readonly SpriteIcon? modIcon;
private Drawable background = null!;
private SpriteIcon? modIcon;
private Color4 activeForegroundColour;
private Color4 inactiveForegroundColour;
@ -36,19 +37,24 @@ namespace osu.Game.Rulesets.UI
{
this.mod = mod;
AutoSizeAxes = Axes.Both;
Size = new Vector2(DEFAULT_SIZE);
}
[BackgroundDependencyLoader]
private void load(TextureStore textures, OsuColour colours, OverlayColourProvider? colourProvider)
{
FillFlowContainer contentFlow;
ModSwitchTiny tinySwitch;
InternalChildren = new Drawable[]
InternalChildren = new[]
{
background = new SpriteIcon
background = new Sprite
{
RelativeSizeAxes = Axes.Both,
FillMode = FillMode.Fit,
Texture = textures.Get("Icons/BeatmapDetails/mod-icon"),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(DEFAULT_SIZE),
Icon = OsuIcon.ModBg
},
contentFlow = new FillFlowContainer
{
@ -78,11 +84,7 @@ namespace osu.Game.Rulesets.UI
});
tinySwitch.Scale = new Vector2(0.3f);
}
}
[BackgroundDependencyLoader(true)]
private void load(OsuColour colours, OverlayColourProvider? colourProvider)
{
inactiveForegroundColour = colourProvider?.Background5 ?? colours.Gray3;
activeForegroundColour = colours.ForModType(mod.Type);

View File

@ -27,44 +27,37 @@ namespace osu.Game.Scoring.Legacy
.DefaultIfEmpty(0)
.Sum();
// This gives a similar feeling to osu!stable scoring (ScoreV1) while keeping classic scoring as only a constant multiple of standardised scoring.
// The invariant is important to ensure that scores don't get re-ordered on leaderboards between the two scoring modes.
double scaledRawScore = score / ScoreProcessor.MAX_SCORE;
return (long)Math.Round(Math.Pow(scaledRawScore * Math.Max(1, maxBasicJudgements), 2) * getStandardisedToClassicMultiplier(rulesetId));
return convertStandardisedToClassic(rulesetId, score, maxBasicJudgements);
}
/// <summary>
/// Returns a ballpark multiplier which gives a similar "feel" for how large scores should get when displayed in "classic" mode.
/// Returns a ballpark "classic" score which gives a similar "feel" to stable.
/// This is different per ruleset to match the different algorithms used in the scoring implementation.
/// </summary>
private static double getStandardisedToClassicMultiplier(int rulesetId)
/// <remarks>
/// The coefficients chosen here were determined by a least-squares fit performed over all beatmaps
/// with the goal of minimising the relative error of maximum possible base score (without bonus).
/// The constant coefficients (100000, 1 / 10d) - while being detrimental to the least-squares fit - are forced,
/// so that every 10 points in standardised mode converts to at least 1 point in classic mode.
/// This is done to account for bonus judgements in a way that does not reorder scores.
/// </remarks>
private static long convertStandardisedToClassic(int rulesetId, long standardisedTotalScore, int objectCount)
{
double multiplier;
switch (rulesetId)
{
// For non-legacy rulesets, just go with the same as the osu! ruleset.
// This is arbitrary, but at least allows the setting to do something to the score.
default:
case 0:
multiplier = 36;
break;
return (long)Math.Round((objectCount * objectCount * 32.57 + 100000) * standardisedTotalScore / ScoreProcessor.MAX_SCORE);
case 1:
multiplier = 22;
break;
return (long)Math.Round((objectCount * 1109 + 100000) * standardisedTotalScore / ScoreProcessor.MAX_SCORE);
case 2:
multiplier = 28;
break;
return (long)Math.Round(Math.Pow(standardisedTotalScore / ScoreProcessor.MAX_SCORE * objectCount, 2) * 21.62 + standardisedTotalScore / 10d);
case 3:
multiplier = 16;
break;
default:
return standardisedTotalScore;
}
return multiplier;
}
public static int? GetCountGeki(this ScoreInfo scoreInfo)

View File

@ -714,8 +714,11 @@ namespace osu.Game.Screens.Edit
}
// if the dialog is already displayed, block exiting until the user explicitly makes a decision.
if (dialogOverlay.CurrentDialog is PromptForSaveDialog)
if (dialogOverlay.CurrentDialog is PromptForSaveDialog saveDialog)
{
saveDialog.Flash();
return true;
}
if (isNewBeatmap || HasUnsavedChanges)
{

View File

@ -247,7 +247,7 @@ namespace osu.Game.Screens.Play
contentIn();
MetadataInfo.Delay(750).FadeIn(500);
MetadataInfo.Delay(750).FadeIn(500, Easing.OutQuint);
// after an initial delay, start the debounced load check.
// this will continue to execute even after resuming back on restart.
@ -420,7 +420,7 @@ namespace osu.Game.Screens.Play
{
MetadataInfo.Loading = true;
content.FadeInFromZero(400);
content.FadeInFromZero(500, Easing.OutQuint);
content.ScaleTo(1, 650, Easing.OutQuint).Then().Schedule(prepareNewPlayer);
settingsScroll.FadeInFromZero(500, Easing.Out)

View File

@ -525,7 +525,11 @@ namespace osu.Game.Screens.Select
if (beatmapInfoNoDebounce == null)
run();
else
selectionChangedDebounce = Scheduler.AddDelayed(run, 200);
{
// Intentionally slightly higher than repeat_tick_rate to avoid loading songs when holding left / right arrows.
// See https://github.com/ppy/osu-framework/blob/master/osu.Framework/Input/InputManager.cs#L44
selectionChangedDebounce = Scheduler.AddDelayed(run, 80);
}
if (beatmap?.Equals(beatmapInfoPrevious) != true)
{

View File

@ -37,7 +37,7 @@
</PackageReference>
<PackageReference Include="Realm" Version="11.5.0" />
<PackageReference Include="ppy.osu.Framework" Version="2023.922.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2023.914.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2023.928.0" />
<PackageReference Include="Sentry" Version="3.39.1" />
<PackageReference Include="SharpCompress" Version="0.33.0" />
<PackageReference Include="NUnit" Version="3.13.3" />