FNFC file rework (includes command line quicklaunch)

This commit is contained in:
EliteMasterEric 2023-10-21 01:04:50 -04:00
parent 8042f6c5a8
commit ffd0a98393
31 changed files with 1162 additions and 187 deletions

View File

@ -4,10 +4,7 @@
<app title="Friday Night Funkin'" file="Funkin" packageName="com.funkin.fnf" package="com.funkin.fnf" main="Main" version="0.3.0" company="ninjamuffin99" />
<!--Switch Export with Unique ApplicationID and Icon-->
<set name="APP_ID" value="0x0100f6c013bbc000" />
<!--The flixel preloader is not accurate in Chrome. You can use it regularly if you embed the swf into a html file
or you can set the actual size of your file manually at "FlxPreloaderBase-onUpdate-bytesTotal"-->
<!-- <app preloader="Preloader" resizable="true" /> -->
<app preloader="Preloader" />
<app preloader="funkin.Preloader" />
<!--Minimum without FLX_NO_GAMEPAD: 11.8, without FLX_NO_NATIVE_CURSOR: 11.2-->
<set name="SWF_VERSION" value="11.8" />
<!-- ____________________________ Window Settings ___________________________ -->

2
assets

@ -1 +1 @@
Subproject commit ef79a6cf1ae3dcbd86a5b798f8117a6c692c0156
Subproject commit 118b622953171aaf127cb160538e21bc468620e2

View File

@ -11,6 +11,7 @@ import openfl.display.Sprite;
import openfl.events.Event;
import openfl.Lib;
import openfl.media.Video;
import funkin.util.CLIUtil;
import openfl.net.NetStream;
class Main extends Sprite

View File

@ -1,5 +1,6 @@
package funkin;
import funkin.ui.debug.charting.ChartEditorState;
import flixel.FlxState;
import flixel.addons.transition.FlxTransitionableState;
import flixel.addons.transition.FlxTransitionSprite.GraphicTransTileDiamond;
@ -26,6 +27,8 @@ import funkin.play.stage.StageData.StageDataParser;
import funkin.play.character.CharacterData.CharacterDataParser;
import funkin.modding.module.ModuleHandler;
import funkin.ui.title.TitleState;
import funkin.util.CLIUtil;
import funkin.util.CLIUtil.CLIParams;
#if discord_rpc
import Discord.DiscordClient;
#end
@ -247,8 +250,21 @@ class InitState extends FlxState
*/
function startGameNormally():Void
{
FlxG.sound.cache(Paths.music('freakyMenu/freakyMenu'));
FlxG.switchState(new TitleState());
var params:CLIParams = CLIUtil.processArgs();
trace('Command line args: ${params}');
if (params.chart.shouldLoadChart)
{
FlxG.switchState(new ChartEditorState(
{
fnfcTargetPath: params.chart.chartPath,
}));
}
else
{
FlxG.sound.cache(Paths.music('freakyMenu/freakyMenu'));
FlxG.switchState(new TitleState());
}
}
/**

View File

@ -1,4 +1,4 @@
package;
package funkin;
import flash.Lib;
import flash.display.Bitmap;
@ -7,6 +7,7 @@ import flash.display.BlendMode;
import flash.display.Sprite;
import flixel.system.FlxBasePreloader;
import openfl.display.Sprite;
import funkin.util.CLIUtil;
@:bitmap("art/preloaderArt.png") class LogoImage extends BitmapData {}
@ -15,6 +16,8 @@ class Preloader extends FlxBasePreloader
public function new(MinDisplayTime:Float = 0, ?AllowedURLs:Array<String>)
{
super(MinDisplayTime, AllowedURLs);
CLIUtil.resetWorkingDir(); // Bug fix for drag-and-drop.
}
var logo:Sprite;

View File

@ -104,6 +104,22 @@ class DataParse
}
}
/**
* Parser which outputs a `Either<Float, Array<Float>>`.
*/
public static function eitherFloatOrFloats(json:Json, name:String):Null<Either<Float, Array<Float>>>
{
switch (json.value)
{
case JNumber(f):
return Either.Left(Std.parseFloat(f));
case JArray(fields):
return Either.Right(fields.map((field) -> cast Tools.getValue(field)));
default:
throw 'Expected property $name to be one or multiple floats, but it was ${json.value}.';
}
}
/**
* Parser which outputs a `Either<Float, LegacyScrollSpeeds>`.
* Used by the FNF legacy JSON importer.

View File

@ -3,11 +3,14 @@ package funkin.data;
import funkin.util.SerializerUtil;
import thx.semver.Version;
import thx.semver.VersionRule;
import haxe.ds.Either;
/**
* `json2object` has an annotation `@:jcustomwrite` which allows for custom serialization of values to be written to JSON.
*
* Functions must be of the signature `(T) -> String`, where `T` is the type of the property.
*
* NOTE: Result must include quotation marks if the value is a string! json2object will not add them for you!
*/
class DataWrite
{
@ -23,11 +26,12 @@ class DataWrite
}
/**
*
* `@:jcustomwrite(funkin.data.DataWrite.semverVersion)`
*/
public static function semverVersion(value:Version):String
{
return value.toString();
return '"${value.toString()}"';
}
/**
@ -35,6 +39,22 @@ class DataWrite
*/
public static function semverVersionRule(value:VersionRule):String
{
return value.toString();
return '"${value.toString()}"';
}
/**
* `@:jcustomwrite(funkin.data.DataWrite.eitherFloatOrFloats)`
*/
public static function eitherFloatOrFloats(value:Null<Either<Float, Array<Float>>>):String
{
switch (value)
{
case null:
return '${1.0}';
case Left(inner):
return '$inner';
case Right(inner):
return dynamicValue(inner);
}
}
}

View File

@ -59,7 +59,10 @@ typedef UnnamedAnimationData =
* The prefix for the frames of the animation as defined by the XML file.
* This will may or may not differ from the `name` of the animation,
* depending on how your animator organized their FLA or whatever.
*
* NOTE: For Sparrow animations, this is not optional, but for Packer animations it is.
*/
@:optional
var prefix:String;
/**

View File

@ -15,8 +15,6 @@ class NoteStyleRegistry extends BaseRegistry<NoteStyle, NoteStyleData>
public static final NOTE_STYLE_DATA_VERSION_RULE:thx.semver.VersionRule = "1.0.x";
public static final DEFAULT_NOTE_STYLE_ID:String = "funkin";
public static final instance:NoteStyleRegistry = new NoteStyleRegistry();
public function new()
@ -26,7 +24,7 @@ class NoteStyleRegistry extends BaseRegistry<NoteStyle, NoteStyleData>
public function fetchDefault():NoteStyle
{
return fetchEntry(DEFAULT_NOTE_STYLE_ID);
return fetchEntry(Constants.DEFAULT_NOTE_STYLE);
}
/**

View File

@ -1,9 +1,13 @@
package funkin.data.song;
import flixel.util.typeLimit.OneOfTwo;
import funkin.data.song.SongRegistry;
import thx.semver.Version;
/**
* Data containing information about a song.
* It should contain all the data needed to display a song in the Freeplay menu, or to load the assets required to play its chart.
* Data which is only necessary in-game should be stored in the SongChartData.
*/
@:nullSafety
class SongMetadata
{
@ -35,13 +39,11 @@ class SongMetadata
*/
public var playData:SongPlayData;
// @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY)
@:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY)
public var generatedBy:String;
// @:default(funkin.data.song.SongData.SongTimeFormat.MILLISECONDS)
public var timeFormat:SongTimeFormat;
// @:default(funkin.data.song.SongData.SongTimeChange.DEFAULT_SONGTIMECHANGES)
public var timeChanges:Array<SongTimeChange>;
/**
@ -64,7 +66,7 @@ class SongMetadata
this.playData.difficulties = [];
this.playData.characters = new SongCharacterData('bf', 'gf', 'dad');
this.playData.stage = 'mainStage';
this.playData.noteSkin = 'funkin';
this.playData.noteStyle = Constants.DEFAULT_NOTE_STYLE;
this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY;
// Variation ID.
this.variation = (variation == null) ? Constants.DEFAULT_VARIATION : variation;
@ -298,23 +300,27 @@ class SongPlayData
/**
* The note style used by this song.
* TODO: Rename to `noteStyle`? Renaming values is a breaking change to the metadata format.
*/
public var noteSkin:String;
public var noteStyle:String;
/**
* The difficulty rating for this song as displayed in Freeplay.
* TODO: Adding this is a non-breaking change to the metadata format.
* The difficulty ratings for this song as displayed in Freeplay.
* Key is a difficulty ID or `default`.
*/
// public var rating:Int;
@:default(['default' => 1])
public var ratings:Map<String, Int>;
/**
* The album ID for the album to display in Freeplay.
* TODO: Adding this is a non-breaking change to the metadata format.
* If `null`, display no album.
*/
// public var album:String;
@:optional
public var album:Null<String>;
public function new() {}
public function new()
{
ratings = new Map<String, Int>();
}
/**
* Produces a string representation suitable for debugging.

View File

@ -2,6 +2,7 @@ package funkin.data.song;
import funkin.data.song.SongData;
import funkin.data.song.migrator.SongData_v2_0_0.SongMetadata_v2_0_0;
import funkin.data.song.migrator.SongData_v2_1_0.SongMetadata_v2_1_0;
import funkin.data.song.SongData.SongChartData;
import funkin.data.song.SongData.SongMetadata;
import funkin.play.song.ScriptedSong;
@ -18,9 +19,9 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
* Handle breaking changes by incrementing this value
* and adding migration to the `migrateStageData()` function.
*/
public static final SONG_METADATA_VERSION:thx.semver.Version = "2.1.0";
public static final SONG_METADATA_VERSION:thx.semver.Version = "2.2.0";
public static final SONG_METADATA_VERSION_RULE:thx.semver.VersionRule = "2.1.x";
public static final SONG_METADATA_VERSION_RULE:thx.semver.VersionRule = "2.2.x";
public static final SONG_CHART_DATA_VERSION:thx.semver.Version = "2.0.0";
@ -165,6 +166,10 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
{
return parseEntryMetadata(id, variation);
}
else if (VersionUtil.validateVersion(version, "2.1.x"))
{
return parseEntryMetadata_v2_1_0(id, variation);
}
else if (VersionUtil.validateVersion(version, "2.0.x"))
{
return parseEntryMetadata_v2_0_0(id, variation);
@ -182,6 +187,10 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
{
return parseEntryMetadataRaw(contents, fileName);
}
else if (VersionUtil.validateVersion(version, "2.1.x"))
{
return parseEntryMetadataRaw_v2_1_0(contents, fileName);
}
else if (VersionUtil.validateVersion(version, "2.0.x"))
{
return parseEntryMetadataRaw_v2_0_0(contents, fileName);
@ -192,12 +201,12 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
}
}
function parseEntryMetadata_v2_0_0(id:String, ?variation:String):Null<SongMetadata>
function parseEntryMetadata_v2_1_0(id:String, ?variation:String):Null<SongMetadata>
{
variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
var parser = new json2object.JsonParser<SongMetadata_v2_0_0>();
switch (loadEntryMetadataFile(id))
var parser = new json2object.JsonParser<SongMetadata_v2_1_0>();
switch (loadEntryMetadataFile(id, variation))
{
case {fileName: fileName, contents: contents}:
parser.fromJson(contents, fileName);
@ -209,6 +218,39 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
printErrors(parser.errors, id);
return null;
}
return cleanMetadata(parser.value.migrate(), variation);
}
function parseEntryMetadata_v2_0_0(id:String, ?variation:String):Null<SongMetadata>
{
variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
var parser = new json2object.JsonParser<SongMetadata_v2_0_0>();
switch (loadEntryMetadataFile(id, variation))
{
case {fileName: fileName, contents: contents}:
parser.fromJson(contents, fileName);
default:
return null;
}
if (parser.errors.length > 0)
{
printErrors(parser.errors, id);
return null;
}
return cleanMetadata(parser.value.migrate(), variation);
}
function parseEntryMetadataRaw_v2_1_0(contents:String, ?fileName:String = 'raw'):Null<SongMetadata>
{
var parser = new json2object.JsonParser<SongMetadata_v2_1_0>();
parser.fromJson(contents, fileName);
if (parser.errors.length > 0)
{
printErrors(parser.errors, fileName);
return null;
}
return parser.value.migrate();
}

