1
0
Fork 0
mirror of https://github.com/ninjamuffin99/Funkin.git synced 2025-03-23 02:19:46 +00:00

Merge branch 'feature/chart-editor-events'

This commit is contained in:
EliteMasterEric 2023-01-22 20:03:08 -05:00
commit e922c026a9
44 changed files with 13825 additions and 12167 deletions

View file

@ -151,16 +151,23 @@
<!-- HScript relies heavily on Reflection, which means we can't use DCE. --> <!-- HScript relies heavily on Reflection, which means we can't use DCE. -->
<haxeflag name="-dce no" /> <haxeflag name="-dce no" />
<!-- Ensure all Funkin' classes are available at runtime. -->
<haxeflag name="--macro" value="include('funkin')" /> <haxeflag name="--macro" value="include('funkin')" />
<!-- Ensure all UI components are available at runtime. -->
<haxeflag name="--macro" value="include('haxe.ui.components')" />
<haxeflag name="--macro" value="include('haxe.ui.containers')" />
<!-- Ensure all UI components are available at runtime. --> <!-- Ensure all UI components are available at runtime. -->
<haxeflag name="--macro" value="include('haxe.ui.components')" /> <haxeflag name="--macro" value="include('haxe.ui.components')" />
<haxeflag name="--macro" value="include('haxe.ui.containers')" /> <haxeflag name="--macro" value="include('haxe.ui.containers')" />
<haxeflag name="--macro" value="include('haxe.ui.containers.menus')" /> <haxeflag name="--macro" value="include('haxe.ui.containers.menus')" />
<!--
Ensure additional class packages are available at runtime (some only really used by scripts).
Ignore packages we can't include.
-->
<haxeflag name="--macro" value="include('flixel', true, [ 'flixel.addons.editors.spine.*', 'flixel.addons.nape.*', 'flixel.system.macros.*' ])" />
<!-- Necessary to provide stack traces for HScript. --> <!-- Necessary to provide stack traces for HScript. -->
<haxedef name="hscriptPos" /> <haxedef name="hscriptPos" />
<haxedef name="HXCPP_CHECK_POINTER" /> <haxedef name="HXCPP_CHECK_POINTER" />

View file

@ -2,11 +2,14 @@
"lineEnds": { "lineEnds": {
"leftCurly": "both", "leftCurly": "both",
"rightCurly": "both", "rightCurly": "both",
"emptyCurly": "break", "emptyCurly": "noBreak",
"objectLiteralCurly": { "objectLiteralCurly": {
"leftCurly": "after" "leftCurly": "after"
} }
}, },
"indentation": {
"character": " "
},
"sameLine": { "sameLine": {
"ifElse": "next", "ifElse": "next",
"doWhile": "next", "doWhile": "next",

View file

@ -131,9 +131,7 @@ class Conductor
return timeSignatureNumerator * 4; return timeSignatureNumerator * 4;
} }
private function new() private function new() {}
{
}
public static function getLastBPMChange() public static function getLastBPMChange()
{ {
@ -181,7 +179,7 @@ class Conductor
public static function update(songPosition:Float = null) public static function update(songPosition:Float = null)
{ {
if (songPosition == null) if (songPosition == null)
songPosition = (FlxG.sound.music != null) ? (FlxG.sound.music.time + Conductor.offset) : 0; songPosition = (FlxG.sound.music != null) ? FlxG.sound.music.time + Conductor.offset : 0.0;
var oldBeat = currentBeat; var oldBeat = currentBeat;
var oldStep = currentStep; var oldStep = currentStep;
@ -199,7 +197,7 @@ class Conductor
break; break;
} }
if (currentTimeChange == null && bpmOverride == null) if (currentTimeChange == null && bpmOverride == null && FlxG.sound.music != null)
{ {
trace('WARNING: Conductor is broken, timeChanges is empty.'); trace('WARNING: Conductor is broken, timeChanges is empty.');
} }

View file

@ -11,7 +11,7 @@ import flixel.util.FlxColor;
import funkin.modding.module.ModuleHandler; import funkin.modding.module.ModuleHandler;
import funkin.play.PlayState; import funkin.play.PlayState;
import funkin.play.character.CharacterData.CharacterDataParser; import funkin.play.character.CharacterData.CharacterDataParser;
import funkin.play.event.SongEvent.SongEventHandler; import funkin.play.event.SongEvent.SongEventParser;
import funkin.play.song.SongData.SongDataParser; import funkin.play.song.SongData.SongDataParser;
import funkin.play.stage.StageData; import funkin.play.stage.StageData;
import funkin.ui.PreferencesMenu; import funkin.ui.PreferencesMenu;
@ -132,6 +132,7 @@ class InitState extends FlxTransitionableState
// FlxG.save.close(); // FlxG.save.close();
// FlxG.sound.loadSavedPrefs(); // FlxG.sound.loadSavedPrefs();
WindowUtil.initWindowEvents(); WindowUtil.initWindowEvents();
WindowUtil.disableCrashHandler();
PreferencesMenu.initPrefs(); PreferencesMenu.initPrefs();
PlayerSettings.init(); PlayerSettings.init();
@ -159,9 +160,9 @@ class InitState extends FlxTransitionableState
// FlxTransitionableState.skipNextTransOut = true; // FlxTransitionableState.skipNextTransOut = true;
FlxTransitionableState.skipNextTransIn = true; FlxTransitionableState.skipNextTransIn = true;
SongEventHandler.registerBaseEventCallbacks();
// TODO: Register custom event callbacks here // TODO: Register custom event callbacks here
SongEventParser.loadEventCache();
SongDataParser.loadSongCache(); SongDataParser.loadSongCache();
StageDataParser.loadStageCache(); StageDataParser.loadStageCache();
CharacterDataParser.loadCharacterCache(); CharacterDataParser.loadCharacterCache();
@ -170,6 +171,8 @@ class InitState extends FlxTransitionableState
FlxG.debugger.toggleKeys = [F2]; FlxG.debugger.toggleKeys = [F2];
ModuleHandler.callOnCreate();
#if song #if song
var song = getSong(); var song = getSong();

View file

@ -103,16 +103,16 @@ class DJBoyfriend extends FlxSprite
switch (name) switch (name)
{ {
case "intro": case "intro":
trace('Finished intro'); // trace('Finished intro');
currentState = Idle; currentState = Idle;
onIntroDone.dispatch(); onIntroDone.dispatch();
case "idle": case "idle":
trace('Finished idle'); // trace('Finished idle');
case "spook": case "spook":
trace('Finished spook'); // trace('Finished spook');
currentState = Idle; currentState = Idle;
case "confirm": case "confirm":
trace('Finished confirm'); // trace('Finished confirm');
} }
} }

View file

@ -6,8 +6,7 @@ import flixel.FlxG; // This one in particular causes a compile error if you're u
// These are great. // These are great.
using Lambda; using Lambda;
using StringTools; using StringTools;
using funkin.util.tools.MapTools;
using funkin.util.tools.IteratorTools; using funkin.util.tools.IteratorTools;
using funkin.util.tools.StringTools; using funkin.util.tools.StringTools;
#end #end

View file

@ -60,6 +60,7 @@ interface IPlayStateScriptedClass extends IScriptedClass
* and can be cancelled by scripts. * and can be cancelled by scripts.
*/ */
public function onPause(event:PauseScriptEvent):Void; public function onPause(event:PauseScriptEvent):Void;
/** /**
* Called when the game is unpaused. * Called when the game is unpaused.
*/ */
@ -70,18 +71,22 @@ interface IPlayStateScriptedClass extends IScriptedClass
* Use this to mutate the chart. * Use this to mutate the chart.
*/ */
public function onSongLoaded(event:SongLoadScriptEvent):Void; public function onSongLoaded(event:SongLoadScriptEvent):Void;
/** /**
* Called when the song starts (conductor time is 0 seconds). * Called when the song starts (conductor time is 0 seconds).
*/ */
public function onSongStart(event:ScriptEvent):Void; public function onSongStart(event:ScriptEvent):Void;
/** /**
* Called when the song ends and the song is about to be unloaded. * Called when the song ends and the song is about to be unloaded.
*/ */
public function onSongEnd(event:ScriptEvent):Void; public function onSongEnd(event:ScriptEvent):Void;
/** /**
* Called as the player runs out of health just before the game over substate is entered. * Called as the player runs out of health just before the game over substate is entered.
*/ */
public function onGameOver(event:ScriptEvent):Void; public function onGameOver(event:ScriptEvent):Void;
/** /**
* Called when the player restarts the song, either via pause menu or restarting after a game over. * Called when the player restarts the song, either via pause menu or restarting after a game over.
*/ */
@ -92,19 +97,27 @@ interface IPlayStateScriptedClass extends IScriptedClass
* Query the note attached to the event to determine if it was hit by the player or CPU. * Query the note attached to the event to determine if it was hit by the player or CPU.
*/ */
public function onNoteHit(event:NoteScriptEvent):Void; public function onNoteHit(event:NoteScriptEvent):Void;
/** /**
* Called when EITHER player (usually the player) misses a note. * Called when EITHER player (usually the player) misses a note.
*/ */
public function onNoteMiss(event:NoteScriptEvent):Void; public function onNoteMiss(event:NoteScriptEvent):Void;
/** /**
* Called when the player presses a key when no note is on the strumline. * Called when the player presses a key when no note is on the strumline.
*/ */
public function onNoteGhostMiss(event:GhostMissNoteScriptEvent):Void; public function onNoteGhostMiss(event:GhostMissNoteScriptEvent):Void;
/**
* Called when the song reaches an event.
*/
public function onSongEvent(event:SongEventScriptEvent):Void;
/** /**
* Called once every step of the song. * Called once every step of the song.
*/ */
public function onStepHit(event:SongTimeScriptEvent):Void; public function onStepHit(event:SongTimeScriptEvent):Void;
/** /**
* Called once every beat of the song. * Called once every beat of the song.
*/ */
@ -114,10 +127,12 @@ interface IPlayStateScriptedClass extends IScriptedClass
* Called when the countdown of the song starts. * Called when the countdown of the song starts.
*/ */
public function onCountdownStart(event:CountdownScriptEvent):Void; public function onCountdownStart(event:CountdownScriptEvent):Void;
/** /**
* Called when the a part of the countdown happens. * Called when the a part of the countdown happens.
*/ */
public function onCountdownStep(event:CountdownScriptEvent):Void; public function onCountdownStep(event:CountdownScriptEvent):Void;
/** /**
* Called when the countdown of the song ends. * Called when the countdown of the song ends.
*/ */

View file

@ -22,41 +22,61 @@ class PolymodErrorHandler
// Perform an action based on the error code. // Perform an action based on the error code.
switch (error.code) switch (error.code)
{ {
case MOD_LOAD_PREPARE: case FRAMEWORK_INIT, FRAMEWORK_AUTODETECT, SCRIPT_PARSING:
logInfo('[POLYMOD]: ${error.message}'); // Unimportant.
case MOD_LOAD_DONE: return;
logInfo('[POLYMOD]: ${error.message}');
case MOD_LOAD_PREPARE, MOD_LOAD_DONE:
logInfo('LOADING MOD - ${error.message}');
case MISSING_ICON: case MISSING_ICON:
logWarn('[POLYMOD]: A mod is missing an icon. Please add one.'); logWarn('A mod is missing an icon. Please add one.');
case SCRIPT_PARSE_ERROR: case SCRIPT_PARSE_ERROR:
// A syntax error when parsing a script. // A syntax error when parsing a script.
logError('[POLYMOD]: ${error.message}'); logError(error.message);
// Notify the user via popup.
showAlert('Polymod Script Parsing Error', error.message); showAlert('Polymod Script Parsing Error', error.message);
case SCRIPT_EXCEPTION: case SCRIPT_RUNTIME_EXCEPTION:
// A runtime error when running a script. // A runtime error when running a script.
logError('[POLYMOD]: ${error.message}'); logError(error.message);
showAlert('Polymod Script Execution Error', error.message); // Notify the user via popup.
case SCRIPT_CLASS_NOT_FOUND: showAlert('Polymod Script Exception', error.message);
// A scripted class tried to reference an unknown superclass. case SCRIPT_CLASS_MODULE_NOT_FOUND:
logError('[POLYMOD]: ${error.message}'); // A scripted class tried to reference an unknown class or module.
showAlert('Polymod Script Parsing Error', error.message); logError(error.message);
// Last word is the class name.
var className:String = error.message.split(' ').pop();
var msg:String = 'Import error in ${error.origin}';
msg += '\nCould not import unknown class ${className}';
msg += '\nCheck to ensure the class exists and is spelled correctly.';
// Notify the user via popup.
showAlert('Polymod Script Import Error', msg);
case SCRIPT_CLASS_MODULE_BLACKLISTED:
// A scripted class tried to reference a blacklisted class or module.
logError(error.message);
// Notify the user via popup.
showAlert('Polymod Script Blacklist Violation', error.message);
default: default:
// Log the message based on its severity. // Log the message based on its severity.
switch (error.severity) switch (error.severity)
{ {
case NOTICE: case NOTICE:
logInfo('[POLYMOD]: ${error.message}'); logInfo(error.message);
case WARNING: case WARNING:
logWarn('[POLYMOD]: ${error.message}'); logWarn(error.message);
case ERROR: case ERROR:
logError('[POLYMOD]: ${error.message}'); logError(error.message);
} }
} }
} }
static function logInfo(message:String):Void static function logInfo(message:String):Void
{ {
trace('[INFO ] ${message}'); trace('[INFO-] ${message}');
} }
static function logError(message:String):Void static function logError(message:String):Void
@ -66,6 +86,6 @@ class PolymodErrorHandler
static function logWarn(message:String):Void static function logWarn(message:String):Void
{ {
trace('[WARN ] ${message}'); trace('[WARN-] ${message}');
} }
} }

View file

@ -1,5 +1,6 @@
package funkin.modding; package funkin.modding;
import funkin.util.macro.ClassMacro;
import funkin.modding.module.ModuleHandler; import funkin.modding.module.ModuleHandler;
import funkin.play.character.CharacterData.CharacterDataParser; import funkin.play.character.CharacterData.CharacterDataParser;
import funkin.play.song.SongData; import funkin.play.song.SongData;
@ -75,6 +76,9 @@ class PolymodHandler
{ {
trace('Attempting to load ${ids.length} mods...'); trace('Attempting to load ${ids.length} mods...');
} }
buildImports();
var loadedModList = polymod.Polymod.init({ var loadedModList = polymod.Polymod.init({
// Root directory for all mods. // Root directory for all mods.
modRoot: MOD_FOLDER, modRoot: MOD_FOLDER,
@ -105,17 +109,17 @@ class PolymodHandler
if (loadedModList == null) if (loadedModList == null)
{ {
trace('[POLYMOD] An error occurred! Failed when loading mods!'); trace('An error occurred! Failed when loading mods!');
} }
else else
{ {
if (loadedModList.length == 0) if (loadedModList.length == 0)
{ {
trace('[POLYMOD] Mod loading complete. We loaded no mods / ${ids.length} mods.'); trace('Mod loading complete. We loaded no mods / ${ids.length} mods.');
} }
else else
{ {
trace('[POLYMOD] Mod loading complete. We loaded ${loadedModList.length} / ${ids.length} mods.'); trace('Mod loading complete. We loaded ${loadedModList.length} / ${ids.length} mods.');
} }
} }
@ -126,32 +130,49 @@ class PolymodHandler
#if debug #if debug
var fileList = Polymod.listModFiles(PolymodAssetType.IMAGE); var fileList = Polymod.listModFiles(PolymodAssetType.IMAGE);
trace('[POLYMOD] Installed mods have replaced ${fileList.length} images.'); trace('Installed mods have replaced ${fileList.length} images.');
for (item in fileList) for (item in fileList)
trace(' * $item'); trace(' * $item');
fileList = Polymod.listModFiles(PolymodAssetType.TEXT); fileList = Polymod.listModFiles(PolymodAssetType.TEXT);
trace('[POLYMOD] Installed mods have replaced ${fileList.length} text files.'); trace('Installed mods have added/replaced ${fileList.length} text files.');
for (item in fileList) for (item in fileList)
trace(' * $item'); trace(' * $item');
fileList = Polymod.listModFiles(PolymodAssetType.AUDIO_MUSIC); fileList = Polymod.listModFiles(PolymodAssetType.AUDIO_MUSIC);
trace('[POLYMOD] Installed mods have replaced ${fileList.length} music files.'); trace('Installed mods have replaced ${fileList.length} music files.');
for (item in fileList) for (item in fileList)
trace(' * $item'); trace(' * $item');
fileList = Polymod.listModFiles(PolymodAssetType.AUDIO_SOUND); fileList = Polymod.listModFiles(PolymodAssetType.AUDIO_SOUND);
trace('[POLYMOD] Installed mods have replaced ${fileList.length} sound files.'); trace('Installed mods have replaced ${fileList.length} sound files.');
for (item in fileList) for (item in fileList)
trace(' * $item'); trace(' * $item');
fileList = Polymod.listModFiles(PolymodAssetType.AUDIO_GENERIC); fileList = Polymod.listModFiles(PolymodAssetType.AUDIO_GENERIC);
trace('[POLYMOD] Installed mods have replaced ${fileList.length} generic audio files.'); trace('Installed mods have replaced ${fileList.length} generic audio files.');
for (item in fileList) for (item in fileList)
trace(' * $item'); trace(' * $item');
#end #end
} }
static function buildImports():Void
{
// Add default imports for common classes.
// Add import aliases for certain classes.
// NOTE: Scripted classes are automatically aliased to their parent class.
Polymod.addImportAlias('flixel.math.FlxPoint', flixel.math.FlxPoint.FlxBasePoint);
// Add blacklisting for prohibited classes and packages.
// `polymod.*`
for (cls in ClassMacro.listClassesInPackage('polymod'))
{
var className = Type.getClassName(cls);
Polymod.blacklistImport(className);
}
}
static function buildParseRules():polymod.format.ParseRules static function buildParseRules():polymod.format.ParseRules
{ {
var output = polymod.format.ParseRules.getDefault(); var output = polymod.format.ParseRules.getDefault();

View file

@ -95,6 +95,14 @@ class ScriptEvent
*/ */
public static inline final NOTE_GHOST_MISS:ScriptEventType = "NOTE_GHOST_MISS"; public static inline final NOTE_GHOST_MISS:ScriptEventType = "NOTE_GHOST_MISS";
/**
* Called when a song event is reached in the chart.
*
* This event IS cancelable! Cancelling this event prevents the event from being triggered,
* thus blocking its normal functionality.
*/
public static inline final SONG_EVENT:ScriptEventType = "SONG_EVENT";
/** /**
* Called when the song starts. This occurs as the countdown ends and the instrumental and vocals begin. * Called when the song starts. This occurs as the countdown ends and the instrumental and vocals begin.
* *
@ -366,6 +374,35 @@ class GhostMissNoteScriptEvent extends ScriptEvent
} }
} }
/**
* An event that is fired when the song reaches an event.
*/
class SongEventScriptEvent extends ScriptEvent
{
/**
* The note associated with this event.
* You cannot replace it, but you can edit it.
*/
public var event(default, null):funkin.play.song.SongData.SongEventData;
/**
* The combo count as it is with this event.
* Will be (combo) on miss events and (combo + 1) on hit events (the stored combo count won't update if the event is cancelled).
*/
public var comboCount(default, null):Int;
public function new(event:funkin.play.song.SongData.SongEventData):Void
{
super(ScriptEvent.SONG_EVENT, true);
this.event = event;
}
public override function toString():String
{
return 'SongEventScriptEvent(event=' + event + ')';
}
}
/** /**
* An event that is fired during the update loop. * An event that is fired during the update loop.
*/ */

View file

@ -94,6 +94,8 @@ class Module implements IPlayStateScriptedClass implements IStateChangingScripte
public function onBeatHit(event:SongTimeScriptEvent) {} public function onBeatHit(event:SongTimeScriptEvent) {}
public function onSongEvent(event:SongEventScriptEvent) {}
public function onCountdownStart(event:CountdownScriptEvent) {} public function onCountdownStart(event:CountdownScriptEvent) {}
public function onCountdownStep(event:CountdownScriptEvent) {} public function onCountdownStep(event:CountdownScriptEvent) {}

View file

@ -139,4 +139,9 @@ class ModuleHandler
} }
} }
} }
public static inline function callOnCreate():Void
{
callEvent(new ScriptEvent(ScriptEvent.CREATE, false));
}
} }

