From 0294ea0b7923f182d99be8a3906354d3b6394c70 Mon Sep 17 00:00:00 2001
From: EliteMasterEric <ericmyllyoja@gmail.com>
Date: Fri, 1 Mar 2024 08:13:06 -0500
Subject: [PATCH] Implemented FlxAnimate characters into Blazin'.

---
 assets                                        |   2 +-
 source/funkin/data/BaseRegistry.hx            |   1 +
 source/funkin/data/stage/StageData.hx         |  11 ++
 .../graphics/adobeanimate/FlxAtlasSprite.hx   |  11 +-
 source/funkin/modding/events/ScriptEvent.hx   |  33 +++++-
 source/funkin/play/GameOverSubState.hx        |  11 ++
 source/funkin/play/PauseSubState.hx           |   1 -
 source/funkin/play/PlayState.hx               | 103 ++++++++++--------
 .../play/character/AnimateAtlasCharacter.hx   |  36 +++++-
 source/funkin/play/character/BaseCharacter.hx |  15 ++-
 source/funkin/play/character/CharacterData.hx |   7 ++
 .../funkin/play/cutscene/dialogue/Speaker.hx  |   8 +-
 source/funkin/play/stage/Bopper.hx            |   8 +-
 source/funkin/play/stage/Stage.hx             |  31 +++++-
 14 files changed, 205 insertions(+), 73 deletions(-)

diff --git a/assets b/assets
index 8b914574f..874f7de39 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 8b914574fc4724c5fe483f4f9d81081bb1518c12
+Subproject commit 874f7de39ee2dfe2ffe4c02edf701d36f2a393fd
diff --git a/source/funkin/data/BaseRegistry.hx b/source/funkin/data/BaseRegistry.hx
index 62a7eb0f7..2df0c18f0 100644
--- a/source/funkin/data/BaseRegistry.hx
+++ b/source/funkin/data/BaseRegistry.hx
@@ -240,6 +240,7 @@ abstract class BaseRegistry<T:(IRegistryEntry<J> & Constructible<EntryConstructo
    */
   function createEntry(id:String):Null<T>
   {
+    // We enforce that T is Constructible to ensure this is valid.
     return new T(id);
   }
 
diff --git a/source/funkin/data/stage/StageData.hx b/source/funkin/data/stage/StageData.hx
index cb914007f..22b883c75 100644
--- a/source/funkin/data/stage/StageData.hx
+++ b/source/funkin/data/stage/StageData.hx
@@ -32,18 +32,21 @@ class StageData
       bf:
         {
           zIndex: 0,
+          scale: 1,
           position: [0, 0],
           cameraOffsets: [-100, -100]
         },
       dad:
         {
           zIndex: 0,
+          scale: 1,
           position: [0, 0],
           cameraOffsets: [100, -100]
         },
       gf:
         {
           zIndex: 0,
+          scale: 1,
           position: [0, 0],
           cameraOffsets: [0, 0]
         }
@@ -114,6 +117,7 @@ typedef StageDataProp =
   @:jcustomparse(funkin.data.DataParse.eitherFloatOrFloats)
   @:jcustomwrite(funkin.data.DataWrite.eitherFloatOrFloats)
   @:optional
+  @:default(Left(1.0))
   var scale:haxe.ds.Either<Float, Array<Float>>;
 
   /**
@@ -190,6 +194,13 @@ typedef StageDataCharacter =
   @:default([0, 0])
   var position:Array<Float>;
 
+  /**
+   * The scale to render the character at.
+   */
+  @:optional
+  @:default(1)
+  var scale:Float;
+
   /**
    * The camera offsets to apply when focusing on the character on this stage.
    * @default [-100, -100] for BF, [100, -100] for DAD/OPPONENT, [0, 0] for GF
diff --git a/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx b/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx
index 2329a2791..90965847e 100644
--- a/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx
+++ b/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx
@@ -82,6 +82,8 @@ class FlxAtlasSprite extends FlxAnimate
    * @param id A string ID 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 loop Whether to loop the animation
+   * NOTE: `loop` and `ignoreOther` are not compatible with each other!
    */
   public function playAnimation(id:String, restart:Bool = false, ignoreOther:Bool = false, ?loop:Bool = false):Void
   {
@@ -116,9 +118,14 @@ class FlxAtlasSprite extends FlxAnimate
     anim.callback = function(_, frame:Int) {
       if (frame == (anim.getFrameLabel(id).duration - 1) + anim.getFrameLabel(id).index)
       {
-        if (loop) playAnimation(id, true, false, true);
+        if (loop)
+        {
+          playAnimation(id, true, false, true);
+        }
         else
+        {
           onAnimationFinish.dispatch(id);
+        }
       }
     };
 
@@ -177,6 +184,6 @@ class FlxAtlasSprite extends FlxAnimate
   {
     canPlayOtherAnims = true;
     this.currentAnimation = null;
-    this.anim.stop();
+    this.anim.pause();
   }
 }
diff --git a/source/funkin/modding/events/ScriptEvent.hx b/source/funkin/modding/events/ScriptEvent.hx
index 5d522e3ae..0d424a281 100644
--- a/source/funkin/modding/events/ScriptEvent.hx
+++ b/source/funkin/modding/events/ScriptEvent.hx
@@ -107,18 +107,18 @@ class NoteScriptEvent extends ScriptEvent
   public var playSound(default, default):Bool;
 
   /**
-   * A multiplier to the health gained or lost from this note.
+   * The health gained or lost from this note.
    * This affects both hits and misses. Remember that max health is 2.00.
    */
-  public var healthMulti:Float;
+  public var healthChange:Float;
 
-  public function new(type:ScriptEventType, note:NoteSprite, comboCount:Int = 0, cancelable:Bool = false):Void
+  public function new(type:ScriptEventType, note:NoteSprite, healthChange:Float, comboCount:Int = 0, cancelable:Bool = false):Void
   {
     super(type, cancelable);
     this.note = note;
     this.comboCount = comboCount;
     this.playSound = true;
-    this.healthMulti = 1.0;
+    this.healthChange = healthChange;
   }
 
   public override function toString():String
@@ -127,6 +127,31 @@ class NoteScriptEvent extends ScriptEvent
   }
 }
 