View File

@ -0,0 +1,84 @@
package funkin.data.song.importer;
/**
* A helper JSON blob found in `.fnfc` files.
*/
class ChartManifestData
{
/**
* The current semantic version of the chart manifest data.
*/
public static final CHART_MANIFEST_DATA_VERSION:thx.semver.Version = "1.0.0";
@:default(funkin.data.song.importer.ChartManifestData.CHART_MANIFEST_DATA_VERSION)
@:jcustomparse(funkin.data.DataParse.semverVersion)
@:jcustomwrite(funkin.data.DataWrite.semverVersion)
public var version:thx.semver.Version;
/**
* The internal song ID for this chart.
* The metadata and chart data file names are derived from this.
*/
public var songId:String;
public function new(songId:String)
{
this.version = CHART_MANIFEST_DATA_VERSION;
this.songId = songId;
}
public function getMetadataFileName(?variation:String):String
{
if (variation == null || variation == '') variation = Constants.DEFAULT_VARIATION;
return '$songId-metadata${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}.${Constants.EXT_DATA}';
}
public function getChartDataFileName(?variation:String):String
{
if (variation == null || variation == '') variation = Constants.DEFAULT_VARIATION;
return '$songId-chart${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}.${Constants.EXT_DATA}';
}
public function getInstFileName(?variation:String):String
{
if (variation == null || variation == '') variation = Constants.DEFAULT_VARIATION;
return 'Inst${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}.${Constants.EXT_SOUND}';
}
public function getVocalsFileName(charId:String, ?variation:String):String
{
if (variation == null || variation == '') variation = Constants.DEFAULT_VARIATION;
return 'Voices-$charId${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}.${Constants.EXT_SOUND}';
}
/**
* Serialize this ChartManifestData into a JSON string.
* @return The JSON string.
*/
public function serialize(pretty:Bool = true):String
{
var writer = new json2object.JsonWriter<ChartManifestData>();
return writer.write(this, pretty ? ' ' : null);
}
public static function deserialize(contents:String):Null<ChartManifestData>
{
var parser = new json2object.JsonParser<ChartManifestData>();
parser.fromJson(contents, 'manifest.json');
if (parser.errors.length > 0)
{
trace('[ChartManifest] Failed to parse chart file manifest');
for (error in parser.errors)
DataError.printError(error);
return null;
}
return parser.value;
}
}

View File

