···44 <app title="Friday Night Funkin'" file="Funkin" packageName="com.funkin.fnf" package="com.funkin.fnf" main="Main" version="0.3.0" company="ninjamuffin99" />
55 <!--Switch Export with Unique ApplicationID and Icon-->
66 <set name="APP_ID" value="0x0100f6c013bbc000" />
77- <!--The flixel preloader is not accurate in Chrome. You can use it regularly if you embed the swf into a html file
88- or you can set the actual size of your file manually at "FlxPreloaderBase-onUpdate-bytesTotal"-->
99- <!-- <app preloader="Preloader" resizable="true" /> -->
1010- <app preloader="Preloader" />
77+ <app preloader="funkin.Preloader" />
118 <!--Minimum without FLX_NO_GAMEPAD: 11.8, without FLX_NO_NATIVE_CURSOR: 11.2-->
129 <set name="SWF_VERSION" value="11.8" />
1310 <!-- ____________________________ Window Settings ___________________________ -->
···105105 }
106106107107 /**
108108+ * Parser which outputs a `Either<Float, Array<Float>>`.
109109+ */
110110+ public static function eitherFloatOrFloats(json:Json, name:String):Null<Either<Float, Array<Float>>>
111111+ {
112112+ switch (json.value)
113113+ {
114114+ case JNumber(f):
115115+ return Either.Left(Std.parseFloat(f));
116116+ case JArray(fields):
117117+ return Either.Right(fields.map((field) -> cast Tools.getValue(field)));
118118+ default:
119119+ throw 'Expected property $name to be one or multiple floats, but it was ${json.value}.';
120120+ }
121121+ }
122122+123123+ /**
108124 * Parser which outputs a `Either<Float, LegacyScrollSpeeds>`.
109125 * Used by the FNF legacy JSON importer.
110126 */
+22-2
source/funkin/data/DataWrite.hx
···33import funkin.util.SerializerUtil;
44import thx.semver.Version;
55import thx.semver.VersionRule;
66+import haxe.ds.Either;
6778/**
89 * `json2object` has an annotation `@:jcustomwrite` which allows for custom serialization of values to be written to JSON.
910 *
1011 * Functions must be of the signature `(T) -> String`, where `T` is the type of the property.
1212+ *
1313+ * NOTE: Result must include quotation marks if the value is a string! json2object will not add them for you!
1114 */
1215class DataWrite
1316{
···2326 }
24272528 /**
2929+ *
2630 * `@:jcustomwrite(funkin.data.DataWrite.semverVersion)`
2731 */
2832 public static function semverVersion(value:Version):String
2933 {
3030- return value.toString();
3434+ return '"${value.toString()}"';
3135 }
32363337 /**
···3539 */
3640 public static function semverVersionRule(value:VersionRule):String
3741 {
3838- return value.toString();
4242+ return '"${value.toString()}"';
4343+ }
4444+4545+ /**
4646+ * `@:jcustomwrite(funkin.data.DataWrite.eitherFloatOrFloats)`
4747+ */
4848+ public static function eitherFloatOrFloats(value:Null<Either<Float, Array<Float>>>):String
4949+ {
5050+ switch (value)
5151+ {
5252+ case null:
5353+ return '${1.0}';
5454+ case Left(inner):
5555+ return '$inner';
5656+ case Right(inner):
5757+ return dynamicValue(inner);
5858+ }
3959 }
4060}
+3
source/funkin/data/animation/AnimationData.hx
···5959 * The prefix for the frames of the animation as defined by the XML file.
6060 * This will may or may not differ from the `name` of the animation,
6161 * depending on how your animator organized their FLA or whatever.
6262+ *
6363+ * NOTE: For Sparrow animations, this is not optional, but for Packer animations it is.
6264 */
6565+ @:optional
6366 var prefix:String;
64676568 /**
+1-3
source/funkin/data/notestyle/NoteStyleRegistry.hx
···15151616 public static final NOTE_STYLE_DATA_VERSION_RULE:thx.semver.VersionRule = "1.0.x";
17171818- public static final DEFAULT_NOTE_STYLE_ID:String = "funkin";
1919-2018 public static final instance:NoteStyleRegistry = new NoteStyleRegistry();
21192220 public function new()
···26242725 public function fetchDefault():NoteStyle
2826 {
2929- return fetchEntry(DEFAULT_NOTE_STYLE_ID);
2727+ return fetchEntry(Constants.DEFAULT_NOTE_STYLE);
3028 }
31293230 /**
+19-13
source/funkin/data/song/SongData.hx
···11package funkin.data.song;
2233-import flixel.util.typeLimit.OneOfTwo;
43import funkin.data.song.SongRegistry;
54import thx.semver.Version;
6566+/**
77+ * Data containing information about a song.
88+ * 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.
99+ * Data which is only necessary in-game should be stored in the SongChartData.
1010+ */
711@:nullSafety
812class SongMetadata
913{
···3539 */
3640 public var playData:SongPlayData;
37413838- // @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY)
4242+ @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY)
3943 public var generatedBy:String;
40444141- // @:default(funkin.data.song.SongData.SongTimeFormat.MILLISECONDS)
4245 public var timeFormat:SongTimeFormat;
43464444- // @:default(funkin.data.song.SongData.SongTimeChange.DEFAULT_SONGTIMECHANGES)
4547 public var timeChanges:Array<SongTimeChange>;
46484749 /**
···6466 this.playData.difficulties = [];
6567 this.playData.characters = new SongCharacterData('bf', 'gf', 'dad');
6668 this.playData.stage = 'mainStage';
6767- this.playData.noteSkin = 'funkin';
6969+ this.playData.noteStyle = Constants.DEFAULT_NOTE_STYLE;
6870 this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY;
6971 // Variation ID.
7072 this.variation = (variation == null) ? Constants.DEFAULT_VARIATION : variation;
···298300299301 /**
300302 * The note style used by this song.
301301- * TODO: Rename to `noteStyle`? Renaming values is a breaking change to the metadata format.
302303 */
303303- public var noteSkin:String;
304304+ public var noteStyle:String;
304305305306 /**
306306- * The difficulty rating for this song as displayed in Freeplay.
307307- * TODO: Adding this is a non-breaking change to the metadata format.
307307+ * The difficulty ratings for this song as displayed in Freeplay.
308308+ * Key is a difficulty ID or `default`.
308309 */
309309- // public var rating:Int;
310310+ @:default(['default' => 1])
311311+ public var ratings:Map<String, Int>;
310312311313 /**
312314 * The album ID for the album to display in Freeplay.
313313- * TODO: Adding this is a non-breaking change to the metadata format.
315315+ * If `null`, display no album.
314316 */
315315- // public var album:String;
317317+ @:optional
318318+ public var album:Null<String>;
316319317317- public function new() {}
320320+ public function new()
321321+ {
322322+ ratings = new Map<String, Int>();
323323+ }
318324319325 /**
320326 * Produces a string representation suitable for debugging.
+45-3
source/funkin/data/song/SongRegistry.hx
···2233import funkin.data.song.SongData;
44import funkin.data.song.migrator.SongData_v2_0_0.SongMetadata_v2_0_0;
55+import funkin.data.song.migrator.SongData_v2_1_0.SongMetadata_v2_1_0;
56import funkin.data.song.SongData.SongChartData;
67import funkin.data.song.SongData.SongMetadata;
78import funkin.play.song.ScriptedSong;
···1819 * Handle breaking changes by incrementing this value
1920 * and adding migration to the `migrateStageData()` function.
2021 */
2121- public static final SONG_METADATA_VERSION:thx.semver.Version = "2.1.0";
2222+ public static final SONG_METADATA_VERSION:thx.semver.Version = "2.2.0";
22232323- public static final SONG_METADATA_VERSION_RULE:thx.semver.VersionRule = "2.1.x";
2424+ public static final SONG_METADATA_VERSION_RULE:thx.semver.VersionRule = "2.2.x";
24252526 public static final SONG_CHART_DATA_VERSION:thx.semver.Version = "2.0.0";
2627···165166 {
166167 return parseEntryMetadata(id, variation);
167168 }
169169+ else if (VersionUtil.validateVersion(version, "2.1.x"))
170170+ {
171171+ return parseEntryMetadata_v2_1_0(id, variation);
172172+ }
168173 else if (VersionUtil.validateVersion(version, "2.0.x"))
169174 {
170175 return parseEntryMetadata_v2_0_0(id, variation);
···182187 {
183188 return parseEntryMetadataRaw(contents, fileName);
184189 }
190190+ else if (VersionUtil.validateVersion(version, "2.1.x"))
191191+ {
192192+ return parseEntryMetadataRaw_v2_1_0(contents, fileName);
193193+ }
185194 else if (VersionUtil.validateVersion(version, "2.0.x"))
186195 {
187196 return parseEntryMetadataRaw_v2_0_0(contents, fileName);
···192201 }
193202 }
194203204204+ function parseEntryMetadata_v2_1_0(id:String, ?variation:String):Null<SongMetadata>
205205+ {
206206+ variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
207207+208208+ var parser = new json2object.JsonParser<SongMetadata_v2_1_0>();
209209+ switch (loadEntryMetadataFile(id, variation))
210210+ {
211211+ case {fileName: fileName, contents: contents}:
212212+ parser.fromJson(contents, fileName);
213213+ default:
214214+ return null;
215215+ }
216216+ if (parser.errors.length > 0)
217217+ {
218218+ printErrors(parser.errors, id);
219219+ return null;
220220+ }
221221+ return cleanMetadata(parser.value.migrate(), variation);
222222+ }
223223+195224 function parseEntryMetadata_v2_0_0(id:String, ?variation:String):Null<SongMetadata>
196225 {
197226 variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
198227199228 var parser = new json2object.JsonParser<SongMetadata_v2_0_0>();
200200- switch (loadEntryMetadataFile(id))
229229+ switch (loadEntryMetadataFile(id, variation))
201230 {
202231 case {fileName: fileName, contents: contents}:
203232 parser.fromJson(contents, fileName);
···207236 if (parser.errors.length > 0)
208237 {
209238 printErrors(parser.errors, id);
239239+ return null;
240240+ }
241241+ return cleanMetadata(parser.value.migrate(), variation);
242242+ }
243243+244244+ function parseEntryMetadataRaw_v2_1_0(contents:String, ?fileName:String = 'raw'):Null<SongMetadata>
245245+ {
246246+ var parser = new json2object.JsonParser<SongMetadata_v2_1_0>();
247247+ parser.fromJson(contents, fileName);
248248+249249+ if (parser.errors.length > 0)
250250+ {
251251+ printErrors(parser.errors, fileName);
210252 return null;
211253 }
212254 return parser.value.migrate();
···4242 @:default(false)
4343 public var looped:Bool;
44444545+ @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY)
4546 public var generatedBy:String;
46474748 public var timeFormat:SongData.SongTimeFormat;
···7071 */
7172 public var playableChars:Map<String, SongPlayableChar_v2_0_0>;
72737474+ /**
7575+ * In metadata version `v2.2.0`, this was renamed to `noteStyle`.
7676+ */
7777+ public var noteSkin:String;
7878+7979+ // In 2.2.0, the ratings value was added.
8080+ // In 2.2.0, the album value was added.
7381 // ==========
7482 // UNMODIFIED VALUES
7583 // ==========
···7785 public var difficulties:Array<String>;
78867987 public var stage:String;
8080- public var noteSkin:String;
81888289 public function new() {}
8390
···11package funkin.play.stage;
2233import funkin.data.animation.AnimationData;
44-import flixel.util.typeLimit.OneOfTwo;
54import funkin.play.stage.ScriptedStage;
65import funkin.play.stage.Stage;
76import funkin.util.VersionUtil;
···157156 return rawJson;
158157 }
159158160160- static function migrateStageData(rawJson:String, stageId:String)
159159+ static function migrateStageData(rawJson:String, stageId:String):Null<StageData>
161160 {
162161 // If you update the stage data format in a breaking way,
163162 // handle migration here by checking the `version` value.
164163165164 try
166165 {
167167- var stageData:StageData = cast Json.parse(rawJson);
168168- return stageData;
166166+ var parser = new json2object.JsonParser<StageData>();
167167+ parser.fromJson(rawJson, '$stageId.json');
168168+169169+ if (parser.errors.length > 0)
170170+ {
171171+ trace('[STAGE] Failed to parse stage data');
172172+173173+ for (error in parser.errors)
174174+ funkin.data.DataError.printError(error);
175175+176176+ return null;
177177+ }
178178+ return parser.value;
169179 }
170180 catch (e)
171181 {
···269279 inputProp.danceEvery = DEFAULT_DANCEEVERY;
270280 }
271281272272- if (inputProp.scale == null)
273273- {
274274- inputProp.scale = DEFAULT_SCALE;
275275- }
276276-277282 if (inputProp.animType == null)
278283 {
279284 inputProp.animType = DEFAULT_ANIMTYPE;
280285 }
281286282282- if (Std.isOfType(inputProp.scale, Float))
287287+ switch (inputProp.scale)
283288 {
284284- inputProp.scale = [inputProp.scale, inputProp.scale];
289289+ case null:
290290+ inputProp.scale = Right([DEFAULT_SCALE, DEFAULT_SCALE]);
291291+ case Left(value):
292292+ inputProp.scale = Right([value, value]);
293293+ case Right(_):
294294+ // Do nothing
285295 }
286296287287- if (inputProp.scroll == null)
297297+ switch (inputProp.scroll)
288298 {
289289- inputProp.scroll = DEFAULT_SCROLL;
299299+ case null:
300300+ inputProp.scroll = Right(DEFAULT_SCROLL);
301301+ case Left(value):
302302+ inputProp.scroll = Right([value, value]);
303303+ case Right(_):
304304+ // Do nothing
290305 }
291306292307 if (inputProp.alpha == null)
293308 {
294309 inputProp.alpha = DEFAULT_ALPHA;
295295- }
296296-297297- if (Std.isOfType(inputProp.scroll, Float))
298298- {
299299- inputProp.scroll = [inputProp.scroll, inputProp.scroll];
300310 }
301311302312 if (inputProp.animations == null)
···392402 }
393403}
394404395395-typedef StageData =
405405+class StageData
396406{
397407 /**
398408 * The sematic version number of the stage data JSON format.
399409 * Supports fancy comparisons like NPM does it's neat.
400410 */
401401- var version:String;
411411+ public var version:String;
402412403403- var name:String;
404404- var cameraZoom:Null<Float>;
405405- var props:Array<StageDataProp>;
406406- var characters:
407407- {
408408- bf:StageDataCharacter,
409409- dad:StageDataCharacter,
410410- gf:StageDataCharacter,
411411- };
413413+ public var name:String;
414414+ public var cameraZoom:Null<Float>;
415415+ public var props:Array<StageDataProp>;
416416+ public var characters:StageDataCharacters;
417417+418418+ public function new()
419419+ {
420420+ this.version = StageDataParser.STAGE_DATA_VERSION;
421421+ }
422422+423423+ /**
424424+ * Convert this StageData into a JSON string.
425425+ */
426426+ public function serialize(pretty:Bool = true):String
427427+ {
428428+ var writer = new json2object.JsonWriter<StageData>();
429429+ return writer.write(this, pretty ? ' ' : null);
430430+ }
431431+}
432432+433433+typedef StageDataCharacters =
434434+{
435435+ var bf:StageDataCharacter;
436436+ var dad:StageDataCharacter;
437437+ var gf:StageDataCharacter;
412438};
413439414440typedef StageDataProp =
···417443 * The name of the prop for later lookup by scripts.
418444 * Optional; if unspecified, the prop can't be referenced by scripts.
419445 */
446446+ @:optional
420447 var name:String;
421448422449 /**
···435462 * This is just like CSS, it isn't hard.
436463 * @default 0
437464 */
438438- var zIndex:Null<Int>;
465465+ @:optional
466466+ @:default(0)
467467+ var zIndex:Int;
439468440469 /**
441470 * If set to true, anti-aliasing will be forcibly disabled on the sprite.
442471 * This prevents blurry images on pixel-art levels.
443472 * @default false
444473 */
445445- var isPixel:Null<Bool>;
474474+ @:optional
475475+ @:default(false)
476476+ var isPixel:Bool;
446477447478 /**
448479 * Either the scale of the prop as a float, or the [w, h] scale as an array of two floats.
449480 * Pro tip: On pixel-art levels, save the sprite small and set this value to 6 or so to save memory.
450450- * @default 1
451481 */
452452- var scale:OneOfTwo<Float, Array<Float>>;
482482+ @:jcustomparse(funkin.data.DataParse.eitherFloatOrFloats)
483483+ @:jcustomwrite(funkin.data.DataWrite.eitherFloatOrFloats)
484484+ @:optional
485485+ var scale:haxe.ds.Either<Float, Array<Float>>;
453486454487 /**
455488 * The alpha of the prop, as a float.
456489 * @default 1.0
457490 */
458458- var alpha:Null<Float>;
491491+ @:optional
492492+ @:default(1.0)
493493+ var alpha:Float;
459494460495 /**
461496 * If not zero, this prop will play an animation every X beats of the song.
···464499 *
465500 * @default 0
466501 */
467467- var danceEvery:Null<Int>;
502502+ @:default(0)
503503+ @:optional
504504+ var danceEvery:Int;
468505469506 /**
470507 * How much the prop scrolls relative to the camera. Used to create a parallax effect.
···474511 * [0, 0] means the prop is not moved.
475512 * @default [0, 0]
476513 */
477477- var scroll:OneOfTwo<Float, Array<Float>>;
514514+ @:jcustomparse(funkin.data.DataParse.eitherFloatOrFloats)
515515+ @:jcustomwrite(funkin.data.DataWrite.eitherFloatOrFloats)
516516+ @:optional
517517+ var scroll:haxe.ds.Either<Float, Array<Float>>;
478518479519 /**
480520 * An optional array of animations which the prop can play.
481521 * @default Prop has no animations.
482522 */
523523+ @:optional
483524 var animations:Array<AnimationData>;
484525485526 /**
486527 * If animations are used, this is the name of the animation to play first.
487528 * @default Don't play an animation.
488529 */
489489- var startingAnimation:String;
530530+ @:optional
531531+ var startingAnimation:Null<String>;
490532491533 /**
492534 * The animation type to use.
493535 * Options: "sparrow", "packer"
494536 * @default "sparrow"
495537 */
538538+ @:default("sparrow")
539539+ @:optional
496540 var animType:String;
497541};
498542···503547 * Again, just like CSS.
504548 * @default 0
505549 */
506506- ?zIndex:Int,
550550+ @:optional
551551+ @:default(0)
552552+ var zIndex:Int;
507553508554 /**
509555 * The position to render the character at.
510556 */
511511- position:Array<Float>,
557557+ @:optional
558558+ @:default([0, 0])
559559+ var position:Array<Float>;
512560513561 /**
514562 * The camera offsets to apply when focusing on the character on this stage.
515563 * @default [-100, -100] for BF, [100, -100] for DAD/OPPONENT, [0, 0] for GF
516564 */
517517- cameraOffsets:Array<Float>,
565565+ @:optional
566566+ @:default([0, 0])
567567+ var cameraOffsets:Array<Float>;
518568};
+2-3
source/funkin/save/migrator/SaveDataMigrator.hx
···1313 */
1414 public static function migrate(inputData:Dynamic):Save
1515 {
1616- // This deserializes directly into a `Version` object, not a `String`.
1717- var version:Null<Version> = inputData?.version ?? null;
1616+ var version:Null<thx.semver.Version> = VersionUtil.parseVersion(inputData?.version ?? null);
18171918 if (version == null)
2019 {
···2423 }
2524 else
2625 {
2727- if (VersionUtil.validateVersionStr(version, Save.SAVE_DATA_VERSION_RULE))
2626+ if (VersionUtil.validateVersion(version, Save.SAVE_DATA_VERSION_RULE))
2827 {
2928 // Simply cast the structured data.
3029 var save:Save = inputData;
···1011101110121012 function get_currentSongNoteStyle():String
10131013 {
10141014- if (currentSongMetadata.playData.noteSkin == null)
10141014+ if (currentSongMetadata.playData.noteStyle == null)
10151015 {
10161016 // Initialize to the default value if not set.
10171017- currentSongMetadata.playData.noteSkin = 'funkin';
10171017+ currentSongMetadata.playData.noteStyle = Constants.DEFAULT_NOTE_STYLE;
10181018 }
10191019- return currentSongMetadata.playData.noteSkin;
10191019+ return currentSongMetadata.playData.noteStyle;
10201020 }
1021102110221022 function set_currentSongNoteStyle(value:String):String
10231023 {
10241024- return currentSongMetadata.playData.noteSkin = value;
10241024+ return currentSongMetadata.playData.noteStyle = value;
10251025 }
1026102610271027 var currentSongStage(get, set):String;
···1232123212331233 var renderedSelectionSquares:FlxTypedSpriteGroup<FlxSprite> = new FlxTypedSpriteGroup<FlxSprite>();
1234123412351235- public function new()
12351235+ /**
12361236+ * The params which were passed in when the Chart Editor was initialized.
12371237+ */
12381238+ var params:Null<ChartEditorParams>;
12391239+12401240+ /**
12411241+ * The current file path which the chart editor is working with.
12421242+ */
12431243+ public var currentWorkingFilePath:Null<String>;
12441244+12451245+ public function new(?params:ChartEditorParams)
12361246 {
12371247 // Load the HaxeUI XML file.
12381248 super(CHART_EDITOR_LAYOUT);
12491249+12501250+ this.params = params;
12391251 }
1240125212411253 override function create():Void
···12511263 fixCamera();
1252126412531265 // Get rid of any music from the previous state.
12541254- FlxG.sound.music.stop();
12661266+ if (FlxG.sound.music != null) FlxG.sound.music.stop();
1255126712561268 // Play the welcome music.
12571269 setupWelcomeMusic();
···1277128912781290 refresh();
1279129112801280- ChartEditorDialogHandler.openWelcomeDialog(this, false);
12921292+ if (params != null && params.fnfcTargetPath != null)
12931293+ {
12941294+ // Chart editor was opened from the command line. Open the FNFC file now!
12951295+ if (ChartEditorImportExportHandler.loadFromFNFCPath(this, params.fnfcTargetPath))
12961296+ {
12971297+ // Don't open the welcome dialog!
12981298+12991299+ #if !mac
13001300+ NotificationManager.instance.addNotification(
13011301+ {
13021302+ title: 'Success',
13031303+ body: 'Loaded chart (${params.fnfcTargetPath})',
13041304+ type: NotificationType.Success,
13051305+ expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
13061306+ });
13071307+ #end
13081308+ }
13091309+ else
13101310+ {
13111311+ // Song failed to load, open the Welcome dialog so we aren't in a broken state.
13121312+ ChartEditorDialogHandler.openWelcomeDialog(this, false);
13131313+ }
13141314+ }
13151315+ else
13161316+ {
13171317+ ChartEditorDialogHandler.openWelcomeDialog(this, false);
13181318+ }
12811319 }
1282132012831321 function setupWelcomeMusic()
···16321670 noteSnapQuantIndex++;
16331671 if (noteSnapQuantIndex >= SNAP_QUANTS.length) noteSnapQuantIndex = 0;
16341672 });
16731673+ addUIRightClickListener('playbarNoteSnap', function(_) {
16741674+ noteSnapQuantIndex--;
16751675+ if (noteSnapQuantIndex < 0) noteSnapQuantIndex = SNAP_QUANTS.length - 1;
16761676+ });
1635167716361678 // Add functionality to the menu items.
1637167916381680 addUIClickListener('menubarItemNewChart', _ -> ChartEditorDialogHandler.openWelcomeDialog(this, true));
16391639- addUIClickListener('menubarItemOpenChart', _ -> ChartEditorDialogHandler.openBrowseWizard(this, true));
16811681+ addUIClickListener('menubarItemOpenChart', _ -> ChartEditorDialogHandler.openBrowseFNFC(this, true));
16401682 addUIClickListener('menubarItemSaveChartAs', _ -> ChartEditorImportExportHandler.exportAllSongData(this));
16411683 addUIClickListener('menubarItemLoadInst', _ -> ChartEditorDialogHandler.openUploadInstDialog(this, true));
16421684 addUIClickListener('menubarItemImportChart', _ -> ChartEditorDialogHandler.openImportChartDialog(this, 'legacy', true));
···17761818 addUIChangeListener('menubarItemVolumeVocals', function(event:UIEvent) {
17771819 var volume:Float = (event?.value ?? 0) / 100.0;
17781820 if (audioVocalTrackGroup != null) audioVocalTrackGroup.volume = volume;
17791779- vocalsVolumeLabel.text = 'Vocals - ${Std.int(event.value)}%';
18211821+ vocalsVolumeLabel.text = 'Voices - ${Std.int(event.value)}%';
17801822 });
17811823 }
17821824···19131955 {
19141956 FlxG.watch.addQuick('scrollPosInPixels', scrollPositionInPixels);
19151957 FlxG.watch.addQuick('playheadPosInPixels', playheadPositionInPixels);
19581958+19591959+ // Add a debug value which displays the current size of the note pool.
19601960+ // The pool will grow as more notes need to be rendered at once.
19611961+ // If this gets too big, something needs to be optimized somewhere! -Eric
19621962+ if (renderedNotes != null && renderedNotes.members != null) FlxG.watch.addQuick("tapNotesRendered", renderedNotes.members.length);
19631963+ if (renderedHoldNotes != null && renderedHoldNotes.members != null) FlxG.watch.addQuick("holdNotesRendered", renderedHoldNotes.members.length);
19641964+ if (renderedEvents != null && renderedEvents.members != null) FlxG.watch.addQuick("eventsRendered", renderedEvents.members.length);
19651965+ if (currentNoteSelection != null) FlxG.watch.addQuick("notesSelected", currentNoteSelection.length);
19661966+ if (currentEventSelection != null) FlxG.watch.addQuick("eventsSelected", currentEventSelection.length);
19161967 }
1917196819181969 /**
···30373088 // Sort the events DESCENDING. This keeps the sustain behind the associated note.
30383089 renderedEvents.sort(FlxSort.byY, FlxSort.DESCENDING); // TODO: .group.insertionSort()
30393090 }
30403040-30413041- // Add a debug value which displays the current size of the note pool.
30423042- // The pool will grow as more notes need to be rendered at once.
30433043- // If this gets too big, something needs to be optimized somewhere! -Eric
30443044- FlxG.watch.addQuick("tapNotesRendered", renderedNotes.members.length);
30453045- FlxG.watch.addQuick("holdNotesRendered", renderedHoldNotes.members.length);
30463046- FlxG.watch.addQuick("eventsRendered", renderedEvents.members.length);
30473047- FlxG.watch.addQuick("notesSelected", currentNoteSelection.length);
30483048- FlxG.watch.addQuick("eventsSelected", currentEventSelection.length);
30493091 }
3050309230513093 /**
···31523194 // CTRL + O = Open Chart
31533195 if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.O)
31543196 {
31553155- ChartEditorDialogHandler.openBrowseWizard(this, true);
31973197+ ChartEditorDialogHandler.openBrowseFNFC(this, true);
31563198 }
3157319931583200 // CTRL + SHIFT + S = Save As
···31683210 }
31693211 }
3170321232133213+ @:nullSafety(Off)
31713214 function quitChartEditor():Void
31723215 {
31733216 autoSave();
31743217 stopWelcomeMusic();
32183218+ // TODO: PR Flixel to make onComplete nullable.
32193219+ if (audioInstTrack != null) audioInstTrack.onComplete = null;
31753220 FlxG.switchState(new MainMenuState());
31763221 }
31773222···36913736 if (inputStage != null) inputStage.value = currentSongMetadata.playData.stage;
3692373736933738 var inputNoteStyle:Null<DropDown> = toolbox.findComponent('inputNoteStyle', DropDown);
36943694- if (inputNoteStyle != null) inputNoteStyle.value = currentSongMetadata.playData.noteSkin;
37393739+ if (inputNoteStyle != null) inputNoteStyle.value = currentSongMetadata.playData.noteStyle;
3695374036963741 var inputBPM:Null<NumberStepper> = toolbox.findComponent('inputBPM', NumberStepper);
36973742 if (inputBPM != null) inputBPM.value = currentSongMetadata.timeChanges[0].bpm;
···43514396 NumberKeys;
43524397 WASD;
43534398}
43994399+44004400+typedef ChartEditorParams =
44014401+{
44024402+ /**
44034403+ * If non-null, load this song immediately instead of the welcome screen.
44044404+ */
44054405+ var ?fnfcTargetPath:String;
44064406+};
+17
source/funkin/ui/haxeui/HaxeUIState.hx
···108108 }
109109 }
110110111111+ /**
112112+ * Add an onRightClick listener to a HaxeUI menu bar item.
113113+ */
114114+ function addUIRightClickListener(key:String, callback:MouseEvent->Void):Void
115115+ {
116116+ var target:Component = findComponent(key);
117117+ if (target == null)
118118+ {
119119+ // Gracefully handle the case where the item can't be located.
120120+ trace('WARN: Could not locate menu item: $key');
121121+ }
122122+ else
123123+ {
124124+ target.onRightClick = callback;
125125+ }
126126+ }
127127+111128 function setComponentText(key:String, text:String):Void
112129 {
113130 var target:Component = findComponent(key);
+134
source/funkin/util/CLIUtil.hx
···11+package funkin.util;
22+33+/**
44+ * Utilties for interpreting command line arguments.
55+ */
66+@:nullSafety
77+class CLIUtil
88+{
99+ /**
1010+ * If we don't do this, dragging and dropping a file onto the executable
1111+ * causes it to be unable to find the assets folder.
1212+ */
1313+ public static function resetWorkingDir():Void
1414+ {
1515+ #if sys
1616+ var exeDir:String = haxe.io.Path.directory(Sys.programPath());
1717+ trace('Changing working directory from ${Sys.getCwd()} to ${exeDir}');
1818+ Sys.setCwd(exeDir);
1919+ #end
2020+ }
2121+2222+ public static function processArgs():CLIParams
2323+ {
2424+ #if sys
2525+ return interpretArgs(cleanArgs(Sys.args()));
2626+ #else
2727+ return buildDefaultParams();
2828+ #end
2929+ }
3030+3131+ static function interpretArgs(args:Array<String>):CLIParams
3232+ {
3333+ var result = buildDefaultParams();
3434+3535+ result.args = [for (arg in args) arg]; // Copy the array.
3636+3737+ while (args.length > 0)
3838+ {
3939+ var arg:Null<String> = args.shift();
4040+ if (arg == null) continue;
4141+4242+ if (arg.startsWith('-'))
4343+ {
4444+ switch (arg)
4545+ {
4646+ // Flags
4747+ case '-h' | '--help':
4848+ printUsage();
4949+ case '-v' | '--version':
5050+ trace(Constants.GENERATED_BY);
5151+ case '--chart':
5252+ if (args.length == 0)
5353+ {
5454+ trace('No chart path provided.');
5555+ printUsage();
5656+ }
5757+ else
5858+ {
5959+ result.chart.shouldLoadChart = true;
6060+ result.chart.chartPath = args.shift();
6161+ }
6262+ }
6363+ }
6464+ else
6565+ {
6666+ // Make an attempt to interpret the argument.
6767+6868+ if (arg.endsWith(Constants.EXT_CHART))
6969+ {
7070+ result.chart.shouldLoadChart = true;
7171+ result.chart.chartPath = arg;
7272+ }
7373+ else
7474+ {
7575+ trace('Unrecognized argument: ${arg}');
7676+ printUsage();
7777+ }
7878+ }
7979+ }
8080+8181+ return result;
8282+ }
8383+8484+ static function printUsage():Void
8585+ {
8686+ trace('Usage: Funkin.exe [--chart <chart>]');
8787+ }
8888+8989+ static function buildDefaultParams():CLIParams
9090+ {
9191+ return {
9292+ args: [],
9393+9494+ chart:
9595+ {
9696+ shouldLoadChart: false,
9797+ chartPath: null
9898+ }
9999+ };
100100+ }
101101+102102+ /**
103103+ * Clean up the arguments passed to the application before parsing them.
104104+ * @param args The arguments to clean up.
105105+ * @return The cleaned up arguments.
106106+ */
107107+ static function cleanArgs(args:Array<String>):Array<String>
108108+ {
109109+ var result:Array<String> = [];
110110+111111+ if (args == null || args.length == 0) return result;
112112+113113+ return args.map(function(arg:String):String {
114114+ if (arg == null) return '';
115115+116116+ return arg.trim();
117117+ }).filter(function(arg:String):Bool {
118118+ return arg != null && arg != '';
119119+ });
120120+ }
121121+}
122122+123123+typedef CLIParams =
124124+{
125125+ var args:Array<String>;
126126+127127+ var chart:CLIChartParams;
128128+}
129129+130130+typedef CLIChartParams =
131131+{
132132+ var shouldLoadChart:Bool;
133133+ var chartPath:Null<String>;
134134+};
+59-6
source/funkin/util/FileUtil.hx
···1414 */
1515class FileUtil
1616{
1717+ public static final FILE_FILTER_FNFC:FileFilter = new FileFilter("Friday Night Funkin' Chart (.fnfc)", "*.fnfc");
1818+ public static final FILE_FILTER_ZIP:FileFilter = new FileFilter("ZIP Archive (.zip)", "*.zip");
1919+1720 /**
1821 * Browses for a single file, then calls `onSelect(path)` when a path chosen.
1922 * Note that on HTML5 this will immediately fail, you should call `openFile(onOpen:Resource->Void)` instead.
···173176 *
174177 * @return Whether the file dialog was opened successfully.
175178 */
176176- public static function saveFile(data:Bytes, ?onSave:String->Void, ?onCancel:Void->Void, ?defaultFileName:String, ?dialogTitle:String):Bool
179179+ public static function saveFile(data:Bytes, ?typeFilter:Array<FileFilter>, ?onSave:String->Void, ?onCancel:Void->Void, ?defaultFileName:String,
180180+ ?dialogTitle:String):Bool
177181 {
178182 #if desktop
179179- var filter:String = defaultFileName != null ? Path.extension(defaultFileName) : null;
183183+ var filter:String = convertTypeFilter(typeFilter);
180184181185 var fileDialog:FileDialog = new FileDialog();
182186 if (onSave != null) fileDialog.onSelect.add(onSave);
···231235 }
232236 catch (_)
233237 {
234234- trace('Failed to write file (probably already exists): $filePath' + filePath);
235235- continue;
238238+ throw 'Failed to write file (probably already exists): $filePath';
236239 }
237240 paths.push(filePath);
238241 }
···269272 };
270273271274 // Prompt the user to save the ZIP file.
272272- saveFile(zipBytes, onSave, onCancel, defaultPath, 'Save files as ZIP...');
275275+ saveFile(zipBytes, [FILE_FILTER_ZIP], onSave, onCancel, defaultPath, 'Save files as ZIP...');
276276+277277+ return true;
278278+ }
279279+280280+ /**
281281+ * Takes an array of file entries and prompts the user to save them as a FNFC file.
282282+ */
283283+ public static function saveChartAsFNFC(resources:Array<Entry>, ?onSave:Array<String>->Void, ?onCancel:Void->Void, ?defaultPath:String,
284284+ force:Bool = false):Bool
285285+ {
286286+ // Create a ZIP file.
287287+ var zipBytes:Bytes = createZIPFromEntries(resources);
288288+289289+ var onSave:String->Void = function(path:String) {
290290+ onSave([path]);
291291+ };
292292+293293+ // Prompt the user to save the ZIP file.
294294+ saveFile(zipBytes, [FILE_FILTER_FNFC], onSave, onCancel, defaultPath, 'Save chart as FNFC...');
273295274296 return true;
275297 }
···322344 public static function readBytesFromPath(path:String):Bytes
323345 {
324346 #if sys
325325- return Bytes.ofString(sys.io.File.getContent(path));
347347+ if (!sys.FileSystem.exists(path)) return null;
348348+ return sys.io.File.getBytes(path);
326349 #else
327350 return null;
328351 #end
···557580 zipWriter.write(entries.list());
558581559582 return o.getBytes();
583583+ }
584584+585585+ public static function readZIPFromBytes(input:Bytes):Array<Entry>
586586+ {
587587+ trace('TEST: ' + input.length);
588588+ trace(input.sub(0, 30).toHex());
589589+590590+ var bytesInput = new haxe.io.BytesInput(input);
591591+ var zippedEntries = haxe.zip.Reader.readZip(bytesInput);
592592+593593+ var results:Array<Entry> = [];
594594+ for (entry in zippedEntries)
595595+ {
596596+ if (entry.compressed)
597597+ {
598598+ entry.data = haxe.zip.Reader.unzip(entry);
599599+ }
600600+ results.push(entry);
601601+ }
602602+ return results;
603603+ }
604604+605605+ public static function mapZIPEntriesByName(input:Array<Entry>):Map<String, Entry>
606606+ {
607607+ var results:Map<String, Entry> = [];
608608+ for (entry in input)
609609+ {
610610+ results.set(entry.fileName, entry);
611611+ }
612612+ return results;
560613 }
561614562615 /**
+2
source/funkin/util/SerializerUtil.hx
···21212222 /**
2323 * Convert a Haxe object to a JSON string.
2424+ * NOTE: Use `json2object.JsonWriter<T>` WHEREVER POSSIBLE. Do not use this one unless you ABSOLUTELY HAVE TO it's SLOW!
2525+ * And don't even THINK about using `haxe.Json.stringify` without the replacer!
2426 */
2527 public static function toJSON(input:Dynamic, pretty:Bool = true):String
2628 {
+18
source/funkin/util/VersionUtil.hx
···6161 var version:thx.semver.Version = versionStr; // Implicit, not explicit, cast.
6262 return version;
6363 }
6464+6565+ public static function parseVersion(input:Dynamic):Null<thx.semver.Version>
6666+ {
6767+ if (input == null) return null;
6868+6969+ if (Std.isOfType(input, String))
7070+ {
7171+ var inputStr:String = input;
7272+ var version:thx.semver.Version = inputStr;
7373+ return version;
7474+ }
7575+ else
7676+ {
7777+ var semVer:thx.semver.Version.SemVer = input;
7878+ var version:thx.semver.Version = semVer;
7979+ return version;
8080+ }
8181+ }
6482}