From 60c6e5ee29dae494b53ebcf358dc725548578671 Mon Sep 17 00:00:00 2001
From: Eric Myllyoja <ericmyllyoja@gmail.com>
Date: Mon, 14 Mar 2022 20:48:45 -0400
Subject: [PATCH] Various bug fixes for strumlines

---
 source/funkin/MusicBeatState.hx               |   5 -
 source/funkin/modding/IHook.hx                |   5 +-
 source/funkin/modding/IScriptedClass.hx       |  19 +-
 source/funkin/modding/events/ScriptEvent.hx   |  28 ++
 .../modding/events/ScriptEventDispatcher.hx   |  13 +-
 source/funkin/modding/module/Module.hx        |  20 +-
 source/funkin/modding/module/ModuleHandler.hx |  12 +
 source/funkin/play/Countdown.hx               |   2 -
 source/funkin/play/PlayState.hx               | 124 ++++----
 source/funkin/play/Strumline.hx               | 274 +++++++++---------
 source/funkin/play/VanillaCutscenes.hx        |   3 +-
 source/funkin/play/stage/Stage.hx             |  17 +-
 source/funkin/util/Constants.hx               |   9 +
 source/funkin/util/macro/GitCommit.hx         |  35 +++
 source/funkin/util/macro/HookableMacro.hx     |  69 ++++-
 15 files changed, 410 insertions(+), 225 deletions(-)
 create mode 100644 source/funkin/util/macro/GitCommit.hx

diff --git a/source/funkin/MusicBeatState.hx b/source/funkin/MusicBeatState.hx
index 9b961f176..2dce0272b 100644
--- a/source/funkin/MusicBeatState.hx
+++ b/source/funkin/MusicBeatState.hx
@@ -2,16 +2,11 @@ package funkin;
 
 import flixel.util.FlxColor;
 import flixel.text.FlxText;
-import cpp.abi.Abi;
 import funkin.modding.events.ScriptEvent;
 import funkin.modding.module.ModuleHandler;
 import funkin.modding.events.ScriptEvent.UpdateScriptEvent;
 import funkin.Conductor.BPMChangeEvent;
-import flixel.FlxGame;
-import flixel.addons.transition.FlxTransitionableState;
 import flixel.addons.ui.FlxUIState;
-import flixel.math.FlxRect;
-import flixel.util.FlxTimer;
 
 class MusicBeatState extends FlxUIState
 {
diff --git a/source/funkin/modding/IHook.hx b/source/funkin/modding/IHook.hx
index d9bc298b7..66a07ec52 100644
--- a/source/funkin/modding/IHook.hx
+++ b/source/funkin/modding/IHook.hx
@@ -5,10 +5,13 @@ import polymod.hscript.HScriptable;
 /**
  * Functions annotated with @:hscript will call the relevant script.
  * Functions annotated with @:hookable can be reassigned.
+ *   NOTE: If you receive the following error when making a function use @:hookable:
+ *   `Cannot access this or other member field in variable initialization`
+ *   This is because you need to perform calls and assignments using a static variable referencing the target object.
  */
 @:hscript({
 	// ALL of these values are added to ALL scripts in the child classes.
 	context: [FlxG, FlxSprite, Math, Paths, Std]
 })
-// @:autoBuild(funkin.util.macro.HookableMacro.build())
+@:autoBuild(funkin.util.macro.HookableMacro.build())
 interface IHook extends HScriptable {}
diff --git a/source/funkin/modding/IScriptedClass.hx b/source/funkin/modding/IScriptedClass.hx
index 9452d04db..9691c1417 100644
--- a/source/funkin/modding/IScriptedClass.hx
+++ b/source/funkin/modding/IScriptedClass.hx
@@ -17,15 +17,24 @@ interface IScriptedClass
 }
 
 /**
- * Defines a set of callbacks available to scripted classes that involve player input.
+ * Defines a set of callbacks available to scripted classes which can follow the game between states.
  */
-interface IInputScriptedClass extends IScriptedClass
+interface IStateChangingScriptedClass extends IScriptedClass
 {
-	public function onKeyDown(event:KeyboardInputScriptEvent):Void;
-	public function onKeyUp(event:KeyboardInputScriptEvent):Void;
-	// TODO: OnMouseDown, OnMouseUp, OnMouseMove
+	public function onStateChangeBegin(event:StateChangeScriptEvent):Void;
+	public function onStateChangeEnd(event:StateChangeScriptEvent):Void;
 }
 
+/**
+ * Developer note:
+ * 
+ * I previously considered adding events for onKeyDown, onKeyUp, mouse events, etc.
+ * However, I realized that you can simply call something like the following within a module:
+ * `FlxG.stage.addEventListener(KeyboardEvent.KEY_DOWN, onKeyDown);`
+ * This is more efficient than adding an entire event handler for every key press.
+ *
+ * -Eric
+ */
 /**
  * Defines a set of callbacks available to scripted classes that involve the lifecycle of the Play State.
  */
diff --git a/source/funkin/modding/events/ScriptEvent.hx b/source/funkin/modding/events/ScriptEvent.hx
index efc922ed5..ce4c08743 100644
--- a/source/funkin/modding/events/ScriptEvent.hx
+++ b/source/funkin/modding/events/ScriptEvent.hx
@@ -165,6 +165,18 @@ class ScriptEvent
 	 */
 	public static inline final SONG_LOADED:ScriptEventType = "SONG_LOADED";
 
