package funkin.ui.debug.charting; import haxe.ui.components.CheckBox; import haxe.ui.containers.TreeViewNode; import flixel.FlxSprite; import flixel.addons.display.FlxGridOverlay; import flixel.addons.display.FlxTiledSprite; import flixel.group.FlxSpriteGroup; import flixel.system.FlxSound; import flixel.util.FlxColor; import flixel.util.FlxSort; import funkin.audio.visualize.PolygonSpectogram; import funkin.play.HealthIcon; import funkin.play.song.SongData.SongChartData; import funkin.play.song.SongData.SongEventData; import funkin.play.song.SongData.SongMetadata; import funkin.play.song.SongData.SongNoteData; import funkin.play.song.SongSerializer; import funkin.ui.debug.charting.ChartEditorCommand; import funkin.ui.haxeui.HaxeUIState; import haxe.ui.containers.TreeView; import haxe.ui.containers.dialogs.Dialog; import haxe.ui.containers.menus.Menu.MenuEvent; import haxe.ui.containers.menus.MenuBar; import haxe.ui.containers.menus.MenuCheckBox; import haxe.ui.containers.menus.MenuItem; import haxe.ui.core.Component; import haxe.ui.events.MouseEvent; import haxe.ui.events.UIEvent; import openfl.display.BitmapData; import openfl.geom.Rectangle; // Since Haxe 3.1.0, if access is allowed to an interface, it extends to all classes implementing that interface. // Thus, any ChartEditorCommand has access to any private field. @:allow(funkin.ui.debug.charting.ChartEditorCommand) class ChartEditorState extends HaxeUIState { /** * CONSTANTS */ // ============================== /** * The location of the chart editor's HaxeUI XML file. */ static final CHART_EDITOR_LAYOUT = Paths.ui('chart-editor/main-view'); static final DEFAULT_VARIATION = 'default'; static final DEFAULT_DIFFICULTY = 'normal'; // UI Element Sizes public static final GRID_SIZE:Int = 40; public static final STRUMLINE_SIZE = 4; static final MENU_BAR_HEIGHT = 32; static final GRID_TOP_PAD:Int = 8; static final SELECTION_SQUARE_BORDER_WIDTH:Int = 1; // UI Element Colors static final BG_COLOR:FlxColor = 0xFF673AB7; static final GRID_ALTERNATE:Bool = true; static final GRID_COLOR_1:FlxColor = 0xFFE7E6E6; static final GRID_COLOR_1_DARK:FlxColor = 0xFF181919; static final GRID_COLOR_2:FlxColor = 0xFFD9D5D5; static final GRID_COLOR_2_DARK:FlxColor = 0xFF262A2A; static final CURSOR_COLOR:FlxColor = 0xC0FFFFFF; static final PREVIEW_BG_COLOR:FlxColor = 0xFF303030; static final PLAYHEAD_COLOR:FlxColor = 0xC0808080; static final SPECTROGRAM_COLOR:FlxColor = 0xFFFF0000; static final SELECTION_SQUARE_BORDER_COLOR:FlxColor = 0xFF339933; static final SELECTION_SQUARE_FILL_COLOR:FlxColor = 0x4033FF33; /** * INSTANCE DATA */ // ============================== /** * scrollPosition is the current position in the song, in pixels. * One pixel is 1/40 of 1 step, and 1 step is 1/4 of a beat. */ var scrollPosition(default, set):Float = -1.0; /** * scrollPosition, converted to steps. * TODO: Handle BPM changes. */ var scrollPositionInSteps(get, null):Float; function get_scrollPositionInSteps():Float { return scrollPosition / GRID_SIZE; } var scrollPositionInMs(get, null):Float; /** * scrollPosition, converted to milliseconds. * TODO: Handle BPM changes. */ function get_scrollPositionInMs():Float { return scrollPositionInSteps * Conductor.stepCrochet; } /** * The position of the playhead, in pixels, relative to the scroll position. * For example, 0 means the playhead is at the top of the grid, and 40 means the playhead is 1 step farther. */ var playheadPosition(default, set):Float; var playheadPositionInSteps(get, null):Float; /** * playheadPosition, converted to steps. */ function get_playheadPositionInSteps():Float { return playheadPosition / GRID_SIZE; } /** * playheadPosition, converted to milliseconds. */ var playheadPositionInMs(get, null):Float; function get_playheadPositionInMs():Float { return playheadPositionInSteps * Conductor.stepCrochet; } /** * This is the song's length in PIXELS , same format as scrollPosition. */ var songLength:Int; /** * songLength, converted to steps. */ var songLengthInSteps(get, null):Float; function get_songLengthInSteps():Float { return songLength / GRID_SIZE; } /** * songLength, converted to milliseconds. */ var songLengthInMs(get, null):Float; function get_songLengthInMs():Float { return songLengthInSteps * Conductor.stepCrochet; } /** * If true, a HaxeUI dialog is open and the user interface underneath should be disabled. */ var isModalDialogOpen:Bool = false; /** * The note kind currently being placed. Defaults to `''`. * Use the input in the sidebar to change this. */ var selectedNoteKind:String = ''; /** * Whether to play a metronome sound while the playhead moves. */ var shouldPlayMetronome:Bool = true; /** * Whether the current view is in downscroll mode. */ var isViewDownscroll(default, set):Bool = false; function set_isViewDownscroll(value:Bool):Bool { // Make sure view is updated. noteDisplayDirty = true; notePreviewDirty = true; return isViewDownscroll = value; } /** * The current variation ID. */ var selectedVariation:String = DEFAULT_VARIATION; /** * The selected difficulty ID. */ var selectedDifficulty:String = DEFAULT_DIFFICULTY; /** * Whether the note display render group needs to be updated. */ var noteDisplayDirty:Bool = true; /** * Whether the neat note preview graphic needs to be updated (i.e. fully rebuilt). */ var notePreviewDirty:Bool = true; /** * Whether the difficulty tree view in the sidebar needs to be updated. */ var difficultySelectDirty:Bool = true; var isInPatternMode:Bool = false; var currentPattern:String = ''; var isInPlaytestMode:Bool = false; /** * The list of command previously performed. Used for undoing previous actions. */ var undoHistory:Array = []; /** * The list of commands that have been undone. Used for redoing previous actions. */ var redoHistory:Array = []; /** * 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 selection. */ var currentSelection:Array = []; /** * The user's current clipboard. Contains a full list of the notes they have copied or cut. * TODO: Replace this with serialization in the real clipboard. */ var currentClipboard:Array = []; /** * AUDIO AND SOUND DATA */ /** * The audio track for the instrumental. */ var audioInstTrack:FlxSound; /** * The audio track for the vocals. * TODO: Replace with a VocalSoundGroup. */ var audioVocalTrack:FlxSound; /** * 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; /** * The song chart data. * - Keys are the variation IDs. At least one (`default`) must exist. * - Values are the relevant chart data, ready to be serialized to JSON. */ var songChartData:Map; /** * Convenience property to get the chart data for the current variation. */ var currentSongMetadata(get, set):SongMetadata; function get_currentSongMetadata():SongMetadata { var result = 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 = songChartData.get(selectedVariation); if (result == null) { result = new SongChartData(1.0, [], []); 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 = 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, null):Array; function get_currentSongChartNoteData():Array { var result = currentSongChartData.notes.get(selectedDifficulty); if (result == null) { // Initialize to the default value if not set. result = []; currentSongChartData.notes.set(selectedDifficulty, result); return result; } return result; } /** * Convenience property to get the event data for the current difficulty. */ var currentSongChartEventData(get, null):Array; function get_currentSongChartEventData():Array { if (currentSongChartData.events == null) { // Initialize to the default value if not set. currentSongChartData.events = []; } return currentSongChartData.events; } var currentSongNoteSkin(get, set):String; function get_currentSongNoteSkin():String { if (currentSongMetadata.playData.noteSkin == null) { // Initialize to the default value if not set. currentSongMetadata.playData.noteSkin = 'Normal'; } return currentSongMetadata.playData.noteSkin; } function set_currentSongNoteSkin(value:String):String { return currentSongMetadata.playData.noteSkin = value; } var currentSongStage(get, set):String; function get_currentSongStage():String { if (currentSongMetadata.playData.stage == null) { // Initialize to the default value if not set. currentSongMetadata.playData.stage = 'mainStage'; } return currentSongMetadata.playData.stage; } function set_currentSongStage(value:String):String { return currentSongMetadata.playData.stage = value; } var currentSongName(get, set):String; function get_currentSongName():String { if (currentSongMetadata.songName == null) { // Initialize to the default value if not set. currentSongMetadata.songName = 'New Song'; } return currentSongMetadata.songName; } function set_currentSongName(value:String):String { return currentSongMetadata.songName = value; } var 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; } /** * RENDER OBJECTS */ // ============================== /** * The IMAGE used for the grid. */ var gridBitmap:BitmapData; /** * The IMAGE used for the selection squares. */ var selectionSquareBitmap: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:FlxSprite; /** * The playhead representing the current position in the song. * Can move around on the grid independently of the view. */ var gridPlayhead:FlxSpriteGroup; /** * A sprite used to highlight the grid square under the cursor. */ var gridCursor:FlxSprite; /** * The waveform which (optionally) displays over the grid, underneath the notes and playhead. */ var gridSpectrogram:PolygonSpectogram; /** * The rectangle used for the note preview area. * Should span the full height of the song. We scribble on this to draw the preview. */ var notePreviewBitmap:BitmapData; /** * The sprite used to display the note preview area. * We move this up and down to scroll the preview. */ var notePreviewSprite:FlxSprite; /** * The opponent's health icon. */ var healthIconDad:HealthIcon; /** * The player's health icon. */ var healthIconBF:HealthIcon; /** * The purple background sprite. */ var menuBG:FlxSprite; /** * 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; var renderedNoteSelectionSquares:FlxTypedSpriteGroup; public function new() { // Load the HaxeUI XML file. super(CHART_EDITOR_LAYOUT); } override function create() { // Get rid of any music from the previous state. FlxG.sound.music.stop(); buildDefaultSongData(); buildBackground(); buildGrid(); buildNoteGroup(); // Add the HaxeUI components after the grid so they're on top. super.create(); // Setup the onClick listeners for the UI after it's been created. setupUIListeners(); // TODO: We should be loading the music later when the user requests it. loadMusic(); } function buildDefaultSongData() { selectedVariation = DEFAULT_VARIATION; selectedDifficulty = DEFAULT_DIFFICULTY; // Initialize the song metadata. songMetadata = new Map(); // Initialize the song chart data. songChartData = new Map(); } /** * Builds and displays the background sprite. */ function buildBackground() { menuBG = new FlxSprite().loadGraphic(Paths.image('menuDesat')); add(menuBG); menuBG.color = BG_COLOR; menuBG.setGraphicSize(Std.int(menuBG.width * 1.1)); menuBG.updateHitbox(); menuBG.screenCenter(); menuBG.scrollFactor.set(0, 0); } /** * Draws the grid texture used for the chart editor, and adds dividing lines to it. * @param dark Whether to draw the grid in a dark color instead of a light one. */ function makeGridBitmap(?dark:Bool = true) { // The checkerboard background image of the chart. // 2 * (Strumline Size) + 1 grid squares wide, by 2 grid squares tall. // This gets reused to fill the screen. gridBitmap = FlxGridOverlay.createGrid(GRID_SIZE, GRID_SIZE, GRID_SIZE * (STRUMLINE_SIZE * 2 + 1), GRID_SIZE * 2, GRID_ALTERNATE, dark ? GRID_COLOR_1_DARK : GRID_COLOR_1, dark ? GRID_COLOR_2_DARK : GRID_COLOR_2); } function makeSelectionSquareBitmap() { selectionSquareBitmap = new BitmapData(GRID_SIZE, GRID_SIZE, true); selectionSquareBitmap.fillRect(new Rectangle(0, 0, GRID_SIZE, GRID_SIZE), SELECTION_SQUARE_BORDER_COLOR); selectionSquareBitmap.fillRect(new Rectangle(SELECTION_SQUARE_BORDER_WIDTH, SELECTION_SQUARE_BORDER_WIDTH, GRID_SIZE - (SELECTION_SQUARE_BORDER_WIDTH * 2), GRID_SIZE - (SELECTION_SQUARE_BORDER_WIDTH * 2)), SELECTION_SQUARE_FILL_COLOR); } /** * Builds and displays the chart editor grid, including the playhead and cursor. */ function buildGrid() { makeGridBitmap(false); makeSelectionSquareBitmap(); // Draw dividers between the strumlines. var dividerLineAX = GRID_SIZE * (STRUMLINE_SIZE) - 1; gridBitmap.fillRect(new Rectangle(dividerLineAX, 0, 2, gridBitmap.height), 0xFF000000); var dividerLineBX = GRID_SIZE * (STRUMLINE_SIZE * 2) - 1; gridBitmap.fillRect(new Rectangle(dividerLineBX, 0, 2, gridBitmap.height), 0xFF000000); 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); /* buildSpectrogram(audioVocalTrack); */ // The cursor that appears when hovering over the grid. gridCursor = new FlxSprite().makeGraphic(GRID_SIZE, GRID_SIZE, CURSOR_COLOR); add(gridCursor); // The playhead that show the current position in the song. gridPlayhead = new FlxSpriteGroup(); add(gridPlayhead); var playheadWidth = GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + 10 + 10; var playheadBaseYPos = MENU_BAR_HEIGHT + GRID_TOP_PAD; gridPlayhead.setPosition(gridTiledSprite.x, playheadBaseYPos); var playheadSprite = new FlxSprite().makeGraphic(playheadWidth, Std.int(GRID_SIZE / 4), PLAYHEAD_COLOR); playheadSprite.x = -10; playheadSprite.y = 0; gridPlayhead.add(playheadSprite); // Character icons. healthIconDad = new HealthIcon('dad'); 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); healthIconBF = new HealthIcon('bf'); healthIconBF.autoUpdate = false; healthIconBF.size.set(0.5, 0.5); healthIconBF.x = gridTiledSprite.x + GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + 15; healthIconBF.y = gridTiledSprite.y + 5; healthIconBF.flipX = true; add(healthIconBF); } function buildSpectrogram(target:FlxSound) { gridSpectrogram = new PolygonSpectogram(target, SPECTROGRAM_COLOR, FlxG.height / 2, Math.floor(FlxG.height / 2)); gridSpectrogram.x = 0; gridSpectrogram.y = 0; gridSpectrogram.waveAmplitude = 50; gridSpectrogram.scrollFactor.set(0, 0); // musSpec.visType = FREQUENCIES; add(gridSpectrogram); } /** * Builds the group that will hold all the notes. */ function buildNoteGroup() { renderedNotes = new FlxTypedSpriteGroup(); renderedNotes.setPosition(gridTiledSprite.x, gridTiledSprite.y); add(renderedNotes); /* var sustainSprite:SustainTrail = new SustainTrail(0, 600, Paths.image('NOTE_hold_assets'), 0.9, false); sustainSprite.scrollFactor.set(0, 0); sustainSprite.x = gridTiledSprite.x; sustainSprite.y = gridTiledSprite.y + 32; sustainSprite.zoom *= 0.258; // 0.77; add(sustainSprite); */ } /** * Sets up the onClick listeners for the UI. */ function setupUIListeners():Void { // Make sure clicking on the menu doesn't affect the grid behind it while it's open. var menubarComponent:MenuBar = findComponent('menubar', MenuBar); if (menubarComponent != null) { menubarComponent.onMenuOpened = (e:MenuEvent) -> { isModalDialogOpen = true; } menubarComponent.onMenuClosed = (e:MenuEvent) -> { isModalDialogOpen = false; } } // Add functionality to the menu items. addUIClickListener('menubarItemUndo', (event:MouseEvent) -> undoLastCommand()); addUIClickListener('menubarItemRedo', (event:MouseEvent) -> redoLastCommand()); addUIClickListener('menubarItemAbout', (event:MouseEvent) -> openDialog('chart-editor/dialogs/about')); addUIClickListener('menubarItemUserGuide', (event:MouseEvent) -> openDialog('chart-editor/dialogs/user-guide')); addUIChangeListener('menubarItemToggleSidebar', (event:UIEvent) -> { var sidebar:Component = findComponent('sidebar', Component); sidebar.visible = event.value; }); setUISelected('menubarItemToggleSidebar', true); addUIChangeListener('menubarItemDownscroll', (event:UIEvent) -> { isViewDownscroll = event.value; }); setUISelected('menubarItemDownscroll', isViewDownscroll); addUIChangeListener('menubarItemMetronomeEnabled', (event:UIEvent) -> { shouldPlayMetronome = event.value; }); setUISelected('menubarItemMetronomeEnabled', shouldPlayMetronome); addUIChangeListener('menubarItemVolumeInstrumental', (event:UIEvent) -> { var volume:Float = event.value / 100.0; audioInstTrack.volume = volume; }); addUIChangeListener('menubarItemVolumeVocals', (event:UIEvent) -> { var volume:Float = event.value / 100.0; audioVocalTrack.volume = volume; }); addUIClickListener('sidebarSaveMetadata', (event:MouseEvent) -> { // Save metadata for current variation. SongSerializer.exportSongMetadata(currentSongMetadata); }); addUIClickListener('sidebarSaveChart', (event:MouseEvent) -> { // Save chart data for current variation. SongSerializer.exportSongChartData(currentSongChartData); }); addUIClickListener('sidebarLoadMetadata', (event:MouseEvent) -> { // Replace metadata for current variation. SongSerializer.importSongMetadataAsync(function(songMetadata:SongMetadata) { currentSongMetadata = songMetadata; }); }); addUIClickListener('sidebarLoadChart', (event:MouseEvent) -> { // Replace chart data for current variation. SongSerializer.importSongChartDataAsync(function(songChartData:SongChartData) { currentSongChartData = songChartData; noteDisplayDirty = true; }); }); addUIChangeListener('sidebarSongName', (event:UIEvent) -> { // Set song name (for current variation) currentSongName = event.value; }); setUIValue('sidebarSongName', currentSongName); addUIChangeListener('sidebarSongArtist', (event:UIEvent) -> { currentSongArtist = event.value; }); setUIValue('sidebarSongArtist', currentSongArtist); addUIChangeListener('sidebarStage', (event:UIEvent) -> { currentSongStage = event.value; }); setUIValue('sidebarStage', currentSongStage); addUIChangeListener('sidebarNoteSkin', (event:UIEvent) -> { currentSongNoteSkin = event.value; }); setUIValue('sidebarNoteSkin', currentSongNoteSkin); // TODO: Pass specific HaxeUI components to add context menus to them. registerContextMenu(null, Paths.ui('chart-editor/context/test')); } public override function update(elapsed:Float) { super.update(elapsed); FlxG.mouse.visible = true; // These ones happen even if the modal dialog is open. handleMusicPlayback(); handleNoteDisplay(); if (!isModalDialogOpen) { // These ones only happen if the modal dialog is not open. handleScrollKeybinds(); handleCursor(); handlePlayheadKeybinds(); handleMenubar(); handleSidebar(); handleFileKeybinds(); handleEditKeybinds(); handleViewKeybinds(); handleHelpKeybinds(); } // DEBUG if (FlxG.keys.justPressed.A) { performCommand(new SwitchDifficultyCommand(selectedDifficulty, 'easy', selectedVariation, 'default')); } if (FlxG.keys.justPressed.S) { performCommand(new SwitchDifficultyCommand(selectedDifficulty, 'normal', selectedVariation, 'default')); } if (FlxG.keys.justPressed.D) { performCommand(new SwitchDifficultyCommand(selectedDifficulty, 'hard', selectedVariation, 'default')); } if (FlxG.keys.justPressed.F) { performCommand(new SwitchDifficultyCommand(selectedDifficulty, 'erect', selectedVariation, 'erect')); } // Right align the BF health icon. // Base X position to the right of the grid. var baseHealthIconXPos = gridTiledSprite.x + GRID_SIZE * (STRUMLINE_SIZE * 2 + 1) + 15; // Will be 0 when not bopping. When bopping, will increase to push the icon left. var healthIconOffset = healthIconBF.width - (HealthIcon.HEALTH_ICON_SIZE * 0.5); healthIconBF.x = baseHealthIconXPos - healthIconOffset; } /** * Beat hit while the song is playing. */ override function beatHit():Bool { if (!super.beatHit()) return false; if (shouldPlayMetronome && audioInstTrack.playing) { playMetronomeTick(Conductor.currentBeat % 4 == 0); } return true; } /** * Step hit while the song is playing. */ override function stepHit():Bool { if (!super.stepHit()) return false; if (audioInstTrack.playing) { healthIconDad.onStepHit(Conductor.currentStep); healthIconBF.onStepHit(Conductor.currentStep); } // if (shouldPlayMetronome) // playMetronomeTick(false); return true; } /** * Handle keybinds for scrolling the chart editor grid. **/ function handleScrollKeybinds() { // Amount to scroll the grid. var scrollAmount:Float = 0; // Amount to scroll the playhead relative to the grid. var playheadAmount:Float = 0; // Up Arrow = Scroll Up if (FlxG.keys.justPressed.UP) { scrollAmount = -GRID_SIZE * 0.25; } // Down Arrow = Scroll Down if (FlxG.keys.justPressed.DOWN) { scrollAmount = GRID_SIZE * 0.25; } // PAGE UP = Jump Up 1 Measure if (FlxG.keys.justPressed.PAGEUP) { scrollAmount = -GRID_SIZE * 4 * Conductor.beatsPerMeasure; } // PAGE DOWN = Jump Down 1 Measure if (FlxG.keys.justPressed.PAGEDOWN) { scrollAmount = GRID_SIZE * 4 * Conductor.beatsPerMeasure; } // Mouse Wheel = Scroll if (FlxG.mouse.wheel != 0) { scrollAmount = -10 * FlxG.mouse.wheel; } // 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 *= 10; } // CONTROL + Scroll = Scroll Precise if (FlxG.keys.pressed.CONTROL) { scrollAmount /= 10; } // ALT = Move playhead instead. if (FlxG.keys.pressed.ALT) { playheadAmount = scrollAmount; scrollAmount = 0; } // HOME = Scroll to Top if (FlxG.keys.justPressed.HOME) { // Scroll amount is the difference between the current position and the top. scrollAmount = 0 - this.scrollPosition; } // END = Scroll to Bottom if (FlxG.keys.justPressed.END) { // Scroll amount is the difference between the current position and the bottom. scrollAmount = this.songLength - this.scrollPosition; } // Apply the scroll amount. this.scrollPosition += scrollAmount; this.playheadPosition += playheadAmount; // Resync the conductor and audio tracks. if (scrollAmount != 0 || playheadAmount != 0) moveSongToScrollPosition(); } /** * Handle display of the mouse cursor. */ function handleCursor() { // Note: If a menu is open in HaxeUI, don't handle cursor behavior. if (FlxG.mouse.overlaps(gridTiledSprite) && (!isModalDialogOpen)) { // Cursor position relative to the grid. var cursorX:Float = FlxG.mouse.screenX - gridTiledSprite.x; var cursorY:Float = FlxG.mouse.screenY - gridTiledSprite.y; // The song position of the cursor, in steps. var cursorFractionalStep:Float = cursorY / GRID_SIZE; var cursorStep:Int = Math.floor(cursorFractionalStep); // 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; } } gridCursor.visible = true; // X and Y are the cursor position relative to the grid, snapped to the top left of the grid square. gridCursor.x = Math.floor(cursorX / GRID_SIZE) * GRID_SIZE + gridTiledSprite.x; gridCursor.y = cursorStep * GRID_SIZE + gridTiledSprite.y; // Handle clicks. // Left click. if (FlxG.mouse.justPressed) { // Find the first note that is at the cursor position. var highlightedNote:ChartEditorNoteSprite = renderedNotes.find(function(note:ChartEditorNoteSprite):Bool { // return note.step == cursorStep && note.column == cursorColumn; return FlxG.mouse.overlaps(note); }); if (FlxG.keys.pressed.CONTROL) { if (highlightedNote != null) { if (isNoteSelected(highlightedNote.noteData)) { performCommand(new SelectNotesCommand([highlightedNote.noteData])); } else { performCommand(new DeselectNotesCommand([highlightedNote.noteData])); } } } else { if (highlightedNote != null) { // Remove the note. performCommand(new RemoveNotesCommand([highlightedNote.noteData])); } else { // Place a note. var eventColumn = (STRUMLINE_SIZE * 2 + 1) - 1; if (cursorColumn == eventColumn) { // Create an event and place it in the chart. var cursorMs = cursorStep * Conductor.stepCrochet; // TODO: Allow configuring the event to place from the sidebar. var newEventData:SongEventData = new SongEventData(cursorMs, "test", {}); performCommand(new AddEventsCommand([newEventData])); } else { // Create a note and place it in the chart. var cursorMs = cursorStep * Conductor.stepCrochet; var newNoteData:SongNoteData = new SongNoteData(cursorMs, cursorColumn, 0, selectedNoteKind); performCommand(new AddNotesCommand([newNoteData])); } } } } } else { gridCursor.visible = false; gridCursor.x = -9999; gridCursor.y = -9999; } } /** * Handle using `renderedNotes` to display notes from `currentSongChartNoteData`. */ function handleNoteDisplay() { if (noteDisplayDirty) { noteDisplayDirty = false; // Update for whether downscroll is enabled. renderedNotes.flipX = (isViewDownscroll); // Calculate the view bounds. var viewAreaTop:Float = this.scrollPosition - GRID_TOP_PAD; var viewHeight:Float = (FlxG.height - MENU_BAR_HEIGHT); var viewAreaBottom:Float = this.scrollPosition + viewHeight; // Remove notes that are no longer visible and list the ones that are. var displayedNoteData:Array = []; for (noteSprite in renderedNotes.members) { if (noteSprite == null || !noteSprite.exists || !noteSprite.visible) continue; if (noteSprite.y + noteSprite.height < viewAreaTop || noteSprite.y > viewAreaBottom) { // This sprite is off-screen. // Kill the note sprite and recycle it. noteSprite.noteData = null; } else if (currentSongChartNoteData.indexOf(noteSprite.noteData) == -1) { // This note was deleted. // Kill the note sprite and recycle it. noteSprite.noteData = null; } else { displayedNoteData.push(noteSprite.noteData); } } // Add notes that are now visible. for (noteData in currentSongChartNoteData) { // Remember if we are already displaying this note. if (displayedNoteData.indexOf(noteData) != -1) continue; var noteTimePixels:Float = noteData.time / Conductor.stepCrochet * GRID_SIZE; // Make sure the note appears when scrolling up. var modifiedViewAreaTop = viewAreaTop - GRID_SIZE; if (noteTimePixels < modifiedViewAreaTop || noteTimePixels > viewAreaBottom) 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(ChartEditorNoteSprite); // The note sprite handles animation playback and positioning. noteSprite.noteData = noteData; // Setting note data resets position relative to the grid so we fix that. noteSprite.x += renderedNotes.x; noteSprite.y += renderedNotes.y; } // Handle selection squares. for (member in renderedNoteSelectionSquares.members) { member.kill(); } for (noteSprite in renderedNotes.members) { if (isNoteSelected(noteSprite.noteData)) { var selectionSquare:FlxSprite = renderedNoteSelectionSquares.recycle(FlxSprite).loadGraphic(selectionSquareBitmap); selectionSquare.x = noteSprite.x; selectionSquare.y = noteSprite.y; selectionSquare.width = noteSprite.width; selectionSquare.height = noteSprite.height; } } } } /** * Handle keybinds for File menu items. */ function handleFileKeybinds() { // CTRL + Q = Quit to Menu if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.Q) { FlxG.switchState(new MainMenuState()); } } /** * Handle keybinds for edit menu items. */ function handleEditKeybinds() { // CTRL + Z = Undo if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.Z) { undoLastCommand(); } // CTRL + Y = Redo if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.Y) { redoLastCommand(); } } /** * Handle keybinds for View menu items. */ function handleViewKeybinds() { // B = Toggle Sidebar if (FlxG.keys.justPressed.B) toggleSidebar(); } /** * Handle keybinds for Help menu items. */ function handleHelpKeybinds() { // F1 = Open Help if (FlxG.keys.justPressed.F1) openDialog('chart-editor/dialogs/user-guide'); } function handleSidebar() { if (difficultySelectDirty) { difficultySelectDirty = false; // Manage the Select Difficulty tree view. var treeView:TreeView = findComponent('sidebarDifficulties'); if (treeView != null) { var treeSong = treeView.addNode({id: 'stv_song_dadbattle', text: "S: Dad Battle", icon: "haxeui-core/styles/default/haxeui_tiny.png"}); treeSong.expanded = true; var treeVariationDefault = treeSong.addNode({ id: 'stv_variation_default', text: "V: Default", icon: "haxeui-core/styles/default/haxeui_tiny.png" }); var treeVariationErect = treeSong.addNode({id: 'stv_variation_erect', text: "V: Erect", icon: "haxeui-core/styles/default/haxeui_tiny.png"}); var treeDifficultyEasy = treeVariationDefault.addNode({ id: 'stv_difficulty_easy', text: "D: Easy", icon: "haxeui-core/styles/default/haxeui_tiny.png" }); var treeDifficultyNormal = treeVariationDefault.addNode({ id: 'stv_difficulty_normal', text: "D: Normal", icon: "haxeui-core/styles/default/haxeui_tiny.png" }); var treeDifficultyHard = treeVariationDefault.addNode({ id: 'stv_difficulty_hard', text: "D: Hard", icon: "haxeui-core/styles/default/haxeui_tiny.png" }); var treeDifficultyErect = treeVariationErect.addNode({ id: 'stv_difficulty_erect', text: "D: Erect", icon: "haxeui-core/styles/default/haxeui_tiny.png" }); treeView.onChange = onChangeTreeDifficulty; } } } function onChangeTreeDifficulty(event:UIEvent):Void { // Get the selected node. var target:TreeView = cast event.target; var targetNode:TreeViewNode = target.selectedNode; trace('Selected node: ${targetNode.id}'); } /** * Handle the player preview/gameplay test area on the left side. */ function handlePlayerDisplay() { } /** * 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() { // if (notePreviewDirty) { notePreviewDirty = false; var PREVIEW_WIDTH:Int = GRID_SIZE * 2; var STEP_HEIGHT:Int = 1; var PREVIEW_HEIGHT:Int = Std.int(Conductor.getTimeInSteps(audioInstTrack.length) * STEP_HEIGHT); notePreviewBitmap = new BitmapData(PREVIEW_WIDTH, PREVIEW_HEIGHT, true); notePreviewBitmap.fillRect(new Rectangle(0, 0, PREVIEW_WIDTH, PREVIEW_HEIGHT), PREVIEW_BG_COLOR); } } /** * Perform a spot update on the note preview, by editing the note preview * only where necessary. More efficient than a full update. */ function updateNotePreview(note:SongNoteData, ?deleteNote:Bool = false) { } /** * 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() { if (commandHistoryDirty) { commandHistoryDirty = false; // Update the Undo and Redo buttons. var undoButton: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: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() { if (audioInstTrack.playing) { if (FlxG.mouse.pressedMiddle) { // If middle mouse panning during song playback, move ONLY the playhead. var oldStepTime = Conductor.currentStepTime; Conductor.update(audioInstTrack.time); var diffStepTime = Conductor.currentStepTime - oldStepTime; // Move the playhead. playheadPosition += diffStepTime * GRID_SIZE; // We don't move the song to scroll position, or update the note sprites. } else { // Else, move the entire view. Conductor.update(audioInstTrack.time); // We need time in fractional steps here to allow the song to actually play. // Also account for a potentially offset playhead. scrollPosition = Conductor.currentStepTime * GRID_SIZE - playheadPosition; // DO NOT move song to scroll position here specifically. // We need to update the note sprites. noteDisplayDirty = true; } } if (FlxG.keys.justPressed.SPACE) { if (audioInstTrack.playing) { audioInstTrack.pause(); audioVocalTrack.pause(); } else { audioInstTrack.play(); audioVocalTrack.play(); } } } function handlePlayheadKeybinds() { // Place notes at the playhead. // TODO: Add the ability to switch modes. if (true) { if (FlxG.keys.justPressed.ONE) placeNoteAtPlayhead(0); if (FlxG.keys.justPressed.TWO) placeNoteAtPlayhead(1); if (FlxG.keys.justPressed.THREE) placeNoteAtPlayhead(2); if (FlxG.keys.justPressed.FOUR) placeNoteAtPlayhead(3); if (FlxG.keys.justPressed.FIVE) placeNoteAtPlayhead(4); if (FlxG.keys.justPressed.SIX) placeNoteAtPlayhead(5); if (FlxG.keys.justPressed.SEVEN) placeNoteAtPlayhead(6); if (FlxG.keys.justPressed.EIGHT) placeNoteAtPlayhead(7); } } function placeNoteAtPlayhead(column:Int):Void { var gridSnappedPlayheadPos = scrollPosition - (scrollPosition % GRID_SIZE); } function set_scrollPosition(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 (playheadPosition > 0) { var amount = scrollPosition - value; playheadPosition -= amount; } value = 0; } if (value > songLength) value = songLength; if (value == scrollPosition) return value; this.scrollPosition = value; // Move the grid sprite to the correct position. if (isViewDownscroll) { gridTiledSprite.y = -scrollPosition + (MENU_BAR_HEIGHT + GRID_TOP_PAD); } else { gridTiledSprite.y = -scrollPosition + (MENU_BAR_HEIGHT + GRID_TOP_PAD); } // Move the rendered notes to the correct position. renderedNotes.setPosition(gridTiledSprite.x, gridTiledSprite.y); return this.scrollPosition; } function set_playheadPosition(value:Float):Float { // Make sure playhead doesn't go outside the song. if (value + scrollPosition < 0) value = -scrollPosition; if (value + scrollPosition > songLength) value = songLength - scrollPosition; this.playheadPosition = value; // Move the playhead sprite to the correct position. gridPlayhead.y = this.playheadPosition + (MENU_BAR_HEIGHT + GRID_TOP_PAD); return this.playheadPosition; } /** * Show the sidebar if it's hidden, or hide it if it's shown. */ function toggleSidebar() { var sidebar:Component = findComponent('sidebar', Component); // Set visibility while syncing the checkbox. if (sidebar != null) { sidebar.visible = setUISelected('menubarItemToggleSidebar', !sidebar.visible); } } /** * Opens a dialog. * @param modal Makes the background uninteractable. */ function openDialog(key:String, modal:Bool = true) { var dialog:Dialog = cast buildComponent(Paths.ui(key)); dialog.onDialogClosed = function(e:DialogEvent) { if (modal) { isModalDialogOpen = false; } } dialog.showDialog(modal); isModalDialogOpen = modal; } /** * Load a music track for playback. */ function loadMusic() { // TODO: How to load music by selecting with a file dialog? audioInstTrack = FlxG.sound.play(Paths.inst('dadbattle'), 1.0, false); audioInstTrack.autoDestroy = false; audioInstTrack.pause(); // Prevent the time from skipping back to 0 when the song ends. audioInstTrack.onComplete = function() { audioInstTrack.pause(); audioVocalTrack.pause(); }; audioVocalTrack = FlxG.sound.play(Paths.voices('dadbattle'), 1.0, false); audioVocalTrack.autoDestroy = false; audioVocalTrack.pause(); // TODO: Make sure Conductor works properly with changing BPMs. var DAD_BATTLE_BPM = 180; var BOPEEBO_BPM = 100; Conductor.forceBPM(DAD_BATTLE_BPM); songLength = Std.int(Conductor.getTimeInSteps(audioInstTrack.length) * GRID_SIZE); gridTiledSprite.height = songLength; if (gridSpectrogram != null) gridSpectrogram.setSound(audioVocalTrack); scrollPosition = 0; playheadPosition = 0; moveSongToScrollPosition(); } /** * 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() { // Update the songPosition in the Conductor. Conductor.update(scrollPositionInMs); // Update the songPosition in the audio tracks. audioInstTrack.time = scrollPositionInMs + playheadPositionInMs; audioVocalTrack.time = scrollPositionInMs + playheadPositionInMs; // We need to update the note sprites because we changed the scroll position. noteDisplayDirty = true; } /** * Add an onClick listener to a HaxeUI menu bar item. **/ function addUIClickListener(key:String, callback:MouseEvent->Void) { var target:Component = findComponent(key); if (target == null) { // Gracefully handle the case where the item can't be located. trace('WARN: Could not locate menu item: $key'); } else { target.onClick = callback; } } /** * Add an onChange listener to a HaxeUI menu bar item such as a slider. */ function addUIChangeListener(key:String, callback:UIEvent->Void) { var target:Component = findComponent(key); if (target == null) { // Gracefully handle the case where the item can't be located. trace('WARN: Could not locate menu item: $key'); } else { target.onChange = callback; } } /** * Set the value of a HaxeUI component. * Usually modifies the text of a label. */ function setUIValue(key:String, value:T):T { var target:Component = findComponent(key); if (target == null) { // Gracefully handle the case where the item can't be located. trace('WARN: Could not locate menu item: $key'); return value; } else { return target.value = value; } } /** * Set the value of a HaxeUI checkbox, * since that's on 'selected' instead of 'value'. */ function setUISelected(key:String, value:Bool):Bool { var targetA:CheckBox = findComponent(key, CheckBox); if (targetA != null) { return targetA.selected = value; } var targetB:MenuCheckBox = findComponent(key, MenuCheckBox); if (targetB != null) { return targetB.selected = value; } // Gracefully handle the case where the item can't be located. trace('WARN: Could not locate check box: $key'); return value; } /** * 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 { if (undoHistory.length == 0) { trace('No actions to undo.'); return; } var command = undoHistory.pop(); undoCommand(command); } /** * Redo the last command in the redo stack, then add it to the undo stack. */ function redoLastCommand():Void { if (redoHistory.length == 0) { trace('No actions to redo.'); return; } var command = redoHistory.pop(); performCommand(command, false); } function sortChartData() { currentSongChartNoteData.sort(function(a:SongNoteData, b:SongNoteData):Int { return FlxSort.byValues(FlxSort.ASCENDING, a.time, b.time); }); currentSongChartEventData.sort(function(a:SongEventData, b:SongEventData):Int { return FlxSort.byValues(FlxSort.ASCENDING, a.time, b.time); }); } function playMetronomeTick(?high:Bool = false) { playSound(Paths.sound('pianoStuff/piano-${high ? '001' : '008'}')); } function isNoteSelected(note:SongNoteData):Bool { return currentSelection.indexOf(note) != -1; } /** * Play a sound effect. * Automatically cleans up after itself and recycles previous FlxSound instances if available, for performance. */ function playSound(path:String) { var snd:FlxSound = FlxG.sound.list.recycle(FlxSound); snd.loadEmbedded(FlxG.sound.cache(path)); snd.autoDestroy = true; FlxG.sound.list.add(snd); snd.play(); } override function destroy() { super.destroy(); @:privateAccess ChartEditorNoteSprite.noteFrameCollection = null; } }