View file

@ -1,5 +1,7 @@
package funkin.play; package funkin.play;
import funkin.play.song.SongData.SongEventData;
import funkin.play.event.SongEvent.SongEventParser;
import flixel.FlxCamera; import flixel.FlxCamera;
import flixel.FlxObject; import flixel.FlxObject;
import flixel.FlxSprite; import flixel.FlxSprite;
@ -29,7 +31,6 @@ import funkin.play.Strumline.StrumlineArrow;
import funkin.play.Strumline.StrumlineStyle; import funkin.play.Strumline.StrumlineStyle;
import funkin.play.character.BaseCharacter; import funkin.play.character.BaseCharacter;
import funkin.play.character.CharacterData; import funkin.play.character.CharacterData;
import funkin.play.event.SongEvent;
import funkin.play.scoring.Scoring; import funkin.play.scoring.Scoring;
import funkin.play.song.Song; import funkin.play.song.Song;
import funkin.play.song.SongData.SongNoteData; import funkin.play.song.SongData.SongNoteData;
@ -162,7 +163,7 @@ class PlayState extends MusicBeatState
*/ */
private var inactiveNotes:Array<Note>; private var inactiveNotes:Array<Note>;
private var songEvents:Array<SongEvent>; private var songEvents:Array<SongEventData>;
/** /**
* If true, the player is allowed to pause the game. * If true, the player is allowed to pause the game.
@ -1146,8 +1147,7 @@ class PlayState extends MusicBeatState
{ {
oldNote = inactiveNotes[Std.int(inactiveNotes.length - 1)]; oldNote = inactiveNotes[Std.int(inactiveNotes.length - 1)];
var sustainNote:Note = new Note(daStrumTime + (Conductor.stepCrochet * susNote) + Conductor.stepCrochet, daNoteData, oldNote, true, var sustainNote:Note = new Note(daStrumTime + (Conductor.stepCrochet * susNote) + Conductor.stepCrochet, daNoteData, oldNote, true, strumlineStyle);
strumlineStyle);
sustainNote.data.noteKind = songNotes.noteKind; sustainNote.data.noteKind = songNotes.noteKind;
sustainNote.scrollFactor.set(); sustainNote.scrollFactor.set();
inactiveNotes.push(sustainNote); inactiveNotes.push(sustainNote);
@ -1199,7 +1199,7 @@ class PlayState extends MusicBeatState
// Reset song events. // Reset song events.
songEvents = currentChart.getEvents(); songEvents = currentChart.getEvents();
SongEventHandler.resetEvents(songEvents); SongEventParser.resetEvents(songEvents);
// Destroy inactive notes. // Destroy inactive notes.
inactiveNotes = []; inactiveNotes = [];
@ -1337,8 +1337,7 @@ class PlayState extends MusicBeatState
if (health > 0 && !paused && FlxG.autoPause) if (health > 0 && !paused && FlxG.autoPause)
{ {
if (Conductor.songPosition > 0.0) if (Conductor.songPosition > 0.0)
DiscordClient.changePresence(detailsText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC, true, DiscordClient.changePresence(detailsText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC, true, songLength - Conductor.songPosition);
songLength - Conductor.songPosition);
else else
DiscordClient.changePresence(detailsText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC); DiscordClient.changePresence(detailsText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC);
} }
@ -1666,8 +1665,7 @@ class PlayState extends MusicBeatState
var strumLineMid = playerStrumline.y + Note.swagWidth / 2; var strumLineMid = playerStrumline.y + Note.swagWidth / 2;
if (daNote.followsTime) if (daNote.followsTime)
daNote.y = (Conductor.songPosition - daNote.data.strumTime) * (0.45 * FlxMath.roundDecimal(SongLoad.getSpeed(), daNote.y = (Conductor.songPosition - daNote.data.strumTime) * (0.45 * FlxMath.roundDecimal(SongLoad.getSpeed(), 2) * daNote.noteSpeedMulti);
2) * daNote.noteSpeedMulti);
if (PreferencesMenu.getPref('downscroll')) if (PreferencesMenu.getPref('downscroll'))
{ {
@ -1763,12 +1761,16 @@ class PlayState extends MusicBeatState
if (songEvents != null && songEvents.length > 0) if (songEvents != null && songEvents.length > 0)
{ {
var songEventsToActivate:Array<SongEvent> = SongEventHandler.queryEvents(songEvents, Conductor.songPosition); var songEventsToActivate:Array<SongEventData> = SongEventParser.queryEvents(songEvents, Conductor.songPosition);
if (songEventsToActivate.length > 0) if (songEventsToActivate.length > 0)
trace('[EVENTS] Found ${songEventsToActivate.length} event(s) to activate.'); {
trace('Found ${songEventsToActivate.length} event(s) to activate.');
SongEventHandler.activateEvents(songEventsToActivate); for (event in songEventsToActivate)
{
SongEventParser.handleEvent(event);
}
}
} }
if (!isInCutscene) if (!isInCutscene)
@ -1833,8 +1835,6 @@ class PlayState extends MusicBeatState
} }
Conductor.songPosition = FlxG.sound.music.time = daPos; Conductor.songPosition = FlxG.sound.music.time = daPos;
Conductor.songPosition += Conductor.offset; Conductor.songPosition += Conductor.offset;
// updateCurStep();
Conductor.update(Conductor.songPosition);
resyncVocals(); resyncVocals();
} }
#end #end
@ -2127,8 +2127,7 @@ class PlayState extends MusicBeatState
{ {
for (coolNote in possibleNotes) for (coolNote in possibleNotes)
{ {
if (coolNote.data.noteData == daNote.data.noteData if (coolNote.data.noteData == daNote.data.noteData && Math.abs(daNote.data.strumTime - coolNote.data.strumTime) < 10)
&& Math.abs(daNote.data.strumTime - coolNote.data.strumTime) < 10)
{ // if it's the same note twice at < 10ms distance, just delete it { // if it's the same note twice at < 10ms distance, just delete it
// EXCEPT u cant delete it in this loop cuz it fucks with the collection lol // EXCEPT u cant delete it in this loop cuz it fucks with the collection lol
dumbNotes.push(daNote); dumbNotes.push(daNote);
@ -2540,8 +2539,7 @@ class PlayState extends MusicBeatState
#if discord_rpc #if discord_rpc
if (startTimer.finished) if (startTimer.finished)
DiscordClient.changePresence(detailsText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC, true, DiscordClient.changePresence(detailsText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC, true, songLength - Conductor.songPosition);
songLength - Conductor.songPosition);
else else
DiscordClient.changePresence(detailsText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC); DiscordClient.changePresence(detailsText, currentSong.song + " (" + storyDifficultyText + ")", iconRPC);
#end #end
@ -2619,9 +2617,7 @@ class PlayState extends MusicBeatState
function performCleanup() function performCleanup()
{ {
// Uncache the song. // Uncache the song.
if (currentChart != null) if (currentChart != null) {}
{
}
else if (currentSong != null) else if (currentSong != null)
{ {
openfl.utils.Assets.cache.clear(Paths.inst(currentSong.song)); openfl.utils.Assets.cache.clear(Paths.inst(currentSong.song));

View file

@ -44,7 +44,7 @@ class CharacterDataParser
{ {
// Clear any stages that are cached if there were any. // Clear any stages that are cached if there were any.
clearCharacterCache(); clearCharacterCache();
trace("[CHARDATA] Loading character cache..."); trace("Loading character cache...");
// //
// UNSCRIPTED CHARACTERS // UNSCRIPTED CHARACTERS
@ -195,7 +195,7 @@ class CharacterDataParser
} }
} }
trace('[CHARDATA] Successfully instantiated character: ${charId}'); trace('Successfully instantiated character: ${charId}');
// Call onCreate only in the fetchCharacter() function, not at application initialization. // Call onCreate only in the fetchCharacter() function, not at application initialization.
ScriptEventDispatcher.callEvent(char, new ScriptEvent(ScriptEvent.CREATE)); ScriptEventDispatcher.callEvent(char, new ScriptEvent(ScriptEvent.CREATE));
@ -204,7 +204,7 @@ class CharacterDataParser
} }
else else
{ {
trace('[CHARDATA] Failed to build character, not found in cache: ${charId}'); trace('Failed to build character, not found in cache: ${charId}');
return null; return null;
} }
} }
@ -317,25 +317,25 @@ class CharacterDataParser
{ {
if (input == null) if (input == null)
{ {
// trace('[CHARDATA] ERROR: Could not parse character data for "${id}".'); // trace('ERROR: Could not parse character data for "${id}".');
return null; return null;
} }
if (input.version == null) if (input.version == null)
{ {
trace('[CHARDATA] WARN: No semantic version specified for character data file "$id", assuming ${CHARACTER_DATA_VERSION}'); trace('WARN: No semantic version specified for character data file "$id", assuming ${CHARACTER_DATA_VERSION}');
input.version = CHARACTER_DATA_VERSION; input.version = CHARACTER_DATA_VERSION;
} }
if (!VersionUtil.validateVersion(input.version, CHARACTER_DATA_VERSION_RULE)) if (!VersionUtil.validateVersion(input.version, CHARACTER_DATA_VERSION_RULE))
{ {
trace('[CHARDATA] ERROR: Could not load character data for "$id": bad version (got ${input.version}, expected ${CHARACTER_DATA_VERSION_RULE})'); trace('ERROR: Could not load character data for "$id": bad version (got ${input.version}, expected ${CHARACTER_DATA_VERSION_RULE})');
return null; return null;
} }
if (input.name == null) if (input.name == null)
{ {
trace('[CHARDATA] WARN: Character data for "$id" missing name'); trace('WARN: Character data for "$id" missing name');
input.name = DEFAULT_NAME; input.name = DEFAULT_NAME;
} }
@ -346,7 +346,7 @@ class CharacterDataParser
if (input.assetPath == null) if (input.assetPath == null)
{ {
trace('[CHARDATA] ERROR: Could not load character data for "$id": missing assetPath'); trace('ERROR: Could not load character data for "$id": missing assetPath');
return null; return null;
} }
@ -417,7 +417,7 @@ class CharacterDataParser
if (input.animations == null || input.animations.length == 0) if (input.animations == null || input.animations.length == 0)
{ {
trace('[CHARDATA] ERROR: Could not load character data for "$id": missing animations'); trace('ERROR: Could not load character data for "$id": missing animations');
input.animations = []; input.animations = [];
} }
@ -435,7 +435,7 @@ class CharacterDataParser
{ {
if (inputAnimation.name == null) if (inputAnimation.name == null)
{ {
trace('[CHARDATA] ERROR: Could not load character data for "$id": missing animation name for prop "${input.name}"'); trace('ERROR: Could not load character data for "$id": missing animation name for prop "${input.name}"');
return null; return null;
} }

View file

@ -42,7 +42,7 @@ class MultiSparrowCharacter extends BaseCharacter
override function onCreate(event:ScriptEvent):Void override function onCreate(event:ScriptEvent):Void
{ {
trace('Creating MULTI SPARROW CHARACTER: ' + this.characterId); trace('Creating Multi-Sparrow character: ' + this.characterId);
buildSprites(); buildSprites();
super.onCreate(event); super.onCreate(event);

View file

@ -18,7 +18,7 @@ class PackerCharacter extends BaseCharacter
override function onCreate(event:ScriptEvent):Void override function onCreate(event:ScriptEvent):Void
{ {
trace('Creating PACKER CHARACTER: ' + this.characterId); trace('Creating Packer character: ' + this.characterId);
loadSpritesheet(); loadSpritesheet();
loadAnimations(); loadAnimations();

View file

@ -20,7 +20,7 @@ class SparrowCharacter extends BaseCharacter
override function onCreate(event:ScriptEvent):Void override function onCreate(event:ScriptEvent):Void
{ {
trace('Creating SPARROW CHARACTER: ' + this.characterId); trace('Creating Sparrow character: ' + this.characterId);
loadSpritesheet(); loadSpritesheet();
loadAnimations(); loadAnimations();

View file

@ -0,0 +1,142 @@
package funkin.play.event;
import funkin.play.event.SongEvent;
import funkin.play.song.SongData;
/**
* This class represents a handler for a type of song event.
* It is used by the ScriptedSongEvent class to handle user-defined events.
*
* Example: Focus on Boyfriend:
* ```
* {
* "e": "FocusCamera",
* "v": {
* "char": 0,
* }
* }
* ```
*
* Example: Focus on 10px above Girlfriend:
* ```
* {
* "e": "FocusCamera",
* "v": {
* "char": 2,
* "y": -10,
* }
* }
* ```
*
* Example: Focus on (100, 100):
* ```
* {
* "e": "FocusCamera",
* "v": {
* "char": -1,
* "x": 100,
* "y": 100,
* }
* }
* ```
*/
class FocusCameraSongEvent extends SongEvent
{
public function new()
{
super('FocusCamera');
}
public override function handleEvent(data:SongEventData)
{
// Does nothing if there is no PlayState camera or stage.
if (PlayState.instance == null || PlayState.instance.currentStage == null)
return;
var posX = data.getFloat('x');
if (posX == null)
posX = 0.0;
var posY = data.getFloat('y');
if (posY == null)
posY = 0.0;
var char = data.getInt('char');
if (char == null)
char = cast data.value;
switch (char)
{
case -1: // Position
trace('Focusing camera on static position.');
var xTarget = posX;
var yTarget = posY;
PlayState.instance.cameraFollowPoint.setPosition(xTarget, yTarget);
case 0: // Boyfriend
// Focus the camera on the player.
trace('Focusing camera on player.');
var xTarget = PlayState.instance.currentStage.getBoyfriend().cameraFocusPoint.x + posX;
var yTarget = PlayState.instance.currentStage.getBoyfriend().cameraFocusPoint.y + posY;
PlayState.instance.cameraFollowPoint.setPosition(xTarget, yTarget);
case 1: // Dad
// Focus the camera on the dad.
trace('Focusing camera on dad.');
var xTarget = PlayState.instance.currentStage.getDad().cameraFocusPoint.x + posX;
var yTarget = PlayState.instance.currentStage.getDad().cameraFocusPoint.y + posY;
PlayState.instance.cameraFollowPoint.setPosition(xTarget, yTarget);
case 2: // Girlfriend
// Focus the camera on the girlfriend.
trace('Focusing camera on girlfriend.');
var xTarget = PlayState.instance.currentStage.getGirlfriend().cameraFocusPoint.x + posX;
var yTarget = PlayState.instance.currentStage.getGirlfriend().cameraFocusPoint.y + posY;
PlayState.instance.cameraFollowPoint.setPosition(xTarget, yTarget);
default:
trace('Unknown camera focus: ' + data);
}
}
public override function getTitle():String
{
return "Focus Camera";
}
/**
* ```
* {
* "char": ENUM, // Which character to point to
* "x": FLOAT, // Optional x offset
* "y": FLOAT, // Optional y offset
* }
* @return SongEventSchema
*/
public override function getEventSchema():SongEventSchema
{
return [
{
name: "char",
title: "Character",
defaultValue: 0,
type: SongEventFieldType.ENUM,
keys: ["Position" => -1, "Boyfriend" => 0, "Dad" => 1, "Girlfriend" => 2]
},
{
name: "x",
title: "X Position",
defaultValue: 0,
step: 10.0,
type: SongEventFieldType.FLOAT,
},
{
name: "y",
title: "Y Position",
defaultValue: 0,
step: 10.0,
type: SongEventFieldType.FLOAT,
}
];
}
}

View file

@ -0,0 +1,111 @@
package funkin.play.event;
import flixel.FlxSprite;
import funkin.play.character.BaseCharacter;
import funkin.play.event.SongEvent;
import funkin.play.song.SongData;
class PlayAnimationSongEvent extends SongEvent
{
public function new()
{
super('PlayAnimation');
}
public override function handleEvent(data:SongEventData)
{
// Does nothing if there is no PlayState camera or stage.
if (PlayState.instance == null || PlayState.instance.currentStage == null)
return;
var targetName = data.getString('target');
var anim = data.getString('anim');
var force = data.getBool('force');
if (force == null)
force = false;
var target:FlxSprite = null;
switch (targetName)
{
case 'boyfriend':
trace('Playing animation $anim on boyfriend.');
target = PlayState.instance.currentStage.getBoyfriend();
case 'bf':
trace('Playing animation $anim on boyfriend.');
target = PlayState.instance.currentStage.getBoyfriend();
case 'player':
trace('Playing animation $anim on boyfriend.');
target = PlayState.instance.currentStage.getBoyfriend();
case 'dad':
trace('Playing animation $anim on dad.');
target = PlayState.instance.currentStage.getDad();
case 'opponent':
trace('Playing animation $anim on dad.');
target = PlayState.instance.currentStage.getDad();
case 'girlfriend':
trace('Playing animation $anim on girlfriend.');
target = PlayState.instance.currentStage.getGirlfriend();
case 'gf':
trace('Playing animation $anim on girlfriend.');
target = PlayState.instance.currentStage.getGirlfriend();
default:
target = PlayState.instance.currentStage.getNamedProp(targetName);
if (target == null)
trace('Unknown animation target: $targetName');
else
trace('Fetched animation target $targetName from stage.');
}
if (target != null)
{
if (Std.isOfType(target, BaseCharacter))
{
var targetChar:BaseCharacter = cast target;
targetChar.playAnimation(anim, force, force);
}
else
{
target.animation.play(anim, force);
}
}
}
public override function getTitle():String
{
return "Play Animation";
}
/**
* ```
* {
* "target": STRING, // Name of character or prop to point to.
* "anim": STRING, // Name of animation to play.
* "force": BOOL, // Whether to force the animation to play.
* }
* @return SongEventSchema
*/
public override function getEventSchema():SongEventSchema
{
return [
{
name: 'target',
title: 'Target',
type: SongEventFieldType.STRING,
defaultValue: 'boyfriend',
},
{
name: 'anim',
title: 'Animation',
type: SongEventFieldType.STRING,
defaultValue: 'idle',
},
{
name: 'force',
title: 'Force',
type: SongEventFieldType.BOOL,
defaultValue: false
}
];
}
}

View file

@ -0,0 +1,9 @@
package funkin.play.event;
import funkin.play.song.Song;
import polymod.hscript.HScriptedClass;
@:hscriptClass
class ScriptedSongEvent extends SongEvent implements HScriptedClass
{
}

View file

