Work in progress on story menu data storage rework

This commit is contained in:
EliteMasterEric 2023-05-16 22:09:53 -04:00
parent 03cab4a326
commit 021f7a0a1c
13 changed files with 925 additions and 4 deletions

View file

@ -86,6 +86,11 @@
"type": "haxelib",
"version": null
},
{
"name": "json2object",
"type": "haxelib",
"version": null
},
{
"name": "lime",
"type": "git",

View file

@ -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;

View file

@ -0,0 +1,162 @@
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<T:(IRegistryEntry<J> & Constructible<EntryConstructorFunction>), J>
{
public final registryId:String;
final dataFilePath:String;
final entries:Map<String, T>;
// public abstract static final instance:BaseRegistry<T, J> = 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<String, T>();
}
public function loadEntries():Void
{
clearEntries();
//
// SCRIPTED ENTRIES
//
var scriptedEntryClassNames:Array<String> = 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<String> = DataAssets.listDataFilesInPath(dataFilePath);
var unscriptedEntryIds:Array<String> = 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<String>
{
return entries.keys().array();
}
public function countEntries():Int
{
return entries.size();
}
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<J>;
/**
* Retrieve the list of scripted class names to load.
* @return An array of scripted class names.
*/
abstract function getScriptedClassNames():Array<String>;
/**
* Create an entry from the given ID.
* @param id
*/
function createEntry(id:String):Null<T>
{
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<T>;
}

View file

@ -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<T>
{
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<T>;
}

View file

@ -0,0 +1,83 @@
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(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<LevelPropData>;
@:default(["bopeebo"])
var songs:Array<String>;
@: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;
/**
* 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.0 = every beat
*/
@:default(1.0)
@:optional
var danceEvery:Float;
/**
* The offset on the position to render the prop at.
* @default [0.0, 0.0]
*/
@:default([0, 0])
@:optional
var offset:Array<Float>;
/**
* A set of animations to play on the prop.
* If default/empty, the prop will be static.
*/
@:default([])
@:optional
var animations:Array<AnimationData>;
}

View file

@ -0,0 +1,75 @@
package funkin.data.level;
import funkin.ui.story.Level;
import funkin.data.level.LevelData;
import funkin.ui.story.ScriptedLevel;
class LevelRegistry extends BaseRegistry<Level, LevelData>
{
/**
* 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<LevelData>
{
// JsonParser does not take type parameters,
// otherwise this function would be in BaseRegistry.
var parser = new json2object.JsonParser<LevelData>();
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<String>
{
return ScriptedLevel.listScriptClasses();
}
/**
* A list of all the story weeks, in order.
* TODO: Should this be hardcoded?
*/
public function listDefaultLevelIds():String
{
return [
"tutorial",
"week1",
"week2",
"week3",
"week4",
"week5",
"week6",
"week7",
"weekend1"
]
}
}

View file

@ -136,6 +136,11 @@ class Song // implements IPlayStateScriptedClass
return difficulties.get(diffId);
}
public function listDifficulties():Array<String>
{
return difficulties.keys().array();
}
/**
* Purge the cached chart data for each difficulty of this song.
*/

View file

@ -0,0 +1,162 @@
package funkin.ui.story;
import flixel.FlxSprite;
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<LevelData>
{
/**
* 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<String>
*/
public function getSongs():Array<String>
{
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<String>
*/
public function getSongDisplayNames(difficulty:String):Array<String>
{
return getSongs().map(function(songId) {
return funkin.play.song.SongData.SongDataParser.fetchSong(songId).getDifficulty(difficulty).songName;
});
}
/**
* 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<String>
{
var difficulties:Array<String> = [];
var songList = getSongs();
var firstSongId:String = songList[0];
var firstSong:Song = funkin.play.song.SongData.SongDataParser.fetchSong(firstSongId);
for (difficulty in firstSong.getDifficulties())
{
difficulties.push(difficulty);
}
// Filter to only include difficulties that are present in all songs
for (songId in 1...songList.length)
{
var song:Song = funkin.play.song.SongData.SongDataParser.fetchSong(songId);
for (difficulty in difficulties)
{
if (!song.hasDifficulty(difficulty))
{
difficulties.remove(difficulty);
}
}
}
return difficulties;
}
public function buildProps():Array<LevelProp>
{
var props:Array<LevelProp> = [];
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;
}
}
public function destroy():Void {}
public function toString():String
{
return 'Level($id)';
}
public function _fetchData(id:String):Null<LevelData>
{
return LevelRegistry.instance.parseEntryData(id);
}
}

View file

@ -0,0 +1,51 @@
package funkin.ui.story;
class LevelProp extends Bopper
{
public function new(danceEvery:Int)
{
super(danceEvery);
}
public static function build(propData:LevelPropData):Null<LevelProp>
{
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;
}
prop.scale.set(propData.scale * (propData.isPixel ? 6 : 1));
prop.updateHitbox();
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]);
}
return prop;
}
}

View file

@ -0,0 +1,78 @@
package funkin.ui.story;
import funkin.CoolUtil;
import flixel.FlxSprite;
import flixel.graphics.frames.FlxAtlasFrames;
import flixel.group.FlxSpriteGroup;
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;
}
public override function create():Void
{
super.create();
buildLevelTitle();
buildLevelLock();
}
// 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 * 120) + 480, 0.17);
if (isFlashing) flashingInt += 1;
if (flashingInt % fakeFramerate >= Math.floor(fakeFramerate / 2)) week.color = 0xFF33ffff;
else
week.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);
}
}

View file

@ -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 {}

View file

@ -0,0 +1,258 @@
package funkin.ui.story;
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 highScore:Int = 42069420;
var highScoreLerp:Int = 12345678;
var exitingMenu:Bool = false;
var selectedWeek: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 title of the week in the middle.
*/
var levelTitles:FlxTypedGroup<LevelTitle>;
/**
* The props in the center.
*/
var levelProps:FlxTypedGroup<LevelProp>;
/**
* 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;
public function new(?stickers:StickerSubState = null)
{
if (stickers != null)
{
stickerSubState = stickers;
}
super();
}
override function create():Void
{
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;
// Explicitly define the background color.
this.bgColor = FlxColor.BLACK;
levelTitles = new FlxTypedGroup<LevelTitle>();
add(levelTitles);
levelBackground = new FlxSprite(0, 56).makeGraphic(FlxG.width, BACKGROUND_HEIGHT, DEFAULT_BACKGROUND_COLOR);
add(levelBackground);
levelProps = new FlxTypedGroup<LevelProp>();
add(levelProps);
scoreText = new FlxText(10, 10, 0, 'HIGH SCORE: 42069420');
scoreText.setFormat("VCR OSD Mono", 32);
add(scoreText);
tracklistText = new FlxText(FlxG.width * 0.05, yellowBG.x + yellowBG.height + 100, 0, "Tracks", 32);
tracklistText.alignment = CENTER;
tracklistText.font = rankText.font;
tracklistText.color = 0xFFe55777;
add(tracklistText);
levelTitleText = new FlxText(FlxG.width * 0.7, 10, 0, 'WEEK 1');
levelTitleText.setFormat("VCR OSD Mono", 32, FlxColor.WHITE, RIGHT);
levelTitleText.alpha = 0.7;
add(levelTitleText);
buildLevelTitles(false);
leftArrow = new FlxSprite(grpWeekText.members[0].x + grpWeekText.members[0].width + 10, grpWeekText.members[0].y + 10);
leftArrow.frames = Paths.getSparrowAtlas('storymenu/ui/arrows');
leftArrow.animation.addByPrefix('idle', 'leftIdle0');
leftArrow.animation.addByPrefix('press', 'leftConfirm0');
leftArrow.animation.play('idle');
add(leftArrow);
rightArrow = new FlxSprite(sprDifficulty.x + sprDifficulty.width + 50, leftArrow.y);
rightArrow.frames = leftArrow.frames;
rightArrow.animation.addByPrefix('idle', 'rightIdle0');
rightArrow.animation.addByPrefix('press', 'rightConfirm0');
rightArrow.animation.play('idle');
add(rightArrow);
difficultySprite = buildDifficultySprite();
changeDifficulty();
add(difficultySprite);
#if discord_rpc
// Updating Discord Rich Presence
DiscordClient.changePresence("In the Menus", null);
#end
}
function buildDifficultySprite():Void
{
difficultySprite = new FlxSprite(leftArrow.x + 130, leftArrow.y);
difficultySprite.frames = ui_tex;
difficultySprite.animation.addByPrefix('easy', 'EASY');
difficultySprite.animation.addByPrefix('normal', 'NORMAL');
difficultySprite.animation.addByPrefix('hard', 'HARD');
difficultySprite.animation.play('easy');
}
function buildLevelTitles(moddedLevels:Bool):Void
{
levelTitles.clear();
var levelIds:Array<String> = LevelRegistry.instance.getLevelIds();
for (levelIndex in 0...levelIds.length)
{
var levelId:String = levelIds[levelIndex];
var level:Level = LevelRegistry.instance.fetchEntry(levelId);
var levelTitleItem:LevelTitle = new LevelTitle(0, yellowBG.y + yellowBG.height + 10, level);
levelTitleItem.targetY = ((weekThing.height + 20) * levelIndex);
levelTitles.add(levelTitleItem);
}
}
override function update(elapsed:Float)
{
highScoreLerp = CoolUtil.coolLerp(highScoreLerp, highScore, 0.5);
scoreText.text = 'WEEK SCORE: ${Math.round(highScoreLerp)}';
txtWeekTitle.text = weekNames[curWeek].toUpperCase();
txtWeekTitle.x = FlxG.width - (txtWeekTitle.width + 10);
handleKeyPresses();
super.update(elapsed);
}
function handleKeyPresses():Void
{
if (!exitingMenu)
{
if (!selectedWeek)
{
if (controls.UI_UP_P)
{
changeLevel(-1);
}
if (controls.UI_DOWN_P)
{
changeLevel(1);
}
if (controls.UI_RIGHT)
{
rightArrow.animation.play('press')
}
else
{
rightArrow.animation.play('idle');
}
if (controls.UI_LEFT)
{
leftArrow.animation.play('press');
}
else
{
leftArrow.animation.play('idle');
}
if (controls.UI_RIGHT_P)
{
changeDifficulty(1);
}
if (controls.UI_LEFT_P)
{
changeDifficulty(-1);
}
}
if (controls.ACCEPT)
{
selectWeek();
}
}
if (controls.BACK && !exitingMenu && !selectedWeek)
{
FlxG.sound.play(Paths.sound('cancelMenu'));
exitingMenu = true;
FlxG.switchState(new MainMenuState());
}
}
function changeLevel(change:Int = 0):Void {}
function changeDifficulty(change:Int = 0):Void {}
}

View file

@ -9,13 +9,27 @@ package funkin.util.tools;
*/
class MapTools
{
/**
* Return the quantity of keys in the map.
*/
public static function size<K, T>(map:Map<K, T>):Int
{
return map.keys().array().length;
}
/**
* Return a list of values from the map, as an array.
*/
public static function values<K, T>(map:Map<K, T>):Array<T>
{
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<K, T>(map:Map<K, T>):Array<K>
{
return [for (i in map.keys()) i];
return map.keys().array();
}
}