Merge branch 'rewrite/master' into rewrite/bugfix/ci-bad-refname-cherrypick

This commit is contained in:
Hazel 2023-10-09 19:04:21 +01:00 committed by GitHub
commit 2a25c4c625
43 changed files with 2543 additions and 1756 deletions

View File

@ -3,18 +3,31 @@ description: "sets up haxe shit, using HMM!"
runs:
using: "composite"
steps:
- uses: krdlab/setup-haxe@v1.5.1
with:
haxe-version: 4.3.1
- name: Config haxelib
run: |
haxelib config
shell: bash
- name: Installing Haxe lol
run: |
haxe -version
haxelib git haxelib https://github.com/HaxeFoundation/haxelib.git development
haxelib version
haxelib --global install hmm
haxelib --global run hmm install --quiet
shell: bash
- uses: krdlab/setup-haxe@v1.5.1
with:
haxe-version: 4.3.1
- name: Config haxelib
run: |
haxelib config
shell: bash
- name: Installing Haxe lol
run: |
haxe -version
haxelib git haxelib https://github.com/HaxeFoundation/haxelib.git development
haxelib version
haxelib --global install hmm
shell: bash
- name: dependency install cache
id: cache-hmm
uses: actions/cache@v3
with:
path: .haxelib
key: ${{ runner.os }}-hmm-${{ hashFiles('**/hmm.json') }}
restore-keys: |
${{ runner.os }}-hmm-
${{ runner.os }}-
- if: ${{ steps.cache-hmm.outputs.cache-hit != 'true' }}
name: hmm install
run: |
haxelib --global run hmm install
shell: bash

5
.github/hooks/README.md vendored Normal file
View File

@ -0,0 +1,5 @@
# Git Hooks
These work even on Windows because of Git Bash.
## Setup
`git config core.hooksPath .github/hooks`

2
.github/hooks/post-checkout vendored Normal file
View File

@ -0,0 +1,2 @@
#!/bin/sh
git submodule update --init --recursive

2
.github/hooks/post-merge vendored Normal file
View File

@ -0,0 +1,2 @@
#!/bin/sh
git submodule update --init --recursive

5
.github/hooks/pre-push vendored Normal file
View File

@ -0,0 +1,5 @@
#!/bin/sh
if git diff --cached --submodule | grep -q "^+"; then
echo "WARNING: You have unpushed changes in submodules."
exit 1
fi

View File

@ -30,6 +30,7 @@ jobs:
- uses: ./.github/actions/setup-haxeshit
- name: Build game
run: |
sudo apt-get update
sudo apt-get install -y libx11-dev xorg-dev libgl-dev libxi-dev libxext-dev libasound2-dev libxinerama-dev libxrandr-dev libgl1-mesa-dev
haxelib run lime build html5 -release --times
ls
@ -60,18 +61,19 @@ jobs:
butler-key: ${{ secrets.BUTLER_API_KEY}}
build-dir: export/release/windows/bin
target: win
test-unit-win:
needs: create-nightly-win
runs-on: windows-latest
permissions:
contents: write
actions: write
steps:
- uses: actions/checkout@v3
with:
submodules: 'recursive'
- uses: ./.github/actions/setup-haxeshit
- name: Run unit tests
run: |
cd ./tests/unit/
./start-win-native.bat
# test-unit-win:
# needs: create-nightly-win
# runs-on: windows-latest
# permissions:
# contents: write
# actions: write
# steps:
# - uses: actions/checkout@v3
# with:
# submodules: 'recursive'
# token: ${{ secrets.GH_RO_PAT }}
# - uses: ./.github/actions/setup-haxeshit
# - name: Run unit tests
# run: |
# cd ./tests/unit/
# ./start-win-native.bat

2
assets

@ -1 +1 @@
Subproject commit a62e7e50d59c14d256c75da651b79dea77e1620e
Subproject commit 7bc9407e0e8141a643605ff4514ba63169cc41e2

View File

@ -97,8 +97,8 @@
"name": "json2object",
"type": "git",
"dir": null,
"ref": "429986134031cbb1980f09d0d3d642b4b4cbcd6a",
"url": "https://github.com/elnabo/json2object"
"ref": "f4df19cfa196f85eece55c3367021fc965f1fa9a",
"url": "https://github.com/EliteMasterEric/json2object"
},
{
"name": "lime",
@ -139,7 +139,7 @@
"name": "openfl",
"type": "git",
"dir": null,
"ref": "d33d489a137ff8fdece4994cf1302f0b6334ed08",
"ref": "de9395d2f367a80f93f082e1b639b9cde2258bf1",
"url": "https://github.com/EliteMasterEric/openfl"
},
{
@ -160,4 +160,4 @@
"version": "0.11.0"
}
]
}
}

View File

@ -6,9 +6,6 @@ import openfl.utils.Assets as OpenFlAssets;
class Paths
{
public static var SOUND_EXT = #if web "mp3" #else "ogg" #end;
public static var VIDEO_EXT = "mp4";
static var currentLevel:String;
static public function setCurrentLevel(name:String)
@ -84,7 +81,7 @@ class Paths
static public function sound(key:String, ?library:String)
{
return getPath('sounds/$key.$SOUND_EXT', SOUND, library);
return getPath('sounds/$key.${Constants.EXT_SOUND}', SOUND, library);
}
inline static public function soundRandom(key:String, min:Int, max:Int, ?library:String)
@ -94,24 +91,24 @@ class Paths
inline static public function music(key:String, ?library:String)
{
return getPath('music/$key.$SOUND_EXT', MUSIC, library);
return getPath('music/$key.${Constants.EXT_SOUND}', MUSIC, library);
}
inline static public function videos(key:String, ?library:String)
{
return getPath('videos/$key.$VIDEO_EXT', BINARY, library);
return getPath('videos/$key.${Constants.EXT_VIDEO}', BINARY, library);
}
inline static public function voices(song:String, ?suffix:String = '')
{
if (suffix == null) suffix = ""; // no suffix, for a sorta backwards compatibility with older-ish voice files
return 'songs:assets/songs/${song.toLowerCase()}/Voices$suffix.$SOUND_EXT';
return 'songs:assets/songs/${song.toLowerCase()}/Voices$suffix.${Constants.EXT_SOUND}';
}
inline static public function inst(song:String, ?suffix:String = '')
{
return 'songs:assets/songs/${song.toLowerCase()}/Inst$suffix.$SOUND_EXT';
return 'songs:assets/songs/${song.toLowerCase()}/Inst$suffix.${Constants.EXT_SOUND}';
}
inline static public function image(key:String, ?library:String)

View File

@ -4,9 +4,6 @@ import openfl.Assets;
import funkin.util.assets.DataAssets;
import funkin.util.VersionUtil;
import haxe.Constraints.Constructible;
import json2object.Position;
import json2object.Position.Line;
import json2object.Error;
/**
* The entry's constructor function must take a single argument, the entry's ID.
@ -179,6 +176,15 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
*/
public abstract function parseEntryData(id:String):Null<J>;
/**
* Parse and validate the JSON data and produce the corresponding data object.
*
* NOTE: Must be implemented on the implementation class.
* @param contents The JSON as a string.
* @param fileName An optional file name for error reporting.
*/
public abstract function parseEntryDataRaw(contents:String, ?fileName:String):Null<J>;
/**
* Read, parse, and validate the JSON data and produce the corresponding data object,
* accounting for old versions of the data.
@ -226,79 +232,12 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
*/
abstract function createScriptedEntry(clsName:String):Null<T>;
function printErrors(errors:Array<Error>, id:String = ''):Void
function printErrors(errors:Array<json2object.Error>, id:String = ''):Void
{
trace('[${registryId}] Failed to parse entry data: ${id}');
for (error in errors)
printError(error);
}
function printError(error:Error):Void
{
switch (error)
{
case IncorrectType(vari, expected, pos):
trace(' Expected field "$vari" to be of type "$expected".');
printPos(pos);
case IncorrectEnumValue(value, expected, pos):
trace(' Invalid enum value (expected "$expected", got "$value")');
printPos(pos);
case InvalidEnumConstructor(value, expected, pos):
trace(' Invalid enum constructor (epxected "$expected", got "$value")');
printPos(pos);
case UninitializedVariable(vari, pos):
trace(' Uninitialized variable "$vari"');
printPos(pos);
case UnknownVariable(vari, pos):
trace(' Unknown variable "$vari"');
printPos(pos);
case ParserError(message, pos):
trace(' Parsing error: ${message}');
printPos(pos);
case CustomFunctionException(e, pos):
if (Std.isOfType(e, String))
{
trace(' ${e}');
}
else
{
printUnknownError(e);
}
printPos(pos);
default:
printUnknownError(error);
}
}
function printUnknownError(e:Dynamic):Void
{
switch (Type.typeof(e))
{
case TClass(c):
trace(' [${Type.getClassName(c)}] ${e.toString()}');
case TEnum(c):
trace(' [${Type.getEnumName(c)}] ${e.toString()}');
default:
trace(' [${Type.typeof(e)}] ${e.toString()}');
}
}
/**
* TODO: Figure out the nicest way to print this.
* Maybe look up how other JSON parsers format their errors?
* @see https://github.com/elnabo/json2object/blob/master/src/json2object/Position.hx
*/
function printPos(pos:Position):Void
{
if (pos.lines[0].number == pos.lines[pos.lines.length - 1].number)
{
trace(' at ${(pos.file == '') ? 'line ' : '${pos.file}:'}${pos.lines[0].number}');
}
else
{
trace(' at ${(pos.file == '') ? 'line ' : '${pos.file}:'}${pos.lines[0].number}-${pos.lines[pos.lines.length - 1].number}');
}
DataError.printError(error);
}
}

View File

@ -0,0 +1,75 @@
package funkin.data;
import json2object.Position;
import json2object.Position.Line;
import json2object.Error;
class DataError
{
public static function printError(error:Error):Void
{
switch (error)
{
case IncorrectType(vari, expected, pos):
trace(' Expected field "$vari" to be of type "$expected".');
printPos(pos);
case IncorrectEnumValue(value, expected, pos):
trace(' Invalid enum value (expected "$expected", got "$value")');
printPos(pos);
case InvalidEnumConstructor(value, expected, pos):
trace(' Invalid enum constructor (epxected "$expected", got "$value")');
printPos(pos);
case UninitializedVariable(vari, pos):
trace(' Uninitialized variable "$vari"');
printPos(pos);
case UnknownVariable(vari, pos):
trace(' Unknown variable "$vari"');
printPos(pos);
case ParserError(message, pos):
trace(' Parsing error: ${message}');
printPos(pos);
case CustomFunctionException(e, pos):
if (Std.isOfType(e, String))
{
trace(' ${e}');
}
else
{
printUnknownError(e);
}
printPos(pos);
default:
printUnknownError(error);
}
}
public static function printUnknownError(e:Dynamic):Void
{
switch (Type.typeof(e))
{
case TClass(c):
trace(' [${Type.getClassName(c)}] ${e.toString()}');
case TEnum(c):
trace(' [${Type.getEnumName(c)}] ${e.toString()}');
default:
trace(' [${Type.typeof(e)}] ${e.toString()}');
}
}
/**
* TODO: Figure out the nicest way to print this.
* Maybe look up how other JSON parsers format their errors?
* @see https://github.com/elnabo/json2object/blob/master/src/json2object/Position.hx
*/
static function printPos(pos:Position):Void
{
if (pos.lines[0].number == pos.lines[pos.lines.length - 1].number)
{
trace(' at ${(pos.file == '') ? 'line ' : '${pos.file}:'}${pos.lines[0].number}');
}
else
{
trace(' at ${(pos.file == '') ? 'line ' : '${pos.file}:'}${pos.lines[0].number}-${pos.lines[pos.lines.length - 1].number}');
}
}
}

View File

@ -1,7 +1,13 @@
package funkin.data;
import funkin.data.song.importer.FNFLegacyData.LegacyNote;
import hxjsonast.Json;
import hxjsonast.Tools;
import hxjsonast.Json.JObjectField;
import haxe.ds.Either;
import funkin.data.song.importer.FNFLegacyData.LegacyNoteSection;
import funkin.data.song.importer.FNFLegacyData.LegacyNoteData;
import funkin.data.song.importer.FNFLegacyData.LegacyScrollSpeeds;
/**
* `json2object` has an annotation `@:jcustomparse` which allows for mutation of parsed values.
@ -39,36 +45,40 @@ class DataParse
*/
public static function dynamicValue(json:Json, name:String):Dynamic
{
return jsonToDynamic(json);
return Tools.getValue(json);
}
/**
* Parser which outputs a Dynamic value, which must be an object with properties.
* @param json
* @param name
* @return Dynamic
* Parser which outputs a `Either<Array<LegacyNoteSection>, LegacyNoteData>`.
* Used by the FNF legacy JSON importer.
*/
public static function dynamicObject(json:Json, name:String):Dynamic
public static function eitherLegacyNoteData(json:Json, name:String):Either<Array<LegacyNoteSection>, LegacyNoteData>
{
switch (json.value)
{
case JArray(values):
return Either.Left(legacyNoteSectionArray(json, name));
case JObject(fields):
return jsonFieldsToDynamicObject(fields);
return Either.Right(cast Tools.getValue(json));
default:
throw 'Expected property $name to be an object, but it was ${json.value}.';
throw 'Expected property $name to be note data, but it was ${json.value}.';
}
}
static function jsonToDynamic(json:Json):Null<Dynamic>
/**
* Parser which outputs a `Either<Float, LegacyScrollSpeeds>`.
* Used by the FNF legacy JSON importer.
*/
public static function eitherLegacyScrollSpeeds(json:Json, name:String):Either<Float, LegacyScrollSpeeds>
{
return switch (json.value)
switch (json.value)
{
case JString(s): s;
case JNumber(n): Std.parseInt(n);
case JBool(b): b;
case JNull: null;
case JObject(fields): jsonFieldsToDynamicObject(fields);
case JArray(values): jsonArrayToDynamicArray(values);
case JNumber(f):
return Either.Left(Std.parseFloat(f));
case JObject(fields):
return Either.Right(cast Tools.getValue(json));
default:
throw 'Expected property $name to be scroll speeds, but it was ${json.value}.';
}
}
@ -82,7 +92,7 @@ class DataParse
var result:Dynamic = {};
for (field in fields)
{
Reflect.setField(result, field.name, jsonToDynamic(field.value));
Reflect.setField(result, field.name, Tools.getValue(field.value));
}
return result;
}
@ -94,6 +104,67 @@ class DataParse
*/
static function jsonArrayToDynamicArray(jsons:Array<Json>):Array<Null<Dynamic>>
{
return [for (json in jsons) jsonToDynamic(json)];
return [for (json in jsons) Tools.getValue(json)];
}
static function legacyNoteSectionArray(json:Json, name:String):Array<LegacyNoteSection>
{
switch (json.value)
{
case JArray(values):
return [for (value in values) legacyNoteSection(value, name)];
default:
throw 'Expected property to be an array, but it was ${json.value}.';
}
}
static function legacyNoteSection(json:Json, name:String):LegacyNoteSection
{
switch (json.value)
{
case JObject(fields):
return cast Tools.getValue(json);
default:
throw 'Expected property $name to be an object, but it was ${json.value}.';
}
}
public static function legacyNoteData(json:Json, name:String):LegacyNoteData
{
switch (json.value)
{
case JObject(fields):
return cast Tools.getValue(json);
default:
throw 'Expected property $name to be an object, but it was ${json.value}.';
}
}
public static function legacyNotes(json:Json, name:String):Array<LegacyNote>
{
switch (json.value)
{
case JArray(values):
return [for (value in values) legacyNote(value, name)];
default:
throw 'Expected property $name to be an array of notes, but it was ${json.value}.';
}
}
public static function legacyNote(json:Json, name:String):LegacyNote
{
switch (json.value)
{
case JArray(values):
// var time:Null<Float> = values[0] == null ? null : Tools.getValue(values[0]);
// var data:Null<Int> = values[1] == null ? null : Tools.getValue(values[1]);
// var length:Null<Float> = values[2] == null ? null : Tools.getValue(values[2]);
// var alt:Null<Bool> = values[3] == null ? null : Tools.getValue(values[3]);
// return new LegacyNote(time, data, length, alt);
return null;
default:
throw 'Expected property $name to be a note, but it was ${json.value}.';
}
}
}

View File

@ -1,8 +1,17 @@
package funkin.data;
import funkin.util.SerializerUtil;
/**
* `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.
*/
class DataWrite {}
class DataWrite
{
public static function dynamicValue(value:Dynamic):String
{
// Is this cheating? Yes. Do I care? No.
return SerializerUtil.toJSON(value);
}
}

View File

