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; 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.SongCharacterData; 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.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 { /** * 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_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<String> = ['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<Int> = [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<String> = 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<Dynamic> = {}; /** * 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<CharacterPlayer> = null; /** * The character sprite in the Opponent Preview window. * `null` until accessed. */ var currentOpponentCharacterPlayer:Null<CharacterPlayer> = 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; // Characters have probably changed too. healthIconsDirty = true; 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; /** * Setter called when we are switching variations. * We will likely need to switch instrumentals as well. */ function set_selectedVariation(value:String):String { // Don't update if we're already on the variation. if (selectedVariation == value) return selectedVariation; selectedVariation = value; // Make sure view is updated when the variation changes. noteDisplayDirty = true; notePreviewDirty = true; notePreviewViewportBoundsDirty = true; switchToCurrentInstrumental(); 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 instrumental ID which is currently selected. */ var currentInstrumentalId(get, set):String; function get_currentInstrumentalId():String { var instId:Null<String> = currentSongMetadata.playData.characters.instrumental; if (instId == null || instId == '') instId = (selectedVariation == Constants.DEFAULT_VARIATION) ? '' : selectedVariation; return instId; } function set_currentInstrumentalId(value:String):String { return currentSongMetadata.playData.characters.instrumental = value; } /** * 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 selected charactesr have been modified and the health icons need to be updated. */ var healthIconsDirty:Bool = true; /** * Whether the note preview graphic needs to be FULLY rebuilt. */ var notePreviewDirty(default, set):Bool = true; function set_notePreviewDirty(value:Bool):Bool { trace('Note preview dirtied!'); return notePreviewDirty = value; } 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<FlxTimer> = 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<ChartEditorCommand> = []; /** * The list of commands that have been undone. Used for redoing previous actions. */ var redoHistory:Array<ChartEditorCommand> = []; /** * 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 W keybind. */ var wKeyHandler:TurboKeyHandler = TurboKeyHandler.build(FlxKey.W); /** * Variable used to track how long the user has been holding the S keybind. */ var sKeyHandler:TurboKeyHandler = TurboKeyHandler.build(FlxKey.S); /** * 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<SongNoteData> = []; /** * The events which are currently in the user's selection. */ var currentEventSelection:Array<SongEventData> = []; /** * 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<FlxPoint> = 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<FlxPoint> = 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<SongNoteData> = null; /** * The Dialog components representing the currently available tool windows. * Dialogs are retained here even when collapsed or hidden. */ var activeToolboxes:Map<String, CollapsibleDialog> = new Map<String, CollapsibleDialog>(); /** * AUDIO AND SOUND DATA */ // ============================== /** * The chill audio track that plays when you open the Chart Editor. */ public var welcomeMusic:FlxSound = new FlxSound(); /** * The audio track for the instrumental. * Replaced when switching instrumentals. * `null` until an instrumental track is loaded. */ var audioInstTrack:Null<FlxSound> = null; /** * The raw byte data for the instrumental audio tracks. * Key is the instrumental name. * `null` until an instrumental track is loaded. */ var audioInstTrackData:Map<String, Bytes> = []; /** * The audio track for the vocals. * `null` until vocal track(s) are loaded. * When switching characters, the elements of the VoicesGroup will be swapped to match the new character. */ var audioVocalTrackGroup:Null<VoicesGroup> = null; /** * A map of the audio tracks for each character's vocals. * - Keys are `characterId-variation` (with `characterId` being the default variation). * - Values are the byte data for the audio track. */ var audioVocalTrackData:Map<String, Bytes> = []; /** * 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<String, SongMetadata> = []; /** * Retrieves the list of variations for the current song. */ var availableVariations(get, never):Array<String>; function get_availableVariations():Array<String> { var variations:Array<String> = [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<String>; function get_availableDifficulties():Array<String> { var m:Null<SongMetadata> = songMetadata.get(selectedVariation); return m?.playData?.difficulties ?? []; } /** * Retrieves the list of difficulties for ALL variations of the current song. */ var allDifficulties(get, never):Array<String>; function get_allDifficulties():Array<String> { var result:Array<Array<String>> = [ for (x in availableVariations) { var m:Null<SongMetadata> = 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<String, SongChartData> = []; /** * Convenience property to get the chart data for the current variation. */ var currentSongMetadata(get, set):SongMetadata; function get_currentSongMetadata():SongMetadata { var result:Null<SongMetadata> = 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> = 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<Float> = 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<SongNoteData>; function get_currentSongChartNoteData():Array<SongNoteData> { var result:Array<SongNoteData> = 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<SongNoteData>):Array<SongNoteData> { currentSongChartData.notes.set(selectedDifficulty, value); return value; } /** * Convenience property to get the event data for the current difficulty. */ var currentSongChartEventData(get, set):Array<SongEventData>; function get_currentSongChartEventData():Array<SongEventData> { if (currentSongChartData.events == null) { // Initialize to the default value if not set. currentSongChartData.events = []; } return currentSongChartData.events; } function set_currentSongChartEventData(value:Array<SongEventData>):Array<SongEventData> { return currentSongChartData.events = value; } public var currentSongNoteStyle(get, set):String; function get_currentSongNoteStyle():String { if (currentSongMetadata.playData.noteSkin == null) { // Initialize to the default value if not set. currentSongMetadata.playData.noteSkin = 'funkin'; } return currentSongMetadata.playData.noteSkin; } function set_currentSongNoteStyle(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; } /** * SIGNALS */ // ============================== // public var onDifficultyChange(default, never):FlxTypedSignal<ChartEditorState->Void> = new FlxTypedSignal<ChartEditorState->Void>(); /** * RENDER OBJECTS */ // ============================== /** * The IMAGE used for the grid. Updated by ChartEditorThemeHandler. */ var gridBitmap:Null<BitmapData> = 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<BitmapData> = 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<BitmapData> = 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<FlxSprite> = 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<FlxSprite> = null; /** * A sprite used to indicate the note that will be placed on click. */ var gridGhostNote:Null<ChartEditorNoteSprite> = null; /** * A sprite used to indicate the note that will be placed on click. */ var gridGhostHoldNote:Null<ChartEditorHoldNoteSprite> = null; /** * A sprite used to indicate the event that will be placed on click. */ var gridGhostEvent:Null<ChartEditorEventSprite> = null; /** * The waveform which (optionally) displays over the grid, underneath the notes and playhead. */ var gridSpectrogram:Null<PolygonSpectogram> = null; /** * The sprite used to display the note preview area. * We move this up and down to scroll the preview. */ var notePreview:Null<ChartEditorNotePreview> = 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<FlxSliceSprite> = 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<FlxSliceSprite> = null; /** * The opponent's health icon. */ var healthIconDad:Null<HealthIcon> = null; /** * The player's health icon. */ var healthIconBF:Null<HealthIcon> = null; /** * The purple background sprite. */ var menuBG:Null<FlxSprite> = null; /** * The layout containing the playbar head slider. */ var playbarHeadLayout:Null<Component> = null; /** * The playbar head slider. */ var playbarHead:Null<Slider> = null; /** * The label by the playbar telling the song position. */ var playbarSongPos:Null<Label> = null; /** * The label by the playbar telling the song time remaining. */ var playbarSongRemaining:Null<Label> = null; /** * The label by the playbar telling the note snap. */ var playbarNoteSnap:Null<Label> = null; /** * The current process that is lerping the scroll position. * Used to cancel the previous lerp if the user scrolls again. */ var currentScrollEase:Null<VarTween>; /** * 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<ChartEditorNoteSprite> = new FlxTypedSpriteGroup<ChartEditorNoteSprite>(); /** * 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<ChartEditorHoldNoteSprite> = new FlxTypedSpriteGroup<ChartEditorHoldNoteSprite>(); /** * 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<ChartEditorEventSprite> = new FlxTypedSpriteGroup<ChartEditorEventSprite>(); var renderedSelectionSquares:FlxTypedSpriteGroup<FlxSprite> = new FlxTypedSpriteGroup<FlxSprite>(); 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; // Show the mouse cursor. Cursor.show(); fixCamera(); // Get rid of any music from the previous state. FlxG.sound.music.stop(); // Play the welcome music. setupWelcomeMusic(); 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 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; selectedDifficulty = Constants.DEFAULT_DIFFICULTY; // Initialize the song metadata. songMetadata = new Map<String, SongMetadata>(); // Initialize the song chart data. songChartData = new Map<String, SongChartData>(); 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; gridGhostHoldNote = new ChartEditorHoldNoteSprite(this); gridGhostHoldNote.alpha = 0.6; gridGhostHoldNote.noteData = new SongNoteData(0, 0, 0, ""); gridGhostHoldNote.visible = false; add(gridGhostHoldNote); gridGhostHoldNote.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(currentSongMetadata.playData.characters.opponent); 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(currentSongMetadata.playData.characters.player); 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; } public function switchToCurrentInstrumental():Void { ChartEditorAudioHandler.switchToInstrumental(this, currentInstrumentalId, currentSongMetadata.playData.characters.player, currentSongMetadata.playData.characters.opponent); } 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', _ -> ChartEditorImportExportHandler.exportAllSongData(this)); addUIClickListener('menubarItemLoadInst', _ -> ChartEditorDialogHandler.openUploadInstDialog(this, true)); addUIClickListener('menubarItemImportChart', _ -> ChartEditorDialogHandler.openImportChartDialog(this, 'legacy', true)); addUIClickListener('menubarItemExit', _ -> quitChartEditor()); 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<Int> = 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))); addUIClickListener('menubarItemPlaytestFull', _ -> testSongInPlayState(false)); addUIClickListener('menubarItemPlaytestMinimal', _ -> testSongInPlayState(true)); addUIClickListener('menuBarItemNoteSnapDecrease', _ -> noteSnapQuantIndex--); addUIClickListener('menuBarItemNoteSnapIncrease', _ -> noteSnapQuantIndex++); addUIChangeListener('menuBarItemInputStyleNone', function(event:UIEvent) { currentLiveInputStyle = None; }); addUIChangeListener('menuBarItemInputStyleNumberKeys', function(event:UIEvent) { currentLiveInputStyle = NumberKeys; }); addUIChangeListener('menuBarItemInputStyleWASD', function(event:UIEvent) { currentLiveInputStyle = WASD; }); addUIClickListener('menubarItemAbout', _ -> ChartEditorDialogHandler.openAboutDialog(this)); addUIClickListener('menubarItemWelcomeDialog', _ -> ChartEditorDialogHandler.openWelcomeDialog(this, true)); 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); addUIClickListener('menubarItemPlayPause', _ -> toggleAudioPlayback()); addUIClickListener('menubarItemLoadInstrumental', _ -> ChartEditorDialogHandler.openUploadInstDialog(this, true)); addUIClickListener('menubarItemLoadVocals', _ -> ChartEditorDialogHandler.openUploadVocalsDialog(this, true)); 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<Label> = findComponent('menubarLabelVolumeInstrumental', Label); if (instVolumeLabel != null) { addUIChangeListener('menubarItemVolumeInstrumental', function(event:UIEvent) { var volume:Float = (event?.value ?? 0) / 100.0; if (audioInstTrack != null) audioInstTrack.volume = volume; instVolumeLabel.text = 'Instrumental - ${Std.int(event.value)}%'; }); } var vocalsVolumeLabel:Null<Label> = findComponent('menubarLabelVolumeVocals', Label); if (vocalsVolumeLabel != null) { addUIChangeListener('menubarItemVolumeVocals', function(event:UIEvent) { var volume:Float = (event?.value ?? 0) / 100.0; if (audioVocalTrackGroup != null) audioVocalTrackGroup.volume = volume; vocalsVolumeLabel.text = 'Vocals - ${Std.int(event.value)}%'; }); } var playbackSpeedLabel:Null<Label> = findComponent('menubarLabelPlaybackSpeed', Label); if (playbackSpeedLabel != null) { addUIChangeListener('menubarItemPlaybackSpeed', function(event:UIEvent) { var pitch:Float = event.value * 2.0 / 100.0; pitch = Math.floor(pitch / 0.25) * 0.25; // Round to nearest 0.25. #if FLX_PITCH if (audioInstTrack != null) audioInstTrack.pitch = pitch; if (audioVocalTrackGroup != null) audioVocalTrackGroup.pitch = pitch; #end var pitchDisplay:Float = Std.int(pitch * 100) / 100; // Round to 2 decimal places. playbackSpeedLabel.text = 'Playback Speed - ${pitchDisplay}x'; }); } 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('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', event -> ChartEditorToolboxHandler.setToolboxState(this, CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT, event.value)); // TODO: Pass specific HaxeUI components to add context menus to them. registerContextMenu(null, Paths.ui('chart-editor/context/test')); } /** * Initialize TurboKeyHandlers and add them to the state (so `update()` is called) * We can then probe `keyHandler.activated` to see if the key combo's action should be taken. */ function setupTurboKeyHandlers():Void { add(undoKeyHandler); add(redoKeyHandler); add(upKeyHandler); add(downKeyHandler); add(wKeyHandler); add(sKeyHandler); add(pageUpKeyHandler); add(pageDownKeyHandler); } /** * Setup timers and listerners to handle auto-save. */ function setupAutoSave():Void { WindowUtil.windowExit.add(onWindowClose); saveDataDirty = false; } /** * Called after 5 minutes without saving. */ function autoSave():Void { saveDataDirty = false; // Auto-save the chart. #if html5 // Auto-save to local storage. #else // Auto-save to temp file. ChartEditorImportExportHandler.exportAllSongData(this, true); #end } function onWindowClose(exitCode:Int):Void { trace('Window exited with exit code: $exitCode'); trace('Should save chart? $saveDataDirty'); if (saveDataDirty) { ChartEditorImportExportHandler.exportAllSongData(this, true); } } function cleanupAutoSave():Void { WindowUtil.windowExit.remove(onWindowClose); } public override function update(elapsed:Float):Void { // Override F4 behavior to include the autosave. if (FlxG.keys.justPressed.F4) { quitChartEditor(); return; } // dispatchEvent gets called here. super.update(elapsed); // These ones happen even if the modal dialog is open. handleMusicPlayback(); handleNoteDisplay(); // These ones only happen if the modal dialog is not open. handleScrollKeybinds(); handleSnap(); handleCursor(); handleMenubar(); handleToolboxes(); handlePlaybar(); handlePlayhead(); handleNotePreview(); handleHealthIcons(); handleFileKeybinds(); handleEditKeybinds(); handleViewKeybinds(); handleTestKeybinds(); handleHelpKeybinds(); } /** * Beat hit while the song is playing. */ override function beatHit():Bool { // dispatchEvent gets called here. if (!super.beatHit()) return false; if (isMetronomeEnabled && this.subState == null && (audioInstTrack != null && audioInstTrack.playing)) { playMetronomeTick(Conductor.currentBeat % 4 == 0); } return true; } /** * Step hit while the song is playing. */ override function stepHit():Bool { // dispatchEvent gets called here. if (!super.stepHit()) return false; if (audioInstTrack != null && audioInstTrack.playing) { if (healthIconDad != null) healthIconDad.onStepHit(Conductor.currentStep); if (healthIconBF != null) healthIconBF.onStepHit(Conductor.currentStep); } // Updating these every step keeps it more accurate. // playerPreviewDirty = true; // opponentPreviewDirty = true; return true; } /** * Handle keybinds for scrolling the chart editor grid. **/ function handleScrollKeybinds():Void { // Don't scroll when the cursor is over the UI, unless a playbar button (the << >> ones) is pressed. if (isCursorOverHaxeUI && playbarButtonPressed == null) return; var scrollAmount:Float = 0; // Amount to scroll the grid. var playheadAmount:Float = 0; // Amount to scroll the playhead relative to the grid. var shouldPause:Bool = false; // Whether to pause the song when scrolling. var shouldEase:Bool = false; // Whether to ease the scroll. // Mouse Wheel = Scroll if (FlxG.mouse.wheel != 0 && !FlxG.keys.pressed.CONTROL) { scrollAmount = -50 * FlxG.mouse.wheel; shouldPause = true; } // Up Arrow = Scroll Up if (upKeyHandler.activated && currentLiveInputStyle == None) { scrollAmount = -GRID_SIZE * 0.25 * 25.0; shouldPause = true; } // Down Arrow = Scroll Down if (downKeyHandler.activated && currentLiveInputStyle == None) { scrollAmount = GRID_SIZE * 0.25 * 25.0; shouldPause = true; } // W = Scroll Up (doesn't work with Ctrl+Scroll) if (wKeyHandler.activated && currentLiveInputStyle == None && !FlxG.keys.pressed.CONTROL) { scrollAmount = -GRID_SIZE * 0.25 * 25.0; shouldPause = true; } // S = Scroll Down (doesn't work with Ctrl+Scroll) if (sKeyHandler.activated && currentLiveInputStyle == None && !FlxG.keys.pressed.CONTROL) { scrollAmount = GRID_SIZE * 0.25 * 25.0; shouldPause = true; } // PAGE UP = Jump up to nearest measure if (pageUpKeyHandler.activated) { var measureHeight:Float = GRID_SIZE * 4 * Conductor.beatsPerMeasure; var playheadPos:Float = scrollPositionInPixels + playheadPositionInPixels; var targetScrollPosition:Float = Math.floor(playheadPos / measureHeight) * measureHeight; // If we would move less than one grid, instead move to the top of the previous measure. var targetScrollAmount = Math.abs(targetScrollPosition - playheadPos); if (targetScrollAmount < GRID_SIZE) { targetScrollPosition -= GRID_SIZE * Constants.STEPS_PER_BEAT * Conductor.beatsPerMeasure; } scrollAmount = targetScrollPosition - playheadPos; shouldPause = true; } if (playbarButtonPressed == 'playbarBack') { playbarButtonPressed = ''; scrollAmount = -GRID_SIZE * 4 * Conductor.beatsPerMeasure; shouldPause = true; } // PAGE DOWN = Jump down to nearest measure if (pageDownKeyHandler.activated) { var measureHeight:Float = GRID_SIZE * 4 * Conductor.beatsPerMeasure; var playheadPos:Float = scrollPositionInPixels + playheadPositionInPixels; var targetScrollPosition:Float = Math.ceil(playheadPos / measureHeight) * measureHeight; // If we would move less than one grid, instead move to the top of the next measure. var targetScrollAmount = Math.abs(targetScrollPosition - playheadPos); if (targetScrollAmount < GRID_SIZE) { targetScrollPosition += GRID_SIZE * Constants.STEPS_PER_BEAT * Conductor.beatsPerMeasure; } scrollAmount = targetScrollPosition - playheadPos; shouldPause = true; } if (playbarButtonPressed == 'playbarForward') { playbarButtonPressed = ''; scrollAmount = GRID_SIZE * 4 * Conductor.beatsPerMeasure; shouldPause = true; } // Middle Mouse + Drag = Scroll but move the playhead the same amount. if (FlxG.mouse.pressedMiddle) { if (FlxG.mouse.deltaY != 0) { // Scroll down by the amount dragged. scrollAmount += -FlxG.mouse.deltaY; // Move the playhead by the same amount in the other direction so it is stationary. playheadAmount += FlxG.mouse.deltaY; } } // SHIFT + Scroll = Scroll Fast if (FlxG.keys.pressed.SHIFT) { scrollAmount *= 2; } // CONTROL + Scroll = Scroll Precise if (FlxG.keys.pressed.CONTROL) { scrollAmount /= 10; } // ALT = Move playhead instead. if (FlxG.keys.pressed.ALT) { playheadAmount = scrollAmount; scrollAmount = 0; shouldPause = false; } // HOME = Scroll to Top if (FlxG.keys.justPressed.HOME) { // Scroll amount is the difference between the current position and the top. scrollAmount = 0 - this.scrollPositionInPixels; playheadAmount = 0 - this.playheadPositionInPixels; shouldPause = true; } if (playbarButtonPressed == 'playbarStart') { playbarButtonPressed = ''; scrollAmount = 0 - this.scrollPositionInPixels; playheadAmount = 0 - this.playheadPositionInPixels; shouldPause = true; } // END = Scroll to Bottom if (FlxG.keys.justPressed.END) { // Scroll amount is the difference between the current position and the bottom. scrollAmount = this.songLengthInPixels - this.scrollPositionInPixels; shouldPause = true; } if (playbarButtonPressed == 'playbarEnd') { playbarButtonPressed = ''; scrollAmount = this.songLengthInPixels - this.scrollPositionInPixels; shouldPause = true; } if (Math.abs(scrollAmount) > GRID_SIZE * 8) { shouldEase = true; } // Resync the conductor and audio tracks. if (scrollAmount != 0 || playheadAmount != 0) { this.playheadPositionInPixels += playheadAmount; if (shouldEase) { easeSongToScrollPosition(this.scrollPositionInPixels + scrollAmount); } else { // Apply the scroll amount. this.scrollPositionInPixels += scrollAmount; moveSongToScrollPosition(); } } if (shouldPause) stopAudioPlayback(); } function handleSnap():Void { if (currentLiveInputStyle == None) { if (FlxG.keys.justPressed.LEFT && !FlxG.keys.pressed.CONTROL) { noteSnapQuantIndex--; } if (FlxG.keys.justPressed.RIGHT && !FlxG.keys.pressed.CONTROL) { noteSnapQuantIndex++; } } } /** * Handle display of the mouse cursor. */ function handleCursor():Void { // Note: If a menu is open in HaxeUI, don't handle cursor behavior. var shouldHandleCursor:Bool = !isCursorOverHaxeUI || (selectionBoxStartPos != null); var eventColumn:Int = (STRUMLINE_SIZE * 2 + 1) - 1; if (shouldHandleCursor) { // Over the course of this big conditional block, // we determine what the cursor should look like, // and fall back to the default cursor if none of the conditions are met. var targetCursorMode:Null<CursorMode> = null; if (gridTiledSprite == null) throw "ERROR: Tried to handle cursor, but gridTiledSprite is null! Check ChartEditorState.buildGrid()"; var overlapsGrid:Bool = FlxG.mouse.overlaps(gridTiledSprite); // Cursor position relative to the grid. var cursorX:Float = FlxG.mouse.screenX - gridTiledSprite.x; var cursorY:Float = FlxG.mouse.screenY - gridTiledSprite.y; var overlapsSelectionBorder:Bool = overlapsGrid && ((cursorX % 40) < (GRID_SELECTION_BORDER_WIDTH / 2) || (cursorX % 40) > (40 - (GRID_SELECTION_BORDER_WIDTH / 2)) || (cursorY % 40) < (GRID_SELECTION_BORDER_WIDTH / 2) || (cursorY % 40) > (40 - (GRID_SELECTION_BORDER_WIDTH / 2))); if (FlxG.mouse.justPressed) { if (gridPlayheadScrollArea != null && FlxG.mouse.overlaps(gridPlayheadScrollArea)) { gridPlayheadScrollAreaPressed = true; } else if (notePreview != null && FlxG.mouse.overlaps(notePreview)) { // Clicked note preview notePreviewScrollAreaStartPos = new FlxPoint(FlxG.mouse.screenX, FlxG.mouse.screenY); } else if (!overlapsGrid || overlapsSelectionBorder) { selectionBoxStartPos = new FlxPoint(FlxG.mouse.screenX, FlxG.mouse.screenY); // Drawing selection box. targetCursorMode = Crosshair; } else { // Deselect all items. if (currentNoteSelection.length > 0 || currentEventSelection.length > 0) { trace('Clicked outside grid, deselecting all items.'); performCommand(new DeselectAllItemsCommand(currentNoteSelection, currentEventSelection)); } } } if (gridPlayheadScrollAreaPressed && FlxG.mouse.released) { gridPlayheadScrollAreaPressed = false; } if (notePreviewScrollAreaStartPos != null && FlxG.mouse.released) { notePreviewScrollAreaStartPos = null; } if (gridPlayheadScrollAreaPressed) { // Clicked on the playhead scroll area. // Move the playhead to the cursor position. this.playheadPositionInPixels = FlxG.mouse.screenY - MENU_BAR_HEIGHT - GRID_TOP_PAD; moveSongToScrollPosition(); // Cursor should be a grabby hand. if (targetCursorMode == null) targetCursorMode = Grabbing; } // The song position of the cursor, in steps. var cursorFractionalStep:Float = cursorY / GRID_SIZE; var cursorMs:Float = Conductor.getStepTimeInMs(cursorFractionalStep); // Round the cursor step to the nearest snap quant. var cursorSnappedStep:Float = Math.floor(cursorFractionalStep / noteSnapRatio) * noteSnapRatio; var cursorSnappedMs:Float = Conductor.getStepTimeInMs(cursorSnappedStep); // The direction value for the column at the cursor. var cursorColumn:Int = Math.floor(cursorX / GRID_SIZE); if (cursorColumn < 0) cursorColumn = 0; if (cursorColumn >= (STRUMLINE_SIZE * 2 + 1 - 1)) { // Don't invert the event column. cursorColumn = (STRUMLINE_SIZE * 2 + 1 - 1); } else { // Invert player and opponent columns. if (cursorColumn >= STRUMLINE_SIZE) { cursorColumn -= STRUMLINE_SIZE; } else { cursorColumn += STRUMLINE_SIZE; } } if (selectionBoxStartPos != null) { var cursorXStart:Float = selectionBoxStartPos.x - gridTiledSprite.x; var cursorYStart:Float = selectionBoxStartPos.y - gridTiledSprite.y; var hasDraggedMouse:Bool = Math.abs(cursorX - cursorXStart) > DRAG_THRESHOLD || Math.abs(cursorY - cursorYStart) > DRAG_THRESHOLD; // Determine if we dragged the mouse at all. if (hasDraggedMouse) { // Handle releasing the selection box. if (FlxG.mouse.justReleased) { // We released the mouse. Select the notes in the box. var cursorFractionalStepStart:Float = cursorYStart / GRID_SIZE; var cursorStepStart:Int = Math.floor(cursorFractionalStepStart); var cursorMsStart:Float = Conductor.getStepTimeInMs(cursorStepStart); var cursorColumnBase:Int = Math.floor(cursorX / GRID_SIZE); var cursorColumnBaseStart:Int = Math.floor(cursorXStart / GRID_SIZE); // Since this selects based on noteData directly, // we don't need to specifically exclude sustain pieces. // This logic is gross because the columns go 4567-0123-8. // We build a list of columns to select. var columnStart:Int = Std.int(Math.min(cursorColumnBase, cursorColumnBaseStart)); var columnEnd:Int = Std.int(Math.max(cursorColumnBase, cursorColumnBaseStart)); var columns:Array<Int> = [for (i in columnStart...(columnEnd + 1)) i].map(function(i:Int):Int { if (i >= eventColumn) { // Don't invert the event column. return eventColumn; } else if (i >= STRUMLINE_SIZE) { // Invert the player columns. return i - STRUMLINE_SIZE; } else if (i >= 0) { // Invert the opponent columns. return i + STRUMLINE_SIZE; } else { // Minimum of 0. return 0; } }); if (columns.length > 0) { var notesToSelect:Array<SongNoteData> = currentSongChartNoteData; notesToSelect = SongDataUtils.getNotesInTimeRange(notesToSelect, Math.min(cursorMsStart, cursorMs), Math.max(cursorMsStart, cursorMs)); notesToSelect = SongDataUtils.getNotesWithData(notesToSelect, columns); var eventsToSelect:Array<SongEventData> = []; if (columns.indexOf(eventColumn) != -1) { // The drag selection included the event column. eventsToSelect = currentSongChartEventData; eventsToSelect = SongDataUtils.getEventsInTimeRange(eventsToSelect, Math.min(cursorMsStart, cursorMs), Math.max(cursorMsStart, cursorMs)); } if (notesToSelect.length > 0 || eventsToSelect.length > 0) { if (FlxG.keys.pressed.CONTROL) { // Add to the selection. performCommand(new SelectItemsCommand(notesToSelect, eventsToSelect)); } else { // Set the selection. performCommand(new SetItemSelectionCommand(notesToSelect, eventsToSelect, currentNoteSelection, currentEventSelection)); } } else { // We made a selection box, but it didn't select anything. if (!FlxG.keys.pressed.CONTROL) { // Deselect all items. if (currentNoteSelection.length > 0 || currentEventSelection.length > 0) { trace('Clicked and dragged outside grid, deselecting all items.'); performCommand(new DeselectAllItemsCommand(currentNoteSelection, currentEventSelection)); } } } } else { // We made a selection box, but it didn't select any columns. } // Clear the selection box. selectionBoxStartPos = null; setSelectionBoxBounds(); } else { // Clicking and dragging. // Scroll the screen if the mouse is above or below the grid. if (FlxG.mouse.screenY < MENU_BAR_HEIGHT) { // Scroll up. var diff:Float = MENU_BAR_HEIGHT - FlxG.mouse.screenY; scrollPositionInPixels -= diff * 0.5; // Too fast! trace('Scroll up: ' + diff); moveSongToScrollPosition(); } else if (FlxG.mouse.screenY > (playbarHeadLayout?.y ?? 0.0)) { // Scroll down. var diff:Float = FlxG.mouse.screenY - (playbarHeadLayout?.y ?? 0.0); scrollPositionInPixels += diff * 0.5; // Too fast! trace('Scroll down: ' + diff); moveSongToScrollPosition(); } // Render the selection box. var selectionRect:FlxRect = new FlxRect(); selectionRect.x = Math.min(FlxG.mouse.screenX, selectionBoxStartPos.x); selectionRect.y = Math.min(FlxG.mouse.screenY, selectionBoxStartPos.y); selectionRect.width = Math.abs(FlxG.mouse.screenX - selectionBoxStartPos.x); selectionRect.height = Math.abs(FlxG.mouse.screenY - selectionBoxStartPos.y); setSelectionBoxBounds(selectionRect); targetCursorMode = Crosshair; } } else if (FlxG.mouse.justReleased) { // Clear the selection box. selectionBoxStartPos = null; setSelectionBoxBounds(); if (overlapsGrid) { // We clicked on the grid without moving the mouse. // Find the first note that is at the cursor position. var highlightedNote:Null<ChartEditorNoteSprite> = renderedNotes.members.find(function(note:ChartEditorNoteSprite):Bool { // If note.alive is false, the note is dead and awaiting recycling. return note.alive && FlxG.mouse.overlaps(note); }); var highlightedEvent:Null<ChartEditorEventSprite> = null; if (highlightedNote == null) { highlightedEvent = renderedEvents.members.find(function(event:ChartEditorEventSprite):Bool { return event.alive && FlxG.mouse.overlaps(event); }); } if (FlxG.keys.pressed.CONTROL) { if (highlightedNote != null && highlightedNote.noteData != null) { // TODO: Handle the case of clicking on a sustain piece. // Control click to select/deselect an individual note. if (isNoteSelected(highlightedNote.noteData)) { performCommand(new DeselectItemsCommand([highlightedNote.noteData], [])); } else { performCommand(new SelectItemsCommand([highlightedNote.noteData], [])); } } else if (highlightedEvent != null && highlightedEvent.eventData != null) { // Control click to select/deselect an individual note. if (isEventSelected(highlightedEvent.eventData)) { performCommand(new DeselectItemsCommand([], [highlightedEvent.eventData])); } else { performCommand(new SelectItemsCommand([], [highlightedEvent.eventData])); } } else { // Do nothing if you control-clicked on an empty space. } } else { if (highlightedNote != null && highlightedNote.noteData != null) { // Click a note to select it. performCommand(new SetItemSelectionCommand([highlightedNote.noteData], [], currentNoteSelection, currentEventSelection)); } else if (highlightedEvent != null && highlightedEvent.eventData != null) { // Click an event to select it. performCommand(new SetItemSelectionCommand([], [highlightedEvent.eventData], currentNoteSelection, currentEventSelection)); } else { // Click on an empty space to deselect everything. performCommand(new DeselectAllItemsCommand(currentNoteSelection, currentEventSelection)); } } } else { // If we clicked and released outside the grid. if (!FlxG.keys.pressed.CONTROL) { // Deselect all items. if (currentNoteSelection.length > 0 || currentEventSelection.length > 0) { trace('Clicked outside grid, deselecting all items.'); performCommand(new DeselectAllItemsCommand(currentNoteSelection, currentEventSelection)); } } } } } else if (notePreviewScrollAreaStartPos != null) { // Player is clicking and holding on note preview to scrub around. targetCursorMode = Grabbing; var clickedPosInPixels:Float = FlxMath.remapToRange(FlxG.mouse.screenY, (notePreview?.y ?? 0.0), (notePreview?.y ?? 0.0) + (notePreview?.height ?? 0.0), 0, songLengthInPixels); scrollPositionInPixels = clickedPosInPixels; moveSongToScrollPosition(); } else if (currentPlaceNoteData != null) { // Handle extending the note as you drag. var stepTime:Float = inline currentPlaceNoteData.getStepTime(); var dragLengthSteps:Float = Conductor.getTimeInSteps(cursorSnappedMs) - stepTime; var dragLengthMs:Float = dragLengthSteps * Conductor.stepLengthMs; var dragLengthPixels:Float = dragLengthSteps * GRID_SIZE; if (dragLengthSteps > 0) { gridGhostHoldNote.visible = true; gridGhostHoldNote.noteData = gridGhostNote.noteData; gridGhostHoldNote.noteDirection = gridGhostNote.noteData.getDirection(); gridGhostHoldNote.setHeightDirectly(dragLengthPixels); gridGhostHoldNote.updateHoldNotePosition(renderedHoldNotes); } else { gridGhostHoldNote.visible = false; } if (FlxG.mouse.justReleased) { if (dragLengthSteps > 0) { // Apply the new length. performCommand(new ExtendNoteLengthCommand(currentPlaceNoteData, dragLengthMs)); } // Finished dragging. Release the note. currentPlaceNoteData = null; } } else { if (FlxG.mouse.justPressed) { // Just clicked to place a note. if (overlapsGrid && !overlapsSelectionBorder) { // We clicked on the grid without moving the mouse. // Find the first note that is at the cursor position. var highlightedNote:Null<ChartEditorNoteSprite> = renderedNotes.members.find(function(note:ChartEditorNoteSprite):Bool { // If note.alive is false, the note is dead and awaiting recycling. return note.alive && FlxG.mouse.overlaps(note); }); var highlightedEvent:Null<ChartEditorEventSprite> = null; if (highlightedNote == null) { highlightedEvent = renderedEvents.members.find(function(event:ChartEditorEventSprite):Bool { // If event.alive is false, the event is dead and awaiting recycling. return event.alive && FlxG.mouse.overlaps(event); }); } if (FlxG.keys.pressed.CONTROL) { // Control click to select/deselect an individual note. if (highlightedNote != null && highlightedNote.noteData != null) { if (isNoteSelected(highlightedNote.noteData)) { performCommand(new DeselectItemsCommand([highlightedNote.noteData], [])); } else { performCommand(new SelectItemsCommand([highlightedNote.noteData], [])); } } else if (highlightedEvent != null && highlightedEvent.eventData != null) { if (isEventSelected(highlightedEvent.eventData)) { performCommand(new DeselectItemsCommand([], [highlightedEvent.eventData])); } else { performCommand(new SelectItemsCommand([], [highlightedEvent.eventData])); } } else { // Do nothing when control clicking nothing. } } else { if (highlightedNote != null && highlightedNote.noteData != null) { // Click a note to select it. performCommand(new SetItemSelectionCommand([highlightedNote.noteData], [], currentNoteSelection, currentEventSelection)); } else if (highlightedEvent != null && highlightedEvent.eventData != null) { // Click an event to select it. performCommand(new SetItemSelectionCommand([], [highlightedEvent.eventData], currentNoteSelection, currentEventSelection)); } else { // Click a blank space to place a note and select it. if (cursorColumn == eventColumn) { // Create an event and place it in the chart. // TODO: Figure out configuring event data. var newEventData:SongEventData = new SongEventData(cursorSnappedMs, selectedEventKind, selectedEventData); performCommand(new AddEventsCommand([newEventData], FlxG.keys.pressed.CONTROL)); } else { // Create a note and place it in the chart. var newNoteData:SongNoteData = new SongNoteData(cursorSnappedMs, cursorColumn, 0, selectedNoteKind); performCommand(new AddNotesCommand([newNoteData], FlxG.keys.pressed.CONTROL)); currentPlaceNoteData = newNoteData; } } } } else { // If we clicked and released outside the grid, do nothing. } } var rightMouseUpdated:Bool = (FlxG.mouse.justPressedRight) || (FlxG.mouse.pressedRight && (FlxG.mouse.deltaX > 0 || FlxG.mouse.deltaY > 0)); if (rightMouseUpdated && overlapsGrid) { // We right clicked on the grid. // Find the first note that is at the cursor position. var highlightedNote:Null<ChartEditorNoteSprite> = renderedNotes.members.find(function(note:ChartEditorNoteSprite):Bool { // If note.alive is false, the note is dead and awaiting recycling. return note.alive && FlxG.mouse.overlaps(note); }); var highlightedEvent:Null<ChartEditorEventSprite> = null; if (highlightedNote == null) { highlightedEvent = renderedEvents.members.find(function(event:ChartEditorEventSprite):Bool { // If event.alive is false, the event is dead and awaiting recycling. return event.alive && FlxG.mouse.overlaps(event); }); } if (highlightedNote != null && highlightedNote.noteData != null) { // TODO: Handle the case of clicking on a sustain piece. // Remove the note. performCommand(new RemoveNotesCommand([highlightedNote.noteData])); } else if (highlightedEvent != null && highlightedEvent.eventData != null) { // Remove the event. performCommand(new RemoveEventsCommand([highlightedEvent.eventData])); } else { // Right clicked on nothing. } } // Handle grid cursor. if (overlapsGrid && !overlapsSelectionBorder && !gridPlayheadScrollAreaPressed) { // Indicate that we can place a note here. if (cursorColumn == eventColumn) { if (gridGhostNote != null) gridGhostNote.visible = false; gridGhostHoldNote.visible = false; if (gridGhostEvent == null) throw "ERROR: Tried to handle cursor, but gridGhostEvent is null! Check ChartEditorState.buildGrid()"; var eventData:SongEventData = gridGhostEvent.eventData != null ? gridGhostEvent.eventData : new SongEventData(cursorMs, selectedEventKind, null); if (selectedEventKind != eventData.event) { eventData.event = selectedEventKind; } eventData.time = cursorSnappedMs; gridGhostEvent.visible = true; gridGhostEvent.eventData = eventData; gridGhostEvent.updateEventPosition(renderedEvents); targetCursorMode = Cell; } else { if (gridGhostEvent != null) gridGhostEvent.visible = false; if (gridGhostNote == null) throw "ERROR: Tried to handle cursor, but gridGhostNote is null! Check ChartEditorState.buildGrid()"; var noteData:SongNoteData = gridGhostNote.noteData != null ? gridGhostNote.noteData : new SongNoteData(cursorMs, cursorColumn, 0, selectedNoteKind); if (cursorColumn != noteData.data || selectedNoteKind != noteData.kind) { noteData.kind = selectedNoteKind; noteData.data = cursorColumn; gridGhostNote.playNoteAnimation(); } noteData.time = cursorSnappedMs; gridGhostNote.visible = true; gridGhostNote.noteData = noteData; gridGhostNote.updateNotePosition(renderedNotes); targetCursorMode = Cell; } } else { if (gridGhostNote != null) gridGhostNote.visible = false; if (gridGhostHoldNote != null) gridGhostHoldNote.visible = false; if (gridGhostEvent != null) gridGhostEvent.visible = false; } } if (targetCursorMode == null) { if (FlxG.mouse.pressed) { if (overlapsSelectionBorder) { targetCursorMode = Crosshair; } } else { if (FlxG.mouse.overlaps(notePreview)) { targetCursorMode = Pointer; } else if (FlxG.mouse.overlaps(gridPlayheadScrollArea)) { targetCursorMode = Pointer; } else if (overlapsSelectionBorder) { targetCursorMode = Crosshair; } else if (overlapsGrid) { targetCursorMode = Cell; } } } // Actually set the cursor mode to the one we specified earlier. Cursor.cursorMode = targetCursorMode ?? Default; } else { if (gridGhostNote != null) gridGhostNote.visible = false; if (gridGhostHoldNote != null) gridGhostHoldNote.visible = false; if (gridGhostEvent != null) gridGhostEvent.visible = false; // Do not set Cursor.cursorMode here, because it will be set by the HaxeUI. } } /** * Handle using `renderedNotes` to display notes from `currentSongChartNoteData`. */ function handleNoteDisplay():Void { if (noteDisplayDirty) { noteDisplayDirty = false; // Update for whether downscroll is enabled. renderedNotes.flipX = (isViewDownscroll); // Calculate the top and bottom of the view area. var viewAreaTopPixels:Float = MENU_BAR_HEIGHT; var visibleGridHeightPixels:Float = FlxG.height - MENU_BAR_HEIGHT - PLAYBAR_HEIGHT; // The area underneath the menu bar and playbar is not visible. var viewAreaBottomPixels:Float = viewAreaTopPixels + visibleGridHeightPixels; // Remove notes that are no longer visible and list the ones that are. var displayedNoteData:Array<SongNoteData> = []; for (noteSprite in renderedNotes.members) { if (noteSprite == null || noteSprite.noteData == null || !noteSprite.exists || !noteSprite.visible) continue; if (!noteSprite.isNoteVisible(viewAreaBottomPixels, viewAreaTopPixels)) { // This sprite is off-screen. // Kill the note sprite and recycle it. noteSprite.noteData = null; } else if (!currentSongChartNoteData.fastContains(noteSprite.noteData)) { // This note was deleted. // Kill the note sprite and recycle it. noteSprite.noteData = null; } else { // Note is already displayed and should remain displayed. displayedNoteData.push(noteSprite.noteData); // Update the note sprite's position. noteSprite.updateNotePosition(renderedNotes); } } // Sort the note data array, using an algorithm that is fast on nearly-sorted data. // We need this sorted to optimize indexing later. displayedNoteData.insertionSort(SortUtil.noteDataByTime.bind(FlxSort.ASCENDING)); var displayedHoldNoteData:Array<SongNoteData> = []; for (holdNoteSprite in renderedHoldNotes.members) { if (holdNoteSprite == null || holdNoteSprite.noteData == null || !holdNoteSprite.exists || !holdNoteSprite.visible) continue; if (!holdNoteSprite.isHoldNoteVisible(FlxG.height - MENU_BAR_HEIGHT, GRID_TOP_PAD)) { holdNoteSprite.kill(); } else if (!currentSongChartNoteData.fastContains(holdNoteSprite.noteData) || holdNoteSprite.noteData.length == 0) { // This hold note was deleted. // Kill the hold note sprite and recycle it. holdNoteSprite.kill(); } else if (displayedHoldNoteData.fastContains(holdNoteSprite.noteData)) { // This hold note is a duplicate. // Kill the hold note sprite and recycle it. holdNoteSprite.kill(); } else { displayedHoldNoteData.push(holdNoteSprite.noteData); // Update the event sprite's position. holdNoteSprite.updateHoldNotePosition(renderedNotes); } } // Sort the note data array, using an algorithm that is fast on nearly-sorted data. // We need this sorted to optimize indexing later. displayedHoldNoteData.insertionSort(SortUtil.noteDataByTime.bind(FlxSort.ASCENDING)); // Remove events that are no longer visible and list the ones that are. var displayedEventData:Array<SongEventData> = []; for (eventSprite in renderedEvents.members) { if (eventSprite == null || eventSprite.eventData == null || !eventSprite.exists || !eventSprite.visible) continue; if (!eventSprite.isEventVisible(FlxG.height - MENU_BAR_HEIGHT, GRID_TOP_PAD)) { // This sprite is off-screen. // Kill the event sprite and recycle it. eventSprite.eventData = null; } else if (!currentSongChartEventData.fastContains(eventSprite.eventData)) { // This event was deleted. // Kill the event sprite and recycle it. eventSprite.eventData = null; } else { // Event is already displayed and should remain displayed. displayedEventData.push(eventSprite.eventData); // Update the event sprite's position. eventSprite.updateEventPosition(renderedEvents); } } // Sort the note data array, using an algorithm that is fast on nearly-sorted data. // We need this sorted to optimize indexing later. displayedEventData.insertionSort(SortUtil.eventDataByTime.bind(FlxSort.ASCENDING)); // Let's try testing only notes within a certain range of the view area. // TODO: I don't think this messes up really long sustains, does it? var viewAreaTopMs:Float = scrollPositionInMs - (Conductor.measureLengthMs * 2); // Is 2 measures enough? var viewAreaBottomMs:Float = scrollPositionInMs + (Conductor.measureLengthMs * 2); // Is 2 measures enough? // Add notes that are now visible. for (noteData in currentSongChartNoteData) { // Remember if we are already displaying this note. if (noteData == null) continue; // Check if we are outside a broad range around the view area. if (noteData.time < viewAreaTopMs || noteData.time > viewAreaBottomMs) continue; if (displayedNoteData.fastContains(noteData)) { continue; } if (!ChartEditorNoteSprite.wouldNoteBeVisible(viewAreaBottomPixels, viewAreaTopPixels, noteData, renderedNotes)) continue; // Else, this note is visible and we need to render it! // Get a note sprite from the pool. // If we can reuse a deleted note, do so. // If a new note is needed, call buildNoteSprite. var noteSprite:ChartEditorNoteSprite = renderedNotes.recycle(() -> new ChartEditorNoteSprite(this)); // trace('Creating new Note... (${renderedNotes.members.length})'); noteSprite.parentState = this; // The note sprite handles animation playback and positioning. noteSprite.noteData = noteData; // Setting note data resets the position relative to the group! // If we don't update the note position AFTER setting the note data, the note will be rendered offscreen at y=5000. noteSprite.updateNotePosition(renderedNotes); // Add hold notes that are now visible (and not already displayed). if (noteSprite.noteData != null && noteSprite.noteData.length > 0 && displayedHoldNoteData.indexOf(noteSprite.noteData) == -1) { var holdNoteSprite:ChartEditorHoldNoteSprite = renderedHoldNotes.recycle(() -> new ChartEditorHoldNoteSprite(this)); // trace('Creating new HoldNote... (${renderedHoldNotes.members.length})'); var noteLengthPixels:Float = noteSprite.noteData.getStepLength() * GRID_SIZE; holdNoteSprite.noteData = noteSprite.noteData; holdNoteSprite.noteDirection = noteSprite.noteData.getDirection(); holdNoteSprite.setHeightDirectly(noteLengthPixels); holdNoteSprite.updateHoldNotePosition(renderedHoldNotes); } } // Add events that are now visible. for (eventData in currentSongChartEventData) { // Remember if we are already displaying this event. if (displayedEventData.indexOf(eventData) != -1) { continue; } if (!ChartEditorEventSprite.wouldEventBeVisible(viewAreaBottomPixels, viewAreaTopPixels, eventData, renderedNotes)) continue; // Else, this event is visible and we need to render it! // Get an event sprite from the pool. // If we can reuse a deleted event, do so. // If a new event is needed, call buildEventSprite. var eventSprite:ChartEditorEventSprite = renderedEvents.recycle(() -> new ChartEditorEventSprite(this), false, true); eventSprite.parentState = this; trace('Creating new Event... (${renderedEvents.members.length})'); // The event sprite handles animation playback and positioning. eventSprite.eventData = eventData; // Setting event data resets position relative to the grid so we fix that. eventSprite.x += renderedEvents.x; eventSprite.y += renderedEvents.y; } // Add hold notes that have been made visible (but not their parents) for (noteData in currentSongChartNoteData) { // Is the note a hold note? if (noteData == null || noteData.length <= 0) continue; // Is the hold note rendered already? if (displayedHoldNoteData.indexOf(noteData) != -1) continue; // Is the hold note offscreen? if (!ChartEditorHoldNoteSprite.wouldHoldNoteBeVisible(viewAreaBottomPixels, viewAreaTopPixels, noteData, renderedHoldNotes)) continue; // Hold note should be rendered. var holdNoteFactory = function() { // TODO: Print some kind of warning if `renderedHoldNotes.members` is too high? return new ChartEditorHoldNoteSprite(this); } var holdNoteSprite:ChartEditorHoldNoteSprite = renderedHoldNotes.recycle(holdNoteFactory); var noteLengthPixels:Float = noteData.getStepLength() * GRID_SIZE; holdNoteSprite.noteData = noteData; holdNoteSprite.noteDirection = noteData.getDirection(); holdNoteSprite.setHeightDirectly(noteLengthPixels); holdNoteSprite.updateHoldNotePosition(renderedHoldNotes); displayedHoldNoteData.push(noteData); } // Destroy all existing selection squares. for (member in renderedSelectionSquares.members) { // Killing the sprite is cheap because we can recycle it. member.kill(); } // Readd selection squares for selected notes. // Recycle selection squares if possible. for (noteSprite in renderedNotes.members) { // TODO: Handle selection of hold notes. if (isNoteSelected(noteSprite.noteData)) { var selectionSquare:FlxSprite = renderedSelectionSquares.recycle(buildSelectionSquare); // Set the position and size (because we might be recycling one with bad values). selectionSquare.x = noteSprite.x; selectionSquare.y = noteSprite.y; selectionSquare.width = noteSprite.width; selectionSquare.height = noteSprite.height; } } for (eventSprite in renderedEvents.members) { if (isEventSelected(eventSprite.eventData)) { var selectionSquare:FlxSprite = renderedSelectionSquares.recycle(buildSelectionSquare); // Set the position and size (because we might be recycling one with bad values). selectionSquare.x = eventSprite.x; selectionSquare.y = eventSprite.y; selectionSquare.width = eventSprite.width; selectionSquare.height = eventSprite.height; } } // Sort the notes DESCENDING. This keeps the sustain behind the associated note. renderedNotes.sort(FlxSort.byY, FlxSort.DESCENDING); // TODO: .group.insertionSort() // Sort the events DESCENDING. This keeps the sustain behind the associated note. renderedEvents.sort(FlxSort.byY, FlxSort.DESCENDING); // TODO: .group.insertionSort() } // Add a debug value which displays the current size of the note pool. // The pool will grow as more notes need to be rendered at once. // If this gets too big, something needs to be optimized somewhere! -Eric FlxG.watch.addQuick("tapNotesRendered", renderedNotes.members.length); FlxG.watch.addQuick("holdNotesRendered", renderedHoldNotes.members.length); FlxG.watch.addQuick("eventsRendered", renderedEvents.members.length); } /** * Handle aligning the health icons next to the grid. */ function handleHealthIcons():Void { if (healthIconsDirty) { if (healthIconBF != null) healthIconBF.characterId = currentSongMetadata.playData.characters.player; if (healthIconDad != null) healthIconDad.characterId = currentSongMetadata.playData.characters.opponent; } // Right align the BF health icon. if (healthIconBF != null) { // Base X position to the right of the grid. var baseHealthIconXPos:Float = (gridTiledSprite == null) ? (0) : (gridTiledSprite.x + gridTiledSprite.width + 15); // Will be 0 when not bopping. When bopping, will increase to push the icon left. var healthIconOffset:Float = healthIconBF.width - (HealthIcon.HEALTH_ICON_SIZE * 0.5); healthIconBF.x = baseHealthIconXPos - healthIconOffset; } } function buildSelectionSquare():FlxSprite { if (selectionSquareBitmap == null) throw "ERROR: Tried to build selection square, but selectionSquareBitmap is null! Check ChartEditorThemeHandler.updateSelectionSquare()"; return new FlxSprite().loadGraphic(selectionSquareBitmap); } /** * Handles display elements for the playbar at the bottom. */ function handlePlaybar():Void { if (playbarHeadLayout == null) throw "ERROR: Tried to handle playbar, but playbarHeadLayout is null!"; if (playbarHead == null) throw "ERROR: Tried to handle playbar, but playbarHeadLayout is null!"; // Make sure the playbar is never nudged out of the correct spot. playbarHeadLayout.x = 4; playbarHeadLayout.y = FlxG.height - 48 - 8; var songPos:Float = Conductor.songPosition; var songRemaining:Float = Math.max(songLengthInMs - songPos, 0.0); // Move the playhead to match the song position, if we aren't dragging it. if (!playbarHeadDragging) { var songPosPercent:Float = songPos / songLengthInMs * 100; if (playbarHead.value != songPosPercent) playbarHead.value = songPosPercent; } var songPosSeconds:String = Std.string(Math.floor((songPos / 1000) % 60)).lpad('0', 2); var songPosMinutes:String = Std.string(Math.floor((songPos / 1000) / 60)).lpad('0', 2); var songPosString:String = '${songPosMinutes}:${songPosSeconds}'; if (playbarSongPos == null) playbarSongPos = findComponent('playbarSongPos', Label); if (playbarSongPos != null && playbarSongPos.value != songPosString) playbarSongPos.value = songPosString; var songRemainingSeconds:String = Std.string(Math.floor((songRemaining / 1000) % 60)).lpad('0', 2); var songRemainingMinutes:String = Std.string(Math.floor((songRemaining / 1000) / 60)).lpad('0', 2); var songRemainingString:String = '-${songRemainingMinutes}:${songRemainingSeconds}'; if (playbarSongRemaining == null) playbarSongRemaining = findComponent('playbarSongRemaining', Label); if (playbarSongRemaining != null && playbarSongRemaining.value != songRemainingString) playbarSongRemaining.value = songRemainingString; if (playbarNoteSnap == null) playbarNoteSnap = findComponent('playbarNoteSnap', Label); if (playbarNoteSnap != null && playbarNoteSnap.value != '1/${noteSnapQuant}') playbarNoteSnap.value = '1/${noteSnapQuant}'; } /** * Handle keybinds for File menu items. */ function handleFileKeybinds():Void { // CTRL + N = New Chart if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.N) { ChartEditorDialogHandler.openWelcomeDialog(this, true); } // CTRL + O = Open Chart if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.O) { 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) { quitChartEditor(); } } function quitChartEditor():Void { autoSave(); FlxG.switchState(new MainMenuState()); } /** * Handle keybinds for edit menu items. */ function handleEditKeybinds():Void { // CTRL + Z = Undo if (undoKeyHandler.activated) { undoLastCommand(); } // CTRL + Y = Redo if (redoKeyHandler.activated) { redoLastCommand(); } // CTRL + C = Copy if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.C) { // Copy selected notes. // We don't need a command for this since we can't undo it. SongDataUtils.writeItemsToClipboard( { notes: SongDataUtils.buildNoteClipboard(currentNoteSelection), events: SongDataUtils.buildEventClipboard(currentEventSelection), }); } // CTRL + X = Cut if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.X) { // Cut selected notes. performCommand(new CutItemsCommand(currentNoteSelection, currentEventSelection)); } // CTRL + V = Paste if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.V) { // Paste notes from clipboard, at the playhead. performCommand(new PasteItemsCommand(scrollPositionInMs + playheadPositionInMs)); } // DELETE = Delete if (FlxG.keys.justPressed.DELETE) { // Delete selected items. 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)); } } // CTRL + A = Select All if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.A) { // Select all items. performCommand(new SelectAllItemsCommand(currentNoteSelection, currentEventSelection)); } // CTRL + I = Select Inverse if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.I) { // Select unselected items and deselect selected items. performCommand(new InvertSelectedItemsCommand(currentNoteSelection, currentEventSelection)); } // CTRL + D = Select None if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.D) { // Deselect all items. performCommand(new DeselectAllItemsCommand(currentNoteSelection, currentEventSelection)); } } /** * Handle keybinds for View menu items. */ function handleViewKeybinds():Void { if (currentLiveInputStyle == None) { if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.LEFT) { incrementDifficulty(-1); } if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.RIGHT) { incrementDifficulty(1); } // Would bind Ctrl+A and Ctrl+D here, but they are already bound to Select All and Select None. } } function incrementDifficulty(change:Int):Void { var currentDifficultyIndex:Int = availableDifficulties.indexOf(selectedDifficulty); var currentAllDifficultyIndex:Int = allDifficulties.indexOf(selectedDifficulty); if (currentDifficultyIndex == -1 || currentAllDifficultyIndex == -1) { trace('ERROR determining difficulty index!'); } var isFirstDiff:Bool = currentAllDifficultyIndex == 0; var isLastDiff:Bool = (currentAllDifficultyIndex == allDifficulties.length - 1); var isFirstDiffInVariation:Bool = currentDifficultyIndex == 0; var isLastDiffInVariation:Bool = (currentDifficultyIndex == availableDifficulties.length - 1); trace(allDifficulties); if (change < 0 && isFirstDiff) { trace('At lowest difficulty! Do nothing.'); return; } if (change > 0 && isLastDiff) { trace('At highest difficulty! Do nothing.'); return; } if (change < 0) { trace('Decrement difficulty.'); // If we reached this point, we are not at the lowest difficulty. if (isFirstDiffInVariation) { // Go to the previous variation, then last difficulty in that variation. var currentVariationIndex:Int = availableVariations.indexOf(selectedVariation); var prevVariation = availableVariations[currentVariationIndex - 1]; selectedVariation = prevVariation; var prevDifficulty = availableDifficulties[availableDifficulties.length - 1]; selectedDifficulty = prevDifficulty; refreshDifficultyTreeSelection(); refreshMetadataToolbox(); } else { // Go to previous difficulty in this variation. var prevDifficulty = availableDifficulties[currentDifficultyIndex - 1]; selectedDifficulty = prevDifficulty; refreshDifficultyTreeSelection(); refreshMetadataToolbox(); } } else { trace('Increment difficulty.'); // If we reached this point, we are not at the highest difficulty. if (isLastDiffInVariation) { // Go to next variation, then first difficulty in that variation. var currentVariationIndex:Int = availableVariations.indexOf(selectedVariation); var nextVariation = availableVariations[currentVariationIndex + 1]; selectedVariation = nextVariation; var nextDifficulty = availableDifficulties[0]; selectedDifficulty = nextDifficulty; refreshDifficultyTreeSelection(); refreshMetadataToolbox(); } else { // Go to next difficulty in this variation. var nextDifficulty = availableDifficulties[currentDifficultyIndex + 1]; selectedDifficulty = nextDifficulty; refreshDifficultyTreeSelection(); refreshMetadataToolbox(); } } #if !mac NotificationManager.instance.addNotification( { title: 'Switch Difficulty', body: 'Switched difficulty to ${selectedDifficulty.toTitleCase()}', type: NotificationType.Success, expiryMs: ChartEditorState.NOTIFICATION_DISMISS_TIME }); #end } /** * Handle keybinds for the Test menu items. */ function handleTestKeybinds():Void { if (!isHaxeUIDialogOpen && !isCursorOverHaxeUI && FlxG.keys.justPressed.ENTER) { var minimal = FlxG.keys.pressed.SHIFT; testSongInPlayState(minimal); } } /** * Handle keybinds for Help menu items. */ function handleHelpKeybinds():Void { // F1 = Open Help if (FlxG.keys.justPressed.F1) ChartEditorDialogHandler.openUserGuideDialog(this); } function handleToolboxes():Void { handleDifficultyToolbox(); handlePlayerPreviewToolbox(); handleOpponentPreviewToolbox(); } function handleDifficultyToolbox():Void { if (difficultySelectDirty) { difficultySelectDirty = false; // Manage the Select Difficulty tree view. var difficultyToolbox:Null<CollapsibleDialog> = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT); if (difficultyToolbox == null) return; var treeView:Null<TreeView> = difficultyToolbox.findComponent('difficultyToolboxTree'); if (treeView == null) return; // Clear the tree view so we can rebuild it. treeView.clearNodes(); // , icon: 'haxeui-core/styles/default/haxeui_tiny.png' var treeSong:TreeViewNode = treeView.addNode({id: 'stv_song', text: 'S: $currentSongName'}); treeSong.expanded = true; for (curVariation in availableVariations) { var variationMetadata:Null<SongMetadata> = songMetadata.get(curVariation); if (variationMetadata == null) continue; var treeVariation:TreeViewNode = treeSong.addNode( { id: 'stv_variation_$curVariation', text: 'V: ${curVariation.toTitleCase()}' }); treeVariation.expanded = true; var difficultyList:Array<String> = variationMetadata.playData.difficulties; for (difficulty in difficultyList) { var _treeDifficulty:TreeViewNode = treeVariation.addNode( { id: 'stv_difficulty_${curVariation}_$difficulty', text: 'D: ${difficulty.toTitleCase()}' }); } } treeView.onChange = onChangeTreeDifficulty; refreshDifficultyTreeSelection(treeView); } } 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) { // Manage the Select Difficulty tree view. var difficultyToolbox:Null<CollapsibleDialog> = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT); if (difficultyToolbox == null) return; treeView = difficultyToolbox.findComponent('difficultyToolboxTree'); if (treeView == null) return; } var currentTreeDifficultyNode = getCurrentTreeDifficultyNode(treeView); if (currentTreeDifficultyNode != null) treeView.selectedNode = currentTreeDifficultyNode; } function handlePlayerPreviewToolbox():Void { // Manage the Select Difficulty tree view. var charPreviewToolbox:Null<CollapsibleDialog> = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT); if (charPreviewToolbox == null) return; // TODO: Re-enable the player preview once we figure out the performance issues. var charPlayer:Null<CharacterPlayer> = null; // charPreviewToolbox.findComponent('charPlayer'); if (charPlayer == null) return; currentPlayerCharacterPlayer = charPlayer; if (playerPreviewDirty) { playerPreviewDirty = false; if (currentSongMetadata.playData.characters.player != charPlayer.charId) { if (healthIconBF != null) healthIconBF.characterId = currentSongMetadata.playData.characters.player; charPlayer.loadCharacter(currentSongMetadata.playData.characters.player); charPlayer.characterType = CharacterType.BF; charPlayer.flip = true; charPlayer.targetScale = 0.5; charPreviewToolbox.title = 'Player Preview - ${charPlayer.charName}'; } if (charPreviewToolbox != null && !charPreviewToolbox.minimized) { charPreviewToolbox.width = charPlayer.width + 32; charPreviewToolbox.height = charPlayer.height + 64; } } } function handleOpponentPreviewToolbox():Void { // Manage the Select Difficulty tree view. var charPreviewToolbox:Null<CollapsibleDialog> = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT); if (charPreviewToolbox == null) return; // TODO: Re-enable the player preview once we figure out the performance issues. var charPlayer:Null<CharacterPlayer> = null; // charPreviewToolbox.findComponent('charPlayer'); if (charPlayer == null) return; currentOpponentCharacterPlayer = charPlayer; if (opponentPreviewDirty) { opponentPreviewDirty = false; if (currentSongMetadata.playData.characters.opponent != charPlayer.charId) { if (healthIconDad != null) healthIconDad.characterId = currentSongMetadata.playData.characters.opponent; charPlayer.loadCharacter(currentSongMetadata.playData.characters.opponent); charPlayer.characterType = CharacterType.DAD; charPlayer.flip = false; charPlayer.targetScale = 0.5; charPreviewToolbox.title = 'Opponent Preview - ${charPlayer.charName}'; } if (charPreviewToolbox != null && !charPreviewToolbox.minimized) { charPreviewToolbox.width = charPlayer.width + 32; charPreviewToolbox.height = charPlayer.height + 64; } } } public override function dispatchEvent(event:ScriptEvent):Void { super.dispatchEvent(event); // We can't use the ScriptedEventDispatcher with currentCharPlayer because we can't use the IScriptedClass interface on it. if (currentPlayerCharacterPlayer != null) { switch (event.type) { case ScriptEvent.UPDATE: currentPlayerCharacterPlayer.onUpdate(cast event); case ScriptEvent.SONG_BEAT_HIT: currentPlayerCharacterPlayer.onBeatHit(cast event); case ScriptEvent.SONG_STEP_HIT: currentPlayerCharacterPlayer.onStepHit(cast event); case ScriptEvent.NOTE_HIT: currentPlayerCharacterPlayer.onNoteHit(cast event); } } if (currentOpponentCharacterPlayer != null) { switch (event.type) { case ScriptEvent.UPDATE: currentOpponentCharacterPlayer.onUpdate(cast event); case ScriptEvent.SONG_BEAT_HIT: currentOpponentCharacterPlayer.onBeatHit(cast event); case ScriptEvent.SONG_STEP_HIT: currentOpponentCharacterPlayer.onStepHit(cast event); case ScriptEvent.NOTE_HIT: currentOpponentCharacterPlayer.onNoteHit(cast event); } } } function getCurrentTreeDifficultyNode(?treeView:TreeView = null):Null<TreeViewNode> { if (treeView == null) { var difficultyToolbox:Null<CollapsibleDialog> = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT); if (difficultyToolbox == null) return null; treeView = difficultyToolbox.findComponent('difficultyToolboxTree'); if (treeView == null) return null; } var result:TreeViewNode = treeView.findNodeByPath('stv_song/stv_variation_$selectedVariation/stv_difficulty_${selectedVariation}_$selectedDifficulty', 'id'); if (result == null) return null; return result; } /** * Called when selecting a tree element in the Difficulty toolbox. * @param event The click event. */ function onChangeTreeDifficulty(event:UIEvent):Void { // Get the newly selected node. var treeView:TreeView = cast event.target; var targetNode:TreeViewNode = treeView.selectedNode; if (targetNode == null) { trace('No target node!'); // Reset the user's selection. var currentTreeDifficultyNode = getCurrentTreeDifficultyNode(treeView); if (currentTreeDifficultyNode != null) treeView.selectedNode = currentTreeDifficultyNode; return; } switch (targetNode.data.id.split('_')[1]) { case 'difficulty': var variation:String = targetNode.data.id.split('_')[2]; var difficulty:String = targetNode.data.id.split('_')[3]; if (variation != null && difficulty != null) { trace('Changing difficulty to "$variation:$difficulty"'); selectedVariation = variation; selectedDifficulty = difficulty; // refreshDifficultyTreeSelection(treeView); refreshMetadataToolbox(); } // case 'song': // case 'variation': default: // Reset the user's selection. trace('Selected wrong node type, resetting selection.'); var currentTreeDifficultyNode = getCurrentTreeDifficultyNode(treeView); if (currentTreeDifficultyNode != null) treeView.selectedNode = currentTreeDifficultyNode; refreshMetadataToolbox(); } } /** * When the difficulty changes, update the song metadata toolbox to reflect the new data. */ function refreshMetadataToolbox():Void { var toolbox:Null<CollapsibleDialog> = ChartEditorToolboxHandler.getToolbox(this, CHART_EDITOR_TOOLBOX_METADATA_LAYOUT); if (toolbox == null) return; var inputSongName:Null<TextField> = toolbox.findComponent('inputSongName', TextField); if (inputSongName != null) inputSongName.value = currentSongMetadata.songName; var inputSongArtist:Null<TextField> = toolbox.findComponent('inputSongArtist', TextField); if (inputSongArtist != null) inputSongArtist.value = currentSongMetadata.artist; var inputStage:Null<DropDown> = toolbox.findComponent('inputStage', DropDown); if (inputStage != null) inputStage.value = currentSongMetadata.playData.stage; 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; var labelScrollSpeed:Null<Label> = toolbox.findComponent('labelScrollSpeed', Label); if (labelScrollSpeed != null) labelScrollSpeed.text = 'Scroll Speed: ${currentSongChartScrollSpeed}x'; var inputScrollSpeed:Null<Slider> = toolbox.findComponent('inputScrollSpeed', Slider); if (inputScrollSpeed != null) inputScrollSpeed.value = currentSongChartScrollSpeed; var frameVariation:Null<Frame> = toolbox.findComponent('frameVariation', Frame); if (frameVariation != null) frameVariation.text = 'Variation: ${selectedVariation.toTitleCase()}'; var frameDifficulty:Null<Frame> = toolbox.findComponent('frameDifficulty', Frame); if (frameDifficulty != null) frameDifficulty.text = 'Difficulty: ${selectedDifficulty.toTitleCase()}'; 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"}; } 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"}; } } /** * Handle the player preview/gameplay test area on the left side. */ function handlePlayerDisplay():Void {} /** * Handles the note preview/scroll area on the right side. * Notes are rendered here as small bars. * This function also handles: * - Moving the viewport preview box around based on its current position. * - Scrolling the note preview area down if the note preview is taller than the screen, * and the viewport nears the end of the visible area. */ function handleNotePreview():Void { if (notePreviewDirty && notePreview != null) { notePreviewDirty = false; // TODO: Only update the notes that have changed. notePreview.erase(); notePreview.addNotes(currentSongChartNoteData, Std.int(songLengthInMs)); notePreview.addEvents(currentSongChartEventData, Std.int(songLengthInMs)); } if (notePreviewViewportBoundsDirty) { setNotePreviewViewportBounds(calculateNotePreviewViewportBounds()); } } /** * Handles passive behavior of the menu bar, such as updating labels or enabled/disabled status. * Does not handle onClick ACTIONS of the menubar. */ function handleMenubar():Void { if (commandHistoryDirty) { commandHistoryDirty = false; // Update the Undo and Redo buttons. var undoButton:Null<MenuItem> = findComponent('menubarItemUndo', MenuItem); if (undoButton != null) { if (undoHistory.length == 0) { // Disable the Undo button. undoButton.disabled = true; undoButton.text = 'Undo'; } else { // Change the label to the last command. undoButton.disabled = false; undoButton.text = 'Undo ${undoHistory[undoHistory.length - 1].toString()}'; } } else { trace('undoButton is null'); } var redoButton:Null<MenuItem> = findComponent('menubarItemRedo', MenuItem); if (redoButton != null) { if (redoHistory.length == 0) { // Disable the Redo button. redoButton.disabled = true; redoButton.text = 'Redo'; } else { // Change the label to the last command. redoButton.disabled = false; redoButton.text = 'Redo ${redoHistory[redoHistory.length - 1].toString()}'; } } else { trace('redoButton is null'); } } } /** * Handle syncronizing the conductor with the music playback. */ function handleMusicPlayback():Void { if (audioInstTrack != null && audioInstTrack.playing) { if (FlxG.mouse.pressedMiddle) { // If middle mouse panning during song playback, we move ONLY the playhead, without scrolling. Neat! var oldStepTime:Float = Conductor.currentStepTime; var oldSongPosition:Float = Conductor.songPosition; Conductor.update(audioInstTrack.time); handleHitsounds(oldSongPosition, Conductor.songPosition); // Resync vocals. if (audioVocalTrackGroup != null && Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100) { audioVocalTrackGroup.time = audioInstTrack.time; } var diffStepTime:Float = Conductor.currentStepTime - oldStepTime; // Move the playhead. playheadPositionInPixels += diffStepTime * GRID_SIZE; // We don't move the song to scroll position, or update the note sprites. } else { // Else, move the entire view. var oldSongPosition:Float = Conductor.songPosition; Conductor.update(audioInstTrack.time); handleHitsounds(oldSongPosition, Conductor.songPosition); // Resync vocals. if (audioVocalTrackGroup != null && Math.abs(audioInstTrack.time - audioVocalTrackGroup.time) > 100) { audioVocalTrackGroup.time = audioInstTrack.time; } // We need time in fractional steps here to allow the song to actually play. // Also account for a potentially offset playhead. scrollPositionInPixels = Conductor.currentStepTime * GRID_SIZE - playheadPositionInPixels; // DO NOT move song to scroll position here specifically. // We need to update the note sprites. noteDisplayDirty = true; // Update the note preview viewport box. setNotePreviewViewportBounds(calculateNotePreviewViewportBounds()); } } if (FlxG.keys.justPressed.SPACE && !isHaxeUIDialogOpen) { toggleAudioPlayback(); } } /** * Handle the playback of hitsounds. */ function handleHitsounds(oldSongPosition:Float, newSongPosition:Float):Void { if (!hitsoundsEnabled) return; // Assume notes are sorted by time. for (noteData in currentSongChartNoteData) { // Check for notes between the old and new song positions. if (noteData.time < oldSongPosition) // Note is in the past. continue; if (noteData.time > newSongPosition) // Note is in the future. return; // Assume all notes are also in the future. // Note was just hit. // Character preview. // NoteScriptEvent takes a sprite, ehe. Need to rework that. var tempNote:NoteSprite = new NoteSprite(NoteStyleRegistry.instance.fetchDefault()); tempNote.noteData = noteData; tempNote.scrollFactor.set(0, 0); var event:NoteScriptEvent = new NoteScriptEvent(ScriptEvent.NOTE_HIT, tempNote, 1, true); dispatchEvent(event); // Calling event.cancelEvent() skips all the other logic! Neat! if (event.eventCanceled) continue; // Hitsounds. switch (noteData.getStrumlineIndex()) { case 0: // Player if (hitsoundsEnabledPlayer) ChartEditorAudioHandler.playSound(Paths.sound('ui/chart-editor/playerHitsound')); case 1: // Opponent if (hitsoundsEnabledOpponent) ChartEditorAudioHandler.playSound(Paths.sound('ui/chart-editor/opponentHitsound')); } } } function startAudioPlayback():Void { if (audioInstTrack != null) { audioInstTrack.play(false, audioInstTrack.time); if (audioVocalTrackGroup != null) audioVocalTrackGroup.play(false, audioInstTrack.time); } setComponentText('playbarPlay', '||'); } function stopAudioPlayback():Void { if (audioInstTrack != null) audioInstTrack.pause(); if (audioVocalTrackGroup != null) audioVocalTrackGroup.pause(); setComponentText('playbarPlay', '>'); } function toggleAudioPlayback():Void { if (audioInstTrack == null) return; if (audioInstTrack.playing) { stopAudioPlayback(); } else { startAudioPlayback(); } } function handlePlayhead():Void { // Place notes at the playhead. switch (currentLiveInputStyle) { case LiveInputStyle.WASD: if (FlxG.keys.justPressed.A) placeNoteAtPlayhead(4); if (FlxG.keys.justPressed.S) placeNoteAtPlayhead(5); if (FlxG.keys.justPressed.W) placeNoteAtPlayhead(6); if (FlxG.keys.justPressed.D) placeNoteAtPlayhead(7); if (FlxG.keys.justPressed.LEFT) placeNoteAtPlayhead(0); if (FlxG.keys.justPressed.DOWN) placeNoteAtPlayhead(1); if (FlxG.keys.justPressed.UP) placeNoteAtPlayhead(2); if (FlxG.keys.justPressed.RIGHT) placeNoteAtPlayhead(3); case LiveInputStyle.NumberKeys: // Flipped because Dad is on the left but represents data 0-3. if (FlxG.keys.justPressed.ONE) placeNoteAtPlayhead(4); if (FlxG.keys.justPressed.TWO) placeNoteAtPlayhead(5); if (FlxG.keys.justPressed.THREE) placeNoteAtPlayhead(6); if (FlxG.keys.justPressed.FOUR) placeNoteAtPlayhead(7); if (FlxG.keys.justPressed.FIVE) placeNoteAtPlayhead(0); if (FlxG.keys.justPressed.SIX) placeNoteAtPlayhead(1); if (FlxG.keys.justPressed.SEVEN) placeNoteAtPlayhead(2); if (FlxG.keys.justPressed.EIGHT) placeNoteAtPlayhead(3); case LiveInputStyle.None: // Do nothing. } } function placeNoteAtPlayhead(column:Int):Void { var playheadPos:Float = scrollPositionInPixels + playheadPositionInPixels; var playheadPosFractionalStep:Float = playheadPos / GRID_SIZE / noteSnapRatio; var playheadPosStep:Int = Std.int(Math.floor(playheadPosFractionalStep)); var playheadPosSnappedMs:Float = playheadPosStep * Conductor.stepLengthMs * noteSnapRatio; // Look for notes within 1 step of the playhead. var notesAtPos:Array<SongNoteData> = SongDataUtils.getNotesInTimeRange(currentSongChartNoteData, playheadPosSnappedMs, playheadPosSnappedMs + Conductor.stepLengthMs * noteSnapRatio); notesAtPos = SongDataUtils.getNotesWithData(notesAtPos, [column]); if (notesAtPos.length == 0) { var newNoteData:SongNoteData = new SongNoteData(playheadPosSnappedMs, column, 0, selectedNoteKind); performCommand(new AddNotesCommand([newNoteData], FlxG.keys.pressed.CONTROL)); } else { trace('Already a note there.'); } } function set_scrollPositionInPixels(value:Float):Float { if (value < 0) { // If we're scrolling up, and we hit the top, // but the playhead is in the middle, move the playhead up. if (playheadPositionInPixels > 0) { var amount:Float = scrollPositionInPixels - value; playheadPositionInPixels -= amount; } value = 0; } if (value > songLengthInPixels) value = songLengthInPixels; if (value == scrollPositionInPixels) return value; // Difference in pixels. var diff:Float = value - scrollPositionInPixels; this.scrollPositionInPixels = value; // Move the grid sprite to the correct position. if (gridTiledSprite != null) { if (isViewDownscroll) { gridTiledSprite.y = -scrollPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD); gridPlayheadScrollArea.y = gridTiledSprite.y; } else { gridTiledSprite.y = -scrollPositionInPixels + (MENU_BAR_HEIGHT + GRID_TOP_PAD); gridPlayheadScrollArea.y = gridTiledSprite.y; } } // Move the rendered notes to the correct position. renderedNotes.setPosition(gridTiledSprite?.x ?? 0.0, gridTiledSprite?.y ?? 0.0); renderedHoldNotes.setPosition(gridTiledSprite?.x ?? 0.0, gridTiledSprite?.y ?? 0.0); renderedEvents.setPosition(gridTiledSprite?.x ?? 0.0, gridTiledSprite?.y ?? 0.0); renderedSelectionSquares.setPosition(gridTiledSprite?.x ?? 0.0, gridTiledSprite?.y ?? 0.0); // Offset the selection box start position, if we are dragging. if (selectionBoxStartPos != null) selectionBoxStartPos.y -= diff; // Update the note preview viewport box. setNotePreviewViewportBounds(calculateNotePreviewViewportBounds()); return this.scrollPositionInPixels; } /** * Transitions to the Play State to test the song */ public function testSongInPlayState(minimal:Bool = false):Void { autoSave(); var startTimestamp:Float = 0; if (playtestStartTime) startTimestamp = scrollPositionInMs + playheadPositionInMs; var targetSong:Song = Song.buildRaw(currentSongId, songMetadata.values(), availableVariations, songChartData, false); // TODO: Rework asset system so we can remove this. switch (currentSongStage) { case 'mainStage': Paths.setCurrentLevel('week1'); case 'spookyMansion': Paths.setCurrentLevel('week2'); case 'phillyTrain': Paths.setCurrentLevel('week3'); case 'limoRide': Paths.setCurrentLevel('week4'); case 'mallXmas' | 'mallEvil': Paths.setCurrentLevel('week5'); case 'school' | 'schoolEvil': Paths.setCurrentLevel('week6'); case 'tankmanBattlefield': Paths.setCurrentLevel('week7'); case 'phillyStreets' | 'phillyBlazin': Paths.setCurrentLevel('weekend1'); } subStateClosed.add(fixCamera); subStateClosed.add(updateConductor); FlxTransitionableState.skipNextTransIn = false; FlxTransitionableState.skipNextTransOut = false; var targetState = new PlayState( { targetSong: targetSong, targetDifficulty: selectedDifficulty, // TODO: Add this. // targetCharacter: targetCharacter, practiceMode: true, minimalMode: minimal, startTimestamp: startTimestamp, overrideMusic: true, }); // Override music. if (audioInstTrack != null) FlxG.sound.music = audioInstTrack; if (audioVocalTrackGroup != null) targetState.vocals = audioVocalTrackGroup; openSubState(targetState); } function fixCamera(_:FlxSubState = null):Void { FlxG.cameras.reset(new FlxCamera()); FlxG.camera.focusOn(new FlxPoint(FlxG.width / 2, FlxG.height / 2)); FlxG.camera.zoom = 1.0; add(this.component); } function updateConductor(_:FlxSubState = null):Void { var targetPos = scrollPositionInMs; Conductor.update(targetPos); } public function postLoadInstrumental():Void { if (audioInstTrack != null) { // Prevent the time from skipping back to 0 when the song ends. audioInstTrack.onComplete = function() { if (audioInstTrack != null) audioInstTrack.pause(); if (audioVocalTrackGroup != null) audioVocalTrackGroup.pause(); }; songLengthInMs = audioInstTrack.length; if (gridTiledSprite != null) gridTiledSprite.height = songLengthInPixels; if (gridPlayheadScrollArea != null) { gridPlayheadScrollArea.setGraphicSize(Std.int(gridPlayheadScrollArea.width), songLengthInPixels); gridPlayheadScrollArea.updateHitbox(); } buildSpectrogram(audioInstTrack); } else { trace('[WARN] Instrumental track was null!'); } // Pretty much everything is going to need to be reset. scrollPositionInPixels = 0; playheadPositionInPixels = 0; notePreviewDirty = true; notePreviewViewportBoundsDirty = true; noteDisplayDirty = true; healthIconsDirty = true; moveSongToScrollPosition(); } /** * Clear the voices group. */ public function clearVocals():Void { if (audioVocalTrackGroup != null) 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. */ function moveSongToScrollPosition():Void { // Update the songPosition in the Conductor. var targetPos = scrollPositionInMs; Conductor.update(targetPos); // Update the songPosition in the audio tracks. if (audioInstTrack != null) audioInstTrack.time = scrollPositionInMs + playheadPositionInMs; if (audioVocalTrackGroup != null) audioVocalTrackGroup.time = scrollPositionInMs + playheadPositionInMs; // We need to update the note sprites because we changed the scroll position. noteDisplayDirty = true; } function easeSongToScrollPosition(targetScrollPosition:Float):Void { if (currentScrollEase != null) cancelScrollEase(currentScrollEase); currentScrollEase = FlxTween.tween(this, {scrollPositionInPixels: targetScrollPosition}, SCROLL_EASE_DURATION, { ease: FlxEase.quintInOut, onUpdate: this.onScrollEaseUpdate, onComplete: this.cancelScrollEase, type: ONESHOT }); } function onScrollEaseUpdate(_:FlxTween):Void { moveSongToScrollPosition(); } function cancelScrollEase(_:FlxTween):Void { if (currentScrollEase != null) { @:privateAccess var targetScrollPosition:Float = currentScrollEase._properties.scrollPositionInPixels; currentScrollEase.cancel(); currentScrollEase = null; this.scrollPositionInPixels = targetScrollPosition; } } /** * Perform (or redo) a command, then add it to the undo stack. * * @param command The command to perform. * @param purgeRedoStack If true, the redo stack will be cleared. */ function performCommand(command:ChartEditorCommand, purgeRedoStack:Bool = true):Void { command.execute(this); undoHistory.push(command); commandHistoryDirty = true; if (purgeRedoStack) redoHistory = []; } /** * Undo a command, then add it to the redo stack. * @param command The command to undo. */ function undoCommand(command:ChartEditorCommand):Void { command.undo(this); redoHistory.push(command); commandHistoryDirty = true; } /** * Undo the last command in the undo stack, then add it to the redo stack. */ function undoLastCommand():Void { var command:Null<ChartEditorCommand> = undoHistory.pop(); if (command == null) { trace('No actions to undo.'); return; } undoCommand(command); } /** * Redo the last command in the redo stack, then add it to the undo stack. */ function redoLastCommand():Void { var command:Null<ChartEditorCommand> = redoHistory.pop(); if (command == null) { trace('No actions to redo.'); return; } performCommand(command, false); } function sortChartData():Void { // TODO: .insertionSort() currentSongChartNoteData.sort(function(a:SongNoteData, b:SongNoteData):Int { return FlxSort.byValues(FlxSort.ASCENDING, a.time, b.time); }); // TODO: .insertionSort() currentSongChartEventData.sort(function(a:SongEventData, b:SongEventData):Int { return FlxSort.byValues(FlxSort.ASCENDING, a.time, b.time); }); } function playMetronomeTick(high:Bool = false):Void { ChartEditorAudioHandler.playSound(Paths.sound('pianoStuff/piano-${high ? '001' : '008'}')); } function isNoteSelected(note:Null<SongNoteData>):Bool { return note != null && currentNoteSelection.indexOf(note) != -1; } function isEventSelected(event:Null<SongEventData>):Bool { return event != null && currentEventSelection.indexOf(event) != -1; } override function destroy():Void { super.destroy(); cleanupAutoSave(); // Hide the mouse cursor on other states. Cursor.hide(); @:privateAccess ChartEditorNoteSprite.noteFrameCollection = null; } /** * Dismiss any existing notifications, if there are any. */ function dismissNotifications():Void { NotificationManager.instance.clearNotifications(); } } enum LiveInputStyle { None; NumberKeys; WASD; }