+	/**
+	 * Called when the game is entering the current FlxState.
+	 * 
+	 * This event is not cancelable.
+	 */
+	public static inline final STATE_ENTER:ScriptEventType = "STATE_ENTER";
+
+	/**
+	 * Called when the game is exiting the current FlxState.
+	 * 
+	 * This event is not cancelable.
+	 */
 	/**
 	 * If true, the behavior associated with this event can be prevented.
 	 * For example, cancelling COUNTDOWN_BEGIN should prevent the countdown from starting,
@@ -385,3 +397,19 @@ class SongLoadScriptEvent extends ScriptEvent
 		return 'SongLoadScriptEvent(notes=$noteStr, id=$id, difficulty=$difficulty)';
 	}
 }
+
+/**
+ * An event that is fired when moving out of or into an FlxState.
+ */
+class StateChangeScriptEvent extends ScriptEvent
+{
+	public function new(type:ScriptEventType):Void
+	{
+		super(type, false);
+	}
+
+	public override function toString():String
+	{
+		return 'StateChangeScriptEvent(type=' + type + ')';
+	}
+}
diff --git a/source/funkin/modding/events/ScriptEventDispatcher.hx b/source/funkin/modding/events/ScriptEventDispatcher.hx
index c3df66170..ecb97a846 100644
--- a/source/funkin/modding/events/ScriptEventDispatcher.hx
+++ b/source/funkin/modding/events/ScriptEventDispatcher.hx
@@ -1,7 +1,6 @@
 package funkin.modding.events;
 
 import funkin.modding.IScriptedClass;
-import funkin.modding.IScriptedClass.IInputScriptedClass;
 import funkin.modding.IScriptedClass.IPlayStateScriptedClass;
 
 /**
@@ -36,16 +35,14 @@ class ScriptEventDispatcher
 				return;
 		}
 
-		if (Std.isOfType(target, IInputScriptedClass))
+		if (Std.isOfType(target, IStateChangingScriptedClass))
 		{
-			var t = cast(target, IInputScriptedClass);
+			var t = cast(target, IStateChangingScriptedClass);
+			var t = cast(target, IPlayStateScriptedClass);
 			switch (event.type)
 			{
-				case ScriptEvent.KEY_DOWN:
-					t.onKeyDown(cast event);
-					return;
-				case ScriptEvent.KEY_UP:
-					t.onKeyUp(cast event);
+				case ScriptEvent.NOTE_HIT:
+					t.onNoteHit(cast event);
 					return;
 			}
 		}
diff --git a/source/funkin/modding/module/Module.hx b/source/funkin/modding/module/Module.hx
index 87b933ccc..3f499c5e1 100644
--- a/source/funkin/modding/module/Module.hx
+++ b/source/funkin/modding/module/Module.hx
@@ -7,13 +7,13 @@ import funkin.modding.events.ScriptEvent.NoteScriptEvent;
 import funkin.modding.events.ScriptEvent.SongTimeScriptEvent;
 import funkin.modding.events.ScriptEvent.CountdownScriptEvent;
 import funkin.modding.IScriptedClass.IPlayStateScriptedClass;
-import funkin.modding.IScriptedClass.IInputScriptedClass;
+import funkin.modding.IScriptedClass.IStateChangingScriptedClass;
 
 /**
  * A module is a scripted class which receives all events without requiring a specific context.
  * You may have the module active at all times, or only when another script enables it.
  */
-class Module implements IInputScriptedClass implements IPlayStateScriptedClass
+class Module implements IPlayStateScriptedClass implements IStateChangingScriptedClass
 {
 	/**
 	 * Whether the module is currently active.
@@ -68,16 +68,20 @@ class Module implements IInputScriptedClass implements IPlayStateScriptedClass
 
 	public function onScriptEvent(event:ScriptEvent) {}
 
+	/**
+	 * Called when the module is first created.
+	 * This happens before the title screen appears!
+	 */
 	public function onCreate(event:ScriptEvent) {}
 
+	/**
+	 * Called when a module is destroyed.
+	 * This currently only happens when reloading modules with F5.
+	 */
 	public function onDestroy(event:ScriptEvent) {}
 
 	public function onUpdate(event:UpdateScriptEvent) {}
 
-	public function onKeyDown(event:KeyboardInputScriptEvent) {}
-
-	public function onKeyUp(event:KeyboardInputScriptEvent) {}
-
 	public function onPause(event:ScriptEvent) {}
 
 	public function onResume(event:ScriptEvent) {}
@@ -107,4 +111,8 @@ class Module implements IInputScriptedClass implements IPlayStateScriptedClass
 	public function onCountdownEnd(event:CountdownScriptEvent) {}
 
 	public function onSongLoaded(eent:SongLoadScriptEvent) {}
+
+	public function onStateChangeBegin(event:StateChangeScriptEvent) {}
+
+	public function onStateChangeEnd(event:StateChangeScriptEvent) {}
 }
diff --git a/source/funkin/modding/module/ModuleHandler.hx b/source/funkin/modding/module/ModuleHandler.hx
index d5a296638..908b5c428 100644
--- a/source/funkin/modding/module/ModuleHandler.hx
+++ b/source/funkin/modding/module/ModuleHandler.hx
@@ -96,10 +96,22 @@ class ModuleHandler
 		}
 	}
 
