1
0
mirror of https://github.com/ppy/osu.git synced 2024-12-14 23:05:37 +08:00

Merge branch 'master' into restore-ruleset-tests

This commit is contained in:
Dan Balasescu 2018-02-14 22:48:29 +09:00 committed by GitHub
commit 9f7736fab3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 1219 additions and 1060 deletions

View File

@ -12,7 +12,7 @@ install:
- cmd: git submodule update --init --recursive --depth=5 - cmd: git submodule update --init --recursive --depth=5
- cmd: choco install resharper-clt -y - cmd: choco install resharper-clt -y
- cmd: choco install nvika -y - cmd: choco install nvika -y
- cmd: appveyor DownloadFile https://github.com/peppy/CodeFileSanity/releases/download/v0.2.3/CodeFileSanity.exe - cmd: appveyor DownloadFile https://github.com/peppy/CodeFileSanity/releases/download/v0.2.4/CodeFileSanity.exe
before_build: before_build:
- cmd: CodeFileSanity.exe - cmd: CodeFileSanity.exe
- cmd: nuget restore -verbosity quiet - cmd: nuget restore -verbosity quiet

@ -1 +1 @@
Subproject commit eba12eb4a0fa6238873dd266deb35bfdece21a6a Subproject commit 52b97a5832410eb868421fd7c55cdb67620330d0

View File

@ -1,200 +0,0 @@
// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Judgements
{
public class HitWindows
{
#region Constants
/// <summary>
/// PERFECT hit window at OD = 10.
/// </summary>
private const double perfect_min = 27.8;
/// <summary>
/// PERFECT hit window at OD = 5.
/// </summary>
private const double perfect_mid = 38.8;
/// <summary>
/// PERFECT hit window at OD = 0.
/// </summary>
private const double perfect_max = 44.8;
/// <summary>
/// GREAT hit window at OD = 10.
/// </summary>
private const double great_min = 68;
/// <summary>
/// GREAT hit window at OD = 5.
/// </summary>
private const double great_mid = 98;
/// <summary>
/// GREAT hit window at OD = 0.
/// </summary>
private const double great_max = 128;
/// <summary>
/// GOOD hit window at OD = 10.
/// </summary>
private const double good_min = 134;
/// <summary>
/// GOOD hit window at OD = 5.
/// </summary>
private const double good_mid = 164;
/// <summary>
/// GOOD hit window at OD = 0.
/// </summary>
private const double good_max = 194;
/// <summary>
/// OK hit window at OD = 10.
/// </summary>
private const double ok_min = 194;
/// <summary>
/// OK hit window at OD = 5.
/// </summary>
private const double ok_mid = 224;
/// <summary>
/// OK hit window at OD = 0.
/// </summary>
private const double ok_max = 254;
/// <summary>
/// BAD hit window at OD = 10.
/// </summary>
private const double bad_min = 242;
/// <summary>
/// BAD hit window at OD = 5.
/// </summary>
private const double bad_mid = 272;
/// <summary>
/// BAD hit window at OD = 0.
/// </summary>
private const double bad_max = 302;
/// <summary>
/// MISS hit window at OD = 10.
/// </summary>
private const double miss_min = 316;
/// <summary>
/// MISS hit window at OD = 5.
/// </summary>
private const double miss_mid = 346;
/// <summary>
/// MISS hit window at OD = 0.
/// </summary>
private const double miss_max = 376;
#endregion
/// <summary>
/// Hit window for a PERFECT hit.
/// </summary>
public double Perfect = perfect_mid;
/// <summary>
/// Hit window for a GREAT hit.
/// </summary>
public double Great = great_mid;
/// <summary>
/// Hit window for a GOOD hit.
/// </summary>
public double Good = good_mid;
/// <summary>
/// Hit window for an OK hit.
/// </summary>
public double Ok = ok_mid;
/// <summary>
/// Hit window for a BAD hit.
/// </summary>
public double Bad = bad_mid;
/// <summary>
/// Hit window for a MISS hit.
/// </summary>
public double Miss = miss_mid;
/// <summary>
/// Constructs default hit windows.
/// </summary>
public HitWindows()
{
}
/// <summary>
/// Constructs hit windows by fitting a parameter to a 2-part piecewise linear function for each hit window.
/// </summary>
/// <param name="difficulty">The parameter.</param>
public HitWindows(double difficulty)
{
Perfect = BeatmapDifficulty.DifficultyRange(difficulty, perfect_max, perfect_mid, perfect_min);
Great = BeatmapDifficulty.DifficultyRange(difficulty, great_max, great_mid, great_min);
Good = BeatmapDifficulty.DifficultyRange(difficulty, good_max, good_mid, good_min);
Ok = BeatmapDifficulty.DifficultyRange(difficulty, ok_max, ok_mid, ok_min);
Bad = BeatmapDifficulty.DifficultyRange(difficulty, bad_max, bad_mid, bad_min);
Miss = BeatmapDifficulty.DifficultyRange(difficulty, miss_max, miss_mid, miss_min);
}
/// <summary>
/// Retrieves the hit result for a time offset.
/// </summary>
/// <param name="hitOffset">The time offset.</param>
/// <returns>The hit result, or null if the time offset results in a miss.</returns>
public HitResult? ResultFor(double hitOffset)
{
if (hitOffset <= Perfect / 2)
return HitResult.Perfect;
if (hitOffset <= Great / 2)
return HitResult.Great;
if (hitOffset <= Good / 2)
return HitResult.Good;
if (hitOffset <= Ok / 2)
return HitResult.Ok;
if (hitOffset <= Bad / 2)
return HitResult.Meh;
return null;
}
/// <summary>
/// Constructs new hit windows which have been multiplied by a value.
/// </summary>
/// <param name="windows">The original hit windows.</param>
/// <param name="value">The value to multiply each hit window by.</param>
public static HitWindows operator *(HitWindows windows, double value)
{
return new HitWindows
{
Perfect = windows.Perfect * value,
Great = windows.Great * value,
Good = windows.Good * value,
Ok = windows.Ok * value,
Bad = windows.Bad * value,
Miss = windows.Miss * value
};
}
/// <summary>
/// Constructs new hit windows which have been divided by a value.
/// </summary>
/// <param name="windows">The original hit windows.</param>
/// <param name="value">The value to divide each hit window by.</param>
public static HitWindows operator /(HitWindows windows, double value)
{
return new HitWindows
{
Perfect = windows.Perfect / value,
Great = windows.Great / value,
Good = windows.Good / value,
Ok = windows.Ok / value,
Bad = windows.Bad / value,
Miss = windows.Miss / value
};
}
}
}

View File

@ -1,7 +1,6 @@
// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>. // Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
using System;
using System.Linq; using System.Linq;
using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
@ -212,7 +211,7 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
{ {
if (!userTriggered) if (!userTriggered)
{ {
if (timeOffset > HitObject.HitWindows.Bad / 2) if (!HitObject.HitWindows.CanBeHit(timeOffset))
{ {
AddJudgement(new HoldNoteTailJudgement AddJudgement(new HoldNoteTailJudgement
{ {
@ -224,14 +223,13 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
return; return;
} }
double offset = Math.Abs(timeOffset); var result = HitObject.HitWindows.ResultFor(timeOffset);
if (result == HitResult.None)
if (offset > HitObject.HitWindows.Miss / 2)
return; return;
AddJudgement(new HoldNoteTailJudgement AddJudgement(new HoldNoteTailJudgement
{ {
Result = HitObject.HitWindows.ResultFor(offset) ?? HitResult.Miss, Result = result,
HasBroken = holdNote.hasBroken HasBroken = holdNote.hasBroken
}); });
} }

View File

@ -1,7 +1,6 @@
// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>. // Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
using System;
using OpenTK.Graphics; using OpenTK.Graphics;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Input.Bindings; using osu.Framework.Input.Bindings;
@ -63,17 +62,16 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
{ {
if (!userTriggered) if (!userTriggered)
{ {
if (timeOffset > HitObject.HitWindows.Bad / 2) if (!HitObject.HitWindows.CanBeHit(timeOffset))
AddJudgement(new ManiaJudgement { Result = HitResult.Miss }); AddJudgement(new ManiaJudgement { Result = HitResult.Miss });
return; return;
} }
double offset = Math.Abs(timeOffset); var result = HitObject.HitWindows.ResultFor(timeOffset);
if (result == HitResult.None)
if (offset > HitObject.HitWindows.Miss / 2)
return; return;
AddJudgement(new ManiaJudgement { Result = HitObject.HitWindows.ResultFor(offset) ?? HitResult.Miss }); AddJudgement(new ManiaJudgement { Result = result });
} }
protected override void UpdateState(ArmedState state) protected override void UpdateState(ArmedState state)

View File

@ -1,6 +1,8 @@
// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>. // Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Mania.Objects.Types; using osu.Game.Rulesets.Mania.Objects.Types;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
@ -9,5 +11,13 @@ namespace osu.Game.Rulesets.Mania.Objects
public abstract class ManiaHitObject : HitObject, IHasColumn public abstract class ManiaHitObject : HitObject, IHasColumn
{ {
public virtual int Column { get; set; } public virtual int Column { get; set; }
protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty)
{
base.ApplyDefaultsToSelf(controlPointInfo, difficulty);
HitWindows.AllowsPerfect = true;
HitWindows.AllowsOk = true;
}
} }
} }

View File

@ -1,11 +1,6 @@
// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>. // Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
using Newtonsoft.Json;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Mania.Judgements;
namespace osu.Game.Rulesets.Mania.Objects namespace osu.Game.Rulesets.Mania.Objects
{ {
/// <summary> /// <summary>
@ -13,17 +8,5 @@ namespace osu.Game.Rulesets.Mania.Objects
/// </summary> /// </summary>
public class Note : ManiaHitObject public class Note : ManiaHitObject
{ {
/// <summary>
/// The key-press hit window for this note.
/// </summary>
[JsonIgnore]
public HitWindows HitWindows { get; protected set; } = new HitWindows();
protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty)
{
base.ApplyDefaultsToSelf(controlPointInfo, difficulty);
HitWindows = new HitWindows(difficulty.OverallDifficulty);
}
} }
} }

View File

@ -76,7 +76,6 @@
<Compile Include="Beatmaps\Patterns\Pattern.cs" /> <Compile Include="Beatmaps\Patterns\Pattern.cs" />
<Compile Include="Configuration\ManiaConfigManager.cs" /> <Compile Include="Configuration\ManiaConfigManager.cs" />
<Compile Include="MathUtils\FastRandom.cs" /> <Compile Include="MathUtils\FastRandom.cs" />
<Compile Include="Judgements\HitWindows.cs" />
<Compile Include="Judgements\HoldNoteTailJudgement.cs" /> <Compile Include="Judgements\HoldNoteTailJudgement.cs" />
<Compile Include="Judgements\HoldNoteTickJudgement.cs" /> <Compile Include="Judgements\HoldNoteTickJudgement.cs" />
<Compile Include="Judgements\ManiaJudgement.cs" /> <Compile Include="Judgements\ManiaJudgement.cs" />

View File

@ -1,6 +1,7 @@
// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>. // Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using osu.Framework.Graphics; using osu.Framework.Graphics;
@ -47,16 +48,20 @@ namespace osu.Game.Rulesets.Osu.Mods
// fade out immediately after fade in. // fade out immediately after fade in.
using (drawable.BeginAbsoluteSequence(fadeOutStartTime, true)) using (drawable.BeginAbsoluteSequence(fadeOutStartTime, true))
{
circle.FadeOut(fadeOutDuration); circle.FadeOut(fadeOutDuration);
}
break; break;
case DrawableSlider slider: case DrawableSlider slider:
using (slider.BeginAbsoluteSequence(fadeOutStartTime, true)) using (slider.BeginAbsoluteSequence(fadeOutStartTime, true))
{
slider.Body.FadeOut(longFadeDuration, Easing.Out); slider.Body.FadeOut(longFadeDuration, Easing.Out);
}
break;
case DrawableSliderTick sliderTick:
// slider ticks fade out over up to one second
var tickFadeOutDuration = Math.Min(sliderTick.HitObject.TimePreempt - DrawableSliderTick.ANIM_DURATION, 1000);
using (sliderTick.BeginAbsoluteSequence(sliderTick.HitObject.StartTime - tickFadeOutDuration, true))
sliderTick.FadeOut(tickFadeOutDuration);
break; break;
case DrawableSpinner spinner: case DrawableSpinner spinner:
@ -66,9 +71,7 @@ namespace osu.Game.Rulesets.Osu.Mods
spinner.Background.Hide(); spinner.Background.Hide();
using (spinner.BeginAbsoluteSequence(fadeOutStartTime + longFadeDuration, true)) using (spinner.BeginAbsoluteSequence(fadeOutStartTime + longFadeDuration, true))
{
spinner.FadeOut(fadeOutDuration); spinner.FadeOut(fadeOutDuration);
}
break; break;
} }

View File

@ -72,14 +72,18 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
{ {
if (!userTriggered) if (!userTriggered)
{ {
if (timeOffset > HitObject.HitWindowFor(HitResult.Meh)) if (!HitObject.HitWindows.CanBeHit(timeOffset))
AddJudgement(new OsuJudgement { Result = HitResult.Miss }); AddJudgement(new OsuJudgement { Result = HitResult.Miss });
return; return;
} }
var result = HitObject.HitWindows.ResultFor(timeOffset);
if (result == HitResult.None)
return;
AddJudgement(new OsuJudgement AddJudgement(new OsuJudgement
{ {
Result = HitObject.ScoreResultForOffset(Math.Abs(timeOffset)), Result = result,
PositionOffset = Vector2.Zero //todo: set to correct value PositionOffset = Vector2.Zero //todo: set to correct value
}); });
} }
@ -104,7 +108,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
Expire(true); Expire(true);
// override lifetime end as FadeIn may have been changed externally, causing out expiration to be too early. // override lifetime end as FadeIn may have been changed externally, causing out expiration to be too early.
LifetimeEnd = HitObject.StartTime + HitObject.HitWindowFor(HitResult.Miss); LifetimeEnd = HitObject.StartTime + HitObject.HitWindows.HalfWindowFor(HitResult.Miss);
break; break;
case ArmedState.Miss: case ArmedState.Miss:
ApproachCircle.FadeOut(50); ApproachCircle.FadeOut(50);

View File

@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
{ {
public class DrawableSliderTick : DrawableOsuHitObject, IRequireTracking public class DrawableSliderTick : DrawableOsuHitObject, IRequireTracking
{ {
private const double anim_duration = 150; public const double ANIM_DURATION = 150;
public bool Tracking { get; set; } public bool Tracking { get; set; }
@ -51,8 +51,8 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
protected override void UpdatePreemptState() protected override void UpdatePreemptState()
{ {
this.Animate( this.Animate(
d => d.FadeIn(anim_duration), d => d.FadeIn(ANIM_DURATION),
d => d.ScaleTo(0.5f).ScaleTo(1f, anim_duration * 4, Easing.OutElasticHalf) d => d.ScaleTo(0.5f).ScaleTo(1f, ANIM_DURATION * 4, Easing.OutElasticHalf)
); );
} }
@ -64,12 +64,12 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
this.Delay(HitObject.TimePreempt).FadeOut(); this.Delay(HitObject.TimePreempt).FadeOut();
break; break;
case ArmedState.Miss: case ArmedState.Miss:
this.FadeOut(anim_duration) this.FadeOut(ANIM_DURATION)
.FadeColour(Color4.Red, anim_duration / 2); .FadeColour(Color4.Red, ANIM_DURATION / 2);
break; break;
case ArmedState.Hit: case ArmedState.Hit:
this.FadeOut(anim_duration, Easing.OutQuint) this.FadeOut(ANIM_DURATION, Easing.OutQuint)
.ScaleTo(Scale * 1.5f, anim_duration, Easing.Out); .ScaleTo(Scale * 1.5f, ANIM_DURATION, Easing.Out);
break; break;
} }
} }

View File

