Funkin/source/funkin/ui/debug/charting/ChartEditorDialogHandler.hx
2023-09-12 19:52:08 -04:00

1184 lines
47 KiB
Haxe
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package funkin.ui.debug.charting;
import funkin.play.character.CharacterData;
import funkin.util.Constants;
import funkin.util.SerializerUtil;
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.input.Cursor;
import funkin.play.character.BaseCharacter;
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.util.FileUtil;
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.TextField;
import haxe.ui.containers.Box;
import haxe.ui.containers.dialogs.Dialog;
import haxe.ui.containers.dialogs.Dialogs;
import haxe.ui.containers.properties.PropertyGrid;
import haxe.ui.containers.properties.PropertyGroup;
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;
using Lambda;
/**
* Handles dialogs for the new Chart Editor.
*/
@:nullSafety
class ChartEditorDialogHandler
{
static final CHART_EDITOR_DIALOG_ABOUT_LAYOUT:String = Paths.ui('chart-editor/dialogs/about');
static final CHART_EDITOR_DIALOG_WELCOME_LAYOUT:String = Paths.ui('chart-editor/dialogs/welcome');
static final CHART_EDITOR_DIALOG_UPLOAD_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');
/**
* Builds and opens a dialog giving brief credits for the chart editor.
* @param state The current chart editor state.
* @return The dialog that was opened.
*/
public static inline function openAboutDialog(state:ChartEditorState):Null<Dialog>
{
return openDialog(state, CHART_EDITOR_DIALOG_ABOUT_LAYOUT, true, true);
}
/**
* Builds and opens a dialog letting the user create a new chart, open a recent chart, or load from a template.
* @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 openWelcomeDialog(state:ChartEditorState, closable:Bool = true):Null<Dialog>
{
var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_WELCOME_LAYOUT, true, closable);
if (dialog == null) throw 'Could not locate Welcome dialog';
// Add handlers to the "Create From Song" section.
var linkCreateBasic:Null<Link> = dialog.findComponent('splashCreateFromSongBasic', Link);
if (linkCreateBasic == null) throw 'Could not locate splashCreateFromSongBasic link in Welcome dialog';
linkCreateBasic.onClick = function(_event) {
// Hide the welcome dialog
dialog.hideDialog(DialogButton.CANCEL);
//
// Create Song Wizard
//
openCreateSongWizard(state, false);
}
var linkImportChartLegacy:Null<Link> = dialog.findComponent('splashImportChartLegacy', Link);
if (linkImportChartLegacy == null) throw 'Could not locate splashImportChartLegacy link in Welcome dialog';
linkImportChartLegacy.onClick = function(_event) {
// Hide the welcome dialog
dialog.hideDialog(DialogButton.CANCEL);
// Open the "Import Chart" dialog
openImportChartWizard(state, 'legacy', false);
};
var buttonBrowse:Null<Button> = dialog.findComponent('splashBrowse', Button);
if (buttonBrowse == null) throw 'Could not locate splashBrowse button in Welcome dialog';
buttonBrowse.onClick = function(_event) {
// Hide the welcome dialog
dialog.hideDialog(DialogButton.CANCEL);
// Open the "Open Chart" dialog
openBrowseWizard(state, false);
}
var splashTemplateContainer:Null<VBox> = dialog.findComponent('splashTemplateContainer', VBox);
if (splashTemplateContainer == null) throw 'Could not locate splashTemplateContainer in Welcome dialog';
var songList:Array<String> = SongRegistry.instance.listEntryIds();
songList.sort(SortUtil.alphabetically);
for (targetSongId in songList)
{
var songData:Null<Song> = SongRegistry.instance.fetchEntry(targetSongId);
if (songData == null) continue;
var songName:Null<String> = songData.getDifficulty('normal')?.songName;
if (songName == null) songName = songData.getDifficulty()?.songName;
if (songName == null) // Still null?
{
trace('[WARN] Could not fetch song name for ${targetSongId}');
continue;
}
var linkTemplateSong:Link = new FunkinLink();
linkTemplateSong.text = songName;
linkTemplateSong.onClick = function(_event) {
dialog.hideDialog(DialogButton.CANCEL);
// Load song from template
state.loadSongAsTemplate(targetSongId);
}
splashTemplateContainer.addComponent(linkTemplateSong);
}
return dialog;
}
/**
* Open the wizard for opening an existing chart from individual files.
* @param state
* @param closable
*/
public static function openBrowseWizard(state:ChartEditorState, closable:Bool):Void
{
// Open the "Open Chart" wizard
// Step 1. Open Chart
var openChartDialog:Dialog = openChartDialog(state);
openChartDialog.onDialogClosed = function(_event) {
state.isHaxeUIDialogOpen = false;
if (_event.button == DialogButton.APPLY)
{
// Step 2. Upload instrumental
var uploadInstDialog:Dialog = openUploadInstDialog(state, closable);
uploadInstDialog.onDialogClosed = function(_event) {
state.isHaxeUIDialogOpen = false;
if (_event.button == DialogButton.APPLY)
{
// Step 3. Upload Vocals
// NOTE: Uploading vocals is optional, so we don't need to check if the user cancelled the wizard.
var uploadVocalsDialog:Dialog = openUploadVocalsDialog(state, closable); // var uploadVocalsDialog:Dialog
uploadVocalsDialog.onDialogClosed = function(_event) {
state.isHaxeUIDialogOpen = false;
state.postLoadInstrumental();
}
}
else
{
// User cancelled the wizard! Back to the welcome dialog.
openWelcomeDialog(state);
}
};
}
else
{
// User cancelled the wizard! Back to the welcome dialog.
openWelcomeDialog(state);
}
};
}
public static function openImportChartWizard(state:ChartEditorState, format:String, closable:Bool):Void
{
// Open the "Open Chart" wizard
// Step 1. Open Chart
var openChartDialog:Null<Dialog> = openImportChartDialog(state, format);
if (openChartDialog == null) throw 'Could not locate Import Chart dialog';
openChartDialog.onDialogClosed = function(_event) {
state.isHaxeUIDialogOpen = false;
if (_event.button == DialogButton.APPLY)
{
// Step 2. Upload instrumental
var uploadInstDialog:Dialog = openUploadInstDialog(state, closable);
uploadInstDialog.onDialogClosed = function(_event) {
state.isHaxeUIDialogOpen = false;
if (_event.button == DialogButton.APPLY)
{
// Step 3. Upload Vocals
// NOTE: Uploading vocals is optional, so we don't need to check if the user cancelled the wizard.
var uploadVocalsDialog:Dialog = openUploadVocalsDialog(state, closable); // var uploadVocalsDialog:Dialog
uploadVocalsDialog.onDialogClosed = function(_event) {
state.isHaxeUIDialogOpen = false;
state.postLoadInstrumental();
}
}
else
{
// User cancelled the wizard! Back to the welcome dialog.
openWelcomeDialog(state);
}
};
}
else
{
// User cancelled the wizard! Back to the welcome dialog.
openWelcomeDialog(state);
}
};
}
public static function openCreateSongWizard(state:ChartEditorState, closable:Bool):Void
{
// Step 1. Upload Instrumental
var uploadInstDialog:Dialog = openUploadInstDialog(state, closable);
uploadInstDialog.onDialogClosed = function(_event) {
state.isHaxeUIDialogOpen = false;
if (_event.button == DialogButton.APPLY)
{
// Step 2. Song Metadata
var songMetadataDialog:Dialog = openSongMetadataDialog(state);
songMetadataDialog.onDialogClosed = function(_event) {
state.isHaxeUIDialogOpen = false;
if (_event.button == DialogButton.APPLY)
{
// Step 3. Upload Vocals
// NOTE: Uploading vocals is optional, so we don't need to check if the user cancelled the wizard.
openUploadVocalsDialog(state, false); // var uploadVocalsDialog:Dialog
}
else
{
// User cancelled the wizard! Back to the welcome dialog.
openWelcomeDialog(state);
}
};
}
else
{
// User cancelled the wizard! Back to the welcome dialog.
openWelcomeDialog(state);
}
};
}
/**
* Builds and opens a dialog where the user uploads an instrumental for the current 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.
*/
@:haxe.warning("-WVarInit") // Hide the warning about the onDropFile handler.
public static function openUploadInstDialog(state:ChartEditorState, closable:Bool = true):Dialog
{
var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_UPLOAD_INST_LAYOUT, true, closable);
if (dialog == null) throw 'Could not locate Upload Instrumental dialog';
var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
if (buttonCancel == null) throw 'Could not locate dialogCancel button in Upload Instrumental dialog';
buttonCancel.onClick = function(_event) {
dialog.hideDialog(DialogButton.CANCEL);
}
var instrumentalBox:Null<Box> = dialog.findComponent('instrumentalBox', Box);
if (instrumentalBox == null) throw 'Could not locate instrumentalBox in Upload Instrumental dialog';
instrumentalBox.onMouseOver = function(_event) {
instrumentalBox.swapClass('upload-bg', 'upload-bg-hover');
Cursor.cursorMode = Pointer;
}
instrumentalBox.onMouseOut = function(_event) {
instrumentalBox.swapClass('upload-bg-hover', 'upload-bg');
Cursor.cursorMode = Default;
}
var onDropFile:String->Void;
instrumentalBox.onClick = function(_event) {
Dialogs.openBinaryFile('Open Instrumental', [
{label: 'Audio File (.ogg)', extension: 'ogg'}], function(selectedFile:SelectedFileInfo) {
if (selectedFile != null && selectedFile.bytes != null)
{
if (state.loadInstrumentalFromBytes(selectedFile.bytes))
{
trace('Selected file: ' + selectedFile.fullPath);
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Success',
body: 'Loaded instrumental track (${selectedFile.name})',
type: NotificationType.Success,
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
});
#end
dialog.hideDialog(DialogButton.APPLY);
removeDropHandler(onDropFile);
}
else
{
trace('Failed to load instrumental (${selectedFile.fullPath})');
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Failure',
body: 'Failed to load instrumental track (${selectedFile.name})',
type: NotificationType.Error,
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
});
#end
}
}
});
}
onDropFile = function(pathStr:String) {
var path:Path = new Path(pathStr);
trace('Dropped file (${path})');
if (state.loadInstrumentalFromPath(path))
{
// Tell the user the load was successful.
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Success',
body: 'Loaded instrumental track (${path.file}.${path.ext})',
type: NotificationType.Success,
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
});
#end
dialog.hideDialog(DialogButton.APPLY);
removeDropHandler(onDropFile);
}
else
{
var message:String = if (!ChartEditorState.SUPPORTED_MUSIC_FORMATS.contains(path.ext ?? ''))
{
'File format (${path.ext}) not supported for instrumental track (${path.file}.${path.ext})';
}
else
{
'Failed to load instrumental track (${path.file}.${path.ext})';
}
// Tell the user the load was successful.
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Failure',
body: message,
type: NotificationType.Error,
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
});
#end
}
};
addDropHandler(instrumentalBox, onDropFile);
return dialog;
}
static var dropHandlers:Array<
{
component:Component,
handler:(String->Void)
}> = [];
/**
* Add a callback for when a file is dropped on a component.
*
* On OS X you cant drop on the application window, but rather only the app icon
* (either in the dock while running or the icon on the hard drive) so this must be disabled
* and UI updated appropriately.
* @param component
* @param handler
*/
static function addDropHandler(component:Component, handler:String->Void):Void
{
#if desktop
if (!FlxG.stage.window.onDropFile.has(onDropFile)) FlxG.stage.window.onDropFile.add(onDropFile);
dropHandlers.push(
{
component: component,
handler: handler
});
#else
trace('addDropHandler not implemented for this platform');
#end
}
static function removeDropHandler(handler:String->Void):Void
{
#if desktop
FlxG.stage.window.onDropFile.remove(handler);
#end
}
static function clearDropHandlers():Void
{
#if desktop
dropHandlers = [];
FlxG.stage.window.onDropFile.remove(onDropFile);
#end
}
static function onDropFile(path:String):Void
{
// a VERY short timer to wait for the mouse position to update
new FlxTimer().start(0.01, function(_) {
for (handler in dropHandlers)
{
if (handler.component.hitTest(FlxG.mouse.screenX, FlxG.mouse.screenY))
{
handler.handler(path);
return;
}
}
});
}
/**
* Opens the dialog in the wizard where the user can set song metadata like name and artist and BPM.
* @param state The ChartEditorState instance.
* @return The dialog to open.
*/
@:haxe.warning("-WVarInit")
public static function openSongMetadataDialog(state:ChartEditorState):Dialog
{
var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_SONG_METADATA_LAYOUT, true, false);
if (dialog == null) throw 'Could not locate Song Metadata dialog';
var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
if (buttonCancel == null) throw 'Could not locate dialogCancel button in Song Metadata dialog';
buttonCancel.onClick = function(_event) {
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 valid:Bool = event.target.text != null && event.target.text != '';
if (valid)
{
dialogSongName.removeClass('invalid-value');
state.currentSongMetadata.songName = event.target.text;
}
else
{
state.currentSongMetadata.songName = "";
}
};
state.currentSongMetadata.songName = "";
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 valid:Bool = event.target.text != null && event.target.text != '';
if (valid)
{
dialogSongArtist.removeClass('invalid-value');
state.currentSongMetadata.artist = event.target.text;
}
else
{
state.currentSongMetadata.artist = "";
}
};
state.currentSongMetadata.artist = "";
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) {
if (event.data == null && event.data.id == null) return;
state.currentSongMetadata.playData.stage = event.data.id;
};
state.currentSongMetadata.playData.stage = 'mainStage';
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) {
if (event.data.id == null) return;
state.currentSongNoteSkin = event.data.id;
};
state.currentSongNoteSkin = 'funkin';
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;
if (timeChanges == null || timeChanges.length == 0)
{
timeChanges = [new SongTimeChange(0, event.value)];
}
else
{
timeChanges[0].bpm = event.value;
}
Conductor.forceBPM(event.value);
state.currentSongMetadata.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);
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.
* @param closable Whether the dialog can be closed by the user.
* @return The dialog that was opened.
*/
public static function openUploadVocalsDialog(state:ChartEditorState, closable:Bool = true):Dialog
{
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 dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_UPLOAD_VOCALS_LAYOUT, true, closable);
if (dialog == null) throw 'Could not locate Upload Vocals dialog';
var dialogContainer:Null<Component> = dialog.findComponent('vocalContainer');
if (dialogContainer == null) throw 'Could not locate vocalContainer in Upload Vocals dialog';
var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
if (buttonCancel == null) throw 'Could not locate dialogCancel button in Upload Vocals dialog';
buttonCancel.onClick = function(_event) {
dialog.hideDialog(DialogButton.CANCEL);
}
var dialogNoVocals:Null<Button> = dialog.findComponent('dialogNoVocals', Button);
if (dialogNoVocals == null) throw 'Could not locate dialogNoVocals button in Upload Vocals dialog';
dialogNoVocals.onClick = function(_event) {
// Dismiss
dialog.hideDialog(DialogButton.APPLY);
};
for (charKey in charIdsForVocals)
{
trace('Adding vocal upload for character ${charKey}');
var charMetadata:Null<CharacterData> = CharacterDataParser.fetchCharacterData(charKey);
var charName:String = charMetadata != null ? charMetadata.name : charKey;
var vocalsEntry:Component = state.buildComponent(CHART_EDITOR_DIALOG_UPLOAD_VOCALS_ENTRY_LAYOUT);
var vocalsEntryLabel:Null<Label> = vocalsEntry.findComponent('vocalsEntryLabel', Label);
if (vocalsEntryLabel == null) throw 'Could not locate vocalsEntryLabel in Upload Vocals dialog';
#if FILE_DROP_SUPPORTED
vocalsEntryLabel.text = 'Drag and drop vocals for $charName here, or click to browse.';
#else
vocalsEntryLabel.text = 'Click to browse for vocals for $charName.';
#end
var onDropFile:String->Void = function(pathStr:String) {
trace('Selected file: $pathStr');
var path:Path = new Path(pathStr);
if (state.loadVocalsFromPath(path, charKey))
{
// Tell the user the load was successful.
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Success',
body: 'Loaded vocal track for $charName (${path.file}.${path.ext})',
type: NotificationType.Success,
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
});
#end
#if FILE_DROP_SUPPORTED
vocalsEntryLabel.text = 'Vocals for $charName (drag and drop, or click to browse)\nSelected file: ${path.file}.${path.ext}';
#else
vocalsEntryLabel.text = 'Vocals for $charName (click to browse)\n${path.file}.${path.ext}';
#end
dialogNoVocals.hidden = true;
removeDropHandler(onDropFile);
}
else
{
var message:String = if (!ChartEditorState.SUPPORTED_MUSIC_FORMATS.contains(path.ext ?? ''))
{
'File format (${path.ext}) not supported for vocal track (${path.file}.${path.ext})';
}
else
{
'Failed to load vocal track (${path.file}.${path.ext})';
}
// Vocals failed to load.
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Failure',
body: message,
type: NotificationType.Error,
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
});
#end
#if FILE_DROP_SUPPORTED
vocalsEntryLabel.text = 'Drag and drop vocals for $charName here, or click to browse.';
#else
vocalsEntryLabel.text = 'Click to browse for vocals for $charName.';
#end
}
};
vocalsEntry.onClick = function(_event) {
Dialogs.openBinaryFile('Open $charName Vocals', [
{label: 'Audio File (.ogg)', extension: 'ogg'}], function(selectedFile) {
if (selectedFile != null && selectedFile.bytes != null)
{
trace('Selected file: ' + selectedFile.name);
#if FILE_DROP_SUPPORTED
vocalsEntryLabel.text = 'Vocals for $charName (drag and drop, or click to browse)\nSelected file: ${selectedFile.name}';
#else
vocalsEntryLabel.text = 'Vocals for $charName (click to browse)\n${selectedFile.name}';
#end
state.loadVocalsFromBytes(selectedFile.bytes, charKey);
dialogNoVocals.hidden = true;
removeDropHandler(onDropFile);
}
});
}
// onDropFile
#if FILE_DROP_SUPPORTED
addDropHandler(vocalsEntry, onDropFile);
#end
dialogContainer.addComponent(vocalsEntry);
}
var dialogContinue:Null<Button> = dialog.findComponent('dialogContinue', Button);
if (dialogContinue == null) throw 'Could not locate dialogContinue button in Upload Vocals dialog';
dialogContinue.onClick = function(_event) {
// Dismiss
dialog.hideDialog(DialogButton.APPLY);
};
return dialog;
}
/**
* Builds and opens a dialog where the user upload the JSON files 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.
*/
@:haxe.warning('-WVarInit')
public static function openChartDialog(state:ChartEditorState, closable:Bool = true):Dialog
{
var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_OPEN_CHART_LAYOUT, true, closable);
if (dialog == null) throw 'Could not locate Open Chart dialog';
var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
if (buttonCancel == null) throw 'Could not locate dialogCancel button in Open Chart dialog';
buttonCancel.onClick = function(_event) {
dialog.hideDialog(DialogButton.CANCEL);
}
var chartContainerA:Null<Component> = dialog.findComponent('chartContainerA');
if (chartContainerA == null) throw 'Could not locate chartContainerA in Open Chart dialog';
var chartContainerB:Null<Component> = dialog.findComponent('chartContainerB');
if (chartContainerB == null) throw 'Could not locate chartContainerB in Open Chart dialog';
var songMetadata:Map<String, SongMetadata> = [];
var songChartData:Map<String, SongChartData> = [];
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);
dialog.hideDialog(DialogButton.APPLY);
}
var onDropFileMetadataVariation:String->Label->String->Void;
var onClickMetadataVariation:String->Label->UIEvent->Void;
var onDropFileChartDataVariation:String->Label->String->Void;
var onClickChartDataVariation:String->Label->UIEvent->Void;
var constructVariationEntries:Array<String>->Void = function(variations:Array<String>) {
// Clear the chart container.
while (chartContainerB.getComponentAt(0) != null)
{
chartContainerB.removeComponent(chartContainerB.getComponentAt(0));
}
// Build an entry for -chart.json.
var songDefaultChartDataEntry:Component = state.buildComponent(CHART_EDITOR_DIALOG_OPEN_CHART_ENTRY_LAYOUT);
var songDefaultChartDataEntryLabel:Null<Label> = songDefaultChartDataEntry.findComponent('chartEntryLabel', Label);
if (songDefaultChartDataEntryLabel == null) throw 'Could not locate chartEntryLabel in Open Chart dialog';
#if FILE_DROP_SUPPORTED
songDefaultChartDataEntryLabel.text = 'Drag and drop <song>-chart.json file, or click to browse.';
#else
songDefaultChartDataEntryLabel.text = 'Click to browse for <song>-chart.json file.';
#end
songDefaultChartDataEntry.onClick = onClickChartDataVariation.bind(Constants.DEFAULT_VARIATION).bind(songDefaultChartDataEntryLabel);
addDropHandler(songDefaultChartDataEntry, onDropFileChartDataVariation.bind(Constants.DEFAULT_VARIATION).bind(songDefaultChartDataEntryLabel));
chartContainerB.addComponent(songDefaultChartDataEntry);
for (variation in variations)
{
// Build entries for -metadata-<variation>.json.
var songVariationMetadataEntry:Component = state.buildComponent(CHART_EDITOR_DIALOG_OPEN_CHART_ENTRY_LAYOUT);
var songVariationMetadataEntryLabel:Null<Label> = songVariationMetadataEntry.findComponent('chartEntryLabel', Label);
if (songVariationMetadataEntryLabel == null) throw 'Could not locate chartEntryLabel in Open Chart dialog';
#if FILE_DROP_SUPPORTED
songVariationMetadataEntryLabel.text = 'Drag and drop <song>-metadata-${variation}.json file, or click to browse.';
#else
songVariationMetadataEntryLabel.text = 'Click to browse for <song>-metadata-${variation}.json file.';
#end
songVariationMetadataEntry.onMouseOver = function(_event) {
songVariationMetadataEntry.swapClass('upload-bg', 'upload-bg-hover');
Cursor.cursorMode = Pointer;
}
songVariationMetadataEntry.onMouseOut = function(_event) {
songVariationMetadataEntry.swapClass('upload-bg-hover', 'upload-bg');
Cursor.cursorMode = Default;
}
songVariationMetadataEntry.onClick = onClickMetadataVariation.bind(variation).bind(songVariationMetadataEntryLabel);
#if FILE_DROP_SUPPORTED
addDropHandler(songVariationMetadataEntry, onDropFileMetadataVariation.bind(variation).bind(songVariationMetadataEntryLabel));
#end
chartContainerB.addComponent(songVariationMetadataEntry);
// Build entries for -chart-<variation>.json.
var songVariationChartDataEntry:Component = state.buildComponent(CHART_EDITOR_DIALOG_OPEN_CHART_ENTRY_LAYOUT);
var songVariationChartDataEntryLabel:Null<Label> = songVariationChartDataEntry.findComponent('chartEntryLabel', Label);
if (songVariationChartDataEntryLabel == null) throw 'Could not locate chartEntryLabel in Open Chart dialog';
#if FILE_DROP_SUPPORTED
songVariationChartDataEntryLabel.text = 'Drag and drop <song>-chart-${variation}.json file, or click to browse.';
#else
songVariationChartDataEntryLabel.text = 'Click to browse for <song>-chart-${variation}.json file.';
#end
songVariationChartDataEntry.onMouseOver = function(_event) {
songVariationChartDataEntry.swapClass('upload-bg', 'upload-bg-hover');
Cursor.cursorMode = Pointer;
}
songVariationChartDataEntry.onMouseOut = function(_event) {
songVariationChartDataEntry.swapClass('upload-bg-hover', 'upload-bg');
Cursor.cursorMode = Default;
}
songVariationChartDataEntry.onClick = onClickChartDataVariation.bind(variation).bind(songVariationChartDataEntryLabel);
#if FILE_DROP_SUPPORTED
addDropHandler(songVariationChartDataEntry, onDropFileChartDataVariation.bind(variation).bind(songVariationChartDataEntryLabel));
#end
chartContainerB.addComponent(songVariationChartDataEntry);
}
}
onDropFileMetadataVariation = function(variation:String, label:Label, pathStr:String) {
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');
if (songMetadataVariation == null)
{
// Tell the user the load was not successful.
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Failure',
body: 'Could not load metadata file (${path.file}.${path.ext})',
type: NotificationType.Error,
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
});
#end
return;
}
songMetadata.set(variation, songMetadataVariation);
// Tell the user the load was successful.
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Success',
body: 'Loaded metadata file (${path.file}.${path.ext})',
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: ${path.file}.${path.ext}';
#else
label.text = 'Metadata file (click to browse)\n${path.file}.${path.ext}';
#end
if (variation == Constants.DEFAULT_VARIATION) constructVariationEntries(songMetadataVariation.playData.songVariations);
};
onClickMetadataVariation = function(variation:String, label:Label, _event:UIEvent) {
Dialogs.openBinaryFile('Open Chart ($variation) Metadata', [
{label: 'JSON File (.json)', extension: 'json'}], function(selectedFile) {
if (selectedFile != null && selectedFile.bytes != null)
{
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;
songMetadata.set(variation, songMetadataVariation);
// 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);
}
});
}
onDropFileChartDataVariation = function(variation:String, label:Label, pathStr:String) {
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');
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
};
onClickChartDataVariation = function(variation:String, label:Label, _event:UIEvent) {
Dialogs.openBinaryFile('Open Chart ($variation) Metadata', [
{label: 'JSON File (.json)', extension: 'json'}], function(selectedFile) {
if (selectedFile != null && selectedFile.bytes != null)
{
trace('Selected file: ' + selectedFile.name);
var songChartDataJson:Dynamic = SerializerUtil.fromJSONBytes(selectedFile.bytes);
var songChartDataVariation:SongChartData = SongMigrator.migrateSongChartData(songChartDataJson, 'import');
songChartDataVariation = SongValidator.validateSongChartData(songChartDataVariation, 'import');
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
}
});
}
var metadataEntry:Component = state.buildComponent(CHART_EDITOR_DIALOG_OPEN_CHART_ENTRY_LAYOUT);
var metadataEntryLabel:Null<Label> = metadataEntry.findComponent('chartEntryLabel', Label);
if (metadataEntryLabel == null) throw 'Could not locate chartEntryLabel in Open Chart dialog';
#if FILE_DROP_SUPPORTED
metadataEntryLabel.text = 'Drag and drop <song>-metadata.json file, or click to browse.';
#else
metadataEntryLabel.text = 'Click to browse for <song>-metadata.json file.';
#end
metadataEntry.onClick = onClickMetadataVariation.bind(Constants.DEFAULT_VARIATION).bind(metadataEntryLabel);
addDropHandler(metadataEntry, onDropFileMetadataVariation.bind(Constants.DEFAULT_VARIATION).bind(metadataEntryLabel));
metadataEntry.onMouseOver = function(_event) {
metadataEntry.swapClass('upload-bg', 'upload-bg-hover');
Cursor.cursorMode = Pointer;
}
metadataEntry.onMouseOut = function(_event) {
metadataEntry.swapClass('upload-bg-hover', 'upload-bg');
Cursor.cursorMode = Default;
}
chartContainerA.addComponent(metadataEntry);
return dialog;
}
/**
* Builds and opens a dialog where the user can import a chart from an existing file format.
* @param state The current chart editor state.
* @param format The format to import from.
* @param closable
* @return Dialog
*/
public static function openImportChartDialog(state:ChartEditorState, format:String, closable:Bool = true):Null<Dialog>
{
var dialog:Null<Dialog> = openDialog(state, CHART_EDITOR_DIALOG_IMPORT_CHART_LAYOUT, true, closable);
if (dialog == null) return null;
var prettyFormat:String = switch (format)
{
case 'legacy': 'FNF Legacy';
default: 'Unknown';
}
var fileFilter = switch (format)
{
case 'legacy': {label: 'JSON Data File (.json)', extension: 'json'};
default: null;
}
dialog.title = 'Import Chart - ${prettyFormat}';
var buttonCancel:Null<Button> = dialog.findComponent('dialogCancel', Button);
if (buttonCancel == null) throw 'Could not locate dialogCancel button in Import Chart dialog';
buttonCancel.onClick = function(_event) {
dialog.hideDialog(DialogButton.CANCEL);
}
var importBox:Null<Box> = dialog.findComponent('importBox', Box);
if (importBox == null) throw 'Could not locate importBox in Import Chart dialog';
importBox.onMouseOver = function(_event) {
importBox.swapClass('upload-bg', 'upload-bg-hover');
Cursor.cursorMode = Pointer;
}
importBox.onMouseOut = function(_event) {
importBox.swapClass('upload-bg-hover', 'upload-bg');
Cursor.cursorMode = Default;
}
var onDropFile:String->Void;
importBox.onClick = function(_event) {
Dialogs.openBinaryFile('Import Chart - ${prettyFormat}', fileFilter != null ? [fileFilter] : [], function(selectedFile:SelectedFileInfo) {
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);
state.loadSong([Constants.DEFAULT_VARIATION => songMetadata], [Constants.DEFAULT_VARIATION => songChartData]);
dialog.hideDialog(DialogButton.APPLY);
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Success',
body: 'Loaded chart file (${selectedFile.name})',
type: NotificationType.Success,
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
});
#end
}
});
}
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);
state.loadSong([Constants.DEFAULT_VARIATION => songMetadata], [Constants.DEFAULT_VARIATION => songChartData]);
dialog.hideDialog(DialogButton.APPLY);
#if !mac
NotificationManager.instance.addNotification(
{
title: 'Success',
body: 'Loaded chart file (${path.file}.${path.ext})',
type: NotificationType.Success,
expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME
});
#end
};
addDropHandler(importBox, onDropFile);
return dialog;
}
/**
* Builds and opens a dialog displaying the user guide, providing guidance and help on how to use the chart editor.
*
* @param state The current chart editor state.
* @return The dialog that was opened.
*/
public static inline function openUserGuideDialog(state:ChartEditorState):Null<Dialog>
{
return openDialog(state, CHART_EDITOR_DIALOG_USER_GUIDE_LAYOUT, true, true);
}
/**
* Builds and opens a dialog from a given layout path.
* @param modal Makes the background uninteractable while the dialog is open.
* @param closable Hides the close button on the dialog, preventing it from being closed unless the user interacts with the dialog.
*/
static function openDialog(state:ChartEditorState, key:String, modal:Bool = true, closable:Bool = true):Null<Dialog>
{
var dialog:Null<Dialog> = cast state.buildComponent(key);
if (dialog == null) return null;
dialog.destroyOnClose = true;
dialog.closable = closable;
dialog.showDialog(modal);
state.isHaxeUIDialogOpen = true;
dialog.onDialogClosed = function(event:UIEvent) {
state.isHaxeUIDialogOpen = false;
};
dialog.zIndex = 1000;
return dialog;
}
}