+class HitNoteScriptEvent extends NoteScriptEvent
+{
+  /**
+   * The judgement the player received for hitting the note.
+   */
+  public var judgement:String;
+
+  /**
+   * The score the player received for hitting the note.
+   */
+  public var score:Int;
+
+  public function new(note:NoteSprite, healthChange:Float, score:Int, judgement:String, comboCount:Int = 0):Void
+  {
+    super(NOTE_HIT, note, healthChange, comboCount, true);
+    this.score = score;
+    this.judgement = judgement;
+  }
+
+  public override function toString():String
+  {
+    return 'HitNoteScriptEvent(note=' + note + ', comboCount=' + comboCount + ', judgement=' + judgement + ', score=' + score + ')';
+  }
+}
+
 /**
  * An event that is fired when you press a key with no note present.
  */
diff --git a/source/funkin/play/GameOverSubState.hx b/source/funkin/play/GameOverSubState.hx
index b7e92d10f..d5132e160 100644
--- a/source/funkin/play/GameOverSubState.hx
+++ b/source/funkin/play/GameOverSubState.hx
@@ -12,6 +12,7 @@ import funkin.modding.events.ScriptEvent;
 import funkin.modding.events.ScriptEventDispatcher;
 import funkin.play.character.BaseCharacter;
 import funkin.play.PlayState;
+import funkin.util.MathUtil;
 import funkin.ui.freeplay.FreeplayState;
 import funkin.ui.MusicBeatSubState;
 import funkin.ui.story.StoryMenuState;
@@ -82,6 +83,9 @@ class GameOverSubState extends MusicBeatSubState
 
   var transparent:Bool;
 
+  final CAMERA_ZOOM_DURATION:Float = 0.5;
+  var targetCameraZoom:Float = 1.0;
+
   public function new(params:GameOverParams)
   {
     super();
@@ -142,6 +146,7 @@ class GameOverSubState extends MusicBeatSubState
 
     FlxG.camera.target = null;
     FlxG.camera.follow(cameraFollowPoint, LOCKON, 0.01);
+    targetCameraZoom = PlayState?.instance?.currentStage?.camZoom * boyfriend.getDeathCameraZoom();
 
     //
     // Set up the audio
@@ -177,6 +182,9 @@ class GameOverSubState extends MusicBeatSubState
       }
     }
 
+    // Smoothly lerp the camera
+    FlxG.camera.zoom = MathUtil.smoothLerp(FlxG.camera.zoom, targetCameraZoom, elapsed, CAMERA_ZOOM_DURATION);
+
     //
     // Handle user inputs.
     //
