From 872f7c93e8e70df55a7ca46681d5e49e91c08062 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 26 May 2023 17:10:08 -0400
Subject: [PATCH] Added onAdd event so GF character can fix position in
 Tutorial

---
 source/funkin/charting/ChartingState.hx       |  10 +-
 source/funkin/modding/IScriptedClass.hx       |  12 ++
 source/funkin/modding/events/ScriptEvent.hx   |   8 ++
 .../modding/events/ScriptEventDispatcher.hx   |  11 ++
 .../play/character/AnimateAtlasCharacter.hx   |   2 +-
 source/funkin/play/character/BaseCharacter.hx |  70 ++++++++----
 source/funkin/play/character/CharacterData.hx |  18 ++-
 .../play/character/MultiSparrowCharacter.hx   |   4 +-
 source/funkin/play/stage/Bopper.hx            |  34 +++---
 source/funkin/play/stage/Stage.hx             | 106 ++++++++++++++++--
 source/funkin/play/stage/StageProp.hx         |  23 +++-
 11 files changed, 240 insertions(+), 58 deletions(-)

diff --git a/source/funkin/charting/ChartingState.hx b/source/funkin/charting/ChartingState.hx
index e8402727f..3cfac53be 100644
--- a/source/funkin/charting/ChartingState.hx
+++ b/source/funkin/charting/ChartingState.hx
@@ -1104,17 +1104,17 @@ class ChartingState extends MusicBeatState
 
       // leftIcon.animation.curAnim.curFrame = p1Muted ? 1 : 0;
       // rightIcon.animation.curAnim.curFrame = p2Muted ? 1 : 0;
-
-      leftIcon.playAnimation(p1Muted ? LOSING : IDLE);
-      rightIcon.playAnimation(p2Muted ? LOSING : IDLE);
+      //
+      // leftIcon.playAnimation(p1Muted ? LOSING : IDLE);
+      // rightIcon.playAnimation(p2Muted ? LOSING : IDLE);
     }
     else
     {
       leftIcon.characterId = (_song.player2);
       rightIcon.characterId = (_song.player1);
 
-      leftIcon.playAnimation(p2Muted ? LOSING : IDLE);
-      rightIcon.playAnimation(p1Muted ? LOSING : IDLE);
+      // leftIcon.playAnimation(p2Muted ? LOSING : IDLE);
+      // rightIcon.playAnimation(p1Muted ? LOSING : IDLE);
 
       // leftIcon.animation.curAnim.curFrame = p2Muted ? 1 : 0;
       // rightIcon.animation.curAnim.curFrame = p1Muted ? 1 : 0;
diff --git a/source/funkin/modding/IScriptedClass.hx b/source/funkin/modding/IScriptedClass.hx
index d6a5e3300..1450f8045 100644
--- a/source/funkin/modding/IScriptedClass.hx
+++ b/source/funkin/modding/IScriptedClass.hx
@@ -30,6 +30,18 @@ interface IStateChangingScriptedClass extends IScriptedClass
   public function onSubstateCloseEnd(event:SubStateScriptEvent):Void;
 }
 
+/**
+ * Defines a set of callbacks available to scripted classes which can be added to the current state.
+ * Generally requires the class to be an instance of FlxBasic.
+ */
+interface IStateStageProp extends IScriptedClass
+{
+  /**
+   * Called when the relevant element is added to the game state.
+   */
+  public function onAdd(event:ScriptEvent):Void;
+}
+
 /**
  * Defines a set of callbacks available to scripted classes which represent notes.
  */
diff --git a/source/funkin/modding/events/ScriptEvent.hx b/source/funkin/modding/events/ScriptEvent.hx
index 5ee0c42ea..c99db1d0f 100644
--- a/source/funkin/modding/events/ScriptEvent.hx
+++ b/source/funkin/modding/events/ScriptEvent.hx
@@ -32,6 +32,14 @@ class ScriptEvent
    */
   public static inline final DESTROY:ScriptEventType = "DESTROY";
 