@ -7,6 +7,8 @@ import funkin.data.song.migrator.SongData_v2_0_0.SongMetadata_v2_0_0;
import funkin.data.song.migrator.SongData_v2_0_0.SongPlayData_v2_0_0;
import funkin.data.song.migrator.SongData_v2_0_0.SongPlayableChar_v2_0_0;
using funkin.data.song.migrator.SongDataMigrator; // Does this even work lol?
/**
* This class contains functions to migrate older data formats to the current one.
*
@ -15,6 +17,48 @@ import funkin.data.song.migrator.SongData_v2_0_0.SongPlayableChar_v2_0_0;
*/
class SongDataMigrator
{
public static overload extern inline function migrate(input:SongData_v2_1_0.SongMetadata_v2_1_0):SongMetadata
{
return migrate_SongMetadata_v2_1_0(input);
}
public static function migrate_SongMetadata_v2_1_0(input:SongData_v2_1_0.SongMetadata_v2_1_0):SongMetadata
{
var result:SongMetadata = new SongMetadata(input.songName, input.artist, input.variation);
result.version = SongRegistry.SONG_METADATA_VERSION;
result.timeFormat = input.timeFormat;
result.divisions = input.divisions;
result.timeChanges = input.timeChanges;
result.looped = input.looped;
result.playData = input.playData.migrate();
result.generatedBy = input.generatedBy;
return result;
}
public static overload extern inline function migrate(input:SongData_v2_1_0.SongPlayData_v2_1_0):SongPlayData
{
return migrate_SongPlayData_v2_1_0(input);
}
public static function migrate_SongPlayData_v2_1_0(input:SongData_v2_1_0.SongPlayData_v2_1_0):SongPlayData
{
var result:SongPlayData = new SongPlayData();
result.songVariations = input.songVariations;
result.difficulties = input.difficulties;
result.stage = input.stage;
result.characters = input.characters;
// Renamed
result.noteStyle = input.noteSkin;
// Added
result.ratings = ['default' => 1];
result.album = null;
return result;
}
public static overload extern inline function migrate(input:SongData_v2_0_0.SongMetadata_v2_0_0):SongMetadata
{
return migrate_SongMetadata_v2_0_0(input);
@ -23,12 +67,12 @@ class SongDataMigrator
public static function migrate_SongMetadata_v2_0_0(input:SongData_v2_0_0.SongMetadata_v2_0_0):SongMetadata
{
var result:SongMetadata = new SongMetadata(input.songName, input.artist, input.variation);
result.version = input.version;
result.version = SongRegistry.SONG_METADATA_VERSION;
result.timeFormat = input.timeFormat;
result.divisions = input.divisions;
result.timeChanges = input.timeChanges;
result.looped = input.looped;
result.playData = migrate_SongPlayData_v2_0_0(input.playData);
result.playData = input.playData.migrate();
result.generatedBy = input.generatedBy;
return result;
@ -45,7 +89,13 @@ class SongDataMigrator
result.songVariations = input.songVariations;
result.difficulties = input.difficulties;
result.stage = input.stage;
result.noteSkin = input.noteSkin;
// Added
result.ratings = ['default' => 1];
result.album = null;
// Renamed
result.noteStyle = input.noteSkin;
// Fetch the first playable character and migrate it.
var firstCharKey:Null<String> = input.playableChars.size() == 0 ? null : input.playableChars.keys().array()[0];

View File

@ -42,6 +42,7 @@ class SongMetadata_v2_0_0
@:default(false)
public var looped:Bool;
@:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY)
public var generatedBy:String;
public var timeFormat:SongData.SongTimeFormat;
@ -70,6 +71,13 @@ class SongPlayData_v2_0_0
*/
public var playableChars:Map<String, SongPlayableChar_v2_0_0>;
/**
* In metadata version `v2.2.0`, this was renamed to `noteStyle`.
*/
public var noteSkin:String;
// In 2.2.0, the ratings value was added.
// In 2.2.0, the album value was added.
// ==========
// UNMODIFIED VALUES
// ==========
@ -77,7 +85,6 @@ class SongPlayData_v2_0_0
public var difficulties:Array<String>;
public var stage:String;
public var noteSkin:String;
public function new() {}

View File

@ -0,0 +1,108 @@
package funkin.data.song.migrator;
import funkin.data.song.SongData;
import funkin.data.song.SongRegistry;
import thx.semver.Version;
@:nullSafety
class SongMetadata_v2_1_0
{
// ==========
// MODIFIED VALUES
// ===========
/**
* In metadata `v2.2.0`, `SongPlayData` was refactored.
*/
public var playData:SongPlayData_v2_1_0;
// ==========
// UNMODIFIED VALUES
// ==========
@:jcustomparse(funkin.data.DataParse.semverVersion)
@:jcustomwrite(funkin.data.DataWrite.semverVersion)
public var version:Version;
@:default("Unknown")
public var songName:String;
@:default("Unknown")
public var artist:String;
@:optional
@:default(96)
public var divisions:Null<Int>; // Optional field
@:optional
@:default(false)
public var looped:Bool;
@:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY)
public var generatedBy:String;
public var timeFormat:SongData.SongTimeFormat;
public var timeChanges:Array<SongData.SongTimeChange>;
/**
* Defaults to `Constants.DEFAULT_VARIATION`. Populated later.
*/
@:jignored
public var variation:String;
public function new(songName:String, artist:String, ?variation:String)
{
this.version = SongRegistry.SONG_METADATA_VERSION;
this.songName = songName;
this.artist = artist;
this.timeFormat = 'ms';
this.divisions = null;
this.timeChanges = [new SongTimeChange(0, 100)];
this.looped = false;
this.playData = new SongPlayData_v2_1_0();
this.playData.songVariations = [];
this.playData.difficulties = [];
this.playData.characters = new SongCharacterData('bf', 'gf', 'dad');
this.playData.stage = 'mainStage';
this.playData.noteSkin = 'funkin';
this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY;
// Variation ID.
this.variation = (variation == null) ? Constants.DEFAULT_VARIATION : variation;
}
/**
* Produces a string representation suitable for debugging.
*/
public function toString():String
{
return 'SongMetadata[LEGACY:v2.1.0](${this.songName} by ${this.artist}, variation ${this.variation})';
}
}
class SongPlayData_v2_1_0
{
/**
* In `v2.2.0`, this value was renamed to `noteStyle`.
*/
public var noteSkin:String;
// In 2.2.0, the ratings value was added.
// In 2.2.0, the album value was added.
// ==========
// UNMODIFIED VALUES
// ==========
public var songVariations:Array<String>;
public var difficulties:Array<String>;
public var characters:SongData.SongCharacterData;
public var stage:String;
public function new() {}
/**
* Produces a string representation suitable for debugging.
*/
public function toString():String
{
return 'SongPlayData[LEGACY:v2.1.0](${this.songVariations}, ${this.difficulties})';
}
}

View File

@ -1471,7 +1471,7 @@ class PlayState extends MusicBeatSubState
{
case 'school': 'pixel';
case 'schoolEvil': 'pixel';
default: 'funkin';
default: Constants.DEFAULT_NOTE_STYLE;
}
var noteStyle:NoteStyle = NoteStyleRegistry.instance.fetchEntry(noteStyleId);
if (noteStyle == null) noteStyle = NoteStyleRegistry.instance.fetchDefault();
@ -2389,8 +2389,8 @@ class PlayState extends MusicBeatSubState
#if sys
// spitter for ravy, teehee!!
var output = SerializerUtil.toJSON(inputSpitter);
var writer = new json2object.JsonWriter<Array<ScoreInput>>();
var output = writer.write(inputSpitter, ' ');
sys.io.File.saveContent("./scores.json", output);
#end

View File

@ -96,11 +96,13 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
if (_data != null && _data.playData != null)
{
for (vari in _data.playData.songVariations)
{
variations.push(vari);
}
for (meta in fetchVariationMetadata(id))
_metadata.push(meta);
var variMeta = fetchVariationMetadata(id, vari);
if (variMeta != null) _metadata.push(variMeta);
}
}
if (_metadata.length == 0)
{
@ -178,7 +180,7 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
difficulty.generatedBy = metadata.generatedBy;
difficulty.stage = metadata.playData.stage;
difficulty.noteStyle = metadata.playData.noteSkin;
difficulty.noteStyle = metadata.playData.noteStyle;
difficulties.set(diffId, difficulty);
@ -337,17 +339,12 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
return SongRegistry.instance.parseEntryMetadataWithMigration(id, Constants.DEFAULT_VARIATION, version);
}
function fetchVariationMetadata(id:String):Array<SongMetadata>
function fetchVariationMetadata(id:String, vari:String):Null<SongMetadata>
{
var result:Array<SongMetadata> = [];
for (vari in variations)
{
var version:Null<thx.semver.Version> = SongRegistry.instance.fetchEntryMetadataVersion(id, vari);
if (version == null) continue;
var meta:Null<SongMetadata> = SongRegistry.instance.parseEntryMetadataWithMigration(id, vari, version);
if (meta != null) result.push(meta);
}
return result;
var version:Null<thx.semver.Version> = SongRegistry.instance.fetchEntryMetadataVersion(id, vari);
if (version == null) return null;
var meta:Null<SongMetadata> = SongRegistry.instance.parseEntryMetadataWithMigration(id, vari, version);
return meta;
}
}

View File

@ -176,13 +176,13 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
continue;
}
if (Std.isOfType(dataProp.scale, Array))
switch (dataProp.scale)
{
propSprite.scale.set(dataProp.scale[0], dataProp.scale[1]);
}
else
{
propSprite.scale.set(dataProp.scale);
case Left(value):
propSprite.scale.set(value);
case Right(values):
propSprite.scale.set(values[0], values[1]);
}
propSprite.updateHitbox();
@ -194,8 +194,15 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
// If pixel, disable antialiasing.
propSprite.antialiasing = !dataProp.isPixel;
propSprite.scrollFactor.x = dataProp.scroll[0];
propSprite.scrollFactor.y = dataProp.scroll[1];
switch (dataProp.scroll)
{
case Left(value):
propSprite.scrollFactor.x = value;
propSprite.scrollFactor.y = value;
case Right(values):
propSprite.scrollFactor.x = values[0];
propSprite.scrollFactor.y = values[1];
}
propSprite.zIndex = dataProp.zIndex;

View File

