Funkin/source/funkin/ui/debug/charting/ChartEditorState.hx
2022-10-10 20:04:09 -04:00

1767 lines
46 KiB
Haxe

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<ChartEditorCommand> = [];
/**
* The list of commands that have been undone. Used for redoing previous actions.
*/
var redoHistory:Array<ChartEditorCommand> = [];
/**
* 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<SongNoteData> = [];
/**
* 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<SongNoteData> = [];
/**
* 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<String, SongMetadata>;
/**
* 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 = 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<SongNoteData>;
function get_currentSongChartNoteData():Array<SongNoteData>
{
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<SongEventData>;
function get_currentSongChartEventData():Array<SongEventData>
{
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<ChartEditorNoteSprite>;
var renderedNoteSelectionSquares:FlxTypedSpriteGroup<FlxSprite>;
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<String, SongMetadata>();
// Initialize the song chart data.
songChartData = new Map<String, SongChartData>();
}
/**
* 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<ChartEditorNoteSprite>();
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<SongNoteData> = [];
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<T>(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<T>(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;
}
}