diff --git a/Project.xml b/Project.xml
index 1382f5f71..4c6ad39a6 100644
--- a/Project.xml
+++ b/Project.xml
@@ -122,7 +122,8 @@
-
+
+
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).
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 = [];
-
- 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 = [];
+
+ 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 = [true, true, true, true, true, true, true, true];
var weekCharacters:Array = [
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', [''], ['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 = null, values:Array = 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, 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;
+
/**
* Offset the character's position by this amount when playing this animation.
* @default [0, 0]
*/
- var offsets:Null>;
+ var offsets:Null>;
/**
* 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 = [];
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;
var talking:Bool = true;
- var songScore:Int = 0;
var doof:DialogueBox;
var grpNoteSplashes:FlxTypedGroup;
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 = [controls.NOTE_LEFT, controls.NOTE_DOWN, controls.NOTE_UP, controls.NOTE_RIGHT];
var pressArray:Array = [
@@ -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;
-
- 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 = new Map();
+ /**
+ * 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 = new Map();
+ static final characterScriptedClass:Map = new Map();
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 = 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 = DataAssets.listDataFilesInPath('characters/');
var unscriptedCharIds:Array = 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 = 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 = 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 = 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 = 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
{
- 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
+ public static function fetchCharacterData(charId:String):Null
{
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 = [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 = [];
+ static final DEFAULT_NAME:String = "Untitled Character";
+ static final DEFAULT_OFFSETS:Array = [0, 0];
+ static final DEFAULT_RENDERTYPE:CharacterRenderType = CharacterRenderType.SPARROW;
+ static final DEFAULT_SCALE:Float = 1;
+ static final DEFAULT_SCROLL:Array = [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 = new Map();
+
+ /**
+ * A map between animation names and what frame collection the animation should use.
+ */
+ private var animAssetPath:Map = new Map();
+
+ /**
+ * 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 = 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> = new Map>();
+ /**
+ * 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 = new Map();
- var characters:Map = new Map();
+ var characters:Map = new Map();
var charactersOld:Map = new Map();
var boppers:Array = new Array();
@@ -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 = new Map();
@@ -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 = [0, 0];
- static final DEFAULT_SCROLL:Array = [0, 0];
- static final DEFAULT_FRAMEINDICES:Array = [];
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 = [0, 0];
+ static final DEFAULT_POSITION:Array = [0, 0];
+ static final DEFAULT_SCALE:Float = 1.0;
+ static final DEFAULT_SCROLL:Array = [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;
var props:Array;
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
{
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)
+ {
+ for (anim in animations)
+ {
+ addSparrowAnimation(target, anim);
+ }
+ }
+}