@ -1,7 +1,6 @@
package funkin.play.stage;
import funkin.data.animation.AnimationData;
import flixel.util.typeLimit.OneOfTwo;
import funkin.play.stage.ScriptedStage;
import funkin.play.stage.Stage;
import funkin.util.VersionUtil;
@ -157,15 +156,26 @@ class StageDataParser
return rawJson;
}
static function migrateStageData(rawJson:String, stageId:String)
static function migrateStageData(rawJson:String, stageId:String):Null<StageData>
{
// If you update the stage data format in a breaking way,
// handle migration here by checking the `version` value.
try
{
var stageData:StageData = cast Json.parse(rawJson);
return stageData;
var parser = new json2object.JsonParser<StageData>();
parser.fromJson(rawJson, '$stageId.json');
if (parser.errors.length > 0)
{
trace('[STAGE] Failed to parse stage data');
for (error in parser.errors)
funkin.data.DataError.printError(error);
return null;
}
return parser.value;
}
catch (e)
{
@ -269,24 +279,29 @@ class StageDataParser
inputProp.danceEvery = DEFAULT_DANCEEVERY;
}
if (inputProp.scale == null)
{
inputProp.scale = DEFAULT_SCALE;
}
if (inputProp.animType == null)
{
inputProp.animType = DEFAULT_ANIMTYPE;
}
if (Std.isOfType(inputProp.scale, Float))
switch (inputProp.scale)
{
inputProp.scale = [inputProp.scale, inputProp.scale];
case null:
inputProp.scale = Right([DEFAULT_SCALE, DEFAULT_SCALE]);
case Left(value):
inputProp.scale = Right([value, value]);
case Right(_):
// Do nothing
}
if (inputProp.scroll == null)
switch (inputProp.scroll)
{
inputProp.scroll = DEFAULT_SCROLL;
case null:
inputProp.scroll = Right(DEFAULT_SCROLL);
case Left(value):
inputProp.scroll = Right([value, value]);
case Right(_):
// Do nothing
}
if (inputProp.alpha == null)
@ -294,11 +309,6 @@ class StageDataParser
inputProp.alpha = DEFAULT_ALPHA;
}
if (Std.isOfType(inputProp.scroll, Float))
{
inputProp.scroll = [inputProp.scroll, inputProp.scroll];
}
if (inputProp.animations == null)
{
inputProp.animations = [];
@ -392,23 +402,39 @@ class StageDataParser
}
}
typedef StageData =
class StageData
{
/**
* The sematic version number of the stage data JSON format.
* Supports fancy comparisons like NPM does it's neat.
*/
var version:String;
public var version:String;
var name:String;
var cameraZoom:Null<Float>;
var props:Array<StageDataProp>;
var characters:
{
bf:StageDataCharacter,
dad:StageDataCharacter,
gf:StageDataCharacter,
};
public var name:String;
public var cameraZoom:Null<Float>;
public var props:Array<StageDataProp>;
public var characters:StageDataCharacters;
public function new()
{
this.version = StageDataParser.STAGE_DATA_VERSION;
}
/**
* Convert this StageData into a JSON string.
*/
public function serialize(pretty:Bool = true):String
{
var writer = new json2object.JsonWriter<StageData>();
return writer.write(this, pretty ? ' ' : null);
}
}
typedef StageDataCharacters =
{
var bf:StageDataCharacter;
var dad:StageDataCharacter;
var gf:StageDataCharacter;
};
typedef StageDataProp =
@ -417,6 +443,7 @@ typedef StageDataProp =
* The name of the prop for later lookup by scripts.
* Optional; if unspecified, the prop can't be referenced by scripts.
*/
@:optional
var name:String;
/**
@ -435,27 +462,35 @@ typedef StageDataProp =
* This is just like CSS, it isn't hard.
* @default 0
*/
var zIndex:Null<Int>;
@:optional
@:default(0)
var zIndex:Int;
/**
* If set to true, anti-aliasing will be forcibly disabled on the sprite.
* This prevents blurry images on pixel-art levels.
* @default false
*/
var isPixel:Null<Bool>;
@:optional
@:default(false)
var isPixel:Bool;
/**
* Either the scale of the prop as a float, or the [w, h] scale as an array of two floats.
* Pro tip: On pixel-art levels, save the sprite small and set this value to 6 or so to save memory.
* @default 1
*/
var scale:OneOfTwo<Float, Array<Float>>;
@:jcustomparse(funkin.data.DataParse.eitherFloatOrFloats)
@:jcustomwrite(funkin.data.DataWrite.eitherFloatOrFloats)
@:optional
var scale:haxe.ds.Either<Float, Array<Float>>;
/**
* The alpha of the prop, as a float.
* @default 1.0
*/
var alpha:Null<Float>;
@:optional
@:default(1.0)
var alpha:Float;
/**
* If not zero, this prop will play an animation every X beats of the song.
@ -464,7 +499,9 @@ typedef StageDataProp =
*
* @default 0
*/
var danceEvery:Null<Int>;
@:default(0)
@:optional
var danceEvery:Int;
/**
* How much the prop scrolls relative to the camera. Used to create a parallax effect.
@ -474,25 +511,32 @@ typedef StageDataProp =
* [0, 0] means the prop is not moved.
* @default [0, 0]
*/
var scroll:OneOfTwo<Float, Array<Float>>;
@:jcustomparse(funkin.data.DataParse.eitherFloatOrFloats)
@:jcustomwrite(funkin.data.DataWrite.eitherFloatOrFloats)
@:optional
var scroll:haxe.ds.Either<Float, Array<Float>>;
/**
* An optional array of animations which the prop can play.
* @default Prop has no animations.
*/
@:optional
var animations:Array<AnimationData>;
/**
* If animations are used, this is the name of the animation to play first.
* @default Don't play an animation.
*/
var startingAnimation:String;
@:optional
var startingAnimation:Null<String>;
/**
* The animation type to use.
* Options: "sparrow", "packer"
* @default "sparrow"
*/
@:default("sparrow")
@:optional
var animType:String;
};
@ -503,16 +547,22 @@ typedef StageDataCharacter =
* Again, just like CSS.
* @default 0
*/
?zIndex:Int,
@:optional
@:default(0)
var zIndex:Int;
/**
* The position to render the character at.
*/
position:Array<Float>,
@:optional
@:default([0, 0])
var position:Array<Float>;
/**
* The camera offsets to apply when focusing on the character on this stage.
* @default [-100, -100] for BF, [100, -100] for DAD/OPPONENT, [0, 0] for GF
*/
cameraOffsets:Array<Float>,
@:optional
@:default([0, 0])
var cameraOffsets:Array<Float>;
};

View File

@ -13,8 +13,7 @@ class SaveDataMigrator
*/
public static function migrate(inputData:Dynamic):Save
{
// This deserializes directly into a `Version` object, not a `String`.
var version:Null<Version> = inputData?.version ?? null;
var version:Null<thx.semver.Version> = VersionUtil.parseVersion(inputData?.version ?? null);
if (version == null)
{
@ -24,7 +23,7 @@ class SaveDataMigrator
}
else
{
if (VersionUtil.validateVersionStr(version, Save.SAVE_DATA_VERSION_RULE))
if (VersionUtil.validateVersion(version, Save.SAVE_DATA_VERSION_RULE))
{
// Simply cast the structured data.
var save:Save = inputData;

View File

@ -265,7 +265,7 @@ class ChartEditorAudioHandler
{
var data:Null<Bytes> = state.audioVocalTrackData.get(key);
if (data == null) continue;
zipEntries.push(FileUtil.makeZIPEntryFromBytes('Vocals-${key}.ogg', data));
zipEntries.push(FileUtil.makeZIPEntryFromBytes('Voices-${key}.ogg', data));
}
return zipEntries;

View File

@ -182,8 +182,8 @@ class SelectItemsCommand implements ChartEditorCommand
state.currentEventSelection.push(event);
}
state.noteDisplayDirty = true;
state.notePreviewDirty = true;
// state.noteDisplayDirty = true;
// state.notePreviewDirty = true;
}
public function undo(state:ChartEditorState):Void
@ -191,8 +191,8 @@ class SelectItemsCommand implements ChartEditorCommand
state.currentNoteSelection = SongDataUtils.subtractNotes(state.currentNoteSelection, this.notes);
state.currentEventSelection = SongDataUtils.subtractEvents(state.currentEventSelection, this.events);
state.noteDisplayDirty = true;
state.notePreviewDirty = true;
// state.noteDisplayDirty = true;
// state.notePreviewDirty = true;
}
public function toString():String
@ -452,8 +452,8 @@ class DeselectItemsCommand implements ChartEditorCommand
state.currentNoteSelection = SongDataUtils.subtractNotes(state.currentNoteSelection, this.notes);
state.currentEventSelection = SongDataUtils.subtractEvents(state.currentEventSelection, this.events);
state.noteDisplayDirty = true;
state.notePreviewDirty = true;
// state.noteDisplayDirty = true;
// state.notePreviewDirty = true;
}
public function undo(state:ChartEditorState):Void
@ -468,8 +468,8 @@ class DeselectItemsCommand implements ChartEditorCommand
state.currentEventSelection.push(event);
}
state.noteDisplayDirty = true;
state.notePreviewDirty = true;
// state.noteDisplayDirty = true;
// state.notePreviewDirty = true;
}
public function toString():String

View File

