package funkin.ui.freeplay; import flixel.util.FlxSignal; import funkin.graphics.FunkinSprite; import funkin.audio.FunkinSound; import funkin.data.freeplay.player.PlayerRegistry; import funkin.data.freeplay.player.PlayerData.PlayerFreeplayDJData; import funkin.ui.freeplay.FreeplayState; @:nullSafety class FreeplayDJ extends FunkinSprite { // Represents the sprite's current status. // Without state machines I would have driven myself crazy years ago. // Made this PRIVATE so we can keep track of everything that can alter the state! // Add a function to this class if you want to edit this value from outside. private var currentState:FreeplayDJState = Intro; // A callback activated when the intro animation finishes. public var onIntroDone:FlxSignal = new FlxSignal(); // A callback activated when the idle easter egg plays. public var onIdleEasterEgg:FlxSignal = new FlxSignal(); var seenIdleEasterEgg:Bool = false; static final IDLE_EGG_PERIOD:Float = 60.0; static final IDLE_CARTOON_PERIOD:Float = 120.0; // Time since last special idle animation you. var timeIdling:Float = 0; final characterId:String = Constants.DEFAULT_CHARACTER; final playableCharData:Null; public function new(x:Float, y:Float, characterId:String) { this.characterId = characterId; var playableChar = PlayerRegistry.instance.fetchEntry(characterId); playableCharData = playableChar?.getFreeplayDJData(); super(x, y); // TODO: dj boyfriend has some crazy fucking light effects that cause lagspikes if they're not cached // bettertextureatlas can bake the filters, but they don't display properly in-game currently // remove `cacheOnLoad` once bettertextureatlas filter baking is improved loadTextureAtlas(playableCharData?.getAtlasPath(), { swfMode: true, cacheOnLoad: PlayerRegistry.instance.hasNewCharacter(), filterQuality: HIGH }); if (playableCharData?.useApplyStageMatrix() ?? false) { this.applyStageMatrix = true; } anim.onFrameChange.add(function(name, number, index) { if (name == playableCharData?.getAnimationPrefix('cartoon')) { if (number == playableCharData?.getCartoonSoundClickFrame()) { FunkinSound.playOnce(Paths.sound('remote_click')); } if (number == playableCharData?.getCartoonSoundCartoonFrame()) { runTvLogic(); } } }); FlxG.debugger.track(this); FlxG.console.registerObject("dj", this); anim.onFinish.add(onFinishAnim); anim.onLoop.add(onFinishAnim); FlxG.console.registerFunction("switchDJState_Intro", function() { currentState = Intro; }); FlxG.console.registerFunction("switchDJState_Idle", function() { currentState = Idle; }); FlxG.console.registerFunction("switchDJState_NewUnlock", function() { currentState = NewUnlock; }); FlxG.console.registerFunction("switchDJState_Confirm", function() { currentState = Confirm; }); FlxG.console.registerFunction("switchDJState_CharSelect", function() { toCharSelect(); }); FlxG.console.registerFunction("switchDJState_FistPump", function() { currentState = FistPump; }); FlxG.console.registerFunction("switchDJState_FistPumpIntro", function() { currentState = FistPumpIntro; }); FlxG.console.registerFunction("switchDJState_IdleEasterEgg", function() { currentState = IdleEasterEgg; }); FlxG.console.registerFunction("switchDJState_Cartoon", function() { currentState = Cartoon; }); } var lowPumpLoopPoint:Int = 4; public override function update(elapsed:Float):Void { switch (currentState) { case Intro: // Play the intro animation then leave this state immediately. var animPrefix = playableCharData?.getAnimationPrefix('intro'); if (animPrefix != null && (getCurrentAnimation() != animPrefix)) playFlashAnimation(animPrefix, true); timeIdling = 0; case Idle: // We are in this state the majority of the time. var animPrefix = playableCharData?.getAnimationPrefix('idle'); if (animPrefix != null && getCurrentAnimation() != animPrefix) { playFlashAnimation(animPrefix, true, false, true); } timeIdling += elapsed; case NewUnlock: var animPrefix = playableCharData?.getAnimationPrefix('newUnlock'); if (animPrefix != null && !hasAnimation(animPrefix)) { currentState = Idle; } if (animPrefix != null && getCurrentAnimation() != animPrefix) { playFlashAnimation(animPrefix, true, false, true); } case Confirm: var animPrefix = playableCharData?.getAnimationPrefix('confirm'); if (animPrefix != null && getCurrentAnimation() != animPrefix) playFlashAnimation(animPrefix, false); timeIdling = 0; case FistPumpIntro: var animPrefixA = playableCharData?.getAnimationPrefix('fistPump'); var animPrefixB = playableCharData?.getAnimationPrefix('loss'); if (getCurrentAnimation() == animPrefixA) { var endFrame = playableCharData?.getFistPumpIntroEndFrame() ?? 0; if (endFrame > -1 && anim.curAnim.curFrame >= endFrame) { playFlashAnimation(animPrefixA, true, false, false, playableCharData?.getFistPumpIntroStartFrame()); } } else if (getCurrentAnimation() == animPrefixB) { var endFrame = playableCharData?.getFistPumpIntroBadEndFrame() ?? 0; if (endFrame > -1 && anim.curAnim.curFrame >= endFrame) { playFlashAnimation(animPrefixB, true, false, false, playableCharData?.getFistPumpIntroBadStartFrame()); } } else { FlxG.log.warn("Unrecognized animation in FistPumpIntro: " + getCurrentAnimation()); } case FistPump: var animPrefixA = playableCharData?.getAnimationPrefix('fistPump'); var animPrefixB = playableCharData?.getAnimationPrefix('loss'); if (getCurrentAnimation() == animPrefixA) { var endFrame = playableCharData?.getFistPumpLoopEndFrame() ?? 0; if (endFrame > -1 && anim.curAnim.curFrame >= endFrame) { playFlashAnimation(animPrefixA, true, false, false, playableCharData?.getFistPumpLoopStartFrame()); } } else if (getCurrentAnimation() == animPrefixB) { var endFrame = playableCharData?.getFistPumpLoopBadEndFrame() ?? 0; if (endFrame > -1 && anim.curAnim.curFrame >= endFrame) { playFlashAnimation(animPrefixB, true, false, false, playableCharData?.getFistPumpLoopBadStartFrame()); } } else { FlxG.log.warn("Unrecognized animation in FistPump: " + getCurrentAnimation()); } case IdleEasterEgg: var animPrefix = playableCharData?.getAnimationPrefix('idleEasterEgg'); if (animPrefix != null && getCurrentAnimation() != animPrefix) { onIdleEasterEgg.dispatch(); playFlashAnimation(animPrefix, false); seenIdleEasterEgg = true; } timeIdling = 0; case Cartoon: var animPrefix = playableCharData?.getAnimationPrefix('cartoon'); if (animPrefix == null) { currentState = IdleEasterEgg; } else { if (getCurrentAnimation() != animPrefix) playFlashAnimation(animPrefix, true); timeIdling = 0; } default: // I shit myself. } // Call the superclass function AFTER updating the current state and playing the next animation. // This ensures that FlxAnimate starts rendering the new animation immediately. super.update(elapsed); } function onFinishAnim(name:String):Void { // var name = anim.curSymbol.name; if (name == playableCharData?.getAnimationPrefix('intro')) { if (PlayerRegistry.instance.hasNewCharacter()) { currentState = NewUnlock; } else { currentState = Idle; } onIntroDone.dispatch(); } else if (name == playableCharData?.getAnimationPrefix('idle')) { // trace('Finished idle'); if (timeIdling >= IDLE_EGG_PERIOD && !seenIdleEasterEgg) { currentState = IdleEasterEgg; } else if (timeIdling >= IDLE_CARTOON_PERIOD) { currentState = Cartoon; } } else if (name == playableCharData?.getAnimationPrefix('confirm')) { // trace('Finished confirm'); } else if (name == playableCharData?.getAnimationPrefix('fistPump')) { // trace('Finished fist pump'); currentState = Idle; } else if (name == playableCharData?.getAnimationPrefix('idleEasterEgg')) { // trace('Finished spook'); currentState = Idle; } else if (name == playableCharData?.getAnimationPrefix('loss')) { // trace('Finished loss reaction'); currentState = Idle; } else if (name == playableCharData?.getAnimationPrefix('cartoon')) { // trace('Finished cartoon'); var frame:Int = FlxG.random.bool(33) ? (playableCharData?.getCartoonLoopBlinkFrame() ?? 0) : (playableCharData?.getCartoonLoopFrame() ?? 0); // Character switches channels when the video ends, or at a 10% chance each time his idle loops. if (FlxG.random.bool(5)) { frame = playableCharData?.getCartoonChannelChangeFrame() ?? 0; // boyfriend switches channel code? // runTvLogic(); } trace('Replay idle: ${frame}'); var animPrefix = playableCharData?.getAnimationPrefix('cartoon'); if (animPrefix != null) playFlashAnimation(animPrefix, true, false, false, frame); // trace('Finished confirm'); } else if (name == playableCharData?.getAnimationPrefix('newUnlock')) { // Animation should loop. } else if (name == playableCharData?.getAnimationPrefix('charSelect')) { onCharSelectComplete(); } else { trace('Finished ${name}'); } } public function resetAFKTimer():Void { timeIdling = 0; seenIdleEasterEgg = false; } /** * Dynamic function, it's actually a variable you can reassign! * `dj.onCharSelectComplete = function() {};` */ public dynamic function onCharSelectComplete():Void { trace('onCharSelectComplete()'); } var offsetX:Float = 0.0; var offsetY:Float = 0.0; var cartoonSnd:Null = null; public var playingCartoon:Bool = false; public function runTvLogic() { if (cartoonSnd == null) { // tv is OFF, but getting turned on FunkinSound.playOnce(Paths.sound('tv_on'), 1.0, function() { loadCartoon(); }); } else { // plays it smidge after the click FunkinSound.playOnce(Paths.sound('channel_switch'), 1.0, function() { if (cartoonSnd != null) cartoonSnd.destroy(); loadCartoon(); }); } // loadCartoon(); } function loadCartoon() { cartoonSnd = FunkinSound.load(Paths.sound(getRandomFlashToon()), 1.0, false, true, true, function() { var animPrefix = playableCharData?.getAnimationPrefix('cartoon'); if (animPrefix != null) playFlashAnimation(animPrefix, true, false, false, 60); }); // Fade out music to 40% volume over 1 second. // This helps make the TV a bit more audible. FlxG.sound.music.fadeOut(1.0, 0.1); // Play the cartoon at a random time between the start and 5 seconds from the end. if (cartoonSnd != null) cartoonSnd.time = FlxG.random.float(0, Math.max(cartoonSnd.length - (5 * Constants.MS_PER_SEC), 0.0)); } final cartoonList:Array = openfl.utils.Assets.list().filter(function(path) return path.startsWith("assets/sounds/cartoons/")); function getRandomFlashToon():String { var randomFile = FlxG.random.getObject(cartoonList); // Strip folder prefix randomFile = randomFile.replace("assets/sounds/", ""); // Strip file extension randomFile = randomFile.substring(0, randomFile.length - 4); return randomFile; } public function confirm():Void { // We really don't want to play anything but the new character animation here. if (PlayerRegistry.instance.hasNewCharacter()) { currentState = NewUnlock; return; } currentState = Confirm; } public function toCharSelect():Void { var animPrefix = playableCharData?.getAnimationPrefix('charSelect'); if (animPrefix != null && hasAnimation(animPrefix)) { currentState = CharSelect; playFlashAnimation(animPrefix, true, false, false, 0); } else { FlxG.log.warn("Freeplay character does not have 'charSelect' animation!"); currentState = Confirm; // Call this immediately; otherwise, we get locked out of Character Select. onCharSelectComplete(); } } public function fistPumpIntro():Void { // We really don't want to play anything but the new character animation here. if (PlayerRegistry.instance.hasNewCharacter()) { currentState = NewUnlock; return; } currentState = FistPumpIntro; var animPrefix = playableCharData?.getAnimationPrefix('fistPump'); if (animPrefix != null) playFlashAnimation(animPrefix, true, false, false, playableCharData?.getFistPumpIntroStartFrame()); } public function fistPump():Void { // We really don't want to play anything but the new character animation here. if (PlayerRegistry.instance.hasNewCharacter()) { currentState = NewUnlock; return; } currentState = FistPump; var animPrefix = playableCharData?.getAnimationPrefix('fistPump'); if (animPrefix != null) playFlashAnimation(animPrefix, true, false, false, playableCharData?.getFistPumpLoopStartFrame()); } public function fistPumpLossIntro():Void { // We really don't want to play anything but the new character animation here. if (PlayerRegistry.instance.hasNewCharacter()) { currentState = NewUnlock; return; } currentState = FistPumpIntro; var animPrefix = playableCharData?.getAnimationPrefix('loss'); if (animPrefix != null) playFlashAnimation(animPrefix, true, false, false, playableCharData?.getFistPumpIntroBadStartFrame()); } public function fistPumpLoss():Void { // We really don't want to play anything but the new character animation here. if (PlayerRegistry.instance.hasNewCharacter()) { currentState = NewUnlock; return; } currentState = FistPump; var animPrefix = playableCharData?.getAnimationPrefix('loss'); if (animPrefix != null) playFlashAnimation(animPrefix, true, false, false, playableCharData?.getFistPumpLoopBadStartFrame()); } public function playFlashAnimation(id:String, Force:Bool = false, Reverse:Bool = false, Loop:Bool = false, Frame:Int = 0):Void { this.anim.play(id, Force, Reverse, Frame); this.anim.curAnim.looped = Loop; applyAnimationOffset(); } function applyAnimationOffset():Void { var animationName:String = getCurrentAnimation(); var animationOffsets:Null> = playableCharData?.getAnimationOffsetsByPrefix(animationName); var globalOffsets:Array = [this.x, this.y]; if (animationOffsets != null) { var finalOffsetX:Float = 0; var finalOffsetY:Float = 0; if (this.applyStageMatrix) { finalOffsetX = animationOffsets[0]; finalOffsetY = animationOffsets[1]; } else { finalOffsetX = globalOffsets[0] - animationOffsets[0] - (FreeplayState.CUTOUT_WIDTH * FreeplayState.DJ_POS_MULTI); finalOffsetY = globalOffsets[1] - animationOffsets[1]; } trace('Successfully applied offset ($animationName): ' + animationOffsets[0] + ', ' + animationOffsets[1]); offset.set(finalOffsetX, finalOffsetY); } else { trace('No offset found ($animationName), defaulting to: 0, 0'); offset.set(0, 0); } } public override function destroy():Void { super.destroy(); if (cartoonSnd != null) { cartoonSnd.destroy(); cartoonSnd = null; } } } enum FreeplayDJState { /** * Character enters the frame and transitions to Idle. */ Intro; /** * Character loops in idle. */ Idle; /** * Plays an easter egg animation after a period in Idle, then reverts to Idle. */ IdleEasterEgg; /** * Plays an elaborate easter egg animation. Does not revert until another animation is triggered. */ Cartoon; /** * Player has selected a song. */ Confirm; /** * Character preps to play the fist pump animation; plays after the Results screen. * The actual frame label that gets played may vary based on the player's success. */ FistPumpIntro; /** * Character plays the fist pump animation. * The actual frame label that gets played may vary based on the player's success. */ FistPump; /** * Plays an animation to indicate that the player has a new unlock in Character Select. * Overrides all idle animations as well as the fist pump. Only Confirm and CharSelect will override this. */ NewUnlock; /** * Plays an animation to transition to the Character Select screen. */ CharSelect; }