1
0
Fork 0
mirror of https://github.com/ninjamuffin99/Funkin.git synced 2024-11-15 11:22:55 +00:00

Implemented playable character registry, added Freeplay character filtering, added alt instrumental support

This commit is contained in:
EliteMasterEric 2024-06-18 17:56:24 -04:00
parent 6f2f2a9aa4
commit 60e741434c
12 changed files with 433 additions and 38 deletions

2
assets

@ -1 +1 @@
Subproject commit 2e1594ee4c04c7148628bae471bdd061c9deb6b7
Subproject commit fece99b3b121045fb2f6f02dba485201b32f1c87

View file

@ -1,5 +1,6 @@
package funkin;
import funkin.data.freeplay.player.PlayerRegistry;
import funkin.ui.debug.charting.ChartEditorState;
import funkin.ui.transition.LoadingState;
import flixel.FlxState;
@ -164,6 +165,7 @@ class InitState extends FlxState
SongRegistry.instance.loadEntries();
LevelRegistry.instance.loadEntries();
NoteStyleRegistry.instance.loadEntries();
PlayerRegistry.instance.loadEntries();
ConversationRegistry.instance.loadEntries();
DialogueBoxRegistry.instance.loadEntries();
SpeakerRegistry.instance.loadEntries();

View file

@ -0,0 +1,9 @@
# Freeplay Playable Character Data Schema Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.0]
Initial release.

View file

@ -0,0 +1,63 @@
package funkin.data.freeplay.player;
import funkin.data.animation.AnimationData;
@:nullSafety
class PlayerData
{
/**
* The sematic version number of the player data JSON format.
* Supports fancy comparisons like NPM does it's neat.
*/
@:default(funkin.data.freeplay.player.PlayerRegistry.PLAYER_DATA_VERSION)
public var version:String;
/**
* A readable name for this playable character.
*/
public var name:String = 'Unknown';
/**
* The character IDs this character is associated with.
* Only songs that use these characters will show up in Freeplay.
*/
@:default([])
public var ownedChars:Array<String> = [];
/**
* Whether to show songs with character IDs that aren't associated with any specific character.
*/
@:optional
@:default(false)
public var showUnownedChars:Bool = false;
/**
* Whether this character is unlocked by default.
* Use a ScriptedPlayableCharacter to add custom logic.
*/
@:optional
@:default(true)
public var unlocked:Bool = true;
public function new()
{
this.version = PlayerRegistry.PLAYER_DATA_VERSION;
}
/**
* Convert this StageData into a JSON string.
*/
public function serialize(pretty:Bool = true):String
{
// Update generatedBy and version before writing.
updateVersionToLatest();
var writer = new json2object.JsonWriter<PlayerData>();
return writer.write(this, pretty ? ' ' : null);
}
public function updateVersionToLatest():Void
{
this.version = PlayerRegistry.PLAYER_DATA_VERSION;
}
}

View file