+  /**
+   * Called when the relevent object is added to the game state.
+   * This assumes all data is loaded and ready to go.
+   * 
+   * This event is not cancelable.
+   */
+  public static inline final ADDED:ScriptEventType = 'ADDED';
+
   /**
    * Called during the update function.
    * This is called every frame, so be careful!
diff --git a/source/funkin/modding/events/ScriptEventDispatcher.hx b/source/funkin/modding/events/ScriptEventDispatcher.hx
index 05a3fb36e..18181a1d1 100644
--- a/source/funkin/modding/events/ScriptEventDispatcher.hx
+++ b/source/funkin/modding/events/ScriptEventDispatcher.hx
@@ -34,6 +34,17 @@ class ScriptEventDispatcher
         return;
     }
 
+    if (Std.isOfType(target, IStateStageProp))
+    {
+      var t:IStateStageProp = cast(target, IStateStageProp);
+      switch (event.type)
+      {
+        case ScriptEvent.ADDED:
+          t.onAdd(cast event);
+          return;
+      }
+    }
+
     if (Std.isOfType(target, IPlayStateScriptedClass))
     {
       var t = cast(target, IPlayStateScriptedClass);
diff --git a/source/funkin/play/character/AnimateAtlasCharacter.hx b/source/funkin/play/character/AnimateAtlasCharacter.hx
index 7e7a979a4..f0ce48a32 100644
--- a/source/funkin/play/character/AnimateAtlasCharacter.hx
+++ b/source/funkin/play/character/AnimateAtlasCharacter.hx
@@ -81,7 +81,7 @@ class AnimateAtlasCharacter extends BaseCharacter
     super.onCreate(event);
   }
 
-  public override function playAnimation(name:String, restart:Bool = false, ?ignoreOther:Bool = false):Void
+  public override function playAnimation(name:String, restart:Bool = false, ?ignoreOther:Bool = false, ?reverse:Bool = false):Void
   {
     if ((!canPlayOtherAnims && !ignoreOther)) return;
 
diff --git a/source/funkin/play/character/BaseCharacter.hx b/source/funkin/play/character/BaseCharacter.hx
index 01156dfab..f47f384a3 100644
--- a/source/funkin/play/character/BaseCharacter.hx
+++ b/source/funkin/play/character/BaseCharacter.hx
@@ -21,7 +21,12 @@ class BaseCharacter extends Bopper
   /**
    * Whether the player is an active character (Boyfriend) or not.
    */
-  public var characterType:CharacterType = OTHER;
+  public var characterType(default, set):CharacterType = OTHER;
+
+  function set_characterType(value:CharacterType):CharacterType
+  {
+    return this.characterType = value;
+  }
 
   /**
    * Tracks how long, in seconds, the character has been playing the current `sing` animation.
@@ -30,8 +35,18 @@ class BaseCharacter extends Bopper
    */
   public var holdTimer:Float = 0;
 
+  /**
+   * Set to true when the character dead. Part of the handling for death animations.
+   */
   public var isDead:Bool = false;
-  public var debugMode:Bool = false;
+
+  /**
+   * Set to true when the character being used in a special way.
+   * This includes the Chart Editor and the Animation Editor.
+   * 
+   * Used by scripts to ensure that they don't try to run code to interact with the stage when the stage doesn't actually exist.
+   */
+  public var debug:Bool = false;
 
   /**
    * This character plays a given animation when hitting these specific combo numbers.
@@ -44,7 +59,7 @@ class BaseCharacter extends Bopper
   public var dropNoteCounts(default, null):Array<Int>;
 
   final _data:CharacterData;
-  final singTimeCrochet:Float;
+  final singTimeSec:Float;
 
   /**
    * The offset between the corner of the sprite and the origin of the sprite (at the character's feet).
@@ -102,14 +117,14 @@ class BaseCharacter extends Bopper
    */
   public var cameraFocusPoint(default, null):FlxPoint = new FlxPoint(0, 0);
 
