diff --git a/README.md b/README.md index 3306c9ab25..ad56178132 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,6 @@ -# osu! [![Build status](https://ci.appveyor.com/api/projects/status/u2p01nx7l6og8buh?svg=true)](https://ci.appveyor.com/project/peppy/osu) [![CodeFactor](https://www.codefactor.io/repository/github/ppy/osu/badge)](https://www.codefactor.io/repository/github/ppy/osu) +# osu! [![Build status](https://ci.appveyor.com/api/projects/status/u2p01nx7l6og8buh?svg=true)](https://ci.appveyor.com/project/peppy/osu) [![CodeFactor](https://www.codefactor.io/repository/github/ppy/osu/badge)](https://www.codefactor.io/repository/github/ppy/osu) [![dev chat](https://discordapp.com/api/guilds/188630481301012481/widget.png?style=shield)](https://discord.gg/ppy) -[osu! on the web](https://osu.ppy.sh) | [dev chat](https://discord.gg/ppy) - -Rhythm is just a *click* away. The future of osu! and the beginning of an open era! +Rhythm is just a *click* away. The future of [osu!](https://osu.ppy.sh) and the beginning of an open era! # Status diff --git a/osu-framework b/osu-framework index fc93e11439..58c108309f 160000 --- a/osu-framework +++ b/osu-framework @@ -1 +1 @@ -Subproject commit fc93e11439b8b391d9e01e208368d96ba85bfa26 +Subproject commit 58c108309f72462d8dc45aa488ab78cd6db08cf1 diff --git a/osu.Game.Rulesets.Osu/Mods/OsuMod.cs b/osu.Game.Rulesets.Osu/Mods/OsuMod.cs index bdb5f386d0..ff277dea1f 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuMod.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuMod.cs @@ -3,6 +3,7 @@ using osu.Game.Beatmaps; using osu.Game.Graphics; +using osu.Game.Rulesets.Osu.Replays; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Objects; using System; @@ -95,7 +96,7 @@ namespace osu.Game.Rulesets.Osu.Mods protected override Score CreateReplayScore(Beatmap beatmap) => new Score { - Replay = new OsuAutoReplay(beatmap) + Replay = new OsuAutoGenerator(beatmap).Generate() }; } diff --git a/osu.Game.Rulesets.Osu/OsuAutoReplay.cs b/osu.Game.Rulesets.Osu/OsuAutoReplay.cs deleted file mode 100644 index da30cf4efb..0000000000 --- a/osu.Game.Rulesets.Osu/OsuAutoReplay.cs +++ /dev/null @@ -1,315 +0,0 @@ -// Copyright (c) 2007-2017 ppy Pty Ltd . -// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE - -using OpenTK; -using osu.Framework.MathUtils; -using osu.Game.Beatmaps; -using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Rulesets.Osu.Objects.Drawables; -using System; -using System.Collections.Generic; -using System.Diagnostics; -using osu.Framework.Graphics; -using osu.Game.Rulesets.Objects.Types; -using osu.Game.Rulesets.Replays; -using osu.Game.Users; - -namespace osu.Game.Rulesets.Osu -{ - public class OsuAutoReplay : Replay - { - private static readonly Vector2 spinner_centre = new Vector2(256, 192); - - private const float spin_radius = 50; - - private readonly Beatmap beatmap; - - public OsuAutoReplay(Beatmap beatmap) - { - this.beatmap = beatmap; - - User = new User - { - Username = @"Autoplay", - }; - - createAutoReplay(); - } - - private class ReplayFrameComparer : IComparer - { - public int Compare(ReplayFrame f1, ReplayFrame f2) - { - if (f1 == null) throw new NullReferenceException($@"{nameof(f1)} cannot be null"); - if (f2 == null) throw new NullReferenceException($@"{nameof(f2)} cannot be null"); - - return f1.Time.CompareTo(f2.Time); - } - } - - private static readonly IComparer replay_frame_comparer = new ReplayFrameComparer(); - - private int findInsertionIndex(ReplayFrame frame) - { - int index = Frames.BinarySearch(frame, replay_frame_comparer); - - if (index < 0) - { - index = ~index; - } - else - { - // Go to the first index which is actually bigger - while (index < Frames.Count && frame.Time == Frames[index].Time) - { - ++index; - } - } - - return index; - } - - private void addFrameToReplay(ReplayFrame frame) => Frames.Insert(findInsertionIndex(frame), frame); - - private static Vector2 circlePosition(double t, double radius) => new Vector2((float)(Math.Cos(t) * radius), (float)(Math.Sin(t) * radius)); - - private double applyModsToTime(double v) => v; - private double applyModsToRate(double v) => v; - - public bool DelayedMovements; // ModManager.CheckActive(Mods.Relax2); - - private void createAutoReplay() - { - int buttonIndex = 0; - - EasingTypes preferredEasing = DelayedMovements ? EasingTypes.InOutCubic : EasingTypes.Out; - - addFrameToReplay(new ReplayFrame(-100000, 256, 500, ReplayButtonState.None)); - addFrameToReplay(new ReplayFrame(beatmap.HitObjects[0].StartTime - 1500, 256, 500, ReplayButtonState.None)); - addFrameToReplay(new ReplayFrame(beatmap.HitObjects[0].StartTime - 1000, 256, 192, ReplayButtonState.None)); - - // We are using ApplyModsToRate and not ApplyModsToTime to counteract the speed up / slow down from HalfTime / DoubleTime so that we remain at a constant framerate of 60 fps. - float frameDelay = (float)applyModsToRate(1000.0 / 60.0); - - // Already superhuman, but still somewhat realistic - int reactionTime = (int)applyModsToRate(100); - - - for (int i = 0; i < beatmap.HitObjects.Count; i++) - { - OsuHitObject h = beatmap.HitObjects[i]; - - //if (h.EndTime < InputManager.ReplayStartTime) - //{ - // h.IsHit = true; - // continue; - //} - - int endDelay = h is Spinner ? 1 : 0; - - if (DelayedMovements && i > 0) - { - OsuHitObject last = beatmap.HitObjects[i - 1]; - - double endTime = (last as IHasEndTime)?.EndTime ?? last.StartTime; - - //Make the cursor stay at a hitObject as long as possible (mainly for autopilot). - if (h.StartTime - h.HitWindowFor(OsuScoreResult.Miss) > endTime + h.HitWindowFor(OsuScoreResult.Hit50) + 50) - { - if (!(last is Spinner) && h.StartTime - endTime < 1000) addFrameToReplay(new ReplayFrame(endTime + h.HitWindowFor(OsuScoreResult.Hit50), last.EndPosition.X, last.EndPosition.Y, ReplayButtonState.None)); - if (!(h is Spinner)) addFrameToReplay(new ReplayFrame(h.StartTime - h.HitWindowFor(OsuScoreResult.Miss), h.Position.X, h.Position.Y, ReplayButtonState.None)); - } - else if (h.StartTime - h.HitWindowFor(OsuScoreResult.Hit50) > endTime + h.HitWindowFor(OsuScoreResult.Hit50) + 50) - { - if (!(last is Spinner) && h.StartTime - endTime < 1000) addFrameToReplay(new ReplayFrame(endTime + h.HitWindowFor(OsuScoreResult.Hit50), last.EndPosition.X, last.EndPosition.Y, ReplayButtonState.None)); - if (!(h is Spinner)) addFrameToReplay(new ReplayFrame(h.StartTime - h.HitWindowFor(OsuScoreResult.Hit50), h.Position.X, h.Position.Y, ReplayButtonState.None)); - } - else if (h.StartTime - h.HitWindowFor(OsuScoreResult.Hit100) > endTime + h.HitWindowFor(OsuScoreResult.Hit100) + 50) - { - if (!(last is Spinner) && h.StartTime - endTime < 1000) addFrameToReplay(new ReplayFrame(endTime + h.HitWindowFor(OsuScoreResult.Hit100), last.EndPosition.X, last.EndPosition.Y, ReplayButtonState.None)); - if (!(h is Spinner)) addFrameToReplay(new ReplayFrame(h.StartTime - h.HitWindowFor(OsuScoreResult.Hit100), h.Position.X, h.Position.Y, ReplayButtonState.None)); - } - } - - - Vector2 targetPosition = h.Position; - EasingTypes easing = preferredEasing; - float spinnerDirection = -1; - - if (h is Spinner) - { - targetPosition = Frames[Frames.Count - 1].Position; - - Vector2 difference = spinner_centre - targetPosition; - - float differenceLength = difference.Length; - float newLength = (float)Math.Sqrt(differenceLength * differenceLength - spin_radius * spin_radius); - - if (differenceLength > spin_radius) - { - float angle = (float)Math.Asin(spin_radius / differenceLength); - - if (angle > 0) - { - spinnerDirection = -1; - } - else - { - spinnerDirection = 1; - } - - difference.X = difference.X * (float)Math.Cos(angle) - difference.Y * (float)Math.Sin(angle); - difference.Y = difference.X * (float)Math.Sin(angle) + difference.Y * (float)Math.Cos(angle); - - difference.Normalize(); - difference *= newLength; - - targetPosition += difference; - - easing = EasingTypes.In; - } - else if (difference.Length > 0) - { - targetPosition = spinner_centre - difference * (spin_radius / difference.Length); - } - else - { - targetPosition = spinner_centre + new Vector2(0, -spin_radius); - } - } - - - // Do some nice easing for cursor movements - if (Frames.Count > 0) - { - ReplayFrame lastFrame = Frames[Frames.Count - 1]; - - // Wait until Auto could "see and react" to the next note. - double waitTime = h.StartTime - Math.Max(0.0, DrawableOsuHitObject.TIME_PREEMPT - reactionTime); - if (waitTime > lastFrame.Time) - { - lastFrame = new ReplayFrame(waitTime, lastFrame.MouseX, lastFrame.MouseY, lastFrame.ButtonState); - addFrameToReplay(lastFrame); - } - - Vector2 lastPosition = lastFrame.Position; - - double timeDifference = applyModsToTime(h.StartTime - lastFrame.Time); - - // Only "snap" to hitcircles if they are far enough apart. As the time between hitcircles gets shorter the snapping threshold goes up. - if (timeDifference > 0 && // Sanity checks - ((lastPosition - targetPosition).Length > h.Radius * (1.5 + 100.0 / timeDifference) || // Either the distance is big enough - timeDifference >= 266)) // ... or the beats are slow enough to tap anyway. - { - // Perform eased movement - for (double time = lastFrame.Time + frameDelay; time < h.StartTime; time += frameDelay) - { - Vector2 currentPosition = Interpolation.ValueAt(time, lastPosition, targetPosition, lastFrame.Time, h.StartTime, easing); - addFrameToReplay(new ReplayFrame((int)time, currentPosition.X, currentPosition.Y, lastFrame.ButtonState)); - } - - buttonIndex = 0; - } - else - { - buttonIndex++; - } - } - - ReplayButtonState button = buttonIndex % 2 == 0 ? ReplayButtonState.Left1 : ReplayButtonState.Right1; - - double hEndTime = ((h as IHasEndTime)?.EndTime ?? h.StartTime) + KEY_UP_DELAY; - - ReplayFrame newFrame = new ReplayFrame(h.StartTime, targetPosition.X, targetPosition.Y, button); - ReplayFrame endFrame = new ReplayFrame(hEndTime + endDelay, h.EndPosition.X, h.EndPosition.Y, ReplayButtonState.None); - - // Decrement because we want the previous frame, not the next one - int index = findInsertionIndex(newFrame) - 1; - - // Do we have a previous frame? No need to check for < replay.Count since we decremented! - if (index >= 0) - { - ReplayFrame previousFrame = Frames[index]; - var previousButton = previousFrame.ButtonState; - - // If a button is already held, then we simply alternate - if (previousButton != ReplayButtonState.None) - { - Debug.Assert(previousButton != (ReplayButtonState.Left1 | ReplayButtonState.Right1)); - - // Force alternation if we have the same button. Otherwise we can just keep the naturally to us assigned button. - if (previousButton == button) - { - button = (ReplayButtonState.Left1 | ReplayButtonState.Right1) & ~button; - newFrame.ButtonState = button; - } - - // We always follow the most recent slider / spinner, so remove any other frames that occur while it exists. - int endIndex = findInsertionIndex(endFrame); - - if (index < Frames.Count - 1) - Frames.RemoveRange(index + 1, Math.Max(0, endIndex - (index + 1))); - - // After alternating we need to keep holding the other button in the future rather than the previous one. - for (int j = index + 1; j < Frames.Count; ++j) - { - // Don't affect frames which stop pressing a button! - if (j < Frames.Count - 1 || Frames[j].ButtonState == previousButton) - Frames[j].ButtonState = button; - } - } - } - - addFrameToReplay(newFrame); - - // We add intermediate frames for spinning / following a slider here. - if (h is Spinner) - { - Spinner s = h as Spinner; - - Vector2 difference = targetPosition - spinner_centre; - - float radius = difference.Length; - float angle = radius == 0 ? 0 : (float)Math.Atan2(difference.Y, difference.X); - - double t; - - for (double j = h.StartTime + frameDelay; j < s.EndTime; j += frameDelay) - { - t = applyModsToTime(j - h.StartTime) * spinnerDirection; - - Vector2 pos = spinner_centre + circlePosition(t / 20 + angle, spin_radius); - addFrameToReplay(new ReplayFrame((int)j, pos.X, pos.Y, button)); - } - - t = applyModsToTime(s.EndTime - h.StartTime) * spinnerDirection; - Vector2 endPosition = spinner_centre + circlePosition(t / 20 + angle, spin_radius); - - addFrameToReplay(new ReplayFrame(s.EndTime, endPosition.X, endPosition.Y, button)); - - endFrame.MouseX = endPosition.X; - endFrame.MouseY = endPosition.Y; - } - else if (h is Slider) - { - Slider s = h as Slider; - - for (double j = frameDelay; j < s.Duration; j += frameDelay) - { - Vector2 pos = s.PositionAt(j / s.Duration); - addFrameToReplay(new ReplayFrame(h.StartTime + j, pos.X, pos.Y, button)); - } - - addFrameToReplay(new ReplayFrame(s.EndTime, s.EndPosition.X, s.EndPosition.Y, button)); - } - - // We only want to let go of our button if we are at the end of the current replay. Otherwise something is still going on after us so we need to keep the button pressed! - if (Frames[Frames.Count - 1].Time <= endFrame.Time) - addFrameToReplay(endFrame); - } - - //Player.currentScore.Replay = InputManager.ReplayScore.Replay; - //Player.currentScore.PlayerName = "osu!"; - } - } -} diff --git a/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs new file mode 100644 index 0000000000..f8365bf9ab --- /dev/null +++ b/osu.Game.Rulesets.Osu/Replays/OsuAutoGenerator.cs @@ -0,0 +1,332 @@ +// Copyright (c) 2007-2017 ppy Pty Ltd . +// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using OpenTK; +using osu.Framework.MathUtils; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using System; +using System.Diagnostics; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Replays; + +namespace osu.Game.Rulesets.Osu.Replays +{ + public class OsuAutoGenerator : OsuAutoGeneratorBase + { + #region Parameters + + /// + /// If delayed movements should be used, causing the cursor to stay on each hitobject for as long as possible. + /// Mainly for Autopilot. + /// + public bool DelayedMovements; // ModManager.CheckActive(Mods.Relax2); + + #endregion + + #region Constants + + /// + /// The "reaction time" in ms between "seeing" a new hit object and moving to "react" to it. + /// + private readonly double reactionTime; + + /// + /// What easing to use when moving between hitobjects + /// + private EasingTypes preferredEasing => DelayedMovements ? EasingTypes.InOutCubic : EasingTypes.Out; + + #endregion + + #region Construction / Initialisation + + public OsuAutoGenerator(Beatmap beatmap) + : base(beatmap) + { + // Already superhuman, but still somewhat realistic + reactionTime = ApplyModsToRate(100); + } + + #endregion + + #region Generator + + /// + /// Which button (left or right) to use for the current hitobject. + /// Even means LMB will be used to click, odd means RMB will be used. + /// This keeps track of the button previously used for alt/singletap logic. + /// + private int buttonIndex; + + public override Replay Generate() + { + buttonIndex = 0; + + AddFrameToReplay(new ReplayFrame(-100000, 256, 500, ReplayButtonState.None)); + AddFrameToReplay(new ReplayFrame(Beatmap.HitObjects[0].StartTime - 1500, 256, 500, ReplayButtonState.None)); + AddFrameToReplay(new ReplayFrame(Beatmap.HitObjects[0].StartTime - 1000, 256, 192, ReplayButtonState.None)); + + for (int i = 0; i < Beatmap.HitObjects.Count; i++) + { + OsuHitObject h = Beatmap.HitObjects[i]; + + if (DelayedMovements && i > 0) + { + OsuHitObject prev = Beatmap.HitObjects[i - 1]; + addDelayedMovements(h, prev); + } + + addHitObjectReplay(h); + } + + return Replay; + } + + private void addDelayedMovements(OsuHitObject h, OsuHitObject prev) + { + double endTime = (prev as IHasEndTime)?.EndTime ?? prev.StartTime; + + // Make the cursor stay at a hitObject as long as possible (mainly for autopilot). + if (h.StartTime - h.HitWindowFor(OsuScoreResult.Miss) > endTime + h.HitWindowFor(OsuScoreResult.Hit50) + 50) + { + if (!(prev is Spinner) && h.StartTime - endTime < 1000) AddFrameToReplay(new ReplayFrame(endTime + h.HitWindowFor(OsuScoreResult.Hit50), prev.EndPosition.X, prev.EndPosition.Y, ReplayButtonState.None)); + if (!(h is Spinner)) AddFrameToReplay(new ReplayFrame(h.StartTime - h.HitWindowFor(OsuScoreResult.Miss), h.Position.X, h.Position.Y, ReplayButtonState.None)); + } + else if (h.StartTime - h.HitWindowFor(OsuScoreResult.Hit50) > endTime + h.HitWindowFor(OsuScoreResult.Hit50) + 50) + { + if (!(prev is Spinner) && h.StartTime - endTime < 1000) AddFrameToReplay(new ReplayFrame(endTime + h.HitWindowFor(OsuScoreResult.Hit50), prev.EndPosition.X, prev.EndPosition.Y, ReplayButtonState.None)); + if (!(h is Spinner)) AddFrameToReplay(new ReplayFrame(h.StartTime - h.HitWindowFor(OsuScoreResult.Hit50), h.Position.X, h.Position.Y, ReplayButtonState.None)); + } + else if (h.StartTime - h.HitWindowFor(OsuScoreResult.Hit100) > endTime + h.HitWindowFor(OsuScoreResult.Hit100) + 50) + { + if (!(prev is Spinner) && h.StartTime - endTime < 1000) AddFrameToReplay(new ReplayFrame(endTime + h.HitWindowFor(OsuScoreResult.Hit100), prev.EndPosition.X, prev.EndPosition.Y, ReplayButtonState.None)); + if (!(h is Spinner)) AddFrameToReplay(new ReplayFrame(h.StartTime - h.HitWindowFor(OsuScoreResult.Hit100), h.Position.X, h.Position.Y, ReplayButtonState.None)); + } + } + + private void addHitObjectReplay(OsuHitObject h) + { + // Default values for circles/sliders + Vector2 startPosition = h.Position; + EasingTypes easing = preferredEasing; + float spinnerDirection = -1; + + // The startPosition for the slider should not be its .Position, but the point on the circle whose tangent crosses the current cursor position + // We also modify spinnerDirection so it spins in the direction it enters the spin circle, to make a smooth transition. + // TODO: Shouldn't the spinner always spin in the same direction? + if (h is Spinner) + { + calcSpinnerStartPosAndDirection(Frames[Frames.Count - 1].Position, out startPosition, out spinnerDirection); + + Vector2 spinCentreOffset = SPINNER_CENTRE - Frames[Frames.Count - 1].Position; + + if (spinCentreOffset.Length > SPIN_RADIUS) + { + // If moving in from the outside, don't ease out (default eases out). This means auto will "start" spinning immediately after moving into position. + easing = EasingTypes.In; + } + } + + // Do some nice easing for cursor movements + if (Frames.Count > 0) + { + moveToHitObject(h.StartTime, startPosition, h.Radius, easing); + } + + // Add frames to click the hitobject + addHitObjectClickFrames(h, startPosition, spinnerDirection); + } + + #endregion + + #region Helper subroutines + + private static void calcSpinnerStartPosAndDirection(Vector2 prevPos, out Vector2 startPosition, out float spinnerDirection) + { + Vector2 spinCentreOffset = SPINNER_CENTRE - prevPos; + float distFromCentre = spinCentreOffset.Length; + float distToTangentPoint = (float)Math.Sqrt(distFromCentre * distFromCentre - SPIN_RADIUS * SPIN_RADIUS); + + if (distFromCentre > SPIN_RADIUS) + { + // Previous cursor position was outside spin circle, set startPosition to the tangent point. + + // Angle between centre offset and tangent point offset. + float angle = (float)Math.Asin(SPIN_RADIUS / distFromCentre); + + if (angle > 0) + { + spinnerDirection = -1; + } + else + { + spinnerDirection = 1; + } + + // Rotate by angle so it's parallel to tangent line + spinCentreOffset.X = spinCentreOffset.X * (float)Math.Cos(angle) - spinCentreOffset.Y * (float)Math.Sin(angle); + spinCentreOffset.Y = spinCentreOffset.X * (float)Math.Sin(angle) + spinCentreOffset.Y * (float)Math.Cos(angle); + + // Set length to distToTangentPoint + spinCentreOffset.Normalize(); + spinCentreOffset *= distToTangentPoint; + + // Move along the tangent line, now startPosition is at the tangent point. + startPosition = prevPos + spinCentreOffset; + } + else if (spinCentreOffset.Length > 0) + { + // Previous cursor position was inside spin circle, set startPosition to the nearest point on spin circle. + startPosition = SPINNER_CENTRE - spinCentreOffset * (SPIN_RADIUS / spinCentreOffset.Length); + spinnerDirection = 1; + } + else + { + // Degenerate case where cursor position is exactly at the centre of the spin circle. + startPosition = SPINNER_CENTRE + new Vector2(0, -SPIN_RADIUS); + spinnerDirection = 1; + } + } + + private void moveToHitObject(double targetTime, Vector2 targetPos, double hitObjectRadius, EasingTypes easing) + { + ReplayFrame lastFrame = Frames[Frames.Count - 1]; + + // Wait until Auto could "see and react" to the next note. + double waitTime = targetTime - Math.Max(0.0, DrawableOsuHitObject.TIME_PREEMPT - reactionTime); + if (waitTime > lastFrame.Time) + { + lastFrame = new ReplayFrame(waitTime, lastFrame.MouseX, lastFrame.MouseY, lastFrame.ButtonState); + AddFrameToReplay(lastFrame); + } + + Vector2 lastPosition = lastFrame.Position; + + double timeDifference = ApplyModsToTime(targetTime - lastFrame.Time); + + // Only "snap" to hitcircles if they are far enough apart. As the time between hitcircles gets shorter the snapping threshold goes up. + if (timeDifference > 0 && // Sanity checks + ((lastPosition - targetPos).Length > hitObjectRadius * (1.5 + 100.0 / timeDifference) || // Either the distance is big enough + timeDifference >= 266)) // ... or the beats are slow enough to tap anyway. + { + // Perform eased movement + for (double time = lastFrame.Time + FrameDelay; time < targetTime; time += FrameDelay) + { + Vector2 currentPosition = Interpolation.ValueAt(time, lastPosition, targetPos, lastFrame.Time, targetTime, easing); + AddFrameToReplay(new ReplayFrame((int)time, currentPosition.X, currentPosition.Y, lastFrame.ButtonState)); + } + + buttonIndex = 0; + } + else + { + buttonIndex++; + } + } + + // Add frames to click the hitobject + private void addHitObjectClickFrames(OsuHitObject h, Vector2 startPosition, float spinnerDirection) + { + // Time to insert the first frame which clicks the object + // Here we mainly need to determine which button to use + ReplayButtonState button = buttonIndex % 2 == 0 ? ReplayButtonState.Left1 : ReplayButtonState.Right1; + + ReplayFrame startFrame = new ReplayFrame(h.StartTime, startPosition.X, startPosition.Y, button); + + // TODO: Why do we delay 1 ms if the object is a spinner? There already is KEY_UP_DELAY from hEndTime. + double hEndTime = ((h as IHasEndTime)?.EndTime ?? h.StartTime) + KEY_UP_DELAY; + int endDelay = h is Spinner ? 1 : 0; + ReplayFrame endFrame = new ReplayFrame(hEndTime + endDelay, h.EndPosition.X, h.EndPosition.Y, ReplayButtonState.None); + + // Decrement because we want the previous frame, not the next one + int index = FindInsertionIndex(startFrame) - 1; + + // If the previous frame has a button pressed, force alternation. + // If there are frames ahead, modify those to use the new button press. + // Do we have a previous frame? No need to check for < replay.Count since we decremented! + if (index >= 0) + { + ReplayFrame previousFrame = Frames[index]; + var previousButton = previousFrame.ButtonState; + + // If a button is already held, then we simply alternate + if (previousButton != ReplayButtonState.None) + { + Debug.Assert(previousButton != (ReplayButtonState.Left1 | ReplayButtonState.Right1), "Previous button state was not Left1 nor Right1 despite only using those two states."); + + // Force alternation if we have the same button. Otherwise we can just keep the naturally to us assigned button. + if (previousButton == button) + { + button = (ReplayButtonState.Left1 | ReplayButtonState.Right1) & ~button; + startFrame.ButtonState = button; + } + + // We always follow the most recent slider / spinner, so remove any other frames that occur while it exists. + int endIndex = FindInsertionIndex(endFrame); + + if (index < Frames.Count - 1) + Frames.RemoveRange(index + 1, Math.Max(0, endIndex - (index + 1))); + + // After alternating we need to keep holding the other button in the future rather than the previous one. + for (int j = index + 1; j < Frames.Count; ++j) + { + // Don't affect frames which stop pressing a button! + if (j < Frames.Count - 1 || Frames[j].ButtonState == previousButton) + Frames[j].ButtonState = button; + } + } + } + + AddFrameToReplay(startFrame); + + // We add intermediate frames for spinning / following a slider here. + if (h is Spinner) + { + Spinner s = h as Spinner; + + Vector2 difference = startPosition - SPINNER_CENTRE; + + float radius = difference.Length; + float angle = radius == 0 ? 0 : (float)Math.Atan2(difference.Y, difference.X); + + double t; + + for (double j = h.StartTime + FrameDelay; j < s.EndTime; j += FrameDelay) + { + t = ApplyModsToTime(j - h.StartTime) * spinnerDirection; + + Vector2 pos = SPINNER_CENTRE + CirclePosition(t / 20 + angle, SPIN_RADIUS); + AddFrameToReplay(new ReplayFrame((int)j, pos.X, pos.Y, button)); + } + + t = ApplyModsToTime(s.EndTime - h.StartTime) * spinnerDirection; + Vector2 endPosition = SPINNER_CENTRE + CirclePosition(t / 20 + angle, SPIN_RADIUS); + + AddFrameToReplay(new ReplayFrame(s.EndTime, endPosition.X, endPosition.Y, button)); + + endFrame.MouseX = endPosition.X; + endFrame.MouseY = endPosition.Y; + } + else if (h is Slider) + { + Slider s = h as Slider; + + for (double j = FrameDelay; j < s.Duration; j += FrameDelay) + { + Vector2 pos = s.PositionAt(j / s.Duration); + AddFrameToReplay(new ReplayFrame(h.StartTime + j, pos.X, pos.Y, button)); + } + + AddFrameToReplay(new ReplayFrame(s.EndTime, s.EndPosition.X, s.EndPosition.Y, button)); + } + + // We only want to let go of our button if we are at the end of the current replay. Otherwise something is still going on after us so we need to keep the button pressed! + if (Frames[Frames.Count - 1].Time <= endFrame.Time) + AddFrameToReplay(endFrame); + } + + #endregion + } +} diff --git a/osu.Game.Rulesets.Osu/Replays/OsuAutoGeneratorBase.cs b/osu.Game.Rulesets.Osu/Replays/OsuAutoGeneratorBase.cs new file mode 100644 index 0000000000..d2cd7a1156 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Replays/OsuAutoGeneratorBase.cs @@ -0,0 +1,96 @@ +// Copyright (c) 2007-2017 ppy Pty Ltd . +// Licensed under the MIT Licence - https://raw.githubusercontent.com/ppy/osu/master/LICENCE + +using OpenTK; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Osu.Objects; +using System; +using System.Collections.Generic; +using osu.Game.Rulesets.Replays; +using osu.Game.Users; + +namespace osu.Game.Rulesets.Osu.Replays +{ + public abstract class OsuAutoGeneratorBase : AutoGenerator + { + #region Constants + + /// + /// Constants (for spinners). + /// + protected static readonly Vector2 SPINNER_CENTRE = new Vector2(256, 192); + protected const float SPIN_RADIUS = 50; + + /// + /// The time in ms between each ReplayFrame. + /// + protected readonly double FrameDelay; + + #endregion + + #region Construction / Initialisation + + protected Replay Replay; + protected List Frames => Replay.Frames; + + protected OsuAutoGeneratorBase(Beatmap beatmap) + : base(beatmap) + { + Replay = new Replay + { + User = new User + { + Username = @"Autoplay", + } + }; + + // We are using ApplyModsToRate and not ApplyModsToTime to counteract the speed up / slow down from HalfTime / DoubleTime so that we remain at a constant framerate of 60 fps. + FrameDelay = ApplyModsToRate(1000.0 / 60.0); + } + + #endregion + + #region Utilities + protected double ApplyModsToTime(double v) => v; + protected double ApplyModsToRate(double v) => v; + + private class ReplayFrameComparer : IComparer + { + public int Compare(ReplayFrame f1, ReplayFrame f2) + { + if (f1 == null) throw new NullReferenceException($@"{nameof(f1)} cannot be null"); + if (f2 == null) throw new NullReferenceException($@"{nameof(f2)} cannot be null"); + + return f1.Time.CompareTo(f2.Time); + } + } + + private static readonly IComparer replay_frame_comparer = new ReplayFrameComparer(); + + protected int FindInsertionIndex(ReplayFrame frame) + { + int index = Frames.BinarySearch(frame, replay_frame_comparer); + + if (index < 0) + { + index = ~index; + } + else + { + // Go to the first index which is actually bigger + while (index < Frames.Count && frame.Time == Frames[index].Time) + { + ++index; + } + } + + return index; + } + + protected void AddFrameToReplay(ReplayFrame frame) => Frames.Insert(FindInsertionIndex(frame), frame); + + protected static Vector2 CirclePosition(double t, double radius) => new Vector2((float)(Math.Cos(t) * radius), (float)(Math.Sin(t) * radius)); + + #endregion + } +} diff --git a/osu.Game.Rulesets.Osu/osu.Game.Rulesets.Osu.csproj b/osu.Game.Rulesets.Osu/osu.Game.Rulesets.Osu.csproj index fcad0061e4..8974b1bcbd 100644 --- a/osu.Game.Rulesets.Osu/osu.Game.Rulesets.Osu.csproj +++ b/osu.Game.Rulesets.Osu/osu.Game.Rulesets.Osu.csproj @@ -69,7 +69,6 @@ - @@ -83,6 +82,8 @@ + + @@ -102,6 +103,9 @@ + + +