@ -1,148 +1,177 @@
package funkin.play.event; package funkin.play.event;
import flixel.FlxSprite; import funkin.util.macro.ClassMacro;
import funkin.play.PlayState; import funkin.play.song.SongData.SongEventData;
import funkin.play.character.BaseCharacter;
import funkin.play.song.SongData.RawSongEventData;
import haxe.DynamicAccess;
typedef RawSongEvent =
{
> RawSongEventData,
/** /**
* Whether the event has been activated or not. * This class represents a handler for a type of song event.
* It is used by the ScriptedSongEvent class to handle user-defined events.
*/ */
var a:Bool; class SongEvent
{
public var id:String;
public function new(id:String)
{
this.id = id;
} }
@:forward public function handleEvent(data:SongEventData)
abstract SongEvent(RawSongEvent)
{ {
public function new(time:Float, event:String, value:Dynamic = null) throw 'SongEvent.handleEvent() must be overridden!';
{
this = {
t: time,
e: event,
v: value,
a: false
};
} }
public var time(get, set):Float; public function getEventSchema():SongEventSchema
public function get_time():Float
{ {
return this.t; return null;
} }
public function set_time(value:Float):Float public function getTitle():String
{ {
return this.t = value; return this.id.toTitleCase();
} }
public var event(get, set):String; public function toString():String
public function get_event():String
{ {
return this.e; return 'SongEvent(${this.id})';
}
public function set_event(value:String):String
{
return this.e = value;
}
public var value(get, set):Dynamic;
public function get_value():Dynamic
{
return this.v;
}
public function set_value(value:Dynamic):Dynamic
{
return this.v = value;
}
public inline function getBool():Bool
{
return cast this.v;
}
public inline function getInt():Int
{
return cast this.v;
}
public inline function getFloat():Float
{
return cast this.v;
}
public inline function getString():String
{
return cast this.v;
}
public inline function getArray():Array<Dynamic>
{
return cast this.v;
}
public inline function getMap():DynamicAccess<Dynamic>
{
return cast this.v;
}
public inline function getBoolArray():Array<Bool>
{
return cast this.v;
} }
} }
typedef SongEventCallback = SongEvent->Void; class SongEventParser
class SongEventHandler
{ {
private static final eventCallbacks:Map<String, SongEventCallback> = new Map<String, SongEventCallback>(); /**
* Every built-in event class must be added to this list.
public static function registerCallback(event:String, callback:SongEventCallback):Void * Thankfully, with the power of `SongEventMacro`, this is done automatically.
{ */
eventCallbacks.set(event, callback); private static final BUILTIN_EVENTS:List<Class<SongEvent>> = ClassMacro.listSubclassesOf(SongEvent);
}
public static function unregisterCallback(event:String):Void
{
eventCallbacks.remove(event);
}
public static function clearCallbacks():Void
{
eventCallbacks.clear();
}
/** /**
* Register each of the event callbacks provided by the base game. * Map of internal handlers for song events.
* These may be either `ScriptedSongEvents` or built-in classes extending `SongEvent`.
*/ */
public static function registerBaseEventCallbacks():Void static final eventCache:Map<String, SongEvent> = new Map<String, SongEvent>();
public static function loadEventCache():Void
{ {
// TODO: Add a system for mods to easily add their own event callbacks. clearEventCache();
// Should be easy as creating character or stage scripts.
registerCallback('FocusCamera', VanillaEventCallbacks.focusCamera); //
registerCallback('PlayAnimation', VanillaEventCallbacks.playAnimation); // BASE GAME EVENTS
//
registerBaseEvents();
registerScriptedEvents();
}
static function registerBaseEvents()
{
trace('Instantiating ${BUILTIN_EVENTS.length} built-in song events...');
for (eventCls in BUILTIN_EVENTS)
{
var eventClsName:String = Type.getClassName(eventCls);
if (eventClsName == 'funkin.play.event.SongEvent' || eventClsName == 'funkin.play.event.ScriptedSongEvent')
continue;
var event:SongEvent = Type.createInstance(eventCls, ["UNKNOWN"]);
if (event != null)
{
trace(' Loaded built-in song event: (${event.id})');
eventCache.set(event.id, event);
}
else
{
trace(' Failed to load built-in song event: ${Type.getClassName(eventCls)}');
}
}
}
static function registerScriptedEvents()
{
var scriptedEventClassNames:Array<String> = ScriptedSongEvent.listScriptClasses();
if (scriptedEventClassNames == null || scriptedEventClassNames.length == 0)
return;
trace('Instantiating ${scriptedEventClassNames.length} scripted song events...');
for (eventCls in scriptedEventClassNames)
{
var event:SongEvent = ScriptedSongEvent.init(eventCls, "UKNOWN");
if (event != null)
{
trace(' Loaded scripted song event: ${event.id}');
eventCache.set(event.id, event);
}
else
{
trace(' Failed to instantiate scripted song event class: ${eventCls}');
}
}
}
public static function listEventIds():Array<String>
{
return eventCache.keys().array();
}
public static function listEvents():Array<SongEvent>
{
return eventCache.values();
}
public static function getEvent(id:String):SongEvent
{
return eventCache.get(id);
}
public static function getEventSchema(id:String):SongEventSchema
{
var event:SongEvent = getEvent(id);
if (event == null)
return null;
return event.getEventSchema();
}
static function clearEventCache()
{
eventCache.clear();
}
public static function handleEvent(data:SongEventData):Void
{
var eventType:String = data.event;
var eventHandler:SongEvent = eventCache.get(eventType);
if (eventHandler != null)
{
eventHandler.handleEvent(data);
}
else
{
trace('WARNING: No event handler for event with id: ${eventType}');
}
data.activated = true;
}
public static inline function handleEvents(events:Array<SongEventData>):Void
{
for (event in events)
{
handleEvent(event);
}
} }
/** /**
* Given a list of song events and the current timestamp, * Given a list of song events and the current timestamp,
* return a list of events that should be activated. * return a list of events that should be handled.
*/ */
public static function queryEvents(events:Array<SongEvent>, currentTime:Float):Array<SongEvent> public static function queryEvents(events:Array<SongEventData>, currentTime:Float):Array<SongEventData>
{ {
return events.filter(function(event:SongEvent):Bool return events.filter(function(event:SongEventData):Bool
{ {
// If the event is already activated, don't activate it again. // If the event is already activated, don't activate it again.
if (event.a) if (event.activated)
return false; return false;
// If the event is in the future, don't activate it. // If the event is in the future, don't activate it.
@ -153,151 +182,89 @@ class SongEventHandler
}); });
} }
public static function activateEvents(events:Array<SongEvent>):Void /**
* Reset activation of all the provided events.
*/
public static function resetEvents(events:Array<SongEventData>):Void
{ {
for (event in events) for (event in events)
{ {
activateEvent(event); event.activated = false;
// TODO: Add an onReset() method to SongEvent?
}
} }
} }
public static function activateEvent(event:SongEvent):Void enum abstract SongEventFieldType(String) from String to String
{
if (event.a)
{
trace('Event already activated: ' + event);
return;
}
// Prevent the event from being activated again.
event.a = true;
// Perform the action.
if (eventCallbacks.exists(event.event))
{
eventCallbacks.get(event.event)(event);
}
}
public static function resetEvents(events:Array<SongEvent>):Void
{
for (event in events)
{
resetEvent(event);
}
}
public static function resetEvent(event:SongEvent):Void
{
// TODO: Add a system for mods to easily add their reset callbacks.
event.a = false;
}
}
class VanillaEventCallbacks
{ {
/** /**
* Event Name: "FocusCamera" * The STRING type will display as a text field.
* Event Value: Int
* 0: Focus on the player.
* 1: Focus on the opponent.
* 2: Focus on the girlfriend.
*/ */
public static function focusCamera(event:SongEvent):Void var STRING = "string";
{
// Does nothing if there is no PlayState camera or stage.
if (PlayState.instance == null || PlayState.instance.currentStage == null)
return;
switch (event.getInt())
{
case 0: // Boyfriend
// Focus the camera on the player.
trace('[EVENT] Focusing camera on player.');
PlayState.instance.cameraFollowPoint.setPosition(PlayState.instance.currentStage.getBoyfriend().cameraFocusPoint.x,
PlayState.instance.currentStage.getBoyfriend().cameraFocusPoint.y);
case 1: // Dad
// Focus the camera on the dad.
trace('[EVENT] Focusing camera on dad.');
PlayState.instance.cameraFollowPoint.setPosition(PlayState.instance.currentStage.getDad().cameraFocusPoint.x,
PlayState.instance.currentStage.getDad().cameraFocusPoint.y);
case 2: // Girlfriend
// Focus the camera on the girlfriend.
trace('[EVENT] Focusing camera on girlfriend.');
PlayState.instance.cameraFollowPoint.setPosition(PlayState.instance.currentStage.getGirlfriend().cameraFocusPoint.x,
PlayState.instance.currentStage.getGirlfriend().cameraFocusPoint.y);
default:
trace('[EVENT] Unknown camera focus: ' + event.value);
}
}
/** /**
* Event Name: "playAnimation" * The INTEGER type will display as a text field that only accepts numbers.
* Event Value: Object
* {
* target: String, // "player", "dad", "girlfriend", or <stage prop id>
* animation: String,
* force: Bool // optional
* }
*/ */
public static function playAnimation(event:SongEvent):Void var INTEGER = "integer";
{
// Does nothing if there is no PlayState camera or stage.
if (PlayState.instance == null || PlayState.instance.currentStage == null)
return;
var data:Dynamic = event.value; /**
* The FLOAT type will display as a text field that only accepts numbers.
*/
var FLOAT = "float";
var targetName:String = Reflect.field(data, 'target'); /**
var anim:String = Reflect.field(data, 'anim'); * The BOOL type will display as a checkbox.
var force:Null<Bool> = Reflect.field(data, 'force'); */
if (force == null) var BOOL = "bool";
force = false;
var target:FlxSprite = null; /**
* The ENUM type will display as a dropdown.
switch (targetName) * Make sure to specify the `keys` field in the schema.
{ */
case 'boyfriend': var ENUM = "enum";
trace('[EVENT] Playing animation $anim on boyfriend.');
target = PlayState.instance.currentStage.getBoyfriend();
case 'bf':
trace('[EVENT] Playing animation $anim on boyfriend.');
target = PlayState.instance.currentStage.getBoyfriend();
case 'player':
trace('[EVENT] Playing animation $anim on boyfriend.');
target = PlayState.instance.currentStage.getBoyfriend();
case 'dad':
trace('[EVENT] Playing animation $anim on dad.');
target = PlayState.instance.currentStage.getDad();
case 'opponent':
trace('[EVENT] Playing animation $anim on dad.');
target = PlayState.instance.currentStage.getDad();
case 'girlfriend':
trace('[EVENT] Playing animation $anim on girlfriend.');
target = PlayState.instance.currentStage.getGirlfriend();
case 'gf':
trace('[EVENT] Playing animation $anim on girlfriend.');
target = PlayState.instance.currentStage.getGirlfriend();
default:
target = PlayState.instance.currentStage.getNamedProp(targetName);
if (target == null)
trace('[EVENT] Unknown animation target: $targetName');
else
trace('[EVENT] Fetched animation target $targetName from stage.');
} }
if (target != null) typedef SongEventSchemaField =
{ {
if (Std.isOfType(target, BaseCharacter)) /**
{ * The name of the property as it should be saved in the event data.
var targetChar:BaseCharacter = cast target; */
targetChar.playAnimation(anim, force, force); name:String,
}
else /**
{ * The title of the field to display in the UI.
target.animation.play(anim, force); */
} title:String,
}
} /**
* The type of the field.
*/
type:SongEventFieldType,
/**
* Used for ENUM values.
* The key is the display name and the value is the actual value.
*/
?keys:Map<String, Dynamic>,
/**
* Used for INTEGER and FLOAT values.
* The minimum value that can be entered.
*/
?min:Float,
/**
* Used for INTEGER and FLOAT values.
* The maximum value that can be entered.
*/
?max:Float,
/**
* Used for INTEGER and FLOAT values.
* The step value that will be used when incrementing/decrementing the value.
*/
?step:Float,
/**
* An optional default value for the field.
*/
?defaultValue:Dynamic,
} }
typedef SongEventSchema = Array<SongEventSchemaField>;

View file

@ -225,7 +225,7 @@ class SongDifficulty
return chars.keys().array(); return chars.keys().array();
} }
public function getEvents():Array<SongEvent> public function getEvents():Array<SongEventData>
{ {
return cast events; return cast events;
} }

View file

@ -30,7 +30,7 @@ class SongDataParser
public static function loadSongCache():Void public static function loadSongCache():Void
{ {
clearSongCache(); clearSongCache();
trace("[SONGDATA] Loading song cache..."); trace("Loading song cache...");
// //
// SCRIPTED SONGS // SCRIPTED SONGS
@ -94,12 +94,12 @@ class SongDataParser
if (songCache.exists(songId)) if (songCache.exists(songId))
{ {
var song:Song = songCache.get(songId); var song:Song = songCache.get(songId);
trace('[SONGDATA] Successfully fetch song: ${songId}'); trace('Successfully fetch song: ${songId}');
return song; return song;
} }
else else
{ {
trace('[SONGDATA] Failed to fetch song, not found in cache: ${songId}'); trace('Failed to fetch song, not found in cache: ${songId}');
return null; return null;
} }
} }
@ -127,9 +127,7 @@ class SongDataParser
{ {
jsonData = Json.parse(rawJson); jsonData = Json.parse(rawJson);
} }
catch (e) catch (e) {}
{
}
var songMetadata:SongMetadata = SongMigrator.migrateSongMetadata(jsonData, songId); var songMetadata:SongMetadata = SongMigrator.migrateSongMetadata(jsonData, songId);
songMetadata = SongValidator.validateSongMetadata(songMetadata, songId); songMetadata = SongValidator.validateSongMetadata(songMetadata, songId);
@ -180,9 +178,7 @@ class SongDataParser
{ {
jsonData = Json.parse(rawJson); jsonData = Json.parse(rawJson);
} }
catch (e) catch (e) {}
{
}
var songChartData:SongChartData = SongMigrator.migrateSongChartData(jsonData, songId); var songChartData:SongChartData = SongMigrator.migrateSongChartData(jsonData, songId);
songChartData = SongValidator.validateSongChartData(songChartData, songId); songChartData = SongValidator.validateSongChartData(songChartData, songId);
@ -510,7 +506,13 @@ typedef RawSongEventData =
* This can allow the event to include information used for custom behavior. * This can allow the event to include information used for custom behavior.
* Data type depends on the event kind. It can be anything that's JSON serializable. * Data type depends on the event kind. It can be anything that's JSON serializable.
*/ */
var v:Dynamic; var v:DynamicAccess<Dynamic>;
/**
* Whether this event has been activated.
* This is only used internally by the game. It should not be serialized.
*/
@:optional var a:Bool;
} }
abstract SongEventData(RawSongEventData) abstract SongEventData(RawSongEventData)
@ -520,7 +522,8 @@ abstract SongEventData(RawSongEventData)
this = { this = {
t: time, t: time,
e: event, e: event,
v: value v: value,
a: false
}; };
} }
@ -536,6 +539,14 @@ abstract SongEventData(RawSongEventData)
return this.t = value; return this.t = value;
} }
public var stepTime(get, never):Float;
public function get_stepTime():Float
{
// TODO: Account for changes in BPM.
return this.t / Conductor.stepCrochet;
}
public var event(get, set):String; public var event(get, set):String;
public function get_event():String public function get_event():String
@ -560,34 +571,51 @@ abstract SongEventData(RawSongEventData)
return this.v = value; return this.v = value;
} }
public inline function getBool():Bool public var activated(get, set):Bool;
public function get_activated():Bool
{ {
return cast this.v; return this.a;
} }
public inline function getInt():Int public function set_activated(value:Bool):Bool
{ {
return cast this.v; return this.a = value;
} }
public inline function getFloat():Float public inline function getDynamic(key:String):Null<Dynamic>
{ {
return cast this.v; return this.v.get(key);
} }
public inline function getString():String public inline function getBool(key:String):Null<Bool>
{ {
return cast this.v; return cast this.v.get(key);
} }
public inline function getArray():Array<Dynamic> public inline function getInt(key:String):Null<Int>
{ {
return cast this.v; return cast this.v.get(key);
} }
public inline function getBoolArray():Array<Bool> public inline function getFloat(key:String):Null<Float>
{ {
return cast this.v; return cast this.v.get(key);
}
public inline function getString(key:String):String
{
return cast this.v.get(key);
}
public inline function getArray(key:String):Array<Dynamic>
{
return cast this.v.get(key);
}
public inline function getBoolArray(key:String):Array<Bool>
{
return cast this.v.get(key);
} }
@:op(A == B) @:op(A == B)

View file

@ -26,6 +26,22 @@ class SongDataUtils
}); });
} }
/**
* Given an array of SongEventData objects, return a new array of SongEventData objects
* whose timestamps are shifted by the given amount.
* Does not mutate the original array.
*
* @param events The events to modify.
* @param offset The time difference to apply in milliseconds.
*/
public static function offsetSongEventData(events:Array<SongEventData>, offset:Int):Array<SongEventData>
{
return events.map(function(event:SongEventData):SongEventData
{
return new SongEventData(event.time + offset, event.event, event.value);
});
}
/** /**
* Return a new array without a certain subset of notes from an array of SongNoteData objects. * Return a new array without a certain subset of notes from an array of SongNoteData objects.
* Does not mutate the original array. * Does not mutate the original array.
@ -94,11 +110,21 @@ class SongDataUtils
* *
* Offset the provided array of notes such that the first note is at 0 milliseconds. * Offset the provided array of notes such that the first note is at 0 milliseconds.
*/ */
public static function buildClipboard(notes:Array<SongNoteData>):Array<SongNoteData> public static function buildNoteClipboard(notes:Array<SongNoteData>):Array<SongNoteData>
{ {
return offsetSongNoteData(sortNotes(notes), -Std.int(notes[0].time)); return offsetSongNoteData(sortNotes(notes), -Std.int(notes[0].time));
} }
/**
* Prepare an array of events to be used as the clipboard data.
*
* Offset the provided array of events such that the first event is at 0 milliseconds.
*/
public static function buildEventClipboard(events:Array<SongEventData>):Array<SongEventData>
{
return offsetSongEventData(sortEvents(events), -Std.int(events[0].time));
}
/** /**
* Sort an array of notes by strum time. * Sort an array of notes by strum time.
*/ */
@ -113,39 +139,55 @@ class SongDataUtils
} }
/** /**
* Serialize an array of note data and write it to the clipboard. * Sort an array of events by strum time.
*/ */
public static function writeNotesToClipboard(notes:Array<SongNoteData>):Void public static function sortEvents(events:Array<SongEventData>, ?desc:Bool = false):Array<SongEventData>
{ {
var notesString = SerializerUtil.toJSON(notes); // TODO: Modifies the array in place. Is this okay?
events.sort(function(a:SongEventData, b:SongEventData):Int
{
return FlxSort.byValues(desc ? FlxSort.DESCENDING : FlxSort.ASCENDING, a.time, b.time);
});
return events;
}
ClipboardUtil.setClipboard(notesString); /**
* Serialize note and event data and write it to the clipboard.
*/
public static function writeItemsToClipboard(data:SongClipboardItems):Void
{
var dataString = SerializerUtil.toJSON(data);
trace('Wrote ' + notes.length + ' notes to clipboard.'); ClipboardUtil.setClipboard(dataString);
trace(notesString); trace('Wrote ' + data.notes.length + ' notes and ' + data.events.length + ' events to clipboard.');
trace(dataString);
} }
/** /**
* Read an array of note data from the clipboard and deserialize it. * Read an array of note data from the clipboard and deserialize it.
*/ */
public static function readNotesFromClipboard():Array<SongNoteData> public static function readItemsFromClipboard():SongClipboardItems
{ {
var notesString = ClipboardUtil.getClipboard(); var notesString = ClipboardUtil.getClipboard();
trace('Read ' + notesString.length + ' characters from clipboard.'); trace('Read ${notesString.length} characters from clipboard.');
var notes:Array<SongNoteData> = notesString.parseJSON(); var data:SongClipboardItems = notesString.parseJSON();
if (notes == null) if (data == null)
{ {
trace('Failed to parse notes from clipboard.'); trace('Failed to parse notes from clipboard.');
return []; return {
notes: [],
events: []
};
} }
else else
{ {
trace('Parsed ' + notes.length + ' notes from clipboard.'); trace('Parsed ' + data.notes.length + ' notes and ' + data.events.length + ' from clipboard.');
return notes; return data;
} }
} }
@ -160,6 +202,17 @@ class SongDataUtils
}); });
} }
/**
* Filter a list of events to only include events that are within the given time range.
*/
public static function getEventsInTimeRange(events:Array<SongEventData>, start:Float, end:Float):Array<SongEventData>
{
return events.filter(function(event:SongEventData):Bool
{
return event.time >= start && event.time <= end;
});
}
/** /**
* Filter a list of notes to only include notes whose data is within the given range. * Filter a list of notes to only include notes whose data is within the given range.
*/ */
@ -182,3 +235,9 @@ class SongDataUtils
}); });
} }
} }
typedef SongClipboardItems =
{
notes:Array<SongNoteData>,
events:Array<SongEventData>
}

