diff --git a/assets b/assets index e19aaca19..721056203 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit e19aaca19254ec2717a4f942005f32b42b6ecb18 +Subproject commit 7210562035c7ab6d2122606ec607b3e897a5ef20 diff --git a/source/funkin/data/freeplay/player/PlayerData.hx b/source/funkin/data/freeplay/player/PlayerData.hx index f6c085018..55657ba46 100644 --- a/source/funkin/data/freeplay/player/PlayerData.hx +++ b/source/funkin/data/freeplay/player/PlayerData.hx @@ -38,6 +38,17 @@ class PlayerData @:optional public var freeplayDJ:Null = null; + /** + * Data for displaying this character in the Character Select menu. + * If null, exclude from Character Select. + */ + @:optional + public var charSelect:Null = null; + + /** + * Data for displaying this character in the results screen. + */ + @:optional public var results:Null = null; /** @@ -97,6 +108,9 @@ class PlayerFreeplayDJData @:optional var cartoon:Null; + @:optional + var fistPump:Null; + public function new() { animationMap = new Map(); @@ -183,6 +197,58 @@ class PlayerFreeplayDJData { return cartoon?.channelChangeFrame ?? 60; } + + public function getFistPumpIntroStartFrame():Int + { + return fistPump?.introStartFrame ?? 0; + } + + public function getFistPumpIntroEndFrame():Int + { + return fistPump?.introEndFrame ?? 0; + } + + public function getFistPumpLoopStartFrame():Int + { + return fistPump?.loopStartFrame ?? 0; + } + + public function getFistPumpLoopEndFrame():Int + { + return fistPump?.loopEndFrame ?? 0; + } + + public function getFistPumpIntroBadStartFrame():Int + { + return fistPump?.introBadStartFrame ?? 0; + } + + public function getFistPumpIntroBadEndFrame():Int + { + return fistPump?.introBadEndFrame ?? 0; + } + + public function getFistPumpLoopBadStartFrame():Int + { + return fistPump?.loopBadStartFrame ?? 0; + } + + public function getFistPumpLoopBadEndFrame():Int + { + return fistPump?.loopBadEndFrame ?? 0; + } +} + +class PlayerCharSelectData +{ + /** + * A zero-indexed number for the character's preferred position in the grid. + * 0 = top left, 4 = center, 8 = bottom right + * In the event of a conflict, the first character alphabetically gets it, + * and others get shifted over. + */ + @:optional + public var position:Null; } typedef PlayerResultsData = @@ -242,3 +308,30 @@ typedef PlayerFreeplayDJCartoonData = var loopFrame:Int; var channelChangeFrame:Int; } + +typedef PlayerFreeplayDJFistPumpData = +{ + @:default(0) + var introStartFrame:Int; + + @:default(4) + var introEndFrame:Int; + + @:default(4) + var loopStartFrame:Int; + + @:default(-1) + var loopEndFrame:Int; + + @:default(0) + var introBadStartFrame:Int; + + @:default(4) + var introBadEndFrame:Int; + + @:default(4) + var loopBadStartFrame:Int; + + @:default(-1) + var loopBadEndFrame:Int; +}; diff --git a/source/funkin/data/freeplay/player/PlayerRegistry.hx b/source/funkin/data/freeplay/player/PlayerRegistry.hx index be8730ccd..c0a15ed1c 100644 --- a/source/funkin/data/freeplay/player/PlayerRegistry.hx +++ b/source/funkin/data/freeplay/player/PlayerRegistry.hx @@ -3,6 +3,7 @@ package funkin.data.freeplay.player; import funkin.data.freeplay.player.PlayerData; import funkin.ui.freeplay.charselect.PlayableCharacter; import funkin.ui.freeplay.charselect.ScriptedPlayableCharacter; +import funkin.save.Save; class PlayerRegistry extends BaseRegistry { @@ -53,6 +54,41 @@ class PlayerRegistry extends BaseRegistry log('Loaded ${countEntries()} playable characters with ${ownedCharacterIds.size()} associations.'); } + public function countUnlockedCharacters():Int + { + var count = 0; + + for (charId in listEntryIds()) + { + var player = fetchEntry(charId); + if (player == null) continue; + + if (player.isUnlocked()) count++; + } + + return count; + } + + public function hasNewCharacter():Bool + { + var characters = Save.instance.charactersSeen.clone(); + + for (charId in listEntryIds()) + { + var player = fetchEntry(charId); + if (player == null) continue; + + if (!player.isUnlocked()) continue; + if (characters.contains(charId)) continue; + + // This character is unlocked but we haven't seen them in Freeplay yet. + return true; + } + + // Fallthrough case. + return false; + } + /** * Get the playable character associated with a given stage character. * @param characterId The stage character ID. diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx index 928f392e5..f4b177763 100644 --- a/source/funkin/play/PlayState.hx +++ b/source/funkin/play/PlayState.hx @@ -1979,7 +1979,7 @@ class PlayState extends MusicBeatSubState if (vocals == null) return; // Skip this if the music is paused (GameOver, Pause menu, start-of-song offset, etc.) - if (!FlxG.sound.music.playing) return; + if (!(FlxG.sound.music?.playing ?? false)) return; var timeToPlayAt:Float = Conductor.instance.songPosition - Conductor.instance.instrumentalOffset; FlxG.sound.music.pause(); vocals.pause(); @@ -2221,10 +2221,14 @@ class PlayState extends MusicBeatSubState // Calling event.cancelEvent() skips all the other logic! Neat! if (event.eventCanceled) continue; + // Skip handling the miss in botplay! + if (!isBotPlayMode) + { // Judge the miss. // NOTE: This is what handles the scoring. trace('Missed note! ${note.noteData}'); onNoteMiss(note, event.playSound, event.healthChange); + } note.handledMiss = true; } @@ -2321,9 +2325,16 @@ class PlayState extends MusicBeatSubState playerStrumline.pressKey(input.noteDirection); + // Don't credit or penalize inputs in Bot Play. + if (isBotPlayMode) continue; + var notesInDirection:Array = notesByDirection[input.noteDirection]; - if (!Constants.GHOST_TAPPING && notesInDirection.length == 0) + #if FEATURE_GHOST_TAPPING + if ((!playerStrumline.mayGhostTap()) && notesInDirection.length == 0) + #else + if (notesInDirection.length == 0) + #end { // Pressed a wrong key with no notes nearby. // Perform a ghost miss (anti-spam). @@ -2333,16 +2344,6 @@ class PlayState extends MusicBeatSubState playerStrumline.playPress(input.noteDirection); trace('PENALTY Score: ${songScore}'); } - else if (Constants.GHOST_TAPPING && (!playerStrumline.mayGhostTap()) && notesInDirection.length == 0) - { - // Pressed a wrong key with notes visible on-screen. - // Perform a ghost miss (anti-spam). - ghostNoteMiss(input.noteDirection, notesInRange.length > 0); - - // Play the strumline animation. - playerStrumline.playPress(input.noteDirection); - trace('PENALTY Score: ${songScore}'); - } else if (notesInDirection.length == 0) { // Press a key with no penalty. diff --git a/source/funkin/play/character/CharacterData.hx b/source/funkin/play/character/CharacterData.hx index d447eb97f..bac2c7141 100644 --- a/source/funkin/play/character/CharacterData.hx +++ b/source/funkin/play/character/CharacterData.hx @@ -305,6 +305,8 @@ class CharacterDataParser icon = "darnell"; case "senpai-angry": icon = "senpai"; + case "spooky-dark": + icon = "spooky"; case "tankman-atlas": icon = "tankman"; } diff --git a/source/funkin/play/event/ZoomCameraSongEvent.hx b/source/funkin/play/event/ZoomCameraSongEvent.hx index 748abda19..ee2eea8ad 100644 --- a/source/funkin/play/event/ZoomCameraSongEvent.hx +++ b/source/funkin/play/event/ZoomCameraSongEvent.hx @@ -114,7 +114,7 @@ class ZoomCameraSongEvent extends SongEvent name: 'zoom', title: 'Zoom Level', defaultValue: 1.0, - step: 0.1, + step: 0.05, type: SongEventFieldType.FLOAT, units: 'x' }, diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx index 1e5782ad2..e894f9c62 100644 --- a/source/funkin/play/notes/Strumline.hx +++ b/source/funkin/play/notes/Strumline.hx @@ -94,6 +94,10 @@ class Strumline extends FlxSpriteGroup final noteStyle:NoteStyle; + #if FEATURE_GHOST_TAPPING + var ghostTapTimer:Float = 0.0; + #end + /** * The note data for the song. Should NOT be altered after the song starts, * so we can easily rewind. @@ -179,21 +183,36 @@ class Strumline extends FlxSpriteGroup super.update(elapsed); updateNotes(); + + #if FEATURE_GHOST_TAPPING + updateGhostTapTimer(elapsed); + #end } + #if FEATURE_GHOST_TAPPING /** * Returns `true` if no notes are in range of the strumline and the player can spam without penalty. */ public function mayGhostTap():Bool { - // TODO: Refine this. Only querying "can be hit" is too tight but "is being rendered" is too loose. - // Also, if you just hit a note, there should be a (short) period where this is off so you can't spam. + // Any notes in range of the strumline. + if (getNotesMayHit().length > 0) + { + return false; + } + // Any hold notes in range of the strumline. + if (getHoldNotesHitOrMissed().length > 0) + { + return false; + } - // If there are any notes on screen, we can't ghost tap. - return notes.members.filter(function(note:NoteSprite) { - return note != null && note.alive && !note.hasBeenHit; - }).length == 0; + // Note has been hit recently. + if (ghostTapTimer > 0.0) return false; + + // **yippee** + return true; } + #end /** * Return notes that are within `Constants.HIT_WINDOW` ms of the strumline. @@ -492,6 +511,32 @@ class Strumline extends FlxSpriteGroup } } + /** + * Return notes that are within, or way after, `Constants.HIT_WINDOW` ms of the strumline. + * @return An array of `NoteSprite` objects. + */ + public function getNotesOnScreen():Array + { + return notes.members.filter(function(note:NoteSprite) { + return note != null && note.alive && !note.hasBeenHit; + }); + } + + #if FEATURE_GHOST_TAPPING + function updateGhostTapTimer(elapsed:Float):Void + { + // If it's still our turn, don't update the ghost tap timer. + if (getNotesOnScreen().length > 0) return; + + ghostTapTimer -= elapsed; + + if (ghostTapTimer <= 0) + { + ghostTapTimer = 0; + } + } + #end + /** * Called when the PlayState skips a large amount of time forward or backward. */ @@ -563,6 +608,10 @@ class Strumline extends FlxSpriteGroup playStatic(dir); } resetScrollSpeed(); + + #if FEATURE_GHOST_TAPPING + ghostTapTimer = 0; + #end } public function applyNoteData(data:Array):Void @@ -602,6 +651,10 @@ class Strumline extends FlxSpriteGroup note.holdNoteSprite.sustainLength = (note.holdNoteSprite.strumTime + note.holdNoteSprite.fullSustainLength) - conductorInUse.songPosition; } + + #if FEATURE_GHOST_TAPPING + ghostTapTimer = Constants.GHOST_TAP_DELAY; + #end } public function killNote(note:NoteSprite):Void diff --git a/source/funkin/play/stage/Bopper.hx b/source/funkin/play/stage/Bopper.hx index de19c51b4..96a217d31 100644 --- a/source/funkin/play/stage/Bopper.hx +++ b/source/funkin/play/stage/Bopper.hx @@ -315,7 +315,7 @@ class Bopper extends StageProp implements IPlayStateScriptedClass public function isAnimationFinished():Bool { - return this.animation.finished; + return this.animation?.finished ?? false; } public function setAnimationOffsets(name:String, xOffset:Float, yOffset:Float):Void diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx index c547b9f5f..c42e41cad 100644 --- a/source/funkin/play/stage/Stage.hx +++ b/source/funkin/play/stage/Stage.hx @@ -436,8 +436,9 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements // Start with the per-stage character position. // Subtracting the origin ensures characters are positioned relative to their feet. // Subtracting the global offset allows positioning on a per-character basis. - character.x = stageCharData.position[0] - character.characterOrigin.x + character.globalOffsets[0]; - character.y = stageCharData.position[1] - character.characterOrigin.y + character.globalOffsets[1]; + // We previously applied the global offset here but that is now done elsewhere. + character.x = stageCharData.position[0] - character.characterOrigin.x; + character.y = stageCharData.position[1] - character.characterOrigin.y; @:privateAccess(funkin.play.stage.Bopper) { diff --git a/source/funkin/save/Save.hx b/source/funkin/save/Save.hx index 2900ce2be..a3d945594 100644 --- a/source/funkin/save/Save.hx +++ b/source/funkin/save/Save.hx @@ -121,6 +121,12 @@ class Save modOptions: [], }, + unlocks: + { + // Default to having seen the default character. + charactersSeen: ["bf"], + }, + optionsChartEditor: { // Reasonable defaults. @@ -393,6 +399,22 @@ class Save return data.optionsChartEditor.playbackSpeed; } + public var charactersSeen(get, never):Array; + + function get_charactersSeen():Array + { + return data.unlocks.charactersSeen; + } + + /** + * When we've seen a character unlock, add it to the list of characters seen. + * @param character + */ + public function addCharacterSeen(character:String):Void + { + data.unlocks.charactersSeen.push(character); + } + /** * Return the score the user achieved for a given level on a given difficulty. * @@ -471,10 +493,18 @@ class Save for (difficulty in difficultyList) { var score:Null = getLevelScore(levelId, difficulty); - // TODO: Do we need to check accuracy/score here? if (score != null) { - return true; + if (score.score > 0) + { + // Level has score data, which means we cleared it! + return true; + } + else + { + // Level has score data, but the score is 0. + return false; + } } } return false; @@ -630,10 +660,18 @@ class Save for (difficulty in difficultyList) { var score:Null = getSongScore(songId, difficulty); - // TODO: Do we need to check accuracy/score here? if (score != null) { - return true; + if (score.score > 0) + { + // Level has score data, which means we cleared it! + return true; + } + else + { + // Level has score data, but the score is 0. + return false; + } } } return false; @@ -956,6 +994,8 @@ typedef RawSaveData = */ var options:SaveDataOptions; + var unlocks:SaveDataUnlocks; + /** * The user's favorited songs in the Freeplay menu, * as a list of song IDs. @@ -980,6 +1020,15 @@ typedef SaveApiNewgroundsData = var sessionId:Null; } +typedef SaveDataUnlocks = +{ + /** + * Every time we see the unlock animation for a character, + * add it to this list so that we don't show it again. + */ + var charactersSeen:Array; +} + /** * An anoymous structure containing options about the user's high scores. */ diff --git a/source/funkin/ui/PixelatedIcon.hx b/source/funkin/ui/PixelatedIcon.hx index 8d9b97d9c..d1ea652c3 100644 --- a/source/funkin/ui/PixelatedIcon.hx +++ b/source/funkin/ui/PixelatedIcon.hx @@ -22,14 +22,26 @@ class PixelatedIcon extends FlxSprite switch (char) { - case 'monster-christmas': - charPath += 'monsterpixel'; - case 'mom-car': - charPath += 'mommypixel'; - case 'darnell-blazin': - charPath += 'darnellpixel'; - case 'senpai-angry': - charPath += 'senpaipixel'; + case "bf-christmas" | "bf-car" | "bf-pixel" | "bf-holding-gf": + charPath += "bfpixel"; + case "monster-christmas": + charPath += "monsterpixel"; + case "mom" | "mom-car": + charPath += "mommypixel"; + case "pico-blazin" | "pico-playable" | "pico-speaker": + charPath += "picopixel"; + case "gf-christmas" | "gf-car" | "gf-pixel" | "gf-tankmen": + charPath += "gfpixel"; + case "dad": + charPath += "dadpixel"; + case "darnell-blazin": + charPath += "darnellpixel"; + case "senpai-angry": + charPath += "senpaipixel"; + case "spooky-dark": + charPath += "spookypixel"; + case "tankman-atlas": + charPath += "tankmanpixel"; default: charPath += '${char}pixel'; } diff --git a/source/funkin/ui/charSelect/CharSelectSubState.hx b/source/funkin/ui/charSelect/CharSelectSubState.hx index 41ed7ef1e..c02ee3c5a 100644 --- a/source/funkin/ui/charSelect/CharSelectSubState.hx +++ b/source/funkin/ui/charSelect/CharSelectSubState.hx @@ -1,27 +1,31 @@ package funkin.ui.charSelect; -import funkin.ui.freeplay.FreeplayState; -import flixel.text.FlxText; -import funkin.ui.PixelatedIcon; -import flixel.system.debug.watch.Tracker.TrackerProfile; -import flixel.math.FlxPoint; -import flixel.tweens.FlxTween; -import openfl.display.BlendMode; -import flixel.group.FlxGroup.FlxTypedGroup; +import flixel.FlxObject; import flixel.FlxSprite; +import flixel.group.FlxGroup; +import flixel.group.FlxGroup.FlxTypedGroup; import flixel.group.FlxSpriteGroup; -import funkin.play.stage.Stage; +import flixel.math.FlxPoint; +import flixel.sound.FlxSound; +import flixel.system.debug.watch.Tracker.TrackerProfile; +import flixel.text.FlxText; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import flixel.util.FlxTimer; +import funkin.audio.FunkinSound; +import funkin.data.freeplay.player.PlayerData; +import funkin.data.freeplay.player.PlayerRegistry; +import funkin.graphics.adobeanimate.FlxAtlasSprite; +import funkin.graphics.FunkinCamera; import funkin.modding.events.ScriptEvent; import funkin.modding.events.ScriptEventDispatcher; -import funkin.graphics.adobeanimate.FlxAtlasSprite; -import flixel.FlxObject; -import openfl.display.BlendMode; -import flixel.group.FlxGroup; +import funkin.play.stage.Stage; +import funkin.ui.freeplay.charselect.PlayableCharacter; +import funkin.ui.freeplay.FreeplayState; +import funkin.ui.PixelatedIcon; import funkin.util.MathUtil; -import flixel.util.FlxTimer; -import flixel.tweens.FlxEase; -import flixel.sound.FlxSound; -import funkin.audio.FunkinSound; +import funkin.vis.dsp.SpectralAnalyzer; +import openfl.display.BlendMode; class CharSelectSubState extends MusicBeatSubState { @@ -67,8 +71,29 @@ class CharSelectSubState extends MusicBeatSubState { super(); - availableChars.set(4, "bf"); - availableChars.set(3, "pico"); + loadAvailableCharacters(); + } + + function loadAvailableCharacters():Void + { + var playerIds:Array = PlayerRegistry.instance.listEntryIds(); + + for (playerId in playerIds) + { + var player:Null = PlayerRegistry.instance.fetchEntry(playerId); + if (player == null) continue; + var playerData = player.getCharSelectData(); + if (playerData == null) continue; + + var targetPosition:Int = playerData.position ?? 0; + while (availableChars.exists(targetPosition)) + { + targetPosition += 1; + } + + trace('Placing player ${playerId} at position ${targetPosition}'); + availableChars.set(targetPosition, playerId); + } } override public function create():Void @@ -269,7 +294,6 @@ class CharSelectSubState extends MusicBeatSubState } var grpIcons:FlxSpriteGroup; - var grpXSpread(default, set):Float = 107; var grpYSpread(default, set):Float = 127; diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx index 8f021840a..70580300e 100644 --- a/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx +++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx @@ -190,8 +190,8 @@ class ChartEditorEventDataToolbox extends ChartEditorBaseToolbox var numberStepper:NumberStepper = new NumberStepper(); numberStepper.id = field.name; numberStepper.step = field.step ?? 1.0; - numberStepper.min = field.min ?? 0.0; - numberStepper.max = field.max ?? 10.0; + if (field.min != null) numberStepper.min = field.min; + if (field.min != null) numberStepper.max = field.max; if (field.defaultValue != null) numberStepper.value = field.defaultValue; input = numberStepper; case FLOAT: diff --git a/source/funkin/ui/freeplay/FreeplayDJ.hx b/source/funkin/ui/freeplay/FreeplayDJ.hx index fd00a9549..b1528d906 100644 --- a/source/funkin/ui/freeplay/FreeplayDJ.hx +++ b/source/funkin/ui/freeplay/FreeplayDJ.hx @@ -15,7 +15,7 @@ class FreeplayDJ extends FlxAtlasSprite { // Represents the sprite's current status. // Without state machines I would have driven myself crazy years ago. - public var currentState:DJBoyfriendState = Intro; + public var currentState:FreeplayDJState = Intro; // A callback activated when the intro animation finishes. public var onIntroDone:FlxSignal = new FlxSignal(); @@ -99,7 +99,7 @@ class FreeplayDJ extends FlxAtlasSprite playFlashAnimation(animPrefix, true, false, true); } - if (getCurrentAnimation() == animPrefix && this.isLoopFinished()) + if (getCurrentAnimation() == animPrefix && this.isLoopComplete()) { if (timeIdling >= IDLE_EGG_PERIOD && !seenIdleEasterEgg) { @@ -111,18 +111,69 @@ class FreeplayDJ extends FlxAtlasSprite } } timeIdling += elapsed; + case NewUnlock: + var animPrefix = playableCharData.getAnimationPrefix('newUnlock'); + if (!hasAnimation(animPrefix)) + { + currentState = Idle; + } + if (getCurrentAnimation() != animPrefix) + { + playFlashAnimation(animPrefix, true, false, true); + } case Confirm: var animPrefix = playableCharData.getAnimationPrefix('confirm'); if (getCurrentAnimation() != animPrefix) playFlashAnimation(animPrefix, false); timeIdling = 0; case FistPumpIntro: - var animPrefix = playableCharData.getAnimationPrefix('fistPump'); - if (getCurrentAnimation() != animPrefix) playFlashAnimation('Boyfriend DJ fist pump', false); - if (getCurrentAnimation() == animPrefix && anim.curFrame >= 4) + var animPrefixA = playableCharData.getAnimationPrefix('fistPump'); + var animPrefixB = playableCharData.getAnimationPrefix('loss'); + + if (getCurrentAnimation() == animPrefixA) { - playAnimation("Boyfriend DJ fist pump", true, false, false, 0); + var endFrame = playableCharData.getFistPumpIntroEndFrame(); + if (endFrame > -1 && anim.curFrame >= endFrame) + { + playFlashAnimation(animPrefixA, true, false, false, playableCharData.getFistPumpIntroStartFrame()); + } } + else if (getCurrentAnimation() == animPrefixB) + { + var endFrame = playableCharData.getFistPumpIntroBadEndFrame(); + if (endFrame > -1 && anim.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(); + if (endFrame > -1 && anim.curFrame >= endFrame) + { + playFlashAnimation(animPrefixA, true, false, false, playableCharData.getFistPumpLoopStartFrame()); + } + } + else if (getCurrentAnimation() == animPrefixB) + { + var endFrame = playableCharData.getFistPumpLoopBadEndFrame(); + if (endFrame > -1 && anim.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'); @@ -185,7 +236,14 @@ class FreeplayDJ extends FlxAtlasSprite if (name == playableCharData.getAnimationPrefix('intro')) { - currentState = Idle; + if (PlayerRegistry.instance.hasNewCharacter()) + { + currentState = NewUnlock; + } + else + { + currentState = Idle; + } onIntroDone.dispatch(); } else if (name == playableCharData.getAnimationPrefix('idle')) @@ -225,9 +283,17 @@ class FreeplayDJ extends FlxAtlasSprite // runTvLogic(); } trace('Replay idle: ${frame}'); - playAnimation(playableCharData.getAnimationPrefix('cartoon'), true, false, false, frame); + playFlashAnimation(playableCharData.getAnimationPrefix('cartoon'), 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}'); @@ -240,6 +306,15 @@ class FreeplayDJ extends FlxAtlasSprite 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; @@ -271,7 +346,7 @@ class FreeplayDJ extends FlxAtlasSprite function loadCartoon() { cartoonSnd = FunkinSound.load(Paths.sound(getRandomFlashToon()), 1.0, false, true, true, function() { - playAnimation("Boyfriend DJ watchin tv OG", true, false, false, 60); + playFlashAnimation(playableCharData.getAnimationPrefix('cartoon'), true, false, false, 60); }); // Fade out music to 40% volume over 1 second. @@ -301,21 +376,48 @@ class FreeplayDJ extends FlxAtlasSprite currentState = Confirm; } - public function fistPump():Void + public function toCharSelect():Void + { + if (hasAnimation('charSelect')) + { + currentState = CharSelect; + var animPrefix = playableCharData.getAnimationPrefix('charSelect'); + playFlashAnimation(animPrefix, true, false, false, 0); + } + else + { + currentState = Confirm; + // Call this immediately; otherwise, we get locked out of Character Select. + onCharSelectComplete(); + } + } + + public function fistPumpIntro():Void { currentState = FistPumpIntro; + var animPrefix = playableCharData.getAnimationPrefix('fistPump'); + playFlashAnimation(animPrefix, true, false, false, playableCharData.getFistPumpIntroStartFrame()); } - public function pumpFist():Void + public function fistPump():Void { currentState = FistPump; - playAnimation("Boyfriend DJ fist pump", true, false, false, 4); + var animPrefix = playableCharData.getAnimationPrefix('fistPump'); + playFlashAnimation(animPrefix, true, false, false, playableCharData.getFistPumpLoopStartFrame()); } - public function pumpFistBad():Void + public function fistPumpLossIntro():Void + { + currentState = FistPumpIntro; + var animPrefix = playableCharData.getAnimationPrefix('loss'); + playFlashAnimation(animPrefix, true, false, false, playableCharData.getFistPumpIntroBadStartFrame()); + } + + public function fistPumpLoss():Void { currentState = FistPump; - playAnimation("Boyfriend DJ loss reaction 1", true, false, false, 4); + var animPrefix = playableCharData.getAnimationPrefix('loss'); + playFlashAnimation(animPrefix, true, false, false, playableCharData.getFistPumpLoopBadStartFrame()); } override public function getCurrentAnimation():String @@ -366,13 +468,53 @@ class FreeplayDJ extends FlxAtlasSprite } } -enum DJBoyfriendState +enum FreeplayDJState { + /** + * Character enters the frame and transitions to Idle. + */ Intro; + + /** + * Character loops in idle. + */ Idle; - Confirm; - FistPumpIntro; - FistPump; + + /** + * 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; } diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx index cffb0b5a4..79ca758c5 100644 --- a/source/funkin/ui/freeplay/FreeplayState.hx +++ b/source/funkin/ui/freeplay/FreeplayState.hx @@ -177,9 +177,22 @@ class FreeplayState extends MusicBeatSubState var stickerSubState:Null = null; - public static var rememberedDifficulty:Null = Constants.DEFAULT_DIFFICULTY; + /** + * The difficulty we were on when this menu was last accessed. + */ + public static var rememberedDifficulty:String = Constants.DEFAULT_DIFFICULTY; + + /** + * The song we were on when this menu was last accessed. + * NOTE: `null` if the last song was `Random`. + */ public static var rememberedSongId:Null = 'tutorial'; + /** + * The character we were on when this menu was last accessed. + */ + public static var rememberedCharacterId:String = Constants.DEFAULT_CHARACTER; + var funnyCam:FunkinCamera; var rankCamera:FunkinCamera; var rankBg:FunkinSprite; @@ -209,14 +222,16 @@ class FreeplayState extends MusicBeatSubState public function new(?params:FreeplayStateParams, ?stickers:StickerSubState) { - currentCharacterId = params?.character ?? Constants.DEFAULT_CHARACTER; + currentCharacterId = params?.character ?? rememberedCharacterId; var fetchPlayableCharacter = function():PlayableCharacter { - var result = PlayerRegistry.instance.fetchEntry(params?.character ?? Constants.DEFAULT_CHARACTER); + var result = PlayerRegistry.instance.fetchEntry(params?.character ?? rememberedCharacterId); if (result == null) throw 'No valid playable character with id ${params?.character}'; return result; }; currentCharacter = fetchPlayableCharacter(); + rememberedCharacterId = currentCharacter?.id ?? Constants.DEFAULT_CHARACTER; + fromResultsParams = params?.fromResults; if (fromResultsParams?.playRankAnim == true) @@ -629,8 +644,8 @@ class FreeplayState extends MusicBeatSubState speed: 0.3 }); - var diffSelLeft:DifficultySelector = new DifficultySelector(20, grpDifficulties.y - 10, false, controls); - var diffSelRight:DifficultySelector = new DifficultySelector(325, grpDifficulties.y - 10, true, controls); + var diffSelLeft:DifficultySelector = new DifficultySelector(this, 20, grpDifficulties.y - 10, false, controls); + var diffSelRight:DifficultySelector = new DifficultySelector(this, 325, grpDifficulties.y - 10, true, controls); diffSelLeft.visible = false; diffSelRight.visible = false; add(diffSelLeft); @@ -743,10 +758,7 @@ class FreeplayState extends MusicBeatSubState var tempSongs:Array> = songs; // Remember just the difficulty because it's important for song sorting. - if (rememberedDifficulty != null) - { - currentDifficulty = rememberedDifficulty; - } + currentDifficulty = rememberedDifficulty; if (filterStuff != null) tempSongs = sortSongs(tempSongs, filterStuff); @@ -901,7 +913,15 @@ class FreeplayState extends MusicBeatSubState changeSelection(); changeDiff(); - if (dj != null) dj.fistPump(); + if (fromResultsParams?.newRank == SHIT) + { + if (dj != null) dj.fistPumpLossIntro(); + } + else + { + if (dj != null) dj.fistPumpIntro(); + } + // rankCamera.fade(FlxColor.BLACK, 0.5, true); rankCamera.fade(0xFF000000, 0.5, true, null, true); if (FlxG.sound.music != null) FlxG.sound.music.volume = 0; @@ -1083,11 +1103,11 @@ class FreeplayState extends MusicBeatSubState if (fromResultsParams?.newRank == SHIT) { - if (dj != null) dj.pumpFistBad(); + if (dj != null) dj.fistPumpLoss(); } else { - if (dj != null) dj.pumpFist(); + if (dj != null) dj.fistPump(); } rankCamera.zoom = 0.8; @@ -1190,7 +1210,7 @@ class FreeplayState extends MusicBeatSubState /** * If true, disable interaction with the interface. */ - var busy:Bool = false; + public var busy:Bool = false; var originalPos:FlxPoint = new FlxPoint(); @@ -1233,7 +1253,32 @@ class FreeplayState extends MusicBeatSubState if (controls.FREEPLAY_CHAR_SELECT && !busy) { - FlxG.switchState(new funkin.ui.charSelect.CharSelectSubState()); + // Check if we have ACCESS to character select! + trace('Is Pico unlocked? ${PlayerRegistry.instance.fetchEntry('pico')?.isUnlocked()}'); + trace('Number of characters: ${PlayerRegistry.instance.countUnlockedCharacters()}'); + + if (PlayerRegistry.instance.countUnlockedCharacters() > 1) + { + if (dj != null) + { + busy = true; + // Transition to character select after animation + dj.onCharSelectComplete = function() { + FlxG.switchState(new funkin.ui.charSelect.CharSelectSubState()); + } + dj.toCharSelect(); + } + else + { + // Transition to character select immediately + FlxG.switchState(new funkin.ui.charSelect.CharSelectSubState()); + } + } + else + { + trace('Not enough characters unlocked to open character select!'); + FunkinSound.playOnce(Paths.sound('cancelMenu')); + } } if (controls.FREEPLAY_FAVORITE && !busy) @@ -1326,6 +1371,8 @@ class FreeplayState extends MusicBeatSubState } handleInputs(elapsed); + + if (dj != null) FlxG.watch.addQuick('dj-anim', dj.getCurrentAnimation()); } function handleInputs(elapsed:Float):Void @@ -1483,7 +1530,7 @@ class FreeplayState extends MusicBeatSubState generateSongList(currentFilter, true); } - if (controls.BACK) + if (controls.BACK && !busy) { busy = true; FlxTween.globalManager.clear(); @@ -1891,7 +1938,7 @@ class FreeplayState extends MusicBeatSubState intendedCompletion = 0.0; diffIdsCurrent = diffIdsTotal; rememberedSongId = null; - rememberedDifficulty = null; + rememberedDifficulty = Constants.DEFAULT_DIFFICULTY; albumRoll.albumId = null; } @@ -2000,10 +2047,13 @@ class DifficultySelector extends FlxSprite var controls:Controls; var whiteShader:PureColor; - public function new(x:Float, y:Float, flipped:Bool, controls:Controls) + var parent:FreeplayState; + + public function new(parent:FreeplayState, x:Float, y:Float, flipped:Bool, controls:Controls) { super(x, y); + this.parent = parent; this.controls = controls; frames = Paths.getSparrowAtlas('freeplay/freeplaySelector'); @@ -2019,8 +2069,8 @@ class DifficultySelector extends FlxSprite override function update(elapsed:Float):Void { - if (flipX && controls.UI_RIGHT_P) moveShitDown(); - if (!flipX && controls.UI_LEFT_P) moveShitDown(); + if (flipX && controls.UI_RIGHT_P && !parent.busy) moveShitDown(); + if (!flipX && controls.UI_LEFT_P && !parent.busy) moveShitDown(); super.update(elapsed); } diff --git a/source/funkin/ui/freeplay/charselect/PlayableCharacter.hx b/source/funkin/ui/freeplay/charselect/PlayableCharacter.hx index c46b4b930..d4dd7aaa4 100644 --- a/source/funkin/ui/freeplay/charselect/PlayableCharacter.hx +++ b/source/funkin/ui/freeplay/charselect/PlayableCharacter.hx @@ -88,6 +88,11 @@ class PlayableCharacter implements IRegistryEntry return _data.freeplayDJ.getFreeplayDJText(index); } + public function getCharSelectData():PlayerCharSelectData + { + return _data.charSelect; + } + /** * @param rank Which rank to get info for * @return An array of animations. For example, BF Great has two animations, one for BF and one for GF diff --git a/source/funkin/ui/mainmenu/MainMenuState.hx b/source/funkin/ui/mainmenu/MainMenuState.hx index 2d809354a..83da967b0 100644 --- a/source/funkin/ui/mainmenu/MainMenuState.hx +++ b/source/funkin/ui/mainmenu/MainMenuState.hx @@ -98,14 +98,7 @@ class MainMenuState extends MusicBeatState add(menuItems); menuItems.onChange.add(onMenuItemChange); menuItems.onAcceptPress.add(function(_) { - if (_.name == 'freeplay') - { - magenta.visible = true; - } - else - { - FlxFlicker.flicker(magenta, 1.1, 0.15, false, true); - } + FlxFlicker.flicker(magenta, 1.1, 0.15, false, true); }); menuItems.enabled = true; // can move on intro @@ -117,10 +110,7 @@ class MainMenuState extends MusicBeatState FlxTransitionableState.skipNextTransIn = true; FlxTransitionableState.skipNextTransOut = true; - openSubState(new FreeplayState( - { - character: FlxG.keys.pressed.SHIFT ? 'pico' : 'bf', - })); + openSubState(new FreeplayState()); }); #if CAN_OPEN_LINKS @@ -355,6 +345,7 @@ class MainMenuState extends MusicBeatState if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.ALT && FlxG.keys.pressed.SHIFT && FlxG.keys.justPressed.W) { + FunkinSound.playOnce(Paths.sound('confirmMenu')); // Give the user a score of 1 point on Weekend 1 story mode. // This makes the level count as cleared and displays the songs in Freeplay. funkin.save.Save.instance.setLevelScore('weekend1', 'easy', @@ -375,6 +366,29 @@ class MainMenuState extends MusicBeatState }); } + if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.ALT && FlxG.keys.pressed.SHIFT && FlxG.keys.justPressed.L) + { + FunkinSound.playOnce(Paths.sound('confirmMenu')); + // Give the user a score of 0 points on Weekend 1 story mode. + // This makes the level count as uncleared and no longer displays the songs in Freeplay. + funkin.save.Save.instance.setLevelScore('weekend1', 'easy', + { + score: 1, + tallies: + { + sick: 0, + good: 0, + bad: 0, + shit: 0, + missed: 0, + combo: 0, + maxCombo: 0, + totalNotesHit: 0, + totalNotes: 0, + } + }); + } + if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.ALT && FlxG.keys.pressed.SHIFT && FlxG.keys.justPressed.R) { // Give the user a hypothetical overridden score, diff --git a/source/funkin/ui/story/LevelProp.hx b/source/funkin/ui/story/LevelProp.hx index 4e78415e3..dfb11dd20 100644 --- a/source/funkin/ui/story/LevelProp.hx +++ b/source/funkin/ui/story/LevelProp.hx @@ -16,7 +16,7 @@ class LevelProp extends Bopper this.propData = value; this.visible = this.propData != null; - danceEvery = this.propData?.danceEvery ?? 0.0; + danceEvery = this.propData?.danceEvery ?? 1.0; applyData(); } @@ -32,7 +32,7 @@ class LevelProp extends Bopper public function playConfirm():Void { - playAnimation('confirm', true, true); + if (hasAnimation('confirm')) playAnimation('confirm', true, true); } function applyData():Void diff --git a/source/funkin/ui/transition/LoadingState.hx b/source/funkin/ui/transition/LoadingState.hx index 067de0e7f..5b82cc741 100644 --- a/source/funkin/ui/transition/LoadingState.hx +++ b/source/funkin/ui/transition/LoadingState.hx @@ -314,25 +314,28 @@ class LoadingState extends MusicBeatSubState FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/num7')); FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/num8')); FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/num9')); + FunkinSprite.cacheTexture(Paths.image('notes', 'shared')); FunkinSprite.cacheTexture(Paths.image('noteSplashes', 'shared')); FunkinSprite.cacheTexture(Paths.image('noteStrumline', 'shared')); FunkinSprite.cacheTexture(Paths.image('NOTE_hold_assets')); + FunkinSprite.cacheTexture(Paths.image('ui/countdown/funkin/ready', 'shared')); FunkinSprite.cacheTexture(Paths.image('ui/countdown/funkin/set', 'shared')); FunkinSprite.cacheTexture(Paths.image('ui/countdown/funkin/go', 'shared')); FunkinSprite.cacheTexture(Paths.image('ui/countdown/pixel/ready', 'shared')); FunkinSprite.cacheTexture(Paths.image('ui/countdown/pixel/set', 'shared')); FunkinSprite.cacheTexture(Paths.image('ui/countdown/pixel/go', 'shared')); - FunkinSprite.cacheTexture(Paths.image('ui/popup/normal/sick')); - FunkinSprite.cacheTexture(Paths.image('ui/popup/normal/good')); - FunkinSprite.cacheTexture(Paths.image('ui/popup/normal/bad')); - FunkinSprite.cacheTexture(Paths.image('ui/popup/normal/shit')); + + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/sick')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/good')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/bad')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/shit')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/sick')); FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/good')); FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/bad')); FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/shit')); - FunkinSprite.cacheTexture(Paths.image('miss', 'shared')); // TODO: remove this // List all image assets in the level's library. // This is crude and I want to remove it when we have a proper asset caching system. diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx index 80318f1a4..fa03b229d 100644 --- a/source/funkin/util/Constants.hx +++ b/source/funkin/util/Constants.hx @@ -524,12 +524,16 @@ class Constants * OTHER */ // ============================== + #if FEATURE_GHOST_TAPPING + // Hey there, Eric here. + // This feature is currently still in development. You can test it out by creating a special debug build! + // lime build windows -DFEATURE_GHOST_TAPPING /** - * If true, the player will not receive the ghost miss penalty if there are no notes within the hit window. - * This is the thing people have been begging for forever lolol. + * Duration, in seconds, after the player's section ends before the player can spam without penalty. */ - public static final GHOST_TAPPING:Bool = false; + public static final GHOST_TAP_DELAY:Float = 3 / 8; + #end /** * The maximum number of previous file paths for the Chart Editor to remember.