1
0
Fork 0
mirror of https://github.com/ninjamuffin99/Funkin.git synced 2025-08-30 18:34:51 +00:00
Funkin/source/funkin/play/GameOverSubState.hx
2025-08-11 11:37:06 -05:00

630 lines
18 KiB
Haxe

package funkin.play;
import funkin.ui.freeplay.charselect.PlayableCharacter;
import flixel.FlxState;
import funkin.data.freeplay.player.PlayerRegistry;
import flixel.FlxG;
import flixel.FlxObject;
import flixel.FlxSprite;
import flixel.util.FlxColor;
import flixel.util.FlxTimer;
import funkin.util.HapticUtil;
import funkin.audio.FunkinSound;
import funkin.graphics.FunkinSprite;
import funkin.modding.events.ScriptEvent;
import funkin.modding.events.ScriptEventDispatcher;
import funkin.play.character.BaseCharacter;
import funkin.ui.freeplay.FreeplayState;
import funkin.ui.MusicBeatSubState;
import funkin.ui.story.StoryMenuState;
import funkin.util.MathUtil;
import funkin.effects.RetroCameraFade;
import flixel.math.FlxPoint;
import funkin.util.TouchUtil;
#if FEATURE_MOBILE_ADVERTISEMENTS
import funkin.mobile.util.AdMobUtil;
#end
/**
* A substate which renders over the PlayState when the player dies.
* Displays the player death animation, plays the music, and handles restarting the song.
*
* The newest implementation uses a substate, which prevents having to reload the song and stage each reset.
*/
@:nullSafety
class GameOverSubState extends MusicBeatSubState
{
/**
* The currently active GameOverSubState.
* There should be only one GameOverSubState in existance at a time, we can use a singleton.
*/
public static var instance:Null<GameOverSubState> = null;
/**
* Which alternate animation on the character to use.
* You can set this via script.
* For example, playing a different animation when BF dies in Week 4
* or Pico dies in Weekend 1.
*/
public static var animationSuffix:String = '';
/**
* Which alternate game over music to use.
* You can set this via script.
* For example, the bf-pixel script sets this to `-pixel`
* and the pico-playable script sets this to `Pico`.
*/
public static var musicSuffix:String = '';
/**
* Which alternate "blue ball" sound effect to use.
*/
public static var blueBallSuffix:String = '';
static var blueballed:Bool = false;
/**
* The boyfriend character.
*/
var boyfriend:Null<BaseCharacter> = null;
/**
* The invisible object in the scene which the camera focuses on.
*/
var cameraFollowPoint:FlxObject;
/**
* The music playing in the background of the state.
*/
var gameOverMusic:Null<FunkinSound> = null;
/**
* Whether the player has confirmed and prepared to restart the level or to go back to the freeplay menu.
* This means the animation and transition have already started.
*/
var isEnding:Bool = false;
/**
* Whether the death music is on its first loop.
*/
var isStarting:Bool = true;
var isChartingMode:Bool = false;
var mustNotExit:Bool = false;
var transparent:Bool;
static final CAMERA_ZOOM_DURATION:Float = 0.5;
var targetCameraZoom:Float = 1.0;
var canInput:Bool = false;
public function new(params:GameOverParams)
{
super();
this.isChartingMode = params?.isChartingMode ?? false;
transparent = params.transparent;
cameraFollowPoint = new FlxObject(0, 0, 1, 1);
if (parentPlayState != null)
{
cameraFollowPoint.x = parentPlayState.cameraFollowPoint.x;
cameraFollowPoint.y = parentPlayState.cameraFollowPoint.y;
}
}
/**
* The PlayState that this GameOverSubState is displaying on top of.
*/
public var parentPlayState:Null<PlayState>;
/**
* Reset the game over configuration to the default.
*/
public static function reset():Void
{
animationSuffix = '';
musicSuffix = '';
blueBallSuffix = '';
blueballed = false;
}
public override function create():Void
{
if (instance != null)
{
FlxG.log.warn('WARNING: GameOverSubState instance already exists. This should not happen.');
}
instance = this;
super.create();
parentPlayState = cast _parentState;
//
// Set up the visuals
//
// Add a black background to the screen.
var bg:FunkinSprite = new FunkinSprite().makeSolidColor(FlxG.width * 2, FlxG.height * 2, FlxColor.BLACK);
// We make this transparent so that we can see the stage underneath during debugging,
// but it's normally opaque.
bg.alpha = transparent ? 0.25 : 1.0;
bg.scrollFactor.set();
bg.screenCenter();
add(bg);
// Pluck Boyfriend from the PlayState and place him (in the same position) in the GameOverSubState.
// We can then play the character's `firstDeath` animation.
if ((parentPlayState?.isMinimalMode ?? true)) {}
else
{
boyfriend = parentPlayState?.currentStage?.getBoyfriend(true);
if (boyfriend != null)
{
boyfriend.canPlayOtherAnims = true;
boyfriend.isDead = true;
add(boyfriend);
boyfriend.resetCharacter();
}
}
setCameraTarget();
//
// Set up the audio
//
// The conductor now represents the BPM of the game over music.
Conductor.instance.update(0);
#if mobile
addBackButton(FlxG.width - 230, FlxG.height - 200, FlxColor.WHITE, goBack);
#end
HapticUtil.vibrate(0, Constants.DEFAULT_VIBRATION_DURATION);
// Allow input a second later to prevent accidental gameover skips.
new FlxTimer().start(1, function(tmr:FlxTimer) {
canInput = true;
});
}
function setCameraTarget():Void
{
if (parentPlayState == null || parentPlayState.isMinimalMode || boyfriend == null) return;
// Assign a camera follow point to the boyfriend's position.
cameraFollowPoint = new FlxObject(parentPlayState.cameraFollowPoint.x, parentPlayState.cameraFollowPoint.y, 1, 1);
cameraFollowPoint.x = getMidPointOld(boyfriend).x;
cameraFollowPoint.y = getMidPointOld(boyfriend).y;
var offsets:Array<Float> = boyfriend.getDeathCameraOffsets();
cameraFollowPoint.x += offsets[0];
cameraFollowPoint.y += offsets[1];
add(cameraFollowPoint);
@:nullSafety(Off)
FlxG.camera.target = null;
FlxG.camera.follow(cameraFollowPoint, LOCKON, Constants.DEFAULT_CAMERA_FOLLOW_RATE / 2);
targetCameraZoom = (parentPlayState?.currentStage?.camZoom ?? 1.0) * boyfriend.getDeathCameraZoom();
}
/**
* FlxSprite.getMidpoint(); calculations changed in this git commit
* https://github.com/HaxeFlixel/flixel/commit/1553b5af0871462fcefedc091b7885437d6c36d2
* https://github.com/HaxeFlixel/flixel/pull/3125
*
* So we use this to do the old math that gets the midpoint of our graphics
* Luckily, we don't use getGraphicMidpoint() much in the code, so it's fine being in GameoverSubState here.
* @return FlxPoint
*/
function getMidPointOld(spr:FlxSprite, ?point:FlxPoint):FlxPoint
{
if (point == null) point = FlxPoint.get();
return point.set(spr.x + spr.frameWidth * 0.5 * spr.scale.x, spr.y + spr.frameHeight * 0.5 * spr.scale.y);
}
/**
* Forcibly reset the camera zoom level to that of the current stage.
* This prevents camera zoom events from adversely affecting the game over state.
*/
public function resetCameraZoom():Void
{
// Apply camera zoom level from stage data.
FlxG.camera.zoom = parentPlayState?.currentStage?.camZoom ?? 1.0;
}
var hasStartedAnimation:Bool = false;
override function update(elapsed:Float):Void
{
if (!hasStartedAnimation)
{
hasStartedAnimation = true;
if (boyfriend == null || (parentPlayState?.isMinimalMode ?? true))
{
// Play the "blue balled" sound. May play a variant if one has been assigned.
playBlueBalledSFX();
}
else
{
if (boyfriend.hasAnimation('fakeoutDeath') && FlxG.random.bool((1 / 4096) * 100))
{
boyfriend.playAnimation('fakeoutDeath', true, false);
}
else
{
boyfriend.playAnimation('firstDeath', true, false); // ignoreOther is set to FALSE since you WANT to be able to mash and confirm game over!
// Play the "blue balled" sound. May play a variant if one has been assigned.
playBlueBalledSFX();
}
}
}
// Smoothly lerp the camera
FlxG.camera.zoom = MathUtil.smoothLerpPrecision(FlxG.camera.zoom, targetCameraZoom, elapsed, CAMERA_ZOOM_DURATION);
//
// Handle user inputs.
//
// Restart the level when pressing the assigned key.
if ((controls.ACCEPT #if mobile || (TouchUtil.pressAction() && !TouchUtil.overlaps(backButton) && canInput) #end)
&& blueballed
&& !mustNotExit)
{
blueballed = false;
confirmDeath();
}
if (controls.BACK && !mustNotExit && !isEnding) goBack();
if (gameOverMusic != null && gameOverMusic.playing)
{
// Match the conductor to the music.
// This enables the stepHit and beatHit events.
Conductor.instance.update(gameOverMusic.time);
}
else if (boyfriend != null)
{
if ((parentPlayState?.isMinimalMode ?? true))
{
// Do nothing?
}
else
{
// Music hasn't started yet.
if (boyfriend.getDeathQuote() != null)
{
if (boyfriend.getCurrentAnimation().startsWith('firstDeath') && boyfriend.isAnimationFinished() && !hasPlayedDeathQuote)
{
hasPlayedDeathQuote = true;
playDeathQuote();
}
}
else
{
// Start music at normal volume once the initial death animation finishes.
if (boyfriend.getCurrentAnimation().startsWith('firstDeath') && boyfriend.isAnimationFinished())
{
startDeathMusic(1.0, false);
boyfriend.playAnimation('deathLoop' + animationSuffix);
}
}
}
}
// Start death music before firstDeath gets replaced
super.update(elapsed);
}
var deathQuoteSound:Null<FunkinSound> = null;
function playDeathQuote():Void
{
if (isEnding) return;
if (boyfriend == null) return;
if (parentPlayState == null) return;
var deathQuote:Null<String> = boyfriend.getDeathQuote();
if (deathQuote == null) return;
if (deathQuoteSound != null)
{
deathQuoteSound.stop();
deathQuoteSound = null;
}
// Start music at lower volume
startDeathMusic(0.2, false);
boyfriend.playAnimation('deathLoop' + animationSuffix);
deathQuoteSound = FunkinSound.playOnce(deathQuote, function() {
// Once the quote ends, fade in the game over music.
if (!isEnding && gameOverMusic != null)
{
gameOverMusic.fadeIn(4, 0.2, 1);
}
});
}
/**
* Do behavior which occurs when you confirm and move to restart the level.
*/
function confirmDeath():Void
{
if (!isEnding)
{
isEnding = true;
// Stop death quotes immediately.
hasPlayedDeathQuote = true;
if (deathQuoteSound != null)
{
deathQuoteSound.stop();
deathQuoteSound = null;
}
startDeathMusic(1.0, true); // isEnding changes this function's behavior.
if ((parentPlayState?.isMinimalMode ?? true) || boyfriend == null) {}
else
{
boyfriend.playAnimation('deathConfirm' + animationSuffix, true);
}
// After the animation finishes...
new FlxTimer().start(0.7, function(tmr:FlxTimer) {
// ...fade out the graphics. Then after that happens...
var resetPlaying = function(pixel:Bool = false) {
// ...close the GameOverSubState.
if (pixel) RetroCameraFade.fadeBlack(FlxG.camera, 10, 1);
else
FlxG.camera.fade(FlxColor.BLACK, 1, true, null, true);
if (parentPlayState != null) parentPlayState.needsReset = true;
if ((parentPlayState?.isMinimalMode ?? true) || boyfriend == null) {}
else
{
// Readd Boyfriend to the stage.
boyfriend.isDead = false;
remove(boyfriend);
parentPlayState?.currentStage?.addCharacter(boyfriend, BF);
}
// Snap reset the camera which may have changed because of the player character data.
resetCameraZoom();
// Close the substate.
close();
};
if (musicSuffix == '-pixel')
{
RetroCameraFade.fadeToBlack(FlxG.camera, 10, 2);
new FlxTimer().start(2, _ -> {
FlxG.camera.filters = [];
#if FEATURE_MOBILE_ADVERTISEMENTS
if (AdMobUtil.PLAYING_COUNTER >= AdMobUtil.MAX_BEFORE_AD)
{
AdMobUtil.loadInterstitial(function():Void {
AdMobUtil.PLAYING_COUNTER = 0;
resetPlaying(true);
});
}
else
resetPlaying(true);
#else
resetPlaying(true);
#end
});
}
else
{
FlxG.camera.fade(FlxColor.BLACK, 2, false, function() {
#if FEATURE_MOBILE_ADVERTISEMENTS
if (AdMobUtil.PLAYING_COUNTER >= AdMobUtil.MAX_BEFORE_AD)
{
AdMobUtil.loadInterstitial(function():Void {
AdMobUtil.PLAYING_COUNTER = 0;
resetPlaying();
});
}
else
resetPlaying();
#else
resetPlaying();
#end
}, true);
}
});
}
}
public override function dispatchEvent(event:ScriptEvent):Void
{
super.dispatchEvent(event);
ScriptEventDispatcher.callEvent(boyfriend, event);
}
/**
* Rather than hardcoding stuff, we look for the presence of a music file
* with the given suffix, and strip it down until we find one that's valid.
*/
function resolveMusicPath(suffix:String, starting:Bool = false, ending:Bool = false):Null<String>
{
var basePath:String = 'gameplay/gameover/gameOver';
if (ending) basePath += 'End';
else if (starting) basePath += 'Start';
var musicPath:String = Paths.music(basePath + suffix);
while (!Assets.exists(musicPath) && suffix.length > 0)
{
suffix = suffix.split('-').slice(0, -1).join('-');
musicPath = Paths.music(basePath + suffix);
}
if (!Assets.exists(musicPath)) return null;
trace('Resolved music path: ' + musicPath);
return musicPath;
}
/**
* Starts the death music at the appropriate volume.
* @param startingVolume The initial volume for the music.
* @param force Whether or not to force the music to restart.
*/
public function startDeathMusic(startingVolume:Float = 1, force:Bool = false):Void
{
var musicPath:Null<String> = resolveMusicPath(musicSuffix, isStarting, isEnding);
var onComplete:Void->Void = () -> {};
if (isStarting)
{
if (musicPath == null)
{
// Looked for starting music and didn't find it. Use middle music instead.
isStarting = false;
musicPath = resolveMusicPath(musicSuffix, isStarting, isEnding);
}
else
{
onComplete = function() {
isStarting = true;
// We need to force to ensure that the non-starting music plays.
startDeathMusic(0.0, true);
};
}
}
if (musicPath == null)
{
FlxG.log.warn('[GAMEOVER] Could not find game over music at path ($musicPath)!');
return;
}
else if (gameOverMusic == null || !gameOverMusic.playing || force)
{
if (gameOverMusic != null) gameOverMusic.stop();
gameOverMusic = FunkinSound.load(musicPath);
if (gameOverMusic == null) return;
gameOverMusic.volume = startingVolume;
gameOverMusic.looped = !(isEnding || isStarting);
gameOverMusic.onComplete = onComplete;
gameOverMusic.play();
}
else
{
@:privateAccess
trace('Music already playing! ${gameOverMusic?._label}');
}
}
/**
* Pressing BACK from the Game Over screen should return the player to the Story/Freeplay menu as appropriate.
*/
public function goBack():Void
{
if (blueballed == false) return;
isEnding = true;
blueballed = false;
if (parentPlayState != null) parentPlayState.deathCounter = 0;
// PlayState.seenCutscene = false; // old thing...
if (gameOverMusic != null) gameOverMusic.stop();
// Stop death quotes immediately.
hasPlayedDeathQuote = true;
if (deathQuoteSound != null)
{
deathQuoteSound.stop();
deathQuoteSound = null;
}
if (isChartingMode)
{
this.close();
if (FlxG.sound.music != null) FlxG.sound.music.pause(); // Don't reset song position!
if (parentPlayState != null) parentPlayState.close(); // This only works because PlayState is a substate!
parentPlayState = null;
return;
}
else
{
var targetState:funkin.ui.transition.stickers.StickerSubState->FlxState = (PlayStatePlaylist.isStoryMode) ? (sticker) ->
new StoryMenuState(sticker) : (sticker) -> FreeplayState.build(sticker);
if (PlayStatePlaylist.isStoryMode)
{
PlayStatePlaylist.reset();
}
var stickerPackId:Null<String> = parentPlayState?.currentChart?.stickerPack;
if (stickerPackId == null)
{
var playerCharacterId:Null<String> = PlayerRegistry.instance.getCharacterOwnerId(parentPlayState?.currentChart?.characters.player);
var playerCharacter:Null<PlayableCharacter> = PlayerRegistry.instance.fetchEntry(playerCharacterId ?? Constants.DEFAULT_CHARACTER);
if (playerCharacter != null)
{
stickerPackId = playerCharacter.getStickerPackID();
}
}
openSubState(new funkin.ui.transition.stickers.StickerSubState({targetState: targetState, stickerPack: stickerPackId}));
}
}
/**
* Play the sound effect that occurs when
* boyfriend's testicles get utterly annihilated.
*/
public static function playBlueBalledSFX():Void
{
blueballed = true;
if (Assets.exists(Paths.sound('gameplay/gameover/fnf_loss_sfx' + blueBallSuffix)))
{
FunkinSound.playOnce(Paths.sound('gameplay/gameover/fnf_loss_sfx' + blueBallSuffix));
}
else
{
FlxG.log.error('Missing blue ball sound effect: ' + Paths.sound('gameplay/gameover/fnf_loss_sfx' + blueBallSuffix));
}
}
var hasPlayedDeathQuote:Bool = false;
public override function destroy():Void
{
super.destroy();
if (gameOverMusic != null)
{
gameOverMusic.stop();
gameOverMusic = null;
}
blueballed = false;
instance = null;
}
public override function toString():String
{
return 'GameOverSubState';
}
}
/**
* Parameters used to instantiate a GameOverSubState.
*/
typedef GameOverParams =
{
var isChartingMode:Bool;
var transparent:Bool;
}