@ -51,12 +51,13 @@ class ChartEditorDialogHandler
{
static final CHART_EDITOR_DIALOG_ABOUT_LAYOUT:String = Paths.ui('chart-editor/dialogs/about');
static final CHART_EDITOR_DIALOG_WELCOME_LAYOUT:String = Paths.ui('chart-editor/dialogs/welcome');
static final CHART_EDITOR_DIALOG_UPLOAD_CHART_LAYOUT:String = Paths.ui('chart-editor/dialogs/upload-chart');
static final CHART_EDITOR_DIALOG_UPLOAD_INST_LAYOUT:String = Paths.ui('chart-editor/dialogs/upload-inst');
static final CHART_EDITOR_DIALOG_SONG_METADATA_LAYOUT:String = Paths.ui('chart-editor/dialogs/song-metadata');
static final CHART_EDITOR_DIALOG_UPLOAD_VOCALS_LAYOUT:String = Paths.ui('chart-editor/dialogs/upload-vocals');
static final CHART_EDITOR_DIALOG_UPLOAD_VOCALS_ENTRY_LAYOUT:String = Paths.ui('chart-editor/dialogs/upload-vocals-entry');
static final CHART_EDITOR_DIALOG_OPEN_CHART_LAYOUT:String = Paths.ui('chart-editor/dialogs/open-chart');
static final CHART_EDITOR_DIALOG_OPEN_CHART_ENTRY_LAYOUT:String = Paths.ui('chart-editor/dialogs/open-chart-entry');
static final CHART_EDITOR_DIALOG_OPEN_CHART_PARTS_LAYOUT:String = Paths.ui('chart-editor/dialogs/open-chart-parts');
static final CHART_EDITOR_DIALOG_OPEN_CHART_PARTS_ENTRY_LAYOUT:String = Paths.ui('chart-editor/dialogs/open-chart-parts-entry');
static final CHART_EDITOR_DIALOG_IMPORT_CHART_LAYOUT:String = Paths.ui('chart-editor/dialogs/import-chart');
static final CHART_EDITOR_DIALOG_USER_GUIDE_LAYOUT:String = Paths.ui('chart-editor/dialogs/user-guide');
static final CHART_EDITOR_DIALOG_ADD_VARIATION_LAYOUT:String = Paths.ui('chart-editor/dialogs/add-variation');
@ -83,6 +84,11 @@ class ChartEditorDialogHandler
var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_WELCOME_LAYOUT, true, closable);
if (dialog == null) throw 'Could not locate Welcome dialog';
dialog.onDialogClosed = function(_event) {
// Called when the Welcome dialog is closed while it is closable.
state.stopWelcomeMusic();
}
// Create New Song "Easy/Normal/Hard"
var linkCreateBasic:Null<Link> = dialog.findComponent('splashCreateFromSongBasic', Link);
if (linkCreateBasic == null) throw 'Could not locate splashCreateFromSongBasic link in Welcome dialog';
@ -129,7 +135,7 @@ class ChartEditorDialogHandler
state.stopWelcomeMusic();
// Open the "Open Chart" dialog
openBrowseWizard(state, false);
openBrowseFNFC(state, false);
}
var splashTemplateContainer:Null<VBox> = dialog.findComponent('splashTemplateContainer', VBox);
@ -168,6 +174,126 @@ class ChartEditorDialogHandler
return dialog;
}
public static function openBrowseFNFC(state:ChartEditorState, closable:Bool):Null<Dialog>
{
var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_UPLOAD_CHART_LAYOUT, true, closable);
if (dialog == null) throw 'Could not locate Upload Chart dialog';
dialog.onDialogClosed = function(_event) {
if (_event.button == DialogButton.APPLY)
{
// Simply let the dialog close.
}
else
{
// User cancelled the wizard! Back to the welcome dialog.
openWelcomeDialog(state);
}
};
var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
if (buttonCancel == null) throw 'Could not locate dialogCancel button in Upload Chart dialog';
buttonCancel.onClick = function(_event) {
dialog.hideDialog(DialogButton.CANCEL);
}
var chartBox:Null<Box> = dialog.findComponent('chartBox', Box);
if (chartBox == null) throw 'Could not locate chartBox in Upload Chart dialog';
chartBox.onMouseOver = function(_event) {
chartBox.swapClass('upload-bg', 'upload-bg-hover');
Cursor.cursorMode = Pointer;
}
chartBox.onMouseOut = function(_event) {
chartBox.swapClass('upload-bg-hover', 'upload-bg');
Cursor.cursorMode = Default;
}
var onDropFile:String->Void;
chartBox.onClick = function(_event) {
Dialogs.openBinaryFile('Open Chart', [
{label: 'Friday Night Funkin\' Chart (.fnfc)', extension: 'fnfc'}], function(selectedFile:SelectedFileInfo) {
if (selectedFile != null && selectedFile.bytes != null)
{
try
{
if (ChartEditorImportExportHandler.loadFromFNFC(state, selectedFile.bytes))
{
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Success',
body: 'Loaded chart (${selectedFile.name})',
type: NotificationType.Success,
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
});
#end
if (selectedFile.fullPath != null) state.currentWorkingFilePath = selectedFile.fullPath;
dialog.hideDialog(DialogButton.APPLY);
removeDropHandler(onDropFile);
}
}
catch (err)
{
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Failure',
body: 'Failed to load chart (${selectedFile.name}): ${err}',
type: NotificationType.Error,
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
});
#end
}
}
});
}
onDropFile = function(pathStr:String) {
var path:Path = new Path(pathStr);
trace('Dropped file (${path})');
try
{
if (ChartEditorImportExportHandler.loadFromFNFCPath(state, path.toString()))
{
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Success',
body: 'Loaded chart (${path.toString()})',
type: NotificationType.Success,
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
});
#end
dialog.hideDialog(DialogButton.APPLY);
removeDropHandler(onDropFile);
}
}
catch (err)
{
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Failure',
body: 'Failed to load chart (${path.toString()}): ${err}',
type: NotificationType.Error,
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
});
#end
}
};
addDropHandler(chartBox, onDropFile);
return dialog;
}
/**
* Open the wizard for opening an existing chart from individual files.
* @param state
@ -622,9 +748,9 @@ class ChartEditorDialogHandler
if (inputNoteStyle == null) throw 'Could not locate inputNoteStyle DropDown in Song Metadata dialog';
inputNoteStyle.onChange = function(event:UIEvent) {
if (event.data.id == null) return;
newSongMetadata.playData.noteSkin = event.data.id;
newSongMetadata.playData.noteStyle = event.data.id;
};
var startingValueNoteStyle = ChartEditorDropdowns.populateDropdownWithNoteStyles(inputNoteStyle, newSongMetadata.playData.noteSkin);
var startingValueNoteStyle = ChartEditorDropdowns.populateDropdownWithNoteStyles(inputNoteStyle, newSongMetadata.playData.noteStyle);
inputNoteStyle.value = startingValueNoteStyle;
var inputCharacterPlayer:Null<FunkinDropDown> = dialog.findComponent('inputCharacterPlayer', FunkinDropDown);
@ -765,9 +891,9 @@ class ChartEditorDialogHandler
});
#end
#if FILE_DROP_SUPPORTED
vocalsEntryLabel.text = 'Vocals for $charName (drag and drop, or click to browse)\nSelected file: ${path.file}.${path.ext}';
vocalsEntryLabel.text = 'Voices for $charName (drag and drop, or click to browse)\nSelected file: ${path.file}.${path.ext}';
#else
vocalsEntryLabel.text = 'Vocals for $charName (click to browse)\n${path.file}.${path.ext}';
vocalsEntryLabel.text = 'Voices for $charName (click to browse)\n${path.file}.${path.ext}';
#end
dialogNoVocals.hidden = true;
@ -820,9 +946,9 @@ class ChartEditorDialogHandler
});
#end
#if FILE_DROP_SUPPORTED
vocalsEntryLabel.text = 'Vocals for $charName (drag and drop, or click to browse)\nSelected file: ${selectedFile.name}';
vocalsEntryLabel.text = 'Voices for $charName (drag and drop, or click to browse)\nSelected file: ${selectedFile.name}';
#else
vocalsEntryLabel.text = 'Vocals for $charName (click to browse)\n${selectedFile.name}';
vocalsEntryLabel.text = 'Voices for $charName (click to browse)\n${selectedFile.name}';
#end
dialogNoVocals.hidden = true;
@ -877,7 +1003,7 @@ class ChartEditorDialogHandler
@:haxe.warning('-WVarInit')
public static function openChartDialog(state:ChartEditorState, closable:Bool = true):Dialog
{
var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_OPEN_CHART_LAYOUT, true, closable);
var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_OPEN_CHART_PARTS_LAYOUT, true, closable);
if (dialog == null) throw 'Could not locate Open Chart dialog';
var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
@ -915,7 +1041,7 @@ class ChartEditorDialogHandler
}
// Build an entry for -chart.json.
var songDefaultChartDataEntry:Component = state.buildComponent(CHART_EDITOR_DIALOG_OPEN_CHART_ENTRY_LAYOUT);
var songDefaultChartDataEntry:Component = state.buildComponent(CHART_EDITOR_DIALOG_OPEN_CHART_PARTS_ENTRY_LAYOUT);
var songDefaultChartDataEntryLabel:Null<Label> = songDefaultChartDataEntry.findComponent('chartEntryLabel', Label);
if (songDefaultChartDataEntryLabel == null) throw 'Could not locate chartEntryLabel in Open Chart dialog';
#if FILE_DROP_SUPPORTED
@ -931,7 +1057,7 @@ class ChartEditorDialogHandler
for (variation in variations)
{
// Build entries for -metadata-<variation>.json.
var songVariationMetadataEntry:Component = state.buildComponent(CHART_EDITOR_DIALOG_OPEN_CHART_ENTRY_LAYOUT);
var songVariationMetadataEntry:Component = state.buildComponent(CHART_EDITOR_DIALOG_OPEN_CHART_PARTS_ENTRY_LAYOUT);
var songVariationMetadataEntryLabel:Null<Label> = songVariationMetadataEntry.findComponent('chartEntryLabel', Label);
if (songVariationMetadataEntryLabel == null) throw 'Could not locate chartEntryLabel in Open Chart dialog';
#if FILE_DROP_SUPPORTED
@ -955,7 +1081,7 @@ class ChartEditorDialogHandler
chartContainerB.addComponent(songVariationMetadataEntry);
// Build entries for -chart-<variation>.json.
var songVariationChartDataEntry:Component = state.buildComponent(CHART_EDITOR_DIALOG_OPEN_CHART_ENTRY_LAYOUT);
var songVariationChartDataEntry:Component = state.buildComponent(CHART_EDITOR_DIALOG_OPEN_CHART_PARTS_ENTRY_LAYOUT);
var songVariationChartDataEntryLabel:Null<Label> = songVariationChartDataEntry.findComponent('chartEntryLabel', Label);
if (songVariationChartDataEntryLabel == null) throw 'Could not locate chartEntryLabel in Open Chart dialog';
#if FILE_DROP_SUPPORTED
@ -1230,7 +1356,7 @@ class ChartEditorDialogHandler
});
}
var metadataEntry:Component = state.buildComponent(CHART_EDITOR_DIALOG_OPEN_CHART_ENTRY_LAYOUT);
var metadataEntry:Component = state.buildComponent(CHART_EDITOR_DIALOG_OPEN_CHART_PARTS_ENTRY_LAYOUT);
var metadataEntryLabel:Null<Label> = metadataEntry.findComponent('chartEntryLabel', Label);
if (metadataEntryLabel == null) throw 'Could not locate chartEntryLabel in Open Chart dialog';
@ -1447,7 +1573,7 @@ class ChartEditorDialogHandler
var dialogNoteStyle:Null<DropDown> = dialog.findComponent('dialogNoteStyle', DropDown);
if (dialogNoteStyle == null) throw 'Could not locate dialogNoteStyle DropDown in Add Variation dialog';
dialogNoteStyle.value = state.currentSongMetadata.playData.noteSkin;
dialogNoteStyle.value = state.currentSongMetadata.playData.noteStyle;
var dialogCharacterPlayer:Null<DropDown> = dialog.findComponent('dialogCharacterPlayer', DropDown);
if (dialogCharacterPlayer == null) throw 'Could not locate dialogCharacterPlayer DropDown in Add Variation dialog';
@ -1479,7 +1605,7 @@ class ChartEditorDialogHandler
var pendingVariation:SongMetadata = new SongMetadata(dialogSongName.text, dialogSongArtist.text, dialogVariationName.text.toLowerCase());
pendingVariation.playData.stage = dialogStage.value.id;
pendingVariation.playData.noteSkin = dialogNoteStyle.value;
pendingVariation.playData.noteStyle = dialogNoteStyle.value;
pendingVariation.timeChanges[0].bpm = dialogBPM.value;
state.songMetadata.set(pendingVariation.variation, pendingVariation);

View File

@ -1,5 +1,6 @@
package funkin.ui.debug.charting;
import funkin.util.VersionUtil;
import haxe.ui.notifications.NotificationType;
import funkin.util.DateUtil;
import haxe.io.Path;
@ -7,10 +8,12 @@ import funkin.util.SerializerUtil;
import haxe.ui.notifications.NotificationManager;
import funkin.util.FileUtil;
import funkin.util.FileUtil;
import haxe.io.Bytes;
import funkin.play.song.Song;
import funkin.data.song.SongData.SongChartData;
import funkin.data.song.SongData.SongMetadata;
import funkin.data.song.SongRegistry;
import funkin.data.song.importer.ChartManifestData;
/**
* Contains functions for importing, loading, saving, and exporting charts.
@ -104,7 +107,7 @@ class ChartEditorImportExportHandler
}
/**
* Loads song metadata and chart data into the editor.
* Loads a chart from parsed song metadata and chart data into the editor.
* @param newSongMetadata The song metadata to load.
* @param newSongChartData The song chart data to load.
*/
@ -135,15 +138,179 @@ class ChartEditorImportExportHandler
}
}
/**
* @param force Whether to force the export without prompting the user for a file location.
*/
public static function exportAllSongData(state:ChartEditorState, force:Bool = false):Void
public static function loadFromFNFCPath(state:ChartEditorState, path:String):Bool
{
var bytes:Null<Bytes> = FileUtil.readBytesFromPath(path);
if (bytes == null) return false;
trace('Loaded ${bytes.length} bytes from $path');
var result:Bool = loadFromFNFC(state, bytes);
if (result)
{
state.currentWorkingFilePath = path;
}
return result;
}
/**
* Load a chart's metadata, chart data, and audio from an FNFC archive..
* @param state
* @param bytes
* @param instId
* @return Bool
*/
public static function loadFromFNFC(state:ChartEditorState, bytes:Bytes):Bool
{
var songMetadatas:Map<String, SongMetadata> = [];
var songChartDatas:Map<String, SongChartData> = [];
var fileEntries:Array<haxe.zip.Entry> = FileUtil.readZIPFromBytes(bytes);
var mappedFileEntries:Map<String, haxe.zip.Entry> = FileUtil.mapZIPEntriesByName(fileEntries);
var manifestBytes:Null<Bytes> = mappedFileEntries.get('manifest.json')?.data;
if (manifestBytes == null) throw 'Could not locate manifest.';
var manifestString = manifestBytes.toString();
var manifest:Null<ChartManifestData> = ChartManifestData.deserialize(manifestString);
if (manifest == null) throw 'Could not read manifest.';
// Get the song ID.
var songId:String = manifest.songId;
var baseMetadataPath:String = manifest.getMetadataFileName();
var baseChartDataPath:String = manifest.getChartDataFileName();
var baseMetadataBytes:Null<Bytes> = mappedFileEntries.get(baseMetadataPath)?.data;
if (baseMetadataBytes == null) throw 'Could not locate metadata (default).';
var baseMetadataString:String = baseMetadataBytes.toString();
var baseMetadataVersion:Null<thx.semver.Version> = VersionUtil.getVersionFromJSON(baseMetadataString);
if (baseMetadataVersion == null) throw 'Could not read metadata version (default).';
var baseMetadata:Null<SongMetadata> = SongRegistry.instance.parseEntryMetadataRawWithMigration(baseMetadataString, baseMetadataPath, baseMetadataVersion);
if (baseMetadata == null) throw 'Could not read metadata (default).';
songMetadatas.set(Constants.DEFAULT_VARIATION, baseMetadata);
var baseChartDataBytes:Null<Bytes> = mappedFileEntries.get(baseChartDataPath)?.data;
if (baseChartDataBytes == null) throw 'Could not locate chart data (default).';
var baseChartDataString:String = baseChartDataBytes.toString();
var baseChartDataVersion:Null<thx.semver.Version> = VersionUtil.getVersionFromJSON(baseChartDataString);
if (baseChartDataVersion == null) throw 'Could not read chart data (default) version.';
var baseChartData:Null<SongChartData> = SongRegistry.instance.parseEntryChartDataRawWithMigration(baseChartDataString, baseChartDataPath,
baseChartDataVersion);
if (baseChartData == null) throw 'Could not read chart data (default).';
songChartDatas.set(Constants.DEFAULT_VARIATION, baseChartData);
var variationList:Array<String> = baseMetadata.playData.songVariations;
for (variation in variationList)
{
var variMetadataPath:String = manifest.getMetadataFileName(variation);
var variChartDataPath:String = manifest.getChartDataFileName(variation);
var variMetadataBytes:Null<Bytes> = mappedFileEntries.get(variMetadataPath)?.data;
if (variMetadataBytes == null) throw 'Could not locate metadata ($variation).';
var variMetadataString:String = variMetadataBytes.toString();
var variMetadataVersion:Null<thx.semver.Version> = VersionUtil.getVersionFromJSON(variMetadataString);
if (variMetadataVersion == null) throw 'Could not read metadata ($variation) version.';
var variMetadata:Null<SongMetadata> = SongRegistry.instance.parseEntryMetadataRawWithMigration(baseMetadataString, variMetadataPath, variMetadataVersion);
if (variMetadata == null) throw 'Could not read metadata ($variation).';
songMetadatas.set(variation, variMetadata);
var variChartDataBytes:Null<Bytes> = mappedFileEntries.get(variChartDataPath)?.data;
if (variChartDataBytes == null) throw 'Could not locate chart data ($variation).';
var variChartDataString:String = variChartDataBytes.toString();
var variChartDataVersion:Null<thx.semver.Version> = VersionUtil.getVersionFromJSON(variChartDataString);
if (variChartDataVersion == null) throw 'Could not read chart data version ($variation).';
var variChartData:Null<SongChartData> = SongRegistry.instance.parseEntryChartDataRawWithMigration(variChartDataString, variChartDataPath,
variChartDataVersion);
if (variChartData == null) throw 'Could not read chart data ($variation).';
songChartDatas.set(variation, variChartData);
}
ChartEditorAudioHandler.stopExistingInstrumental(state);
ChartEditorAudioHandler.stopExistingVocals(state);
// Load instrumentals
for (variation in [Constants.DEFAULT_VARIATION].concat(variationList))
{
var variMetadata:Null<SongMetadata> = songMetadatas.get(variation);
if (variMetadata == null) continue;
var instId:String = variMetadata?.playData?.characters?.instrumental ?? '';
var playerCharId:String = variMetadata?.playData?.characters?.player ?? Constants.DEFAULT_CHARACTER;
var opponentCharId:Null<String> = variMetadata?.playData?.characters?.opponent;
var instFileName:String = manifest.getInstFileName(instId);
var instFileBytes:Null<Bytes> = mappedFileEntries.get(instFileName)?.data;
if (instFileBytes != null)
{
if (!ChartEditorAudioHandler.loadInstFromBytes(state, instFileBytes, instId))
{
throw 'Could not load instrumental ($instFileName).';
}
}
else
{
throw 'Could not find instrumental ($instFileName).';
}
var playerVocalsFileName:String = manifest.getVocalsFileName(playerCharId);
var playerVocalsFileBytes:Null<Bytes> = mappedFileEntries.get(playerVocalsFileName)?.data;
if (playerVocalsFileBytes != null)
{
if (!ChartEditorAudioHandler.loadVocalsFromBytes(state, playerVocalsFileBytes, playerCharId, instId))
{
throw 'Could not load vocals ($playerCharId).';
}
}
else
{
throw 'Could not find vocals ($playerVocalsFileName).';
}
if (opponentCharId != null)
{
var opponentVocalsFileName:String = manifest.getVocalsFileName(opponentCharId);
var opponentVocalsFileBytes:Null<Bytes> = mappedFileEntries.get(opponentVocalsFileName)?.data;
if (opponentVocalsFileBytes != null)
{
if (!ChartEditorAudioHandler.loadVocalsFromBytes(state, opponentVocalsFileBytes, opponentCharId, instId))
{
throw 'Could not load vocals ($opponentCharId).';
}
}
else
{
throw 'Could not load vocals ($playerCharId-$instId).';
}
}
}
// Apply chart data.
trace(songMetadatas);
trace(songChartDatas);
loadSong(state, songMetadatas, songChartDatas);
state.switchToCurrentInstrumental();
return true;
}
/**
* @param force Whether to export without prompting. `false` will prompt the user for a location.
* @param targetPath where to export if `force` is `true`. If `null`, will export to the `backups` folder.
*/
public static function exportAllSongData(state:ChartEditorState, force:Bool = false, ?targetPath:String):Void
{
var tmp = false;
var zipEntries:Array<haxe.zip.Entry> = [];
for (variation in state.availableVariations)
var variations = state.availableVariations;
for (variation in variations)
{
var variationId:String = variation;
if (variation == '' || variation == 'default' || variation == 'normal')
@ -162,50 +329,51 @@ class ChartEditorImportExportHandler
{
var variationMetadata:Null<SongMetadata> = state.songMetadata.get(variation);
if (variationMetadata != null) zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-metadata-$variationId.json',
SerializerUtil.toJSON(variationMetadata)));
variationMetadata.serialize()));
var variationChart:Null<SongChartData> = state.songChartData.get(variation);
if (variationChart != null) zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-chart-$variationId.json',
SerializerUtil.toJSON(variationChart)));
if (variationChart != null) zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-chart-$variationId.json', variationChart.serialize()));
}
}
if (state.audioInstTrackData != null) zipEntries.concat(ChartEditorAudioHandler.makeZIPEntriesFromInstrumentals(state));
if (state.audioVocalTrackData != null) zipEntries.concat(ChartEditorAudioHandler.makeZIPEntriesFromVocals(state));
if (state.audioInstTrackData != null) zipEntries = zipEntries.concat(ChartEditorAudioHandler.makeZIPEntriesFromInstrumentals(state));
if (state.audioVocalTrackData != null) zipEntries = zipEntries.concat(ChartEditorAudioHandler.makeZIPEntriesFromVocals(state));
var manifest:ChartManifestData = new ChartManifestData(state.currentSongId);
zipEntries.push(FileUtil.makeZIPEntry('manifest.json', manifest.serialize()));
trace('Exporting ${zipEntries.length} files to ZIP...');
if (force)
{
var targetPath:String = if (tmp)
if (targetPath == null)
{
Path.join([
FileUtil.getTempDir(),
'chart-editor-exit-${DateUtil.generateTimestamp()}.${Constants.EXT_CHART}'
]);
}
else
{
Path.join([
targetPath = Path.join([
'./backups/',
'chart-editor-exit-${DateUtil.generateTimestamp()}.${Constants.EXT_CHART}'
'chart-editor-${DateUtil.generateTimestamp()}.${Constants.EXT_CHART}'
]);
}
// We have to force write because the program will die before the save dialog is closed.
trace('Force exporting to $targetPath...');
FileUtil.saveFilesAsZIPToPath(zipEntries, targetPath);
return;
}
else
{
// Prompt and save.
var onSave:Array<String>->Void = function(paths:Array<String>) {
trace('Successfully exported files.');
};
// Prompt and save.
var onSave:Array<String>->Void = function(paths:Array<String>) {
trace('Successfully exported files.');
};
var onCancel:Void->Void = function() {
trace('Export cancelled.');
};
var onCancel:Void->Void = function() {
trace('Export cancelled.');
};
FileUtil.saveMultipleFiles(zipEntries, onSave, onCancel, '${state.currentSongId}-chart.${Constants.EXT_CHART}');
trace('Exporting to user-defined location...');
try
{
FileUtil.saveChartAsFNFC(zipEntries, onSave, onCancel, '${state.currentSongId}.${Constants.EXT_CHART}');
}
catch (e) {}
}
}
}