@ -7,7 +7,6 @@ using OpenTK;
using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects.Types;
using OpenTK.Graphics; using OpenTK.Graphics;
using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Osu.Objects namespace osu.Game.Rulesets.Osu.Objects
{ {
@ -15,11 +14,6 @@ namespace osu.Game.Rulesets.Osu.Objects
{ {
public const double OBJECT_RADIUS = 64; public const double OBJECT_RADIUS = 64;
private const double hittable_range = 300;
public double HitWindow50 = 150;
public double HitWindow100 = 80;
public double HitWindow300 = 30;
public double TimePreempt = 600; public double TimePreempt = 600;
public double TimeFadein = 400; public double TimeFadein = 400;
@ -45,32 +39,6 @@ namespace osu.Game.Rulesets.Osu.Objects
public virtual bool NewCombo { get; set; } public virtual bool NewCombo { get; set; }
public int IndexInCurrentCombo { get; set; } public int IndexInCurrentCombo { get; set; }
public double HitWindowFor(HitResult result)
{
switch (result)
{
default:
return hittable_range;
case HitResult.Meh:
return HitWindow50;
case HitResult.Good:
return HitWindow100;
case HitResult.Great:
return HitWindow300;
}
}
public HitResult ScoreResultForOffset(double offset)
{
if (offset < HitWindowFor(HitResult.Great))
return HitResult.Great;
if (offset < HitWindowFor(HitResult.Good))
return HitResult.Good;
if (offset < HitWindowFor(HitResult.Meh))
return HitResult.Meh;
return HitResult.Miss;
}
protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty)
{ {
base.ApplyDefaultsToSelf(controlPointInfo, difficulty); base.ApplyDefaultsToSelf(controlPointInfo, difficulty);
@ -78,10 +46,6 @@ namespace osu.Game.Rulesets.Osu.Objects
TimePreempt = (float)BeatmapDifficulty.DifficultyRange(difficulty.ApproachRate, 1800, 1200, 450); TimePreempt = (float)BeatmapDifficulty.DifficultyRange(difficulty.ApproachRate, 1800, 1200, 450);
TimeFadein = (float)BeatmapDifficulty.DifficultyRange(difficulty.ApproachRate, 1200, 800, 300); TimeFadein = (float)BeatmapDifficulty.DifficultyRange(difficulty.ApproachRate, 1200, 800, 300);
HitWindow50 = BeatmapDifficulty.DifficultyRange(difficulty.OverallDifficulty, 200, 150, 100);
HitWindow100 = BeatmapDifficulty.DifficultyRange(difficulty.OverallDifficulty, 140, 100, 60);
HitWindow300 = BeatmapDifficulty.DifficultyRange(difficulty.OverallDifficulty, 80, 50, 20);
Scale = (1.0f - 0.7f * (difficulty.CircleSize - 5) / 5) / 2; Scale = (1.0f - 0.7f * (difficulty.CircleSize - 5) / 5) / 2;
} }
} }

View File

@ -123,9 +123,7 @@ namespace osu.Game.Rulesets.Osu.Objects
StartTime = EndTime, StartTime = EndTime,
Position = StackedEndPosition, Position = StackedEndPosition,
IndexInCurrentCombo = IndexInCurrentCombo, IndexInCurrentCombo = IndexInCurrentCombo,
ComboColour = ComboColour, ComboColour = ComboColour
Samples = Samples,
SampleControlPoint = SampleControlPoint
}; };
AddNested(HeadCircle); AddNested(HeadCircle);

View File

@ -89,20 +89,20 @@ namespace osu.Game.Rulesets.Osu.Replays
double endTime = (prev as IHasEndTime)?.EndTime ?? prev.StartTime; double endTime = (prev as IHasEndTime)?.EndTime ?? prev.StartTime;
// Make the cursor stay at a hitObject as long as possible (mainly for autopilot). // Make the cursor stay at a hitObject as long as possible (mainly for autopilot).
if (h.StartTime - h.HitWindowFor(HitResult.Miss) > endTime + h.HitWindowFor(HitResult.Meh) + 50) if (h.StartTime - h.HitWindows.HalfWindowFor(HitResult.Miss) > endTime + h.HitWindows.HalfWindowFor(HitResult.Meh) + 50)
{ {
if (!(prev is Spinner) && h.StartTime - endTime < 1000) AddFrameToReplay(new ReplayFrame(endTime + h.HitWindowFor(HitResult.Meh), prev.StackedEndPosition.X, prev.StackedEndPosition.Y, ReplayButtonState.None)); if (!(prev is Spinner) && h.StartTime - endTime < 1000) AddFrameToReplay(new ReplayFrame(endTime + h.HitWindows.HalfWindowFor(HitResult.Meh), prev.StackedEndPosition.X, prev.StackedEndPosition.Y, ReplayButtonState.None));
if (!(h is Spinner)) AddFrameToReplay(new ReplayFrame(h.StartTime - h.HitWindowFor(HitResult.Miss), h.StackedPosition.X, h.StackedPosition.Y, ReplayButtonState.None)); if (!(h is Spinner)) AddFrameToReplay(new ReplayFrame(h.StartTime - h.HitWindows.HalfWindowFor(HitResult.Meh), h.StackedPosition.X, h.StackedPosition.Y, ReplayButtonState.None));
} }
else if (h.StartTime - h.HitWindowFor(HitResult.Meh) > endTime + h.HitWindowFor(HitResult.Meh) + 50) else if (h.StartTime - h.HitWindows.HalfWindowFor(HitResult.Meh) > endTime + h.HitWindows.HalfWindowFor(HitResult.Meh) + 50)
{ {
if (!(prev is Spinner) && h.StartTime - endTime < 1000) AddFrameToReplay(new ReplayFrame(endTime + h.HitWindowFor(HitResult.Meh), prev.StackedEndPosition.X, prev.StackedEndPosition.Y, ReplayButtonState.None)); if (!(prev is Spinner) && h.StartTime - endTime < 1000) AddFrameToReplay(new ReplayFrame(endTime + h.HitWindows.HalfWindowFor(HitResult.Meh), prev.StackedEndPosition.X, prev.StackedEndPosition.Y, ReplayButtonState.None));
if (!(h is Spinner)) AddFrameToReplay(new ReplayFrame(h.StartTime - h.HitWindowFor(HitResult.Meh), h.StackedPosition.X, h.StackedPosition.Y, ReplayButtonState.None)); if (!(h is Spinner)) AddFrameToReplay(new ReplayFrame(h.StartTime - h.HitWindows.HalfWindowFor(HitResult.Meh), h.StackedPosition.X, h.StackedPosition.Y, ReplayButtonState.None));
} }
else if (h.StartTime - h.HitWindowFor(HitResult.Good) > endTime + h.HitWindowFor(HitResult.Good) + 50) else if (h.StartTime - h.HitWindows.HalfWindowFor(HitResult.Meh) > endTime + h.HitWindows.HalfWindowFor(HitResult.Meh) + 50)
{ {
if (!(prev is Spinner) && h.StartTime - endTime < 1000) AddFrameToReplay(new ReplayFrame(endTime + h.HitWindowFor(HitResult.Good), prev.StackedEndPosition.X, prev.StackedEndPosition.Y, ReplayButtonState.None)); if (!(prev is Spinner) && h.StartTime - endTime < 1000) AddFrameToReplay(new ReplayFrame(endTime + h.HitWindows.HalfWindowFor(HitResult.Meh), prev.StackedEndPosition.X, prev.StackedEndPosition.Y, ReplayButtonState.None));
if (!(h is Spinner)) AddFrameToReplay(new ReplayFrame(h.StartTime - h.HitWindowFor(HitResult.Good), h.StackedPosition.X, h.StackedPosition.Y, ReplayButtonState.None)); if (!(h is Spinner)) AddFrameToReplay(new ReplayFrame(h.StartTime - h.HitWindows.HalfWindowFor(HitResult.Meh), h.StackedPosition.X, h.StackedPosition.Y, ReplayButtonState.None));
} }
} }

View File

@ -1,7 +1,6 @@
// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>. // Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
using System;
using System.Linq; using System.Linq;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables;
@ -38,30 +37,27 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
{ {
if (!userTriggered) if (!userTriggered)
{ {
if (timeOffset > HitObject.HitWindowGood) if (!HitObject.HitWindows.CanBeHit(timeOffset))
AddJudgement(new TaikoJudgement { Result = HitResult.Miss }); AddJudgement(new TaikoJudgement { Result = HitResult.Miss });
return; return;
} }
double hitOffset = Math.Abs(timeOffset); var result = HitObject.HitWindows.ResultFor(timeOffset);
if (result == HitResult.None)
if (hitOffset > HitObject.HitWindowMiss)
return; return;
if (!validKeyPressed) if (!validKeyPressed || result == HitResult.Miss)
AddJudgement(new TaikoJudgement { Result = HitResult.Miss }); AddJudgement(new TaikoJudgement { Result = HitResult.Miss });
else if (hitOffset < HitObject.HitWindowGood) else
{ {
AddJudgement(new TaikoJudgement AddJudgement(new TaikoJudgement
{ {
Result = hitOffset < HitObject.HitWindowGreat ? HitResult.Great : HitResult.Good, Result = result,
Final = !HitObject.IsStrong Final = !HitObject.IsStrong
}); });
SecondHitAllowed = true; SecondHitAllowed = true;
} }
else
AddJudgement(new TaikoJudgement { Result = HitResult.Miss });
} }
public override bool OnPressed(TaikoAction action) public override bool OnPressed(TaikoAction action)
@ -90,7 +86,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
switch (State.Value) switch (State.Value)
{ {
case ArmedState.Idle: case ArmedState.Idle:
this.Delay(HitObject.HitWindowMiss).Expire(); this.Delay(HitObject.HitWindows.HalfWindowFor(HitResult.Miss)).Expire();
break; break;
case ArmedState.Miss: case ArmedState.Miss:
this.FadeOut(100) this.FadeOut(100)

View File

@ -1,35 +1,9 @@
// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>. // Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
namespace osu.Game.Rulesets.Taiko.Objects namespace osu.Game.Rulesets.Taiko.Objects
{ {
public class Hit : TaikoHitObject public class Hit : TaikoHitObject
{ {
/// <summary>
/// The hit window that results in a "GREAT" hit.
/// </summary>
public double HitWindowGreat = 35;
/// <summary>
/// The hit window that results in a "GOOD" hit.
/// </summary>
public double HitWindowGood = 80;
/// <summary>
/// The hit window that results in a "MISS".
/// </summary>
public double HitWindowMiss = 95;
protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty)
{
base.ApplyDefaultsToSelf(controlPointInfo, difficulty);
HitWindowGreat = BeatmapDifficulty.DifficultyRange(difficulty.OverallDifficulty, 50, 35, 20);
HitWindowGood = BeatmapDifficulty.DifficultyRange(difficulty.OverallDifficulty, 120, 80, 50);
HitWindowMiss = BeatmapDifficulty.DifficultyRange(difficulty.OverallDifficulty, 135, 95, 70);
}
} }
} }

View File

@ -24,31 +24,138 @@ namespace osu.Game.Tests.Beatmaps.IO
public void TestImportWhenClosed() public void TestImportWhenClosed()
{ {
//unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here. //unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here.
using (HeadlessGameHost host = new HeadlessGameHost("TestImportWhenClosed")) using (HeadlessGameHost host = new CleanRunHeadlessGameHost("TestImportWhenClosed"))
{
try
{
loadOszIntoOsu(loadOsu(host));
}
finally
{
host.Exit();
}
}
}
[Test]
public void TestImportThenDelete()
{
//unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here.
using (HeadlessGameHost host = new CleanRunHeadlessGameHost("TestImportThenDelete"))
{
try
{ {
var osu = loadOsu(host); var osu = loadOsu(host);
var temp = prepareTempCopy(osz_path); var imported = loadOszIntoOsu(osu);
Assert.IsTrue(File.Exists(temp));
osu.Dependencies.Get<BeatmapManager>().Import(temp);
ensureLoaded(osu);
waitForOrAssert(() => !File.Exists(temp), "Temporary file still exists after standard import", 5000);
deleteBeatmapSet(imported, osu);
}
finally
{
host.Exit(); host.Exit();
} }
} }
}
[Test]
public void TestImportThenImport()
{
//unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here.
using (HeadlessGameHost host = new CleanRunHeadlessGameHost("TestImportThenImport"))
{
try
{
var osu = loadOsu(host);
var imported = loadOszIntoOsu(osu);
var importedSecondTime = loadOszIntoOsu(osu);
// check the newly "imported" beatmap is actually just the restored previous import. since it matches hash.
Assert.IsTrue(imported.ID == importedSecondTime.ID);
Assert.IsTrue(imported.Beatmaps.First().ID == importedSecondTime.Beatmaps.First().ID);
var manager = osu.Dependencies.Get<BeatmapManager>();
Assert.IsTrue(manager.GetAllUsableBeatmapSets().Count == 1);
Assert.IsTrue(manager.QueryBeatmapSets(_ => true).ToList().Count == 1);
}
finally
{
host.Exit();
}
}
}
[Test]
public void TestImportThenImportDifferentHash()
{
//unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here.
using (HeadlessGameHost host = new CleanRunHeadlessGameHost("TestImportThenImportDifferentHash"))
{
try
{
var osu = loadOsu(host);
var manager = osu.Dependencies.Get<BeatmapManager>();
var imported = loadOszIntoOsu(osu);
//var change = manager.QueryBeatmapSets(_ => true).First();
imported.Hash += "-changed";
manager.Update(imported);
var importedSecondTime = loadOszIntoOsu(osu);
// check the newly "imported" beatmap is actually just the restored previous import. since it matches hash.
Assert.IsTrue(imported.ID != importedSecondTime.ID);
Assert.IsTrue(imported.Beatmaps.First().ID < importedSecondTime.Beatmaps.First().ID);
Assert.IsTrue(manager.GetAllUsableBeatmapSets().Count == 1);
Assert.IsTrue(manager.QueryBeatmapSets(_ => true).ToList().Count == 1);
}
finally
{
host.Exit();
}
}
}
[Test]
public void TestImportThenDeleteThenImport()
{
//unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here.
using (HeadlessGameHost host = new CleanRunHeadlessGameHost("TestImportThenDeleteThenImport"))
{
try
{
var osu = loadOsu(host);
var imported = loadOszIntoOsu(osu);
deleteBeatmapSet(imported, osu);
var importedSecondTime = loadOszIntoOsu(osu);
// check the newly "imported" beatmap is actually just the restored previous import. since it matches hash.
Assert.IsTrue(imported.ID == importedSecondTime.ID);
Assert.IsTrue(imported.Beatmaps.First().ID == importedSecondTime.Beatmaps.First().ID);
}
finally
{
host.Exit();
}
}
}
[Test] [Test]
[NonParallelizable] [NonParallelizable]
[Ignore("Binding IPC on Appveyor isn't working (port in use). Need to figure out why")] [Ignore("Binding IPC on Appveyor isn't working (port in use). Need to figure out why")]
public void TestImportOverIPC() public void TestImportOverIPC()
{ {
using (HeadlessGameHost host = new HeadlessGameHost("host", true)) using (HeadlessGameHost host = new CleanRunHeadlessGameHost("host", true))
using (HeadlessGameHost client = new HeadlessGameHost("client", true)) using (HeadlessGameHost client = new CleanRunHeadlessGameHost("client", true))
{
try
{ {
Assert.IsTrue(host.IsPrimaryInstance); Assert.IsTrue(host.IsPrimaryInstance);
Assert.IsFalse(client.IsPrimaryInstance); Assert.IsFalse(client.IsPrimaryInstance);
@ -56,7 +163,6 @@ namespace osu.Game.Tests.Beatmaps.IO
var osu = loadOsu(host); var osu = loadOsu(host);
var temp = prepareTempCopy(osz_path); var temp = prepareTempCopy(osz_path);
Assert.IsTrue(File.Exists(temp)); Assert.IsTrue(File.Exists(temp));
var importer = new BeatmapIPCChannel(client); var importer = new BeatmapIPCChannel(client);
@ -66,33 +172,60 @@ namespace osu.Game.Tests.Beatmaps.IO
ensureLoaded(osu); ensureLoaded(osu);
waitForOrAssert(() => !File.Exists(temp), "Temporary still exists after IPC import", 5000); waitForOrAssert(() => !File.Exists(temp), "Temporary still exists after IPC import", 5000);
}
finally
{
host.Exit(); host.Exit();
} }
} }
}
[Test] [Test]
public void TestImportWhenFileOpen() public void TestImportWhenFileOpen()
{ {
using (HeadlessGameHost host = new HeadlessGameHost("TestImportWhenFileOpen")) using (HeadlessGameHost host = new CleanRunHeadlessGameHost("TestImportWhenFileOpen"))
{
try
{ {
var osu = loadOsu(host); var osu = loadOsu(host);
var temp = prepareTempCopy(osz_path); var temp = prepareTempCopy(osz_path);
Assert.IsTrue(File.Exists(temp), "Temporary file copy never substantiated"); Assert.IsTrue(File.Exists(temp), "Temporary file copy never substantiated");
using (File.OpenRead(temp)) using (File.OpenRead(temp))
osu.Dependencies.Get<BeatmapManager>().Import(temp); osu.Dependencies.Get<BeatmapManager>().Import(temp);
ensureLoaded(osu);
File.Delete(temp);
Assert.IsFalse(File.Exists(temp), "We likely held a read lock on the file when we shouldn't");
}
finally
{
host.Exit();
}
}
}
private BeatmapSetInfo loadOszIntoOsu(OsuGameBase osu)
{
var temp = prepareTempCopy(osz_path);
Assert.IsTrue(File.Exists(temp));
var imported = osu.Dependencies.Get<BeatmapManager>().Import(temp);
ensureLoaded(osu); ensureLoaded(osu);
File.Delete(temp); waitForOrAssert(() => !File.Exists(temp), "Temporary file still exists after standard import", 5000);
Assert.IsFalse(File.Exists(temp), "We likely held a read lock on the file when we shouldn't"); return imported.FirstOrDefault();
host.Exit();
} }
private void deleteBeatmapSet(BeatmapSetInfo imported, OsuGameBase osu)
{
var manager = osu.Dependencies.Get<BeatmapManager>();
manager.Delete(imported);
Assert.IsTrue(manager.GetAllUsableBeatmapSets().Count == 0);
Assert.IsTrue(manager.QueryBeatmapSets(_ => true).ToList().Count == 1);
Assert.IsTrue(manager.QueryBeatmapSets(_ => true).First().DeletePending);
} }
private string prepareTempCopy(string path) private string prepareTempCopy(string path)
@ -105,65 +238,55 @@ namespace osu.Game.Tests.Beatmaps.IO
{ {
var osu = new OsuGameBase(); var osu = new OsuGameBase();
Task.Run(() => host.Run(osu)); Task.Run(() => host.Run(osu));
waitForOrAssert(() => osu.IsLoaded, @"osu! failed to start in a reasonable amount of time"); waitForOrAssert(() => osu.IsLoaded, @"osu! failed to start in a reasonable amount of time");
return osu; return osu;
} }
private void ensureLoaded(OsuGameBase osu, int timeout = 60000) private void ensureLoaded(OsuGameBase osu, int timeout = 60000)
{ {
IEnumerable<BeatmapSetInfo> resultSets = null; IEnumerable<BeatmapSetInfo> resultSets = null;
var store = osu.Dependencies.Get<BeatmapManager>(); var store = osu.Dependencies.Get<BeatmapManager>();
waitForOrAssert(() => (resultSets = store.QueryBeatmapSets(s => s.OnlineBeatmapSetID == 241526)).Any(), waitForOrAssert(() => (resultSets = store.QueryBeatmapSets(s => s.OnlineBeatmapSetID == 241526)).Any(),
@"BeatmapSet did not import to the database in allocated time.", timeout); @"BeatmapSet did not import to the database in allocated time.", timeout);
//ensure we were stored to beatmap database backing... //ensure we were stored to beatmap database backing...
Assert.IsTrue(resultSets.Count() == 1, $@"Incorrect result count found ({resultSets.Count()} but should be 1)."); Assert.IsTrue(resultSets.Count() == 1, $@"Incorrect result count found ({resultSets.Count()} but should be 1).");
IEnumerable<BeatmapInfo> queryBeatmaps() => store.QueryBeatmaps(s => s.BeatmapSet.OnlineBeatmapSetID == 241526 && s.BaseDifficultyID > 0); IEnumerable<BeatmapInfo> queryBeatmaps() => store.QueryBeatmaps(s => s.BeatmapSet.OnlineBeatmapSetID == 241526 && s.BaseDifficultyID > 0);
IEnumerable<BeatmapSetInfo> queryBeatmapSets() => store.QueryBeatmapSets(s => s.OnlineBeatmapSetID == 241526); IEnumerable<BeatmapSetInfo> queryBeatmapSets() => store.QueryBeatmapSets(s => s.OnlineBeatmapSetID == 241526);
//if we don't re-check here, the set will be inserted but the beatmaps won't be present yet. //if we don't re-check here, the set will be inserted but the beatmaps won't be present yet.
waitForOrAssert(() => queryBeatmaps().Count() == 12, waitForOrAssert(() => queryBeatmaps().Count() == 12,
@"Beatmaps did not import to the database in allocated time", timeout); @"Beatmaps did not import to the database in allocated time", timeout);
waitForOrAssert(() => queryBeatmapSets().Count() == 1, waitForOrAssert(() => queryBeatmapSets().Count() == 1,
@"BeatmapSet did not import to the database in allocated time", timeout); @"BeatmapSet did not import to the database in allocated time", timeout);
int countBeatmapSetBeatmaps = 0; int countBeatmapSetBeatmaps = 0;
int countBeatmaps = 0; int countBeatmaps = 0;
waitForOrAssert(() => waitForOrAssert(() =>
(countBeatmapSetBeatmaps = queryBeatmapSets().First().Beatmaps.Count) == (countBeatmapSetBeatmaps = queryBeatmapSets().First().Beatmaps.Count) ==
(countBeatmaps = queryBeatmaps().Count()), (countBeatmaps = queryBeatmaps().Count()),
$@"Incorrect database beatmap count post-import ({countBeatmaps} but should be {countBeatmapSetBeatmaps}).", timeout); $@"Incorrect database beatmap count post-import ({countBeatmaps} but should be {countBeatmapSetBeatmaps}).", timeout);
var set = queryBeatmapSets().First(); var set = queryBeatmapSets().First();
foreach (BeatmapInfo b in set.Beatmaps) foreach (BeatmapInfo b in set.Beatmaps)
Assert.IsTrue(set.Beatmaps.Any(c => c.OnlineBeatmapID == b.OnlineBeatmapID)); Assert.IsTrue(set.Beatmaps.Any(c => c.OnlineBeatmapID == b.OnlineBeatmapID));
Assert.IsTrue(set.Beatmaps.Count > 0); Assert.IsTrue(set.Beatmaps.Count > 0);
var beatmap = store.GetWorkingBeatmap(set.Beatmaps.First(b => b.RulesetID == 0))?.Beatmap; var beatmap = store.GetWorkingBeatmap(set.Beatmaps.First(b => b.RulesetID == 0))?.Beatmap;
Assert.IsTrue(beatmap?.HitObjects.Count > 0); Assert.IsTrue(beatmap?.HitObjects.Count > 0);
beatmap = store.GetWorkingBeatmap(set.Beatmaps.First(b => b.RulesetID == 1))?.Beatmap; beatmap = store.GetWorkingBeatmap(set.Beatmaps.First(b => b.RulesetID == 1))?.Beatmap;
Assert.IsTrue(beatmap?.HitObjects.Count > 0); Assert.IsTrue(beatmap?.HitObjects.Count > 0);
beatmap = store.GetWorkingBeatmap(set.Beatmaps.First(b => b.RulesetID == 2))?.Beatmap; beatmap = store.GetWorkingBeatmap(set.Beatmaps.First(b => b.RulesetID == 2))?.Beatmap;
Assert.IsTrue(beatmap?.HitObjects.Count > 0); Assert.IsTrue(beatmap?.HitObjects.Count > 0);
beatmap = store.GetWorkingBeatmap(set.Beatmaps.First(b => b.RulesetID == 3))?.Beatmap; beatmap = store.GetWorkingBeatmap(set.Beatmaps.First(b => b.RulesetID == 3))?.Beatmap;
Assert.IsTrue(beatmap?.HitObjects.Count > 0); Assert.IsTrue(beatmap?.HitObjects.Count > 0);
} }
private void waitForOrAssert(Func<bool> result, string failureMessage, int timeout = 60000) private void waitForOrAssert(Func<bool> result, string failureMessage, int timeout = 60000)
{ {
Action waitAction = () => { while (!result()) Thread.Sleep(200); }; Action waitAction = () =>
{
while (!result()) Thread.Sleep(200);
};
Assert.IsTrue(waitAction.BeginInvoke(null, null).AsyncWaitHandle.WaitOne(timeout), failureMessage); Assert.IsTrue(waitAction.BeginInvoke(null, null).AsyncWaitHandle.WaitOne(timeout), failureMessage);
} }
} }

