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(); } }