+	/**
+	 * Clear the module cache, forcing all modules to call shutdown events.
+	 */
 	public static function clearModuleCache():Void
 	{
 		if (moduleCache != null)
 		{
+			var event = new ScriptEvent(ScriptEvent.DESTROY, false);
+
+			// Note: Ignore stopPropagation()
+			for (key => value in moduleCache)
+			{
+				ScriptEventDispatcher.callEvent(value, event);
+				moduleCache.remove(key);
+			}
+
 			moduleCache.clear();
 			modulePriorityOrder = [];
 		}
diff --git a/source/funkin/play/Countdown.hx b/source/funkin/play/Countdown.hx
index d6770b11a..8413110ca 100644
--- a/source/funkin/play/Countdown.hx
+++ b/source/funkin/play/Countdown.hx
@@ -4,8 +4,6 @@ import funkin.util.Constants;
 import flixel.tweens.FlxEase;
 import flixel.tweens.FlxTween;
 import flixel.FlxSprite;
-import flixel.input.actions.FlxAction.FlxActionAnalog;
-import cpp.abi.Abi;
 import funkin.modding.events.ScriptEventDispatcher;
 import funkin.modding.module.ModuleHandler;
 import funkin.modding.events.ScriptEvent;
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 3925288fc..61ede4246 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -1,6 +1,6 @@
 package funkin.play;
 
-import funkin.play.Strumline.StrumlineStyle;
+import funkin.play.Strumline.StrumlineArrow;
 import flixel.addons.effects.FlxTrail;
 import flixel.addons.transition.FlxTransitionableState;
 import flixel.FlxCamera;
@@ -24,12 +24,13 @@ import funkin.modding.events.ScriptEvent;
 import funkin.modding.events.ScriptEvent.SongTimeScriptEvent;
 import funkin.modding.events.ScriptEvent.UpdateScriptEvent;
 import funkin.modding.events.ScriptEventDispatcher;
+import funkin.modding.IHook;
 import funkin.modding.module.ModuleHandler;
 import funkin.Note;
 import funkin.play.stage.Stage;
 import funkin.play.stage.StageData;
+import funkin.play.Strumline.StrumlineStyle;
 import funkin.Section.SwagSection;
-import funkin.shaderslmfao.ColorSwap;
 import funkin.SongLoad.SwagSong;
 import funkin.ui.PopUpStuff;
 import funkin.ui.PreferencesMenu;
@@ -43,7 +44,7 @@ using StringTools;
 import Discord.DiscordClient;
 #end
 
-class PlayState extends MusicBeatState
+class PlayState extends MusicBeatState implements IHook
 {
 	/**
 	 * STATIC VARIABLES
@@ -139,11 +140,6 @@ class PlayState extends MusicBeatState
 	 */
 	private var inactiveNotes:Array<Note>;
 
-	/**
-	 * An object which the strumline (and its notes) are positioned relative to.
-	 */
-	private var strumlineAnchor:FlxObject;
-
 	/**
 	 * If true, the player is allowed to pause the game.
 	 * Disabled during the ending of a song.
@@ -231,7 +227,6 @@ class PlayState extends MusicBeatState
 	private var vocals:VoicesGroup;
 	private var vocalsFinished:Bool = false;
 
-	private var playerStrums:FlxTypedGroup<FlxSprite>;
 	private var camZooming:Bool = false;
 	private var gfSpeed:Int = 1;
 	private var combo:Int = 0;
@@ -331,8 +326,6 @@ class PlayState extends MusicBeatState
 
 		add(grpNoteSplashes);
 
-		playerStrums = new FlxTypedGroup<FlxSprite>();
-
 		generateSong();
 
 		cameraFollowPoint = new FlxObject(0, 0, 1, 1);
@@ -407,6 +400,10 @@ class PlayState extends MusicBeatState
 				case 'guns':
 					VanillaCutscenes.playGunsCutscene();
 				default:
+					// VanillaCutscenes will call startCountdown later.
+					// TODO: Alternatively: make a song script that allows startCountdown to be called,
+					// then cancels the countdown, hides the strumline, plays the cutscene,
+					// then calls Countdown.performCountdown()
 					startCountdown();
 			}
 		}
@@ -415,8 +412,9 @@ class PlayState extends MusicBeatState
 			startCountdown();
 		}
 
-		// this.leftWatermarkText.text = '${currentSong.song.toUpperCase()} - ${SongLoad.curDiff.toUpperCase()}';
+		#if debug
 		this.rightWatermarkText.text = Constants.VERSION;
+		#end
 	}
 
 	/**
@@ -936,6 +934,7 @@ class PlayState extends MusicBeatState
 		super.update(elapsed);
 
 		updateHealthBar();
+		updateScoreText();
 
 		if (needsReset)
 		{
@@ -1173,7 +1172,7 @@ class PlayState extends MusicBeatState
 					daNote.active = true;
 				}
 
-				var strumLineMid = playerStrumline.offset.y + Note.swagWidth / 2;
+				var strumLineMid = playerStrumline.y + Note.swagWidth / 2;
 
 				if (daNote.followsTime)
 					daNote.y = (Conductor.songPosition - daNote.data.strumTime) * (0.45 * FlxMath.roundDecimal(SongLoad.getSpeed(),
@@ -1181,7 +1180,7 @@ class PlayState extends MusicBeatState
 
 				if (PreferencesMenu.getPref('downscroll'))
 				{
-					daNote.y += playerStrumline.offset.y;
+					daNote.y += playerStrumline.y;
 					if (daNote.isSustainNote)
 					{
 						if (daNote.animation.curAnim.name.endsWith("end") && daNote.prevNote != null)
@@ -1199,7 +1198,7 @@ class PlayState extends MusicBeatState
 				else
 				{
 					if (daNote.followsTime)
-						daNote.y = playerStrumline.offset.y - daNote.y;
+						daNote.y = playerStrumline.y - daNote.y;
 					if (daNote.isSustainNote
 						&& (!daNote.mustPress || (daNote.wasGoodHit || (daNote.prevNote.wasGoodHit && !daNote.canBeHit)))
 						&& daNote.y + daNote.offset.y * daNote.scale.y <= strumLineMid)
@@ -1284,7 +1283,7 @@ class PlayState extends MusicBeatState
 		}
 
 		if (!isInCutscene)
-			keyShit();
+			keyShit(true);
 
 		dispatchEvent(new UpdateScriptEvent(elapsed));
 	}
@@ -1293,7 +1292,7 @@ class PlayState extends MusicBeatState
 	{
 		// clipRect is applied to graphic itself so use frame Heights
 		var swagRect:FlxRect = new FlxRect(0, 0, daNote.frameWidth, daNote.frameHeight);
-		var strumLineMid = playerStrumline.offset.y + Note.swagWidth / 2;
+		var strumLineMid = playerStrumline.y + Note.swagWidth / 2;
 
 		if (PreferencesMenu.getPref('downscroll'))
 		{
@@ -1549,7 +1548,14 @@ class PlayState extends MusicBeatState
 		}
 	}
 
-	private function keyShit():Void
+	public var test:(PlayState) -> Void = function(instance:PlayState)
+	{
+		trace('test');
+		trace(instance.currentStageId);
+	};
+
+	@:hookable
+	public function keyShit(test:Bool):Void
 	{
 		// control arrays, order L D R U
 		var holdArray:Array<Bool> = [controls.NOTE_LEFT, controls.NOTE_DOWN, controls.NOTE_UP, controls.NOTE_RIGHT];
@@ -1566,27 +1572,27 @@ class PlayState extends MusicBeatState
 			controls.NOTE_RIGHT_R
 		];
 		// HOLDS, check for sustain notes
-		if (holdArray.contains(true) && generatedMusic)
+		if (holdArray.contains(true) && PlayState.instance.generatedMusic)
 		{
-			activeNotes.forEachAlive(function(daNote:Note)
+			PlayState.instance.activeNotes.forEachAlive(function(daNote:Note)
 			{
 				if (daNote.isSustainNote && daNote.canBeHit && daNote.mustPress && holdArray[daNote.data.noteData])
-					goodNoteHit(daNote);
+					PlayState.instance.goodNoteHit(daNote);
 			});
 		}
 
 		// PRESSES, check for note hits
-		if (pressArray.contains(true) && generatedMusic)
+		if (pressArray.contains(true) && PlayState.instance.generatedMusic)
 		{
 			Haptic.vibrate(100, 100);
 
-			currentStage.getBoyfriend().holdTimer = 0;
+			PlayState.instance.currentStage.getBoyfriend().holdTimer = 0;
 
 			var possibleNotes:Array<Note> = []; // notes that can be hit
 			var directionList:Array<Int> = []; // directions that can be hit
 			var dumbNotes:Array<Note> = []; // notes to kill later
 
-			activeNotes.forEachAlive(function(daNote:Note)
+			PlayState.instance.activeNotes.forEachAlive(function(daNote:Note)
 			{
 				if (daNote.canBeHit && daNote.mustPress && !daNote.tooLate && !daNote.wasGoodHit)
 				{
@@ -1621,63 +1627,60 @@ class PlayState extends MusicBeatState
 			{
 				FlxG.log.add("killing dumb ass note at " + note.data.strumTime);
 				note.kill();
-				activeNotes.remove(note, true);
+				PlayState.instance.activeNotes.remove(note, true);
 				note.destroy();
 			}
 
 			possibleNotes.sort((a, b) -> Std.int(a.data.strumTime - b.data.strumTime));
 
-			if (perfectMode)
-				goodNoteHit(possibleNotes[0]);
+			if (PlayState.instance.perfectMode)
+				PlayState.instance.goodNoteHit(possibleNotes[0]);
 			else if (possibleNotes.length > 0)
 			{
 				for (shit in 0...pressArray.length)
 				{ // if a direction is hit that shouldn't be
 					if (pressArray[shit] && !directionList.contains(shit))
-						noteMiss(shit);
+						PlayState.instance.noteMiss(shit);
 				}
 				for (coolNote in possibleNotes)
 				{
 					if (pressArray[coolNote.data.noteData])
-						goodNoteHit(coolNote);
+						PlayState.instance.goodNoteHit(coolNote);
 				}
 			}
 			else
 			{
 				for (shit in 0...pressArray.length)
 					if (pressArray[shit])
-						noteMiss(shit);
+						PlayState.instance.noteMiss(shit);
 			}
 		}
 
-		if (currentStage == null)
+		if (PlayState.instance.currentStage == null)
 			return;
-		if (currentStage.getBoyfriend().holdTimer > Conductor.stepCrochet * 4 * 0.001 && !holdArray.contains(true))
+		if (PlayState.instance.currentStage.getBoyfriend().holdTimer > Conductor.stepCrochet * 4 * 0.001 && !holdArray.contains(true))
 		{
-			if (currentStage.getBoyfriend().animation != null
-				&& currentStage.getBoyfriend().animation.curAnim.name.startsWith('sing')
-				&& !currentStage.getBoyfriend().animation.curAnim.name.endsWith('miss'))
+			if (PlayState.instance.currentStage.getBoyfriend().animation != null
+				&& PlayState.instance.currentStage.getBoyfriend().animation.curAnim.name.startsWith('sing')
+				&& !PlayState.instance.currentStage.getBoyfriend().animation.curAnim.name.endsWith('miss'))
 			{
-				currentStage.getBoyfriend().playAnim('idle');
+				PlayState.instance.currentStage.getBoyfriend().playAnim('idle');
 			}
 		}
 
-		playerStrums.forEach(function(spr:FlxSprite)
+		for (keyId => isPressed in pressArray)
 		{
-			if (pressArray[spr.ID] && spr.animation.curAnim.name != 'confirm')
-				spr.animation.play('pressed');
-			if (!holdArray[spr.ID])
-				spr.animation.play('static');
+			var arrow:StrumlineArrow = PlayState.instance.playerStrumline.getArrow(keyId);
 
-			if (spr.animation.curAnim.name == 'confirm' && !currentStageId.startsWith('school'))
+			if (isPressed && arrow.animation.curAnim.name != 'confirm')
 			{
-				spr.centerOffsets();
-				spr.offset.x -= 13;
-				spr.offset.y -= 13;
+				arrow.playAnimation('pressed');
 			}
-			else
-				spr.centerOffsets();
-		});
+			if (!holdArray[keyId])
+			{
+				arrow.playAnimation('static');
+			}
+		}
 	}
 
 	function noteMiss(direction:NoteDir = 1):Void
@@ -1707,13 +1710,7 @@ class PlayState extends MusicBeatState
 
 			currentStage.getBoyfriend().playAnim('sing' + note.dirNameUpper, true);
 
-			playerStrums.forEach(function(spr:FlxSprite)
-			{
-				if (Math.abs(note.data.noteData) == spr.ID)
-				{
-					spr.animation.play('confirm', true);
-				}
-			});
+			playerStrumline.getArrow(note.data.noteData).playAnimation('confirm', true);
 
 			note.wasGoodHit = true;
 			vocals.volume = 1;
@@ -1848,19 +1845,31 @@ class PlayState extends MusicBeatState
 		var strumlineYPos = Strumline.getYPos();
 
 		playerStrumline = new Strumline(0, strumlineStyle, 4);
-		playerStrumline.offset = new FlxPoint(50 + FlxG.width / 2, strumlineYPos);
+		playerStrumline.x = 50 + FlxG.width / 2;
+		playerStrumline.y = strumlineYPos;
 		// Set the z-index so they don't appear in front of notes.
 		playerStrumline.zIndex = 100;
 		add(playerStrumline);
 		playerStrumline.cameras = [camHUD];
 
+		if (!isStoryMode)
+		{
+			playerStrumline.fadeInArrows();
+		}
+
 		enemyStrumline = new Strumline(1, strumlineStyle, 4);
-		enemyStrumline.offset = new FlxPoint(50, strumlineYPos);
+		enemyStrumline.x = 50;
+		enemyStrumline.y = strumlineYPos;
 		// Set the z-index so they don't appear in front of notes.
 		enemyStrumline.zIndex = 100;
 		add(enemyStrumline);
 		enemyStrumline.cameras = [camHUD];
 
+		if (!isStoryMode)
+		{
+			enemyStrumline.fadeInArrows();
+		}
+
 		this.refresh();
 	}
 
@@ -1997,6 +2006,7 @@ class PlayState extends MusicBeatState
 		{
 			remove(currentStage);
 			currentStage.kill();
+			dispatchEvent(new ScriptEvent(ScriptEvent.DESTROY, false));
 			currentStage = null;
 		}
 
diff --git a/source/funkin/play/Strumline.hx b/source/funkin/play/Strumline.hx
index 4254acb28..1bdbde374 100644
--- a/source/funkin/play/Strumline.hx
+++ b/source/funkin/play/Strumline.hx
@@ -1,30 +1,23 @@
 package funkin.play;
 
-import funkin.ui.PreferencesMenu;
+import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup;
+import flixel.FlxSprite;
+import flixel.math.FlxPoint;
+import flixel.tweens.FlxEase;
+import flixel.tweens.FlxTween;
 import funkin.Note.NoteColor;
 import funkin.Note.NoteDir;
 import funkin.Note.NoteType;
-import flixel.tweens.FlxTween;
-import flixel.tweens.FlxEase;
+import funkin.ui.PreferencesMenu;
 import funkin.util.Constants;
-import flixel.FlxSprite;
-import flixel.math.FlxPoint;
-import flixel.group.FlxGroup.FlxTypedGroup;
 
 /**
  * A group controlling the individual notes of the strumline for a given player.
+ * 
+ * FUN FACT: Setting the X and Y of a FlxSpriteGroup will move all the sprites in the group.
  */
-class Strumline extends FlxTypedGroup<FlxSprite>
+class Strumline extends FlxTypedSpriteGroup<StrumlineArrow>
 {
-	public var offset(default, set):FlxPoint = new FlxPoint(0, 0);
-
-	function set_offset(value:FlxPoint):FlxPoint
-	{
-		this.offset = value;
-		updatePositions();
-		return value;
-	}
-
 	/**
 	 * The style of the strumline.
 	 * Options are normal and pixel.
@@ -62,132 +55,30 @@ class Strumline extends FlxTypedGroup<FlxSprite>
 
 	function createStrumlineArrow(index:Int):Void
 	{
-		var arrow:FlxSprite = new FlxSprite(0, 0);
-
-		arrow.ID = index;
-
-		// Color changing for arrows is a WIP.
-		/*
-			var colorSwapShader:ColorSwap = new ColorSwap();
-			colorSwapShader.update(Note.arrowColors[i]);
-			arrow.shader = colorSwapShader;
-		 */
-
-		switch (style)
-		{
-			case NORMAL:
-				createNormalNote(arrow);
-			case PIXEL:
-				createPixelNote(arrow);
-		}
-
-		arrow.updateHitbox();
-		arrow.scrollFactor.set();
-
-		arrow.animation.play('static');
-
-		applyFadeIn(arrow);
-
+		var arrow:StrumlineArrow = new StrumlineArrow(index, style);
 		add(arrow);
 	}
 
 	/**
 	 * Apply a small animation which moves the arrow down and fades it in.
-	 * Only plays at the start of Free Play songs I guess?
+	 * Only plays at the start of Free Play songs.
+	 * 
+	 * Note that modifying the offset of the whole strumline won't have the 
 	 * @param arrow The arrow to animate.
 	 * @param index The index of the arrow in the strumline.
 	 */
-	function applyFadeIn(arrow:FlxSprite):Void
+	function fadeInArrow(arrow:FlxSprite):Void
 	{
-		if (!PlayState.isStoryMode)
-		{
-			arrow.y -= 10;
-			arrow.alpha = 0;
-			FlxTween.tween(arrow, {y: arrow.y + 10, alpha: 1}, 1, {ease: FlxEase.circOut, startDelay: 0.5 + (0.2 * arrow.ID)});
-		}
+		arrow.y -= 10;
+		arrow.alpha = 0;
+		FlxTween.tween(arrow, {y: arrow.y + 10, alpha: 1}, 1, {ease: FlxEase.circOut, startDelay: 0.5 + (0.2 * arrow.ID)});
 	}
 
-	/**
-	 * Applies the default note style to an arrow.
-	 * @param arrow The arrow to apply the style to.
-	 * @param index The index of the arrow in the strumline.
-	 */
-	function createNormalNote(arrow:FlxSprite):Void
+	public function fadeInArrows():Void
 	{
-		arrow.frames = Paths.getSparrowAtlas('NOTE_assets');
-
-		arrow.animation.addByPrefix('green', 'arrowUP');
-		arrow.animation.addByPrefix('blue', 'arrowDOWN');
-		arrow.animation.addByPrefix('purple', 'arrowLEFT');
-		arrow.animation.addByPrefix('red', 'arrowRIGHT');
-
-		arrow.setGraphicSize(Std.int(arrow.width * 0.7));
-		arrow.antialiasing = true;
-
-		arrow.x += Note.swagWidth * arrow.ID;
-
-		switch (Math.abs(arrow.ID))
+		for (arrow in this.members)
 		{
-			case 0:
-				arrow.animation.addByPrefix('static', 'arrow static instance 1');
-				arrow.animation.addByPrefix('pressed', 'left press', 24, false);
-				arrow.animation.addByPrefix('confirm', 'left confirm', 24, false);
-			case 1:
-				arrow.animation.addByPrefix('static', 'arrow static instance 2');
-				arrow.animation.addByPrefix('pressed', 'down press', 24, false);
-				arrow.animation.addByPrefix('confirm', 'down confirm', 24, false);
-			case 2:
-				arrow.animation.addByPrefix('static', 'arrow static instance 4');
-				arrow.animation.addByPrefix('pressed', 'up press', 24, false);
-				arrow.animation.addByPrefix('confirm', 'up confirm', 24, false);
-			case 3:
-				arrow.animation.addByPrefix('static', 'arrow static instance 3');
-				arrow.animation.addByPrefix('pressed', 'right press', 24, false);
-				arrow.animation.addByPrefix('confirm', 'right confirm', 24, false);
-		}
-	}
-
-	/**
-	 * Applies the pixel note style to an arrow.
-	 * @param arrow The arrow to apply the style to.
-	 * @param index The index of the arrow in the strumline.
-	 */
-	function createPixelNote(arrow:FlxSprite):Void
-	{
-		arrow.loadGraphic(Paths.image('weeb/pixelUI/arrows-pixels'), true, 17, 17);
-
-		arrow.animation.add('purplel', [4]);
-		arrow.animation.add('blue', [5]);
-		arrow.animation.add('green', [6]);
-		arrow.animation.add('red', [7]);
-
-		arrow.setGraphicSize(Std.int(arrow.width * Constants.PIXEL_ART_SCALE));
-		arrow.updateHitbox();
-
-		// Forcibly disable anti-aliasing on pixel graphics to stop blur.
-		arrow.antialiasing = false;
-
-		arrow.x += Note.swagWidth * arrow.ID;
-
-		// TODO: Seems weird that these are hardcoded like this... no XML?
-		switch (Math.abs(arrow.ID))
-		{
-			case 0:
-				arrow.animation.add('static', [0]);
-				arrow.animation.add('pressed', [4, 8], 12, false);
-				arrow.animation.add('confirm', [12, 16], 24, false);
-			case 1:
-				arrow.animation.add('static', [1]);
-				arrow.animation.add('pressed', [5, 9], 12, false);
-				arrow.animation.add('confirm', [13, 17], 24, false);
-			case 2:
-				arrow.animation.add('static', [2]);
-				arrow.animation.add('pressed', [6, 10], 12, false);
-				arrow.animation.add('confirm', [14, 18], 12, false);
-			case 3:
-				arrow.animation.add('static', [3]);
-				arrow.animation.add('pressed', [7, 11], 12, false);
-				arrow.animation.add('confirm', [15, 19], 24, false);
+			fadeInArrow(arrow);
 		}
 	}
 
@@ -208,33 +99,150 @@ class Strumline extends FlxTypedGroup<FlxSprite>
 	 * @param index The index to retrieve.
 	 * @return The corresponding FlxSprite.
 	 */
-	public inline function getArrow(value:Int):FlxSprite
+	public inline function getArrow(value:Int):StrumlineArrow
 	{
 		// members maintains the order that the arrows were added.
 		return this.members[value];
 	}
 
-	public inline function getArrowByNoteType(value:NoteType):FlxSprite
+	public inline function getArrowByNoteType(value:NoteType):StrumlineArrow
 	{
 		return getArrow(value.int);
 	}
 
-	public inline function getArrowByNoteDir(value:NoteDir):FlxSprite
+	public inline function getArrowByNoteDir(value:NoteDir):StrumlineArrow
 	{
 		return getArrow(value.int);
 	}
 
-	public inline function getArrowByNoteColor(value:NoteColor):FlxSprite
+	public inline function getArrowByNoteColor(value:NoteColor):StrumlineArrow
 	{
 		return getArrow(value.int);
 	}
 
+	/**
+	 * Get the default Y offset of the strumline.
+	 * @return Int
+	 */
 	public static inline function getYPos():Int
 	{
 		return PreferencesMenu.getPref('downscroll') ? (FlxG.height - 150) : 50;
 	}
 }
 
+class StrumlineArrow extends FlxSprite
+{
+	var style:StrumlineStyle;
+
+	public function new(id:Int, style:StrumlineStyle)
+	{
+		super(0, 0);
+
+		this.ID = id;
+		this.style = style;
+
+		// TODO: Unhardcode this. Maybe use a note style system>
+		switch (style)
+		{
+			case PIXEL:
+				buildPixelGraphic();
+			case NORMAL:
+				buildNormalGraphic();
+		}
+
+		this.updateHitbox();
+		scrollFactor.set(0, 0);
+		animation.play('static');
+	}
+
+	public function playAnimation(anim:String, force:Bool = false)
+	{
+		animation.play(anim, force);
+		centerOffsets();
+		centerOrigin();
+	}
+
+	/**
+	 * Applies the default note style to an arrow.
+	 */
+	function buildNormalGraphic():Void
+	{
+		this.frames = Paths.getSparrowAtlas('NOTE_assets');
+
+		this.animation.addByPrefix('green', 'arrowUP');
+		this.animation.addByPrefix('blue', 'arrowDOWN');
+		this.animation.addByPrefix('purple', 'arrowLEFT');
+		this.animation.addByPrefix('red', 'arrowRIGHT');
+
+		this.setGraphicSize(Std.int(this.width * 0.7));
+		this.antialiasing = true;
+
+		this.x += Note.swagWidth * this.ID;
+
+		switch (Math.abs(this.ID))
+		{
+			case 0:
+				this.animation.addByPrefix('static', 'arrow static instance 1');
+				this.animation.addByPrefix('pressed', 'left press', 24, false);
+				this.animation.addByPrefix('confirm', 'left confirm', 24, false);
+			case 1:
+				this.animation.addByPrefix('static', 'arrow static instance 2');
+				this.animation.addByPrefix('pressed', 'down press', 24, false);
+				this.animation.addByPrefix('confirm', 'down confirm', 24, false);
+			case 2:
+				this.animation.addByPrefix('static', 'arrow static instance 4');
+				this.animation.addByPrefix('pressed', 'up press', 24, false);
+				this.animation.addByPrefix('confirm', 'up confirm', 24, false);
+			case 3:
+				this.animation.addByPrefix('static', 'arrow static instance 3');
+				this.animation.addByPrefix('pressed', 'right press', 24, false);
+				this.animation.addByPrefix('confirm', 'right confirm', 24, false);
+		}
+	}
+
+	/**
+	 * Applies the pixel note style to an arrow.
+	 */
+	function buildPixelGraphic():Void
+	{
+		this.loadGraphic(Paths.image('weeb/pixelUI/arrows-pixels'), true, 17, 17);
+
+		this.animation.add('purplel', [4]);
+		this.animation.add('blue', [5]);
+		this.animation.add('green', [6]);
+		this.animation.add('red', [7]);
+
+		this.setGraphicSize(Std.int(this.width * Constants.PIXEL_ART_SCALE));
+		this.updateHitbox();
+
+		// Forcibly disable anti-aliasing on pixel graphics to stop blur.
+		this.antialiasing = false;
+
+		this.x += Note.swagWidth * this.ID;
+
+		// TODO: Seems weird that these are hardcoded like this... no XML?
+		switch (Math.abs(this.ID))
+		{
+			case 0:
+				this.animation.add('static', [0]);
+				this.animation.add('pressed', [4, 8], 12, false);
+				this.animation.add('confirm', [12, 16], 24, false);
+			case 1:
+				this.animation.add('static', [1]);
+				this.animation.add('pressed', [5, 9], 12, false);
+				this.animation.add('confirm', [13, 17], 24, false);
+			case 2:
+				this.animation.add('static', [2]);
+				this.animation.add('pressed', [6, 10], 12, false);
+				this.animation.add('confirm', [14, 18], 12, false);
+			case 3:
+				this.animation.add('static', [3]);
+				this.animation.add('pressed', [7, 11], 12, false);
+				this.animation.add('confirm', [15, 19], 24, false);
+		}
+	}
+}
+
 /**
  * TODO: Unhardcode this and make it part of the note style system.
  */
diff --git a/source/funkin/play/VanillaCutscenes.hx b/source/funkin/play/VanillaCutscenes.hx
index 2d3d059a6..a67ff8773 100644
--- a/source/funkin/play/VanillaCutscenes.hx
+++ b/source/funkin/play/VanillaCutscenes.hx
@@ -61,7 +61,8 @@ class VanillaCutscenes
 		blackScreen = null;
 
 		FlxTween.tween(FlxG.camera, {zoom: PlayState.defaultCameraZoom}, (Conductor.crochet / 1000) * 5, {ease: FlxEase.quadInOut});
-		Countdown.performCountdown(false);
+		@:privateAccess
+		PlayState.instance.startCountdown();
 		@:privateAccess
 		PlayState.instance.cameraMovement();
 	}
diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx
index 6bbc48282..a17f340d4 100644
--- a/source/funkin/play/stage/Stage.hx
+++ b/source/funkin/play/stage/Stage.hx
@@ -19,7 +19,7 @@ import funkin.util.SortUtil;
  * 
  * A Stage is comprised of one or more props, each of which is a FlxSprite.
  */
-class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScriptedClass implements IInputScriptedClass
+class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScriptedClass
 {
 	public final stageId:String;
 	public final stageName:String;
@@ -312,7 +312,8 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte
 	}
 
 	/**
-	 * Perform cleanup for when you are leaving the level.
+	 * onDestroy gets called when the player is leaving the PlayState,
+	 * and is used to clean up any objects that need to be destroyed.
 	 */
 	public function onDestroy(event:ScriptEvent):Void
 	{
@@ -322,24 +323,32 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte
 
 		for (prop in this.namedProps)
 		{
+			remove(prop);
+			prop.kill();
 			prop.destroy();
 		}
 		namedProps.clear();
 
 		for (char in this.characters)
 		{
+			remove(char);
+			char.kill();
 			char.destroy();
 		}
 		characters.clear();
 
 		for (bopper in boppers)
 		{
+			remove(bopper);
+			bopper.kill();
 			bopper.destroy();
 		}
 		boppers = [];
 
 		for (sprite in this.group)
 		{
+			remove(sprite);
+			sprite.kill();
 			sprite.destroy();
 		}
 		group.clear();
@@ -391,10 +400,6 @@ class Stage extends FlxSpriteGroup implements IHook implements IPlayStateScripte
 
 	public function onCountdownEnd(event:CountdownScriptEvent) {}
 
-	public function onKeyDown(event:KeyboardInputScriptEvent) {}
-
-	public function onKeyUp(event:KeyboardInputScriptEvent) {}
-
 	/**
 	 * A function that should get called every frame.
 	 */
diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx
index f6c3b204f..fb121e144 100644
--- a/source/funkin/util/Constants.hx
+++ b/source/funkin/util/Constants.hx
@@ -18,8 +18,17 @@ class Constants
 	public static final VERSION_SUFFIX = ' PROTOTYPE';
 	public static var VERSION(get, null):String;
 
+	#if debug
+	public static final GIT_HASH = funkin.util.macro.GitCommit.getGitCommitHash();
+
+	static function get_VERSION():String
+	{
+		return 'v${Application.current.meta.get('version')} (${GIT_HASH})' + VERSION_SUFFIX;
+	}
+	#else
 	static function get_VERSION():String
 	{
 		return 'v${Application.current.meta.get('version')}' + VERSION_SUFFIX;
 	}
+	#end
 }