-  override function set_animOffsets(value:Array<Float>)
+  override function set_animOffsets(value:Array<Float>):Array<Float>
   {
     if (animOffsets == null) value = [0, 0];
-    if (animOffsets == value) return value;
+    if ((animOffsets[0] == value[0]) && (animOffsets[1] == value[1])) return value;
 
     // Make sure animOffets are halved when scale is 0.5.
-    var xDiff = (animOffsets[0] * this.scale.x) - value[0];
-    var yDiff = (animOffsets[1] * this.scale.y) - value[1];
+    var xDiff = (animOffsets[0] * this.scale.x / (this.isPixel ? 6 : 1)) - value[0];
+    var yDiff = (animOffsets[1] * this.scale.y / (this.isPixel ? 6 : 1)) - value[1];
 
     // Call the super function so that camera focus point is not affected.
     super.set_x(this.x + xDiff);
@@ -164,7 +179,7 @@ class BaseCharacter extends Bopper
     {
       this.characterName = _data.name;
       this.name = _data.name;
-      this.singTimeCrochet = _data.singTime;
+      this.singTimeSec = _data.singTime;
       this.globalOffsets = _data.offsets;
       this.flipX = _data.flipX;
     }
@@ -229,7 +244,7 @@ class BaseCharacter extends Bopper
    * Set the sprite scale to the appropriate value.
    * @param scale 
    */
-  function setScale(scale:Null<Float>):Void
+  public function setScale(scale:Null<Float>):Void
   {
     if (scale == null) scale = 1.0;
 
@@ -257,7 +272,7 @@ class BaseCharacter extends Bopper
     super.onCreate(event);
 
     // Make sure we are playing the idle animation...
-    this.dance();
+    this.dance(true);
     // ...then update the hitbox so that this.width and this.height are correct.
     this.updateHitbox();
     // Without the above code, width and height (and therefore character position)
@@ -291,7 +306,9 @@ class BaseCharacter extends Bopper
       if (PlayState.instance.iconP1 == null)
       {
         trace('[WARN] Player 1 health icon not found!');
+        return;
       }
+      PlayState.instance.iconP1.isPixel = _data.healthIcon?.isPixel ?? false;
       PlayState.instance.iconP1.characterId = _data.healthIcon.id;
       PlayState.instance.iconP1.size.set(_data.healthIcon.scale, _data.healthIcon.scale);
       PlayState.instance.iconP1.offset.x = _data.healthIcon.offsets[0];
@@ -303,12 +320,14 @@ class BaseCharacter extends Bopper
       if (PlayState.instance.iconP2 == null)
       {
         trace('[WARN] Player 2 health icon not found!');
+        return;
       }
+      PlayState.instance.iconP2.isPixel = _data.healthIcon?.isPixel ?? false;
       PlayState.instance.iconP2.characterId = _data.healthIcon.id;
       PlayState.instance.iconP2.size.set(_data.healthIcon.scale, _data.healthIcon.scale);
       PlayState.instance.iconP2.offset.x = _data.healthIcon.offsets[0];
       PlayState.instance.iconP2.offset.y = _data.healthIcon.offsets[1];
-      PlayState.instance.iconP1.flipX = _data.healthIcon.flipX;
+      PlayState.instance.iconP2.flipX = _data.healthIcon.flipX;
     }
   }
 
@@ -328,13 +347,16 @@ class BaseCharacter extends Bopper
       return;
     }
 
-    // This logic turns the idle animation into a "lead-in" animation.
-    if (hasAnimation('idle-hold') && getCurrentAnimation() == 'idle' && isAnimationFinished()) playAnimation('idle-hold');
+    // If there is an animation, and another animation with the same name + "-hold" exists,
+    // the second animation will play (and be looped if configured to do so) after the first animation finishes.
+    // This is good for characters that need to hold a pose while maintaining an animation, like the parents (this keeps their eyes flickering)
+    // and Darnell (this keeps the flame on his lighter flickering).
+    // Works for idle, singLEFT/RIGHT/UP/DOWN, alt singing animations, and anything else really.
 