View File

@ -63,12 +63,10 @@ namespace osu.Game.Tests.Visual
var storage = new TestStorage(@"TestCasePlaySongSelect"); var storage = new TestStorage(@"TestCasePlaySongSelect");
// this is by no means clean. should be replacing inside of OsuGameBase somehow. // this is by no means clean. should be replacing inside of OsuGameBase somehow.
var context = new OsuDbContext(); IDatabaseContextFactory factory = new SingletonContextFactory(new OsuDbContext());
OsuDbContext contextFactory() => context; dependencies.Cache(rulesets = new RulesetStore(factory));
dependencies.Cache(manager = new BeatmapManager(storage, factory, rulesets, null)
dependencies.Cache(rulesets = new RulesetStore(contextFactory));
dependencies.Cache(manager = new BeatmapManager(storage, contextFactory, rulesets, null)
{ {
DefaultBeatmap = defaultBeatmap = game.Beatmap.Default DefaultBeatmap = defaultBeatmap = game.Beatmap.Default
}); });

View File

@ -20,7 +20,15 @@ namespace osu.Game.Beatmaps
public float DrainRate { get; set; } = DEFAULT_DIFFICULTY; public float DrainRate { get; set; } = DEFAULT_DIFFICULTY;
public float CircleSize { get; set; } = DEFAULT_DIFFICULTY; public float CircleSize { get; set; } = DEFAULT_DIFFICULTY;
public float OverallDifficulty { get; set; } = DEFAULT_DIFFICULTY; public float OverallDifficulty { get; set; } = DEFAULT_DIFFICULTY;
public float ApproachRate { get; set; } = DEFAULT_DIFFICULTY;
private float? approachRate;
public float ApproachRate
{
get => approachRate ?? OverallDifficulty;
set => approachRate = value;
}
public float SliderMultiplier { get; set; } = 1; public float SliderMultiplier { get; set; } = 1;
public float SliderTickRate { get; set; } = 1; public float SliderTickRate { get; set; } = 1;
@ -40,5 +48,17 @@ namespace osu.Game.Beatmaps
return mid - (mid - min) * (5 - difficulty) / 5; return mid - (mid - min) * (5 - difficulty) / 5;
return mid; return mid;
} }
/// <summary>
/// Maps a difficulty value [0, 10] to a two-piece linear range of values.
/// </summary>
/// <param name="difficulty">The difficulty value to be mapped.</param>
/// <param name="range">The values that define the two linear ranges.</param>
/// <param name="range.od0">Minimum of the resulting range which will be achieved by a difficulty value of 0.</param>
/// <param name="range.od5">Midpoint of the resulting range which will be achieved by a difficulty value of 5.</param>
/// <param name="range.od10">Maximum of the resulting range which will be achieved by a difficulty value of 10.</param>
/// <returns>Value to which the difficulty value maps in the specified range.</returns>
public static double DifficultyRange(double difficulty, (double od0, double od5, double od10) range)
=> DifficultyRange(difficulty, range.od0, range.od5, range.od10);
} }
} }

View File