View file

@ -21,7 +21,7 @@ class SongMigrator
{ {
if (VersionUtil.validateVersion(jsonData.version, CHART_VERSION_RULE)) if (VersionUtil.validateVersion(jsonData.version, CHART_VERSION_RULE))
{ {
trace('[SONGDATA] Song (${songId}) metadata version (${jsonData.version}) is valid and up-to-date.'); trace('Song (${songId}) metadata version (${jsonData.version}) is valid and up-to-date.');
var songMetadata:SongMetadata = cast jsonData; var songMetadata:SongMetadata = cast jsonData;
@ -29,19 +29,19 @@ class SongMigrator
} }
else else
{ {
trace('[SONGDATA] Song (${songId}) metadata version (${jsonData.version}) is outdated.'); trace('Song (${songId}) metadata version (${jsonData.version}) is outdated.');
switch (jsonData.version) switch (jsonData.version)
{ {
// TODO: Add migration functions as cases here. // TODO: Add migration functions as cases here.
default: default:
// Unknown version. // Unknown version.
trace('[SONGDATA] Song (${songId}) unknown metadata version: ${jsonData.version}'); trace('Song (${songId}) unknown metadata version: ${jsonData.version}');
} }
} }
} }
else else
{ {
trace('[SONGDATA] Song metadata version is missing.'); trace('Song metadata version is missing.');
} }
return null; return null;
} }
@ -52,7 +52,7 @@ class SongMigrator
{ {
if (VersionUtil.validateVersion(jsonData.version, CHART_VERSION_RULE)) if (VersionUtil.validateVersion(jsonData.version, CHART_VERSION_RULE))
{ {
trace('[SONGDATA] Song (${songId}) chart version (${jsonData.version}) is valid and up-to-date.'); trace('Song (${songId}) chart version (${jsonData.version}) is valid and up-to-date.');
var songChartData:SongChartData = cast jsonData; var songChartData:SongChartData = cast jsonData;
@ -60,19 +60,19 @@ class SongMigrator
} }
else else
{ {
trace('[SONGDATA] Song (${songId}) chart version (${jsonData.version}) is outdated.'); trace('Song (${songId}) chart version (${jsonData.version}) is outdated.');
switch (jsonData.version) switch (jsonData.version)
{ {
// TODO: Add migration functions as cases here. // TODO: Add migration functions as cases here.
default: default:
// Unknown version. // Unknown version.
trace('[SONGDATA] Song (${songId}) unknown chart version: ${jsonData.version}'); trace('Song (${songId}) unknown chart version: ${jsonData.version}');
} }
} }
} }
else else
{ {
trace('[SONGDATA] Song chart version is missing.'); trace('Song chart version is missing.');
} }
return null; return null;
} }

View file

@ -72,7 +72,6 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass
this.x += xDiff; this.x += xDiff;
this.y += yDiff; this.y += yDiff;
return animOffsets = value; return animOffsets = value;
} }
private var animOffsets(default, set):Array<Float> = [0, 0]; private var animOffsets(default, set):Array<Float> = [0, 0];
@ -333,75 +332,41 @@ class Bopper extends FlxSprite implements IPlayStateScriptedClass
return this.animation.curAnim.name; return this.animation.curAnim.name;
} }
public function onScriptEvent(event:ScriptEvent) public function onScriptEvent(event:ScriptEvent) {}
{
}
public function onCreate(event:ScriptEvent) public function onCreate(event:ScriptEvent) {}
{
}
public function onDestroy(event:ScriptEvent) public function onDestroy(event:ScriptEvent) {}
{
}
public function onUpdate(event:UpdateScriptEvent) public function onUpdate(event:UpdateScriptEvent) {}
{
}
public function onPause(event:PauseScriptEvent) public function onPause(event:PauseScriptEvent) {}
{
}
public function onResume(event:ScriptEvent) public function onResume(event:ScriptEvent) {}
{
}
public function onSongStart(event:ScriptEvent) public function onSongStart(event:ScriptEvent) {}
{
}
public function onSongEnd(event:ScriptEvent) public function onSongEnd(event:ScriptEvent) {}
{
}
public function onGameOver(event:ScriptEvent) public function onGameOver(event:ScriptEvent) {}
{
}
public function onNoteHit(event:NoteScriptEvent) public function onNoteHit(event:NoteScriptEvent) {}
{
}
public function onNoteMiss(event:NoteScriptEvent) public function onNoteMiss(event:NoteScriptEvent) {}
{
}
public function onNoteGhostMiss(event:GhostMissNoteScriptEvent) public function onSongEvent(event:SongEventScriptEvent) {}
{
}
public function onStepHit(event:SongTimeScriptEvent) public function onNoteGhostMiss(event:GhostMissNoteScriptEvent) {}
{
}
public function onCountdownStart(event:CountdownScriptEvent) public function onStepHit(event:SongTimeScriptEvent) {}
{
}
public function onCountdownStep(event:CountdownScriptEvent) public function onCountdownStart(event:CountdownScriptEvent) {}
{
}
public function onCountdownEnd(event:CountdownScriptEvent) public function onCountdownStep(event:CountdownScriptEvent) {}
{
}
public function onSongLoaded(event:SongLoadScriptEvent) public function onCountdownEnd(event:CountdownScriptEvent) {}
{
}
public function onSongRetry(event:ScriptEvent) public function onSongLoaded(event:SongLoadScriptEvent) {}
{
} public function onSongRetry(event:ScriptEvent) {}
} }

View file