-    if (hasAnimation('singLEFT-hold') && getCurrentAnimation() == 'singLEFT' && isAnimationFinished()) playAnimation('singLEFT-hold');
-    if (hasAnimation('singDOWN-hold') && getCurrentAnimation() == 'singDOWN' && isAnimationFinished()) playAnimation('singDOWN-hold');
-    if (hasAnimation('singUP-hold') && getCurrentAnimation() == 'singUP' && isAnimationFinished()) playAnimation('singUP-hold');
-    if (hasAnimation('singRIGHT-hold') && getCurrentAnimation() == 'singRIGHT' && isAnimationFinished()) playAnimation('singRIGHT-hold');
+    if (!getCurrentAnimation().endsWith('-hold') && hasAnimation(getCurrentAnimation() + '-hold') && isAnimationFinished())
+    {
+      playAnimation(getCurrentAnimation() + '-hold');
+    }
 
     // Handle character note hold time.
     if (getCurrentAnimation().startsWith('sing'))
@@ -345,18 +367,18 @@ class BaseCharacter extends Bopper
       // This lets you add frames to the end of the sing animation to ease back into the idle!
 
       holdTimer += event.elapsed;
-      var singTimeMs:Float = singTimeCrochet * (Conductor.crochet * 0.001); // x beats, to ms.
+      var singTimeSec:Float = singTimeSec * (Conductor.crochet * 0.001); // x beats, to ms.
 
-      if (getCurrentAnimation().endsWith('miss')) singTimeMs *= 2; // makes it feel more awkward when you miss
+      if (getCurrentAnimation().endsWith('miss')) singTimeSec *= 2; // makes it feel more awkward when you miss
 
       // Without this check here, the player character would only play the `sing` animation
       // for one beat, as opposed to holding it as long as the player is holding the button.
       var shouldStopSinging:Bool = (this.characterType == BF) ? !isHoldingNote() : true;
 
-      FlxG.watch.addQuick('singTimeMs-${characterId}', singTimeMs);
-      if (holdTimer > singTimeMs && shouldStopSinging)
+      FlxG.watch.addQuick('singTimeSec-${characterId}', singTimeSec);
+      if (holdTimer > singTimeSec && shouldStopSinging)
       {
-        // trace('holdTimer reached ${holdTimer}sec (> ${singTimeMs}), stopping sing animation');
+        // trace('holdTimer reached ${holdTimer}sec (> ${singTimeSec}), stopping sing animation');
         holdTimer = 0;
         dance(true);
       }
@@ -370,7 +392,7 @@ class BaseCharacter extends Bopper
   }
 
   /**
-   * Since no `onBeatHit` or `dance` calls happen in GameOverSubstate,
+   * Since no `onBeatHit` or `dance` calls happen in GameOverSubState,
    * this regularly gets called instead.
    * 
    * @param force Force the deathLoop animation to play, even if `firstDeath` is still playing.
@@ -386,7 +408,7 @@ class BaseCharacter extends Bopper
   override function dance(force:Bool = false):Void
   {
     // Prevent default dancing behavior.
-    if (debugMode || isDead) return;
+    if (isDead) return;
 
     if (!force)
     {
diff --git a/source/funkin/play/character/CharacterData.hx b/source/funkin/play/character/CharacterData.hx
index 28763710f..886047ec2 100644
--- a/source/funkin/play/character/CharacterData.hx
+++ b/source/funkin/play/character/CharacterData.hx
@@ -189,7 +189,7 @@ class CharacterDataParser
    * @param charId The character ID to fetch.
    * @return The character instance, or null if the character was not found.
    */
-  public static function fetchCharacter(charId:String):Null<BaseCharacter>
+  public static function fetchCharacter(charId:String, ?debug:Bool = false):Null<BaseCharacter>
   {
     if (charId == null || charId == '' || !characterCache.exists(charId))
     {
@@ -246,7 +246,9 @@ class CharacterDataParser
       return null;
     }
 
-    trace('Successfully instantiated character: ${charId}');
+    trace('Successfully instantiated character (${debug ? 'debug' : 'stable'}): ${charId}');
+
+    char.debug = debug;
 
     // Call onCreate only in the fetchCharacter() function, not at application initialization.
     ScriptEventDispatcher.callEvent(char, new ScriptEvent(ScriptEvent.CREATE));
@@ -419,6 +421,7 @@ class CharacterDataParser
           id: null,
           scale: null,
           flipX: null,
+          isPixel: null,
           offsets: null
         };
     }