@ -9,31 +9,26 @@ using System.Linq.Expressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using Ionic.Zip; using Ionic.Zip;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using osu.Framework.Audio.Track;
using osu.Framework.Extensions; using osu.Framework.Extensions;
using osu.Framework.Graphics.Textures;
using osu.Framework.IO.Stores;
using osu.Framework.Logging; using osu.Framework.Logging;
using osu.Framework.Platform; using osu.Framework.Platform;
using osu.Game.Beatmaps.Formats; using osu.Game.Beatmaps.Formats;
using osu.Game.Beatmaps.IO; using osu.Game.Beatmaps.IO;
using osu.Game.Database; using osu.Game.Database;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Textures;
using osu.Game.IO; using osu.Game.IO;
using osu.Game.IPC; using osu.Game.IPC;
using osu.Game.Online.API; using osu.Game.Online.API;
using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests;
using osu.Game.Overlays.Notifications; using osu.Game.Overlays.Notifications;
using osu.Game.Rulesets; using osu.Game.Rulesets;
using osu.Game.Storyboards;
namespace osu.Game.Beatmaps namespace osu.Game.Beatmaps
{ {
/// <summary> /// <summary>
/// Handles the storage and retrieval of Beatmaps/WorkingBeatmaps. /// Handles the storage and retrieval of Beatmaps/WorkingBeatmaps.
/// </summary> /// </summary>
public class BeatmapManager public partial class BeatmapManager
{ {
/// <summary> /// <summary>
/// Fired when a new <see cref="BeatmapSetInfo"/> becomes available in the database. /// Fired when a new <see cref="BeatmapSetInfo"/> becomes available in the database.
@ -65,19 +60,7 @@ namespace osu.Game.Beatmaps
/// </summary> /// </summary>
public WorkingBeatmap DefaultBeatmap { private get; set; } public WorkingBeatmap DefaultBeatmap { private get; set; }
private readonly Storage storage; private readonly IDatabaseContextFactory contextFactory;
private BeatmapStore createBeatmapStore(Func<OsuDbContext> context)
{
var store = new BeatmapStore(context);
store.BeatmapSetAdded += s => BeatmapSetAdded?.Invoke(s);
store.BeatmapSetRemoved += s => BeatmapSetRemoved?.Invoke(s);
store.BeatmapHidden += b => BeatmapHidden?.Invoke(b);
store.BeatmapRestored += b => BeatmapRestored?.Invoke(b);
return store;
}
private readonly Func<OsuDbContext> createContext;
private readonly FileStore files; private readonly FileStore files;
@ -102,31 +85,19 @@ namespace osu.Game.Beatmaps
/// </summary> /// </summary>
public Func<Storage> GetStableStorage { private get; set; } public Func<Storage> GetStableStorage { private get; set; }
private void refreshImportContext() public BeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, APIAccess api, IIpcHost importHost = null)
{ {
lock (importContextLock) this.contextFactory = contextFactory;
{
importContext?.Value?.Dispose();
importContext = new Lazy<OsuDbContext>(() => beatmaps = new BeatmapStore(contextFactory);
{
var c = createContext();
c.Database.AutoTransactionsEnabled = false;
return c;
});
}
}
public BeatmapManager(Storage storage, Func<OsuDbContext> context, RulesetStore rulesets, APIAccess api, IIpcHost importHost = null) beatmaps.BeatmapSetAdded += s => BeatmapSetAdded?.Invoke(s);
{ beatmaps.BeatmapSetRemoved += s => BeatmapSetRemoved?.Invoke(s);
createContext = context; beatmaps.BeatmapHidden += b => BeatmapHidden?.Invoke(b);
beatmaps.BeatmapRestored += b => BeatmapRestored?.Invoke(b);
refreshImportContext(); files = new FileStore(contextFactory, storage);
beatmaps = createBeatmapStore(context);
files = new FileStore(context, storage);
this.storage = files.Storage;
this.rulesets = rulesets; this.rulesets = rulesets;
this.api = api; this.api = api;
@ -138,10 +109,10 @@ namespace osu.Game.Beatmaps
/// <summary> /// <summary>
/// Import one or more <see cref="BeatmapSetInfo"/> from filesystem <paramref name="paths"/>. /// Import one or more <see cref="BeatmapSetInfo"/> from filesystem <paramref name="paths"/>.
/// This will post a notification tracking import progress. /// This will post notifications tracking progress.
/// </summary> /// </summary>
/// <param name="paths">One or more beatmap locations on disk.</param> /// <param name="paths">One or more beatmap locations on disk.</param>
public void Import(params string[] paths) public List<BeatmapSetInfo> Import(params string[] paths)
{ {
var notification = new ProgressNotification var notification = new ProgressNotification
{ {
@ -153,18 +124,20 @@ namespace osu.Game.Beatmaps
PostNotification?.Invoke(notification); PostNotification?.Invoke(notification);
List<BeatmapSetInfo> imported = new List<BeatmapSetInfo>();
int i = 0; int i = 0;
foreach (string path in paths) foreach (string path in paths)
{ {
if (notification.State == ProgressNotificationState.Cancelled) if (notification.State == ProgressNotificationState.Cancelled)
// user requested abort // user requested abort
return; return imported;
try try
{ {
notification.Text = $"Importing ({i} of {paths.Length})\n{Path.GetFileName(path)}"; notification.Text = $"Importing ({i} of {paths.Length})\n{Path.GetFileName(path)}";
using (ArchiveReader reader = getReaderFrom(path)) using (ArchiveReader reader = getReaderFrom(path))
Import(reader); imported.Add(Import(reader));
notification.Progress = (float)++i / paths.Length; notification.Progress = (float)++i / paths.Length;
@ -186,62 +159,66 @@ namespace osu.Game.Beatmaps
{ {
e = e.InnerException ?? e; e = e.InnerException ?? e;
Logger.Error(e, $@"Could not import beatmap set ({Path.GetFileName(path)})"); Logger.Error(e, $@"Could not import beatmap set ({Path.GetFileName(path)})");
refreshImportContext();
} }
} }
notification.State = ProgressNotificationState.Completed; notification.State = ProgressNotificationState.Completed;
return imported;
} }
private readonly object importContextLock = new object();
private Lazy<OsuDbContext> importContext;
/// <summary> /// <summary>
/// Import a beatmap from an <see cref="ArchiveReader"/>. /// Import a beatmap from an <see cref="ArchiveReader"/>.
/// </summary> /// </summary>
/// <param name="archiveReader">The beatmap to be imported.</param> /// <param name="archive">The beatmap to be imported.</param>
public BeatmapSetInfo Import(ArchiveReader archiveReader) public BeatmapSetInfo Import(ArchiveReader archive)
{ {
// let's only allow one concurrent import at a time for now. using (contextFactory.GetForWrite()) // used to share a context for full import. keep in mind this will block all writes.
lock (importContextLock)
{ {
var context = importContext.Value; // create a new set info (don't yet add to database)
var beatmapSet = createBeatmapSetInfo(archive);
using (var transaction = context.BeginTransaction()) // check if this beatmap has already been imported and exit early if so
var existingHashMatch = beatmaps.BeatmapSets.FirstOrDefault(b => b.Hash == beatmapSet.Hash);
if (existingHashMatch != null)
{ {
// create local stores so we can isolate and thread safely, and share a context/transaction. Undelete(existingHashMatch);
var iFiles = new FileStore(() => context, storage); return existingHashMatch;
var iBeatmaps = createBeatmapStore(() => context);
BeatmapSetInfo set = importToStorage(iFiles, iBeatmaps, archiveReader);
if (set.ID == 0)
{
iBeatmaps.Add(set);
context.SaveChanges();
} }
context.SaveChanges(transaction); // check if a set already exists with the same online id
return set; if (beatmapSet.OnlineBeatmapSetID != null)
{
var existingOnlineId = beatmaps.BeatmapSets.FirstOrDefault(b => b.OnlineBeatmapSetID == beatmapSet.OnlineBeatmapSetID);
if (existingOnlineId != null)
{
Delete(existingOnlineId);
beatmaps.Cleanup(s => s.ID == existingOnlineId.ID);
} }
} }
beatmapSet.Files = createFileInfos(archive, files);
beatmapSet.Beatmaps = createBeatmapDifficulties(archive);
// remove metadata from difficulties where it matches the set
foreach (BeatmapInfo b in beatmapSet.Beatmaps)
if (beatmapSet.Metadata.Equals(b.Metadata))
b.Metadata = null;
// import to beatmap store
Import(beatmapSet);
return beatmapSet;
}
} }
/// <summary> /// <summary>
/// Import a beatmap from a <see cref="BeatmapSetInfo"/>. /// Import a beatmap from a <see cref="BeatmapSetInfo"/>.
/// </summary> /// </summary>
/// <param name="beatmapSetInfo">The beatmap to be imported.</param> /// <param name="beatmapSet">The beatmap to be imported.</param>
public void Import(BeatmapSetInfo beatmapSetInfo) public void Import(BeatmapSetInfo beatmapSet) => beatmaps.Add(beatmapSet);
{
// If we have an ID then we already exist in the database.
if (beatmapSetInfo.ID != 0) return;
createBeatmapStore(createContext).Add(beatmapSetInfo);
}
/// <summary> /// <summary>
/// Downloads a beatmap. /// Downloads a beatmap.
/// This will post notifications tracking progress.
/// </summary> /// </summary>
/// <param name="beatmapSetInfo">The <see cref="BeatmapSetInfo"/> to be downloaded.</param> /// <param name="beatmapSetInfo">The <see cref="BeatmapSetInfo"/> to be downloaded.</param>
/// <param name="noVideo">Whether the beatmap should be downloaded without video. Defaults to false.</param> /// <param name="noVideo">Whether the beatmap should be downloaded without video. Defaults to false.</param>
@ -323,6 +300,12 @@ namespace osu.Game.Beatmaps
/// <returns>The <see cref="DownloadBeatmapSetRequest"/> object if it exists, or null.</returns> /// <returns>The <see cref="DownloadBeatmapSetRequest"/> object if it exists, or null.</returns>
public DownloadBeatmapSetRequest GetExistingDownload(BeatmapSetInfo beatmap) => currentDownloads.Find(d => d.BeatmapSet.OnlineBeatmapSetID == beatmap.OnlineBeatmapSetID); public DownloadBeatmapSetRequest GetExistingDownload(BeatmapSetInfo beatmap) => currentDownloads.Find(d => d.BeatmapSet.OnlineBeatmapSetID == beatmap.OnlineBeatmapSetID);
/// <summary>
/// Update a BeatmapSetInfo with all changes. TODO: This only supports very basic updates currently.
/// </summary>
/// <param name="beatmapSet">The beatmap set to update.</param>
public void Update(BeatmapSetInfo beatmap) => beatmaps.Update(beatmap);
/// <summary> /// <summary>
/// Delete a beatmap from the manager. /// Delete a beatmap from the manager.
/// Is a no-op for already deleted beatmaps. /// Is a no-op for already deleted beatmaps.
@ -330,33 +313,29 @@ namespace osu.Game.Beatmaps
/// <param name="beatmapSet">The beatmap set to delete.</param> /// <param name="beatmapSet">The beatmap set to delete.</param>
public void Delete(BeatmapSetInfo beatmapSet) public void Delete(BeatmapSetInfo beatmapSet)
{ {
lock (importContextLock) using (var usage = contextFactory.GetForWrite())
{ {
var context = importContext.Value; var context = usage.Context;
using (var transaction = context.BeginTransaction())
{
context.ChangeTracker.AutoDetectChangesEnabled = false; context.ChangeTracker.AutoDetectChangesEnabled = false;
// re-fetch the beatmap set on the import context. // re-fetch the beatmap set on the import context.
beatmapSet = context.BeatmapSetInfo.Include(s => s.Files).ThenInclude(f => f.FileInfo).First(s => s.ID == beatmapSet.ID); beatmapSet = context.BeatmapSetInfo.Include(s => s.Files).ThenInclude(f => f.FileInfo).First(s => s.ID == beatmapSet.ID);
// create local stores so we can isolate and thread safely, and share a context/transaction. if (beatmaps.Delete(beatmapSet))
var iFiles = new FileStore(() => context, storage);
var iBeatmaps = createBeatmapStore(() => context);
if (iBeatmaps.Delete(beatmapSet))
{ {
if (!beatmapSet.Protected) if (!beatmapSet.Protected)
iFiles.Dereference(beatmapSet.Files.Select(f => f.FileInfo).ToArray()); files.Dereference(beatmapSet.Files.Select(f => f.FileInfo).ToArray());
} }
context.ChangeTracker.AutoDetectChangesEnabled = true; context.ChangeTracker.AutoDetectChangesEnabled = true;
context.SaveChanges(transaction);
}
} }
} }
/// <summary>
/// Restore all beatmaps that were previously deleted.
/// This will post notifications tracking progress.
/// </summary>
public void UndeleteAll() public void UndeleteAll()
{ {
var deleteMaps = QueryBeatmapSets(bs => bs.DeletePending).ToList(); var deleteMaps = QueryBeatmapSets(bs => bs.DeletePending).ToList();
@ -388,27 +367,25 @@ namespace osu.Game.Beatmaps
notification.State = ProgressNotificationState.Completed; notification.State = ProgressNotificationState.Completed;
} }
/// <summary>
/// Restore a beatmap that was previously deleted. Is a no-op if the beatmap is not in a deleted state, or has its protected flag set.
/// </summary>
/// <param name="beatmapSet">The beatmap to restore</param>
public void Undelete(BeatmapSetInfo beatmapSet) public void Undelete(BeatmapSetInfo beatmapSet)
{ {
if (beatmapSet.Protected) if (beatmapSet.Protected)
return; return;
lock (importContextLock) using (var usage = contextFactory.GetForWrite())
{ {
var context = importContext.Value; usage.Context.ChangeTracker.AutoDetectChangesEnabled = false;
using (var transaction = context.BeginTransaction()) if (!beatmaps.Undelete(beatmapSet)) return;
{
context.ChangeTracker.AutoDetectChangesEnabled = false;
var iFiles = new FileStore(() => context, storage); if (!beatmapSet.Protected)
var iBeatmaps = createBeatmapStore(() => context); files.Reference(beatmapSet.Files.Select(f => f.FileInfo).ToArray());
undelete(iBeatmaps, iFiles, beatmapSet); usage.Context.ChangeTracker.AutoDetectChangesEnabled = true;
context.ChangeTracker.AutoDetectChangesEnabled = true;
context.SaveChanges(transaction);
}
} }
} }
@ -424,21 +401,6 @@ namespace osu.Game.Beatmaps
/// <param name="beatmap">The beatmap difficulty to restore.</param> /// <param name="beatmap">The beatmap difficulty to restore.</param>
public void Restore(BeatmapInfo beatmap) => beatmaps.Restore(beatmap); public void Restore(BeatmapInfo beatmap) => beatmaps.Restore(beatmap);
/// <summary>
/// Returns a <see cref="BeatmapSetInfo"/> to a usable state if it has previously been deleted but not yet purged.
/// Is a no-op for already usable beatmaps.
/// </summary>
/// <param name="beatmaps">The store to restore beatmaps from.</param>
/// <param name="files">The store to restore beatmap files from.</param>
/// <param name="beatmapSet">The beatmap to restore.</param>
private void undelete(BeatmapStore beatmaps, FileStore files, BeatmapSetInfo beatmapSet)
{
if (!beatmaps.Undelete(beatmapSet)) return;
if (!beatmapSet.Protected)
files.Reference(beatmapSet.Files.Select(f => f.FileInfo).ToArray());
}
/// <summary> /// <summary>
/// Retrieve a <see cref="WorkingBeatmap"/> instance for the provided <see cref="BeatmapInfo"/> /// Retrieve a <see cref="WorkingBeatmap"/> instance for the provided <see cref="BeatmapInfo"/>
/// </summary> /// </summary>
@ -474,6 +436,12 @@ namespace osu.Game.Beatmaps
/// <returns>A fresh instance.</returns> /// <returns>A fresh instance.</returns>
public BeatmapSetInfo Refresh(BeatmapSetInfo beatmapSet) => QueryBeatmapSet(s => s.ID == beatmapSet.ID); public BeatmapSetInfo Refresh(BeatmapSetInfo beatmapSet) => QueryBeatmapSet(s => s.ID == beatmapSet.ID);
/// <summary>
/// Returns a list of all usable <see cref="BeatmapSetInfo"/>s.
/// </summary>
/// <returns>A list of available <see cref="BeatmapSetInfo"/>.</returns>
public List<BeatmapSetInfo> GetAllUsableBeatmapSets() => beatmaps.BeatmapSets.Where(s => !s.DeletePending).ToList();
/// <summary> /// <summary>
/// Perform a lookup query on available <see cref="BeatmapSetInfo"/>s. /// Perform a lookup query on available <see cref="BeatmapSetInfo"/>s.
/// </summary> /// </summary>
@ -496,220 +464,8 @@ namespace osu.Game.Beatmaps
public IEnumerable<BeatmapInfo> QueryBeatmaps(Expression<Func<BeatmapInfo, bool>> query) => beatmaps.Beatmaps.AsNoTracking().Where(query); public IEnumerable<BeatmapInfo> QueryBeatmaps(Expression<Func<BeatmapInfo, bool>> query) => beatmaps.Beatmaps.AsNoTracking().Where(query);
/// <summary> /// <summary>
/// Creates an <see cref="ArchiveReader"/> from a valid storage path. /// Denotes whether an osu-stable installation is present to perform automated imports from.
/// </summary> /// </summary>
/// <param name="path">A file or folder path resolving the beatmap content.</param>
/// <returns>A reader giving access to the beatmap's content.</returns>
private ArchiveReader getReaderFrom(string path)
{
if (ZipFile.IsZipFile(path))
// ReSharper disable once InconsistentlySynchronizedField
return new OszArchiveReader(storage.GetStream(path));
return new LegacyFilesystemReader(path);
}
/// <summary>
/// Import a beamap into our local <see cref="FileStore"/> storage.
/// If the beatmap is already imported, the existing instance will be returned.
/// </summary>
/// <param name="files">The store to import beatmap files to.</param>
/// <param name="beatmaps">The store to import beatmaps to.</param>
/// <param name="reader">The beatmap archive to be read.</param>
/// <returns>The imported beatmap, or an existing instance if it is already present.</returns>
private BeatmapSetInfo importToStorage(FileStore files, BeatmapStore beatmaps, ArchiveReader reader)
{
// let's make sure there are actually .osu files to import.
string mapName = reader.Filenames.FirstOrDefault(f => f.EndsWith(".osu"));
if (string.IsNullOrEmpty(mapName))
throw new InvalidOperationException("No beatmap files found in the map folder.");
// for now, concatenate all .osu files in the set to create a unique hash.
MemoryStream hashable = new MemoryStream();
foreach (string file in reader.Filenames.Where(f => f.EndsWith(".osu")))
using (Stream s = reader.GetStream(file))
s.CopyTo(hashable);
var hash = hashable.ComputeSHA2Hash();
// check if this beatmap has already been imported and exit early if so.
var beatmapSet = beatmaps.BeatmapSets.FirstOrDefault(b => b.Hash == hash);
if (beatmapSet != null)
{
undelete(beatmaps, files, beatmapSet);
// ensure all files are present and accessible
foreach (var f in beatmapSet.Files)
{
if (!storage.Exists(f.FileInfo.StoragePath))
using (Stream s = reader.GetStream(f.Filename))
files.Add(s, false);
}
// todo: delete any files which shouldn't exist any more.
return beatmapSet;
}
List<BeatmapSetFileInfo> fileInfos = new List<BeatmapSetFileInfo>();
// import files to manager
foreach (string file in reader.Filenames)
using (Stream s = reader.GetStream(file))
fileInfos.Add(new BeatmapSetFileInfo
{
Filename = file,
FileInfo = files.Add(s)
});
BeatmapMetadata metadata;
using (var stream = new StreamReader(reader.GetStream(mapName)))
metadata = Decoder.GetDecoder(stream).DecodeBeatmap(stream).Metadata;
// check if a set already exists with the same online id.
if (metadata.OnlineBeatmapSetID != null)
beatmapSet = beatmaps.BeatmapSets.FirstOrDefault(b => b.OnlineBeatmapSetID == metadata.OnlineBeatmapSetID);
if (beatmapSet == null)
beatmapSet = new BeatmapSetInfo
{
OnlineBeatmapSetID = metadata.OnlineBeatmapSetID,
Beatmaps = new List<BeatmapInfo>(),
Hash = hash,
Files = fileInfos,
Metadata = metadata
};
var mapNames = reader.Filenames.Where(f => f.EndsWith(".osu"));
foreach (var name in mapNames)
{
using (var raw = reader.GetStream(name))
using (var ms = new MemoryStream()) //we need a memory stream so we can seek and shit
using (var sr = new StreamReader(ms))
{
raw.CopyTo(ms);
ms.Position = 0;
var decoder = Decoder.GetDecoder(sr);
Beatmap beatmap = decoder.DecodeBeatmap(sr);
beatmap.BeatmapInfo.Path = name;
beatmap.BeatmapInfo.Hash = ms.ComputeSHA2Hash();
beatmap.BeatmapInfo.MD5Hash = ms.ComputeMD5Hash();
var existing = beatmaps.Beatmaps.FirstOrDefault(b => b.Hash == beatmap.BeatmapInfo.Hash || beatmap.BeatmapInfo.OnlineBeatmapID != null && b.OnlineBeatmapID == beatmap.BeatmapInfo.OnlineBeatmapID);
if (existing == null)
{
// Exclude beatmap-metadata if it's equal to beatmapset-metadata
if (metadata.Equals(beatmap.Metadata))
beatmap.BeatmapInfo.Metadata = null;
RulesetInfo ruleset = rulesets.GetRuleset(beatmap.BeatmapInfo.RulesetID);
// TODO: this should be done in a better place once we actually need to dynamically update it.
beatmap.BeatmapInfo.Ruleset = ruleset;
beatmap.BeatmapInfo.StarDifficulty = ruleset?.CreateInstance()?.CreateDifficultyCalculator(beatmap).Calculate() ?? 0;
beatmapSet.Beatmaps.Add(beatmap.BeatmapInfo);
}
}
}
return beatmapSet;
}
/// <summary>
/// Returns a list of all usable <see cref="BeatmapSetInfo"/>s.
/// </summary>
/// <returns>A list of available <see cref="BeatmapSetInfo"/>.</returns>
public List<BeatmapSetInfo> GetAllUsableBeatmapSets()
{
return beatmaps.BeatmapSets.Where(s => !s.DeletePending).ToList();
}
protected class BeatmapManagerWorkingBeatmap : WorkingBeatmap
{
private readonly IResourceStore<byte[]> store;
public BeatmapManagerWorkingBeatmap(IResourceStore<byte[]> store, BeatmapInfo beatmapInfo)
: base(beatmapInfo)
{
this.store = store;
}
protected override Beatmap GetBeatmap()
{
try
{
using (var stream = new StreamReader(store.GetStream(getPathForFile(BeatmapInfo.Path))))
{
Decoder decoder = Decoder.GetDecoder(stream);
return decoder.DecodeBeatmap(stream);
}
}
catch
{
return null;
}
}
private string getPathForFile(string filename) => BeatmapSetInfo.Files.First(f => string.Equals(f.Filename, filename, StringComparison.InvariantCultureIgnoreCase)).FileInfo.StoragePath;
protected override Texture GetBackground()
{
if (Metadata?.BackgroundFile == null)
return null;
try
{
return new LargeTextureStore(new RawTextureLoaderStore(store)).Get(getPathForFile(Metadata.BackgroundFile));
}
catch
{
return null;
}
}
protected override Track GetTrack()
{
try
{
var trackData = store.GetStream(getPathForFile(Metadata.AudioFile));
return trackData == null ? null : new TrackBass(trackData);
}
catch
{
return new TrackVirtual();
}
}
protected override Waveform GetWaveform() => new Waveform(store.GetStream(getPathForFile(Metadata.AudioFile)));
protected override Storyboard GetStoryboard()
{
try
{
using (var beatmap = new StreamReader(store.GetStream(getPathForFile(BeatmapInfo.Path))))
{
Decoder decoder = Decoder.GetDecoder(beatmap);
if (BeatmapSetInfo?.StoryboardFile == null)
return decoder.GetStoryboardDecoder().DecodeStoryboard(beatmap);
using (var storyboard = new StreamReader(store.GetStream(getPathForFile(BeatmapSetInfo.StoryboardFile))))
return decoder.GetStoryboardDecoder().DecodeStoryboard(beatmap, storyboard);
}
}
catch
{
return new Storyboard();
}
}
}
public bool StableInstallationAvailable => GetStableStorage?.Invoke() != null; public bool StableInstallationAvailable => GetStableStorage?.Invoke() != null;
/// <summary> /// <summary>
@ -728,6 +484,10 @@ namespace osu.Game.Beatmaps
await Task.Factory.StartNew(() => Import(stable.GetDirectories("Songs")), TaskCreationOptions.LongRunning); await Task.Factory.StartNew(() => Import(stable.GetDirectories("Songs")), TaskCreationOptions.LongRunning);
} }
/// <summary>
/// Delete all beatmaps.
/// This will post notifications tracking progress.
/// </summary>
public void DeleteAll() public void DeleteAll()
{ {
var maps = GetAllUsableBeatmapSets(); var maps = GetAllUsableBeatmapSets();
@ -758,5 +518,109 @@ namespace osu.Game.Beatmaps
notification.State = ProgressNotificationState.Completed; notification.State = ProgressNotificationState.Completed;
} }
/// <summary>
/// Creates an <see cref="ArchiveReader"/> from a valid storage path.
/// </summary>
/// <param name="path">A file or folder path resolving the beatmap content.</param>
/// <returns>A reader giving access to the beatmap's content.</returns>
private ArchiveReader getReaderFrom(string path)
{
if (ZipFile.IsZipFile(path))
// ReSharper disable once InconsistentlySynchronizedField
return new OszArchiveReader(files.Storage.GetStream(path));
return new LegacyFilesystemReader(path);
}
/// <summary>
/// Create a SHA-2 hash from the provided archive based on contained beatmap (.osu) file content.
/// </summary>
private string computeBeatmapSetHash(ArchiveReader reader)
{
// for now, concatenate all .osu files in the set to create a unique hash.
MemoryStream hashable = new MemoryStream();
foreach (string file in reader.Filenames.Where(f => f.EndsWith(".osu")))
using (Stream s = reader.GetStream(file))
s.CopyTo(hashable);
return hashable.ComputeSHA2Hash();
}
/// <summary>
/// Create a <see cref="BeatmapSetInfo"/> from a provided archive.
/// </summary>
private BeatmapSetInfo createBeatmapSetInfo(ArchiveReader reader)
{
// let's make sure there are actually .osu files to import.
string mapName = reader.Filenames.FirstOrDefault(f => f.EndsWith(".osu"));
if (string.IsNullOrEmpty(mapName)) throw new InvalidOperationException("No beatmap files found in the map folder.");
BeatmapMetadata metadata;
using (var stream = new StreamReader(reader.GetStream(mapName)))
metadata = Decoder.GetDecoder(stream).DecodeBeatmap(stream).Metadata;
return new BeatmapSetInfo
{
OnlineBeatmapSetID = metadata.OnlineBeatmapSetID,
Beatmaps = new List<BeatmapInfo>(),
Hash = computeBeatmapSetHash(reader),
Metadata = metadata
};
}
/// <summary>
/// Create all required <see cref="FileInfo"/>s for the provided archive, adding them to the global file store.
/// </summary>
private List<BeatmapSetFileInfo> createFileInfos(ArchiveReader reader, FileStore files)
{
List<BeatmapSetFileInfo> fileInfos = new List<BeatmapSetFileInfo>();
// import files to manager
foreach (string file in reader.Filenames)
using (Stream s = reader.GetStream(file))
fileInfos.Add(new BeatmapSetFileInfo
{
Filename = file,
FileInfo = files.Add(s)
});
return fileInfos;
}
/// <summary>
/// Create all required <see cref="BeatmapInfo"/>s for the provided archive.
/// </summary>
private List<BeatmapInfo> createBeatmapDifficulties(ArchiveReader reader)
{
var beatmapInfos = new List<BeatmapInfo>();
foreach (var name in reader.Filenames.Where(f => f.EndsWith(".osu")))
{
using (var raw = reader.GetStream(name))
using (var ms = new MemoryStream()) //we need a memory stream so we can seek and shit
using (var sr = new StreamReader(ms))
{
raw.CopyTo(ms);
ms.Position = 0;
var decoder = Decoder.GetDecoder(sr);
Beatmap beatmap = decoder.DecodeBeatmap(sr);
beatmap.BeatmapInfo.Path = name;
beatmap.BeatmapInfo.Hash = ms.ComputeSHA2Hash();
beatmap.BeatmapInfo.MD5Hash = ms.ComputeMD5Hash();
RulesetInfo ruleset = rulesets.GetRuleset(beatmap.BeatmapInfo.RulesetID);
// TODO: this should be done in a better place once we actually need to dynamically update it.
beatmap.BeatmapInfo.Ruleset = ruleset;
beatmap.BeatmapInfo.StarDifficulty = ruleset?.CreateInstance()?.CreateDifficultyCalculator(beatmap).Calculate() ?? 0;
beatmapInfos.Add(beatmap.BeatmapInfo);
}
}
return beatmapInfos;
}
} }
} }

View File

@ -0,0 +1,98 @@
// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
using System;
using System.IO;
using System.Linq;
using osu.Framework.Audio.Track;
using osu.Framework.Graphics.Textures;
using osu.Framework.IO.Stores;
using osu.Game.Beatmaps.Formats;
using osu.Game.Graphics.Textures;
using osu.Game.Storyboards;
namespace osu.Game.Beatmaps
{
public partial class BeatmapManager
{
protected class BeatmapManagerWorkingBeatmap : WorkingBeatmap
{
private readonly IResourceStore<byte[]> store;
public BeatmapManagerWorkingBeatmap(IResourceStore<byte[]> store, BeatmapInfo beatmapInfo)
: base(beatmapInfo)
{
this.store = store;
}
protected override Beatmap GetBeatmap()
{
try
{
using (var stream = new StreamReader(store.GetStream(getPathForFile(BeatmapInfo.Path))))
{
Decoder decoder = Decoder.GetDecoder(stream);
return decoder.DecodeBeatmap(stream);
}
}
catch
{
return null;
}
}
private string getPathForFile(string filename) => BeatmapSetInfo.Files.First(f => string.Equals(f.Filename, filename, StringComparison.InvariantCultureIgnoreCase)).FileInfo.StoragePath;
protected override Texture GetBackground()
{
if (Metadata?.BackgroundFile == null)
return null;
try
{
return new LargeTextureStore(new RawTextureLoaderStore(store)).Get(getPathForFile(Metadata.BackgroundFile));
}
catch
{
return null;
}
}
protected override Track GetTrack()
{
try
{
var trackData = store.GetStream(getPathForFile(Metadata.AudioFile));
return trackData == null ? null : new TrackBass(trackData);
}
catch
{
return new TrackVirtual();
}
}
protected override Waveform GetWaveform() => new Waveform(store.GetStream(getPathForFile(Metadata.AudioFile)));
protected override Storyboard GetStoryboard()
{
try
{
using (var beatmap = new StreamReader(store.GetStream(getPathForFile(BeatmapInfo.Path))))
{
Decoder decoder = Decoder.GetDecoder(beatmap);
if (BeatmapSetInfo?.StoryboardFile == null)
return decoder.GetStoryboardDecoder().DecodeStoryboard(beatmap);
using (var storyboard = new StreamReader(store.GetStream(getPathForFile(BeatmapSetInfo.StoryboardFile))))
return decoder.GetStoryboardDecoder().DecodeStoryboard(beatmap, storyboard);
}
}
catch
{
return new Storyboard();
}
}
}
}
}

View File

@ -3,6 +3,7 @@
using System; using System;
using System.Linq; using System.Linq;
using System.Linq.Expressions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using osu.Game.Database; using osu.Game.Database;
@ -19,7 +20,7 @@ namespace osu.Game.Beatmaps
public event Action<BeatmapInfo> BeatmapHidden; public event Action<BeatmapInfo> BeatmapHidden;
public event Action<BeatmapInfo> BeatmapRestored; public event Action<BeatmapInfo> BeatmapRestored;
public BeatmapStore(Func<OsuDbContext> factory) public BeatmapStore(IDatabaseContextFactory factory)
: base(factory) : base(factory)
{ {
} }
@ -30,7 +31,9 @@ namespace osu.Game.Beatmaps
/// <param name="beatmapSet">The beatmap to add.</param> /// <param name="beatmapSet">The beatmap to add.</param>
public void Add(BeatmapSetInfo beatmapSet) public void Add(BeatmapSetInfo beatmapSet)
{ {
var context = GetContext(); using (var usage = ContextFactory.GetForWrite())
{
var context = usage.Context;
foreach (var beatmap in beatmapSet.Beatmaps.Where(b => b.Metadata != null)) foreach (var beatmap in beatmapSet.Beatmaps.Where(b => b.Metadata != null))
{ {
@ -45,7 +48,21 @@ namespace osu.Game.Beatmaps
} }
context.BeatmapSetInfo.Attach(beatmapSet); context.BeatmapSetInfo.Attach(beatmapSet);
context.SaveChanges();
BeatmapSetAdded?.Invoke(beatmapSet);
}
}
/// <summary>
/// Update a <see cref="BeatmapSetInfo"/> in the database. TODO: This only supports very basic updates currently.
/// </summary>
/// <param name="beatmapSet">The beatmap to update.</param>
public void Update(BeatmapSetInfo beatmapSet)
{
BeatmapSetRemoved?.Invoke(beatmapSet);
using (var usage = ContextFactory.GetForWrite())
usage.Context.BeatmapSetInfo.Update(beatmapSet);
BeatmapSetAdded?.Invoke(beatmapSet); BeatmapSetAdded?.Invoke(beatmapSet);
} }
@ -57,13 +74,14 @@ namespace osu.Game.Beatmaps
/// <returns>Whether the beatmap's <see cref="BeatmapSetInfo.DeletePending"/> was changed.</returns> /// <returns>Whether the beatmap's <see cref="BeatmapSetInfo.DeletePending"/> was changed.</returns>
public bool Delete(BeatmapSetInfo beatmapSet) public bool Delete(BeatmapSetInfo beatmapSet)
{ {
var context = GetContext(); using (ContextFactory.GetForWrite())
{
Refresh(ref beatmapSet, BeatmapSets); Refresh(ref beatmapSet, BeatmapSets);
if (beatmapSet.DeletePending) return false; if (beatmapSet.DeletePending) return false;
beatmapSet.DeletePending = true; beatmapSet.DeletePending = true;
context.SaveChanges(); }
BeatmapSetRemoved?.Invoke(beatmapSet); BeatmapSetRemoved?.Invoke(beatmapSet);
return true; return true;
@ -76,13 +94,14 @@ namespace osu.Game.Beatmaps
/// <returns>Whether the beatmap's <see cref="BeatmapSetInfo.DeletePending"/> was changed.</returns> /// <returns>Whether the beatmap's <see cref="BeatmapSetInfo.DeletePending"/> was changed.</returns>
public bool Undelete(BeatmapSetInfo beatmapSet) public bool Undelete(BeatmapSetInfo beatmapSet)
{ {
var context = GetContext(); using (ContextFactory.GetForWrite())
{
Refresh(ref beatmapSet, BeatmapSets); Refresh(ref beatmapSet, BeatmapSets);
if (!beatmapSet.DeletePending) return false; if (!beatmapSet.DeletePending) return false;
beatmapSet.DeletePending = false; beatmapSet.DeletePending = false;
context.SaveChanges(); }
BeatmapSetAdded?.Invoke(beatmapSet); BeatmapSetAdded?.Invoke(beatmapSet);
return true; return true;
@ -95,15 +114,17 @@ namespace osu.Game.Beatmaps
/// <returns>Whether the beatmap's <see cref="BeatmapInfo.Hidden"/> was changed.</returns> /// <returns>Whether the beatmap's <see cref="BeatmapInfo.Hidden"/> was changed.</returns>
public bool Hide(BeatmapInfo beatmap) public bool Hide(BeatmapInfo beatmap)
{ {
var context = GetContext(); using (ContextFactory.GetForWrite())
{
Refresh(ref beatmap, Beatmaps); Refresh(ref beatmap, Beatmaps);
if (beatmap.Hidden) return false; if (beatmap.Hidden) return false;
beatmap.Hidden = true; beatmap.Hidden = true;
context.SaveChanges();
BeatmapHidden?.Invoke(beatmap); BeatmapHidden?.Invoke(beatmap);
}
return true; return true;
} }
@ -114,26 +135,34 @@ namespace osu.Game.Beatmaps
/// <returns>Whether the beatmap's <see cref="BeatmapInfo.Hidden"/> was changed.</returns> /// <returns>Whether the beatmap's <see cref="BeatmapInfo.Hidden"/> was changed.</returns>
public bool Restore(BeatmapInfo beatmap) public bool Restore(BeatmapInfo beatmap)
{ {
var context = GetContext(); using (ContextFactory.GetForWrite())
{
Refresh(ref beatmap, Beatmaps); Refresh(ref beatmap, Beatmaps);
if (!beatmap.Hidden) return false; if (!beatmap.Hidden) return false;
beatmap.Hidden = false; beatmap.Hidden = false;
context.SaveChanges(); }
BeatmapRestored?.Invoke(beatmap); BeatmapRestored?.Invoke(beatmap);
return true; return true;
} }
public override void Cleanup() public override void Cleanup() => Cleanup(_ => true);
public void Cleanup(Expression<Func<BeatmapSetInfo, bool>> query)
{ {
var context = GetContext(); using (var usage = ContextFactory.GetForWrite())
{
var context = usage.Context;
var purgeable = context.BeatmapSetInfo.Where(s => s.DeletePending && !s.Protected) var purgeable = context.BeatmapSetInfo.Where(s => s.DeletePending && !s.Protected)
.Where(query)
.Include(s => s.Beatmaps).ThenInclude(b => b.Metadata) .Include(s => s.Beatmaps).ThenInclude(b => b.Metadata)
.Include(s => s.Beatmaps).ThenInclude(b => b.BaseDifficulty) .Include(s => s.Beatmaps).ThenInclude(b => b.BaseDifficulty)
.Include(s => s.Metadata); .Include(s => s.Metadata).ToList();
if (!purgeable.Any()) return;
// metadata is M-N so we can't rely on cascades // metadata is M-N so we can't rely on cascades
context.BeatmapMetadata.RemoveRange(purgeable.Select(s => s.Metadata)); context.BeatmapMetadata.RemoveRange(purgeable.Select(s => s.Metadata));
@ -144,17 +173,17 @@ namespace osu.Game.Beatmaps
// cascades down to beatmaps. // cascades down to beatmaps.
context.BeatmapSetInfo.RemoveRange(purgeable); context.BeatmapSetInfo.RemoveRange(purgeable);
context.SaveChanges(); }
} }
public IQueryable<BeatmapSetInfo> BeatmapSets => GetContext().BeatmapSetInfo public IQueryable<BeatmapSetInfo> BeatmapSets => ContextFactory.Get().BeatmapSetInfo
.Include(s => s.Metadata) .Include(s => s.Metadata)
.Include(s => s.Beatmaps).ThenInclude(s => s.Ruleset) .Include(s => s.Beatmaps).ThenInclude(s => s.Ruleset)
.Include(s => s.Beatmaps).ThenInclude(b => b.BaseDifficulty) .Include(s => s.Beatmaps).ThenInclude(b => b.BaseDifficulty)
.Include(s => s.Beatmaps).ThenInclude(b => b.Metadata) .Include(s => s.Beatmaps).ThenInclude(b => b.Metadata)
.Include(s => s.Files).ThenInclude(f => f.FileInfo); .Include(s => s.Files).ThenInclude(f => f.FileInfo);
public IQueryable<BeatmapInfo> Beatmaps => GetContext().BeatmapInfo public IQueryable<BeatmapInfo> Beatmaps => ContextFactory.Get().BeatmapInfo
.Include(b => b.BeatmapSet).ThenInclude(s => s.Metadata) .Include(b => b.BeatmapSet).ThenInclude(s => s.Metadata)
.Include(b => b.BeatmapSet).ThenInclude(s => s.Files).ThenInclude(f => f.FileInfo) .Include(b => b.BeatmapSet).ThenInclude(s => s.Files).ThenInclude(f => f.FileInfo)
.Include(b => b.Metadata) .Include(b => b.Metadata)

View File

@ -12,8 +12,8 @@ namespace osu.Game.Configuration
{ {
public event Action SettingChanged; public event Action SettingChanged;
public SettingsStore(Func<OsuDbContext> createContext) public SettingsStore(DatabaseContextFactory contextFactory)
: base(createContext) : base(contextFactory)
{ {
} }
@ -24,19 +24,16 @@ namespace osu.Game.Configuration
/// <param name="variant">An optional variant.</param> /// <param name="variant">An optional variant.</param>
/// <returns></returns> /// <returns></returns>
public List<DatabasedSetting> Query(int? rulesetId = null, int? variant = null) => public List<DatabasedSetting> Query(int? rulesetId = null, int? variant = null) =>
GetContext().DatabasedSetting.Where(b => b.RulesetID == rulesetId && b.Variant == variant).ToList(); ContextFactory.Get().DatabasedSetting.Where(b => b.RulesetID == rulesetId && b.Variant == variant).ToList();
public void Update(DatabasedSetting setting) public void Update(DatabasedSetting setting)
{ {
var context = GetContext(); using (ContextFactory.GetForWrite())
{
var newValue = setting.Value; var newValue = setting.Value;
Refresh(ref setting); Refresh(ref setting);
setting.Value = newValue; setting.Value = newValue;
}
context.SaveChanges();
SettingChanged?.Invoke(); SettingChanged?.Invoke();
} }

View File

@ -1,10 +1,8 @@
// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>. // Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using osu.Framework.Platform; using osu.Framework.Platform;
@ -17,9 +15,7 @@ namespace osu.Game.Database
/// <summary> /// <summary>
/// Create a new <see cref="OsuDbContext"/> instance (separate from the shared context via <see cref="GetContext"/> for performing isolated operations. /// Create a new <see cref="OsuDbContext"/> instance (separate from the shared context via <see cref="GetContext"/> for performing isolated operations.
/// </summary> /// </summary>
protected readonly Func<OsuDbContext> CreateContext; protected readonly IDatabaseContextFactory ContextFactory;
private readonly ThreadLocal<OsuDbContext> queryContext;
/// <summary> /// <summary>
/// Refresh an instance potentially from a different thread with a local context-tracked instance. /// Refresh an instance potentially from a different thread with a local context-tracked instance.
@ -29,33 +25,24 @@ namespace osu.Game.Database
/// <typeparam name="T">A valid EF-stored type.</typeparam> /// <typeparam name="T">A valid EF-stored type.</typeparam>
protected virtual void Refresh<T>(ref T obj, IEnumerable<T> lookupSource = null) where T : class, IHasPrimaryKey protected virtual void Refresh<T>(ref T obj, IEnumerable<T> lookupSource = null) where T : class, IHasPrimaryKey
{ {
var context = GetContext(); using (var usage = ContextFactory.GetForWrite())
{
var context = usage.Context;
if (context.Entry(obj).State != EntityState.Detached) return; if (context.Entry(obj).State != EntityState.Detached) return;
var id = obj.ID; var id = obj.ID;
var foundObject = lookupSource?.SingleOrDefault(t => t.ID == id) ?? context.Find<T>(id); var foundObject = lookupSource?.SingleOrDefault(t => t.ID == id) ?? context.Find<T>(id);
if (foundObject != null) if (foundObject != null)
{
obj = foundObject; obj = foundObject;
context.Entry(obj).Reload();
}
else else
context.Add(obj); context.Add(obj);
} }
}
/// <summary> protected DatabaseBackedStore(IDatabaseContextFactory contextFactory, Storage storage = null)
/// Retrieve a shared context for performing lookups (or write operations on the update thread, for now).
/// </summary>
protected OsuDbContext GetContext() => queryContext.Value;
protected DatabaseBackedStore(Func<OsuDbContext> createContext, Storage storage = null)
{ {
CreateContext = createContext; ContextFactory = contextFactory;
// todo: while this seems to work quite well, we need to consider that contexts could enter a state where they are never cleaned up.
queryContext = new ThreadLocal<OsuDbContext>(CreateContext);
Storage = storage; Storage = storage;
} }

View File

@ -1,27 +1,93 @@
// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>. // Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
using System.Threading;
using osu.Framework.Platform; using osu.Framework.Platform;
namespace osu.Game.Database namespace osu.Game.Database
{ {
public class DatabaseContextFactory public class DatabaseContextFactory : IDatabaseContextFactory
{ {
private readonly GameHost host; private readonly GameHost host;
private const string database_name = @"client"; private const string database_name = @"client";
private ThreadLocal<OsuDbContext> threadContexts;
private readonly object writeLock = new object();
private bool currentWriteDidWrite;
private volatile int currentWriteUsages;
public DatabaseContextFactory(GameHost host) public DatabaseContextFactory(GameHost host)
{ {
this.host = host; this.host = host;
recycleThreadContexts();
} }
public OsuDbContext GetContext() => new OsuDbContext(host.Storage.GetDatabaseConnectionString(database_name)); /// <summary>
/// Get a context for read-only usage.
/// </summary>
public OsuDbContext Get() => threadContexts.Value;
/// <summary>
/// Request a context for write usage. Can be consumed in a nested fashion (and will return the same underlying context).
/// This method may block if a write is already active on a different thread.
/// </summary>
/// <returns>A usage containing a usable context.</returns>
public DatabaseWriteUsage GetForWrite()
{
Monitor.Enter(writeLock);
Interlocked.Increment(ref currentWriteUsages);
return new DatabaseWriteUsage(threadContexts.Value, usageCompleted);
}
private void usageCompleted(DatabaseWriteUsage usage)
{
int usages = Interlocked.Decrement(ref currentWriteUsages);
try
{
currentWriteDidWrite |= usage.PerformedWrite;
if (usages > 0) return;
if (currentWriteDidWrite)
{
// explicitly dispose to ensure any outstanding flushes happen as soon as possible (and underlying resources are purged).
usage.Context.Dispose();
currentWriteDidWrite = false;
// once all writes are complete, we want to refresh thread-specific contexts to make sure they don't have stale local caches.
recycleThreadContexts();
}
}
finally
{
Monitor.Exit(writeLock);
}
}
private void recycleThreadContexts() => threadContexts = new ThreadLocal<OsuDbContext>(CreateContext);
protected virtual OsuDbContext CreateContext()
{
var ctx = new OsuDbContext(host.Storage.GetDatabaseConnectionString(database_name));
ctx.Database.AutoTransactionsEnabled = false;
return ctx;
}
public void ResetDatabase() public void ResetDatabase()
{ {
// todo: we probably want to make sure there are no active contexts before performing this operation. lock (writeLock)
{
recycleThreadContexts();
host.Storage.DeleteDatabase(database_name); host.Storage.DeleteDatabase(database_name);
} }
} }
}
} }

View File

@ -0,0 +1,46 @@
// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
using System;
using Microsoft.EntityFrameworkCore.Storage;
namespace osu.Game.Database
{
public class DatabaseWriteUsage : IDisposable
{
public readonly OsuDbContext Context;
private readonly IDbContextTransaction transaction;
private readonly Action<DatabaseWriteUsage> usageCompleted;
public DatabaseWriteUsage(OsuDbContext context, Action<DatabaseWriteUsage> onCompleted)
{
Context = context;
transaction = Context.BeginTransaction();
usageCompleted = onCompleted;
}
public bool PerformedWrite { get; private set; }
private bool isDisposed;
protected void Dispose(bool disposing)
{
if (isDisposed) return;
isDisposed = true;
PerformedWrite |= Context.SaveChanges(transaction) > 0;
usageCompleted?.Invoke(this);
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
~DatabaseWriteUsage()
{
Dispose(false);
}
}
}

View File

@ -0,0 +1,20 @@
// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
namespace osu.Game.Database
{
public interface IDatabaseContextFactory
{
/// <summary>
/// Get a context for read-only usage.
/// </summary>
OsuDbContext Get();
/// <summary>
/// Request a context for write usage. Can be consumed in a nested fashion (and will return the same underlying context).
/// This method may block if a write is already active on a different thread.
/// </summary>
/// <returns>A usage containing a usable context.</returns>
DatabaseWriteUsage GetForWrite();
}
}

View File

@ -111,7 +111,7 @@ namespace osu.Game.Database
public int SaveChanges(IDbContextTransaction transaction = null) public int SaveChanges(IDbContextTransaction transaction = null)
{ {
var ret = base.SaveChanges(); var ret = base.SaveChanges();
transaction?.Commit(); if (ret > 0) transaction?.Commit();
return ret; return ret;
} }
@ -186,8 +186,6 @@ namespace osu.Game.Database
public void Migrate() public void Migrate()
{ {
migrateFromSqliteNet();
try try
{ {
Database.Migrate(); Database.Migrate();
@ -197,86 +195,6 @@ namespace osu.Game.Database
throw new MigrationFailedException(e); throw new MigrationFailedException(e);
} }
} }
private void migrateFromSqliteNet()
{
try
{
// will fail if the database isn't in a sane EF-migrated state.
Database.ExecuteSqlCommand("SELECT MetadataID FROM BeatmapSetInfo LIMIT 1");
}
catch
{
try
{
Database.ExecuteSqlCommand("DROP TABLE IF EXISTS __EFMigrationsHistory");
// will fail (intentionally) if we don't have sqlite-net data present.
Database.ExecuteSqlCommand("SELECT OnlineBeatmapSetId FROM BeatmapMetadata LIMIT 1");
try
{
Logger.Log("Performing migration from sqlite-net to EF...", LoggingTarget.Database, Framework.Logging.LogLevel.Important);
// we are good to perform messy migration of data!.
Database.ExecuteSqlCommand("ALTER TABLE BeatmapDifficulty RENAME TO BeatmapDifficulty_Old");
Database.ExecuteSqlCommand("ALTER TABLE BeatmapMetadata RENAME TO BeatmapMetadata_Old");
Database.ExecuteSqlCommand("ALTER TABLE FileInfo RENAME TO FileInfo_Old");
Database.ExecuteSqlCommand("ALTER TABLE KeyBinding RENAME TO KeyBinding_Old");
Database.ExecuteSqlCommand("ALTER TABLE BeatmapSetInfo RENAME TO BeatmapSetInfo_Old");
Database.ExecuteSqlCommand("ALTER TABLE BeatmapInfo RENAME TO BeatmapInfo_Old");
Database.ExecuteSqlCommand("ALTER TABLE BeatmapSetFileInfo RENAME TO BeatmapSetFileInfo_Old");
Database.ExecuteSqlCommand("ALTER TABLE RulesetInfo RENAME TO RulesetInfo_Old");
Database.ExecuteSqlCommand("DROP TABLE StoreVersion");
// perform EF migrations to create sane table structure.
Database.Migrate();
// copy data table by table to new structure, dropping old tables as we go.
Database.ExecuteSqlCommand("INSERT INTO FileInfo SELECT * FROM FileInfo_Old");
Database.ExecuteSqlCommand("DROP TABLE FileInfo_Old");
Database.ExecuteSqlCommand("INSERT INTO KeyBinding SELECT ID, [Action], Keys, RulesetID, Variant FROM KeyBinding_Old");
Database.ExecuteSqlCommand("DROP TABLE KeyBinding_Old");
Database.ExecuteSqlCommand(
"INSERT INTO BeatmapMetadata SELECT ID, Artist, ArtistUnicode, AudioFile, Author, BackgroundFile, PreviewTime, Source, Tags, Title, TitleUnicode FROM BeatmapMetadata_Old");
Database.ExecuteSqlCommand("DROP TABLE BeatmapMetadata_Old");
Database.ExecuteSqlCommand(
"INSERT INTO BeatmapDifficulty SELECT `ID`, `ApproachRate`, `CircleSize`, `DrainRate`, `OverallDifficulty`, `SliderMultiplier`, `SliderTickRate` FROM BeatmapDifficulty_Old");
Database.ExecuteSqlCommand("DROP TABLE BeatmapDifficulty_Old");
Database.ExecuteSqlCommand("INSERT INTO BeatmapSetInfo SELECT ID, DeletePending, Hash, BeatmapMetadataID, OnlineBeatmapSetID, Protected FROM BeatmapSetInfo_Old");
Database.ExecuteSqlCommand("DROP TABLE BeatmapSetInfo_Old");
Database.ExecuteSqlCommand("INSERT INTO BeatmapSetFileInfo SELECT ID, BeatmapSetInfoID, FileInfoID, Filename FROM BeatmapSetFileInfo_Old");
Database.ExecuteSqlCommand("DROP TABLE BeatmapSetFileInfo_Old");
Database.ExecuteSqlCommand("INSERT INTO RulesetInfo SELECT ID, Available, InstantiationInfo, Name FROM RulesetInfo_Old");
Database.ExecuteSqlCommand("DROP TABLE RulesetInfo_Old");
Database.ExecuteSqlCommand(
"INSERT INTO BeatmapInfo SELECT ID, AudioLeadIn, BaseDifficultyID, BeatDivisor, BeatmapSetInfoID, Countdown, DistanceSpacing, GridSize, Hash, IFNULL(Hidden, 0), LetterboxInBreaks, MD5Hash, NULLIF(BeatmapMetadataID, 0), NULLIF(OnlineBeatmapID, 0), Path, RulesetID, SpecialStyle, StackLeniency, StarDifficulty, StoredBookmarks, TimelineZoom, Version, WidescreenStoryboard FROM BeatmapInfo_Old");
Database.ExecuteSqlCommand("DROP TABLE BeatmapInfo_Old");
Logger.Log("Migration complete!", LoggingTarget.Database, Framework.Logging.LogLevel.Important);
}
catch (Exception e)
{
throw new MigrationFailedException(e);
}
}
catch (MigrationFailedException)
{
throw;
}
catch
{
}
}
}
} }
public class MigrationFailedException : Exception public class MigrationFailedException : Exception

View File

@ -0,0 +1,19 @@
// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
namespace osu.Game.Database
{
public class SingletonContextFactory : IDatabaseContextFactory
{
private readonly OsuDbContext context;
public SingletonContextFactory(OsuDbContext context)
{
this.context = context;
}
public OsuDbContext Get() => context;
public DatabaseWriteUsage GetForWrite() => new DatabaseWriteUsage(context, null);
}
}

View File

@ -76,6 +76,16 @@ namespace osu.Game.Graphics.UserInterface
Nub.Current.BindTo(Current); Nub.Current.BindTo(Current);
Current.DisabledChanged += disabled =>
{
Alpha = disabled ? 0.3f : 1;
};
}
protected override void LoadComplete()
{
base.LoadComplete();
Current.ValueChanged += newValue => Current.ValueChanged += newValue =>
{ {
if (newValue) if (newValue)
@ -83,11 +93,6 @@ namespace osu.Game.Graphics.UserInterface
else else
sampleUnchecked?.Play(); sampleUnchecked?.Play();
}; };
Current.DisabledChanged += disabled =>
{
Alpha = disabled ? 0.3f : 1;
};
} }
protected override bool OnHover(InputState state) protected override bool OnHover(InputState state)

View File

@ -21,18 +21,18 @@ namespace osu.Game.IO
public new Storage Storage => base.Storage; public new Storage Storage => base.Storage;
public FileStore(Func<OsuDbContext> createContext, Storage storage) : base(createContext, storage.GetStorageForDirectory(@"files")) public FileStore(IDatabaseContextFactory contextFactory, Storage storage) : base(contextFactory, storage.GetStorageForDirectory(@"files"))
{ {
Store = new StorageBackedResourceStore(Storage); Store = new StorageBackedResourceStore(Storage);
} }
public FileInfo Add(Stream data, bool reference = true) public FileInfo Add(Stream data, bool reference = true)
{ {
var context = GetContext(); using (var usage = ContextFactory.GetForWrite())
{
string hash = data.ComputeSHA2Hash(); string hash = data.ComputeSHA2Hash();
var existing = context.FileInfo.FirstOrDefault(f => f.Hash == hash); var existing = usage.Context.FileInfo.FirstOrDefault(f => f.Hash == hash);
var info = existing ?? new FileInfo { Hash = hash }; var info = existing ?? new FileInfo { Hash = hash };
@ -54,38 +54,47 @@ namespace osu.Game.IO
return info; return info;
} }
}
public void Reference(params FileInfo[] files) => reference(GetContext(), files); public void Reference(params FileInfo[] files)
private void reference(OsuDbContext context, FileInfo[] files)
{ {
if (files.Length == 0) return;
using (var usage = ContextFactory.GetForWrite())
{
var context = usage.Context;
foreach (var f in files.GroupBy(f => f.ID)) foreach (var f in files.GroupBy(f => f.ID))
{ {
var refetch = context.Find<FileInfo>(f.First().ID) ?? f.First(); var refetch = context.Find<FileInfo>(f.First().ID) ?? f.First();
refetch.ReferenceCount += f.Count(); refetch.ReferenceCount += f.Count();
context.FileInfo.Update(refetch); context.FileInfo.Update(refetch);
} }
}
context.SaveChanges();
} }
public void Dereference(params FileInfo[] files) => dereference(GetContext(), files); public void Dereference(params FileInfo[] files)
private void dereference(OsuDbContext context, FileInfo[] files)
{ {
if (files.Length == 0) return;
using (var usage = ContextFactory.GetForWrite())
{
var context = usage.Context;
foreach (var f in files.GroupBy(f => f.ID)) foreach (var f in files.GroupBy(f => f.ID))
{ {
var refetch = context.FileInfo.Find(f.Key); var refetch = context.FileInfo.Find(f.Key);
refetch.ReferenceCount -= f.Count(); refetch.ReferenceCount -= f.Count();
context.FileInfo.Update(refetch); context.FileInfo.Update(refetch);
} }
}
context.SaveChanges();
} }
public override void Cleanup() public override void Cleanup()
{ {
var context = GetContext(); using (var usage = ContextFactory.GetForWrite())
{
var context = usage.Context;
foreach (var f in context.FileInfo.Where(f => f.ReferenceCount < 1)) foreach (var f in context.FileInfo.Where(f => f.ReferenceCount < 1))
{ {
@ -99,8 +108,7 @@ namespace osu.Game.IO
Logger.Error(e, $@"Could not delete beatmap {f}"); Logger.Error(e, $@"Could not delete beatmap {f}");
} }
} }
}
context.SaveChanges();
} }
} }
} }