View File

@ -1011,17 +1011,17 @@ class ChartEditorState extends HaxeUIState
function get_currentSongNoteStyle():String
{
if (currentSongMetadata.playData.noteSkin == null)
if (currentSongMetadata.playData.noteStyle == null)
{
// Initialize to the default value if not set.
currentSongMetadata.playData.noteSkin = 'funkin';
currentSongMetadata.playData.noteStyle = Constants.DEFAULT_NOTE_STYLE;
}
return currentSongMetadata.playData.noteSkin;
return currentSongMetadata.playData.noteStyle;
}
function set_currentSongNoteStyle(value:String):String
{
return currentSongMetadata.playData.noteSkin = value;
return currentSongMetadata.playData.noteStyle = value;
}
var currentSongStage(get, set):String;
@ -1232,10 +1232,22 @@ class ChartEditorState extends HaxeUIState
var renderedSelectionSquares:FlxTypedSpriteGroup<FlxSprite> = new FlxTypedSpriteGroup<FlxSprite>();
public function new()
/**
* The params which were passed in when the Chart Editor was initialized.
*/
var params:Null<ChartEditorParams>;
/**
* The current file path which the chart editor is working with.
*/
public var currentWorkingFilePath:Null<String>;
public function new(?params:ChartEditorParams)
{
// Load the HaxeUI XML file.
super(CHART_EDITOR_LAYOUT);
this.params = params;
}
override function create():Void
@ -1251,7 +1263,7 @@ class ChartEditorState extends HaxeUIState
fixCamera();
// Get rid of any music from the previous state.
FlxG.sound.music.stop();
if (FlxG.sound.music != null) FlxG.sound.music.stop();
// Play the welcome music.
setupWelcomeMusic();
@ -1277,7 +1289,33 @@ class ChartEditorState extends HaxeUIState
refresh();
ChartEditorDialogHandler.openWelcomeDialog(this, false);
if (params != null && params.fnfcTargetPath != null)
{
// Chart editor was opened from the command line. Open the FNFC file now!
if (ChartEditorImportExportHandler.loadFromFNFCPath(this, params.fnfcTargetPath))
{
// Don't open the welcome dialog!
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Success',
body: 'Loaded chart (${params.fnfcTargetPath})',
type: NotificationType.Success,
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
});
#end
}
else
{
// Song failed to load, open the Welcome dialog so we aren't in a broken state.
ChartEditorDialogHandler.openWelcomeDialog(this, false);
}
}
else
{
ChartEditorDialogHandler.openWelcomeDialog(this, false);
}
}
function setupWelcomeMusic()
@ -1632,11 +1670,15 @@ class ChartEditorState extends HaxeUIState
noteSnapQuantIndex++;
if (noteSnapQuantIndex >= SNAP_QUANTS.length) noteSnapQuantIndex = 0;
});
addUIRightClickListener('playbarNoteSnap', function(_) {
noteSnapQuantIndex--;
if (noteSnapQuantIndex < 0) noteSnapQuantIndex = SNAP_QUANTS.length - 1;
});
// Add functionality to the menu items.
addUIClickListener('menubarItemNewChart', _ -> ChartEditorDialogHandler.openWelcomeDialog(this, true));
addUIClickListener('menubarItemOpenChart', _ -> ChartEditorDialogHandler.openBrowseWizard(this, true));
addUIClickListener('menubarItemOpenChart', _ -> ChartEditorDialogHandler.openBrowseFNFC(this, true));
addUIClickListener('menubarItemSaveChartAs', _ -> ChartEditorImportExportHandler.exportAllSongData(this));
addUIClickListener('menubarItemLoadInst', _ -> ChartEditorDialogHandler.openUploadInstDialog(this, true));
addUIClickListener('menubarItemImportChart', _ -> ChartEditorDialogHandler.openImportChartDialog(this, 'legacy', true));
@ -1776,7 +1818,7 @@ class ChartEditorState extends HaxeUIState
addUIChangeListener('menubarItemVolumeVocals', function(event:UIEvent) {
var volume:Float = (event?.value ?? 0) / 100.0;
if (audioVocalTrackGroup != null) audioVocalTrackGroup.volume = volume;
vocalsVolumeLabel.text = 'Vocals - ${Std.int(event.value)}%';
vocalsVolumeLabel.text = 'Voices - ${Std.int(event.value)}%';
});
}
@ -1913,6 +1955,15 @@ class ChartEditorState extends HaxeUIState
{
FlxG.watch.addQuick('scrollPosInPixels', scrollPositionInPixels);
FlxG.watch.addQuick('playheadPosInPixels', playheadPositionInPixels);
// Add a debug value which displays the current size of the note pool.
// The pool will grow as more notes need to be rendered at once.
// If this gets too big, something needs to be optimized somewhere! -Eric
if (renderedNotes != null && renderedNotes.members != null) FlxG.watch.addQuick("tapNotesRendered", renderedNotes.members.length);
if (renderedHoldNotes != null && renderedHoldNotes.members != null) FlxG.watch.addQuick("holdNotesRendered", renderedHoldNotes.members.length);
if (renderedEvents != null && renderedEvents.members != null) FlxG.watch.addQuick("eventsRendered", renderedEvents.members.length);
if (currentNoteSelection != null) FlxG.watch.addQuick("notesSelected", currentNoteSelection.length);
if (currentEventSelection != null) FlxG.watch.addQuick("eventsSelected", currentEventSelection.length);
}
/**
@ -3037,15 +3088,6 @@ class ChartEditorState extends HaxeUIState
// Sort the events DESCENDING. This keeps the sustain behind the associated note.
renderedEvents.sort(FlxSort.byY, FlxSort.DESCENDING); // TODO: .group.insertionSort()
}
// Add a debug value which displays the current size of the note pool.
// The pool will grow as more notes need to be rendered at once.
// If this gets too big, something needs to be optimized somewhere! -Eric
FlxG.watch.addQuick("tapNotesRendered", renderedNotes.members.length);
FlxG.watch.addQuick("holdNotesRendered", renderedHoldNotes.members.length);
FlxG.watch.addQuick("eventsRendered", renderedEvents.members.length);
FlxG.watch.addQuick("notesSelected", currentNoteSelection.length);
FlxG.watch.addQuick("eventsSelected", currentEventSelection.length);
}
/**
@ -3152,7 +3194,7 @@ class ChartEditorState extends HaxeUIState
// CTRL + O = Open Chart
if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.O)
{
ChartEditorDialogHandler.openBrowseWizard(this, true);
ChartEditorDialogHandler.openBrowseFNFC(this, true);
}
// CTRL + SHIFT + S = Save As
@ -3168,10 +3210,13 @@ class ChartEditorState extends HaxeUIState
}
}
@:nullSafety(Off)
function quitChartEditor():Void
{
autoSave();
stopWelcomeMusic();
// TODO: PR Flixel to make onComplete nullable.
if (audioInstTrack != null) audioInstTrack.onComplete = null;
FlxG.switchState(new MainMenuState());
}
@ -3691,7 +3736,7 @@ class ChartEditorState extends HaxeUIState
if (inputStage != null) inputStage.value = currentSongMetadata.playData.stage;
var inputNoteStyle:Null<DropDown> = toolbox.findComponent('inputNoteStyle', DropDown);
if (inputNoteStyle != null) inputNoteStyle.value = currentSongMetadata.playData.noteSkin;
if (inputNoteStyle != null) inputNoteStyle.value = currentSongMetadata.playData.noteStyle;
var inputBPM:Null<NumberStepper> = toolbox.findComponent('inputBPM', NumberStepper);
if (inputBPM != null) inputBPM.value = currentSongMetadata.timeChanges[0].bpm;
@ -4351,3 +4396,11 @@ enum LiveInputStyle
NumberKeys;
WASD;
}
typedef ChartEditorParams =
{
/**
* If non-null, load this song immediately instead of the welcome screen.
*/
var ?fnfcTargetPath:String;
};