@@ -286,6 +294,9 @@ class GameOverSubState extends MusicBeatSubState
           remove(boyfriend);
           PlayState.instance.currentStage.addCharacter(boyfriend, BF);
 
+          // Snap reset the camera which may have changed because of the player character data.
+          resetCameraZoom();
+
           // Close the substate.
           close();
         });
diff --git a/source/funkin/play/PauseSubState.hx b/source/funkin/play/PauseSubState.hx
index 2b705ea9e..03681ce13 100644
--- a/source/funkin/play/PauseSubState.hx
+++ b/source/funkin/play/PauseSubState.hx
@@ -386,7 +386,6 @@ class PauseSubState extends MusicBeatSubState
       // Set the position.
       var targetX = FlxMath.remapToRange((entryIndex - currentEntry), 0, 1, 0, 1.3) * 20 + 90;
       var targetY = FlxMath.remapToRange((entryIndex - currentEntry), 0, 1, 0, 1.3) * 120 + (FlxG.height * 0.48);
-      trace(targetY);
       FlxTween.globalManager.cancelTweensOf(text);
       FlxTween.tween(text, {x: targetX, y: targetY}, 0.33, {ease: FlxEase.quartOut});
     }
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index f6bb463e7..5cb2e20f3 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -927,7 +927,7 @@ class PlayState extends MusicBeatSubState
       camHUD.zoom = FlxMath.lerp(defaultHUDCameraZoom, camHUD.zoom, 0.95);
     }
 
-    if (currentStage != null)
+    if (currentStage != null && currentStage.getBoyfriend() != null)
     {
       FlxG.watch.addQuick('bfAnim', currentStage.getBoyfriend().getCurrentAnimation());
     }
@@ -1498,17 +1498,17 @@ class PlayState extends MusicBeatSubState
     if (dad != null)
     {
       dad.characterType = CharacterType.DAD;
-    }
 
-    //
-    // OPPONENT HEALTH ICON
-    //
-    iconP2 = new HealthIcon('dad', 1);
-    iconP2.y = healthBar.y - (iconP2.height / 2);
-    dad.initHealthIcon(true); // Apply the character ID here
-    iconP2.zIndex = 850;
-    add(iconP2);
-    iconP2.cameras = [camHUD];
+      //
+      // OPPONENT HEALTH ICON
+      //
+      iconP2 = new HealthIcon('dad', 1);
+      iconP2.y = healthBar.y - (iconP2.height / 2);
+      dad.initHealthIcon(true); // Apply the character ID here
+      iconP2.zIndex = 850;
+      add(iconP2);
+      iconP2.cameras = [camHUD];
+    }
 
     //
     // BOYFRIEND
@@ -1518,17 +1518,17 @@ class PlayState extends MusicBeatSubState
     if (boyfriend != null)
     {
       boyfriend.characterType = CharacterType.BF;
-    }
 
-    //
-    // PLAYER HEALTH ICON
-    //
-    iconP1 = new HealthIcon('bf', 0);
-    iconP1.y = healthBar.y - (iconP1.height / 2);
-    boyfriend.initHealthIcon(false); // Apply the character ID here
-    iconP1.zIndex = 850;
-    add(iconP1);
-    iconP1.cameras = [camHUD];
+      //
+      // PLAYER HEALTH ICON
+      //
+      iconP1 = new HealthIcon('bf', 0);
+      iconP1.y = healthBar.y - (iconP1.height / 2);
+      boyfriend.initHealthIcon(false); // Apply the character ID here
+      iconP1.zIndex = 850;
+      add(iconP1);
+      iconP1.cameras = [camHUD];
+    }
 
     //
     // ADD CHARACTERS TO SCENE
@@ -2016,7 +2016,7 @@ class PlayState extends MusicBeatSubState
       {
         // Call an event to allow canceling the note miss.
         // NOTE: This is what handles the character animations!
-        var event:NoteScriptEvent = new NoteScriptEvent(NOTE_MISS, note, 0, true);
+        var event:NoteScriptEvent = new NoteScriptEvent(NOTE_MISS, note, -Constants.HEALTH_MISS_PENALTY, 0, true);
         dispatchEvent(event);
 
         // Calling event.cancelEvent() skips all the other logic! Neat!
@@ -2025,7 +2025,7 @@ class PlayState extends MusicBeatSubState
         // Judge the miss.
         // NOTE: This is what handles the scoring.
         trace('Missed note! ${note.noteData}');
-        onNoteMiss(note, event.playSound, event.healthMulti);
+        onNoteMiss(note, event.playSound, event.healthChange);
 
         note.handledMiss = true;
       }