@ -0,0 +1,151 @@
package funkin.data.freeplay.player;
import funkin.data.freeplay.player.PlayerData;
import funkin.ui.freeplay.charselect.PlayableCharacter;
import funkin.ui.freeplay.charselect.ScriptedPlayableCharacter;
class PlayerRegistry extends BaseRegistry<PlayableCharacter, PlayerData>
{
/**
* The current version string for the stage data format.
* Handle breaking changes by incrementing this value
* and adding migration to the `migratePlayerData()` function.
*/
public static final PLAYER_DATA_VERSION:thx.semver.Version = "1.0.0";
public static final PLAYER_DATA_VERSION_RULE:thx.semver.VersionRule = "1.0.x";
public static var instance(get, never):PlayerRegistry;
static var _instance:Null<PlayerRegistry> = null;
static function get_instance():PlayerRegistry
{
if (_instance == null) _instance = new PlayerRegistry();
return _instance;
}
/**
* A mapping between stage character IDs and Freeplay playable character IDs.
*/
var ownedCharacterIds:Map<String, String> = [];
public function new()
{
super('PLAYER', 'players', PLAYER_DATA_VERSION_RULE);
}
public override function loadEntries():Void
{
super.loadEntries();
for (playerId in listEntryIds())
{
var player = fetchEntry(playerId);
if (player == null) continue;
var currentPlayerCharIds = player.getOwnedCharacterIds();
for (characterId in currentPlayerCharIds)
{
ownedCharacterIds.set(characterId, playerId);
}
}
log('Loaded ${countEntries()} playable characters with ${ownedCharacterIds.size()} associations.');
}
/**
* Get the playable character associated with a given stage character.
* @param characterId The stage character ID.
* @return The playable character.
*/
public function getCharacterOwnerId(characterId:String):String
{
return ownedCharacterIds[characterId];
}
/**
* Return true if the given stage character is associated with a specific playable character.
* If so, the level should only appear if that character is selected in Freeplay.
* @param characterId The stage character ID.
* @return Whether the character is owned by any one character.
*/
public function isCharacterOwned(characterId:String):Bool
{
return ownedCharacterIds.exists(characterId);
}
/**
* Read, parse, and validate the JSON data and produce the corresponding data object.
*/
public function parseEntryData(id:String):Null<PlayerData>
{
// JsonParser does not take type parameters,
// otherwise this function would be in BaseRegistry.
var parser = new json2object.JsonParser<PlayerData>();
parser.ignoreUnknownVariables = false;
switch (loadEntryFile(id))
{
case {fileName: fileName, contents: contents}:
parser.fromJson(contents, fileName);
default:
return null;
}
if (parser.errors.length > 0)
{
printErrors(parser.errors, id);
return null;
}
return parser.value;
}
/**
* Parse and validate the JSON data and produce the corresponding data object.
*
* NOTE: Must be implemented on the implementation class.
* @param contents The JSON as a string.
* @param fileName An optional file name for error reporting.
*/
public function parseEntryDataRaw(contents:String, ?fileName:String):Null<PlayerData>
{
var parser = new json2object.JsonParser<PlayerData>();
parser.ignoreUnknownVariables = false;
parser.fromJson(contents, fileName);
if (parser.errors.length > 0)
{
printErrors(parser.errors, fileName);
return null;
}
return parser.value;
}
function createScriptedEntry(clsName:String):PlayableCharacter
{
return ScriptedPlayableCharacter.init(clsName, "unknown");
}
function getScriptedClassNames():Array<String>
{
return ScriptedPlayableCharacter.listScriptClasses();
}
/**
* A list of all the playable characters from the base game, in order.
*/
public function listBaseGamePlayerIds():Array<String>
{
return ["bf", "pico"];
}
/**
* A list of all installed playable characters that are not from the base game.
*/
public function listModdedPlayerIds():Array<String>
{
return listEntryIds().filter(function(id:String):Bool {
return listBaseGamePlayerIds().indexOf(id) == -1;
});
}
}

View file

@ -8,6 +8,7 @@ import funkin.data.event.SongEventRegistry;
import funkin.data.story.level.LevelRegistry;
import funkin.data.notestyle.NoteStyleRegistry;
import funkin.data.song.SongRegistry;
import funkin.data.freeplay.player.PlayerRegistry;
import funkin.data.stage.StageRegistry;
import funkin.data.freeplay.album.AlbumRegistry;
import funkin.modding.module.ModuleHandler;
@ -369,15 +370,18 @@ class PolymodHandler
// These MUST be imported at the top of the file and not referred to by fully qualified name,
// to ensure build macros work properly.
SongEventRegistry.loadEventCache();
SongRegistry.instance.loadEntries();
LevelRegistry.instance.loadEntries();
NoteStyleRegistry.instance.loadEntries();
SongEventRegistry.loadEventCache();
PlayerRegistry.instance.loadEntries();
ConversationRegistry.instance.loadEntries();
DialogueBoxRegistry.instance.loadEntries();
SpeakerRegistry.instance.loadEntries();
AlbumRegistry.instance.loadEntries();
StageRegistry.instance.loadEntries();
CharacterDataParser.loadCharacterCache(); // TODO: Migrate characters to BaseRegistry.
ModuleHandler.loadModuleCache();
}

View file

