diff --git a/Project.xml b/Project.xml index 1382f5f71..4c6ad39a6 100644 --- a/Project.xml +++ b/Project.xml @@ -122,7 +122,8 @@ <!--haxelib name="newgrounds" unless="switch"/> --> <haxelib name="faxe" if='switch' /> <haxelib name="polymod" /> - <haxelib name="firetongue" /> + + <haxelib name="thx.semver" /> <!-- <haxelib name="colyseus"/> --> <!-- <haxelib name="colyseus-websocket" /> --> diff --git a/example_mods/introMod/images/gfDanceTitle.png b/example_mods/introMod/images/gfDanceTitle.png deleted file mode 100644 index 989f2a68a..000000000 Binary files a/example_mods/introMod/images/gfDanceTitle.png and /dev/null differ diff --git a/source/Main.hx b/source/Main.hx index fb2a01a69..32b7433f3 100644 --- a/source/Main.hx +++ b/source/Main.hx @@ -46,8 +46,6 @@ class Main extends Sprite // 4. Replace the call to PolymodHandler.loadAllMods() with a call to PolymodHandler.loadModsById(ids:Array<String>). funkin.modding.PolymodHandler.loadAllMods(); - funkin.i18n.FireTongueHandler.init(); - if (stage != null) { init(); diff --git a/source/funkin/Character.hx b/source/funkin/Character.hx index a81d599a6..0ffac56e8 100644 --- a/source/funkin/Character.hx +++ b/source/funkin/Character.hx @@ -57,7 +57,6 @@ class Character extends FlxSprite loadOffsetFile(curCharacter); playAnim('danceRight'); - case 'gf-christmas': tex = Paths.getSparrowAtlas('characters/gfChristmas'); frames = tex; @@ -133,19 +132,6 @@ class Character extends FlxSprite updateHitbox(); antialiasing = false; - case 'dad': - // DAD ANIMATION LOADING CODE - tex = Paths.getSparrowAtlas('characters/DADDY_DEAREST'); - frames = tex; - quickAnimAdd('idle', 'Dad idle dance'); - quickAnimAdd('singUP', 'Dad Sing Note UP'); - quickAnimAdd('singRIGHT', 'Dad Sing Note RIGHT'); - quickAnimAdd('singDOWN', 'Dad Sing Note DOWN'); - quickAnimAdd('singLEFT', 'Dad Sing Note LEFT'); - - loadOffsetFile(curCharacter); - - playAnim('idle'); case 'spooky': tex = Paths.getSparrowAtlas('characters/spooky_kids_assets'); frames = tex; @@ -259,36 +245,6 @@ class Character extends FlxSprite loadMappedAnims(); - case 'bf': - var tex = Paths.getSparrowAtlas('characters/BOYFRIEND'); - frames = tex; - quickAnimAdd('idle', 'BF idle dance'); - quickAnimAdd('singUP', 'BF NOTE UP0'); - quickAnimAdd('singLEFT', 'BF NOTE LEFT0'); - quickAnimAdd('singRIGHT', 'BF NOTE RIGHT0'); - quickAnimAdd('singDOWN', 'BF NOTE DOWN0'); - quickAnimAdd('singUPmiss', 'BF NOTE UP MISS'); - quickAnimAdd('singLEFTmiss', 'BF NOTE LEFT MISS'); - quickAnimAdd('singRIGHTmiss', 'BF NOTE RIGHT MISS'); - quickAnimAdd('singDOWNmiss', 'BF NOTE DOWN MISS'); - quickAnimAdd('preAttack', 'bf pre attack'); - quickAnimAdd('attack', 'boyfriend attack'); - quickAnimAdd('hey', 'BF HEY'); - - quickAnimAdd('firstDeath', "BF dies"); - animation.addByPrefix('deathLoop', "BF Dead Loop", 24, true); - quickAnimAdd('deathConfirm', "BF Dead confirm"); - - animation.addByPrefix('scared', 'BF idle shaking', 24, true); - - loadOffsetFile(curCharacter); - - playAnim('idle'); - - flipX = true; - - loadOffsetFile(curCharacter); - case 'bf-christmas': var tex = Paths.getSparrowAtlas('characters/bfChristmas'); frames = tex; @@ -693,7 +649,7 @@ class Character extends FlxSprite */ public function dance() { - if (animation == null) + if (animation == null || animation.curAnim == null) return; if (!debugMode) { diff --git a/source/funkin/GameOverSubstate.hx b/source/funkin/GameOverSubstate.hx index 3c397ee78..fe957af25 100644 --- a/source/funkin/GameOverSubstate.hx +++ b/source/funkin/GameOverSubstate.hx @@ -1,5 +1,8 @@ package funkin; +import funkin.modding.events.ScriptEventDispatcher; +import funkin.modding.events.ScriptEvent; +import funkin.play.character.BaseCharacter; import flixel.FlxObject; import flixel.FlxSubState; import flixel.math.FlxPoint; @@ -10,80 +13,83 @@ import haxe.display.Display; import funkin.ui.PreferencesMenu; import funkin.play.PlayState; +using StringTools; + +/** + * A substate which renders over the PlayState when the player dies. + * Displays the player death animation, plays the music, and handles restarting the song. + * + * The newest implementation uses a substate, which prevents having to reload the song and stage each reset. + */ class GameOverSubstate extends MusicBeatSubstate { - var bf:Boyfriend; - var camFollow:FlxObject; + /** + * The boyfriend character. + */ + var boyfriend:BaseCharacter; - var stageSuffix:String = ""; - var randomGameover:Int = 1; + /** + * The invisible object in the scene which the camera focuses on. + */ + var cameraFollowPoint:FlxObject; - var gameOverMusic:FlxSound; + /** + * The music playing in the background of the state. + */ + var gameOverMusic:FlxSound = new FlxSound(); + + /** + * Whether the player has confirmed and prepared to restart the level. + * This means the animation and transition have already started. + */ + var isEnding:Bool = false; + + /** + * Music variant to use. + * TODO: De-hardcode this somehow. + */ + var musicVariant:String = ""; public function new() { - gameOverMusic = new FlxSound(); - FlxG.sound.list.add(gameOverMusic); - - var daStage = PlayState.instance.currentStageId; - var daBf:String = ''; - switch (daStage) - { - case 'school' | 'schoolEvil': - stageSuffix = '-pixel'; - daBf = 'bf-pixel-dead'; - default: - daBf = 'bf'; - } - - var daSong = PlayState.currentSong.song.toLowerCase(); - - switch (daSong) - { - case 'stress': - daBf = 'bf-holding-gf-dead'; - } - super(); + FlxG.sound.list.add(gameOverMusic); + gameOverMusic.stop(); + Conductor.songPosition = 0; - var bfXPos = PlayState.instance.currentStage.getBoyfriend().getScreenPosition().x; - var bfYPos = PlayState.instance.currentStage.getBoyfriend().getScreenPosition().y; - bf = new Boyfriend(bfXPos, bfYPos, daBf); - add(bf); + playBlueBalledSFX(); - camFollow = new FlxObject(bf.getGraphicMidpoint().x, bf.getGraphicMidpoint().y, 1, 1); - add(camFollow); - - FlxG.sound.play(Paths.sound('fnf_loss_sfx' + stageSuffix)); - // Conductor.changeBPM(100); - - switch (PlayState.currentSong.player1) + switch (PlayState.instance.currentStageId) { - case 'pico': - stageSuffix = 'Pico'; + case 'school' | 'schoolEvil': + musicVariant = "-pixel"; + default: + if (PlayState.instance.currentStage.getBoyfriend().characterId == 'pico') + { + musicVariant = "Pico"; + } + else + { + musicVariant = ""; + } } - // FlxG.camera.followLerp = 1; - // FlxG.camera.focusOn(FlxPoint.get(FlxG.width / 2, FlxG.height / 2)); + // We have to remove boyfriend from the stage. Then we can add him back at the end. + boyfriend = PlayState.instance.currentStage.getBoyfriend(true); + boyfriend.isDead = true; + boyfriend.playAnimation('firstDeath'); + add(boyfriend); - // commented out for now - FlxG.camera.scroll.set(); + cameraFollowPoint = new FlxObject(PlayState.instance.cameraFollowPoint.x, PlayState.instance.cameraFollowPoint.y, 1, 1); + add(cameraFollowPoint); + + // FlxG.camera.scroll.set(); FlxG.camera.target = null; - - bf.playAnim('firstDeath'); - - var randomCensor:Array<Int> = []; - - if (PreferencesMenu.getPref('censor-naughty')) - randomCensor = [1, 3, 8, 13, 17, 21]; - - randomGameover = FlxG.random.int(1, 25, randomCensor); + FlxG.camera.follow(cameraFollowPoint, LOCKON, 0.01); } - var playingDeathSound:Bool = false; - override function update(elapsed:Float) { // makes the lerp non-dependant on the framerate @@ -96,14 +102,14 @@ class GameOverSubstate extends MusicBeatSubstate var touch = FlxG.touches.getFirst(); if (touch != null) { - if (touch.overlaps(bf)) - endBullshit(); + if (touch.overlaps(boyfriend)) + confirmDeath(); } } if (controls.ACCEPT) { - endBullshit(); + confirmDeath(); } if (controls.BACK) @@ -119,74 +125,129 @@ class GameOverSubstate extends MusicBeatSubstate FlxG.switchState(new FreeplayState()); } - if (bf.animation.curAnim.name == 'firstDeath' && bf.animation.curAnim.curFrame == 12) + // Start panning the camera to BF after 12 frames. + // TODO: Should this be de-hardcoded? + if (boyfriend.getCurrentAnimation().startsWith('firstDeath') && boyfriend.animation.curAnim.curFrame == 12) { - FlxG.camera.follow(camFollow, LOCKON, 0.01); - } - - switch (PlayState.storyWeek) - { - case 7: - if (bf.animation.curAnim.name == 'firstDeath' && bf.animation.curAnim.finished && !playingDeathSound) - { - playingDeathSound = true; - - bf.startedDeath = true; - coolStartDeath(0.2); - - FlxG.sound.play(Paths.sound('jeffGameover/jeffGameover-' + randomGameover), 1, false, null, true, function() - { - if (!isEnding) - { - gameOverMusic.fadeIn(4, 0.2, 1); - } - // FlxG.sound.music.fadeIn(4, 0.2, 1); - }); - } - default: - if (bf.animation.curAnim.name == 'firstDeath' && bf.animation.curAnim.finished) - { - bf.startedDeath = true; - coolStartDeath(); - } + cameraFollowPoint.x = boyfriend.getGraphicMidpoint().x; + cameraFollowPoint.y = boyfriend.getGraphicMidpoint().y; } if (gameOverMusic.playing) { Conductor.songPosition = gameOverMusic.time; } + else + { + switch (PlayState.storyWeek) + { + case 7: + if (boyfriend.getCurrentAnimation().startsWith('firstDeath') && boyfriend.isAnimationFinished() && !playingJeffQuote) + { + playingJeffQuote = true; + playJeffQuote(); + + startDeathMusic(0.2); + } + default: + if (boyfriend.getCurrentAnimation().startsWith('firstDeath') && boyfriend.isAnimationFinished()) + { + startDeathMusic(); + } + } + } + + dispatchEvent(new UpdateScriptEvent(elapsed)); } - private function coolStartDeath(?vol:Float = 1):Void + override function dispatchEvent(event:ScriptEvent) + { + super.dispatchEvent(event); + + ScriptEventDispatcher.callEvent(boyfriend, event); + } + + /** + * Starts the death music at the appropriate volume. + * @param startingVolume + */ + function startDeathMusic(?startingVolume:Float = 1):Void { if (!isEnding) { - gameOverMusic.loadEmbedded(Paths.music('gameOver' + stageSuffix)); - gameOverMusic.volume = vol; + gameOverMusic.loadEmbedded(Paths.music('gameOver' + musicVariant)); + gameOverMusic.volume = startingVolume; + gameOverMusic.play(); + } + else + { + gameOverMusic.loadEmbedded(Paths.music('gameOverEnd' + musicVariant)); + gameOverMusic.volume = startingVolume; gameOverMusic.play(); } - // FlxG.sound.playMusic(); } - var isEnding:Bool = false; + /** + * Play the sound effect that occurs when + * boyfriend's testicles get utterly annihilated. + */ + function playBlueBalledSFX() + { + FlxG.sound.play(Paths.sound('fnf_loss_sfx' + musicVariant)); + } - function endBullshit():Void + var playingJeffQuote:Bool = false; + + /** + * Week 7-specific hardcoded behavior, to play a custom death quote. + * TODO: Make this a module somehow. + */ + function playJeffQuote() + { + var randomCensor:Array<Int> = []; + + if (PreferencesMenu.getPref('censor-naughty')) + randomCensor = [1, 3, 8, 13, 17, 21]; + + FlxG.sound.play(Paths.sound('jeffGameover/jeffGameover-' + FlxG.random.int(1, 25, randomCensor)), 1, false, null, true, function() + { + // Once the quote ends, fade in the game over music. + if (!isEnding && gameOverMusic != null) + { + gameOverMusic.fadeIn(4, 0.2, 1); + } + }); + } + + /** + * Do behavior which occurs when you confirm and move to restart the level. + */ + function confirmDeath():Void { if (!isEnding) { isEnding = true; - bf.playAnim('deathConfirm', true); - gameOverMusic.stop(); - // FlxG.sound.music.stop(); - FlxG.sound.play(Paths.music('gameOverEnd' + stageSuffix)); + startDeathMusic(); // isEnding changes this function's behavior. + + boyfriend.playAnimation('deathConfirm', true); + + // After the animation finishes... new FlxTimer().start(0.7, function(tmr:FlxTimer) { + // ...fade out the graphics. Then after that happens... FlxG.camera.fade(FlxColor.BLACK, 2, false, function() { + // ...close the GameOverSubstate. FlxG.camera.fade(FlxColor.BLACK, 1, true, null, true); PlayState.needsReset = true; + + // Readd Boyfriend to the stage. + boyfriend.isDead = false; + remove(boyfriend); + PlayState.instance.currentStage.addCharacter(boyfriend, BF); + + // Close the substate. close(); - // LoadingState.loadAndSwitchState(new PlayState()); }); }); } diff --git a/source/funkin/MusicBeatState.hx b/source/funkin/MusicBeatState.hx index 2dce0272b..f27e21b15 100644 --- a/source/funkin/MusicBeatState.hx +++ b/source/funkin/MusicBeatState.hx @@ -1,5 +1,9 @@ package funkin; +import funkin.play.stage.StageData.StageDataParser; +import funkin.play.character.CharacterData.CharacterDataParser; +import flixel.FlxState; +import flixel.FlxSubState; import flixel.util.FlxColor; import flixel.text.FlxText; import funkin.modding.events.ScriptEvent; @@ -8,6 +12,10 @@ import funkin.modding.events.ScriptEvent.UpdateScriptEvent; import funkin.Conductor.BPMChangeEvent; import flixel.addons.ui.FlxUIState; +/** + * 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 { private var curStep:Int = 0; @@ -21,6 +29,19 @@ class MusicBeatState extends FlxUIState public var leftWatermarkText:FlxText = null; public var rightWatermarkText:FlxText = null; + public function new() + { + super(); + + initCallbacks(); + } + + function initCallbacks() + { + subStateOpened.add(onOpenSubstateComplete); + subStateClosed.add(onCloseSubstateComplete); + } + override function create() { super.create(); @@ -35,6 +56,10 @@ class MusicBeatState extends FlxUIState { super.update(elapsed); + // This can now be used in EVERY STATE YAY! + if (FlxG.keys.justPressed.F5) + debug_refreshModules(); + // everyStep(); var oldStep:Int = curStep; @@ -71,6 +96,23 @@ class MusicBeatState extends FlxUIState ModuleHandler.callEvent(event); } + function debug_refreshModules() + { + ModuleHandler.clearModuleCache(); + + // Forcibly reload scripts so that scripted stages can be edited. + polymod.hscript.PolymodScriptClass.clearScriptClasses(); + polymod.hscript.PolymodScriptClass.registerAllScriptClasses(); + + // Reload the stages in cache. This might cause a lag spike but who cares this is a debug utility. + StageDataParser.loadStageCache(); + CharacterDataParser.loadCharacterCache(); + ModuleHandler.loadModuleCache(); + + // Create a new instance of the current state class. + FlxG.resetState(); + } + private function updateBeat():Void { curBeat = Math.floor(curStep / 4); @@ -103,4 +145,56 @@ class MusicBeatState extends FlxUIState lastBeatHitTime = Conductor.songPosition; // do literally nothing dumbass } + + override function switchTo(nextState:FlxState):Bool + { + var event = new StateChangeScriptEvent(ScriptEvent.STATE_CHANGE_BEGIN, nextState, true); + + dispatchEvent(event); + + if (event.eventCanceled) + { + return false; + } + + return super.switchTo(nextState); + } + + public override function openSubState(targetSubstate:FlxSubState):Void + { + var event = new SubStateScriptEvent(ScriptEvent.SUBSTATE_OPEN_BEGIN, targetSubstate, true); + + dispatchEvent(event); + + if (event.eventCanceled) + { + return; + } + + super.openSubState(targetSubstate); + } + + function onOpenSubstateComplete(targetState:FlxSubState):Void + { + dispatchEvent(new SubStateScriptEvent(ScriptEvent.SUBSTATE_OPEN_END, targetState, true)); + } + + public override function closeSubState():Void + { + var event = new SubStateScriptEvent(ScriptEvent.SUBSTATE_CLOSE_BEGIN, this.subState, true); + + dispatchEvent(event); + + if (event.eventCanceled) + { + return; + } + + super.closeSubState(); + } + + function onCloseSubstateComplete(targetState:FlxSubState):Void + { + dispatchEvent(new SubStateScriptEvent(ScriptEvent.SUBSTATE_CLOSE_END, targetState, true)); + } } diff --git a/source/funkin/MusicBeatSubstate.hx b/source/funkin/MusicBeatSubstate.hx index 8374848e4..12cb56cff 100644 --- a/source/funkin/MusicBeatSubstate.hx +++ b/source/funkin/MusicBeatSubstate.hx @@ -1,8 +1,13 @@ package funkin; +import funkin.modding.module.ModuleHandler; +import funkin.modding.events.ScriptEvent; import funkin.Conductor.BPMChangeEvent; import flixel.FlxSubState; +/** + * MusicBeatSubstate reincorporates the functionality of MusicBeatState into an FlxSubState. + */ class MusicBeatSubstate extends FlxSubState { public function new() @@ -53,6 +58,11 @@ class MusicBeatSubstate extends FlxSubState beatHit(); } + function dispatchEvent(event:ScriptEvent) + { + ModuleHandler.callEvent(event); + } + public function beatHit():Void { // do literally nothing dumbass diff --git a/source/funkin/Note.hx b/source/funkin/Note.hx index d523d59a5..ab56f938e 100644 --- a/source/funkin/Note.hx +++ b/source/funkin/Note.hx @@ -227,6 +227,7 @@ class Note extends FlxSprite { super.update(elapsed); + // mustPress indicates the player is the one pressing the key if (mustPress) { // miss on the NEXT frame so lag doesnt make u miss notes @@ -244,7 +245,8 @@ class Note extends FlxSprite } if (data.strumTime > Conductor.songPosition - HIT_WINDOW) - { // * 0.5 if sustain note, so u have to keep holding it closer to all the way thru! + { + // * 0.5 if sustain note, so u have to keep holding it closer to all the way thru! if (data.strumTime < Conductor.songPosition + (HIT_WINDOW * (isSustainNote ? 0.5 : 1))) canBeHit = true; } @@ -455,7 +457,12 @@ enum abstract NoteColor(NoteType) from Int to Int from NoteType enum abstract NoteKind(String) from String to String { + /** + * The default note type. + */ var NORMAL = "normal"; + + // Testing shiz var PYRO_LIGHT = "pyro_light"; var PYRO_KICK = "pyro_kick"; var PYRO_TOSS = "pyro_toss"; diff --git a/source/funkin/StoryMenuState.hx b/source/funkin/StoryMenuState.hx index dff3fce14..5cfdd38c1 100644 --- a/source/funkin/StoryMenuState.hx +++ b/source/funkin/StoryMenuState.hx @@ -34,6 +34,8 @@ class StoryMenuState extends MusicBeatState ]; var curDifficulty:Int = 1; + // TODO: This info is just hardcoded right now. + // We should probably make it so that weeks must be completed in order to unlock the next week. public static var weekUnlocked:Array<Bool> = [true, true, true, true, true, true, true, true]; var weekCharacters:Array<Dynamic> = [ diff --git a/source/funkin/i18n/FireTongueHandler.hx b/source/funkin/i18n/FireTongueHandler.hx deleted file mode 100644 index fdb13e41c..000000000 --- a/source/funkin/i18n/FireTongueHandler.hx +++ /dev/null @@ -1,114 +0,0 @@ -package funkin.i18n; - -import firetongue.FireTongue; - -class FireTongueHandler -{ - static final DEFAULT_LOCALE = 'en-US'; - // static final DEFAULT_LOCALE = 'pt-BR'; - static final LOCALE_DIR = 'assets/locales/'; - - static var tongue:FireTongue; - - /** - * Initialize the FireTongue instance. - * This will automatically start with the default locale for you. - */ - public static function init():Void - { - tongue = new FireTongue(OPENFL, // Haxe framework being used. - // This should really have been a parameterized object... - null, // Function to check if a file exists. Specify null to use the one from the framework. - null, // Function to retrieve the text of a file. Specify null to use the one from the framework. - null, // Function to get a list of files in a directory. Specify null to use the one from the framework. - firetongue.FireTongue.Case.Upper); - - // TODO: Make this use the language from the user's preferences. - setLanguage(DEFAULT_LOCALE); - - trace('[FIRETONGUE] Initialized. Available locales: ${tongue.locales.join(', ')}'); - } - - /** - * Switch the language used by FireTongue. - * @param locale The name of the locale to use, such as `en-US`. - */ - public static function setLanguage(locale:String):Void - { - tongue.initialize({ - locale: locale, // The locale to load. - - finishedCallback: onFinishLoad, // Function run when the locale is loaded. - directory: LOCALE_DIR, // Folder (relative to assets/) to load data from. - replaceMissing: false, // If true, missing flags fallback to the default locale. - checkMissing: true, // If true, check for and store the list of missing flags for this locale. - }); - } - - /** - * Function called when FireTongue finishes loading a language. - */ - static function onFinishLoad() - { - if (tongue == null) - return; - - trace('[FIRETONGUE] Finished loading locale: ${tongue.locale}'); - if (tongue.missingFlags != null) - { - if (tongue.missingFlags.get(tongue.locale) != null && tongue.missingFlags.get(tongue.locale).length != 0) - { - trace('[FIRETONGUE] Missing flags: ${tongue.missingFlags.get(tongue.locale).join(', ')}'); - } - else - { - trace('[FIRETONGUE] No missing flags for this locale. (Note: Another locale has missing flags.)'); - } - } - else - { - trace('[FIRETONGUE] No missing flags.'); - } - - trace('[FIRETONGUE] HELLO_WORLD = ${t("HELLO_WORLD")}'); - } - - /** - * Retrieve a localized string based on the given key. - * - * Example: - * import i18n.FiretongueHandler.t; - * trace(t('HELLO')); // Prints "Hello!" - * - * @param key The key to use to retrieve the localized string. - * @param context The data file to load the key from. - * @return The localized string. - */ - public static function t(key:String, context:String = 'data'):String - { - // The localization strings can be stored all in one file, - // or split into several contexts. - return tongue.get(key, context); - } - - /** - * Retrieve a localized string while replacing specific values. - * In this way, you can use the same invocation call to properly localize - * a variety of different languages with distinct grammar. - * - * Example: - * import i18n.FiretongueHandler.f; - * trace(f('COLLECT_X_APPLES', 'data', ['<X>'], ['10']); // Prints "Collect 10 apples!" - * - * @param key The key to use to retrieve the localized string. - * @param context The data file to load the key from. - * @param flags The flags to replace in the string. - * @param values The values to replace those flags with. - * @return The localized string. - */ - public static function f(key:String, context:String = 'data', flags:Array<String> = null, values:Array<String> = null):String - { - var str = t(key, context); - return firetongue.Replace.flags(str, flags, values); - } -} diff --git a/source/funkin/i18n/README.md b/source/funkin/i18n/README.md deleted file mode 100644 index d55a8f41c..000000000 --- a/source/funkin/i18n/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# i18n - -This package contains functions used for internationalization (i18n). diff --git a/source/funkin/modding/IScriptedClass.hx b/source/funkin/modding/IScriptedClass.hx index 9691c1417..70ab28983 100644 --- a/source/funkin/modding/IScriptedClass.hx +++ b/source/funkin/modding/IScriptedClass.hx @@ -23,6 +23,20 @@ interface IStateChangingScriptedClass extends IScriptedClass { public function onStateChangeBegin(event:StateChangeScriptEvent):Void; public function onStateChangeEnd(event:StateChangeScriptEvent):Void; + + public function onSubstateOpenBegin(event:SubStateScriptEvent):Void; + public function onSubstateOpenEnd(event:SubStateScriptEvent):Void; + public function onSubstateCloseBegin(event:SubStateScriptEvent):Void; + public function onSubstateCloseEnd(event:SubStateScriptEvent):Void; +} + +/** + * Defines a set of callbacks available to scripted classes which represent notes. + */ +interface INoteScriptedClass extends IScriptedClass +{ + public function onNoteHit(event:NoteScriptEvent):Void; + public function onNoteMiss(event:NoteScriptEvent):Void; } /** @@ -46,12 +60,12 @@ interface IPlayStateScriptedClass extends IScriptedClass public function onSongLoaded(eent:SongLoadScriptEvent):Void; public function onSongStart(event:ScriptEvent):Void; public function onSongEnd(event:ScriptEvent):Void; - public function onSongReset(event:ScriptEvent):Void; public function onGameOver(event:ScriptEvent):Void; - public function onGameRetry(event:ScriptEvent):Void; + public function onSongRetry(event:ScriptEvent):Void; public function onNoteHit(event:NoteScriptEvent):Void; public function onNoteMiss(event:NoteScriptEvent):Void; + public function onNoteGhostMiss(event:GhostMissNoteScriptEvent):Void; public function onStepHit(event:SongTimeScriptEvent):Void; public function onBeatHit(event:SongTimeScriptEvent):Void; diff --git a/source/funkin/modding/events/ScriptEvent.hx b/source/funkin/modding/events/ScriptEvent.hx index ce4c08743..1c55616fc 100644 --- a/source/funkin/modding/events/ScriptEvent.hx +++ b/source/funkin/modding/events/ScriptEvent.hx @@ -1,5 +1,8 @@ package funkin.modding.events; +import flixel.FlxState; +import flixel.FlxSubState; +import funkin.Note.NoteDir; import funkin.play.Countdown.CountdownStep; import openfl.events.EventType; import openfl.events.KeyboardEvent; @@ -83,6 +86,15 @@ class ScriptEvent */ public static inline final NOTE_MISS:ScriptEventType = "NOTE_MISS"; + /** + * Called when a character presses a note when there was none there, causing them to lose health. + * Important information such as direction pressed, etc. are all provided. + * + * This event IS cancelable! Canceling this event prevents the note from being considered missed, + * avoiding lost health/score and preventing the miss animation. + */ + public static inline final NOTE_GHOST_MISS:ScriptEventType = "NOTE_GHOST_MISS"; + /** * Called when the song starts. This occurs as the countdown ends and the instrumental and vocals begin. * @@ -97,13 +109,6 @@ class ScriptEvent */ public static inline final SONG_END:ScriptEventType = "SONG_END"; - /** - * Called when the song is reset. This can happen from the pause menu or the game over screen. - * - * This event is not cancelable. - */ - public static inline final SONG_RESET:ScriptEventType = "SONG_RESET"; - /** * Called when the countdown begins. This occurs before the song starts. * @@ -130,18 +135,19 @@ class ScriptEvent public static inline final COUNTDOWN_END:ScriptEventType = "COUNTDOWN_END"; /** - * Called when the game over screen triggers and the death animation plays. + * Called before the game over screen triggers and the death animation plays. * * This event is not cancelable. */ public static inline final GAME_OVER:ScriptEventType = "GAME_OVER"; /** - * Called when the player presses a key to restart the game after the death animation. + * Called when the player presses a key to restart the game. + * This can happen from the pause menu or the game over screen. * * This event IS cancelable! Canceling this event will prevent the game from restarting. */ - public static inline final GAME_RETRY:ScriptEventType = "GAME_RETRY"; + public static inline final SONG_RETRY:ScriptEventType = "SONG_RETRY"; /** * Called when the player pushes down any key on the keyboard. @@ -166,11 +172,46 @@ class ScriptEvent public static inline final SONG_LOADED:ScriptEventType = "SONG_LOADED"; /** - * Called when the game is entering the current FlxState. + * Called when the game is about to switch the current FlxState. * * This event is not cancelable. */ - public static inline final STATE_ENTER:ScriptEventType = "STATE_ENTER"; + public static inline final STATE_CHANGE_BEGIN:ScriptEventType = "STATE_CHANGE_BEGIN"; + + /** + * Called when the game has finished switching the current FlxState. + * + * This event is not cancelable. + */ + public static inline final STATE_CHANGE_END:ScriptEventType = "STATE_CHANGE_END"; + + /** + * Called when the game is about to open a new FlxSubState. + * + * This event is not cancelable. + */ + public static inline final SUBSTATE_OPEN_BEGIN:ScriptEventType = "SUBSTATE_OPEN_BEGIN"; + + /** + * Called when the game has finished opening a new FlxSubState. + * + * This event is not cancelable. + */ + public static inline final SUBSTATE_OPEN_END:ScriptEventType = "SUBSTATE_OPEN_END"; + + /** + * Called when the game is about to close the current FlxSubState. + * + * This event is not cancelable. + */ + public static inline final SUBSTATE_CLOSE_BEGIN:ScriptEventType = "SUBSTATE_CLOSE_BEGIN"; + + /** + * Called when the game has finished closing the current FlxSubState. + * + * This event is not cancelable. + */ + public static inline final SUBSTATE_CLOSE_END:ScriptEventType = "SUBSTATE_CLOSE_END"; /** * Called when the game is exiting the current FlxState. @@ -265,6 +306,59 @@ class NoteScriptEvent extends ScriptEvent } } +/** + * An event that is fired when you press a key with no note present. + */ +class GhostMissNoteScriptEvent extends ScriptEvent +{ + /** + * The direction that was mistakenly pressed. + */ + public var dir(default, null):NoteDir; + + /** + * Whether there was a note within judgement range when this ghost note was pressed. + */ + public var hasPossibleNotes(default, null):Bool; + + /** + * How much health should be lost when this ghost note is pressed. + * Remember that max health is 2.00. + */ + public var healthChange(default, default):Float; + + /** + * How much score should be lost when this ghost note is pressed. + */ + public var scoreChange(default, default):Int; + + /** + * Whether to play the record scratch sound. + */ + public var playSound(default, default):Bool; + + /** + * Whether to play the miss animation on the player. + */ + public var playAnim(default, default):Bool; + + public function new(dir:NoteDir, hasPossibleNotes:Bool, healthChange:Float, scoreChange:Int):Void + { + super(ScriptEvent.NOTE_GHOST_MISS, true); + this.dir = dir; + this.hasPossibleNotes = hasPossibleNotes; + this.healthChange = healthChange; + this.scoreChange = scoreChange; + this.playSound = true; + this.playAnim = true; + } + + public override function toString():String + { + return 'GhostMissNoteScriptEvent(dir=' + dir + ', hasPossibleNotes=' + hasPossibleNotes + ')'; + } +} + /** * An event that is fired during the update loop. */ @@ -403,13 +497,41 @@ class SongLoadScriptEvent extends ScriptEvent */ class StateChangeScriptEvent extends ScriptEvent { - public function new(type:ScriptEventType):Void + /** + * The state the game is moving into. + */ + public var targetState(default, null):FlxState; + + public function new(type:ScriptEventType, targetState:FlxState, cancelable:Bool = false):Void { - super(type, false); + super(type, cancelable); + this.targetState = targetState; } public override function toString():String { - return 'StateChangeScriptEvent(type=' + type + ')'; + return 'StateChangeScriptEvent(type=' + type + ', targetState=' + targetState + ')'; + } +} + +/** + * An event that is fired when moving out of or into an FlxSubState. + */ +class SubStateScriptEvent extends ScriptEvent +{ + /** + * The state the game is moving into. + */ + public var targetState(default, null):FlxSubState; + + public function new(type:ScriptEventType, targetState:FlxSubState, cancelable:Bool = false):Void + { + super(type, cancelable); + this.targetState = targetState; + } + + public override function toString():String + { + return 'SubStateScriptEvent(type=' + type + ', targetState=' + targetState + ')'; } } diff --git a/source/funkin/modding/events/ScriptEventDispatcher.hx b/source/funkin/modding/events/ScriptEventDispatcher.hx index ecb97a846..45edf5214 100644 --- a/source/funkin/modding/events/ScriptEventDispatcher.hx +++ b/source/funkin/modding/events/ScriptEventDispatcher.hx @@ -35,18 +35,6 @@ class ScriptEventDispatcher return; } - if (Std.isOfType(target, IStateChangingScriptedClass)) - { - var t = cast(target, IStateChangingScriptedClass); - var t = cast(target, IPlayStateScriptedClass); - switch (event.type) - { - case ScriptEvent.NOTE_HIT: - t.onNoteHit(cast event); - return; - } - } - if (Std.isOfType(target, IPlayStateScriptedClass)) { var t = cast(target, IPlayStateScriptedClass); @@ -58,6 +46,9 @@ class ScriptEventDispatcher case ScriptEvent.NOTE_MISS: t.onNoteMiss(cast event); return; + case ScriptEvent.NOTE_GHOST_MISS: + t.onNoteGhostMiss(cast event); + return; case ScriptEvent.SONG_BEAT_HIT: t.onBeatHit(cast event); return; @@ -70,8 +61,11 @@ class ScriptEventDispatcher case ScriptEvent.SONG_END: t.onSongEnd(event); return; - case ScriptEvent.SONG_RESET: - t.onSongReset(event); + case ScriptEvent.SONG_RETRY: + t.onSongRetry(event); + return; + case ScriptEvent.GAME_OVER: + t.onGameOver(event); return; case ScriptEvent.PAUSE: t.onPause(event); @@ -94,7 +88,38 @@ class ScriptEventDispatcher } } - throw "No helper for event type: " + event.type; + if (Std.isOfType(target, IStateChangingScriptedClass)) + { + var t = cast(target, IStateChangingScriptedClass); + switch (event.type) + { + case ScriptEvent.STATE_CHANGE_BEGIN: + t.onStateChangeBegin(cast event); + return; + case ScriptEvent.STATE_CHANGE_END: + t.onStateChangeEnd(cast event); + return; + case ScriptEvent.SUBSTATE_OPEN_BEGIN: + t.onSubstateOpenBegin(cast event); + return; + case ScriptEvent.SUBSTATE_OPEN_END: + t.onSubstateOpenEnd(cast event); + return; + case ScriptEvent.SUBSTATE_CLOSE_BEGIN: + t.onSubstateCloseBegin(cast event); + return; + case ScriptEvent.SUBSTATE_CLOSE_END: + t.onSubstateCloseEnd(cast event); + return; + } + } + else + { + // Prevent "NO HELPER error." + return; + } + + throw "No function called for event type: " + event.type; } public static function callEventOnAllTargets(targets:Iterator<IScriptedClass>, event:ScriptEvent):Void diff --git a/source/funkin/modding/module/Module.hx b/source/funkin/modding/module/Module.hx index 3f499c5e1..207fc2a4a 100644 --- a/source/funkin/modding/module/Module.hx +++ b/source/funkin/modding/module/Module.hx @@ -18,7 +18,7 @@ class Module implements IPlayStateScriptedClass implements IStateChangingScripte /** * Whether the module is currently active. */ - public var active(default, set):Bool = false; + public var active(default, set):Bool = true; function set_active(value:Bool):Bool { @@ -48,14 +48,11 @@ class Module implements IPlayStateScriptedClass implements IStateChangingScripte * Called when the module is initialized. * It may not be safe to reference other modules here since they may not be loaded yet. * - * @param startActive Whether to start with the module active. - * If false, the module will be inactive and must be enabled by another script, - * such as a stage or another module. + * NOTE: To make the module start inactive, call `this.active = false` in the constructor. */ - public function new(moduleId:String, active:Bool = true, priority:Int = 1000):Void + public function new(moduleId:String, priority:Int = 1000):Void { this.moduleId = moduleId; - this.active = active; this.priority = priority; } @@ -90,16 +87,14 @@ class Module implements IPlayStateScriptedClass implements IStateChangingScripte public function onSongEnd(event:ScriptEvent) {} - public function onSongReset(event:ScriptEvent) {} - public function onGameOver(event:ScriptEvent) {} - public function onGameRetry(event:ScriptEvent) {} - public function onNoteHit(event:NoteScriptEvent) {} public function onNoteMiss(event:NoteScriptEvent) {} + public function onNoteGhostMiss(event:GhostMissNoteScriptEvent) {} + public function onStepHit(event:SongTimeScriptEvent) {} public function onBeatHit(event:SongTimeScriptEvent) {} @@ -110,9 +105,19 @@ class Module implements IPlayStateScriptedClass implements IStateChangingScripte public function onCountdownEnd(event:CountdownScriptEvent) {} - public function onSongLoaded(eent:SongLoadScriptEvent) {} + public function onSongLoaded(event:SongLoadScriptEvent) {} public function onStateChangeBegin(event:StateChangeScriptEvent) {} public function onStateChangeEnd(event:StateChangeScriptEvent) {} + + public function onSubstateOpenBegin(event:SubStateScriptEvent) {} + + public function onSubstateOpenEnd(event:SubStateScriptEvent) {} + + public function onSubstateCloseBegin(event:SubStateScriptEvent) {} + + public function onSubstateCloseEnd(event:SubStateScriptEvent) {} + + public function onSongRetry(event:ScriptEvent) {} } diff --git a/source/funkin/modding/module/ModuleHandler.hx b/source/funkin/modding/module/ModuleHandler.hx index 908b5c428..e533a39fd 100644 --- a/source/funkin/modding/module/ModuleHandler.hx +++ b/source/funkin/modding/module/ModuleHandler.hx @@ -47,6 +47,16 @@ class ModuleHandler trace("[MODULEHANDLER] Module cache loaded."); } + public static function buildModuleCallbacks():Void + { + FlxG.signals.postStateSwitch.add(onStateSwitchComplete); + } + + static function onStateSwitchComplete():Void + { + callEvent(new StateChangeScriptEvent(ScriptEvent.STATE_CHANGE_END, FlxG.state, true)); + } + static function addToModuleCache(module:Module):Void { moduleCache.set(module.moduleId, module); diff --git a/source/funkin/play/AnimationData.hx b/source/funkin/play/AnimationData.hx index 5a9586b9f..1d75e82a5 100644 --- a/source/funkin/play/AnimationData.hx +++ b/source/funkin/play/AnimationData.hx @@ -16,11 +16,18 @@ typedef AnimationData = */ var prefix:String; + /** + * Optionally specify an asset path to use for this specific animation. + * ONLY for use by MultiSparrow characters. + * @default The assetPath of the parent sprite + */ + var assetPath:Null<String>; + /** * Offset the character's position by this amount when playing this animation. * @default [0, 0] */ - var offsets:Null<Array<Float>>; + var offsets:Null<Array<Int>>; /** * Whether the animation should loop when it finishes. diff --git a/source/funkin/play/Countdown.hx b/source/funkin/play/Countdown.hx index 8413110ca..ec6fd9dce 100644 --- a/source/funkin/play/Countdown.hx +++ b/source/funkin/play/Countdown.hx @@ -37,6 +37,9 @@ class Countdown PlayState.isInCountdown = true; Conductor.songPosition = Conductor.crochet * -5; countdownStep = BEFORE; + // Handle onBeatHit events manually + @:privateAccess + PlayState.instance.dispatchEvent(new SongTimeScriptEvent(ScriptEvent.SONG_BEAT_HIT, 0, 0)); var cancelled:Bool = propagateCountdownEvent(countdownStep); if (cancelled) @@ -49,9 +52,9 @@ class Countdown { countdownStep = decrement(countdownStep); - // Play the dance animations manually. + // Handle onBeatHit events manually @:privateAccess - PlayState.instance.danceOnBeat(); + PlayState.instance.dispatchEvent(new SongTimeScriptEvent(ScriptEvent.SONG_BEAT_HIT, 0, 0)); // Countdown graphic. showCountdownGraphic(countdownStep, isPixelStyle); diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx index 05353d155..9471737f5 100644 --- a/source/funkin/play/PlayState.hx +++ b/source/funkin/play/PlayState.hx @@ -1,6 +1,6 @@ package funkin.play; -import funkin.play.character.CharacterBase; +import funkin.play.character.BaseCharacter; import flixel.addons.effects.FlxTrail; import flixel.addons.transition.FlxTransitionableState; import flixel.FlxCamera; @@ -86,6 +86,12 @@ class PlayState extends MusicBeatState implements IHook */ public static var isInCountdown:Bool = false; + /** + * Gets set to true when the PlayState needs to reset (player opted to restart or died). + * Gets disabled once resetting happens. + */ + public static var needsReset:Bool = false; + /** * The current "Blueball Counter" to display in the pause menu. * Resets when you beat a song or go back to the main menu. @@ -125,6 +131,11 @@ class PlayState extends MusicBeatState implements IHook */ public var health:Float = 1; + /** + * The player's current score. + */ + public var songScore:Int = 0; + /** * An empty FlxObject contained in the scene. * The current gameplay camera will be centered on this object. Tween its position to move the camera smoothly. @@ -225,7 +236,6 @@ class PlayState extends MusicBeatState implements IHook public static var storyWeek:Int = 0; public static var storyPlaylist:Array<String> = []; public static var storyDifficulty:Int = 1; - public static var needsReset:Bool = false; public static var seenCutscene:Bool = false; public static var campaignScore:Int = 0; @@ -242,7 +252,6 @@ class PlayState extends MusicBeatState implements IHook var dialogue:Array<String>; var talking:Bool = true; - var songScore:Int = 0; var doof:DialogueBox; var grpNoteSplashes:FlxTypedGroup<NoteSplash>; var comboPopUps:PopUpStuff; @@ -377,6 +386,8 @@ class PlayState extends MusicBeatState implements IHook iconP1.cameras = [camHUD]; iconP2.cameras = [camHUD]; scoreText.cameras = [camHUD]; + leftWatermarkText.cameras = [camHUD]; + rightWatermarkText.cameras = [camHUD]; // if (SONG.song == 'South') // FlxG.camera.alpha = 0.7; @@ -494,26 +505,48 @@ class PlayState extends MusicBeatState implements IHook if (currentSong.song.toLowerCase() == 'stress') gfVersion = 'pico-speaker'; - var girlfriend:Character = new Character(350, -70, gfVersion); - girlfriend.scrollFactor.set(0.95, 0.95); - if (gfVersion == 'pico-speaker') + if (currentSong.song.toLowerCase() == 'tutorial') + gfVersion = ''; + + // + // GIRLFRIEND + // + var girlfriend:BaseCharacter = CharacterDataParser.fetchCharacter(gfVersion); + + if (girlfriend != null) { - girlfriend.x -= 50; - girlfriend.y -= 200; + girlfriend.characterType = CharacterType.GF; + girlfriend.scrollFactor.set(0.95, 0.95); + if (gfVersion == 'pico-speaker') + { + girlfriend.x -= 50; + girlfriend.y -= 200; + } + } + else if (gfVersion != '') + { + trace('WARNING: Could not load girlfriend character with ID ${gfVersion}, skipping...'); } // // DAD // - var dad = new Character(100, 100, currentSong.player2); + var dad:BaseCharacter = CharacterDataParser.fetchCharacter(currentSong.player2); - cameraFollowPoint.setPosition(dad.getGraphicMidpoint().x, dad.getGraphicMidpoint().y); + if (dad != null) + { + dad.characterType = CharacterType.DAD; + cameraFollowPoint.setPosition(dad.getGraphicMidpoint().x, dad.getGraphicMidpoint().y); + } switch (currentSong.player2) { case 'gf': - dad.setPosition(girlfriend.x, girlfriend.y); - girlfriend.visible = false; + var gfPoint:FlxPoint = currentStage.getGirlfriendPosition(); + dad.setPosition(gfPoint.x, gfPoint.y); + + // girlfriend.visible = false; + if (isStoryMode) { cameraFollowPoint.x += 600; @@ -553,12 +586,11 @@ class PlayState extends MusicBeatState implements IHook // // BOYFRIEND // - var boyfriend:CharacterBase; - switch (currentSong.player1) + var boyfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentSong.player1); + + if (boyfriend != null) { - default: - boyfriend = CharacterDataParser.fetchCharacter(currentSong.player1); - boyfriend.characterType = CharacterType.BF; + boyfriend.characterType = CharacterType.BF; } // REPOSITIONING PER STAGE @@ -589,8 +621,8 @@ class PlayState extends MusicBeatState implements IHook // We're using Eric's stage handler. // Characters get added to the stage, not the main scene. currentStage.addCharacter(boyfriend, BF); - currentStage.addCharacterOld(girlfriend, GF); - currentStage.addCharacterOld(dad, DAD); + currentStage.addCharacter(girlfriend, GF); + currentStage.addCharacter(dad, DAD); // Redo z-indexes. currentStage.refresh(); @@ -612,7 +644,7 @@ class PlayState extends MusicBeatState implements IHook * * Call this by pressing F5 on a debug build. */ - function debug_refreshStages() + override function debug_refreshModules() { // Remove the current stage. If the stage gets deleted while it's still in use, // it'll probably crash the game or something. @@ -624,19 +656,7 @@ class PlayState extends MusicBeatState implements IHook currentStage = null; } - ModuleHandler.clearModuleCache(); - - // Forcibly reload scripts so that scripted stages can be edited. - polymod.hscript.PolymodScriptClass.clearScriptClasses(); - polymod.hscript.PolymodScriptClass.registerAllScriptClasses(); - - // Reload the stages in cache. This might cause a lag spike but who cares this is a debug utility. - StageDataParser.loadStageCache(); - CharacterDataParser.loadCharacterCache(); - ModuleHandler.loadModuleCache(); - - // Reload the level. This should use new data from the assets folder. - LoadingState.loadAndSwitchState(new PlayState()); + super.debug_refreshModules(); } /** @@ -957,6 +977,8 @@ class PlayState extends MusicBeatState implements IHook if (needsReset) { + dispatchEvent(new ScriptEvent(ScriptEvent.SONG_RETRY)); + resetCamera(); persistentUpdate = true; @@ -967,11 +989,10 @@ class PlayState extends MusicBeatState implements IHook FlxG.sound.music.pause(); vocals.pause(); - var event:ScriptEvent = new ScriptEvent(ScriptEvent.SONG_RESET, false); - FlxG.sound.music.time = 0; regenNoteData(); // loads the note data from start health = 1; + songScore = 0; Countdown.performCountdown(currentStageId.startsWith('school')); needsReset = false; @@ -1059,9 +1080,6 @@ class PlayState extends MusicBeatState implements IHook if (FlxG.keys.justPressed.EIGHT) FlxG.switchState(new funkin.ui.animDebugShit.DebugBoundingState()); - if (FlxG.keys.justPressed.F5) - debug_refreshStages(); - if (FlxG.keys.justPressed.NINE) iconP1.swapOldIcon(); @@ -1158,6 +1176,8 @@ class PlayState extends MusicBeatState implements IHook deathCounter += 1; + dispatchEvent(new ScriptEvent(ScriptEvent.GAME_OVER)); + openSubState(new GameOverSubstate()); #if discord_rpc @@ -1226,35 +1246,28 @@ class PlayState extends MusicBeatState implements IHook } } - if (!daNote.mustPress && daNote.wasGoodHit) + if (!daNote.mustPress && daNote.wasGoodHit && !daNote.tooLate) { if (currentSong.song != 'Tutorial') camZooming = true; - var altAnim:String = ""; + var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, daNote, true); + dispatchEvent(event); - if (SongLoad.getSong()[Math.floor(curStep / 16)] != null) + // Calling event.cancelEvent() in a module should force the CPU to miss the note. + // This is useful for cool shit, including but not limited to: + // - Making the AI ignore notes which are hazardous. + // - Making the AI miss notes on purpose for aesthetic reasons. + if (event.eventCanceled) { - if (SongLoad.getSong()[Math.floor(curStep / 16)].altAnim) - altAnim = '-alt'; + daNote.tooLate = true; } - - if (daNote.data.altNote) - altAnim = '-alt'; - - if (!daNote.isSustainNote) + else { - currentStage.getDad().playAnim('sing' + daNote.dirNameUpper + altAnim, true); + // Volume of DAD. + if (currentSong.needsVoices) + vocals.volume = 1; } - - currentStage.getDad().holdTimer = 0; - - if (currentSong.needsVoices) - vocals.volume = 1; - - daNote.kill(); - activeNotes.remove(daNote, true); - daNote.destroy(); } // WIP interpolation shit? Need to fix the pause issue @@ -1279,18 +1292,8 @@ class PlayState extends MusicBeatState implements IHook daNote.destroy(); } } - else if (daNote.tooLate || daNote.wasGoodHit) + if (daNote.wasGoodHit) { - // TODO: Why the hell is the noteMiss logic in two different places? - if (daNote.tooLate) - { - var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_MISS, daNote, true); - dispatchEvent(event); - health -= 0.0775; - vocals.volume = 0; - killCombo(); - } - daNote.active = false; daNote.visible = false; @@ -1298,6 +1301,11 @@ class PlayState extends MusicBeatState implements IHook activeNotes.remove(daNote, true); daNote.destroy(); } + + if (daNote.tooLate) + { + noteMiss(daNote); + } }); } @@ -1329,8 +1337,10 @@ class PlayState extends MusicBeatState implements IHook function killCombo():Void { - if (combo > 5 && currentStage.getGirlfriend().animOffsets.exists('sad')) - currentStage.getGirlfriend().playAnim('sad'); + // Girlfriend gets sad if you combo break after hitting 5 notes. + if (currentStage.getGirlfriend() != null) + if (combo > 5 && currentStage.getGirlfriend().hasAnimation('sad')) + currentStage.getGirlfriend().playAnimation('sad'); if (combo != 0) { @@ -1494,15 +1504,6 @@ class PlayState extends MusicBeatState implements IHook health += healthMulti; - // TODO: Redo note hit logic to make sure this always gets called - var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, daNote, true); - dispatchEvent(event); - - if (event.eventCanceled) - { - // TODO: Do a thing! - } - if (isSick) { var noteSplash:NoteSplash = grpNoteSplashes.recycle(NoteSplash); @@ -1531,7 +1532,7 @@ class PlayState extends MusicBeatState implements IHook cameraFollowPoint.setPosition(currentStage.getDad().getMidpoint().x + 150, currentStage.getDad().getMidpoint().y - 100); // camFollow.setPosition(lucky.getMidpoint().x - 120, lucky.getMidpoint().y + 210); - switch (currentStage.getDad().curCharacter) + switch (currentStage.getDad().characterId) { case 'mom': cameraFollowPoint.y = currentStage.getDad().getMidpoint().y; @@ -1540,7 +1541,7 @@ class PlayState extends MusicBeatState implements IHook cameraFollowPoint.x = currentStage.getDad().getMidpoint().x - 100; } - if (currentStage.getDad().curCharacter == 'mom') + if (currentStage.getDad().characterId == 'mom') vocals.volume = 1; if (currentSong.song.toLowerCase() == 'tutorial') @@ -1573,9 +1574,11 @@ class PlayState extends MusicBeatState implements IHook trace(instance.currentStageId); }; - @:hookable public function keyShit(test:Bool):Void { + if (PlayState.instance == null) + return; + // control arrays, order L D R U var holdArray:Array<Bool> = [controls.NOTE_LEFT, controls.NOTE_DOWN, controls.NOTE_UP, controls.NOTE_RIGHT]; var pressArray:Array<Bool> = [ @@ -1659,7 +1662,7 @@ class PlayState extends MusicBeatState implements IHook for (shit in 0...pressArray.length) { // if a direction is hit that shouldn't be if (pressArray[shit] && !directionList.contains(shit)) - PlayState.instance.noteMiss(shit); + PlayState.instance.ghostNoteMiss(shit); } for (coolNote in possibleNotes) { @@ -1669,23 +1672,15 @@ class PlayState extends MusicBeatState implements IHook } else { + // HNGGG I really want to add an option for ghost tapping for (shit in 0...pressArray.length) if (pressArray[shit]) - PlayState.instance.noteMiss(shit); + PlayState.instance.ghostNoteMiss(shit, false); } } if (PlayState.instance.currentStage == null) return; - if (PlayState.instance.currentStage.getBoyfriend().holdTimer > Conductor.stepCrochet * 4 * 0.001 && !holdArray.contains(true)) - { - if (PlayState.instance.currentStage.getBoyfriend().animation != null - && PlayState.instance.currentStage.getBoyfriend().animation.curAnim.name.startsWith('sing') - && !PlayState.instance.currentStage.getBoyfriend().animation.curAnim.name.endsWith('miss')) - { - PlayState.instance.currentStage.getBoyfriend().playAnimation('idle'); - } - } for (keyId => isPressed in pressArray) { @@ -1702,33 +1697,78 @@ class PlayState extends MusicBeatState implements IHook } } - function noteMiss(direction:NoteDir = 1):Void + /** + * Called when a player presses a key with no note present. + * Scripts can modify the amount of health/score lost, whether player animations or sounds are used, + * or even cancel the event entirely. + * + * @param direction + * @param hasPossibleNotes + */ + function ghostNoteMiss(direction:NoteType = 1, hasPossibleNotes:Bool = true):Void { - // whole function used to be encased in if (!boyfriend.stunned) - health -= 0.07; - killCombo(); + var event:GhostMissNoteScriptEvent = new GhostMissNoteScriptEvent(direction, // Direction missed in. + hasPossibleNotes, // Whether there was a note you could have hit. + - 0.035 * 2, // How much health to add (negative). + - 10 // Amount of score to add (negative). + ); + dispatchEvent(event); + + // Calling event.cancelEvent() skips animations and penalties. Neat! + if (event.eventCanceled) + return; + + health += event.healthChange; + if (!isPracticeMode) + songScore += event.scoreChange; + + if (event.playSound) + { + vocals.volume = 0; + FlxG.sound.play(Paths.soundRandom('missnote', 1, 3), FlxG.random.float(0.1, 0.2)); + } + } + + function noteMiss(note:Note):Void + { + var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_MISS, note, true); + dispatchEvent(event); + // Calling event.cancelEvent() skips all the other logic! Neat! + if (event.eventCanceled) + return; + + health -= 0.0775; if (!isPracticeMode) songScore -= 10; - vocals.volume = 0; - FlxG.sound.play(Paths.soundRandom('missnote', 1, 3), FlxG.random.float(0.1, 0.2)); + killCombo(); - currentStage.getBoyfriend().playAnimation('sing' + direction.nameUpper + 'miss', true); + note.active = false; + note.visible = false; + + note.kill(); + activeNotes.remove(note, true); + note.destroy(); } function goodNoteHit(note:Note):Void { if (!note.wasGoodHit) { + var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, note, true); + dispatchEvent(event); + + // Calling event.cancelEvent() skips all the other logic! Neat! + if (event.eventCanceled) + return; + if (!note.isSustainNote) { combo += 1; popUpScore(note.data.strumTime, note); } - currentStage.getBoyfriend().playAnimation('sing' + note.dirNameUpper, true); - playerStrumline.getArrow(note.data.noteData).playAnimation('confirm', true); note.wasGoodHit = true; @@ -1813,22 +1853,6 @@ class PlayState extends MusicBeatState implements IHook if (currentStage == null) return; - if (curBeat % gfSpeed == 0) - currentStage.getGirlfriend().dance(); - - if (curBeat % 2 == 0) - { - if (currentStage.getBoyfriend().animation != null && !currentStage.getBoyfriend().animation.curAnim.name.startsWith("sing")) - currentStage.getBoyfriend().playAnimation('idle'); - if (currentStage.getDad().animation != null && !currentStage.getDad().animation.curAnim.name.startsWith("sing")) - currentStage.getDad().dance(); - } - else if (currentStage.getDad().curCharacter == 'spooky') - { - if (!currentStage.getDad().animation.curAnim.name.startsWith("sing")) - currentStage.getDad().dance(); - } - if (curBeat % 8 == 7 && currentSong.song == 'Bopeebo') { currentStage.getBoyfriend().playAnimation('hey', true); @@ -1836,12 +1860,12 @@ class PlayState extends MusicBeatState implements IHook if (curBeat % 16 == 15 && currentSong.song == 'Tutorial' - && currentStage.getDad().curCharacter == 'gf' + && currentStage.getDad().characterId == 'gf' && curBeat > 16 && curBeat < 48) { currentStage.getBoyfriend().playAnimation('hey', true); - currentStage.getDad().playAnim('cheer', true); + currentStage.getDad().playAnimation('cheer', true); } } @@ -1976,12 +2000,20 @@ class PlayState extends MusicBeatState implements IHook override function dispatchEvent(event:ScriptEvent):Void { + // ORDER: Module, Stage, Character, Song, Note + // Modules should get the first chance to cancel the event. + + // super.dispatchEvent(event) dispatches event to module scripts. + super.dispatchEvent(event); + + // Dispatch event to stage script. ScriptEventDispatcher.callEvent(currentStage, event); - // TODO: Dispatch event to song script - // TODO: Dispatch events to character scripts + // Dispatch event to character script(s). + if (currentStage != null) + currentStage.dispatchToCharacters(event); - super.dispatchEvent(event); + // TODO: Dispatch event to song script } /** @@ -2045,11 +2077,17 @@ class PlayState extends MusicBeatState implements IHook /** * This function is called whenever Flixel switches switching to a new FlxState. + * @return Whether to actually switch to the new state. */ override function switchTo(nextState:FlxState):Bool { - performCleanup(); + var result = super.switchTo(nextState); - return super.switchTo(nextState); + if (result) + { + performCleanup(); + } + + return result; } } diff --git a/source/funkin/play/character/BaseCharacter.hx b/source/funkin/play/character/BaseCharacter.hx new file mode 100644 index 000000000..c833c9942 --- /dev/null +++ b/source/funkin/play/character/BaseCharacter.hx @@ -0,0 +1,281 @@ +package funkin.play.character; + +import funkin.modding.events.ScriptEvent; +import funkin.modding.events.ScriptEvent.UpdateScriptEvent; +import funkin.play.character.CharacterData.CharacterDataParser; +import funkin.Note.NoteDir; +import funkin.modding.events.ScriptEvent.NoteScriptEvent; +import funkin.play.stage.Bopper; + +using StringTools; + +/** + * A Character is a stage prop which bops to the music as well as controlled by the strumlines. + * + * Remember: The character's origin is at its FEET. (horizontal center, vertical bottom) + */ +class BaseCharacter extends Bopper +{ + // Metadata about a character. + public var characterId(default, null):String; + public var characterName(default, null):String; + + /** + * Whether the player is an active character (Boyfriend) or not. + */ + public var characterType:CharacterType = OTHER; + + final _data:CharacterData; + + /** + * Tracks how long, in seconds, the character has been playing the current `sing` animation. + * This is used to ensure that characters play the `sing` animations for at least one beat, + * preventing them from reverting to the `idle` animation between notes. + */ + public var holdTimer:Float = 0; + + final singTimeCrochet:Float; + + public var isDead:Bool = false; + + public function new(id:String) + { + super(); + this.characterId = id; + + _data = CharacterDataParser.fetchCharacterData(this.characterId); + if (_data == null) + { + throw 'Could not find character data for characterId: $characterId'; + } + else + { + this.characterName = _data.name; + this.singTimeCrochet = _data.singTime; + } + } + + public override function onUpdate(event:UpdateScriptEvent):Void + { + super.onUpdate(event); + + // Reset hold timer for each note pressed. + if (justPressedNote()) + { + holdTimer = 0; + } + + if (isDead) + { + playDeathAnimation(); + } + + // Handle character note hold time. + if (getCurrentAnimation().startsWith("sing")) + { + holdTimer += event.elapsed; + var singTimeMs:Float = singTimeCrochet * (Conductor.crochet * 0.001); // x beats, to ms. + // Without this check here, the player character would only play the `sing` animation + // for one beat, as opposed to holding it as long as the player is holding the button. + var shouldStopSinging:Bool = (this.characterType == BF) ? !isHoldingNote() : true; + + FlxG.watch.addQuick('singTimeMs-${characterId}', singTimeMs); + if (holdTimer > singTimeMs && shouldStopSinging) + { + trace('holdTimer reached ${holdTimer}sec (> ${singTimeMs}), stopping sing animation'); + holdTimer = 0; + dance(true); + } + } + else + { + holdTimer = 0; + // super.onBeatHit handles the regular `dance()` calls. + } + FlxG.watch.addQuick('holdTimer-${characterId}', holdTimer); + } + + /** + * Since no `onBeatHit` or `dance` calls happen in GameOverSubstate, + * this regularly gets called instead. + */ + public function playDeathAnimation(force:Bool = false):Void + { + if (force || (getCurrentAnimation().startsWith("firstDeath") && isAnimationFinished())) + { + playAnimation("deathLoop"); + } + } + + override function dance(force:Bool = false) + { + if (!force) + { + if (getCurrentAnimation().startsWith("sing")) + { + return; + } + if (["hey", "cheer"].contains(getCurrentAnimation()) && !isAnimationFinished()) + { + return; + } + } + + // Prevent dancing while another animation is playing. + if (!force && getCurrentAnimation().startsWith("sing")) + { + return; + } + + // Otherwise, fallback to the super dance() method, which handles playing the idle animation. + super.dance(); + } + + /** + * Returns true if the player just pressed a note. + * Used when determing whether a the player character should revert to the `idle` animation. + * On non-player characters, this should be ignored. + */ + function justPressedNote(player:Int = 1):Bool + { + // Returns true if at least one of LEFT, DOWN, UP, or RIGHT is being held. + switch (player) + { + case 1: + return [ + PlayerSettings.player1.controls.NOTE_LEFT_P, + PlayerSettings.player1.controls.NOTE_DOWN_P, + PlayerSettings.player1.controls.NOTE_UP_P, + PlayerSettings.player1.controls.NOTE_RIGHT_P, + ].contains(true); + case 2: + return [ + PlayerSettings.player2.controls.NOTE_LEFT_P, + PlayerSettings.player2.controls.NOTE_DOWN_P, + PlayerSettings.player2.controls.NOTE_UP_P, + PlayerSettings.player2.controls.NOTE_RIGHT_P, + ].contains(true); + } + return false; + } + + /** + * Returns true if the player is holding a note. + * Used when determing whether a the player character should revert to the `idle` animation. + * On non-player characters, this should be ignored. + */ + function isHoldingNote(player:Int = 1):Bool + { + // Returns true if at least one of LEFT, DOWN, UP, or RIGHT is being held. + switch (player) + { + case 1: + return [ + PlayerSettings.player1.controls.NOTE_LEFT, + PlayerSettings.player1.controls.NOTE_DOWN, + PlayerSettings.player1.controls.NOTE_UP, + PlayerSettings.player1.controls.NOTE_RIGHT, + ].contains(true); + case 2: + return [ + PlayerSettings.player2.controls.NOTE_LEFT, + PlayerSettings.player2.controls.NOTE_DOWN, + PlayerSettings.player2.controls.NOTE_UP, + PlayerSettings.player2.controls.NOTE_RIGHT, + ].contains(true); + } + return false; + } + + /** + * Every time a note is hit, check if the note is from the same strumline. + * If it is, then play the sing animation. + */ + public override function onNoteHit(event:NoteScriptEvent) + { + super.onNoteHit(event); + + trace('HIT NOTE: ${event.note.data.dir} : ${event.note.isSustainNote}'); + + holdTimer = 0; + + if (event.note.mustPress && characterType == BF) + { + // If the note is from the same strumline, play the sing animation. + this.playSingAnimation(event.note.data.dir, false, event.note.data.altNote ? "alt" : null); + } + else if (!event.note.mustPress && characterType == DAD) + { + // If the note is from the same strumline, play the sing animation. + this.playSingAnimation(event.note.data.dir, false, event.note.data.altNote ? "alt" : null); + } + } + + /** + * Every time a note is missed, check if the note is from the same strumline. + * If it is, then play the sing animation. + */ + public override function onNoteMiss(event:NoteScriptEvent) + { + super.onNoteMiss(event); + + if (event.note.mustPress && characterType == BF) + { + // If the note is from the same strumline, play the sing animation. + this.playSingAnimation(event.note.data.dir, true, event.note.data.altNote ? "alt" : null); + } + else if (!event.note.mustPress && characterType == DAD) + { + // If the note is from the same strumline, play the sing animation. + this.playSingAnimation(event.note.data.dir, true, event.note.data.altNote ? "alt" : null); + } + } + + /** + * Every time a wrong key is pressed, play the miss animation if we are Boyfriend. + */ + public override function onNoteGhostMiss(event:GhostMissNoteScriptEvent) + { + super.onNoteGhostMiss(event); + + if (event.eventCanceled || !event.playAnim) + { + // Skipping... + return; + } + + if (characterType == BF) + { + trace('Playing ghost miss animation...'); + // If the note is from the same strumline, play the sing animation. + this.playSingAnimation(event.dir, true, null); + } + } + + public override function onDestroy(event:ScriptEvent):Void + { + this.characterType = OTHER; + } + + /** + * Play the appropriate singing animation, for the given note direction. + * @param dir The direction of the note. + * @param miss If true, play the miss animation instead of the sing animation. + * @param suffix A suffix to append to the animation name, like `alt`. + */ + public function playSingAnimation(dir:NoteDir, miss:Bool = false, suffix:String = ""):Void + { + var anim:String = 'sing${dir.nameUpper}${miss ? 'miss' : ''}${suffix != "" ? '-${suffix}' : ''}'; + + // restart even if already playing, because the character might sing the same note twice. + playAnimation(anim, true); + } +} + +enum CharacterType +{ + BF; + DAD; + GF; + OTHER; +} diff --git a/source/funkin/play/character/CharacterBase.hx b/source/funkin/play/character/CharacterBase.hx deleted file mode 100644 index 22d842fa6..000000000 --- a/source/funkin/play/character/CharacterBase.hx +++ /dev/null @@ -1,140 +0,0 @@ -package funkin.play.character; - -import funkin.modding.events.ScriptEvent; -import funkin.modding.events.ScriptEvent.UpdateScriptEvent; -import funkin.play.character.CharacterData.CharacterDataParser; -import funkin.Note.NoteDir; -import funkin.modding.events.ScriptEvent.NoteScriptEvent; -import funkin.play.stage.Bopper; - -/** - * A Character is a stage prop which bops to the music as well as controlled by the strumlines. - * - * Remember: The character's origin is at its FEET. (horizontal center, vertical bottom) - */ -class CharacterBase extends Bopper -{ - public var characterId(default, null):String; - public var characterName(default, null):String; - - /** - * Whether the player is an active character (Boyfriend) or not. - */ - public var characterType:CharacterType = OTHER; - - public var attachedStrumlines(default, null):Array<Int>; - - final _data:CharacterData; - - /** - * Tracks how long, in seconds, the character has been playing the current `sing` animation. - * This is used to ensure that characters play the `sing` animations for at least one beat, - * preventing them from reverting to the `idle` animation between notes. - */ - public var holdTimer:Float = 0; - - final singTimeCrochet:Float; - - public function new(id:String) - { - super(); - this.characterId = id; - this.attachedStrumlines = []; - - _data = CharacterDataParser.parseCharacterData(this.characterId); - if (_data == null) - { - throw 'Could not find character data for characterId: $characterId'; - } - else - { - this.characterName = _data.name; - this.singTimeCrochet = _data.singTime; - } - } - - public override function onUpdate(event:UpdateScriptEvent):Void - { - super.onUpdate(event); - - // Handle character note hold time. - holdTimer += event.elapsed; - var singTimeMs:Float = singTimeCrochet * Conductor.crochet; - // Without this check here, the player character would only play the `sing` animation - // for one beat, as opposed to holding it as long as the player is holding the button. - var shouldStopSinging:Bool = (this.characterType == BF) ? !isHoldingNote() : true; - - if (holdTimer > singTimeMs && shouldStopSinging) - { - holdTimer = 0; - dance(); - } - } - - /** - * Returns true if the player is holding a note. - * Used when determing whether a the player character should revert to the `idle` animation. - * On non-player characters, this should be ignored. - */ - function isHoldingNote(player:Int = 1):Bool - { - // Returns true if at least one of LEFT, DOWN, UP, or RIGHT is being held. - switch (player) - { - case 1: - return [ - PlayerSettings.player1.controls.NOTE_LEFT, - PlayerSettings.player1.controls.NOTE_DOWN, - PlayerSettings.player1.controls.NOTE_UP, - PlayerSettings.player1.controls.NOTE_RIGHT, - ].contains(true); - case 2: - return [ - PlayerSettings.player2.controls.NOTE_LEFT, - PlayerSettings.player2.controls.NOTE_DOWN, - PlayerSettings.player2.controls.NOTE_UP, - PlayerSettings.player2.controls.NOTE_RIGHT, - ].contains(true); - } - return false; - } - - /** - * Every time a note is hit, check if the note is from the same strumline. - * If it is, then play the sing animation. - */ - public override function onNoteHit(event:NoteScriptEvent) - { - super.onNoteHit(event); - // If event.note is from the same strumline as this character, then sing. - // if (this.attachedStrumlines.indexOf(event.note.strumline) != -1) - // { - // this.playSingAnimation(event.note.dir, false, note.alt); - // } - } - - public override function onDestroy(event:ScriptEvent):Void - { - this.characterType = OTHER; - } - - /** - * Play the appropriate singing animation, for the given note direction. - * @param dir The direction of the note. - * @param miss If true, play the miss animation instead of the sing animation. - * @param suffix A suffix to append to the animation name, like `alt`. - */ - function playSingAnimation(dir:NoteDir, miss:Bool = false, suffix:String = ""):Void - { - var anim:String = 'sing${dir.nameUpper}${miss ? 'miss' : ''}${suffix != "" ? '-${suffix}' : ''}'; - playAnimation(anim, true); - } -} - -enum CharacterType -{ - BF; - DAD; - GF; - OTHER; -} diff --git a/source/funkin/play/character/CharacterData.hx b/source/funkin/play/character/CharacterData.hx index 6affbc765..87be5f83e 100644 --- a/source/funkin/play/character/CharacterData.hx +++ b/source/funkin/play/character/CharacterData.hx @@ -1,14 +1,20 @@ package funkin.play.character; -import openfl.Assets; -import haxe.Json; -import funkin.play.character.render.PackerCharacter; -import funkin.play.character.render.SparrowCharacter; -import funkin.util.assets.DataAssets; -import funkin.play.character.CharacterBase; -import funkin.play.character.ScriptedCharacter.ScriptedSparrowCharacter; -import funkin.play.character.ScriptedCharacter.ScriptedPackerCharacter; import flixel.util.typeLimit.OneOfTwo; +import funkin.modding.events.ScriptEvent; +import funkin.modding.events.ScriptEventDispatcher; +import funkin.play.character.BaseCharacter; +import funkin.play.character.MultiSparrowCharacter; +import funkin.play.character.PackerCharacter; +import funkin.play.character.SparrowCharacter; +import funkin.play.character.ScriptedCharacter.ScriptedBaseCharacter; +import funkin.play.character.ScriptedCharacter.ScriptedMultiSparrowCharacter; +import funkin.play.character.ScriptedCharacter.ScriptedPackerCharacter; +import funkin.play.character.ScriptedCharacter.ScriptedSparrowCharacter; +import funkin.util.assets.DataAssets; +import funkin.util.VersionUtil; +import haxe.Json; +import openfl.utils.Assets; using StringTools; @@ -19,9 +25,15 @@ class CharacterDataParser * Handle breaking changes by incrementing this value * and adding migration to the `migrateStageData()` function. */ - public static final CHARACTER_DATA_VERSION:String = "1.0"; + public static final CHARACTER_DATA_VERSION:String = "1.0.0"; - static final characterCache:Map<String, CharacterBase> = new Map<String, CharacterBase>(); + /** + * The current version rule check for the stage data format. + */ + public static final CHARACTER_DATA_VERSION_RULE:String = "1.0.x"; + + static final characterCache:Map<String, CharacterData> = new Map<String, CharacterData>(); + static final characterScriptedClass:Map<String, String> = new Map<String, String>(); static final DEFAULT_CHAR_ID:String = 'UNKNOWN'; @@ -37,73 +49,23 @@ class CharacterDataParser trace("[CHARDATA] Loading character cache..."); // - // SCRIPTED CHARACTERS - // - - // Generic (Sparrow) characters - var scriptedCharClassNames:Array<String> = ScriptedCharacter.listScriptClasses(); - trace(' Instantiating ${scriptedCharClassNames.length} scripted characters...'); - for (charCls in scriptedCharClassNames) - { - _storeChar(ScriptedCharacter.init(charCls, DEFAULT_CHAR_ID), charCls); - } - - // Sparrow characters - scriptedCharClassNames = ScriptedSparrowCharacter.listScriptClasses(); - if (scriptedCharClassNames.length > 0) - { - trace(' Instantiating ${scriptedCharClassNames.length} scripted characters (SPARROW)...'); - for (charCls in scriptedCharClassNames) - { - _storeChar(ScriptedSparrowCharacter.init(charCls, DEFAULT_CHAR_ID), charCls); - } - } - - // // Packer characters - // scriptedCharClassNames = ScriptedPackerCharacter.listScriptClasses(); - // if (scriptedCharClassNames.length > 0) - // { - // trace(' Instantiating ${scriptedCharClassNames.length} scripted characters (PACKER)...'); - // for (charCls in scriptedCharClassNames) - // { - // _storeChar(ScriptedPackerCharacter.init(charCls, DEFAULT_CHAR_ID), charCls); - // } - // } - - // TODO: Add more character types. - - // - // UNSCRIPTED STAGES + // UNSCRIPTED CHARACTERS // var charIdList:Array<String> = DataAssets.listDataFilesInPath('characters/'); var unscriptedCharIds:Array<String> = charIdList.filter(function(charId:String):Bool { return !characterCache.exists(charId); }); - trace(' Instantiating ${unscriptedCharIds.length} non-scripted characters...'); + trace(' Fetching data for ${unscriptedCharIds.length} characters...'); for (charId in unscriptedCharIds) { - var char:CharacterBase = null; try { var charData:CharacterData = parseCharacterData(charId); if (charData != null) { - switch (charData.renderType) - { - case CharacterRenderType.PACKER: - char = new PackerCharacter(charId); - case CharacterRenderType.SPARROW: - // default - char = new SparrowCharacter(charId); - default: - trace(' Failed to instantiate character: ${charId} (Bad render type ${charData.renderType})'); - } - } - if (char != null) - { - trace(' Loaded character data: ${char.characterName}'); - characterCache.set(charId, char); + trace(' Loaded character data: ${charId}'); + characterCache.set(charId, charData); } } catch (e) @@ -113,39 +75,140 @@ class CharacterDataParser } } + // + // SCRIPTED CHARACTERS + // + + // Fuck I wish scripted classes supported static functions. + + var scriptedCharClassNames1:Array<String> = ScriptedSparrowCharacter.listScriptClasses(); + if (scriptedCharClassNames1.length > 0) + { + trace(' Instantiating ${scriptedCharClassNames1.length} (Sparrow) scripted characters...'); + for (charCls in scriptedCharClassNames1) + { + var character = ScriptedSparrowCharacter.init(charCls, DEFAULT_CHAR_ID); + characterScriptedClass.set(character.characterId, charCls); + } + } + + var scriptedCharClassNames2:Array<String> = ScriptedPackerCharacter.listScriptClasses(); + if (scriptedCharClassNames2.length > 0) + { + trace(' Instantiating ${scriptedCharClassNames2.length} (Packer) scripted characters...'); + for (charCls in scriptedCharClassNames2) + { + var character = ScriptedPackerCharacter.init(charCls, DEFAULT_CHAR_ID); + characterScriptedClass.set(character.characterId, charCls); + } + } + + var scriptedCharClassNames3:Array<String> = ScriptedMultiSparrowCharacter.listScriptClasses(); + trace(' Instantiating ${scriptedCharClassNames3.length} (Multi-Sparrow) scripted characters...'); + for (charCls in scriptedCharClassNames3) + { + var character = ScriptedBaseCharacter.init(charCls, DEFAULT_CHAR_ID); + if (character == null) + { + trace(' Failed to instantiate scripted character: ${charCls}'); + continue; + } + characterScriptedClass.set(character.characterId, charCls); + } + + // NOTE: Only instantiate the ones not populated above. + // ScriptedBaseCharacter.listScriptClasses() will pick up scripts extending the other classes. + var scriptedCharClassNames:Array<String> = ScriptedBaseCharacter.listScriptClasses(); + scriptedCharClassNames.filter(function(charCls:String):Bool + { + return !scriptedCharClassNames1.contains(charCls) + && !scriptedCharClassNames2.contains(charCls) + && !scriptedCharClassNames3.contains(charCls); + }); + + trace(' Instantiating ${scriptedCharClassNames.length} (Base) scripted characters...'); + for (charCls in scriptedCharClassNames) + { + var character = ScriptedBaseCharacter.init(charCls, DEFAULT_CHAR_ID); + if (character == null) + { + trace(' Failed to instantiate scripted character: ${charCls}'); + continue; + } + characterScriptedClass.set(character.characterId, charCls); + } + trace(' Successfully loaded ${Lambda.count(characterCache)} stages.'); } - static function _storeChar(char:CharacterBase, charCls:String):Void + public static function fetchCharacter(charId:String):Null<BaseCharacter> { - if (char != null) + if (charId == null || charId == '') { - trace(' Loaded scripted character: ${char.characterName}'); - // Disable the rendering logic for stage until it's loaded. - // Note that kill() =/= destroy() - char.kill(); + // Gracefully handle songs that don't use this character. + return null; + } - // Then store it. - characterCache.set(char.characterId, char); + if (characterCache.exists(charId)) + { + var charData:CharacterData = characterCache.get(charId); + var charScriptClass:String = characterScriptedClass.get(charId); + + var char:BaseCharacter; + + if (charScriptClass != null) + { + switch (charData.renderType) + { + case CharacterRenderType.MULTISPARROW: + char = ScriptedMultiSparrowCharacter.init(charScriptClass, charId); + case CharacterRenderType.SPARROW: + char = ScriptedSparrowCharacter.init(charScriptClass, charId); + case CharacterRenderType.PACKER: + char = ScriptedPackerCharacter.init(charScriptClass, charId); + default: + // We're going to assume that the script class does the rendering. + char = ScriptedBaseCharacter.init(charScriptClass, charId); + } + } + else + { + switch (charData.renderType) + { + case CharacterRenderType.MULTISPARROW: + char = new MultiSparrowCharacter(charId); + case CharacterRenderType.SPARROW: + char = new SparrowCharacter(charId); + case CharacterRenderType.PACKER: + char = new PackerCharacter(charId); + default: + trace('[WARN] Creating character with undefined renderType ${charData.renderType}'); + char = new BaseCharacter(charId); + } + } + + trace('[CHARDATA] Successfully instantiated character: ${charId}'); + + // Call onCreate only in the fetchCharacter() function, not at application initialization. + ScriptEventDispatcher.callEvent(char, new ScriptEvent(ScriptEvent.CREATE)); + + return char; } else { - trace(' Failed to instantiate scripted character class: ${charCls}'); + trace('[CHARDATA] Failed to build character, not found in cache: ${charId}'); + return null; } } - public static function fetchCharacter(charId:String):Null<CharacterBase> + public static function fetchCharacterData(charId:String):Null<CharacterData> { if (characterCache.exists(charId)) { - trace('[CHARDATA] Successfully fetch stage: ${charId}'); - var character:CharacterBase = characterCache.get(charId); - character.revive(); - return character; + return characterCache.get(charId); } else { - trace('[CHARDATA] Failed to fetch character, not found in cache: ${charId}'); return null; } } @@ -154,12 +217,12 @@ class CharacterDataParser { if (characterCache != null) { - for (char in characterCache) - { - char.destroy(); - } characterCache.clear(); } + if (characterScriptedClass != null) + { + characterScriptedClass.clear(); + } } /** @@ -180,9 +243,9 @@ class CharacterDataParser static function loadCharacterFile(charPath:String):String { var charFilePath:String = Paths.json('characters/${charPath}'); - var rawJson = Assets.getText(charFilePath).trim(); + var rawJson = StringTools.trim(Assets.getText(charFilePath)); - while (!rawJson.endsWith("}")) + while (!StringTools.endsWith(rawJson, "}")) { rawJson = rawJson.substr(0, rawJson.length - 1); } @@ -208,18 +271,26 @@ class CharacterDataParser } } - static final DEFAULT_NAME:String = "Untitled Character"; - static final DEFAULT_RENDERTYPE:CharacterRenderType = CharacterRenderType.SPARROW; - static final DEFAULT_STARTINGANIM:String = "idle"; - static final DEFAULT_SCROLL:Array<Float> = [0, 0]; - static final DEFAULT_ISPIXEL:Bool = false; + /** + * The default time the character should sing for, in beats. + * Values that are too low will cause the character to stop singing between notes. + * Originally, this value was set to 1, but it was changed to 2 because that became + * too low after some other code changes. + */ + static final DEFAULT_SINGTIME:Float = 2.0; + static final DEFAULT_DANCEEVERY:Int = 1; - static final DEFAULT_FRAMERATE:Int = 24; static final DEFAULT_FLIPX:Bool = false; - static final DEFAULT_SCALE:Float = 1; static final DEFAULT_FLIPY:Bool = false; + static final DEFAULT_FRAMERATE:Int = 24; + static final DEFAULT_ISPIXEL:Bool = false; static final DEFAULT_LOOP:Bool = false; - static final DEFAULT_FRAMEINDICES:Array<Int> = []; + static final DEFAULT_NAME:String = "Untitled Character"; + static final DEFAULT_OFFSETS:Array<Int> = [0, 0]; + static final DEFAULT_RENDERTYPE:CharacterRenderType = CharacterRenderType.SPARROW; + static final DEFAULT_SCALE:Float = 1; + static final DEFAULT_SCROLL:Array<Float> = [0, 0]; + static final DEFAULT_STARTINGANIM:String = "idle"; /** * Set unspecified parameters to their defaults. @@ -238,13 +309,13 @@ class CharacterDataParser if (input.version == null) { - trace('[CHARDATA] ERROR: Could not load character data for "$id": missing version'); - return null; + trace('[CHARDATA] WARN: No semantic version specified for character data file "$id", assuming ${CHARACTER_DATA_VERSION}'); + input.version = CHARACTER_DATA_VERSION; } - if (input.version == CHARACTER_DATA_VERSION) + if (!VersionUtil.validateVersion(input.version, CHARACTER_DATA_VERSION_RULE)) { - trace('[CHARDATA] ERROR: Could not load character data for "$id": bad/outdated version (got ${input.version}, expected ${CHARACTER_DATA_VERSION})'); + trace('[CHARDATA] ERROR: Could not load character data for "$id": bad version (got ${input.version}, expected ${CHARACTER_DATA_VERSION_RULE})'); return null; } @@ -285,6 +356,11 @@ class CharacterDataParser input.danceEvery = DEFAULT_DANCEEVERY; } + if (input.singTime == null) + { + input.singTime = DEFAULT_SINGTIME; + } + if (input.animations == null || input.animations.length == 0) { trace('[CHARDATA] ERROR: Could not load character data for "$id": missing animations'); @@ -309,9 +385,9 @@ class CharacterDataParser inputAnimation.frameRate = DEFAULT_FRAMERATE; } - if (inputAnimation.frameIndices == null) + if (inputAnimation.offsets == null) { - inputAnimation.frameIndices = DEFAULT_FRAMEINDICES; + inputAnimation.offsets = DEFAULT_OFFSETS; } if (inputAnimation.looped == null) @@ -339,15 +415,20 @@ enum abstract CharacterRenderType(String) from String to String { var SPARROW = 'sparrow'; var PACKER = 'packer'; - // TODO: Aesprite? + var MULTISPARROW = 'multisparrow'; + // TODO: FlxSpine? + // https://api.haxeflixel.com/flixel/addons/editors/spine/FlxSpine.html + // TODO: Aseprite? + // https://lib.haxe.org/p/openfl-aseprite/ // TODO: Animate? - // TODO: Experimental... + // https://lib.haxe.org/p/flxanimate + // TODO: REDACTED } typedef CharacterData = { /** - * The sematic version of the chart data format. + * The sematic version number of the character data JSON format. */ var version:String; diff --git a/source/funkin/play/character/MultiSparrowCharacter.hx b/source/funkin/play/character/MultiSparrowCharacter.hx new file mode 100644 index 000000000..22cabccf4 --- /dev/null +++ b/source/funkin/play/character/MultiSparrowCharacter.hx @@ -0,0 +1,217 @@ +package funkin.play.character; + +import funkin.modding.events.ScriptEvent; +import funkin.util.assets.FlxAnimationUtil; +import flixel.graphics.frames.FlxFramesCollection; + +/** + * For some characters which use Sparrow atlases, the spritesheets need to be split + * into multiple files. This character renderer handles by showing the appropriate sprite. + * + * Examples in base game include BF Holding GF (most of the sprites are in one file + * but the death animation is in a separate file). + * Only example I can think of in mods is Tricky (which has a separate file for each animation). + * + * BaseCharacter has game logic, SparrowCharacter has only rendering logic. + * KEEP THEM SEPARATE! + */ +class MultiSparrowCharacter extends BaseCharacter +{ + /** + * The actual group which holds all spritesheets this character uses. + */ + private var members:Map<String, FlxFramesCollection> = new Map<String, FlxFramesCollection>(); + + /** + * A map between animation names and what frame collection the animation should use. + */ + private var animAssetPath:Map<String, String> = new Map<String, String>(); + + /** + * The current frame collection being used. + */ + private var activeMember:String; + + public function new(id:String) + { + super(id); + } + + override function onCreate(event:ScriptEvent):Void + { + trace('Creating MULTI SPARROW CHARACTER: ' + this.characterId); + + buildSprite(); + + playAnimation(_data.startingAnimation); + } + + function buildSprite() + { + buildSpritesheets(); + buildAnimations(); + + if (_data.isPixel) + { + this.antialiasing = false; + } + else + { + this.antialiasing = true; + } + + if (_data.scale != null) + { + this.setGraphicSize(Std.int(this.width * this.scale.x)); + this.updateHitbox(); + } + } + + function buildSpritesheets() + { + // Build the list of asset paths to use. + // Ignore nulls and duplicates. + var assetList = [_data.assetPath]; + for (anim in _data.animations) + { + if (anim.assetPath != null && !assetList.contains(anim.assetPath)) + { + assetList.push(anim.assetPath); + } + animAssetPath.set(anim.name, anim.assetPath); + } + + // Load the Sparrow atlas for each path and store them in the members map. + for (asset in assetList) + { + var texture:FlxFramesCollection = Paths.getSparrowAtlas(asset, 'shared'); + // If we don't do this, the unused textures will be removed as soon as they're loaded. + texture.parent.destroyOnNoUse = false; + + if (texture == null) + { + trace('Multi-Sparrow atlas could not load texture: ${asset}'); + } + else + { + trace('Adding multi-sparrow atlas: ${asset}'); + members.set(asset, texture); + } + } + + // Use the default frame collection to start. + loadFramesByAssetPath(_data.assetPath); + } + + /** + * Replace this sprite's animation frames with the ones at this asset path. + */ + function loadFramesByAssetPath(assetPath:String):Void + { + if (_data.assetPath == null) + { + trace('[ERROR] Multi-Sparrow character has no default asset path!'); + return; + } + if (assetPath == null) + { + trace('Asset path is null, falling back to default. This is normal!'); + loadFramesByAssetPath(_data.assetPath); + return; + } + + if (this.activeMember == assetPath) + { + trace('Already using this asset path: ${assetPath}'); + return; + } + + if (members.exists(assetPath)) + { + trace('Loading frames from asset path: ${assetPath}'); + this.frames = members.get(assetPath); + this.activeMember = assetPath; + } + else + { + trace('Multi-Sparrow could not find asset path: ${assetPath}'); + } + } + + /** + * Replace this sprite's animation frames with the ones needed to play this animation. + */ + function loadFramesByAnimName(animName) + { + if (animAssetPath.exists(animName)) + { + loadFramesByAssetPath(animAssetPath.get(animName)); + } + else + { + trace('Multi-Sparrow could not find animation: ${animName}'); + } + } + + function buildAnimations() + { + trace('[SPARROWCHAR] Loading ${_data.animations.length} animations for ${characterId}'); + + // We need to swap to the proper frame collection before adding the animations, I think? + for (anim in _data.animations) + { + trace('Using frames: ${anim.name}'); + loadFramesByAnimName(anim.name); + trace('Adding animation'); + FlxAnimationUtil.addSparrowAnimation(this, anim); + + if (anim.offsets == null) + { + setAnimationOffsets(anim.name, 0, 0); + } + else + { + setAnimationOffsets(anim.name, anim.offsets[0], anim.offsets[1]); + } + } + + var animNames = this.animation.getNameList(); + trace('[SPARROWCHAR] Successfully loaded ${animNames.length} animations for ${characterId}'); + } + + public override function playAnimation(name:String, restart:Bool = false):Void + { + loadFramesByAnimName(name); + super.playAnimation(name, restart); + } + + override function set_frames(value:FlxFramesCollection):FlxFramesCollection + { + // DISABLE THIS SO WE DON'T DESTROY OUR HARD WORK + // WE WILL MAKE SURE TO LOAD THE PROPER SPRITESHEET BEFORE PLAYING AN ANIM + // if (animation != null) + // { + // animation.destroyAnimations(); + // } + + if (value != null) + { + graphic = value.parent; + this.frames = value; + this.frame = value.getByIndex(0); + this.numFrames = value.numFrames; + resetHelpers(); + this.bakedRotationAngle = 0; + this.animation.frameIndex = 0; + graphicLoaded(); + } + else + { + this.frames = null; + this.frame = null; + this.graphic = null; + } + + return this.frames; + } +} diff --git a/source/funkin/play/character/render/PackerCharacter.hx b/source/funkin/play/character/PackerCharacter.hx similarity index 53% rename from source/funkin/play/character/render/PackerCharacter.hx rename to source/funkin/play/character/PackerCharacter.hx index cc9e82709..72cf2c7e0 100644 --- a/source/funkin/play/character/render/PackerCharacter.hx +++ b/source/funkin/play/character/PackerCharacter.hx @@ -1,15 +1,16 @@ -package funkin.play.character.render; +package funkin.play.character; -import funkin.play.character.CharacterBase.CharacterType; +import funkin.play.character.BaseCharacter.CharacterType; /** * A PackerCharacter is a Character which is rendered by * displaying an animation derived from a Packer spritesheet file. */ -class PackerCharacter extends CharacterBase +class PackerCharacter extends BaseCharacter { public function new(id:String) { super(id); } + // TODO: Put code here, dumbass! } diff --git a/source/funkin/play/character/ScriptedCharacter.hx b/source/funkin/play/character/ScriptedCharacter.hx index b182b283d..1ce8f7f93 100644 --- a/source/funkin/play/character/ScriptedCharacter.hx +++ b/source/funkin/play/character/ScriptedCharacter.hx @@ -1,14 +1,23 @@ package funkin.play.character; -import funkin.play.character.render.PackerCharacter; -import funkin.play.character.render.SparrowCharacter; +import funkin.play.character.PackerCharacter; +import funkin.play.character.SparrowCharacter; +import funkin.play.character.MultiSparrowCharacter; import funkin.modding.IHook; +/** + * Note: Making a scripted class extending BaseCharacter is not recommended. + * Do so ONLY if are handling all the character rendering yourself, + * and can't use one of the built-in render modes. + */ @:hscriptClass -class ScriptedCharacter extends SparrowCharacter implements IHook {} +class ScriptedBaseCharacter extends BaseCharacter implements IHook {} @:hscriptClass class ScriptedSparrowCharacter extends SparrowCharacter implements IHook {} +@:hscriptClass +class ScriptedMultiSparrowCharacter extends MultiSparrowCharacter implements IHook {} + @:hscriptClass class ScriptedPackerCharacter extends PackerCharacter implements IHook {} diff --git a/source/funkin/play/character/SparrowCharacter.hx b/source/funkin/play/character/SparrowCharacter.hx new file mode 100644 index 000000000..d331b7da2 --- /dev/null +++ b/source/funkin/play/character/SparrowCharacter.hx @@ -0,0 +1,81 @@ +package funkin.play.character; + +import funkin.modding.events.ScriptEvent; +import funkin.util.assets.FlxAnimationUtil; +import flixel.graphics.frames.FlxFramesCollection; + +/** + * A SparrowCharacter is a Character which is rendered by + * displaying an animation derived from a SparrowV2 atlas spritesheet file. + * + * BaseCharacter has game logic, SparrowCharacter has only rendering logic. + * KEEP THEM SEPARATE! + */ +class SparrowCharacter extends BaseCharacter +{ + public function new(id:String) + { + super(id); + } + + override function onCreate(event:ScriptEvent):Void + { + trace('Creating SPARROW CHARACTER: ' + this.characterId); + + loadSpritesheet(); + loadAnimations(); + + playAnimation(_data.startingAnimation); + } + + function loadSpritesheet() + { + trace('[SPARROWCHAR] Loading spritesheet ${_data.assetPath} for ${characterId}'); + + var tex:FlxFramesCollection = Paths.getSparrowAtlas(_data.assetPath, 'shared'); + if (tex == null) + { + trace('Could not load Sparrow sprite: ${_data.assetPath}'); + return; + } + + this.frames = tex; + + if (_data.isPixel) + { + this.antialiasing = false; + } + else + { + this.antialiasing = true; + } + + if (_data.scale != null) + { + this.setGraphicSize(Std.int(this.width * this.scale.x)); + this.updateHitbox(); + } + } + + function loadAnimations() + { + trace('[SPARROWCHAR] Loading ${_data.animations.length} animations for ${characterId}'); + + FlxAnimationUtil.addSparrowAnimations(this, _data.animations); + + for (anim in _data.animations) + { + if (anim.offsets == null) + { + setAnimationOffsets(anim.name, 0, 0); + } + else + { + setAnimationOffsets(anim.name, anim.offsets[0], anim.offsets[1]); + } + } + + var animNames = this.animation.getNameList(); + trace('[SPARROWCHAR] Successfully loaded ${animNames.length} animations for ${characterId}'); + } +} diff --git a/source/funkin/play/character/render/SparrowCharacter.hx b/source/funkin/play/character/render/SparrowCharacter.hx deleted file mode 100644 index 3b2de6e92..000000000 --- a/source/funkin/play/character/render/SparrowCharacter.hx +++ /dev/null @@ -1,15 +0,0 @@ -package funkin.play.character.render; - -import funkin.play.character.CharacterBase.CharacterType; - -/** - * A SparrowCharacter is a Character which is rendered by - * displaying an animation derived from a SparrowV2 atlas spritesheet file. - */ -class SparrowCharacter extends CharacterBase -{ - public function new(id:String) - { - super(id); - } -} diff --git a/source/funkin/play/stage/Bopper.hx b/source/funkin/play/stage/Bopper.hx index 632c57f3c..6fe111c50 100644 --- a/source/funkin/play/stage/Bopper.hx +++ b/source/funkin/play/stage/Bopper.hx @@ -30,24 +30,14 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass public var shouldAlternate:Null<Bool> = null; /** - * Set this value to define an additional horizontal offset to this sprite's position. + * Offset the character's sprite by this much when playing each animation. */ - public var xOffset(default, set):Float = 0; - - override function set_x(value:Float):Float - { - this.x = this.xOffset + value; - return this.x; - } - - function set_xOffset(value:Float):Float - { - var diff = value - this.xOffset; - this.xOffset = value; - this.x += diff; - return value; - } + var animationOffsets:Map<String, Array<Int>> = new Map<String, Array<Int>>(); + /** + * Add a suffix to the `idle` animation (or `danceLeft` and `danceRight` animations) + * that this bopper will play. + */ public var idleSuffix(default, set):String = ""; function set_idleSuffix(value:String):String @@ -110,7 +100,7 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass /** * Called every `danceEvery` beats of the song. */ - function dance():Void + function dance(force:Bool = false):Void { if (this.animation == null) { @@ -142,23 +132,95 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass public function hasAnimation(id:String):Bool { + if (this.animation == null) + return false; + return this.animation.getByName(id) != null; } - /* - * @param AnimName The string name of the animation you want to play. - * @param Force Whether to force the animation to restart. + /** + * Ensure that a given animation exists before playing it. + * Will gracefully check for name, then name with stripped suffixes, then 'idle', then fail to play. + * @param name */ - public function playAnimation(name:String, force:Bool = false):Void + function correctAnimationName(name:String) { - this.animation.play(name, force, false, 0); + // If the animation exists, we're good. + if (hasAnimation(name)) + return name; + + trace('[BOPPER] Animation "$name" does not exist!'); + + // Attempt to strip a `-alt` suffix, if it exists. + if (name.lastIndexOf('-') != -1) + { + var correctName = name.substring(0, name.lastIndexOf('-')); + trace('[BOPPER] Attempting to fallback to "$correctName"'); + return correctAnimationName(correctName); + } + else + { + if (name != 'idle') + { + trace('[BOPPER] Attempting to fallback to "idle"'); + return correctAnimationName('idle'); + } + else + { + trace('[BOPPER] Failing animation playback.'); + return null; + } + } + } + + /** + * @param name The name of the animation to play. + * @param restart Whether to restart the animation if it is already playing. + */ + public function playAnimation(name:String, restart:Bool = false):Void + { + var correctName = correctAnimationName(name); + if (correctName == null) + return; + + this.animation.play(correctName, restart, false, 0); + + applyAnimationOffsets(correctName); + } + + function applyAnimationOffsets(name:String) + { + var offsets = animationOffsets.get(name); + if (offsets != null) + { + this.offset.set(offsets[0], offsets[1]); + } + else + { + this.offset.set(0, 0); + } + } + + public function isAnimationFinished():Bool + { + return this.animation.finished; + } + + public function setAnimationOffsets(name:String, xOffset:Int, yOffset:Int):Void + { + animationOffsets.set(name, [xOffset, yOffset]); + applyAnimationOffsets(name); } /** * Returns the name of the animation that is currently playing. + * If no animation is playing (usually this means the character is BROKEN!), + * returns an empty string to prevent NPEs. */ public function getCurrentAnimation():String { + if (this.animation == null || this.animation.curAnim == null) + return ""; return this.animation.curAnim.name; } @@ -178,16 +240,14 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass public function onSongEnd(event:ScriptEvent) {} - public function onSongReset(event:ScriptEvent) {} - public function onGameOver(event:ScriptEvent) {} - public function onGameRetry(event:ScriptEvent) {} - public function onNoteHit(event:NoteScriptEvent) {} public function onNoteMiss(event:NoteScriptEvent) {} + public function onNoteGhostMiss(event:GhostMissNoteScriptEvent) {} + public function onStepHit(event:SongTimeScriptEvent) {} public function onCountdownStart(event:CountdownScriptEvent) {} @@ -197,4 +257,6 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass public function onCountdownEnd(event:CountdownScriptEvent) {} public function onSongLoaded(eent:SongLoadScriptEvent) {} + + public function onSongRetry(event:ScriptEvent) {} } diff --git a/source/funkin/play/stage/ScriptedBopper.hx b/source/funkin/play/stage/ScriptedBopper.hx index 14e7644da..4dd98e82c 100644 --- a/source/funkin/play/stage/ScriptedBopper.hx +++ b/source/funkin/play/stage/ScriptedBopper.hx @@ -2,6 +2,7 @@ package funkin.play.stage; import funkin.modding.IHook; -@:hscriptClass -@:keep -class ScriptedBopper extends Bopper implements IHook {} +// +// @:hscriptClass +// @:keep +// class ScriptedBopper extends Bopper implements IHook {} diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx index 233e02a66..68c5ef865 100644 --- a/source/funkin/play/stage/Stage.hx +++ b/source/funkin/play/stage/Stage.hx @@ -1,6 +1,7 @@ package funkin.play.stage; -import funkin.play.character.CharacterBase; +import funkin.util.assets.FlxAnimationUtil; +import funkin.play.character.BaseCharacter; import funkin.modding.events.ScriptEventDispatcher; import funkin.modding.events.ScriptEvent; import funkin.modding.events.ScriptEvent.CountdownScriptEvent; @@ -11,7 +12,7 @@ import flixel.group.FlxSpriteGroup; import flixel.math.FlxPoint; import flixel.util.FlxSort; import funkin.modding.IHook; -import funkin.play.character.CharacterBase.CharacterType; +import funkin.play.character.BaseCharacter.CharacterType; import funkin.play.stage.StageData.StageDataParser; import funkin.util.SortUtil; @@ -30,7 +31,7 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte public var camZoom:Float = 1.0; var namedProps:Map<String, FlxSprite> = new Map<String, FlxSprite>(); - var characters:Map<String, CharacterBase> = new Map<String, CharacterBase>(); + var characters:Map<String, BaseCharacter> = new Map<String, BaseCharacter>(); var charactersOld:Map<String, Character> = new Map<String, Character>(); var boppers:Array<Bopper> = new Array<Bopper>(); @@ -145,21 +146,22 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte for (propAnim in dataProp.animations) { propSprite.animation.add(propAnim.name, propAnim.frameIndices); + + if (Std.isOfType(propSprite, Bopper)) + { + cast(propSprite, Bopper).setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]); + } } default: // "sparrow" - for (propAnim in dataProp.animations) - { - if (propAnim.frameIndices.length == 0) - { - propSprite.animation.addByPrefix(propAnim.name, propAnim.prefix, propAnim.frameRate, propAnim.looped, propAnim.flipX, - propAnim.flipY); - } - else - { - propSprite.animation.addByIndices(propAnim.name, propAnim.prefix, propAnim.frameIndices, "", propAnim.frameRate, propAnim.looped, - propAnim.flipX, propAnim.flipY); - } - } + FlxAnimationUtil.addSparrowAnimations(propSprite, dataProp.animations); + } + + if (Std.isOfType(propSprite, Bopper)) + { + for (propAnim in dataProp.animations) + { + cast(propSprite, Bopper).setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]); + } } if (dataProp.startingAnimation != null) @@ -236,8 +238,11 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte /** * Used by the PlayState to add a character to the stage. */ - public function addCharacter(character:CharacterBase, charType:CharacterType) + public function addCharacter(character:BaseCharacter, charType:CharacterType) { + if (character == null) + return; + // Apply position and z-index. switch (charType) { @@ -264,59 +269,63 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte this.add(character); } - /** - * Used by the PlayState to add a character to the stage. - */ - public function addCharacterOld(character:Character, charType:CharacterType) + public inline function getGirlfriendPosition():FlxPoint { - // Apply position and z-index. - switch (charType) - { - case BF: - this.charactersOld.set("bf", character); - character.zIndex = _data.characters.bf.zIndex; - character.x = _data.characters.bf.position[0]; - character.y = _data.characters.bf.position[1]; - case GF: - this.charactersOld.set("gf", character); - character.zIndex = _data.characters.gf.zIndex; - character.x = _data.characters.gf.position[0]; - character.y = _data.characters.gf.position[1]; - case DAD: - this.charactersOld.set("dad", character); - character.zIndex = _data.characters.dad.zIndex; - character.x = _data.characters.dad.position[0]; - character.y = _data.characters.dad.position[1]; - default: - this.charactersOld.set(character.curCharacter, character); - } + return new FlxPoint(_data.characters.gf.position[0], _data.characters.gf.position[1]); + } - // Add the character to the scene. - this.add(character); + public inline function getBoyfriendPosition():FlxPoint + { + return new FlxPoint(_data.characters.bf.position[0], _data.characters.bf.position[1]); + } + + public inline function getDadPosition():FlxPoint + { + return new FlxPoint(_data.characters.dad.position[0], _data.characters.dad.position[1]); } /** * Retrieves a given character from the stage. */ - public function getCharacter(id:String):CharacterBase + public function getCharacter(id:String):BaseCharacter { return this.characters.get(id); } - public function getBoyfriend():CharacterBase + /** + * Retrieve the Boyfriend character. + * @param pop If true, the character will be removed from the stage as well. + */ + public function getBoyfriend(?pop:Bool = false):BaseCharacter { - return getCharacter('bf'); + if (pop) + { + var boyfriend:BaseCharacter = getCharacter("bf"); + + // Remove the character from the stage. + this.remove(boyfriend); + this.characters.remove("bf"); + + return boyfriend; + } + else + { + return getCharacter('bf'); + } + // return this.charactersOld.get('bf'); } - public function getGirlfriend():Character + public function getGirlfriend():BaseCharacter { - return this.charactersOld.get('gf'); + return getCharacter('gf'); + // return this.charactersOld.get('gf'); } - public function getDad():Character + public function getDad():BaseCharacter { - return this.charactersOld.get('dad'); + return getCharacter('dad'); + // return this.charactersOld.get('dad'); } /** @@ -345,6 +354,32 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte return result; } + /** + * Dispatch an event to all the characters in the stage. + * @param event The script event to dispatch. + */ + public function dispatchToCharacters(event:ScriptEvent):Void + { + for (characterId in characters.keys()) + { + dispatchToCharacter(characterId, event); + } + } + + /** + * Dispatch an event to a specific character. + * @param characterId The ID of the character to dispatch to. + * @param event The script event to dispatch. + */ + public function dispatchToCharacter(characterId:String, event:ScriptEvent):Void + { + var character:BaseCharacter = getCharacter(characterId); + if (character != null) + { + ScriptEventDispatcher.callEvent(character, event); + } + } + /** * onDestroy gets called when the player is leaving the PlayState, * and is used to clean up any objects that need to be destroyed. @@ -419,15 +454,8 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte public function onSongEnd(event:ScriptEvent) {} - /** - * Resets the stage and its props. - */ - public function onSongReset(event:ScriptEvent) {} - public function onGameOver(event:ScriptEvent) {} - public function onGameRetry(event:ScriptEvent) {} - public function onCountdownStart(event:CountdownScriptEvent) {} public function onCountdownStep(event:CountdownScriptEvent) {} @@ -443,5 +471,9 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte public function onNoteMiss(event:NoteScriptEvent) {} + public function onNoteGhostMiss(event:GhostMissNoteScriptEvent) {} + public function onSongLoaded(eent:SongLoadScriptEvent) {} + + public function onSongRetry(event:ScriptEvent) {} } diff --git a/source/funkin/play/stage/StageData.hx b/source/funkin/play/stage/StageData.hx index bc9946509..475f4ad9c 100644 --- a/source/funkin/play/stage/StageData.hx +++ b/source/funkin/play/stage/StageData.hx @@ -1,5 +1,6 @@ package funkin.play.stage; +import funkin.util.VersionUtil; import flixel.util.typeLimit.OneOfTwo; import funkin.util.assets.DataAssets; import haxe.Json; @@ -17,7 +18,12 @@ class StageDataParser * Handle breaking changes by incrementing this value * and adding migration to the `migrateStageData()` function. */ - public static final STAGE_DATA_VERSION:String = "1.0"; + public static final STAGE_DATA_VERSION:String = "1.0.0"; + + /** + * The current version rule check for the stage data format. + */ + public static final STAGE_DATA_VERSION_RULE:String = "1.0.x"; static final stageCache:Map<String, Stage> = new Map<String, Stage>(); @@ -163,16 +169,16 @@ class StageDataParser } } - static final DEFAULT_NAME:String = "Untitled Stage"; - static final DEFAULT_CAMERAZOOM:Float = 1.0; - static final DEFAULT_ZINDEX:Int = 0; - static final DEFAULT_DANCEEVERY:Int = 0; - static final DEFAULT_SCALE:Float = 1.0; - static final DEFAULT_ISPIXEL:Bool = false; - static final DEFAULT_POSITION:Array<Float> = [0, 0]; - static final DEFAULT_SCROLL:Array<Float> = [0, 0]; - static final DEFAULT_FRAMEINDICES:Array<Int> = []; static final DEFAULT_ANIMTYPE:String = "sparrow"; + static final DEFAULT_CAMERAZOOM:Float = 1.0; + static final DEFAULT_DANCEEVERY:Int = 0; + static final DEFAULT_ISPIXEL:Bool = false; + static final DEFAULT_NAME:String = "Untitled Stage"; + static final DEFAULT_OFFSETS:Array<Int> = [0, 0]; + static final DEFAULT_POSITION:Array<Float> = [0, 0]; + static final DEFAULT_SCALE:Float = 1.0; + static final DEFAULT_SCROLL:Array<Float> = [0, 0]; + static final DEFAULT_ZINDEX:Int = 0; static final DEFAULT_CHARACTER_DATA:StageDataCharacter = { zIndex: DEFAULT_ZINDEX, @@ -200,9 +206,9 @@ class StageDataParser return null; } - if (input.version != STAGE_DATA_VERSION) + if (!VersionUtil.validateVersion(input.version, STAGE_DATA_VERSION_RULE)) { - trace('[STAGEDATA] ERROR: Could not load stage data for "$id": bad/outdated version (got ${input.version}, expected ${STAGE_DATA_VERSION})'); + trace('[STAGEDATA] ERROR: Could not load stage data for "$id": bad version (got ${input.version}, expected ${STAGE_DATA_VERSION_RULE})'); return null; } @@ -302,9 +308,9 @@ class StageDataParser inputAnimation.frameRate = 24; } - if (inputAnimation.frameIndices == null) + if (inputAnimation.offsets == null) { - inputAnimation.frameIndices = DEFAULT_FRAMEINDICES; + inputAnimation.offsets = DEFAULT_OFFSETS; } if (inputAnimation.looped == null) @@ -362,8 +368,12 @@ class StageDataParser typedef StageData = { - // Uses semantic versioning. + /** + * The sematic version number of the stage data JSON format. + * Supports fancy comparisons like NPM does it's neat. + */ var version:String; + var name:String; var cameraZoom:Null<Float>; var props:Array<StageDataProp>; diff --git a/source/funkin/ui/AtlasText.hx b/source/funkin/ui/AtlasText.hx index 6df5f9e22..8eb0d041d 100644 --- a/source/funkin/ui/AtlasText.hx +++ b/source/funkin/ui/AtlasText.hx @@ -15,7 +15,8 @@ abstract BoldText(AtlasText) from AtlasText to AtlasText } /** - * Alphabet.hx has a ton of bugs and does a bunch of stuff I don't need, fuck that class + * AtlasText is an improved version of Alphabet and FlxBitmapText. + * It supports animations on the letters, and is less buggy than Alphabet. */ class AtlasText extends FlxTypedSpriteGroup<AtlasChar> { diff --git a/source/funkin/ui/OptionsState.hx b/source/funkin/ui/OptionsState.hx index 1b6d03e93..fc5c928c2 100644 --- a/source/funkin/ui/OptionsState.hx +++ b/source/funkin/ui/OptionsState.hx @@ -5,7 +5,6 @@ import flixel.FlxSubState; import flixel.addons.transition.FlxTransitionableState; import flixel.group.FlxGroup; import flixel.util.FlxSignal; -import funkin.i18n.FireTongueHandler.t; // typedef OptionsState = OptionsMenu_old; // class OptionsState_new extends MusicBeatState @@ -172,25 +171,25 @@ class OptionsMenu extends Page super(); add(items = new TextMenuList()); - createItem(t("PREFERENCES"), function() switchPage(Preferences)); - createItem(t("CONTROLS"), function() switchPage(Controls)); - // createItem(t("COLORS"), function() switchPage(Colors)); - createItem(t("MODS"), function() switchPage(Mods)); + createItem("PREFERENCES", function() switchPage(Preferences)); + createItem("CONTROLS", function() switchPage(Controls)); + // createItem("COLORS", function() switchPage(Colors)); + createItem("MODS", function() switchPage(Mods)); #if CAN_OPEN_LINKS if (showDonate) { var hasPopupBlocker = #if web true #else false #end; - createItem(t("DONATE"), selectDonate, hasPopupBlocker); + createItem("DONATE", selectDonate, hasPopupBlocker); } #end #if newgrounds if (NGio.isLoggedIn) - createItem(t("LOGOUT"), selectLogout); + createItem("LOGOUT", selectLogout); else - createItem(t("LOGIN"), selectLogin); + createItem("LOGIN", selectLogin); #end - createItem(t("EXIT"), exit); + createItem("EXIT", exit); } function createItem(name:String, callback:Void->Void, fireInstantly = false) diff --git a/source/funkin/util/VersionUtil.hx b/source/funkin/util/VersionUtil.hx new file mode 100644 index 000000000..a5c7e71f4 --- /dev/null +++ b/source/funkin/util/VersionUtil.hx @@ -0,0 +1,31 @@ +package funkin.util; + +import thx.semver.VersionRule; +import thx.semver.Version; + +/** + * Remember, increment the patch version (1.0.x) if you make a bugfix, + * increment the minor version (1.x.0) if you make a new feature (but previous content is still compatible), + * and increment the major version (x.0.0) if you make a breaking change (e.g. new API or reorganized file format). + */ +class VersionUtil +{ + /** + * Checks that a given verison number satisisfies a given version rule. + * Version rule can be complex, e.g. "1.0.x" or ">=1.0.0,<1.1.0", or anything NPM supports. + */ + public static function validateVersion(version:String, versionRule:String):Bool + { + try + { + var v:Version = version; // Perform a cast. + var vr:VersionRule = versionRule; // Perform a cast. + return v.satisfies(vr); + } + catch (e) + { + trace('[VERSIONUTIL] Invalid semantic version: ${version}'); + return false; + } + } +} diff --git a/source/funkin/util/assets/DataAssets.hx b/source/funkin/util/assets/DataAssets.hx index d00703068..ed4805276 100644 --- a/source/funkin/util/assets/DataAssets.hx +++ b/source/funkin/util/assets/DataAssets.hx @@ -21,7 +21,10 @@ class DataAssets { var pathNoSuffix = textPath.substring(0, textPath.length - ext.length); var pathNoPrefix = pathNoSuffix.substring(queryPath.length); - results.push(pathNoPrefix); + + // No duplicates! Why does this happen? + if (!results.contains(pathNoPrefix)) + results.push(pathNoPrefix); } } diff --git a/source/funkin/util/assets/FlxAnimationUtil.hx b/source/funkin/util/assets/FlxAnimationUtil.hx new file mode 100644 index 000000000..0f3a35728 --- /dev/null +++ b/source/funkin/util/assets/FlxAnimationUtil.hx @@ -0,0 +1,42 @@ +package funkin.util.assets; + +import funkin.play.AnimationData; +import flixel.FlxSprite; + +class FlxAnimationUtil +{ + /** + * Properly adds an animation to a sprite based on JSON data. + */ + public static function addSparrowAnimation(target:FlxSprite, anim:AnimationData) + { + var frameRate = anim.frameRate == null ? 24 : anim.frameRate; + var looped = anim.looped == null ? false : anim.looped; + var flipX = anim.flipX == null ? false : anim.flipX; + var flipY = anim.flipY == null ? false : anim.flipY; + + if (anim.frameIndices != null && anim.frameIndices.length > 0) + { + // trace('addByIndices(${anim.name}, ${anim.prefix}, ${anim.frameIndices}, ${frameRate}, ${looped}, ${flipX}, ${flipY})'); + target.animation.addByIndices(anim.name, anim.prefix, anim.frameIndices, "", frameRate, looped, flipX, flipY); + // trace('RESULT:${target.animation.getAnimationList()}'); + } + else + { + // trace('addByPrefix(${anim.name}, ${anim.prefix}, ${frameRate}, ${looped}, ${flipX}, ${flipY})'); + target.animation.addByPrefix(anim.name, anim.prefix, frameRate, looped, flipX, flipY); + // trace('RESULT:${target.animation.getAnimationList()}'); + } + } + + /** + * Properly adds multiple animations to a sprite based on JSON data. + */ + public static function addSparrowAnimations(target:FlxSprite, animations:Array<AnimationData>) + { + for (anim in animations) + { + addSparrowAnimation(target, anim); + } + } +}