package funkin.play.song; import funkin.audio.VoicesGroup; import funkin.audio.FunkinSound; import funkin.data.IRegistryEntry; import funkin.data.song.SongData.SongCharacterData; import funkin.data.song.SongData.SongChartData; import funkin.data.song.SongData.SongEventData; import funkin.data.song.SongData.SongMetadata; import funkin.data.song.SongData.SongNoteData; import funkin.data.song.SongData.SongOffsets; import funkin.data.song.SongData.SongTimeChange; import funkin.data.song.SongData.SongTimeFormat; import funkin.data.song.SongRegistry; import funkin.modding.IScriptedClass.IPlayStateScriptedClass; import funkin.modding.events.ScriptEvent; import funkin.ui.freeplay.charselect.PlayableCharacter; import funkin.util.SortUtil; import openfl.utils.Assets; /** * This is a data structure managing information about the current song. * This structure is created when the game starts, and includes all the data * from the `metadata.json` file. * It also includes the chart data, but only when this is the currently loaded song. * * It also receives script events; scripted classes which extend this class * can be used to perform custom gameplay behaviors only on specific songs. */ @:nullSafety class Song implements IPlayStateScriptedClass implements IRegistryEntry { /** * The default value for the song's name */ public static final DEFAULT_SONGNAME:String = 'Unknown'; /** * The default value for the song's artist */ public static final DEFAULT_ARTIST:String = 'Unknown'; /** * The default value for the song's time format */ public static final DEFAULT_TIMEFORMAT:SongTimeFormat = SongTimeFormat.MILLISECONDS; /** * The default value for the song's divisions */ public static final DEFAULT_DIVISIONS:Null = null; /** * The default value for whether the song loops. */ public static final DEFAULT_LOOPED:Bool = false; /** * The default value for the song's playable stage. */ public static final DEFAULT_STAGE:String = 'mainStage'; /** * The default value for the song's scroll speed. */ public static final DEFAULT_SCROLLSPEED:Float = 1.0; /** * The internal ID of the song. */ public final id:String; /** * Song metadata as parsed from the JSON file. * This is the data for the `default` variation specifically, * and is needed for the IRegistryEntry interface. * Will only be null if the song data could not be loaded. */ public final _data:Null; // key = variation id, value = metadata final _metadata:Map; final difficulties:Map; /** * The list of variations a song has. */ public var variations(get, never):Array; function get_variations():Array { return _metadata.keys().array(); } // this returns false so that any new song can override this and return true when needed public function isSongNew(currentDifficulty:String):Bool { return false; } /** * Set to false if the song was edited in the charter and should not be saved as a high score. */ public var validScore:Bool = true; /** * The readable name of the song. */ public var songName(get, never):String; function get_songName():String { if (_data != null) return _data?.songName ?? DEFAULT_SONGNAME; if (_metadata.size() > 0) return _metadata.get(Constants.DEFAULT_VARIATION)?.songName ?? DEFAULT_SONGNAME; return DEFAULT_SONGNAME; } /** * The artist of the song. */ public var songArtist(get, never):String; function get_songArtist():String { if (_data != null) return _data?.artist ?? DEFAULT_ARTIST; if (_metadata.size() > 0) return _metadata.get(Constants.DEFAULT_VARIATION)?.artist ?? DEFAULT_ARTIST; return DEFAULT_ARTIST; } /** * The artist of the song. */ public var charter(get, never):String; function get_charter():String { if (_data != null) return _data?.charter ?? 'Unknown'; if (_metadata.size() > 0) return _metadata.get(Constants.DEFAULT_VARIATION)?.charter ?? 'Unknown'; return Constants.DEFAULT_CHARTER; } /** * @param id The ID of the song to load. * @param ignoreErrors If false, an exception will be thrown if the song data could not be loaded. */ public function new(id:String) { this.id = id; difficulties = new Map(); _data = _fetchData(id); _metadata = _data == null ? [] : [Constants.DEFAULT_VARIATION => _data]; if (_data != null && _data.playData != null) { for (vari in _data.playData.songVariations) { var variMeta:Null = fetchVariationMetadata(id, vari); if (variMeta != null) { _metadata.set(variMeta.variation, variMeta); trace(' Loaded variation: $vari'); } else { FlxG.log.warn('[SONG] Failed to load variation metadata (${id}:${vari}), is the path correct?'); trace(' FAILED to load variation: $vari'); } } } if (_metadata.size() == 0) { trace('[WARN] Could not find song data for songId: $id'); return; } populateDifficulties(); } /** * Build a song from existing metadata rather than loading it from the `assets` folder. * Used by the Chart Editor. * * @param songId The ID of the song. * @param metadata The metadata of the song. * @param variations The list of variations this song has. * @param charts The chart data for each variation. * @param includeScript Whether to initialize the scripted class tied to the song, if it exists. * @param validScore Whether the song is elegible for highscores. * @return The constructed song object. */ public static function buildRaw(songId:String, metadata:Array, variations:Array, charts:Map, includeScript:Bool = true, validScore:Bool = false):Song { @:privateAccess var result:Null; if (includeScript && SongRegistry.instance.isScriptedEntry(songId)) { var songClassName:String = SongRegistry.instance.getScriptedEntryClassName(songId); @:privateAccess result = SongRegistry.instance.createScriptedEntry(songClassName); } else { @:privateAccess result = SongRegistry.instance.createEntry(songId); } if (result == null) throw 'ERROR: Could not build Song instance ($songId), is the attached script bad?'; result._metadata.clear(); for (meta in metadata) { result._metadata.set(meta.variation, meta); } result.difficulties.clear(); result.populateDifficulties(); for (variation => chartData in charts) { result.applyChartData(chartData, variation); } result.validScore = validScore; return result; } /** * Retrieve a list of the raw metadata for the song. * @return The metadata JSON objects for the song's variations. */ public function getRawMetadata():Array { return _metadata.values(); } /** * List the album IDs for each variation of the song. * @return A map of variation IDs to album IDs. */ public function listAlbums():Map { var result:Map = new Map(); for (difficultyId in difficulties.keys()) { var meta:Null = difficulties.get(difficultyId); if (meta != null && meta.album != null) { result.set(difficultyId, meta.album); } } return result; } /** * Populate the difficulty data from the provided metadata. * Does not load chart data (that is triggered later when we want to play the song). */ function populateDifficulties():Void { if (_metadata == null || _metadata.size() == 0) return; // Variations may have different artist, time format, generatedBy, etc. for (metadata in _metadata.values()) { if (metadata == null || metadata.playData == null) continue; // If there are no difficulties in the metadata, there's a problem. if (metadata.playData.difficulties.length == 0) { trace('[SONG] Warning: Song $id (variation ${metadata.variation}) has no difficulties listed in metadata!'); continue; } // There may be more difficulties in the chart file than in the metadata, // (i.e. non-playable charts like the one used for Pico on the speaker in Stress) // but all the difficulties in the metadata must be in the chart file. for (diffId in metadata.playData.difficulties) { var difficulty:SongDifficulty = new SongDifficulty(this, diffId, metadata.variation); difficulty.songName = metadata.songName; difficulty.songArtist = metadata.artist; difficulty.charter = metadata.charter ?? Constants.DEFAULT_CHARTER; difficulty.timeFormat = metadata.timeFormat; difficulty.divisions = metadata.divisions; difficulty.timeChanges = metadata.timeChanges; difficulty.looped = metadata.looped; difficulty.generatedBy = metadata.generatedBy; difficulty.offsets = metadata?.offsets ?? new SongOffsets(); difficulty.difficultyRating = metadata.playData.ratings.get(diffId) ?? 0; difficulty.album = metadata.playData.album; difficulty.stage = metadata.playData.stage; difficulty.noteStyle = metadata.playData.noteStyle; difficulty.characters = metadata.playData.characters; var variationSuffix = (metadata.variation != Constants.DEFAULT_VARIATION) ? '-${metadata.variation}' : ''; difficulties.set('$diffId$variationSuffix', difficulty); } } } /** * Parse and cache the chart for all difficulties of this song. * @param force Whether to forcibly clear the list of charts first. */ public function cacheCharts(force:Bool = false):Void { if (force) { clearCharts(); } trace('Caching ${variations.length} chart files for song $id'); for (variation in variations) { var version:Null = SongRegistry.instance.fetchEntryChartVersion(id, variation); if (version == null) continue; var chart:Null = SongRegistry.instance.parseEntryChartDataWithMigration(id, variation, version); if (chart == null) continue; applyChartData(chart, variation); } trace('Done caching charts.'); } function applyChartData(chartData:SongChartData, variation:String):Void { var chartNotes = chartData.notes; for (diffId in chartNotes.keys()) { // Retrieve the cached difficulty data. var variationSuffix = (variation != Constants.DEFAULT_VARIATION) ? '-$variation' : ''; var difficulty:Null = difficulties.get('$diffId$variationSuffix'); if (difficulty == null) { trace('Fabricated new difficulty for $diffId.'); difficulty = new SongDifficulty(this, diffId, variation); var metadata = _metadata.get(variation); difficulties.set('$diffId$variationSuffix', difficulty); if (metadata != null) { difficulty.songName = metadata.songName; difficulty.songArtist = metadata.artist; difficulty.charter = metadata.charter ?? Constants.DEFAULT_CHARTER; difficulty.timeFormat = metadata.timeFormat; difficulty.divisions = metadata.divisions; difficulty.timeChanges = metadata.timeChanges; difficulty.looped = metadata.looped; difficulty.generatedBy = metadata.generatedBy; difficulty.offsets = metadata?.offsets ?? new SongOffsets(); difficulty.stage = metadata.playData.stage; difficulty.noteStyle = metadata.playData.noteStyle; difficulty.characters = metadata.playData.characters; } } // Add the chart data to the difficulty. difficulty.notes = chartNotes.get(diffId) ?? []; difficulty.scrollSpeed = chartData.getScrollSpeed(diffId) ?? 1.0; difficulty.events = chartData.events; } } /** * Retrieve the metadata for a specific difficulty, including the chart if it is loaded. * @param diffId The difficulty ID, such as `easy` or `hard`. * @param variation The variation ID to fetch the difficulty for. Or you can use `variations`. * @param variations A list of variations to fetch the difficulty for. Looks for the first variation that exists. * @return The difficulty data. */ public function getDifficulty(?diffId:String, ?variation:String, ?variations:Array):Null { if (diffId == null) diffId = listDifficulties(variation, variations)[0]; if (variation == null) variation = Constants.DEFAULT_VARIATION; if (variations == null) variations = [variation]; for (currentVariation in variations) { var variationSuffix = (currentVariation != Constants.DEFAULT_VARIATION) ? '-$currentVariation' : ''; if (difficulties.exists('$diffId$variationSuffix')) { return difficulties.get('$diffId$variationSuffix'); } } return null; } public function getFirstValidVariation(?diffId:String, ?currentCharacter:PlayableCharacter, ?possibleVariations:Array):Null { if (possibleVariations == null) { possibleVariations = getVariationsByCharacter(currentCharacter); possibleVariations.sort(SortUtil.defaultsThenAlphabetically.bind(Constants.DEFAULT_VARIATION_LIST)); } if (diffId == null) diffId = listDifficulties(null, possibleVariations)[0]; for (variationId in possibleVariations) { var variationSuffix = (variationId != Constants.DEFAULT_VARIATION) ? '-$variationId' : ''; if (difficulties.exists('$diffId$variationSuffix')) return variationId; } return null; } /** * Given that this character is selected in the Freeplay menu, * which variations should be available? * @param char The playable character to query. * @return An array of available variations. */ public function getVariationsByCharacter(?char:PlayableCharacter):Array { if (char == null) return variations; var result = []; trace('Evaluating variations for ${this.id} ${char.id}: ${this.variations}'); for (variation in variations) { var metadata = _metadata.get(variation); var playerCharId = metadata?.playData?.characters?.player; if (playerCharId == null) continue; if (char.shouldShowCharacter(playerCharId)) { result.push(variation); } } return result; } /** * List all the difficulties in this song. * * @param variationId Optionally filter by a single variation. * @param variationIds Optionally filter by multiple variations. * @param showLocked Include charts which are not unlocked * @param showHidden Include charts which are not accessible to the player. * * @return The list of difficulties. */ public function listDifficulties(?variationId:String, ?variationIds:Array, showLocked:Bool = false, showHidden:Bool = false):Array { if (variationIds == null) variationIds = []; if (variationId != null) variationIds.push(variationId); if (variationIds.length == 0) return []; // The difficulties array contains entries like 'normal', 'nightmare-erect', and 'normal-pico', // so we have to map it to the actual difficulty names. // We also filter out difficulties that don't match the variation or that don't exist. var diffFiltered:Array = difficulties.keys() .array() .map(function(diffId:String):Null { var difficulty:Null = difficulties.get(diffId); if (difficulty == null) return null; if (variationIds.length > 0 && !variationIds.contains(difficulty.variation)) return null; return difficulty.difficulty; }) .filterNull() .distinct(); diffFiltered = diffFiltered.filter(function(diffId:String):Bool { if (showHidden) return true; for (targetVariation in variationIds) { if (isDifficultyVisible(diffId, targetVariation)) return true; } return false; }); diffFiltered.sort(SortUtil.defaultsThenAlphabetically.bind(Constants.DEFAULT_DIFFICULTY_LIST)); return diffFiltered; } public function listSuffixedDifficulties(variationIds:Array, ?showLocked:Bool, ?showHidden:Bool):Array { var result = []; for (variation in variationIds) { var difficulties = listDifficulties(variation, null, showLocked, showHidden); for (difficulty in difficulties) { var suffixedDifficulty = (variation != Constants.DEFAULT_VARIATION && variation != 'erect') ? '$difficulty-${variation}' : difficulty; result.push(suffixedDifficulty); } } return result; } public function hasDifficulty(diffId:String, ?variationId:String, ?variationIds:Array):Bool { if (variationIds == null) variationIds = []; if (variationId != null) variationIds.push(variationId); for (targetVariation in variationIds) { var variationSuffix = (targetVariation != Constants.DEFAULT_VARIATION) ? '-$targetVariation' : ''; if (difficulties.exists('$diffId$variationSuffix')) return true; } return false; } public function isDifficultyVisible(diffId:String, variationId:String):Bool { var variation = _metadata.get(variationId); if (variation == null) return false; return variation.playData.difficulties.contains(diffId); } /** * Return the list of available alternate instrumentals. * Scripts can override this, fun. * @param variationId * @param difficultyId */ public function listAltInstrumentalIds(difficultyId:String, variationId:String):Array { var targetDifficulty:Null = getDifficulty(difficultyId, variationId); if (targetDifficulty == null) return []; return targetDifficulty?.characters?.altInstrumentals ?? []; } public function getBaseInstrumentalId(difficultyId:String, variationId:String):String { var targetDifficulty:Null = getDifficulty(difficultyId, variationId); if (targetDifficulty == null) return ''; return targetDifficulty?.characters?.instrumental ?? ''; } /** * Purge the cached chart data for each difficulty of this song. */ public function clearCharts():Void { for (diff in difficulties) { diff.clearChart(); } } public function toString():String { return 'Song($id)'; } public function destroy():Void {} public function onPause(event:PauseScriptEvent):Void {}; public function onResume(event:ScriptEvent):Void {}; public function onSongLoaded(event:SongLoadScriptEvent):Void {}; public function onSongStart(event:ScriptEvent):Void {}; public function onSongEnd(event:ScriptEvent):Void {}; public function onGameOver(event:ScriptEvent):Void {}; public function onSongRetry(event:ScriptEvent):Void {}; public function onNoteIncoming(event:NoteScriptEvent) {} public function onNoteHit(event:HitNoteScriptEvent) {} public function onNoteMiss(event:NoteScriptEvent):Void {}; public function onNoteGhostMiss(event:GhostMissNoteScriptEvent):Void {}; public function onSongEvent(event:SongEventScriptEvent):Void {}; public function onStepHit(event:SongTimeScriptEvent):Void {}; public function onBeatHit(event:SongTimeScriptEvent):Void {}; public function onCountdownStart(event:CountdownScriptEvent):Void {}; public function onCountdownStep(event:CountdownScriptEvent):Void {}; public function onCountdownEnd(event:CountdownScriptEvent):Void {}; public function onScriptEvent(event:ScriptEvent):Void {}; public function onCreate(event:ScriptEvent):Void {}; public function onDestroy(event:ScriptEvent):Void {}; public function onUpdate(event:UpdateScriptEvent):Void {}; static function _fetchData(id:String):Null { trace('Fetching song metadata for $id'); var version:Null = SongRegistry.instance.fetchEntryMetadataVersion(id); if (version == null) return null; return SongRegistry.instance.parseEntryMetadataWithMigration(id, Constants.DEFAULT_VARIATION, version); } function fetchVariationMetadata(id:String, vari:String):Null { var version:Null = SongRegistry.instance.fetchEntryMetadataVersion(id, vari); if (version == null) return null; var meta:Null = SongRegistry.instance.parseEntryMetadataWithMigration(id, vari, version); return meta; } } class SongDifficulty { /** * The parent song for this difficulty. */ public final song:Song; /** * The difficulty ID, such as `easy` or `hard`. */ public final difficulty:String; /** * The metadata file that contains this difficulty. */ public final variation:String; /** * The note chart for this difficulty. */ public var notes:Array; /** * The event chart for this difficulty. */ public var events:Array; public var songName:String = Constants.DEFAULT_SONGNAME; public var songArtist:String = Constants.DEFAULT_ARTIST; public var charter:String = Constants.DEFAULT_CHARTER; public var timeFormat:SongTimeFormat = Constants.DEFAULT_TIMEFORMAT; public var divisions:Null = null; public var looped:Bool = false; public var offsets:SongOffsets = new SongOffsets(); public var generatedBy:String = SongRegistry.DEFAULT_GENERATEDBY; public var timeChanges:Array = []; public var stage:String = Constants.DEFAULT_STAGE; public var noteStyle:String = Constants.DEFAULT_NOTE_STYLE; public var characters:SongCharacterData = null; public var scrollSpeed:Float = Constants.DEFAULT_SCROLLSPEED; public var difficultyRating:Int = 0; public var album:Null = null; public function new(song:Song, diffId:String, variation:String) { this.song = song; this.difficulty = diffId; this.variation = variation; } public function clearChart():Void { notes = null; } public function getStartingBPM():Float { if (timeChanges.length == 0) { return 0; } return timeChanges[0].bpm; } public function getEvents():Array { return cast events; } public function getInstPath(instrumental = ''):String { if (characters != null) { if (instrumental != '' && characters.altInstrumentals.contains(instrumental)) { var instId = '-$instrumental'; return Paths.inst(this.song.id, instId); } else { // Fallback to default instrumental. var instId = (characters.instrumental ?? '') != '' ? '-${characters.instrumental}' : ''; return Paths.inst(this.song.id, instId); } } else { return Paths.inst(this.song.id); } } public function cacheInst(instrumental = ''):Void { FlxG.sound.cache(getInstPath(instrumental)); } public function playInst(volume:Float = 1.0, instId:String = '', looped:Bool = false):Void { var suffix:String = (instId != '') ? '-$instId' : ''; FlxG.sound.music = FunkinSound.load(Paths.inst(this.song.id, suffix), volume, looped, false, true); // Workaround for a bug where FlxG.sound.music.update() was being called twice. FlxG.sound.list.remove(FlxG.sound.music); } /** * Cache the vocals for a given character. * @param id The character we are about to play. */ public function cacheVocals():Void { for (voice in buildVoiceList()) { trace('Caching vocal track: $voice'); FlxG.sound.cache(voice); } } /** * Build a list of vocal files for the given character. * Automatically resolves suffixed character IDs (so bf-car will resolve to bf if needed). * * @param id The character we are about to play. */ public function buildVoiceList():Array { var result:Array = []; result = result.concat(buildPlayerVoiceList()); result = result.concat(buildOpponentVoiceList()); if (result.length == 0) { var suffix:String = (variation != null && variation != '' && variation != 'default') ? '-$variation' : ''; // Try to use `Voices.ogg` if no other voices are found. if (Assets.exists(Paths.voices(this.song.id, ''))) result.push(Paths.voices(this.song.id, '$suffix')); } return result; } public function buildPlayerVoiceList():Array { var suffix:String = (variation != null && variation != '' && variation != 'default') ? '-$variation' : ''; // Automatically resolve voices by removing suffixes. // For example, if `Voices-bf-car-erect.ogg` does not exist, check for `Voices-bf-erect.ogg`. // Then, check for `Voices-bf-car.ogg`, then `Voices-bf.ogg`. if (characters.playerVocals == null) { var playerId:String = characters.player; var playerVoice:String = Paths.voices(this.song.id, '-${playerId}$suffix'); while (playerVoice != null && !Assets.exists(playerVoice)) { // Remove the last suffix. // For example, bf-car becomes bf. playerId = playerId.split('-').slice(0, -1).join('-'); // Try again. playerVoice = playerId == '' ? null : Paths.voices(this.song.id, '-${playerId}$suffix'); } if (playerVoice == null) { // Try again without $suffix. playerId = characters.player; playerVoice = Paths.voices(this.song.id, '-${playerId}'); while (playerVoice != null && !Assets.exists(playerVoice)) { // Remove the last suffix. playerId = playerId.split('-').slice(0, -1).join('-'); // Try again. playerVoice = playerId == '' ? null : Paths.voices(this.song.id, '-${playerId}$suffix'); } } return playerVoice != null ? [playerVoice] : []; } else { // The metadata explicitly defines the list of voices. var playerIds:Array = characters?.playerVocals ?? [characters.player]; var playerVoices:Array = playerIds.map((id) -> Paths.voices(this.song.id, '-$id$suffix')); return playerVoices; } } public function buildOpponentVoiceList():Array { var suffix:String = (variation != null && variation != '' && variation != 'default') ? '-$variation' : ''; // Automatically resolve voices by removing suffixes. // For example, if `Voices-bf-car-erect.ogg` does not exist, check for `Voices-bf-erect.ogg`. // Then, check for `Voices-bf-car.ogg`, then `Voices-bf.ogg`. if (characters.opponentVocals == null) { var opponentId:String = characters.opponent; var opponentVoice:String = Paths.voices(this.song.id, '-${opponentId}$suffix'); while (opponentVoice != null && !Assets.exists(opponentVoice)) { // Remove the last suffix. opponentId = opponentId.split('-').slice(0, -1).join('-'); // Try again. opponentVoice = opponentId == '' ? null : Paths.voices(this.song.id, '-${opponentId}$suffix'); } if (opponentVoice == null) { // Try again without $suffix. opponentId = characters.opponent; opponentVoice = Paths.voices(this.song.id, '-${opponentId}'); while (opponentVoice != null && !Assets.exists(opponentVoice)) { // Remove the last suffix. opponentId = opponentId.split('-').slice(0, -1).join('-'); // Try again. opponentVoice = opponentId == '' ? null : Paths.voices(this.song.id, '-${opponentId}$suffix'); } } return opponentVoice != null ? [opponentVoice] : []; } else { // The metadata explicitly defines the list of voices. var opponentIds:Array = characters?.opponentVocals ?? [characters.opponent]; var opponentVoices:Array = opponentIds.map((id) -> Paths.voices(this.song.id, '-$id$suffix')); return opponentVoices; } } /** * Create a VoicesGroup, an audio object that can play the vocals for all characters. * @param charId The player ID. * @return The generated vocal group. */ public function buildVocals(?instId:String = ''):VoicesGroup { var result:VoicesGroup = new VoicesGroup(); var playerVoiceList:Array = this.buildPlayerVoiceList(); var opponentVoiceList:Array = this.buildOpponentVoiceList(); // Add player vocals. for (playerVoice in playerVoiceList) { result.addPlayerVoice(FunkinSound.load(playerVoice)); } // Add opponent vocals. for (opponentVoice in opponentVoiceList) { result.addOpponentVoice(FunkinSound.load(opponentVoice)); } result.playerVoicesOffset = offsets.getVocalOffset(characters.player, instId); result.opponentVoicesOffset = offsets.getVocalOffset(characters.opponent, instId); return result; } }