View File

@ -16,8 +16,10 @@ namespace osu.Game.Input
{ {
public event Action KeyBindingChanged; public event Action KeyBindingChanged;
public KeyBindingStore(Func<OsuDbContext> createContext, RulesetStore rulesets, Storage storage = null) public KeyBindingStore(DatabaseContextFactory contextFactory, RulesetStore rulesets, Storage storage = null)
: base(createContext, storage) : base(contextFactory, storage)
{
using (ContextFactory.GetForWrite())
{ {
foreach (var info in rulesets.AvailableRulesets) foreach (var info in rulesets.AvailableRulesets)
{ {
@ -26,14 +28,13 @@ namespace osu.Game.Input
insertDefaults(ruleset.GetDefaultKeyBindings(variant), info.ID, variant); insertDefaults(ruleset.GetDefaultKeyBindings(variant), info.ID, variant);
} }
} }
}
public void Register(KeyBindingContainer manager) => insertDefaults(manager.DefaultKeyBindings); public void Register(KeyBindingContainer manager) => insertDefaults(manager.DefaultKeyBindings);
private void insertDefaults(IEnumerable<KeyBinding> defaults, int? rulesetId = null, int? variant = null) private void insertDefaults(IEnumerable<KeyBinding> defaults, int? rulesetId = null, int? variant = null)
{ {
var context = GetContext(); using (var usage = ContextFactory.GetForWrite())
using (var transaction = context.BeginTransaction())
{ {
// compare counts in database vs defaults // compare counts in database vs defaults
foreach (var group in defaults.GroupBy(k => k.Action)) foreach (var group in defaults.GroupBy(k => k.Action))
@ -46,7 +47,7 @@ namespace osu.Game.Input
foreach (var insertable in group.Skip(count).Take(aimCount - count)) foreach (var insertable in group.Skip(count).Take(aimCount - count))
// insert any defaults which are missing. // insert any defaults which are missing.
context.DatabasedKeyBinding.Add(new DatabasedKeyBinding usage.Context.DatabasedKeyBinding.Add(new DatabasedKeyBinding
{ {
KeyCombination = insertable.KeyCombination, KeyCombination = insertable.KeyCombination,
Action = insertable.Action, Action = insertable.Action,
@ -54,8 +55,6 @@ namespace osu.Game.Input
Variant = variant Variant = variant
}); });
} }
context.SaveChanges(transaction);
} }
} }
@ -66,19 +65,20 @@ namespace osu.Game.Input
/// <param name="variant">An optional variant.</param> /// <param name="variant">An optional variant.</param>
/// <returns></returns> /// <returns></returns>
public List<DatabasedKeyBinding> Query(int? rulesetId = null, int? variant = null) => public List<DatabasedKeyBinding> Query(int? rulesetId = null, int? variant = null) =>
GetContext().DatabasedKeyBinding.Where(b => b.RulesetID == rulesetId && b.Variant == variant).ToList(); ContextFactory.Get().DatabasedKeyBinding.Where(b => b.RulesetID == rulesetId && b.Variant == variant).ToList();
public void Update(KeyBinding keyBinding) public void Update(KeyBinding keyBinding)
{
using (ContextFactory.GetForWrite())
{ {
var dbKeyBinding = (DatabasedKeyBinding)keyBinding; var dbKeyBinding = (DatabasedKeyBinding)keyBinding;
var context = GetContext();
Refresh(ref dbKeyBinding); Refresh(ref dbKeyBinding);
dbKeyBinding.KeyCombination = keyBinding.KeyCombination; if (dbKeyBinding.KeyCombination.Equals(keyBinding.KeyCombination))
return;
context.SaveChanges(); dbKeyBinding.KeyCombination = keyBinding.KeyCombination;
}
KeyBindingChanged?.Invoke(); KeyBindingChanged?.Invoke();
} }