@ -67,7 +67,6 @@ typedef UnnamedAnimationData =
* ONLY for use by MultiSparrow characters.
* @default The assetPath of the parent sprite
*/
@:default(null)
@:optional
var assetPath:Null<String>;
@ -85,7 +84,7 @@ typedef UnnamedAnimationData =
*/
@:default(false)
@:optional
var looped:Null<Bool>;
var looped:Bool;
/**
* Whether the animation's sprites should be flipped horizontally.

View File

@ -47,6 +47,26 @@ class LevelRegistry extends BaseRegistry<Level, LevelData>
return parser.value;
}
/**
* Parse and validate the JSON data and produce the corresponding data object.
*
* NOTE: Must be implemented on the implementation class.
* @param contents The JSON as a string.
* @param fileName An optional file name for error reporting.
*/
public function parseEntryDataRaw(contents:String, ?fileName:String):Null<LevelData>
{
var parser = new json2object.JsonParser<LevelData>();
parser.fromJson(contents, fileName);
if (parser.errors.length > 0)
{
printErrors(parser.errors, fileName);
return null;
}
return parser.value;
}
function createScriptedEntry(clsName:String):Level
{
return ScriptedLevel.init(clsName, "unknown");

View File

@ -54,6 +54,26 @@ class NoteStyleRegistry extends BaseRegistry<NoteStyle, NoteStyleData>
return parser.value;
}
/**
* Parse and validate the JSON data and produce the corresponding data object.
*
* NOTE: Must be implemented on the implementation class.
* @param contents The JSON as a string.
* @param fileName An optional file name for error reporting.
*/
public function parseEntryDataRaw(contents:String, ?fileName:String):Null<NoteStyleData>
{
var parser = new json2object.JsonParser<NoteStyleData>();
parser.fromJson(contents, fileName);
if (parser.errors.length > 0)
{
printErrors(parser.errors, fileName);
return null;
}
return parser.value;
}
function createScriptedEntry(clsName:String):NoteStyle
{
return ScriptedNoteStyle.init(clsName, "unknown");

View File

@ -1,8 +1,6 @@
package funkin.data.song;
import flixel.util.typeLimit.OneOfTwo;
import funkin.play.song.SongMigrator;
import funkin.play.song.SongValidator;
import funkin.data.song.SongRegistry;
import thx.semver.Version;
@ -47,32 +45,33 @@ class SongMetadata
* Defaults to `default` or `''`. Populated later.
*/
@:jignored
public var variation:String = 'default';
public var variation:String;
public function new(songName:String, artist:String, variation:String = 'default')
public function new(songName:String, artist:String, ?variation:String)
{
this.version = SongMigrator.CHART_VERSION;
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 =
{
songVariations: [],
difficulties: ['normal'],
playableChars: ['bf' => new SongPlayableChar('gf', 'dad')],
stage: 'mainStage',
noteSkin: 'Normal'
};
this.playData = new SongPlayData();
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;
this.variation = (variation == null) ? Constants.DEFAULT_VARIATION : variation;
}
/**
* Create a copy of this SongMetadata with the same information.
* @param newVariation Set to a new variation ID to change the new metadata.
* @return The cloned SongMetadata
*/
public function clone(?newVariation:String = null):SongMetadata
{
var result:SongMetadata = new SongMetadata(this.songName, this.artist, newVariation == null ? this.variation : newVariation);
@ -87,6 +86,22 @@ class SongMetadata
return result;
}
/**
* Serialize this SongMetadata into a JSON string.
* @return The JSON string.
*/
public function serialize(pretty:Bool = true):String
{
var writer = new json2object.JsonWriter<SongMetadata>();
// I believe @:jignored should be iggnored by the writer?
// var output = this.clone();
// output.variation = null; // Not sure how to make a field optional on the reader and ignored on the writer.
return writer.write(this, pretty ? ' ' : null);
}
/**
* Produces a string representation suitable for debugging.
*/
public function toString():String
{
return 'SongMetadata(${this.songName} by ${this.artist}, variation ${this.variation})';
@ -121,7 +136,6 @@ class SongTimeChange
*/
@:optional
@:alias("b")
// @:default(funkin.data.song.SongData.SongTimeChange.DEFAULT_BEAT_TIME)
public var beatTime:Null<Float>;
/**
@ -168,6 +182,9 @@ class SongTimeChange
this.beatTuplets = beatTuplets == null ? DEFAULT_BEAT_TUPLETS : beatTuplets;
}
/**
* Produces a string representation suitable for debugging.
*/
public function toString():String
{
return 'SongTimeChange(${this.timeStamp}ms,${this.bpm}bpm)';
@ -199,7 +216,7 @@ class SongMusicData
@:optional
@:default(false)
public var looped:Bool;
public var looped:Null<Bool>;
// @:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY)
public var generatedBy:String;
@ -214,11 +231,11 @@ class SongMusicData
* Defaults to `default` or `''`. Populated later.
*/
@:jignored
public var variation:String = 'default';
public var variation:String = Constants.DEFAULT_VARIATION;
public function new(songName:String, artist:String, variation:String = 'default')
{
this.version = SongMigrator.CHART_VERSION;
this.version = SongRegistry.SONG_CHART_DATA_VERSION;
this.songName = songName;
this.artist = artist;
this.timeFormat = 'ms';
@ -227,7 +244,7 @@ class SongMusicData
this.looped = false;
this.generatedBy = SongRegistry.DEFAULT_GENERATEDBY;
// Variation ID.
this.variation = variation;
this.variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
}
public function clone(?newVariation:String = null):SongMusicData
@ -243,53 +260,106 @@ class SongMusicData
return result;
}
/**
* Produces a string representation suitable for debugging.
*/
public function toString():String
{
return 'SongMusicData(${this.songName} by ${this.artist}, variation ${this.variation})';
}
}
typedef SongPlayData =
class SongPlayData
{
/**
* The variations this song has. The associated metadata files should exist.
*/
public var songVariations:Array<String>;
/**
* The difficulties contained in this song's chart file.
*/
public var difficulties:Array<String>;
/**
* Keys are the player characters and the values give info on what opponent/GF/inst to use.
* The characters used by this song.
*/
public var playableChars:Map<String, SongPlayableChar>;
public var characters:SongCharacterData;
/**
* The stage used by this song.
*/
public var stage:String;
/**
* 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;
/**
* The difficulty rating for this song as displayed in Freeplay.
* TODO: Adding this is a non-breaking change to the metadata format.
*/
// public var rating:Int;
/**
* The album ID for the album to display in Freeplay.
* TODO: Adding this is a non-breaking change to the metadata format.
*/
// public var album:String;
public function new() {}
/**
* Produces a string representation suitable for debugging.
*/
public function toString():String
{
return 'SongPlayData(${this.songVariations}, ${this.difficulties})';
}
}
class SongPlayableChar
/**
* Information about the characters used in this variation of the song.
* Create a new variation if you want to change the characters.
*/
class SongCharacterData
{
@:alias('g')
@:optional
@:default('')
public var player:String = '';
@:optional
@:default('')
public var girlfriend:String = '';
@:alias('o')
@:optional
@:default('')
public var opponent:String = '';
@:alias('i')
@:optional
@:default('')
public var inst:String = '';
public var instrumental:String = '';
public function new(girlfriend:String = '', opponent:String = '', inst:String = '')
@:optional
@:default([])
public var altInstrumentals:Array<String> = [];
public function new(player:String = '', girlfriend:String = '', opponent:String = '', instrumental:String = '')
{
this.player = player;
this.girlfriend = girlfriend;
this.opponent = opponent;
this.inst = inst;
this.instrumental = instrumental;
}
/**
* Produces a string representation suitable for debugging.
*/
public function toString():String
{
return 'SongPlayableChar(${this.girlfriend}, ${this.opponent}, ${this.inst})';
return 'SongCharacterData(${this.player}, ${this.girlfriend}, ${this.opponent}, ${this.instrumental}, [${this.altInstrumentals.join(', ')}])';
}
}
@ -305,6 +375,9 @@ class SongChartData
@:default(funkin.data.song.SongRegistry.DEFAULT_GENERATEDBY)
public var generatedBy:String;
@:jignored
public var variation:String;
public function new(scrollSpeed:Map<String, Float>, events:Array<SongEventData>, notes:Map<String, Array<SongNoteData>>)
{
this.version = SongRegistry.SONG_CHART_DATA_VERSION;
@ -346,14 +419,21 @@ class SongChartData
return value;
}
public function getEvents():Array<SongEventData>
/**
* Convert this SongChartData into a JSON string.
*/
public function serialize(pretty:Bool = true):String
{
return this.events;
var writer = new json2object.JsonWriter<SongChartData>();
return writer.write(this, pretty ? ' ' : null);
}
public function setEvents(value:Array<SongEventData>):Array<SongEventData>
/**
* Produces a string representation suitable for debugging.
*/
public function toString():String
{
return this.events = value;
return 'SongChartData(${this.events.length} events, ${this.notes.size()} difficulties, ${generatedBy})';
}
}
@ -387,6 +467,7 @@ class SongEventData
@:alias("v")
@:optional
@:jcustomparse(funkin.data.DataParse.dynamicValue)
@:jcustomwrite(funkin.data.DataWrite.dynamicValue)
public var value:Dynamic = null;
/**
@ -484,6 +565,9 @@ class SongEventData
return this.time <= other.time;
}
/**
* Produces a string representation suitable for debugging.
*/
public function toString():String
{
return 'SongEventData(${this.time}ms, ${this.event}: ${this.value})';
@ -703,6 +787,9 @@ class SongNoteData
return this.time <= other.time;
}
/**
* Produces a string representation suitable for debugging.
*/
public function toString():String
{
return 'SongNoteData(${this.time}ms, ' + (this.length > 0 ? '[${this.length}ms hold]' : '') + ' ${this.data}'

View File

@ -8,6 +8,9 @@ import funkin.util.SerializerUtil;
using Lambda;
/**
* Utility functions for working with song data, including note data, event data, metadata, etc.
*/
class SongDataUtils
{
/**

View File

@ -1,6 +1,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.SongData.SongChartData;
import funkin.data.song.SongData.SongMetadata;
import funkin.play.song.ScriptedSong;
@ -8,6 +9,8 @@ import funkin.play.song.Song;
import funkin.util.assets.DataAssets;
import funkin.util.VersionUtil;
using funkin.data.song.migrator.SongDataMigrator;
class SongRegistry extends BaseRegistry<Song, SongMetadata>
{
/**
@ -15,14 +18,18 @@ 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.0.0";
public static final SONG_METADATA_VERSION:thx.semver.Version = "2.1.0";
public static final SONG_METADATA_VERSION_RULE:thx.semver.VersionRule = "2.0.x";
public static final SONG_METADATA_VERSION_RULE:thx.semver.VersionRule = "2.1.x";
public static final SONG_CHART_DATA_VERSION:thx.semver.Version = "2.0.0";
public static final SONG_CHART_DATA_VERSION_RULE:thx.semver.VersionRule = "2.0.x";
public static final SONG_MUSIC_DATA_VERSION:thx.semver.Version = "2.0.0";
public static final SONG_MUSIC_DATA_VERSION_RULE:thx.semver.VersionRule = "2.0.x";
public static var DEFAULT_GENERATEDBY(get, null):String;
static function get_DEFAULT_GENERATEDBY():String
@ -30,6 +37,10 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
return '${Constants.TITLE} - ${Constants.VERSION}';
}
/**
* TODO: What if there was a Singleton macro which created static functions
* that redirected to the instance?
*/
public static final instance:SongRegistry = new SongRegistry();
public function new()
@ -101,12 +112,91 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
return parseEntryMetadata(id);
}
public function parseEntryMetadata(id:String, variation:String = ""):Null<SongMetadata>
/**
* Parse, and validate the JSON data and produce the corresponding data object.
*/
public function parseEntryDataRaw(contents:String, ?fileName:String = 'raw'):Null<SongMetadata>
{
// JsonParser does not take type parameters,
// otherwise this function would be in BaseRegistry.
return parseEntryMetadataRaw(contents);
}
public function parseEntryMetadata(id:String, ?variation:String):Null<SongMetadata>
{
variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
var parser = new json2object.JsonParser<SongMetadata>();
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, variation);
}
public function parseEntryMetadataRaw(contents:String, ?fileName:String = 'raw', ?variation:String):Null<SongMetadata>
{
variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
var parser = new json2object.JsonParser<SongMetadata>();
parser.fromJson(contents, fileName);
if (parser.errors.length > 0)
{
printErrors(parser.errors, fileName);
return null;
}
return cleanMetadata(parser.value, variation);
}
public function parseEntryMetadataWithMigration(id:String, ?variation:String, version:thx.semver.Version):Null<SongMetadata>
{
variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
// If a version rule is not specified, do not check against it.
if (SONG_METADATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_METADATA_VERSION_RULE))
{
return parseEntryMetadata(id, variation);
}
else if (VersionUtil.validateVersion(version, "2.0.x"))
{
return parseEntryMetadata_v2_0_0(id, variation);
}
else
{
throw '[${registryId}] Metadata entry ${id}:${variation} does not support migration to version ${SONG_METADATA_VERSION_RULE}.';
}
}
public function parseEntryMetadataRawWithMigration(contents:String, ?fileName:String = 'raw', version:thx.semver.Version):Null<SongMetadata>
{
// If a version rule is not specified, do not check against it.
if (SONG_METADATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_METADATA_VERSION_RULE))
{
return parseEntryMetadataRaw(contents, fileName);
}
else if (VersionUtil.validateVersion(version, "2.0.x"))
{
return parseEntryMetadataRaw_v2_0_0(contents, fileName);
}
else
{
throw '[${registryId}] Metadata entry "${fileName}" does not support migration to version ${SONG_METADATA_VERSION_RULE}.';
}
}
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))
{
case {fileName: fileName, contents: contents}:
@ -114,6 +204,39 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
default:
return null;
}
if (parser.errors.length > 0)
{
printErrors(parser.errors, id);
return null;
}
return parser.value.migrate();
}
function parseEntryMetadataRaw_v2_0_0(contents:String, ?fileName:String = 'raw'):Null<SongMetadata>
{
var parser = new json2object.JsonParser<SongMetadata_v2_0_0>();
parser.fromJson(contents, fileName);
if (parser.errors.length > 0)
{
printErrors(parser.errors, fileName);
return null;
}
return parser.value.migrate();
}
public function parseMusicData(id:String, ?variation:String):Null<SongMusicData>
{
variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
var parser = new json2object.JsonParser<SongMusicData>();
switch (loadMusicDataFile(id, variation))
{
case {fileName: fileName, contents: contents}:
parser.fromJson(contents, fileName);
default:
return null;
}
if (parser.errors.length > 0)
{
@ -123,48 +246,54 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
return parser.value;
}
public function parseEntryMetadataWithMigration(id:String, variation:String = '', version:thx.semver.Version):Null<SongMetadata>
public function parseMusicDataRaw(contents:String, ?fileName:String = 'raw'):Null<SongMusicData>
{
// If a version rule is not specified, do not check against it.
if (SONG_METADATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_METADATA_VERSION_RULE))
var parser = new json2object.JsonParser<SongMusicData>();
parser.fromJson(contents, fileName);
if (parser.errors.length > 0)
{
return parseEntryMetadata(id);
printErrors(parser.errors, fileName);
return null;
}
return parser.value;
}
public function parseMusicDataWithMigration(id:String, ?variation:String, version:thx.semver.Version):Null<SongMusicData>
{
variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
// If a version rule is not specified, do not check against it.
if (SONG_MUSIC_DATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_MUSIC_DATA_VERSION_RULE))
{
return parseMusicData(id, variation);
}
else
{
throw '[${registryId}] Metadata entry ${id}:${variation == '' ? 'default' : variation} does not support migration to version ${SONG_METADATA_VERSION_RULE}.';
throw '[${registryId}] Chart entry ${id}:${variation} does not support migration to version ${SONG_CHART_DATA_VERSION_RULE}.';
}
}
public function parseMusicData(id:String, variation:String = ""):Null<SongMusicData>
public function parseMusicDataRawWithMigration(contents:String, ?fileName:String = 'raw', version:thx.semver.Version):Null<SongMusicData>
{
// JsonParser does not take type parameters,
// otherwise this function would be in BaseRegistry.
var parser = new json2object.JsonParser<SongMusicData>();
switch (loadMusicDataFile(id))
// If a version rule is not specified, do not check against it.
if (SONG_MUSIC_DATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_MUSIC_DATA_VERSION_RULE))
{
case {fileName: fileName, contents: contents}:
parser.fromJson(contents, fileName);
default:
return null;
return parseMusicDataRaw(contents, fileName);
}
if (parser.errors.length > 0)
else
{
printErrors(parser.errors, id);
return null;
throw '[${registryId}] Chart entry "$fileName" does not support migration to version ${SONG_CHART_DATA_VERSION_RULE}.';
}
return parser.value;
}
public function parseEntryChartData(id:String, variation:String = ''):Null<SongChartData>
public function parseEntryChartData(id:String, ?variation:String):Null<SongChartData>
{
// JsonParser does not take type parameters,
// otherwise this function would be in BaseRegistry.
variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
var parser = new json2object.JsonParser<SongChartData>();
switch (loadEntryChartFile(id))
switch (loadEntryChartFile(id, variation))
{
case {fileName: fileName, contents: contents}:
parser.fromJson(contents, fileName);
@ -177,11 +306,28 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
printErrors(parser.errors, id);
return null;
}
return parser.value;
return cleanChartData(parser.value, variation);
}
public function parseEntryChartDataWithMigration(id:String, variation:String = '', version:thx.semver.Version):Null<SongChartData>
public function parseEntryChartDataRaw(contents:String, ?fileName:String = 'raw', ?variation:String):Null<SongChartData>
{
variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
var parser = new json2object.JsonParser<SongChartData>();
parser.fromJson(contents, fileName);
if (parser.errors.length > 0)
{
printErrors(parser.errors, fileName);
return null;
}
return cleanChartData(parser.value, variation);
}
public function parseEntryChartDataWithMigration(id:String, ?variation:String, version:thx.semver.Version):Null<SongChartData>
{
variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
// If a version rule is not specified, do not check against it.
if (SONG_CHART_DATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_CHART_DATA_VERSION_RULE))
{
@ -189,7 +335,20 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
}
else
{
throw '[${registryId}] Chart entry ${id}:${variation == '' ? 'default' : variation} does not support migration to version ${SONG_CHART_DATA_VERSION_RULE}.';
throw '[${registryId}] Chart entry ${id}:${variation} does not support migration to version ${SONG_CHART_DATA_VERSION_RULE}.';
}
}
public function parseEntryChartDataRawWithMigration(contents:String, ?fileName:String = 'raw', version:thx.semver.Version):Null<SongChartData>
{
// If a version rule is not specified, do not check against it.
if (SONG_CHART_DATA_VERSION_RULE == null || VersionUtil.validateVersion(version, SONG_CHART_DATA_VERSION_RULE))
{
return parseEntryChartDataRaw(contents, fileName);
}
else
{
throw '[${registryId}] Chart entry "${fileName}" does not support migration to version ${SONG_CHART_DATA_VERSION_RULE}.';
}
}
@ -203,9 +362,10 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
return ScriptedSong.listScriptClasses();
}
function loadEntryMetadataFile(id:String, variation:String = ''):Null<BaseRegistry.JsonFile>
function loadEntryMetadataFile(id:String, ?variation:String):Null<BaseRegistry.JsonFile>
{
var entryFilePath:String = Paths.json('$dataFilePath/$id/$id${variation == '' ? '' : '-$variation'}-metadata');
variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
var entryFilePath:String = Paths.json('$dataFilePath/$id/$id-metadata${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}');
if (!openfl.Assets.exists(entryFilePath)) return null;
var rawJson:Null<String> = openfl.Assets.getText(entryFilePath);
if (rawJson == null) return null;
@ -213,9 +373,10 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
return {fileName: entryFilePath, contents: rawJson};
}
function loadMusicDataFile(id:String, variation:String = ''):Null<BaseRegistry.JsonFile>
function loadMusicDataFile(id:String, ?variation:String):Null<BaseRegistry.JsonFile>
{
var entryFilePath:String = Paths.file('music/$id/$id${variation == '' ? '' : '-$variation'}-metadata.json');
variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
var entryFilePath:String = Paths.file('music/$id/$id-metadata${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}.json');
if (!openfl.Assets.exists(entryFilePath)) return null;
var rawJson:String = openfl.Assets.getText(entryFilePath);
if (rawJson == null) return null;
@ -223,9 +384,10 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
return {fileName: entryFilePath, contents: rawJson};
}
function loadEntryChartFile(id:String, variation:String = ''):Null<BaseRegistry.JsonFile>
function loadEntryChartFile(id:String, ?variation:String):Null<BaseRegistry.JsonFile>
{
var entryFilePath:String = Paths.json('$dataFilePath/$id/$id${variation == '' ? '' : '-$variation'}-chart');
variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
var entryFilePath:String = Paths.json('$dataFilePath/$id/$id-chart${variation == Constants.DEFAULT_VARIATION ? '' : '-$variation'}');
if (!openfl.Assets.exists(entryFilePath)) return null;
var rawJson:String = openfl.Assets.getText(entryFilePath);
if (rawJson == null) return null;
@ -233,20 +395,36 @@ class SongRegistry extends BaseRegistry<Song, SongMetadata>
return {fileName: entryFilePath, contents: rawJson};
}
public function fetchEntryMetadataVersion(id:String, variation:String = ''):Null<thx.semver.Version>
public function fetchEntryMetadataVersion(id:String, ?variation:String):Null<thx.semver.Version>
{
variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
var entryStr:Null<String> = loadEntryMetadataFile(id, variation)?.contents;
var entryVersion:thx.semver.Version = VersionUtil.getVersionFromJSON(entryStr);
return entryVersion;
}
public function fetchEntryChartVersion(id:String, variation:String = ''):Null<thx.semver.Version>
public function fetchEntryChartVersion(id:String, ?variation:String):Null<thx.semver.Version>
{
variation = variation == null ? Constants.DEFAULT_VARIATION : variation;
var entryStr:Null<String> = loadEntryChartFile(id, variation)?.contents;
var entryVersion:thx.semver.Version = VersionUtil.getVersionFromJSON(entryStr);
return entryVersion;
}
function cleanMetadata(metadata:SongMetadata, variation:String):SongMetadata
{
metadata.variation = variation;
return metadata;
}
function cleanChartData(chartData:SongChartData, variation:String):SongChartData
{
chartData.variation = variation;
return chartData;
}
/**
* A list of all the story weeks from the base game, in order.
* TODO: Should this be hardcoded?

View File

@ -0,0 +1,124 @@
package funkin.data.song.importer;
import haxe.ds.Either;
/**
* A data structure representing a song in the old chart format.
* This only works for charts compatible with Week 7, so you'll need a custom program
* to handle importing charts from mods or other engines.
*/
class FNFLegacyData
{
public var song:LegacySongData;
}
class LegacySongData
{
public var player1:String; // Boyfriend
public var player2:String; // Opponent
@:jcustomparse(funkin.data.DataParse.eitherLegacyScrollSpeeds)
public var speed:Either<Float, LegacyScrollSpeeds>;
public var stageDefault:String;
public var bpm:Float;
@:jcustomparse(funkin.data.DataParse.eitherLegacyNoteData)
public var notes:Either<Array<LegacyNoteSection>, LegacyNoteData>;
public var song:String; // Song name
public function new() {}
public function toString():String
{
var notesStr:String = switch (notes)
{
case Left(sections): 'single difficulty w/ ${sections.length} sections';
case Right(data):
var difficultyCount:Int = 0;
if (data.easy != null) difficultyCount++;
if (data.normal != null) difficultyCount++;
if (data.hard != null) difficultyCount++;
'${difficultyCount} difficulties';
};
return 'LegacySongData($player1, $player2, $notesStr)';
}
}
typedef LegacyScrollSpeeds =
{
public var ?easy:Float;
public var ?normal:Float;
public var ?hard:Float;
};
typedef LegacyNoteData =
{
/**
* The easy difficulty.
*/
public var ?easy:Array<LegacyNoteSection>;
/**
* The normal difficulty.
*/
public var ?normal:Array<LegacyNoteSection>;
/**
* The hard difficulty.
*/
public var ?hard:Array<LegacyNoteSection>;
};
typedef LegacyNoteSection =
{
/**
* Whether the section is a must-hit section.
* If true, 0-3 are boyfriends notes, 4-7 are opponents notes.
* If false, 0-3 are opponents notes, 4-7 are boyfriends notes.
*/
public var mustHitSection:Bool;
/**
* Array of note data:
* - Direction
* - Time (ms)
* - Sustain Duration (ms)
* - Note kind (true = "alt", or string)
*/
public var sectionNotes:Array<LegacyNote>;
public var ?typeOfSection:Int;
public var ?lengthInSteps:Int;
// BPM changes
public var ?changeBPM:Bool;
public var ?bpm:Float;
}
/**
* Notes in the old format are stored as an Array<Dynamic>
* We use a custom parser to manage this.
*/
@:jcustomparse(funkin.data.DataParse.legacyNote)
class LegacyNote
{
public var time:Float;
public var data:Int;
public var length:Float;
public var alt:Bool;
public function new(time:Float, data:Int, ?length:Float, ?alt:Bool)
{
this.time = time;
this.data = data;
this.length = length ?? 0.0;
this.alt = alt ?? false;
}
public inline function getKind():String
{
return this.alt ? 'alt' : 'normal';
}
}

View File

@ -0,0 +1,202 @@
package funkin.data.song.importer; // import is a reserved word dumbass
import funkin.data.song.SongData.SongMetadata;
import funkin.data.song.SongData.SongChartData;
import funkin.data.song.SongData.SongCharacterData;
import funkin.data.song.SongData.SongEventData;
import funkin.data.song.SongData.SongNoteData;
import funkin.data.song.SongData.SongTimeChange;
import funkin.data.song.importer.FNFLegacyData;
import funkin.data.song.importer.FNFLegacyData.LegacyNoteSection;
class FNFLegacyImporter
{
public static function parseLegacyDataRaw(input:String, fileName:String = 'raw'):FNFLegacyData
{
var parser = new json2object.JsonParser<FNFLegacyData>();
parser.fromJson(input, fileName);
if (parser.errors.length > 0)
{
trace('[FNFLegacyImporter] Error parsing JSON data from ' + fileName + ':');
for (error in parser.errors)
DataError.printError(error);
return null;
}
return parser.value;
}
/**
* @param data The raw parsed JSON data to migrate, as a Dynamic.
* @param difficulty
* @return SongMetadata
*/
public static function migrateMetadata(songData:FNFLegacyData, difficulty:String = 'normal'):SongMetadata
{
trace('Migrating song metadata from FNF Legacy.');
var songMetadata:SongMetadata = new SongMetadata('Import', 'Kawai Sprite', 'default');
var hadError:Bool = false;
// Set generatedBy string for debugging.
songMetadata.generatedBy = 'Chart Editor Import (FNF Legacy)';
songMetadata.playData.stage = songData?.song?.stageDefault ?? 'mainStage';
songMetadata.songName = songData?.song?.song ?? 'Import';
songMetadata.playData.difficulties = [];
if (songData?.song?.notes != null)
{
switch (songData.song.notes)
{
case Left(notes):
// One difficulty of notes.
songMetadata.playData.difficulties.push(difficulty);
case Right(difficulties):
if (difficulties.easy != null) songMetadata.playData.difficulties.push('easy');
if (difficulties.normal != null) songMetadata.playData.difficulties.push('normal');
if (difficulties.hard != null) songMetadata.playData.difficulties.push('hard');
}
}
songMetadata.playData.songVariations = [];
songMetadata.timeChanges = rebuildTimeChanges(songData);
songMetadata.playData.characters = new SongCharacterData(songData?.song?.player1 ?? 'bf', 'gf', songData?.song?.player2 ?? 'dad', 'mom');
return songMetadata;
}
public static function migrateChartData(songData:FNFLegacyData, difficulty:String = 'normal'):SongChartData
{
trace('Migrating song chart data from FNF Legacy.');
var songChartData:SongChartData = new SongChartData([difficulty => 1.0], [], [difficulty => []]);
if (songData?.song?.notes != null)
{
switch (songData.song.notes)
{
case Left(notes):
// One difficulty of notes.
songChartData.notes.set(difficulty, migrateNoteSections(notes));
case Right(difficulties):
var baseDifficulty = null;
if (difficulties.easy != null) songChartData.notes.set('easy', migrateNoteSections(difficulties.easy));
if (difficulties.normal != null) songChartData.notes.set('normal', migrateNoteSections(difficulties.normal));
if (difficulties.hard != null) songChartData.notes.set('hard', migrateNoteSections(difficulties.hard));
}
}
// Import event data.
songChartData.events = rebuildEventData(songData);
switch (songData.song.speed)
{
case Left(speed):
// All difficulties will use the one scroll speed.
songChartData.scrollSpeed.set('default', speed);
case Right(speeds):
if (speeds.easy != null) songChartData.scrollSpeed.set('easy', speeds.easy);
if (speeds.normal != null) songChartData.scrollSpeed.set('normal', speeds.normal);
if (speeds.hard != null) songChartData.scrollSpeed.set('hard', speeds.hard);
}
return songChartData;
}
/**
* FNF Legacy doesn't have song events, but without them the song won't look right,
* so we insert camera events when the character changes.
*/
static function rebuildEventData(songData:FNFLegacyData):Array<SongEventData>
{
var result:Array<SongEventData> = [];
var noteSections = [];
switch (songData.song.notes)
{
case Left(notes):
// All difficulties will use the one scroll speed.
noteSections = notes;
case Right(difficulties):
if (difficulties.normal != null) noteSections = difficulties.normal;
if (difficulties.hard != null) noteSections = difficulties.normal;
if (difficulties.easy != null) noteSections = difficulties.normal;
}
if (noteSections == null || noteSections.length == 0) return result;
// Add camera events.
var lastSectionWasMustHit:Null<Bool> = null;
for (section in noteSections)
{
// Skip empty sections.
if (section.sectionNotes.length == 0) continue;
if (section.mustHitSection != lastSectionWasMustHit)
{
lastSectionWasMustHit = section.mustHitSection;
var firstNote:LegacyNote = section.sectionNotes[0];
result.push(new SongEventData(firstNote.time, 'FocusCamera', {char: section.mustHitSection ? 0 : 1}));
}
}
return result;
}
/**
* Port over time changes from FNF Legacy.
* If a section contains a BPM change, it will be applied at the timestamp of the first note in that section.
*/
static function rebuildTimeChanges(songData:FNFLegacyData):Array<SongTimeChange>
{
var result:Array<SongTimeChange> = [];
result.push(new SongTimeChange(0, songData?.song?.bpm ?? Constants.DEFAULT_BPM));
var noteSections = [];
switch (songData.song.notes)
{
case Left(notes):
// All difficulties will use the one scroll speed.
noteSections = notes;
case Right(difficulties):
if (difficulties.normal != null) noteSections = difficulties.normal;
if (difficulties.hard != null) noteSections = difficulties.normal;
if (difficulties.easy != null) noteSections = difficulties.normal;
}
if (noteSections == null || noteSections.length == 0) return result;
for (noteSection in noteSections)
{
if (noteSection.changeBPM ?? false)
{
var firstNote:LegacyNote = noteSection.sectionNotes[0];
if (firstNote != null) result.push(new SongTimeChange(firstNote.time, noteSection.bpm));
}
}
return result;
}
static function migrateNoteSections(input:Array<LegacyNoteSection>):Array<SongNoteData>
{
var result:Array<SongNoteData> = [];
for (section in input)
{
for (note in section.sectionNotes)
{
result.push(new SongNoteData(note.time, note.data, note.length, note.getKind()));
}
}
return result;
}
}