@@ -2171,13 +2171,41 @@ class PlayState extends MusicBeatSubState
 
   function goodNoteHit(note:NoteSprite, input:PreciseInputEvent):Void
   {
-    var event:NoteScriptEvent = new NoteScriptEvent(NOTE_HIT, note, Highscore.tallies.combo + 1, true);
+    // Calculate the input latency (do this as late as possible).
+    // trace('Compare: ${PreciseInputManager.getCurrentTimestamp()} - ${input.timestamp}');
+    var inputLatencyNs:Int64 = PreciseInputManager.getCurrentTimestamp() - input.timestamp;
+    var inputLatencyMs:Float = inputLatencyNs.toFloat() / Constants.NS_PER_MS;
+    // trace('Input: ${daNote.noteData.getDirectionName()} pressed ${inputLatencyMs}ms ago!');
+
+    // Get the offset and compensate for input latency.
+    // Round inward (trim remainder) for consistency.
+    var noteDiff:Int = Std.int(Conductor.instance.songPosition - note.noteData.time - inputLatencyMs);
+
+    var score = Scoring.scoreNote(noteDiff, PBOT1);
+    var daRating = Scoring.judgeNote(noteDiff, PBOT1);
+
+    var healthChange = 0.0;
+    switch (daRating)
+    {
+      case 'sick':
+        healthChange = Constants.HEALTH_SICK_BONUS;
+      case 'good':
+        healthChange = Constants.HEALTH_GOOD_BONUS;
+      case 'bad':
+        healthChange = Constants.HEALTH_BAD_BONUS;
+      case 'shit':
+        healthChange = Constants.HEALTH_SHIT_BONUS;
+    }
+
+    // Send the note hit event.
+    var event:HitNoteScriptEvent = new HitNoteScriptEvent(note, healthChange, score, daRating, Highscore.tallies.combo + 1);
     dispatchEvent(event);
 
     // Calling event.cancelEvent() skips all the other logic! Neat!
     if (event.eventCanceled) return;
 
-    popUpScore(note, input, event.healthMulti);
+    // Display the combo meter and add the calculation to the score.
+    popUpScore(note, event.score, event.judgement, event.healthChange);
 
     if (note.isHoldNote && note.holdNoteSprite != null)
     {
@@ -2191,11 +2219,11 @@ class PlayState extends MusicBeatSubState
    * Called when a note leaves the screen and is considered missed by the player.
    * @param note
    */
-  function onNoteMiss(note:NoteSprite, playSound:Bool = false, healthLossMulti:Float = 1.0):Void
+  function onNoteMiss(note:NoteSprite, playSound:Bool = false, healthChange:Float):Void
   {
     // If we are here, we already CALLED the onNoteMiss script hook!
 
-    health -= Constants.HEALTH_MISS_PENALTY * healthLossMulti;
+    health += healthChange;
     songScore -= 10;
 
     if (!isPracticeMode)
@@ -2367,23 +2395,10 @@ class PlayState extends MusicBeatSubState
   /**
    * Handles health, score, and rating popups when a note is hit.
    */
-  function popUpScore(daNote:NoteSprite, input:PreciseInputEvent, healthGainMulti:Float = 1.0):Void
+  function popUpScore(daNote:NoteSprite, score:Int, daRating:String, healthChange:Float):Void
   {
     vocals.playerVolume = 1;
 
-    // Calculate the input latency (do this as late as possible).
-    // trace('Compare: ${PreciseInputManager.getCurrentTimestamp()} - ${input.timestamp}');
-    var inputLatencyNs:Int64 = PreciseInputManager.getCurrentTimestamp() - input.timestamp;
-    var inputLatencyMs:Float = inputLatencyNs.toFloat() / Constants.NS_PER_MS;
-    // trace('Input: ${daNote.noteData.getDirectionName()} pressed ${inputLatencyMs}ms ago!');
-
-    // Get the offset and compensate for input latency.
-    // Round inward (trim remainder) for consistency.
-    var noteDiff:Int = Std.int(Conductor.instance.songPosition - daNote.noteData.time - inputLatencyMs);
-
-    var score = Scoring.scoreNote(noteDiff, PBOT1);
-    var daRating = Scoring.judgeNote(noteDiff, PBOT1);
-
     if (daRating == 'miss')
     {
       // If daRating is 'miss', that means we made a mistake and should not continue.
@@ -2398,22 +2413,20 @@ class PlayState extends MusicBeatSubState
     {
       case 'sick':
         Highscore.tallies.sick += 1;
-        health += Constants.HEALTH_SICK_BONUS * healthGainMulti;
         isComboBreak = Constants.JUDGEMENT_SICK_COMBO_BREAK;
       case 'good':
         Highscore.tallies.good += 1;
-        health += Constants.HEALTH_GOOD_BONUS * healthGainMulti;
         isComboBreak = Constants.JUDGEMENT_GOOD_COMBO_BREAK;
       case 'bad':
         Highscore.tallies.bad += 1;
-        health += Constants.HEALTH_BAD_BONUS * healthGainMulti;
         isComboBreak = Constants.JUDGEMENT_BAD_COMBO_BREAK;
       case 'shit':
         Highscore.tallies.shit += 1;
-        health += Constants.HEALTH_SHIT_BONUS * healthGainMulti;
         isComboBreak = Constants.JUDGEMENT_SHIT_COMBO_BREAK;
     }
 
+    health += healthChange;
+
     if (isComboBreak)
     {
       // Break the combo, but don't increment tallies.misses.
diff --git a/source/funkin/play/character/AnimateAtlasCharacter.hx b/source/funkin/play/character/AnimateAtlasCharacter.hx
index 418982bef..f180bd457 100644
--- a/source/funkin/play/character/AnimateAtlasCharacter.hx
+++ b/source/funkin/play/character/AnimateAtlasCharacter.hx
@@ -77,6 +77,7 @@ class AnimateAtlasCharacter extends BaseCharacter
 
     var atlasSprite:FlxAtlasSprite = loadAtlasSprite();
     setSprite(atlasSprite);
+
     loadAnimations();
 
     super.onCreate(event);
@@ -86,10 +87,21 @@ class AnimateAtlasCharacter extends BaseCharacter
   {
     if ((!canPlayOtherAnims && !ignoreOther)) return;
 
-    currentAnimation = name;
-    var prefix:String = getAnimationData(name).prefix;
-    if (prefix == null) prefix = name;
-    this.mainSprite.playAnimation(prefix, restart, ignoreOther);
+    var correctName = correctAnimationName(name);
+    if (correctName == null) return;
+
+    var animData = getAnimationData(correctName);
+    currentAnimation = correctName;
+    var prefix:String = animData.prefix;
+    if (prefix == null) prefix = correctName;
+    var loop:Bool = animData.looped;
+
+    this.mainSprite.playAnimation(prefix, restart, ignoreOther, loop);
+  }
+
+  public override function hasAnimation(name:String):Bool
+  {
+    return getAnimationData(name) != null;
   }
 
   function loadAtlasSprite():FlxAtlasSprite
@@ -114,7 +126,11 @@ class AnimateAtlasCharacter extends BaseCharacter
     }
     else
     {
+      // Make the game hold on the last frame.
       this.mainSprite.cleanupAnimation(prefix);
+
+      // Fallback to idle!
+      // playAnimation('idle', true, false);
     }
   }
 
@@ -140,14 +156,24 @@ class AnimateAtlasCharacter extends BaseCharacter
 
   function loadAnimations():Void
   {
-    trace('[ATLASCHAR] Loading ${_data.animations.length} animations for ${characterId}');
+    trace('[ATLASCHAR] Attempting to load ${_data.animations.length} animations for ${characterId}');
 
     var animData:Array<AnimateAtlasAnimation> = cast _data.animations;
 
     for (anim in animData)
     {
+      // Validate the animation before adding.
+      var prefix = anim.prefix;
+      if (!this.mainSprite.hasAnimation(prefix))
+      {
+        FlxG.log.warn('[ATLASCHAR] Animation ${prefix} not found in Animate Atlas ${_data.assetPath}');
+        continue;
+      }
       animations.set(anim.name, anim);
+      trace('[ATLASCHAR] - Successfully loaded animation ${anim.name} to ${characterId}');
     }
+
+    trace('[ATLASCHAR] Loaded ${animations.size()} animations for ${characterId}');
   }
 
   public override function getCurrentAnimation():String
diff --git a/source/funkin/play/character/BaseCharacter.hx b/source/funkin/play/character/BaseCharacter.hx
index 390864148..bfba3715a 100644
--- a/source/funkin/play/character/BaseCharacter.hx
+++ b/source/funkin/play/character/BaseCharacter.hx
@@ -60,7 +60,7 @@ class BaseCharacter extends Bopper
 
   @:allow(funkin.ui.debug.anim.DebugBoundingState)
   final _data:CharacterData;
-  final singTimeSec:Float;
+  final singTimeSteps:Float;
 
   /**
    * The offset between the corner of the sprite and the origin of the sprite (at the character's feet).
@@ -180,7 +180,7 @@ class BaseCharacter extends Bopper
     {
       this.characterName = _data.name;
       this.name = _data.name;
-      this.singTimeSec = _data.singTime;
+      this.singTimeSteps = _data.singTime;
       this.globalOffsets = _data.offsets;
       this.flipX = _data.flipX;
     }
@@ -193,6 +193,11 @@ class BaseCharacter extends Bopper
     return _data.death?.cameraOffsets ?? [0.0, 0.0];
   }
 
+  public function getDeathCameraZoom():Float
+  {
+    return _data.death?.cameraZoom ?? 1.0;
+  }
+
   /**
    * Gets the value of flipX from the character data.
    * `!getFlipX()` is the direction Boyfriend should face.
@@ -367,9 +372,9 @@ 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 singTimeSec:Float = singTimeSec * (Conductor.instance.beatLengthMs * 0.001); // x beats, to ms.
+      var singTimeSec:Float = singTimeSteps * (Conductor.instance.stepLengthMs / Constants.MS_PER_SEC); // x beats, to ms.
 
-      if (getCurrentAnimation().endsWith('miss')) singTimeSec *= 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.
@@ -378,7 +383,7 @@ class BaseCharacter extends Bopper
       FlxG.watch.addQuick('singTimeSec-${characterId}', singTimeSec);
       if (holdTimer > singTimeSec && shouldStopSinging)
       {
-        // trace('holdTimer reached ${holdTimer}sec (> ${singTimeSec}), stopping sing animation');
+        trace('holdTimer reached ${holdTimer}sec (> ${singTimeSec}), stopping sing animation');
         holdTimer = 0;
         dance(true);
       }
diff --git a/source/funkin/play/character/CharacterData.hx b/source/funkin/play/character/CharacterData.hx
index f3c7d7613..23710274c 100644
--- a/source/funkin/play/character/CharacterData.hx
+++ b/source/funkin/play/character/CharacterData.hx
@@ -744,4 +744,11 @@ typedef DeathData =
    * @default [0, 0]
    */
   var ?cameraOffsets:Array<Float>;
+
+  /**
+   * The amount to zoom the camera by while focusing on this character as they die.
+   * Value is a multiplier of the default camera zoom for the stage.
+   * @default 1.0
+   */
+  var ?cameraZoom:Float;
 }
diff --git a/source/funkin/play/cutscene/dialogue/Speaker.hx b/source/funkin/play/cutscene/dialogue/Speaker.hx
index f848d79c8..0d29481c4 100644
--- a/source/funkin/play/cutscene/dialogue/Speaker.hx
+++ b/source/funkin/play/cutscene/dialogue/Speaker.hx
@@ -218,25 +218,25 @@ class Speaker extends FlxSprite implements IDialogueScriptedClass implements IRe
     // If the animation exists, we're good.
     if (hasAnimation(name)) return name;
 
-    trace('[BOPPER] Animation "$name" does not exist!');
+    FlxG.log.notice('Speaker tried to play animation "$name" that does not exist, stripping suffixes...');
 
     // Attempt to strip a `-alt` suffix, if it exists.
     if (name.lastIndexOf('-') != -1)
     {
       var correctName = name.substring(0, name.lastIndexOf('-'));
-      trace('[BOPPER] Attempting to fallback to "$correctName"');
+      FlxG.log.notice('Speaker tried to play animation "$name" that does not exist, stripping suffixes...');
       return correctAnimationName(correctName);
     }
     else
     {
       if (name != 'idle')
       {
-        trace('[BOPPER] Attempting to fallback to "idle"');
+        FlxG.log.warn('Speaker tried to play animation "$name" that does not exist, fallback to idle...');
         return correctAnimationName('idle');
       }
       else
       {
-        trace('[BOPPER] Failing animation playback.');
+        FlxG.log.error('Speaker tried to play animation "idle" that does not exist! This is bad!');
         return null;
       }
     }
diff --git a/source/funkin/play/stage/Bopper.hx b/source/funkin/play/stage/Bopper.hx
index 1bc0632f9..7974900d8 100644
--- a/source/funkin/play/stage/Bopper.hx
+++ b/source/funkin/play/stage/Bopper.hx
@@ -236,25 +236,25 @@ class Bopper extends StageProp implements IPlayStateScriptedClass
     // If the animation exists, we're good.
     if (hasAnimation(name)) return name;
 
-    trace('[BOPPER] Animation "$name" does not exist!');
+    FlxG.log.notice('Bopper tried to play animation "$name" that does not exist, stripping suffixes...');
 
     // Attempt to strip a `-alt` suffix, if it exists.
     if (name.lastIndexOf('-') != -1)
     {
       var correctName = name.substring(0, name.lastIndexOf('-'));
-      trace('[BOPPER] Attempting to fallback to "$correctName"');
+      FlxG.log.notice('Bopper tried to play animation "$name" that does not exist, stripping suffixes...');
       return correctAnimationName(correctName);
     }
     else
     {
       if (name != 'idle')
       {
-        trace('[BOPPER] Attempting to fallback to "idle"');
+        FlxG.log.warn('Bopper tried to play animation "$name" that does not exist, fallback to idle...');
         return correctAnimationName('idle');
       }
       else
       {
-        trace('[BOPPER] Failing animation playback.');
+        FlxG.log.error('Bopper tried to play animation "idle" that does not exist! This is bad!');
         return null;
       }
     }
diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx
index c20202245..32c0509a5 100644
--- a/source/funkin/play/stage/Stage.hx
+++ b/source/funkin/play/stage/Stage.hx
@@ -110,6 +110,7 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements
       getBoyfriend().resetCharacter(true);
       // Reapply the camera offsets.
       var charData = _data.characters.bf;
+      getBoyfriend().scale.set(charData.scale, charData.scale);
       getBoyfriend().cameraFocusPoint.x += charData.cameraOffsets[0];
       getBoyfriend().cameraFocusPoint.y += charData.cameraOffsets[1];
     }
@@ -122,6 +123,7 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements
       getGirlfriend().resetCharacter(true);
       // Reapply the camera offsets.
       var charData = _data.characters.gf;
+      getGirlfriend().scale.set(charData.scale, charData.scale);
       getGirlfriend().cameraFocusPoint.x += charData.cameraOffsets[0];
       getGirlfriend().cameraFocusPoint.y += charData.cameraOffsets[1];
     }
@@ -130,6 +132,7 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements
       getDad().resetCharacter(true);
       // Reapply the camera offsets.
       var charData = _data.characters.dad;
+      getDad().scale.set(charData.scale, charData.scale);
       getDad().cameraFocusPoint.x += charData.cameraOffsets[0];
       getDad().cameraFocusPoint.y += charData.cameraOffsets[1];
     }