View File

@ -106,12 +106,12 @@ namespace osu.Game
Token = LocalConfig.Get<string>(OsuSetting.Token) Token = LocalConfig.Get<string>(OsuSetting.Token)
}); });
dependencies.Cache(RulesetStore = new RulesetStore(contextFactory.GetContext)); dependencies.Cache(RulesetStore = new RulesetStore(contextFactory));
dependencies.Cache(FileStore = new FileStore(contextFactory.GetContext, Host.Storage)); dependencies.Cache(FileStore = new FileStore(contextFactory, Host.Storage));
dependencies.Cache(BeatmapManager = new BeatmapManager(Host.Storage, contextFactory.GetContext, RulesetStore, API, Host)); dependencies.Cache(BeatmapManager = new BeatmapManager(Host.Storage, contextFactory, RulesetStore, API, Host));
dependencies.Cache(ScoreStore = new ScoreStore(Host.Storage, contextFactory.GetContext, Host, BeatmapManager, RulesetStore)); dependencies.Cache(ScoreStore = new ScoreStore(Host.Storage, contextFactory, Host, BeatmapManager, RulesetStore));
dependencies.Cache(KeyBindingStore = new KeyBindingStore(contextFactory.GetContext, RulesetStore)); dependencies.Cache(KeyBindingStore = new KeyBindingStore(contextFactory, RulesetStore));
dependencies.Cache(SettingsStore = new SettingsStore(contextFactory.GetContext)); dependencies.Cache(SettingsStore = new SettingsStore(contextFactory));
dependencies.Cache(new OsuColour()); dependencies.Cache(new OsuColour());
//this completely overrides the framework default. will need to change once we make a proper FontStore. //this completely overrides the framework default. will need to change once we make a proper FontStore.
@ -179,8 +179,8 @@ namespace osu.Game
{ {
try try
{ {
using (var context = contextFactory.GetContext()) using (var db = contextFactory.GetForWrite())
context.Migrate(); db.Context.Migrate();
} }
catch (MigrationFailedException e) catch (MigrationFailedException e)
{ {
@ -191,8 +191,8 @@ namespace osu.Game
contextFactory.ResetDatabase(); contextFactory.ResetDatabase();
Logger.Log("Database purged successfully.", LoggingTarget.Database, LogLevel.Important); Logger.Log("Database purged successfully.", LoggingTarget.Database, LogLevel.Important);
using (var context = contextFactory.GetContext()) using (var db = contextFactory.GetForWrite())
context.Migrate(); db.Context.Migrate();
} }
} }
@ -218,7 +218,7 @@ namespace osu.Game
CursorOverrideContainer.Child = globalBinding = new GlobalActionContainer(this) CursorOverrideContainer.Child = globalBinding = new GlobalActionContainer(this)
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Child = content = new OsuTooltipContainer(CursorOverrideContainer.Cursor) { RelativeSizeAxes = Axes.Both } Child = content = new OsuTooltipContainer(CursorOverrideContainer.Cursor) { RelativeSizeAxes = Axes.Both }
}; };
base.Content.Add(new DrawSizePreservingFillContainer { Child = CursorOverrideContainer }); base.Content.Add(new DrawSizePreservingFillContainer { Child = CursorOverrideContainer });

