From 2b5ba8dfa989e516a0c30bb84c1672e6630b6b65 Mon Sep 17 00:00:00 2001 From: kade-github <26305836+Kade-github@users.noreply.github.com> Date: Thu, 19 Jun 2025 15:52:41 -0700 Subject: [PATCH] Offsets menu wip --- assets | 2 +- project.hxp | 1 + source/funkin/Conductor.hx | 24 +- source/funkin/FunkinMemory.hx | 2 + source/funkin/Preferences.hx | 20 + source/funkin/input/PreciseInputManager.hx | 2 +- source/funkin/play/PauseSubState.hx | 80 +- source/funkin/play/notes/NoteSprite.hx | 2 + source/funkin/play/notes/Strumline.hx | 41 +- source/funkin/save/Save.hx | 7 +- source/funkin/ui/AtlasText.hx | 2 +- source/funkin/ui/MenuList.hx | 10 +- source/funkin/ui/TextMenuList.hx | 4 +- source/funkin/ui/debug/DebugMenuSubState.hx | 9 - .../funkin/ui/debug/latency/LatencyState.hx | 500 ----------- source/funkin/ui/options/OffsetMenu.hx | 849 ++++++++++++++++++ source/funkin/ui/options/OptionsState.hx | 63 +- source/funkin/util/GRhythmUtil.hx | 12 +- 18 files changed, 1077 insertions(+), 553 deletions(-) delete mode 100644 source/funkin/ui/debug/latency/LatencyState.hx create mode 100644 source/funkin/ui/options/OffsetMenu.hx diff --git a/assets b/assets index 8c1fe1619..028e633af 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 8c1fe161958a7b14cea1b84e0d342f15b342731e +Subproject commit 028e633afa4e96f160dda735a83e43add1e7a04c diff --git a/project.hxp b/project.hxp index 2a269eb5a..c314b5734 100644 --- a/project.hxp +++ b/project.hxp @@ -790,6 +790,7 @@ class Project extends HXProject FEATURE_OPEN_URL.apply(this, true); FEATURE_PARTIAL_SOUNDS.apply(this, true); FEATURE_POLYMOD_MODS.apply(this, true); + FEATURE_INPUT_OFFSETS.apply(this, true); // Video playback should be enabled on desktop, web, and mobile. // HXCPP has trouble with iOS simulator builds though... diff --git a/source/funkin/Conductor.hx b/source/funkin/Conductor.hx index ab339e6e7..d4b8f7881 100644 --- a/source/funkin/Conductor.hx +++ b/source/funkin/Conductor.hx @@ -248,25 +248,18 @@ class Conductor * No matter if you're using a local conductor or not, this always loads * to/from the save file */ - public var inputOffset(get, set):Int; + public var globalOffset(get, never):Int; /** * An offset set by the user to compensate for audio/visual lag * No matter if you're using a local conductor or not, this always loads * to/from the save file */ - public var audioVisualOffset(get, set):Int; + public var audioVisualOffset(get, never):Int; - function get_inputOffset():Int + function get_globalOffset():Int { - return Save?.instance?.options?.inputOffset ?? 0; - } - - function set_inputOffset(value:Int):Int - { - Save.instance.options.inputOffset = value; - Save.instance.flush(); - return Save.instance.options.inputOffset; + return Preferences.globalOffset; } function get_audioVisualOffset():Int @@ -274,18 +267,11 @@ class Conductor return Save?.instance?.options?.audioVisualOffset ?? 0; } - function set_audioVisualOffset(value:Int):Int - { - Save.instance.options.audioVisualOffset = value; - Save.instance.flush(); - return Save.instance.options.audioVisualOffset; - } - public var combinedOffset(get, never):Float; function get_combinedOffset():Float { - return instrumentalOffset + audioVisualOffset + inputOffset; + return instrumentalOffset + formatOffset + globalOffset; } /** diff --git a/source/funkin/FunkinMemory.hx b/source/funkin/FunkinMemory.hx index 025a872b1..0095cfd10 100644 --- a/source/funkin/FunkinMemory.hx +++ b/source/funkin/FunkinMemory.hx @@ -88,6 +88,8 @@ class FunkinMemory permanentCacheSound(Paths.sound("soundtray/VolMAX")); permanentCacheSound(Paths.sound("soundtray/Volup")); permanentCacheSound(Paths.music("freakyMenu/freakyMenu")); + permanentCacheSound(Paths.music("placeholder/placeholder")); + permanentCacheSound(Paths.music("placeholder/drumsPlaceholder")); permanentCacheSound(Paths.sound("missnote1", "shared")); permanentCacheSound(Paths.sound("missnote2", "shared")); permanentCacheSound(Paths.sound("missnote3", "shared")); diff --git a/source/funkin/Preferences.hx b/source/funkin/Preferences.hx index 33f80b680..93053aa57 100644 --- a/source/funkin/Preferences.hx +++ b/source/funkin/Preferences.hx @@ -246,6 +246,26 @@ class Preferences return value; } + /** + * A global audio offset in milliseconds. + * This is used to sync the audio. + * @default `0` + */ + public static var globalOffset(get, set):Int; + + static function get_globalOffset():Int + { + return Save?.instance?.options?.globalOffset ?? 0; + } + + static function set_globalOffset(value:Int):Int + { + var save:Save = Save.instance; + save.options.globalOffset = value; + save.flush(); + return value; + } + /** * If enabled, the game will utilize VSync (or adaptive VSync) on startup. * @default `OFF` diff --git a/source/funkin/input/PreciseInputManager.hx b/source/funkin/input/PreciseInputManager.hx index ade45b6f0..46dbef905 100644 --- a/source/funkin/input/PreciseInputManager.hx +++ b/source/funkin/input/PreciseInputManager.hx @@ -294,7 +294,7 @@ class PreciseInputManager extends FlxKeyManager // TODO: Remove this line with SDL3 when timestamps change meaning. // This is because SDL3's timestamps are measured in nanoseconds, not milliseconds. timestamp *= Constants.NS_PER_MS; // 18126000000 38367000000 - timestamp -= Conductor.instance.inputOffset * Constants.NS_PER_MS; + // timestamp -= globalOffset * Constants.NS_PER_MS; // trace(timestamp); updateKeyStates(key, true); diff --git a/source/funkin/play/PauseSubState.hx b/source/funkin/play/PauseSubState.hx index 01b73e294..4c9e68115 100644 --- a/source/funkin/play/PauseSubState.hx +++ b/source/funkin/play/PauseSubState.hx @@ -198,6 +198,16 @@ class PauseSubState extends MusicBeatSubState */ var metadataArtist:FlxText; + /** + * A text object that displays the current global offset. + */ + var offsetText:FlxText; + + /** + * A text object that displays information about the current global offset. + */ + var offsetTextInfo:FlxText; + /** * The actual text objects for the menu entries. */ @@ -420,11 +430,29 @@ class PauseSubState extends MusicBeatSubState metadataPractice.scrollFactor.set(0, 0); metadata.add(metadataPractice); + // Left side + offsetText = new FlxText(20, metadataSong.y - 12, camera.width - Math.max(40, funkin.ui.FullScreenScaleMode.gameNotchSize.x), + 'Global Offset: ${Preferences.globalOffset ?? 0}ms'); + offsetText.setFormat(Paths.font('vcr.ttf'), 16, FlxColor.WHITE, FlxTextAlign.LEFT); + offsetText.scrollFactor.set(0, 0); + + offsetTextInfo = new FlxText(20, offsetText.y + 16, camera.width - Math.max(40, funkin.ui.FullScreenScaleMode.gameNotchSize.x), + 'Hold SHIFT and use UP/DOWN to change the offset.'); + offsetTextInfo.setFormat(Paths.font('vcr.ttf'), 16, FlxColor.WHITE, FlxTextAlign.LEFT); + offsetTextInfo.scrollFactor.set(0, 0); + + #if !mobile + metadata.add(offsetText); + metadata.add(offsetTextInfo); + #end + metadataArtist.alpha = 0; metadataPractice.alpha = 0; metadataSong.alpha = 0; metadataDifficulty.alpha = 0; metadataDeaths.alpha = 0; + offsetText.alpha = 0; + offsetTextInfo.alpha = 0; updateMetadataText(); } @@ -532,6 +560,9 @@ class PauseSubState extends MusicBeatSubState // Input Handling // =============== + var fastOffset:Bool = false; + var lastOffsetPress:Float = 0; + /** * Process user inputs every frame. */ @@ -543,6 +574,51 @@ class PauseSubState extends MusicBeatSubState final upP:Bool = controls.UI_UP_P; final downP:Bool = controls.UI_DOWN_P; + #if !mobile + final up:Bool = controls.UI_UP; + final down:Bool = controls.UI_DOWN; + var offset:Int = Preferences.globalOffset ?? 0; + if (FlxG.keys.pressed.SHIFT && (up || down)) + { + lastOffsetPress += FlxG.elapsed; + if (!fastOffset) + { + // If the last offset press was more than 0.5 seconds ago, reset the fast offset. + if (lastOffsetPress > 0.5) + { + fastOffset = true; + lastOffsetPress = 0; + } + + if (upP || downP) + { + offset += (upP || up) ? 1 : -1; + + offsetText.text = 'Global Offset: ${offset}ms'; + } + } + else + { + offset += (upP || up) ? 1 : -1; + + offsetText.text = 'Global Offset: ${offset}ms'; + } + + if (offset > 1500) offset = 1500; + if (offset < -1500) offset = -1500; + + Preferences.globalOffset = offset; + + return; + } + else + { + // Reset the fast offset if the user is not holding SHIFT. + fastOffset = false; + lastOffsetPress = 0; + } + #end + if (upP) { changeSelection(-1); @@ -552,7 +628,7 @@ class PauseSubState extends MusicBeatSubState changeSelection(1); } - if (!SwipeUtil.justSwipedAny && !justOpened) + if (!SwipeUtil.justSwipedAny && !justOpened && currentMenuEntries.length > 0) { for (i in 0...menuEntryText.members.length) { @@ -572,7 +648,7 @@ class PauseSubState extends MusicBeatSubState } } - if (controls.ACCEPT) + if (controls.ACCEPT && currentMenuEntries.length > 0) { currentMenuEntries[currentEntry].callback(this); } diff --git a/source/funkin/play/notes/NoteSprite.hx b/source/funkin/play/notes/NoteSprite.hx index 509bfc3e6..0962521fa 100644 --- a/source/funkin/play/notes/NoteSprite.hx +++ b/source/funkin/play/notes/NoteSprite.hx @@ -154,6 +154,8 @@ class NoteSprite extends FunkinSprite this.hsvShader = new HSVShader(); + this.alpha = 1; + setupNoteGraphic(noteStyle); } diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx index 4f0b2ffc6..66ea2d383 100644 --- a/source/funkin/play/notes/Strumline.hx +++ b/source/funkin/play/notes/Strumline.hx @@ -543,7 +543,6 @@ class Strumline extends FlxSpriteGroup for (noteIndex in nextNoteIndex...noteData.length) { var note:Null = noteData[noteIndex]; - if (note == null) continue; // Note is blank if (note.time < songStart || note.time < hitWindowStart) { @@ -569,7 +568,6 @@ class Strumline extends FlxSpriteGroup for (note in notes.members) { if (note == null || !note.alive) continue; - // Set the note's position. if (!customPositionData) note.y = this.y - INITIAL_OFFSET @@ -787,6 +785,7 @@ class Strumline extends FlxSpriteGroup */ public function onBeatHit():Void { + // why are we doing this every beat? >:( if (notes.members.length > 1) notes.members.insertionSort(compareNoteSprites.bind(FlxSort.ASCENDING)); if (holdNotes.members.length > 1) holdNotes.members.insertionSort(compareHoldNoteSprites.bind(FlxSort.ASCENDING)); @@ -880,6 +879,20 @@ class Strumline extends FlxSpriteGroup this.noteData.insertionSort(compareNoteData.bind(FlxSort.ASCENDING)); } + /** + * Add a note data to the strumline. + * This will not remove existing notes, so you should call `applyNoteData` if you want to reset the strumline. + * @param note The note data to add. + * @param sort Whether to sort the note data after adding. + */ + public function addNoteData(note:SongNoteData, sort:Bool = true):Void + { + if (note == null) return; + + this.noteData.push(note); + if (sort) this.noteData.sort(compareNoteData.bind(FlxSort.ASCENDING)); + } + /** * Hit a note. * @param note The note to hit. @@ -1298,6 +1311,18 @@ class Strumline extends FlxSpriteGroup FlxTween.tween(arrow, {y: arrow.y + 10, alpha: 1}, 1, {ease: FlxEase.circOut, startDelay: 0.5 + (0.2 * index)}); } + /** + * Apply a small animation which moves the arrow up and fades it out. + * Used when the song ends in Freeplay mode. + * + * @param index The index of the arrow in the strumline. + * @param arrow The arrow to animate. + */ + public function fadeOutArrow(index:Int, arrow:StrumlineNote):Void + { + FlxTween.tween(arrow, {y: arrow.y - 10, alpha: 0}, 0.5, {ease: FlxEase.circIn}); + } + /** * Play a fade in animation on all arrows in the strumline. * Used when starting a song in Freeplay mode. @@ -1310,6 +1335,18 @@ class Strumline extends FlxSpriteGroup } } + /** + * Play a fade out animation on all arrows in the strumline. + * Used when ending a song in Freeplay mode. + */ + public function fadeOutArrows():Void + { + for (index => arrow in this.strumlineNotes.members.keyValueIterator()) + { + fadeOutArrow(index, arrow); + } + } + /** * Compare two note data objects by their strumtime. * @param order The order to sort the notes in. diff --git a/source/funkin/save/Save.hx b/source/funkin/save/Save.hx index b5eee3090..056c4a121 100644 --- a/source/funkin/save/Save.hx +++ b/source/funkin/save/Save.hx @@ -22,7 +22,7 @@ import funkin.api.newgrounds.Leaderboards; @:nullSafety class Save { - public static final SAVE_DATA_VERSION:thx.semver.Version = "2.1.0"; + public static final SAVE_DATA_VERSION:thx.semver.Version = "2.1.1"; public static final SAVE_DATA_VERSION_RULE:thx.semver.VersionRule = ">=2.1.0 <2.2.0"; // We load this version's saves from a new save path, to maintain SOME level of backwards compatibility. @@ -127,7 +127,7 @@ class Save vsyncMode: 'Off', strumlineBackgroundOpacity: 0, autoFullscreen: false, - inputOffset: 0, + globalOffset: 0, audioVisualOffset: 0, unlockedFramerate: false, @@ -1582,9 +1582,10 @@ typedef SaveDataOptions = * Offset the user's inputs by this many ms. * @default `0` */ - var inputOffset:Int; + var globalOffset:Int; /** + * Unused !! * Affects the delay between the audio and the visuals during gameplay. * @default `0` */ diff --git a/source/funkin/ui/AtlasText.hx b/source/funkin/ui/AtlasText.hx index e9441f0b1..cdbe93f51 100644 --- a/source/funkin/ui/AtlasText.hx +++ b/source/funkin/ui/AtlasText.hx @@ -212,7 +212,7 @@ class AtlasChar extends FlxSprite } else { - trace('Could not find animation for char "' + value + '"'); + // trace('Could not find animation for char "' + value + '"'); } updateHitbox(); } diff --git a/source/funkin/ui/MenuList.hx b/source/funkin/ui/MenuList.hx index 152027542..6074e981b 100644 --- a/source/funkin/ui/MenuList.hx +++ b/source/funkin/ui/MenuList.hx @@ -16,6 +16,8 @@ import flixel.tweens.FlxTween; @:nullSafety class MenuTypedList extends FlxTypedGroup { + // Pause input variable + public static var pauseInput:Bool = false; public var selectedIndex(default, null):Int = 0; public var selectedItem(get, never):T; @@ -99,7 +101,7 @@ class MenuTypedList extends FlxTypedGroup { super.update(elapsed); - if (enabled && !busy) updateControls(); + if (enabled && !busy && !pauseInput) updateControls(); } inline function updateControls():Void @@ -290,7 +292,11 @@ class MenuTypedList extends FlxTypedGroup busy = false; } - public function selectItem(index:Int) + /** + * Selects an item in the list. If the item is not available, it will select the next available item. + * @param index The index of the item to select. + */ + public function selectItem(index:Int):Void { members[selectedIndex].idle(); diff --git a/source/funkin/ui/TextMenuList.hx b/source/funkin/ui/TextMenuList.hx index 1125b55ef..eb7711815 100644 --- a/source/funkin/ui/TextMenuList.hx +++ b/source/funkin/ui/TextMenuList.hx @@ -24,9 +24,11 @@ class TextMenuList extends MenuTypedList @:nullSafety class TextMenuItem extends TextTypedMenuItem { + public var atlasText:AtlasText; public function new(x = 0.0, y = 0.0, name:String, font:AtlasFont = BOLD, ?callback:Void->Void, available:Bool = true) { - super(x, y, new AtlasText(0, 0, name, font), name, callback, available); + atlasText = new AtlasText(0, 0, name, font); + super(x, y, atlasText, name, callback, available); setEmptyBackground(); } } diff --git a/source/funkin/ui/debug/DebugMenuSubState.hx b/source/funkin/ui/debug/DebugMenuSubState.hx index dd004a966..0b8b30b14 100644 --- a/source/funkin/ui/debug/DebugMenuSubState.hx +++ b/source/funkin/ui/debug/DebugMenuSubState.hx @@ -63,9 +63,6 @@ class DebugMenuSubState extends MusicBeatSubState #if FEATURE_RESULTS_DEBUG createItem("RESULTS SCREEN DEBUG", openTestResultsScreen); #end - // createItem("Input Offset Testing", openInputOffsetTesting); - // createItem("CHARACTER SELECT", openCharSelect, true); - // createItem("TEST STICKERS", testStickers); #if sys createItem("OPEN CRASH LOG FOLDER", openLogFolder); #end @@ -107,12 +104,6 @@ class DebugMenuSubState extends MusicBeatSubState FlxG.switchState(() -> new ChartEditorState()); } - function openInputOffsetTesting():Void - { - openSubState(new funkin.ui.debug.latency.LatencyState()); - trace('Input Offset Testing'); - } - function openCharSelect():Void { FlxG.switchState(() -> new funkin.ui.charSelect.CharSelectSubState()); diff --git a/source/funkin/ui/debug/latency/LatencyState.hx b/source/funkin/ui/debug/latency/LatencyState.hx deleted file mode 100644 index cb9ade241..000000000 --- a/source/funkin/ui/debug/latency/LatencyState.hx +++ /dev/null @@ -1,500 +0,0 @@ -package funkin.ui.debug.latency; - -import funkin.data.notestyle.NoteStyleRegistry; -import flixel.FlxSprite; -import flixel.group.FlxGroup.FlxTypedGroup; -import flixel.math.FlxMath; -import funkin.ui.MusicBeatSubState; -import flixel.sound.FlxSound; -import flixel.text.FlxText; -import flixel.util.FlxColor; -import funkin.input.PreciseInputManager; -import funkin.play.notes.Strumline; -import funkin.data.song.SongData.SongNoteData; -#if mobile -import funkin.util.TouchUtil; -import funkin.mobile.ui.FunkinHitbox; -import funkin.play.notes.NoteDirection; -import funkin.graphics.FunkinSprite; -#end -import haxe.Timer; -import flixel.FlxCamera; - -class LatencyState extends MusicBeatSubState -{ - var visualOffsetText:FlxText; - #if mobile - var visualOffsetLeftArrow:FlxText; - var visualOffsetRightArrow:FlxText; - #end - - var offsetText:FlxText; - #if mobile - var offsetLeftArrow:FlxText; - var offsetRightArrow:FlxText; - #end - - var helpText:FlxText; - var noteGrp:Array = []; - var strumLine:Strumline; - - var blocks:FlxTypedGroup; - - var songPosVis:FlxSprite; - var songVisFollowVideo:FlxSprite; - var songVisFollowAudio:FlxSprite; - - var beatTrail:FlxSprite; - var diffGrp:FlxTypedGroup; - var offsetsPerBeat:Array> = []; - var swagSong:HomemadeMusic; - - var previousVolume:Float; - - var stateCamera:FlxCamera; - - /** - * A local conductor instance for this testing class, in-case we are in a PlayState - * because I'm too lazy to set the old variables for conductor stuff ! - */ - var localConductor:Conductor; - - // stores values of what the previous persistent draw/update stuff was, example if opened - // from pause menu, we want to NOT draw persistently, but then resume drawing once closed - var prevPersistentDraw:Bool; - var prevPersistentUpdate:Bool; - - override function create() - { - super.create(); - - prevPersistentDraw = FlxG.state.persistentDraw; - prevPersistentUpdate = FlxG.state.persistentUpdate; - - FlxG.state.persistentDraw = false; - FlxG.state.persistentUpdate = false; - - localConductor = new Conductor(); - conductorInUse = localConductor; - - stateCamera = new FlxCamera(0, 0, FlxG.width, FlxG.height); - stateCamera.bgColor = FlxColor.BLACK; - FlxG.cameras.add(stateCamera); - - var bg:FlxSprite = new FlxSprite().makeGraphic(FlxG.width, FlxG.height, FlxColor.BLACK); - add(bg); - - if (FlxG.sound.music != null) - { - previousVolume = FlxG.sound.music.volume; - FlxG.sound.music.volume = 0; // only want to mute the volume, incase we are coming from pause menu - } - else - previousVolume = 1; // defaults to 1 if no music is playing 🤔 also fuck it, emoji in code comment - - swagSong = new HomemadeMusic(); - swagSong.loadEmbedded(Paths.sound('soundTest'), true); - swagSong.looped = true; - swagSong.play(); - FlxG.sound.list.add(swagSong); - - PreciseInputManager.instance.onInputPressed.add(preciseInputPressed); - - PreciseInputManager.instance.onInputReleased.add(preciseInputReleased); - - localConductor.forceBPM(60); - - Conductor.instance.forceBPM(60); - - diffGrp = new FlxTypedGroup(); - add(diffGrp); - - for (beat in 0...Math.floor(swagSong.length / (localConductor.stepLengthMs * 2))) - { - var beatTick:FlxSprite = new FlxSprite(songPosToX(beat * (localConductor.stepLengthMs * 2)), FlxG.height - 15); - beatTick.makeGraphic(2, 15); - beatTick.alpha = 0.3; - add(beatTick); - - var offsetTxt:FlxText = new FlxText(songPosToX(beat * (localConductor.stepLengthMs * 2)), FlxG.height - 26, 0, ""); - offsetTxt.alpha = 0.5; - diffGrp.add(offsetTxt); - - offsetsPerBeat.push(null); - } - - songVisFollowAudio = new FlxSprite(0, FlxG.height - 20).makeGraphic(2, 20, FlxColor.YELLOW); - add(songVisFollowAudio); - - songVisFollowVideo = new FlxSprite(0, FlxG.height - 20).makeGraphic(2, 20, FlxColor.BLUE); - add(songVisFollowVideo); - - songPosVis = new FlxSprite(0, FlxG.height - 20).makeGraphic(2, 20, FlxColor.RED); - add(songPosVis); - - beatTrail = new FlxSprite(0, songPosVis.y).makeGraphic(2, 20, FlxColor.PURPLE); - beatTrail.alpha = 0.7; - add(beatTrail); - - blocks = new FlxTypedGroup(); - add(blocks); - - for (i in 0...8) - { - var block = new FlxSprite(2, ((FlxG.height / 8) + 2) * i).makeGraphic(Std.int(FlxG.height / 8), Std.int((FlxG.height / 8) - 4)); - block.alpha = 0.1; - blocks.add(block); - } - - var strumlineBG:FlxSprite = new FlxSprite(); - add(strumlineBG); - - strumLine = new Strumline(NoteStyleRegistry.instance.fetchDefault(), true); - strumLine.conductorInUse = localConductor; - strumLine.screenCenter(); - add(strumLine); - - strumlineBG.x = strumLine.x; - strumlineBG.makeGraphic(Std.int(strumLine.width), FlxG.height, 0xFFFFFFFF); - strumlineBG.alpha = 0.1; - - visualOffsetText = new FlxText(); - visualOffsetText.setFormat(Paths.font("vcr.ttf"), 20); - visualOffsetText.x = (FlxG.height / 8) + 10; - visualOffsetText.y = 10; - visualOffsetText.fieldWidth = strumLine.x - visualOffsetText.x - 10; - add(visualOffsetText); - - #if mobile - visualOffsetLeftArrow = new FlxText(); - visualOffsetLeftArrow.setFormat(Paths.font("vcr.ttf"), 60); - visualOffsetLeftArrow.text = "<"; - visualOffsetLeftArrow.x = visualOffsetText.x; - visualOffsetLeftArrow.y = visualOffsetText.y * 4; - add(visualOffsetLeftArrow); - - visualOffsetRightArrow = new FlxText(); - visualOffsetRightArrow.setFormat(Paths.font("vcr.ttf"), 60); - visualOffsetRightArrow.text = ">"; - visualOffsetRightArrow.x = visualOffsetLeftArrow.x * 2; - visualOffsetRightArrow.y = visualOffsetLeftArrow.y; - add(visualOffsetRightArrow); - #end - - offsetText = new FlxText(); - offsetText.setFormat(Paths.font("vcr.ttf"), 20); - offsetText.x = strumLine.x + strumLine.width + 10; - offsetText.y = 10; - offsetText.fieldWidth = FlxG.width - offsetText.x - 10; - add(offsetText); - - #if mobile - offsetLeftArrow = new FlxText(); - offsetLeftArrow.setFormat(Paths.font("vcr.ttf"), 60); - offsetLeftArrow.text = "<"; - offsetLeftArrow.x = offsetText.x; - offsetLeftArrow.y = offsetText.y * 9; - add(offsetLeftArrow); - - offsetRightArrow = new FlxText(); - offsetRightArrow.setFormat(Paths.font("vcr.ttf"), 60); - offsetRightArrow.text = ">"; - offsetRightArrow.x = offsetLeftArrow.x * 1.2; - offsetRightArrow.y = offsetLeftArrow.y; - add(offsetRightArrow); - #end - - helpText = new FlxText(); - helpText.setFormat(Paths.font("vcr.ttf"), 20 #if mobile * 3 #end); - helpText.text = #if mobile "Back" #else "Press BACK to return to options menu" #end; - helpText.x = FlxG.width - helpText.width #if mobile * 1.5 #end; - helpText.y = FlxG.height - (helpText.height * 2) - 2; - add(helpText); - - #if mobile - addHitbox(true, true, FunkinHitboxControlSchemes.FourLanes, [NoteDirection.DOWN], [0xFF00FFFF]); - - hitbox.forEachAlive(function(hint:FunkinHint) { - @:privateAccess - if (hint.label != null) hint.label.visible = false; - - for (text in [ - helpText, - visualOffsetLeftArrow, - visualOffsetRightArrow, - offsetLeftArrow, - offsetRightArrow - ]) - hint.deadZones.push(cast(text, FunkinSprite)); - }); - #end - - regenNoteData(); - } - - function preciseInputPressed(event:PreciseInputEvent) - { - generateBeatStuff(event); - strumLine.pressKey(event.noteDirection); - strumLine.playPress(event.noteDirection); - } - - function preciseInputReleased(event:PreciseInputEvent) - { - strumLine.playStatic(event.noteDirection); - strumLine.releaseKey(event.noteDirection); - } - - override public function destroy():Void - { - cleanup(); - super.destroy(); - } - - function cleanup():Void - { - PreciseInputManager.instance.onInputPressed.remove(preciseInputPressed); - PreciseInputManager.instance.onInputReleased.remove(preciseInputReleased); - - if (FlxG.sound.music != null) - { - FlxG.sound.music.volume = previousVolume; - } - - swagSong.stop(); - FlxG.sound.list.remove(swagSong); - - FlxG.cameras.remove(stateCamera); - - FlxG.state.persistentDraw = prevPersistentDraw; - FlxG.state.persistentUpdate = prevPersistentUpdate; - } - - function regenNoteData() - { - for (i in 0...32) - { - var note:SongNoteData = new SongNoteData((localConductor.stepLengthMs * 2) * i, 1); - noteGrp.push(note); - } - - strumLine.applyNoteData(noteGrp); - } - - override function stepHit():Bool - { - if (localConductor.currentStep % 4 == 2) - { - blocks.members[((localConductor.currentBeat % 8) + 1) % 8].alpha = 0.5; - } - - return super.stepHit(); - } - - override function beatHit():Bool - { - if (localConductor.currentBeat % 8 == 0) blocks.forEach(blok -> { - blok.alpha = 0.1; - }); - - blocks.members[localConductor.currentBeat % 8].alpha = 1; - // block.visible = !block.visible; - - return super.beatHit(); - } - - override function update(elapsed:Float) - { - /* trace("1: " + swagSong.frfrTime); - @:privateAccess - trace(FlxG.sound.music._channel.position); - */ - - localConductor.update(swagSong.time, false); - - // localConductor.songPosition += (Timer.stamp() * 1000) - FlxG.sound.music.prevTimestamp; - - songPosVis.x = songPosToX(localConductor.songPosition); - songVisFollowAudio.x = songPosToX(localConductor.songPosition - localConductor.audioVisualOffset); - songVisFollowVideo.x = songPosToX(localConductor.songPosition - localConductor.inputOffset); - - visualOffsetText.text = "Visual Offset: " + localConductor.audioVisualOffset + "ms"; - #if !mobile - visualOffsetText.text += "\n\nYou can press SPACE+Left/Right to change this value."; - visualOffsetText.text += "\n\nYou can hold SHIFT to step 1ms at a time"; - #end - - #if mobile - offsetText.text = "INPUT Offset: " + localConductor.inputOffset + "ms"; - #else - offsetText.text = "INPUT Offset (Left/Right to change): " + localConductor.inputOffset + "ms"; - offsetText.text += "\n\nYou can hold SHIFT to step 1ms at a time"; - #end - - var avgOffsetInput:Float = 0; - - var loopInd:Int = 0; - for (offsetThing in offsetsPerBeat) - { - if (offsetThing == null) continue; - avgOffsetInput += offsetThing; - loopInd++; - } - - avgOffsetInput /= loopInd; - - offsetText.text += "\n\nEstimated average input offset needed: " + avgOffsetInput; - - var multiply:Int = 10; - - if (FlxG.keys.pressed.SHIFT || FlxG.onMobile) multiply = 1; - - #if mobile - handleSelectionInputMobile(multiply); - #else - handleSelectionInput(multiply); - #end - - if (controls.BACK #if mobile || (TouchUtil.pressAction(helpText)) #end) - { - // close(); - cleanup(); - FlxG.switchState(() -> new funkin.ui.options.OptionsState()); - } - - super.update(elapsed); - } - - function handleSelectionInput(multiply:Int) - { - if (FlxG.keys.pressed.CONTROL || FlxG.keys.pressed.SPACE) - { - if (FlxG.keys.justPressed.RIGHT) - { - localConductor.audioVisualOffset += 1 * multiply; - } - - if (FlxG.keys.justPressed.LEFT) - { - localConductor.audioVisualOffset -= 1 * multiply; - } - } - else - { - if (FlxG.keys.anyJustPressed([LEFT, RIGHT])) - { - if (FlxG.keys.justPressed.RIGHT) - { - localConductor.inputOffset += 1 * multiply; - } - - if (FlxG.keys.justPressed.LEFT) - { - localConductor.inputOffset -= 1 * multiply; - } - - // reset the average, so you don't need to wait a full loop to start getting averages - // also reset each text member - offsetsPerBeat = []; - diffGrp.forEach(memb -> memb.text = ""); - } - } - } - - #if mobile - function handleSelectionInputMobile(multiply:Int) - { - final touchPressCheck:Bool = TouchUtil.justPressed - || TouchUtil.pressed - && TouchUtil.touch != null - && TouchUtil.touch.ticksDeltaSincePress >= 200; - - if (TouchUtil.overlapsComplex(visualOffsetRightArrow) && touchPressCheck) - { - cleanup(); - FlxG.switchState(() -> new funkin.ui.options.OptionsState()); - localConductor.audioVisualOffset += 1 * multiply; - } - - if (TouchUtil.overlapsComplex(visualOffsetLeftArrow) && touchPressCheck) - { - localConductor.audioVisualOffset -= 1 * multiply; - } - - if ((TouchUtil.overlapsComplex(offsetRightArrow) || TouchUtil.overlapsComplex(offsetLeftArrow)) && touchPressCheck) - { - if (TouchUtil.overlapsComplex(offsetRightArrow) && touchPressCheck) - { - localConductor.inputOffset += 1 * multiply; - } - - if (TouchUtil.overlapsComplex(offsetLeftArrow) && touchPressCheck) - { - localConductor.inputOffset -= 1 * multiply; - } - - // reset the average, so you don't need to wait a full loop to start getting averages - // also reset each text member - offsetsPerBeat = []; - diffGrp.forEach(memb -> memb.text = ""); - } - } - #end - - function generateBeatStuff(event:PreciseInputEvent) - { - // localConductor.update(swagSong.getTimeWithDiff()); - - var inputLatencyMs:Float = haxe.Int64.toInt(PreciseInputManager.getCurrentTimestamp() - event.timestamp) / 1000.0 / 1000.0; - // trace("input latency: " + inputLatencyMs + "ms"); - // trace("cur timestamp: " + PreciseInputManager.getCurrentTimestamp() + "ns"); - // trace("event timestamp: " + event.timestamp + "ns"); - // trace("songtime: " + localConductor.getTimeWithDiff(swagSong) + "ms"); - - var closestBeat:Int = Math.round(localConductor.getTimeWithDiff(swagSong) / (localConductor.stepLengthMs * 2)) % diffGrp.members.length; - var getDiff:Float = localConductor.getTimeWithDiff(swagSong) - (closestBeat * (localConductor.stepLengthMs * 2)); - // getDiff -= localConductor.inputOffset; - getDiff -= inputLatencyMs; - getDiff -= localConductor.audioVisualOffset; - - // lil fix for end of song - if (closestBeat == 0 && getDiff >= localConductor.stepLengthMs * 2) getDiff -= swagSong.length; - - beatTrail.x = songPosVis.x; - - diffGrp.members[closestBeat].text = getDiff + "ms"; - offsetsPerBeat[closestBeat] = Math.round(getDiff); - } - - function songPosToX(pos:Float):Float - { - return FlxMath.remapToRange(pos, 0, swagSong.length, 0, FlxG.width); - } -} - -class HomemadeMusic extends FlxSound -{ - public var prevTimestamp:Int = 0; - - public function new() - { - super(); - } - - var prevTime:Float = 0; - - override function update(elapsed:Float) - { - super.update(elapsed); - if (prevTime != time) - { - prevTime = time; - prevTimestamp = Std.int(Timer.stamp() * 1000); - } - } - - public function getTimeWithDiff():Float - { - return time + (Std.int(Timer.stamp() * 1000) - prevTimestamp); - } -} diff --git a/source/funkin/ui/options/OffsetMenu.hx b/source/funkin/ui/options/OffsetMenu.hx new file mode 100644 index 000000000..7a793477d --- /dev/null +++ b/source/funkin/ui/options/OffsetMenu.hx @@ -0,0 +1,849 @@ +package funkin.ui.options; + +import funkin.ui.MenuList.MenuTypedList; +import funkin.ui.TextMenuList.TextMenuItem; +import funkin.util.GRhythmUtil; +import funkin.mobile.ui.FunkinBackButton; +#if mobile +import funkin.mobile.ui.FunkinHitbox; +import funkin.mobile.ui.FunkinHitbox.FunkinHitboxControlSchemes; +import funkin.util.TouchUtil; +#end +import funkin.input.PreciseInputManager; +import funkin.audio.FunkinSound; +import funkin.play.notes.Strumline; +import funkin.play.notes.NoteSprite; +import funkin.graphics.FunkinCamera; +import funkin.graphics.FunkinSprite; +import funkin.data.song.SongData.SongNoteData; +import funkin.data.notestyle.NoteStyleRegistry; +import funkin.play.notes.notestyle.NoteStyle; +import funkin.ui.options.items.NumberPreferenceItem; + +import haxe.Int64; +import flixel.FlxSprite; +import flixel.text.FlxText; +import flixel.FlxObject; +import flixel.util.FlxColor; +import flixel.math.FlxMath; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; + +/* + ArrowData is a structure that holds the sprite and beat of an arrow. + @param sprite The sprite of the arrow. + @param beat The beat of the arrow. + */ +typedef ArrowData = +{ + var sprite:FunkinSprite; + // var debugText:FlxText; + var beat:Float; + var direction:Int; // 0 = left, 1 = down, 2 = up, 3 = right +}; + +class OffsetMenu extends Page +{ + static final BPM:Int = 100; + + // var debugBeatText:FlxText; + var jumpInText:FlxText; + var countText:FlxText; + + var arrows:Array = []; + var receptor:FunkinSprite; + var offsetItem:NumberPreferenceItem; + var items:TextMenuList; + var preferenceItems:FlxTypedSpriteGroup; + + var testStrumline:Strumline; + + var menuCamera:FunkinCamera; + var camFollow:FlxObject; + var savedOffset:Int = 0; + + var tempOffset:Int = 0; + + var blackRect:FlxSprite; + + var lerped:Float = 0; + + var calibrating:Bool = false; + + var shouldOffset:Int = 0; + var offsetLerp:Float = 0; + + var scaleModifier:Float = 1; + + var localConductor:Conductor; + + var arrowBeat = 0; + + var _gotMad:Bool = false; + + var differences:Array = []; + + var appliedOffsetLerp:Float = 0; + + var backButton:FunkinBackButton; + + var msPerBeat(get, never):Float; + + // The milliseconds per beat, calculated from the BPM. + function get_msPerBeat():Float + { + return 60000 / BPM; + } + + /** + * Key press inputs which have been received but not yet processed. + * These are encoded with an OS timestamp, so we can account for input latency. + **/ + var inputPressQueue:Array = []; + + /** + * Key release inputs which have been received but not yet processed. + * These are encoded with an OS timestamp, so we can account for input latency. + **/ + var inputReleaseQueue:Array = []; + + /* + Creates an arrow at the specified beat. + The arrow will be positioned below the screen and will move up to the receptor. + @param beat The beat at which to create the arrow. + */ + public function createArrow(beat:Float):Void + { + var arrow = new FunkinSprite(0, 0); + arrow.loadGraphic(Paths.image('latencyArrow')); + arrow.origin.set(0.5, 0.5); + arrow.setPosition(FlxG.width / 2, FlxG.height + arrow.height); // Below the screen + arrow.updateHitbox(); + add(arrow); + + /*var debugText = new FlxText(0, 0); + debugText.setFormat(Paths.font('vcr.ttf'), 16, FlxColor.WHITE, FlxTextAlign.CENTER); + debugText.text = 'Beat: ' + beat; + debugText.setBorderStyle(FlxTextBorderStyle.OUTLINE, FlxColor.BLACK); + add(debugText); */ + + arrows.push( + { + sprite: arrow, + /*debugText: debugText,*/ + beat: beat, + direction: 0 + }); + } + + /* + Creates a directed arrow at the specified beat and direction. + The direction can be 0 (left), 1 (down), 2 (up), or 3 (right). + @param beat The beat at which to create the arrow. + @param direction The direction of the arrow. + */ + public function createDirectedArrow(beat:Float, direction:Int):Void + { + var arrow = new FunkinSprite(0, 0); + arrow.loadGraphic(Paths.image('latencyArrow')); + arrow.origin.set(0.5, 0.5); + arrow.setPosition(FlxG.width / 2, FlxG.height + arrow.height); // Below the screen + arrow.updateHitbox(); + add(arrow); + + arrows.push({sprite: arrow, beat: beat, direction: direction}); + } + + /* + Gets the arrow at the specified beat. + @param beat The beat at which to get the arrow. + @return The ArrowData object containing the sprite and beat, or null if no arrow is found. + */ + public function getArrowAtBeat(beat:Float):ArrowData + { + for (arrow in arrows) + { + if (arrow.beat == beat) return arrow; + } + return null; + } + + /* + Gets the closest arrow to the specified beat. + This is used to find the arrow that is closest to the current time. + @param beat The beat at which to find the closest arrow. + @return The ArrowData object containing the sprite and beat of the closest arrow. + */ + public function getClosestArrowAtBeat(beat:Float):ArrowData + { + var closest:ArrowData = null; + var closestDiff:Float = 1000000; // A large number to start with + + for (arrow in arrows) + { + var diff:Float = arrow.beat - beat; + // trace('Checking arrow at beat: ' + arrow.beat + ' (diff: ' + diff + ')'); + if (diff < closestDiff) + { + closestDiff = diff; + closest = arrow; + } + } + + // trace('Closest arrow at beat: ' + (closest != null ? closest.beat : 0) + ' (diff: ' + closestDiff + ')'); + + return closest; + } + + public function new() + { + super(); + + localConductor = new Conductor(); + localConductor.forceBPM(100); + + menuCamera = new FunkinCamera('prefMenu'); + FlxG.cameras.add(menuCamera, false); + menuCamera.bgColor = 0x0; + + camera = menuCamera; + + blackRect = new FlxSprite(0, 0); + blackRect.makeGraphic(FlxG.width, FlxG.height, FlxColor.BLACK); + blackRect.alpha = 0; + blackRect.scrollFactor.set(0, 0); + add(blackRect); + + /*debugBeatText = new FlxText(0, 0); + debugBeatText.setFormat(Paths.font('vcr.ttf'), 16, FlxColor.WHITE, FlxTextAlign.LEFT); + debugBeatText.setBorderStyle(FlxTextBorderStyle.OUTLINE, FlxColor.BLACK); + debugBeatText.setPosition(10, 10); + debugBeatText.scrollFactor.set(0, 0); + add(debugBeatText); + + debugBeatText.alpha = 0; */ + + receptor = new FunkinSprite(0, 0); + receptor.loadGraphic(Paths.image('latencyReceptor')); + receptor.origin.set(0.5, 0.5); + add(receptor); + + var noteStyle:NoteStyle = NoteStyleRegistry.instance.fetchDefault(); + + testStrumline = new Strumline(noteStyle, true); + // center + testStrumline.setPosition(FlxG.width / 2, FlxG.height / 2); + testStrumline.x -= testStrumline.width / 2; + testStrumline.y = Preferences.downscroll ? FlxG.height - testStrumline.height - Constants.STRUMLINE_Y_OFFSET - noteStyle.getStrumlineOffsets()[1] : Constants.STRUMLINE_Y_OFFSET; + testStrumline.scrollFactor.set(0, 0); + add(testStrumline); + + #if mobile + if (Preferences.controlsScheme == FunkinHitboxControlSchemes.Arrows) + { + final amplification:Float = (FlxG.width / FlxG.height) / (FlxG.initialWidth / FlxG.initialHeight); + final playerStrumlineScale:Float = ((FlxG.height / FlxG.width) * 1.95) * amplification; + final playerNoteSpacing:Float = ((FlxG.height / FlxG.width) * 2.8) * amplification; + + testStrumline.strumlineScale.set(playerStrumlineScale, playerStrumlineScale); + testStrumline.setNoteSpacing(playerNoteSpacing); + for (strum in testStrumline) + { + strum.width *= 2; + } + + testStrumline.x = (FlxG.width - testStrumline.width) / 2 + Constants.STRUMLINE_X_OFFSET; + testStrumline.y = (FlxG.height - testStrumline.height) * 0.95 - Constants.STRUMLINE_Y_OFFSET; + testStrumline.y -= 10; + } + #end + + testStrumline.conductorInUse = localConductor; + testStrumline.zIndex = 1001; + for (strum in testStrumline) + { + strum.alpha = 0; + } + + receptor.alpha = 0; + receptor.centerOffsets(); + receptor.scale.set(0, 0); + receptor.centerOrigin(); + receptor.updateHitbox(); + + jumpInText = new FlxText(0, 0); + jumpInText.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.CENTER); + jumpInText.setBorderStyle(FlxTextBorderStyle.OUTLINE, FlxColor.BLACK, 4); + add(jumpInText); + + // below receptor + + countText = new FlxText(0, 0); + countText.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.CENTER); + countText.setBorderStyle(FlxTextBorderStyle.OUTLINE, FlxColor.BLACK, 4); + add(countText); + + jumpInText.alpha = 0; + jumpInText.setPosition(FlxG.width / 2, 150); + jumpInText.scrollFactor.set(0, 0); + + countText.text = ''; + countText.alpha = 0; + countText.setPosition(FlxG.width / 2, 600); + countText.scrollFactor.set(0, 0); + + add(items = new TextMenuList()); + add(preferenceItems = new FlxTypedSpriteGroup()); + + offsetItem = createPrefItemNumber('Offset (Global)', 'Offset (Global)', function(value:Float) { + Preferences.globalOffset = Std.int(value); + }, null, Preferences.globalOffset, -1500, 1500, 1.0, 2); + createButtonItem('Reset Offset', function() { + Preferences.globalOffset = 0; + offsetItem.currentValue = Preferences.globalOffset; + }); + createButtonItem('Offset Calibration', function() { + jumpInText.text = 'Press any key to the beat!\nThe arrow will start to sync to the receptor.'; + #if mobile + jumpInText.text = 'Tap to the beat!\nThe arrow will start to sync to the receptor.'; + #end + + jumpInText.y = 100; + + countText.text = 'Current Offset: 0ms'; + + calibrating = true; + MenuTypedList.pauseInput = true; + OptionsState.instance.drumsBG.fadeIn(1, 0, 1); + canExit = false; + differences = []; + offsetLerp = 0; + savedOffset = Preferences.globalOffset; + Preferences.globalOffset = 0; + shouldOffset = 1; + tempOffset = 0; + appliedOffsetLerp = 0; + + arrowBeat = Math.floor(localConductor.currentBeatTime) + 2; + receptor.angle = 0; + + _gotMad = false; + }); + createButtonItem('Test', function() { + shouldOffset = 1; + jumpInText.y = 350; + + jumpInText.text = 'Hit the notes as they come in!'; + if (OptionsState.instance.hitbox != null) OptionsState.instance.hitbox.visible = true; + MenuTypedList.pauseInput = true; + OptionsState.instance.drumsBG.fadeIn(1, 0, 1); + canExit = false; + differences = []; + + testStrumline.fadeInArrows(); + }); + PreciseInputManager.instance.onInputPressed.add(onKeyPress); + PreciseInputManager.instance.onInputReleased.add(onKeyRelease); + + camFollow = new FlxObject(FlxG.width / 2, 0, 140, 70); + if (items != null) camFollow.y = items.selectedItem.y; + + menuCamera.follow(camFollow, null, 0.085); + var margin = 160; + menuCamera.deadzone.set(0, margin, menuCamera.width, menuCamera.height - margin * 2); + menuCamera.minScrollY = 0; + + items.onChange.add(function(selected) { + camFollow.y = selected.y; + }); + backButton = new FunkinBackButton(FlxG.width - 230, FlxG.height - 200, FlxColor.WHITE, handleMobileExit); + #if FEATURE_TOUCH_CONTROLS + add(backButton); + #end + } + + /** + * Callback executed when one of the note keys is pressed. + */ + function onKeyPress(event:PreciseInputEvent):Void + { + // Do the minimal possible work here. + inputPressQueue.push(event); + } + + /** + * Callback executed when one of the note keys is released. + */ + function onKeyRelease(event:PreciseInputEvent):Void + { + // Do the minimal possible work here. + inputReleaseQueue.push(event); + } + + // Exits the calibration and resets the offset. + public function exitCalibration(cancel:Bool):Void + { + shouldOffset = -1; + if (OptionsState.instance.hitbox != null) OptionsState.instance.hitbox.visible = false; + + if (!calibrating) + { + testStrumline.fadeOutArrows(); + } + tempOffset = 0; + if (cancel) + { + if (calibrating) Preferences.globalOffset = savedOffset; + FunkinSound.playOnce(Paths.sound('cancelMenu')); + } + else + FunkinSound.playOnce(Paths.sound('confirmMenu')); + offsetItem.currentValue = Preferences.globalOffset; + OptionsState.instance.drumsBG.fadeOut(1, 0); + } + + // Handles the exit for mobile devices. + public function handleMobileExit():Void + { + if (shouldOffset == 1) exitCalibration(true); + else if (shouldOffset == 0) exit(); + } + + // Returns the average of the differences in milliseconds. + // Average is the sum of all differences divided by the number of differences. + public function getAverage():Float + { + if (differences.length == 0) return 0; + + var avg:Float = 0; + for (i in 0...differences.length) + { + avg += differences[i]; + } + avg /= differences.length; + + return avg; + } + + // Returns the consistency of the differences. + // Consistency is the average of the squared differences from the mean. + public function getConsistency():Float + { + if (differences.length == 0) return 0; + + var avg:Float = getAverage(); + + var variance:Float = 0; + for (i in 0...differences.length) + { + variance += Math.pow(differences[i] - avg, 2); + } + variance /= differences.length; + + return Math.sqrt(variance); + } + + var _offsetLerpTime:Float = 0; + var _lastOffset:Float = 0; + var _lastDirection:Int = 0; + + /* Adds a difference in milliseconds to the list. + If there are more than 4 differences, it calculates the average and sets the global offset. + This is used for calibrating the offset based on user input. + @param ms The difference in milliseconds to add. + @see Preferences.globalOffset + */ + public function addDifference(ms:Float):Void + { + differences.push(ms); + + if (differences.length > 2 && differences.length % 2 != 0) + { + var avg:Float = getAverage(); + tempOffset = -Std.int(avg); + _lastOffset = appliedOffsetLerp; + _offsetLerpTime = 0; + } + } + + var _lastBeat:Float = 0; + override function update(elapsed:Float):Void + { + super.update(elapsed); + localConductor.update(); + + var b:Float = localConductor.currentBeatTime; + + if (b < _lastBeat) // we restarted the song + { + arrowBeat = Math.floor(b) + 2; + // Update arrows to be the correct distance away from the receptor. + for (i in 0...arrows.length) + { + var arrow:ArrowData = arrows[i]; + var beatDiff:Float = arrow.beat - _lastBeat; + + arrow.beat = b + beatDiff; + } + testStrumline.clean(); + } + + _lastBeat = b; + + if (controls.BACK && shouldOffset == 1) + { + exitCalibration(true); + return; + } + + if (shouldOffset == 1 && calibrating) + { + // Lerp our offset + if (_offsetLerpTime < 1) _offsetLerpTime += elapsed * 2; + else + _offsetLerpTime = 1; + + appliedOffsetLerp = FlxMath.lerp(_lastOffset, tempOffset, _offsetLerpTime); + + countText.text = 'Current Offset: ' + Std.int(appliedOffsetLerp) + 'ms'; + + var toRemove:Array = []; + + // Update arrows + for (i in 0...arrows.length) + { + var arrow:ArrowData = arrows[i]; + + var beatOffset:Float = appliedOffsetLerp / msPerBeat; + + var ms:Float = arrow.beat * msPerBeat; + var offset:Float = GRhythmUtil.getNoteY(ms + appliedOffsetLerp, 2, false, localConductor); + arrow.sprite.y = receptor.y + offset - (arrow.sprite.height / 2); + arrow.sprite.x = receptor.x - (arrow.sprite.width / 2); + + // arrow.debugText.text = 'Beat: ' + arrow.beat; + // arrow.debugText.setPosition(arrow.sprite.x + arrow.sprite.width, arrow.sprite.y - 20); + + if ((arrow.beat + beatOffset) < b - 0.25) + { + arrow.sprite.alpha -= elapsed * 5; + } + + if (arrow.sprite.alpha <= 0) + { + toRemove.push(arrow); + arrow.sprite.kill(); + // arrow.debugText.kill(); + } + } + + // Remove arrows that are marked for removal. + for (arrow in toRemove) + { + // trace("Removing arrow at beat: " + arrow.beat); + arrows.remove(arrow); + } + + while (b >= arrowBeat - 1) + { + // trace("Spawning arrow at beat: " + arrowBeat); + // Create a new arrow at the next beat division. + arrowBeat = arrowBeat + 2; + var nextBeat:Float = arrowBeat; + createArrow(nextBeat); + } + + if (FlxG.keys.justPressed.ANY #if FEATURE_TOUCH_CONTROLS || TouchUtil.justPressed #end) + { + var arrow:ArrowData = getClosestArrowAtBeat(b); + + var closestBeat:Float = Math.round(b); + var diff:Float = closestBeat - b; + var ms:Float = diff * msPerBeat; + + if (arrow != null) // eric sees this and goes "OMG NULL REF!!!!" + { + var beatOffset:Float = appliedOffsetLerp / msPerBeat; + + var arrowDiff:Float = (arrow.beat + beatOffset) - b; + + if (Math.abs(arrowDiff) < 0.25) + { + arrow.sprite.alpha = 0; + arrow.sprite.kill(); + // arrow.debugText.kill(); + arrows.remove(arrow); + } + } + + var consistency:Float = getConsistency(); + + if (consistency > 80 && differences.length > 4) + { + jumpInText.text = 'Try to be a little more consistent with your timing!'; + differences = []; + tempOffset = 0; + appliedOffsetLerp = 0; + _gotMad = true; + return; + } + + addDifference(ms); + + if (differences.length > 16) + { + jumpInText.text = 'Calibration complete!'; + Preferences.globalOffset = tempOffset; + exitCalibration(false); + return; + } + + if (!_gotMad) + { + if (Math.abs(ms + tempOffset) < 45) jumpInText.text = 'Great job, keep going!'; + else + jumpInText.text = 'Nice job, keep going!'; + } + + jumpInText.text += '\n' + differences.length + '/16'; + + _gotMad = false; + + scaleModifier = 0.75; + } + } + else if (shouldOffset == 1) + { + // If we are not calibrating, we are just testing the strumline. + + processInputQueue(); + + while (b >= arrowBeat - 2) + { + // trace("Spawning arrow at beat: " + arrowBeat); + // Create a new arrow at the next beat division. + arrowBeat = arrowBeat + 1; + var data:SongNoteData = new SongNoteData(arrowBeat * msPerBeat, _lastDirection, 0, null, null); + testStrumline.addNoteData(data, false); + + _lastDirection = (_lastDirection + 1) % 4; // Cycle through directions 0-3 + } + } + else + { + var toRemove:Array = []; + for (i in 0...arrows.length) + { + var arrow:ArrowData = arrows[i]; + arrow.sprite.alpha -= elapsed * 5; + if (arrow.sprite.alpha <= 0) + { + arrow.sprite.kill(); + // arrow.debugText.kill(); + toRemove.push(arrow); + } + } + + // Remove arrows that are marked for removal. + for (arrow in toRemove) + { + arrows.remove(arrow); + } + } + if (lerped < 1) lerped += elapsed / 2; + else if (lerped > 1) lerped = 1; + + if (shouldOffset == 1) + { + offsetLerp += elapsed / 2; + if (offsetLerp >= 1) offsetLerp = 1; + } + else if (shouldOffset == -1) + { + offsetLerp -= elapsed / 3; + if (offsetLerp <= 0) + { + canExit = true; + calibrating = false; + MenuTypedList.pauseInput = false; + offsetLerp = 0; + shouldOffset = 0; + } + } + + blackRect.alpha = FlxMath.lerp(0, 0.5, FlxEase.cubeInOut(lerped)); + + var yLerp = FlxMath.lerp(-485, 150, FlxEase.cubeInOut(lerped)); + var xLerp = FlxMath.lerp(0, FlxG.width, FlxEase.cubeInOut(offsetLerp)); + + // center + var recW = receptor.width; + var recH = receptor.height; + jumpInText.x = FlxG.width / 2 - (jumpInText.width / 2); + countText.x = FlxG.width / 2 - (countText.width / 2); + + receptor.x = FlxG.width / 2 - (recW / 2); + receptor.y = FlxG.height / 2 - (recH / 2); + + jumpInText.alpha = FlxMath.lerp(0, 1, FlxEase.cubeInOut(offsetLerp)); + + if (calibrating) + { + receptor.alpha = FlxMath.lerp(0, 1, FlxEase.cubeInOut(offsetLerp)); + // debugBeatText.alpha = FlxMath.lerp(0, 1, FlxEase.cubeInOut(offsetLerp)); + countText.alpha = FlxMath.lerp(0, 1, FlxEase.cubeInOut(offsetLerp)); + } + else + { + backButton.y = FlxMath.lerp(FlxG.height - 200, 50, FlxEase.cubeInOut(offsetLerp)); + } + + if (scaleModifier < 1) + { + // trace("scaleModifier: " + scaleModifier); + scaleModifier += elapsed / 2; + if (scaleModifier >= 1) scaleModifier = 1; + } + + receptor.scale.x = FlxMath.lerp(0, 1, FlxEase.cubeInOut(offsetLerp)) * scaleModifier; + receptor.scale.y = FlxMath.lerp(0, 1, FlxEase.cubeInOut(offsetLerp)) * scaleModifier; + + for (note in testStrumline.notes.members) + { + if (note == null) continue; + + GRhythmUtil.processWindow(note, true, localConductor); + note.alpha = FlxMath.lerp(0, 1, FlxEase.cubeInOut(offsetLerp)); + } + + /*debugBeatText.x = receptor.x + receptor.width * 2; + debugBeatText.y = receptor.y - 20; + + debugBeatText.text = 'Beat: ' + b; */ + + // receptor.angle += angleVel * elapsed; + + var ind = 0; + // Indent the selected item. + items.forEach(function(daItem:TextMenuItem) { + // Initializing thy text width (if thou text present) + var thyTextWidth:Int = 0; + switch (Type.typeof(daItem)) + { + case TClass(NumberPreferenceItem): + var numPref:NumberPreferenceItem = cast(daItem, NumberPreferenceItem); + thyTextWidth = numPref.lefthandText.getWidth(); + + numPref.lefthandText.x = xLerp + + (FlxG.width / 2) + - ((thyTextWidth / 2) + daItem.atlasText.getWidth() / 2); + numPref.lefthandText.y = yLerp + ((120 * ind) + 30); + default: + // Huh? x2 + } + + daItem.y = yLerp + ((120 * ind) + 30); + daItem.x = xLerp + + (FlxG.width / 2) + + thyTextWidth + - daItem.atlasText.getWidth() / 2; + ind++; + }); + } + + function hitNote(note:NoteSprite, input:PreciseInputEvent):Void + { + var inputLatencyNs:Int64 = PreciseInputManager.getCurrentTimestamp() - input.timestamp; + var inputLatencyMs:Float = inputLatencyNs.toFloat() / Constants.NS_PER_MS; + + var noteDiff:Int = Std.int(localConductor.songPosition - note.noteData.time - inputLatencyMs); + + if (noteDiff == 0) + { + jumpInText.text = 'Perfect!'; + } + else + { + jumpInText.text = noteDiff < 0 ? 'Early!\n' + noteDiff + 'ms' : 'Late!\n' + noteDiff + 'ms'; + } + + testStrumline.hitNote(note); + } + + /** + * PreciseInputEvents are put into a queue between update() calls, + * and then processed here. + */ + function processInputQueue():Void + { + if (inputPressQueue.length + inputReleaseQueue.length == 0 || shouldOffset != 1) return; + + var notesInRange:Array = testStrumline.getNotesMayHit(); + + var notesByDirection:Array> = [[], [], [], []]; + + for (note in notesInRange) + notesByDirection[note.direction].push(note); + + while (inputPressQueue.length > 0) + { + var input:PreciseInputEvent = inputPressQueue.shift(); + + testStrumline.pressKey(input.noteDirection); + + var notesInDirection:Array = notesByDirection[input.noteDirection]; + + trace('Processing input: ' + input.noteDirection + ' with ' + notesInDirection.length + ' notes in range.'); + + if (notesInDirection.length == 0) + { + testStrumline.playPress(input.noteDirection); + } + else + { + // Choose the first note, deprioritizing low priority notes. + var targetNote:Null = notesInDirection.find((note) -> !note.lowPriority); + if (targetNote == null) targetNote = notesInDirection[0]; + if (targetNote == null) continue; + + hitNote(targetNote, input); + notesInDirection.remove(targetNote); + + // Play the strumline animation. + testStrumline.playConfirm(input.noteDirection); + } + } + + while (inputReleaseQueue.length > 0) + { + var input:PreciseInputEvent = inputReleaseQueue.shift(); + + // Play the strumline animation. + testStrumline.playStatic(input.noteDirection); + + testStrumline.releaseKey(input.noteDirection); + } + + testStrumline.noteVibrations.tryNoteVibration(); + } + + // Creates a button item with a callback. + function createButtonItem(name:String, callback:Void->Void):Void + { + var item = items.createItem(funkin.ui.FullScreenScaleMode.gameNotchSize.x, (120 * items.length) + 30, name, BOLD, callback); + items.addItem(name, item); + } + + // Creates a preference item with a number input. + function createPrefItemNumber(prefName:String, prefDesc:String, onChange:Float->Void, ?valueFormatter:Float->String, defaultValue:Int, min:Int, max:Int, + step:Float = 0.1, precision:Int):NumberPreferenceItem + { + var item = new NumberPreferenceItem(funkin.ui.FullScreenScaleMode.gameNotchSize.x, (120 * items.length) + 30, prefName, defaultValue, min, max, step, + precision, onChange, valueFormatter); + items.addItem(prefName, item); + preferenceItems.add(item.lefthandText); + return item; + } + +} diff --git a/source/funkin/ui/options/OptionsState.hx b/source/funkin/ui/options/OptionsState.hx index df8cb436b..6d489014c 100644 --- a/source/funkin/ui/options/OptionsState.hx +++ b/source/funkin/ui/options/OptionsState.hx @@ -2,10 +2,11 @@ package funkin.ui.options; import funkin.ui.Page.PageName; import funkin.ui.transition.LoadingState; -import funkin.ui.debug.latency.LatencyState; import funkin.ui.TextMenuList; import funkin.ui.TextMenuList.TextMenuItem; import flixel.math.FlxPoint; +import funkin.ui.TextMenuList; +import funkin.ui.TextMenuList.TextMenuItem; import flixel.FlxSprite; import flixel.FlxObject; import flixel.FlxSubState; @@ -36,14 +37,25 @@ import flixel.util.FlxColor; */ class OptionsState extends MusicBeatState { + /** + * Instance of the OptionsState + */ + public static var instance:OptionsState; + var optionsCodex:Codex; + public var drumsBG:FunkinSound; + public static var rememberedSelectedIndex:Int = 0; override function create():Void { + instance = this; + persistentUpdate = true; + drumsBG = FunkinSound.load(Paths.music('placeholder/drumsPlaceholder'), 0, true, false, false, false); + var menuBG = new FlxSprite().loadGraphic(Paths.image('menuBG')); var hsv = new HSVShader(-0.6, 0.9, 3.6); menuBG.shader = hsv; @@ -59,12 +71,18 @@ class OptionsState extends MusicBeatState var options:OptionsMenu = optionsCodex.addPage(Options, new OptionsMenu()); var preferences:PreferencesMenu = optionsCodex.addPage(Preferences, new PreferencesMenu()); var controls:ControlsMenu = optionsCodex.addPage(Controls, new ControlsMenu()); + #if FEATURE_INPUT_OFFSETS + var offsets:OffsetMenu = optionsCodex.addPage(Offsets, new OffsetMenu()); + #end if (options.hasMultipleOptions()) { options.onExit.add(exitToMainMenu); controls.onExit.add(exitControls); preferences.onExit.add(optionsCodex.switchPage.bind(Options)); + #if FEATURE_INPUT_OFFSETS + offsets.onExit.add(exitOffsets); + #end } else { @@ -79,6 +97,29 @@ class OptionsState extends MusicBeatState } super.create(); + #if mobile + addHitbox(); + hitbox.visible = false; + #end + } + + function exitOffsets():Void + { + if (drumsBG.volume > 0) + { + drumsBG.fadeOut(0.5, 0); + } + FlxG.sound.music.fadeOut(0.5, 0, function(tw) { + FunkinSound.playMusic('freakyMenu', + { + startingVolume: 0, + overrideExisting: true, + restartTrack: true, + persist: true + }); + FlxG.sound.music.fadeIn(0.5, 1); + }); + optionsCodex.switchPage(Options); } function exitControls():Void @@ -132,12 +173,19 @@ class OptionsMenu extends Page // }); #if FEATURE_INPUT_OFFSETS createItem("INPUT OFFSETS", function() { - OptionsState.rememberedSelectedIndex = items.selectedIndex; - #if web - LoadingState.transitionToState(() -> new LatencyState()); - #else - FlxG.state.openSubState(new LatencyState()); - #end + FlxG.sound.music.fadeOut(0.5, 0, function(tw) { + FunkinSound.playMusic('placeholder', + { + startingVolume: 0, + overrideExisting: true, + restartTrack: true, + loop: true + }); + OptionsState.instance.drumsBG.play(true); + FlxG.sound.music.fadeIn(1, 1); + }); + + codex.switchPage(Offsets); }); #end #if FEATURE_MOBILE_IAP @@ -275,4 +323,5 @@ enum abstract OptionsMenuPageName(String) to PageName var Colors = "colors"; var Mods = "mods"; var Preferences = "preferences"; + var Offsets = "offsets"; } diff --git a/source/funkin/util/GRhythmUtil.hx b/source/funkin/util/GRhythmUtil.hx index f5334c9c7..974e2412c 100644 --- a/source/funkin/util/GRhythmUtil.hx +++ b/source/funkin/util/GRhythmUtil.hx @@ -53,8 +53,10 @@ class GRhythmUtil * @param isControlled True if the note is controlled by the player, false otherwise. * @return A HitWindowRes object containing the result of the hit window check. */ - public static function processWindow(note:NoteSprite, isControlled:Bool = true):HitWindowRes + public static function processWindow(note:NoteSprite, isControlled:Bool = true, ?inUseConductor:Conductor = null):HitWindowRes { + if (inUseConductor == null) inUseConductor = Conductor.instance; + var window:HitWindow = getHitWindow(note); var windowStart:Float = window.start; @@ -71,7 +73,7 @@ class GRhythmUtil } // Treat notes as not in window if they are greater or less than the hit window - if (Conductor.instance.songPosition > windowEnd) + if (inUseConductor.songPosition > windowEnd) { note.tooEarly = false; note.hasMissed = true; @@ -81,12 +83,12 @@ class GRhythmUtil } // Check if we're not being controlled (ie, botplay/opponent) - if (!isControlled && Conductor.instance.songPosition >= windowCenter) + if (!isControlled && inUseConductor.songPosition >= windowCenter) return {botplayHit: true, cont: true }; if (note.holdNoteSprite != null) note.holdNoteSprite.missedNote = false; - if (Conductor.instance.songPosition >= windowStart) + if (inUseConductor.songPosition >= windowStart) { note.tooEarly = false; note.hasMissed = false; @@ -111,6 +113,6 @@ class GRhythmUtil public static function getNoteY(strumTime:Float, scrollSpeed:Float, downscroll:Bool = false, ?conductorInUse:Conductor = null):Float { if (conductorInUse == null) conductorInUse = Conductor.instance; - return Constants.PIXELS_PER_MS * (conductorInUse.getTimeWithDelta() - strumTime - conductorInUse.inputOffset) * scrollSpeed * (downscroll ? 1 : -1); + return Constants.PIXELS_PER_MS * (conductorInUse.getTimeWithDelta() - strumTime) * scrollSpeed * (downscroll ? 1 : -1); } }