diff --git a/source/funkin/util/macro/GitCommit.hx b/source/funkin/util/macro/GitCommit.hx
new file mode 100644
index 000000000..14c68639a
--- /dev/null
+++ b/source/funkin/util/macro/GitCommit.hx
@@ -0,0 +1,35 @@
+package funkin.util.macro;
+
+#if debug
+class GitCommit
+{
+	public static macro function getGitCommitHash():haxe.macro.Expr.ExprOf<String>
+	{
+		#if !display
+		// Get the current line number.
+		var pos = haxe.macro.Context.currentPos();
+
+		var process = new sys.io.Process('git', ['rev-parse', 'HEAD']);
+		if (process.exitCode() != 0)
+		{
+			var message = process.stderr.readAll().toString();
+			haxe.macro.Context.info('[WARN] Could not determine current git commit; is this a proper Git repository?', pos);
+		}
+
+		// read the output of the process
+		var commitHash:String = process.stdout.readLine();
+		var commitHashSplice:String = commitHash.substr(0, 7);
+
+		trace('Git Commit ID ${commitHashSplice}');
+
+		// Generates a string expression
+		return macro $v{commitHashSplice};
+		#else
+		// `#if display` is used for code completion. In this case returning an
+		// empty string is good enough; We don't want to call git on every hint.
+		var commitHash:String = "";
+		return macro $v{commitHashSplice};
+		#end
+	}
+}
+#end
diff --git a/source/funkin/util/macro/HookableMacro.hx b/source/funkin/util/macro/HookableMacro.hx
index 93d9545af..967a92cb6 100644
--- a/source/funkin/util/macro/HookableMacro.hx
+++ b/source/funkin/util/macro/HookableMacro.hx
@@ -1,3 +1,70 @@
 package funkin.util.macro;
 
