package funkin.ui.debug.charting; import flixel.system.FlxAssets.FlxSoundAsset; import flixel.math.FlxMath; import haxe.ui.components.TextField; import haxe.ui.components.DropDown; import haxe.ui.components.NumberStepper; import haxe.ui.containers.Frame; import flixel.addons.display.FlxSliceSprite; import flixel.addons.display.FlxTiledSprite; import flixel.FlxCamera; import flixel.FlxSprite; import flixel.FlxSubState; import flixel.group.FlxSpriteGroup; import flixel.addons.transition.FlxTransitionableState; import flixel.input.keyboard.FlxKey; import flixel.math.FlxPoint; import flixel.math.FlxRect; import flixel.sound.FlxSound; import flixel.tweens.FlxEase; import flixel.tweens.FlxTween; import flixel.tweens.misc.VarTween; import flixel.util.FlxColor; import flixel.util.FlxSort; import flixel.util.FlxTimer; import funkin.audio.visualize.PolygonSpectogram; import funkin.audio.VoicesGroup; import funkin.data.notestyle.NoteStyleRegistry; import funkin.data.notestyle.NoteStyleRegistry; import funkin.input.Cursor; import funkin.input.TurboKeyHandler; import funkin.modding.events.ScriptEvent; import funkin.play.character.BaseCharacter.CharacterType; import funkin.play.HealthIcon; import funkin.play.notes.NoteSprite; import funkin.play.notes.Strumline; import funkin.play.PlayState; import funkin.play.song.Song; import funkin.data.song.SongData.SongChartData; 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.SongDataUtils; import funkin.ui.debug.charting.ChartEditorCommand; import funkin.ui.debug.charting.ChartEditorCommand; import funkin.ui.debug.charting.ChartEditorThemeHandler.ChartEditorTheme; import funkin.ui.debug.charting.ChartEditorToolboxHandler.ChartEditorToolMode; import funkin.ui.haxeui.components.CharacterPlayer; import funkin.ui.haxeui.HaxeUIState; import funkin.util.Constants; import funkin.util.DateUtil; import funkin.util.FileUtil; import funkin.util.SerializerUtil; import funkin.util.SortUtil; import funkin.util.WindowUtil; import haxe.DynamicAccess; import haxe.io.Bytes; import haxe.io.Path; import haxe.ui.components.Label; import haxe.ui.components.Slider; import haxe.ui.containers.dialogs.CollapsibleDialog; import haxe.ui.containers.menus.MenuItem; import haxe.ui.containers.TreeView; import haxe.ui.containers.TreeViewNode; import haxe.ui.core.Component; import haxe.ui.core.Screen; import haxe.ui.events.DragEvent; import haxe.ui.events.UIEvent; import haxe.ui.notifications.NotificationManager; import haxe.ui.notifications.NotificationType; import openfl.Assets; import openfl.display.BitmapData; import openfl.geom.Rectangle; using Lambda; /** * A state dedicated to allowing the user to create and edit song charts. * Built with HaxeUI for use by both developers and modders. * * Some functionality is moved to other classes to help maintain my sanity. * * @author MasterEric */ // Give other classes access to private instance fields // @:nullSafety(Loose) // Enable this while developing, then disable to keep unit tests functional! @:allow(funkin.ui.debug.charting.ChartEditorCommand) @:allow(funkin.ui.debug.charting.ChartEditorDialogHandler) @:allow(funkin.ui.debug.charting.ChartEditorThemeHandler) @:allow(funkin.ui.debug.charting.ChartEditorToolboxHandler) class ChartEditorState extends HaxeUIState { /** * CONSTANTS */ // ============================== // XML Layouts static final CHART_EDITOR_LAYOUT:String = Paths.ui('chart-editor/main-view'); static final CHART_EDITOR_NOTIFBAR_LAYOUT:String = Paths.ui('chart-editor/components/notifbar'); static final CHART_EDITOR_PLAYBARHEAD_LAYOUT:String = Paths.ui('chart-editor/components/playbar-head'); static final CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT:String = Paths.ui('chart-editor/toolbox/tools'); static final CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT:String = Paths.ui('chart-editor/toolbox/notedata'); 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'); // Validation static final SUPPORTED_MUSIC_FORMATS:Array = ['ogg']; /** * The base grid size for the chart editor. */ public static final GRID_SIZE:Int = 40; public static final PLAYHEAD_SCROLL_AREA_WIDTH:Int = 12; public static final PLAYHEAD_HEIGHT:Int = Std.int(GRID_SIZE / 8); public static final GRID_SELECTION_BORDER_WIDTH:Int = 6; /** * Number of notes in each player's strumline. */ public static final STRUMLINE_SIZE:Int = 4; /** * The height of the menu bar in the layout. */ static final MENU_BAR_HEIGHT:Int = 32; /** * The height of the playbar in the layout. */ static final PLAYBAR_HEIGHT:Int = 48; /** * Duration to wait before autosaving the chart. */ static final AUTOSAVE_TIMER_DELAY:Float = 60.0 * 5.0; /** * The amount of padding between the menu bar and the chart grid when fully scrolled up. */ static final GRID_TOP_PAD:Int = 8; /** * Duration, in milliseconds, until toast notifications are automatically hidden. */ static final NOTIFICATION_DISMISS_TIME:Int = 5000; /** * Duration, in seconds, for the scroll easing animation. */ static final SCROLL_EASE_DURATION:Float = 0.2; // UI Element Colors // Background color tint. static final CURSOR_COLOR:FlxColor = 0xE0FFFFFF; static final PREVIEW_BG_COLOR:FlxColor = 0xFF303030; static final PLAYHEAD_SCROLL_AREA_COLOR:FlxColor = 0xFF682B2F; static final SPECTROGRAM_COLOR:FlxColor = 0xFFFF0000; static final PLAYHEAD_COLOR:FlxColor = 0xC0BD0231; /** * How many pixels far the user needs to move the mouse before the cursor is considered to be dragged rather than clicked. */ static final DRAG_THRESHOLD:Float = 16.0; /** * Types of notes you can snap to. */ static final SNAP_QUANTS:Array = [4, 8, 12, 16, 20, 24, 32, 48, 64, 96, 192]; static final BASE_QUANT:Int = 16; /** * INSTANCE DATA */ // ============================== /** * The internal index of what note snapping value is in use. * Increment to make placement more preceise and decrement to make placement less precise. */ var noteSnapQuantIndex:Int = 3; // default is 16 /** * The current note snapping value. * For example, `32` when snapping to 32nd notes. */ public var noteSnapQuant(get, never):Int; function get_noteSnapQuant():Int { return SNAP_QUANTS[noteSnapQuantIndex]; } /** * The ratio of the current note snapping value to the default. * For example, `32` becomes `0.5` when snapping to 16th notes. */ public var noteSnapRatio(get, never):Float; function get_noteSnapRatio():Float { return BASE_QUANT / noteSnapQuant; } /** * scrollPosition is the current position in the song, in pixels. * One pixel is 1/40 of 1 step, and 1/160 of 1 beat. */ var scrollPositionInPixels(default, set):Float = -1.0; /** * scrollPosition, converted to steps. * NOT dependant on BPM, because the size of a grid square does not change with BPM. */ var scrollPositionInSteps(get, set):Float; function get_scrollPositionInSteps():Float { return scrollPositionInPixels / GRID_SIZE; } function set_scrollPositionInSteps(value:Float):Float { scrollPositionInPixels = value * GRID_SIZE; return value; } /** * scrollPosition, converted to milliseconds. * DEPENDANT on BPM, because the duration of a grid square changes with BPM. */ var scrollPositionInMs(get, set):Float; function get_scrollPositionInMs():Float { return Conductor.getStepTimeInMs(scrollPositionInSteps); } function set_scrollPositionInMs(value:Float):Float { scrollPositionInSteps = Conductor.getTimeInSteps(value); return value; } /** * The position of the playhead, in pixels, relative to the scrollPosition. * 0 means playhead is at the top of the grid. * 40 means the playhead is 1 grid length below the base position. * -40 means the playhead is 1 grid length above the base position. */ var playheadPositionInPixels(default, set):Float = 0.0; function set_playheadPositionInPixels(value:Float):Float { // Make sure playhead doesn't go outside the song. if (value + scrollPositionInPixels < 0) value = -scrollPositionInPixels; if (value + scrollPositionInPixels > songLengthInPixels) value = songLengthInPixels - scrollPositionInPixels; this.playheadPositionInPixels = value; // Move the playhead sprite to the correct position. gridPlayhead.y = this.playheadPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD); return this.playheadPositionInPixels; } /** * playheadPosition, converted to steps. * NOT dependant on BPM, because the size of a grid square does not change with BPM. */ var playheadPositionInSteps(get, set):Float; function get_playheadPositionInSteps():Float { return playheadPositionInPixels / GRID_SIZE; } function set_playheadPositionInSteps(value:Float):Float { playheadPositionInPixels = value * GRID_SIZE; return value; } /** * playheadPosition, converted to milliseconds. * DEPENDANT on BPM, because the duration of a grid square changes with BPM. */ var playheadPositionInMs(get, set):Float; function get_playheadPositionInMs():Float { return Conductor.getStepTimeInMs(playheadPositionInSteps); } function set_playheadPositionInMs(value:Float):Float { playheadPositionInSteps = Conductor.getTimeInSteps(value); return value; } /** * songLength, in milliseconds. */ @:isVar var songLengthInMs(get, set):Float = 0; function get_songLengthInMs():Float { if (songLengthInMs <= 0) return 1000; return songLengthInMs; } function set_songLengthInMs(value:Float):Float { this.songLengthInMs = value; // Make sure playhead doesn't go outside the song. if (playheadPositionInMs > songLengthInMs) playheadPositionInMs = songLengthInMs; return this.songLengthInMs; } /** * songLength, converted to steps. * Dependant on BPM, because the size of a grid square does not change with BPM but the length of a beat does. */ var songLengthInSteps(get, set):Float; function get_songLengthInSteps():Float { return Conductor.getTimeInSteps(songLengthInMs); } function set_songLengthInSteps(value:Float):Float { // Getting a reasonable result from setting songLengthInSteps requires that Conductor.mapBPMChanges be called first. songLengthInMs = Conductor.getStepTimeInMs(value); return value; } /** * This is the song's length in PIXELS, same format as scrollPosition. * Dependant on BPM, because the size of a grid square does not change with BPM but the length of a beat does. */ var songLengthInPixels(get, set):Int; function get_songLengthInPixels():Int { return Std.int(songLengthInSteps * GRID_SIZE); } function set_songLengthInPixels(value:Int):Int { songLengthInSteps = value / GRID_SIZE; return value; } /** * The current theme used by the editor. * Dictates the appearance of many UI elements. * Currently hardcoded to just Light and Dark. */ var currentTheme(default, set):ChartEditorTheme = ChartEditorTheme.Light; function set_currentTheme(value:ChartEditorTheme):ChartEditorTheme { if (value == null || value == currentTheme) return currentTheme; currentTheme = value; ChartEditorThemeHandler.updateTheme(this); return value; } /** * Whether a skip button has been pressed on the playbar, and which one. * `null` if no button has been pressed. * This will be used to update the scrollPosition (in the same function that handles the scroll wheel), then cleared. */ var playbarButtonPressed:Null = null; /** * Whether the head of the playbar is currently being dragged with the mouse by the user. */ var playbarHeadDragging:Bool = false; /** * Whether music was playing before we started dragging the playbar head. * If so, then when we stop dragging the playbar head, we should resume song playback. */ var playbarHeadDraggingWasPlaying:Bool = false; /** * The note kind to use for notes being placed in the chart. Defaults to `''`. */ var selectedNoteKind:String = ''; /** * The note kind to use for notes being placed in the chart. Defaults to `''`. */ var selectedEventKind:String = 'FocusCamera'; /** * The note data as a struct. */ var selectedEventData:DynamicAccess = {}; /** * Whether to play a metronome sound while the playhead is moving. */ var isMetronomeEnabled:Bool = true; /** * Use the tool window to affect how the user interacts with the program. */ var currentToolMode:ChartEditorToolMode = ChartEditorToolMode.Select; /** * The character sprite in the Player Preview window. * `null` until accessed. */ var currentPlayerCharacterPlayer:Null = null; /** * The character sprite in the Opponent Preview window. * `null` until accessed. */ var currentOpponentCharacterPlayer:Null = null; /** * The currently selected live input style. */ var currentLiveInputStyle:LiveInputStyle = LiveInputStyle.None; /** * Whether the current view is in downscroll mode. */ var isViewDownscroll(default, set):Bool = false; function set_isViewDownscroll(value:Bool):Bool { isViewDownscroll = value; // Make sure view is updated when we change view modes. noteDisplayDirty = true; notePreviewDirty = true; notePreviewViewportBoundsDirty = true; this.scrollPositionInPixels = this.scrollPositionInPixels; return isViewDownscroll; } /** * If true, playtesting a chart will skip to the current playhead position. */ var playtestStartTime:Bool = false; /** * Whether hitsounds are enabled for at least one character. */ var hitsoundsEnabled(get, never):Bool; function get_hitsoundsEnabled():Bool { return hitsoundsEnabledPlayer || hitsoundsEnabledOpponent; } /** * Whether hitsounds are enabled for the player. */ var hitsoundsEnabledPlayer:Bool = true; /** * Whether hitsounds are enabled for the opponent. */ var hitsoundsEnabledOpponent:Bool = true; /** * Whether the user's mouse cursor is hovering over a SOLID component of the HaxeUI. * If so, ignore mouse events underneath. */ var isCursorOverHaxeUI(get, never):Bool; function get_isCursorOverHaxeUI():Bool { return Screen.instance.hasSolidComponentUnderPoint(FlxG.mouse.screenX, FlxG.mouse.screenY); } var isCursorOverHaxeUIButton(get, never):Bool; function get_isCursorOverHaxeUIButton():Bool { return Screen.instance.hasSolidComponentUnderPoint(FlxG.mouse.screenX, FlxG.mouse.screenY, haxe.ui.components.Button) || Screen.instance.hasSolidComponentUnderPoint(FlxG.mouse.screenX, FlxG.mouse.screenY, haxe.ui.components.Link); } /** * Set by ChartEditorDialogHandler, used to prevent background interaction while the dialog is open. */ public var isHaxeUIDialogOpen:Bool = false; /** * The variation ID for the difficulty which is currently being edited. */ var selectedVariation(default, set):String = Constants.DEFAULT_VARIATION; function set_selectedVariation(value:String):String { selectedVariation = value; // Make sure view is updated when the variation changes. noteDisplayDirty = true; notePreviewDirty = true; notePreviewViewportBoundsDirty = true; return selectedVariation; } /** * The difficulty ID for the difficulty which is currently being edited. */ var selectedDifficulty(default, set):String = Constants.DEFAULT_DIFFICULTY; function set_selectedDifficulty(value:String):String { selectedDifficulty = value; // Make sure view is updated when the difficulty changes. noteDisplayDirty = true; notePreviewDirty = true; notePreviewViewportBoundsDirty = true; return selectedDifficulty; } /** * The character ID for the character which is currently selected. */ var selectedCharacter(default, set):String = Constants.DEFAULT_CHARACTER; function set_selectedCharacter(value:String):String { selectedCharacter = value; // Make sure view is updated when the character changes. noteDisplayDirty = true; notePreviewDirty = true; notePreviewViewportBoundsDirty = true; return selectedCharacter; } /** * Whether the user is currently in Pattern Mode. * This overrides the chart editor's normal behavior. */ var isInPatternMode(default, set):Bool = false; function set_isInPatternMode(value:Bool):Bool { isInPatternMode = value; // Make sure view is updated when we change modes. noteDisplayDirty = true; notePreviewDirty = true; notePreviewViewportBoundsDirty = true; this.scrollPositionInPixels = 0; return isInPatternMode; } var currentPattern:String = ''; /** * Whether the note display render group has been modified and needs to be updated. * This happens when we scroll or add/remove notes, and need to update what notes are displayed and where. */ var noteDisplayDirty:Bool = true; /** * Whether the note preview graphic needs to be FULLY rebuilt. */ var notePreviewDirty:Bool = true; var notePreviewViewportBoundsDirty:Bool = true; /** * Whether the chart has been modified since it was last saved. * Used to determine whether to auto-save, etc. */ var saveDataDirty(default, set):Bool = false; function set_saveDataDirty(value:Bool):Bool { if (value == saveDataDirty) return value; if (value) { // Start the auto-save timer. autoSaveTimer = new FlxTimer().start(AUTOSAVE_TIMER_DELAY, (_) -> autoSave()); } else { if (autoSaveTimer != null) { // Stop the auto-save timer. autoSaveTimer.cancel(); autoSaveTimer.destroy(); autoSaveTimer = null; } } return saveDataDirty = value; } /** * A timer used to auto-save the chart after a period of inactivity. */ var autoSaveTimer:Null = null; /** * Whether the difficulty tree view in the toolbox has been modified and needs to be updated. * This happens when we add/remove difficulties. */ var difficultySelectDirty:Bool = true; /** * Whether the character select view in the toolbox has been modified and needs to be updated. * This happens when we add/remove characters. */ var characterSelectDirty:Bool = true; /** * Whether the player preview toolbox have been modified and need to be updated. * This happens when we switch characters. */ var playerPreviewDirty:Bool = true; /** * Whether the opponent preview toolbox have been modified and need to be updated. * This happens when we switch characters. */ var opponentPreviewDirty:Bool = true; var isInPlaytestMode:Bool = false; /** * The list of command previously performed. Used for undoing previous actions. */ var undoHistory:Array = []; /** * The list of commands that have been undone. Used for redoing previous actions. */ var redoHistory:Array = []; /** * Handler used to track how long the user has been holding the undo keybind. */ var undoKeyHandler:TurboKeyHandler = TurboKeyHandler.build([FlxKey.CONTROL, FlxKey.Z]); /** * Variable used to track how long the user has been holding the redo keybind. */ var redoKeyHandler:TurboKeyHandler = TurboKeyHandler.build([FlxKey.CONTROL, FlxKey.Y]); /** * Variable used to track how long the user has been holding the up keybind. */ var upKeyHandler:TurboKeyHandler = TurboKeyHandler.build(FlxKey.UP); /** * Variable used to track how long the user has been holding the down keybind. */ var downKeyHandler:TurboKeyHandler = TurboKeyHandler.build(FlxKey.DOWN); /** * Variable used to track how long the user has been holding the page-up keybind. */ var pageUpKeyHandler:TurboKeyHandler = TurboKeyHandler.build(FlxKey.PAGEUP); /** * Variable used to track how long the user has been holding the page-down keybind. */ var pageDownKeyHandler:TurboKeyHandler = TurboKeyHandler.build(FlxKey.PAGEDOWN); /** * Whether the undo/redo histories have changed since the last time the UI was updated. */ var commandHistoryDirty:Bool = true; /** * The notes which are currently in the user's selection. */ var currentNoteSelection:Array = []; /** * The events which are currently in the user's selection. */ var currentEventSelection:Array = []; /** * The position where the user clicked to start a selection. * `null` if the user isn't currently selecting anything. * The selection box extends from this point to the current mouse position. */ var selectionBoxStartPos:Null = null; /** * Whether the user's last mouse click was on the playhead scroll area. */ var gridPlayheadScrollAreaPressed:Bool = false; /** * Where the user's last mouse click was on the note preview scroll area. * `null` if the user isn't clicking on the note preview. */ var notePreviewScrollAreaStartPos:Null = null; /** * The SongNoteData which is currently being placed. * `null` if the user isn't currently placing a note. * As the user drags, we will update this note's sustain length. */ var currentPlaceNoteData:Null = null; /** * The Dialog components representing the currently available tool windows. * Dialogs are retained here even when collapsed or hidden. */ var activeToolboxes:Map = new Map(); /** * AUDIO AND SOUND DATA */ // ============================== /** * The audio track for the instrumental. * `null` until an instrumental track is loaded. */ var audioInstTrack:Null = null; /** * The raw byte data for the instrumental audio track. * `null` until an instrumental track is loaded. */ var audioInstTrackData:Null = null; /** * The audio track for the vocals. * `null` until vocal track(s) are loaded. */ var audioVocalTrackGroup:Null = null; /** * A map of the audio tracks for each character's vocals. * - Keys are the character IDs. * - Values are the FlxSound objects to play that character's vocals. * * When switching characters, the elements of the VoicesGroup will be swapped to match the new character. */ var audioVocalTrackData:Map = []; /** * CHART DATA */ // ============================== /** * The song metadata. * - Keys are the variation IDs. At least one (`default`) must exist. * - Values are the relevant metadata, ready to be serialized to JSON. */ var songMetadata:Map = []; /** * Retrieves the list of variations for the current song. */ var availableVariations(get, never):Array; function get_availableVariations():Array { var variations:Array = [for (x in songMetadata.keys()) x]; variations.sort(SortUtil.defaultThenAlphabetically.bind('default')); return variations; } /** * Retrieves the list of difficulties for the current variation of the current song. * ONLY CONTAINS DIFFICULTIES FOR THE CURRENT VARIATION so if on the default variation, erect/nightmare won't be included. */ var availableDifficulties(get, never):Array; function get_availableDifficulties():Array { var m:Null = songMetadata.get(selectedVariation); return m?.playData?.difficulties ?? []; } /** * Retrieves the list of difficulties for ALL variations of the current song. */ var allDifficulties(get, never):Array; function get_allDifficulties():Array { var result:Array> = [ for (x in availableVariations) { var m:Null = songMetadata.get(x); m?.playData?.difficulties ?? []; } ]; return result.flatten(); } /** * The song chart data. * - Keys are the variation IDs. At least one (`default`) must exist. * - Values are the relevant chart data, ready to be serialized to JSON. */ var songChartData:Map = []; /** * Convenience property to get the chart data for the current variation. */ var currentSongMetadata(get, set):SongMetadata; function get_currentSongMetadata():SongMetadata { var result:Null = songMetadata.get(selectedVariation); if (result == null) { result = new SongMetadata('Dad Battle', 'Kawai Sprite', selectedVariation); songMetadata.set(selectedVariation, result); } return result; } function set_currentSongMetadata(value:SongMetadata):SongMetadata { songMetadata.set(selectedVariation, value); return value; } /** * Convenience property to get the chart data for the current variation. */ var currentSongChartData(get, set):SongChartData; function get_currentSongChartData():SongChartData { var result:Null = songChartData.get(selectedVariation); if (result == null) { result = new SongChartData(["normal" => 1.0], [], ["normal" => []]); songChartData.set(selectedVariation, result); } return result; } function set_currentSongChartData(value:SongChartData):SongChartData { songChartData.set(selectedVariation, value); return value; } /** * Convenience property to get (and set) the scroll speed for the current difficulty. */ var currentSongChartScrollSpeed(get, set):Float; function get_currentSongChartScrollSpeed():Float { var result:Null = currentSongChartData.scrollSpeed.get(selectedDifficulty); if (result == null) { // Initialize to the default value if not set. currentSongChartData.scrollSpeed.set(selectedDifficulty, 1.0); return 1.0; } return result; } function set_currentSongChartScrollSpeed(value:Float):Float { currentSongChartData.scrollSpeed.set(selectedDifficulty, value); return value; } /** * Convenience property to get the note data for the current difficulty. */ var currentSongChartNoteData(get, set):Array; function get_currentSongChartNoteData():Array { var result:Array = currentSongChartData.notes.get(selectedDifficulty); if (result == null) { // Initialize to the default value if not set. result = []; trace('Initializing blank note data for difficulty ' + selectedDifficulty); currentSongChartData.notes.set(selectedDifficulty, result); return result; } return result; } function set_currentSongChartNoteData(value:Array):Array { currentSongChartData.notes.set(selectedDifficulty, value); return value; } /** * Convenience property to get the event data for the current difficulty. */ var currentSongChartEventData(get, set):Array; function get_currentSongChartEventData():Array { if (currentSongChartData.events == null) { // Initialize to the default value if not set. currentSongChartData.events = []; } return currentSongChartData.events; } function set_currentSongChartEventData(value:Array):Array { return currentSongChartData.events = value; } public var currentSongNoteSkin(get, set):String; function get_currentSongNoteSkin():String { if (currentSongMetadata.playData.noteSkin == null) { // Initialize to the default value if not set. currentSongMetadata.playData.noteSkin = 'Normal'; } return currentSongMetadata.playData.noteSkin; } function set_currentSongNoteSkin(value:String):String { return currentSongMetadata.playData.noteSkin = value; } var currentSongStage(get, set):String; function get_currentSongStage():String { if (currentSongMetadata.playData.stage == null) { // Initialize to the default value if not set. currentSongMetadata.playData.stage = 'mainStage'; } return currentSongMetadata.playData.stage; } function set_currentSongStage(value:String):String { return currentSongMetadata.playData.stage = value; } var currentSongName(get, set):String; function get_currentSongName():String { if (currentSongMetadata.songName == null) { // Initialize to the default value if not set. currentSongMetadata.songName = 'New Song'; } return currentSongMetadata.songName; } function set_currentSongName(value:String):String { return currentSongMetadata.songName = value; } var currentSongId(get, never):String; function get_currentSongId():String { return currentSongName.toLowerKebabCase().replace('.', '').replace(' ', '-'); } var currentSongArtist(get, set):String; function get_currentSongArtist():String { if (currentSongMetadata.artist == null) { // Initialize to the default value if not set. currentSongMetadata.artist = 'Unknown'; } return currentSongMetadata.artist; } function set_currentSongArtist(value:String):String { return currentSongMetadata.artist = value; } var currentSongPlayableCharacters(get, never):Array; function get_currentSongPlayableCharacters():Array { 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; } function set_currentSongCharacterPlayer(value:String):String { if (!currentSongPlayableCharacters.contains(value)) { trace('Invalid character selected: ' + value); return value; } return selectedCharacter = 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; } function set_currentSongCharacterOpponent(value:String):String { var playableCharData:SongPlayableChar = currentSongMetadata.playData.playableChars.get(selectedCharacter); return playableCharData.opponent = value; } /** * SIGNALS */ // ============================== // public var onDifficultyChange(default, never):FlxTypedSignalVoid> = new FlxTypedSignalVoid>(); /** * RENDER OBJECTS */ // ============================== /** * The IMAGE used for the grid. Updated by ChartEditorThemeHandler. */ var gridBitmap:Null = null; /** * The IMAGE used for the selection squares. Updated by ChartEditorThemeHandler. * Used two ways: * 1. A sprite is given this bitmap and placed over selected notes. * 2. The image is split and used for a 9-slice sprite for the selection box. */ var selectionSquareBitmap:Null = null; /** * The IMAGE used for the note preview bitmap. Updated by ChartEditorThemeHandler. * The image is split and used for a 9-slice sprite for the box over the note preview. */ var notePreviewViewportBitmap:Null = null; /** * The tiled sprite used to display the grid. * The height is the length of the song, and scrolling is done by simply the sprite. */ var gridTiledSprite:Null = null; /** * The playhead representing the current position in the song. * Can move around on the grid independently of the view. */ var gridPlayhead:FlxSpriteGroup = new FlxSpriteGroup(); var gridPlayheadScrollArea:Null = null; /** * A sprite used to indicate the note that will be placed on click. */ var gridGhostNote:Null = null; /** * A sprite used to indicate the event that will be placed on click. */ var gridGhostEvent:Null = null; /** * The waveform which (optionally) displays over the grid, underneath the notes and playhead. */ var gridSpectrogram:Null = null; /** * The sprite used to display the note preview area. * We move this up and down to scroll the preview. */ var notePreview:Null = null; /** * The rectangular sprite used for representing the current viewport on the note preview. * We move this up and down and resize it to represent the visible area. */ var notePreviewViewport:Null = null; /** * The rectangular sprite used for rendering the selection box. * Uses a 9-slice to stretch the selection box to the correct size without warping. */ var selectionBoxSprite:Null = null; /** * The opponent's health icon. */ var healthIconDad:Null = null; /** * The player's health icon. */ var healthIconBF:Null = null; /** * The purple background sprite. */ var menuBG:Null = null; /** * The layout containing the playbar head slider. */ var playbarHeadLayout:Null = null; /** * The playbar head slider. */ var playbarHead:Null = null; /** * The current process that is lerping the scroll position. * Used to cancel the previous lerp if the user scrolls again. */ var currentScrollEase:Null; /** * The sprite group containing the note graphics. * Only displays a subset of the data from `currentSongChartNoteData`, * and kills notes that are off-screen to be recycled later. */ var renderedNotes:FlxTypedSpriteGroup = new FlxTypedSpriteGroup(); /** * The sprite group containing the hold note graphics. * Only displays a subset of the data from `currentSongChartNoteData`, * and kills notes that are off-screen to be recycled later. */ var renderedHoldNotes:FlxTypedSpriteGroup = new FlxTypedSpriteGroup(); /** * The sprite group containing the song events. * Only displays a subset of the data from `currentSongChartEventData`, * and kills events that are off-screen to be recycled later. */ var renderedEvents:FlxTypedSpriteGroup = new FlxTypedSpriteGroup(); var renderedSelectionSquares:FlxTypedSpriteGroup = new FlxTypedSpriteGroup(); public function new() { // Load the HaxeUI XML file. super(CHART_EDITOR_LAYOUT); } override function create():Void { // super.create() must be called first, the HaxeUI components get created here. super.create(); // Set the z-index of the HaxeUI. this.component.zIndex = 100; fixCamera(); // Get rid of any music from the previous state. FlxG.sound.music.stop(); buildDefaultSongData(); buildBackground(); ChartEditorThemeHandler.updateTheme(this); buildGrid(); // buildSpectrogram(audioInstTrack); buildNotePreview(); buildSelectionBox(); buildAdditionalUI(); // Setup the onClick listeners for the UI after it's been created. setupUIListeners(); setupTurboKeyHandlers(); setupAutoSave(); refresh(); ChartEditorDialogHandler.openWelcomeDialog(this, false); } function buildDefaultSongData():Void { selectedVariation = Constants.DEFAULT_VARIATION; selectedDifficulty = Constants.DEFAULT_DIFFICULTY; // Initialize the song metadata. songMetadata = new Map(); // Initialize the song chart data. songChartData = new Map(); audioVocalTrackGroup = new VoicesGroup(); } /** * Builds and displays the background sprite. */ function buildBackground():Void { menuBG = new FlxSprite().loadGraphic(Paths.image('menuDesat')); add(menuBG); menuBG.setGraphicSize(Std.int(menuBG.width * 1.1)); menuBG.updateHitbox(); menuBG.screenCenter(); menuBG.scrollFactor.set(0, 0); menuBG.zIndex = -100; } /** * Builds and displays the chart editor grid, including the playhead and cursor. */ function buildGrid():Void { if (gridBitmap == null) throw 'ERROR: Tried to build grid, but gridBitmap is null! Check ChartEditorThemeHandler.updateTheme().'; gridTiledSprite = new FlxTiledSprite(gridBitmap, gridBitmap.width, 1000, false, true); gridTiledSprite.x = FlxG.width / 2 - GRID_SIZE * STRUMLINE_SIZE; // Center the grid. gridTiledSprite.y = MENU_BAR_HEIGHT + GRID_TOP_PAD; // Push down to account for the menu bar. add(gridTiledSprite); gridTiledSprite.zIndex = 10; gridGhostNote = new ChartEditorNoteSprite(this); gridGhostNote.alpha = 0.6; gridGhostNote.noteData = new SongNoteData(0, 0, 0, ""); gridGhostNote.visible = false; add(gridGhostNote); gridGhostNote.zIndex = 11; gridGhostEvent = new ChartEditorEventSprite(this); gridGhostEvent.alpha = 0.6; gridGhostEvent.eventData = new SongEventData(-1, '', {}); gridGhostEvent.visible = false; add(gridGhostEvent); gridGhostEvent.zIndex = 12; buildNoteGroup(); gridPlayheadScrollArea = new FlxSprite(0, 0); gridPlayheadScrollArea.makeGraphic(10, 10, PLAYHEAD_SCROLL_AREA_COLOR); // Make it 10x10px and then scale it as needed. add(gridPlayheadScrollArea); gridPlayheadScrollArea.setGraphicSize(PLAYHEAD_SCROLL_AREA_WIDTH, 3000); gridPlayheadScrollArea.updateHitbox(); gridPlayheadScrollArea.x = gridTiledSprite.x - PLAYHEAD_SCROLL_AREA_WIDTH; gridPlayheadScrollArea.y = MENU_BAR_HEIGHT + GRID_TOP_PAD; gridPlayheadScrollArea.zIndex = 25; // The playhead that show the current position in the song. add(gridPlayhead); gridPlayhead.zIndex = 30; var playheadWidth:Int = GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + (PLAYHEAD_SCROLL_AREA_WIDTH * 2); var playheadBaseYPos:Float = MENU_BAR_HEIGHT + GRID_TOP_PAD; gridPlayhead.setPosition(gridTiledSprite.x, playheadBaseYPos); var playheadSprite:FlxSprite = new FlxSprite().makeGraphic(playheadWidth, PLAYHEAD_HEIGHT, PLAYHEAD_COLOR); playheadSprite.x = -PLAYHEAD_SCROLL_AREA_WIDTH; playheadSprite.y = 0; gridPlayhead.add(playheadSprite); var playheadBlock:FlxSprite = ChartEditorThemeHandler.buildPlayheadBlock(); playheadBlock.x = -PLAYHEAD_SCROLL_AREA_WIDTH; playheadBlock.y = -PLAYHEAD_HEIGHT / 2; gridPlayhead.add(playheadBlock); // Character icons. healthIconDad = new HealthIcon(currentSongCharacterOpponent); healthIconDad.autoUpdate = false; healthIconDad.size.set(0.5, 0.5); healthIconDad.x = gridTiledSprite.x - 15 - (HealthIcon.HEALTH_ICON_SIZE * 0.5); healthIconDad.y = gridTiledSprite.y + 5; add(healthIconDad); healthIconDad.zIndex = 30; healthIconBF = new HealthIcon(currentSongCharacterPlayer); healthIconBF.autoUpdate = false; healthIconBF.size.set(0.5, 0.5); healthIconBF.x = gridTiledSprite.x + gridTiledSprite.width + 15; healthIconBF.y = gridTiledSprite.y + 5; healthIconBF.flipX = true; add(healthIconBF); healthIconBF.zIndex = 30; } function buildSelectionBox():Void { if (selectionBoxSprite == null) throw 'ERROR: Tried to build selection box, but selectionBoxSprite is null! Check ChartEditorThemeHandler.updateTheme().'; selectionBoxSprite.scrollFactor.set(0, 0); add(selectionBoxSprite); selectionBoxSprite.zIndex = 30; setSelectionBoxBounds(); } function setSelectionBoxBounds(bounds:FlxRect = null):Void { if (selectionBoxSprite == null) throw 'ERROR: Tried to set selection box bounds, but selectionBoxSprite is null! Check ChartEditorThemeHandler.updateTheme().'; if (bounds == null) { selectionBoxSprite.visible = false; selectionBoxSprite.x = -9999; selectionBoxSprite.y = -9999; } else { selectionBoxSprite.visible = true; selectionBoxSprite.x = bounds.x; selectionBoxSprite.y = bounds.y; selectionBoxSprite.width = bounds.width; selectionBoxSprite.height = bounds.height; } } function buildNotePreview():Void { var height:Int = FlxG.height - MENU_BAR_HEIGHT - GRID_TOP_PAD - PLAYBAR_HEIGHT - GRID_TOP_PAD - GRID_TOP_PAD; notePreview = new ChartEditorNotePreview(height); notePreview.x = 350; notePreview.y = MENU_BAR_HEIGHT + GRID_TOP_PAD; add(notePreview); if (notePreviewViewport == null) throw 'ERROR: Tried to build note preview, but notePreviewViewport is null! Check ChartEditorThemeHandler.updateTheme().'; notePreviewViewport.scrollFactor.set(0, 0); add(notePreviewViewport); notePreviewViewport.zIndex = 30; setNotePreviewViewportBounds(calculateNotePreviewViewportBounds()); } function calculateNotePreviewViewportBounds():FlxRect { var bounds:FlxRect = new FlxRect(); // Return 0, 0, 0, 0 if the note preview doesn't exist for some reason. if (notePreview == null) return bounds; // Horizontal position and width are constant. bounds.x = notePreview.x; bounds.width = notePreview.width; // Vertical position depends on scroll position. bounds.y = notePreview.y + (notePreview.height * (scrollPositionInPixels / songLengthInPixels)); // Height depends on the viewport size. bounds.height = notePreview.height * (FlxG.height / songLengthInPixels); // Make sure the viewport doesn't go off the top or bottom of the note preview. if (bounds.y < notePreview.y) { bounds.height -= notePreview.y - bounds.y; bounds.y = notePreview.y; } else if (bounds.y + bounds.height > notePreview.y + notePreview.height) { bounds.height -= (bounds.y + bounds.height) - (notePreview.y + notePreview.height); } var MIN_HEIGHT:Int = 8; if (bounds.height < MIN_HEIGHT) { bounds.y -= MIN_HEIGHT - bounds.height; bounds.height = MIN_HEIGHT; } return bounds; } function setNotePreviewViewportBounds(bounds:FlxRect = null):Void { if (notePreviewViewport == null) throw 'ERROR: Tried to set note preview viewport bounds, but notePreviewViewport is null! Check ChartEditorThemeHandler.updateTheme().'; if (bounds == null) { notePreviewViewport.visible = false; notePreviewViewport.x = -9999; notePreviewViewport.y = -9999; } else { notePreviewViewport.visible = true; notePreviewViewport.x = bounds.x; notePreviewViewport.y = bounds.y; notePreviewViewport.width = bounds.width; notePreviewViewport.height = bounds.height; } } function buildSpectrogram(target:FlxSound):Void { gridSpectrogram = new PolygonSpectogram(FlxG.sound.music, FlxColor.RED, FlxG.height / 2, Math.floor(FlxG.height / 2)); gridSpectrogram.x += 170; gridSpectrogram.scrollFactor.set(); gridSpectrogram.waveAmplitude = 50; gridSpectrogram.visType = UPDATED; add(gridSpectrogram); } /** * Builds the group that will hold all the notes. */ function buildNoteGroup():Void { if (gridTiledSprite == null) throw 'ERROR: Tried to build note groups, but gridTiledSprite is null! Check ChartEditorState.buildGrid().'; renderedHoldNotes.setPosition(gridTiledSprite.x, gridTiledSprite.y); add(renderedHoldNotes); renderedHoldNotes.zIndex = 24; renderedNotes.setPosition(gridTiledSprite.x, gridTiledSprite.y); add(renderedNotes); renderedNotes.zIndex = 25; renderedEvents.setPosition(gridTiledSprite.x, gridTiledSprite.y); add(renderedEvents); renderedNotes.zIndex = 25; renderedSelectionSquares.setPosition(gridTiledSprite.x, gridTiledSprite.y); add(renderedSelectionSquares); renderedNotes.zIndex = 26; } function buildAdditionalUI():Void { playbarHeadLayout = buildComponent(CHART_EDITOR_PLAYBARHEAD_LAYOUT); if (playbarHeadLayout == null) throw 'ERROR: Failed to construct playbarHeadLayout! Check "${CHART_EDITOR_PLAYBARHEAD_LAYOUT}".'; playbarHeadLayout.zIndex = 110; playbarHeadLayout.width = FlxG.width - 8; playbarHeadLayout.height = 10; playbarHeadLayout.x = 4; playbarHeadLayout.y = FlxG.height - 48 - 8; playbarHead = playbarHeadLayout.findComponent('playbarHead', Slider); if (playbarHead == null) throw 'ERROR: Failed to fetch playbarHead from playbarHeadLayout! Check "${CHART_EDITOR_PLAYBARHEAD_LAYOUT}".'; playbarHead.allowFocus = false; playbarHead.width = FlxG.width; playbarHead.height = 10; playbarHead.styleString = 'padding-left: 0px; padding-right: 0px; border-left: 0px; border-right: 0px;'; playbarHead.onDragStart = function(_:DragEvent) { playbarHeadDragging = true; // If we were dragging the playhead while the song was playing, resume playing. if (audioInstTrack != null && audioInstTrack.playing) { playbarHeadDraggingWasPlaying = true; stopAudioPlayback(); } else { playbarHeadDraggingWasPlaying = false; } } playbarHead.onDragEnd = function(_:DragEvent) { playbarHeadDragging = false; // Set the song position to where the playhead was moved to. scrollPositionInPixels = songLengthInPixels * (playbarHead?.value ?? 0 / 100); // Update the conductor and audio tracks to match. moveSongToScrollPosition(); // If we were dragging the playhead while the song was playing, resume playing. if (playbarHeadDraggingWasPlaying) { playbarHeadDraggingWasPlaying = false; // Disabled code to resume song playback on drag. // startAudioPlayback(); } } add(playbarHeadLayout); // Setup notifications. @:privateAccess // NotificationManager.GUTTER_SIZE = 56; NotificationManager.GUTTER_SIZE = 20; } /** * Sets up the onClick listeners for the UI. */ function setupUIListeners():Void { // Add functionality to the playbar. addUIClickListener('playbarPlay', _ -> toggleAudioPlayback()); addUIClickListener('playbarStart', _ -> playbarButtonPressed = 'playbarStart'); addUIClickListener('playbarBack', _ -> playbarButtonPressed = 'playbarBack'); addUIClickListener('playbarForward', _ -> playbarButtonPressed = 'playbarForward'); addUIClickListener('playbarEnd', _ -> playbarButtonPressed = 'playbarEnd'); // Add functionality to the menu items. addUIClickListener('menubarItemNewChart', _ -> ChartEditorDialogHandler.openWelcomeDialog(this, true)); addUIClickListener('menubarItemOpenChart', _ -> ChartEditorDialogHandler.openBrowseWizard(this, true)); addUIClickListener('menubarItemSaveChartAs', _ -> exportAllSongData()); addUIClickListener('menubarItemLoadInst', _ -> ChartEditorDialogHandler.openUploadInstDialog(this, true)); addUIClickListener('menubarItemImportChart', _ -> ChartEditorDialogHandler.openImportChartDialog(this, 'legacy', true)); addUIClickListener('menubarItemUndo', _ -> undoLastCommand()); addUIClickListener('menubarItemRedo', _ -> redoLastCommand()); addUIClickListener('menubarItemCopy', function(_) { // Doesn't use a command because it's not undoable. // Calculate a single time offset for all the notes and events. var timeOffset:Null = currentNoteSelection.length > 0 ? Std.int(currentNoteSelection[0].time) : null; if (currentEventSelection.length > 0) { if (timeOffset == null || currentEventSelection[0].time < timeOffset) { timeOffset = Std.int(currentEventSelection[0].time); } } SongDataUtils.writeItemsToClipboard( { notes: SongDataUtils.buildNoteClipboard(currentNoteSelection, timeOffset), events: SongDataUtils.buildEventClipboard(currentEventSelection, timeOffset), }); }); addUIClickListener('menubarItemCut', _ -> performCommand(new CutItemsCommand(currentNoteSelection, currentEventSelection))); addUIClickListener('menubarItemPaste', _ -> performCommand(new PasteItemsCommand(scrollPositionInMs + playheadPositionInMs))); addUIClickListener('menubarItemDelete', function(_) { if (currentNoteSelection.length > 0 && currentEventSelection.length > 0) { performCommand(new RemoveItemsCommand(currentNoteSelection, currentEventSelection)); } else if (currentNoteSelection.length > 0) { performCommand(new RemoveNotesCommand(currentNoteSelection)); } else if (currentEventSelection.length > 0) { performCommand(new RemoveEventsCommand(currentEventSelection)); } else { // Do nothing??? } }); addUIClickListener('menubarItemSelectAll', _ -> performCommand(new SelectAllItemsCommand(currentNoteSelection, currentEventSelection))); addUIClickListener('menubarItemSelectInverse', _ -> performCommand(new InvertSelectedItemsCommand(currentNoteSelection, currentEventSelection))); addUIClickListener('menubarItemSelectNone', _ -> performCommand(new DeselectAllItemsCommand(currentNoteSelection, currentEventSelection))); // TODO: Implement these. // addUIClickListener('menubarItemSelectRegion', _ -> doSomething()); // addUIClickListener('menubarItemSelectBeforeCursor', _ -> doSomething()); // addUIClickListener('menubarItemSelectAfterCursor', _ -> doSomething()); addUIClickListener('menubarItemPlaytestFull', _ -> testSongInPlayState(false)); addUIClickListener('menubarItemPlaytestMinimal', _ -> testSongInPlayState(true)); addUIChangeListener('menubarItemInputStyleGroup', function(event:UIEvent) { trace('Change input style: ${event.target}'); }); addUIClickListener('menubarItemAbout', _ -> ChartEditorDialogHandler.openAboutDialog(this)); addUIClickListener('menubarItemUserGuide', _ -> ChartEditorDialogHandler.openUserGuideDialog(this)); addUIChangeListener('menubarItemDownscroll', event -> isViewDownscroll = event.value); setUICheckboxSelected('menubarItemDownscroll', isViewDownscroll); addUIClickListener('menubarItemDifficultyUp', _ -> incrementDifficulty(1)); addUIClickListener('menubarItemDifficultyDown', _ -> incrementDifficulty(-1)); addUIChangeListener('menubarItemPlaytestStartTime', event -> playtestStartTime = event.value); setUICheckboxSelected('menubarItemPlaytestStartTime', playtestStartTime); addUIChangeListener('menuBarItemThemeLight', function(event:UIEvent) { if (event.target.value) currentTheme = ChartEditorTheme.Light; }); setUICheckboxSelected('menuBarItemThemeLight', currentTheme == ChartEditorTheme.Light); addUIChangeListener('menuBarItemThemeDark', function(event:UIEvent) { if (event.target.value) currentTheme = ChartEditorTheme.Dark; }); setUICheckboxSelected('menuBarItemThemeDark', currentTheme == ChartEditorTheme.Dark); addUIChangeListener('menubarItemMetronomeEnabled', event -> isMetronomeEnabled = event.value); setUICheckboxSelected('menubarItemMetronomeEnabled', isMetronomeEnabled); addUIChangeListener('menubarItemPlayerHitsounds', event -> hitsoundsEnabledPlayer = event.value); setUICheckboxSelected('menubarItemPlayerHitsounds', hitsoundsEnabledPlayer); addUIChangeListener('menubarItemOpponentHitsounds', event -> hitsoundsEnabledOpponent = event.value); setUICheckboxSelected('menubarItemOpponentHitsounds', hitsoundsEnabledOpponent); var instVolumeLabel:Null