View File

@ -108,6 +108,23 @@ class HaxeUIState extends MusicBeatState
}
}
/**
* Add an onRightClick listener to a HaxeUI menu bar item.
*/
function addUIRightClickListener(key:String, callback:MouseEvent->Void):Void
{
var target:Component = findComponent(key);
if (target == null)
{
// Gracefully handle the case where the item can't be located.
trace('WARN: Could not locate menu item: $key');
}
else
{
target.onRightClick = callback;
}
}
function setComponentText(key:String, text:String):Void
{
var target:Component = findComponent(key);

View File

@ -0,0 +1,134 @@
package funkin.util;
/**
* Utilties for interpreting command line arguments.
*/
@:nullSafety
class CLIUtil
{
/**
* If we don't do this, dragging and dropping a file onto the executable
* causes it to be unable to find the assets folder.
*/
public static function resetWorkingDir():Void
{
#if sys
var exeDir:String = haxe.io.Path.directory(Sys.programPath());
trace('Changing working directory from ${Sys.getCwd()} to ${exeDir}');
Sys.setCwd(exeDir);
#end
}
public static function processArgs():CLIParams
{
#if sys
return interpretArgs(cleanArgs(Sys.args()));
#else
return buildDefaultParams();
#end
}
static function interpretArgs(args:Array<String>):CLIParams
{
var result = buildDefaultParams();
result.args = [for (arg in args) arg]; // Copy the array.
while (args.length > 0)
{
var arg:Null<String> = args.shift();
if (arg == null) continue;
if (arg.startsWith('-'))
{
switch (arg)
{
// Flags
case '-h' | '--help':
printUsage();
case '-v' | '--version':
trace(Constants.GENERATED_BY);
case '--chart':
if (args.length == 0)
{
trace('No chart path provided.');
printUsage();
}
else
{
result.chart.shouldLoadChart = true;
result.chart.chartPath = args.shift();
}
}
}
else
{
// Make an attempt to interpret the argument.
if (arg.endsWith(Constants.EXT_CHART))
{
result.chart.shouldLoadChart = true;
result.chart.chartPath = arg;
}
else
{
trace('Unrecognized argument: ${arg}');
printUsage();
}
}
}
return result;
}
static function printUsage():Void
{
trace('Usage: Funkin.exe [--chart <chart>]');
}
static function buildDefaultParams():CLIParams
{
return {
args: [],
chart:
{
shouldLoadChart: false,
chartPath: null
}
};
}
/**
* Clean up the arguments passed to the application before parsing them.
* @param args The arguments to clean up.
* @return The cleaned up arguments.
*/
static function cleanArgs(args:Array<String>):Array<String>
{
var result:Array<String> = [];
if (args == null || args.length == 0) return result;
return args.map(function(arg:String):String {
if (arg == null) return '';
return arg.trim();
}).filter(function(arg:String):Bool {
return arg != null && arg != '';
});
}
}
typedef CLIParams =
{
var args:Array<String>;
var chart:CLIChartParams;
}
typedef CLIChartParams =
{
var shouldLoadChart:Bool;
var chartPath:Null<String>;
};

View File

@ -14,6 +14,9 @@ import openfl.events.IOErrorEvent;
*/
class FileUtil
{
public static final FILE_FILTER_FNFC:FileFilter = new FileFilter("Friday Night Funkin' Chart (.fnfc)", "*.fnfc");
public static final FILE_FILTER_ZIP:FileFilter = new FileFilter("ZIP Archive (.zip)", "*.zip");
/**
* Browses for a single file, then calls `onSelect(path)` when a path chosen.
* Note that on HTML5 this will immediately fail, you should call `openFile(onOpen:Resource->Void)` instead.
@ -173,10 +176,11 @@ class FileUtil
*
* @return Whether the file dialog was opened successfully.
*/
public static function saveFile(data:Bytes, ?onSave:String->Void, ?onCancel:Void->Void, ?defaultFileName:String, ?dialogTitle:String):Bool
public static function saveFile(data:Bytes, ?typeFilter:Array<FileFilter>, ?onSave:String->Void, ?onCancel:Void->Void, ?defaultFileName:String,
?dialogTitle:String):Bool
{
#if desktop
var filter:String = defaultFileName != null ? Path.extension(defaultFileName) : null;
var filter:String = convertTypeFilter(typeFilter);
var fileDialog:FileDialog = new FileDialog();
if (onSave != null) fileDialog.onSelect.add(onSave);
@ -231,8 +235,7 @@ class FileUtil
}
catch (_)
{
trace('Failed to write file (probably already exists): $filePath' + filePath);
continue;
throw 'Failed to write file (probably already exists): $filePath';
}
paths.push(filePath);
}
@ -269,7 +272,26 @@ class FileUtil
};
// Prompt the user to save the ZIP file.
saveFile(zipBytes, onSave, onCancel, defaultPath, 'Save files as ZIP...');
saveFile(zipBytes, [FILE_FILTER_ZIP], onSave, onCancel, defaultPath, 'Save files as ZIP...');
return true;
}
/**
* Takes an array of file entries and prompts the user to save them as a FNFC file.
*/
public static function saveChartAsFNFC(resources:Array<Entry>, ?onSave:Array<String>->Void, ?onCancel:Void->Void, ?defaultPath:String,
force:Bool = false):Bool
{
// Create a ZIP file.
var zipBytes:Bytes = createZIPFromEntries(resources);
var onSave:String->Void = function(path:String) {
onSave([path]);
};
// Prompt the user to save the ZIP file.
saveFile(zipBytes, [FILE_FILTER_FNFC], onSave, onCancel, defaultPath, 'Save chart as FNFC...');
return true;
}
@ -322,7 +344,8 @@ class FileUtil
public static function readBytesFromPath(path:String):Bytes
{
#if sys
return Bytes.ofString(sys.io.File.getContent(path));
if (!sys.FileSystem.exists(path)) return null;
return sys.io.File.getBytes(path);
#else
return null;
#end
@ -559,6 +582,36 @@ class FileUtil
return o.getBytes();
}
public static function readZIPFromBytes(input:Bytes):Array<Entry>
{
trace('TEST: ' + input.length);
trace(input.sub(0, 30).toHex());
var bytesInput = new haxe.io.BytesInput(input);
var zippedEntries = haxe.zip.Reader.readZip(bytesInput);
var results:Array<Entry> = [];
for (entry in zippedEntries)
{
if (entry.compressed)
{
entry.data = haxe.zip.Reader.unzip(entry);
}
results.push(entry);
}
return results;
}
public static function mapZIPEntriesByName(input:Array<Entry>):Map<String, Entry>
{
var results:Map<String, Entry> = [];
for (entry in input)
{
results.set(entry.fileName, entry);
}
return results;
}
/**
* Create a ZIP file entry from a file name and its string contents.
*

View File

@ -21,6 +21,8 @@ class SerializerUtil
/**
* Convert a Haxe object to a JSON string.
* NOTE: Use `json2object.JsonWriter<T>` WHEREVER POSSIBLE. Do not use this one unless you ABSOLUTELY HAVE TO it's SLOW!
* And don't even THINK about using `haxe.Json.stringify` without the replacer!
*/
public static function toJSON(input:Dynamic, pretty:Bool = true):String
{

View File

@ -61,4 +61,22 @@ class VersionUtil
var version:thx.semver.Version = versionStr; // Implicit, not explicit, cast.
return version;
}
public static function parseVersion(input:Dynamic):Null<thx.semver.Version>
{
if (input == null) return null;
if (Std.isOfType(input, String))
{
var inputStr:String = input;
var version:thx.semver.Version = inputStr;
return version;
}
else
{
var semVer:thx.semver.Version.SemVer = input;
var version:thx.semver.Version = semVer;
return version;
}
}
}

View File

@ -95,6 +95,6 @@ class NoteStyleRegistryTest extends FunkinTest
// Verify the underlying call.
nsrMock.fetchEntry(NoteStyleRegistry.DEFAULT_NOTE_STYLE_ID).verify(times(1));
nsrMock.fetchEntry(Constants.DEFAULT_NOTE_STYLE).verify(times(1));
}
}