-class HookableMacro {}
+import haxe.macro.Context;
+import haxe.macro.Expr;
+
+using Lambda;
+
+class HookableMacro
+{
+	/**
+	 * The @:hookable annotation replaces a given function with a variable that contains a function.
+	 * It's still callable, like normal, but now you can also replace the value! Neat!
+	 * 
+	 * NOTE: If you receive the following error when making a function use @:hookable:
+	 *   `Cannot access this or other member field in variable initialization`
+	 *   This is because you need to perform calls and assignments using a static variable referencing the target object.
+	 */
+	public static macro function build():Array<Field>
+	{
+		Context.info('Running HookableMacro...', Context.currentPos());
+
+		var cls:haxe.macro.Type.ClassType = Context.getLocalClass().get();
+		var fields:Array<Field> = Context.getBuildFields();
+		// Find all fields with @:hookable metadata
+		for (field in fields)
+		{
+			if (field.meta == null)
+				continue;
+			var scriptable_meta = field.meta.find(function(m) return m.name == ':hookable');
+			if (scriptable_meta != null)
+			{
+				Context.info('  @:hookable annotation found on field ${field.name}', Context.currentPos());
+				switch (field.kind)
+				{
+					case FFun(originalFunc):
+						// This is the type of the function, like (Int, Int) -> Int
+						var replFieldTypeRet:ComplexType = originalFunc.ret == null ? Context.toComplexType(Context.getType('Void')) : originalFunc.ret;
+						var replFieldType:ComplexType = TFunction([for (arg in originalFunc.args) arg.type], replFieldTypeRet);
+						// This is the expression of the function, i.e. the function body.
+
+						var replFieldExpr:ExprDef = EFunction(FAnonymous, {
+							ret: originalFunc.ret,
+							params: originalFunc.params,
+							args: originalFunc.args,
+							expr: originalFunc.expr
+						});
+
+						var replField:Field = {
+							name: field.name,
+							doc: field.doc,
+							access: field.access,
+							pos: field.pos,
+							meta: field.meta,
+							kind: FVar(replFieldType, {
+								expr: replFieldExpr,
+								pos: field.pos
+							}),
+						};
+
+						// Replace the original field with the new field
+						fields[fields.indexOf(field)] = replField;
+					default:
+						Context.error('@:hookable can only be used on functions', field.pos);
+				}
+			}
+		}
+
+		return fields;
+	}
+}