diff --git a/scripts/Rhythia.cs b/scripts/Rhythia.cs index 535dd847..a190dabc 100644 --- a/scripts/Rhythia.cs +++ b/scripts/Rhythia.cs @@ -181,6 +181,7 @@ public static void RegisterModifiers() { Modifiers = [ new NoFailModifier(), + new AutoplayModifier(), new GhostModifier(), new StrobeModifier(), new ChaosModifier(), diff --git a/scripts/game/AutoplayHandler.cs b/scripts/game/AutoplayHandler.cs new file mode 100644 index 00000000..04b2d115 --- /dev/null +++ b/scripts/game/AutoplayHandler.cs @@ -0,0 +1,477 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Godot; + +public class AutoplayHandler +{ + private readonly Attempt attempt; + private readonly List processedData = []; + private int lastLoadedNote; + + private const float MaxShiftMultiplier = 0.25f; + + private readonly struct AutoNote(float x, float y, double millisecond) + { + public readonly float X = x; + public readonly float Y = y; + public readonly double Millisecond = millisecond; + + public Vector2 Position => new(X, Y); + + public AutoNote WithPosition(Vector2 position) => new(position.X, position.Y, Millisecond); + + public AutoNote WithMillisecond(double millisecond) => new(X, Y, millisecond); + } + + public AutoplayHandler(Attempt attempt) + { + this.attempt = attempt; + + var notes = attempt.Map.Notes + .Select(note => new AutoNote(note.X, note.Y, note.Millisecond)) + .ToList(); + + if (notes.Count == 0) + { + return; + } + + List preprocessedData = initialPreprocess(notes); + List secondaryPreprocessedData = compressStacks(preprocessedData); + shiftPreprocess(notes, preprocessedData, secondaryPreprocessedData); + } + + public void Reset(double progress) + { + lastLoadedNote = 0; + + while (lastLoadedNote + 1 < processedData.Count && processedData[lastLoadedNote + 1].Millisecond <= progress) + { + lastLoadedNote++; + } + } + + public Vector2 GetCursorPosition(double progress) + { + if (processedData.Count == 0) + { + return Vector2.Zero; + } + + while (lastLoadedNote + 1 < processedData.Count) + { + AutoNote note = processedData[lastLoadedNote + 1]; + + if (note.Millisecond > progress) + { + break; + } + + lastLoadedNote++; + } + + AutoNote note0 = processedData[Math.Max(lastLoadedNote - 1, 0)]; + AutoNote note1 = processedData[lastLoadedNote]; + AutoNote note2 = processedData[Math.Min(lastLoadedNote + 1, processedData.Count - 1)]; + AutoNote note3 = processedData[Math.Min(lastLoadedNote + 2, processedData.Count - 1)]; + + return GetSplinePosition(note0, note1, note2, note3, progress).Clamp(-Constants.BOUNDS, Constants.BOUNDS); + } + + private List initialPreprocess(List notes) + { + List preprocessedData = []; + int i = 0; + + while (i < notes.Count) + { + AutoNote note = notes[i]; + i++; + + List collected = [note]; + + while (i < notes.Count) + { + AutoNote next = notes[i]; + + if (Math.Abs(next.Millisecond - note.Millisecond) <= 5 && CheckHit(note, next, hitboxSize() * 0.9f)) + { + collected.Add(next); + i++; + } + else + { + break; + } + } + + Vector2 avgPos = Vector2.Zero; + + foreach (AutoNote collectedNote in collected) + { + avgPos += collectedNote.Position; + } + + avgPos /= collected.Count; + + if (i > 1) + { + AutoNote prevData = preprocessedData[^1]; + Vector2 prevDataPos = prevData.Position; + float timeElapsed = (float)(Math.Abs(prevData.Millisecond - note.Millisecond) / 1000.0); + + if (timeElapsed > 0) + { + float speed = prevDataPos.DistanceTo(avgPos) / (timeElapsed * 100); + avgPos *= 0.8f + (Sigmoid(speed * 5) - 0.5f) * 2 * 0.2f; + } + + if (i > 2 && preprocessedData.Count >= 2) + { + AutoNote prevPrevData = preprocessedData[^2]; + Vector2 dir1 = (prevDataPos - prevPrevData.Position).Normalized(); + Vector2 dir2 = (avgPos - prevDataPos).Normalized(); + + avgPos *= 1 + Math.Max(-dir1.Dot(dir2) - 0.5f, 0) * (1 / (timeElapsed * 20 + 1)); + } + } + + AutoNote newNote = new(avgPos.X, avgPos.Y, note.Millisecond); + AutoNote previousNote = notes[Math.Max(i - 2, 0)]; + + if (collected.Count == 1 && note.X == previousNote.X && note.Y == previousNote.Y && preprocessedData.Count > 0) + { + newNote = new(preprocessedData[^1].X, preprocessedData[^1].Y, newNote.Millisecond); + } + + preprocessedData.Add(newNote); + } + + return preprocessedData; + } + + private List compressStacks(List preprocessedData) + { + if (preprocessedData.Count <= 5) + { + return [.. preprocessedData]; + } + + List secondaryPreprocessedData = [preprocessedData[0], preprocessedData[1]]; + int i = 2; + + while (i + 3 < preprocessedData.Count) + { + int stackLength = 1; + AutoNote topNote = preprocessedData[i]; + int topNoteIndex = i; + i++; + + while (i + 2 < preprocessedData.Count) + { + AutoNote nextNote = preprocessedData[i]; + i++; + + if (CheckHit(nextNote, topNote, 0.1f)) + { + stackLength++; + } + else + { + break; + } + } + + i--; + + if (stackLength > 1) + { + AutoNote endNote = preprocessedData[i - 1]; + bool valid = false; + AutoNote validTest = topNote; + List tests = buildStackTests(topNote, endNote); + int checkStart = Math.Max(0, topNoteIndex - 6); + List notesToCheck = preprocessedData.GetRange(checkStart, Math.Min(i + 6, preprocessedData.Count) - checkStart); + List cursorPositionNotes = secondaryPreprocessedData.GetRange(Math.Max(0, secondaryPreprocessedData.Count - stackLength - 12), Math.Min(stackLength + 12, secondaryPreprocessedData.Count)); + List cursorPositionNotesEnd = preprocessedData.GetRange(i, Math.Min(12, preprocessedData.Count - i)); + + foreach (AutoNote test in tests) + { + bool currentValid = true; + List validation = [.. cursorPositionNotes, test, .. cursorPositionNotesEnd]; + + foreach (AutoNote note in notesToCheck) + { + bool anyValid = false; + + for (int offsetTest = 0; offsetTest < 10; offsetTest++) + { + float offsetCheck = Mathf.Lerp(5, (float)Constants.HIT_WINDOW * 0.8f, 1 - offsetTest / 9.0f); + + if (CheckHit(note, GetCursorPositionFromNotes(validation, note.Millisecond + offsetCheck), hitboxSize() * 0.74f)) + { + anyValid = true; + break; + } + } + + if (!anyValid) + { + currentValid = false; + break; + } + } + + if (currentValid) + { + valid = true; + validTest = test; + break; + } + } + + if (valid) + { + secondaryPreprocessedData.Add(validTest); + } + else + { + secondaryPreprocessedData.Add(topNote); + secondaryPreprocessedData.Add(topNote.WithMillisecond(topNote.Millisecond + 10)); + secondaryPreprocessedData.Add(endNote.WithMillisecond(endNote.Millisecond - 10)); + secondaryPreprocessedData.Add(endNote); + } + } + else + { + secondaryPreprocessedData.Add(topNote); + } + } + + secondaryPreprocessedData.Add(preprocessedData[^3]); + secondaryPreprocessedData.Add(preprocessedData[^2]); + secondaryPreprocessedData.Add(preprocessedData[^1]); + + return secondaryPreprocessedData; + } + + private List buildStackTests(AutoNote topNote, AutoNote endNote) + { + List tests = []; + float testWidth = hitboxSize() * 0.5f; + const int testWidthFidelity = 3; + const int testCount = 11; + + for (int i = 0; i < testCount; i++) + { + double millisecond = Mathf.Lerp(topNote.Millisecond, endNote.Millisecond, i / (testCount - 1.0f)); + tests.Add(new(topNote.X, topNote.Y, millisecond)); + + for (int x = -testWidthFidelity; x <= testWidthFidelity; x++) + { + for (int y = -testWidthFidelity; y <= testWidthFidelity; y++) + { + if (x == 0 && y == 0) + { + continue; + } + + Vector2 offset = new Vector2(x, y) / testWidthFidelity * testWidth; + tests.Add(new(topNote.X + offset.X, topNote.Y + offset.Y, millisecond)); + } + } + } + + Vector2 topNotePos = topNote.Position; + tests.Sort((a, b) => + { + int timeCompare = a.Millisecond.CompareTo(b.Millisecond); + return timeCompare != 0 ? timeCompare : a.Position.DistanceSquaredTo(topNotePos).CompareTo(b.Position.DistanceSquaredTo(topNotePos)); + }); + + return tests; + } + + private void shiftPreprocess(List originalNotes, List preprocessedData, List secondaryPreprocessedData) + { + float maxRange = hitboxSize() * MaxShiftMultiplier; + + for (int i = 0; i + 1 < secondaryPreprocessedData.Count; i++) + { + AutoNote note0 = secondaryPreprocessedData[Math.Max(i - 2, 0)]; + AutoNote note1 = secondaryPreprocessedData[Math.Max(i - 1, 0)]; + AutoNote note2 = secondaryPreprocessedData[i]; + AutoNote note3 = secondaryPreprocessedData[i + 1]; + AutoNote note4 = secondaryPreprocessedData[Math.Min(i + 2, secondaryPreprocessedData.Count - 1)]; + + Vector2 shiftVec; + + if (note2.X == note3.X && note2.Y == note3.Y) + { + Vector2 previous = processedData.Count > 0 ? processedData[^1].Position : note1.Position; + Vector2 desired = (previous + note2.Position * 0.5f + note3.Position) / 2.5f; + shiftVec = clampShift(desired - note2.Position, hitboxSize() * 0.75f); + } + else + { + Vector2 pos = GetSplinePosition(note0, note1, note3, note4, note2.Millisecond); + shiftVec = clampShift(pos - note2.Position, maxRange); + } + + bool valid = false; + + for (int testIndex = 0; testIndex < 1; testIndex++) + { + float shiftMulti = (10 - testIndex) / 10.0f; + AutoNote newNote = note2.WithPosition(note2.Position + shiftVec * shiftMulti); + List validation = [ + .. processedData.TakeLast(3), + newNote, + .. secondaryPreprocessedData.Skip(i + 1).Take(3) + ]; + + if (validation.Count < 4) + { + processedData.Add(newNote); + valid = true; + break; + } + + bool currentValid = true; + double low = validation[Math.Min(2, validation.Count - 1)].Millisecond; + double high = validation[Math.Max(0, validation.Count - 3)].Millisecond; + + foreach (AutoNote note in originalNotes) + { + if (note.Millisecond >= low && note.Millisecond <= high) + { + bool hit = CheckHit(note, GetCursorPositionFromNotes(validation, note.Millisecond + 1), hitboxSize() * 0.9f) + || CheckHit(note, GetCursorPositionFromNotes(validation, note.Millisecond + 5), hitboxSize() * 0.9f); + + if (!hit) + { + currentValid = false; + break; + } + } + } + + if (currentValid) + { + processedData.Add(newNote); + valid = true; + break; + } + } + + if (!valid) + { + processedData.Add(note2); + } + } + + processedData.Add(preprocessedData[^1]); + } + + private Vector2 GetCursorPositionFromNotes(List noteData, double elapsed) + { + if (noteData.Count == 0) + { + return Vector2.Zero; + } + + int tempLastLoadedNote = 0; + + while (tempLastLoadedNote + 1 < noteData.Count) + { + AutoNote note = noteData[tempLastLoadedNote + 1]; + + if (note.Millisecond > elapsed) + { + break; + } + + tempLastLoadedNote++; + } + + AutoNote note0 = noteData[Math.Max(tempLastLoadedNote - 1, 0)]; + AutoNote note1 = noteData[tempLastLoadedNote]; + AutoNote note2 = noteData[Math.Min(tempLastLoadedNote + 1, noteData.Count - 1)]; + AutoNote note3 = noteData[Math.Min(tempLastLoadedNote + 2, noteData.Count - 1)]; + + return GetSplinePosition(note0, note1, note2, note3, elapsed).Clamp(-Constants.BOUNDS, Constants.BOUNDS); + } + + private static bool CheckHit(AutoNote notePos, AutoNote cursorPos, float size) => CheckHit(notePos, cursorPos.Position, size); + + private static bool CheckHit(AutoNote notePos, Vector2 cursorPos, float size) + { + Vector2 diff = (notePos.Position - cursorPos).Abs(); + return Math.Max(diff.X, diff.Y) < size; + } + + private static Vector2 GetSplinePosition(AutoNote note0, AutoNote note1, AutoNote note2, AutoNote note3, double time) + { + double segmentDuration = note2.Millisecond - note1.Millisecond; + + if (segmentDuration <= 0) + { + return note1.Position; + } + + float u = (float)((time - note1.Millisecond) / segmentDuration); + + return new( + CatmullRomRaw(note0.X, note1.X, note2.X, note3.X, u, note0.Millisecond, note1.Millisecond, note2.Millisecond, note3.Millisecond), + CatmullRomRaw(note0.Y, note1.Y, note2.Y, note3.Y, u, note0.Millisecond, note1.Millisecond, note2.Millisecond, note3.Millisecond) + ); + } + + private static float CatmullRomRaw(float pos0, float pos1, float pos2, float pos3, float u, double time0, double time1, double time2, double time3) + { + const float splineAlpha = 0.4f; + const float splineTension = -1f; + + float t01 = Mathf.Pow(Math.Max(Math.Abs((float)(time0 - time1)), 1), splineAlpha); + float t12 = Mathf.Pow(Math.Max(Math.Abs((float)(time1 - time2)), 1), splineAlpha); + float t23 = Mathf.Pow(Math.Max(Math.Abs((float)(time2 - time3)), 1), splineAlpha); + + float m1 = (1 - splineTension) * (pos2 - pos1 + t12 * ((pos1 - pos0) / t01 - (pos2 - pos0) / (t01 + t12))); + float m2 = (1 - splineTension) * (pos2 - pos1 + t12 * ((pos3 - pos2) / t23 - (pos3 - pos1) / (t12 + t23))); + + if (!float.IsFinite(m1)) + { + m1 = 0; + } + + if (!float.IsFinite(m2)) + { + m2 = 0; + } + + float u2 = u * u; + + return (2 * (pos1 - pos2) + m1 + m2) * u2 * u + + (-3 * (pos1 - pos2) - m1 - m1 - m2) * u2 + + m1 * u + + pos1; + } + + private static Vector2 clampShift(Vector2 shift, float limit) + { + if (shift.X == 0 || shift.Y == 0) + { + return shift.Clamp(Vector2.One * -limit, Vector2.One * limit); + } + + shift *= Math.Clamp(shift.X, -limit, limit) / shift.X; + shift *= Math.Clamp(shift.Y, -limit, limit) / shift.Y; + + return shift; + } + + private static float Sigmoid(float value) => 1 / (1 + Mathf.Exp(-value)); + + private static float hitboxSize() => (float)(0.5 + Constants.HIT_BOX_SIZE); +} diff --git a/scripts/game/AutoplayHandler.cs.uid b/scripts/game/AutoplayHandler.cs.uid new file mode 100644 index 00000000..756374cf --- /dev/null +++ b/scripts/game/AutoplayHandler.cs.uid @@ -0,0 +1 @@ +uid://dx76ccbly75a diff --git a/scripts/game/Runner.cs b/scripts/game/Runner.cs index c7b11711..3bea9da2 100644 --- a/scripts/game/Runner.cs +++ b/scripts/game/Runner.cs @@ -24,9 +24,11 @@ public partial class Runner : Node3D public bool StopQueued = false; private SettingsProfile settings; + private AutoplayHandler autoplayHandler; private double lastFrame = Time.GetTicksUsec(); private bool firstFrame = true; private bool eventsConnected = false; + private bool autoplayEnabled = false; [ExportCategory("Settings")] [Export] public bool NotesOnly = false; @@ -80,6 +82,11 @@ public override void _Process(double delta) // Save replay frame + if (autoplayEnabled) + { + Game.Instance.CursorManager.UpdateAutoplayCursor(autoplayHandler.GetCursorPosition(Attempt.Progress)); + } + // if not paused & record replays on & not a temporary map & time from now and last replay frame was 60 frames apart if (!Attempt.Stopped && settings.RecordReplays && !Attempt.Map.Ephemeral && now - Attempt.LastReplayFrame >= 1000000 / 60) { @@ -240,6 +247,22 @@ public void Play() } } + autoplayEnabled = Attempt.Modifiers.Any(mod => mod is AutoplayModifier) && !Attempt.IsReplay; + + if (autoplayEnabled) + { + foreach (var mod in Attempt.Modifiers.Where(mod => mod is AutoplayModifier)) + { + mod.Activate(Attempt); + } + + autoplayHandler = new(Attempt); + } + else + { + autoplayHandler = null; + } + foreach (var renderer in Renderers) { renderer.Setup(Attempt.Settings, SkinManager.Instance.Skin); @@ -308,6 +331,7 @@ public void Skip() public void Seek(double ms) { Attempt.Progress = ms; + autoplayHandler?.Reset(Attempt.Progress); foreach (var entry in Attempt.Objects) { diff --git a/scripts/game/managers/CursorManager.cs b/scripts/game/managers/CursorManager.cs index 30b15c88..5e1f10c8 100644 --- a/scripts/game/managers/CursorManager.cs +++ b/scripts/game/managers/CursorManager.cs @@ -115,6 +115,31 @@ public void UpdateCursor(Vector2 inputDelta, int cursorIndex = 0) attempt.CameraMode.Process(attempt, replayManager, camera, cursors[cursorIndex], inputDelta, sensitivity); } + public void UpdateAutoplayCursor(Vector2 position) + { + EmitSignalOnCursorUpdated(position); + + var attempt = runner.Attempt; + attempt.RawCursorPosition = position; + attempt.CursorPosition = position.Clamp(-Constants.BOUNDS, Constants.BOUNDS); + + var origin = new Vector3(0, 0, attempt.CameraMode.Name == "Spin" ? 3.5f : 3.75f); + float parallax = (float)settings.CameraParallax; + camera.Position = origin + new Vector3(attempt.CursorPosition.X, attempt.CursorPosition.Y, 0) * parallax; + + if (attempt.CameraMode.Name != "Spin") + { + camera.Rotation = Vector3.Zero; + } + + Vector3 cursorPos = new(attempt.CursorPosition.X, attempt.CursorPosition.Y, 0); + + if (cursorPos.IsFinite()) + { + cursors[0].Position = cursorPos; + } + } + // Reset everything to zero so it doesn't have infinite sensitivity private void repositionAbsolute() { diff --git a/scripts/game/modifiers/AutoplayModifier.cs b/scripts/game/modifiers/AutoplayModifier.cs new file mode 100644 index 00000000..bc77780b --- /dev/null +++ b/scripts/game/modifiers/AutoplayModifier.cs @@ -0,0 +1,14 @@ +using Godot; + +public class AutoplayModifier : Modifier +{ + public override string Name => "Autoplay"; + + public override Color Color => new(0xffd166ff); + + public override void Activate(Attempt attempt) + { + base.Activate(attempt); + attempt.Qualifies = false; + } +} diff --git a/scripts/game/modifiers/AutoplayModifier.cs.uid b/scripts/game/modifiers/AutoplayModifier.cs.uid new file mode 100644 index 00000000..78531d58 --- /dev/null +++ b/scripts/game/modifiers/AutoplayModifier.cs.uid @@ -0,0 +1 @@ +uid://cobkyirweynfj diff --git a/scripts/map/Replay.cs b/scripts/map/Replay.cs index 6da741ea..b3e4cf6b 100644 --- a/scripts/map/Replay.cs +++ b/scripts/map/Replay.cs @@ -129,6 +129,9 @@ public Replay(string path) case "NoFail": Modifiers.Add(new NoFailModifier()); break; + case "Autoplay": + Modifiers.Add(new AutoplayModifier()); + break; case "Ghost": Modifiers.Add(new GhostModifier()); break; diff --git a/scripts/util/Misc.cs b/scripts/util/Misc.cs index 38a1716c..4509f9fe 100644 --- a/scripts/util/Misc.cs +++ b/scripts/util/Misc.cs @@ -17,6 +17,9 @@ public static Texture2D GetModIcon(string mod) case "NoFail": tex = skin.ModNoFailImage; break; + case "Autoplay": + tex = GD.Load("res://textures/ui/autoplay.png"); + break; case "Ghost": tex = skin.ModGhostImage; break; diff --git a/textures/ui/autoplay.png b/textures/ui/autoplay.png new file mode 100644 index 00000000..a8c47aca Binary files /dev/null and b/textures/ui/autoplay.png differ diff --git a/textures/ui/autoplay.png.import b/textures/ui/autoplay.png.import new file mode 100644 index 00000000..421958ca --- /dev/null +++ b/textures/ui/autoplay.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://pkytlqp7mt2g" +path="res://.godot/imported/autoplay.png-68ce854bc9795d220ef473f6447c55d9.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://textures/ui/autoplay.png" +dest_files=["res://.godot/imported/autoplay.png-68ce854bc9795d220ef473f6447c55d9.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1