View File

@ -0,0 +1,66 @@
package funkin.data.song.migrator;
import funkin.data.song.SongData.SongMetadata;
import funkin.data.song.SongData.SongPlayData;
import funkin.data.song.SongData.SongCharacterData;
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;
/**
* This class contains functions to migrate older data formats to the current one.
*
* Utilizes static extensions with overloaded inline functions to make migration as easy as `.migrate()`.
* @see https://try.haxe.org/#e1c1cf22
*/
class SongDataMigrator
{
public static overload extern inline function migrate(input:SongData_v2_0_0.SongMetadata_v2_0_0):SongMetadata
{
return migrate_SongMetadata_v2_0_0(input);
}
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.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.generatedBy = input.generatedBy;
return result;
}
public static overload extern inline function migrate(input:SongData_v2_0_0.SongPlayData_v2_0_0):SongPlayData
{
return migrate_SongPlayData_v2_0_0(input);
}
public static function migrate_SongPlayData_v2_0_0(input:SongData_v2_0_0.SongPlayData_v2_0_0):SongPlayData
{
var result:SongPlayData = new SongPlayData();
result.songVariations = input.songVariations;
result.difficulties = input.difficulties;
result.stage = input.stage;
result.noteSkin = input.noteSkin;
// Fetch the first playable character and migrate it.
var firstCharKey:Null<String> = input.playableChars.size() == 0 ? null : input.playableChars.keys().array()[0];
var firstCharData:Null<SongPlayableChar_v2_0_0> = input.playableChars.get(firstCharKey);
if (firstCharData == null)
{
// Fill in a default playable character.
result.characters = new SongCharacterData('bf', 'gf', 'dad');
}
else
{
result.characters = new SongCharacterData(firstCharKey, firstCharData.girlfriend, firstCharData.opponent, firstCharData.inst);
}
return result;
}
}

View File

@ -0,0 +1,122 @@
package funkin.data.song.migrator;
import thx.semver.Version;
import funkin.data.song.SongData;
class SongMetadata_v2_0_0
{
// ==========
// MODIFIED VALUES
// ===========
/**
* In metadata `v2.1.0`, `SongPlayData` was refactored.
*/
public var playData:SongPlayData_v2_0_0;
/**
* In metadata `v2.1.0`, `variation` was set to `ignore` when writing.
*/
@:optional
@:default('default')
public var variation:String;
// ==========
// UNMODIFIED VALUES
// ==========
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;
public var generatedBy:String;
public var timeFormat:SongData.SongTimeFormat;
public var timeChanges:Array<SongData.SongTimeChange>;
public function new() {}
/**
* Produces a string representation suitable for debugging.
*/
public function toString():String
{
return 'SongMetadata[LEGACY:v2.0.0](${this.songName} by ${this.artist}, variation ${this.variation})';
}
}
class SongPlayData_v2_0_0
{
// ==========
// MODIFIED VALUES
// ===========
/**
* In metadata version `v2.1.0`, this was refactored to a single `SongCharacterData` object.
*/
public var playableChars:Map<String, SongPlayableChar_v2_0_0>;
// ==========
// UNMODIFIED VALUES
// ==========
public var songVariations:Array<String>;
public var difficulties:Array<String>;
public var stage:String;
public var noteSkin:String;
public function new() {}
/**
* Produces a string representation suitable for debugging.
*/
public function toString():String
{
return 'SongPlayData[LEGACY:v2.0.0](${this.songVariations}, ${this.difficulties})';
}
}
class SongPlayableChar_v2_0_0
{
@:alias('g')
@:optional
@:default('')
public var girlfriend:String = '';
@:alias('o')
@:optional
@:default('')
public var opponent:String = '';
@:alias('i')
@:optional
@:default('')
public var inst:String = '';
public function new(girlfriend:String = '', opponent:String = '', inst:String = '')
{
this.girlfriend = girlfriend;
this.opponent = opponent;
this.inst = inst;
}
/**
* Produces a string representation suitable for debugging.
*/
public function toString():String
{
return 'SongPlayableChar[LEGACY:v2.0.0](${this.girlfriend}, ${this.opponent}, ${this.inst})';
}
}

View File