View File

@ -34,7 +34,19 @@ namespace osu.Game.Overlays.Music
public Action<BeatmapSetInfo> OnSelect; public Action<BeatmapSetInfo> OnSelect;
public bool IsDraggable => handle.IsHovered; public bool IsDraggable { get; private set; }
protected override bool OnMouseDown(InputState state, MouseDownEventArgs args)
{
IsDraggable = handle.IsHovered;
return base.OnMouseDown(state, args);
}
protected override bool OnMouseUp(InputState state, MouseUpEventArgs args)
{
IsDraggable = false;
return base.OnMouseUp(state, args);
}
private bool selected; private bool selected;
public bool Selected public bool Selected

View File

@ -2,7 +2,6 @@
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
using System; using System;
using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using osu.Framework.Allocation; using osu.Framework.Allocation;
@ -65,9 +64,12 @@ namespace osu.Game.Overlays
AlwaysPresent = true; AlwaysPresent = true;
} }
private Vector2 dragStart;
protected override bool OnDragStart(InputState state) protected override bool OnDragStart(InputState state)
{ {
base.OnDragStart(state); base.OnDragStart(state);
dragStart = state.Mouse.Position;
return true; return true;
} }
@ -75,9 +77,7 @@ namespace osu.Game.Overlays
{ {
if (base.OnDrag(state)) return true; if (base.OnDrag(state)) return true;
Trace.Assert(state.Mouse.PositionMouseDown != null, "state.Mouse.PositionMouseDown != null"); Vector2 change = state.Mouse.Position - dragStart;
Vector2 change = state.Mouse.Position - state.Mouse.PositionMouseDown.Value;
// Diminish the drag distance as we go further to simulate "rubber band" feeling. // Diminish the drag distance as we go further to simulate "rubber band" feeling.
change *= change.Length <= 0 ? 0 : (float)Math.Pow(change.Length, 0.7f) / change.Length; change *= change.Length <= 0 ? 0 : (float)Math.Pow(change.Length, 0.7f) / change.Length;

View File

@ -49,6 +49,19 @@ namespace osu.Game.Rulesets.Objects
[JsonIgnore] [JsonIgnore]
public bool Kiai { get; private set; } public bool Kiai { get; private set; }
private float overallDifficulty = BeatmapDifficulty.DEFAULT_DIFFICULTY;
private HitWindows hitWindows;
/// <summary>
/// The hit windows for this <see cref="HitObject"/>.
/// </summary>
public HitWindows HitWindows
{
get => hitWindows ?? (hitWindows = new HitWindows(overallDifficulty));
protected set => hitWindows = value;
}
private readonly SortedList<HitObject> nestedHitObjects = new SortedList<HitObject>((h1, h2) => h1.StartTime.CompareTo(h2.StartTime)); private readonly SortedList<HitObject> nestedHitObjects = new SortedList<HitObject>((h1, h2) => h1.StartTime.CompareTo(h2.StartTime));
[JsonIgnore] [JsonIgnore]
@ -75,6 +88,9 @@ namespace osu.Game.Rulesets.Objects
Kiai = effectPoint.KiaiMode; Kiai = effectPoint.KiaiMode;
SampleControlPoint = samplePoint; SampleControlPoint = samplePoint;
overallDifficulty = difficulty.OverallDifficulty;
hitWindows = null;
} }
protected virtual void CreateNestedHitObjects() protected virtual void CreateNestedHitObjects()

View File

@ -0,0 +1,173 @@
// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
using System;
using System.Collections.Generic;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Objects
{
public class HitWindows
{
private static readonly IReadOnlyDictionary<HitResult, (double od0, double od5, double od10)> base_ranges = new Dictionary<HitResult, (double, double, double)>
{
{ HitResult.Perfect, (44.8, 38.8, 27.8) },
{ HitResult.Great, (128, 98, 68 ) },
{ HitResult.Good, (194, 164, 134) },
{ HitResult.Ok, (254, 224, 194) },
{ HitResult.Meh, (302, 272, 242) },
{ HitResult.Miss, (376, 346, 316) },
};
/// <summary>
/// Hit window for a <see cref="HitResult.Perfect"/> result.
/// The user can only achieve receive this result if <see cref="AllowsPerfect"/> is true.
/// </summary>
public double Perfect { get; protected set; }
/// <summary>
/// Hit window for a <see cref="HitResult.Great"/> result.
/// </summary>
public double Great { get; protected set; }
/// <summary>
/// Hit window for a <see cref="HitResult.Good"/> result.
/// </summary>
public double Good { get; protected set; }
/// <summary>
/// Hit window for an <see cref="HitResult.Ok"/> result.
/// The user can only achieve this result if <see cref="AllowsOk"/> is true.
/// </summary>
public double Ok { get; protected set; }
/// <summary>
/// Hit window for a <see cref="HitResult.Meh"/> result.
/// </summary>
public double Meh { get; protected set; }
/// <summary>
/// Hit window for a <see cref="HitResult.Miss"/> result.
/// </summary>
public double Miss { get; protected set; }
/// <summary>
/// Whether it's possible to achieve a <see cref="HitResult.Perfect"/> result.
/// </summary>
public bool AllowsPerfect;
/// <summary>
/// Whether it's possible to achieve a <see cref="HitResult.Ok"/> result.
/// </summary>
public bool AllowsOk;
/// <summary>
/// Constructs hit windows by fitting a parameter to a 2-part piecewise linear function for each hit window.
/// </summary>
/// <param name="difficulty">The parameter.</param>
public HitWindows(double difficulty)
{
Perfect = BeatmapDifficulty.DifficultyRange(difficulty, base_ranges[HitResult.Perfect]);
Great = BeatmapDifficulty.DifficultyRange(difficulty, base_ranges[HitResult.Great]);
Good = BeatmapDifficulty.DifficultyRange(difficulty, base_ranges[HitResult.Good]);
Ok = BeatmapDifficulty.DifficultyRange(difficulty, base_ranges[HitResult.Ok]);
Meh = BeatmapDifficulty.DifficultyRange(difficulty, base_ranges[HitResult.Meh]);
Miss = BeatmapDifficulty.DifficultyRange(difficulty, base_ranges[HitResult.Miss]);
}
/// <summary>
/// Retrieves the <see cref="HitResult"/> for a time offset.
/// </summary>
/// <param name="timeOffset">The time offset.</param>
/// <returns>The hit result, or <see cref="HitResult.None"/> if <paramref name="timeOffset"/> doesn't result in a judgement.</returns>
public HitResult ResultFor(double timeOffset)
{
timeOffset = Math.Abs(timeOffset);
if (AllowsPerfect && timeOffset <= HalfWindowFor(HitResult.Perfect))
return HitResult.Perfect;
if (timeOffset <= HalfWindowFor(HitResult.Great))
return HitResult.Great;
if (timeOffset <= HalfWindowFor(HitResult.Good))
return HitResult.Good;
if (AllowsOk && timeOffset <= HalfWindowFor(HitResult.Ok))
return HitResult.Ok;
if (timeOffset <= HalfWindowFor(HitResult.Meh))
return HitResult.Meh;
if (timeOffset <= HalfWindowFor(HitResult.Miss))
return HitResult.Miss;
return HitResult.None;
}
/// <summary>
/// Retrieves half the hit window for a <see cref="HitResult"/>.
/// This is useful if the hit window for one half of the hittable range of a <see cref="HitObject"/> is required.
/// </summary>
/// <param name="result">The expected <see cref="HitResult"/>.</param>
/// <returns>One half of the hit window for <paramref name="result"/>.</returns>
public double HalfWindowFor(HitResult result)
{
switch (result)
{
case HitResult.Perfect:
return Perfect / 2;
case HitResult.Great:
return Great / 2;
case HitResult.Good:
return Good / 2;
case HitResult.Ok:
return Ok / 2;
case HitResult.Meh:
return Meh / 2;
case HitResult.Miss:
return Miss / 2;
default:
throw new ArgumentException(nameof(result));
}
}
/// <summary>
/// Given a time offset, whether the <see cref="HitObject"/> can ever be hit in the future with a non-<see cref="HitResult.Miss"/> result.
/// This happens if <paramref name="timeOffset"/> is less than what is required for a <see cref="Meh"/> result.
/// </summary>
/// <param name="timeOffset">The time offset.</param>
/// <returns>Whether the <see cref="HitObject"/> can be hit at any point in the future from this time offset.</returns>
public bool CanBeHit(double timeOffset) => timeOffset <= HalfWindowFor(HitResult.Meh);
/// <summary>
/// Multiplies all hit windows by a value.
/// </summary>
/// <param name="windows">The hit windows to multiply.</param>
/// <param name="value">The value to multiply each hit window by.</param>
public static HitWindows operator *(HitWindows windows, double value)
{
windows.Perfect *= value;
windows.Great *= value;
windows.Good *= value;
windows.Ok *= value;
windows.Meh *= value;
windows.Miss *= value;
return windows;
}
/// <summary>
/// Divides all hit windows by a value.
/// </summary>
/// <param name="windows">The hit windows to divide.</param>
/// <param name="value">The value to divide each hit window by.</param>
public static HitWindows operator /(HitWindows windows, double value)
{
windows.Perfect /= value;
windows.Great /= value;
windows.Good /= value;
windows.Ok /= value;
windows.Meh /= value;
windows.Miss /= value;
return windows;
}
}
}

View File