@ -14,6 +14,7 @@ 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;
@ -401,11 +402,11 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
return null;
}
public function getFirstValidVariation(?diffId:String, ?possibleVariations:Array<String>):Null<String>
public function getFirstValidVariation(?diffId:String, ?currentCharacter:PlayableCharacter, ?possibleVariations:Array<String>):Null<String>
{
if (possibleVariations == null)
{
possibleVariations = variations;
possibleVariations = getVariationsByCharacter(currentCharacter);
possibleVariations.sort(SortUtil.defaultsThenAlphabetically.bind(Constants.DEFAULT_VARIATION_LIST));
}
if (diffId == null) diffId = listDifficulties(null, possibleVariations)[0];
@ -422,22 +423,29 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
/**
* Given that this character is selected in the Freeplay menu,
* which variations should be available?
* @param charId The character ID to query.
* @param char The playable character to query.
* @return An array of available variations.
*/
public function getVariationsByCharId(?charId:String):Array<String>
public function getVariationsByCharacter(?char:PlayableCharacter):Array<String>
{
if (charId == null) charId = Constants.DEFAULT_CHARACTER;
if (char == null) return variations;
if (variations.contains(charId))
var result = [];
trace('Evaluating variations for ${this.id} ${char.id}: ${this.variations}');
for (variation in variations)
{
return [charId];
}
else
{
// TODO: How to exclude character variations while keeping other custom variations?
return 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;
}
/**
@ -455,6 +463,8 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
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.

View file

@ -808,8 +808,11 @@ class ChartEditorDialogHandler
}
songVariationMetadataEntry.onClick = onClickMetadataVariation.bind(variation).bind(songVariationMetadataEntryLabel);
#if FILE_DROP_SUPPORTED
state.addDropHandler({component: songVariationMetadataEntry, handler: onDropFileMetadataVariation.bind(variation)
.bind(songVariationMetadataEntryLabel)});
state.addDropHandler(
{
component: songVariationMetadataEntry,
handler: onDropFileMetadataVariation.bind(variation).bind(songVariationMetadataEntryLabel)
});
#end
chartContainerB.addComponent(songVariationMetadataEntry);

View file

@ -23,6 +23,7 @@ import flixel.util.FlxTimer;
import funkin.audio.FunkinSound;
import funkin.data.story.level.LevelRegistry;
import funkin.data.song.SongRegistry;
import funkin.data.freeplay.player.PlayerRegistry;
import funkin.graphics.FunkinCamera;
import funkin.graphics.FunkinSprite;
import funkin.graphics.shaders.AngleMask;
@ -47,6 +48,7 @@ import lime.utils.Assets;
import flixel.tweens.misc.ShakeTween;
import funkin.effects.IntervalShake;
import funkin.ui.freeplay.SongMenuItem.FreeplayRank;
import funkin.ui.freeplay.charselect.PlayableCharacter;
/**
* Parameters used to initialize the FreeplayState.
@ -102,7 +104,9 @@ class FreeplayState extends MusicBeatSubState
* The current character for this FreeplayState.
* You can't change this without transitioning to a new FreeplayState.
*/
final currentCharacter:String;
final currentCharacterId:String;
final currentCharacter:PlayableCharacter;
/**
* For the audio preview, the duration of the fade-in effect.
@ -205,7 +209,8 @@ class FreeplayState extends MusicBeatSubState
public function new(?params:FreeplayStateParams, ?stickers:StickerSubState)
{
currentCharacter = params?.character ?? Constants.DEFAULT_CHARACTER;
currentCharacterId = params?.character ?? Constants.DEFAULT_CHARACTER;
currentCharacter = PlayerRegistry.instance.fetchEntry(currentCharacterId);
fromResultsParams = params?.fromResults;
@ -290,11 +295,10 @@ class FreeplayState extends MusicBeatSubState
}
// Only display songs which actually have available difficulties for the current character.
var displayedVariations = song.getVariationsByCharId(currentCharacter);
trace(songId);
trace(displayedVariations);
var displayedVariations = song.getVariationsByCharacter(currentCharacter);
trace('Displayed Variations (${songId}): $displayedVariations');
var availableDifficultiesForSong:Array<String> = song.listDifficulties(displayedVariations, false);
trace(availableDifficultiesForSong);
trace('Available Difficulties: $availableDifficultiesForSong');
if (availableDifficultiesForSong.length == 0) continue;
songs.push(new FreeplaySongData(levelId, songId, song, displayedVariations));
@ -454,7 +458,7 @@ class FreeplayState extends MusicBeatSubState
});
// TODO: Replace this.
if (currentCharacter == 'pico') dj.visible = false;
if (currentCharacterId == 'pico') dj.visible = false;
add(dj);
@ -1195,6 +1199,16 @@ class FreeplayState extends MusicBeatSubState
rankAnimStart(fromResultsParams);
}
if (FlxG.keys.justPressed.P)
{
FlxG.switchState(FreeplayState.build(
{
{
character: currentCharacterId == "pico" ? "bf" : "pico",
}
}));
}
// if (FlxG.keys.justPressed.H)
// {
// rankDisplayNew(fromResultsParams);
@ -1302,9 +1316,9 @@ class FreeplayState extends MusicBeatSubState
{
if (busy) return;
var upP:Bool = controls.UI_UP_P && !FlxG.keys.pressed.CONTROL;
var downP:Bool = controls.UI_DOWN_P && !FlxG.keys.pressed.CONTROL;
var accepted:Bool = controls.ACCEPT && !FlxG.keys.pressed.CONTROL;
var upP:Bool = controls.UI_UP_P;
var downP:Bool = controls.UI_DOWN_P;
var accepted:Bool = controls.ACCEPT;
if (FlxG.onMobile)
{
@ -1378,7 +1392,7 @@ class FreeplayState extends MusicBeatSubState
}
#end
if (!FlxG.keys.pressed.CONTROL && (controls.UI_UP || controls.UI_DOWN))
if ((controls.UI_UP || controls.UI_DOWN))
{
if (spamming)
{
@ -1440,13 +1454,13 @@ class FreeplayState extends MusicBeatSubState
}
#end
if (controls.UI_LEFT_P && !FlxG.keys.pressed.CONTROL)
if (controls.UI_LEFT_P)
{
dj.resetAFKTimer();
changeDiff(-1);
generateSongList(currentFilter, true);
}
if (controls.UI_RIGHT_P && !FlxG.keys.pressed.CONTROL)
if (controls.UI_RIGHT_P)
{
dj.resetAFKTimer();
changeDiff(1);
@ -1720,7 +1734,7 @@ class FreeplayState extends MusicBeatSubState
return;
}
var targetDifficultyId:String = currentDifficulty;
var targetVariation:String = targetSong.getFirstValidVariation(targetDifficultyId);
var targetVariation:String = targetSong.getFirstValidVariation(targetDifficultyId, currentCharacter);
PlayStatePlaylist.campaignId = cap.songData.levelId;
var targetDifficulty:SongDifficulty = targetSong.getDifficulty(targetDifficultyId, targetVariation);
@ -1730,8 +1744,18 @@ class FreeplayState extends MusicBeatSubState
return;
}
// TODO: Change this with alternate instrumentals
var targetInstId:String = targetDifficulty.characters.instrumental;
var baseInstrumentalId:String = targetDifficulty?.characters?.instrumental ?? '';
var altInstrumentalIds:Array<String> = targetDifficulty?.characters?.altInstrumentals ?? [];
var targetInstId:String = baseInstrumentalId;
// TODO: Make this a UI element.
#if (debug || FORCE_DEBUG_VERSION)
if (altInstrumentalIds.length > 0 && FlxG.keys.pressed.CONTROL)
{
targetInstId = altInstrumentalIds[0];
}
#end
// Visual and audio effects.
FunkinSound.playOnce(Paths.sound('confirmMenu'));
@ -1883,9 +1907,23 @@ class FreeplayState extends MusicBeatSubState
else
{
var previewSong:Null<Song> = SongRegistry.instance.fetchEntry(daSongCapsule.songData.songId);
var instSuffix:String = previewSong?.getDifficulty(currentDifficulty,
previewSong?.getVariationsByCharId(currentCharacter) ?? Constants.DEFAULT_VARIATION_LIST)?.characters?.instrumental ?? '';
var songDifficulty = previewSong?.getDifficulty(currentDifficulty,
previewSong?.getVariationsByCharacter(currentCharacter) ?? Constants.DEFAULT_VARIATION_LIST);
var baseInstrumentalId:String = songDifficulty?.characters?.instrumental ?? '';
var altInstrumentalIds:Array<String> = songDifficulty?.characters?.altInstrumentals ?? [];
var instSuffix:String = baseInstrumentalId;
// TODO: Make this a UI element.
#if (debug || FORCE_DEBUG_VERSION)
if (altInstrumentalIds.length > 0 && FlxG.keys.pressed.CONTROL)
{
instSuffix = altInstrumentalIds[0];
}
#end
instSuffix = (instSuffix != '') ? '-$instSuffix' : '';
FunkinSound.playMusic(daSongCapsule.songData.songId,
{
startingVolume: 0.0,
@ -1913,7 +1951,7 @@ class FreeplayState extends MusicBeatSubState
public static function build(?params:FreeplayStateParams, ?stickers:StickerSubState):MusicBeatState
{
var result:MainMenuState;
if (params?.fromResults.playRankAnim) result = new MainMenuState(true);
if (params?.fromResults?.playRankAnim) result = new MainMenuState(true);
else
result = new MainMenuState(false);
@ -1951,8 +1989,8 @@ class DifficultySelector extends FlxSprite
override function update(elapsed:Float):Void
{
if (flipX && controls.UI_RIGHT_P && !FlxG.keys.pressed.CONTROL) moveShitDown();
if (!flipX && controls.UI_LEFT_P && !FlxG.keys.pressed.CONTROL) moveShitDown();
if (flipX && controls.UI_RIGHT_P) moveShitDown();
if (!flipX && controls.UI_LEFT_P) moveShitDown();
super.update(elapsed);
}

View file

@ -0,0 +1,108 @@
package funkin.ui.freeplay.charselect;
import funkin.data.IRegistryEntry;
import funkin.data.freeplay.player.PlayerData;
import funkin.data.freeplay.player.PlayerRegistry;
/**
* An object used to retrieve data about a playable character (also known as "weeks").
* Can be scripted to override each function, for custom behavior.
*/
class PlayableCharacter implements IRegistryEntry<PlayerData>
{
/**
* The ID of the playable character.
*/
public final id:String;
/**
* Playable character data as parsed from the JSON file.
*/
public final _data:PlayerData;
/**
* @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 playable character data for id: $id';
}
}
/**
* Retrieve the readable name of the playable character.
*/
public function getName():String
{
// TODO: Maybe add localization support?
return _data.name;
}
/**
* Retrieve the list of stage character IDs associated with this playable character.
* @return The list of associated character IDs
*/
public function getOwnedCharacterIds():Array<String>
{
return _data.ownedChars;
}
/**
* Return `true` if, when this character is selected in Freeplay,
* songs unassociated with a specific character should appear.
*/
public function shouldShowUnownedChars():Bool
{
return _data.showUnownedChars;
}
public function shouldShowCharacter(id:String):Bool
{
if (_data.ownedChars.contains(id))
{
return true;
}
if (_data.showUnownedChars)
{
var result = !PlayerRegistry.instance.isCharacterOwned(id);
return result;
}
return false;
}
/**
* Returns whether this character is unlocked.
*/
public function isUnlocked():Bool
{
return _data.unlocked;
}
/**
* Called when the character is destroyed.
* TODO: Document when this gets called
*/
public function destroy():Void {}
public function toString():String
{
return 'PlayableCharacter($id)';
}
/**
* Retrieve and parse the JSON data for a playable character by ID.
* @param id The ID of the character
* @return The parsed player data, or null if not found or invalid
*/
static function _fetchData(id:String):Null<PlayerData>
{
return PlayerRegistry.instance.parseEntryDataWithMigration(id, PlayerRegistry.instance.fetchEntryVersion(id));
}
}

View file

@ -0,0 +1,8 @@
package funkin.ui.freeplay.charselect;
/**
* A script that can be tied to a PlayableCharacter.
* Create a scripted class that extends PlayableCharacter to use this.
*/
@:hscriptClass
class ScriptedPlayableCharacter extends funkin.ui.freeplay.charselect.PlayableCharacter implements polymod.hscript.HScriptedClass {}

View file

@ -24,7 +24,6 @@ class VersionUtil
try
{
var versionRaw:thx.semver.Version.SemVer = version;
trace('${versionRaw} satisfies (${versionRule})? ${version.satisfies(versionRule)}');
return version.satisfies(versionRule);
}
catch (e)