@ -230,7 +230,6 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
{ {
addProp(propSprite, dataProp.name); addProp(propSprite, dataProp.name);
} }
trace(' Prop placed.');
} }
} }
@ -556,9 +555,7 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
* A function that gets called once per step in the song. * A function that gets called once per step in the song.
* @param curStep The current step number. * @param curStep The current step number.
*/ */
public function onStepHit(event:SongTimeScriptEvent):Void public function onStepHit(event:SongTimeScriptEvent):Void {}
{
}
/** /**
* A function that gets called once per beat in the song (once every four steps). * A function that gets called once per beat in the song (once every four steps).
@ -583,59 +580,33 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
} }
} }
public function onScriptEvent(event:ScriptEvent) public function onScriptEvent(event:ScriptEvent) {}
{
}
public function onPause(event:PauseScriptEvent) public function onPause(event:PauseScriptEvent) {}
{
}
public function onResume(event:ScriptEvent) public function onResume(event:ScriptEvent) {}
{
}
public function onSongStart(event:ScriptEvent) public function onSongStart(event:ScriptEvent) {}
{
}
public function onSongEnd(event:ScriptEvent) public function onSongEnd(event:ScriptEvent) {}
{
}
public function onGameOver(event:ScriptEvent) public function onGameOver(event:ScriptEvent) {}
{
}
public function onCountdownStart(event:CountdownScriptEvent) public function onCountdownStart(event:CountdownScriptEvent) {}
{
}
public function onCountdownStep(event:CountdownScriptEvent) public function onCountdownStep(event:CountdownScriptEvent) {}
{
}
public function onCountdownEnd(event:CountdownScriptEvent) public function onCountdownEnd(event:CountdownScriptEvent) {}
{
}
public function onNoteHit(event:NoteScriptEvent) public function onNoteHit(event:NoteScriptEvent) {}
{
}
public function onNoteMiss(event:NoteScriptEvent) public function onNoteMiss(event:NoteScriptEvent) {}
{
}
public function onNoteGhostMiss(event:GhostMissNoteScriptEvent) public function onSongEvent(event:SongEventScriptEvent) {}
{
}
public function onSongLoaded(event:SongLoadScriptEvent) public function onNoteGhostMiss(event:GhostMissNoteScriptEvent) {}
{
}
public function onSongRetry(event:ScriptEvent) public function onSongLoaded(event:SongLoadScriptEvent) {}
{
} public function onSongRetry(event:ScriptEvent) {}
} }

View file

@ -38,7 +38,7 @@ class StageDataParser
{ {
// Clear any stages that are cached if there were any. // Clear any stages that are cached if there were any.
clearStageCache(); clearStageCache();
trace("[STAGEDATA] Loading stage cache..."); trace("Loading stage cache...");
// //
// SCRIPTED STAGES // SCRIPTED STAGES
@ -100,14 +100,14 @@ class StageDataParser
{ {
if (stageCache.exists(stageId)) if (stageCache.exists(stageId))
{ {
trace('[STAGEDATA] Successfully fetch stage: ${stageId}'); trace('Successfully fetch stage: ${stageId}');
var stage:Stage = stageCache.get(stageId); var stage:Stage = stageCache.get(stageId);
stage.revive(); stage.revive();
return stage; return stage;
} }
else else
{ {
trace('[STAGEDATA] Failed to fetch stage, not found in cache: ${stageId}'); trace('Failed to fetch stage, not found in cache: ${stageId}');
return null; return null;
} }
} }
@ -206,25 +206,25 @@ class StageDataParser
{ {
if (input == null) if (input == null)
{ {
trace('[STAGEDATA] ERROR: Could not parse stage data for "${id}".'); trace('ERROR: Could not parse stage data for "${id}".');
return null; return null;
} }
if (input.version == null) if (input.version == null)
{ {
trace('[STAGEDATA] ERROR: Could not load stage data for "$id": missing version'); trace('ERROR: Could not load stage data for "$id": missing version');
return null; return null;
} }
if (!VersionUtil.validateVersion(input.version, STAGE_DATA_VERSION_RULE)) if (!VersionUtil.validateVersion(input.version, STAGE_DATA_VERSION_RULE))
{ {
trace('[STAGEDATA] ERROR: Could not load stage data for "$id": bad version (got ${input.version}, expected ${STAGE_DATA_VERSION_RULE})'); trace('ERROR: Could not load stage data for "$id": bad version (got ${input.version}, expected ${STAGE_DATA_VERSION_RULE})');
return null; return null;
} }
if (input.name == null) if (input.name == null)
{ {
trace('[STAGEDATA] WARN: Stage data for "$id" missing name'); trace('WARN: Stage data for "$id" missing name');
input.name = DEFAULT_NAME; input.name = DEFAULT_NAME;
} }
@ -244,7 +244,7 @@ class StageDataParser
if (inputProp.assetPath == null) if (inputProp.assetPath == null)
{ {
trace('[STAGEDATA] ERROR: Could not load stage data for "$id": missing assetPath for prop "${inputProp.name}"'); trace('ERROR: Could not load stage data for "$id": missing assetPath for prop "${inputProp.name}"');
return null; return null;
} }
@ -305,7 +305,7 @@ class StageDataParser
if (inputProp.animations.length == 0 && inputProp.startingAnimation != null) if (inputProp.animations.length == 0 && inputProp.startingAnimation != null)
{ {
trace('[STAGEDATA] ERROR: Could not load stage data for "$id": missing animations for prop "${inputProp.name}"'); trace('ERROR: Could not load stage data for "$id": missing animations for prop "${inputProp.name}"');
return null; return null;
} }
@ -313,7 +313,7 @@ class StageDataParser
{ {
if (inputAnimation.name == null) if (inputAnimation.name == null)
{ {
trace('[STAGEDATA] ERROR: Could not load stage data for "$id": missing animation name for prop "${inputProp.name}"'); trace('ERROR: Could not load stage data for "$id": missing animation name for prop "${inputProp.name}"');
return null; return null;
} }
@ -346,7 +346,7 @@ class StageDataParser
if (input.characters == null) if (input.characters == null)
{ {
trace('[STAGEDATA] ERROR: Could not load stage data for "$id": missing characters'); trace('ERROR: Could not load stage data for "$id": missing characters');
return null; return null;
} }

View file

@ -55,11 +55,12 @@ class AddNotesCommand implements ChartEditorCommand
if (appendToSelection) if (appendToSelection)
{ {
state.currentSelection = state.currentSelection.concat(notes); state.currentNoteSelection = state.currentNoteSelection.concat(notes);
} }
else else
{ {
state.currentSelection = notes; state.currentNoteSelection = notes;
state.currentEventSelection = [];
} }
state.playSound(Paths.sound('funnyNoise/funnyNoise-08')); state.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
@ -74,7 +75,8 @@ class AddNotesCommand implements ChartEditorCommand
public function undo(state:ChartEditorState):Void public function undo(state:ChartEditorState):Void
{ {
state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes); state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes);
state.currentSelection = []; state.currentNoteSelection = [];
state.currentEventSelection = [];
state.playSound(Paths.sound('funnyNoise/funnyNoise-01')); state.playSound(Paths.sound('funnyNoise/funnyNoise-01'));
state.saveDataDirty = true; state.saveDataDirty = true;
@ -108,7 +110,8 @@ class RemoveNotesCommand implements ChartEditorCommand
public function execute(state:ChartEditorState):Void public function execute(state:ChartEditorState):Void
{ {
state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes); state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes);
state.currentSelection = []; state.currentNoteSelection = [];
state.currentEventSelection = [];
state.playSound(Paths.sound('funnyNoise/funnyNoise-01')); state.playSound(Paths.sound('funnyNoise/funnyNoise-01'));
state.saveDataDirty = true; state.saveDataDirty = true;
@ -124,7 +127,8 @@ class RemoveNotesCommand implements ChartEditorCommand
{ {
state.currentSongChartNoteData.push(note); state.currentSongChartNoteData.push(note);
} }
state.currentSelection = notes; state.currentNoteSelection = notes;
state.currentEventSelection = [];
state.playSound(Paths.sound('funnyNoise/funnyNoise-08')); state.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
state.saveDataDirty = true; state.saveDataDirty = true;
@ -146,6 +150,241 @@ class RemoveNotesCommand implements ChartEditorCommand
} }
} }
/**
* Appends one or more items to the selection.
*/
class SelectItemsCommand implements ChartEditorCommand
{
private var notes:Array<SongNoteData>;
private var events:Array<SongEventData>;
public function new(notes:Array<SongNoteData>, events:Array<SongEventData>)
{
this.notes = notes;
this.events = events;
}
public function execute(state:ChartEditorState):Void
{
for (note in this.notes)
{
state.currentNoteSelection.push(note);
}
for (event in this.events)
{
state.currentEventSelection.push(event);
}
state.noteDisplayDirty = true;
state.notePreviewDirty = true;
}
public function undo(state:ChartEditorState):Void
{
state.currentNoteSelection = SongDataUtils.subtractNotes(state.currentNoteSelection, this.notes);
state.currentEventSelection = SongDataUtils.subtractEvents(state.currentEventSelection, this.events);
state.noteDisplayDirty = true;
state.notePreviewDirty = true;
}
public function toString():String
{
var len:Int = notes.length + events.length;
if (notes.length == 0)
{
if (events.length == 1)
{
return 'Select Event';
}
else
{
return 'Select ${events.length} Events';
}
}
else if (events.length == 0)
{
if (notes.length == 1)
{
return 'Select Note';
}
else
{
return 'Select ${notes.length} Notes';
}
}
return 'Select ${len} Items';
}
}
class AddEventsCommand implements ChartEditorCommand
{
private var events:Array<SongEventData>;
private var appendToSelection:Bool;
public function new(events:Array<SongEventData>, ?appendToSelection:Bool = false)
{
this.events = events;
this.appendToSelection = appendToSelection;
}
public function execute(state:ChartEditorState):Void
{
for (event in events)
{
state.currentSongChartEventData.push(event);
}
if (appendToSelection)
{
state.currentEventSelection = state.currentEventSelection.concat(events);
}
else
{
state.currentNoteSelection = [];
state.currentEventSelection = events;
}
state.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
state.saveDataDirty = true;
state.noteDisplayDirty = true;
state.notePreviewDirty = true;
state.sortChartData();
}
public function undo(state:ChartEditorState):Void
{
state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events);
state.currentNoteSelection = [];
state.currentEventSelection = [];
state.saveDataDirty = true;
state.noteDisplayDirty = true;
state.notePreviewDirty = true;
state.sortChartData();
}
public function toString():String
{
var len:Int = events.length;
return 'Add $len Events';
}
}
class RemoveEventsCommand implements ChartEditorCommand
{
private var events:Array<SongEventData>;
public function new(events:Array<SongEventData>)
{
this.events = events;
}
public function execute(state:ChartEditorState):Void
{
state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events);
state.currentEventSelection = [];
state.playSound(Paths.sound('funnyNoise/funnyNoise-01'));
state.saveDataDirty = true;
state.noteDisplayDirty = true;
state.notePreviewDirty = true;
state.sortChartData();
}
public function undo(state:ChartEditorState):Void
{
for (event in events)
{
state.currentSongChartEventData.push(event);
}
state.currentEventSelection = events;
state.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
state.saveDataDirty = true;
state.noteDisplayDirty = true;
state.notePreviewDirty = true;
state.sortChartData();
}
public function toString():String
{
if (events.length == 1 && events[0] != null)
{
return 'Remove Event';
}
return 'Remove ${events.length} Events';
}
}
class RemoveItemsCommand implements ChartEditorCommand
{
private var notes:Array<SongNoteData>;
private var events:Array<SongEventData>;
public function new(notes:Array<SongNoteData>, events:Array<SongEventData>)
{
this.notes = notes;
this.events = events;
}
public function execute(state:ChartEditorState):Void
{
state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes);
state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events);
state.currentNoteSelection = [];
state.currentEventSelection = [];
state.playSound(Paths.sound('funnyNoise/funnyNoise-01'));
state.saveDataDirty = true;
state.noteDisplayDirty = true;
state.notePreviewDirty = true;
state.sortChartData();
}
public function undo(state:ChartEditorState):Void
{
for (note in notes)
{
state.currentSongChartNoteData.push(note);
}
for (event in events)
{
state.currentSongChartEventData.push(event);
}
state.currentNoteSelection = notes;
state.currentEventSelection = events;
state.playSound(Paths.sound('funnyNoise/funnyNoise-08'));
state.saveDataDirty = true;
state.noteDisplayDirty = true;
state.notePreviewDirty = true;
state.sortChartData();
}
public function toString():String
{
return 'Remove ${notes.length + events.length} Items';
}
}
class SwitchDifficultyCommand implements ChartEditorCommand class SwitchDifficultyCommand implements ChartEditorCommand
{ {
private var prevDifficulty:String; private var prevDifficulty:String;
@ -185,61 +424,21 @@ class SwitchDifficultyCommand implements ChartEditorCommand
} }
} }
/** class DeselectItemsCommand implements ChartEditorCommand
* Adds one or more notes to the selection.
*/
class SelectNotesCommand implements ChartEditorCommand
{ {
private var notes:Array<SongNoteData>; private var notes:Array<SongNoteData>;
private var events:Array<SongEventData>;
public function new(notes:Array<SongNoteData>) public function new(notes:Array<SongNoteData>, events:Array<SongEventData>)
{ {
this.notes = notes; this.notes = notes;
this.events = events;
} }
public function execute(state:ChartEditorState):Void public function execute(state:ChartEditorState):Void
{ {
for (note in this.notes) state.currentNoteSelection = SongDataUtils.subtractNotes(state.currentNoteSelection, this.notes);
{ state.currentEventSelection = SongDataUtils.subtractEvents(state.currentEventSelection, this.events);
state.currentSelection.push(note);
}
state.noteDisplayDirty = true;
state.notePreviewDirty = true;
}
public function undo(state:ChartEditorState):Void
{
state.currentSelection = SongDataUtils.subtractNotes(state.currentSelection, this.notes);
state.noteDisplayDirty = true;
state.notePreviewDirty = true;
}
public function toString():String
{
if (notes.length == 1)
{
var dir:String = notes[0].getDirectionName();
return 'Select $dir Note';
}
return 'Select ${notes.length} Notes';
}
}
class DeselectNotesCommand implements ChartEditorCommand
{
private var notes:Array<SongNoteData>;
public function new(notes:Array<SongNoteData>)
{
this.notes = notes;
}
public function execute(state:ChartEditorState):Void
{
state.currentSelection = SongDataUtils.subtractNotes(state.currentSelection, this.notes);
state.noteDisplayDirty = true; state.noteDisplayDirty = true;
state.notePreviewDirty = true; state.notePreviewDirty = true;
@ -249,7 +448,12 @@ class DeselectNotesCommand implements ChartEditorCommand
{ {
for (note in this.notes) for (note in this.notes)
{ {
state.currentSelection.push(note); state.currentNoteSelection.push(note);
}
for (event in this.events)
{
state.currentEventSelection.push(event);
} }
state.noteDisplayDirty = true; state.noteDisplayDirty = true;
@ -258,13 +462,15 @@ class DeselectNotesCommand implements ChartEditorCommand
public function toString():String public function toString():String
{ {
if (notes.length == 1) var noteCount = notes.length + events.length;
if (noteCount == 1)
{ {
var dir:String = notes[0].getDirectionName(); var dir:String = notes[0].getDirectionName();
return 'Deselect $dir Note'; return 'Deselect $dir Items';
} }
return 'Deselect ${notes.length} Notes'; return 'Deselect ${noteCount} Items';
} }
} }
@ -272,20 +478,26 @@ class DeselectNotesCommand implements ChartEditorCommand
* Sets the selection rather than appends it. * Sets the selection rather than appends it.
* Deselects any notes that are not in the new selection. * Deselects any notes that are not in the new selection.
*/ */
class SetNoteSelectionCommand implements ChartEditorCommand class SetItemSelectionCommand implements ChartEditorCommand
{ {
private var notes:Array<SongNoteData>; private var notes:Array<SongNoteData>;
private var previousSelection:Array<SongNoteData>; private var events:Array<SongEventData>;
private var previousNoteSelection:Array<SongNoteData>;
private var previousEventSelection:Array<SongEventData>;
public function new(notes:Array<SongNoteData>, ?previousSelection:Array<SongNoteData>) public function new(notes:Array<SongNoteData>, events:Array<SongEventData>, previousNoteSelection:Array<SongNoteData>,
previousEventSelection:Array<SongEventData>)
{ {
this.notes = notes; this.notes = notes;
this.previousSelection = previousSelection == null ? [] : previousSelection; this.events = events;
this.previousNoteSelection = previousNoteSelection == null ? [] : previousNoteSelection;
this.previousEventSelection = previousEventSelection == null ? [] : previousEventSelection;
} }
public function execute(state:ChartEditorState):Void public function execute(state:ChartEditorState):Void
{ {
state.currentSelection = notes; state.currentNoteSelection = notes;
state.currentEventSelection = events;
state.noteDisplayDirty = true; state.noteDisplayDirty = true;
state.notePreviewDirty = true; state.notePreviewDirty = true;
@ -293,7 +505,8 @@ class SetNoteSelectionCommand implements ChartEditorCommand
public function undo(state:ChartEditorState):Void public function undo(state:ChartEditorState):Void
{ {
state.currentSelection = previousSelection; state.currentNoteSelection = previousNoteSelection;
state.currentEventSelection = previousEventSelection;
state.noteDisplayDirty = true; state.noteDisplayDirty = true;
state.notePreviewDirty = true; state.notePreviewDirty = true;
@ -301,82 +514,25 @@ class SetNoteSelectionCommand implements ChartEditorCommand
public function toString():String public function toString():String
{ {
return 'Select ${notes.length} Notes'; return 'Select ${notes.length} Items';
} }
} }
class SelectAllNotesCommand implements ChartEditorCommand class SelectAllItemsCommand implements ChartEditorCommand
{ {
private var previousSelection:Array<SongNoteData>; private var previousNoteSelection:Array<SongNoteData>;
private var previousEventSelection:Array<SongEventData>;
public function new(?previousSelection:Array<SongNoteData>) public function new(?previousNoteSelection:Array<SongNoteData>, ?previousEventSelection:Array<SongEventData>)
{ {
this.previousSelection = previousSelection == null ? [] : previousSelection; this.previousNoteSelection = previousNoteSelection == null ? [] : previousNoteSelection;
this.previousEventSelection = previousEventSelection == null ? [] : previousEventSelection;
} }
public function execute(state:ChartEditorState):Void public function execute(state:ChartEditorState):Void
{ {
state.currentSelection = state.currentSongChartNoteData; state.currentNoteSelection = state.currentSongChartNoteData;
state.noteDisplayDirty = true; state.currentEventSelection = state.currentSongChartEventData;
state.notePreviewDirty = true;
}
public function undo(state:ChartEditorState):Void
{
state.currentSelection = previousSelection;
state.noteDisplayDirty = true;
state.notePreviewDirty = true;
}
public function toString():String
{
return 'Select All Notes';
}
}
class InvertSelectedNotesCommand implements ChartEditorCommand
{
private var previousSelection:Array<SongNoteData>;
public function new(?previousSelection:Array<SongNoteData>)
{
this.previousSelection = previousSelection == null ? [] : previousSelection;
}
public function execute(state:ChartEditorState):Void
{
state.currentSelection = SongDataUtils.subtractNotes(state.currentSongChartNoteData, previousSelection);
state.noteDisplayDirty = true;
state.notePreviewDirty = true;
}
public function undo(state:ChartEditorState):Void
{
state.currentSelection = previousSelection;
state.noteDisplayDirty = true;
state.notePreviewDirty = true;
}
public function toString():String
{
return 'Invert Selected Notes';
}
}
class DeselectAllNotesCommand implements ChartEditorCommand
{
private var previousSelection:Array<SongNoteData>;
public function new(?previousSelection:Array<SongNoteData>)
{
this.previousSelection = previousSelection == null ? [] : previousSelection;
}
public function execute(state:ChartEditorState):Void
{
state.currentSelection = [];
state.noteDisplayDirty = true; state.noteDisplayDirty = true;
state.notePreviewDirty = true; state.notePreviewDirty = true;
@ -384,7 +540,8 @@ class DeselectAllNotesCommand implements ChartEditorCommand
public function undo(state:ChartEditorState):Void public function undo(state:ChartEditorState):Void
{ {
state.currentSelection = previousSelection; state.currentNoteSelection = previousNoteSelection;
state.currentEventSelection = previousEventSelection;
state.noteDisplayDirty = true; state.noteDisplayDirty = true;
state.notePreviewDirty = true; state.notePreviewDirty = true;
@ -392,27 +549,104 @@ class DeselectAllNotesCommand implements ChartEditorCommand
public function toString():String public function toString():String
{ {
return 'Deselect All Notes'; return 'Select All Items';
} }
} }
class CutNotesCommand implements ChartEditorCommand class InvertSelectedItemsCommand implements ChartEditorCommand
{
private var previousNoteSelection:Array<SongNoteData>;
private var previousEventSelection:Array<SongEventData>;
public function new(?previousNoteSelection:Array<SongNoteData>, ?previousEventSelection:Array<SongEventData>)
{
this.previousNoteSelection = previousNoteSelection == null ? [] : previousNoteSelection;
this.previousEventSelection = previousEventSelection == null ? [] : previousEventSelection;
}
public function execute(state:ChartEditorState):Void
{
state.currentNoteSelection = SongDataUtils.subtractNotes(state.currentSongChartNoteData, previousNoteSelection);
state.currentEventSelection = SongDataUtils.subtractEvents(state.currentSongChartEventData, previousEventSelection);
state.noteDisplayDirty = true;
state.notePreviewDirty = true;
}
public function undo(state:ChartEditorState):Void
{
state.currentNoteSelection = previousNoteSelection;
state.currentEventSelection = previousEventSelection;
state.noteDisplayDirty = true;
state.notePreviewDirty = true;
}
public function toString():String
{
return 'Invert Selected Items';
}
}
class DeselectAllItemsCommand implements ChartEditorCommand
{
private var previousNoteSelection:Array<SongNoteData>;
private var previousEventSelection:Array<SongEventData>;
public function new(?previousNoteSelection:Array<SongNoteData>, ?previousEventSelection:Array<SongEventData>)
{
this.previousNoteSelection = previousNoteSelection == null ? [] : previousNoteSelection;
this.previousEventSelection = previousEventSelection == null ? [] : previousEventSelection;
}
public function execute(state:ChartEditorState):Void
{
state.currentNoteSelection = [];
state.currentEventSelection = [];
state.noteDisplayDirty = true;
state.notePreviewDirty = true;
}
public function undo(state:ChartEditorState):Void
{
state.currentNoteSelection = previousNoteSelection;
state.currentEventSelection = previousEventSelection;
state.noteDisplayDirty = true;
state.notePreviewDirty = true;
}
public function toString():String
{
return 'Deselect All Items';
}
}
class CutItemsCommand implements ChartEditorCommand
{ {
private var notes:Array<SongNoteData>; private var notes:Array<SongNoteData>;
private var events:Array<SongEventData>;
public function new(notes:Array<SongNoteData>) public function new(notes:Array<SongNoteData>, events:Array<SongEventData>)
{ {
this.notes = notes; this.notes = notes;
this.events = events;
} }
public function execute(state:ChartEditorState):Void public function execute(state:ChartEditorState):Void
{ {
// Copy the notes. // Copy the notes.
SongDataUtils.writeNotesToClipboard(SongDataUtils.buildClipboard(notes)); SongDataUtils.writeItemsToClipboard({
notes: SongDataUtils.buildNoteClipboard(notes),
events: SongDataUtils.buildEventClipboard(events)
});
// Delete the notes. // Delete the notes.
state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes); state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, notes);
state.currentSelection = []; state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events);
state.currentNoteSelection = [];
state.currentEventSelection = [];
state.saveDataDirty = true; state.saveDataDirty = true;
state.noteDisplayDirty = true; state.noteDisplayDirty = true;
state.notePreviewDirty = true; state.notePreviewDirty = true;
@ -422,19 +656,27 @@ class CutNotesCommand implements ChartEditorCommand
public function undo(state:ChartEditorState):Void public function undo(state:ChartEditorState):Void
{ {
state.currentSongChartNoteData = state.currentSongChartNoteData.concat(notes); state.currentSongChartNoteData = state.currentSongChartNoteData.concat(notes);
state.currentSelection = notes; state.currentSongChartEventData = state.currentSongChartEventData.concat(events);
state.currentNoteSelection = notes;
state.currentEventSelection = events;
state.saveDataDirty = true; state.saveDataDirty = true;
state.noteDisplayDirty = true; state.noteDisplayDirty = true;
state.notePreviewDirty = true; state.notePreviewDirty = true;
state.sortChartData(); state.sortChartData();
} }
public function toString():String public function toString():String
{ {
var len:Int = notes.length; var len:Int = notes.length + events.length;
if (notes.length == 0)
return 'Cut $len Events to Clipboard';
else if (events.length == 0)
return 'Cut $len Notes to Clipboard'; return 'Cut $len Notes to Clipboard';
else
return 'Cut $len Items to Clipboard';
} }
} }
@ -457,7 +699,8 @@ class FlipNotesCommand implements ChartEditorCommand
flippedNotes = SongDataUtils.flipNotes(notes); flippedNotes = SongDataUtils.flipNotes(notes);
state.currentSongChartNoteData = state.currentSongChartNoteData.concat(flippedNotes); state.currentSongChartNoteData = state.currentSongChartNoteData.concat(flippedNotes);
state.currentSelection = flippedNotes; state.currentNoteSelection = flippedNotes;
state.currentEventSelection = [];
state.saveDataDirty = true; state.saveDataDirty = true;
state.noteDisplayDirty = true; state.noteDisplayDirty = true;
@ -470,7 +713,8 @@ class FlipNotesCommand implements ChartEditorCommand
state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, flippedNotes); state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, flippedNotes);
state.currentSongChartNoteData = state.currentSongChartNoteData.concat(notes); state.currentSongChartNoteData = state.currentSongChartNoteData.concat(notes);
state.currentSelection = notes; state.currentNoteSelection = notes;
state.currentEventSelection = [];
state.saveDataDirty = true; state.saveDataDirty = true;
state.noteDisplayDirty = true; state.noteDisplayDirty = true;
@ -486,11 +730,12 @@ class FlipNotesCommand implements ChartEditorCommand
} }
} }
class PasteNotesCommand implements ChartEditorCommand class PasteItemsCommand implements ChartEditorCommand
{ {
private var targetTimestamp:Float; private var targetTimestamp:Float;
// Notes we added with this command, for undo. // Notes we added with this command, for undo.
private var addedNotes:Array<SongNoteData>; private var addedNotes:Array<SongNoteData>;
private var addedEvents:Array<SongEventData>;
public function new(targetTimestamp:Float) public function new(targetTimestamp:Float)
{ {
@ -499,12 +744,15 @@ class PasteNotesCommand implements ChartEditorCommand
public function execute(state:ChartEditorState):Void public function execute(state:ChartEditorState):Void
{ {
var currentClipboard:Array<SongNoteData> = SongDataUtils.readNotesFromClipboard(); var currentClipboard:SongClipboardItems = SongDataUtils.readItemsFromClipboard();
addedNotes = SongDataUtils.offsetSongNoteData(currentClipboard, Std.int(targetTimestamp)); addedNotes = SongDataUtils.offsetSongNoteData(currentClipboard.notes, Std.int(targetTimestamp));
addedEvents = SongDataUtils.offsetSongEventData(currentClipboard.events, Std.int(targetTimestamp));
state.currentSongChartNoteData = state.currentSongChartNoteData.concat(addedNotes); state.currentSongChartNoteData = state.currentSongChartNoteData.concat(addedNotes);
state.currentSelection = addedNotes.copy(); state.currentSongChartEventData = state.currentSongChartEventData.concat(addedEvents);
state.currentNoteSelection = addedNotes.copy();
state.currentEventSelection = addedEvents.copy();
state.saveDataDirty = true; state.saveDataDirty = true;
state.noteDisplayDirty = true; state.noteDisplayDirty = true;
@ -516,7 +764,9 @@ class PasteNotesCommand implements ChartEditorCommand
public function undo(state:ChartEditorState):Void public function undo(state:ChartEditorState):Void
{ {
state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, addedNotes); state.currentSongChartNoteData = SongDataUtils.subtractNotes(state.currentSongChartNoteData, addedNotes);
state.currentSelection = []; state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, addedEvents);
state.currentNoteSelection = [];
state.currentEventSelection = [];
state.saveDataDirty = true; state.saveDataDirty = true;
state.noteDisplayDirty = true; state.noteDisplayDirty = true;
@ -527,52 +777,16 @@ class PasteNotesCommand implements ChartEditorCommand
public function toString():String public function toString():String
{ {
var currentClipboard:Array<SongNoteData> = SongDataUtils.readNotesFromClipboard(); var currentClipboard:SongClipboardItems = SongDataUtils.readItemsFromClipboard();
return 'Paste ${currentClipboard.length} Notes from Clipboard';
}
}
class AddEventsCommand implements ChartEditorCommand var len:Int = currentClipboard.notes.length + currentClipboard.events.length;
{
private var events:Array<SongEventData>;
private var appendToSelection:Bool;
public function new(events:Array<SongEventData>, ?appendToSelection:Bool = false) if (currentClipboard.notes.length == 0)
{ return 'Paste $len Events';
this.events = events; else if (currentClipboard.events.length == 0)
this.appendToSelection = appendToSelection; return 'Paste $len Notes';
} else
return 'Paste $len Items';
public function execute(state:ChartEditorState):Void
{
state.currentSongChartEventData = state.currentSongChartEventData.concat(events);
// TODO: Allow selecting events.
// state.currentSelection = events;
state.saveDataDirty = true;
state.noteDisplayDirty = true;
state.notePreviewDirty = true;
state.sortChartData();
}
public function undo(state:ChartEditorState):Void
{
state.currentSongChartEventData = SongDataUtils.subtractEvents(state.currentSongChartEventData, events);
state.currentSelection = [];
state.saveDataDirty = true;
state.noteDisplayDirty = true;
state.notePreviewDirty = true;
state.sortChartData();
}
public function toString():String
{
var len:Int = events.length;
return 'Add $len Events';
} }
} }

View file

@ -0,0 +1,101 @@
package funkin.ui.debug.charting;
import openfl.display.BitmapData;
import openfl.utils.Assets;
import flixel.FlxObject;
import flixel.FlxBasic;
import flixel.FlxSprite;
import flixel.graphics.frames.FlxFramesCollection;
import flixel.graphics.frames.FlxTileFrames;
import flixel.math.FlxPoint;
import funkin.play.song.SongData.SongEventData;
/**
* A event sprite that can be used to display a song event in a chart.
* Designed to be used and reused efficiently. Has no gameplay functionality.
*/
class ChartEditorEventSprite extends FlxSprite
{
public var parentState:ChartEditorState;
/**
* The note data that this sprite represents.
* You can set this to null to kill the sprite and flag it for recycling.
*/
public var eventData(default, set):SongEventData;
/**
* The image used for all song events. Cached for performance.
*/
var eventGraphic:BitmapData;
public function new(parent:ChartEditorState)
{
super();
this.parentState = parent;
buildGraphic();
}
function buildGraphic():Void
{
if (eventGraphic == null)
{
eventGraphic = Assets.getBitmapData(Paths.image('ui/chart-editor/event'));
}
loadGraphic(eventGraphic);
setGraphicSize(ChartEditorState.GRID_SIZE);
this.updateHitbox();
}
function set_eventData(value:SongEventData):SongEventData
{
this.eventData = value;
if (this.eventData == null)
{
// Disown parent.
this.kill();
return this.eventData;
}
this.visible = true;
// Update the position to match the note data.
updateEventPosition();
return this.eventData;
}
public function updateEventPosition(?origin:FlxObject)
{
this.x = (ChartEditorState.STRUMLINE_SIZE * 2 + 1 - 1) * ChartEditorState.GRID_SIZE;
if (this.eventData.stepTime >= 0)
this.y = this.eventData.stepTime * ChartEditorState.GRID_SIZE;
if (origin != null)
{
this.x += origin.x;
this.y += origin.y;
}
}
/**
* Return whether this note (or its parent) is currently visible.
*/
public function isEventVisible(viewAreaBottom:Float, viewAreaTop:Float):Bool
{
var outsideViewArea = (this.y + this.height < viewAreaTop || this.y > viewAreaBottom);
if (!outsideViewArea)
{
return true;
}
// TODO: Check if this note's parent or child is visible.
return false;
}
}

View file

@ -169,7 +169,8 @@ class ChartEditorNoteSprite extends FlxSprite
if (this.noteData.stepTime >= 0) if (this.noteData.stepTime >= 0)
this.y = this.noteData.stepTime * ChartEditorState.GRID_SIZE; this.y = this.noteData.stepTime * ChartEditorState.GRID_SIZE;
if (origin != null) { if (origin != null)
{
this.x += origin.x; this.x += origin.x;
this.y += origin.y; this.y += origin.y;
} }

View file

@ -1,5 +1,6 @@
package funkin.ui.debug.charting; package funkin.ui.debug.charting;
import haxe.DynamicAccess;
import haxe.io.Path; import haxe.io.Path;
import flixel.addons.display.FlxSliceSprite; import flixel.addons.display.FlxSliceSprite;
import flixel.addons.display.FlxTiledSprite; import flixel.addons.display.FlxTiledSprite;
@ -91,13 +92,25 @@ class ChartEditorState extends HaxeUIState
static final CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT = Paths.ui('chart-editor/toolbox/player-preview'); static final CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT = Paths.ui('chart-editor/toolbox/player-preview');
static final CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT = Paths.ui('chart-editor/toolbox/opponent-preview'); static final CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT = Paths.ui('chart-editor/toolbox/opponent-preview');
// The base grid size for the chart editor. /**
* The base grid size for the chart editor.
*/
public static final GRID_SIZE:Int = 40; public static final GRID_SIZE:Int = 40;
// Number of notes in each strumline. 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 = 4; public static final STRUMLINE_SIZE = 4;
// The height of the menu bar in the layout. /**
* The height of the menu bar in the layout.
*/
static final MENU_BAR_HEIGHT = 32; static final MENU_BAR_HEIGHT = 32;
/** /**
@ -105,15 +118,14 @@ class ChartEditorState extends HaxeUIState
*/ */
static final AUTOSAVE_TIMER_DELAY:Float = 60.0 * 5.0; 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. /**
* The amount of padding between the menu bar and the chart grid when fully scrolled up.
*/
static final GRID_TOP_PAD:Int = 8; static final GRID_TOP_PAD:Int = 8;
public static final PLAYHEAD_SCROLL_AREA_WIDTH:Int = 12; /**
public static final PLAYHEAD_HEIGHT:Int = Std.int(GRID_SIZE / 8); * Duration, in seconds, until toast notifications are automatically hidden.
*/
public static final GRID_SELECTION_BORDER_WIDTH:Int = 6;
// Duration until notifications are automatically hidden.
static final NOTIFICATION_DISMISS_TIME:Float = 3.0; static final NOTIFICATION_DISMISS_TIME:Float = 3.0;
// Start performing rapid undo after this many seconds. // Start performing rapid undo after this many seconds.
@ -297,6 +309,16 @@ class ChartEditorState extends HaxeUIState
*/ */
var selectedNoteKind:String = ''; 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. * Whether to play a metronome sound while the playhead is moving.
*/ */
@ -498,8 +520,14 @@ class ChartEditorState extends HaxeUIState
*/ */
var redoHistory:Array<ChartEditorCommand> = []; var redoHistory:Array<ChartEditorCommand> = [];
/**
* Variable used to track how long the user has been holding the undo keybind.
*/
var undoHeldTime:Float = 0.0; var undoHeldTime:Float = 0.0;
/**
* Variable used to track how long the user has been holding the redo keybind.
*/
var redoHeldTime:Float = 0.0; var redoHeldTime:Float = 0.0;
/** /**
@ -508,9 +536,14 @@ class ChartEditorState extends HaxeUIState
var commandHistoryDirty:Bool = true; var commandHistoryDirty:Bool = true;
/** /**
* The notes which are currently in the selection. * The notes which are currently in the user's selection.
*/ */
var currentSelection:Array<SongNoteData> = []; 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. * The position where the user clicked to start a selection.
@ -809,6 +842,11 @@ class ChartEditorState extends HaxeUIState
*/ */
var gridGhostNote:ChartEditorNoteSprite; var gridGhostNote:ChartEditorNoteSprite;
/**
* A sprite used to indicate the event that will be placed on click.
*/
var gridGhostEvent:ChartEditorEventSprite;
/** /**
* The waveform which (optionally) displays over the grid, underneath the notes and playhead. * The waveform which (optionally) displays over the grid, underneath the notes and playhead.
*/ */
@ -854,7 +892,14 @@ class ChartEditorState extends HaxeUIState
*/ */
var renderedNotes:FlxTypedSpriteGroup<ChartEditorNoteSprite>; var renderedNotes:FlxTypedSpriteGroup<ChartEditorNoteSprite>;
var renderedNoteSelectionSquares:FlxTypedSpriteGroup<FlxSprite>; /**
* 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>;
var renderedSelectionSquares:FlxTypedSpriteGroup<FlxSprite>;
var notifBar:SideBar; var notifBar:SideBar;
var playbarHead:Slider; var playbarHead:Slider;
@ -940,6 +985,12 @@ class ChartEditorState extends HaxeUIState
gridGhostNote.visible = false; gridGhostNote.visible = false;
add(gridGhostNote); add(gridGhostNote);
gridGhostEvent = new ChartEditorEventSprite(this);
gridGhostEvent.alpha = 0.6;
gridGhostEvent.eventData = new SongEventData(-1, "", {});
gridGhostEvent.visible = false;
add(gridGhostEvent);
buildNoteGroup(); buildNoteGroup();
gridPlayheadScrollArea = new FlxSprite(gridTiledSprite.x - PLAYHEAD_SCROLL_AREA_WIDTH, gridPlayheadScrollArea = new FlxSprite(gridTiledSprite.x - PLAYHEAD_SCROLL_AREA_WIDTH,
@ -1029,18 +1080,13 @@ class ChartEditorState extends HaxeUIState
renderedNotes.setPosition(gridTiledSprite.x, gridTiledSprite.y); renderedNotes.setPosition(gridTiledSprite.x, gridTiledSprite.y);
add(renderedNotes); add(renderedNotes);
renderedNoteSelectionSquares = new FlxTypedSpriteGroup<FlxSprite>(); renderedEvents = new FlxTypedSpriteGroup<ChartEditorEventSprite>();
renderedNoteSelectionSquares.setPosition(gridTiledSprite.x, gridTiledSprite.y); renderedEvents.setPosition(gridTiledSprite.x, gridTiledSprite.y);
add(renderedNoteSelectionSquares); add(renderedEvents);
/* renderedSelectionSquares = new FlxTypedSpriteGroup<FlxSprite>();
var sustainSprite:SustainTrail = new SustainTrail(0, 600, Paths.image('NOTE_hold_assets'), 0.9, false); renderedSelectionSquares.setPosition(gridTiledSprite.x, gridTiledSprite.y);
sustainSprite.scrollFactor.set(0, 0); add(renderedSelectionSquares);
sustainSprite.x = gridTiledSprite.x;
sustainSprite.y = gridTiledSprite.y + 32;
sustainSprite.zoom *= 0.258; // 0.77;
add(sustainSprite);
*/
} }
var playbarHeadLayout:Component; var playbarHeadLayout:Component;
@ -1048,7 +1094,6 @@ class ChartEditorState extends HaxeUIState
function buildAdditionalUI():Void function buildAdditionalUI():Void
{ {
notifBar = cast buildComponent(CHART_EDITOR_NOTIFBAR_LAYOUT); notifBar = cast buildComponent(CHART_EDITOR_NOTIFBAR_LAYOUT);
add(notifBar); add(notifBar);
playbarHeadLayout = buildComponent(CHART_EDITOR_PLAYBARHEAD_LAYOUT); playbarHeadLayout = buildComponent(CHART_EDITOR_PLAYBARHEAD_LAYOUT);
@ -1097,7 +1142,7 @@ class ChartEditorState extends HaxeUIState
} }
} }
add(playbarHeadLayout); // add(playbarHeadLayout);
} }
/** /**
@ -1125,37 +1170,56 @@ class ChartEditorState extends HaxeUIState
addUIClickListener('menubarItemCopy', (event:MouseEvent) -> addUIClickListener('menubarItemCopy', (event:MouseEvent) ->
{ {
SongDataUtils.writeNotesToClipboard(SongDataUtils.buildClipboard(currentSelection)); // Doesn't use a command because it's not undoable.
SongDataUtils.writeItemsToClipboard({
notes: SongDataUtils.buildNoteClipboard(currentNoteSelection),
events: SongDataUtils.buildEventClipboard(currentEventSelection),
});
}); });
addUIClickListener('menubarItemCut', (event:MouseEvent) -> addUIClickListener('menubarItemCut', (event:MouseEvent) ->
{ {
performCommand(new CutNotesCommand(currentSelection)); performCommand(new CutItemsCommand(currentNoteSelection, currentEventSelection));
}); });
addUIClickListener('menubarItemPaste', (event:MouseEvent) -> addUIClickListener('menubarItemPaste', (event:MouseEvent) ->
{ {
performCommand(new PasteNotesCommand(scrollPositionInMs + playheadPositionInMs)); performCommand(new PasteItemsCommand(scrollPositionInMs + playheadPositionInMs));
}); });
addUIClickListener('menubarItemDelete', (event:MouseEvent) -> addUIClickListener('menubarItemDelete', (event:MouseEvent) ->
{ {
performCommand(new RemoveNotesCommand(currentSelection)); 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', (event:MouseEvent) -> addUIClickListener('menubarItemSelectAll', (event:MouseEvent) ->
{ {
performCommand(new SelectAllNotesCommand(currentSelection)); performCommand(new SelectAllItemsCommand(currentNoteSelection, currentEventSelection));
}); });
addUIClickListener('menubarItemSelectInverse', (event:MouseEvent) -> addUIClickListener('menubarItemSelectInverse', (event:MouseEvent) ->
{ {
performCommand(new InvertSelectedNotesCommand(currentSelection)); performCommand(new InvertSelectedItemsCommand(currentNoteSelection, currentEventSelection));
}); });
addUIClickListener('menubarItemSelectNone', (event:MouseEvent) -> addUIClickListener('menubarItemSelectNone', (event:MouseEvent) ->
{ {
performCommand(new DeselectAllNotesCommand(currentSelection)); performCommand(new DeselectAllItemsCommand(currentNoteSelection, currentEventSelection));
}); });
addUIClickListener('menubarItemSelectRegion', (event:MouseEvent) -> { addUIClickListener('menubarItemSelectRegion', (event:MouseEvent) -> {
@ -1357,7 +1421,7 @@ class ChartEditorState extends HaxeUIState
// This breaks the layout don't use it. // This breaks the layout don't use it.
// showNotification('Hi there :)'); // showNotification('Hi there :)');
autoSave(); // autoSave();
} }
if (FlxG.keys.justPressed.E) if (FlxG.keys.justPressed.E)
@ -1660,8 +1724,10 @@ class ChartEditorState extends HaxeUIState
var cursorXStart:Float = selectionBoxStartPos.x - gridTiledSprite.x; var cursorXStart:Float = selectionBoxStartPos.x - gridTiledSprite.x;
var cursorYStart:Float = selectionBoxStartPos.y - gridTiledSprite.y; var cursorYStart:Float = selectionBoxStartPos.y - gridTiledSprite.y;
// Determine if we moved the mouse at all. var hasDraggedMouse:Bool = Math.abs(cursorX - cursorXStart) > DRAG_THRESHOLD || Math.abs(cursorY - cursorYStart) > DRAG_THRESHOLD;
if (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. // Handle releasing the selection box.
if (FlxG.mouse.justReleased) if (FlxG.mouse.justReleased)
@ -1676,19 +1742,16 @@ class ChartEditorState extends HaxeUIState
// Since this selects based on noteData directly, // Since this selects based on noteData directly,
// we don't need to specifically exclude sustain pieces. // we don't need to specifically exclude sustain pieces.
var notesToSelect:Array<SongNoteData> = currentSongChartNoteData;
notesToSelect = SongDataUtils.getNotesInTimeRange(notesToSelect, Math.min(cursorMsStart, cursorMs), Math.max(cursorMsStart, cursorMs));
// This logic is gross because the columns go 4567-0123-8. // This logic is gross because the columns go 4567-0123-8.
// We build a list of columns to select. // We build a list of columns to select.
var columnStart:Int = Std.int(Math.min(cursorColumnBase, cursorColumnBaseStart)); var columnStart:Int = Std.int(Math.min(cursorColumnBase, cursorColumnBaseStart));
var columnEnd:Int = Std.int(Math.max(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 var columns:Array<Int> = [for (i in columnStart...(columnEnd + 1)) i].map(function(i:Int):Int
{ {
if (i >= (STRUMLINE_SIZE * 2 + 1 - 1)) if (i >= eventColumn)
{ {
// Don't invert the event column. // Don't invert the event column.
return (STRUMLINE_SIZE * 2 + 1 - 1); return eventColumn;
} }
else if (i >= STRUMLINE_SIZE) else if (i >= STRUMLINE_SIZE)
{ {
@ -1706,25 +1769,44 @@ class ChartEditorState extends HaxeUIState
return 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); notesToSelect = SongDataUtils.getNotesWithData(notesToSelect, columns);
if (notesToSelect != null && notesToSelect.length > 0) 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) if (FlxG.keys.pressed.CONTROL)
{ {
// Add to the selection. // Add to the selection.
performCommand(new SelectNotesCommand(notesToSelect)); performCommand(new SelectItemsCommand(notesToSelect, eventsToSelect));
} }
else else
{ {
// Set the selection. // Set the selection.
performCommand(new SetNoteSelectionCommand(notesToSelect, currentSelection)); performCommand(new SetItemSelectionCommand(notesToSelect, eventsToSelect, currentNoteSelection, currentEventSelection));
} }
} }
else else
{ {
// We made a selection box, but it didn't select anything. // We made a selection box, but it didn't select anything.
} }
}
else
{
// We made a selection box, but it didn't select any columns.
}
// Clear the selection box. // Clear the selection box.
selectionBoxStartPos = null; selectionBoxStartPos = null;
@ -1757,6 +1839,14 @@ class ChartEditorState extends HaxeUIState
// If note.alive is false, the note is dead and awaiting recycling. // If note.alive is false, the note is dead and awaiting recycling.
return note.alive && FlxG.mouse.overlaps(note); return note.alive && FlxG.mouse.overlaps(note);
}); });
var highlightedEvent: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 (FlxG.keys.pressed.CONTROL)
{ {
@ -1767,41 +1857,46 @@ class ChartEditorState extends HaxeUIState
// Control click to select/deselect an individual note. // Control click to select/deselect an individual note.
if (isNoteSelected(highlightedNote.noteData)) if (isNoteSelected(highlightedNote.noteData))
{ {
performCommand(new DeselectNotesCommand([highlightedNote.noteData])); performCommand(new DeselectItemsCommand([highlightedNote.noteData], []));
} }
else else
{ {
performCommand(new SelectNotesCommand([highlightedNote.noteData])); performCommand(new SelectItemsCommand([highlightedNote.noteData], []));
} }
} }
else if (highlightedEvent != null)
{
// Control click to select/deselect an individual note.
if (isEventSelected(highlightedEvent.eventData))
{
performCommand(new DeselectItemsCommand([], [highlightedEvent.eventData]));
}
else else
{ {
if (highlightedNote != null) performCommand(new SelectItemsCommand([], [highlightedEvent.eventData]));
{ }
// Click to select an individual note and deselect everything else.
if (isNoteSelected(highlightedNote.noteData))
{
performCommand(new SetNoteSelectionCommand([highlightedNote.noteData], currentSelection));
} }
else else
{ {
// Do nothing if you control-clicked on an empty space. // Do nothing if you control-clicked on an empty space.
} }
} }
}
}
else else
{ {
if (highlightedNote != null) if (highlightedNote != null)
{ {
// Click a note to select it. // Click a note to select it.
performCommand(new SetNoteSelectionCommand([highlightedNote.noteData], currentSelection)); performCommand(new SetItemSelectionCommand([highlightedNote.noteData], [], currentNoteSelection, currentEventSelection));
}
else if (highlightedEvent != null)
{
// Click an event to select it.
performCommand(new SetItemSelectionCommand([], [highlightedEvent.eventData], currentNoteSelection, currentEventSelection));
} }
else else
{ {
// Click on an empty space to deselect everything. // Click on an empty space to deselect everything.
// We don't place a note since this is the Select tool mode. performCommand(new DeselectAllItemsCommand(currentNoteSelection, currentEventSelection));
performCommand(new DeselectAllNotesCommand(currentSelection));
} }
} }
} }
@ -1851,17 +1946,44 @@ class ChartEditorState extends HaxeUIState
// If note.alive is false, the note is dead and awaiting recycling. // If note.alive is false, the note is dead and awaiting recycling.
return note.alive && FlxG.mouse.overlaps(note); return note.alive && FlxG.mouse.overlaps(note);
}); });
var highlightedEvent: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) if (FlxG.keys.pressed.CONTROL)
{ {
// Control click to select/deselect an individual note. // Control click to select/deselect an individual note.
if (highlightedNote != null)
{
if (isNoteSelected(highlightedNote.noteData)) if (isNoteSelected(highlightedNote.noteData))
{ {
performCommand(new DeselectNotesCommand([highlightedNote.noteData])); performCommand(new DeselectItemsCommand([highlightedNote.noteData], []));
} }
else else
{ {
performCommand(new SelectNotesCommand([highlightedNote.noteData])); performCommand(new SelectItemsCommand([highlightedNote.noteData], []));
}
}
else if (highlightedEvent != null)
{
if (isEventSelected(highlightedEvent.eventData))
{
performCommand(new DeselectItemsCommand([], [highlightedEvent.eventData]));
}
else
{
performCommand(new SelectItemsCommand([], [highlightedEvent.eventData]));
}
}
else
{
// Do nothing when control clicking nothing.
} }
} }
else else
@ -1869,7 +1991,12 @@ class ChartEditorState extends HaxeUIState
if (highlightedNote != null) if (highlightedNote != null)
{ {
// Click a note to select it. // Click a note to select it.
performCommand(new SetNoteSelectionCommand([highlightedNote.noteData], currentSelection)); performCommand(new SetItemSelectionCommand([highlightedNote.noteData], [], currentNoteSelection, currentEventSelection));
}
else if (highlightedEvent != null)
{
// Click an event to select it.
performCommand(new SetItemSelectionCommand([], [highlightedEvent.eventData], currentNoteSelection, currentEventSelection));
} }
else else
{ {
@ -1878,8 +2005,8 @@ class ChartEditorState extends HaxeUIState
if (cursorColumn == eventColumn) if (cursorColumn == eventColumn)
{ {
// Create an event and place it in the chart. // Create an event and place it in the chart.
// TODO: Allow configuring the event to place. // TODO: Figure out configuring event data.
var newEventData:SongEventData = new SongEventData(cursorMs, "test", {}); var newEventData:SongEventData = new SongEventData(cursorMs, selectedEventKind, selectedEventData);
performCommand(new AddEventsCommand([newEventData], FlxG.keys.pressed.CONTROL)); performCommand(new AddEventsCommand([newEventData], FlxG.keys.pressed.CONTROL));
} }
@ -1913,6 +2040,15 @@ class ChartEditorState extends HaxeUIState
// If note.alive is false, the note is dead and awaiting recycling. // If note.alive is false, the note is dead and awaiting recycling.
return note.alive && FlxG.mouse.overlaps(note); return note.alive && FlxG.mouse.overlaps(note);
}); });
var highlightedEvent: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) if (highlightedNote != null)
{ {
@ -1921,6 +2057,15 @@ class ChartEditorState extends HaxeUIState
// Remove the note. // Remove the note.
performCommand(new RemoveNotesCommand([highlightedNote.noteData])); performCommand(new RemoveNotesCommand([highlightedNote.noteData]));
} }
else if (highlightedEvent != null)
{
// Remove the event.
performCommand(new RemoveEventsCommand([highlightedEvent.eventData]));
}
else
{
// Right clicked on nothing.
}
} }
// Handle grid cursor. // Handle grid cursor.
@ -1928,8 +2073,25 @@ class ChartEditorState extends HaxeUIState
{ {
Cursor.cursorMode = Pointer; Cursor.cursorMode = Pointer;
// Indicate that we can pla // Indicate that we can place a note here.
gridGhostNote.visible = (cursorColumn != eventColumn);
if (cursorColumn == eventColumn)
{
gridGhostEvent.visible = true;
gridGhostNote.visible = false;
if (selectedEventKind != gridGhostEvent.eventData.event)
{
gridGhostEvent.eventData.event = selectedEventKind;
}
gridGhostEvent.eventData.time = cursorMs;
gridGhostEvent.updateEventPosition(renderedEvents);
}
else
{
gridGhostEvent.visible = false;
gridGhostNote.visible = true;
if (cursorColumn != gridGhostNote.noteData.data || selectedNoteKind != gridGhostNote.noteData.kind) if (cursorColumn != gridGhostNote.noteData.data || selectedNoteKind != gridGhostNote.noteData.kind)
{ {
@ -1940,6 +2102,7 @@ class ChartEditorState extends HaxeUIState
gridGhostNote.noteData.time = cursorMs; gridGhostNote.noteData.time = cursorMs;
gridGhostNote.updateNotePosition(renderedNotes); gridGhostNote.updateNotePosition(renderedNotes);
}
// gridCursor.visible = true; // gridCursor.visible = true;
// // X and Y are the cursor position relative to the grid, snapped to the top left of the grid square. // // X and Y are the cursor position relative to the grid, snapped to the top left of the grid square.
@ -1949,6 +2112,7 @@ class ChartEditorState extends HaxeUIState
else else
{ {
gridGhostNote.visible = false; gridGhostNote.visible = false;
gridGhostEvent.visible = false;
Cursor.cursorMode = Default; Cursor.cursorMode = Default;
} }
} }
@ -1956,6 +2120,7 @@ class ChartEditorState extends HaxeUIState
else else
{ {
gridGhostNote.visible = false; gridGhostNote.visible = false;
gridGhostEvent.visible = false;
} }
if (isCursorOverHaxeUIButton && Cursor.cursorMode == Default) if (isCursorOverHaxeUIButton && Cursor.cursorMode == Default)
@ -2022,6 +2187,35 @@ class ChartEditorState extends HaxeUIState
} }
} }
// 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.exists || !eventSprite.visible)
continue;
if (!eventSprite.isEventVisible(viewAreaBottom, viewAreaTop))
{
// This sprite is off-screen.
// Kill the event sprite and recycle it.
eventSprite.eventData = null;
}
else if (currentSongChartEventData.indexOf(eventSprite.eventData) == -1)
{
// 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);
}
}
// Add notes that are now visible. // Add notes that are now visible.
for (noteData in currentSongChartNoteData) for (noteData in currentSongChartNoteData)
{ {
@ -2088,22 +2282,56 @@ class ChartEditorState extends HaxeUIState
} }
} }
// Destroy and recreate smaller selection squares. // Add events that are now visible.
for (member in renderedNoteSelectionSquares.members) for (eventData in currentSongChartEventData)
{
// Remember if we are already displaying this event.
if (displayedEventData.indexOf(eventData) != -1)
{
continue;
}
// Get the position the event should be at.
var eventTimePixels:Float = eventData.time / Conductor.stepCrochet * GRID_SIZE;
// Make sure the event appears when scrolling up.
var modifiedViewAreaTop = viewAreaTop - GRID_SIZE;
if (eventTimePixels < modifiedViewAreaTop || eventTimePixels > viewAreaBottom)
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));
eventSprite.parentState = this;
// 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;
}
// Destroy all existing selection squares.
for (member in renderedSelectionSquares.members)
{ {
// Killing the sprite is cheap because we can recycle it. // Killing the sprite is cheap because we can recycle it.
member.kill(); member.kill();
} }
// Readd selection squares for selected notes.
// Recycle selection squares if possible.
for (noteSprite in renderedNotes.members) for (noteSprite in renderedNotes.members)
{ {
if (isNoteSelected(noteSprite.noteData) && noteSprite.parentNoteSprite == null) if (isNoteSelected(noteSprite.noteData) && noteSprite.parentNoteSprite == null)
{ {
var selectionSquare:FlxSprite = renderedNoteSelectionSquares.recycle(() -> var selectionSquare:FlxSprite = renderedSelectionSquares.recycle(buildSelectionSquare);
{
return new FlxSprite().loadGraphic(selectionSquareBitmap);
});
// Set the position and size (because we might be recycling one with bad values).
selectionSquare.x = noteSprite.x; selectionSquare.x = noteSprite.x;
selectionSquare.y = noteSprite.y; selectionSquare.y = noteSprite.y;
selectionSquare.width = noteSprite.width; selectionSquare.width = noteSprite.width;
@ -2111,11 +2339,33 @@ class ChartEditorState extends HaxeUIState
} }
} }
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. // Sort the notes DESCENDING. This keeps the sustain behind the associated note.
renderedNotes.sort(FlxSort.byY, FlxSort.DESCENDING); renderedNotes.sort(FlxSort.byY, FlxSort.DESCENDING);
// Sort the events DESCENDING. This keeps the sustain behind the associated note.
renderedEvents.sort(FlxSort.byY, FlxSort.DESCENDING);
} }
} }
function buildSelectionSquare():FlxSprite
{
return new FlxSprite().loadGraphic(selectionSquareBitmap);
}
/** /**
* Handles display elements for the playbar at the bottom. * Handles display elements for the playbar at the bottom.
*/ */
@ -2210,58 +2460,70 @@ class ChartEditorState extends HaxeUIState
{ {
// Copy selected notes. // Copy selected notes.
// We don't need a command for this since we can't undo it. // We don't need a command for this since we can't undo it.
SongDataUtils.writeNotesToClipboard(SongDataUtils.buildClipboard(currentSelection)); SongDataUtils.writeItemsToClipboard({
notes: SongDataUtils.buildNoteClipboard(currentNoteSelection),
events: SongDataUtils.buildEventClipboard(currentEventSelection),
});
} }
// CTRL + X = Cut // CTRL + X = Cut
if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.X) if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.X)
{ {
// Cut selected notes. // Cut selected notes.
performCommand(new CutNotesCommand(currentSelection)); performCommand(new CutItemsCommand(currentNoteSelection, currentEventSelection));
} }
// CTRL + V = Paste // CTRL + V = Paste
if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.V) if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.V)
{ {
// Paste notes from clipboard, at the playhead. // Paste notes from clipboard, at the playhead.
performCommand(new PasteNotesCommand(scrollPositionInMs + playheadPositionInMs)); performCommand(new PasteItemsCommand(scrollPositionInMs + playheadPositionInMs));
} }
// DELETE = Delete // DELETE = Delete
if (FlxG.keys.justPressed.DELETE) if (FlxG.keys.justPressed.DELETE)
{ {
// Delete selected notes. // Delete selected items.
performCommand(new RemoveNotesCommand(currentSelection)); 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 // CTRL + A = Select All
if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.A) if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.A)
{ {
// Select all notes. // Select all items.
performCommand(new SelectAllNotesCommand(currentSelection)); performCommand(new SelectAllItemsCommand(currentNoteSelection, currentEventSelection));
} }
// CTRL + I = Select Inverse // CTRL + I = Select Inverse
if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.I) if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.I)
{ {
// Select unselected notes and deselect selected notes.. // Select unselected items and deselect selected items.
performCommand(new InvertSelectedNotesCommand(currentSelection)); performCommand(new InvertSelectedItemsCommand(currentNoteSelection, currentEventSelection));
} }
// CTRL + D = Select None // CTRL + D = Select None
if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.D) if (FlxG.keys.pressed.CONTROL && FlxG.keys.justPressed.D)
{ {
// Deselect all notes. // Deselect all items.
performCommand(new DeselectAllNotesCommand(currentSelection)); performCommand(new DeselectAllItemsCommand(currentNoteSelection, currentEventSelection));
} }
} }
/** /**
* Handle keybinds for View menu items. * Handle keybinds for View menu items.
*/ */
function handleViewKeybinds() function handleViewKeybinds() {}
{
}
/** /**
* Handle keybinds for Help menu items. * Handle keybinds for Help menu items.
@ -2443,9 +2705,7 @@ class ChartEditorState extends HaxeUIState
} }
} }
function addDifficulty(variation:String) function addDifficulty(variation:String) {}
{
}
function addVariation(variationId:String) function addVariation(variationId:String)
{ {
@ -2458,9 +2718,7 @@ class ChartEditorState extends HaxeUIState
/** /**
* Handle the player preview/gameplay test area on the left side. * Handle the player preview/gameplay test area on the left side.
*/ */
function handlePlayerDisplay() function handlePlayerDisplay() {}
{
}
/** /**
* Handles the note preview/scroll area on the right side. * Handles the note preview/scroll area on the right side.
@ -2490,9 +2748,7 @@ class ChartEditorState extends HaxeUIState
* Perform a spot update on the note preview, by editing the note preview * Perform a spot update on the note preview, by editing the note preview
* only where necessary. More efficient than a full update. * only where necessary. More efficient than a full update.
*/ */
function updateNotePreview(note:SongNoteData, ?deleteNote:Bool = false) function updateNotePreview(note:SongNoteData, ?deleteNote:Bool = false) {}
{
}
/** /**
* Handles passive behavior of the menu bar, such as updating labels or enabled/disabled status. * Handles passive behavior of the menu bar, such as updating labels or enabled/disabled status.
@ -2751,7 +3007,8 @@ class ChartEditorState extends HaxeUIState
} }
// Move the rendered notes to the correct position. // Move the rendered notes to the correct position.
renderedNotes.setPosition(gridTiledSprite.x, gridTiledSprite.y); renderedNotes.setPosition(gridTiledSprite.x, gridTiledSprite.y);
renderedNoteSelectionSquares.setPosition(renderedNotes.x, renderedNotes.y); renderedEvents.setPosition(gridTiledSprite.x, gridTiledSprite.y);
renderedSelectionSquares.setPosition(gridTiledSprite.x, gridTiledSprite.y);
if (gridSpectrogram != null) if (gridSpectrogram != null)
{ {
// Move the spectrogram to the correct position. // Move the spectrogram to the correct position.
@ -3017,7 +3274,12 @@ class ChartEditorState extends HaxeUIState
function isNoteSelected(note:SongNoteData):Bool function isNoteSelected(note:SongNoteData):Bool
{ {
return currentSelection.indexOf(note) != -1; return currentNoteSelection.indexOf(note) != -1;
}
function isEventSelected(event:SongEventData):Bool
{
return currentEventSelection.indexOf(event) != -1;
} }
/** /**
@ -3048,24 +3310,9 @@ class ChartEditorState extends HaxeUIState
*/ */
function showNotification(text:String) function showNotification(text:String)
{ {
var notifBarText:Label = notifBar.findComponent('notifBarText', Label);
var notifBarAction1:Button = notifBar.findComponent('notifBarAction1', Button);
// Make it appear. // Make it appear.
notifBar.show(); notifBar.show();
// Don't shift the UI up.
notifBar.method = "float";
// Anchor to far right.
notifBar.x = FlxG.width - notifBar.width;
// Set the message.
notifBarText.text = text;
// Configure the action button.
notifBarAction1.text = 'Dismiss';
notifBarAction1.onClick = (_:UIEvent) -> dismissNotification();
// Auto dismiss. // Auto dismiss.
new FlxTimer().start(NOTIFICATION_DISMISS_TIME, (_:FlxTimer) -> dismissNotification()); new FlxTimer().start(NOTIFICATION_DISMISS_TIME, (_:FlxTimer) -> dismissNotification());
} }