@@ -226,7 +229,7 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements
         switch (dataProp.scale)
         {
           case Left(value):
-            propSprite.scale.set(value);
+            propSprite.scale.set(value, value);
 
           case Right(values):
             propSprite.scale.set(values[0], values[1]);
@@ -435,6 +438,7 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements
         character.originalPosition.y = character.y + character.animOffsets[1];
       }
 
+      character.scale.set(charData.scale, charData.scale);
       character.cameraFocusPoint.x += charData.cameraOffsets[0];
       character.cameraFocusPoint.y += charData.cameraOffsets[1];
 
@@ -637,7 +641,30 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements
    */
   public function dispatchToCharacters(event:ScriptEvent):Void
   {
-    for (characterId in characters.keys())
+    var charList = this.characters.keys().array();
+
+    // Dad, then BF, then GF, in that order.
+
+    if (charList.contains('dad'))
+    {
+      dispatchToCharacter('dad', event);
+      charList.remove('dad');
+    }
+
+    if (charList.contains('bf'))
+    {
+      dispatchToCharacter('bf', event);
+      charList.remove('bf');
+    }
+
+    if (charList.contains('gf'))
+    {
+      dispatchToCharacter('gf', event);
+      charList.remove('gf');
+    }
+
+    // Then the rest of the characters, if any.
+    for (characterId in charList)
     {
       dispatchToCharacter(characterId, event);
     }