diff --git a/.github/actions/setup-haxeshit/action.yml b/.github/actions/setup-haxeshit/action.yml index 6b565bfa2..ec8ed52d8 100644 --- a/.github/actions/setup-haxeshit/action.yml +++ b/.github/actions/setup-haxeshit/action.yml @@ -21,7 +21,7 @@ runs: - name: Installing Haxe lol run: | haxe -version - haxelib git haxelib https://github.com/HaxeFoundation/haxelib.git + haxelib git haxelib https://github.com/HaxeFoundation/haxelib.git development haxelib version haxelib --global install hmm haxelib --global run hmm install --quiet diff --git a/.github/workflows/build-shit.yml b/.github/workflows/build-shit.yml index 32c2a0ede..ddd6e8be0 100644 --- a/.github/workflows/build-shit.yml +++ b/.github/workflows/build-shit.yml @@ -10,7 +10,7 @@ jobs: outputs: should_run: ${{ steps.should_run.outputs.should_run }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: print latest_commit run: echo ${{ github.sha }} - id: should_run @@ -24,22 +24,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 + with: + submodules: 'recursive' - uses: ./.github/actions/setup-haxeshit - - name: Build Lime - # TODO: Remove the step that builds Lime later. - # Bash method - run: | - LIME_PATH=`haxelib libpath lime` - echo "Moving to $LIME_PATH" - cd $LIME_PATH - git submodule sync --recursive - git submodule update --recursive - git status - sudo apt-get install -y libxinerama-dev - haxelib run lime rebuild linux --clean - name: Build game run: | - sudo apt-get install -y libx11-dev libxinerama-dev libxrandr-dev libgl1-mesa-dev libgl-dev libxi-dev libxext-dev libasound2-dev + sudo apt-get install -y libx11-dev xorg-dev libgl-dev libxi-dev libxext-dev libasound2-dev libxinerama-dev libxrandr-dev libgl1-mesa-dev haxelib run lime build html5 -debug --times ls - uses: ./.github/actions/upload-itch @@ -56,18 +46,9 @@ jobs: actions: write steps: - uses: actions/checkout@v3 + with: + submodules: 'recursive' - uses: ./.github/actions/setup-haxeshit - - name: Build Lime - # TODO: Remove the step that builds Lime later. - # Powershell method - run: | - $LIME_PATH = haxelib libpath lime - echo "Moving to $LIME_PATH" - cd $LIME_PATH - git submodule sync --recursive - git submodule update --recursive - git status - haxelib run lime rebuild windows --clean - name: Build game run: | haxelib run lime build windows -debug diff --git a/hmm.json b/hmm.json index 74a1f57a1..52d5ffad8 100644 --- a/hmm.json +++ b/hmm.json @@ -49,7 +49,7 @@ "name": "haxeui-flixel", "type": "git", "dir": null, - "ref": "999faddf862d8a1584ae3794d932c55e94fc65cc", + "ref": "be0b18553189a55fd42821026618a18615b070e3", "url": "https://github.com/haxeui/haxeui-flixel" }, { @@ -88,9 +88,16 @@ "name": "lime", "type": "git", "dir": null, - "ref": "acb0334c59bd4618f3c0277584d524ed0b288b5f", + "ref": "558798adc5bf0e82d70fef589a59ce88892e0b5b", "url": "https://github.com/EliteMasterEric/lime" }, + { + "name": "mockatoo", + "type": "git", + "dir": null, + "ref": "master", + "url": "https://github.com/EliteMasterEric/mockatoo" + }, { "name": "openfl", "type": "git", @@ -102,7 +109,7 @@ "name": "polymod", "type": "git", "dir": null, - "ref": "631a3637f30997e47cd37bbab3cb6a75636a4b2a", + "ref": "4bcd614103469af79a320898b823d1df8a55c3de", "url": "https://github.com/larsiusprime/polymod" }, { diff --git a/source/flixel/addons/transition/FlxTransitionableSubState.hx b/source/flixel/addons/transition/FlxTransitionableSubState.hx new file mode 100644 index 000000000..7bb536bb2 --- /dev/null +++ b/source/flixel/addons/transition/FlxTransitionableSubState.hx @@ -0,0 +1,234 @@ +package flixel.addons.transition; + +import flixel.FlxSubState; +import flixel.addons.transition.FlxTransitionableState; + +/** + * A `FlxSubState` which can perform visual transitions + * + * Usage: + * + * First, extend `FlxTransitionableSubState` as ie, `FooState`. + * + * Method 1: + * + * ```haxe + * var in:TransitionData = new TransitionData(...); // add your data where "..." is + * var out:TransitionData = new TransitionData(...); + * + * FlxG.switchState(new FooState(in,out)); + * ``` + * + * Method 2: + * + * ```haxe + * FlxTransitionableSubState.defaultTransIn = new TransitionData(...); + * FlxTransitionableSubState.defaultTransOut = new TransitionData(...); + * + * FlxG.switchState(new FooState()); + * ``` + */ +class FlxTransitionableSubState extends FlxSubState +{ + // global default transitions for ALL states, used if transIn/transOut are null + public static var defaultTransIn(get, set):TransitionData; + + static function get_defaultTransIn():TransitionData + { + return FlxTransitionableState.defaultTransIn; + } + + static function set_defaultTransIn(value:TransitionData):TransitionData + { + return FlxTransitionableState.defaultTransIn = value; + } + + public static var defaultTransOut(get, set):TransitionData; + + static function get_defaultTransOut():TransitionData + { + return FlxTransitionableState.defaultTransOut; + } + + static function set_defaultTransOut(value:TransitionData):TransitionData + { + return FlxTransitionableState.defaultTransOut = value; + } + + public static var skipNextTransIn(get, set):Bool; + + static function get_skipNextTransIn():Bool + { + return FlxTransitionableState.skipNextTransIn; + } + + static function set_skipNextTransIn(value:Bool):Bool + { + return FlxTransitionableState.skipNextTransIn = value; + } + + public static var skipNextTransOut(get, set):Bool; + + static function get_skipNextTransOut():Bool + { + return FlxTransitionableState.skipNextTransOut; + } + + static function set_skipNextTransOut(value:Bool):Bool + { + return FlxTransitionableState.skipNextTransOut = value; + } + + // beginning & ending transitions for THIS state: + public var transIn:TransitionData; + public var transOut:TransitionData; + + public var hasTransIn(get, never):Bool; + public var hasTransOut(get, never):Bool; + + /** + * Create a state with the ability to do visual transitions + * @param TransIn Plays when the state begins + * @param TransOut Plays when the state ends + */ + public function new(?TransIn:TransitionData, ?TransOut:TransitionData) + { + transIn = TransIn; + transOut = TransOut; + + if (transIn == null && defaultTransIn != null) + { + transIn = defaultTransIn; + } + if (transOut == null && defaultTransOut != null) + { + transOut = defaultTransOut; + } + super(); + } + + override function destroy():Void + { + super.destroy(); + transIn = null; + transOut = null; + _onExit = null; + } + + override function create():Void + { + super.create(); + transitionIn(); + } + + override function startOutro(onOutroComplete:() -> Void) + { + if (!hasTransOut) onOutroComplete(); + else if (!_exiting) + { + // play the exit transition, and when it's done call FlxG.switchState + _exiting = true; + transitionOut(onOutroComplete); + + if (skipNextTransOut) + { + skipNextTransOut = false; + finishTransOut(); + } + } + } + + /** + * Starts the in-transition. Can be called manually at any time. + */ + public function transitionIn():Void + { + if (transIn != null && transIn.type != NONE) + { + if (skipNextTransIn) + { + skipNextTransIn = false; + if (finishTransIn != null) + { + finishTransIn(); + } + return; + } + + var _trans = createTransition(transIn); + + _trans.setStatus(FULL); + openSubState(_trans); + + _trans.finishCallback = finishTransIn; + _trans.start(OUT); + } + } + + /** + * Starts the out-transition. Can be called manually at any time. + */ + public function transitionOut(?OnExit:Void->Void):Void + { + _onExit = OnExit; + if (hasTransOut) + { + var _trans = createTransition(transOut); + + _trans.setStatus(EMPTY); + openSubState(_trans); + + _trans.finishCallback = finishTransOut; + _trans.start(IN); + } + else + { + _onExit(); + } + } + + var transOutFinished:Bool = false; + + var _exiting:Bool = false; + var _onExit:Void->Void; + + function get_hasTransIn():Bool + { + return transIn != null && transIn.type != NONE; + } + + function get_hasTransOut():Bool + { + return transOut != null && transOut.type != NONE; + } + + function createTransition(data:TransitionData):Transition + { + return switch (data.type) + { + case TILES: new Transition(data); + case FADE: new Transition(data); + default: null; + } + } + + function finishTransIn() + { + closeSubState(); + } + + function finishTransOut() + { + transOutFinished = true; + + if (!_exiting) + { + closeSubState(); + } + + if (_onExit != null) + { + _onExit(); + } + } +} diff --git a/source/funkin/Controls.hx b/source/funkin/Controls.hx index 88b637e72..e8a66cb14 100644 --- a/source/funkin/Controls.hx +++ b/source/funkin/Controls.hx @@ -16,7 +16,6 @@ import flixel.input.keyboard.FlxKey; import flixel.input.mouse.FlxMouseButton.FlxMouseButtonID; import flixel.math.FlxAngle; import flixel.math.FlxPoint; -import flixel.ui.FlxVirtualPad; import flixel.util.FlxColor; import flixel.util.FlxTimer; import lime.ui.Haptic; diff --git a/source/funkin/FreeplayState.hx b/source/funkin/FreeplayState.hx index 9cc68c462..9ad27bfcc 100644 --- a/source/funkin/FreeplayState.hx +++ b/source/funkin/FreeplayState.hx @@ -890,6 +890,13 @@ class FreeplayState extends MusicBeatSubState FlxG.sound.play(Paths.sound('confirmMenu')); dj.confirm(); + if (targetSong != null) + { + // Load and cache the song's charts. + // TODO: Do this in the loading state. + targetSong.cacheCharts(true); + } + new FlxTimer().start(1, function(tmr:FlxTimer) { LoadingState.loadAndSwitchState(new PlayState( { diff --git a/source/funkin/GitarooPause.hx b/source/funkin/GitarooPause.hx index 5747de5e5..a4dc766be 100644 --- a/source/funkin/GitarooPause.hx +++ b/source/funkin/GitarooPause.hx @@ -3,6 +3,7 @@ package funkin; import flixel.FlxSprite; import flixel.graphics.frames.FlxAtlasFrames; import funkin.play.PlayState; +import flixel.addons.transition.FlxTransitionableState; class GitarooPause extends MusicBeatState { @@ -61,6 +62,8 @@ class GitarooPause extends MusicBeatState { if (replaySelect) { + FlxTransitionableState.skipNextTransIn = false; + FlxTransitionableState.skipNextTransOut = false; FlxG.switchState(new PlayState(previousParams)); } else diff --git a/source/funkin/LatencyState.hx b/source/funkin/LatencyState.hx index 4a8ed2d2e..7385ca640 100644 --- a/source/funkin/LatencyState.hx +++ b/source/funkin/LatencyState.hx @@ -191,7 +191,7 @@ class LatencyState extends MusicBeatSubState if (FlxG.keys.pressed.D) FlxG.sound.music.time += 1000 * FlxG.elapsed; - Conductor.songPosition = swagSong.getTimeWithDiff() - Conductor.offset; + Conductor.update(swagSong.getTimeWithDiff() - Conductor.offset); // Conductor.songPosition += (Timer.stamp() * 1000) - FlxG.sound.music.prevTimestamp; songPosVis.x = songPosToX(Conductor.songPosition); diff --git a/source/funkin/MainMenuState.hx b/source/funkin/MainMenuState.hx index 2c251635c..fc493ef4b 100644 --- a/source/funkin/MainMenuState.hx +++ b/source/funkin/MainMenuState.hx @@ -1,5 +1,6 @@ package funkin; +import flixel.addons.transition.FlxTransitionableSubState; import funkin.ui.debug.DebugMenuSubState; import flixel.FlxObject; import flixel.FlxSprite; @@ -12,7 +13,6 @@ import flixel.input.touch.FlxTouch; import flixel.text.FlxText; import flixel.tweens.FlxEase; import flixel.tweens.FlxTween; -import flixel.ui.FlxButton; import flixel.util.FlxColor; import flixel.util.FlxTimer; import funkin.NGio; @@ -103,6 +103,9 @@ class MainMenuState extends MusicBeatState createMenuItem('freeplay', 'mainmenu/freeplay', function() { persistentDraw = true; persistentUpdate = false; + // Freeplay has its own custom transition + FlxTransitionableSubState.skipNextTransIn = true; + FlxTransitionableSubState.skipNextTransOut = true; openSubState(new FreeplayState()); }); diff --git a/source/funkin/MusicBeatState.hx b/source/funkin/MusicBeatState.hx index 20330f257..9a986a8b5 100644 --- a/source/funkin/MusicBeatState.hx +++ b/source/funkin/MusicBeatState.hx @@ -3,7 +3,7 @@ package funkin; import funkin.modding.IScriptedClass.IEventHandler; import flixel.FlxState; import flixel.FlxSubState; -import flixel.addons.ui.FlxUIState; +import flixel.addons.transition.FlxTransitionableState; import flixel.text.FlxText; import flixel.util.FlxColor; import flixel.util.FlxSort; @@ -16,7 +16,7 @@ import funkin.util.SortUtil; * MusicBeatState actually represents the core utility FlxState of the game. * It includes functionality for event handling, as well as maintaining BPM-based update events. */ -class MusicBeatState extends FlxUIState implements IEventHandler +class MusicBeatState extends FlxTransitionableState implements IEventHandler { var controls(get, never):Controls; diff --git a/source/funkin/MusicBeatSubState.hx b/source/funkin/MusicBeatSubState.hx index 244d2ceea..31d1bd14c 100644 --- a/source/funkin/MusicBeatSubState.hx +++ b/source/funkin/MusicBeatSubState.hx @@ -1,24 +1,28 @@ package funkin; +import flixel.addons.transition.FlxTransitionableSubState; import flixel.FlxSubState; -import funkin.modding.IScriptedClass.IEventHandler; +import flixel.text.FlxText; import flixel.util.FlxColor; import funkin.modding.events.ScriptEvent; +import funkin.modding.IScriptedClass.IEventHandler; import funkin.modding.module.ModuleHandler; -import flixel.text.FlxText; import funkin.modding.PolymodHandler; +import funkin.util.SortUtil; +import flixel.util.FlxSort; /** * MusicBeatSubState reincorporates the functionality of MusicBeatState into an FlxSubState. */ -class MusicBeatSubState extends FlxSubState implements IEventHandler +class MusicBeatSubState extends FlxTransitionableSubState implements IEventHandler { public var leftWatermarkText:FlxText = null; public var rightWatermarkText:FlxText = null; public function new(bgColor:FlxColor = FlxColor.TRANSPARENT) { - super(bgColor); + super(); + this.bgColor = bgColor; } var controls(get, never):Controls; @@ -57,6 +61,15 @@ class MusicBeatSubState extends FlxSubState implements IEventHandler // This can now be used in EVERY STATE YAY! if (FlxG.keys.justPressed.F5) debug_refreshModules(); + + // Display Conductor info in the watch window. + FlxG.watch.addQuick("songPosition", Conductor.songPosition); + FlxG.watch.addQuick("bpm", Conductor.bpm); + FlxG.watch.addQuick("currentMeasureTime", Conductor.currentBeatTime); + FlxG.watch.addQuick("currentBeatTime", Conductor.currentBeatTime); + FlxG.watch.addQuick("currentStepTime", Conductor.currentStepTime); + + dispatchEvent(new UpdateScriptEvent(elapsed)); } function debug_refreshModules() @@ -67,6 +80,15 @@ class MusicBeatSubState extends FlxSubState implements IEventHandler FlxG.resetState(); } + /** + * Refreshes the state, by redoing the render order of all sprites. + * It does this based on the `zIndex` of each prop. + */ + public function refresh() + { + sort(SortUtil.byZIndex, FlxSort.ASCENDING); + } + /** * Called when a step is hit in the current song. * Continues outside of PlayState, for things like animations in menus. diff --git a/source/funkin/PauseSubState.hx b/source/funkin/PauseSubState.hx index d5584fbc7..9133a8fab 100644 --- a/source/funkin/PauseSubState.hx +++ b/source/funkin/PauseSubState.hx @@ -16,14 +16,17 @@ class PauseSubState extends MusicBeatSubState { var grpMenuShit:FlxTypedGroup; - var pauseOG:Array = [ + var pauseOptionsBase:Array = [ 'Resume', 'Restart Song', 'Change Difficulty', 'Toggle Practice Mode', 'Exit to Menu' ]; - var difficultyChoices:Array = ['EASY', 'NORMAL', 'HARD', 'ERECT', 'BACK']; + + var pauseOptionsDifficulty:Array = ['EASY', 'NORMAL', 'HARD', 'ERECT', 'BACK']; + + var pauseOptionsCharting:Array = ['Resume', 'Restart Song', 'Exit to Chart Editor']; var menuItems:Array = []; var curSelected:Int = 0; @@ -36,11 +39,15 @@ class PauseSubState extends MusicBeatSubState var bg:FlxSprite; var metaDataGrp:FlxTypedGroup; - public function new() + var isChartingMode:Bool; + + public function new(?isChartingMode:Bool = false) { super(); - menuItems = pauseOG; + this.isChartingMode = isChartingMode; + + menuItems = this.isChartingMode ? pauseOptionsCharting : pauseOptionsBase; if (PlayStatePlaylist.campaignId == 'week6') { @@ -180,14 +187,13 @@ class PauseSubState extends MusicBeatSubState { var daSelected:String = menuItems[curSelected]; - // TODO: Why is this based on the menu item's name? Make this an enum or something. switch (daSelected) { case 'Resume': close(); case 'Change Difficulty': - menuItems = difficultyChoices; + menuItems = pauseOptionsDifficulty; regenMenu(); case 'EASY' | 'NORMAL' | 'HARD' | 'ERECT': @@ -199,7 +205,7 @@ class PauseSubState extends MusicBeatSubState close(); case 'BACK': - menuItems = pauseOG; + menuItems = this.isChartingMode ? pauseOptionsCharting : pauseOptionsBase; regenMenu(); case 'Toggle Practice Mode': @@ -226,6 +232,11 @@ class PauseSubState extends MusicBeatSubState if (PlayStatePlaylist.isStoryMode) openSubState(new funkin.ui.StickerSubState(null, STORY)); else openSubState(new funkin.ui.StickerSubState(null, FREEPLAY)); + + case 'Exit to Chart Editor': + this.close(); + if (FlxG.sound.music != null) FlxG.sound.music.stop(); + PlayState.instance.close(); // This only works because PlayState is a substate! } } diff --git a/source/funkin/TitleState.hx b/source/funkin/TitleState.hx index 8ba5121fa..47cc33a38 100644 --- a/source/funkin/TitleState.hx +++ b/source/funkin/TitleState.hx @@ -256,7 +256,7 @@ class TitleState extends MusicBeatState FlxTween.tween(FlxG.stage.window, {y: FlxG.stage.window.y + 100}, 0.7, {ease: FlxEase.quadInOut, type: PINGPONG}); } - if (FlxG.sound.music != null) Conductor.songPosition = FlxG.sound.music.time; + if (FlxG.sound.music != null) Conductor.update(FlxG.sound.music.time); if (FlxG.keys.justPressed.F) FlxG.fullscreen = !FlxG.fullscreen; // do controls.PAUSE | controls.ACCEPT instead? diff --git a/source/funkin/import.hx b/source/funkin/import.hx index 4ba062b8f..cd0af4b55 100644 --- a/source/funkin/import.hx +++ b/source/funkin/import.hx @@ -11,6 +11,7 @@ using Lambda; using StringTools; using funkin.util.tools.ArrayTools; using funkin.util.tools.ArraySortTools; +using funkin.util.tools.Int64Tools; using funkin.util.tools.IteratorTools; using funkin.util.tools.MapTools; using funkin.util.tools.StringTools; diff --git a/source/funkin/input/PreciseInputManager.hx b/source/funkin/input/PreciseInputManager.hx index 11a3c2007..6217b2fe7 100644 --- a/source/funkin/input/PreciseInputManager.hx +++ b/source/funkin/input/PreciseInputManager.hx @@ -28,10 +28,6 @@ class PreciseInputManager extends FlxKeyManager return instance ?? (instance = new PreciseInputManager()); } - static final MS_TO_US:Int64 = 1000; - static final US_TO_NS:Int64 = 1000; - static final MS_TO_NS:Int64 = MS_TO_US * US_TO_NS; - static final DIRECTIONS:Array = [NoteDirection.LEFT, NoteDirection.DOWN, NoteDirection.UP, NoteDirection.RIGHT]; public var onInputPressed:FlxTypedSignalVoid>; @@ -88,6 +84,11 @@ class PreciseInputManager extends FlxKeyManager }; } + /** + * Convert from int to Int64. + */ + static final NS_PER_MS:Int64 = Constants.NS_PER_MS; + /** * Returns a precise timestamp, measured in nanoseconds. * Timestamp is only useful for comparing against other timestamps. @@ -101,11 +102,11 @@ class PreciseInputManager extends FlxKeyManager // NOTE: This timestamp isn't that precise on standard HTML5 builds. // This is because of browser safeguards against timing attacks. // See https://web.dev/coop-coep to enable headers which allow for more precise timestamps. - return js.Browser.window.performance.now() * MS_TO_NS; + return haxe.Int64.fromFloat(js.Browser.window.performance.now()) * NS_PER_MS; #elseif cpp // NOTE: If the game hard crashes on this line, rebuild Lime! // `lime rebuild windows -clean` - return lime._internal.backend.native.NativeCFFI.lime_sdl_get_ticks() * MS_TO_NS; + return lime._internal.backend.native.NativeCFFI.lime_sdl_get_ticks() * NS_PER_MS; #else throw "Eric didn't implement precise timestamps on this platform!"; #end @@ -176,7 +177,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 *= MS_TO_NS; + timestamp *= Constants.NS_PER_MS; updateKeyStates(key, true); @@ -198,7 +199,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 *= MS_TO_NS; + timestamp *= Constants.NS_PER_MS; updateKeyStates(key, false); diff --git a/source/funkin/play/Countdown.hx b/source/funkin/play/Countdown.hx index 51d72693e..5ccb6e24c 100644 --- a/source/funkin/play/Countdown.hx +++ b/source/funkin/play/Countdown.hx @@ -31,26 +31,35 @@ class Countdown { countdownStep = BEFORE; var cancelled:Bool = propagateCountdownEvent(countdownStep); - if (cancelled) return false; + if (cancelled) + { + return false; + } // Stop any existing countdown. stopCountdown(); PlayState.instance.isInCountdown = true; - Conductor.songPosition = Conductor.beatLengthMs * -5; + Conductor.update(PlayState.instance.startTimestamp + Conductor.beatLengthMs * -5); // Handle onBeatHit events manually - @:privateAccess - PlayState.instance.dispatchEvent(new SongTimeScriptEvent(ScriptEvent.SONG_BEAT_HIT, 0, 0)); + // @:privateAccess + // PlayState.instance.dispatchEvent(new SongTimeScriptEvent(ScriptEvent.SONG_BEAT_HIT, 0, 0)); // The timer function gets called based on the beat of the song. countdownTimer = new FlxTimer(); countdownTimer.start(Conductor.beatLengthMs / 1000, function(tmr:FlxTimer) { + if (PlayState.instance == null) + { + tmr.cancel(); + return; + } + countdownStep = decrement(countdownStep); - // Handle onBeatHit events manually - @:privateAccess - PlayState.instance.dispatchEvent(new SongTimeScriptEvent(ScriptEvent.SONG_BEAT_HIT, 0, 0)); + // onBeatHit events are now properly dispatched by the Conductor even at negative timestamps, + // so calling this is no longer necessary. + // PlayState.instance.dispatchEvent(new SongTimeScriptEvent(ScriptEvent.SONG_BEAT_HIT, 0, 0)); // Countdown graphic. showCountdownGraphic(countdownStep, isPixelStyle); @@ -61,7 +70,10 @@ class Countdown // Event handling bullshit. var cancelled:Bool = propagateCountdownEvent(countdownStep); - if (cancelled) pauseCountdown(); + if (cancelled) + { + pauseCountdown(); + } if (countdownStep == AFTER) { @@ -146,7 +158,7 @@ class Countdown { stopCountdown(); // This will trigger PlayState.startSong() - Conductor.songPosition = 0; + Conductor.update(0); // PlayState.isInCountdown = false; } diff --git a/source/funkin/play/GameOverSubState.hx b/source/funkin/play/GameOverSubState.hx index 18962d549..b53937361 100644 --- a/source/funkin/play/GameOverSubState.hx +++ b/source/funkin/play/GameOverSubState.hx @@ -117,15 +117,13 @@ class GameOverSubState extends MusicBeatSubState gameOverMusic.stop(); // The conductor now represents the BPM of the game over music. - Conductor.songPosition = 0; + Conductor.update(0); } var hasStartedAnimation:Bool = false; override function update(elapsed:Float) { - super.update(elapsed); - if (!hasStartedAnimation) { hasStartedAnimation = true; @@ -183,7 +181,7 @@ class GameOverSubState extends MusicBeatSubState { // Match the conductor to the music. // This enables the stepHit and beatHit events. - Conductor.songPosition = gameOverMusic.time; + Conductor.update(gameOverMusic.time); } else { @@ -205,12 +203,13 @@ class GameOverSubState extends MusicBeatSubState if (boyfriend.getCurrentAnimation().startsWith('firstDeath') && boyfriend.isAnimationFinished()) { startDeathMusic(1.0, false); + boyfriend.playAnimation('deathLoop' + animationSuffix); } } } - // Dispatch the onUpdate event. - dispatchEvent(new UpdateScriptEvent(elapsed)); + // Start death music before firstDeath gets replaced + super.update(elapsed); } /** diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx index 3e7325ae4..a28c35c67 100644 --- a/source/funkin/play/PlayState.hx +++ b/source/funkin/play/PlayState.hx @@ -1,10 +1,13 @@ package funkin.play; +import flixel.addons.transition.FlxTransitionableSubState; +import funkin.ui.debug.charting.ChartEditorState; import haxe.Int64; import funkin.play.notes.notestyle.NoteStyle; import funkin.data.notestyle.NoteStyleData; import funkin.data.notestyle.NoteStyleRegistry; import flixel.addons.display.FlxPieDial; +import flixel.addons.transition.Transition; import flixel.addons.transition.FlxTransitionableState; import flixel.FlxCamera; import flixel.FlxObject; @@ -77,12 +80,32 @@ typedef PlayStateParams = * @default `bf`, or the first character in the song's character list. */ ?targetCharacter:String, + /** + * Whether the song should start in Practice Mode. + * @default `false` + */ + ?practiceMode:Bool, + /** + * Whether the song should be in minimal mode. + * @default `false` + */ + ?minimalMode:Bool, + /** + * If specified, the game will jump to the specified timestamp after the countdown ends. + */ + ?startTimestamp:Float, + /** + * If specified, the game will not load the instrumental or vocal tracks, + * and must be loaded externally. + */ + ?overrideMusic:Bool, } /** * The gameplay state, where all the rhythm gaming happens. + * SubState so it can be loaded as a child of the chart editor. */ -class PlayState extends MusicBeatState +class PlayState extends MusicBeatSubState { /** * STATIC VARIABLES @@ -209,6 +232,11 @@ class PlayState extends MusicBeatState */ public var isPracticeMode:Bool = false; + /** + * In Minimal Mode, the stage and characters are not loaded and a standard background is used. + */ + public var isMinimalMode:Bool = false; + /** * Whether the game is currently in an animated cutscene, and gameplay should be stopped. */ @@ -219,6 +247,24 @@ class PlayState extends MusicBeatState */ public var disableKeys:Bool = false; + public var startTimestamp:Float = 0.0; + + var overrideMusic:Bool = false; + + public var isSubState(get, null):Bool; + + function get_isSubState():Bool + { + return this._parentState != null; + } + + public var isChartingMode(get, null):Bool; + + function get_isChartingMode():Bool + { + return this._parentState != null && Std.isOfType(this._parentState, ChartEditorState); + } + /** * The current dialogue. */ @@ -275,10 +321,15 @@ class PlayState extends MusicBeatState */ var startingSong:Bool = false; + /** + * False until `create()` has completed. + */ + var initialized:Bool = false; + /** * A group of audio tracks, used to play the song's vocals. */ - var vocals:VoicesGroup; + public var vocals:VoicesGroup; #if discord_rpc // Discord RPC variables @@ -438,6 +489,10 @@ class PlayState extends MusicBeatState currentSong = params.targetSong; if (params.targetDifficulty != null) currentDifficulty = params.targetDifficulty; if (params.targetCharacter != null) currentPlayerId = params.targetCharacter; + isPracticeMode = params.practiceMode ?? false; + isMinimalMode = params.minimalMode ?? false; + startTimestamp = params.startTimestamp ?? 0.0; + overrideMusic = params.overrideMusic ?? false; // Don't do anything else here! Wait until create() when we attach to the camera. } @@ -447,8 +502,6 @@ class PlayState extends MusicBeatState */ public override function create():Void { - super.create(); - if (instance != null) { // TODO: Do something in this case? IDK. @@ -458,13 +511,6 @@ class PlayState extends MusicBeatState NoteSplash.buildSplashFrames(); - if (currentSong != null) - { - // Load and cache the song's charts. - // TODO: Do this in the loading state. - currentSong.cacheCharts(true); - } - // Returns null if the song failed to load or doesn't have the selected difficulty. if (currentSong == null || currentChart == null) { @@ -490,7 +536,14 @@ class PlayState extends MusicBeatState lime.app.Application.current.window.alert(message, 'Error loading PlayState'); // Force the user back to the main menu. - FlxG.switchState(new MainMenuState()); + if (isSubState) + { + this.close(); + } + else + { + FlxG.switchState(new MainMenuState()); + } return; } @@ -516,24 +569,32 @@ class PlayState extends MusicBeatState this.persistentDraw = true; // Stop any pre-existing music. - if (FlxG.sound.music != null) FlxG.sound.music.stop(); + if (!overrideMusic && FlxG.sound.music != null) FlxG.sound.music.stop(); // Prepare the current song's instrumental and vocals to be played. - if (currentChart != null) + if (!overrideMusic && currentChart != null) { currentChart.cacheInst(currentPlayerId); currentChart.cacheVocals(currentPlayerId); } // Prepare the Conductor. + Conductor.forceBPM(null); Conductor.mapTimeChanges(currentChart.timeChanges); - Conductor.update(-5000); + Conductor.update((Conductor.beatLengthMs * -5) + startTimestamp); // The song is now loaded. We can continue to initialize the play state. initCameras(); initHealthBar(); - initStage(); - initCharacters(); + if (!isMinimalMode) + { + initStage(); + initCharacters(); + } + else + { + initMinimalMode(); + } initStrumlines(); // Initialize the judgements and combo meter. @@ -582,6 +643,9 @@ class PlayState extends MusicBeatState startCountdown(); } + // Do this last to prevent beatHit from being called before create() is done. + super.create(); + leftWatermarkText.cameras = [camHUD]; rightWatermarkText.cameras = [camHUD]; @@ -592,6 +656,8 @@ class PlayState extends MusicBeatState FlxG.console.registerObject('playState', this); #end + + initialized = true; } public override function update(elapsed:Float):Void @@ -629,7 +695,7 @@ class PlayState extends MusicBeatState FlxG.sound.music.pause(); vocals.pause(); - FlxG.sound.music.time = 0; + FlxG.sound.music.time = (startTimestamp); vocals.time = 0; FlxG.sound.music.volume = 1; @@ -637,7 +703,7 @@ class PlayState extends MusicBeatState vocals.playerVolume = 1; vocals.opponentVolume = 1; - currentStage.resetStage(); + if (currentStage != null) currentStage.resetStage(); playerStrumline.vwooshNotes(); opponentStrumline.vwooshNotes(); @@ -666,8 +732,8 @@ class PlayState extends MusicBeatState { if (isInCountdown) { - Conductor.songPosition += elapsed * 1000; - if (Conductor.songPosition >= 0) startSong(); + Conductor.update(Conductor.songPosition + elapsed * 1000); + if (Conductor.songPosition >= startTimestamp) startSong(); } } else @@ -712,7 +778,7 @@ class PlayState extends MusicBeatState // There is a 1/1000 change to use a special pause menu. // This prevents the player from resuming, but that's the point. // It's a reference to Gitaroo Man, which doesn't let you pause the game. - if (event.gitaroo) + if (!isSubState && event.gitaroo) { FlxG.switchState(new GitarooPause( { @@ -731,8 +797,10 @@ class PlayState extends MusicBeatState boyfriendPos = currentStage.getBoyfriend().getScreenPosition(); } - var pauseSubState:FlxSubState = new PauseSubState(); + var pauseSubState:FlxSubState = new PauseSubState(isChartingMode); + FlxTransitionableSubState.skipNextTransIn = true; + FlxTransitionableSubState.skipNextTransOut = true; openSubState(pauseSubState); pauseSubState.camera = camHUD; // boyfriendPos.put(); // TODO: Why is this here? @@ -807,6 +875,8 @@ class PlayState extends MusicBeatState #end var gameOverSubState = new GameOverSubState(); + FlxTransitionableSubState.skipNextTransIn = true; + FlxTransitionableSubState.skipNextTransOut = true; openSubState(gameOverSubState); #if discord_rpc @@ -827,6 +897,13 @@ class PlayState extends MusicBeatState trace('Found ${songEventsToActivate.length} event(s) to activate.'); for (event in songEventsToActivate) { + // If an event is trying to play, but it's over 5 seconds old, skip it. + if (event.time - Conductor.songPosition < -5000) + { + event.activated = true; + continue; + }; + var eventEvent:SongEventScriptEvent = new SongEventScriptEvent(event); dispatchEvent(eventEvent); // Calling event.cancelEvent() skips the event. Neat! @@ -846,9 +923,6 @@ class PlayState extends MusicBeatState // Moving notes into position is now done by Strumline.update(). processNotes(elapsed); - - // Dispatch the onUpdate event to scripted elements. - dispatchEvent(new UpdateScriptEvent(elapsed)); } public override function dispatchEvent(event:ScriptEvent):Void @@ -882,7 +956,7 @@ class PlayState extends MusicBeatState { // If there is a substate which requires the game to continue, // then make this a condition. - var shouldPause = true; + var shouldPause = (Std.isOfType(subState, PauseSubState) || Std.isOfType(subState, GameOverSubState)); if (shouldPause) { @@ -896,6 +970,7 @@ class PlayState extends MusicBeatState // Pause the countdown. Countdown.pauseCountdown(); } + else {} super.openSubState(subState); } @@ -906,7 +981,7 @@ class PlayState extends MusicBeatState */ public override function closeSubState():Void { - if (isGamePaused) + if (Std.isOfType(subState, PauseSubState)) { var event:ScriptEvent = new ScriptEvent(ScriptEvent.RESUME, true); @@ -914,6 +989,9 @@ class PlayState extends MusicBeatState if (event.eventCanceled) return; + // Resume + FlxG.sound.music.play(); + if (FlxG.sound.music != null && !startingSong && !isInCutscene) resyncVocals(); // Resume the countdown. @@ -931,6 +1009,10 @@ class PlayState extends MusicBeatState } #end } + else if (Std.isOfType(subState, Transition)) + { + // Do nothing. + } super.closeSubState(); } @@ -1029,12 +1111,15 @@ class PlayState extends MusicBeatState override function stepHit():Bool { - if (criticalFailure) return false; + if (criticalFailure || !initialized) return false; // super.stepHit() returns false if a module cancelled the event. if (!super.stepHit()) return false; - if (FlxG.sound.music != null + if (isGamePaused) return false; + + if (!startingSong + && FlxG.sound.music != null && (Math.abs(FlxG.sound.music.time - (Conductor.songPosition - Conductor.offset)) > 200 || Math.abs(vocals.checkSyncError(Conductor.songPosition - Conductor.offset)) > 200)) { @@ -1052,11 +1137,13 @@ class PlayState extends MusicBeatState override function beatHit():Bool { - if (criticalFailure) return false; + if (criticalFailure || !initialized) return false; // super.beatHit() returns false if a module cancelled the event. if (!super.beatHit()) return false; + if (isGamePaused) return false; + if (generatedMusic) { // TODO: Sort more efficiently, or less often, to improve performance. @@ -1209,6 +1296,19 @@ class PlayState extends MusicBeatState loadStage(currentStageId); } + function initMinimalMode():Void + { + // Create the green background. + var menuBG = new FlxSprite().loadGraphic(Paths.image('menuDesat')); + menuBG.color = 0xFF4CAF50; + menuBG.setGraphicSize(Std.int(menuBG.width * 1.1)); + menuBG.updateHitbox(); + menuBG.screenCenter(); + menuBG.scrollFactor.set(0, 0); + menuBG.zIndex = -1000; + add(menuBG); + } + /** * Loads stage data from cache, assembles the props, * and adds it to the state. @@ -1453,12 +1553,16 @@ class PlayState extends MusicBeatState trace('Song difficulty could not be loaded.'); } - Conductor.forceBPM(currentChart.getStartingBPM()); + // Conductor.forceBPM(currentChart.getStartingBPM()); - vocals = currentChart.buildVocals(currentPlayerId); - if (vocals.members.length == 0) + if (!overrideMusic) { - trace('WARNING: No vocals found for this song.'); + vocals = currentChart.buildVocals(currentPlayerId); + + if (vocals.members.length == 0) + { + trace('WARNING: No vocals found for this song.'); + } } regenNoteData(); @@ -1469,7 +1573,7 @@ class PlayState extends MusicBeatState /** * Read note data from the chart and generate the notes. */ - function regenNoteData():Void + function regenNoteData(startTime:Float = 0):Void { Highscore.tallies.combo = 0; Highscore.tallies = new Tallies(); @@ -1485,6 +1589,8 @@ class PlayState extends MusicBeatState for (songNote in currentChart.notes) { var strumTime:Float = songNote.time; + if (strumTime < startTime) continue; // Skip notes that are before the start time. + var noteData:Int = songNote.getDirection(); var playerNote:Bool = true; @@ -1565,20 +1671,29 @@ class PlayState extends MusicBeatState startingSong = false; - if (!isGamePaused && currentChart != null) + if (!overrideMusic && !isGamePaused && currentChart != null) { currentChart.playInst(1.0, false); } FlxG.sound.music.onComplete = endSong; + FlxG.sound.music.play(); + FlxG.sound.music.time = startTimestamp; trace('Playing vocals...'); add(vocals); vocals.play(); + resyncVocals(); #if discord_rpc // Updating Discord Rich Presence (with Time Left) DiscordClient.changePresence(detailsText, '${currentChart.songName} ($storyDifficultyText)', iconRPC, true, currentSongLengthMs); #end + + if (startTimestamp > 0) + { + FlxG.sound.music.time = startTimestamp; + handleSkippedNotes(); + } } /** @@ -1588,13 +1703,16 @@ class PlayState extends MusicBeatState { if (_exiting || vocals == null) return; + // Skip this if the music is paused (GameOver, Pause menu, etc.) + if (!FlxG.sound.music.playing) return; + vocals.pause(); FlxG.sound.music.play(); Conductor.update(); vocals.time = FlxG.sound.music.time; - vocals.play(); + vocals.play(false, FlxG.sound.music.time); } /** @@ -1619,6 +1737,8 @@ class PlayState extends MusicBeatState */ function onKeyPress(event:PreciseInputEvent):Void { + if (isGamePaused) return; + // Do the minimal possible work here. inputPressQueue.push(event); } @@ -1628,6 +1748,8 @@ class PlayState extends MusicBeatState */ function onKeyRelease(event:PreciseInputEvent):Void { + if (isGamePaused) return; + // Do the minimal possible work here. inputReleaseQueue.push(event); } @@ -1761,6 +1883,7 @@ class PlayState extends MusicBeatState // Judge the miss. // NOTE: This is what handles the scoring. + trace('Missed note! ${note.noteData}'); onNoteMiss(note); note.handledMiss = true; @@ -1790,6 +1913,25 @@ class PlayState extends MusicBeatState */ var inputSpitter:Array = []; + function handleSkippedNotes():Void + { + for (note in playerStrumline.notes.members) + { + if (note == null || note.hasBeenHit) continue; + var hitWindowEnd = note.strumTime + Constants.HIT_WINDOW_MS; + + if (Conductor.songPosition > hitWindowEnd) + { + // We have passed this note. + // Flag the note for deletion without actually penalizing the player. + note.handledMiss = true; + } + } + + playerStrumline.handleSkippedNotes(); + opponentStrumline.handleSkippedNotes(); + } + /** * PreciseInputEvents are put into a queue between update() calls, * and then processed here. @@ -1851,6 +1993,7 @@ class PlayState extends MusicBeatState if (targetNote == null) continue; // Judge and hit the note. + trace('Hit note! ${targetNote.noteData}'); goodNoteHit(targetNote, input); targetNote.visible = false; @@ -2018,11 +2161,10 @@ class PlayState extends MusicBeatState if (event.eventCanceled) return; health -= Constants.HEALTH_MISS_PENALTY; + songScore -= 10; if (!isPracticeMode) { - songScore -= 10; - // messy copy paste rn lol var pressArray:Array = [ controls.NOTE_LEFT_P, @@ -2093,11 +2235,10 @@ class PlayState extends MusicBeatState if (event.eventCanceled) return; health += event.healthChange; + songScore += event.scoreChange; if (!isPracticeMode) { - songScore += event.scoreChange; - var pressArray:Array = [ controls.NOTE_LEFT_P, controls.NOTE_DOWN_P, @@ -2139,6 +2280,7 @@ class PlayState extends MusicBeatState if (FlxG.keys.justPressed.H) camHUD.visible = !camHUD.visible; #end + // Eject button if (FlxG.keys.justPressed.F4) FlxG.switchState(new MainMenuState()); if (FlxG.keys.justPressed.F5) debug_refreshModules(); @@ -2170,18 +2312,21 @@ class PlayState extends MusicBeatState } // 8: Move to the offset editor. - if (FlxG.keys.justPressed.EIGHT) FlxG.switchState(new funkin.ui.animDebugShit.DebugBoundingState()); + if (FlxG.keys.justPressed.EIGHT) + { + lime.app.Application.current.window.alert("Press ~ on the main menu to get to the editor", 'LOL'); + } // 9: Toggle the old icon. if (FlxG.keys.justPressed.NINE) iconP1.toggleOldIcon(); #if debug - // PAGEUP: Skip forward one section. - // SHIFT+PAGEUP: Skip forward ten sections. - if (FlxG.keys.justPressed.PAGEUP) changeSection(FlxG.keys.pressed.SHIFT ? 10 : 1); - // PAGEDOWN: Skip backward one section. Doesn't replace notes. - // SHIFT+PAGEDOWN: Skip backward ten sections. - if (FlxG.keys.justPressed.PAGEDOWN) changeSection(FlxG.keys.pressed.SHIFT ? -10 : -1); + // PAGEUP: Skip forward two sections. + // SHIFT+PAGEUP: Skip forward twenty sections. + if (FlxG.keys.justPressed.PAGEUP) changeSection(FlxG.keys.pressed.SHIFT ? 20 : 2); + // PAGEDOWN: Skip backward two section. Doesn't replace notes. + // SHIFT+PAGEDOWN: Skip backward twenty sections. + if (FlxG.keys.justPressed.PAGEDOWN) changeSection(FlxG.keys.pressed.SHIFT ? -20 : -2); #end if (FlxG.keys.justPressed.B) trace(inputSpitter.join('\n')); @@ -2232,11 +2377,10 @@ class PlayState extends MusicBeatState playerStrumline.playNoteSplash(daNote.noteData.getDirection()); } - // Only add the score if you're not on practice mode + songScore += score; + if (!isPracticeMode) { - songScore += score; - // TODO: Input splitter uses old input system, make it pull from the precise input queue directly. var pressArray:Array = [ controls.NOTE_LEFT_P, @@ -2391,7 +2535,14 @@ class PlayState extends MusicBeatState // FlxG.save.data.weekUnlocked = StoryMenuState.weekUnlocked; FlxG.save.flush(); - moveToResultsScreen(); + if (isSubState) + { + this.close(); + } + else + { + moveToResultsScreen(); + } } else { @@ -2445,10 +2596,24 @@ class PlayState extends MusicBeatState } else { - moveToResultsScreen(); + if (isSubState) + { + this.close(); + } + else + { + moveToResultsScreen(); + } } } + public override function close():Void + { + criticalFailure = true; // Stop game updates. + performCleanup(); + super.close(); + } + /** * Perform necessary cleanup before leaving the PlayState. */ @@ -2459,6 +2624,19 @@ class PlayState extends MusicBeatState // TODO: Uncache the song. } + if (!overrideMusic) + { + // Stop the music. + FlxG.sound.music.pause(); + vocals.stop(); + } + else + { + FlxG.sound.music.pause(); + vocals.pause(); + remove(vocals); + } + // Remove reference to stage and remove sprites from it to save memory. if (currentStage != null) { @@ -2559,6 +2737,7 @@ class PlayState extends MusicBeatState FlxG.camera.follow(cameraFollowPoint, LOCKON, 0.04); FlxG.camera.targetOffset.set(); FlxG.camera.zoom = defaultCameraZoom; + // Snap the camera to the follow point immediately. FlxG.camera.focusOn(cameraFollowPoint.getPosition()); } @@ -2572,30 +2751,16 @@ class PlayState extends MusicBeatState { FlxG.sound.music.pause(); - FlxG.sound.music.time += sections * Conductor.measureLengthMs; + var targetTimeSteps:Float = Conductor.currentStepTime + (Conductor.timeSignatureNumerator * Constants.STEPS_PER_BEAT * sections); + var targetTimeMs:Float = Conductor.getStepTimeInMs(targetTimeSteps); + + FlxG.sound.music.time = targetTimeMs; + + handleSkippedNotes(); + // regenNoteData(FlxG.sound.music.time); Conductor.update(FlxG.sound.music.time); - /** - * - // TODO: Redo this for the new conductor. - var daBPM:Float = Conductor.bpm; - var daPos:Float = 0; - for (i in 0...(Std.int(Conductor.currentStep / 16 + sec))) - { - var section = .getSong()[i]; - if (section == null) continue; - if (section.changeBPM) - { - daBPM = .getSong()[i].bpm; - } - daPos += 4 * (1000 * 60 / daBPM); - } - Conductor.songPosition = FlxG.sound.music.time = daPos; - Conductor.songPosition += Conductor.offset; - - */ - resyncVocals(); } #end diff --git a/source/funkin/play/character/BaseCharacter.hx b/source/funkin/play/character/BaseCharacter.hx index bcb73d543..72f968538 100644 --- a/source/funkin/play/character/BaseCharacter.hx +++ b/source/funkin/play/character/BaseCharacter.hx @@ -227,13 +227,16 @@ class BaseCharacter extends Bopper public function resetCharacter(resetCamera:Bool = true):Void { // Reset the animation offsets. This will modify x and y to be the absolute position of the character. - this.animOffsets = [0, 0]; + // this.animOffsets = [0, 0]; // Now we can set the x and y to be their original values without having to account for animOffsets. this.resetPosition(); - // Make sure we are playing the idle animation (to reapply animOffsets)... + // Then reapply animOffsets... + // applyAnimationOffsets(getCurrentAnimation()); + this.dance(true); // Force to avoid the old animation playing with the wrong offset at the start of the song. + // Make sure we are playing the idle animation // ...then update the hitbox so that this.width and this.height are correct. this.updateHitbox(); @@ -344,7 +347,7 @@ class BaseCharacter extends Bopper if (isDead) { - playDeathAnimation(); + // playDeathAnimation(); return; } @@ -392,20 +395,6 @@ class BaseCharacter extends Bopper FlxG.watch.addQuick('holdTimer-${characterId}', holdTimer); } - /** - * Since no `onBeatHit` or `dance` calls happen in GameOverSubState, - * this regularly gets called instead. - * - * @param force Force the deathLoop animation to play, even if `firstDeath` is still playing. - */ - public function playDeathAnimation(force:Bool = false):Void - { - if (force || (getCurrentAnimation().startsWith('firstDeath') && isAnimationFinished())) - { - playAnimation('deathLoop' + GameOverSubState.animationSuffix); - } - } - public function isSinging():Bool { return getCurrentAnimation().startsWith('sing'); diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx index ab9cfdec5..b343bee86 100644 --- a/source/funkin/play/notes/Strumline.hx +++ b/source/funkin/play/notes/Strumline.hx @@ -58,7 +58,12 @@ class Strumline extends FlxSpriteGroup final noteStyle:NoteStyle; + /** + * The note data for the song. Should NOT be altered after the song starts, + * so we can easily rewind. + */ var noteData:Array = []; + var nextNoteIndex:Int = -1; var heldKeys:Array = []; @@ -279,14 +284,22 @@ class Strumline extends FlxSpriteGroup { if (noteData.length == 0) return; + var songStart:Float = PlayState.instance.startTimestamp ?? 0.0; + var hitWindowStart:Float = Conductor.songPosition - Constants.HIT_WINDOW_MS; var renderWindowStart:Float = Conductor.songPosition + RENDER_DISTANCE_MS; for (noteIndex in nextNoteIndex...noteData.length) { var note:Null = noteData[noteIndex]; - if (note == null) continue; - if (note.time > renderWindowStart) break; + if (note == null) continue; // Note is blank + if (note.time < songStart || note.time < hitWindowStart) + { + // Note is in the past, skip it. + nextNoteIndex = noteIndex + 1; + continue; + } + if (note.time > renderWindowStart) break; // Note is too far ahead to render var noteSprite = buildNoteSprite(note); @@ -295,7 +308,7 @@ class Strumline extends FlxSpriteGroup noteSprite.holdNoteSprite = buildHoldNoteSprite(note); } - nextNoteIndex++; // Increment the nextNoteIndex rather than splicing the array, because splicing is slow. + nextNoteIndex = noteIndex + 1; // Increment the nextNoteIndex rather than splicing the array, because splicing is slow. } // Update rendering of notes. @@ -433,6 +446,17 @@ class Strumline extends FlxSpriteGroup } } + /** + * Called when the PlayState skips a large amount of time forward or backward. + */ + public function handleSkippedNotes():Void + { + // By calling clean(), we remove all existing notes so they can be re-added. + clean(); + // By setting noteIndex to 0, the next update will skip past all the notes that are in the past. + nextNoteIndex = 0; + } + public function onBeatHit():Void { if (notes.members.length > 1) notes.members.insertionSort(compareNoteSprites.bind(FlxSort.ASCENDING)); @@ -485,6 +509,13 @@ class Strumline extends FlxSpriteGroup if (cover == null) continue; cover.kill(); } + + heldKeys = [false, false, false, false]; + + for (dir in DIRECTIONS) + { + playStatic(dir); + } } public function applyNoteData(data:Array):Void diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx index 8f8e24a71..398c28753 100644 --- a/source/funkin/play/song/Song.hx +++ b/source/funkin/play/song/Song.hx @@ -39,7 +39,11 @@ class Song implements IPlayStateScriptedClass var difficultyIds:Array; - public function new(id:String) + /** + * @param id The ID of the song to load. + * @param ignoreErrors If false, an exception will be thrown if the song data could not be loaded. + */ + public function new(id:String, ignoreErrors:Bool = false) { this.songId = id; @@ -47,13 +51,49 @@ class Song implements IPlayStateScriptedClass difficultyIds = []; difficulties = new Map(); - _metadata = SongDataParser.loadSongMetadata(songId); - if (_metadata == null || _metadata.length == 0) + try + { + _metadata = SongDataParser.loadSongMetadata(songId); + } + catch (e) + { + _metadata = []; + } + + if (_metadata.length == 0 && !ignoreErrors) { throw 'Could not find song data for songId: $songId'; } + else + { + populateFromMetadata(); + } + } - populateFromMetadata(); + @:allow(funkin.play.song.Song) + public static function buildRaw(songId:String, metadata:Array, variations:Array, charts:Map, + ?validScore:Bool = false):Song + { + var result:Song = new Song(songId, true); + + result._metadata.clear(); + for (meta in metadata) + result._metadata.push(meta); + + result.variations.clear(); + for (vari in variations) + result.variations.push(vari); + + result.difficultyIds.clear(); + + result.populateFromMetadata(); + + for (variation => chartData in charts) + result.applyChartData(chartData, variation); + + result.validScore = validScore; + + return result; } public function getRawMetadata():Array @@ -67,6 +107,8 @@ class Song implements IPlayStateScriptedClass */ function populateFromMetadata():Void { + if (_metadata == null || _metadata.length == 0) return; + // Variations may have different artist, time format, generatedBy, etc. for (metadata in _metadata) { @@ -119,28 +161,33 @@ class Song implements IPlayStateScriptedClass for (variation in variations) { var chartData:SongChartData = SongDataParser.parseSongChartData(songId, variation); - var chartNotes = chartData.notes; - - for (diffId in chartNotes.keys()) - { - // Retrieve the cached difficulty data. - var difficulty:Null = difficulties.get(diffId); - if (difficulty == null) - { - trace('Fabricated new difficulty for $diffId.'); - difficulty = new SongDifficulty(this, diffId, variation); - difficulties.set(diffId, difficulty); - } - // Add the chart data to the difficulty. - difficulty.notes = chartData.notes.get(diffId); - difficulty.scrollSpeed = chartData.getScrollSpeed(diffId); - - difficulty.events = chartData.events; - } + applyChartData(chartData, variation); } trace('Done caching charts.'); } + function applyChartData(chartData:SongChartData, variation:String):Void + { + var chartNotes = chartData.notes; + + for (diffId in chartNotes.keys()) + { + // Retrieve the cached difficulty data. + var difficulty:Null = difficulties.get(diffId); + if (difficulty == null) + { + trace('Fabricated new difficulty for $diffId.'); + difficulty = new SongDifficulty(this, diffId, variation); + difficulties.set(diffId, difficulty); + } + // Add the chart data to the difficulty. + difficulty.notes = chartData.notes.get(diffId); + difficulty.scrollSpeed = chartData.getScrollSpeed(diffId); + + difficulty.events = chartData.events; + } + } + /** * Retrieve the metadata for a specific difficulty, including the chart if it is loaded. * @param diffId The difficulty ID, such as `easy` or `hard`. diff --git a/source/funkin/play/song/SongData.hx b/source/funkin/play/song/SongData.hx index c44180b20..6f2475cf9 100644 --- a/source/funkin/play/song/SongData.hx +++ b/source/funkin/play/song/SongData.hx @@ -205,7 +205,7 @@ class SongDataParser static function loadMusicMetadataFile(musicPath:String, variation:String = ''):String { - var musicMetadataFilePath:String = (variation != '') ? Paths.file('$MUSIC_DATA_PATH$musicPath/$musicPath-metadata-$variation.json') : Paths.file('$MUSIC_DATA_PATH$musicPath/$musicPath-metadata.json'); + var musicMetadataFilePath:String = (variation != '' || variation == "default") ? Paths.file('$MUSIC_DATA_PATH$musicPath/$musicPath-metadata-$variation.json') : Paths.file('$MUSIC_DATA_PATH$musicPath/$musicPath-metadata.json'); var rawJson:String = Assets.getText(musicMetadataFilePath).trim(); @@ -245,7 +245,7 @@ class SongDataParser static function loadSongChartDataFile(songPath:String, variation:String = ''):String { - var songChartDataFilePath:String = (variation != '') ? Paths.json('$SONG_DATA_PATH$songPath/$songPath-chart-$variation') : Paths.json('$SONG_DATA_PATH$songPath/$songPath-chart'); + var songChartDataFilePath:String = (variation != '' || variation == 'default') ? Paths.json('$SONG_DATA_PATH$songPath/$songPath-chart-$variation') : Paths.json('$SONG_DATA_PATH$songPath/$songPath-chart'); var rawJson:String = Assets.getText(songChartDataFilePath).trim(); diff --git a/source/funkin/play/stage/Bopper.hx b/source/funkin/play/stage/Bopper.hx index 5fb1022fe..a144026f5 100644 --- a/source/funkin/play/stage/Bopper.hx +++ b/source/funkin/play/stage/Bopper.hx @@ -158,8 +158,11 @@ class Bopper extends StageProp implements IPlayStateScriptedClass */ public function resetPosition() { - this.x = originalPosition.x + animOffsets[0]; - this.y = originalPosition.y + animOffsets[1]; + var oldAnimOffsets = [animOffsets[0], animOffsets[1]]; + animOffsets = [0, 0]; + this.x = originalPosition.x; + this.y = originalPosition.y; + animOffsets = oldAnimOffsets; } function update_shouldAlternate():Void @@ -200,10 +203,12 @@ class Bopper extends StageProp implements IPlayStateScriptedClass { if (hasDanced) { + trace('DanceRight (alternate)'); playAnimation('danceRight$idleSuffix', forceRestart); } else { + trace('DanceLeft (alternate)'); playAnimation('danceLeft$idleSuffix', forceRestart); } hasDanced = !hasDanced; diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx index ef2d28430..f4f380a0b 100644 --- a/source/funkin/play/stage/Stage.hx +++ b/source/funkin/play/stage/Stage.hx @@ -78,6 +78,10 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass if (getBoyfriend() != null) { getBoyfriend().resetCharacter(true); + // Reapply the camera offsets. + var charData = _data.characters.bf; + getBoyfriend().cameraFocusPoint.x += charData.cameraOffsets[0]; + getBoyfriend().cameraFocusPoint.y += charData.cameraOffsets[1]; } else { @@ -86,10 +90,18 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass if (getGirlfriend() != null) { getGirlfriend().resetCharacter(true); + // Reapply the camera offsets. + var charData = _data.characters.gf; + getGirlfriend().cameraFocusPoint.x += charData.cameraOffsets[0]; + getGirlfriend().cameraFocusPoint.y += charData.cameraOffsets[1]; } if (getDad() != null) { getDad().resetCharacter(true); + // Reapply the camera offsets. + var charData = _data.characters.dad; + getDad().cameraFocusPoint.x += charData.cameraOffsets[0]; + getDad().cameraFocusPoint.y += charData.cameraOffsets[1]; } // Reset positions of named props. @@ -216,8 +228,12 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass { cast(propSprite, Bopper).setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]); } - cast(propSprite, Bopper).originalPosition.x = dataProp.position[0]; - cast(propSprite, Bopper).originalPosition.y = dataProp.position[1]; + + if (!Std.isOfType(propSprite, BaseCharacter)) + { + cast(propSprite, Bopper).originalPosition.x = dataProp.position[0]; + cast(propSprite, Bopper).originalPosition.y = dataProp.position[1]; + } } if (dataProp.startingAnimation != null) @@ -225,7 +241,11 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass propSprite.animation.play(dataProp.startingAnimation); } - if (Std.isOfType(propSprite, Bopper)) + if (Std.isOfType(propSprite, BaseCharacter)) + { + // Character stuff. + } + else if (Std.isOfType(propSprite, Bopper)) { addBopper(cast propSprite, dataProp.name); } @@ -357,8 +377,12 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass character.x = charData.position[0] - character.characterOrigin.x + character.globalOffsets[0]; character.y = charData.position[1] - character.characterOrigin.y + character.globalOffsets[1]; - character.originalPosition.x = character.x; - character.originalPosition.y = character.y; + @:privateAccess(funkin.play.stage.Bopper) + { + // Undo animOffsets before saving original position. + character.originalPosition.x = character.x + character.animOffsets[0]; + character.originalPosition.y = character.y + character.animOffsets[1]; + } character.cameraFocusPoint.x += charData.cameraOffsets[0]; character.cameraFocusPoint.y += charData.cameraOffsets[1]; @@ -674,6 +698,27 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass } } + public override function kill() + { + _skipTransformChildren = true; + alive = false; + exists = false; + _skipTransformChildren = false; + if (group != null) group.kill(); + } + + public override function remove(Sprite:FlxSprite, Splice:Bool = false):FlxSprite + { + var sprite:FlxSprite = cast Sprite; + sprite.x -= x; + sprite.y -= y; + // alpha + sprite.cameras = null; + + if (group != null) group.remove(Sprite, Splice); + return Sprite; + } + public function onScriptEvent(event:ScriptEvent) {} public function onPause(event:PauseScriptEvent) {} diff --git a/source/funkin/ui/debug/DebugMenuSubState.hx b/source/funkin/ui/debug/DebugMenuSubState.hx index 9f18acd35..7ef4cb238 100644 --- a/source/funkin/ui/debug/DebugMenuSubState.hx +++ b/source/funkin/ui/debug/DebugMenuSubState.hx @@ -1,5 +1,6 @@ package funkin.ui.debug; +import flixel.math.FlxPoint; import flixel.FlxObject; import flixel.FlxSprite; import funkin.MusicBeatSubState; @@ -48,6 +49,9 @@ class DebugMenuSubState extends MusicBeatSubState createItem("ANIMATION EDITOR", openAnimationEditor); createItem("STAGE EDITOR", openStageEditor); createItem("TEST STICKERS", testStickers); + + FlxG.camera.focusOn(new FlxPoint(camFocusPoint.x, camFocusPoint.y)); + FlxG.camera.focusOn(new FlxPoint(camFocusPoint.x, camFocusPoint.y + 500)); } function onMenuChange(selected:TextMenuItem) diff --git a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx index bb08e8d6b..6641a16c0 100644 --- a/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx +++ b/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx @@ -986,6 +986,8 @@ class ChartEditorDialogHandler state.isHaxeUIDialogOpen = false; }; + dialog.zIndex = 1000; + return dialog; } } diff --git a/source/funkin/ui/debug/charting/ChartEditorNotePreview.hx b/source/funkin/ui/debug/charting/ChartEditorNotePreview.hx index 27951f079..d3296c400 100644 --- a/source/funkin/ui/debug/charting/ChartEditorNotePreview.hx +++ b/source/funkin/ui/debug/charting/ChartEditorNotePreview.hx @@ -40,7 +40,7 @@ class ChartEditorNotePreview extends FlxSprite */ function buildBackground():Void { - makeGraphic(WIDTH, 0, BG_COLOR); + makeGraphic(WIDTH, previewHeight, BG_COLOR); } /** diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx index e1a55f947..26b001d7e 100644 --- a/source/funkin/ui/debug/charting/ChartEditorState.hx +++ b/source/funkin/ui/debug/charting/ChartEditorState.hx @@ -2,8 +2,11 @@ package funkin.ui.debug.charting; import flixel.addons.display.FlxSliceSprite; import flixel.addons.display.FlxTiledSprite; +import flixel.FlxCamera; import flixel.FlxSprite; +import flixel.FlxSubState; import flixel.group.FlxSpriteGroup; +import flixel.addons.transition.FlxTransitionableState; import flixel.input.keyboard.FlxKey; import flixel.math.FlxPoint; import flixel.math.FlxRect; @@ -25,6 +28,7 @@ import funkin.play.character.BaseCharacter.CharacterType; import funkin.play.HealthIcon; import funkin.play.notes.NoteSprite; import funkin.play.notes.Strumline; +import funkin.play.PlayState; import funkin.play.song.Song; import funkin.play.song.SongData.SongChartData; import funkin.play.song.SongData.SongDataParser; @@ -374,7 +378,7 @@ class ChartEditorState extends HaxeUIState /** * Whether to play a metronome sound while the playhead is moving. */ - var shouldPlayMetronome:Bool = true; + var isMetronomeEnabled:Bool = true; /** * Use the tool window to affect how the user interacts with the program. @@ -413,6 +417,11 @@ class ChartEditorState extends HaxeUIState return isViewDownscroll; } + /** + * If true, playtesting a chart will skip to the current playhead position. + */ + var playtestStartTime:Bool = false; + /** * Whether hitsounds are enabled for at least one character. */ @@ -894,7 +903,7 @@ class ChartEditorState extends HaxeUIState function get_currentSongId():String { - return currentSongName.toLowerKebabCase(); + return currentSongName.toLowerKebabCase().replace('.', '').replace(' ', '-'); } var currentSongArtist(get, set):String; @@ -1074,6 +1083,13 @@ class ChartEditorState extends HaxeUIState override function create():Void { + // super.create() must be called first, the HaxeUI components get created here. + super.create(); + // Set the z-index of the HaxeUI. + this.component.zIndex = 100; + + fixCamera(); + // Get rid of any music from the previous state. FlxG.sound.music.stop(); @@ -1088,8 +1104,6 @@ class ChartEditorState extends HaxeUIState buildNotePreview(); buildSelectionBox(); - // Add the HaxeUI components after the grid so they're on top. - super.create(); buildAdditionalUI(); // Setup the onClick listeners for the UI after it's been created. @@ -1098,6 +1112,8 @@ class ChartEditorState extends HaxeUIState setupAutoSave(); + refresh(); + ChartEditorDialogHandler.openWelcomeDialog(this, false); } @@ -1127,6 +1143,7 @@ class ChartEditorState extends HaxeUIState menuBG.updateHitbox(); menuBG.screenCenter(); menuBG.scrollFactor.set(0, 0); + menuBG.zIndex = -100; } /** @@ -1138,28 +1155,33 @@ class ChartEditorState extends HaxeUIState gridTiledSprite.x = FlxG.width / 2 - GRID_SIZE * STRUMLINE_SIZE; // Center the grid. gridTiledSprite.y = MENU_BAR_HEIGHT + GRID_TOP_PAD; // Push down to account for the menu bar. add(gridTiledSprite); + gridTiledSprite.zIndex = 10; gridGhostNote = new ChartEditorNoteSprite(this); gridGhostNote.alpha = 0.6; gridGhostNote.noteData = new SongNoteData(0, 0, 0, ""); gridGhostNote.visible = false; add(gridGhostNote); + gridGhostNote.zIndex = 11; gridGhostEvent = new ChartEditorEventSprite(this); gridGhostEvent.alpha = 0.6; gridGhostEvent.eventData = new SongEventData(-1, '', {}); gridGhostEvent.visible = false; add(gridGhostEvent); + gridGhostEvent.zIndex = 12; buildNoteGroup(); gridPlayheadScrollArea = new FlxSprite(gridTiledSprite.x - PLAYHEAD_SCROLL_AREA_WIDTH, MENU_BAR_HEIGHT).makeGraphic(PLAYHEAD_SCROLL_AREA_WIDTH, FlxG.height - MENU_BAR_HEIGHT, PLAYHEAD_SCROLL_AREA_COLOR); add(gridPlayheadScrollArea); + gridPlayheadScrollArea.zIndex = 25; // The playhead that show the current position in the song. gridPlayhead = new FlxSpriteGroup(); add(gridPlayhead); + gridPlayhead.zIndex = 30; var playheadWidth:Int = GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + (PLAYHEAD_SCROLL_AREA_WIDTH * 2); var playheadBaseYPos:Float = MENU_BAR_HEIGHT + GRID_TOP_PAD; @@ -1175,26 +1197,29 @@ class ChartEditorState extends HaxeUIState gridPlayhead.add(playheadBlock); // Character icons. - healthIconDad = new HealthIcon('dad'); + healthIconDad = new HealthIcon(currentSongCharacterOpponent); healthIconDad.autoUpdate = false; healthIconDad.size.set(0.5, 0.5); healthIconDad.x = gridTiledSprite.x - 15 - (HealthIcon.HEALTH_ICON_SIZE * 0.5); healthIconDad.y = gridTiledSprite.y + 5; add(healthIconDad); + healthIconDad.zIndex = 30; - healthIconBF = new HealthIcon('bf'); + healthIconBF = new HealthIcon(currentSongCharacterPlayer); healthIconBF.autoUpdate = false; healthIconBF.size.set(0.5, 0.5); healthIconBF.x = gridTiledSprite.x + GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + 15; healthIconBF.y = gridTiledSprite.y + 5; healthIconBF.flipX = true; add(healthIconBF); + healthIconBF.zIndex = 30; } function buildSelectionBox():Void { selectionBoxSprite.scrollFactor.set(0, 0); add(selectionBoxSprite); + selectionBoxSprite.zIndex = 30; setSelectionBoxBounds(); } @@ -1222,7 +1247,8 @@ class ChartEditorState extends HaxeUIState var height:Int = FlxG.height - MENU_BAR_HEIGHT - GRID_TOP_PAD - 200; notePreview = new ChartEditorNotePreview(height); notePreview.y = MENU_BAR_HEIGHT + GRID_TOP_PAD; - add(notePreview); + // TODO: Re-enable. + // add(notePreview); } function buildSpectrogram(target:FlxSound):Void @@ -1243,18 +1269,22 @@ class ChartEditorState extends HaxeUIState renderedHoldNotes = new FlxTypedSpriteGroup(); renderedHoldNotes.setPosition(gridTiledSprite.x, gridTiledSprite.y); add(renderedHoldNotes); + renderedHoldNotes.zIndex = 24; renderedNotes = new FlxTypedSpriteGroup(); renderedNotes.setPosition(gridTiledSprite.x, gridTiledSprite.y); add(renderedNotes); + renderedNotes.zIndex = 25; renderedEvents = new FlxTypedSpriteGroup(); renderedEvents.setPosition(gridTiledSprite.x, gridTiledSprite.y); add(renderedEvents); + renderedNotes.zIndex = 25; renderedSelectionSquares = new FlxTypedSpriteGroup(); renderedSelectionSquares.setPosition(gridTiledSprite.x, gridTiledSprite.y); add(renderedSelectionSquares); + renderedNotes.zIndex = 26; } var playbarHeadLayout:Component; @@ -1262,6 +1292,7 @@ class ChartEditorState extends HaxeUIState function buildAdditionalUI():Void { playbarHeadLayout = buildComponent(CHART_EDITOR_PLAYBARHEAD_LAYOUT); + playbarHeadLayout.zIndex = 110; playbarHeadLayout.width = FlxG.width - 8; playbarHeadLayout.height = 10; @@ -1393,6 +1424,9 @@ class ChartEditorState extends HaxeUIState // addUIClickListener('menubarItemSelectBeforeCursor', _ -> doSomething()); // addUIClickListener('menubarItemSelectAfterCursor', _ -> doSomething()); + addUIClickListener('menubarItemPlaytestFull', _ -> testSongInPlayState(false)); + addUIClickListener('menubarItemPlaytestMinimal', _ -> testSongInPlayState(true)); + addUIChangeListener('menubarItemInputStyleGroup', function(event:UIEvent) { trace('Change input style: ${event.target}'); }); @@ -1404,6 +1438,9 @@ class ChartEditorState extends HaxeUIState addUIChangeListener('menubarItemDownscroll', event -> isViewDownscroll = event.value); setUICheckboxSelected('menubarItemDownscroll', isViewDownscroll); + addUIChangeListener('menubarItemPlaytestStartTime', event -> playtestStartTime = event.value); + setUICheckboxSelected('menubarItemPlaytestStartTime', playtestStartTime); + addUIChangeListener('menuBarItemThemeLight', function(event:UIEvent) { if (event.target.value) currentTheme = ChartEditorTheme.Light; }); @@ -1414,8 +1451,8 @@ class ChartEditorState extends HaxeUIState }); setUICheckboxSelected('menuBarItemThemeDark', currentTheme == ChartEditorTheme.Dark); - addUIChangeListener('menubarItemMetronomeEnabled', event -> shouldPlayMetronome = event.value); - setUICheckboxSelected('menubarItemMetronomeEnabled', shouldPlayMetronome); + addUIChangeListener('menubarItemMetronomeEnabled', event -> isMetronomeEnabled = event.value); + setUICheckboxSelected('menubarItemMetronomeEnabled', isMetronomeEnabled); addUIChangeListener('menubarItemPlayerHitsounds', event -> hitsoundsEnabledPlayer = event.value); setUICheckboxSelected('menubarItemPlayerHitsounds', hitsoundsEnabledPlayer); @@ -1551,6 +1588,7 @@ class ChartEditorState extends HaxeUIState handleFileKeybinds(); handleEditKeybinds(); handleViewKeybinds(); + handleTestKeybinds(); handleHelpKeybinds(); // DEBUG @@ -1578,7 +1616,7 @@ class ChartEditorState extends HaxeUIState // dispatchEvent gets called here. if (!super.beatHit()) return false; - if (shouldPlayMetronome && (audioInstTrack != null && audioInstTrack.playing)) + if (isMetronomeEnabled && this.subState == null && (audioInstTrack != null && audioInstTrack.playing)) { playMetronomeTick(Conductor.currentBeat % 4 == 0); } @@ -2719,6 +2757,18 @@ class ChartEditorState extends HaxeUIState */ function handleViewKeybinds():Void {} + /** + * Handle keybinds for the Test menu items. + */ + function handleTestKeybinds():Void + { + if (!isHaxeUIDialogOpen && FlxG.keys.justPressed.ENTER) + { + var minimal = FlxG.keys.pressed.SHIFT; + testSongInPlayState(minimal); + } + } + /** * Handle keybinds for Help menu items. */ @@ -3138,8 +3188,8 @@ class ChartEditorState extends HaxeUIState function startAudioPlayback():Void { - if (audioInstTrack != null) audioInstTrack.play(); - if (audioVocalTrackGroup != null) audioVocalTrackGroup.play(); + if (audioInstTrack != null) audioInstTrack.play(false, audioInstTrack.time); + if (audioVocalTrackGroup != null) audioVocalTrackGroup.play(false, audioInstTrack.time); setComponentText('playbarPlay', '||'); } @@ -3252,6 +3302,77 @@ class ChartEditorState extends HaxeUIState return this.scrollPositionInPixels; } + /** + * Transitions to the Play State to test the song + */ + public function testSongInPlayState(?minimal:Bool = false):Void + { + var startTimestamp:Float = 0; + if (playtestStartTime) startTimestamp = scrollPositionInMs + playheadPositionInMs; + + var targetSong:Song = Song.buildRaw(currentSongId, songMetadata.values(), availableVariations, songChartData, false); + + // TODO: Rework asset system so we can remove this. + switch (currentSongStage) + { + case 'mainStage': + Paths.setCurrentLevel('week1'); + case 'spookyMansion': + Paths.setCurrentLevel('week2'); + case 'phillyTrain': + Paths.setCurrentLevel('week3'); + case 'limoRide': + Paths.setCurrentLevel('week4'); + case 'mallXmas' | 'mallEvil': + Paths.setCurrentLevel('week5'); + case 'school' | 'schoolEvil': + Paths.setCurrentLevel('week6'); + case 'tankmanBattlefield': + Paths.setCurrentLevel('week7'); + case 'phillyStreets' | 'phillyBlazin': + Paths.setCurrentLevel('weekend1'); + } + + subStateClosed.add(fixCamera); + subStateClosed.add(updateConductor); + + FlxTransitionableState.skipNextTransIn = false; + FlxTransitionableState.skipNextTransOut = false; + + var targetState = new PlayState( + { + targetSong: targetSong, + targetDifficulty: selectedDifficulty, + // TODO: Add this. + // targetCharacter: targetCharacter, + practiceMode: true, + minimalMode: minimal, + startTimestamp: startTimestamp, + overrideMusic: true, + }); + + // Override music. + FlxG.sound.music = audioInstTrack; + targetState.vocals = audioVocalTrackGroup; + + openSubState(targetState); + } + + function fixCamera(_:FlxSubState = null):Void + { + FlxG.cameras.reset(new FlxCamera()); + FlxG.camera.focusOn(new FlxPoint(FlxG.width / 2, FlxG.height / 2)); + FlxG.camera.zoom = 1.0; + + add(this.component); + } + + function updateConductor(_:FlxSubState = null):Void + { + var targetPos = scrollPositionInMs; + Conductor.update(targetPos); + } + /** * Loads an instrumental from an absolute file path, replacing the current instrumental. * @@ -3374,14 +3495,23 @@ class ChartEditorState extends HaxeUIState * @param charKey Character to load the vocal track for. * @return Success or failure. */ - public function loadVocalsFromAsset(path:String, charKey:String = 'default'):Bool + public function loadVocalsFromAsset(path:String, charType:CharacterType = OTHER):Bool { var vocalTrack:FlxSound = FlxG.sound.load(path, 1.0, false); if (vocalTrack != null) { - audioVocalTrackGroup.add(vocalTrack); - - audioVocalTrackData.set(charKey, Assets.getBytes(path)); + switch (charType) + { + case CharacterType.BF: + audioVocalTrackGroup.addPlayerVoice(vocalTrack); + audioVocalTrackData.set(currentSongCharacterPlayer, Assets.getBytes(path)); + case CharacterType.DAD: + audioVocalTrackGroup.addOpponentVoice(vocalTrack); + audioVocalTrackData.set(currentSongCharacterOpponent, Assets.getBytes(path)); + default: + audioVocalTrackGroup.add(vocalTrack); + audioVocalTrackData.set('default', Assets.getBytes(path)); + } return true; } @@ -3442,10 +3572,18 @@ class ChartEditorState extends HaxeUIState loadInstrumentalFromAsset(Paths.inst(songId)); - var voiceList:Array = song.getDifficulty(selectedDifficulty).buildVoiceList(); - for (voicePath in voiceList) + var voiceList:Array = song.getDifficulty(selectedDifficulty).buildVoiceList(currentSongCharacterPlayer); + if (voiceList.length == 2) { - loadVocalsFromAsset(voicePath); + loadVocalsFromAsset(voiceList[0], BF); + loadVocalsFromAsset(voiceList[1], DAD); + } + else + { + for (voicePath in voiceList) + { + loadVocalsFromAsset(voicePath); + } } NotificationManager.instance.addNotification( diff --git a/source/funkin/ui/story/Level.hx b/source/funkin/ui/story/Level.hx index 83682fec9..7913ac8ca 100644 --- a/source/funkin/ui/story/Level.hx +++ b/source/funkin/ui/story/Level.hx @@ -71,7 +71,12 @@ class Level implements IRegistryEntry { var songList:Array = getSongs() ?? []; var songNameList:Array = songList.map(function(songId) { - return funkin.play.song.SongData.SongDataParser.fetchSong(songId) ?.getDifficulty(difficulty) ?.songName ?? 'Unknown'; + var song:Song = funkin.play.song.SongData.SongDataParser.fetchSong(songId); + if (song == null) return 'Unknown'; + var songDifficulty:SongDifficulty = song.getDifficulty(difficulty); + if (songDifficulty == null) songDifficulty = song.getDifficulty(); + var songName:String = songDifficulty?.songName; + return songName ?? 'Unknown'; }); return songNameList; } diff --git a/source/funkin/ui/story/StoryMenuState.hx b/source/funkin/ui/story/StoryMenuState.hx index 12b0f58c5..8276777ab 100644 --- a/source/funkin/ui/story/StoryMenuState.hx +++ b/source/funkin/ui/story/StoryMenuState.hx @@ -5,6 +5,7 @@ import flixel.addons.transition.FlxTransitionableState; import flixel.FlxSprite; import flixel.group.FlxGroup.FlxTypedGroup; import flixel.text.FlxText; +import flixel.addons.transition.FlxTransitionableState; import flixel.tweens.FlxEase; import flixel.tweens.FlxTween; import flixel.util.FlxColor; @@ -469,7 +470,7 @@ class StoryMenuState extends MusicBeatState // super.dispatchEvent(event) dispatches event to module scripts. super.dispatchEvent(event); - if ((levelProps?.length ?? 0) > 0) + if (levelProps != null && levelProps.length > 0) { // Dispatch event to props. for (prop in levelProps) @@ -513,7 +514,17 @@ class StoryMenuState extends MusicBeatState PlayStatePlaylist.campaignId = currentLevel.id; PlayStatePlaylist.campaignTitle = currentLevel.getTitle(); + if (targetSong != null) + { + // Load and cache the song's charts. + // TODO: Do this in the loading state. + targetSong.cacheCharts(true); + } + new FlxTimer().start(1, function(tmr:FlxTimer) { + FlxTransitionableState.skipNextTransIn = false; + FlxTransitionableState.skipNextTransOut = false; + LoadingState.loadAndSwitchState(new PlayState( { targetSong: targetSong, diff --git a/source/funkin/util/tools/ArrayTools.hx b/source/funkin/util/tools/ArrayTools.hx index c27f1bf43..67cc1c041 100644 --- a/source/funkin/util/tools/ArrayTools.hx +++ b/source/funkin/util/tools/ArrayTools.hx @@ -37,4 +37,15 @@ class ArrayTools } return null; } + + /** + * Remove all elements from the array, without creating a new array. + * @param array The array to clear. + */ + public static function clear(array:Array):Void + { + // This method is faster than array.splice(0, array.length) + while (array.length > 0) + array.pop(); + } } diff --git a/source/funkin/util/tools/Int64Tools.hx b/source/funkin/util/tools/Int64Tools.hx new file mode 100644 index 000000000..75448b36f --- /dev/null +++ b/source/funkin/util/tools/Int64Tools.hx @@ -0,0 +1,32 @@ +package funkin.util.tools; + +/** + * @see https://github.com/fponticelli/thx.core/blob/master/src/thx/Int64s.hx + */ +class Int64Tools +{ + static var min = haxe.Int64.make(0x80000000, 0); + static var one = haxe.Int64.make(0, 1); + static var two = haxe.Int64.ofInt(2); + static var zero = haxe.Int64.make(0, 0); + static var ten = haxe.Int64.ofInt(10); + + public static function toFloat(i:haxe.Int64):Float + { + var isNegative = false; + if (i < 0) + { + if (i < min) return -9223372036854775808.0; // most -ve value can't be made +ve + isNegative = true; + i = -i; + } + var multiplier = 1.0, ret = 0.0; + for (_ in 0...64) + { + if (haxe.Int64.and(i, one) != zero) ret += multiplier; + multiplier *= 2.0; + i = haxe.Int64.shr(i, 1); + } + return (isNegative ? -1 : 1) * ret; + } +}