View file

@ -1,17 +1,25 @@
package funkin.ui.debug.charting; package funkin.ui.debug.charting;
import funkin.play.song.SongData.SongTimeChange; import haxe.ui.data.ArrayDataSource;
import haxe.ui.components.Slider;
import haxe.ui.components.NumberStepper;
import haxe.ui.components.NumberStepper;
import haxe.ui.components.TextField;
import funkin.play.character.BaseCharacter.CharacterType; import funkin.play.character.BaseCharacter.CharacterType;
import funkin.ui.haxeui.components.CharacterPlayer; import funkin.play.event.SongEvent;
import funkin.play.song.SongData.SongTimeChange;
import funkin.play.song.SongSerializer; import funkin.play.song.SongSerializer;
import funkin.ui.haxeui.components.CharacterPlayer;
import haxe.ui.components.Button; import haxe.ui.components.Button;
import haxe.ui.components.CheckBox;
import haxe.ui.components.DropDown; import haxe.ui.components.DropDown;
import haxe.ui.containers.Group; import haxe.ui.components.Label;
import haxe.ui.components.NumberStepper;
import haxe.ui.components.NumberStepper;
import haxe.ui.components.Slider;
import haxe.ui.components.TextField;
import haxe.ui.containers.dialogs.Dialog; import haxe.ui.containers.dialogs.Dialog;
import haxe.ui.containers.Box;
import haxe.ui.containers.Frame;
import haxe.ui.containers.Grid;
import haxe.ui.containers.Group;
import haxe.ui.core.Component;
import haxe.ui.events.UIEvent; import haxe.ui.events.UIEvent;
/** /**
@ -67,13 +75,9 @@ class ChartEditorToolboxHandler
} }
} }
public static function minimizeToolbox(state:ChartEditorState, id:String):Void public static function minimizeToolbox(state:ChartEditorState, id:String):Void {}
{
}
public static function maximizeToolbox(state:ChartEditorState, id:String):Void public static function maximizeToolbox(state:ChartEditorState, id:String):Void {}
{
}
public static function initToolbox(state:ChartEditorState, id:String):Dialog public static function initToolbox(state:ChartEditorState, id:String):Dialog
{ {
@ -97,10 +101,15 @@ class ChartEditorToolboxHandler
case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT: case ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT:
toolbox = buildToolboxOpponentPreviewLayout(state); toolbox = buildToolboxOpponentPreviewLayout(state);
default: default:
// This happens if you try to load an unknown layout.
trace('ChartEditorToolboxHandler.initToolbox() - Unknown toolbox ID: $id'); trace('ChartEditorToolboxHandler.initToolbox() - Unknown toolbox ID: $id');
toolbox = null; toolbox = null;
} }
// This happens if the layout you try to load has a syntax error.
if (toolbox == null)
return null;
// Make sure we can reuse the toolbox later. // Make sure we can reuse the toolbox later.
toolbox.destroyOnClose = false; toolbox.destroyOnClose = false;
state.activeToolboxes.set(id, toolbox); state.activeToolboxes.set(id, toolbox);
@ -123,7 +132,8 @@ class ChartEditorToolboxHandler
{ {
var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT); var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_TOOLS_LAYOUT);
if (toolbox == null) return null; if (toolbox == null)
return null;
// Starting position. // Starting position.
toolbox.x = 50; toolbox.x = 50;
@ -136,7 +146,8 @@ class ChartEditorToolboxHandler
var toolsGroup:Group = toolbox.findComponent("toolboxToolsGroup", Group); var toolsGroup:Group = toolbox.findComponent("toolboxToolsGroup", Group);
if (toolsGroup == null) return null; if (toolsGroup == null)
return null;
toolsGroup.onChange = (event:UIEvent) -> toolsGroup.onChange = (event:UIEvent) ->
{ {
@ -158,7 +169,8 @@ class ChartEditorToolboxHandler
{ {
var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT); var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_NOTEDATA_LAYOUT);
if (toolbox == null) return null; if (toolbox == null)
return null;
// Starting position. // Starting position.
toolbox.x = 75; toolbox.x = 75;
@ -170,11 +182,33 @@ class ChartEditorToolboxHandler
} }
var toolboxNotesNoteKind:DropDown = toolbox.findComponent("toolboxNotesNoteKind", DropDown); var toolboxNotesNoteKind:DropDown = toolbox.findComponent("toolboxNotesNoteKind", DropDown);
var toolboxNotesCustomKindLabel:Label = toolbox.findComponent("toolboxNotesCustomKindLabel", Label);
var toolboxNotesCustomKind:TextField = toolbox.findComponent("toolboxNotesCustomKind", TextField);
toolboxNotesNoteKind.onChange = (event:UIEvent) -> toolboxNotesNoteKind.onChange = (event:UIEvent) ->
{ {
var isCustom = (event.data.id == '~CUSTOM~');
if (isCustom)
{
toolboxNotesCustomKindLabel.hidden = false;
toolboxNotesCustomKind.hidden = false;
state.selectedNoteKind = toolboxNotesCustomKind.text;
}
else
{
toolboxNotesCustomKindLabel.hidden = true;
toolboxNotesCustomKind.hidden = true;
state.selectedNoteKind = event.data.id; state.selectedNoteKind = event.data.id;
} }
}
toolboxNotesCustomKind.onChange = (event:UIEvent) ->
{
state.selectedNoteKind = toolboxNotesCustomKind.text;
}
return toolbox; return toolbox;
} }
@ -183,7 +217,8 @@ class ChartEditorToolboxHandler
{ {
var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT); var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_EVENTDATA_LAYOUT);
if (toolbox == null) return null; if (toolbox == null)
return null;
// Starting position. // Starting position.
toolbox.x = 100; toolbox.x = 100;
@ -194,14 +229,124 @@ class ChartEditorToolboxHandler
state.setUICheckboxSelected('menubarItemToggleToolboxEvents', false); state.setUICheckboxSelected('menubarItemToggleToolboxEvents', false);
} }
var toolboxEventsEventKind:DropDown = toolbox.findComponent("toolboxEventsEventKind", DropDown);
var toolboxEventsDataGrid:Grid = toolbox.findComponent("toolboxEventsDataGrid", Grid);
toolboxEventsEventKind.dataSource = new ArrayDataSource();
var songEvents:Array<SongEvent> = SongEventParser.listEvents();
for (event in songEvents)
{
toolboxEventsEventKind.dataSource.add({text: event.getTitle(), value: event.id});
}
toolboxEventsEventKind.onChange = (event:UIEvent) ->
{
var eventType:String = event.data.value;
trace('ChartEditorToolboxHandler.buildToolboxEventDataLayout() - Event type changed: $eventType');
var schema:SongEventSchema = SongEventParser.getEventSchema(eventType);
if (schema == null)
{
trace('ChartEditorToolboxHandler.buildToolboxEventDataLayout() - Unknown event kind: $eventType');
return;
}
buildEventDataFormFromSchema(state, toolboxEventsDataGrid, schema);
}
return toolbox; return toolbox;
} }
static function buildEventDataFormFromSchema(state:ChartEditorState, target:Box, schema:SongEventSchema):Void
{
trace(schema);
// Clear the frame.
target.removeAllComponents();
state.selectedEventData = {};
for (field in schema)
{
// Add a label.
var label:Label = new Label();
label.text = field.title;
target.addComponent(label);
var input:Component;
switch (field.type)
{
case INTEGER:
var numberStepper:NumberStepper = new NumberStepper();
numberStepper.id = field.name;
numberStepper.step = field.step == null ? 1.0 : field.step;
numberStepper.min = field.min;
numberStepper.max = field.max;
numberStepper.value = field.defaultValue;
input = numberStepper;
case FLOAT:
var numberStepper:NumberStepper = new NumberStepper();
numberStepper.id = field.name;
numberStepper.step = field.step == null ? 0.1 : field.step;
numberStepper.min = field.min;
numberStepper.max = field.max;
numberStepper.value = field.defaultValue;
input = numberStepper;
case BOOL:
var checkBox = new CheckBox();
checkBox.id = field.name;
checkBox.selected = field.defaultValue == true;
input = checkBox;
case ENUM:
var dropDown:DropDown = new DropDown();
dropDown.id = field.name;
dropDown.dataSource = new ArrayDataSource();
// Add entries to the dropdown.
for (optionName in field.keys.keys())
{
var optionValue = field.keys.get(optionName);
trace('$optionName : $optionValue');
dropDown.dataSource.add({value: optionValue, text: optionName});
}
dropDown.value = field.defaultValue;
input = dropDown;
case STRING:
input = new TextField();
input.id = field.name;
input.text = field.defaultValue;
default:
// Unknown type. Display a label so we know what it is.
input = new Label();
input.id = field.name;
input.text = field.type;
}
target.addComponent(input);
input.onChange = (event:UIEvent) ->
{
trace('ChartEditorToolboxHandler.buildEventDataFormFromSchema() - ${event.target.id} = ${event.target.value}');
if (event.target.value == null)
state.selectedEventData.remove(event.target.id);
else
state.selectedEventData.set(event.target.id, event.target.value);
}
}
}
static function buildToolboxDifficultyLayout(state:ChartEditorState):Dialog static function buildToolboxDifficultyLayout(state:ChartEditorState):Dialog
{ {
var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT); var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_DIFFICULTY_LAYOUT);
if (toolbox == null) return null; if (toolbox == null)
return null;
// Starting position. // Starting position.
toolbox.x = 125; toolbox.x = 125;
@ -261,7 +406,8 @@ class ChartEditorToolboxHandler
{ {
var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT); var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_METADATA_LAYOUT);
if (toolbox == null) return null; if (toolbox == null)
return null;
// Starting position. // Starting position.
toolbox.x = 150; toolbox.x = 150;
@ -309,7 +455,8 @@ class ChartEditorToolboxHandler
{ {
var valid = event.data != null && event.data.id != null; var valid = event.data != null && event.data.id != null;
if (valid) { if (valid)
{
state.currentSongMetadata.playData.stage = event.data.id; state.currentSongMetadata.playData.stage = event.data.id;
} }
}; };
@ -359,7 +506,6 @@ class ChartEditorToolboxHandler
} }
}; };
return toolbox; return toolbox;
} }
@ -367,7 +513,8 @@ class ChartEditorToolboxHandler
{ {
var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT); var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_CHARACTERS_LAYOUT);
if (toolbox == null) return null; if (toolbox == null)
return null;
// Starting position. // Starting position.
toolbox.x = 175; toolbox.x = 175;
@ -385,7 +532,8 @@ class ChartEditorToolboxHandler
{ {
var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT); var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_PLAYER_PREVIEW_LAYOUT);
if (toolbox == null) return null; if (toolbox == null)
return null;
// Starting position. // Starting position.
toolbox.x = 200; toolbox.x = 200;
@ -410,7 +558,8 @@ class ChartEditorToolboxHandler
{ {
var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT); var toolbox:Dialog = cast state.buildComponent(ChartEditorState.CHART_EDITOR_TOOLBOX_OPPONENT_PREVIEW_LAYOUT);
if (toolbox == null) return null; if (toolbox == null)
return null;
// Starting position. // Starting position.
toolbox.x = 200; toolbox.x = 200;

View file

@ -0,0 +1,113 @@
package funkin.ui.haxeui.components;
import flixel.FlxG;
import flixel.util.FlxTimer;
import haxe.ui.RuntimeComponentBuilder;
import haxe.ui.components.Button;
import haxe.ui.components.Label;
import haxe.ui.containers.Box;
import haxe.ui.containers.SideBar;
import haxe.ui.containers.VBox;
import haxe.ui.core.Component;
class Notifbar extends SideBar
{
final NOTIFICATION_DISMISS_TIME = 5.0; // seconds
var dismissTimer:FlxTimer = null;
var outerContainer:Box = null;
var container:VBox = null;
var message:Label = null;
var action:Button = null;
var dismiss:Button = null;
public function new()
{
super();
buildSidebar();
buildChildren();
}
public function showNotification(message:String, ?actionText:String = null, ?actionCallback:Void->Void = null, ?dismissTime:Float = null)
{
if (dismissTimer != null)
dismissNotification();
if (dismissTime == null)
dismissTime = NOTIFICATION_DISMISS_TIME;
// Message text.
this.message.text = message;
// Action
if (actionText != null)
{
this.action.text = actionText;
this.action.visible = true;
this.action.disabled = false;
this.action.onClick = (_) ->
{
actionCallback();
};
}
else
{
this.action.visible = false;
this.action.disabled = false;
this.action.onClick = null;
}
this.show();
// Auto dismiss.
dismissTimer = new FlxTimer().start(dismissTime, (_:FlxTimer) -> dismissNotification());
}
public function dismissNotification()
{
if (dismissTimer != null)
{
dismissTimer.cancel();
dismissTimer = null;
}
this.hide();
}
function buildSidebar():Void
{
this.width = 256;
this.height = 80;
// border-top: 1px solid #000; border-left: 1px solid #000;
this.styleString = "border: 1px solid #000; background-color: #3d3f41; padding: 8px; border-top-left-radius: 8px;";
// float to the right
this.x = FlxG.width - this.width;
this.position = "bottom";
this.method = "float";
}
function buildChildren():Void
{
outerContainer = cast(buildComponent("assets/data/notifbar.xml"), Box);
addComponent(outerContainer);
container = outerContainer.findComponent('notifbarContainer', VBox);
message = outerContainer.findComponent('notifbarMessage', Label);
action = outerContainer.findComponent('notifbarAction', Button);
dismiss = outerContainer.findComponent('notifbarDismiss', Button);
dismiss.onClick = (_) ->
{
dismissNotification();
};
}
function buildComponent(path:String):Component
{
return RuntimeComponentBuilder.fromAsset(path);
}
}

View file

@ -2,6 +2,13 @@ package funkin.util;
import flixel.util.FlxSignal.FlxTypedSignal; import flixel.util.FlxSignal.FlxTypedSignal;
#if cpp
@:cppFileCode('
#include <iostream>
#include <windows.h>
#include <psapi.h>
')
#end
class WindowUtil class WindowUtil
{ {
public static function openURL(targetUrl:String) public static function openURL(targetUrl:String)
@ -33,4 +40,16 @@ class WindowUtil
windowExit.dispatch(exitCode); windowExit.dispatch(exitCode);
}); });
} }
/**
* Turns off that annoying "Report to Microsoft" dialog that pops up when the game crashes.
*/
public static function disableCrashHandler()
{
#if cpp
untyped __cpp__('SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX);');
#else
// Do nothing.
#end
}
} }