@ -35,6 +35,7 @@ namespace osu.Game.Rulesets.Objects.Types
/// Ranges from [0, 1] where 0 is the beginning of the curve and 1 is the end of the curve. /// Ranges from [0, 1] where 0 is the beginning of the curve and 1 is the end of the curve.
/// </para> /// </para>
/// </summary> /// </summary>
/// <param name="obj">The curve.</param>
/// <param name="progress">[0, 1] where 0 is the beginning of the curve and 1 is the end of the curve.</param> /// <param name="progress">[0, 1] where 0 is the beginning of the curve and 1 is the end of the curve.</param>
public static Vector2 PositionAt(this IHasCurve obj, double progress) public static Vector2 PositionAt(this IHasCurve obj, double progress)
=> obj.Curve.PositionAt(obj.ProgressAt(progress)); => obj.Curve.PositionAt(obj.ProgressAt(progress));
@ -42,6 +43,7 @@ namespace osu.Game.Rulesets.Objects.Types
/// <summary> /// <summary>
/// Finds the progress along the curve, accounting for repeat logic. /// Finds the progress along the curve, accounting for repeat logic.
/// </summary> /// </summary>
/// <param name="obj">The curve.</param>
/// <param name="progress">[0, 1] where 0 is the beginning of the curve and 1 is the end of the curve.</param> /// <param name="progress">[0, 1] where 0 is the beginning of the curve and 1 is the end of the curve.</param>
/// <returns>[0, 1] where 0 is the beginning of the curve and 1 is the end of the curve.</returns> /// <returns>[0, 1] where 0 is the beginning of the curve and 1 is the end of the curve.</returns>
public static double ProgressAt(this IHasCurve obj, double progress) public static double ProgressAt(this IHasCurve obj, double progress)
@ -55,6 +57,7 @@ namespace osu.Game.Rulesets.Objects.Types
/// <summary> /// <summary>
/// Determines which span of the curve the progress point is on. /// Determines which span of the curve the progress point is on.
/// </summary> /// </summary>
/// <param name="obj">The curve.</param>
/// <param name="progress">[0, 1] where 0 is the beginning of the curve and 1 is the end of the curve.</param> /// <param name="progress">[0, 1] where 0 is the beginning of the curve and 1 is the end of the curve.</param>
/// <returns>[0, SpanCount) where 0 is the first run.</returns> /// <returns>[0, SpanCount) where 0 is the first run.</returns>
public static int SpanAt(this IHasCurve obj, double progress) public static int SpanAt(this IHasCurve obj, double progress)

View File

@ -25,7 +25,7 @@ namespace osu.Game.Rulesets
loadRulesetFromFile(file); loadRulesetFromFile(file);
} }
public RulesetStore(Func<OsuDbContext> factory) public RulesetStore(IDatabaseContextFactory factory)
: base(factory) : base(factory)
{ {
AddMissingRulesets(); AddMissingRulesets();
@ -56,7 +56,9 @@ namespace osu.Game.Rulesets
protected void AddMissingRulesets() protected void AddMissingRulesets()
{ {
var context = GetContext(); using (var usage = ContextFactory.GetForWrite())
{
var context = usage.Context;
var instances = loaded_assemblies.Values.Select(r => (Ruleset)Activator.CreateInstance(r, (RulesetInfo)null)).ToList(); var instances = loaded_assemblies.Values.Select(r => (Ruleset)Activator.CreateInstance(r, (RulesetInfo)null)).ToList();
@ -98,6 +100,7 @@ namespace osu.Game.Rulesets
AvailableRulesets = context.RulesetInfo.Where(r => r.Available).ToList(); AvailableRulesets = context.RulesetInfo.Where(r => r.Available).ToList();
} }
}
private static void loadRulesetFromFile(string file) private static void loadRulesetFromFile(string file)
{ {

View File

@ -1,7 +1,6 @@
// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>. // Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE // Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using osu.Framework.Platform; using osu.Framework.Platform;
@ -27,7 +26,7 @@ namespace osu.Game.Rulesets.Scoring
// ReSharper disable once NotAccessedField.Local (we should keep a reference to this so it is not finalised) // ReSharper disable once NotAccessedField.Local (we should keep a reference to this so it is not finalised)
private ScoreIPCChannel ipc; private ScoreIPCChannel ipc;
public ScoreStore(Storage storage, Func<OsuDbContext> factory, IIpcHost importHost = null, BeatmapManager beatmaps = null, RulesetStore rulesets = null) : base(factory) public ScoreStore(Storage storage, DatabaseContextFactory factory, IIpcHost importHost = null, BeatmapManager beatmaps = null, RulesetStore rulesets = null) : base(factory)
{ {
this.storage = storage; this.storage = storage;
this.beatmaps = beatmaps; this.beatmaps = beatmaps;

View File

@ -52,7 +52,7 @@ namespace osu.Game.Rulesets.UI.Scrolling
protected virtual bool UserScrollSpeedAdjustment => true; protected virtual bool UserScrollSpeedAdjustment => true;
/// <summary> /// <summary>
/// The container that contains the <see cref="SpeedAdjustmentContainer"/>s and <see cref="DrawableHitObject"/>s. /// The container that contains the <see cref="DrawableHitObject"/>s.
/// </summary> /// </summary>
public new ScrollingHitObjectContainer HitObjects => (ScrollingHitObjectContainer)base.HitObjects; public new ScrollingHitObjectContainer HitObjects => (ScrollingHitObjectContainer)base.HitObjects;
@ -61,7 +61,7 @@ namespace osu.Game.Rulesets.UI.Scrolling
/// <summary> /// <summary>
/// Creates a new <see cref="ScrollingPlayfield"/>. /// Creates a new <see cref="ScrollingPlayfield"/>.
/// </summary> /// </summary>
/// <param name="scrollingAxes">The axes on which <see cref="DrawableHitObject"/>s in this container should scroll.</param> /// <param name="direction">The direction in which <see cref="DrawableHitObject"/>s in this container should scroll.</param>
/// <param name="customWidth">Whether we want our internal coordinate system to be scaled to a specified width</param> /// <param name="customWidth">Whether we want our internal coordinate system to be scaled to a specified width</param>
protected ScrollingPlayfield(ScrollingDirection direction, float? customWidth = null) protected ScrollingPlayfield(ScrollingDirection direction, float? customWidth = null)
: base(customWidth) : base(customWidth)

View File

@ -36,6 +36,7 @@ namespace osu.Game.Screens.Play
public int Retries { set { pauseOverlay.Retries = value; } } public int Retries { set { pauseOverlay.Retries = value; } }
public bool CanPause => (CheckCanPause?.Invoke() ?? true) && Time.Current >= lastPauseActionTime + pause_cooldown; public bool CanPause => (CheckCanPause?.Invoke() ?? true) && Time.Current >= lastPauseActionTime + pause_cooldown;
public bool IsResuming { get; private set; }
public Action OnRetry; public Action OnRetry;
public Action OnQuit; public Action OnQuit;
@ -54,7 +55,11 @@ namespace osu.Game.Screens.Play
AddInternal(pauseOverlay = new PauseOverlay AddInternal(pauseOverlay = new PauseOverlay
{ {
OnResume = () => this.Delay(400).Schedule(Resume), OnResume = () =>
{
IsResuming = true;
this.Delay(400).Schedule(Resume);
},
OnRetry = () => OnRetry(), OnRetry = () => OnRetry(),
OnQuit = () => OnQuit(), OnQuit = () => OnQuit(),
}); });
@ -100,6 +105,7 @@ namespace osu.Game.Screens.Play
pauseOverlay.Hide(); pauseOverlay.Hide();
AudioClock.Start(); AudioClock.Start();
IsResuming = false;
} }
private OsuGameBase game; private OsuGameBase game;

View File

@ -351,7 +351,7 @@ namespace osu.Game.Screens.Play
protected override bool OnExiting(Screen next) protected override bool OnExiting(Screen next)
{ {
if (!AllowPause || HasFailed || !ValidForResume || pauseContainer?.IsPaused != false || RulesetContainer?.HasReplayLoaded != false) if ((!AllowPause || HasFailed || !ValidForResume || pauseContainer?.IsPaused != false || RulesetContainer?.HasReplayLoaded != false) && (!pauseContainer?.IsResuming ?? false))
{ {
// In the case of replays, we may have changed the playback rate. // In the case of replays, we may have changed the playback rate.
applyRateFromMods(); applyRateFromMods();
@ -362,7 +362,7 @@ namespace osu.Game.Screens.Play
if (loadedSuccessfully) if (loadedSuccessfully)
{ {
pauseContainer.Pause(); pauseContainer?.Pause();
} }
return true; return true;

View File

@ -25,10 +25,10 @@ namespace osu.Game.Screens.Select.Carousel
{ {
public class DrawableCarouselBeatmapSet : DrawableCarouselItem, IHasContextMenu public class DrawableCarouselBeatmapSet : DrawableCarouselItem, IHasContextMenu
{ {
private Action<BeatmapSetInfo> deleteRequested;
private Action<BeatmapSetInfo> restoreHiddenRequested; private Action<BeatmapSetInfo> restoreHiddenRequested;
private Action<int> viewDetails; private Action<int> viewDetails;
private DialogOverlay dialogOverlay;
private readonly BeatmapSetInfo beatmapSet; private readonly BeatmapSetInfo beatmapSet;
public DrawableCarouselBeatmapSet(CarouselBeatmapSet set) public DrawableCarouselBeatmapSet(CarouselBeatmapSet set)
@ -38,13 +38,13 @@ namespace osu.Game.Screens.Select.Carousel
} }
[BackgroundDependencyLoader(true)] [BackgroundDependencyLoader(true)]
private void load(LocalisationEngine localisation, BeatmapManager manager, BeatmapSetOverlay beatmapOverlay) private void load(LocalisationEngine localisation, BeatmapManager manager, BeatmapSetOverlay beatmapOverlay, DialogOverlay overlay)
{ {
if (localisation == null) if (localisation == null)
throw new ArgumentNullException(nameof(localisation)); throw new ArgumentNullException(nameof(localisation));
restoreHiddenRequested = s => s.Beatmaps.ForEach(manager.Restore); restoreHiddenRequested = s => s.Beatmaps.ForEach(manager.Restore);
deleteRequested = manager.Delete; dialogOverlay = overlay;
if (beatmapOverlay != null) if (beatmapOverlay != null)
viewDetails = beatmapOverlay.ShowBeatmapSet; viewDetails = beatmapOverlay.ShowBeatmapSet;
@ -104,7 +104,7 @@ namespace osu.Game.Screens.Select.Carousel
if (beatmapSet.Beatmaps.Any(b => b.Hidden)) if (beatmapSet.Beatmaps.Any(b => b.Hidden))
items.Add(new OsuMenuItem("Restore all hidden", MenuItemType.Standard, () => restoreHiddenRequested?.Invoke(beatmapSet))); items.Add(new OsuMenuItem("Restore all hidden", MenuItemType.Standard, () => restoreHiddenRequested?.Invoke(beatmapSet)));
items.Add(new OsuMenuItem("Delete", MenuItemType.Destructive, () => deleteRequested?.Invoke(beatmapSet))); items.Add(new OsuMenuItem("Delete", MenuItemType.Destructive, () => dialogOverlay?.Push(new BeatmapDeleteDialog(beatmapSet))));
return items.ToArray(); return items.ToArray();
} }

View File

@ -0,0 +1,19 @@
// Copyright (c) 2007-2018 ppy Pty Ltd <contact@ppy.sh>.
// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE
using osu.Framework.Platform;
namespace osu.Game.Tests
{
/// <summary>
/// A headless host which cleans up before running (removing any remnants from a previous execution).
/// </summary>
public class CleanRunHeadlessGameHost : HeadlessGameHost
{
public CleanRunHeadlessGameHost(string gameName = @"", bool bindIPC = false, bool realtime = true)
: base(gameName, bindIPC, realtime)
{
Storage.DeleteDirectory(string.Empty);
}
}
}

View File

@ -13,12 +13,9 @@ namespace osu.Game.Tests.Visual
{ {
public override void RunTest() public override void RunTest()
{ {
using (var host = new HeadlessGameHost($"test-{Guid.NewGuid()}", realtime: false)) using (var host = new CleanRunHeadlessGameHost($"test-{Guid.NewGuid()}", realtime: false))
{
host.Storage.DeleteDirectory(string.Empty);
host.Run(new OsuTestCaseTestRunner(this)); host.Run(new OsuTestCaseTestRunner(this));
} }
}
public class OsuTestCaseTestRunner : OsuGameBase public class OsuTestCaseTestRunner : OsuGameBase
{ {

View File

@ -56,19 +56,19 @@ namespace osu.Game.Users
public int? Id; public int? Id;
} }
[JsonProperty(@"isAdmin")] [JsonProperty(@"is_admin")]
public bool IsAdmin; public bool IsAdmin;
[JsonProperty(@"isSupporter")] [JsonProperty(@"is_supporter")]
public bool IsSupporter; public bool IsSupporter;
[JsonProperty(@"isGMT")] [JsonProperty(@"is_gmt")]
public bool IsGMT; public bool IsGMT;
[JsonProperty(@"isQAT")] [JsonProperty(@"is_qat")]
public bool IsQAT; public bool IsQAT;
[JsonProperty(@"isBNG")] [JsonProperty(@"is_bng")]
public bool IsBNG; public bool IsBNG;
[JsonProperty(@"is_active")] [JsonProperty(@"is_active")]
@ -107,7 +107,7 @@ namespace osu.Game.Users
[JsonProperty(@"playmode")] [JsonProperty(@"playmode")]
public string PlayMode; public string PlayMode;
[JsonProperty(@"profileOrder")] [JsonProperty(@"profile_order")]
public string[] ProfileOrder; public string[] ProfileOrder;
[JsonProperty(@"kudosu")] [JsonProperty(@"kudosu")]

View File

@ -248,6 +248,7 @@
<Compile Include="Beatmaps\BeatmapDifficulty.cs" /> <Compile Include="Beatmaps\BeatmapDifficulty.cs" />
<Compile Include="Beatmaps\BeatmapInfo.cs" /> <Compile Include="Beatmaps\BeatmapInfo.cs" />
<Compile Include="Beatmaps\BeatmapManager.cs" /> <Compile Include="Beatmaps\BeatmapManager.cs" />
<Compile Include="Beatmaps\BeatmapManager_WorkingBeatmap.cs" />
<Compile Include="Beatmaps\BeatmapMetadata.cs" /> <Compile Include="Beatmaps\BeatmapMetadata.cs" />
<Compile Include="Beatmaps\BeatmapMetrics.cs" /> <Compile Include="Beatmaps\BeatmapMetrics.cs" />
<Compile Include="Beatmaps\BeatmapOnlineInfo.cs" /> <Compile Include="Beatmaps\BeatmapOnlineInfo.cs" />
@ -274,7 +275,10 @@
<Compile Include="Configuration\DatabasedConfigManager.cs" /> <Compile Include="Configuration\DatabasedConfigManager.cs" />
<Compile Include="Configuration\SpeedChangeVisualisationMethod.cs" /> <Compile Include="Configuration\SpeedChangeVisualisationMethod.cs" />
<Compile Include="Database\DatabaseContextFactory.cs" /> <Compile Include="Database\DatabaseContextFactory.cs" />
<Compile Include="Database\DatabaseWriteUsage.cs" />
<Compile Include="Database\IDatabaseContextFactory.cs" />
<Compile Include="Database\IHasPrimaryKey.cs" /> <Compile Include="Database\IHasPrimaryKey.cs" />
<Compile Include="Database\SingletonContextFactory.cs" />
<Compile Include="Graphics\Containers\LinkFlowContainer.cs" /> <Compile Include="Graphics\Containers\LinkFlowContainer.cs" />
<Compile Include="Graphics\Textures\LargeTextureStore.cs" /> <Compile Include="Graphics\Textures\LargeTextureStore.cs" />
<Compile Include="Online\API\Requests\GetUserRequest.cs" /> <Compile Include="Online\API\Requests\GetUserRequest.cs" />
@ -339,6 +343,7 @@
<Compile Include="Overlays\Social\SocialListPanel.cs" /> <Compile Include="Overlays\Social\SocialListPanel.cs" />
<Compile Include="Overlays\Social\SocialPanel.cs" /> <Compile Include="Overlays\Social\SocialPanel.cs" />
<Compile Include="Rulesets\Mods\IApplicableToDrawableHitObject.cs" /> <Compile Include="Rulesets\Mods\IApplicableToDrawableHitObject.cs" />
<Compile Include="Rulesets\Objects\HitWindows.cs" />
<Compile Include="Screens\Play\PlayerSettings\VisualSettings.cs" /> <Compile Include="Screens\Play\PlayerSettings\VisualSettings.cs" />
<Compile Include="Rulesets\Objects\CatmullApproximator.cs" /> <Compile Include="Rulesets\Objects\CatmullApproximator.cs" />
<Compile Include="Rulesets\UI\HitObjectContainer.cs" /> <Compile Include="Rulesets\UI\HitObjectContainer.cs" />
@ -855,6 +860,7 @@
<Compile Include="Storyboards\StoryboardSample.cs" /> <Compile Include="Storyboards\StoryboardSample.cs" />
<Compile Include="Storyboards\StoryboardSprite.cs" /> <Compile Include="Storyboards\StoryboardSprite.cs" />
<Compile Include="Tests\Beatmaps\TestWorkingBeatmap.cs" /> <Compile Include="Tests\Beatmaps\TestWorkingBeatmap.cs" />
<Compile Include="Tests\CleanRunHeadlessGameHost.cs" />
<Compile Include="Tests\Platform\TestStorage.cs" /> <Compile Include="Tests\Platform\TestStorage.cs" />
<Compile Include="Tests\Visual\OsuTestCase.cs" /> <Compile Include="Tests\Visual\OsuTestCase.cs" />
<Compile Include="Tests\Visual\TestCasePerformancePoints.cs" /> <Compile Include="Tests\Visual\TestCasePerformancePoints.cs" />