diff --git a/Project.xml b/Project.xml
index 393248698..972749939 100644
--- a/Project.xml
+++ b/Project.xml
@@ -130,6 +130,7 @@
+
@@ -144,6 +145,9 @@
+
+
+
diff --git a/hmm.json b/hmm.json
index b68cb4701..9061d594b 100644
--- a/hmm.json
+++ b/hmm.json
@@ -11,7 +11,7 @@
"name": "flixel",
"type": "git",
"dir": null,
- "ref": "d6100cc8",
+ "ref": "32cee07",
"url": "https://github.com/EliteMasterEric/flixel"
},
{
@@ -42,14 +42,14 @@
"name": "haxeui-core",
"type": "git",
"dir": null,
- "ref": "59157d2",
+ "ref": "08fbc9d",
"url": "https://github.com/haxeui/haxeui-core/"
},
{
"name": "haxeui-flixel",
"type": "git",
"dir": null,
- "ref": "d353389",
+ "ref": "999fadd",
"url": "https://github.com/haxeui/haxeui-flixel"
},
{
@@ -68,13 +68,13 @@
"name": "hxcodec",
"type": "git",
"dir": null,
- "ref": "d74c2aa",
+ "ref": "91adeec",
"url": "https://github.com/polybiusproxy/hxCodec"
},
{
"name": "hxcpp",
"type": "haxelib",
- "version": "4.2.1"
+ "version": "4.3.2"
},
{
"name": "hxcpp-debug-server",
@@ -84,13 +84,18 @@
{
"name": "hxp",
"type": "haxelib",
+ "version": "1.2.2"
+ },
+ {
+ "name": "json2object",
+ "type": "haxelib",
"version": null
},
{
"name": "lime",
"type": "git",
"dir": null,
- "ref": "afadf5f",
+ "ref": "deecd6c",
"url": "https://github.com/openfl/lime"
},
{
diff --git a/source/funkin/Conductor.hx b/source/funkin/Conductor.hx
index d1176fa03..a0493869b 100644
--- a/source/funkin/Conductor.hx
+++ b/source/funkin/Conductor.hx
@@ -207,9 +207,15 @@ class Conductor
}
// FlxSignals are really cool.
- if (currentStep != oldStep) stepHit.dispatch();
+ if (currentStep != oldStep)
+ {
+ stepHit.dispatch();
+ }
- if (currentBeat != oldBeat) beatHit.dispatch();
+ if (currentBeat != oldBeat)
+ {
+ beatHit.dispatch();
+ }
}
@:deprecated // Switch to TimeChanges instead.
diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index fea8899d2..45c2645df 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -78,6 +78,7 @@ class InitState extends FlxTransitionableState
}
});
+ #if FLX_DEBUG
FlxG.debugger.addButton(CENTER, new BitmapData(20, 20, true, 0xFF2222CC), function() {
FlxG.game.debugger.vcr.onStep();
@@ -90,6 +91,7 @@ class InitState extends FlxTransitionableState
FlxG.sound.music.pause();
FlxG.sound.music.time += FlxG.elapsed * 1000;
});
+ #end
FlxG.sound.muteKeys = [ZERO];
FlxG.game.focusLostFramerate = 60;
@@ -153,6 +155,7 @@ class InitState extends FlxTransitionableState
// TODO: Register custom event callbacks here
+ funkin.data.level.LevelRegistry.instance.loadEntries();
SongEventParser.loadEventCache();
SongDataParser.loadSongCache();
StageDataParser.loadStageCache();
diff --git a/source/funkin/LatencyState.hx b/source/funkin/LatencyState.hx
index bb0008ef9..ad803b963 100644
--- a/source/funkin/LatencyState.hx
+++ b/source/funkin/LatencyState.hx
@@ -31,7 +31,7 @@ class LatencyState extends MusicBeatSubstate
var offsetsPerBeat:Array = [];
var swagSong:HomemadeMusic;
- #if debug
+ #if FLX_DEBUG
var funnyStatsGraph:CoolStatsGraph;
var realStats:CoolStatsGraph;
#end
@@ -44,7 +44,7 @@ class LatencyState extends MusicBeatSubstate
FlxG.sound.music = swagSong;
FlxG.sound.music.play();
- #if debug
+ #if FLX_DEBUG
funnyStatsGraph = new CoolStatsGraph(0, Std.int(FlxG.height / 2), FlxG.width, Std.int(FlxG.height / 2), FlxColor.PINK, "time");
FlxG.addChildBelowMouse(funnyStatsGraph);
@@ -170,7 +170,7 @@ class LatencyState extends MusicBeatSubstate
trace(FlxG.sound.music._channel.position);
*/
- #if debug
+ #if FLX_DEBUG
funnyStatsGraph.update(FlxG.sound.music.time % 500);
realStats.update(swagSong.getTimeWithDiff() % 500);
#end
diff --git a/source/funkin/MainMenuState.hx b/source/funkin/MainMenuState.hx
index 85c91db93..e4bdfbe35 100644
--- a/source/funkin/MainMenuState.hx
+++ b/source/funkin/MainMenuState.hx
@@ -21,6 +21,7 @@ import funkin.shaderslmfao.ScreenWipeShader;
import funkin.ui.AtlasMenuList;
import funkin.ui.MenuList.MenuItem;
import funkin.ui.MenuList;
+import funkin.ui.story.StoryMenuState;
import funkin.ui.OptionsState;
import funkin.ui.PreferencesMenu;
import funkin.ui.Prompt;
diff --git a/source/funkin/Paths.hx b/source/funkin/Paths.hx
index 3a1c65285..60dcfad38 100644
--- a/source/funkin/Paths.hx
+++ b/source/funkin/Paths.hx
@@ -103,9 +103,9 @@ class Paths
return 'songs:assets/songs/${song.toLowerCase()}/Voices$suffix.$SOUND_EXT';
}
- inline static public function inst(song:String)
+ inline static public function inst(song:String, ?suffix:String)
{
- return 'songs:assets/songs/${song.toLowerCase()}/Inst.$SOUND_EXT';
+ return 'songs:assets/songs/${song.toLowerCase()}/Inst$suffix.$SOUND_EXT';
}
inline static public function image(key:String, ?library:String)
diff --git a/source/funkin/StoryMenuState.hx b/source/funkin/StoryMenuState.hx
index d9640d620..89d59de1f 100644
--- a/source/funkin/StoryMenuState.hx
+++ b/source/funkin/StoryMenuState.hx
@@ -122,10 +122,10 @@ class StoryMenuState extends MusicBeatState
persistentUpdate = persistentDraw = true;
- scoreText = new FlxText(10, 10, 0, "SCORE: 49324858", 36);
+ scoreText = new FlxText(10, 10, 0, "SCORE: 49324858");
scoreText.setFormat("VCR OSD Mono", 32);
- txtWeekTitle = new FlxText(FlxG.width * 0.7, 10, 0, "", 32);
+ txtWeekTitle = new FlxText(FlxG.width * 0.7, 10, 0, "");
txtWeekTitle.setFormat("VCR OSD Mono", 32, FlxColor.WHITE, RIGHT);
txtWeekTitle.alpha = 0.7;
diff --git a/source/funkin/data/BaseRegistry.hx b/source/funkin/data/BaseRegistry.hx
new file mode 100644
index 000000000..0864fddd9
--- /dev/null
+++ b/source/funkin/data/BaseRegistry.hx
@@ -0,0 +1,167 @@
+package funkin.data;
+
+import openfl.Assets;
+import funkin.util.assets.DataAssets;
+import haxe.Constraints.Constructible;
+
+/**
+ * The entry's constructor function must take a single argument, the entry's ID.
+ */
+typedef EntryConstructorFunction = String->Void;
+
+/**
+ * A base type for a Registry, which is an object which handles loading scriptable objects.
+ *
+ * @param T The type to construct. Must implement `IRegistryEntry`.
+ * @param J The type of the JSON data used when constructing.
+ */
+@:generic
+abstract class BaseRegistry & Constructible), J>
+{
+ public final registryId:String;
+
+ final dataFilePath:String;
+
+ final entries:Map;
+
+ // public abstract static final instance:BaseRegistry = new BaseRegistry<>();
+
+ /**
+ * @param registryId A readable ID for this registry, used when logging.
+ * @param dataFilePath The path (relative to `assets/data`) to search for JSON files.
+ */
+ public function new(registryId:String, dataFilePath:String)
+ {
+ this.registryId = registryId;
+ this.dataFilePath = dataFilePath;
+
+ this.entries = new Map();
+ }
+
+ public function loadEntries():Void
+ {
+ clearEntries();
+
+ //
+ // SCRIPTED ENTRIES
+ //
+ var scriptedEntryClassNames:Array = getScriptedClassNames();
+ log('Registering ${scriptedEntryClassNames.length} scripted entries...');
+
+ for (entryCls in scriptedEntryClassNames)
+ {
+ var entry:T = createScriptedEntry(entryCls);
+
+ if (entry != null)
+ {
+ log('Successfully created scripted entry (${entryCls} = ${entry.id})');
+ entries.set(entry.id, entry);
+ }
+ else
+ {
+ log('Failed to create scripted entry (${entryCls})');
+ }
+ }
+
+ //
+ // UNSCRIPTED ENTRIES
+ //
+ var entryIdList:Array = DataAssets.listDataFilesInPath('${dataFilePath}/');
+ var unscriptedEntryIds:Array = entryIdList.filter(function(entryId:String):Bool {
+ return !entries.exists(entryId);
+ });
+ log('Fetching data for ${unscriptedEntryIds.length} unscripted entries...');
+ for (entryId in unscriptedEntryIds)
+ {
+ try
+ {
+ var entry:T = createEntry(entryId);
+ if (entry != null)
+ {
+ trace(' Loaded entry data: ${entry}');
+ entries.set(entry.id, entry);
+ }
+ }
+ catch (e:Dynamic)
+ {
+ trace(' Failed to load entry data: ${entryId}');
+ trace(e);
+ continue;
+ }
+ }
+ }
+
+ public function listEntryIds():Array
+ {
+ return entries.keys().array();
+ }
+
+ public function countEntries():Int
+ {
+ return entries.size();
+ }
+
+ public function fetchEntry(id:String):Null
+ {
+ return entries.get(id);
+ }
+
+ public function toString():String
+ {
+ return 'Registry(' + registryId + ', ${countEntries()} entries)';
+ }
+
+ function log(message:String):Void
+ {
+ trace('[' + registryId + '] ' + message);
+ }
+
+ function loadEntryFile(id:String):String
+ {
+ var entryFilePath:String = Paths.json('${dataFilePath}/${id}');
+ var rawJson:String = openfl.Assets.getText(entryFilePath).trim();
+ return rawJson;
+ }
+
+ function clearEntries():Void
+ {
+ for (entry in entries)
+ {
+ entry.destroy();
+ }
+
+ entries.clear();
+ }
+
+ //
+ // FUNCTIONS TO IMPLEMENT
+ //
+
+ /**
+ * Read, parse, and validate the JSON data and produce the corresponding data object.
+ *
+ * NOTE: Must be implemented on the implementation class annd
+ */
+ public abstract function parseEntryData(id:String):Null;
+
+ /**
+ * Retrieve the list of scripted class names to load.
+ * @return An array of scripted class names.
+ */
+ abstract function getScriptedClassNames():Array;
+
+ /**
+ * Create an entry from the given ID.
+ * @param id
+ */
+ function createEntry(id:String):Null
+ {
+ return new T(id);
+ }
+
+ /**
+ * Create a entry, attached to a scripted class, from the given class name.
+ * @param clsName
+ */
+ abstract function createScriptedEntry(clsName:String):Null;
+}
diff --git a/source/funkin/data/IRegistryEntry.hx b/source/funkin/data/IRegistryEntry.hx
new file mode 100644
index 000000000..0fb704b7c
--- /dev/null
+++ b/source/funkin/data/IRegistryEntry.hx
@@ -0,0 +1,19 @@
+package funkin.data;
+
+/**
+ * An interface defining the necessary functions for a registry entry.
+ * A `String->Void` constructor is also mandatory, but enforced elsewhere.
+ * @param T The JSON data type of the registry entry.
+ */
+interface IRegistryEntry
+{
+ public final id:String;
+
+ // public function new(id:String):Void;
+ public function destroy():Void;
+ public function toString():String;
+
+ // Can't make an interface field private I guess.
+ public final _data:T;
+ public function _fetchData(id:String):Null;
+}
diff --git a/source/funkin/data/level/LevelData.hx b/source/funkin/data/level/LevelData.hx
new file mode 100644
index 000000000..0342c3d39
--- /dev/null
+++ b/source/funkin/data/level/LevelData.hx
@@ -0,0 +1,91 @@
+package funkin.data.level;
+
+import funkin.play.AnimationData;
+
+/**
+ * A type definition for the data in a story mode level JSON file.
+ * @see https://lib.haxe.org/p/json2object/
+ */
+typedef LevelData =
+{
+ /**
+ * The version number of the level data schema.
+ * When making changes to the level data format, this should be incremented,
+ * and a migration function should be added to LevelDataParser to handle old versions.
+ */
+ @:default(funkin.data.level.LevelRegistry.LEVEL_DATA_VERSION)
+ var version:String;
+
+ /**
+ * The title of the week, as seen in the top corner.
+ */
+ var name:String;
+
+ /**
+ * The graphic for the level, as seen in the scrolling list.
+ */
+ var titleAsset:String;
+
+ @:default([])
+ var props:Array;
+ @:default(["bopeebo"])
+ var songs:Array;
+ @:default("#F9CF51")
+ @:optional
+ var background:String;
+}
+
+typedef LevelPropData =
+{
+ /**
+ * The image to use for the prop. May optionally be a sprite sheet.
+ */
+ var assetPath:String;
+
+ /**
+ * The scale to render the prop at.
+ * @default 1.0
+ */
+ @:default(1.0)
+ @:optional
+ var scale:Float;
+
+ /**
+ * The opacity to render the prop at.
+ * @default 1.0
+ */
+ @:default(1.0)
+ @:optional
+ var alpha:Float;
+
+ /**
+ * If true, the prop is a pixel sprite, and will be rendered without smoothing.
+ */
+ @:default(false)
+ @:optional
+ var isPixel:Bool;
+
+ /**
+ * The frequency to bop at, in beats.
+ * @default 1 = every beat, 2 = every other beat, etc.
+ */
+ @:default(1)
+ @:optional
+ var danceEvery:Int;
+
+ /**
+ * The offset on the position to render the prop at.
+ * @default [0.0, 0.0]
+ */
+ @:default([0, 0])
+ @:optional
+ var offsets:Array;
+
+ /**
+ * A set of animations to play on the prop.
+ * If default/empty, the prop will be static.
+ */
+ @:default([])
+ @:optional
+ var animations:Array;
+}
diff --git a/source/funkin/data/level/LevelRegistry.hx b/source/funkin/data/level/LevelRegistry.hx
new file mode 100644
index 000000000..54ed81093
--- /dev/null
+++ b/source/funkin/data/level/LevelRegistry.hx
@@ -0,0 +1,85 @@
+package funkin.data.level;
+
+import funkin.ui.story.Level;
+import funkin.data.level.LevelData;
+import funkin.ui.story.ScriptedLevel;
+
+class LevelRegistry extends BaseRegistry
+{
+ /**
+ * The current version string for the stage data format.
+ * Handle breaking changes by incrementing this value
+ * and adding migration to the `migrateStageData()` function.
+ */
+ public static final LEVEL_DATA_VERSION:String = "1.0.0";
+
+ public static final instance:LevelRegistry = new LevelRegistry();
+
+ public function new()
+ {
+ super('LEVEL', 'levels');
+ }
+
+ /**
+ * Read, parse, and validate the JSON data and produce the corresponding data object.
+ */
+ public function parseEntryData(id:String):Null
+ {
+ // JsonParser does not take type parameters,
+ // otherwise this function would be in BaseRegistry.
+ var parser = new json2object.JsonParser();
+ var jsonStr:String = loadEntryFile(id);
+
+ parser.fromJson(jsonStr);
+
+ if (parser.errors.length > 0)
+ {
+ trace('Failed to parse entry data: ${id}');
+ for (error in parser.errors)
+ {
+ trace(error);
+ }
+ return null;
+ }
+ return parser.value;
+ }
+
+ function createScriptedEntry(clsName:String):Level
+ {
+ return ScriptedLevel.init(clsName, "unknown");
+ }
+
+ function getScriptedClassNames():Array
+ {
+ return ScriptedLevel.listScriptClasses();
+ }
+
+ /**
+ * A list of all the story weeks from the base game, in order.
+ * TODO: Should this be hardcoded?
+ */
+ public function listBaseGameLevelIds():Array
+ {
+ return [
+ "tutorial",
+ "week1",
+ "week2",
+ "week3",
+ "week4",
+ "week5",
+ "week6",
+ "week7",
+ "weekend1"
+ ];
+ }
+
+ /**
+ * A list of all installed story weeks that are not from the base game.
+ */
+ public function listModdedLevelIds():Array
+ {
+ return listEntryIds().filter(function(id:String):Bool {
+ return listBaseGameLevelIds().indexOf(id) == -1;
+ });
+ }
+}
diff --git a/source/funkin/modding/PolymodHandler.hx b/source/funkin/modding/PolymodHandler.hx
index 08b070835..a8e4a6109 100644
--- a/source/funkin/modding/PolymodHandler.hx
+++ b/source/funkin/modding/PolymodHandler.hx
@@ -277,6 +277,7 @@ class PolymodHandler
// TODO: Reload event callbacks
+ funkin.data.level.LevelRegistry.instance.loadEntries();
SongDataParser.loadSongCache();
StageDataParser.loadStageCache();
CharacterDataParser.loadCharacterCache();
diff --git a/source/funkin/play/AnimationData.hx b/source/funkin/play/AnimationData.hx
index 87bc10102..e512bb757 100644
--- a/source/funkin/play/AnimationData.hx
+++ b/source/funkin/play/AnimationData.hx
@@ -21,36 +21,48 @@ typedef AnimationData =
* ONLY for use by MultiSparrow characters.
* @default The assetPath of the parent sprite
*/
+ @:default(null)
+ @:optional
var assetPath:Null;
/**
* Offset the character's position by this amount when playing this animation.
* @default [0, 0]
*/
+ @:default([0, 0])
+ @:optional
var offsets:Null>;
/**
* Whether the animation should loop when it finishes.
* @default false
*/
+ @:default(false)
+ @:optional
var looped:Null;
/**
* Whether the animation's sprites should be flipped horizontally.
* @default false
*/
+ @:default(false)
+ @:optional
var flipX:Null;
/**
* Whether the animation's sprites should be flipped vertically.
* @default false
*/
+ @:default(false)
+ @:optional
var flipY:Null;
/**
* The frame rate of the animation.
* @default 24
*/
+ @:default(24)
+ @:optional
var frameRate:Null;
/**
@@ -59,5 +71,7 @@ typedef AnimationData =
* @example [0, 1, 2, 3] (use only the first four frames)
* @default [] (all frames)
*/
+ @:default([])
+ @:optional
var frameIndices:Null>;
}
diff --git a/source/funkin/play/GameOverSubstate.hx b/source/funkin/play/GameOverSubstate.hx
index 079490501..3d5470324 100644
--- a/source/funkin/play/GameOverSubstate.hx
+++ b/source/funkin/play/GameOverSubstate.hx
@@ -2,7 +2,8 @@ package funkin.play;
import flixel.FlxObject;
import flixel.FlxSprite;
-import flixel.sound.FlxSound;
+import flixel.system.FlxSound;
+import funkin.ui.story.StoryMenuState;
import flixel.util.FlxColor;
import flixel.util.FlxTimer;
import funkin.modding.events.ScriptEvent;
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index b261a54d9..4698c7e7e 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -5,6 +5,7 @@ import flixel.sound.FlxSound;
import flixel.addons.transition.FlxTransitionableState;
import flixel.FlxCamera;
import flixel.FlxObject;
+import funkin.ui.story.StoryMenuState;
import flixel.FlxSprite;
import flixel.FlxState;
import flixel.FlxSubState;
diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx
index 19c24e7a8..93f7d42a3 100644
--- a/source/funkin/play/song/Song.hx
+++ b/source/funkin/play/song/Song.hx
@@ -37,11 +37,14 @@ class Song implements IPlayStateScriptedClass
*/
public var validScore:Bool = true;
+ var difficultyIds:Array;
+
public function new(id:String)
{
this.songId = id;
variations = [];
+ difficultyIds = [];
difficulties = new Map();
_metadata = SongDataParser.parseSongMetadata(songId);
@@ -72,6 +75,8 @@ class Song implements IPlayStateScriptedClass
// but all the difficulties in the metadata must be in the chart file.
for (diffId in metadata.playData.difficulties)
{
+ difficultyIds.push(diffId);
+
var difficulty:SongDifficulty = new SongDifficulty(this, diffId, metadata.variation);
variations.push(metadata.variation);
@@ -148,6 +153,16 @@ class Song implements IPlayStateScriptedClass
return difficulties.get(diffId);
}
+ public function listDifficulties():Array
+ {
+ return difficultyIds;
+ }
+
+ public function hasDifficulty(diffId:String):Bool
+ {
+ return difficulties.exists(diffId);
+ }
+
/**
* Purge the cached chart data for each difficulty of this song.
*/
@@ -290,7 +305,8 @@ class SongDifficulty
public inline function playInst(volume:Float = 1.0, looped:Bool = false):Void
{
- FlxG.sound.playMusic(Paths.inst(this.song.songId), volume, looped);
+ var suffix:String = variation == null ? null : '-$variation';
+ FlxG.sound.playMusic(Paths.inst(this.song.songId, suffix), volume, looped);
}
/**
@@ -320,28 +336,30 @@ class SongDifficulty
return [];
}
+ var suffix:String = variation != null ? '-$variation' : '';
+
// Automatically resolve voices by removing suffixes.
// For example, if `Voices-bf-car.ogg` does not exist, check for `Voices-bf.ogg`.
var playerId:String = id;
- var voicePlayer:String = Paths.voices(this.song.songId, '-$id');
+ var voicePlayer:String = Paths.voices(this.song.songId, '-$id$suffix');
while (voicePlayer != null && !Assets.exists(voicePlayer))
{
// Remove the last suffix.
// For example, bf-car becomes bf.
playerId = playerId.split('-').slice(0, -1).join('-');
// Try again.
- voicePlayer = playerId == '' ? null : Paths.voices(this.song.songId, '-${playerId}');
+ voicePlayer = playerId == '' ? null : Paths.voices(this.song.songId, '-${playerId}$suffix');
}
var opponentId:String = playableCharData.opponent;
- var voiceOpponent:String = Paths.voices(this.song.songId, '-${opponentId}');
+ var voiceOpponent:String = Paths.voices(this.song.songId, '-${opponentId}$suffix');
while (voiceOpponent != null && !Assets.exists(voiceOpponent))
{
// Remove the last suffix.
opponentId = opponentId.split('-').slice(0, -1).join('-');
// Try again.
- voiceOpponent = opponentId == '' ? null : Paths.voices(this.song.songId, '-${opponentId}');
+ voiceOpponent = opponentId == '' ? null : Paths.voices(this.song.songId, '-${opponentId}$suffix');
}
var result:Array = [];
@@ -350,7 +368,7 @@ class SongDifficulty
if (voicePlayer == null && voiceOpponent == null)
{
// Try to use `Voices.ogg` if no other voices are found.
- if (Assets.exists(Paths.voices(this.song.songId, ''))) result.push(Paths.voices(this.song.songId, ''));
+ if (Assets.exists(Paths.voices(this.song.songId, ''))) result.push(Paths.voices(this.song.songId, '$suffix'));
}
return result;
}
diff --git a/source/funkin/play/song/SongData.hx b/source/funkin/play/song/SongData.hx
index a8fc0e4d7..982ccb402 100644
--- a/source/funkin/play/song/SongData.hx
+++ b/source/funkin/play/song/SongData.hx
@@ -143,9 +143,15 @@ class SongDataParser
for (variation in variations)
{
- var variationRawJson:String = loadSongMetadataFile(songId, variation);
- var variationSongMetadata:SongMetadata = SongMigrator.migrateSongMetadata(variationRawJson, '${songId}_${variation}');
- variationSongMetadata = SongValidator.validateSongMetadata(variationSongMetadata, '${songId}_${variation}');
+ var variationJsonStr:String = loadSongMetadataFile(songId, variation);
+ var variationJsonData:Dynamic = null;
+ try
+ {
+ variationJsonData = Json.parse(variationJsonStr);
+ }
+ catch (e) {}
+ var variationSongMetadata:SongMetadata = SongMigrator.migrateSongMetadata(variationJsonData, '${songId}-${variation}');
+ variationSongMetadata = SongValidator.validateSongMetadata(variationSongMetadata, '${songId}-${variation}');
if (variationSongMetadata != null)
{
variationSongMetadata.variation = variation;
diff --git a/source/funkin/play/stage/Bopper.hx b/source/funkin/play/stage/Bopper.hx
index 09aa910e0..623660af7 100644
--- a/source/funkin/play/stage/Bopper.hx
+++ b/source/funkin/play/stage/Bopper.hx
@@ -256,6 +256,7 @@ class Bopper extends StageProp implements IPlayStateScriptedClass
var correctName = correctAnimationName(name);
if (correctName == null) return;
+ this.animation.paused = false;
this.animation.play(correctName, restart, false, 0);
if (ignoreOther)
diff --git a/source/funkin/ui/StickerSubState.hx b/source/funkin/ui/StickerSubState.hx
index b300640a4..9658275e9 100644
--- a/source/funkin/ui/StickerSubState.hx
+++ b/source/funkin/ui/StickerSubState.hx
@@ -4,6 +4,7 @@ import flixel.FlxSprite;
import haxe.Json;
import lime.utils.Assets;
// import flxtyped group
+import funkin.ui.story.StoryMenuState;
import flixel.group.FlxGroup.FlxTypedGroup;
import flixel.util.FlxTimer;
import flixel.FlxG;
diff --git a/source/funkin/ui/story/Level.hx b/source/funkin/ui/story/Level.hx
new file mode 100644
index 000000000..5d24de312
--- /dev/null
+++ b/source/funkin/ui/story/Level.hx
@@ -0,0 +1,177 @@
+package funkin.ui.story;
+
+import flixel.FlxSprite;
+import flixel.util.FlxColor;
+import funkin.play.song.Song;
+import funkin.data.IRegistryEntry;
+import funkin.data.level.LevelRegistry;
+import funkin.data.level.LevelData;
+
+/**
+ * An object used to retrieve data about a story mode level (also known as "weeks").
+ * Can be scripted to override each function, for custom behavior.
+ */
+class Level implements IRegistryEntry
+{
+ /**
+ * The ID of the story mode level.
+ */
+ public final id:String;
+
+ /**
+ * Level data as parsed from the JSON file.
+ */
+ public final _data:LevelData;
+
+ /**
+ * @param id The ID of the JSON file to parse.
+ */
+ public function new(id:String)
+ {
+ this.id = id;
+ _data = _fetchData(id);
+
+ if (_data == null)
+ {
+ throw 'Could not parse level data for id: $id';
+ }
+ }
+
+ /**
+ * Get the list of songs in this level, as an array of IDs.
+ * @return Array
+ */
+ public function getSongs():Array
+ {
+ return _data.songs;
+ }
+
+ /**
+ * Retrieve the title of the level for display on the menu.
+ */
+ public function getTitle():String
+ {
+ // TODO: Maybe add localization support?
+ return _data.name;
+ }
+
+ public function buildTitleGraphic():FlxSprite
+ {
+ var result = new FlxSprite().loadGraphic(Paths.image(_data.titleAsset));
+
+ return result;
+ }
+
+ /**
+ * Get the list of songs in this level, as an array of names, for display on the menu.
+ * @return Array
+ */
+ public function getSongDisplayNames(difficulty:String):Array
+ {
+ var songList:Array = getSongs() ?? [];
+ var songNameList:Array = songList.map(function(songId) {
+ return funkin.play.song.SongData.SongDataParser.fetchSong(songId) ?.getDifficulty(difficulty) ?.songName ?? 'Unknown';
+ });
+ return songNameList;
+ }
+
+ /**
+ * Whether this level is unlocked. If not, it will be greyed out on the menu and have a lock icon.
+ * TODO: Change this behavior in a later release.
+ */
+ public function isUnlocked():Bool
+ {
+ return true;
+ }
+
+ /**
+ * Whether this level is visible. If not, it will not be shown on the menu at all.
+ */
+ public function isVisible():Bool
+ {
+ return true;
+ }
+
+ public function buildBackground():FlxSprite
+ {
+ if (_data.background.startsWith('#'))
+ {
+ // Color specified
+ var color:FlxColor = FlxColor.fromString(_data.background);
+ return new FlxSprite().makeGraphic(FlxG.width, 400, color);
+ }
+ else
+ {
+ // Image specified
+ return new FlxSprite().loadGraphic(Paths.image(_data.background));
+ }
+ }
+
+ public function getDifficulties():Array
+ {
+ var difficulties:Array = [];
+
+ var songList = getSongs();
+
+ var firstSongId:String = songList[0];
+ var firstSong:Song = funkin.play.song.SongData.SongDataParser.fetchSong(firstSongId);
+
+ if (firstSong != null)
+ {
+ for (difficulty in firstSong.listDifficulties())
+ {
+ difficulties.push(difficulty);
+ }
+ }
+
+ // Filter to only include difficulties that are present in all songs
+ for (songIndex in 1...songList.length)
+ {
+ var songId:String = songList[songIndex];
+ var song:Song = funkin.play.song.SongData.SongDataParser.fetchSong(songId);
+
+ if (song == null) continue;
+
+ for (difficulty in difficulties)
+ {
+ if (!song.hasDifficulty(difficulty))
+ {
+ difficulties.remove(difficulty);
+ }
+ }
+ }
+
+ if (difficulties.length == 0) difficulties = ['normal'];
+
+ return difficulties;
+ }
+
+ public function buildProps():Array
+ {
+ var props:Array = [];
+
+ if (_data.props.length == 0) return props;
+
+ for (propIndex in 0..._data.props.length)
+ {
+ var propData = _data.props[propIndex];
+ var propSprite:LevelProp = LevelProp.build(propData);
+ propSprite.x += FlxG.width * 0.25 * propIndex;
+ props.push(propSprite);
+ }
+
+ return props;
+ }
+
+ public function destroy():Void {}
+
+ public function toString():String
+ {
+ return 'Level($id)';
+ }
+
+ public function _fetchData(id:String):Null
+ {
+ return LevelRegistry.instance.parseEntryData(id);
+ }
+}
diff --git a/source/funkin/ui/story/LevelProp.hx b/source/funkin/ui/story/LevelProp.hx
new file mode 100644
index 000000000..a474b363c
--- /dev/null
+++ b/source/funkin/ui/story/LevelProp.hx
@@ -0,0 +1,63 @@
+package funkin.ui.story;
+
+import funkin.play.stage.Bopper;
+import funkin.util.assets.FlxAnimationUtil;
+import funkin.data.level.LevelData;
+
+class LevelProp extends Bopper
+{
+ public function new(danceEvery:Int)
+ {
+ super(danceEvery);
+ }
+
+ public function playConfirm():Void
+ {
+ playAnimation('confirm', true, true);
+ }
+
+ public static function build(propData:LevelPropData):Null
+ {
+ var isAnimated:Bool = propData.animations.length > 0;
+ var prop:LevelProp = new LevelProp(propData.danceEvery);
+
+ if (isAnimated)
+ {
+ // Initalize sprite frames.
+ // Sparrow atlas only LEL.
+ prop.frames = Paths.getSparrowAtlas(propData.assetPath);
+ }
+ else
+ {
+ // Initalize static sprite.
+ prop.loadGraphic(Paths.image(propData.assetPath));
+
+ // Disables calls to update() for a performance boost.
+ prop.active = false;
+ }
+
+ if (prop.frames == null || prop.frames.numFrames == 0)
+ {
+ trace('ERROR: Could not build texture for level prop (${propData.assetPath}).');
+ return null;
+ }
+
+ var scale:Float = propData.scale * (propData.isPixel ? 6 : 1);
+ prop.scale.set(scale, scale);
+ prop.antialiasing = !propData.isPixel;
+ prop.alpha = propData.alpha;
+ prop.x = propData.offsets[0];
+ prop.y = propData.offsets[1];
+
+ FlxAnimationUtil.addAtlasAnimations(prop, propData.animations);
+ for (propAnim in propData.animations)
+ {
+ prop.setAnimationOffsets(propAnim.name, propAnim.offsets[0], propAnim.offsets[1]);
+ }
+
+ prop.dance();
+ prop.animation.paused = true;
+
+ return prop;
+ }
+}
diff --git a/source/funkin/ui/story/LevelTitle.hx b/source/funkin/ui/story/LevelTitle.hx
new file mode 100644
index 000000000..e1765d453
--- /dev/null
+++ b/source/funkin/ui/story/LevelTitle.hx
@@ -0,0 +1,90 @@
+package funkin.ui.story;
+
+import flixel.FlxSprite;
+import flixel.graphics.frames.FlxAtlasFrames;
+import flixel.group.FlxSpriteGroup;
+import flixel.util.FlxColor;
+import funkin.CoolUtil;
+
+class LevelTitle extends FlxSpriteGroup
+{
+ static final LOCK_PAD:Int = 4;
+
+ public final level:Level;
+
+ public var targetY:Float;
+ public var isFlashing:Bool = false;
+
+ var title:FlxSprite;
+ var lock:FlxSprite;
+
+ var flashingInt:Int = 0;
+
+ public function new(x:Int, y:Int, level:Level)
+ {
+ super(x, y);
+
+ this.level = level;
+
+ if (this.level == null) throw "Level cannot be null!";
+
+ buildLevelTitle();
+ buildLevelLock();
+ }
+
+ override function get_width():Float
+ {
+ if (length == 0) return 0;
+
+ if (lock.visible)
+ {
+ return title.width + lock.width + LOCK_PAD;
+ }
+ else
+ {
+ return title.width;
+ }
+ }
+
+ // if it runs at 60fps, fake framerate will be 6
+ // if it runs at 144 fps, fake framerate will be like 14, and will update the graphic every 0.016666 * 3 seconds still???
+ // so it runs basically every so many seconds, not dependant on framerate??
+ // I'm still learning how math works thanks whoever is reading this lol
+ var fakeFramerate:Int = Math.round((1 / FlxG.elapsed) / 10);
+
+ public override function update(elapsed:Float):Void
+ {
+ this.y = CoolUtil.coolLerp(y, targetY, 0.17);
+
+ if (isFlashing) flashingInt += 1;
+ if (flashingInt % fakeFramerate >= Math.floor(fakeFramerate / 2)) title.color = 0xFF33ffff;
+ else
+ title.color = FlxColor.WHITE;
+ }
+
+ public function showLock():Void
+ {
+ lock.visible = true;
+ this.x -= (lock.width + LOCK_PAD) / 2;
+ }
+
+ public function hideLock():Void
+ {
+ lock.visible = false;
+ this.x += (lock.width + LOCK_PAD) / 2;
+ }
+
+ function buildLevelTitle():Void
+ {
+ title = level.buildTitleGraphic();
+ add(title);
+ }
+
+ function buildLevelLock():Void
+ {
+ lock = new FlxSprite(0, 0).loadGraphic(Paths.image('storymenu/ui/lock'));
+ lock.x = title.x + title.width + LOCK_PAD;
+ lock.visible = false;
+ add(lock);
+ }
+}
diff --git a/source/funkin/ui/story/ScriptedLevel.hx b/source/funkin/ui/story/ScriptedLevel.hx
new file mode 100644
index 000000000..a9921741c
--- /dev/null
+++ b/source/funkin/ui/story/ScriptedLevel.hx
@@ -0,0 +1,9 @@
+package funkin.ui.story;
+
+/**
+ * A script that can be tied to a Level, which persists across states.
+ * Create a scripted class that extends Level to use this.
+ * This allows you to customize how a specific level appears.
+ */
+@:hscriptClass
+class ScriptedLevel extends funkin.ui.story.Level implements polymod.hscript.HScriptedClass {}
diff --git a/source/funkin/ui/story/StoryMenuState.hx b/source/funkin/ui/story/StoryMenuState.hx
new file mode 100644
index 000000000..8a856baf6
--- /dev/null
+++ b/source/funkin/ui/story/StoryMenuState.hx
@@ -0,0 +1,549 @@
+package funkin.ui.story;
+
+import openfl.utils.Assets;
+import flixel.addons.transition.FlxTransitionableState;
+import flixel.FlxSprite;
+import flixel.group.FlxGroup.FlxTypedGroup;
+import flixel.text.FlxText;
+import flixel.tweens.FlxEase;
+import flixel.tweens.FlxTween;
+import flixel.util.FlxColor;
+import flixel.util.FlxTimer;
+import funkin.data.level.LevelRegistry;
+import funkin.modding.events.ScriptEvent;
+import funkin.modding.events.ScriptEventDispatcher;
+import funkin.play.PlayState;
+import funkin.play.song.SongData.SongDataParser;
+import funkin.util.Constants;
+
+class StoryMenuState extends MusicBeatState
+{
+ static final DEFAULT_BACKGROUND_COLOR:FlxColor = FlxColor.fromString("#F9CF51");
+ static final BACKGROUND_HEIGHT:Int = 400;
+
+ var currentDifficultyId:String = 'normal';
+
+ var currentLevelId:String = 'tutorial';
+ var currentLevel:Level;
+ var isLevelUnlocked:Bool;
+ var currentLevelTitle:LevelTitle;
+
+ var highScore:Int = 42069420;
+ var highScoreLerp:Int = 12345678;
+
+ var exitingMenu:Bool = false;
+ var selectedLevel:Bool = false;
+
+ var displayingModdedLevels:Bool = false;
+
+ //
+ // RENDER OBJECTS
+ //
+
+ /**
+ * The title of the level at the top.
+ */
+ var levelTitleText:FlxText;
+
+ /**
+ * The score text at the top.
+ */
+ var scoreText:FlxText;
+
+ /**
+ * The list of songs on the left.
+ */
+ var tracklistText:FlxText;
+
+ /**
+ * The titles of the levels in the middle.
+ */
+ var levelTitles:FlxTypedGroup;
+
+ /**
+ * The props in the center.
+ */
+ var levelProps:FlxTypedGroup;
+
+ /**
+ * The background behind the props.
+ */
+ var levelBackground:FlxSprite;
+
+ /**
+ * The left arrow of the difficulty selector.
+ */
+ var leftDifficultyArrow:FlxSprite;
+
+ /**
+ * The right arrow of the difficulty selector.
+ */
+ var rightDifficultyArrow:FlxSprite;
+
+ /**
+ * The text of the difficulty selector.
+ */
+ var difficultySprite:FlxSprite;
+
+ var difficultySprites:Map;
+
+ var stickerSubState:StickerSubState;
+
+ public function new(?stickers:StickerSubState = null)
+ {
+ super();
+
+ if (stickers != null)
+ {
+ stickerSubState = stickers;
+ }
+ }
+
+ override function create():Void
+ {
+ super.create();
+
+ difficultySprites = new Map();
+
+ transIn = FlxTransitionableState.defaultTransIn;
+ transOut = FlxTransitionableState.defaultTransOut;
+
+ if (!FlxG.sound.music.playing)
+ {
+ FlxG.sound.playMusic(Paths.music('freakyMenu'));
+ FlxG.sound.music.fadeIn(4, 0, 0.7);
+ Conductor.forceBPM(Constants.FREAKY_MENU_BPM);
+ }
+
+ if (stickerSubState != null)
+ {
+ this.persistentUpdate = true;
+ this.persistentDraw = true;
+
+ openSubState(stickerSubState);
+ stickerSubState.degenStickers();
+
+ // resetSubState();
+ }
+
+ persistentUpdate = persistentDraw = true;
+
+ updateData();
+
+ // Explicitly define the background color.
+ this.bgColor = FlxColor.BLACK;
+
+ levelTitles = new FlxTypedGroup();
+ add(levelTitles);
+
+ updateBackground();
+
+ levelProps = new FlxTypedGroup();
+ levelProps.zIndex = 1000;
+ add(levelProps);
+
+ updateProps();
+
+ scoreText = new FlxText(10, 10, 0, 'HIGH SCORE: 42069420');
+ scoreText.setFormat("VCR OSD Mono", 32);
+ add(scoreText);
+
+ tracklistText = new FlxText(FlxG.width * 0.05, levelBackground.x + levelBackground.height + 100, 0, "Tracks", 32);
+ tracklistText.setFormat("VCR OSD Mono", 32);
+ tracklistText.alignment = CENTER;
+ tracklistText.color = 0xFFe55777;
+ add(tracklistText);
+
+ levelTitleText = new FlxText(FlxG.width * 0.7, 10, 0, 'LEVEL 1');
+ levelTitleText.setFormat("VCR OSD Mono", 32, FlxColor.WHITE, RIGHT);
+ levelTitleText.alpha = 0.7;
+ add(levelTitleText);
+
+ buildLevelTitles();
+
+ leftDifficultyArrow = new FlxSprite(levelTitles.members[0].x + levelTitles.members[0].width + 10, levelTitles.members[0].y + 10);
+ leftDifficultyArrow.frames = Paths.getSparrowAtlas('storymenu/ui/arrows');
+ leftDifficultyArrow.animation.addByPrefix('idle', 'leftIdle0');
+ leftDifficultyArrow.animation.addByPrefix('press', 'leftConfirm0');
+ leftDifficultyArrow.animation.play('idle');
+ add(leftDifficultyArrow);
+
+ buildDifficultySprite();
+
+ rightDifficultyArrow = new FlxSprite(difficultySprite.x + difficultySprite.width + 10, leftDifficultyArrow.y);
+ rightDifficultyArrow.frames = leftDifficultyArrow.frames;
+ rightDifficultyArrow.animation.addByPrefix('idle', 'rightIdle0');
+ rightDifficultyArrow.animation.addByPrefix('press', 'rightConfirm0');
+ rightDifficultyArrow.animation.play('idle');
+ add(rightDifficultyArrow);
+
+ add(difficultySprite);
+
+ updateText();
+ changeDifficulty();
+ changeLevel();
+ refresh();
+
+ #if discord_rpc
+ // Updating Discord Rich Presence
+ DiscordClient.changePresence("In the Menus", null);
+ #end
+ }
+
+ function updateData():Void
+ {
+ currentLevel = LevelRegistry.instance.fetchEntry(currentLevelId);
+ isLevelUnlocked = currentLevel == null ? false : currentLevel.isUnlocked();
+ }
+
+ function buildDifficultySprite():Void
+ {
+ remove(difficultySprite);
+ difficultySprite = difficultySprites.get(currentDifficultyId);
+ if (difficultySprite == null)
+ {
+ difficultySprite = new FlxSprite(leftDifficultyArrow.x + leftDifficultyArrow.width + 10, leftDifficultyArrow.y);
+
+ if (Assets.exists(Paths.file('images/storymenu/difficulties/${currentDifficultyId}.xml')))
+ {
+ difficultySprite.frames = Paths.getSparrowAtlas('storymenu/difficulties/${currentDifficultyId}');
+ difficultySprite.animation.addByPrefix('idle', 'idle0', 24, true);
+ difficultySprite.animation.play('idle');
+ }
+ else
+ {
+ difficultySprite.loadGraphic(Paths.image('storymenu/difficulties/${currentDifficultyId}'));
+ }
+
+ difficultySprites.set(currentDifficultyId, difficultySprite);
+
+ difficultySprite.x += (difficultySprites.get('normal').width - difficultySprite.width) / 2;
+ }
+ difficultySprite.alpha = 0;
+
+ difficultySprite.y = leftDifficultyArrow.y - 15;
+ var targetY:Float = leftDifficultyArrow.y + 10;
+ targetY -= (difficultySprite.height - difficultySprites.get('normal').height) / 2;
+ FlxTween.tween(difficultySprite, {y: targetY, alpha: 1}, 0.07);
+
+ add(difficultySprite);
+ }
+
+ function buildLevelTitles():Void
+ {
+ levelTitles.clear();
+
+ var levelIds:Array = displayingModdedLevels ? LevelRegistry.instance.listModdedLevelIds() : LevelRegistry.instance.listBaseGameLevelIds();
+ if (levelIds.length == 0) levelIds = ['tutorial']; // Make sure there's at least one level to display.
+
+ for (levelIndex in 0...levelIds.length)
+ {
+ var levelId:String = levelIds[levelIndex];
+ var level:Level = LevelRegistry.instance.fetchEntry(levelId);
+ if (level == null) continue;
+
+ var levelTitleItem:LevelTitle = new LevelTitle(0, Std.int(levelBackground.y + levelBackground.height + 10), level);
+ levelTitleItem.targetY = ((levelTitleItem.height + 20) * levelIndex);
+ levelTitleItem.screenCenter(X);
+ levelTitles.add(levelTitleItem);
+ }
+ }
+
+ function switchMode(moddedLevels:Bool):Void
+ {
+ displayingModdedLevels = moddedLevels;
+ buildLevelTitles();
+
+ changeLevel(0);
+ changeDifficulty(0);
+ }
+
+ override function update(elapsed:Float)
+ {
+ Conductor.update();
+
+ highScoreLerp = Std.int(CoolUtil.coolLerp(highScoreLerp, highScore, 0.5));
+
+ scoreText.text = 'LEVEL SCORE: ${Math.round(highScoreLerp)}';
+
+ levelTitleText.text = currentLevel.getTitle();
+ levelTitleText.x = FlxG.width - (levelTitleText.width + 10); // Right align.
+
+ handleKeyPresses();
+
+ super.update(elapsed);
+ }
+
+ function handleKeyPresses():Void
+ {
+ if (!exitingMenu)
+ {
+ if (!selectedLevel)
+ {
+ if (controls.UI_UP_P)
+ {
+ changeLevel(-1);
+ changeDifficulty(0);
+ }
+
+ if (controls.UI_DOWN_P)
+ {
+ changeLevel(1);
+ changeDifficulty(0);
+ }
+
+ if (controls.UI_RIGHT)
+ {
+ rightDifficultyArrow.animation.play('press');
+ }
+ else
+ {
+ rightDifficultyArrow.animation.play('idle');
+ }
+
+ if (controls.UI_LEFT)
+ {
+ leftDifficultyArrow.animation.play('press');
+ }
+ else
+ {
+ leftDifficultyArrow.animation.play('idle');
+ }
+
+ if (controls.UI_RIGHT_P)
+ {
+ changeDifficulty(1);
+ }
+
+ if (controls.UI_LEFT_P)
+ {
+ changeDifficulty(-1);
+ }
+
+ if (FlxG.keys.justPressed.TAB)
+ {
+ switchMode(!displayingModdedLevels);
+ }
+ }
+
+ if (controls.ACCEPT)
+ {
+ selectLevel();
+ }
+ }
+
+ if (controls.BACK && !exitingMenu && !selectedLevel)
+ {
+ FlxG.sound.play(Paths.sound('cancelMenu'));
+ exitingMenu = true;
+ FlxG.switchState(new MainMenuState());
+ }
+ }
+
+ /**
+ * Changes the selected level.
+ * @param change +1 (down), -1 (up)
+ */
+ function changeLevel(change:Int = 0):Void
+ {
+ var levelList:Array = displayingModdedLevels ? LevelRegistry.instance.listModdedLevelIds() : LevelRegistry.instance.listBaseGameLevelIds();
+ if (levelList.length == 0) levelList = ['tutorial'];
+
+ var currentIndex:Int = levelList.indexOf(currentLevelId);
+
+ currentIndex += change;
+
+ // Wrap around
+ if (currentIndex < 0) currentIndex = levelList.length - 1;
+ if (currentIndex >= levelList.length) currentIndex = 0;
+
+ currentLevelId = levelList[currentIndex];
+
+ updateData();
+
+ for (index in 0...levelTitles.members.length)
+ {
+ var item:LevelTitle = levelTitles.members[index];
+
+ item.targetY = (index - currentIndex) * 120 + 480;
+
+ if (index == currentIndex)
+ {
+ currentLevelTitle = item;
+ item.alpha = 1.0;
+ }
+ else if (index > currentIndex)
+ {
+ item.alpha = 0.6;
+ }
+ else
+ {
+ item.alpha = 0.0;
+ }
+ }
+
+ updateText();
+ updateBackground();
+ updateProps();
+ refresh();
+ }
+
+ /**
+ * Changes the selected difficulty.
+ * @param change +1 (right) to increase difficulty, -1 (left) to decrease difficulty
+ */
+ function changeDifficulty(change:Int = 0):Void
+ {
+ var difficultyList:Array = currentLevel.getDifficulties();
+ var currentIndex:Int = difficultyList.indexOf(currentDifficultyId);
+
+ currentIndex += change;
+
+ // Wrap around
+ if (currentIndex < 0) currentIndex = difficultyList.length - 1;
+ if (currentIndex >= difficultyList.length) currentIndex = 0;
+
+ var hasChanged:Bool = currentDifficultyId != difficultyList[currentIndex];
+ currentDifficultyId = difficultyList[currentIndex];
+
+ if (difficultyList.length <= 1)
+ {
+ leftDifficultyArrow.visible = false;
+ rightDifficultyArrow.visible = false;
+ }
+ else
+ {
+ leftDifficultyArrow.visible = true;
+ rightDifficultyArrow.visible = true;
+ }
+
+ if (hasChanged)
+ {
+ buildDifficultySprite();
+ funnyMusicThing();
+ }
+ }
+
+ final FADE_OUT_TIME:Float = 1.5;
+
+ function funnyMusicThing():Void
+ {
+ if (currentDifficultyId == "nightmare")
+ {
+ FlxG.sound.music.fadeOut(FADE_OUT_TIME, 0.0);
+ }
+ else
+ {
+ FlxG.sound.music.fadeOut(FADE_OUT_TIME, 1.0);
+ }
+ }
+
+ override function dispatchEvent(event:ScriptEvent):Void
+ {
+ // super.dispatchEvent(event) dispatches event to module scripts.
+ super.dispatchEvent(event);
+
+ if ((levelProps?.length ?? 0) > 0)
+ {
+ // Dispatch event to props.
+ for (prop in levelProps)
+ {
+ ScriptEventDispatcher.callEvent(prop, event);
+ }
+ }
+ }
+
+ function selectLevel()
+ {
+ if (!currentLevel.isUnlocked())
+ {
+ FlxG.sound.play(Paths.sound('cancelMenu'));
+ return;
+ }
+
+ if (selectedLevel) return;
+
+ selectedLevel = true;
+
+ FlxG.sound.play(Paths.sound('confirmMenu'));
+
+ currentLevelTitle.isFlashing = true;
+
+ for (prop in levelProps.members)
+ {
+ prop.playConfirm();
+ }
+
+ PlayState.storyPlaylist = currentLevel.getSongs();
+ PlayState.isStoryMode = true;
+
+ PlayState.currentSong = SongLoad.loadFromJson(PlayState.storyPlaylist[0].toLowerCase(), PlayState.storyPlaylist[0].toLowerCase());
+ PlayState.currentSong_NEW = SongDataParser.fetchSong(PlayState.storyPlaylist[0].toLowerCase());
+
+ // TODO: Fix this.
+ PlayState.storyWeek = 0;
+ PlayState.campaignScore = 0;
+
+ // TODO: Fix this.
+ PlayState.storyDifficulty = 0;
+ PlayState.storyDifficulty_NEW = currentDifficultyId;
+
+ SongLoad.curDiff = PlayState.storyDifficulty_NEW;
+
+ new FlxTimer().start(1, function(tmr:FlxTimer) {
+ LoadingState.loadAndSwitchState(new PlayState(), true);
+ });
+ }
+
+ function updateBackground():Void
+ {
+ if (levelBackground != null)
+ {
+ var oldBackground:FlxSprite = levelBackground;
+
+ FlxTween.tween(oldBackground, {alpha: 0.0}, 0.6,
+ {
+ ease: FlxEase.linear,
+ onComplete: function(_) {
+ remove(oldBackground);
+ }
+ });
+ }
+
+ levelBackground = currentLevel.buildBackground();
+ levelBackground.x = 0;
+ levelBackground.y = 56;
+ levelBackground.alpha = 0.0;
+ levelBackground.zIndex = 100;
+ add(levelBackground);
+
+ FlxTween.tween(levelBackground, {alpha: 1.0}, 0.6,
+ {
+ ease: FlxEase.linear
+ });
+ }
+
+ function updateProps():Void
+ {
+ levelProps.clear();
+ for (prop in currentLevel.buildProps())
+ {
+ prop.zIndex = 1000;
+ levelProps.add(prop);
+ }
+
+ refresh();
+ }
+
+ function updateText():Void
+ {
+ tracklistText.text = 'TRACKS\n\n';
+ tracklistText.text += currentLevel.getSongDisplayNames(currentDifficultyId).join('\n');
+
+ tracklistText.screenCenter(X);
+ tracklistText.x -= FlxG.width * 0.35;
+
+ // TODO: Fix this.
+ highScore = Highscore.getWeekScore(0, 0);
+ }
+}
diff --git a/source/funkin/util/tools/MapTools.hx b/source/funkin/util/tools/MapTools.hx
index daedb4aab..430b7bc81 100644
--- a/source/funkin/util/tools/MapTools.hx
+++ b/source/funkin/util/tools/MapTools.hx
@@ -9,13 +9,27 @@ package funkin.util.tools;
*/
class MapTools
{
+ /**
+ * Return the quantity of keys in the map.
+ */
+ public static function size(map:Map):Int
+ {
+ return map.keys().array().length;
+ }
+
+ /**
+ * Return a list of values from the map, as an array.
+ */
public static function values(map:Map):Array
{
return [for (i in map.iterator()) i];
}
+ /**
+ * Return a list of keys from the map (as an array, rather than an iterator).
+ */
public static function keyValues(map:Map):Array
{
- return [for (i in map.keys()) i];
+ return map.keys().array();
}
}