@@ -458,6 +461,11 @@ class CharacterDataParser
       input.isPixel = DEFAULT_ISPIXEL;
     }
 
+    if (input.healthIcon.isPixel == null)
+    {
+      input.healthIcon.isPixel = input.isPixel;
+    }
+
     if (input.danceEvery == null)
     {
       input.danceEvery = DEFAULT_DANCEEVERY;
@@ -674,6 +682,12 @@ typedef HealthIconData =
    */
   var flipX:Null<Bool>;
 
+  /**
+   * Multiply scale by 6 and disable antialiasing
+   * @default false
+   */
+  var isPixel:Null<Bool>;
+
   /**
    * The offset of the health icon, in pixels.
    * @default [0, 25]
diff --git a/source/funkin/play/character/MultiSparrowCharacter.hx b/source/funkin/play/character/MultiSparrowCharacter.hx
index 974a1c431..5d4deee5d 100644
--- a/source/funkin/play/character/MultiSparrowCharacter.hx
+++ b/source/funkin/play/character/MultiSparrowCharacter.hx
@@ -179,14 +179,14 @@ class MultiSparrowCharacter extends BaseCharacter
     trace('[MULTISPARROWCHAR] Successfully loaded ${animNames.length} animations for ${characterId}');
   }
 
-  public override function playAnimation(name:String, restart:Bool = false, ?ignoreOther:Bool = false):Void
+  public override function playAnimation(name:String, restart:Bool = false, ?ignoreOther:Bool = false, ?reverse:Bool = false):Void
   {
     // Make sure we ignore other animations if we're currently playing a forced one,
     // unless we're forcing a new animation.
     if (!this.canPlayOtherAnims && !ignoreOther) return;
 
     loadFramesByAnimName(name);
-    super.playAnimation(name, restart, ignoreOther);
+    super.playAnimation(name, restart, ignoreOther, reverse);
   }
 
   override function set_frames(value:FlxFramesCollection):FlxFramesCollection
diff --git a/source/funkin/play/stage/Bopper.hx b/source/funkin/play/stage/Bopper.hx
index 623660af7..9b5d1f314 100644
--- a/source/funkin/play/stage/Bopper.hx
+++ b/source/funkin/play/stage/Bopper.hx
@@ -42,6 +42,18 @@ class Bopper extends StageProp implements IPlayStateScriptedClass
    */
   public var idleSuffix(default, set):String = '';
 
+  /**
+   * If this bopper is rendered with pixel art,
+   * disable anti-aliasing and render at 6x scale.
+   */
+  public var isPixel(default, set):Bool = false;
+
+  function set_isPixel(value:Bool):Bool
+  {
+    if (isPixel == value) return value;
+    return isPixel = value;
+  }
+
   /**
    * Whether this bopper should bop every beat. By default it's true, but when used
    * for characters/players, it should be false so it doesn't cut off their animations!!!!!
@@ -80,7 +92,7 @@ class Bopper extends StageProp implements IPlayStateScriptedClass
   function set_animOffsets(value:Array<Float>):Array<Float>
   {
     if (animOffsets == null) animOffsets = [0, 0];
-    if (animOffsets == value) return value;
+    if ((animOffsets[0] == value[0]) && (animOffsets[1] == value[1])) return value;
 
     var xDiff = animOffsets[0] - value[0];
     var yDiff = animOffsets[1] - value[1];
@@ -213,7 +225,7 @@ class Bopper extends StageProp implements IPlayStateScriptedClass
    * Will gracefully check for name, then name with stripped suffixes, then 'idle', then fail to play.
    * @param name 
    */
-  function correctAnimationName(name:String)
+  function correctAnimationName(name:String):String
   {
     // If the animation exists, we're good.
     if (hasAnimation(name)) return name;
@@ -248,16 +260,16 @@ class Bopper extends StageProp implements IPlayStateScriptedClass
    * @param name The name of the animation to play.
    * @param restart Whether to restart the animation if it is already playing.
    * @param ignoreOther Whether to ignore all other animation inputs, until this one is done playing
+   * @param reversed If true, play the animation backwards, from the last frame to the first.
    */
-  public function playAnimation(name:String, restart:Bool = false, ?ignoreOther:Bool = false):Void
+  public function playAnimation(name:String, restart:Bool = false, ?ignoreOther:Bool = false, ?reversed:Bool = false):Void
   {
     if (!canPlayOtherAnims && !ignoreOther) return;
 
     var correctName = correctAnimationName(name);
     if (correctName == null) return;
 
-    this.animation.paused = false;
-    this.animation.play(correctName, restart, false, 0);
+    this.animation.play(correctName, restart, reversed, 0);
 
     if (ignoreOther)
     {
@@ -291,10 +303,10 @@ class Bopper extends StageProp implements IPlayStateScriptedClass
     }, 1);
   }
 
-  function applyAnimationOffsets(name:String)
+  function applyAnimationOffsets(name:String):Void
   {
     var offsets = animationOffsets.get(name);
-    if (offsets != null)
+    if (offsets != null && !(offsets[0] == 0 && offsets[1] == 0))
     {
       this.animOffsets = [offsets[0] + globalOffsets[0], offsets[1] + globalOffsets[1]];
     }
@@ -325,14 +337,6 @@ class Bopper extends StageProp implements IPlayStateScriptedClass
     return this.animation.curAnim.name;
   }
 
-  public function onScriptEvent(event:ScriptEvent) {}
-
-  public function onCreate(event:ScriptEvent) {}
-
-  public function onDestroy(event:ScriptEvent) {}
-
-  public function onUpdate(event:UpdateScriptEvent) {}
-
   public function onPause(event:PauseScriptEvent) {}
 
   public function onResume(event:ScriptEvent) {}
diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx
index e58a9fa84..71ba522c0 100644
--- a/source/funkin/play/stage/Stage.hx
+++ b/source/funkin/play/stage/Stage.hx
@@ -77,7 +77,7 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
     // Reset positions of characters.
     if (getBoyfriend() != null)
     {
-      getBoyfriend().resetCharacter(false);
+      getBoyfriend().resetCharacter(true);
     }
     else
     {
@@ -85,11 +85,11 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
     }
     if (getGirlfriend() != null)
     {
-      getGirlfriend().resetCharacter(false);
+      getGirlfriend().resetCharacter(true);
     }
     if (getDad() != null)
     {
-      getDad().resetCharacter(false);
+      getDad().resetCharacter(true);
     }
 
     // Reset positions of named props.
@@ -286,6 +286,7 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
    */
   override function preAdd(Sprite:FlxSprite):Void
   {
+    if (Sprite == null) return;
     var sprite:FlxSprite = cast Sprite;
     sprite.x += x;
     sprite.y += y;
@@ -377,22 +378,36 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
     // Add the character to the scene.
     this.add(character);
 
+    ScriptEventDispatcher.callEvent(character, new ScriptEvent(ScriptEvent.ADDED, false));
+
     #if debug
     debugIconGroup.add(debugIcon);
     debugIconGroup.add(debugIcon2);
     #end
   }
 
+  /**
+   * Get the position of the girlfriend character, as defined in the stage data.
+   * @return An FlxPoint position.
+   */
   public inline function getGirlfriendPosition():FlxPoint
   {
     return new FlxPoint(_data.characters.gf.position[0], _data.characters.gf.position[1]);
   }
 
+  /**
+   * Get the position of the boyfriend character, as defined in the stage data.
+   * @return An FlxPoint position.
+   */
   public inline function getBoyfriendPosition():FlxPoint
   {
     return new FlxPoint(_data.characters.bf.position[0], _data.characters.bf.position[1]);
   }
 
+  /**
+   * Get the position of the dad character, as defined in the stage data.
+   * @return An FlxPoint position.
+   */
   public inline function getDadPosition():FlxPoint
   {
     return new FlxPoint(_data.characters.dad.position[0], _data.characters.dad.position[1]);
@@ -409,6 +424,7 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
   /**
    * Retrieve the Boyfriend character.
    * @param pop If true, the character will be removed from the stage as well.
+   * @return The Boyfriend character.
    */
   public function getBoyfriend(?pop:Bool = false):BaseCharacter
   {
@@ -428,14 +444,70 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
     }
   }
 
-  public function getGirlfriend():BaseCharacter
+  /**
+   * Retrieve the player/Boyfriend character.
+   * @param pop If true, the character will be removed from the stage as well.
+   * @return The player/Boyfriend character.
+   */
+  public function getPlayer(?pop:Bool = false):BaseCharacter
   {
-    return getCharacter('gf');
+    return getBoyfriend(pop);
   }
 
-  public function getDad():BaseCharacter
+  /**
+   * Retrieve the Girlfriend character.
+   * @param pop If true, the character will be removed from the stage as well.
+   * @return The Girlfriend character.
+   */
+  public function getGirlfriend(?pop:Bool = false):BaseCharacter
   {
-    return getCharacter('dad');
+    if (pop)
+    {
+      var girlfriend:BaseCharacter = getCharacter('gf');
+
+      // Remove the character from the stage.
+      this.remove(girlfriend);
+      this.characters.remove('gf');
+
+      return girlfriend;
+    }
+    else
+    {
+      return getCharacter('gf');
+    }
+  }
+
+  /**
+   * Retrieve the Dad character.
+   * @param pop If true, the character will be removed from the stage as well.
+   * @return The Dad character.
+   */
+  public function getDad(?pop:Bool = false):BaseCharacter
+  {
+    if (pop)
+    {
+      var dad:BaseCharacter = getCharacter('dad');
+
+      // Remove the character from the stage.
+      this.remove(dad);
+      this.characters.remove('dad');
+
+      return dad;
+    }
+    else
+    {
+      return getCharacter('dad');
+    }
+  }
+
+  /**
+   * Retrieve the opponent/Dad character.
+   * @param pop If true, the character will be removed from the stage as well.
+   * @return The opponent character.
+   */
+  public function getOpponent(?pop:Bool = false):BaseCharacter
+  {
+    return getDad(pop);
   }
 
   /**
@@ -448,6 +520,26 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass
     return this.namedProps.get(name);
   }
 
+  /**
+   * Pause the animations of ALL sprites in this group.
+   */
+  public function pause():Void
+  {
+    forEachAlive(function(prop:FlxSprite) {
+      if (prop.animation != null) prop.animation.pause();
+    });
+  }
+
+  /**
+   * Resume the animations of ALL sprites in this group.
+   */
+  public function resume():Void
+  {
+    forEachAlive(function(prop:FlxSprite) {
+      if (prop.animation != null) prop.animation.resume();
+    });
+  }
+
   /**
    * Retrieve a list of all the asset paths required to load the stage.
    * Override this in a scripted class to ensure that all necessary assets are loaded!
diff --git a/source/funkin/play/stage/StageProp.hx b/source/funkin/play/stage/StageProp.hx
index 0cce506d1..58de55293 100644
--- a/source/funkin/play/stage/StageProp.hx
+++ b/source/funkin/play/stage/StageProp.hx
@@ -1,13 +1,32 @@
 package funkin.play.stage;
 
+import funkin.modding.events.ScriptEvent;
 import flixel.FlxSprite;
+import funkin.modding.IScriptedClass.IStateStageProp;
 
-class StageProp extends FlxSprite
+class StageProp extends FlxSprite implements IStateStageProp
 {
-  public var name:String = "";
+  /**
+   * An internal name for this prop.
+   */
+  public var name:String = '';
 
   public function new()
   {
     super();
   }
+
+  /**
+   * Called when this prop is added to the stage.
+   * @param event 
+   */
+  public function onAdd(event:ScriptEvent):Void {}
+
+  public function onScriptEvent(event:ScriptEvent) {}
+
+  public function onCreate(event:ScriptEvent) {}
+
+  public function onDestroy(event:ScriptEvent) {}
+
+  public function onUpdate(event:UpdateScriptEvent) {}
 }