diff --git a/Project.xml b/Project.xml
index c0da3c89a..4b81fd07b 100644
--- a/Project.xml
+++ b/Project.xml
@@ -183,6 +183,7 @@
+
diff --git a/assets b/assets
index f462b181f..3ccfe33ac 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit f462b181f13c016f79e022d08b4d23f61d4a7e8b
+Subproject commit 3ccfe33acef6e62c40317af583af764838544a24
diff --git a/hmm.json b/hmm.json
index 0dfe88ded..641ef1bbd 100644
--- a/hmm.json
+++ b/hmm.json
@@ -139,7 +139,7 @@
"name": "openfl",
"type": "git",
"dir": null,
- "ref": "f229d76361c7e31025a048fe7909847f75bb5d5e",
+ "ref": "228c1b5063911e2ad75cef6e3168ef0a4b9f9134",
"url": "https://github.com/FunkinCrew/openfl"
},
{
diff --git a/source/funkin/Highscore.hx b/source/funkin/Highscore.hx
index 996e2367e..94f41cea4 100644
--- a/source/funkin/Highscore.hx
+++ b/source/funkin/Highscore.hx
@@ -59,6 +59,7 @@ abstract Tallies(RawTallies)
totalNotes: 0,
totalNotesHit: 0,
maxCombo: 0,
+ score: 0,
isNewHighscore: false
}
}
@@ -81,6 +82,9 @@ typedef RawTallies =
var good:Int;
var sick:Int;
var maxCombo:Int;
+
+ var score:Int;
+
var isNewHighscore:Bool;
/**
diff --git a/source/funkin/Paths.hx b/source/funkin/Paths.hx
index d2c4833f2..fd4ef76fa 100644
--- a/source/funkin/Paths.hx
+++ b/source/funkin/Paths.hx
@@ -9,7 +9,7 @@ import openfl.utils.Assets as OpenFlAssets;
*/
class Paths
{
- static var currentLevel:String;
+ static var currentLevel:Null = null;
public static function setCurrentLevel(name:String):Void
{
diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx
index 0df290feb..8c1bf3b41 100644
--- a/source/funkin/audio/FunkinSound.hx
+++ b/source/funkin/audio/FunkinSound.hx
@@ -265,10 +265,16 @@ class FunkinSound extends FlxSound implements ICloneable
@:allow(flixel.sound.FlxSoundGroup)
override function updateTransform():Void
{
- _transform.volume = #if FLX_SOUND_SYSTEM ((FlxG.sound.muted || this.muted) ? 0 : 1) * FlxG.sound.volume * #end
- (group != null ? group.volume : 1) * _volume * _volumeAdjust;
+ if (_transform != null)
+ {
+ _transform.volume = #if FLX_SOUND_SYSTEM ((FlxG.sound.muted || this.muted) ? 0 : 1) * FlxG.sound.volume * #end
+ (group != null ? group.volume : 1) * _volume * _volumeAdjust;
+ }
- if (_channel != null) _channel.soundTransform = _transform;
+ if (_channel != null)
+ {
+ _channel.soundTransform = _transform;
+ }
}
public function clone():FunkinSound
@@ -315,6 +321,13 @@ class FunkinSound extends FlxSound implements ICloneable
}
}
+ if (FlxG.sound.music != null)
+ {
+ FlxG.sound.music.fadeTween?.cancel();
+ FlxG.sound.music.stop();
+ FlxG.sound.music.kill();
+ }
+
if (params?.mapTimeChanges ?? true)
{
var songMusicData:Null = SongRegistry.instance.parseMusicData(key);
@@ -329,13 +342,6 @@ class FunkinSound extends FlxSound implements ICloneable
}
}
- if (FlxG.sound.music != null)
- {
- FlxG.sound.music.fadeTween?.cancel();
- FlxG.sound.music.stop();
- FlxG.sound.music.kill();
- }
-
var music = FunkinSound.load(Paths.music('$key/$key'), params?.startingVolume ?? 1.0, params.loop ?? true, false, true);
if (music != null)
{
@@ -391,10 +397,10 @@ class FunkinSound extends FlxSound implements ICloneable
sound._label = 'unknown';
}
+ if (autoPlay) sound.play();
sound.volume = volume;
sound.group = FlxG.sound.defaultSoundGroup;
sound.persist = true;
- if (autoPlay) sound.play();
// Call onLoad() because the sound already loaded
if (onLoad != null && sound._sound != null) onLoad();
@@ -402,10 +408,16 @@ class FunkinSound extends FlxSound implements ICloneable
return sound;
}
+ @:nullSafety(Off)
public override function destroy():Void
{
// trace('[FunkinSound] Destroying sound "${this._label}"');
super.destroy();
+ if (fadeTween != null)
+ {
+ fadeTween.cancel();
+ fadeTween = null;
+ }
FlxTween.cancelTweensOf(this);
this._label = 'unknown';
}
diff --git a/source/funkin/audio/SoundGroup.hx b/source/funkin/audio/SoundGroup.hx
index 020d5f5bb..5fc2abe0e 100644
--- a/source/funkin/audio/SoundGroup.hx
+++ b/source/funkin/audio/SoundGroup.hx
@@ -150,7 +150,7 @@ class SoundGroup extends FlxTypedGroup
/**
* Stop all the sounds in the group.
*/
- public function stop()
+ public function stop():Void
{
if (members != null)
{
@@ -160,7 +160,7 @@ class SoundGroup extends FlxTypedGroup
}
}
- public override function destroy()
+ public override function destroy():Void
{
stop();
super.destroy();
@@ -178,9 +178,14 @@ class SoundGroup extends FlxTypedGroup
function get_time():Float
{
- if (getFirstAlive() != null) return getFirstAlive().time;
+ if (getFirstAlive() != null)
+ {
+ return getFirstAlive().time;
+ }
else
+ {
return 0;
+ }
}
function set_time(time:Float):Float
@@ -195,16 +200,26 @@ class SoundGroup extends FlxTypedGroup
function get_playing():Bool
{
- if (getFirstAlive() != null) return getFirstAlive().playing;
+ if (getFirstAlive() != null)
+ {
+ return getFirstAlive().playing;
+ }
else
+ {
return false;
+ }
}
function get_volume():Float
{
- if (getFirstAlive() != null) return getFirstAlive().volume;
+ if (getFirstAlive() != null)
+ {
+ return getFirstAlive().volume;
+ }
else
+ {
return 1;
+ }
}
// in PlayState, adjust the code so that it only mutes the player1 vocal tracks?
diff --git a/source/funkin/audio/VoicesGroup.hx b/source/funkin/audio/VoicesGroup.hx
index 91054cfb0..5037ee1d0 100644
--- a/source/funkin/audio/VoicesGroup.hx
+++ b/source/funkin/audio/VoicesGroup.hx
@@ -159,10 +159,18 @@ class VoicesGroup extends SoundGroup
public override function destroy():Void
{
- playerVoices.destroy();
- playerVoices = null;
- opponentVoices.destroy();
- opponentVoices = null;
+ if (playerVoices != null)
+ {
+ playerVoices.destroy();
+ playerVoices = null;
+ }
+
+ if (opponentVoices != null)
+ {
+ opponentVoices.destroy();
+ opponentVoices = null;
+ }
+
super.destroy();
}
}
diff --git a/source/funkin/data/BaseRegistry.hx b/source/funkin/data/BaseRegistry.hx
index 7419d9425..118516bec 100644
--- a/source/funkin/data/BaseRegistry.hx
+++ b/source/funkin/data/BaseRegistry.hx
@@ -325,12 +325,3 @@ abstract class BaseRegistry & Constructible;
}
diff --git a/source/funkin/data/song/SongRegistry.hx b/source/funkin/data/song/SongRegistry.hx
index 4fdf5d0df..277dcd9e1 100644
--- a/source/funkin/data/song/SongRegistry.hx
+++ b/source/funkin/data/song/SongRegistry.hx
@@ -427,7 +427,7 @@ class SongRegistry extends BaseRegistry
return ScriptedSong.listScriptClasses();
}
- function loadEntryMetadataFile(id:String, ?variation:String):Null
+ function loadEntryMetadataFile(id:String, ?variation:String):Null
{
variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
var entryFilePath:String = Paths.json('$dataFilePath/$id/$id-metadata${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}');
@@ -442,7 +442,7 @@ class SongRegistry extends BaseRegistry
return {fileName: entryFilePath, contents: rawJson};
}
- function loadMusicDataFile(id:String, ?variation:String):Null
+ function loadMusicDataFile(id:String, ?variation:String):Null
{
variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
var entryFilePath:String = Paths.file('music/$id/$id-metadata${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}.json');
@@ -460,7 +460,7 @@ class SongRegistry extends BaseRegistry
return openfl.Assets.exists(entryFilePath);
}
- function loadEntryChartFile(id:String, ?variation:String):Null
+ function loadEntryChartFile(id:String, ?variation:String):Null
{
variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
var entryFilePath:String = Paths.json('$dataFilePath/$id/$id-chart${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}');
diff --git a/source/funkin/modding/events/ScriptEventDispatcher.hx b/source/funkin/modding/events/ScriptEventDispatcher.hx
index fd58d0fad..c262c311d 100644
--- a/source/funkin/modding/events/ScriptEventDispatcher.hx
+++ b/source/funkin/modding/events/ScriptEventDispatcher.hx
@@ -8,7 +8,12 @@ import funkin.modding.IScriptedClass;
*/
class ScriptEventDispatcher
{
- public static function callEvent(target:IScriptedClass, event:ScriptEvent):Void
+ /**
+ * Invoke the given event hook on the given scripted class.
+ * @param target The target class to call script hooks on.
+ * @param event The event, which determines the script hook to call and provides parameters for it.
+ */
+ public static function callEvent(target:Null, event:ScriptEvent):Void
{
if (target == null || event == null) return;
diff --git a/source/funkin/play/GameOverSubState.hx b/source/funkin/play/GameOverSubState.hx
index a1796e912..e7b128385 100644
--- a/source/funkin/play/GameOverSubState.hx
+++ b/source/funkin/play/GameOverSubState.hx
@@ -3,18 +3,18 @@ package funkin.play;
import flixel.FlxG;
import flixel.FlxObject;
import flixel.FlxSprite;
-import funkin.audio.FunkinSound;
+import flixel.input.touch.FlxTouch;
import flixel.util.FlxColor;
import flixel.util.FlxTimer;
+import funkin.audio.FunkinSound;
import funkin.graphics.FunkinSprite;
import funkin.modding.events.ScriptEvent;
import funkin.modding.events.ScriptEventDispatcher;
import funkin.play.character.BaseCharacter;
-import funkin.play.PlayState;
-import funkin.util.MathUtil;
import funkin.ui.freeplay.FreeplayState;
import funkin.ui.MusicBeatSubState;
import funkin.ui.story.StoryMenuState;
+import funkin.util.MathUtil;
import openfl.utils.Assets;
/**
@@ -23,13 +23,14 @@ import openfl.utils.Assets;
*
* The newest implementation uses a substate, which prevents having to reload the song and stage each reset.
*/
+@:nullSafety
class GameOverSubState extends MusicBeatSubState
{
/**
* The currently active GameOverSubState.
* There should be only one GameOverSubState in existance at a time, we can use a singleton.
*/
- public static var instance:GameOverSubState = null;
+ public static var instance:Null = null;
/**
* Which alternate animation on the character to use.
@@ -37,7 +38,7 @@ class GameOverSubState extends MusicBeatSubState
* For example, playing a different animation when BF dies in Week 4
* or Pico dies in Weekend 1.
*/
- public static var animationSuffix:String = "";
+ public static var animationSuffix:String = '';
/**
* Which alternate game over music to use.
@@ -45,17 +46,19 @@ class GameOverSubState extends MusicBeatSubState
* For example, the bf-pixel script sets this to `-pixel`
* and the pico-playable script sets this to `Pico`.
*/
- public static var musicSuffix:String = "";
+ public static var musicSuffix:String = '';
/**
* Which alternate "blue ball" sound effect to use.
*/
- public static var blueBallSuffix:String = "";
+ public static var blueBallSuffix:String = '';
+
+ static var blueballed:Bool = false;
/**
* The boyfriend character.
*/
- var boyfriend:BaseCharacter;
+ var boyfriend:Null = null;
/**
* The invisible object in the scene which the camera focuses on.
@@ -82,7 +85,8 @@ class GameOverSubState extends MusicBeatSubState
var transparent:Bool;
- final CAMERA_ZOOM_DURATION:Float = 0.5;
+ static final CAMERA_ZOOM_DURATION:Float = 0.5;
+
var targetCameraZoom:Float = 1.0;
public function new(params:GameOverParams)
@@ -91,6 +95,8 @@ class GameOverSubState extends MusicBeatSubState
this.isChartingMode = params?.isChartingMode ?? false;
transparent = params.transparent;
+
+ cameraFollowPoint = new FlxObject(PlayState.instance.cameraFollowPoint.x, PlayState.instance.cameraFollowPoint.y, 1, 1);
}
/**
@@ -101,14 +107,15 @@ class GameOverSubState extends MusicBeatSubState
animationSuffix = '';
musicSuffix = '';
blueBallSuffix = '';
+ blueballed = false;
}
- override public function create():Void
+ public override function create():Void
{
if (instance != null)
{
// TODO: Do something in this case? IDK.
- trace('WARNING: GameOverSubState instance already exists. This should not happen.');
+ FlxG.log.warn('WARNING: GameOverSubState instance already exists. This should not happen.');
}
instance = this;
@@ -121,7 +128,7 @@ class GameOverSubState extends MusicBeatSubState
var playState = PlayState.instance;
// Add a black background to the screen.
- var bg = new FunkinSprite().makeSolidColor(FlxG.width * 2, FlxG.height * 2, FlxColor.BLACK);
+ var bg:FunkinSprite = new FunkinSprite().makeSolidColor(FlxG.width * 2, FlxG.height * 2, FlxColor.BLACK);
// We make this transparent so that we can see the stage underneath during debugging,
// but it's normally opaque.
bg.alpha = transparent ? 0.25 : 1.0;
@@ -138,21 +145,10 @@ class GameOverSubState extends MusicBeatSubState
boyfriend.isDead = true;
add(boyfriend);
boyfriend.resetCharacter();
-
- // Assign a camera follow point to the boyfriend's position.
- cameraFollowPoint = new FlxObject(PlayState.instance.cameraFollowPoint.x, PlayState.instance.cameraFollowPoint.y, 1, 1);
- cameraFollowPoint.x = boyfriend.getGraphicMidpoint().x;
- cameraFollowPoint.y = boyfriend.getGraphicMidpoint().y;
- var offsets:Array = boyfriend.getDeathCameraOffsets();
- cameraFollowPoint.x += offsets[0];
- cameraFollowPoint.y += offsets[1];
- add(cameraFollowPoint);
-
- FlxG.camera.target = null;
- FlxG.camera.follow(cameraFollowPoint, LOCKON, 0.01);
- targetCameraZoom = PlayState?.instance?.currentStage?.camZoom * boyfriend.getDeathCameraZoom();
}
+ setCameraTarget();
+
//
// Set up the audio
//
@@ -161,6 +157,27 @@ class GameOverSubState extends MusicBeatSubState
Conductor.instance.update(0);
}
+ @:nullSafety(Off)
+ function setCameraTarget():Void
+ {
+ // Assign a camera follow point to the boyfriend's position.
+ cameraFollowPoint = new FlxObject(PlayState.instance.cameraFollowPoint.x, PlayState.instance.cameraFollowPoint.y, 1, 1);
+ cameraFollowPoint.x = boyfriend.getGraphicMidpoint().x;
+ cameraFollowPoint.y = boyfriend.getGraphicMidpoint().y;
+ var offsets:Array = boyfriend.getDeathCameraOffsets();
+ cameraFollowPoint.x += offsets[0];
+ cameraFollowPoint.y += offsets[1];
+ add(cameraFollowPoint);
+
+ FlxG.camera.target = null;
+ FlxG.camera.follow(cameraFollowPoint, LOCKON, Constants.DEFAULT_CAMERA_FOLLOW_RATE / 2);
+ targetCameraZoom = (PlayState?.instance?.currentStage?.camZoom ?? 1.0) * boyfriend.getDeathCameraZoom();
+ }
+
+ /**
+ * Forcibly reset the camera zoom level to that of the current stage.
+ * This prevents camera zoom events from adversely affecting the game over state.
+ */
public function resetCameraZoom():Void
{
// Apply camera zoom level from stage data.
@@ -175,7 +192,7 @@ class GameOverSubState extends MusicBeatSubState
{
hasStartedAnimation = true;
- if (PlayState.instance.isMinimalMode)
+ if (boyfriend == null || PlayState.instance.isMinimalMode)
{
// Play the "blue balled" sound. May play a variant if one has been assigned.
playBlueBalledSFX();
@@ -205,10 +222,10 @@ class GameOverSubState extends MusicBeatSubState
// MOBILE ONLY: Restart the level when tapping Boyfriend.
if (FlxG.onMobile)
{
- var touch = FlxG.touches.getFirst();
+ var touch:FlxTouch = FlxG.touches.getFirst();
if (touch != null)
{
- if (touch.overlaps(boyfriend))
+ if (boyfriend == null || touch.overlaps(boyfriend))
{
confirmDeath();
}
@@ -228,7 +245,7 @@ class GameOverSubState extends MusicBeatSubState
blueballed = false;
PlayState.instance.deathCounter = 0;
// PlayState.seenCutscene = false; // old thing...
- gameOverMusic.stop();
+ if (gameOverMusic != null) gameOverMusic.stop();
if (isChartingMode)
{
@@ -238,11 +255,11 @@ class GameOverSubState extends MusicBeatSubState
}
else if (PlayStatePlaylist.isStoryMode)
{
- FlxG.switchState(() -> new StoryMenuState());
+ openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new StoryMenuState(sticker)));
}
else
{
- FlxG.switchState(() -> new FreeplayState());
+ openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> FreeplayState.build(sticker)));
}
}
@@ -252,7 +269,7 @@ class GameOverSubState extends MusicBeatSubState
// This enables the stepHit and beatHit events.
Conductor.instance.update(gameOverMusic.time);
}
- else
+ else if (boyfriend != null)
{
if (PlayState.instance.isMinimalMode)
{
@@ -299,7 +316,7 @@ class GameOverSubState extends MusicBeatSubState
isEnding = true;
startDeathMusic(1.0, true); // isEnding changes this function's behavior.
- if (PlayState.instance.isMinimalMode) {}
+ if (PlayState.instance.isMinimalMode || boyfriend == null) {}
else
{
boyfriend.playAnimation('deathConfirm' + animationSuffix, true);
@@ -313,7 +330,7 @@ class GameOverSubState extends MusicBeatSubState
FlxG.camera.fade(FlxColor.BLACK, 1, true, null, true);
PlayState.instance.needsReset = true;
- if (PlayState.instance.isMinimalMode) {}
+ if (PlayState.instance.isMinimalMode || boyfriend == null) {}
else
{
// Readd Boyfriend to the stage.
@@ -332,7 +349,7 @@ class GameOverSubState extends MusicBeatSubState
}
}
- public override function dispatchEvent(event:ScriptEvent)
+ public override function dispatchEvent(event:ScriptEvent):Void
{
super.dispatchEvent(event);
@@ -345,11 +362,11 @@ class GameOverSubState extends MusicBeatSubState
*/
function resolveMusicPath(suffix:String, starting:Bool = false, ending:Bool = false):Null
{
- var basePath = 'gameplay/gameover/gameOver';
- if (starting) basePath += 'Start';
- else if (ending) basePath += 'End';
+ var basePath:String = 'gameplay/gameover/gameOver';
+ if (ending) basePath += 'End';
+ else if (starting) basePath += 'Start';
- var musicPath = Paths.music(basePath + suffix);
+ var musicPath:String = Paths.music(basePath + suffix);
while (!Assets.exists(musicPath) && suffix.length > 0)
{
suffix = suffix.split('-').slice(0, -1).join('-');
@@ -362,23 +379,26 @@ class GameOverSubState extends MusicBeatSubState
/**
* Starts the death music at the appropriate volume.
- * @param startingVolume
+ * @param startingVolume The initial volume for the music.
+ * @param force Whether or not to force the music to restart.
*/
public function startDeathMusic(startingVolume:Float = 1, force:Bool = false):Void
{
- var musicPath = resolveMusicPath(musicSuffix, isStarting, isEnding);
- var onComplete = null;
+ var musicPath:Null = resolveMusicPath(musicSuffix, isStarting, isEnding);
+ var onComplete:() -> Void = () -> {};
+
if (isStarting)
{
if (musicPath == null)
{
+ // Looked for starting music and didn't find it. Use middle music instead.
isStarting = false;
musicPath = resolveMusicPath(musicSuffix, isStarting, isEnding);
}
else
{
onComplete = function() {
- isStarting = false;
+ isStarting = true;
// We need to force to ensure that the non-starting music plays.
startDeathMusic(1.0, true);
};
@@ -387,13 +407,16 @@ class GameOverSubState extends MusicBeatSubState
if (musicPath == null)
{
- trace('Could not find game over music!');
+ FlxG.log.warn('[GAMEOVER] Could not find game over music at path ($musicPath)!');
return;
}
else if (gameOverMusic == null || !gameOverMusic.playing || force)
{
if (gameOverMusic != null) gameOverMusic.stop();
+
gameOverMusic = FunkinSound.load(musicPath);
+ if (gameOverMusic == null) return;
+
gameOverMusic.volume = startingVolume;
gameOverMusic.looped = !(isEnding || isStarting);
gameOverMusic.onComplete = onComplete;
@@ -406,13 +429,11 @@ class GameOverSubState extends MusicBeatSubState
}
}
- static var blueballed:Bool = false;
-
/**
* Play the sound effect that occurs when
* boyfriend's testicles get utterly annihilated.
*/
- public static function playBlueBalledSFX()
+ public static function playBlueBalledSFX():Void
{
blueballed = true;
if (Assets.exists(Paths.sound('gameplay/gameover/fnf_loss_sfx' + blueBallSuffix)))
@@ -431,7 +452,7 @@ class GameOverSubState extends MusicBeatSubState
* Week 7-specific hardcoded behavior, to play a custom death quote.
* TODO: Make this a module somehow.
*/
- function playJeffQuote()
+ function playJeffQuote():Void
{
var randomCensor:Array = [];
@@ -446,20 +467,27 @@ class GameOverSubState extends MusicBeatSubState
});
}
- public override function destroy()
+ public override function destroy():Void
{
super.destroy();
- if (gameOverMusic != null) gameOverMusic.stop();
- gameOverMusic = null;
+ if (gameOverMusic != null)
+ {
+ gameOverMusic.stop();
+ gameOverMusic = null;
+ }
+ blueballed = false;
instance = null;
}
public override function toString():String
{
- return "GameOverSubState";
+ return 'GameOverSubState';
}
}
+/**
+ * Parameters used to instantiate a GameOverSubState.
+ */
typedef GameOverParams =
{
var isChartingMode:Bool;
diff --git a/source/funkin/play/PauseSubState.hx b/source/funkin/play/PauseSubState.hx
index f16aa00d8..f1375cc63 100644
--- a/source/funkin/play/PauseSubState.hx
+++ b/source/funkin/play/PauseSubState.hx
@@ -12,6 +12,7 @@ import flixel.tweens.FlxTween;
import flixel.util.FlxColor;
import funkin.audio.FunkinSound;
import funkin.data.song.SongRegistry;
+import funkin.ui.freeplay.FreeplayState;
import funkin.graphics.FunkinSprite;
import funkin.play.cutscene.VideoCutscene;
import funkin.play.PlayState;
@@ -72,8 +73,8 @@ class PauseSubState extends MusicBeatSubState
*/
static final PAUSE_MENU_ENTRIES_VIDEO_CUTSCENE:Array = [
{text: 'Resume', callback: resume},
- {text: 'Restart Cutscene', callback: restartVideoCutscene},
{text: 'Skip Cutscene', callback: skipVideoCutscene},
+ {text: 'Restart Cutscene', callback: restartVideoCutscene},
{text: 'Exit to Menu', callback: quitToMenu},
];
@@ -230,7 +231,7 @@ class PauseSubState extends MusicBeatSubState
*/
function startPauseMusic():Void
{
- var pauseMusicPath:String = Paths.music('breakfast$musicSuffix');
+ var pauseMusicPath:String = Paths.music('breakfast$musicSuffix/breakfast$musicSuffix');
pauseMusic = FunkinSound.load(pauseMusicPath, true, true);
if (pauseMusic == null)
@@ -567,6 +568,8 @@ class PauseSubState extends MusicBeatSubState
PlayStatePlaylist.campaignDifficulty = difficulty;
PlayState.instance.currentDifficulty = PlayStatePlaylist.campaignDifficulty;
+ FreeplayState.rememberedDifficulty = difficulty;
+
PlayState.instance.needsReset = true;
state.close();
@@ -658,7 +661,7 @@ class PauseSubState extends MusicBeatSubState
}
else
{
- state.openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new funkin.ui.freeplay.FreeplayState(null, sticker)));
+ state.openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> FreeplayState.build(null, sticker)));
}
}
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 274ee4fe8..52bfedd8d 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -728,6 +728,10 @@ class PlayState extends MusicBeatSubState
#end
initialized = true;
+
+ // This step ensures z-indexes are applied properly,
+ // and it's important to call it last so all elements get affected.
+ refresh();
}
public override function draw():Void
@@ -1562,7 +1566,7 @@ class PlayState extends MusicBeatSubState
{
if (PlayState.instance.isMinimalMode) return;
// Apply camera zoom level from stage data.
- defaultCameraZoom = currentStage.camZoom;
+ defaultCameraZoom = currentStage?.camZoom ?? 1.0;
currentCameraZoom = defaultCameraZoom;
FlxG.camera.zoom = currentCameraZoom;
@@ -1720,8 +1724,6 @@ class PlayState extends MusicBeatSubState
playerStrumline.fadeInArrows();
opponentStrumline.fadeInArrows();
}
-
- this.refresh();
}
/**
@@ -2731,7 +2733,7 @@ class PlayState extends MusicBeatSubState
*/
public function endSong(rightGoddamnNow:Bool = false):Void
{
- FlxG.sound.music.volume = 0;
+ if (FlxG.sound.music != null) FlxG.sound.music.volume = 0;
vocals.volume = 0;
mayPauseGame = false;
@@ -2749,6 +2751,8 @@ class PlayState extends MusicBeatSubState
deathCounter = 0;
+ var isNewHighscore = false;
+
if (currentSong != null && currentSong.validScore)
{
// crackhead double thingie, sets whether was new highscore, AND saves the song!
@@ -2779,11 +2783,14 @@ class PlayState extends MusicBeatSubState
#if newgrounds
NGio.postScore(score, currentSong.id);
#end
+ isNewHighscore = true;
}
}
if (PlayStatePlaylist.isStoryMode)
{
+ isNewHighscore = false;
+
PlayStatePlaylist.campaignScore += songScore;
// Pop the next song ID from the list.
@@ -2792,18 +2799,6 @@ class PlayState extends MusicBeatSubState
if (targetSongId == null)
{
- FunkinSound.playMusic('freakyMenu',
- {
- overrideExisting: true,
- restartTrack: false
- });
-
- // transIn = FlxTransitionableState.defaultTransIn;
- // transOut = FlxTransitionableState.defaultTransOut;
-
- // TODO: Rework week unlock logic.
- // StoryMenuState.weekUnlocked[Std.int(Math.min(storyWeek + 1, StoryMenuState.weekUnlocked.length - 1))] = true;
-
if (currentSong.validScore)
{
NGio.unlockMedal(60961);
@@ -2833,6 +2828,7 @@ class PlayState extends MusicBeatSubState
#if newgrounds
NGio.postScore(score, 'Level ${PlayStatePlaylist.campaignId}');
#end
+ isNewHighscore = true;
}
}
@@ -2844,11 +2840,11 @@ class PlayState extends MusicBeatSubState
{
if (rightGoddamnNow)
{
- moveToResultsScreen();
+ moveToResultsScreen(isNewHighscore);
}
else
{
- zoomIntoResultsScreen();
+ zoomIntoResultsScreen(isNewHighscore);
}
}
}
@@ -2909,11 +2905,11 @@ class PlayState extends MusicBeatSubState
{
if (rightGoddamnNow)
{
- moveToResultsScreen();
+ moveToResultsScreen(isNewHighscore);
}
else
{
- zoomIntoResultsScreen();
+ zoomIntoResultsScreen(isNewHighscore);
}
}
}
@@ -2987,7 +2983,7 @@ class PlayState extends MusicBeatSubState
/**
* Play the camera zoom animation and then move to the results screen once it's done.
*/
- function zoomIntoResultsScreen():Void
+ function zoomIntoResultsScreen(isNewHighscore:Bool):Void
{
trace('WENT TO RESULTS SCREEN!');
@@ -3044,7 +3040,7 @@ class PlayState extends MusicBeatSubState
{
ease: FlxEase.expoIn,
onComplete: function(_) {
- moveToResultsScreen();
+ moveToResultsScreen(isNewHighscore);
}
});
});
@@ -3053,7 +3049,7 @@ class PlayState extends MusicBeatSubState
/**
* Move to the results screen right goddamn now.
*/
- function moveToResultsScreen():Void
+ function moveToResultsScreen(isNewHighscore:Bool):Void
{
persistentUpdate = false;
vocals.stop();
@@ -3065,7 +3061,24 @@ class PlayState extends MusicBeatSubState
{
storyMode: PlayStatePlaylist.isStoryMode,
title: PlayStatePlaylist.isStoryMode ? ('${PlayStatePlaylist.campaignTitle}') : ('${currentChart.songName} by ${currentChart.songArtist}'),
- tallies: talliesToUse,
+ scoreData:
+ {
+ score: songScore,
+ tallies:
+ {
+ sick: Highscore.tallies.sick,
+ good: Highscore.tallies.good,
+ bad: Highscore.tallies.bad,
+ shit: Highscore.tallies.shit,
+ missed: Highscore.tallies.missed,
+ combo: Highscore.tallies.combo,
+ maxCombo: Highscore.tallies.maxCombo,
+ totalNotesHit: Highscore.tallies.totalNotesHit,
+ totalNotes: Highscore.tallies.totalNotes,
+ },
+ accuracy: Highscore.tallies.totalNotesHit / Highscore.tallies.totalNotes,
+ },
+ isNewHighscore: isNewHighscore
});
res.camera = camHUD;
openSubState(res);
@@ -3212,7 +3225,10 @@ class PlayState extends MusicBeatSubState
// Don't go back in time to before the song started.
targetTimeMs = Math.max(0, targetTimeMs);
- FlxG.sound.music.time = targetTimeMs;
+ if (FlxG.sound.music != null)
+ {
+ FlxG.sound.music.time = targetTimeMs;
+ }
handleSkippedNotes();
SongEventRegistry.handleSkippedEvents(songEvents, Conductor.instance.songPosition);
diff --git a/source/funkin/play/PlayStatePlaylist.hx b/source/funkin/play/PlayStatePlaylist.hx
index 3b0fb01f6..e47a6288a 100644
--- a/source/funkin/play/PlayStatePlaylist.hx
+++ b/source/funkin/play/PlayStatePlaylist.hx
@@ -5,12 +5,13 @@ package funkin.play;
*
* TODO: Add getters/setters for all these properties to validate them.
*/
+@:nullSafety
class PlayStatePlaylist
{
/**
* Whether the game is currently in Story Mode. If false, we are in Free Play Mode.
*/
- public static var isStoryMode(default, default):Bool = false;
+ public static var isStoryMode:Bool = false;
/**
* The loist of upcoming songs to be played.
@@ -31,8 +32,9 @@ class PlayStatePlaylist
/**
* The internal ID of the current playlist, for example `week4` or `weekend-1`.
+ * @default `null`, used when no playlist is loaded
*/
- public static var campaignId:String = 'unknown';
+ public static var campaignId:Null = null;
public static var campaignDifficulty:String = Constants.DEFAULT_DIFFICULTY;
@@ -45,7 +47,7 @@ class PlayStatePlaylist
playlistSongIds = [];
campaignScore = 0;
campaignTitle = 'UNKNOWN';
- campaignId = 'unknown';
+ campaignId = null;
campaignDifficulty = Constants.DEFAULT_DIFFICULTY;
}
}
diff --git a/source/funkin/play/ResultState.hx b/source/funkin/play/ResultState.hx
index 821f4ba3c..3ae8ad138 100644
--- a/source/funkin/play/ResultState.hx
+++ b/source/funkin/play/ResultState.hx
@@ -1,5 +1,6 @@
package funkin.play;
+import funkin.util.MathUtil;
import funkin.ui.story.StoryMenuState;
import funkin.graphics.adobeanimate.FlxAtlasSprite;
import flixel.FlxSprite;
@@ -16,6 +17,8 @@ import flixel.tweens.FlxTween;
import funkin.audio.FunkinSound;
import flixel.util.FlxGradient;
import flixel.util.FlxTimer;
+import funkin.save.Save;
+import funkin.save.Save.SaveScoreData;
import funkin.graphics.shaders.LeftMaskShader;
import funkin.play.components.TallyCounter;
@@ -42,12 +45,15 @@ class ResultState extends MusicBeatSubState
override function create():Void
{
- if (params.tallies.sick == params.tallies.totalNotesHit
- && params.tallies.maxCombo == params.tallies.totalNotesHit) resultsVariation = PERFECT;
- else if (params.tallies.missed + params.tallies.bad + params.tallies.shit >= params.tallies.totalNotes * 0.50)
- resultsVariation = SHIT; // if more than half of your song was missed, bad, or shit notes, you get shit ending!
- else
- resultsVariation = NORMAL;
+ /*
+ if (params.scoreData.sick == params.scoreData.totalNotesHit
+ && params.scoreData.maxCombo == params.scoreData.totalNotesHit) resultsVariation = PERFECT;
+ else if (params.scoreData.missed + params.scoreData.bad + params.scoreData.shit >= params.scoreData.totalNotes * 0.50)
+ resultsVariation = SHIT; // if more than half of your song was missed, bad, or shit notes, you get shit ending!
+ else
+ resultsVariation = NORMAL;
+ */
+ resultsVariation = NORMAL;
FunkinSound.playMusic('results$resultsVariation',
{
@@ -130,12 +136,16 @@ class ResultState extends MusicBeatSubState
var diffSpr:String = switch (PlayState.instance.currentDifficulty)
{
- case 'EASY':
+ case 'easy':
'difEasy';
- case 'NORMAL':
+ case 'normal':
'difNormal';
- case 'HARD':
+ case 'hard':
'difHard';
+ case 'erect':
+ 'difErect';
+ case 'nightmare':
+ 'difNightmare';
case _:
'difNormal';
}
@@ -195,29 +205,33 @@ class ResultState extends MusicBeatSubState
* NOTE: We display how many notes were HIT, not how many notes there were in total.
*
*/
- var totalHit:TallyCounter = new TallyCounter(375, hStuf * 3, params.tallies.totalNotesHit);
+ var totalHit:TallyCounter = new TallyCounter(375, hStuf * 3, params.scoreData.tallies.totalNotesHit);
ratingGrp.add(totalHit);
- var maxCombo:TallyCounter = new TallyCounter(375, hStuf * 4, params.tallies.maxCombo);
+ var maxCombo:TallyCounter = new TallyCounter(375, hStuf * 4, params.scoreData.tallies.maxCombo);
ratingGrp.add(maxCombo);
hStuf += 2;
var extraYOffset:Float = 5;
- var tallySick:TallyCounter = new TallyCounter(230, (hStuf * 5) + extraYOffset, params.tallies.sick, 0xFF89E59E);
+ var tallySick:TallyCounter = new TallyCounter(230, (hStuf * 5) + extraYOffset, params.scoreData.tallies.sick, 0xFF89E59E);
ratingGrp.add(tallySick);
- var tallyGood:TallyCounter = new TallyCounter(210, (hStuf * 6) + extraYOffset, params.tallies.good, 0xFF89C9E5);
+ var tallyGood:TallyCounter = new TallyCounter(210, (hStuf * 6) + extraYOffset, params.scoreData.tallies.good, 0xFF89C9E5);
ratingGrp.add(tallyGood);
- var tallyBad:TallyCounter = new TallyCounter(190, (hStuf * 7) + extraYOffset, params.tallies.bad, 0xFFE6CF8A);
+ var tallyBad:TallyCounter = new TallyCounter(190, (hStuf * 7) + extraYOffset, params.scoreData.tallies.bad, 0xFFE6CF8A);
ratingGrp.add(tallyBad);
- var tallyShit:TallyCounter = new TallyCounter(220, (hStuf * 8) + extraYOffset, params.tallies.shit, 0xFFE68C8A);
+ var tallyShit:TallyCounter = new TallyCounter(220, (hStuf * 8) + extraYOffset, params.scoreData.tallies.shit, 0xFFE68C8A);
ratingGrp.add(tallyShit);
- var tallyMissed:TallyCounter = new TallyCounter(260, (hStuf * 9) + extraYOffset, params.tallies.missed, 0xFFC68AE6);
+ var tallyMissed:TallyCounter = new TallyCounter(260, (hStuf * 9) + extraYOffset, params.scoreData.tallies.missed, 0xFFC68AE6);
ratingGrp.add(tallyMissed);
+ var score:TallyCounter = new TallyCounter(825, 630, params.scoreData.score, RIGHT);
+ score.scale.set(2, 2);
+ ratingGrp.add(score);
+
for (ind => rating in ratingGrp.members)
{
rating.visible = false;
@@ -235,9 +249,16 @@ class ResultState extends MusicBeatSubState
scorePopin.animation.play("score");
scorePopin.visible = true;
- highscoreNew.visible = true;
- highscoreNew.animation.play("new");
- FlxTween.tween(highscoreNew, {y: highscoreNew.y + 10}, 0.8, {ease: FlxEase.quartOut});
+ if (params.isNewHighscore)
+ {
+ highscoreNew.visible = true;
+ highscoreNew.animation.play("new");
+ FlxTween.tween(highscoreNew, {y: highscoreNew.y + 10}, 0.8, {ease: FlxEase.quartOut});
+ }
+ else
+ {
+ highscoreNew.visible = false;
+ }
};
switch (resultsVariation)
@@ -276,8 +297,6 @@ class ResultState extends MusicBeatSubState
}
});
- if (params.tallies.isNewHighscore) trace("ITS A NEW HIGHSCORE!!!");
-
super.create();
}
@@ -365,7 +384,7 @@ class ResultState extends MusicBeatSubState
}
else
{
- openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new FreeplayState(null, sticker)));
+ openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> FreeplayState.build(null, sticker)));
}
}
@@ -393,8 +412,13 @@ typedef ResultsStateParams =
*/
var title:String;
+ /**
+ * Whether the displayed score is a new highscore
+ */
+ var isNewHighscore:Bool;
+
/**
* The score, accuracy, and judgements.
*/
- var tallies:Highscore.Tallies;
+ var scoreData:SaveScoreData;
};
diff --git a/source/funkin/play/components/PopUpStuff.hx b/source/funkin/play/components/PopUpStuff.hx
index 39fc192a0..724bf0cb9 100644
--- a/source/funkin/play/components/PopUpStuff.hx
+++ b/source/funkin/play/components/PopUpStuff.hx
@@ -85,7 +85,7 @@ class PopUpStuff extends FlxTypedGroup
comboSpr.velocity.y -= 150;
comboSpr.velocity.x += FlxG.random.int(1, 10);
- add(comboSpr);
+ // add(comboSpr);
if (PlayState.instance.currentStageId.startsWith('school'))
{
diff --git a/source/funkin/play/components/TallyCounter.hx b/source/funkin/play/components/TallyCounter.hx
index 77e6ef4ec..35a8f3f51 100644
--- a/source/funkin/play/components/TallyCounter.hx
+++ b/source/funkin/play/components/TallyCounter.hx
@@ -6,6 +6,8 @@ import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
import flixel.math.FlxMath;
import flixel.tweens.FlxEase;
import flixel.tweens.FlxTween;
+import flixel.text.FlxText.FlxTextAlign;
+import funkin.util.MathUtil;
/**
* Numerical counters used next to each judgement in the Results screen.
@@ -13,18 +15,23 @@ import flixel.tweens.FlxTween;
class TallyCounter extends FlxTypedSpriteGroup
{
public var curNumber:Float = 0;
-
public var neededNumber:Int = 0;
+
public var flavour:Int = 0xFFFFFFFF;
- public function new(x:Float, y:Float, neededNumber:Int = 0, ?flavour:Int = 0xFFFFFFFF)
+ public var align:FlxTextAlign = FlxTextAlign.LEFT;
+
+ public function new(x:Float, y:Float, neededNumber:Int = 0, ?flavour:Int = 0xFFFFFFFF, align:FlxTextAlign = FlxTextAlign.LEFT)
{
super(x, y);
+ this.align = align;
+
this.flavour = flavour;
this.neededNumber = neededNumber;
- drawNumbers();
+
+ if (curNumber == neededNumber) drawNumbers();
}
var tmr:Float = 0;
@@ -41,6 +48,8 @@ class TallyCounter extends FlxTypedSpriteGroup
var seperatedScore:Array = [];
var tempCombo:Int = Math.round(curNumber);
+ var fullNumberDigits:Int = Std.int(Math.max(1, Math.ceil(MathUtil.logBase(10, neededNumber))));
+
while (tempCombo != 0)
{
seperatedScore.push(tempCombo % 10);
@@ -55,7 +64,13 @@ class TallyCounter extends FlxTypedSpriteGroup
{
if (ind >= members.length)
{
- var numb:TallyNumber = new TallyNumber(ind * 43, 0, num);
+ var xPos = ind * (43 * this.scale.x);
+ if (this.align == FlxTextAlign.RIGHT)
+ {
+ xPos -= (fullNumberDigits * (43 * this.scale.x));
+ }
+ var numb:TallyNumber = new TallyNumber(xPos, 0, num);
+ numb.scale.set(this.scale.x, this.scale.y);
add(numb);
numb.color = flavour;
}
diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx
index 9a6699c43..2b10c05ee 100644
--- a/source/funkin/play/notes/Strumline.hx
+++ b/source/funkin/play/notes/Strumline.hx
@@ -295,6 +295,11 @@ class Strumline extends FlxSpriteGroup
{
if (noteData.length == 0) return;
+ // Ensure note data gets reset if the song happens to loop.
+ // NOTE: I had to remove this line because it was causing notes visible during the countdown to be placed multiple times.
+ // I don't remember what bug I was trying to fix by adding this.
+ // if (conductorInUse.currentStep == 0) nextNoteIndex = 0;
+
var songStart:Float = PlayState.instance?.startTimestamp ?? 0.0;
var hitWindowStart:Float = Conductor.instance.songPosition - Constants.HIT_WINDOW_MS;
var renderWindowStart:Float = Conductor.instance.songPosition + RENDER_DISTANCE_MS;
@@ -822,7 +827,7 @@ class Strumline extends FlxSpriteGroup
{
// The note sprite pool is full and all note splashes are active.
// We have to create a new note.
- result = new SustainTrail(0, 100, noteStyle);
+ result = new SustainTrail(0, 0, noteStyle);
this.holdNotes.add(result);
}
diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx
index db42b0dd3..eb9eb1810 100644
--- a/source/funkin/play/stage/Stage.hx
+++ b/source/funkin/play/stage/Stage.hx
@@ -223,7 +223,8 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements
if (propSprite.frames == null || propSprite.frames.numFrames == 0)
{
- trace(' ERROR: Could not build texture for prop.');
+ @:privateAccess
+ trace(' ERROR: Could not build texture for prop. Check the asset path (${Paths.currentLevel ?? 'default'}, ${dataProp.assetPath}).');
continue;
}
diff --git a/source/funkin/save/Save.hx b/source/funkin/save/Save.hx
index 73ba8efa0..af2730ddd 100644
--- a/source/funkin/save/Save.hx
+++ b/source/funkin/save/Save.hx
@@ -9,12 +9,13 @@ import funkin.save.migrator.SaveDataMigrator;
import funkin.ui.debug.charting.ChartEditorState.ChartEditorLiveInputStyle;
import funkin.ui.debug.charting.ChartEditorState.ChartEditorTheme;
import thx.semver.Version;
+import funkin.util.SerializerUtil;
@:nullSafety
class Save
{
// Version 2.0.2 adds attributes to `optionsChartEditor`, that should return default values if they are null.
- public static final SAVE_DATA_VERSION:thx.semver.Version = "2.0.2";
+ public static final SAVE_DATA_VERSION:thx.semver.Version = "2.0.3";
public static final SAVE_DATA_VERSION_RULE:thx.semver.VersionRule = "2.0.x";
// We load this version's saves from a new save path, to maintain SOME level of backwards compatibility.
@@ -391,6 +392,22 @@ class Save
*/
public function getLevelScore(levelId:String, difficultyId:String = 'normal'):Null
{
+ if (data.scores?.levels == null)
+ {
+ if (data.scores == null)
+ {
+ data.scores =
+ {
+ songs: [],
+ levels: []
+ };
+ }
+ else
+ {
+ data.scores.levels = [];
+ }
+ }
+
var level = data.scores.levels.get(levelId);
if (level == null)
{
@@ -641,6 +658,9 @@ class Save
{
trace("[SAVE] Loading save from slot " + slot + "...");
+ // Prevent crashes if the save data is corrupted.
+ SerializerUtil.initSerializer();
+
FlxG.save.bind('$SAVE_NAME${slot}', SAVE_PATH);
if (FlxG.save.isEmpty())
@@ -650,9 +670,9 @@ class Save
if (legacySaveData != null)
{
trace('[SAVE] Found legacy save data, converting...');
- var gameSave = SaveDataMigrator.migrate(legacySaveData);
+ var gameSave = SaveDataMigrator.migrateFromLegacy(legacySaveData);
@:privateAccess
- FlxG.save.mergeData(gameSave.data);
+ FlxG.save.mergeData(gameSave.data, true);
}
else
{
@@ -664,7 +684,7 @@ class Save
trace('[SAVE] Loaded save data.');
@:privateAccess
var gameSave = SaveDataMigrator.migrate(FlxG.save.data);
- FlxG.save.mergeData(gameSave.data);
+ FlxG.save.mergeData(gameSave.data, true);
}
}
diff --git a/source/funkin/save/migrator/SaveDataMigrator.hx b/source/funkin/save/migrator/SaveDataMigrator.hx
index 00637d52a..3ed59e726 100644
--- a/source/funkin/save/migrator/SaveDataMigrator.hx
+++ b/source/funkin/save/migrator/SaveDataMigrator.hx
@@ -3,6 +3,7 @@ package funkin.save.migrator;
import funkin.save.Save;
import funkin.save.migrator.RawSaveData_v1_0_0;
import thx.semver.Version;
+import funkin.util.StructureUtil;
import funkin.util.VersionUtil;
@:nullSafety
@@ -26,7 +27,7 @@ class SaveDataMigrator
if (VersionUtil.validateVersion(version, Save.SAVE_DATA_VERSION_RULE))
{
// Simply import the structured data.
- var save:Save = new Save(inputData);
+ var save:Save = new Save(StructureUtil.deepMerge(Save.getDefault(), inputData));
return save;
}
else
diff --git a/source/funkin/ui/credits/CreditsData.hx b/source/funkin/ui/credits/CreditsData.hx
new file mode 100644
index 000000000..bf7f13ad5
--- /dev/null
+++ b/source/funkin/ui/credits/CreditsData.hx
@@ -0,0 +1,34 @@
+package funkin.ui.credits;
+
+/**
+ * The members of the Funkin' Crew, organized by their roles.
+ */
+typedef CreditsData =
+{
+ var entries:Array;
+}
+
+/**
+ * The members of a specific role on the Funkin' Crew.
+ */
+typedef CreditsDataRole =
+{
+ @:optional
+ var header:String;
+
+ @:optional
+ @:default([])
+ var body:Array;
+
+ @:optional
+ @:default(false)
+ var appendBackers:Bool;
+}
+
+/**
+ * A member of a specific person on the Funkin' Crew.
+ */
+typedef CreditsDataMember =
+{
+ var line:String;
+}
diff --git a/source/funkin/ui/credits/CreditsDataHandler.hx b/source/funkin/ui/credits/CreditsDataHandler.hx
new file mode 100644
index 000000000..86afdafd1
--- /dev/null
+++ b/source/funkin/ui/credits/CreditsDataHandler.hx
@@ -0,0 +1,139 @@
+package funkin.ui.credits;
+
+import funkin.data.JsonFile;
+
+using StringTools;
+
+@:nullSafety
+class CreditsDataHandler
+{
+ public static final BACKER_PUBLIC_URL:String = 'https://funkin.me/backers';
+
+ #if HARDCODED_CREDITS
+ static final CREDITS_DATA_PATH:String = "assets/exclude/data/credits.json";
+ #else
+ static final CREDITS_DATA_PATH:String = "assets/data/credits.json";
+ #end
+
+ public static function debugPrint(data:Null):Void
+ {
+ if (data == null)
+ {
+ trace('CreditsData(NULL)');
+ return;
+ }
+
+ if (data.entries == null || data.entries.length == 0)
+ {
+ trace('CreditsData(EMPTY)');
+ return;
+ }
+
+ var entryCount = data.entries.length;
+ var lineCount = 0;
+ for (entry in data.entries)
+ {
+ lineCount += entry?.body?.length ?? 0;
+ }
+
+ trace('CreditsData($entryCount entries containing $lineCount lines)');
+ }
+
+ /**
+ * If for some reason the full credits won't load,
+ * use this hardcoded data for the original Funkin' Crew.
+ *
+ * @return `CreditsData`
+ */
+ public static inline function getFallback():CreditsData
+ {
+ return {
+ entries: [
+ {
+ header: 'Founders',
+ body: [
+ {line: 'ninjamuffin99'},
+ {line: 'PhantomArcade'},
+ {line: 'KawaiSprite'},
+ {line: 'evilsk8r'},
+ ]
+ },
+ {
+ header: 'Kickstarter Backers',
+ appendBackers: true
+ }
+ ]
+ };
+ }
+
+ public static function fetchBackerEntries():Array
+ {
+ // TODO: Replace this with a web request.
+ // We can't just grab the current Kickstarter data and include it in builds,
+ // because we don't want to deadname people who haven't logged into the portal yet.
+ // It can be async and paginated for performance!
+ return ['See the list of backers at $BACKER_PUBLIC_URL.'];
+ }
+
+ #if HARDCODED_CREDITS
+ /**
+ * The data for the credits.
+ * Hardcoded into game via a macro at compile time.
+ */
+ public static final CREDITS_DATA:Null = #if macro null #else CreditsDataMacro.loadCreditsData() #end;
+ #else
+
+ /**
+ * The data for the credits.
+ * Loaded dynamically from the game folder when needed.
+ * Nullable because data may fail to parse.
+ */
+ public static var CREDITS_DATA(get, default):Null = null;
+
+ static function get_CREDITS_DATA():Null
+ {
+ if (CREDITS_DATA == null) CREDITS_DATA = parseCreditsData(fetchCreditsData());
+
+ return CREDITS_DATA;
+ }
+
+ static function fetchCreditsData():funkin.data.JsonFile
+ {
+ var rawJson:String = openfl.Assets.getText(CREDITS_DATA_PATH).trim();
+
+ return {
+ fileName: CREDITS_DATA_PATH,
+ contents: rawJson
+ };
+ }
+
+ static function parseCreditsData(file:JsonFile):Null
+ {
+ #if !macro
+ if (file.contents == null) return null;
+
+ var parser = new json2object.JsonParser();
+ parser.ignoreUnknownVariables = false;
+ trace('[CREDITS] Parsing credits data from ${CREDITS_DATA_PATH}');
+ parser.fromJson(file.contents, file.fileName);
+
+ if (parser.errors.length > 0)
+ {
+ printErrors(parser.errors, file.fileName);
+ return null;
+ }
+ return parser.value;
+ #else
+ return null;
+ #end
+ }
+
+ static function printErrors(errors:Array, id:String = ''):Void
+ {
+ trace('[CREDITS] Failed to parse credits data: ${id}');
+
+ for (error in errors)
+ funkin.data.DataError.printError(error);
+ }
+ #end
+}
diff --git a/source/funkin/ui/credits/CreditsDataMacro.hx b/source/funkin/ui/credits/CreditsDataMacro.hx
new file mode 100644
index 000000000..c97770eef
--- /dev/null
+++ b/source/funkin/ui/credits/CreditsDataMacro.hx
@@ -0,0 +1,67 @@
+package funkin.ui.credits;
+
+#if macro
+import haxe.macro.Context;
+#end
+
+@:access(funkin.ui.credits.CreditsDataHandler)
+class CreditsDataMacro
+{
+ public static macro function loadCreditsData():haxe.macro.Expr.ExprOf
+ {
+ #if !display
+ trace('Hardcoding credits data...');
+ var json = CreditsDataMacro.fetchJSON();
+
+ if (json == null)
+ {
+ Context.info('[WARN] Could not fetch JSON data for credits.', Context.currentPos());
+ return macro $v{CreditsDataHandler.getFallback()};
+ }
+
+ var creditsData = CreditsDataMacro.parseJSON(json);
+
+ if (creditsData == null)
+ {
+ Context.info('[WARN] Could not parse JSON data for credits.', Context.currentPos());
+ return macro $v{CreditsDataHandler.getFallback()};
+ }
+
+ CreditsDataHandler.debugPrint(creditsData);
+ return macro $v{creditsData};
+ // return macro $v{null};
+ #else
+ // `#if display` is used for code completion. In this case we return
+ // a minimal value to keep code completion fast.
+ return macro $v{CreditsDataHandler.getFallback()};
+ #end
+ }
+
+ #if macro
+ static function fetchJSON():Null
+ {
+ return sys.io.File.getContent(CreditsDataHandler.CREDITS_DATA_PATH);
+ }
+
+ /**
+ * Parse the JSON data for the credits.
+ *
+ * @param json The string data to parse.
+ * @return The parsed data.
+ */
+ static function parseJSON(json:String):Null
+ {
+ try
+ {
+ // TODO: Use something with better validation but that still works at macro time.
+ return haxe.Json.parse(json);
+ }
+ catch (e)
+ {
+ trace('[ERROR] Failed to parse JSON data for credits.');
+ trace(e);
+ return null;
+ }
+ }
+ #end
+}
diff --git a/source/funkin/ui/credits/CreditsState.hx b/source/funkin/ui/credits/CreditsState.hx
new file mode 100644
index 000000000..d43e25114
--- /dev/null
+++ b/source/funkin/ui/credits/CreditsState.hx
@@ -0,0 +1,213 @@
+package funkin.ui.credits;
+
+import flixel.text.FlxText;
+import flixel.util.FlxColor;
+import funkin.audio.FunkinSound;
+import flixel.FlxSprite;
+import flixel.group.FlxSpriteGroup;
+
+/**
+ * The state used to display the credits scroll.
+ * AAA studios often fail to credit properly, and we're better than them!
+ */
+class CreditsState extends MusicBeatState
+{
+ /**
+ * The height the credits should start at.
+ * Make this an instanced variable so it gets set by the constructor.
+ */
+ final STARTING_HEIGHT = FlxG.height;
+
+ /**
+ * The padding on each side of the screen.
+ */
+ static final SCREEN_PAD = 24;
+
+ /**
+ * The width of the screen the credits should maximally fill up.
+ * Make this an instanced variable so it gets set by the constructor.
+ */
+ final FULL_WIDTH = FlxG.width - (SCREEN_PAD * 2);
+
+ /**
+ * The font to use to display the text.
+ * To use a font from the `assets` folder, use `Paths.font(...)`.
+ * Choose something that will render Unicode properly.
+ */
+ static final CREDITS_FONT = 'Arial';
+
+ /**
+ * The size of the font.
+ */
+ static final CREDITS_FONT_SIZE = 48;
+
+ static final CREDITS_HEADER_FONT_SIZE = 72;
+
+ /**
+ * The color of the text itself.
+ */
+ static final CREDITS_FONT_COLOR = FlxColor.WHITE;
+
+ /**
+ * The color of the text's outline.
+ */
+ static final CREDITS_FONT_STROKE_COLOR = FlxColor.BLACK;
+
+ /**
+ * The speed the credits scroll at, in pixels per second.
+ */
+ static final CREDITS_SCROLL_BASE_SPEED = 25.0;
+
+ /**
+ * The speed the credits scroll at while the button is held, in pixels per second.
+ */
+ static final CREDITS_SCROLL_FAST_SPEED = CREDITS_SCROLL_BASE_SPEED * 4.0;
+
+ /**
+ * The actual sprites and text used to display the credits.
+ */
+ var creditsGroup:FlxSpriteGroup;
+
+ var scrollPaused:Bool = false;
+
+ public function new()
+ {
+ super();
+ }
+
+ public override function create():Void
+ {
+ super.create();
+
+ // Background
+ var bg = new FlxSprite(Paths.image('menuDesat'));
+ bg.scrollFactor.x = 0;
+ bg.scrollFactor.y = 0;
+ bg.setGraphicSize(Std.int(FlxG.width));
+ bg.updateHitbox();
+ bg.x = 0;
+ bg.y = 0;
+ bg.visible = true;
+ bg.color = 0xFFB57EDC; // Lavender
+ add(bg);
+
+ // TODO: Once we need to display Kickstarter backers,
+ // make this use a recycled pool so we don't kill peformance.
+ creditsGroup = new FlxSpriteGroup();
+ creditsGroup.x = SCREEN_PAD;
+ creditsGroup.y = STARTING_HEIGHT;
+
+ buildCreditsGroup();
+
+ add(creditsGroup);
+
+ // Music
+ FunkinSound.playMusic('freeplayRandom',
+ {
+ startingVolume: 0.0,
+ overrideExisting: true,
+ restartTrack: true,
+ loop: true
+ });
+ FlxG.sound.music.fadeIn(2, 0, 0.8);
+ }
+
+ function buildCreditsGroup():Void
+ {
+ var y = 0;
+
+ for (entry in CreditsDataHandler.CREDITS_DATA.entries)
+ {
+ if (entry.header != null)
+ {
+ creditsGroup.add(buildCreditsLine(entry.header, y, true, CreditsSide.Center));
+ y += CREDITS_HEADER_FONT_SIZE;
+ }
+
+ for (line in entry?.body ?? [])
+ {
+ creditsGroup.add(buildCreditsLine(line.line, y, false, CreditsSide.Center));
+ y += CREDITS_FONT_SIZE;
+ }
+
+ if (entry.appendBackers)
+ {
+ var backers = CreditsDataHandler.fetchBackerEntries();
+ for (backer in backers)
+ {
+ creditsGroup.add(buildCreditsLine(backer, y, false, CreditsSide.Center));
+ y += CREDITS_FONT_SIZE;
+ }
+ }
+
+ // Padding between each role.
+ y += CREDITS_FONT_SIZE * 2;
+ }
+ }
+
+ function buildCreditsLine(text:String, yPos:Float, header:Bool, side:CreditsSide = CreditsSide.Center):FlxText
+ {
+ // CreditsSide.Center: Full screen width
+ // CreditsSide.Left: Left half of screen
+ // CreditsSide.Right: Right half of screen
+ var xPos = (side == CreditsSide.Right) ? (FULL_WIDTH / 2) : 0;
+ var width = (side == CreditsSide.Center) ? FULL_WIDTH : (FULL_WIDTH / 2);
+ var size = header ? CREDITS_HEADER_FONT_SIZE : CREDITS_FONT_SIZE;
+
+ var creditsLine:FlxText = new FlxText(xPos, yPos, width, text);
+ creditsLine.setFormat(CREDITS_FONT, size, CREDITS_FONT_COLOR, FlxTextAlign.CENTER, FlxTextBorderStyle.OUTLINE, CREDITS_FONT_STROKE_COLOR, true);
+
+ return creditsLine;
+ }
+
+ public override function update(elapsed:Float):Void
+ {
+ super.update(elapsed);
+
+ if (!scrollPaused)
+ {
+ // TODO: Replace with whatever the special note button is.
+ if (controls.ACCEPT || FlxG.keys.pressed.SPACE)
+ {
+ // Move the whole group.
+ creditsGroup.y -= CREDITS_SCROLL_FAST_SPEED * elapsed;
+ }
+ else
+ {
+ // Move the whole group.
+ creditsGroup.y -= CREDITS_SCROLL_BASE_SPEED * elapsed;
+ }
+ }
+
+ if (controls.BACK || hasEnded())
+ {
+ exit();
+ }
+ else if (controls.PAUSE)
+ {
+ scrollPaused = !scrollPaused;
+ }
+ }
+
+ function hasEnded():Bool
+ {
+ return creditsGroup.y < -creditsGroup.height;
+ }
+
+ function exit():Void
+ {
+ FlxG.switchState(new funkin.ui.mainmenu.MainMenuState());
+ }
+
+ public override function destroy():Void
+ {
+ super.destroy();
+ }
+}
+
+enum CreditsSide
+{
+ Left;
+ Center;
+ Right;
+}
diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx
index 888398f34..dba1a7e55 100644
--- a/source/funkin/ui/debug/charting/ChartEditorState.hx
+++ b/source/funkin/ui/debug/charting/ChartEditorState.hx
@@ -6,15 +6,14 @@ import flixel.addons.transition.FlxTransitionableState;
import flixel.FlxCamera;
import flixel.FlxSprite;
import flixel.FlxSubState;
-import flixel.graphics.FlxGraphic;
import flixel.group.FlxGroup.FlxTypedGroup;
-import funkin.graphics.FunkinCamera;
import flixel.group.FlxSpriteGroup;
import flixel.input.keyboard.FlxKey;
import flixel.input.mouse.FlxMouseEvent;
import flixel.math.FlxMath;
import flixel.math.FlxPoint;
import flixel.math.FlxRect;
+import flixel.sound.FlxSound;
import flixel.system.debug.log.LogStyle;
import flixel.system.FlxAssets.FlxSoundAsset;
import flixel.text.FlxText;
@@ -26,26 +25,19 @@ import flixel.util.FlxSort;
import flixel.util.FlxTimer;
import funkin.audio.FunkinSound;
import funkin.audio.visualize.PolygonSpectogram;
-import funkin.audio.visualize.PolygonSpectogram;
import funkin.audio.VoicesGroup;
import funkin.audio.waveform.WaveformSprite;
import funkin.data.notestyle.NoteStyleRegistry;
import funkin.data.song.SongData.SongCharacterData;
-import funkin.data.song.SongData.SongCharacterData;
-import funkin.data.song.SongData.SongChartData;
import funkin.data.song.SongData.SongChartData;
import funkin.data.song.SongData.SongEventData;
-import funkin.data.song.SongData.SongEventData;
import funkin.data.song.SongData.SongMetadata;
-import funkin.data.song.SongData.SongMetadata;
-import funkin.data.song.SongData.SongNoteData;
import funkin.data.song.SongData.SongNoteData;
import funkin.data.song.SongData.SongOffsets;
import funkin.data.song.SongDataUtils;
-import funkin.data.song.SongDataUtils;
-import funkin.data.song.SongRegistry;
import funkin.data.song.SongRegistry;
import funkin.data.stage.StageData;
+import funkin.graphics.FunkinCamera;
import funkin.graphics.FunkinSprite;
import funkin.input.Cursor;
import funkin.input.TurboKeyHandler;
@@ -56,13 +48,12 @@ import funkin.play.character.CharacterData.CharacterDataParser;
import funkin.play.components.HealthIcon;
import funkin.play.notes.NoteSprite;
import funkin.play.PlayState;
+import funkin.play.PlayStatePlaylist;
import funkin.play.song.Song;
import funkin.save.Save;
import funkin.ui.debug.charting.commands.AddEventsCommand;
import funkin.ui.debug.charting.commands.AddNotesCommand;
import funkin.ui.debug.charting.commands.ChartEditorCommand;
-import funkin.ui.debug.charting.commands.ChartEditorCommand;
-import funkin.ui.debug.charting.commands.ChartEditorCommand;
import funkin.ui.debug.charting.commands.CopyItemsCommand;
import funkin.ui.debug.charting.commands.CutItemsCommand;
import funkin.ui.debug.charting.commands.DeselectAllItemsCommand;
@@ -95,6 +86,7 @@ import funkin.ui.debug.charting.toolboxes.ChartEditorOffsetsToolbox;
import funkin.ui.haxeui.components.CharacterPlayer;
import funkin.ui.haxeui.HaxeUIState;
import funkin.ui.mainmenu.MainMenuState;
+import funkin.ui.transition.LoadingState;
import funkin.util.Constants;
import funkin.util.FileUtil;
import funkin.util.logging.CrashHandler;
@@ -119,7 +111,6 @@ import haxe.ui.containers.Grid;
import haxe.ui.containers.HBox;
import haxe.ui.containers.menus.Menu;
import haxe.ui.containers.menus.MenuBar;
-import haxe.ui.containers.menus.MenuBar;
import haxe.ui.containers.menus.MenuCheckBox;
import haxe.ui.containers.menus.MenuItem;
import haxe.ui.containers.ScrollView;
@@ -130,7 +121,6 @@ import haxe.ui.core.Screen;
import haxe.ui.events.DragEvent;
import haxe.ui.events.MouseEvent;
import haxe.ui.events.UIEvent;
-import haxe.ui.events.UIEvent;
import haxe.ui.focus.FocusManager;
import haxe.ui.Toolkit;
import openfl.display.BitmapData;
@@ -5338,30 +5328,31 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
}
catch (e)
{
- this.error("Could Not Playtest", 'Got an error trying to playtest the song.\n${e}');
+ this.error('Could Not Playtest', 'Got an error trying to playtest the song.\n${e}');
return;
}
- // TODO: Rework asset system so we can remove this.
+ // TODO: Rework asset system so we can remove this jank.
switch (currentSongStage)
{
case 'mainStage':
- Paths.setCurrentLevel('week1');
+ PlayStatePlaylist.campaignId = 'week1';
case 'spookyMansion':
- Paths.setCurrentLevel('week2');
+ PlayStatePlaylist.campaignId = 'week2';
case 'phillyTrain':
- Paths.setCurrentLevel('week3');
+ PlayStatePlaylist.campaignId = 'week3';
case 'limoRide':
- Paths.setCurrentLevel('week4');
+ PlayStatePlaylist.campaignId = 'week4';
case 'mallXmas' | 'mallEvil':
- Paths.setCurrentLevel('week5');
+ PlayStatePlaylist.campaignId = 'week5';
case 'school' | 'schoolEvil':
- Paths.setCurrentLevel('week6');
+ PlayStatePlaylist.campaignId = 'week6';
case 'tankmanBattlefield':
- Paths.setCurrentLevel('week7');
+ PlayStatePlaylist.campaignId = 'week7';
case 'phillyStreets' | 'phillyBlazin' | 'phillyBlazin2':
- Paths.setCurrentLevel('weekend1');
+ PlayStatePlaylist.campaignId = 'weekend1';
}
+ Paths.setCurrentLevel(PlayStatePlaylist.campaignId);
subStateClosed.add(reviveUICamera);
subStateClosed.add(resetConductorAfterTest);
@@ -5369,7 +5360,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
FlxTransitionableState.skipNextTransIn = false;
FlxTransitionableState.skipNextTransOut = false;
- var targetState = new PlayState(
+ var targetStateParams =
{
targetSong: targetSong,
targetDifficulty: selectedDifficulty,
@@ -5380,14 +5371,13 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
startTimestamp: startTimestamp,
playbackRate: playbackRate,
overrideMusic: true,
- });
+ };
// Override music.
if (audioInstTrack != null)
{
FlxG.sound.music = audioInstTrack;
}
- targetState.vocals = audioVocalTrackGroup;
// Kill and replace the UI camera so it doesn't get destroyed during the state transition.
uiCamera.kill();
@@ -5397,7 +5387,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState
this.persistentUpdate = false;
this.persistentDraw = false;
stopWelcomeMusic();
- openSubState(targetState);
+
+ LoadingState.loadPlayState(targetStateParams, false, true, function(targetState) {
+ targetState.vocals = audioVocalTrackGroup;
+ });
}
/**
diff --git a/source/funkin/ui/debug/latency/LatencyState.hx b/source/funkin/ui/debug/latency/LatencyState.hx
index 7b2eabb1c..620f0edd7 100644
--- a/source/funkin/ui/debug/latency/LatencyState.hx
+++ b/source/funkin/ui/debug/latency/LatencyState.hx
@@ -171,10 +171,7 @@ class LatencyState extends MusicBeatSubState
trace(FlxG.sound.music._channel.position);
*/
- #if FLX_DEBUG
- funnyStatsGraph.update(FlxG.sound.music.time % 500);
- realStats.update(swagSong.getTimeWithDiff() % 500);
- #end
+ // localConductor.update(swagSong.time, false);
if (FlxG.keys.justPressed.S)
{
diff --git a/source/funkin/ui/freeplay/Album.hx b/source/funkin/ui/freeplay/Album.hx
index 7291c7357..3060d3eb8 100644
--- a/source/funkin/ui/freeplay/Album.hx
+++ b/source/funkin/ui/freeplay/Album.hx
@@ -1,6 +1,7 @@
package funkin.ui.freeplay;
import funkin.data.freeplay.AlbumData;
+import funkin.data.animation.AnimationData;
import funkin.data.freeplay.AlbumRegistry;
import funkin.data.IRegistryEntry;
import flixel.graphics.FlxGraphic;
@@ -75,6 +76,16 @@ class Album implements IRegistryEntry
return _data.albumTitleAsset;
}
+ public function hasAlbumTitleAnimations()
+ {
+ return _data.albumTitleAnimations.length > 0;
+ }
+
+ public function getAlbumTitleAnimations():Array
+ {
+ return _data.albumTitleAnimations;
+ }
+
public function toString():String
{
return 'Album($id)';
diff --git a/source/funkin/ui/freeplay/AlbumRoll.hx b/source/funkin/ui/freeplay/AlbumRoll.hx
index a1e63c9a1..bde946e79 100644
--- a/source/funkin/ui/freeplay/AlbumRoll.hx
+++ b/source/funkin/ui/freeplay/AlbumRoll.hx
@@ -7,6 +7,7 @@ import flixel.tweens.FlxTween;
import flixel.util.FlxTimer;
import flixel.tweens.FlxEase;
import funkin.data.freeplay.AlbumRegistry;
+import funkin.util.assets.FlxAnimationUtil;
import funkin.graphics.FunkinSprite;
import funkin.util.SortUtil;
import openfl.utils.Assets;
@@ -21,9 +22,9 @@ class AlbumRoll extends FlxSpriteGroup
* The ID of the album to display.
* Modify this value to automatically update the album art and title.
*/
- public var albumId(default, set):String;
+ public var albumId(default, set):Null;
- function set_albumId(value:String):String
+ function set_albumId(value:Null):Null
{
if (this.albumId != value)
{
@@ -65,6 +66,17 @@ class AlbumRoll extends FlxSpriteGroup
*/
function updateAlbum():Void
{
+ if (albumId == null)
+ {
+ albumArt.visible = false;
+ albumTitle.visible = false;
+ if (titleTimer != null)
+ {
+ titleTimer.cancel();
+ titleTimer = null;
+ }
+ }
+
albumData = AlbumRegistry.instance.fetchEntry(albumId);
if (albumData == null)
@@ -94,7 +106,15 @@ class AlbumRoll extends FlxSpriteGroup
if (Assets.exists(Paths.image(albumData.getAlbumTitleAssetKey())))
{
- albumTitle.loadGraphic(Paths.image(albumData.getAlbumTitleAssetKey()));
+ if (albumData.hasAlbumTitleAnimations())
+ {
+ albumTitle.loadSparrow(albumData.getAlbumTitleAssetKey());
+ FlxAnimationUtil.addAtlasAnimations(albumTitle, albumData.getAlbumTitleAnimations());
+ }
+ else
+ {
+ albumTitle.loadGraphic(Paths.image(albumData.getAlbumTitleAssetKey()));
+ }
}
else
{
@@ -155,6 +175,8 @@ class AlbumRoll extends FlxSpriteGroup
});
}
+ var titleTimer:Null = null;
+
/**
* Play the intro animation on the album art.
*/
@@ -164,7 +186,14 @@ class AlbumRoll extends FlxSpriteGroup
FlxTween.tween(albumArt, {x: 950, y: 320, angle: -340}, 0.5, {ease: FlxEase.elasticOut});
albumTitle.visible = false;
- new FlxTimer().start(0.75, function(_) {
+
+ if (titleTimer != null)
+ {
+ titleTimer.cancel();
+ titleTimer = null;
+ }
+
+ titleTimer = new FlxTimer().start(0.75, function(_) {
showTitle();
});
}
@@ -179,6 +208,8 @@ class AlbumRoll extends FlxSpriteGroup
public function showTitle():Void
{
albumTitle.visible = true;
+ albumTitle.animation.play('active');
+ albumTitle.animation.finishCallback = (_) -> albumTitle.animation.play('idle');
}
/**
diff --git a/source/funkin/ui/freeplay/DJBoyfriend.hx b/source/funkin/ui/freeplay/DJBoyfriend.hx
index 33f264301..5f1144fab 100644
--- a/source/funkin/ui/freeplay/DJBoyfriend.hx
+++ b/source/funkin/ui/freeplay/DJBoyfriend.hx
@@ -27,8 +27,8 @@ class DJBoyfriend extends FlxAtlasSprite
var gotSpooked:Bool = false;
- static final SPOOK_PERIOD:Float = 10.0;
- static final TV_PERIOD:Float = 10.0;
+ static final SPOOK_PERIOD:Float = 120.0;
+ static final TV_PERIOD:Float = 180.0;
// Time since dad last SPOOKED you.
var timeSinceSpook:Float = 0;
@@ -43,7 +43,14 @@ class DJBoyfriend extends FlxAtlasSprite
switch (name)
{
case "Boyfriend DJ watchin tv OG":
- if (number == 85) runTvLogic();
+ if (number == 80)
+ {
+ FunkinSound.playOnce(Paths.sound('remote_click'));
+ }
+ if (number == 85)
+ {
+ runTvLogic();
+ }
default:
}
};
@@ -219,19 +226,17 @@ class DJBoyfriend extends FlxAtlasSprite
if (cartoonSnd == null)
{
// tv is OFF, but getting turned on
- // Eric got FUCKING TROLLED there is no `tv_on` or `channel_switch` sound!
- // FunkinSound.playOnce(Paths.sound('tv_on'), 1.0, function() {
- // });
- loadCartoon();
+ FunkinSound.playOnce(Paths.sound('tv_on'), 1.0, function() {
+ loadCartoon();
+ });
}
else
{
// plays it smidge after the click
- // new FlxTimer().start(0.1, function(_) {
- // // FunkinSound.playOnce(Paths.sound('channel_switch'));
- // });
- cartoonSnd.destroy();
- loadCartoon();
+ FunkinSound.playOnce(Paths.sound('channel_switch'), 1.0, function() {
+ cartoonSnd.destroy();
+ loadCartoon();
+ });
}
// loadCartoon();
diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx
index 6cb0d1d9a..66c829e11 100644
--- a/source/funkin/ui/freeplay/FreeplayState.hx
+++ b/source/funkin/ui/freeplay/FreeplayState.hx
@@ -133,8 +133,8 @@ class FreeplayState extends MusicBeatSubState
var stickerSubState:StickerSubState;
- static var rememberedDifficulty:Null = Constants.DEFAULT_DIFFICULTY;
- static var rememberedSongId:Null = null;
+ public static var rememberedDifficulty:Null = Constants.DEFAULT_DIFFICULTY;
+ public static var rememberedSongId:Null = 'tutorial';
public function new(?params:FreeplayStateParams, ?stickers:StickerSubState)
{
@@ -145,7 +145,7 @@ class FreeplayState extends MusicBeatSubState
stickerSubState = stickers;
}
- super();
+ super(FlxColor.TRANSPARENT);
}
override function create():Void
@@ -380,7 +380,7 @@ class FreeplayState extends MusicBeatSubState
}
albumRoll = new AlbumRoll();
- albumRoll.albumId = 'volume1';
+ albumRoll.albumId = null;
add(albumRoll);
albumRoll.applyExitMovers(exitMovers);
@@ -536,21 +536,18 @@ class FreeplayState extends MusicBeatSubState
});
}
+ var currentFilter:SongFilter = null;
+ var currentFilteredSongs:Array = [];
+
/**
* Given the current filter, rebuild the current song list.
*
* @param filterStuff A filter to apply to the song list (regex, startswith, all, favorite)
* @param force
+ * @param onlyIfChanged Only apply the filter if the song list has changed
*/
- public function generateSongList(?filterStuff:SongFilter, force:Bool = false):Void
+ public function generateSongList(filterStuff:Null, force:Bool = false, onlyIfChanged:Bool = true):Void
{
- curSelected = 1;
-
- for (cap in grpCapsules.members)
- {
- cap.kill();
- }
-
var tempSongs:Array = songs;
if (filterStuff != null)
@@ -582,6 +579,35 @@ class FreeplayState extends MusicBeatSubState
}
}
+ // Filter further by current selected difficulty.
+ if (currentDifficulty != null)
+ {
+ tempSongs = tempSongs.filter(song -> {
+ if (song == null) return true; // Random
+ return song.songDifficulties.contains(currentDifficulty);
+ });
+ }
+
+ if (onlyIfChanged)
+ {
+ // == performs equality by reference
+ if (tempSongs.isEqualUnordered(currentFilteredSongs)) return;
+ }
+
+ // Only now do we know that the filter is actually changing.
+
+ rememberedSongId = grpCapsules.members[curSelected]?.songData?.songId ?? rememberedSongId;
+
+ for (cap in grpCapsules.members)
+ {
+ cap.kill();
+ }
+
+ currentFilter = filterStuff;
+
+ currentFilteredSongs = tempSongs;
+ curSelected = 0;
+
var hsvShader:HSVShader = new HSVShader();
var randomCapsule:SongMenuItem = grpCapsules.recycle(SongMenuItem);
@@ -658,11 +684,12 @@ class FreeplayState extends MusicBeatSubState
if (FlxG.keys.justPressed.F)
{
- if (songs[curSelected] != null)
+ var targetSong = grpCapsules.members[curSelected]?.songData;
+ if (targetSong != null)
{
var realShit:Int = curSelected;
- songs[curSelected].isFav = !songs[curSelected].isFav;
- if (songs[curSelected].isFav)
+ targetSong.isFav = !targetSong.isFav;
+ if (targetSong.isFav)
{
FlxTween.tween(grpCapsules.members[realShit], {angle: 360}, 0.4,
{
@@ -854,11 +881,13 @@ class FreeplayState extends MusicBeatSubState
{
dj.resetAFKTimer();
changeDiff(-1);
+ generateSongList(currentFilter, true);
}
if (controls.UI_RIGHT_P && !FlxG.keys.pressed.CONTROL)
{
dj.resetAFKTimer();
changeDiff(1);
+ generateSongList(currentFilter, true);
}
if (controls.BACK && !typing.hasFocus)
@@ -877,6 +906,8 @@ class FreeplayState extends MusicBeatSubState
for (spr in grpSpr)
{
+ if (spr == null) continue;
+
var funnyMoveShit:MoveData = moveData;
if (moveData.x == null) funnyMoveShit.x = spr.x;
@@ -899,7 +930,7 @@ class FreeplayState extends MusicBeatSubState
if (Type.getClass(FlxG.state) == MainMenuState)
{
- FlxG.state.persistentUpdate = true;
+ FlxG.state.persistentUpdate = false;
FlxG.state.persistentDraw = true;
}
@@ -908,6 +939,11 @@ class FreeplayState extends MusicBeatSubState
FlxTransitionableState.skipNextTransOut = true;
if (Type.getClass(FlxG.state) == MainMenuState)
{
+ FunkinSound.playMusic('freakyMenu',
+ {
+ overrideExisting: true,
+ restartTrack: false
+ });
close();
}
else
@@ -926,7 +962,7 @@ class FreeplayState extends MusicBeatSubState
public override function destroy():Void
{
super.destroy();
- var daSong:Null = songs[curSelected];
+ var daSong:Null = currentFilteredSongs[curSelected];
if (daSong != null)
{
clearDaCache(daSong.songName);
@@ -948,10 +984,10 @@ class FreeplayState extends MusicBeatSubState
currentDifficulty = diffIdsCurrent[currentDifficultyIndex];
- var daSong:Null = songs[curSelected];
+ var daSong:Null = grpCapsules.members[curSelected].songData;
if (daSong != null)
{
- var songScore:SaveScoreData = Save.instance.getSongScore(songs[curSelected].songId, currentDifficulty);
+ var songScore:SaveScoreData = Save.instance.getSongScore(grpCapsules.members[curSelected].songData.songId, currentDifficulty);
intendedScore = songScore?.score ?? 0;
intendedCompletion = songScore?.accuracy ?? 0.0;
rememberedDifficulty = currentDifficulty;
@@ -1015,7 +1051,7 @@ class FreeplayState extends MusicBeatSubState
albumRoll.setDifficultyStars(daSong?.songRating);
// Set the album graphic and play the animation if relevant.
- var newAlbumId:String = daSong?.albumId ?? Constants.DEFAULT_ALBUM_ID;
+ var newAlbumId:String = daSong?.albumId;
if (albumRoll.albumId != newAlbumId)
{
albumRoll.albumId = newAlbumId;
@@ -1103,6 +1139,12 @@ class FreeplayState extends MusicBeatSubState
targetVariation: targetVariation,
practiceMode: false,
minimalMode: false,
+
+ #if (debug || FORCE_DEBUG_VERSION)
+ botPlayMode: FlxG.keys.pressed.SHIFT,
+ #else
+ botPlayMode: false,
+ #end
// TODO: Make these an option! It's currently only accessible via chart editor.
// startTimestamp: 0.0,
// playbackRate: 0.5,
@@ -1115,10 +1157,12 @@ class FreeplayState extends MusicBeatSubState
{
if (rememberedSongId != null)
{
- curSelected = songs.findIndex(function(song) {
+ curSelected = currentFilteredSongs.findIndex(function(song) {
if (song == null) return false;
return song.songId == rememberedSongId;
});
+
+ if (curSelected == -1) curSelected = 0;
}
if (rememberedDifficulty != null)
@@ -1127,7 +1171,7 @@ class FreeplayState extends MusicBeatSubState
}
// Set the difficulty star count on the right.
- var daSong:Null = songs[curSelected];
+ var daSong:Null = grpCapsules.members[curSelected]?.songData;
albumRoll.setDifficultyStars(daSong?.songRating ?? 0);
}
@@ -1156,8 +1200,10 @@ class FreeplayState extends MusicBeatSubState
{
intendedScore = 0;
intendedCompletion = 0.0;
+ diffIdsCurrent = diffIdsTotal;
rememberedSongId = null;
rememberedDifficulty = null;
+ albumRoll.albumId = null;
}
for (index => capsule in grpCapsules.members)
@@ -1195,12 +1241,33 @@ class FreeplayState extends MusicBeatSubState
});
if (didReplace)
{
+ FunkinSound.playMusic('freakyMenu',
+ {
+ startingVolume: 0.0,
+ overrideExisting: true,
+ restartTrack: false
+ });
FlxG.sound.music.fadeIn(2, 0, 0.8);
}
}
grpCapsules.members[curSelected].selected = true;
}
}
+
+ /**
+ * Build an instance of `FreeplayState` that is above the `MainMenuState`.
+ * @return The MainMenuState with the FreeplayState as a substate.
+ */
+ public static function build(?params:FreeplayStateParams, ?stickers:StickerSubState):MusicBeatState
+ {
+ var result = new MainMenuState();
+ result.persistentUpdate = false;
+ result.persistentDraw = true;
+
+ result.openSubState(new FreeplayState(params, stickers));
+
+ return result;
+ }
}
/**
@@ -1307,7 +1374,7 @@ class FreeplaySongData
public var songName(default, null):String = '';
public var songCharacter(default, null):String = '';
public var songRating(default, null):Int = 0;
- public var albumId(default, null):String = '';
+ public var albumId(default, null):Null = null;
public var currentDifficulty(default, set):String = Constants.DEFAULT_DIFFICULTY;
public var displayedVariations(default, null):Array = [Constants.DEFAULT_VARIATION];
@@ -1341,7 +1408,15 @@ class FreeplaySongData
this.songName = songDifficulty.songName;
this.songCharacter = songDifficulty.characters.opponent;
this.songRating = songDifficulty.difficultyRating;
- this.albumId = songDifficulty.album;
+ if (songDifficulty.album == null)
+ {
+ FlxG.log.warn('No album for: ${songDifficulty.songName}');
+ this.albumId = Constants.DEFAULT_ALBUM_ID;
+ }
+ else
+ {
+ this.albumId = songDifficulty.album;
+ }
}
}
diff --git a/source/funkin/ui/mainmenu/MainMenuState.hx b/source/funkin/ui/mainmenu/MainMenuState.hx
index a8c2039ab..f38db1ccd 100644
--- a/source/funkin/ui/mainmenu/MainMenuState.hx
+++ b/source/funkin/ui/mainmenu/MainMenuState.hx
@@ -51,12 +51,10 @@ class MainMenuState extends MusicBeatState
transIn = FlxTransitionableState.defaultTransIn;
transOut = FlxTransitionableState.defaultTransOut;
- if (!(FlxG?.sound?.music?.playing ?? false))
- {
- playMenuMusic();
- }
+ playMenuMusic();
- persistentUpdate = persistentDraw = true;
+ persistentUpdate = false;
+ persistentDraw = true;
var bg:FlxSprite = new FlxSprite(Paths.image('menuBG'));
bg.scrollFactor.x = 0;
@@ -109,14 +107,21 @@ class MainMenuState extends MusicBeatState
});
#if CAN_OPEN_LINKS
+ // In order to prevent popup blockers from triggering,
+ // we need to open the link as an immediate result of a keypress event,
+ // so we can't wait for the flicker animation to complete.
var hasPopupBlocker = #if web true #else false #end;
- createMenuItem('donate', 'mainmenu/donate', selectDonate, hasPopupBlocker);
+ createMenuItem('merch', 'mainmenu/merch', selectMerch, hasPopupBlocker);
#end
createMenuItem('options', 'mainmenu/options', function() {
startExitState(() -> new funkin.ui.options.OptionsState());
});
+ createMenuItem('credits', 'mainmenu/credits', function() {
+ startExitState(() -> new funkin.ui.credits.CreditsState());
+ });
+
// Reset position of menu items.
var spacing = 160;
var top = (FlxG.height - (spacing * (menuItems.length - 1))) / 2;
@@ -125,6 +130,9 @@ class MainMenuState extends MusicBeatState
var menuItem = menuItems.members[i];
menuItem.x = FlxG.width / 2;
menuItem.y = top + spacing * i;
+ menuItem.scrollFactor.x = 0.0;
+ // This one affects how much the menu items move when you scroll between them.
+ menuItem.scrollFactor.y = 0.4;
}
resetCamStuff();
@@ -212,6 +220,11 @@ class MainMenuState extends MusicBeatState
{
WindowUtil.openURL(Constants.URL_ITCH);
}
+
+ function selectMerch()
+ {
+ WindowUtil.openURL(Constants.URL_MERCH);
+ }
#end
#if newgrounds
@@ -311,8 +324,6 @@ class MainMenuState extends MusicBeatState
// Open the debug menu, defaults to ` / ~
if (controls.DEBUG_MENU)
{
- this.persistentUpdate = false;
- this.persistentDraw = false;
FlxG.state.openSubState(new DebugMenuSubState());
}
diff --git a/source/funkin/ui/title/AttractState.hx b/source/funkin/ui/title/AttractState.hx
index 0af97afd9..a42a6c3d9 100644
--- a/source/funkin/ui/title/AttractState.hx
+++ b/source/funkin/ui/title/AttractState.hx
@@ -17,7 +17,7 @@ import funkin.ui.MusicBeatState;
*/
class AttractState extends MusicBeatState
{
- static final ATTRACT_VIDEO_PATH:String = Paths.videos('kickstarterTrailer');
+ static final ATTRACT_VIDEO_PATH:String = Paths.stripLibrary(Paths.videos('kickstarterTrailer', 'shared'));
public override function create():Void
{
@@ -29,10 +29,12 @@ class AttractState extends MusicBeatState
}
#if html5
+ trace('Playing web video ${ATTRACT_VIDEO_PATH}');
playVideoHTML5(ATTRACT_VIDEO_PATH);
#end
#if hxCodec
+ trace('Playing native video ${ATTRACT_VIDEO_PATH}');
playVideoNative(ATTRACT_VIDEO_PATH);
#end
}
diff --git a/source/funkin/ui/title/TitleState.hx b/source/funkin/ui/title/TitleState.hx
index 1a4e13ab1..e76e66003 100644
--- a/source/funkin/ui/title/TitleState.hx
+++ b/source/funkin/ui/title/TitleState.hx
@@ -220,7 +220,7 @@ class TitleState extends MusicBeatState
function playMenuMusic():Void
{
- var shouldFadeIn = (FlxG.sound.music == null);
+ var shouldFadeIn:Bool = (FlxG.sound.music == null);
// Load music. Includes logic to handle BPM changes.
FunkinSound.playMusic('freakyMenu',
{
@@ -229,7 +229,7 @@ class TitleState extends MusicBeatState
restartTrack: true
});
// Fade from 0.0 to 0.7 over 4 seconds
- if (shouldFadeIn) FlxG.sound.music.fadeIn(4.0, 0.0, 0.7);
+ if (shouldFadeIn) FlxG.sound.music.fadeIn(4.0, 0.0, 1.0);
}
function getIntroTextShit():Array>
@@ -290,18 +290,6 @@ class TitleState extends MusicBeatState
// do controls.PAUSE | controls.ACCEPT instead?
var pressedEnter:Bool = FlxG.keys.justPressed.ENTER;
- if (FlxG.onMobile)
- {
- for (touch in FlxG.touches.list)
- {
- if (touch.justPressed)
- {
- FlxG.switchState(() -> new FreeplayState());
- pressedEnter = true;
- }
- }
- }
-
var gamepad:FlxGamepad = FlxG.gamepads.lastActive;
if (gamepad != null)
diff --git a/source/funkin/ui/transition/LoadingState.hx b/source/funkin/ui/transition/LoadingState.hx
index 980c264e3..af8798ae2 100644
--- a/source/funkin/ui/transition/LoadingState.hx
+++ b/source/funkin/ui/transition/LoadingState.hx
@@ -22,10 +22,12 @@ import openfl.filters.ShaderFilter;
import openfl.utils.Assets;
import flixel.util.typeLimit.NextState;
-class LoadingState extends MusicBeatState
+class LoadingState extends MusicBeatSubState
{
inline static var MIN_TIME = 1.0;
+ var asSubState:Bool = false;
+
var target:NextState;
var playParams:Null;
var stopMusic:Bool = false;
@@ -178,7 +180,16 @@ class LoadingState extends MusicBeatState
FlxG.sound.music = null;
}
- FlxG.switchState(target);
+ if (asSubState)
+ {
+ this.close();
+ // We will assume the target is a valid substate.
+ FlxG.state.openSubState(cast target);
+ }
+ else
+ {
+ FlxG.switchState(target);
+ }
}
static function getSongPath():String
@@ -190,17 +201,41 @@ class LoadingState extends MusicBeatState
* Starts the transition to a new `PlayState` to start a new song.
* First switches to the `LoadingState` if assets need to be loaded.
* @param params The parameters for the next `PlayState`.
+ * @param asSubState Whether to open as a substate rather than switching to the `PlayState`.
* @param shouldStopMusic Whether to stop the current music while loading.
*/
- public static function loadPlayState(params:PlayStateParams, shouldStopMusic = false):Void
+ public static function loadPlayState(params:PlayStateParams, shouldStopMusic = false, asSubState = false, ?onConstruct:PlayState->Void):Void
{
Paths.setCurrentLevel(PlayStatePlaylist.campaignId);
- var playStateCtor:NextState = () -> new PlayState(params);
+ var playStateCtor:() -> PlayState = function() {
+ return new PlayState(params);
+ };
+
+ if (onConstruct != null)
+ {
+ playStateCtor = function() {
+ var result = new PlayState(params);
+ onConstruct(result);
+ return result;
+ };
+ }
#if NO_PRELOAD_ALL
// Switch to loading state while we load assets (default on HTML5 target).
- var loadStateCtor:NextState = () -> new LoadingState(playStateCtor, shouldStopMusic, params);
- FlxG.switchState(loadStateCtor);
+ var loadStateCtor = function() {
+ var result = new LoadingState(playStateCtor, shouldStopMusic, params);
+ @:privateAccess
+ result.asSubState = asSubState;
+ return result;
+ }
+ if (asSubState)
+ {
+ FlxG.state.openSubState(cast loadStateCtor());
+ }
+ else
+ {
+ FlxG.switchState(loadStateCtor);
+ }
#else
// All assets preloaded, switch directly to play state (defualt on other targets).
if (shouldStopMusic && FlxG.sound.music != null)
@@ -210,11 +245,40 @@ class LoadingState extends MusicBeatState
}
// Load and cache the song's charts.
- if (params?.targetSong != null)
+ // Don't do this if we already provided the music and charts.
+ if (params?.targetSong != null && !params.overrideMusic)
{
params.targetSong.cacheCharts(true);
}
+ var shouldPreloadLevelAssets:Bool = !(params?.minimalMode ?? false);
+
+ if (shouldPreloadLevelAssets) preloadLevelAssets();
+
+ if (asSubState)
+ {
+ FlxG.state.openSubState(cast playStateCtor());
+ }
+ else
+ {
+ FlxG.switchState(playStateCtor);
+ }
+ #end
+ }
+
+ #if NO_PRELOAD_ALL
+ static function isSoundLoaded(path:String):Bool
+ {
+ return Assets.cache.hasSound(path);
+ }
+
+ static function isLibraryLoaded(library:String):Bool
+ {
+ return Assets.getLibrary(library) != null;
+ }
+ #else
+ static function preloadLevelAssets():Void
+ {
// TODO: This section is a hack! Redo this later when we have a proper asset caching system.
FunkinSprite.preparePurgeCache();
FunkinSprite.cacheTexture(Paths.image('combo'));
@@ -247,7 +311,10 @@ class LoadingState extends MusicBeatState
// List all image assets in the level's library.
// This is crude and I want to remove it when we have a proper asset caching system.
// TODO: Get rid of this junk!
- var library = openfl.utils.Assets.getLibrary(PlayStatePlaylist.campaignId);
+ var library = PlayStatePlaylist.campaignId != null ? openfl.utils.Assets.getLibrary(PlayStatePlaylist.campaignId) : null;
+
+ if (library == null) return; // We don't need to do anymore precaching.
+
var assets = library.list(lime.utils.AssetType.IMAGE);
trace('Got ${assets.length} assets: ${assets}');
@@ -278,20 +345,6 @@ class LoadingState extends MusicBeatState
// FunkinSprite.cacheAllSongTextures(stage)
FunkinSprite.purgeCache();
-
- FlxG.switchState(playStateCtor);
- #end
- }
-
- #if NO_PRELOAD_ALL
- static function isSoundLoaded(path:String):Bool
- {
- return Assets.cache.hasSound(path);
- }
-
- static function isLibraryLoaded(library:String):Bool
- {
- return Assets.getLibrary(library) != null;
}
#end
diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx
index 33525a7e2..e04af0097 100644
--- a/source/funkin/util/Constants.hx
+++ b/source/funkin/util/Constants.hx
@@ -60,6 +60,11 @@ class Constants
*/
// ==============================
+ /**
+ * Link to buy merch for the game.
+ */
+ public static final URL_MERCH:String = 'https://needlejuicerecords.com/pages/friday-night-funkin';
+
/**
* Preloader sitelock.
* Matching is done by `FlxStringUtil.getDomain`, so any URL on the domain will work.
@@ -181,6 +186,12 @@ class Constants
*/
public static final DEFAULT_DIFFICULTY_LIST:Array = ['easy', 'normal', 'hard'];
+ /**
+ * List of all difficulties used by the base game.
+ * Includes Erect and Nightmare.
+ */
+ public static final DEFAULT_DIFFICULTY_LIST_FULL:Array = ['easy', 'normal', 'hard', 'erect', 'nightmare'];
+
/**
* Default player character for charts.
*/
@@ -514,4 +525,10 @@ class Constants
* The vertical offset of the strumline from the top edge of the screen.
*/
public static final STRUMLINE_Y_OFFSET:Float = 24;
+
+ /**
+ * The rate at which the camera lerps to its target.
+ * 0.04 = 4% of distance per frame.
+ */
+ public static final DEFAULT_CAMERA_FOLLOW_RATE:Float = 0.04;
}
diff --git a/source/funkin/util/SerializerUtil.hx b/source/funkin/util/SerializerUtil.hx
index c87d3f6c0..fa602cc73 100644
--- a/source/funkin/util/SerializerUtil.hx
+++ b/source/funkin/util/SerializerUtil.hx
@@ -63,6 +63,31 @@ class SerializerUtil
}
}
+ public static function initSerializer():Void
+ {
+ haxe.Unserializer.DEFAULT_RESOLVER = new FunkinTypeResolver();
+ }
+
+ /**
+ * Serialize a Haxe object using the built-in Serializer.
+ * @param input The object to serialize
+ * @return The serialized object as a string
+ */
+ public static function fromHaxeObject(input:Dynamic):String
+ {
+ return haxe.Serializer.run(input);
+ }
+
+ /**
+ * Convert a serialized Haxe object back into a Haxe object.
+ * @param input The serialized object as a string
+ * @return The deserialized object
+ */
+ public static function toHaxeObject(input:String):Dynamic
+ {
+ return haxe.Unserializer.run(input);
+ }
+
/**
* Customize how certain types are serialized when converting to JSON.
*/
@@ -90,3 +115,26 @@ class SerializerUtil
return result;
}
}
+
+class FunkinTypeResolver
+{
+ public function new()
+ {
+ // Blank constructor.
+ }
+
+ public function resolveClass(name:String):Class
+ {
+ if (name == 'Dynamic')
+ {
+ FlxG.log.warn('Found invalid class type in save data, indicates partial save corruption.');
+ return null;
+ }
+ return Type.resolveClass(name);
+ };
+
+ public function resolveEnum(name:String):Enum
+ {
+ return Type.resolveEnum(name);
+ };
+}
diff --git a/source/funkin/util/StructureUtil.hx b/source/funkin/util/StructureUtil.hx
new file mode 100644
index 000000000..f94de4652
--- /dev/null
+++ b/source/funkin/util/StructureUtil.hx
@@ -0,0 +1,125 @@
+package funkin.util;
+
+import funkin.util.tools.MapTools;
+import haxe.DynamicAccess;
+
+/**
+ * Utilities for working with anonymous structures.
+ */
+class StructureUtil
+{
+ /**
+ * Merge two structures, with the second overwriting the first.
+ * Performs a SHALLOW clone, where child structures are not merged.
+ * @param a The base structure.
+ * @param b The new structure.
+ * @return The merged structure.
+ */
+ public static function merge(a:Dynamic, b:Dynamic):Dynamic
+ {
+ var result:DynamicAccess = Reflect.copy(a);
+
+ for (field in Reflect.fields(b))
+ {
+ result.set(field, Reflect.field(b, field));
+ }
+
+ return result;
+ }
+
+ public static function toMap(a:Dynamic):haxe.ds.Map
+ {
+ var result:haxe.ds.Map = [];
+
+ for (field in Reflect.fields(a))
+ {
+ result.set(field, Reflect.field(a, field));
+ }
+
+ return result;
+ }
+
+ public static function isMap(a:Dynamic):Bool
+ {
+ return Std.isOfType(a, haxe.Constraints.IMap);
+ }
+
+ public static function isObject(a:Dynamic):Bool
+ {
+ switch (Type.typeof(a))
+ {
+ case TObject:
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ public static function isPrimitive(a:Dynamic):Bool
+ {
+ switch (Type.typeof(a))
+ {
+ case TInt | TFloat | TBool:
+ return true;
+ case TClass(c):
+ return false;
+ case TEnum(e):
+ return false;
+ case TObject:
+ return false;
+ case TFunction:
+ return false;
+ case TNull:
+ return true;
+ case TUnknown:
+ return false;
+ default:
+ return false;
+ }
+ }
+
+ /**
+ * Merge two structures, with the second overwriting the first.
+ * Performs a DEEP clone, where child structures are also merged recursively.
+ * @param a The base structure.
+ * @param b The new structure.
+ * @return The merged structure.
+ */
+ public static function deepMerge(a:Dynamic, b:Dynamic):Dynamic
+ {
+ if (a == null) return b;
+ if (b == null) return null;
+ if (isPrimitive(a) && isPrimitive(b)) return b;
+ if (isMap(b))
+ {
+ if (isMap(a))
+ {
+ return MapTools.merge(a, b);
+ }
+ else
+ {
+ return StructureUtil.toMap(a).merge(b);
+ }
+ }
+ if (!Reflect.isObject(a) || !Reflect.isObject(b)) return b;
+
+ var result:DynamicAccess = Reflect.copy(a);
+
+ for (field in Reflect.fields(b))
+ {
+ if (Reflect.isObject(b))
+ {
+ // Note that isObject also returns true for class instances,
+ // but we just assume that's not a problem here.
+ result.set(field, deepMerge(Reflect.field(result, field), Reflect.field(b, field)));
+ }
+ else
+ {
+ // If we're here, b[field] is a primitive.
+ result.set(field, Reflect.field(b, field));
+ }
+ }
+
+ return result;
+ }
+}
diff --git a/source/funkin/util/tools/MapTools.hx b/source/funkin/util/tools/MapTools.hx
index 1399fb791..b98cb0adf 100644
--- a/source/funkin/util/tools/MapTools.hx
+++ b/source/funkin/util/tools/MapTools.hx
@@ -33,6 +33,24 @@ class MapTools
return map.copy();
}
+ /**
+ * Create a new map which is a combination of the two given maps.
+ * @param a The base map.
+ * @param b The other map. The values from this take precedence.
+ * @return The combined map.
+ */
+ public static function merge(a:Map, b:Map):Map
+ {
+ var result = a.copy();
+
+ for (pair in b.keyValueIterator())
+ {
+ result.set(pair.key, pair.value);
+ }
+
+ return result;
+ }
+
/**
* Create a new array with clones of all elements of the given array, to prevent modifying the original.
*/