View file

@ -4,7 +4,7 @@ class DataAssets
{ {
static function buildDataPath(path:String):String static function buildDataPath(path:String):String
{ {
return 'default:assets/data/${path}'; return 'assets/data/${path}';
} }
public static function listDataFilesInPath(path:String, ?suffix:String = '.json'):Array<String> public static function listDataFilesInPath(path:String, ?suffix:String = '.json'):Array<String>

View file

@ -0,0 +1,204 @@
package funkin.util.macro;
import haxe.macro.Context;
import haxe.macro.Expr;
import haxe.macro.Type;
import funkin.util.macro.MacroUtil;
/**
* Macros to generate lists of classes at compile time.
*
* This code is a bitch glad Jason figured it out.
* Based on code from CompileTime: https://github.com/jasononeil/compiletime
*/
class ClassMacro
{
/**
* Gets a list of `Class<T>` for all classes in a specified package.
*
* Example: `var list:Array<Class<Dynamic>> = listClassesInPackage("funkin", true);`
*
* @param targetPackage A String containing the package name to query.
* @param includeSubPackages Whether to include classes located in sub-packages of the target package.
* @return A list of classes matching the specified criteria.
*/
public static macro function listClassesInPackage(targetPackage:String, ?includeSubPackages:Bool = true):ExprOf<Iterable<Class<Dynamic>>>
{
if (!onGenerateCallbackRegistered)
{
onGenerateCallbackRegistered = true;
Context.onGenerate(onGenerate);
}
var request:String = 'package~${targetPackage}~${includeSubPackages ? "recursive" : "nonrecursive"}';
classListsToGenerate.push(request);
return macro funkin.util.macro.CompiledClassList.get($v{request});
}
/**
* Get a list of `Class<T>` for all classes extending a specified class.
*
* Example: `var list:Array<Class<FlxSprite>> = listSubclassesOf(FlxSprite);`
*
* @param targetClass The class to query for subclasses.
* @return A list of classes matching the specified criteria.
*/
public static macro function listSubclassesOf<T>(targetClassExpr:ExprOf<Class<T>>):ExprOf<List<Class<T>>>
{
if (!onGenerateCallbackRegistered)
{
onGenerateCallbackRegistered = true;
Context.onGenerate(onGenerate);
}
var targetClass:ClassType = MacroUtil.getClassTypeFromExpr(targetClassExpr);
var targetClassPath:String = null;
if (targetClass != null)
targetClassPath = targetClass.pack.join('.') + '.' + targetClass.name;
var request:String = 'extend~${targetClassPath}';
classListsToGenerate.push(request);
return macro funkin.util.macro.CompiledClassList.getTyped($v{request}, ${targetClassExpr});
}
#if macro
/**
* Callback executed after the typing phase but before the generation phase.
* Receives a list of `haxe.macro.Type` for all types in the program.
*
* Only metadata can be modified at this time, which makes it a BITCH to access the data at runtime.
*/
static function onGenerate(allTypes:Array<haxe.macro.Type>)
{
// Reset these, since onGenerate persists across multiple builds.
classListsRaw = [];
for (request in classListsToGenerate)
{
classListsRaw.set(request, []);
}
for (type in allTypes)
{
switch (type)
{
// Class instances
case TInst(t, _params):
var classType:ClassType = t.get();
var className:String = t.toString();
if (classType.isInterface)
{
// Ignore interfaces.
}
else
{
for (request in classListsToGenerate)
{
if (doesClassMatchRequest(classType, request))
{
classListsRaw.get(request).push(className);
}
}
}
// Other types (things like enums)
default:
continue;
}
}
compileClassLists();
}
/**
* At this stage in the program, `classListsRaw` is generated, but only accessible by macros.
* To make it accessible at runtime, we must:
* - Convert the String names to actual `Class<T>` instances, and store it as `classLists`
* - Insert the `classLists` into the metadata of the `CompiledClassList` class.
* `CompiledClassList` then extracts the metadata and stores it where it can be accessed at runtime.
*/
static function compileClassLists()
{
var compiledClassList:ClassType = MacroUtil.getClassType("funkin.util.macro.CompiledClassList");
if (compiledClassList == null)
throw "Could not find CompiledClassList class.";
// Reset outdated metadata.
if (compiledClassList.meta.has('classLists'))
compiledClassList.meta.remove('classLists');
var classLists:Array<Expr> = [];
// Generate classLists.
for (request in classListsToGenerate)
{
// Expression contains String, [Class<T>...]
var classListEntries:Array<Expr> = [macro $v{request}];
for (i in classListsRaw.get(request))
{
// TODO: Boost performance by making this an Array<Class<T>> instead of an Array<String>
// How to perform perform macro reificiation to types given a name?
classListEntries.push(macro $v{i});
}
classLists.push(macro $a{classListEntries});
}
// Insert classLists into metadata.
compiledClassList.meta.add('classLists', classLists, Context.currentPos());
}
static function doesClassMatchRequest(classType:ClassType, request:String):Bool
{
var splitRequest:Array<String> = request.split('~');
var requestType:String = splitRequest[0];
switch (requestType)
{
case 'package':
var targetPackage:String = splitRequest[1];
var recursive:Bool = splitRequest[2] == 'recursive';
var classPackage:String = classType.pack.join('.');
if (recursive)
{
return StringTools.startsWith(classPackage, targetPackage);
}
else
{
var regex:EReg = ~/^${targetPackage}(\.|$)/;
return regex.match(classPackage);
}
case 'extend':
var targetClassName:String = splitRequest[1];
var targetClassType:ClassType = MacroUtil.getClassType(targetClassName);
if (MacroUtil.implementsInterface(classType, targetClassType))
{
return true;
}
else if (MacroUtil.isSubclassOf(classType, targetClassType))
{
return true;
}
return false;
default:
throw 'Unknown request type: ${requestType}';
}
}
static var onGenerateCallbackRegistered:Bool = false;
static var classListsRaw:Map<String, Array<String>> = [];
static var classListsToGenerate:Array<String> = [];
#end
}

View file

@ -0,0 +1,69 @@
package funkin.util.macro;
import haxe.rtti.Meta;
/**
* A complement to `ClassMacro`. See `ClassMacro` for more information.
*/
class CompiledClassList
{
static var classLists:Map<String, List<Class<Dynamic>>>;
/**
* Class lists are injected into this class's metadata during the typing phase.
* This function extracts the metadata, at runtime, and stores it in `classLists`.
*/
static function init():Void
{
classLists = [];
// Meta.getType returns Dynamic<Array<Dynamic>>.
var metaData = Meta.getType(CompiledClassList);
if (metaData.classLists != null)
{
for (list in metaData.classLists)
{
var data:Array<Dynamic> = cast list;
// First element is the list ID.
var id:String = cast data[0];
// All other elements are class types.
var classes:List<Class<Dynamic>> = new List();
for (i in 1...data.length)
{
var className:String = cast data[i];
// var classType:Class<Dynamic> = cast data[i];
var classType:Class<Dynamic> = cast Type.resolveClass(className);
classes.push(classType);
}
classLists.set(id, classes);
}
}
else
{
throw "Class lists not properly generated. Try cleaning out your export folder, restarting your IDE, and rebuilding your project.";
}
}
public static function get(request:String):List<Class<Dynamic>>
{
if (classLists == null)
init();
if (!classLists.exists(request))
{
trace('[WARNING] Class list $request not properly generated. Please debug the build macro.');
classLists.set(request, new List()); // Make the error only appear once.
}
return classLists.get(request);
}
public static inline function getTyped<T>(request:String, type:Class<T>):List<Class<T>>
{
return cast get(request);
}
}

View file

@ -3,6 +3,9 @@ package funkin.util.macro;
#if debug #if debug
class GitCommit class GitCommit
{ {
/**
* Get the SHA1 hash of the current Git commit.
*/
public static macro function getGitCommitHash():haxe.macro.Expr.ExprOf<String> public static macro function getGitCommitHash():haxe.macro.Expr.ExprOf<String>
{ {
#if !display #if !display
@ -32,6 +35,9 @@ class GitCommit
#end #end
} }
/**
* Get the branch name of the current Git commit.
*/
public static macro function getGitBranch():haxe.macro.Expr.ExprOf<String> public static macro function getGitBranch():haxe.macro.Expr.ExprOf<String>
{ {
#if !display #if !display

View file

@ -1,7 +1,18 @@
package funkin.util.macro; package funkin.util.macro;
import haxe.macro.Context;
import haxe.macro.Expr;
import haxe.macro.Type;
class MacroUtil class MacroUtil
{ {
/**
* Gets the value of a Haxe compiler define.
*
* @param key The name of the define to get the value of.
* @param defaultValue The value to return if the define is not set.
* @return An expression containing the value of the define.
*/
public static macro function getDefine(key:String, defaultValue:String = null):haxe.macro.Expr public static macro function getDefine(key:String, defaultValue:String = null):haxe.macro.Expr
{ {
var value = haxe.macro.Context.definedValue(key); var value = haxe.macro.Context.definedValue(key);
@ -9,4 +20,154 @@ class MacroUtil
value = defaultValue; value = defaultValue;
return macro $v{value}; return macro $v{value};
} }
/**
* Gets the current date and time (at compile time).
* @return A `Date` object containing the current date and time.
*/
public static macro function getDate():ExprOf<Date>
{
var date = Date.now();
var year = toExpr(date.getFullYear());
var month = toExpr(date.getMonth());
var day = toExpr(date.getDate());
var hours = toExpr(date.getHours());
var mins = toExpr(date.getMinutes());
var secs = toExpr(date.getSeconds());
return macro new Date($year, $month, $day, $hours, $mins, $secs);
}
#if macro
//
// MACRO HELPER FUNCTIONS
//
/**
* Convert an ExprOf<Class<T>> to a ClassType.
* @see https://github.com/jasononeil/compiletime/blob/master/src/CompileTime.hx#L201
*/
public static function getClassTypeFromExpr(e:Expr):ClassType
{
var classType:ClassType = null;
var parts:Array<String> = [];
var nextSection:ExprDef = e.expr;
while (nextSection != null)
{
var section:ExprDef = nextSection;
nextSection = null;
switch (section)
{
// Expression is a class name with no packages
case EConst(c):
switch (c)
{
case CIdent(cn):
if (cn != "null") parts.unshift(cn);
default:
}
// Expression is a fully qualified package name.
// We need to traverse the expression tree to get the full package name.
case EField(exp, field):
nextSection = exp.expr;
parts.unshift(field);
// We've reached the end of the expression tree.
default:
}
}
var fullClassName:String = parts.join('.');
if (fullClassName != "")
{
var classType:Type = Context.getType(fullClassName);
// Follow typedefs to get the actual class type.
var classTypeParsed:Type = Context.follow(classType, false);
switch (classTypeParsed)
{
case TInst(t, params):
return t.get();
default:
// We couldn't parse this class type.
// This function may need to be updated to be more robust.
throw 'Class type could not be parsed: ${fullClassName}';
}
}
return null;
}
/**
* Converts a value to an equivalent macro expression.
*/
public static function toExpr(value:Dynamic):ExprOf<Dynamic>
{
return Context.makeExpr(value, Context.currentPos());
}
public static function areClassesEqual(class1:ClassType, class2:ClassType):Bool
{
return class1.pack.join('.') == class2.pack.join('.') && class1.name == class2.name;
}
/**
* Retrieve a ClassType from a string name.
*/
public static function getClassType(name:String):ClassType
{
switch (Context.getType(name))
{
case TInst(t, _params):
return t.get();
default:
throw 'Class type could not be parsed: ${name}';
}
}
/**
* Determine whether a given ClassType is a subclass of a given superclass.
* @param classType The class to check.
* @param superClass The superclass to check for.
* @return Whether the class is a subclass of the superclass.
*/
public static function isSubclassOf(classType:ClassType, superClass:ClassType):Bool
{
if (areClassesEqual(classType, superClass))
return true;
if (classType.superClass != null)
{
return isSubclassOf(classType.superClass.t.get(), superClass);
}
return false;
}
/**
* Determine whether a given ClassType implements a given interface.
* @param classType The class to check.
* @param interfaceType The interface to check for.
* @return Whether the class implements the interface.
*/
public static function implementsInterface(classType:ClassType, interfaceType:ClassType):Bool
{
for (i in classType.interfaces)
{
if (areClassesEqual(i.t.get(), interfaceType))
{
return true;
}
}
if (classType.superClass != null)
{
return implementsInterface(classType.superClass.t.get(), interfaceType);
}
return false;
}
#end
} }

View file

@ -0,0 +1,16 @@
package funkin.util.tools;
/**
* A static extension which provides utility functions for Maps.
*
* For example, add `using MapTools` then call `map.values()`.
*
* @see https://haxe.org/manual/lf-static-extension.html
*/
class MapTools
{
public static function values<K, T>(map:Map<K, T>):Array<T>
{
return [for (i in map.iterator()) i];
}
}