@ -46,7 +46,7 @@ import funkin.play.song.Song;
import funkin.data.song.SongRegistry;
import funkin.data.song.SongData.SongEventData;
import funkin.data.song.SongData.SongNoteData;
import funkin.data.song.SongData.SongPlayableChar;
import funkin.data.song.SongData.SongCharacterData;
import funkin.play.stage.Stage;
import funkin.play.stage.StageData.StageDataParser;
import funkin.ui.PopUpStuff;
@ -574,8 +574,8 @@ class PlayState extends MusicBeatSubState
// Prepare the current song's instrumental and vocals to be played.
if (!overrideMusic && currentChart != null)
{
currentChart.cacheInst(currentPlayerId);
currentChart.cacheVocals(currentPlayerId);
currentChart.cacheInst();
currentChart.cacheVocals();
}
// Prepare the Conductor.
@ -733,7 +733,7 @@ class PlayState extends MusicBeatSubState
// DO NOT FORGET TO REMOVE THE HARDCODE! WHEN I MAKE BETTER OFFSET SYSTEM!
// :nerd: um ackshually it's not 13 it's 11.97278911564
if (Paths.SOUND_EXT == 'mp3') Conductor.offset = Constants.MP3_DELAY_MS;
if (Constants.EXT_SOUND == 'mp3') Conductor.offset = Constants.MP3_DELAY_MS;
Conductor.update();
@ -1344,34 +1344,20 @@ class PlayState extends MusicBeatSubState
trace('Song difficulty could not be loaded.');
}
// Switch the character we are playing as by manipulating currentPlayerId.
// TODO: How to choose which one to use for story mode?
var playableChars:Array<String> = currentChart.getPlayableChars();
if (playableChars.length == 0)
{
trace('WARNING: No playable characters found for this song.');
}
else if (playableChars.indexOf(currentPlayerId) == -1)
{
currentPlayerId = playableChars[0];
}
//
var currentCharData:SongPlayableChar = currentChart.getPlayableChar(currentPlayerId);
var currentCharacterData:SongCharacterData = currentChart.characters; // Switch the character we are playing as by manipulating currentPlayerId.
//
// GIRLFRIEND
//
var girlfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharData.girlfriend);
var girlfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharacterData.girlfriend);
if (girlfriend != null)
{
girlfriend.characterType = CharacterType.GF;
}
else if (currentCharData.girlfriend != '')
else if (currentCharacterData.girlfriend != '')
{
trace('WARNING: Could not load girlfriend character with ID ${currentCharData.girlfriend}, skipping...');
trace('WARNING: Could not load girlfriend character with ID ${currentCharacterData.girlfriend}, skipping...');
}
else
{
@ -1381,7 +1367,7 @@ class PlayState extends MusicBeatSubState
//
// DAD
//
var dad:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharData.opponent);
var dad:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharacterData.opponent);
if (dad != null)
{
@ -1400,7 +1386,7 @@ class PlayState extends MusicBeatSubState
//
// BOYFRIEND
//
var boyfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentPlayerId);
var boyfriend:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharacterData.player);
if (boyfriend != null)
{
@ -1549,7 +1535,7 @@ class PlayState extends MusicBeatSubState
if (!overrideMusic)
{
vocals = currentChart.buildVocals(currentPlayerId);
vocals = currentChart.buildVocals();
if (vocals.members.length == 0)
{
@ -1893,6 +1879,7 @@ class PlayState extends MusicBeatSubState
{
// Grant the player health.
health += Constants.HEALTH_HOLD_BONUS_PER_SECOND * elapsed;
songScore += Std.int(Constants.SCORE_HOLD_BONUS_PER_SECOND * elapsed);
}
// TODO: Potential penalty for dropping a hold note?
@ -2013,103 +2000,6 @@ class PlayState extends MusicBeatSubState
}
}
/**
* Handle player inputs.
*/
function keyShit(test:Bool):Void
{
// control arrays, order L D R U
var holdArray:Array<Bool> = [controls.NOTE_LEFT, controls.NOTE_DOWN, controls.NOTE_UP, controls.NOTE_RIGHT];
var pressArray:Array<Bool> = [
controls.NOTE_LEFT_P,
controls.NOTE_DOWN_P,
controls.NOTE_UP_P,
controls.NOTE_RIGHT_P
];
var releaseArray:Array<Bool> = [
controls.NOTE_LEFT_R,
controls.NOTE_DOWN_R,
controls.NOTE_UP_R,
controls.NOTE_RIGHT_R
];
// if (pressArray.contains(true))
// {
// var lol:Array<Int> = cast pressArray;
// inputSpitter.push(Std.int(Conductor.songPosition) + ' ' + lol.join(' '));
// }
// HOLDS, check for sustain notes
if (holdArray.contains(true) && generatedMusic)
{
/*
activeNotes.forEachAlive(function(daNote:Note) {
if (daNote.isSustainNote && daNote.canBeHit && daNote.mustPress && holdArray[daNote.data.noteData]) goodNoteHit(daNote);
});
*/
}
// PRESSES, check for note hits
if (pressArray.contains(true) && generatedMusic)
{
Haptic.vibrate(100, 100);
if (currentStage != null && currentStage.getBoyfriend() != null)
{
currentStage.getBoyfriend().holdTimer = 0;
}
var possibleNotes:Array<NoteSprite> = []; // notes that can be hit
var directionList:Array<Int> = []; // directions that can be hit
var dumbNotes:Array<NoteSprite> = []; // notes to kill later
for (note in dumbNotes)
{
FlxG.log.add('killing dumb ass note at ' + note.noteData.time);
note.kill();
// activeNotes.remove(note, true);
note.destroy();
}
possibleNotes.sort((a, b) -> Std.int(a.noteData.time - b.noteData.time));
if (perfectMode)
{
goodNoteHit(possibleNotes[0], null);
}
else if (possibleNotes.length > 0)
{
for (shit in 0...pressArray.length)
{ // if a direction is hit that shouldn't be
if (pressArray[shit] && !directionList.contains(shit)) ghostNoteMiss(shit);
}
for (coolNote in possibleNotes)
{
if (pressArray[coolNote.noteData.getDirection()]) goodNoteHit(coolNote, null);
}
}
else
{
// HNGGG I really want to add an option for ghost tapping
// L + ratio
for (shit in 0...pressArray.length)
if (pressArray[shit]) ghostNoteMiss(shit, false);
}
}
if (currentStage == null) return;
for (keyId => isPressed in pressArray)
{
if (playerStrumline == null) continue;
var dir:NoteDirection = Strumline.DIRECTIONS[keyId];
if (isPressed && !playerStrumline.isConfirm(dir)) playerStrumline.playPress(dir);
if (!holdArray[keyId]) playerStrumline.playStatic(dir);
}
}
function goodNoteHit(note:NoteSprite, input:PreciseInputEvent):Void
{
var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, note, Highscore.tallies.combo + 1, true);
@ -2118,19 +2008,16 @@ class PlayState extends MusicBeatSubState
// Calling event.cancelEvent() skips all the other logic! Neat!
if (event.eventCanceled) return;
if (!note.isHoldNote)
{
Highscore.tallies.combo++;
Highscore.tallies.totalNotesHit++;
Highscore.tallies.combo++;
Highscore.tallies.totalNotesHit++;
if (Highscore.tallies.combo > Highscore.tallies.maxCombo) Highscore.tallies.maxCombo = Highscore.tallies.combo;
if (Highscore.tallies.combo > Highscore.tallies.maxCombo) Highscore.tallies.maxCombo = Highscore.tallies.combo;
popUpScore(note, input);
}
popUpScore(note, input);
playerStrumline.hitNote(note);
if (note.holdNoteSprite != null)
if (note.isHoldNote && note.holdNoteSprite != null)
{
playerStrumline.playNoteHoldCover(note.holdNoteSprite);
}

View File

@ -172,9 +172,13 @@ enum abstract BackdropType(String) from String to String
class MusicData
{
public var asset:String;
public var looped:Bool;
public var fadeTime:Float;
@:optional
@:default(false)
public var looped:Bool;
public function new(asset:String, looped:Bool, fadeTime:Float = 0.0)
{
this.asset = asset;

View File

@ -6,6 +6,7 @@ import funkin.play.cutscene.dialogue.ScriptedConversation;
/**
* Contains utilities for loading and parsing conversation data.
* TODO: Refactor to use the json2object + BaseRegistry system that actually validates things for you.
*/
class ConversationDataParser
{

View File

@ -11,7 +11,7 @@ import funkin.data.song.SongData.SongEventData;
import funkin.data.song.SongData.SongNoteData;
import funkin.data.song.SongRegistry;
import funkin.data.song.SongData.SongMetadata;
import funkin.data.song.SongData.SongPlayableChar;
import funkin.data.song.SongData.SongCharacterData;
import funkin.data.song.SongData.SongTimeChange;
import funkin.data.song.SongData.SongTimeFormat;
import funkin.data.IRegistryEntry;
@ -92,6 +92,15 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
_metadata = _data == null ? [] : [_data];
variations.clear();
variations.push(Constants.DEFAULT_VARIATION);
if (_data != null && _data.playData != null)
{
for (vari in _data.playData.songVariations)
variations.push(vari);
}
for (meta in fetchVariationMetadata(id))
_metadata.push(meta);
@ -101,15 +110,7 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
return;
}
variations.clear();
variations.push('default');
if (_data != null && _data.playData != null)
{
for (vari in _data.playData.songVariations)
variations.push(vari);
populateFromMetadata();
}
populateDifficulties();
}
@:allow(funkin.play.song.Song)
@ -128,7 +129,7 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
result.difficultyIds.clear();
result.populateFromMetadata();
result.populateDifficulties();
for (variation => chartData in charts)
result.applyChartData(chartData, variation);
@ -144,10 +145,10 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
}
/**
* Populate the song data from the provided metadata,
* including data from individual difficulties. Does not load chart data.
* Populate the difficulty data from the provided metadata.
* Does not load chart data (that is triggered later when we want to play the song).
*/
function populateFromMetadata():Void
function populateDifficulties():Void
{
if (_metadata == null || _metadata.length == 0) return;
@ -176,18 +177,11 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
difficulty.generatedBy = metadata.generatedBy;
difficulty.stage = metadata.playData.stage;
// difficulty.noteSkin = metadata.playData.noteSkin;
difficulty.noteStyle = metadata.playData.noteSkin;
difficulties.set(diffId, difficulty);
difficulty.chars = new Map<String, SongPlayableChar>();
if (metadata.playData.playableChars == null) continue;
for (charId in metadata.playData.playableChars.keys())
{
var char:Null<SongPlayableChar> = metadata.playData.playableChars.get(charId);
if (char == null) continue;
difficulty.chars.set(charId, char);
}
difficulty.characters = metadata.playData.characters;
}
}
}
@ -321,7 +315,7 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry<SongMeta
trace('Fetching song metadata for $id');
var version:Null<thx.semver.Version> = SongRegistry.instance.fetchEntryMetadataVersion(id);
if (version == null) return null;
return SongRegistry.instance.parseEntryMetadataWithMigration(id, '', version);
return SongRegistry.instance.parseEntryMetadataWithMigration(id, Constants.DEFAULT_VARIATION, version);
}
function fetchVariationMetadata(id:String):Array<SongMetadata>
@ -365,19 +359,20 @@ class SongDifficulty
*/
public var events:Array<SongEventData>;
public var songName:String = SongValidator.DEFAULT_SONGNAME;
public var songArtist:String = SongValidator.DEFAULT_ARTIST;
public var timeFormat:SongTimeFormat = SongValidator.DEFAULT_TIMEFORMAT;
public var divisions:Null<Int> = SongValidator.DEFAULT_DIVISIONS;
public var looped:Bool = SongValidator.DEFAULT_LOOPED;
public var songName:String = Constants.DEFAULT_SONGNAME;
public var songArtist:String = Constants.DEFAULT_ARTIST;
public var timeFormat:SongTimeFormat = Constants.DEFAULT_TIMEFORMAT;
public var divisions:Null<Int> = null;
public var looped:Bool = false;
public var generatedBy:String = SongRegistry.DEFAULT_GENERATEDBY;
public var timeChanges:Array<SongTimeChange> = [];
public var stage:String = SongValidator.DEFAULT_STAGE;
public var chars:Map<String, SongPlayableChar> = null;
public var stage:String = Constants.DEFAULT_STAGE;
public var noteStyle:String = Constants.DEFAULT_NOTE_STYLE;
public var characters:SongCharacterData = null;
public var scrollSpeed:Float = SongValidator.DEFAULT_SCROLLSPEED;
public var scrollSpeed:Float = Constants.DEFAULT_SCROLLSPEED;
public function new(song:Song, diffId:String, variation:String)
{
@ -401,28 +396,24 @@ class SongDifficulty
return timeChanges[0].bpm;
}
public function getPlayableChar(id:String):Null<SongPlayableChar>
{
if (id == null || id == '') return null;
return chars.get(id);
}
public function getPlayableChars():Array<String>
{
return chars.keys().array();
}
public function getEvents():Array<SongEventData>
{
return cast events;
}
public inline function cacheInst(?currentPlayerId:String = null):Void
public function cacheInst(instrumental = ''):Void
{
var currentPlayer:Null<SongPlayableChar> = getPlayableChar(currentPlayerId);
if (currentPlayer != null)
if (characters != null)
{
FlxG.sound.cache(Paths.inst(this.song.id, currentPlayer.inst));
if (instrumental != '' && characters.altInstrumentals.contains(instrumental))
{
FlxG.sound.cache(Paths.inst(this.song.id, instrumental));
}
else
{
// Fallback to default instrumental.
FlxG.sound.cache(Paths.inst(this.song.id, characters.instrumental));
}
}
else
{
@ -440,9 +431,9 @@ class SongDifficulty
* Cache the vocals for a given character.
* @param id The character we are about to play.
*/
public inline function cacheVocals(?id:String = 'bf'):Void
public inline function cacheVocals():Void
{
for (voice in buildVoiceList(id))
for (voice in buildVoiceList())
{
FlxG.sound.cache(voice);
}
@ -454,22 +445,15 @@ class SongDifficulty
*
* @param id The character we are about to play.
*/
public function buildVoiceList(?id:String = 'bf'):Array<String>
public function buildVoiceList():Array<String>
{
var playableCharData:SongPlayableChar = getPlayableChar(id);
if (playableCharData == null)
{
trace('Could not find playable char $id for song ${this.song.id}');
return [];
}
var suffix:String = (variation != null && variation != '' && variation != 'default') ? '-$variation' : '';
// Automatically resolve voices by removing suffixes.
// For example, if `Voices-bf-car.ogg` does not exist, check for `Voices-bf.ogg`.
var playerId:String = id;
var voicePlayer:String = Paths.voices(this.song.id, '-$id$suffix');
var playerId:String = characters.player;
var voicePlayer:String = Paths.voices(this.song.id, '-$playerId$suffix');
while (voicePlayer != null && !Assets.exists(voicePlayer))
{
// Remove the last suffix.
@ -479,7 +463,7 @@ class SongDifficulty
voicePlayer = playerId == '' ? null : Paths.voices(this.song.id, '-${playerId}$suffix');
}
var opponentId:String = playableCharData.opponent;
var opponentId:String = characters.opponent;
var voiceOpponent:String = Paths.voices(this.song.id, '-${opponentId}$suffix');
while (voiceOpponent != null && !Assets.exists(voiceOpponent))
{
@ -505,11 +489,11 @@ class SongDifficulty
* @param charId The player ID.
* @return The generated vocal group.
*/
public function buildVocals(charId:String = 'bf'):VoicesGroup
public function buildVocals():VoicesGroup
{
var result:VoicesGroup = new VoicesGroup();
var voiceList:Array<String> = buildVoiceList(charId);
var voiceList:Array<String> = buildVoiceList();
if (voiceList.length == 0)
{

View File

@ -1,256 +0,0 @@
package funkin.play.song;
import funkin.play.song.formats.FNFLegacy;
import funkin.data.song.SongData.SongChartData;
import funkin.data.song.SongData.SongEventData;
import funkin.data.song.SongData.SongMetadata;
import funkin.data.song.SongData.SongNoteData;
import funkin.data.song.SongData.SongPlayableChar;
import funkin.util.VersionUtil;
class SongMigrator
{
/**
* The current latest version string for the song data format.
* Handle breaking changes by incrementing this value
* and adding migration to the SongMigrator class.
*/
public static final CHART_VERSION:String = '2.0.0';
/**
* Version rule for which chart versions are compatible with the current version.
*/
public static final CHART_VERSION_RULE:String = '2.0.x';
/**
* Migrate song data from an older chart version to the current version.
* @param jsonData The song metadata to migrate.
* @param songId The ID of the song (only used for error reporting).
* @return The migrated song metadata, or null if the migration failed.
*/
public static function migrateSongMetadata(jsonData:Dynamic, songId:String):SongMetadata
{
if (jsonData.version != null)
{
if (VersionUtil.validateVersionStr(jsonData.version, CHART_VERSION_RULE))
{
trace('Song (${songId}) metadata version (${jsonData.version}) is valid and up-to-date.');
var songMetadata:SongMetadata = cast jsonData;
return songMetadata;
}
else
{
trace('Song (${songId}) metadata version (${jsonData.version}) is outdated.');
switch (jsonData.version)
{
case '1.0.0':
return migrateSongMetadataFromLegacy(jsonData);
default:
trace('Song (${songId}) has unknown metadata version (${jsonData.version}), assuming FNF Legacy.');
return migrateSongMetadataFromLegacy(jsonData);
}
}
}
else
{
trace('Song metadata version is missing.');
}
return null;
}
/**
* Migrate song chart data from an older chart version to the current version.
* @param jsonData The song chart data to migrate.
* @param songId The ID of the song (only used for error reporting).
* @return The migrated song chart data, or null if the migration failed.
*/
public static function migrateSongChartData(jsonData:Dynamic, songId:String):SongChartData
{
if (jsonData.version)
{
if (VersionUtil.validateVersionStr(jsonData.version, CHART_VERSION_RULE))
{
trace('Song (${songId}) chart version (${jsonData.version}) is valid and up-to-date.');
var songChartData:SongChartData = cast jsonData;
return songChartData;
}
else
{
trace('Song (${songId}) chart version (${jsonData.version}) is outdated.');
switch (jsonData.version)
{
// TODO: Add migration functions as cases here.
default:
// Unknown version.
trace('Song (${songId}) unknown chart version: ${jsonData.version}');
}
}
}
else
{
trace('Song chart version is missing.');
}
return null;
}
/**
* Migrate song metadata from FNF Legacy chart version to the current version.
* @param jsonData The song metadata to migrate.
* @param songId The ID of the song (only used for error reporting).
* @return The migrated song metadata, or null if the migration failed.
*/
public static function migrateSongMetadataFromLegacy(jsonData:Dynamic, difficulty:String = 'normal'):SongMetadata
{
trace('Migrating song metadata from FNF Legacy.');
var songData:FNFLegacy = cast jsonData;
var songMetadata:SongMetadata = new SongMetadata('Import', 'Kawai Sprite', 'default');
var hadError:Bool = false;
// Set generatedBy string for debugging.
songMetadata.generatedBy = 'Chart Editor Import (FNF Legacy)';
try
{
// Set the song's BPM.
songMetadata.timeChanges[0].bpm = songData.song.bpm;
}
catch (e)
{
trace("Couldn't parse BPM!");
hadError = true;
}
try
{
// Set the song's stage.
songMetadata.playData.stage = songData.song.stageDefault;
}
catch (e)
{
trace("Couldn't parse stage!");
hadError = true;
}
try
{
// Set's the song's name.
songMetadata.songName = songData.song.song;
}
catch (e)
{
trace("Couldn't parse song name!");
hadError = true;
}
songMetadata.playData.difficulties = [];
if (songData.song != null && songData.song.notes != null)
{
if (Std.isOfType(songData.song.notes, Array))
{
// One difficulty of notes.
songMetadata.playData.difficulties.push(difficulty);
}
else
{
// Multiple difficulties of notes.
var songNoteDataDynamic:haxe.DynamicAccess<Dynamic> = cast songData.song.notes;
for (difficultyKey in songNoteDataDynamic.keys())
{
songMetadata.playData.difficulties.push(difficultyKey);
}
}
}
else
{
trace("Couldn't parse difficulties!");
hadError = true;
}
songMetadata.playData.songVariations = [];
// Set the song's song variations.
songMetadata.playData.playableChars = [];
try
{
songMetadata.playData.playableChars.set(songData.song.player1, new SongPlayableChar('', songData.song.player2));
}
catch (e)
{
trace("Couldn't parse characters!");
hadError = true;
}
return songMetadata;
}
/**
* Migrate song chart data from FNF Legacy chart version to the current version.
* @param jsonData The song data to migrate.
* @param songId The ID of the song (only used for error reporting).
* @param difficulty The difficulty to migrate.
* @return The migrated song chart data, or null if the migration failed.
*/
public static function migrateSongChartDataFromLegacy(jsonData:Dynamic, difficulty:String = 'normal'):SongChartData
{
trace('Migrating song chart data from FNF Legacy.');
var songData:FNFLegacy = cast jsonData;
var songChartData:SongChartData = new SongChartData(["normal" => 1.0], [], ["normal" => []]);
var songEventsEmpty:Bool = songChartData.getEvents() == null || songChartData.getEvents().length == 0;
if (songEventsEmpty) songChartData.setEvents(migrateSongEventDataFromLegacy(songData.song.notes));
songChartData.setNotes(migrateSongNoteDataFromLegacy(songData.song.notes), difficulty);
songChartData.setScrollSpeed(songData.song.speed, difficulty);
return songChartData;
}
static function migrateSongNoteDataFromLegacy(sections:Array<LegacyNoteSection>):Array<SongNoteData>
{
var songNotes:Array<SongNoteData> = [];
for (section in sections)
{
// Skip empty sections.
if (section.sectionNotes.length == 0) continue;
for (note in section.sectionNotes)
{
songNotes.push(new SongNoteData(note.time, note.getData(section.mustHitSection), note.length, note.kind));
}
}
return songNotes;
}
static function migrateSongEventDataFromLegacy(sections:Array<LegacyNoteSection>):Array<SongEventData>
{
var songEvents:Array<SongEventData> = [];
var lastSectionWasMustHit:Null<Bool> = null;
for (section in sections)
{
// Skip empty sections.
if (section.sectionNotes.length == 0) continue;
if (section.mustHitSection != lastSectionWasMustHit)
{
lastSectionWasMustHit = section.mustHitSection;
var firstNote:LegacyNote = section.sectionNotes[0];
songEvents.push(new SongEventData(firstNote.time, 'FocusCamera', {char: section.mustHitSection ? 0 : 1}));
}
}
return songEvents;
}
}

View File

@ -3,14 +3,14 @@ package funkin.play.song;
import funkin.data.song.SongData.SongChartData;
import funkin.data.song.SongData.SongMetadata;
import funkin.util.SerializerUtil;
import funkin.util.FileUtil;
import lime.utils.Bytes;
import openfl.events.Event;
import openfl.events.IOErrorEvent;
import openfl.net.FileReference;
/**
* Utilities for exporting a chart to a JSON file.
* Primarily used for the chart editor.
* TODO: Refactor and remove this.
*/
class SongSerializer
{
@ -20,7 +20,7 @@ class SongSerializer
*/
public static function importSongChartDataSync(path:String):SongChartData
{
var fileData = readFile(path);
var fileData = FileUtil.readStringFromPath(path);
if (fileData == null) return null;
@ -35,7 +35,7 @@ class SongSerializer
*/
public static function importSongMetadataSync(path:String):SongMetadata
{
var fileData = readFile(path);
var fileData = FileUtil.readStringFromPath(path);
if (fileData == null) return null;
@ -50,7 +50,7 @@ class SongSerializer
*/
public static function importSongChartDataAsync(callback:SongChartData->Void):Void
{
browseFileReference(function(fileReference:FileReference) {
FileUtil.browseFileReference(function(fileReference:FileReference) {
var data = fileReference.data.toString();
if (data == null) return;
@ -67,7 +67,7 @@ class SongSerializer
*/
public static function importSongMetadataAsync(callback:SongMetadata->Void):Void
{
browseFileReference(function(fileReference:FileReference) {
FileUtil.browseFileReference(function(fileReference:FileReference) {
var data = fileReference.data.toString();
if (data == null) return;
@ -77,126 +77,4 @@ class SongSerializer
if (songMetadata != null) callback(songMetadata);
});
}
/**
* Save a SongChartData object as a JSON file to an automatically generated path.
* Works great on HTML5 and desktop.
*/
public static function exportSongChartData(data:SongChartData, songId:String)
{
var path = '${songId}-chart.json';
exportSongChartDataAs(path, data);
}
/**
* Save a SongMetadata object as a JSON file to an automatically generated path.
* Works great on HTML5 and desktop.
*/
public static function exportSongMetadata(data:SongMetadata, songId:String)
{
var path = '${songId}-metadata.json';
exportSongMetadataAs(path, data);
}
/**
* Save a SongChartData object as a JSON file to a specified path.
* Works great on HTML5 and desktop.
*
* @param path The file path to save to.
*/
public static function exportSongChartDataAs(path:String, data:SongChartData)
{
var dataString = SerializerUtil.toJSON(data);
writeFileReference(path, dataString);
}
/**
* Save a SongMetadata object as a JSON file to a specified path.
* Works great on HTML5 and desktop.
*
* @param path The file path to save to.
*/
public static function exportSongMetadataAs(path:String, data:SongMetadata)
{
var dataString = SerializerUtil.toJSON(data);
writeFileReference(path, dataString);
}
/**
* Read the string contents of a file.
* Only works on desktop platforms.
* @param path The file path to read from.
*/
static function readFile(path:String):String
{
#if sys
var fileBytes:Bytes = sys.io.File.getBytes(path);
if (fileBytes == null) return null;
return fileBytes.toString();
#end
trace('ERROR: readFile not implemented for this platform');
return null;
}
/**
* Write string contents to a file.
* Only works on desktop platforms.
* @param path The file path to read from.
*/
static function writeFile(path:String, data:String):Void
{
#if sys
sys.io.File.saveContent(path, data);
return;
#end
trace('ERROR: writeFile not implemented for this platform');
return;
}
/**
* Browse for a file to read and execute a callback once we have a file reference.
* Works great on HTML5 or desktop.
*
* @param callback The function to call when the file is loaded.
*/
static function browseFileReference(callback:FileReference->Void)
{
var file = new FileReference();
file.addEventListener(Event.SELECT, function(e) {
var selectedFileRef:FileReference = e.target;
trace('Selected file: ' + selectedFileRef.name);
selectedFileRef.addEventListener(Event.COMPLETE, function(e) {
var loadedFileRef:FileReference = e.target;
trace('Loaded file: ' + loadedFileRef.name);
callback(loadedFileRef);
});
selectedFileRef.load();
});
file.browse();
}
/**
* Prompts the user to save a file to their computer.
*/
static function writeFileReference(path:String, data:String)
{
var file = new FileReference();
file.addEventListener(Event.COMPLETE, function(e:Event) {
trace('Successfully wrote file.');
});
file.addEventListener(Event.CANCEL, function(e:Event) {
trace('Cancelled writing file.');
});
file.addEventListener(IOErrorEvent.IO_ERROR, function(e:IOErrorEvent) {
trace('IO error writing file.');
});
file.save(data, path);
}
}

View File

@ -1,149 +0,0 @@
package funkin.play.song;
import funkin.data.song.SongRegistry;
import funkin.data.song.SongData.SongChartData;
import funkin.data.song.SongData.SongMetadata;
import funkin.data.song.SongData.SongPlayData;
import funkin.data.song.SongData.SongTimeChange;
import funkin.data.song.SongData.SongTimeFormat;
/**
* For SongMetadata and SongChartData objects,
* ensures mandatory fields are present and populates optional fields with default values.
*/
class SongValidator
{
public static final DEFAULT_SONGNAME:String = "Unknown";
public static final DEFAULT_ARTIST:String = "Unknown";
public static final DEFAULT_TIMEFORMAT:SongTimeFormat = SongTimeFormat.MILLISECONDS;
public static final DEFAULT_DIVISIONS:Null<Int> = null;
public static final DEFAULT_LOOPED:Bool = false;
public static final DEFAULT_STAGE:String = "mainStage";
public static final DEFAULT_SCROLLSPEED:Float = 1.0;
public static var DEFAULT_GENERATEDBY(get, never):String;
static function get_DEFAULT_GENERATEDBY():String
{
return '${Constants.TITLE} - ${Constants.VERSION}';
}
/**
* Validates the fields of a SongMetadata object (excluding the version field).
*
* @param input The SongMetadata object to validate.
* @param songId The ID of the song being validated. Only used for error messages.
* @return The validated SongMetadata object.
*/
public static function validateSongMetadata(input:SongMetadata, songId:String = 'unknown'):SongMetadata
{
if (input == null)
{
trace('[SONGDATA] Could not parse metadata for song ${songId}');
return null;
}
if (input.songName == null)
{
trace('[SONGDATA] Song ${songId} is missing a songName field. ');
input.songName = DEFAULT_SONGNAME;
}
if (input.artist == null)
{
trace('[SONGDATA] Song ${songId} is missing an artist field. ');
input.artist = DEFAULT_ARTIST;
}
if (input.timeFormat == null)
{
trace('[SONGDATA] Song ${songId} is missing a timeFormat field. ');
input.timeFormat = DEFAULT_TIMEFORMAT;
}
if (input.generatedBy == null)
{
input.generatedBy = SongRegistry.DEFAULT_GENERATEDBY;
}
input.timeChanges = validateTimeChanges(input.timeChanges, songId);
if (input.timeChanges == null)
{
trace('[SONGDATA] Song ${songId} is missing a timeChanges field. ');
return null;
}
input.playData = validatePlayData(input.playData, songId);
if (input.variation == null) input.variation = '';
return input;
}
/**
* Validates the fields of a SongPlayData object.
*
* @param input The SongPlayData object to validate.
* @param songId The ID of the song being validated. Only used for error messages.
* @return The validated SongPlayData object.
*/
public static function validatePlayData(input:SongPlayData, songId:String = 'unknown'):SongPlayData
{
if (input == null)
{
trace('[SONGDATA] Could not parse metadata.playData for song ${songId}');
return null;
}
return input;
}
/**
* Validates the fields of a TimeChange object.
*
* @param input The TimeChange object to validate.
* @param songId The ID of the song being validated. Only used for error messages.
* @return The validated TimeChange object.
*/
public static function validateTimeChange(input:SongTimeChange, songId:String = 'unknown'):SongTimeChange
{
if (input == null)
{
trace('[SONGDATA] Could not parse metadata.timeChange for song ${songId}');
return null;
}
return input;
}
/**
* Validates multiple TimeChange objects in an array.
*/
public static function validateTimeChanges(input:Array<SongTimeChange>, songId:String = 'unknown'):Array<SongTimeChange>
{
if (input == null)
{
trace('[SONGDATA] Could not parse metadata.timeChange for song ${songId}');
return null;
}
input = input.map((timeChange) -> validateTimeChange(timeChange, songId));
return input;
}
/**
* Validates the fields of a SongChartData object (excluding the version field).
*
* @param input The SongChartData object to validate.
* @param songId The ID of the song being validated. Only used for error messages.
* @return The validated SongChartData object.
*/
public static function validateSongChartData(input:SongChartData, songId:String = 'unknown'):SongChartData
{
if (input == null)
{
trace('[SONGDATA] Could not parse chart data for song ${songId}');
return null;
}
return input;
}
}

View File

@ -1,131 +0,0 @@
package funkin.play.song.formats;
typedef FNFLegacy =
{
var song:LegacySongData;
}
typedef LegacySongData =
{
var player1:String; // Boyfriend
var player2:String; // Opponent
var speed:Float;
var stageDefault:String;
var bpm:Float;
var notes:Array<LegacyNoteSection>;
var song:String; // Song name
};
typedef LegacyScrollSpeeds =
{
var easy:Float;
var normal:Float;
var hard:Float;
};
typedef LegacyNoteData =
{
/**
* The easy difficulty.
*/
var ?easy:Array<LegacyNoteSection>;
/**
* The normal difficulty.
*/
var ?normal:Array<LegacyNoteSection>;
/**
* The hard difficulty.
*/
var ?hard:Array<LegacyNoteSection>;
};
typedef LegacyNoteSection =
{
/**
* Whether the section is a must-hit section.
* If true, 0-3 are boyfriends notes, 4-7 are opponents notes.
* If false, 0-3 are opponents notes, 4-7 are boyfriends notes.
*/
var mustHitSection:Bool;
/**
* Array of note data:
* - Direction
* - Time (ms)
* - Sustain Duration (ms)
* - Note kind (true = "alt", or string)
*/
var sectionNotes:Array<LegacyNote>;
var typeOfSection:Int;
var lengthInSteps:Int;
}
/**
* Notes in the old format are stored as an Array<Dynamic>
*/
abstract LegacyNote(Array<Dynamic>)
{
public var time(get, set):Float;
function get_time():Float
{
return this[0];
}
function set_time(value:Float):Float
{
return this[0] = value;
}
public var data(get, set):Int;
function get_data():Int
{
return this[1];
}
function set_data(value:Int):Int
{
return this[1] = value;
}
public function getData(mustHitSection:Bool):Int
{
if (mustHitSection) return this[1];
return (this[1] + 4) % 8;
}
public var length(get, set):Float;
function get_length():Float
{
if (this.length < 3) return 0.0;
return this[2];
}
function set_length(value:Float):Float
{
return this[2] = value;
}
public var kind(get, set):String;
function get_kind():String
{
if (this.length < 4) return 'normal';
if (Std.isOfType(this[3], Bool)) return this[3] ? 'alt' : 'normal';
return this[3];
}
function set_kind(value:String):String
{
return this[3] = value;
}
}

View File

@ -0,0 +1,170 @@
package funkin.ui.debug.charting;
import openfl.utils.Assets;
import flixel.system.FlxAssets.FlxSoundAsset;
import flixel.system.FlxSound;
import funkin.play.character.BaseCharacter.CharacterType;
import flixel.system.FlxSound;
import haxe.io.Path;
/**
* Functions for loading audio for the chart editor.
*/
@:nullSafety
@:allow(funkin.ui.debug.charting.ChartEditorState)
@:allow(funkin.ui.debug.charting.ChartEditorDialogHandler)
@:allow(funkin.ui.debug.charting.ChartEditorImportExportHandler)
class ChartEditorAudioHandler
{
/**
* Loads a vocal track from an absolute file path.
* @param path The absolute path to the audio file.
* @param charKey The character to load the vocal track for.
* @return Success or failure.
*/
static function loadVocalsFromPath(state:ChartEditorState, path:Path, charKey:String = 'default'):Bool
{
#if sys
var fileBytes:haxe.io.Bytes = sys.io.File.getBytes(path.toString());
return loadVocalsFromBytes(state, fileBytes, charKey);
#else
trace("[WARN] This platform can't load audio from a file path, you'll need to fetch the bytes some other way.");
return false;
#end
}
/**
* Load a vocal track for a given song and character and add it to the voices group.
*
* @param path ID of the asset.
* @param charKey Character to load the vocal track for.
* @return Success or failure.
*/
static function loadVocalsFromAsset(state:ChartEditorState, path:String, charType:CharacterType = OTHER):Bool
{
var vocalTrack:FlxSound = FlxG.sound.load(path, 1.0, false);
if (vocalTrack != null)
{
switch (charType)
{
case CharacterType.BF:
if (state.audioVocalTrackGroup != null) state.audioVocalTrackGroup.addPlayerVoice(vocalTrack);
state.audioVocalTrackData.set(state.currentSongCharacterPlayer, Assets.getBytes(path));
case CharacterType.DAD:
if (state.audioVocalTrackGroup != null) state.audioVocalTrackGroup.addOpponentVoice(vocalTrack);
state.audioVocalTrackData.set(state.currentSongCharacterOpponent, Assets.getBytes(path));
default:
if (state.audioVocalTrackGroup != null) state.audioVocalTrackGroup.add(vocalTrack);
state.audioVocalTrackData.set('default', Assets.getBytes(path));
}
return true;
}
return false;
}
/**
* Loads a vocal track from audio byte data.
*/
static function loadVocalsFromBytes(state:ChartEditorState, bytes:haxe.io.Bytes, charKey:String = ''):Bool
{
var openflSound:openfl.media.Sound = new openfl.media.Sound();
openflSound.loadCompressedDataFromByteArray(openfl.utils.ByteArray.fromBytes(bytes), bytes.length);
var vocalTrack:FlxSound = FlxG.sound.load(openflSound, 1.0, false);
if (state.audioVocalTrackGroup != null) state.audioVocalTrackGroup.add(vocalTrack);
state.audioVocalTrackData.set(charKey, bytes);
return true;
}
/**
* Loads an instrumental from an absolute file path, replacing the current instrumental.
*
* @param path The absolute path to the audio file.
*
* @return Success or failure.
*/
static function loadInstrumentalFromPath(state:ChartEditorState, path:Path):Bool
{
#if sys
// Validate file extension.
if (path.ext != null && !ChartEditorState.SUPPORTED_MUSIC_FORMATS.contains(path.ext))
{
return false;
}
var fileBytes:haxe.io.Bytes = sys.io.File.getBytes(path.toString());
return loadInstrumentalFromBytes(state, fileBytes, '${path.file}.${path.ext}');
#else
trace("[WARN] This platform can't load audio from a file path, you'll need to fetch the bytes some other way.");
return false;
#end
}
/**
* Loads an instrumental from audio byte data, replacing the current instrumental.
* @param bytes The audio byte data.
* @param fileName The name of the file, if available. Used for notifications.
* @return Success or failure.
*/
static function loadInstrumentalFromBytes(state:ChartEditorState, bytes:haxe.io.Bytes, fileName:String = null):Bool
{
if (bytes == null)
{
return false;
}
var openflSound:openfl.media.Sound = new openfl.media.Sound();
openflSound.loadCompressedDataFromByteArray(openfl.utils.ByteArray.fromBytes(bytes), bytes.length);
state.audioInstTrack = FlxG.sound.load(openflSound, 1.0, false);
state.audioInstTrack.autoDestroy = false;
state.audioInstTrack.pause();
state.audioInstTrackData = bytes;
state.postLoadInstrumental();
return true;
}
/**
* Loads an instrumental from an OpenFL asset, replacing the current instrumental.
* @param path The path to the asset. Use `Paths` to build this.
* @return Success or failure.
*/
static function loadInstrumentalFromAsset(state:ChartEditorState, path:String):Bool
{
var instTrack:FlxSound = FlxG.sound.load(path, 1.0, false);
if (instTrack != null)
{
state.audioInstTrack = instTrack;
state.audioInstTrackData = Assets.getBytes(path);
state.postLoadInstrumental();
return true;
}
return false;
}
/**
* Play a sound effect.
* Automatically cleans up after itself and recycles previous FlxSound instances if available, for performance.
*/
public static function playSound(path:String):Void
{
var snd:FlxSound = FlxG.sound.list.recycle(FlxSound) ?? new FlxSound();
var asset:Null<FlxSoundAsset> = FlxG.sound.cache(path);
if (asset == null)
{
trace('WARN: Failed to play sound $path, asset not found.');
return;
}
snd.loadEmbedded(asset);
snd.autoDestroy = true;
FlxG.sound.list.add(snd);
snd.play();
}
}

View File

@ -64,7 +64,7 @@ class AddNotesCommand implements ChartEditorCommand
state.currentEventSelection = [];
}
state.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
state.saveDataDirty = true;
state.noteDisplayDirty = true;
@ -78,7 +78,7 @@ class AddNotesCommand implements ChartEditorCommand
state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes);
state.currentNoteSelection = [];
state.currentEventSelection = [];
state.playSound(Paths.sound('funnyNoise/funnyNoise-01'));
ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-01'));
state.saveDataDirty = true;
state.noteDisplayDirty = true;
@ -114,7 +114,7 @@ class RemoveNotesCommand implements ChartEditorCommand
state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes);
state.currentNoteSelection = [];
state.currentEventSelection = [];
state.playSound(Paths.sound('funnyNoise/funnyNoise-01'));
ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-01'));
state.saveDataDirty = true;
state.noteDisplayDirty = true;
@ -131,7 +131,7 @@ class RemoveNotesCommand implements ChartEditorCommand
}
state.currentNoteSelection = notes;
state.currentEventSelection = [];
state.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
state.saveDataDirty = true;
state.noteDisplayDirty = true;
@ -252,7 +252,7 @@ class AddEventsCommand implements ChartEditorCommand
state.currentEventSelection = events;
}
state.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
state.saveDataDirty = true;
state.noteDisplayDirty = true;
@ -296,7 +296,7 @@ class RemoveEventsCommand implements ChartEditorCommand
{
state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events);
state.currentEventSelection = [];
state.playSound(Paths.sound('funnyNoise/funnyNoise-01'));
ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-01'));
state.saveDataDirty = true;
state.noteDisplayDirty = true;
@ -312,7 +312,7 @@ class RemoveEventsCommand implements ChartEditorCommand
state.currentSongChartEventData.push(event);
}
state.currentEventSelection = events;
state.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
state.saveDataDirty = true;
state.noteDisplayDirty = true;
@ -352,7 +352,7 @@ class RemoveItemsCommand implements ChartEditorCommand
state.currentNoteSelection = [];
state.currentEventSelection = [];
state.playSound(Paths.sound('funnyNoise/funnyNoise-01'));
ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-01'));
state.saveDataDirty = true;
state.noteDisplayDirty = true;
@ -376,7 +376,7 @@ class RemoveItemsCommand implements ChartEditorCommand
state.currentNoteSelection = notes;
state.currentEventSelection = events;
state.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
state.saveDataDirty = true;
state.noteDisplayDirty = true;

View File

@ -1,40 +1,45 @@
package funkin.ui.debug.charting;
import funkin.play.character.CharacterData;
import funkin.util.Constants;
import funkin.util.SerializerUtil;
import funkin.ui.haxeui.components.FunkinDropDown;
import flixel.util.FlxTimer;
import funkin.data.song.importer.FNFLegacyData;
import funkin.data.song.importer.FNFLegacyImporter;
import funkin.data.song.SongData.SongCharacterData;
import funkin.data.song.SongData.SongChartData;
import funkin.data.song.SongData.SongMetadata;
import flixel.util.FlxTimer;
import funkin.ui.haxeui.components.FunkinLink;
import funkin.util.SortUtil;
import funkin.data.song.SongData.SongTimeChange;
import funkin.data.song.SongRegistry;
import funkin.input.Cursor;
import funkin.play.character.BaseCharacter;
import funkin.play.character.CharacterData;
import funkin.play.character.CharacterData.CharacterDataParser;
import funkin.play.song.Song;
import funkin.play.song.SongMigrator;
import funkin.play.song.SongValidator;
import funkin.data.song.SongRegistry;
import funkin.data.song.SongData.SongPlayableChar;
import funkin.data.song.SongData.SongTimeChange;
import funkin.play.stage.StageData;
import funkin.ui.haxeui.components.FunkinLink;
import funkin.util.Constants;
import funkin.util.FileUtil;
import funkin.util.SerializerUtil;
import funkin.util.SortUtil;
import funkin.util.VersionUtil;
import haxe.io.Path;
import haxe.ui.components.Button;
import haxe.ui.components.DropDown;
import haxe.ui.components.Label;
import haxe.ui.components.Link;
import haxe.ui.components.NumberStepper;
import haxe.ui.components.Slider;
import haxe.ui.components.TextField;
import haxe.ui.containers.Box;
import haxe.ui.containers.dialogs.Dialog;
import haxe.ui.containers.dialogs.Dialog.DialogButton;
import haxe.ui.containers.dialogs.Dialogs;
import haxe.ui.containers.properties.PropertyGrid;
import haxe.ui.containers.properties.PropertyGroup;
import haxe.ui.containers.Form;
import haxe.ui.containers.VBox;
import haxe.ui.core.Component;
import haxe.ui.events.UIEvent;
import haxe.ui.notifications.NotificationManager;
import haxe.ui.notifications.NotificationType;
import thx.semver.Version;
using Lambda;
@ -48,13 +53,14 @@ class ChartEditorDialogHandler
static final CHART_EDITOR_DIALOG_WELCOME_LAYOUT:String = Paths.ui('chart-editor/dialogs/welcome');
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_SONG_METADATA_CHARGROUP_LAYOUT:String = Paths.ui('chart-editor/dialogs/song-metadata-chargroup');
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_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');
static final CHART_EDITOR_DIALOG_ADD_DIFFICULTY_LAYOUT:String = Paths.ui('chart-editor/dialogs/add-difficulty');
/**
* Builds and opens a dialog giving brief credits for the chart editor.
@ -83,6 +89,7 @@ class ChartEditorDialogHandler
linkCreateBasic.onClick = function(_event) {
// Hide the welcome dialog
dialog.hideDialog(DialogButton.CANCEL);
state.stopWelcomeMusic();
//
// Create Song Wizard
@ -95,6 +102,7 @@ class ChartEditorDialogHandler
linkImportChartLegacy.onClick = function(_event) {
// Hide the welcome dialog
dialog.hideDialog(DialogButton.CANCEL);
state.stopWelcomeMusic();
// Open the "Import Chart" dialog
openImportChartWizard(state, 'legacy', false);
@ -105,6 +113,7 @@ class ChartEditorDialogHandler
buttonBrowse.onClick = function(_event) {
// Hide the welcome dialog
dialog.hideDialog(DialogButton.CANCEL);
state.stopWelcomeMusic();
// Open the "Open Chart" dialog
openBrowseWizard(state, false);
@ -133,14 +142,16 @@ class ChartEditorDialogHandler
linkTemplateSong.text = songName;
linkTemplateSong.onClick = function(_event) {
dialog.hideDialog(DialogButton.CANCEL);
state.stopWelcomeMusic();
// Load song from template
state.loadSongAsTemplate(targetSongId);
ChartEditorImportExportHandler.loadSongAsTemplate(state, targetSongId);
}
splashTemplateContainer.addComponent(linkTemplateSong);
}
state.fadeInWelcomeMusic();
return dialog;
}
@ -298,7 +309,7 @@ class ChartEditorDialogHandler
{label: 'Audio File (.ogg)', extension: 'ogg'}], function(selectedFile:SelectedFileInfo) {
if (selectedFile != null && selectedFile.bytes != null)
{
if (state.loadInstrumentalFromBytes(selectedFile.bytes))
if (ChartEditorAudioHandler.loadInstrumentalFromBytes(state, selectedFile.bytes))
{
trace('Selected file: ' + selectedFile.fullPath);
#if !mac
@ -335,7 +346,7 @@ class ChartEditorDialogHandler
onDropFile = function(pathStr:String) {
var path:Path = new Path(pathStr);
trace('Dropped file (${path})');
if (state.loadInstrumentalFromPath(path))
if (ChartEditorAudioHandler.loadInstrumentalFromPath(state, path))
{
// Tell the user the load was successful.
#if !mac
@ -457,62 +468,96 @@ class ChartEditorDialogHandler
dialog.hideDialog(DialogButton.CANCEL);
}
var dialogSongName:Null<TextField> = dialog.findComponent('dialogSongName', TextField);
if (dialogSongName == null) throw 'Could not locate dialogSongName TextField in Song Metadata dialog';
dialogSongName.onChange = function(event:UIEvent) {
var newSongMetadata:SongMetadata = new SongMetadata('', '', 'default');
var inputSongName:Null<TextField> = dialog.findComponent('inputSongName', TextField);
if (inputSongName == null) throw 'Could not locate inputSongName TextField in Song Metadata dialog';
inputSongName.onChange = function(event:UIEvent) {
var valid:Bool = event.target.text != null && event.target.text != '';
if (valid)
{
dialogSongName.removeClass('invalid-value');
state.currentSongMetadata.songName = event.target.text;
inputSongName.removeClass('invalid-value');
newSongMetadata.songName = event.target.text;
}
else
{
state.currentSongMetadata.songName = "";
newSongMetadata.songName = "";
}
};
state.currentSongMetadata.songName = "";
inputSongName.text = "";
var dialogSongArtist:Null<TextField> = dialog.findComponent('dialogSongArtist', TextField);
if (dialogSongArtist == null) throw 'Could not locate dialogSongArtist TextField in Song Metadata dialog';
dialogSongArtist.onChange = function(event:UIEvent) {
var inputSongArtist:Null<TextField> = dialog.findComponent('inputSongArtist', TextField);
if (inputSongArtist == null) throw 'Could not locate inputSongArtist TextField in Song Metadata dialog';
inputSongArtist.onChange = function(event:UIEvent) {
var valid:Bool = event.target.text != null && event.target.text != '';
if (valid)
{
dialogSongArtist.removeClass('invalid-value');
state.currentSongMetadata.artist = event.target.text;
inputSongArtist.removeClass('invalid-value');
newSongMetadata.artist = event.target.text;
}
else
{
state.currentSongMetadata.artist = "";
newSongMetadata.artist = "";
}
};
state.currentSongMetadata.artist = "";
inputSongArtist.text = "";
var dialogStage:Null<DropDown> = dialog.findComponent('dialogStage', DropDown);
if (dialogStage == null) throw 'Could not locate dialogStage DropDown in Song Metadata dialog';
dialogStage.onChange = function(event:UIEvent) {
var inputStage:Null<DropDown> = dialog.findComponent('inputStage', DropDown);
if (inputStage == null) throw 'Could not locate inputStage DropDown in Song Metadata dialog';
inputStage.onChange = function(event:UIEvent) {
if (event.data == null && event.data.id == null) return;
state.currentSongMetadata.playData.stage = event.data.id;
newSongMetadata.playData.stage = event.data.id;
};
state.currentSongMetadata.playData.stage = 'mainStage';
var startingValueStage = ChartEditorDropdowns.populateDropdownWithStages(inputStage, newSongMetadata.playData.stage);
inputStage.value = startingValueStage;
var dialogNoteSkin:Null<DropDown> = dialog.findComponent('dialogNoteSkin', DropDown);
if (dialogNoteSkin == null) throw 'Could not locate dialogNoteSkin DropDown in Song Metadata dialog';
dialogNoteSkin.onChange = function(event:UIEvent) {
var inputNoteStyle:Null<FunkinDropDown> = dialog.findComponent('inputNoteStyle', FunkinDropDown);
if (inputNoteStyle == null) throw 'Could not locate inputNoteStyle DropDown in Song Metadata dialog';
inputNoteStyle.onChange = function(event:UIEvent) {
if (event.data.id == null) return;
state.currentSongNoteSkin = event.data.id;
newSongMetadata.playData.noteSkin = event.data.id;
};
state.currentSongNoteSkin = 'funkin';
var startingValueNoteStyle = ChartEditorDropdowns.populateDropdownWithNoteStyles(inputNoteStyle, newSongMetadata.playData.noteSkin);
inputNoteStyle.value = startingValueNoteStyle;
var inputCharacterPlayer:Null<FunkinDropDown> = dialog.findComponent('inputCharacterPlayer', FunkinDropDown);
if (inputCharacterPlayer == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputCharacterPlayer component.';
inputCharacterPlayer.onChange = function(event:UIEvent) {
if (event.data?.id == null) return;
newSongMetadata.playData.characters.player = event.data.id;
};
var startingValuePlayer = ChartEditorDropdowns.populateDropdownWithCharacters(inputCharacterPlayer, CharacterType.BF,
newSongMetadata.playData.characters.player);
inputCharacterPlayer.value = startingValuePlayer;
var inputCharacterOpponent:Null<FunkinDropDown> = dialog.findComponent('inputCharacterOpponent', FunkinDropDown);
if (inputCharacterOpponent == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputCharacterOpponent component.';
inputCharacterOpponent.onChange = function(event:UIEvent) {
if (event.data?.id == null) return;
newSongMetadata.playData.characters.opponent = event.data.id;
};
var startingValueOpponent = ChartEditorDropdowns.populateDropdownWithCharacters(inputCharacterOpponent, CharacterType.DAD,
newSongMetadata.playData.characters.opponent);
inputCharacterOpponent.value = startingValueOpponent;
var inputCharacterGirlfriend:Null<FunkinDropDown> = dialog.findComponent('inputCharacterGirlfriend', FunkinDropDown);
if (inputCharacterGirlfriend == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputCharacterGirlfriend component.';
inputCharacterGirlfriend.onChange = function(event:UIEvent) {
if (event.data?.id == null) return;
newSongMetadata.playData.characters.girlfriend = event.data.id == "none" ? "" : event.data.id;
};
var startingValueGirlfriend = ChartEditorDropdowns.populateDropdownWithCharacters(inputCharacterGirlfriend, CharacterType.GF,
newSongMetadata.playData.characters.girlfriend);
inputCharacterGirlfriend.value = startingValueGirlfriend;
var dialogBPM:Null<NumberStepper> = dialog.findComponent('dialogBPM', NumberStepper);
if (dialogBPM == null) throw 'Could not locate dialogBPM NumberStepper in Song Metadata dialog';
dialogBPM.onChange = function(event:UIEvent) {
if (event.value == null || event.value <= 0) return;
var timeChanges:Array<SongTimeChange> = state.currentSongMetadata.timeChanges;
var timeChanges:Array<SongTimeChange> = newSongMetadata.timeChanges;
if (timeChanges == null || timeChanges.length == 0)
{
timeChanges = [new SongTimeChange(0, event.value)];
@ -524,24 +569,9 @@ class ChartEditorDialogHandler
Conductor.forceBPM(event.value);
state.currentSongMetadata.timeChanges = timeChanges;
newSongMetadata.timeChanges = timeChanges;
};
var dialogCharGrid:Null<PropertyGrid> = dialog.findComponent('dialogCharGrid', PropertyGrid);
if (dialogCharGrid == null) throw 'Could not locate dialogCharGrid PropertyGrid in Song Metadata dialog';
var dialogCharAdd:Null<Button> = dialog.findComponent('dialogCharAdd', Button);
if (dialogCharAdd == null) throw 'Could not locate dialogCharAdd Button in Song Metadata dialog';
dialogCharAdd.onClick = function(event:UIEvent) {
var charGroup:PropertyGroup;
charGroup = buildCharGroup(state, null, () -> dialogCharGrid.removeComponent(charGroup));
dialogCharGrid.addComponent(charGroup);
};
// Empty the character list.
state.currentSongMetadata.playData.playableChars = [];
// Add at least one character group with no Remove button.
dialogCharGrid.addComponent(buildCharGroup(state, 'bf'));
var dialogContinue:Null<Button> = dialog.findComponent('dialogContinue', Button);
if (dialogContinue == null) throw 'Could not locate dialogContinue button in Song Metadata dialog';
dialogContinue.onClick = (_event) -> dialog.hideDialog(DialogButton.APPLY);
@ -549,78 +579,6 @@ class ChartEditorDialogHandler
return dialog;
}
static function buildCharGroup(state:ChartEditorState, key:String = '', removeFunc:Void->Void = null):PropertyGroup
{
var groupKey:String = key;
var getCharData:Void->Null<SongPlayableChar> = function():Null<SongPlayableChar> {
if (state.currentSongMetadata.playData == null) return null;
if (groupKey == null) groupKey = 'newChar${state.currentSongMetadata.playData.playableChars.keys().count()}';
var result = state.currentSongMetadata.playData.playableChars.get(groupKey);
if (result == null)
{
result = new SongPlayableChar('', 'dad');
state.currentSongMetadata.playData.playableChars.set(groupKey, result);
}
return result;
}
var moveCharGroup:String->Void = function(target:String):Void {
var charData:Null<SongPlayableChar> = getCharData();
if (charData == null) return;
if (state.currentSongMetadata.playData.playableChars == null) return;
state.currentSongMetadata.playData.playableChars.remove(groupKey);
state.currentSongMetadata.playData.playableChars.set(target, charData);
groupKey = target;
}
var removeGroup:Void->Void = function():Void {
if (state?.currentSongMetadata?.playData?.playableChars == null) return;
state.currentSongMetadata.playData.playableChars.remove(groupKey);
if (removeFunc != null) removeFunc();
}
var charData:Null<SongPlayableChar> = getCharData();
var charGroup:PropertyGroup = cast state.buildComponent(CHART_EDITOR_DIALOG_SONG_METADATA_CHARGROUP_LAYOUT);
var charGroupPlayer:Null<DropDown> = charGroup.findComponent('charGroupPlayer', DropDown);
if (charGroupPlayer == null) throw 'Could not locate charGroupPlayer DropDown in Song Metadata dialog';
charGroupPlayer.onChange = function(event:UIEvent):Void {
if (charData != null) return;
charGroup.text = event.data.text;
moveCharGroup(event.data.id);
};
var charGroupOpponent:Null<DropDown> = charGroup.findComponent('charGroupOpponent', DropDown);
if (charGroupOpponent == null) throw 'Could not locate charGroupOpponent DropDown in Song Metadata dialog';
charGroupOpponent.onChange = function(event:UIEvent):Void {
if (charData == null) return;
charData.opponent = event.data.id;
};
charGroupOpponent.value = charData.opponent;
var charGroupGirlfriend:Null<DropDown> = charGroup.findComponent('charGroupGirlfriend', DropDown);
if (charGroupGirlfriend == null) throw 'Could not locate charGroupGirlfriend DropDown in Song Metadata dialog';
charGroupGirlfriend.onChange = function(event:UIEvent):Void {
if (charData == null) return;
charData.girlfriend = event.data.id;
};
charGroupGirlfriend.value = charData.girlfriend;
var charGroupRemove:Null<Button> = charGroup.findComponent('charGroupRemove', Button);
if (charGroupRemove == null) throw 'Could not locate charGroupRemove Button in Song Metadata dialog';
charGroupRemove.onClick = function(event:UIEvent):Void {
removeGroup();
};
if (removeFunc == null) charGroupRemove.hidden = true;
return charGroup;
}
/**
* Builds and opens a dialog where the user uploads vocals for the current song.
* @param state The current chart editor state.
@ -631,13 +589,10 @@ class ChartEditorDialogHandler
{
var charIdsForVocals:Array<String> = [];
for (charKey in state.currentSongMetadata.playData.playableChars.keys())
{
var charData:Null<SongPlayableChar> = state.currentSongMetadata.playData.playableChars.get(charKey);
if (charData == null) continue;
charIdsForVocals.push(charKey);
if (charData.opponent != null) charIdsForVocals.push(charData.opponent);
}
var charData:SongCharacterData = state.currentSongMetadata.playData.characters;
charIdsForVocals.push(charData.player);
charIdsForVocals.push(charData.opponent);
var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_UPLOAD_VOCALS_LAYOUT, true, closable);
if (dialog == null) throw 'Could not locate Upload Vocals dialog';
@ -678,7 +633,7 @@ class ChartEditorDialogHandler
trace('Selected file: $pathStr');
var path:Path = new Path(pathStr);
if (state.loadVocalsFromPath(path, charKey))
if (ChartEditorAudioHandler.loadVocalsFromPath(state, path, charKey))
{
// Tell the user the load was successful.
#if !mac
@ -740,7 +695,7 @@ class ChartEditorDialogHandler
#else
vocalsEntryLabel.text = 'Vocals for $charName (click to browse)\n${selectedFile.name}';
#end
state.loadVocalsFromBytes(selectedFile.bytes, charKey);
ChartEditorAudioHandler.loadVocalsFromBytes(state, selectedFile.bytes, charKey);
dialogNoVocals.hidden = true;
removeDropHandler(onDropFile);
}
@ -793,7 +748,7 @@ class ChartEditorDialogHandler
var buttonContinue:Null<Button> = dialog.findComponent('dialogContinue', Button);
if (buttonContinue == null) throw 'Could not locate dialogContinue button in Open Chart dialog';
buttonContinue.onClick = function(_event) {
state.loadSong(songMetadata, songChartData);
ChartEditorImportExportHandler.loadSong(state, songMetadata, songChartData);
dialog.hideDialog(DialogButton.APPLY);
}
@ -880,9 +835,26 @@ class ChartEditorDialogHandler
var path:Path = new Path(pathStr);
trace('Dropped JSON file (${path})');
var songMetadataJson:Dynamic = FileUtil.readJSONFromPath(path.toString());
var songMetadataVariation:SongMetadata = SongMigrator.migrateSongMetadata(songMetadataJson, 'import');
songMetadataVariation = SongValidator.validateSongMetadata(songMetadataVariation, 'import');
var songMetadataTxt:String = FileUtil.readStringFromPath(path.toString());
var songMetadataVersion:Null<Version> = VersionUtil.getVersionFromJSON(songMetadataTxt);
if (songMetadataVersion == null)
{
// Tell the user the load was not successful.
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Failure',
body: 'Could not parse metadata file version (${path.file}.${path.ext})',
type: NotificationType.Error,
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
});
#end
return;
}
var songMetadataVariation:Null<SongMetadata> = SongRegistry.instance.parseEntryMetadataRawWithMigration(songMetadataTxt, path.toString(),
songMetadataVersion);
if (songMetadataVariation == null)
{
@ -928,31 +900,63 @@ class ChartEditorDialogHandler
{
trace('Selected file: ' + selectedFile.name);
var songMetadataJson:Dynamic = SerializerUtil.fromJSONBytes(selectedFile.bytes);
var songMetadataVariation:SongMetadata = SongMigrator.migrateSongMetadata(songMetadataJson, 'import');
songMetadataVariation = SongValidator.validateSongMetadata(songMetadataVariation, 'import');
songMetadataVariation.variation = variation;
var songMetadataTxt:String = selectedFile.bytes.toString();
songMetadata.set(variation, songMetadataVariation);
var songMetadataVersion:Null<Version> = VersionUtil.getVersionFromJSON(songMetadataTxt);
if (songMetadataVersion == null)
{
// Tell the user the load was not successful.
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Failure',
body: 'Could not parse metadata file version (${selectedFile.name})',
type: NotificationType.Error,
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
});
#end
return;
}
// Tell the user the load was successful.
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Success',
body: 'Loaded metadata file (${selectedFile.name})',
type: NotificationType.Success,
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
});
#end
var songMetadataVariation:Null<SongMetadata> = SongRegistry.instance.parseEntryMetadataRawWithMigration(songMetadataTxt, selectedFile.name,
songMetadataVersion);
#if FILE_DROP_SUPPORTED
label.text = 'Metadata file (drag and drop, or click to browse)\nSelected file: ${selectedFile.name}';
#else
label.text = 'Metadata file (click to browse)\n${selectedFile.name}';
#end
if (songMetadataVariation != null)
{
songMetadata.set(variation, songMetadataVariation);
if (variation == Constants.DEFAULT_VARIATION) constructVariationEntries(songMetadataVariation.playData.songVariations);
// Tell the user the load was successful.
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Success',
body: 'Loaded metadata file (${selectedFile.name})',
type: NotificationType.Success,
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
});
#end
#if FILE_DROP_SUPPORTED
label.text = 'Metadata file (drag and drop, or click to browse)\nSelected file: ${selectedFile.name}';
#else
label.text = 'Metadata file (click to browse)\n${selectedFile.name}';
#end
if (variation == Constants.DEFAULT_VARIATION) constructVariationEntries(songMetadataVariation.playData.songVariations);
}
else
{
// Tell the user the load was unsuccessful.
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Failure',
body: 'Failed to load metadata file (${selectedFile.name})',
type: NotificationType.Error,
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
});
#end
}
}
});
}
@ -961,31 +965,64 @@ class ChartEditorDialogHandler
var path:Path = new Path(pathStr);
trace('Dropped JSON file (${path})');
var songChartDataJson:Dynamic = FileUtil.readJSONFromPath(path.toString());
var songChartDataVariation:SongChartData = SongMigrator.migrateSongChartData(songChartDataJson, 'import');
songChartDataVariation = SongValidator.validateSongChartData(songChartDataVariation, 'import');
var songChartDataTxt:String = FileUtil.readStringFromPath(path.toString());
songChartData.set(variation, songChartDataVariation);
state.notePreviewDirty = true;
state.notePreviewViewportBoundsDirty = true;
state.noteDisplayDirty = true;
var songChartDataVersion:Null<Version> = VersionUtil.getVersionFromJSON(songChartDataTxt);
if (songChartDataVersion == null)
{
// Tell the user the load was not successful.
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Failure',
body: 'Could not parse chart data file version (${path.file}.${path.ext})',
type: NotificationType.Error,
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
});
#end
return;
}
// Tell the user the load was successful.
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Success',
body: 'Loaded chart data file (${path.file}.${path.ext})',
type: NotificationType.Success,
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
});
#end
var songChartDataVariation:Null<SongChartData> = SongRegistry.instance.parseEntryChartDataRawWithMigration(songChartDataTxt, path.toString(),
songChartDataVersion);
#if FILE_DROP_SUPPORTED
label.text = 'Chart data file (drag and drop, or click to browse)\nSelected file: ${path.file}.${path.ext}';
#else
label.text = 'Chart data file (click to browse)\n${path.file}.${path.ext}';
#end
if (songChartDataVariation != null)
{
songChartData.set(variation, songChartDataVariation);
state.notePreviewDirty = true;
state.notePreviewViewportBoundsDirty = true;
state.noteDisplayDirty = true;
// Tell the user the load was successful.
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Success',
body: 'Loaded chart data file (${path.file}.${path.ext})',
type: NotificationType.Success,
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
});
#end
#if FILE_DROP_SUPPORTED
label.text = 'Chart data file (drag and drop, or click to browse)\nSelected file: ${path.file}.${path.ext}';
#else
label.text = 'Chart data file (click to browse)\n${path.file}.${path.ext}';
#end
}
else
{
// Tell the user the load was unsuccessful.
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Failure',
body: 'Failed to load chart data file (${path.file}.${path.ext})',
type: NotificationType.Error,
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
});
#end
}
};
onClickChartDataVariation = function(variation:String, label:Label, _event:UIEvent) {
@ -995,31 +1032,51 @@ class ChartEditorDialogHandler
{
trace('Selected file: ' + selectedFile.name);
var songChartDataJson:Dynamic = SerializerUtil.fromJSONBytes(selectedFile.bytes);
var songChartDataVariation:SongChartData = SongMigrator.migrateSongChartData(songChartDataJson, 'import');
songChartDataVariation = SongValidator.validateSongChartData(songChartDataVariation, 'import');
var songChartDataTxt:String = selectedFile.bytes.toString();
songChartData.set(variation, songChartDataVariation);
state.notePreviewDirty = true;
state.notePreviewViewportBoundsDirty = true;
state.noteDisplayDirty = true;
var songChartDataVersion:Null<Version> = VersionUtil.getVersionFromJSON(songChartDataTxt);
if (songChartDataVersion == null)
{
// Tell the user the load was not successful.
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Failure',
body: 'Could not parse chart data file version (${selectedFile.name})',
type: NotificationType.Error,
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
});
#end
return;
}
// Tell the user the load was successful.
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Success',
body: 'Loaded chart data file (${selectedFile.name})',
type: NotificationType.Success,
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
});
#end
var songChartDataVariation:Null<SongChartData> = SongRegistry.instance.parseEntryChartDataRawWithMigration(songChartDataTxt, selectedFile.name,
songChartDataVersion);
#if FILE_DROP_SUPPORTED
label.text = 'Chart data file (drag and drop, or click to browse)\nSelected file: ${selectedFile.name}';
#else
label.text = 'Chart data file (click to browse)\n${selectedFile.name}';
#end
if (songChartDataVariation != null)
{
songChartData.set(variation, songChartDataVariation);
state.notePreviewDirty = true;
state.notePreviewViewportBoundsDirty = true;
state.noteDisplayDirty = true;
// Tell the user the load was successful.
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Success',
body: 'Loaded chart data file (${selectedFile.name})',
type: NotificationType.Success,
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
});
#end
#if FILE_DROP_SUPPORTED
label.text = 'Chart data file (drag and drop, or click to browse)\nSelected file: ${selectedFile.name}';
#else
label.text = 'Chart data file (click to browse)\n${selectedFile.name}';
#end
}
}
});
}
@ -1102,11 +1159,27 @@ class ChartEditorDialogHandler
if (selectedFile != null && selectedFile.bytes != null)
{
trace('Selected file: ' + selectedFile.fullPath);
var selectedFileJson:Dynamic = SerializerUtil.fromJSONBytes(selectedFile.bytes);
var songMetadata:SongMetadata = SongMigrator.migrateSongMetadataFromLegacy(selectedFileJson);
var songChartData:SongChartData = SongMigrator.migrateSongChartDataFromLegacy(selectedFileJson);
var selectedFileTxt:String = selectedFile.bytes.toString();
var fnfLegacyData:Null<FNFLegacyData> = FNFLegacyImporter.parseLegacyDataRaw(selectedFileTxt, selectedFile.fullPath);
state.loadSong([Constants.DEFAULT_VARIATION => songMetadata], [Constants.DEFAULT_VARIATION => songChartData]);
if (fnfLegacyData == null)
{
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Failure',
body: 'Failed to parse FNF chart file (${selectedFile.name})',
type: NotificationType.Error,
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
});
#end
return;
}
var songMetadata:SongMetadata = FNFLegacyImporter.migrateMetadata(fnfLegacyData);
var songChartData:SongChartData = FNFLegacyImporter.migrateChartData(fnfLegacyData);
ChartEditorImportExportHandler.loadSong(state, [Constants.DEFAULT_VARIATION => songMetadata], [Constants.DEFAULT_VARIATION => songChartData]);
dialog.hideDialog(DialogButton.APPLY);
#if !mac
@ -1124,11 +1197,12 @@ class ChartEditorDialogHandler
onDropFile = function(pathStr:String) {
var path:Path = new Path(pathStr);
var selectedFileJson:Dynamic = FileUtil.readJSONFromPath(path.toString());
var songMetadata:SongMetadata = SongMigrator.migrateSongMetadataFromLegacy(selectedFileJson);
var songChartData:SongChartData = SongMigrator.migrateSongChartDataFromLegacy(selectedFileJson);
var selectedFileText:String = FileUtil.readStringFromPath(path.toString());
var selectedFileData:FNFLegacyData = FNFLegacyImporter.parseLegacyDataRaw(selectedFileText, path.toString());
var songMetadata:SongMetadata = FNFLegacyImporter.migrateMetadata(selectedFileData);
var songChartData:SongChartData = FNFLegacyImporter.migrateChartData(selectedFileData);
state.loadSong([Constants.DEFAULT_VARIATION => songMetadata], [Constants.DEFAULT_VARIATION => songChartData]);
ChartEditorImportExportHandler.loadSong(state, [Constants.DEFAULT_VARIATION => songMetadata], [Constants.DEFAULT_VARIATION => songChartData]);
dialog.hideDialog(DialogButton.APPLY);
#if !mac
@ -1181,4 +1255,161 @@ class ChartEditorDialogHandler
return dialog;
}
/**
* Builds and opens a dialog where the user can add a new variation for a song.
* @param state The current chart editor state.
* @param closable Whether the dialog can be closed by the user.
* @return The dialog that was opened.
*/
public static function openAddVariationDialog(state:ChartEditorState, closable:Bool = true):Dialog
{
var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_ADD_VARIATION_LAYOUT, true, false);
if (dialog == null) throw 'Could not locate Add Variation dialog';
var variationForm:Null<Form> = dialog.findComponent('variationForm', Form);
if (variationForm == null) throw 'Could not locate variationForm Form in Add Variation dialog';
var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
if (buttonCancel == null) throw 'Could not locate dialogCancel button in Add Variation dialog';
buttonCancel.onClick = function(_event) {
dialog.hideDialog(DialogButton.CANCEL);
}
var buttonAdd:Null<Button> = dialog.findComponent('dialogAdd', Button);
if (buttonAdd == null) throw 'Could not locate dialogAdd button in Add Variation dialog';
buttonAdd.onClick = function(_event) {
// This performs validation before the onSubmit callback is called.
variationForm.submit();
}
var dialogSongName:Null<TextField> = dialog.findComponent('dialogSongName', TextField);
if (dialogSongName == null) throw 'Could not locate dialogSongName TextField in Add Variation dialog';
dialogSongName.value = state.currentSongMetadata.songName;
var dialogSongArtist:Null<TextField> = dialog.findComponent('dialogSongArtist', TextField);
if (dialogSongArtist == null) throw 'Could not locate dialogSongArtist TextField in Add Variation dialog';
dialogSongArtist.value = state.currentSongMetadata.artist;
var dialogStage:Null<DropDown> = dialog.findComponent('dialogStage', DropDown);
if (dialogStage == null) throw 'Could not locate dialogStage DropDown in Add Variation dialog';
var startingValueStage = ChartEditorDropdowns.populateDropdownWithStages(dialogStage, state.currentSongMetadata.playData.stage);
dialogStage.value = startingValueStage;
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;
var dialogCharacterPlayer:Null<DropDown> = dialog.findComponent('dialogCharacterPlayer', DropDown);
if (dialogCharacterPlayer == null) throw 'Could not locate dialogCharacterPlayer DropDown in Add Variation dialog';
dialogCharacterPlayer.value = ChartEditorDropdowns.populateDropdownWithCharacters(dialogCharacterPlayer, CharacterType.BF,
state.currentSongMetadata.playData.characters.player);
var dialogCharacterOpponent:Null<DropDown> = dialog.findComponent('dialogCharacterOpponent', DropDown);
if (dialogCharacterOpponent == null) throw 'Could not locate dialogCharacterOpponent DropDown in Add Variation dialog';
dialogCharacterOpponent.value = ChartEditorDropdowns.populateDropdownWithCharacters(dialogCharacterOpponent, CharacterType.DAD,
state.currentSongMetadata.playData.characters.opponent);
var dialogCharacterGirlfriend:Null<DropDown> = dialog.findComponent('dialogCharacterGirlfriend', DropDown);
if (dialogCharacterGirlfriend == null) throw 'Could not locate dialogCharacterGirlfriend DropDown in Add Variation dialog';
dialogCharacterGirlfriend.value = ChartEditorDropdowns.populateDropdownWithCharacters(dialogCharacterGirlfriend, CharacterType.GF,
state.currentSongMetadata.playData.characters.girlfriend);
var dialogBPM:Null<NumberStepper> = dialog.findComponent('dialogBPM', NumberStepper);
if (dialogBPM == null) throw 'Could not locate dialogBPM NumberStepper in Add Variation dialog';
dialogBPM.value = state.currentSongMetadata.timeChanges[0].bpm;
// If all validators succeeded, this callback is called.
variationForm.onSubmit = function(_event) {
trace('Add Variation dialog submitted, validation succeeded!');
var dialogVariationName:Null<TextField> = dialog.findComponent('dialogVariationName', TextField);
if (dialogVariationName == null) throw 'Could not locate dialogVariationName TextField in Add Variation dialog';
var pendingVariation:SongMetadata = new SongMetadata(dialogSongName.text, dialogSongArtist.text, dialogVariationName.text.toLowerCase());
pendingVariation.playData.stage = dialogStage.value.id;
pendingVariation.playData.noteSkin = dialogNoteStyle.value;
pendingVariation.timeChanges[0].bpm = dialogBPM.value;
state.songMetadata.set(pendingVariation.variation, pendingVariation);
state.difficultySelectDirty = true; // Force the Difficulty toolbox to update.
#if !mac
NotificationManager.instance.addNotification(
{
title: "Add Variation",
body: 'Added new variation "${pendingVariation.variation}"',
type: NotificationType.Success
});
#end
dialog.hideDialog(DialogButton.APPLY);
}
return dialog;
}
/**
* Builds and opens a dialog where the user can add a new difficulty for a song.
* @param state The current chart editor state.
* @param closable Whether the dialog can be closed by the user.
* @return The dialog that was opened.
*/
public static function openAddDifficultyDialog(state:ChartEditorState, closable:Bool = true):Dialog
{
var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_ADD_DIFFICULTY_LAYOUT, true, false);
if (dialog == null) throw 'Could not locate Add Difficulty dialog';
var difficultyForm:Null<Form> = dialog.findComponent('difficultyForm', Form);
if (difficultyForm == null) throw 'Could not locate difficultyForm Form in Add Difficulty dialog';
var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
if (buttonCancel == null) throw 'Could not locate dialogCancel button in Add Difficulty dialog';
buttonCancel.onClick = function(_event) {
dialog.hideDialog(DialogButton.CANCEL);
}
var buttonAdd:Null<Button> = dialog.findComponent('dialogAdd', Button);
if (buttonAdd == null) throw 'Could not locate dialogAdd button in Add Difficulty dialog';
buttonAdd.onClick = function(_event) {
// This performs validation before the onSubmit callback is called.
difficultyForm.submit();
}
var dialogVariation:Null<DropDown> = dialog.findComponent('dialogVariation', DropDown);
if (dialogVariation == null) throw 'Could not locate dialogVariation DropDown in Add Variation dialog';
dialogVariation.value = ChartEditorDropdowns.populateDropdownWithVariations(dialogVariation, state, true);
var labelScrollSpeed:Null<Label> = dialog.findComponent('labelScrollSpeed', Label);
if (labelScrollSpeed == null) throw 'Could not find labelScrollSpeed component.';
var inputScrollSpeed:Null<Slider> = dialog.findComponent('inputScrollSpeed', Slider);
if (inputScrollSpeed == null) throw 'Could not find inputScrollSpeed component.';
inputScrollSpeed.onChange = function(event:UIEvent) {
labelScrollSpeed.text = 'Scroll Speed: ${inputScrollSpeed.value}x';
};
inputScrollSpeed.value = state.currentSongChartScrollSpeed;
labelScrollSpeed.text = 'Scroll Speed: ${inputScrollSpeed.value}x';
difficultyForm.onSubmit = function(_event) {
trace('Add Difficulty dialog submitted, validation succeeded!');
var dialogDifficultyName:Null<TextField> = dialog.findComponent('dialogDifficultyName', TextField);
if (dialogDifficultyName == null) throw 'Could not locate dialogDifficultyName TextField in Add Difficulty dialog';
state.createDifficulty(dialogVariation.value.id, dialogDifficultyName.text.toLowerCase(), inputScrollSpeed.value ?? 1.0);
#if !mac
NotificationManager.instance.addNotification(
{
title: "Add Difficulty",
body: 'Added new difficulty "${dialogDifficultyName.text.toLowerCase()}"',
type: NotificationType.Success
});
#end
dialog.hideDialog(DialogButton.APPLY);
}
return dialog;
}
}

View File

@ -0,0 +1,129 @@
package funkin.ui.debug.charting;
import funkin.data.notestyle.NoteStyleRegistry;
import funkin.play.notes.notestyle.NoteStyle;
import funkin.play.stage.StageData;
import funkin.play.stage.StageData.StageDataParser;
import funkin.play.character.CharacterData;
import haxe.ui.components.DropDown;
import funkin.play.character.BaseCharacter.CharacterType;
import funkin.play.character.CharacterData.CharacterDataParser;
/**
* This class contains functions for populating dropdowns based on game data.
* These get used by both dialogs and toolboxes so they're in their own class to prevent "reaching over."
*/
@:nullSafety
@:access(ChartEditorState)
class ChartEditorDropdowns
{
public static function populateDropdownWithCharacters(dropDown:DropDown, charType:CharacterType, startingCharId:String):DropDownEntry
{
dropDown.dataSource.clear();
// TODO: Filter based on charType.
var charIds:Array<String> = CharacterDataParser.listCharacterIds();
var returnValue:DropDownEntry = switch (charType)
{
case BF: {id: "bf", text: "Boyfriend"};
case DAD: {id: "dad", text: "Daddy Dearest"};
default: {
dropDown.dataSource.add({id: "none", text: ""});
{id: "none", text: "None"};
}
}
for (charId in charIds)
{
var character:Null<CharacterData> = CharacterDataParser.fetchCharacterData(charId);
if (character == null) continue;
var value = {id: charId, text: character.name};
if (startingCharId == charId) returnValue = value;
dropDown.dataSource.add(value);
}
dropDown.dataSource.sort('text', ASCENDING);
return returnValue;
}
public static function populateDropdownWithStages(dropDown:DropDown, startingStageId:String):DropDownEntry
{
dropDown.dataSource.clear();
var stageIds:Array<String> = StageDataParser.listStageIds();
var returnValue:DropDownEntry = {id: "mainStage", text: "Main Stage"};
for (stageId in stageIds)
{
var stage:Null<StageData> = StageDataParser.parseStageData(stageId);
if (stage == null) continue;
var value = {id: stageId, text: stage.name};
if (startingStageId == stageId) returnValue = value;
dropDown.dataSource.add(value);
}
dropDown.dataSource.sort('text', ASCENDING);
return returnValue;
}
public static function populateDropdownWithNoteStyles(dropDown:DropDown, startingStyleId:String):DropDownEntry
{
dropDown.dataSource.clear();
var noteStyleIds:Array<String> = NoteStyleRegistry.instance.listEntryIds();
var returnValue:DropDownEntry = {id: "funkin", text: "Funkin'"};
for (noteStyleId in noteStyleIds)
{
var noteStyle:Null<NoteStyle> = NoteStyleRegistry.instance.fetchEntry(noteStyleId);
if (noteStyle == null) continue;
var value = {id: noteStyleId, text: noteStyle.getName()};
if (startingStyleId == noteStyleId) returnValue = value;
dropDown.dataSource.add(value);
}
dropDown.dataSource.sort('text', ASCENDING);
return returnValue;
}
public static function populateDropdownWithVariations(dropDown:DropDown, state:ChartEditorState, includeNone:Bool = true):DropDownEntry
{
dropDown.dataSource.clear();
var variationIds:Array<String> = state.availableVariations;
if (includeNone)
{
dropDown.dataSource.add({id: "none", text: ""});
}
var returnValue:DropDownEntry = includeNone ? ({id: "none", text: ""}) : ({id: "default", text: "Default"});
for (variationId in variationIds)
{
dropDown.dataSource.add({id: variationId, text: variationId.toTitleCase()});
}
dropDown.dataSource.sort('text', ASCENDING);
return returnValue;
}
}
typedef DropDownEntry =
{
id:String,
text:String
};

View File

@ -0,0 +1,195 @@
package funkin.ui.debug.charting;
import haxe.ui.notifications.NotificationType;
import funkin.util.DateUtil;
import haxe.io.Path;
import funkin.util.SerializerUtil;
import haxe.ui.notifications.NotificationManager;
import funkin.util.FileUtil;
import funkin.util.FileUtil;
import funkin.play.song.Song;
import funkin.data.song.SongData.SongChartData;
import funkin.data.song.SongData.SongMetadata;
import funkin.data.song.SongRegistry;
/**
* Contains functions for importing, loading, saving, and exporting charts.
*/
@:nullSafety
@:allow(funkin.ui.debug.charting.ChartEditorState)
class ChartEditorImportExportHandler
{
/**
* Fetch's a song's existing chart and audio and loads it, replacing the current song.
*/
public static function loadSongAsTemplate(state:ChartEditorState, songId:String):Void
{
var song:Null<Song> = SongRegistry.instance.fetchEntry(songId);
if (song == null) return;
// Load the song metadata.
var rawSongMetadata:Array<SongMetadata> = song.getRawMetadata();
var songMetadata:Map<String, SongMetadata> = [];
var songChartData:Map<String, SongChartData> = [];
for (metadata in rawSongMetadata)
{
if (metadata == null) continue;
var variation = (metadata.variation == null || metadata.variation == '') ? 'default' : metadata.variation;
// Clone to prevent modifying the original.
var metadataClone:SongMetadata = metadata.clone(variation);
if (metadataClone != null) songMetadata.set(variation, metadataClone);
var chartData:Null<SongChartData> = SongRegistry.instance.parseEntryChartData(songId, metadata.variation);
if (chartData != null) songChartData.set(variation, chartData);
}
loadSong(state, songMetadata, songChartData);
state.sortChartData();
state.clearVocals();
ChartEditorAudioHandler.loadInstrumentalFromAsset(state, Paths.inst(songId));
var diff:Null<SongDifficulty> = song.getDifficulty(state.selectedDifficulty);
var voiceList:Array<String> = diff != null ? diff.buildVoiceList() : [];
if (voiceList.length == 2)
{
ChartEditorAudioHandler.loadVocalsFromAsset(state, voiceList[0], BF);
ChartEditorAudioHandler.loadVocalsFromAsset(state, voiceList[1], DAD);
}
else
{
for (voicePath in voiceList)
{
ChartEditorAudioHandler.loadVocalsFromAsset(state, voicePath);
}
}
state.refreshMetadataToolbox();
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Success',
body: 'Loaded song (${rawSongMetadata[0].songName})',
type: NotificationType.Success,
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
});
#end
}
/**
* Loads song metadata and chart data into the editor.
* @param newSongMetadata The song metadata to load.
* @param newSongChartData The song chart data to load.
*/
public static function loadSong(state:ChartEditorState, newSongMetadata:Map<String, SongMetadata>, newSongChartData:Map<String, SongChartData>):Void
{
state.songMetadata = newSongMetadata;
state.songChartData = newSongChartData;
Conductor.forceBPM(null); // Disable the forced BPM.
Conductor.mapTimeChanges(state.currentSongMetadata.timeChanges);
state.notePreviewDirty = true;
state.notePreviewViewportBoundsDirty = true;
state.difficultySelectDirty = true;
state.opponentPreviewDirty = true;
state.playerPreviewDirty = true;
// Remove instrumental and vocal tracks, they will be loaded next.
if (state.audioInstTrack != null)
{
state.audioInstTrack.stop();
state.audioInstTrack = null;
}
if (state.audioVocalTrackGroup != null)
{
state.audioVocalTrackGroup.stop();
state.audioVocalTrackGroup.clear();
}
}
/**
* @param force Whether to force the export without prompting the user for a file location.
* @param tmp If true, save to the temporary directory instead of the local `backup` directory.
*/
public static function exportAllSongData(state:ChartEditorState, force:Bool = false, tmp:Bool = false):Void
{
var zipEntries:Array<haxe.zip.Entry> = [];
for (variation in state.availableVariations)
{
var variationId:String = variation;
if (variation == '' || variation == 'default' || variation == 'normal')
{
variationId = '';
}
if (variationId == '')
{
var variationMetadata:Null<SongMetadata> = state.songMetadata.get(variation);
if (variationMetadata != null) zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-metadata.json', SerializerUtil.toJSON(variationMetadata)));
var variationChart:Null<SongChartData> = state.songChartData.get(variation);
if (variationChart != null) zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-chart.json', SerializerUtil.toJSON(variationChart)));
}
else
{
var variationMetadata:Null<SongMetadata> = state.songMetadata.get(variation);
if (variationMetadata != null) zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-metadata-$variationId.json',
SerializerUtil.toJSON(variationMetadata)));
var variationChart:Null<SongChartData> = state.songChartData.get(variation);
if (variationChart != null) zipEntries.push(FileUtil.makeZIPEntry('${state.currentSongId}-chart-$variationId.json',
SerializerUtil.toJSON(variationChart)));
}
}
if (state.audioInstTrackData != null) zipEntries.push(FileUtil.makeZIPEntryFromBytes('Inst.ogg', state.audioInstTrackData));
for (charId in state.audioVocalTrackData.keys())
{
var entryData = state.audioVocalTrackData.get(charId);
if (entryData == null) continue;
zipEntries.push(FileUtil.makeZIPEntryFromBytes('Vocals-$charId.ogg', entryData));
}
trace('Exporting ${zipEntries.length} files to ZIP...');
if (force)
{
var targetPath:String = if (tmp)
{
Path.join([
FileUtil.getTempDir(),
'chart-editor-exit-${DateUtil.generateTimestamp()}.${Constants.EXT_CHART}'
]);
}
else
{
Path.join([
'./backups/',
'chart-editor-exit-${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;
}
// Prompt and save.
var onSave:Array<String>->Void = function(paths:Array<String>) {
trace('Successfully exported files.');
};
var onCancel:Void->Void = function() {
trace('Export cancelled.');
};
FileUtil.saveMultipleFiles(zipEntries, onSave, onCancel, '${state.currentSongId}-chart.${Constants.EXT_CHART}');
}
}

View File

@ -19,7 +19,7 @@ class ChartEditorNoteSprite extends FlxSprite
/**
* The list of available note skin to validate against.
*/
public static final NOTE_STYLES:Array<String> = ['Normal', 'Pixel'];
public static final NOTE_STYLES:Array<String> = ['funkin', 'pixel'];
/**
* The ChartEditorState this note belongs to.
@ -54,20 +54,20 @@ class ChartEditorNoteSprite extends FlxSprite
// Initialize all the animations, not just the one we're going to use immediately,
// so that later we can reuse the sprite without having to initialize more animations during scrolling.
this.animation.addByPrefix('tapLeftNormal', 'purple instance');
this.animation.addByPrefix('tapDownNormal', 'blue instance');
this.animation.addByPrefix('tapUpNormal', 'green instance');
this.animation.addByPrefix('tapRightNormal', 'red instance');
this.animation.addByPrefix('tapLeftFunkin', 'purple instance');
this.animation.addByPrefix('tapDownFunkin', 'blue instance');
this.animation.addByPrefix('tapUpFunkin', 'green instance');
this.animation.addByPrefix('tapRightFunkin', 'red instance');
this.animation.addByPrefix('holdLeftNormal', 'LeftHoldPiece');
this.animation.addByPrefix('holdDownNormal', 'DownHoldPiece');
this.animation.addByPrefix('holdUpNormal', 'UpHoldPiece');
this.animation.addByPrefix('holdRightNormal', 'RightHoldPiece');
this.animation.addByPrefix('holdLeftFunkin', 'LeftHoldPiece');
this.animation.addByPrefix('holdDownFunkin', 'DownHoldPiece');
this.animation.addByPrefix('holdUpFunkin', 'UpHoldPiece');
this.animation.addByPrefix('holdRightFunkin', 'RightHoldPiece');
this.animation.addByPrefix('holdEndLeftNormal', 'LeftHoldEnd');
this.animation.addByPrefix('holdEndDownNormal', 'DownHoldEnd');
this.animation.addByPrefix('holdEndUpNormal', 'UpHoldEnd');
this.animation.addByPrefix('holdEndRightNormal', 'RightHoldEnd');
this.animation.addByPrefix('holdEndLeftFunkin', 'LeftHoldEnd');
this.animation.addByPrefix('holdEndDownFunkin', 'DownHoldEnd');
this.animation.addByPrefix('holdEndUpFunkin', 'UpHoldEnd');
this.animation.addByPrefix('holdEndRightFunkin', 'RightHoldEnd');
this.animation.addByPrefix('tapLeftPixel', 'pixel4');
this.animation.addByPrefix('tapDownPixel', 'pixel5');
@ -187,8 +187,8 @@ class ChartEditorNoteSprite extends FlxSprite
function get_noteStyle():String
{
// Fall back to 'Normal' if it's not a valid note style.
return if (NOTE_STYLES.contains(this.parentState.currentSongNoteSkin)) this.parentState.currentSongNoteSkin else 'Normal';
// Fall back to Funkin' if it's not a valid note style.
return if (NOTE_STYLES.contains(this.parentState.currentSongNoteStyle)) this.parentState.currentSongNoteStyle else 'funkin';
}
public function playNoteAnimation():Void
@ -199,7 +199,7 @@ class ChartEditorNoteSprite extends FlxSprite
var baseAnimationName:String = 'tap';
// Play the appropriate animation for the type, direction, and skin.
var animationName:String = '${baseAnimationName}${this.noteData.getDirectionName()}${this.noteStyle}';
var animationName:String = '${baseAnimationName}${this.noteData.getDirectionName()}${this.noteStyle.toTitleCase()}';
this.animation.play(animationName);
@ -213,7 +213,7 @@ class ChartEditorNoteSprite extends FlxSprite
this.updateHitbox();
// TODO: Make this an attribute of the note skin.
this.antialiasing = (this.parentState.currentSongNoteSkin != 'Pixel');
this.antialiasing = (this.parentState.currentSongNoteStyle != 'Pixel');
}
/**

View File

@ -1,5 +1,8 @@
package funkin.ui.debug.charting;
import funkin.play.stage.StageData;
import funkin.play.character.CharacterData.CharacterDataParser;
import funkin.play.character.CharacterData;
import flixel.system.FlxAssets.FlxSoundAsset;
import flixel.math.FlxMath;
import haxe.ui.components.TextField;
@ -41,7 +44,7 @@ import funkin.data.song.SongRegistry;
import funkin.data.song.SongData.SongEventData;
import funkin.data.song.SongData.SongMetadata;
import funkin.data.song.SongData.SongNoteData;
import funkin.data.song.SongData.SongPlayableChar;
import funkin.data.song.SongData.SongCharacterData;
import funkin.data.song.SongDataUtils;
import funkin.ui.debug.charting.ChartEditorCommand;
import funkin.ui.debug.charting.ChartEditorCommand;
@ -88,8 +91,11 @@ using Lambda;
// @:nullSafety(Loose) // Enable this while developing, then disable to keep unit tests functional!
@:allow(funkin.ui.debug.charting.ChartEditorCommand)
@:allow(funkin.ui.debug.charting.ChartEditorDropdowns)
@:allow(funkin.ui.debug.charting.ChartEditorDialogHandler)
@:allow(funkin.ui.debug.charting.ChartEditorThemeHandler)
@:allow(funkin.ui.debug.charting.ChartEditorAudioHandler)
@:allow(funkin.ui.debug.charting.ChartEditorImportExportHandler)
@:allow(funkin.ui.debug.charting.ChartEditorToolboxHandler)
class ChartEditorState extends HaxeUIState
{
@ -108,7 +114,6 @@ class ChartEditorState extends HaxeUIState
static final CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/eventdata');
static final CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/metadata');
static final CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT:String = Paths.ui('chart-editor/toolbox/difficulty');
static final CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT:String = Paths.ui('chart-editor/toolbox/characters');
static final CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:String = Paths.ui('chart-editor/toolbox/player-preview');
static final CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:String = Paths.ui('chart-editor/toolbox/opponent-preview');
@ -751,6 +756,11 @@ class ChartEditorState extends HaxeUIState
*/
// ==============================
/**
* The chill audio track that plays when you open the Chart Editor.
*/
public var welcomeMusic:FlxSound = new FlxSound();
/**
* The audio track for the instrumental.
* `null` until an instrumental track is loaded.
@ -950,19 +960,19 @@ class ChartEditorState extends HaxeUIState
return currentSongChartData.events = value;
}
public var currentSongNoteSkin(get, set):String;
public var currentSongNoteStyle(get, set):String;
function get_currentSongNoteSkin():String
function get_currentSongNoteStyle():String
{
if (currentSongMetadata.playData.noteSkin == null)
{
// Initialize to the default value if not set.
currentSongMetadata.playData.noteSkin = 'Normal';
currentSongMetadata.playData.noteSkin = 'funkin';
}
return currentSongMetadata.playData.noteSkin;
}
function set_currentSongNoteSkin(value:String):String
function set_currentSongNoteStyle(value:String):String
{
return currentSongMetadata.playData.noteSkin = value;
}
@ -1025,57 +1035,28 @@ class ChartEditorState extends HaxeUIState
return currentSongMetadata.artist = value;
}
var currentSongPlayableCharacters(get, never):Array<String>;
function get_currentSongPlayableCharacters():Array<String>
{
return currentSongMetadata.playData.playableChars.keys().array();
}
var currentSongCharacterPlayer(get, set):String;
function get_currentSongCharacterPlayer():String
{
// Validate selected character before returning it.
if (!currentSongPlayableCharacters.contains(selectedCharacter))
{
trace('Invalid character selected: ' + selectedCharacter);
selectedCharacter = currentSongPlayableCharacters[0];
}
return selectedCharacter;
return currentSongMetadata.playData.characters.player;
}
function set_currentSongCharacterPlayer(value:String):String
{
if (!currentSongPlayableCharacters.contains(value))
{
trace('Invalid character selected: ' + value);
return value;
}
return selectedCharacter = value;
return currentSongMetadata.playData.characters.player = value;
}
var currentSongCharacterOpponent(get, set):String;
function get_currentSongCharacterOpponent():String
{
// Validate selected character before returning it.
if (!currentSongPlayableCharacters.contains(selectedCharacter))
{
trace('Invalid character selected: ' + selectedCharacter);
selectedCharacter = currentSongPlayableCharacters[0];
}
var playableCharData:SongPlayableChar = currentSongMetadata.playData.playableChars.get(selectedCharacter);
return playableCharData.opponent;
return currentSongMetadata.playData.characters.opponent;
}
function set_currentSongCharacterOpponent(value:String):String
{
var playableCharData:SongPlayableChar = currentSongMetadata.playData.playableChars.get(selectedCharacter);
return playableCharData.opponent = value;
return currentSongMetadata.playData.characters.opponent = value;
}
/**
@ -1249,6 +1230,9 @@ class ChartEditorState extends HaxeUIState
// Get rid of any music from the previous state.
FlxG.sound.music.stop();
// Play the welcome music.
setupWelcomeMusic();
buildDefaultSongData();
buildBackground();
@ -1273,6 +1257,26 @@ class ChartEditorState extends HaxeUIState
ChartEditorDialogHandler.openWelcomeDialog(this, false);
}
function setupWelcomeMusic()
{
this.welcomeMusic.loadEmbedded(Paths.music('chartEditorLoop/chartEditorLoop'));
this.welcomeMusic.looped = true;
// this.welcomeMusic.play();
// fadeInWelcomeMusic();
}
public function fadeInWelcomeMusic():Void
{
this.welcomeMusic.play();
this.welcomeMusic.fadeIn(4, 0, 1.0);
}
public function stopWelcomeMusic():Void
{
// this.welcomeMusic.fadeOut(4, 0);
this.welcomeMusic.pause();
}
function buildDefaultSongData():Void
{
selectedVariation = Constants.DEFAULT_VARIATION;
@ -1602,7 +1606,7 @@ class ChartEditorState extends HaxeUIState
addUIClickListener('menubarItemNewChart', _ -> ChartEditorDialogHandler.openWelcomeDialog(this, true));
addUIClickListener('menubarItemOpenChart', _ -> ChartEditorDialogHandler.openBrowseWizard(this, true));
addUIClickListener('menubarItemSaveChartAs', _ -> exportAllSongData());
addUIClickListener('menubarItemSaveChartAs', _ -> ChartEditorImportExportHandler.exportAllSongData(this));
addUIClickListener('menubarItemLoadInst', _ -> ChartEditorDialogHandler.openUploadInstDialog(this, true));
addUIClickListener('menubarItemImportChart', _ -> ChartEditorDialogHandler.openImportChartDialog(this, 'legacy', true));
@ -1738,18 +1742,14 @@ class ChartEditorState extends HaxeUIState
});
}
addUIChangeListener('menubarItemToggleToolboxTools',
event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT, event.value));
addUIChangeListener('menubarItemToggleToolboxNotes',
event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT, event.value));
addUIChangeListener('menubarItemToggleToolboxEvents',
event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT, event.value));
addUIChangeListener('menubarItemToggleToolboxDifficulty',
event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT, event.value));
addUIChangeListener('menubarItemToggleToolboxMetadata',
event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_METADATA_LAYOUT, event.value));
addUIChangeListener('menubarItemToggleToolboxCharacters',
event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT, event.value));
addUIChangeListener('menubarItemToggleToolboxNotes',
event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT, event.value));
addUIChangeListener('menubarItemToggleToolboxEvents',
event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT, event.value));
addUIChangeListener('menubarItemToggleToolboxPlayerPreview',
event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT, event.value));
addUIChangeListener('menubarItemToggleToolboxOpponentPreview',
@ -1795,7 +1795,7 @@ class ChartEditorState extends HaxeUIState
// Auto-save to local storage.
#else
// Auto-save to temp file.
exportAllSongData(true, true);
ChartEditorImportExportHandler.exportAllSongData(this, true, true);
#end
}
@ -1806,7 +1806,7 @@ class ChartEditorState extends HaxeUIState
if (saveDataDirty)
{
exportAllSongData(true);
ChartEditorImportExportHandler.exportAllSongData(this, true);
}
}
@ -2407,13 +2407,20 @@ class ChartEditorState extends HaxeUIState
var dragLengthMs:Float = dragLengthSteps * Conductor.stepLengthMs;
var dragLengthPixels:Float = dragLengthSteps * GRID_SIZE;
gridGhostHoldNote.visible = true;
gridGhostHoldNote.noteData = gridGhostNote.noteData;
gridGhostHoldNote.noteDirection = gridGhostNote.noteData.getDirection();
if (dragLengthSteps > 0)
{
gridGhostHoldNote.visible = true;
gridGhostHoldNote.noteData = gridGhostNote.noteData;
gridGhostHoldNote.noteDirection = gridGhostNote.noteData.getDirection();
gridGhostHoldNote.setHeightDirectly(dragLengthPixels);
gridGhostHoldNote.setHeightDirectly(dragLengthPixels);
gridGhostHoldNote.updateHoldNotePosition(renderedHoldNotes);
gridGhostHoldNote.updateHoldNotePosition(renderedHoldNotes);
}
else
{
gridGhostHoldNote.visible = false;
}
if (FlxG.mouse.justReleased)
{
@ -3016,6 +3023,12 @@ class ChartEditorState extends HaxeUIState
ChartEditorDialogHandler.openBrowseWizard(this, true);
}
// CTRL + SHIFT + S = Save As
if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.SHIFT && FlxG.keys.justPressed.S)
{
ChartEditorImportExportHandler.exportAllSongData(this, false);
}
// CTRL + Q = Quit to Menu
if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.Q)
{
@ -3167,7 +3180,7 @@ class ChartEditorState extends HaxeUIState
selectedDifficulty = prevDifficulty;
refreshDifficultyTreeSelection();
refreshSongMetadataToolbox();
refreshMetadataToolbox();
}
else
{
@ -3176,7 +3189,7 @@ class ChartEditorState extends HaxeUIState
selectedDifficulty = prevDifficulty;
refreshDifficultyTreeSelection();
refreshSongMetadataToolbox();
refreshMetadataToolbox();
}
}
else
@ -3195,7 +3208,7 @@ class ChartEditorState extends HaxeUIState
selectedDifficulty = nextDifficulty;
refreshDifficultyTreeSelection();
refreshSongMetadataToolbox();
refreshMetadataToolbox();
}
else
{
@ -3204,7 +3217,7 @@ class ChartEditorState extends HaxeUIState
selectedDifficulty = nextDifficulty;
refreshDifficultyTreeSelection();
refreshSongMetadataToolbox();
refreshMetadataToolbox();
}
}
@ -3296,6 +3309,28 @@ class ChartEditorState extends HaxeUIState
}
}
public function createDifficulty(variation:String, difficulty:String, scrollSpeed:Float = 1.0)
{
var variationMetadata:Null<SongMetadata> = songMetadata.get(variation);
if (variationMetadata == null) return;
variationMetadata.playData.difficulties.push(difficulty);
var resultChartData = songChartData.get(variation);
if (resultChartData == null)
{
resultChartData = new SongChartData([difficulty => scrollSpeed], [], [difficulty => []]);
songChartData.set(variation, resultChartData);
}
else
{
resultChartData.scrollSpeed.set(difficulty, scrollSpeed);
resultChartData.notes.set(difficulty, []);
}
difficultySelectDirty = true; // Force the Difficulty toolbox to update.
}
function refreshDifficultyTreeSelection(?treeView:TreeView):Void
{
if (treeView == null)
@ -3469,7 +3504,7 @@ class ChartEditorState extends HaxeUIState
selectedVariation = variation;
selectedDifficulty = difficulty;
// refreshDifficultyTreeSelection(treeView);
refreshSongMetadataToolbox();
refreshMetadataToolbox();
}
// case 'song':
// case 'variation':
@ -3478,14 +3513,14 @@ class ChartEditorState extends HaxeUIState
trace('Selected wrong node type, resetting selection.');
var currentTreeDifficultyNode = getCurrentTreeDifficultyNode(treeView);
if (currentTreeDifficultyNode != null) treeView.selectedNode = currentTreeDifficultyNode;
refreshSongMetadataToolbox();
refreshMetadataToolbox();
}
}
/**
* When the difficulty changes, update the song metadata toolbox to reflect the new data.
*/
function refreshSongMetadataToolbox():Void
function refreshMetadataToolbox():Void
{
var toolbox:Null<CollapsibleDialog> = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
if (toolbox == null) return;
@ -3499,8 +3534,8 @@ class ChartEditorState extends HaxeUIState
var inputStage:Null<DropDown> = toolbox.findComponent('inputStage', DropDown);
if (inputStage != null) inputStage.value = currentSongMetadata.playData.stage;
var inputNoteSkin:Null<DropDown> = toolbox.findComponent('inputNoteSkin', DropDown);
if (inputNoteSkin != null) inputNoteSkin.value = currentSongMetadata.playData.noteSkin;
var inputNoteStyle:Null<DropDown> = toolbox.findComponent('inputNoteStyle', DropDown);
if (inputNoteStyle != null) inputNoteStyle.value = currentSongMetadata.playData.noteSkin;
var inputBPM:Null<NumberStepper> = toolbox.findComponent('inputBPM', NumberStepper);
if (inputBPM != null) inputBPM.value = currentSongMetadata.timeChanges[0].bpm;
@ -3515,16 +3550,54 @@ class ChartEditorState extends HaxeUIState
if (frameVariation != null) frameVariation.text = 'Variation: ${selectedVariation.toTitleCase()}';
var frameDifficulty:Null<Frame> = toolbox.findComponent('frameDifficulty', Frame);
if (frameDifficulty != null) frameDifficulty.text = 'Difficulty: ${selectedDifficulty.toTitleCase()}';
}
function addDifficulty(variation:String):Void {}
var inputStage:Null<DropDown> = toolbox.findComponent('inputStage', DropDown);
var stageId:String = currentSongMetadata.playData.stage;
var stageData:Null<StageData> = StageDataParser.parseStageData(stageId);
if (stageData != null)
{
inputStage.value = {id: stageId, text: stageData.name};
}
else
{
inputStage.value = {id: "mainStage", text: "Main Stage"};
}
function addVariation(variationId:String):Void
{
// Create a new variation with the specified ID.
songMetadata.set(variationId, currentSongMetadata.clone(variationId));
// Switch to the new variation.
selectedVariation = variationId;
var inputCharacterPlayer:Null<DropDown> = toolbox.findComponent('inputCharacterPlayer', DropDown);
var charIdPlayer:String = currentSongMetadata.playData.characters.player;
var charDataPlayer:Null<CharacterData> = CharacterDataParser.fetchCharacterData(charIdPlayer);
if (charDataPlayer != null)
{
inputCharacterPlayer.value = {id: charIdPlayer, text: charDataPlayer.name};
}
else
{
inputCharacterPlayer.value = {id: "bf", text: "Boyfriend"};
}
var inputCharacterOpponent:Null<DropDown> = toolbox.findComponent('inputCharacterOpponent', DropDown);
var charIdOpponent:String = currentSongMetadata.playData.characters.opponent;
var charDataOpponent:Null<CharacterData> = CharacterDataParser.fetchCharacterData(charIdOpponent);
if (charDataOpponent != null)
{
inputCharacterOpponent.value = {id: charIdOpponent, text: charDataOpponent.name};
}
else
{
inputCharacterOpponent.value = {id: "dad", text: "Dad"};
}
var inputCharacterGirlfriend:Null<DropDown> = toolbox.findComponent('inputCharacterGirlfriend', DropDown);
var charIdGirlfriend:String = currentSongMetadata.playData.characters.girlfriend;
var charDataGirlfriend:Null<CharacterData> = CharacterDataParser.fetchCharacterData(charIdGirlfriend);
if (charDataGirlfriend != null)
{
inputCharacterGirlfriend.value = {id: charIdGirlfriend, text: charDataGirlfriend.name};
}
else
{
inputCharacterGirlfriend.value = {id: "none", text: "None"};
}
}
/**
@ -3710,9 +3783,9 @@ class ChartEditorState extends HaxeUIState
switch (noteData.getStrumlineIndex())
{
case 0: // Player
if (hitsoundsEnabledPlayer) playSound(Paths.sound('funnyNoise/funnyNoise-09'));
if (hitsoundsEnabledPlayer) ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-09'));
case 1: // Opponent
if (hitsoundsEnabledOpponent) playSound(Paths.sound('funnyNoise/funnyNoise-010'));
if (hitsoundsEnabledOpponent) ChartEditorAudioHandler.playSound(Paths.sound('funnyNoise/funnyNoise-010'));
}
}
}
@ -3913,77 +3986,6 @@ class ChartEditorState extends HaxeUIState
Conductor.update(targetPos);
}
/**
* Loads an instrumental from an absolute file path, replacing the current instrumental.
*
* @param path The absolute path to the audio file.
*
* @return Success or failure.
*/
public function loadInstrumentalFromPath(path:Path):Bool
{
#if sys
// Validate file extension.
if (path.ext != null && !SUPPORTED_MUSIC_FORMATS.contains(path.ext))
{
return false;
}
var fileBytes:haxe.io.Bytes = sys.io.File.getBytes(path.toString());
return loadInstrumentalFromBytes(fileBytes, '${path.file}.${path.ext}');
#else
trace("[WARN] This platform can't load audio from a file path, you'll need to fetch the bytes some other way.");
return false;
#end
}
/**
* Loads an instrumental from audio byte data, replacing the current instrumental.
* @param bytes The audio byte data.
* @param fileName The name of the file, if available. Used for notifications.
* @return Success or failure.
*/
public function loadInstrumentalFromBytes(bytes:haxe.io.Bytes, fileName:String = null):Bool
{
if (bytes == null)
{
return false;
}
var openflSound:openfl.media.Sound = new openfl.media.Sound();
openflSound.loadCompressedDataFromByteArray(openfl.utils.ByteArray.fromBytes(bytes), bytes.length);
audioInstTrack = FlxG.sound.load(openflSound, 1.0, false);
audioInstTrack.autoDestroy = false;
audioInstTrack.pause();
audioInstTrackData = bytes;
postLoadInstrumental();
return true;
}
/**
* Loads an instrumental from an OpenFL asset, replacing the current instrumental.
* @param path The path to the asset. Use `Paths` to build this.
* @return Success or failure.
*/
public function loadInstrumentalFromAsset(path:String):Bool
{
var instTrack:FlxSound = FlxG.sound.load(path, 1.0, false);
if (instTrack != null)
{
audioInstTrack = instTrack;
audioInstTrackData = Assets.getBytes(path);
postLoadInstrumental();
return true;
}
return false;
}
public function postLoadInstrumental():Void
{
if (audioInstTrack != null)
@ -4014,23 +4016,6 @@ class ChartEditorState extends HaxeUIState
moveSongToScrollPosition();
}
/**
* Loads a vocal track from an absolute file path.
* @param path The absolute path to the audio file.
* @param charKey The character to load the vocal track for.
* @return Success or failure.
*/
public function loadVocalsFromPath(path:Path, charKey:String = 'default'):Bool
{
#if sys
var fileBytes:haxe.io.Bytes = sys.io.File.getBytes(path.toString());
return loadVocalsFromBytes(fileBytes, charKey);
#else
trace("[WARN] This platform can't load audio from a file path, you'll need to fetch the bytes some other way.");
return false;
#end
}
/**
* Clear the voices group.
*/
@ -4039,141 +4024,6 @@ class ChartEditorState extends HaxeUIState
if (audioVocalTrackGroup != null) audioVocalTrackGroup.clear();
}
/**
* Load a vocal track for a given song and character and add it to the voices group.
*
* @param path ID of the asset.
* @param charKey Character to load the vocal track for.
* @return Success or failure.
*/
public function loadVocalsFromAsset(path:String, charType:CharacterType = OTHER):Bool
{
var vocalTrack:FlxSound = FlxG.sound.load(path, 1.0, false);
if (vocalTrack != null)
{
switch (charType)
{
case CharacterType.BF:
if (audioVocalTrackGroup != null) audioVocalTrackGroup.addPlayerVoice(vocalTrack);
audioVocalTrackData.set(currentSongCharacterPlayer, Assets.getBytes(path));
case CharacterType.DAD:
if (audioVocalTrackGroup != null) audioVocalTrackGroup.addOpponentVoice(vocalTrack);
audioVocalTrackData.set(currentSongCharacterOpponent, Assets.getBytes(path));
default:
if (audioVocalTrackGroup != null) audioVocalTrackGroup.add(vocalTrack);
audioVocalTrackData.set('default', Assets.getBytes(path));
}
return true;
}
return false;
}
/**
* Loads a vocal track from audio byte data.
*/
public function loadVocalsFromBytes(bytes:haxe.io.Bytes, charKey:String = ''):Bool
{
var openflSound:openfl.media.Sound = new openfl.media.Sound();
openflSound.loadCompressedDataFromByteArray(openfl.utils.ByteArray.fromBytes(bytes), bytes.length);
var vocalTrack:FlxSound = FlxG.sound.load(openflSound, 1.0, false);
if (audioVocalTrackGroup != null) audioVocalTrackGroup.add(vocalTrack);
audioVocalTrackData.set(charKey, bytes);
return true;
}
/**
* Fetch's a song's existing chart and audio and loads it, replacing the current song.
*/
public function loadSongAsTemplate(songId:String):Void
{
var song:Null<Song> = SongRegistry.instance.fetchEntry(songId);
if (song == null) return;
// Load the song metadata.
var rawSongMetadata:Array<SongMetadata> = song.getRawMetadata();
var songMetadata:Map<String, SongMetadata> = [];
var songChartData:Map<String, SongChartData> = [];
for (metadata in rawSongMetadata)
{
if (metadata == null) continue;
var variation = (metadata.variation == null || metadata.variation == '') ? 'default' : metadata.variation;
// Clone to prevent modifying the original.
var metadataClone:SongMetadata = metadata.clone(variation);
if (metadataClone != null) songMetadata.set(variation, metadataClone);
songChartData.set(variation, SongRegistry.instance.parseEntryChartData(songId, metadata.variation));
}
loadSong(songMetadata, songChartData);
sortChartData();
clearVocals();
loadInstrumentalFromAsset(Paths.inst(songId));
var diff:Null<SongDifficulty> = song.getDifficulty(selectedDifficulty);
var voiceList:Array<String> = diff != null ? diff.buildVoiceList(currentSongCharacterPlayer) : [];
if (voiceList.length == 2)
{
loadVocalsFromAsset(voiceList[0], BF);
loadVocalsFromAsset(voiceList[1], DAD);
}
else
{
for (voicePath in voiceList)
{
loadVocalsFromAsset(voicePath);
}
}
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Success',
body: 'Loaded song (${rawSongMetadata[0].songName})',
type: NotificationType.Success,
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
});
#end
}
/**
* Loads song metadata and chart data into the editor.
* @param newSongMetadata The song metadata to load.
* @param newSongChartData The song chart data to load.
*/
public function loadSong(newSongMetadata:Map<String, SongMetadata>, newSongChartData:Map<String, SongChartData>):Void
{
this.songMetadata = newSongMetadata;
this.songChartData = newSongChartData;
Conductor.forceBPM(null); // Disable the forced BPM.
Conductor.mapTimeChanges(currentSongMetadata.timeChanges);
notePreviewDirty = true;
notePreviewViewportBoundsDirty = true;
difficultySelectDirty = true;
opponentPreviewDirty = true;
playerPreviewDirty = true;
// Remove instrumental and vocal tracks, they will be loaded next.
if (audioInstTrack != null)
{
audioInstTrack.stop();
audioInstTrack = null;
}
if (audioVocalTrackGroup != null)
{
audioVocalTrackGroup.stop();
audioVocalTrackGroup.clear();
}
}
/**
* When setting the scroll position, except when automatically scrolling during song playback,
* we need to update the conductor's current step time and the timestamp of the audio tracks.
@ -4291,7 +4141,7 @@ class ChartEditorState extends HaxeUIState
function playMetronomeTick(high:Bool = false):Void
{
playSound(Paths.sound('pianoStuff/piano-${high ? '001' : '008'}'));
ChartEditorAudioHandler.playSound(Paths.sound('pianoStuff/piano-${high ? '001' : '008'}'));
}
function isNoteSelected(note:Null<SongNoteData>):Bool
@ -4304,27 +4154,6 @@ class ChartEditorState extends HaxeUIState
return event != null && currentEventSelection.indexOf(event) != -1;
}
/**
* Play a sound effect.
* Automatically cleans up after itself and recycles previous FlxSound instances if available, for performance.
*/
function playSound(path:String):Void
{
var snd:FlxSound = FlxG.sound.list.recycle(FlxSound) ?? new FlxSound();
var asset:Null<FlxSoundAsset> = FlxG.sound.cache(path);
if (asset == null)
{
trace('WARN: Failed to play sound $path, asset not found.');
return;
}
snd.loadEmbedded(asset);
snd.autoDestroy = true;
FlxG.sound.list.add(snd);
snd.play();
}
override function destroy():Void
{
super.destroy();
@ -4345,78 +4174,6 @@ class ChartEditorState extends HaxeUIState
{
NotificationManager.instance.clearNotifications();
}
/**
* @param force Whether to force the export without prompting the user for a file location.
* @param tmp If true, save to the temporary directory instead of the local `backup` directory.
*/
public function exportAllSongData(force:Bool = false, tmp:Bool = false):Void
{
var zipEntries:Array<haxe.zip.Entry> = [];
for (variation in availableVariations)
{
var variationId:String = variation;
if (variation == '' || variation == 'default' || variation == 'normal')
{
variationId = '';
}
if (variationId == '')
{
var variationMetadata:Null<SongMetadata> = songMetadata.get(variation);
if (variationMetadata != null) zipEntries.push(FileUtil.makeZIPEntry('$currentSongId-metadata.json', SerializerUtil.toJSON(variationMetadata)));
var variationChart:Null<SongChartData> = songChartData.get(variation);
if (variationChart != null) zipEntries.push(FileUtil.makeZIPEntry('$currentSongId-chart.json', SerializerUtil.toJSON(variationChart)));
}
else
{
var variationMetadata:Null<SongMetadata> = songMetadata.get(variation);
if (variationMetadata != null) zipEntries.push(FileUtil.makeZIPEntry('$currentSongId-metadata-$variationId.json',
SerializerUtil.toJSON(variationMetadata)));
var variationChart:Null<SongChartData> = songChartData.get(variation);
if (variationChart != null) zipEntries.push(FileUtil.makeZIPEntry('$currentSongId-chart-$variationId.json', SerializerUtil.toJSON(variationChart)));
}
}
if (audioInstTrackData != null) zipEntries.push(FileUtil.makeZIPEntryFromBytes('Inst.ogg', audioInstTrackData));
for (charId in audioVocalTrackData.keys())
{
var entryData = audioVocalTrackData.get(charId);
if (entryData == null) continue;
zipEntries.push(FileUtil.makeZIPEntryFromBytes('Vocals-$charId.ogg', entryData));
}
trace('Exporting ${zipEntries.length} files to ZIP...');
if (force)
{
var targetPath:String = if (tmp)
{
Path.join([FileUtil.getTempDir(), 'chart-editor-exit-${DateUtil.generateTimestamp()}.zip']);
}
else
{
Path.join(['./backups/', 'chart-editor-exit-${DateUtil.generateTimestamp()}.zip']);
}
// 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;
}
// Prompt and save.
var onSave:Array<String>->Void = function(paths:Array<String>) {
trace('Successfully exported files.');
};
var onCancel:Void->Void = function() {
trace('Export cancelled.');
};
FileUtil.saveMultipleFiles(zipEntries, onSave, onCancel, '$currentSongId-chart.zip');
}
}
enum LiveInputStyle

View File

@ -1,5 +1,10 @@
package funkin.ui.debug.charting;
import funkin.ui.haxeui.components.FunkinDropDown;
import funkin.play.stage.StageData.StageDataParser;
import funkin.play.stage.StageData;
import funkin.play.character.CharacterData;
import funkin.play.character.CharacterData.CharacterDataParser;
import haxe.ui.components.HorizontalSlider;
import haxe.ui.containers.TreeView;
import haxe.ui.containers.TreeViewNode;
@ -9,6 +14,7 @@ import funkin.data.event.SongEventData;
import funkin.data.song.SongData.SongTimeChange;
import funkin.play.song.SongSerializer;
import funkin.ui.haxeui.components.CharacterPlayer;
import funkin.util.FileUtil;
import haxe.ui.components.Button;
import haxe.ui.components.CheckBox;
import haxe.ui.components.DropDown;
@ -78,8 +84,6 @@ class ChartEditorToolboxHandler
onShowToolboxDifficulty(state, toolbox);
case ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:
onShowToolboxMetadata(state, toolbox);
case ChartEditorState.CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT:
onShowToolboxCharacters(state, toolbox);
case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:
onShowToolboxPlayerPreview(state, toolbox);
case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:
@ -117,8 +121,6 @@ class ChartEditorToolboxHandler
onHideToolboxDifficulty(state, toolbox);
case ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:
onHideToolboxMetadata(state, toolbox);
case ChartEditorState.CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT:
onHideToolboxCharacters(state, toolbox);
case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:
onHideToolboxPlayerPreview(state, toolbox);
case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:
@ -167,8 +169,6 @@ class ChartEditorToolboxHandler
toolbox = buildToolboxDifficultyLayout(state);
case ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT:
toolbox = buildToolboxMetadataLayout(state);
case ChartEditorState.CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT:
toolbox = buildToolboxCharactersLayout(state);
case ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT:
toolbox = buildToolboxPlayerPreviewLayout(state);
case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:
@ -445,14 +445,20 @@ class ChartEditorToolboxHandler
state.setUICheckboxSelected('menubarItemToggleToolboxDifficulty', false);
}
var difficultyToolboxAddVariation:Null<Button> = toolbox.findComponent('difficultyToolboxAddVariation', Button);
if (difficultyToolboxAddVariation == null)
throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxAddVariation component.';
var difficultyToolboxAddDifficulty:Null<Button> = toolbox.findComponent('difficultyToolboxAddDifficulty', Button);
if (difficultyToolboxAddDifficulty == null)
throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxAddDifficulty component.';
var difficultyToolboxSaveMetadata:Null<Button> = toolbox.findComponent('difficultyToolboxSaveMetadata', Button);
if (difficultyToolboxSaveMetadata == null)
throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxSaveMetadata component.';
var difficultyToolboxSaveChart:Null<Button> = toolbox.findComponent('difficultyToolboxSaveChart', Button);
if (difficultyToolboxSaveChart == null)
throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxSaveChart component.';
var difficultyToolboxSaveAll:Null<Button> = toolbox.findComponent('difficultyToolboxSaveAll', Button);
if (difficultyToolboxSaveAll == null) throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxSaveAll component.';
// var difficultyToolboxSaveAll:Null<Button> = toolbox.findComponent('difficultyToolboxSaveAll', Button);
// if (difficultyToolboxSaveAll == null) throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxSaveAll component.';
var difficultyToolboxLoadMetadata:Null<Button> = toolbox.findComponent('difficultyToolboxLoadMetadata', Button);
if (difficultyToolboxLoadMetadata == null)
throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxLoadMetadata component.';
@ -460,26 +466,32 @@ class ChartEditorToolboxHandler
if (difficultyToolboxLoadChart == null)
throw 'ChartEditorToolboxHandler.buildToolboxDifficultyLayout() - Could not find difficultyToolboxLoadChart component.';
difficultyToolboxSaveMetadata.onClick = function(event:UIEvent) {
SongSerializer.exportSongMetadata(state.currentSongMetadata, state.currentSongId);
difficultyToolboxAddVariation.onClick = function(_:UIEvent) {
ChartEditorDialogHandler.openAddVariationDialog(state, true);
};
difficultyToolboxSaveChart.onClick = function(event:UIEvent) {
SongSerializer.exportSongChartData(state.currentSongChartData, state.currentSongId);
difficultyToolboxAddDifficulty.onClick = function(_:UIEvent) {
ChartEditorDialogHandler.openAddDifficultyDialog(state, true);
};
difficultyToolboxSaveAll.onClick = function(event:UIEvent) {
state.exportAllSongData();
difficultyToolboxSaveMetadata.onClick = function(_:UIEvent) {
var vari:String = state.selectedVariation != Constants.DEFAULT_VARIATION ? '-${state.selectedVariation}' : '';
FileUtil.writeFileReference('${state.currentSongId}$vari-metadata.json', state.currentSongMetadata.serialize());
};
difficultyToolboxLoadMetadata.onClick = function(event:UIEvent) {
difficultyToolboxSaveChart.onClick = function(_:UIEvent) {
var vari:String = state.selectedVariation != Constants.DEFAULT_VARIATION ? '-${state.selectedVariation}' : '';
FileUtil.writeFileReference('${state.currentSongId}$vari-chart.json', state.currentSongChartData.serialize());
};
difficultyToolboxLoadMetadata.onClick = function(_:UIEvent) {
// Replace metadata for current variation.
SongSerializer.importSongMetadataAsync(function(songMetadata) {
state.currentSongMetadata = songMetadata;
});
};
difficultyToolboxLoadChart.onClick = function(event:UIEvent) {
difficultyToolboxLoadChart.onClick = function(_:UIEvent) {
// Replace chart data for current variation.
SongSerializer.importSongChartDataAsync(function(songChartData) {
state.currentSongChartData = songChartData;
@ -554,7 +566,7 @@ class ChartEditorToolboxHandler
};
inputSongArtist.value = state.currentSongMetadata.artist;
var inputStage:Null<DropDown> = toolbox.findComponent('inputStage', DropDown);
var inputStage:Null<FunkinDropDown> = toolbox.findComponent('inputStage', FunkinDropDown);
if (inputStage == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputStage component.';
inputStage.onChange = function(event:UIEvent) {
var valid:Bool = event.data != null && event.data.id != null;
@ -564,15 +576,48 @@ class ChartEditorToolboxHandler
state.currentSongMetadata.playData.stage = event.data.id;
}
};
inputStage.value = state.currentSongMetadata.playData.stage;
var startingValueStage = ChartEditorDropdowns.populateDropdownWithStages(inputStage, state.currentSongMetadata.playData.stage);
inputStage.value = startingValueStage;
var inputNoteSkin:Null<DropDown> = toolbox.findComponent('inputNoteSkin', DropDown);
if (inputNoteSkin == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputNoteSkin component.';
inputNoteSkin.onChange = function(event:UIEvent) {
if ((event?.data?.id ?? null) == null) return;
state.currentSongNoteSkin = event.data.id;
var inputNoteStyle:Null<FunkinDropDown> = toolbox.findComponent('inputNoteStyle', FunkinDropDown);
if (inputNoteStyle == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputNoteStyle component.';
inputNoteStyle.onChange = function(event:UIEvent) {
if (event.data?.id == null) return;
state.currentSongNoteStyle = event.data.id;
};
inputNoteSkin.value = state.currentSongNoteSkin;
inputNoteStyle.value = state.currentSongNoteStyle;
// By using this flag, we prevent the dropdown value from changing while it is being populated.
var inputCharacterPlayer:Null<FunkinDropDown> = toolbox.findComponent('inputCharacterPlayer', FunkinDropDown);
if (inputCharacterPlayer == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputCharacterPlayer component.';
inputCharacterPlayer.onChange = function(event:UIEvent) {
if (event.data?.id == null) return;
state.currentSongMetadata.playData.characters.player = event.data.id;
};
var startingValuePlayer = ChartEditorDropdowns.populateDropdownWithCharacters(inputCharacterPlayer, CharacterType.BF,
state.currentSongMetadata.playData.characters.player);
inputCharacterPlayer.value = startingValuePlayer;
var inputCharacterOpponent:Null<FunkinDropDown> = toolbox.findComponent('inputCharacterOpponent', FunkinDropDown);
if (inputCharacterOpponent == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputCharacterOpponent component.';
inputCharacterOpponent.onChange = function(event:UIEvent) {
if (event.data?.id == null) return;
state.currentSongMetadata.playData.characters.opponent = event.data.id;
};
var startingValueOpponent = ChartEditorDropdowns.populateDropdownWithCharacters(inputCharacterOpponent, CharacterType.DAD,
state.currentSongMetadata.playData.characters.opponent);
inputCharacterOpponent.value = startingValueOpponent;
var inputCharacterGirlfriend:Null<FunkinDropDown> = toolbox.findComponent('inputCharacterGirlfriend', FunkinDropDown);
if (inputCharacterGirlfriend == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputCharacterGirlfriend component.';
inputCharacterGirlfriend.onChange = function(event:UIEvent) {
if (event.data?.id == null) return;
state.currentSongMetadata.playData.characters.girlfriend = event.data.id == "none" ? "" : event.data.id;
};
var startingValueGirlfriend = ChartEditorDropdowns.populateDropdownWithCharacters(inputCharacterGirlfriend, CharacterType.GF,
state.currentSongMetadata.playData.characters.girlfriend);
inputCharacterGirlfriend.value = startingValueGirlfriend;
var inputBPM:Null<NumberStepper> = toolbox.findComponent('inputBPM', NumberStepper);
if (inputBPM == null) throw 'ChartEditorToolboxHandler.buildToolboxMetadataLayout() - Could not find inputBPM component.';
@ -630,32 +675,11 @@ class ChartEditorToolboxHandler
static function onShowToolboxMetadata(state:ChartEditorState, toolbox:CollapsibleDialog):Void
{
state.refreshSongMetadataToolbox();
state.refreshMetadataToolbox();
}
static function onHideToolboxMetadata(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
static function buildToolboxCharactersLayout(state:ChartEditorState):Null<CollapsibleDialog>
{
var toolbox:CollapsibleDialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT);
if (toolbox == null) return null;
// Starting position.
toolbox.x = 175;
toolbox.y = 300;
toolbox.onDialogClosed = function(event:DialogEvent) {
state.setUICheckboxSelected('menubarItemToggleToolboxCharacters', false);
}
return toolbox;
}
static function onShowToolboxCharacters(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
static function onHideToolboxCharacters(state:ChartEditorState, toolbox:CollapsibleDialog):Void {}
static function buildToolboxPlayerPreviewLayout(state:ChartEditorState):Null<CollapsibleDialog>
{
var toolbox:CollapsibleDialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT);

View File

@ -2,6 +2,7 @@ package funkin.util;
import flixel.util.FlxColor;
import lime.app.Application;
import funkin.data.song.SongData.SongTimeFormat;
class Constants
{
@ -22,6 +23,16 @@ class Constants
*/
public static var VERSION(get, never):String;
/**
* The generatedBy string embedded in the chart files made by this application.
*/
public static var GENERATED_BY(get, never):String;
static function get_GENERATED_BY():String
{
return '${Constants.TITLE} - ${Constants.VERSION}';
}
/**
* A suffix to add to the game version.
* Add a suffix to prototype builds and remove it for releases.
@ -140,7 +151,32 @@ class Constants
/**
* The default BPM for charts, so things don't break if none is specified.
*/
public static final DEFAULT_BPM:Int = 100;
public static final DEFAULT_BPM:Float = 100.0;
/**
* The default name for songs.
*/
public static final DEFAULT_SONGNAME:String = "Unknown";
/**
* The default artist for songs.
*/
public static final DEFAULT_ARTIST:String = "Unknown";
/**
* The default note style for songs.
*/
public static final DEFAULT_NOTE_STYLE:String = "funkin";
/**
* The default timing format for songs.
*/
public static final DEFAULT_TIMEFORMAT:SongTimeFormat = SongTimeFormat.MILLISECONDS;
/**
* The default scroll speed for songs.
*/
public static final DEFAULT_SCROLLSPEED:Float = 1.0;
/**
* Default numerator for the time signature.
@ -288,16 +324,60 @@ class Constants
public static final HEALTH_MINE_PENALTY:Float = 15.0 / 100.0 * HEALTH_MAX; // 15.0%
/**
* If true, the player will not receive the ghost miss penalty if there are no notes within the hit window.
* This is the thing people have been begging for forever lolol.
* SCORE VALUES
*/
public static final GHOST_TAPPING:Bool = false;
// ==============================
/**
* The amount of score the player gains for every send they hold a hold note.
* A fraction of this value is granted every frame.
*/
public static final SCORE_HOLD_BONUS_PER_SECOND:Float = 250.0;
/**
* FILE EXTENSIONS
*/
// ==============================
/**
* The file extension used when exporting chart files.
*
* - "I made a new file format"
* - "Actually new or just a renamed ZIP?"
*/
public static final EXT_CHART = "fnfc";
/**
* The file extension used when loading audio files.
*/
public static final EXT_SOUND = #if web "mp3" #else "ogg" #end;
/**
* The file extension used when loading video files.
*/
public static final EXT_VIDEO = "mp4";
/**
* The file extension used when loading image files.
*/
public static final EXT_IMAGE = "png";
/**
* The file extension used when loading data files.
*/
public static final EXT_DATA = "json";
/**
* OTHER
*/
// ==============================
/**
* If true, the player will not receive the ghost miss penalty if there are no notes within the hit window.
* This is the thing people have been begging for forever lolol.
*/
public static final GHOST_TAPPING:Bool = false;
/**
* The separator between an asset library and the asset path.
*/

View File

@ -5,10 +5,9 @@ import lime.utils.Bytes;
import lime.ui.FileDialog;
import openfl.net.FileFilter;
import haxe.io.Path;
#if html5
import openfl.net.FileReference;
import openfl.events.Event;
#end
import openfl.events.IOErrorEvent;
/**
* Utilities for reading and writing files on various platforms.
@ -260,8 +259,7 @@ class FileUtil
/**
* Takes an array of file entries and prompts the user to save them as a ZIP file.
*/
public static function saveFilesAsZIP(resources:Array<Entry>, ?onSave:Array<String>->Void, ?onCancel:Void->Void, ?defaultPath:String,
force:Bool = false):Bool
public static function saveFilesAsZIP(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);
@ -309,6 +307,7 @@ class FileUtil
#if sys
return sys.io.File.getContent(path);
#else
trace('ERROR: readStringFromPath not implemented for this platform');
return null;
#end
}
@ -329,6 +328,48 @@ class FileUtil
#end
}
/**
* Browse for a file to read and execute a callback once we have a file reference.
* Works great on HTML5 or desktop.
*
* @param callback The function to call when the file is loaded.
*/
public static function browseFileReference(callback:FileReference->Void)
{
var file = new FileReference();
file.addEventListener(Event.SELECT, function(e) {
var selectedFileRef:FileReference = e.target;
trace('Selected file: ' + selectedFileRef.name);
selectedFileRef.addEventListener(Event.COMPLETE, function(e) {
var loadedFileRef:FileReference = e.target;
trace('Loaded file: ' + loadedFileRef.name);
callback(loadedFileRef);
});
selectedFileRef.load();
});
file.browse();
}
/**
* Prompts the user to save a file to their computer.
*/
public static function writeFileReference(path:String, data:String)
{
var file = new FileReference();
file.addEventListener(Event.COMPLETE, function(e:Event) {
trace('Successfully wrote file.');
});
file.addEventListener(Event.CANCEL, function(e:Event) {
trace('Cancelled writing file.');
});
file.addEventListener(IOErrorEvent.IO_ERROR, function(e:IOErrorEvent) {
trace('IO error writing file.');
});
file.save(data, path);
}
/**
* Read JSON file contents directly from a given path.
* Only works on desktop.

View File

@ -13,6 +13,7 @@ typedef ScoreInput =
/**
* A class of functions dedicated to serializing and deserializing data.
* TODO: Rewrite/refactor this to use json2object.
*/
class SerializerUtil
{