diff --git a/.gitignore b/.gitignore index 11df4ac..93ac0f6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ # macOS garbage data .DS_Store __MACOSX/ + +# Dumped Funker Selector save data +data/FS_dumpedSave.json diff --git a/CHANGELOG.md b/CHANGELOG.md index c4a5b63..d8abe5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,48 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.0.0] - 2024-12-17 +### Added +- The Freeplay DJ will now change if your character is associated with a Playable Character (and has valid Freeplay DJ data). + - You can disable this in the Options Menu. + - This also changes the backing text to what you set in the Playable Character data. +- Added 2 new Script Events that fire when you enter/exit the Character Menu SubState. +### Changed +- **Complete UI makeover and refactor**: + - Characters are now shown next to eachother in a scrollable layout + - Character descriptions are now shown with a typewriter effect. + - In addition, the description text is resized dynamically to fit the text box. + - Text might be a bit unreadable the longer it is, so be careful! + - You can now use the scroll wheel to navigate. + - The menu now uses your controls as configured in the Options Menu. + - The icon grid now scrolls digonally, the scroll speed is relative to the current song's BPM. + - The speed is capped at 300 BPM. I don't want to give you motion sickness. + - There is now a visual indicator for character variations, the menu will tell you how many variations are available if any exist. + - Press the DEBUG MENU key (look in Options -> Controls to see what it's mapped to) to see the variation and associated songs. + - **Your characters might be positioned weird, please update your character positions in the JSON data.** + - **The menu might take a while to load when it's opened for the first time, especially if "Preload Sprites" is disabled!!** +- Completely reworked the save data system. Internally, everything is now one object instead of multiple. + - Your save data will be migrated! +- Reworked the structure for suffixes in the JSON data. + - **This is a breaking change for characters which use suffixes!** + - Please see the documentation for the new structure. +- Added several new values in the JSON data: + - `speaker`: An alias for the GF character type. + - `voiceID`: A custom ID that can be used in place of the character ID when using Vocal Replacement. + - `introSwapFrame`: The frame where the turntable stops moving and the character appears in the Freeplay DJ's intro animation. + - This is used when swapping out DJs, the default value is 3. +- You can now skip the unlock animation by holding shift before the animation is played. +- If "Random" is selected, the BPM is now obtained from the song metadata instead of using a hardcoded value. +- Reworked how the Result Screen animations/music are swapped out. + - This should have no effect on the functionality itself, it's simply a code refactor. +- The icon in Freeplay has been remade, it now shows the health icon of your currently selected Playable Character. +### Fixed +- Fixed Pico's results animations not playing if Pico (Playable) was used on Week 2 Erect. +- Fixed a bug where the Character Menu SubState can be re-opened if it was already open. +### Removed +- All the Boyfriend / Pico variants have been moved to a separate mod. +- Removed the `size` and `offsets` properties from the JSON description data as the description text is now dynamically resized. + ## [1.5.2] - 2024-10-23 ### Fixed - Fixed an issue where Character Data wasn't parsed properly if the Character ID is different from the JSON filename. @@ -19,12 +61,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [1.5.0] - 2024-10-22 ### Added - The characters now bop to the BPM of the currently selected song in Freeplay. -- New `mustUnlock` variable for JSON characters. If enabled, your character will become locked until you meet a requirement specified in the JSON file. - - Please see the documentation on how to do this. +- A brand new unlock system. You can assign an unlock condition for your character. + - Please see the documentation on how to do this. - In addition: Pico (Playable), Pico (Christmas), Daddy Dearest and all the Boyfriend variants are now locked, requiring you to complete their specified requirements to unlock them. - You'll probably be seeing the unlock animation a LOT if you already completed them. This is only done once! - - There's also a new `unlockCondition` variable for description data in turn. -- The window title will change if you're in the Character Menu. +- The window title will now change if you're in the Character Menu. - Added better visual feedback for selecting a character. - New option which allows you to change the SFX used in the menu. Currently, you can only switch between `Funkin' Main Menu` and `Funkin' Character Select` ### Changed @@ -48,7 +89,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - This is due to functions in the code that do not work on v0.5.0. - Please use v0.5.1 instead. -## [1.4.0] - 2024-09-12 +## [1.4.0] - 2024-09-30 ### Added - v0.5.0 support. - You can now have multiple song IDs for Character Variations as an Array. @@ -85,7 +126,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Optimized a few spots in the `charSelect` module and `CharacterMenu` substate. - Refactored vocal replacement. `PlayState.instance.voices` is now replaced properly instead of the hacky way of setting `PlayState.instance.currentChart.characters.player` and basically running with it. - Use a more memory-efficient method for "Preload Sprites". -### Fixed +### Fixed - Fixed "Preload Sprites" doing the opposite of what it was supposed to do. - In addition, disabling the option will now remove the sprites from memory. - Fixed a random Null Object Reference from occuring when "Default" was selected. @@ -99,7 +140,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Added a count to the UI which shows the current character, and the total amount of characters for the current page. ### Changed - Gave the entire UI a fresh coat of paint. If the current character is a JSON Character, they will have their sprites displayed, along with a neat little description about them! - - The UI now uses PhantomMuff by Cracsthor! Allows for text resizing, making the UI less cluttered in general. + - The UI now uses PhantomMuff by Cracsthor! Allows for text resizing, making the UI less cluttered in general. - With this UI overhaul comes some new options related to it: - Simplify UI: Simplifies the UI if it's too much. - Preload Sprites: Wether to preload the character sprites or not. @@ -122,4 +163,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [1.0.0] - 2024-07-21 ### Added -- Pretty much everything. \ No newline at end of file +- Pretty much everything. diff --git a/_merge/data/players/pico.json b/_merge/data/players/pico.json new file mode 100644 index 0000000..61b89f7 --- /dev/null +++ b/_merge/data/players/pico.json @@ -0,0 +1,4 @@ +[ + { "op": "replace", "path": "/freeplayDJ/text1", "value": "PICO PICO PICO" }, + { "op": "replace", "path": "/freeplayDJ/animations/4/offsets", "value": [970, 270] } +] \ No newline at end of file diff --git a/_polymod_meta.json b/_polymod_meta.json index 68390c6..27afaa1 100644 --- a/_polymod_meta.json +++ b/_polymod_meta.json @@ -3,6 +3,6 @@ "description": "Adds a Character Select in Freeplay! No more needing to replace Boyfriend or Pico for reskins.", "author": "kagaminerinlen", "api_version": "0.5.0", - "mod_version": "1.5.2", + "mod_version": "2.0.0", "license": "MIT" -} \ No newline at end of file +} diff --git a/data/funkerSelector/bf-car.json b/data/funkerSelector/bf-car.json deleted file mode 100644 index cb84034..0000000 --- a/data/funkerSelector/bf-car.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": "1.0.0", - "characterID": "bf-car", - "characterType": "bf", - "mustUnlock": true, - "unlockMethod": { - "type": "storyWeek", - "levelID": "week4", - "difficultyList": ["hard"] - }, - "description": { - "text": "Boyfriend with his hair blowing in... wind? Featured in Week 4. He isn't on top of a moving car, though, so where is all that wind coming from?", - "unlockCondition": "Beat Week 4 on Hard." - }, - "characterMenu": - { - "position": [150, 250], - "scale": 1 - } -} \ No newline at end of file diff --git a/data/funkerSelector/bf-christmas.json b/data/funkerSelector/bf-christmas.json deleted file mode 100644 index 196af7f..0000000 --- a/data/funkerSelector/bf-christmas.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": "1.0.0", - "characterID": "bf-christmas", - "characterType": "bf", - "mustUnlock": true, - "unlockMethod": { - "type": "storyWeek", - "levelID": "week5", - "difficultyList": ["hard"] - }, - "description": { - "text": "Boyfriend wearing a special attire just for the Holidays! Featured in Week 5.", - "unlockCondition": "Beat Week 5 on Hard." - }, - "characterMenu": - { - "position": [150, 250], - "scale": 1 - } -} \ No newline at end of file diff --git a/data/funkerSelector/bf-holding-gf.json b/data/funkerSelector/bf-holding-gf.json deleted file mode 100644 index 3b3faf9..0000000 --- a/data/funkerSelector/bf-holding-gf.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": "1.0.0", - "characterID": "bf-holding-gf", - "characterType": "bf", - "mustUnlock": true, - "unlockMethod": { - "type": "storyWeek", - "levelID": "week7", - "difficultyList": ["hard"] - }, - "description": { - "text": "Boyfriend holding Girlfriend in his arms, used for the song 'Stress' in Week 7.", - "unlockCondition": "Beat Week 7 on Hard." - }, - "characterMenu": - { - "position": [155, 230], - "scale": 1 - } -} \ No newline at end of file diff --git a/data/funkerSelector/bf-pixel.json b/data/funkerSelector/bf-pixel.json deleted file mode 100644 index 017d348..0000000 --- a/data/funkerSelector/bf-pixel.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "version": "1.0.0", - "characterID": "bf-pixel", - "characterType": "bf", - "mustUnlock": true, - "unlockMethod": { - "type": "storyWeek", - "levelID": "week6", - "difficultyList": ["hard"] - }, - "description": { - "text": "Boyfriend in a pixelated artstyle, featured in Week 6! Spritework was done by Moawling, with animation by JohnnyUtah.", - "unlockCondition": "Beat Week 6 on Hard." - }, - "characterMenu": - { - "position": [340, 560], - "scale": 1.15, - "isPixel": true - } -} \ No newline at end of file diff --git a/data/funkerSelector/bf.json b/data/funkerSelector/bf.json index a16e1f4..0bcfad0 100644 --- a/data/funkerSelector/bf.json +++ b/data/funkerSelector/bf.json @@ -5,9 +5,8 @@ "description": { "text": "The main protagonist of Friday Night Funkin', sporting his iconic blue hair and red-blue cap. Girlfriend loves him, her parents on the other hand..." }, - "characterMenu": - { + "characterMenu": { "position": [150, 250], "scale": 1 } -} \ No newline at end of file +} diff --git a/data/funkerSelector/gf.json b/data/funkerSelector/gf.json index c89210c..bbd14fe 100644 --- a/data/funkerSelector/gf.json +++ b/data/funkerSelector/gf.json @@ -7,7 +7,7 @@ }, "characterMenu": { - "position": [50, 100], + "position": [30, 100], "scale": 0.7, "selectedAnim": "cheer" } diff --git a/data/funkerSelector/pico-christmas.json b/data/funkerSelector/pico-christmas.json deleted file mode 100644 index 37ae557..0000000 --- a/data/funkerSelector/pico-christmas.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": "1.0.0", - "characterID": "pico-christmas", - "characterType": "bf", - "mustUnlock": true, - "unlockMethod": { - "type": "song", - "songID": "eggnog", - "difficultyList": ["hard-pico"] - }, - "description": { - "text": "Pico, all dressed up for the holidays! Featured in Eggnog (Pico Mix), and added in an update dedicated to him.", - "unlockCondition": "Beat Eggnog (Pico Mix) on Hard." - }, - "characterMenu": - { - "position": [100, 200], - "scale": 1 - } -} \ No newline at end of file diff --git a/data/funkerSelector/pico-playable.json b/data/funkerSelector/pico-playable.json index 5abad7a..1334db8 100644 --- a/data/funkerSelector/pico-playable.json +++ b/data/funkerSelector/pico-playable.json @@ -10,8 +10,7 @@ "text": "It's Pico from the 1999 Flash Game: 'Pico's School'! featured as an opponent in Week 3, and as a playable character in WeekEnd 1.", "unlockCondition": "Beat WeekEnd 1 on any difficulty." }, - "characterMenu": - { + "characterMenu": { "position": [150, 200], "scale": 1 }, @@ -22,4 +21,4 @@ "songVariation": "erect" } ] -} \ No newline at end of file +} diff --git a/docs/Funker Selector JSON Character Format.md b/docs/Funker Selector JSON Character Format.md index 9e73f7e..1f4545d 100644 --- a/docs/Funker Selector JSON Character Format.md +++ b/docs/Funker Selector JSON Character Format.md @@ -12,9 +12,7 @@ The mod queries `data/funkerSelector/` at runtime and parses the JSON files in t "characterID": "bf", "characterType": "bf", "description": { - "text": "The main protagonist of Friday Night Funkin', sporting his iconic blue hair and red-blue cap. Girlfriend loves him, her parents on the other hand...", - "size": 35, - "offsets": [0, 0] + "text": "The main protagonist of Friday Night Funkin', sporting his iconic blue hair and red-blue cap. Girlfriend loves him, her parents on the other hand..." }, "characterMenu": { @@ -61,8 +59,10 @@ The Character ID to use, this is ***needed*** or else the character will not sho ```json "characterType": "bf" ``` -The character type, accepted values are: `bf`, `gf`, `dad`, `player`, and `opponent`. -(`player` and `opponent` are just aliases for `bf` and `dad` respectively.) +The character type, accepted values are: `bf`, `gf`, `dad`, `player`, `speaker`, and `opponent`. +(`player`, `speaker`, and `opponent` are just aliases for `bf`, `gf`, and `dad` respectively.) + +--- ```json "mustUnlock": false @@ -72,6 +72,21 @@ You'll have to specify an unlock method. --- +```json +"voiceID": "bf" +``` +The ID to use for Vocal Replacement, defaults to the character ID. + +--- + +```json +"introSwapFrame": 4 +``` +The frame where the turntable stops moving and the character appears in the Freeplay DJ's intro animation. This is used for swapping out DJs. +The default value is 3. + +--- + #### Unlock data ```json "unlockMethod": { @@ -120,25 +135,33 @@ The required difficulties, defaults to `["easy", "normal", "hard"]`. --- -***(These will be grouped together since they serve basically the same functionality.)*** +#### Suffix data ```json -"gameOverMusicSuffix": "" +"suffixes": { + "gameOverMusic": "-pico", + "blueBall": "-pico", + "pauseMusic": "-pico" +} +``` + +```json +"gameOverMusic": "-pico" ``` The music suffix used in the Game Over screen. ```json -"blueBallSuffix": "" +"blueBall": "-pico" ``` The blue ball suffix used in the Game Over screen. ```json -"pauseMusicSuffix": "" +"pauseMusic": "-pico" ``` The music suffix used in the Pause Menu. You can use pretty much any suffix for these three as long as it's valid (exists in the files, either through a mod or the game's assets). -For example, Boyfriend (Pixel) uses `-pixel`, while Pico (Playable) uses `-pico`! +For example, Boyfriend (Pixel) uses `-pixel`, while Pico (Playable) uses `-pico`. --- @@ -146,32 +169,20 @@ For example, Boyfriend (Pixel) uses `-pixel`, while Pico (Playable) uses `-pico` ```json "description": { - "text": "The main protagonist of Friday Night Funkin', sporting his iconic blue hair and red-blue cap. Girlfriend loves him, her parents on the other hand...", - "size": 35, - "offsets": [0, 0] + "text": "The main protagonist of Friday Night Funkin', sporting his iconic blue hair and red-blue cap. Girlfriend loves him, her parents on the other hand..." } ``` This is a short description of the character, with a few properties: ```json "text": "The main protagonist of Friday Night Funkin', sporting his iconic blue hair and red-blue cap. Girlfriend loves him, her parents on the other hand..." -``` +``` The text itself. ```json "unlockCondition": "Beat WeekEnd 1 to unlock." -``` -The unlock condition that's shown if the character is locked. This overrides the description text! - -```json -"size": 35 -``` -The font size. - -```json -"offsets": [0, 0] ``` -The offsets for the description. These are ***not*** the actual X and Y coordinates of the text, this is mainly for adjustment. +The unlock condition that's shown if the character is locked. This overrides the description text! --- diff --git a/JSON Format Proposal.md b/docs/archive/JSON Format Proposal.md similarity index 100% rename from JSON Format Proposal.md rename to docs/archive/JSON Format Proposal.md diff --git a/hxformat.json b/hxformat.json new file mode 100644 index 0000000..eaa559d --- /dev/null +++ b/hxformat.json @@ -0,0 +1,30 @@ +{ + "disableFormatting": false, + + "indentation": { + "character": " ", + "tabWidth": 2, + "indentCaseLabels": true + }, + "lineEnds": { + "anonFunctionCurly": { + "emptyCurly": "break", + "leftCurly": "after", + "rightCurly": "both" + }, + "leftCurly": "after", + "rightCurly": "both" + }, + + "sameLine": { + "ifBody": "same", + "ifElse": "next", + "doWhile": "next", + "tryBody": "next", + "tryCatch": "next" + }, + + "whitespace": { + "switchPolicy": "around" + } +} diff --git a/images/characters/FS_extras/NOTE.txt b/images/characters/FS_extras/NOTE.txt new file mode 100644 index 0000000..a54583f --- /dev/null +++ b/images/characters/FS_extras/NOTE.txt @@ -0,0 +1,2 @@ +This spritesheet is UNUSED! I was originally going to include custom animations in 2.0.0, but it doesn't really seem like a good or interesting idea anymore... +Feel free to use this for whatever, if you really want to. diff --git a/images/characters/FS_extras/bf burp thing.png b/images/characters/FS_extras/bf burp thing.png new file mode 100755 index 0000000..48d24a6 Binary files /dev/null and b/images/characters/FS_extras/bf burp thing.png differ diff --git a/images/characters/FS_extras/bf burp thing.xml b/images/characters/FS_extras/bf burp thing.xml new file mode 100755 index 0000000..ed5362b --- /dev/null +++ b/images/characters/FS_extras/bf burp thing.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/images/freeplay/char_select.png b/images/freeplay/char_select.png index 2de6974..1d77346 100755 Binary files a/images/freeplay/char_select.png and b/images/freeplay/char_select.png differ diff --git a/images/freeplay/char_select.xml b/images/freeplay/char_select.xml old mode 100644 new mode 100755 index 75413d2..21a142f --- a/images/freeplay/char_select.xml +++ b/images/freeplay/char_select.xml @@ -2,31 +2,20 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/images/funkerSelector/locked_character.png b/images/funkerSelector/locked_character.png index 790b37c..ef66157 100644 Binary files a/images/funkerSelector/locked_character.png and b/images/funkerSelector/locked_character.png differ diff --git a/images/funkerSelector/locked_character.xml b/images/funkerSelector/locked_character.xml index 8cfc1ee..56f23fa 100644 --- a/images/funkerSelector/locked_character.xml +++ b/images/funkerSelector/locked_character.xml @@ -1,112 +1,113 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/scripts/graphics/freeplayIcon.hxc b/scripts/graphics/freeplayIcon.hxc new file mode 100644 index 0000000..03909bc --- /dev/null +++ b/scripts/graphics/freeplayIcon.hxc @@ -0,0 +1,66 @@ +import funkin.graphics.FunkinSprite; +import funkin.modding.module.Module; +import funkin.modding.module.ModuleHandler; +import funkin.play.components.HealthIcon; +import flixel.FlxG; + +class FunkerSelectorFreeplayIcon extends FunkinSprite { + /** + * The currently selected character's health icon. + * This defaults to BF. + */ + var currentCharacterIcon:HealthIcon; + + /** + * The CharacterHandler module. + */ + var characterHandler:Module = ModuleHandler.getModule("CharacterHandler"); + + public function new() { + super(-20, 60); + + zIndex = 900; + + scale.set(0.65, 0.65); + + loadSparrow('freeplay/char_select'); + active = true; + + animation.addByPrefix('intro', 'char select intro', 24, false); + animation.addByIndices('idle', 'char select intro', [14], "", 24, true); + animation.callback = function(name:String, frameNumber:Int, frameIndex:Int) { + if (name == 'intro' && frameNumber == 3) { + spawnIcon(); + } + }; + } + + function spawnIcon():Void { + if (currentCharacterIcon != null) FlxG.state.subState.remove(currentCharacterIcon); + + var saveData:Dynamic = characterHandler.scriptGet('saveData'); + var characterData:Dynamic = characterHandler.scriptCall('getCharacterData', [saveData.characterIDs.bf, 1]); + + currentCharacterIcon = new HealthIcon('dad', 0); + currentCharacterIcon.configure(characterData?.healthIcon); + currentCharacterIcon.x = 125; + currentCharacterIcon.y = 195; + currentCharacterIcon.camera = FlxG.state.subState.funnyCam; + currentCharacterIcon.zIndex = 1000; + + FlxG.state.subState.add(currentCharacterIcon); + FlxG.state.subState.refresh(); + + if (currentCharacterIcon.width > currentCharacterIcon.height) { + currentCharacterIcon.setGraphicSize(Std.int(currentCharacterIcon.width + (150 * currentCharacterIcon.size.x * 0.2)), 0); + } + else { + currentCharacterIcon.setGraphicSize(0, Std.int(currentCharacterIcon.height + (150 * currentCharacterIcon.size.y * 0.2))); + } + + currentCharacterIcon.angle += 1; + + currentCharacterIcon.updateHitbox(); + currentCharacterIcon.updatePosition(); + } +} diff --git a/scripts/modules/CharacterHandler.hxc b/scripts/modules/CharacterHandler.hxc new file mode 100644 index 0000000..dac1bb6 --- /dev/null +++ b/scripts/modules/CharacterHandler.hxc @@ -0,0 +1,1028 @@ +import Array; +import flixel.FlxG; +import flixel.graphics.FlxGraphic; +import funkin.audio.FunkinSound; +import funkin.audio.VoicesGroup; +import funkin.data.freeplay.player.PlayerRegistry; +import funkin.data.song.SongRegistry; +import funkin.graphics.FunkinSprite; +import funkin.modding.PolymodErrorHandler; +import funkin.modding.base.ScriptedFunkinSprite; +import funkin.modding.base.ScriptedMusicBeatSubState; +import funkin.modding.module.Module; +import funkin.modding.module.ModuleHandler; +import funkin.play.GameOverSubState; +import funkin.play.PauseSubState; +import funkin.play.PlayState; +import funkin.play.PlayStatePlaylist; +import funkin.play.ResultState; +import funkin.play.character.CharacterDataParser; +import funkin.play.character.CharacterType; +import funkin.save.Save; +import funkin.ui.AtlasText; +import funkin.ui.MusicBeatSubState; +import funkin.ui.freeplay.FreeplayState; +import funkin.ui.options.OptionsState; +import funkin.util.Constants; +import funkin.util.FileUtil; +import funkin.util.FileWriteMode; +import funkin.util.MemoryUtil; +import funkin.util.PlatformUtil; +import funkin.util.ReflectUtil; +import funkin.util.SerializerUtil; +import funkin.util.VersionUtil; +import funkin.util.assets.DataAssets; +import haxe.Exception; +import haxe.ds.StringMap; +import haxe.io.Bytes; +import lime.app.Application; +import openfl.display.BitmapData; +import openfl.display3D.textures.RectangleTexture; +import openfl.display3D.textures.TextureBase; +import Std; +import String; + +/** + * This is a Module that's essentially the "core" + * of Funker Selector. + * + * It initializes the save data, the JSON cache, caches + * sparrows, houses the character replacement logic, and + * a bunch of other stuff. + */ +class CharacterHandler extends Module { + /** + * The save data for Funker Selector. + * This holds Character IDs, Preferences and some other stuff. + */ + public var saveData:Dynamic; + + /** + * The current voice list, used for vocal replacement. + */ + var voiceList:Array; + + /** + * The current supported game version. + * NOTE: Only change this if there's breaking changes that + * make it not function on older versions!! + */ + var supportedVersionRule:String = ">=0.5.2 <0.6.0"; + + /** + * currentCachedTextures gets cleared everytime we load into a song. + * It'd be annoying to have to re-cache everything everytime we exit, so we + * keep track of JSON characters in a separate map instead. + */ + var cachedJSONSparrows:StringMap = new StringMap(); + + /** + * A map containing every character. + */ + var characterMap:StringMap> = new StringMap(); + + /** + * Cache for JSON character data. + */ + var jsonCharacterCache:StringMap, Array> = new StringMap(); + + /** + * A map of all the locked characters. + */ + var lockedCharMap:StringMap = new StringMap(); + + /** + * Some janky shit for passing the BPM through CharacterMenu don't worry about it. + */ + var freeplayBPM:Float = null; + + /** + * The current Character Select SubState. + * This is set to `null` when not in use. + */ + var charSelect:MusicBeatSubState = null; + + /** + * An `AtlasText` object, used for the Character Select button. + */ + var charText:AtlasText; + + /** + * The Icon as a `FunkinSprite`. + */ + var charSelectIcon:FunkinSprite; + + /** + * Check the game version, initialize save data, and build the JSON character cache. + */ + function new() { + super("CharacterHandler", 1); + + // The FIRST thing we check is the game version. + // We use `lime.app.Application` instead of `Constants.VERSION` because the latter + // has added suffixes depending on if the game is a debug build or not. + if (!VersionUtil.validateVersionStr(Application.current.meta.get('version'), supportedVersionRule)) { + PolymodErrorHandler.showAlert("Funker Selector Error", + "Funker Selector is not supported on Friday Night Funkin' v" + Application.current.meta.get('version') + "."); + this.active = false; + return; + } + + // Initialize the save data. + initializeSaveData(); + + // Parses and caches JSON character data. + loadJSONDataCache(); + + if (isDebugBuild()) { + trace("[Funker Selector] Game is a debug build."); + } + else { + trace("[Funker Selector] Game is a release build."); + } + } + + /** + * Retrieves the Funker Selector save data. + * @return The saved data as a dynamic object. + */ + function initializeSaveData():Dynamic { + var defaultSaveData:Dynamic = { + characterIDs: { + bf: 'default', + gf: 'default', + dad: 'default' + }, + preferences: new StringMap(), + seenUnlocks: [] + }; + + var defaultSettings:Dynamic = { + potatoMode: false, + preloadSprites: true, + preferredSFX: "funkin", + djSwapping: true + }; + + if (!Save.instance.modOptions.exists("FunkerSelector")) { + for (key in ReflectUtil.getAnonymousFieldsOf(defaultSettings)) { + var value = ReflectUtil.getAnonymousField(defaultSettings, key); + defaultSaveData.preferences.set(key, value); + }; + + Save.instance.modOptions.set("FunkerSelector", defaultSaveData); + Save.instance.flush(); + saveData = defaultSaveData; + + trace('[Funker Selector] Successfully created save data:\n\n---Character IDs---\n\nBoyfriend: ' + + saveData.characterIDs.bf + + '\nGirlfriend: ' + + saveData.characterIDs.gf + + '\nOpponent: ' + + saveData.characterIDs.dad + + '\n\n---Settings---\n\nSimplify UI: ' + + saveData.preferences.get("potatoMode") + + '\nPreload Sprites: ' + + saveData.preferences.get("preloadSprites") + + '\nPreferred SFX: ' + + saveData.preferences.get("preferredSFX") + + '\nDJ Replacement: ' + + saveData.preferences.get("djSwapping") + + '\n\n---Seen Unlock Animations---\n\n' + + saveData.seenUnlocks); + } + else { + saveData = Save.instance.modOptions.get("FunkerSelector"); + + // Check if save data uses the old system. + if (saveData.characterIDs == null && saveData.preferences == null && saveData.seenUnlocks == null) { + trace('[Funker Selector] Save data needs migration!'); + saveData = migrateSaveData(); + } + + // Add new settings if they don't exist + for (key in ReflectUtil.getAnonymousFieldsOf(defaultSettings)) { + var value = ReflectUtil.getAnonymousField(defaultSettings, key); + if (!saveData.preferences.exists(key)) saveData.preferences.set(key, value); + }; + + trace('[Funker Selector] Successfully retrieved save data:\n\n---Character IDs---\n\nBoyfriend: ' + + saveData.characterIDs.bf + + '\nGirlfriend: ' + + saveData.characterIDs.gf + + '\nOpponent: ' + + saveData.characterIDs.dad + + '\n\n---Settings---\n\nSimplify UI: ' + + saveData.preferences.get("potatoMode") + + '\nPreload Sprites: ' + + saveData.preferences.get("preloadSprites") + + '\nPreferred SFX: ' + + saveData.preferences.get("preferredSFX") + + '\nDJ Replacement: ' + + saveData.preferences.get("djSwapping") + + '\n\n---Seen Unlock Animations---\n\n' + + saveData.seenUnlocks); + } + + return saveData; + } + + /** + * Migrate the save data to the new format + * @param oldSave The old save data to use. + * @return The migrated save file. + */ + function migrateSaveData():Dynamic { + trace('[Funker Selector] Migrating save data...'); + + var oldCharacterIDs:Dynamic = Save.instance.modOptions.get("FunkerSelector"); + var oldPrefs:Dynamic = Save.instance.modOptions.get("FunkerSelectorSettings"); + var oldSeenUnlocks:Array = Save.instance.modOptions.get("FunkerSelector-SeenChars"); + + var migratedSave:Dynamic = { + characterIDs: { + bf: oldCharacterIDs?.bf != null ? oldCharacterIDs.bf : 'default', + gf: oldCharacterIDs?.gf != null ? oldCharacterIDs.gf : 'default', + dad: oldCharacterIDs?.dad != null ? oldCharacterIDs.dad : 'default' + }, + preferences: new StringMap(), + seenUnlocks: oldSeenUnlocks != null ? oldSeenUnlocks : [] + }; + + // Settings is a special case... + var settings:Dynamic = { + potatoMode: oldPrefs?.potatoMode != null ? oldPrefs.potatoMode : false, + preloadSprites: oldPrefs?.preloadSprites != null ? oldPrefs.preloadSprites : false, + preferredSFX: oldPrefs?.preferredSFX != null ? oldPrefs.preferredSFX : 'funkin', + djSwapping: oldPrefs?.djSwapping != null ? oldPrefs.djSwapping : true + }; + + for (key in ReflectUtil.getAnonymousFieldsOf(settings)) { + var value = ReflectUtil.getAnonymousField(settings, key); + migratedSave.preferences.set(key, value); + }; + + // Clear out the old save data. + Save.instance.modOptions.remove("FunkerSelector"); + Save.instance.modOptions.remove("FunkerSelectorSettings"); + Save.instance.modOptions.remove("FunkerSelector-SeenChars"); + + // Save the new data. + Save.instance.modOptions.set("FunkerSelector", migratedSave); + Save.instance.flush(); + + return migratedSave; + } + + /** + * DEBUG: Loads save data from a JSON file. + */ + function debug_loadSaveFromJSON():Void { + if (!Assets.exists(Paths.json("FS_dumpedSave"))) { + trace("[Funker Selector] DEBUG: No JSON save data found!"); + return; + } + + var parsedData:Dynamic = SerializerUtil.fromJSON(Assets.getText(Paths.json("FS_dumpedSave"))); + + var saveObject:Dynamic = { + characterIDs: { + bf: 'default', + gf: 'default', + dad: 'default' + }, + preferences: new StringMap(), + seenUnlocks: [] + }; + + if (parsedData.characterIDs != null) { + saveObject.characterIDs.bf = parsedData.characterIDs.bf; + saveObject.characterIDs.gf = parsedData.characterIDs.gf; + saveObject.characterIDs.dad = parsedData.characterIDs.dad; + } + + if (parsedData.preferences != null) { + for (key in ReflectUtil.getAnonymousFieldsOf(parsedData.preferences)) { + saveObject.preferences.set(key, ReflectUtil.getAnonymousField(parsedData.preferences, key)); + } + } + + if (parsedData.seenUnlocks != null) { + saveObject.seenUnlocks = parsedData.seenUnlocks; + } + + saveData = saveObject; + + Save.instance.modOptions.set("FunkerSelector", saveData); + Save.instance.flush(); + + trace('[Funker Selector] DEBUG: Loaded save data from JSON:\n\n---Character IDs---\n\nBoyfriend: ' + + saveData.characterIDs.bf + + '\nGirlfriend: ' + + saveData.characterIDs.gf + + '\nOpponent: ' + + saveData.characterIDs.dad + + '\n\n---Settings---\n\nSimplify UI: ' + + saveData.preferences.get("potatoMode") + + '\nPreload Sprites: ' + + saveData.preferences.get("preloadSprites") + + '\nPreferred SFX: ' + + saveData.preferences.get("preferredSFX") + + '\nDJ Replacement: ' + + saveData.preferences.get("djSwapping") + + '\n\n---Seen Unlock Animations---\n\n' + + saveData.seenUnlocks); + } + + /** + * Whether or not the game is a debug build. + * @return Bool + */ + function isDebugBuild():Bool { + // Debug builds use a different background color. + return Application.current.window.context.attributes.background == 0xFFFF00FF; + } + + /** + * DEBUG: Writes Funker Selector save data to a JSON file. + */ + function debug_dumpSave():Void { + var fileString:String = PlatformUtil.isMacOS() ? "../../../../../../../" : "../../../../"; + var filePath:String = fileString + "example_mods/[V-Slice] Funker Selector/data/FS_dumpedSave.json"; + + FileUtil.writeStringToPath(filePath, SerializerUtil.toJSON(saveData, true), FileWriteMode.Force); + + trace("[Funker Selector] DEBUG: Wrote save data to " + filePath); + } + + /** + * Gets the BPM of the currently selected song in Freeplay. + * @param freeplay The Freeplay SubState to use as a dynamic object. + * @return The BPM. + */ + function getFreeplayCapsuleBPM(freeplay:Dynamic):Float { + var daSongCapsule = freeplay.grpCapsules.members[freeplay.curSelected]; + var songBPM = daSongCapsule?.freeplayData?.songStartingBpm; + + if (daSongCapsule.songText.text == 'Random' && songBPM == null) { + var songMusicData:Null = SongRegistry.instance.parseMusicData('freeplayRandom'); + songBPM = songMusicData.timeChanges[0].bpm; + } + + if (songBPM == null) songBPM = 120; + + return songBPM; + } + + /** + * Parse a Funker Selector JSON file and return the data. + * @param characterID The character ID to use. + * @return The JSON data as a Json object. + */ + function parseJSONData(characterID:String = 'default'):Dynamic { + var filePath = Paths.json('funkerSelector/' + characterID); + + if (Assets.exists(filePath)) { + var result = SerializerUtil.fromJSON(Assets.getText(filePath)); + if (result == null) trace("[Funker Selector] Something went wrong when parsing " + Assets.getPath(filePath) + "! Returning null..."); + return result; + } + + return null; + } + + /** + * Whether or not the character should be shown. + * @param characterID The character ID we're using. + * @param unlockType The method we're using to determine if this character is unlocked. + * @return Bool + * + * Unlock Methods: + * - playableCharacter: Checks if the associated playable character is unlocked. + * - song: Checks if the song has been beaten on the specified difficulty list. + * - storyWeek: Checks if the week has been beaten on the specified difficulty list. + */ + function isCharacterUnlocked(characterID:String, unlockMethod:Dynamic):Bool { + if (unlockMethod == null) return false; + trace("[Funker Selector] Checking unlock methods for ID " + characterID + " ..."); + switch (unlockMethod.type) { + case 'playableCharacter': + if (PlayerRegistry.instance.isCharacterOwned(characterID)) { + var ownerID:String = PlayerRegistry.instance.getCharacterOwnerId(characterID); + var playableCharacter:PlayableCharacter = PlayerRegistry.instance.fetchEntry(ownerID); + if (playableCharacter != null && playableCharacter.isUnlocked()) { + trace("[Funker Selector] Playable character is unlocked with ID " + ownerID); + return true; + } + else { + trace("[Funker Selector] Playable character is not unlocked with ID " + ownerID); + return false; + } + } + case "song": + if (Save.instance.hasBeatenSong(unlockMethod.songID, unlockMethod.difficultyList)) { + trace("[Funker Selector] Song ID " + unlockMethod.songID + " was beaten with difficulty list " + unlockMethod.difficultyList); + return true; + } + else { + trace("[Funker Selector] Song ID " + + unlockMethod.songID + + " has not been beaten with difficulty list " + + unlockMethod.difficultyList); + return false; + } + case 'storyWeek': + if (Save.instance.hasBeatenLevel(unlockMethod.levelID, unlockMethod.difficultyList)) { + trace("[Funker Selector] Level ID " + unlockMethod.levelID + " was beaten with difficulty list " + unlockMethod.difficultyList); + return true; + } + else { + trace("[Funker Selector] Level ID " + + unlockMethod.levelID + + " has not been beaten with difficulty list " + + unlockMethod.difficultyList); + return false; + } + default: + trace("[Funker Selector] Unlock Method " + unlockMethod.type + " not recognized."); + return false; + } + } + + /** + * We preload the sprites for the JSON characters + * when the Module is created in order to reduce lag when + * they're shown in the Character Selection screen. + * + * If "Simplify UI" is enabled, the sprites are not + * cached as they won't show up in the menu. + */ + function cacheJSONSparrows():Void { + var funkerJSONs:Array = DataAssets.listDataFilesInPath('funkerSelector/'); + for (funker in funkerJSONs) { + var funkerData:Dynamic = jsonCharacterCache.get(funker); + var data:Dynamic = funkerData[0]; + var charData:Dynamic = funkerData[1]; + + if (data == null) continue; + + if (data.characterID == null) { + trace('[ERROR] Failed to cache sprite for ' + Assets.getPath(Paths.json('funkerSelector/' + funker)) + '! The "characterID" field does not exist.'); + continue; + } + + if (!charJSONCheck(data.characterID)) { + trace('[ERROR] Failed to cache sprite! The Character ID (' + data.characterID + ') is invalid.'); + continue; + } + + switch (charData.renderType) { + case 'multisparrow': + for (anim in charData.animations) { + if (anim.assetPath != null) cacheSprite(Paths.image(anim.assetPath)); + } + case 'animateatlas': + cacheSprite(Paths.image(charData.assetPath + '/spritemap1')); + case 'sparrow': + cacheSprite(Paths.image(charData.assetPath)); + default: + cacheSprite(Paths.image(charData.assetPath)); + } + } + } + + /** + * Initializing and caching character data for use in the `CharacterMenu` SubState itself. + * + * Characters use a JSON file and are loaded from `data/funkerSelector/` and + * then parsed, they can specify Game Over music, Blue Ball, + * and Pause Menu music suffixes. + * If no character type is specified in the JSON, it is pushed + * to the Boyfriend character list. + * If no character ID exists in the file (or the character ID is invalid), the character will be skipped. + */ + function loadJSONDataCache():Void { + var funkerJSONs:Array = DataAssets.listDataFilesInPath('funkerSelector/'); + var data:Dynamic = null; + + for (funker in funkerJSONs) { + trace('[Funker Selector] Parsing JSON data for ' + funker); + + data = parseJSONData(funker); + + // If the data is null (Caused by an issue when parsing the json file) + if (data == null) { + PolymodErrorHandler.showAlert("Funker Selector JSON Parsing Error", + "Something went wrong when parsing the following JSON file:\n\n" + Assets.getPath(Paths.json('funkerSelector/' + funker)) + + "\n\nPlease check the JSON file for any syntax errors."); + continue; + } + + // If the "characterID" field was not found. + if (data.characterID == null) { + PolymodErrorHandler.showAlert('Funker Selector JSON Parsing Error', + 'In "' + Assets.getPath(Paths.json('funkerSelector/' + funker)) + + '":\n\nThe "characterID" field was not found. This character will be skipped to prevent any issues.'); + continue; + } + + // If the Character ID is "default" + if (data.characterID == 'default') { + PolymodErrorHandler.showAlert('Funker Selector JSON Parsing Error', + 'In "' + + Assets.getPath(Paths.json('funkerSelector/' + funker)) + + '":\n\nThe specified Character ID (' + + data.characterID + + + ') is set to "default"! Please change it, since it will conflict with the "default" character object in the menu.\nThis character will be skipped to prevent any issues.'); + continue; + } + + // If the specified Character ID does not exist in data/characters/ + if (!charJSONCheck(data.characterID)) { + PolymodErrorHandler.showAlert('Funker Selector JSON Parsing Error', + 'In "' + + Assets.getPath(Paths.json('funkerSelector/' + funker)) + + '":\n\nThe specified Character ID (' + + data.characterID + + ') is invalid. This character will be skipped to prevent any issues.'); + continue; + } + + if (data.mustUnlock && data.unlockMethod != null) { + if (!isCharacterUnlocked(data.characterID, data.unlockMethod)) { + trace('[Funker Selector] Character ID ' + data.characterID + ' is locked!'); + lockedCharMap.set(data.characterID, data); + } + else { + if (!saveData.seenUnlocks.contains(data.characterID)) { + trace('[Funker Selector] Character ID ' + data.characterID + ' was unlocked!'); + lockedCharMap.set(data.characterID, data); + } + } + } + + // Default to bf if the character type doesn't exist. + if (data.characterType == null) data.characterType = 'bf'; + + // Caching the character data. + charData = CharacterDataParser.parseCharacterData(data.characterID); + + var variationList:Array = []; + var variationNum:Int = 0; + + if (data.characterVariations != null) { + for (variation in data.characterVariations) { + if (charJSONCheck(variation.characterID)) { + variationList.push(variation.characterID); + } + } + variationNum = variationList.length; + } + + // NOTE: `jsonCharacterCache` and `characterMap` serve 2 different purposes. + jsonCharacterCache.set(funker, [data, charData, variationNum]); + characterMap.set(data.characterID, [funker, data.characterType]); + + trace("[Funker Selector] " + funker + " has " + variationNum + " available variation(s)."); + } + } + + /** + * Stolen from FunkinSprite. + * + * @param path The file path. + */ + function cacheSprite(path:String) { + if (cachedJSONSparrows.exists(path)) { + trace('[Funker Selector] Graphic is already cached: ' + path); + return cachedJSONSparrows.get(path); + } + trace('[Funker Selector] Caching sprite: ' + path); + + var graphic:FlxGraphic = FlxGraphic.fromAssetKey(path, false, null, true); + if (graphic == null) { + trace('[Funker Selector] Failed to cache graphic: ' + path); + } + else { + trace('[Funker Selector] Successfully cached graphic: ' + path); + graphic.persist = true; + cachedJSONSparrows.set(path, graphic); + } + } + + /** + * Clear the JSON sparrows from memory. + */ + function purgeJSONCache():Void { + for (graphicKey in cachedJSONSparrows.keys()) { + trace("[Funker Selector] Clearing from memory: " + graphicKey); + var graphic = cachedJSONSparrows.get(graphicKey); + if (graphic == null) { + trace("[Funker Selector] Graphic is null!!"); + continue; + } + FlxG.bitmap.remove(graphic); + graphic.persist = false; + graphic.destroyOnNoUse = true; + graphic.destroy(); + cachedJSONSparrows.remove(graphicKey); + } + MemoryUtil.collect(true); + } + + /** + * The actual logic for character replacement. + * For Boyfriend and Dad, it checks to see if a vocal + * file of the current song exists for the specified character, + * and replaces the original character's vocals with that. + * + * @param characterID The character ID to change into. + * @param characterType The character type we're using. + * + */ + function replaceChar(characterID:String = 'bf', characterType:CharacterType = null):Void { + var fs_characterData:Dynamic = getCharacterData(characterID); + var oldChar:BaseCharacter = null; + + var voiceID:String = fs_characterData?.voiceID != null ? fs_characterData.voiceID : characterID; + var variationSuffix:String = PlayState.instance.currentVariation != 'default' ? '-' + PlayState.instance.currentVariation : ''; + var voiceFile:String = Paths.voices(PlayState.instance.currentSong.id, '-' + voiceID + variationSuffix); + + if (characterID == 'default') return; + + trace('[Funker Selector] Replacing type ' + characterType + ' with id ' + characterID); + + switch (characterType) { + case CharacterType.BF: + oldChar = PlayState.instance.currentStage.getBoyfriend(); + if (Assets.exists(voiceFile)) { + voiceList[0] = voiceFile; + } + case CharacterType.GF: + oldChar = PlayState.instance.currentStage.getGirlfriend(); + case CharacterType.DAD: + oldChar = PlayState.instance.currentStage.getDad(); + if (Assets.exists(voiceFile)) { + voiceList[1] = voiceFile; + } + default: + trace('How did you get here?'); + return; + } + + // Return if the target character does not exist. + if (oldChar == null) return; + + if (fs_characterData?.characterVariations != null) { + for (variation in fs_characterData.characterVariations) { + if (variation.songID == null || variation.characterID == null) { + continue; + } + + var songMatch:Bool = false; + var variationMatch:Bool = false; + + if (variation.songID is Array) { + songMatch = (variation.songID.contains(PlayState.instance.currentSong.id)); + } + else if (variation.songID is String) { + songMatch = (variation.songID == PlayState.instance.currentSong.id); + } + + if (variation.songVariation != null) { + if (variation.songVariation is Array) { + variationMatch = (variation.songVariation.contains(PlayState.instance.currentVariation)); + } + else if (variation.songVariation is String) { + variationMatch = (variation.songVariation == PlayState.instance.currentVariation); + } + } + else { + variationMatch = true; // Assume true if songVariation does not exist. + } + + if (songMatch && variationMatch) { + fs_characterData = getCharacterData(variation.characterID) != null ? getCharacterData(variation.characterID) : fs_characterData; + + if (charJSONCheck(variation.characterID)) { + trace('[Funker Selector] Variation found: ' + variation.characterID); + characterID = variation.characterID; + } + else { + PolymodErrorHandler.showAlert('Funker Selector Error', + 'The character variation "' + variation.characterID + '" does not exist. The base character ID will be used instead.'); + } + break; + } + } + } + + // Don't bother replacing the character if both character IDs match. + if (oldChar.characterId != characterID) { + var charZIndex:Int = oldChar.zIndex; + var character = CharacterDataParser.fetchCharacter(characterID); + + if (character != null) { + oldChar.destroy(); + character.zIndex = charZIndex; + PlayState.instance.currentStage.addCharacter(character, characterType); + } + else { + PolymodErrorHandler.showAlert('Funker Selector Error', 'Something went wrong replacing the ' + characterType + ' character. How the fuck???'); + } + } + else { + trace('[Funker Selector] ID ' + characterID + ' and ID ' + oldChar.characterId + ' match. Not replacing...'); + } + + if (fs_characterData != null && characterType == CharacterType.BF) { + PauseSubState.musicSuffix = (fs_characterData.suffixes?.pauseMusic != null + && fs_characterData.suffixes.pauseMusic != '') ? fs_characterData.suffixes.pauseMusic : PauseSubState.musicSuffix; + GameOverSubState.musicSuffix = (fs_characterData.suffixes?.gameOverMusic != null + && fs_characterData.suffixes.gameOverMusic != '') ? fs_characterData.suffixes.gameOverMusic : GameOverSubState.musicSuffix; + GameOverSubState.blueBallSuffix = (fs_characterData.suffixes?.blueBall != null + && fs_characterData.suffixes.blueBall != '') ? fs_characterData.suffixes.blueBall : GameOverSubState.blueBallSuffix; + } + + trace('[Funker Selector] Suffixes\n\nPause Music: ' + PauseSubState.musicSuffix + '\nGame Over Music: ' + GameOverSubState.musicSuffix + + '\nGame Over SFX: ' + GameOverSubState.blueBallSuffix); + } + + /** + * Copy-pasted from CharacterMenu. + */ + function charJSONCheck(string:String):Bool { + if (Assets.exists(Paths.json('characters/' + string))) { + var characterFiles = DataAssets.listDataFilesInPath('characters/'); + return characterFiles.contains(string); + } + return false; + } + + /** + * Modified version of buildVocals(); to handle vocal replacement. + * + * @param voiceList The supplied array. + */ + function replaceVocals(voiceList:Array):VoicesGroup { + var currentChart = PlayState.instance.currentChart; + var result:VoicesGroup = new VoicesGroup(); + var songVoices:Array = currentChart.buildVoiceList(); + + if (voiceList[0] != null) { + result.addPlayerVoice(FunkinSound.load(voiceList[0])); + } + else { + result.addPlayerVoice(FunkinSound.load(songVoices[0])); + } + + if (voiceList[1] != null) { + result.addOpponentVoice(FunkinSound.load(voiceList[1])); + } + else { + result.addOpponentVoice(FunkinSound.load(songVoices[1])); + } + + result.playerVoicesOffset = currentChart.offsets.getVocalOffset(currentChart.characters.player); + result.opponentVoicesOffset = currentChart.offsets.getVocalOffset(currentChart.characters.opponent); + + return result; + } + + /** + * Fetches the Character Data from the cache. + * @param characterID The character ID we are using. + * @param arrayNum The index specifying what part of the JSON cache to get. + * @return The character data as a dynamic object. + */ + function getCharacterData(characterID:String, ?arrayNum:Int = 0):Dynamic { + characterInfo = characterMap.get(characterID); + + if (characterInfo == null) return null; + + ownerJSONname = characterInfo[0]; + jsonData = jsonCharacterCache.get(ownerJSONname); + + // Checking to see if it's within bounds... + if (jsonData == null || arrayNum < 0 || arrayNum >= jsonData.length) return null; + + characterData = jsonData[arrayNum]; + + if (characterData == null) return null; + return characterData; + } + + override function onCreate(event) { + super.onCreate(event); + // Cache the sprites for JSON characters. + // This is only done once. + if (saveData.preferences.get("preloadSprites") && !saveData.preferences.get("potatoMode")) cacheJSONSparrows(); + } + + override function onUpdate(event) { + super.onUpdate(event); + if (FlxG.state.subState is FreeplayState) { + // Open the CharacterMenu substate. + // We need to make a new one everytime since it gets destroyed when it's closed. + if (FlxG.keys.justPressed.G && charText.visible && !FlxG.state.subState.busy) { + if (charSelect == null) { + freeplayBPM = getFreeplayCapsuleBPM((FlxG.state.subState)); + charSelect = ScriptedMusicBeatSubState.init("CharacterSelectSubState"); + charSelect.camera = FlxG.state.subState.funnyCam; + FlxG.state.subState.persistentUpdate = false; + trace("[Funker Selector] Opening the Character Select SubState..."); + FlxG.state.subState.busy = true; + FlxG.state.subState.openSubState(charSelect); + } + else { + trace("[Funker Selector] The Character Select SubState is already open!"); + } + } + } + + // DEBUG FEATURES + // These are functions that are only available + // on debug builds. + + if (isDebugBuild()) { + // Force reload the JSON cache with F6. + if (FlxG.keys.justPressed.F6) { + trace("[Funker Selector] Force reloading JSON cache..."); + loadJSONDataCache(); + } + + // Write the save data to a JSON file. + if (FlxG.keys.justPressed.F7) { + trace("[Funker Selector] Writing save data to JSON..."); + debug_dumpSave(); + } + + // Load the save data from a JSON file. + if (FlxG.keys.justPressed.F8) { + trace("[Funker Selector] Loading save data from JSON..."); + debug_loadSaveFromJSON(); + } + } + } + + /** + * Stuff for Results and Freeplay. + */ + override function onSubStateOpenEnd(event) { + super.onSubStateOpenEnd(event); + var state = event.targetState; + // Set up the Character Select button. + if (state is FreeplayState) { + if (saveData.preferences.get("djSwapping")) { + trace("[Funker Selector] Switching out DJ..."); + ModuleHandler.getModule("DJReplace").scriptCall('transitionToNewDJ', [FlxG.state.subState, saveData, true]); + } + + if (charSelect != null) { + trace("[Funker Selector] Character SubState still exists! Resetting..."); + charSelect = null; + } + + charSelectIcon = ScriptedFunkinSprite.init('FunkerSelectorFreeplayIcon'); + charSelectIcon.camera = state.funnyCam; + charSelectIcon.visible = false; + + charText = new AtlasText(170, 300, 'G', 'bold'); + charText.visible = false; + charText.zIndex = 1001; + charText.camera = state.funnyCam; + + state.add(charSelectIcon); + state.add(charText); + + state.dj.onIntroDone.add(function() { + charSelectIcon.visible = true; + charSelectIcon.animation.play('intro'); + charSelectIcon.animation.finishCallback = function(_) { + charSelectIcon.animation.play('idle'); + charText.visible = true; + }; + }); + } + } + + /** + * Resetting Game Over and Pause Menu suffixes. + * We put this in `onStateChangeBegin();` instead of `onStateChangeEnd();`, otherwise, + * scripted characters will have their suffixes overridden! + */ + override function onStateChangeBegin(event) { + super.onStateChangeBegin(event); + if (event.targetState is PlayState) { + PauseSubState.musicSuffix = ''; + GameOverSubState.musicSuffix = ''; + GameOverSubState.blueBallSuffix = ''; + } + } + + override function onStateChangeEnd(event) { + super.onStateChangeEnd(event); + + // Options Menu stuff + if (event.targetState is OptionsState) { + var prefs = event.targetState.pages.get("preferences"); + if (prefs != null) { + prefs.add(prefs.items.createItem(120, 120 * prefs.items.length + 30, "-- FUNKER SELECTOR --", "bold", () -> {})).fireInstantly = true; + + prefs.createPrefItemCheckbox("Simplify UI", "Simplifies the UI and disables character sprite caching.", (value) -> { + saveData.preferences.set("potatoMode", value); + Save.instance.modOptions.set("FunkerSelector", saveData); + Save.instance.flush(); + }, saveData.preferences.get("potatoMode")); + + prefs.createPrefItemCheckbox("Preload Sprites", + "Whether to preload the character sprites or not. Will cause lag on the Character Selection Menu if off!", (value) -> { + saveData.preferences.set("preloadSprites", value); + Save.instance.modOptions.set("FunkerSelector", saveData); + Save.instance.flush(); + if (value) { + cacheJSONSparrows(); + } + else { + // Remove the JSON Characters from memory when the user disables the option. + purgeJSONCache(); + } + }, saveData.preferences.get("preloadSprites")); + + prefs.createPrefItemEnum('Menu SFX', 'Change the SFX used for the Character Menu.', + ["funkin" => "Funkin' Main Menu", "charSelect" => "Funkin' Character Select"], (value) -> { + saveData.preferences.set("preferredSFX", value); + Save.instance.modOptions.set("FunkerSelector", saveData); + Save.instance.flush(); + if (value == "charSelect") { + FunkinSound.playOnce(Paths.sound('CS_confirm')); + } + else { + FunkinSound.playOnce(Paths.sound('confirmMenu')); + } + }, saveData.preferences.get("preferredSFX")); + + prefs.createPrefItemCheckbox("DJ Replacement", "When enabled, the Freeplay DJ can be swapped out for the currently selected character's own DJ.", + (value) -> { + saveData.preferences.set("djSwapping", value); + Save.instance.modOptions.set("FunkerSelector", saveData); + Save.instance.flush(); + }, saveData.preferences.get("djSwapping")); + + prefs.add(prefs.items.createItem(120, 120 * prefs.items.length, "-------------------", "bold", () -> {})).fireInstantly = true; + } + } + } + + override function onScriptEvent(event) { + super.onScriptEvent(event); + if (event.type == "FS_EXITED_SUBSTATE" && FlxG.state.subState is FreeplayState) { + FlxG.state.subState.busy = false; + charSelect = null; + if (saveData.preferences.get("djSwapping")) { + trace("[Funker Selector] Switching out DJ..."); + ModuleHandler.getModule("DJReplace").scriptCall('transitionToNewDJ', [FlxG.state.subState, saveData]); + } + + if (charSelectIcon != null) { + characterData = getCharacterData(saveData.characterIDs.bf, 1); + currentCharacterIcon = charSelectIcon.scriptGet('currentCharacterIcon'); + + if (currentCharacterIcon.characterId != characterData?.healthIcon?.id) { + charSelectIcon.scriptCall('spawnIcon'); + } + } + } + } + + /** + * Reset the voiceList, swap out the characters, and + * replace vocals if any are found. + */ + override function onCountdownStart(event) { + super.onCountdownStart(event); + if (PlayState.instance == null + || PlayState.instance.currentStage == null + || PlayStatePlaylist.isStoryMode + || PlayState.instance.isMinimalMode + || PlayState.instance.isChartingMode) return; + + // Setting voiceList to null in order to reset the voices, + // since module variables persist between state changes. + voiceList = [null, null]; + + // Replace and swap out the characters. + replaceChar(saveData.characterIDs.bf, CharacterType.BF); + replaceChar(saveData.characterIDs.gf, CharacterType.GF); + replaceChar(saveData.characterIDs.dad, CharacterType.DAD); + PlayState.instance.currentStage.refresh(); + + // If at least ONE of them was changed (means a vocal file was found), replace the vocals. + if (voiceList[0] != null || voiceList[1] != null) { + trace('[Funker Selector] Voice List:\n\nPlayer: ' + voiceList[0] + '\nOpponent: ' + voiceList[1]); + PlayState.instance.vocals.stop(); + PlayState.instance.vocals = replaceVocals(voiceList); + } + } +} diff --git a/scripts/modules/ResultsHandler.hxc b/scripts/modules/ResultsHandler.hxc new file mode 100644 index 0000000..986f00a --- /dev/null +++ b/scripts/modules/ResultsHandler.hxc @@ -0,0 +1,91 @@ +import flixel.FlxG; +import funkin.modding.module.Module; +import funkin.modding.module.ModuleHandler; +import funkin.play.PlayState; +import funkin.play.ResultState; +import funkin.data.freeplay.player.PlayerRegistry; + +/** + * This is a module to help with the Results Screen. + */ +class ResultsHandler extends Module { + var originalPlayerID:String; + var shouldReplace:Bool; + var replaced:Bool; + + function new() { + super('ResultsHandler'); + } + + /** + * Revert the playable character ID to the original after the animations and music are loaded. + * @param event + */ + function onUpdate(event) { + if (FlxG.state.subState is ResultState) { + if (shouldReplace + && (FlxG.state.subState.characterAtlasAnimations.length > 0 || FlxG.state.subState.characterSparrowAnimations.length > 0) + && replaced) { + shouldReplace = false; + replaced = false; + FlxG.state.subState.playerCharacterId = originalPlayerID; + trace("[Funker Selector] Reverting ResultState character ID back to original: " + FlxG.state.subState.playerCharacterId); + } + } + } + + /** + * Replace the Playable Character ID to load their results animations/music instead of the default. + * NOTE: We modify `params` instead of `playerCharacterID` directly, else it gets set too late. + * @param event + */ + override function onSubStateOpenBegin(event) { + if (event.targetState is ResultState) { + shouldReplace = false; + replaced = false; + + if (PlayState.instance.currentStage?.getBoyfriend() == null) return; + + var saveData:Dynamic = ModuleHandler.getModule('CharacterHandler').scriptGet('saveData'); + var currentCharacterID:String = PlayState.instance.currentStage.getBoyfriend().characterId; + + // Save the original ID. + // We manually retrieve the owner ID since the character ID specified in `params` is NOT the + // playable character ID we want. + originalPlayerID = PlayerRegistry.instance.getCharacterOwnerId(event.targetState.params.characterId); + trace("[Funker Selector] Original character ID: " + originalPlayerID); + + // Check if the saved character is owned, then check if we're ACTUALLY playing as them. + if (PlayerRegistry.instance.isCharacterOwned(saveData.characterIDs.bf)) { + shouldReplace = (currentCharacterID == saveData.characterIDs.bf || isVariation(currentCharacterID, saveData.characterIDs.bf)); + + if (shouldReplace) { + replaced = true; + event.targetState.params.characterId = PlayerRegistry.instance.getCharacterOwnerId(saveData.characterIDs.bf); + trace('[Funker Selector] Using character ID ' + event.targetState.params.characterId); + } + else { + trace('[Funker Selector] Not replacing results!'); + } + } + } + } + + /** + * Simple helper function for character variations. + * @param currentID The ID we're comparing + * @param savedID The ID we're comparing + * @return Bool + */ + function isVariation(currentID:String, savedID:String):Bool { + var fs_characterData:Dynamic = ModuleHandler.getModule('CharacterHandler').scriptCall('getCharacterData', [savedID]); + if (fs_characterData?.characterVariations != null) { + for (variation in fs_characterData.characterVariations) { + if (variation.characterID == currentID) { + return true; + } + } + } + return false; + } +} diff --git a/scripts/modules/charHandler.hxc b/scripts/modules/charHandler.hxc deleted file mode 100644 index 78b1577..0000000 --- a/scripts/modules/charHandler.hxc +++ /dev/null @@ -1,791 +0,0 @@ -import Array; -import flixel.FlxG; -import flixel.graphics.FlxGraphic; -import funkin.audio.FunkinSound; -import funkin.audio.VoicesGroup; -import funkin.data.freeplay.player.PlayerRegistry; -import funkin.data.song.SongRegistry; -import funkin.graphics.FunkinSprite; -import funkin.modding.PolymodErrorHandler; -import funkin.modding.base.ScriptedMusicBeatSubState; -import funkin.modding.module.Module; -import funkin.play.GameOverSubState; -import funkin.play.PauseSubState; -import funkin.play.PlayState; -import funkin.play.PlayStatePlaylist; -import funkin.play.ResultState; -import funkin.play.character.CharacterDataParser; -import funkin.play.character.CharacterType; -import funkin.save.Save; -import funkin.ui.AtlasText; -import funkin.ui.freeplay.FreeplayState; -import funkin.ui.options.OptionsState; -import funkin.util.Constants; -import funkin.util.MemoryUtil; -import funkin.util.assets.DataAssets; -import funkin.util.VersionUtil; -import haxe.Json; -import haxe.Exception; -import haxe.ds.StringMap; -import lime.app.Application; -import openfl.display.BitmapData; -import openfl.display3D.textures.RectangleTexture; -import openfl.display3D.textures.TextureBase; -import Std; -import String; - -/** - * Module that handles the Character Select button in - * Freeplay, along with character replacement. - */ -class CharacterHandler extends Module { - /** - * The character IDs. - * This is retrieved from the save data. - */ - public var characterIDs:Dynamic; - - /** - * The preferences for Funker Selector specifically. - * This is retrieved from the save data. - */ - public var funkerSettings:Dynamic; - - /** - * The current voice list, used for vocal replacement. - */ - var voiceList:Array; - - /** - * The current supported game version. - * NOTE: Only change this if there's breaking changes that - * make it not function on older versions!! - */ - var supportedVersionRule:String = ">=0.5.2 <=0.5.3"; - - /** - * currentCachedTextures gets cleared everytime we load into a song. - * It'd be annoying to have to re-cache everything everytime we exit, so we - * keep track of JSON characters in a separate map instead. - */ - var cachedJSONSparrows:StringMap = new StringMap(); - - /** - * A map containing every character. - */ - var characterMap:StringMap> = new StringMap(); - - /** - * Cache for JSON character data. - */ - var jsonCharacterCache:StringMap> = new StringMap(); - - /** - * A map of all the locked characters. - */ - var lockedCharMap:StringMap = new StringMap(); - - /** - * An array of unlock animations we've seen already. - * This ensures the unlock animation only plays once per save file. - */ - var seenUnlocks:Array = []; - - /** - * Some janky shit for passing the BPM through CharacterMenu don't worry about it. - */ - var freeplayBPM:Float = null; - - /** - * The original character ID from the chart. - */ - var ogCharID:String = null; - - var charText:AtlasText; - var charSelectIcon:FunkinSprite; - - function new() { - super("CharacterHandler"); - - // The FIRST thing we check is the game version. - // We use `lime.app.Application` instead of `Constants.VERSION` because the latter - // has added suffixes depending on if the game is a debug build or not. - if (!VersionUtil.validateVersionStr(Application.current.meta.get('version'), supportedVersionRule)) { - PolymodErrorHandler.showAlert("Funker Selector Error", - "Funker Selector is not supported on Friday Night Funkin' v" + Application.current.meta.get('version') + "."); - this.active = false; - return; - } - - // Initialize the save data. - initializeSave(); - - // Parses and caches JSON character data. - loadJSONDataCache(); - - // Initialize the Character Map. - initChars(); - } - - /** - * Retrieves Character IDs and Preferences from the save data. - */ - function initializeSave():Void { - var defaultCharacterIDs:Dynamic = { - bf: 'default', - gf: 'default', - dad: 'default' - }; - - var defaultSettings:Dynamic = { - potatoMode: false, - preloadSprites: true, - preferredSFX: "funkin" - }; - - if (Save.instance.modOptions.get("FunkerSelector") == null) { - Save.instance.modOptions.set("FunkerSelector", defaultCharacterIDs); - Save.instance.flush(); - characterIDs = defaultCharacterIDs; - - trace('[Funker Selector] Successfully created save data:\n\nBoyfriend: ' - + characterIDs.bf - + '\nGirlfriend: ' - + characterIDs.gf - + '\nOpponent: ' - + characterIDs.dad); - } else { - characterIDs = Save.instance.modOptions.get("FunkerSelector"); - trace('[Funker Selector] Successfully retrieved save data:\n\nBoyfriend: ' - + characterIDs.bf - + '\nGirlfriend: ' - + characterIDs.gf - + '\nOpponent: ' - + characterIDs.dad); - } - - if (Save.instance.modOptions.get("FunkerSelectorSettings") == null) { - Save.instance.modOptions.set("FunkerSelectorSettings", defaultSettings); - Save.instance.flush(); - funkerSettings = defaultSettings; - - trace('[Funker Selector] Successfully created save data:\n\nSimplify UI: ' - + funkerSettings.potatoMode - + '\nPreload Sprites: ' - + funkerSettings.preloadSprites); - } else { - funkerSettings = Save.instance.modOptions.get("FunkerSelectorSettings"); - - trace('[Funker Selector] Successfully created save data:\n\nSimplify UI: ' - + funkerSettings.potatoMode - + '\nPreload Sprites: ' - + funkerSettings.preloadSprites); - } - - if (Save.instance.modOptions.get("FunkerSelector-SeenChars") == null) { - Save.instance.modOptions.set("FunkerSelector-SeenChars", seenUnlocks); - Save.instance.flush(); - } else { - seenUnlocks = Save.instance.modOptions.get("FunkerSelector-SeenChars"); - } - - trace("SEEN UNLOCK ANIMATIONS: " + seenUnlocks); - } - - function onCreate(event) { - super.onCreate(event); - - // Cache the sprites for JSON characters. - // This is only done once. - if (funkerSettings.preloadSprites && !funkerSettings.potatoMode) - cacheJSONSparrows(); - } - - function onUpdate(event) { - super.onUpdate(event); - if (FlxG.state.subState is FreeplayState) { - // Open the CharacterMenu substate. - // We need to make a new one everytime since it gets destroyed when it's closed. - if (FlxG.keys.justPressed.G && charText.visible && !FlxG.state.subState.busy) { - freeplayBPM = getBPM((FlxG.state.subState)); - var charSelect:FlxSubState = ScriptedMusicBeatSubState.init("CharacterSelectSubState"); - charSelect.camera = FlxG.state.subState.funnyCam; - FlxG.state.subState.persistentUpdate = false; - FlxG.state.subState.openSubState(charSelect); - } - } - } - - /** - * Gets the BPM of the currently selected song in Freeplay. - * @param freeplay The Freeplay SubState to use as a dynamic object. - * @return The BPM. - */ - function getBPM(freeplay:Dynamic):Float { - var daSongCapsule = freeplay.grpCapsules.members[freeplay.curSelected]; - var songBPM = daSongCapsule?.freeplayData?.songStartingBpm; - if (songBPM == null) - songBPM = 120; - - return songBPM; - } - - /** - * Stuff for Results and Freeplay. - */ - function onSubStateOpenEnd(event) { - super.onSubStateOpenEnd(event); - var state = event.targetState; - // Set up the Character Select button. - if (state is FreeplayState) { - charSelectIcon = FunkinSprite.createSparrow(10, 120, 'freeplay/char_select'); - charSelectIcon.animation.addByPrefix('intro', 'icon enter', 24, false); - charSelectIcon.animation.addByPrefix('idle', 'idle', 24, true); - charSelectIcon.zIndex = 1000; - charSelectIcon.scale.set(0.7, 0.7); - charSelectIcon.camera = state.funnyCam; - charSelectIcon.visible = false; - - charText = new AtlasText(170, 270, 'G', 'bold'); - charText.visible = false; - charText.zIndex = 1001; - charText.camera = state.funnyCam; - - state.add(charSelectIcon); - state.add(charText); - - state.dj.onIntroDone.add(function() { - charSelectIcon.visible = true; - charSelectIcon.animation.play('intro'); - charSelectIcon.animation.finishCallback = function(_) { - charSelectIcon.animation.play('idle'); - charText.visible = true; - if (charSelectIcon.animation.curAnim.name == 'idle') - charSelectIcon.offset.set(-15, 14); - else - charSelectIcon.offset.set(); - }; - }); - } - - // Results screen bullshit. - if (state is ResultState) { - // More playable character shit - // The results anim will still play, it's just Freeplay won't bug out when we do this. - // If the original player is part of a Playable Character, we don't do this. - if (PlayState.instance.currentStage.getBoyfriend()?.characterId == characterIDs.bf) { - PlayState.instance.currentChart.characters.player = ogCharID; - state.playerCharacterId = null; - if (PlayerRegistry.instance.isCharacterOwned(ogCharID)) - state.playerCharacterId = PlayerRegistry.instance.getCharacterOwnerId(ogCharID); - } - } - } - - /** - * Resetting Game Over and Pause Menu suffixes. - * We put this in `onStateChangeBegin();` instead of `onStateChangeEnd();`, otherwise, - * scripted characters will have their suffixes overridden! - */ - override function onStateChangeBegin(event) { - super.onStateChangeBegin(event); - if (event.targetState is PlayState) { - PauseSubState.musicSuffix = ''; - GameOverSubState.musicSuffix = ''; - GameOverSubState.blueBallSuffix = ''; - - // Reload the song entries since `PlayState.instance.currentChart.characters.player` - // doesn't reset properly when we change it. - // TODO: Find the root cause and fix this in Funkin's source code. - SongRegistry.instance.loadEntries(); - } - } - - override function onStateChangeEnd(event) { - super.onStateChangeEnd(event); - - // Options Menu stuff - if (event.targetState is OptionsState) { - var prefs = event.targetState.pages.get("preferences"); - if (prefs != null) { - prefs.add(prefs.items.createItem(120, 120 * prefs.items.length + 30, "-- FUNKER SELECTOR --", "bold", () -> {})).fireInstantly = true; - - prefs.createPrefItemCheckbox("Simplify UI", "Simplifies the UI and disables character sprite caching.", (value) -> { - funkerSettings.potatoMode = value; - Save.instance.modOptions.set("FunkerSelectorSettings", funkerSettings); - Save.instance.flush(); - }, funkerSettings.potatoMode); - - prefs.createPrefItemCheckbox("Preload Sprites", - "Wether to preload the character sprites or not. Will cause lag on the Character Selection Menu if off!", (value) -> { - funkerSettings.preloadSprites = value; - Save.instance.modOptions.set("FunkerSelectorSettings", funkerSettings); - Save.instance.flush(); - if (value) { - cacheJSONSparrows(); - } else { - // Remove the JSON Characters from memory when the user disables the option. - for (jsonChar in cachedJSONSparrows.keys()) { - var char = cachedJSONSparrows.get(jsonChar); - if (char == null) - continue; - FlxG.bitmap.remove(char); - char.persist = false; - char.destroyOnNoUse = true; - char.destroy(); - cachedJSONSparrows.remove(jsonChar); - } - MemoryUtil.collect(true); - } - }, funkerSettings.preloadSprites); - - prefs.createPrefItemEnum('Menu SFX', 'Change the SFX used for the Character Menu.', - ["funkin" => "Funkin' Main Menu", "charSelect" => "Funkin' Character Select"], (value) -> { - funkerSettings.preferredSFX = value; - Save.instance.modOptions.set("FunkerSelectorSettings", funkerSettings); - Save.instance.flush(); - if (value == "charSelect") { - FunkinSound.playOnce(Paths.sound('CS_confirm')); - } else { - FunkinSound.playOnce(Paths.sound('confirmMenu')); - } - }, funkerSettings.preferredSFX); - - prefs.add(prefs.items.createItem(120, 120 * prefs.items.length, "-------------------", "bold", () -> {})).fireInstantly = true; - } - } - } - - /** - * Parse a Funker Selector JSON file and return the data. - * @param characterID The character ID to use. - * @return The JSON data. - */ - function parseJSONData(characterID:String = 'default'):Dynamic { - if (Assets.exists(Paths.json('funkerSelector/' + characterID))) { - return Json.parse(Assets.getText(Paths.json('funkerSelector/' + characterID))); - } - return null; - } - - /** - * Initializing character data for use in the `CharacterMenu` SubState itself. - * - * Characters use a JSON file and are loaded from `data/funkerSelector/` and - * then parsed, they can specify Game Over music, Blue Ball, - * and Pause Menu music suffixes. - * If no character type is specified in the JSON, it is pushed - * to the Boyfriend character list. - * If no character ID exists in the file (or the character ID is invalid), the character will be skipped. - */ - function initChars():Void { - var funkerJSONs:Array = DataAssets.listDataFilesInPath('funkerSelector/'); - var data:Dynamic = null; - - for (funker in funkerJSONs) { - trace('[Funker Selector] Fetching JSON data for ' + funker); - - var funkerData:Dynamic = jsonCharacterCache.get(funker); - var data:Dynamic = funkerData[0]; - - if (data == null) - continue; - - // If the "characterID" field was not found. - if (data.characterID == null) { - PolymodErrorHandler.showAlert('Funker Selector JSON Parsing Error', - 'In "' + Assets.getPath(Paths.json('funkerSelector/' + funker)) + - '":\n\nThe "characterID" field was not found. This character will be skipped to prevent any issues.'); - continue; - } - - // If the Character ID is "default" - if (data.characterID == 'default') { - PolymodErrorHandler.showAlert('Funker Selector JSON Parsing Error', - 'In "' - + Assets.getPath(Paths.json('funkerSelector/' + funker)) - + '":\n\nThe specified Character ID (' - + data.characterID - + - ') is set to "default"! Please change it, since it will conflict with the "default" character object in the menu.\nThis character will be skipped to prevent any issues.'); - continue; - } - - // If the specified Character ID does not exist in data/characters/ - if (!charJSONCheck(data.characterID)) { - PolymodErrorHandler.showAlert('Funker Selector JSON Parsing Error', - 'In "' - + Assets.getPath(Paths.json('funkerSelector/' + funker)) - + '":\n\nThe specified Character ID (' - + data.characterID - + ') is invalid. This character will be skipped to prevent any issues.'); - continue; - } - - if (data.mustUnlock && data.unlockMethod != null) { - if (!isCharacterUnlocked(data.characterID, data.unlockMethod)) { - trace('[Funker Selector] Character ID ' + data.characterID + ' is locked!'); - lockedCharMap.set(data.characterID, data); - } else { - if (seenUnlocks.indexOf(data.characterID) == -1) { - trace('[Funker Selector] Character ID ' + data.characterID + ' was unlocked!'); - lockedCharMap.set(data.characterID, data); - } - } - } - - if (data.characterType == null) - data.characterType = 'bf'; - characterMap.set(data.characterID, [funker, data.characterType]); - } - } - - /** - * Wether or not the character should be shown. - * @param characterID The character ID we're using. - * @param unlockType The method we're using to determine if this character is unlocked. - * @return Bool - * - * Unlock Methods: - * - playableCharacter: Checks if the associated playable character is unlocked. - * - song: Checks if the song has been beaten on the specified difficulty list. - * - storyWeek: Checks if the week has been beaten on the specified difficulty list. - */ - function isCharacterUnlocked(characterID:String, unlockMethod:Dynamic):Bool { - switch (unlockMethod.type) { - case 'playableCharacter': - if (PlayerRegistry.instance.isCharacterOwned(characterID)) { - var ownerID:String = PlayerRegistry.instance.getCharacterOwnerId(characterID); - var playableCharacter:PlayableCharacter = PlayerRegistry.instance.fetchEntry(ownerID); - return playableCharacter != null && playableCharacter.isUnlocked(); - } - case "song": - return Save.instance.hasBeatenSong(unlockMethod.songID, unlockMethod.difficultyList); - case 'storyWeek': - return Save.instance.hasBeatenLevel(unlockMethod.levelID, unlockMethod.difficultyList); - default: - return false; - } - } - - /** - * We preload the sprites for the JSON characters - * when the Module is created in order to reduce lag when - * they're shown in the Character Selection screen. - * - * If "Simplify UI" is enabled, the sprites are not - * cached as they won't show up in the menu. - */ - function cacheJSONSparrows():Void { - var funkerJSONs:Array = DataAssets.listDataFilesInPath('funkerSelector/'); - for (funker in funkerJSONs) { - trace('[Funker Selector] Fetching JSON data for ' + funker); - - var funkerData:Dynamic = jsonCharacterCache.get(funker); - var data:Dynamic = funkerData[0]; - var charData:Dynamic = funkerData[1]; - - if (data == null) - continue; - - if (data.characterID == null) { - trace('[ERROR] Failed to cache sprite for ' + Assets.getPath(Paths.json('funkerSelector/' + funker)) - + '! The "characterID" field does not exist.'); - continue; - } - - if (!charJSONCheck(data.characterID)) { - trace('[ERROR] Failed to cache sprite! The Character ID (' + data.characterID + ') is invalid.'); - continue; - } - - switch (charData.renderType) { - case 'multisparrow': - for (anim in charData.animations) { - if (anim.assetPath != null) - cacheSprite(Paths.image(anim.assetPath)); - } - case 'animateatlas': - cacheSprite(Paths.image(charData.assetPath + '/spritemap1')); - case 'sparrow': - cacheSprite(Paths.image(charData.assetPath)); - default: - cacheSprite(Paths.image(charData.assetPath)); - } - } - } - - /** - * Loads and caches the JSON data for characters. - */ - function loadJSONDataCache():Void { - var funkerJSONs:Array = DataAssets.listDataFilesInPath('funkerSelector/'); - var data:Dynamic = null; - var tempArray:Array = []; - for (funker in funkerJSONs) { - trace('[Funker Selector] Loading JSON data for ' + funker); - - // Exceptions continue the loop so there's no need for a continue; here. - try { - data = parseJSONData(funker); - } catch (e:Dynamic) { - trace(':('); - } - - // If the parsing succeeds, then they get pushed to a temporary array. - tempArray.push(funker); - - // Caching the character data. - charData = CharacterDataParser.parseCharacterData(data.characterID); - - jsonCharacterCache.set(funker, [data, charData]); - } - - // If a character ID doesn't exist in the array, then something definitely went wrong - for (funker in funkerJSONs) { - if (tempArray.indexOf(funker) == -1) { - trace("[Funker Selector] Something went wrong when parsing " + Assets.getPath(Paths.json('funkerSelector/' + funker))) + - "!\nIs the syntax correct?"; - continue; - } - } - } - - /** - * This is taken from Psych Engine, with some modifications. - * This is a lot more Memory-efficient compared to using FunkinSprite's cacheSparrow function! - * - * @param path The file path. - */ - function cacheSprite(path:String) { - if (cachedJSONSparrows.exists(path)) - return; - trace('[Funker Selector] Caching sprite ' + path); - var bitmap:BitmapData = Assets.getBitmapData(path); - if (bitmap != null) { - var texture:RectangleTexture = FlxG.stage.context3D.createRectangleTexture(bitmap.width, bitmap.height, "bgra", true); - texture.uploadFromBitmapData(bitmap); - bitmap.image.data = null; - bitmap.dispose(); - bitmap.disposeImage(); - bitmap = BitmapData.fromTexture(texture); - var graphic:FlxGraphic = FlxGraphic.fromBitmapData(bitmap, false, path); - graphic.persist = true; - cachedJSONSparrows.set(path); - trace('[Funker Selector] Successfully cached graphic: ' + path); - } else { - trace('[ERROR] Failed to cache graphic: ' + path); - } - } - - /** - * Reset the voiceList, swap out the characters, and - * replace vocals if any are found. - */ - override function onCountdownStart(event) { - super.onCountdownStart(event); - if (PlayState.instance == null - || PlayState.instance.currentStage == null - || PlayStatePlaylist.isStoryMode - || PlayState.instance.isMinimalMode - || PlayState.instance.isChartingMode) - return; - - // Setting voiceList to null in order to reset the voices, - // since module variables persist between state changes. - voiceList = [null, null]; - - // Saving the original character ID. - ogCharID = PlayState.instance.currentChart.characters.player; - - // Replace and swap out the characters. - replaceChar(characterIDs.bf, 'bf'); - replaceChar(characterIDs.gf, 'gf'); - replaceChar(characterIDs.dad, 'dad'); - PlayState.instance.currentStage.refresh(); - - // If at least ONE of them was changed (means a vocal file was found), replace the vocals. - if (voiceList[0] != null || voiceList[1] != null) { - trace('[Funker Selector] Voice List:\n\nPlayer: ' + voiceList[0] + '\nOpponent: ' + voiceList[1]); - PlayState.instance.vocals.stop(); - PlayState.instance.vocals = replaceVocals(voiceList); - } - } - - /** - * Changing `player` in `currentChart` to trick the Results Screen - * into loading the Results Screen animations for the currently - * selected character if they exist. - */ - function onSongEnd() { - if (PlayerRegistry.instance.isCharacterOwned(characterIDs.bf) - && PlayState.instance.currentStage.getBoyfriend()?.characterId == characterIDs.bf) { - PlayState.instance.currentChart.characters.player = characterIDs.bf; - } - } - - /** - * The actual logic for character replacement. - * For Boyfriend and Dad, it checks to see if a vocal - * file of the current song exists for the specified character, - * and replaces the original character's vocals with that. - * - * @param id The character ID to change into. - * @param type The character type we're replacing. - * - */ - function replaceChar(id:String = 'bf', type:String = 'bf'):Void { - var characterInfo:Dynamic = characterMap.get(id); - var ownerJSONname:String = characterInfo != null ? characterInfo[0] : null; - var characterData:Dynamic = ownerJSONname != null ? jsonCharacterCache.get(ownerJSONname)[0] : null; - var oldChar:BaseCharacter = null; - var charType:CharacterType = null; - - if (id == 'default') - return; - - trace('[Funker Selector] Replacing type ' + type + ' with id ' + id); - - switch (type) { - case 'bf': - oldChar = PlayState.instance.currentStage.getBoyfriend(); - charType = CharacterType.BF; - case 'gf': - oldChar = PlayState.instance.currentStage.getGirlfriend(); - charType = CharacterType.GF; - case 'dad': - oldChar = PlayState.instance.currentStage.getDad(); - charType = CharacterType.DAD; - default: - trace('How did you get here?'); - return; - } - - // Return if the target character does not exist. - if (oldChar == null) - return; - - // TODO: Add visual indicator for variations. - if (characterData != null && characterData.characterVariations != null) { - for (variation in characterData.characterVariations) { - if (variation.songID == null || variation.characterID == null) { - continue; - } - - var songMatch = false; - var variationMatch = false; - - if (variation.songID is Array) { - songMatch = (variation.songID.indexOf(PlayState.instance.currentSong.id) != -1); - } else if (variation.songID is String) { - songMatch = (variation.songID == PlayState.instance.currentSong.id); - } - - if (variation.songVariation != null) { - if (variation.songVariation is Array) { - variationMatch = (variation.songVariation.indexOf(PlayState.instance.currentVariation) != -1); - } else if (variation.songVariation is String) { - variationMatch = (variation.songVariation == PlayState.instance.currentVariation); - } - } else { - variationMatch = true; // Assume true if songVariation does not exist. - } - - if (songMatch && variationMatch) { - characterData = characterMap.get(variation.characterID) != null ? jsonCharacterCache.get(characterMap.get(variation.characterID)[0])[0] : characterData; - - if (charJSONCheck(variation.characterID)) { - trace('[Funker Selector] Variation found: ' + variation.characterID); - id = variation.characterID; - } else { - PolymodErrorHandler.showAlert('Funker Selector Error', - 'The character variation "' + variation.characterID + '" does not exist. The base character ID will be used instead.'); - } - break; - } - } - } - - var suffix:String = (PlayState.instance.currentVariation != null && PlayState.instance.currentVariation != 'default') ? '-' - + PlayState.instance.currentVariation : ''; - var voiceFile:String = Paths.voices(PlayState.instance.currentSong.id, '-' + id + suffix); - - // Don't bother replacing the character if both character IDs match. - if (oldChar.characterId != id) { - var charZIndex:Int = oldChar.zIndex; - var character = CharacterDataParser.fetchCharacter(id); - - if (character != null) { - oldChar.destroy(); - character.zIndex = charZIndex; - PlayState.instance.currentStage.addCharacter(character, charType); - } else { - PolymodErrorHandler.showAlert('Funker Selector Error', 'Something went wrong replacing the ' + type + ' character. How the fuck???'); - } - } else { - trace('[Funker Selector] ID ' + id + ' and ID ' + oldChar.characterID + ' match. Not replacing...'); - } - - if (characterData != null && type == 'bf') { - PauseSubState.musicSuffix = (characterData.pauseMusicSuffix != null - && characterData.pauseMusicSuffix != '') ? characterData.pauseMusicSuffix : PauseSubState.musicSuffix; - GameOverSubState.musicSuffix = (characterData.gameOverMusicSuffix != null - && characterData.gameOverMusicSuffix != '') ? characterData.gameOverMusicSuffix : GameOverSubState.musicSuffix; - GameOverSubState.blueBallSuffix = (characterData.blueBallSuffix != null - && characterData.blueBallSuffix != '') ? characterData.blueBallSuffix : GameOverSubState.blueBallSuffix; - } - - trace('[Funker Selector] Suffixes\n\nPause Music: ' + PauseSubState.musicSuffix + '\nGame Over Music: ' + GameOverSubState.musicSuffix - + '\nGame Over SFX: ' + GameOverSubState.blueBallSuffix); - - switch (type) { - case 'bf': - if (Assets.exists(voiceFile)) { - voiceList[0] = voiceFile; - } - case 'dad': - if (Assets.exists(voiceFile)) { - voiceList[1] = voiceFile; - } - } - } - - /** - * Copy-pasted from CharacterMenu. - */ - function charJSONCheck(string:String):Bool { - if (Assets.exists(Paths.json('characters/' + string))) { - var characterFiles = DataAssets.listDataFilesInPath('characters/'); - return characterFiles.indexOf(string) != -1; - } - return false; - } - - /** - * Modified version of buildVocals(); to handle vocal replacement. - * - * @param voiceList The supplied array. - */ - function replaceVocals(voiceList:Array):VoicesGroup { - var currentChart = PlayState.instance.currentChart; - var result:VoicesGroup = new VoicesGroup(); - var songVoices:Array = currentChart.buildVoiceList(); - - if (voiceList[0] != null) { - result.addPlayerVoice(FunkinSound.load(voiceList[0])); - } else { - result.addPlayerVoice(FunkinSound.load(songVoices[0])); - } - - if (voiceList[1] != null) { - result.addOpponentVoice(FunkinSound.load(voiceList[1])); - } else { - result.addOpponentVoice(FunkinSound.load(songVoices[1])); - } - - result.playerVoicesOffset = currentChart.offsets.getVocalOffset(currentChart.characters.player); - result.opponentVoicesOffset = currentChart.offsets.getVocalOffset(currentChart.characters.opponent); - - return result; - } -} diff --git a/scripts/modules/freeplayDJReplace.hxc b/scripts/modules/freeplayDJReplace.hxc new file mode 100644 index 0000000..50c8406 --- /dev/null +++ b/scripts/modules/freeplayDJReplace.hxc @@ -0,0 +1,200 @@ +import flixel.FlxG; +import flixel.text.FlxText; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import funkin.data.freeplay.player.PlayerRegistry; +import funkin.modding.module.Module; +import funkin.modding.module.ModuleHandler; +import funkin.ui.freeplay.FreeplayDJ; +import funkin.ui.freeplay.FreeplayDJState; +import funkin.ui.freeplay.FreeplayState; +import haxe.Timer; + +/** + * Separate module for replacing the Freeplay DJ to prevent messy code in CharacterHandler. + */ +class DJReplace extends Module { + public var saveData:Dynamic; + + public function new() { + super('DJReplace'); + } + + /** + * Swap out the Freeplay DJ. + * @param freeplay The Freeplay SubState we're using. + */ + function transitionToNewDJ(freeplay:FreeplayState, FS_saveData:Dynamic, ?inIntro:Bool = false):Void { + saveData = FS_saveData; + + var freeplayDJ = freeplay.dj; + var playableCharData = freeplayDJ.playableCharData; + var characterID:String = PlayerRegistry.instance.isCharacterOwned(saveData.characterIDs.bf) ? saveData.characterIDs.bf : freeplay.currentCharacterId; + var playableCharId = PlayerRegistry.instance.getCharacterOwnerId(characterID); + + if (freeplayDJ.characterId != playableCharId) { + if (!inIntro) { + if (freeplayDJ.hasAnimation(playableCharData.getAnimationPrefix('charSelect'))) { + freeplay.busy = true; + freeplayDJ.onIntroDone.removeAll(); + freeplayDJ.onAnimationComplete.addOnce(replaceDJNoIntro); + freeplayDJ.currentState = FreeplayDJState.CharSelect; + var animPrefix = playableCharData.getAnimationPrefix('charSelect'); + freeplayDJ.playFlashAnimation(animPrefix, true, false, false); + } + } + else { + replaceDJIntro(); + } + } + else { + trace("[Funker Selector] DJ Character IDs match! Not replacing."); + } + } + + /** + * Replaces the Freeplay DJ. + */ + function replaceDJNoIntro():Void { + var freeplayDJ = FlxG.state.subState.dj; + var characterID:String = PlayerRegistry.instance.isCharacterOwned(saveData.characterIDs.bf) ? saveData.characterIDs.bf : FlxG.state.subState.currentCharacterId; + var fs_characterData:Dynamic = ModuleHandler.getModule("CharacterHandler").scriptCall('getCharacterData', [saveData.characterIDs.bf]); + var playableCharId:String = PlayerRegistry.instance.getCharacterOwnerId(characterID); + var playableChar:PlayableCharacter = PlayerRegistry.instance.fetchEntry(playableCharId); + var playableCharData:Dynamic = playableChar != null ? playableChar.getFreeplayDJData() : null; + var introFrame:Int = fs_characterData?.introSwapFrame != null ? fs_characterData.introSwapFrame : 3; + + var startTime = Timer.stamp(); + + if (playableCharData == null) { + trace('[WARNING] Tried to replace Freeplay DJ with invalid data.'); + trace(characterID); + return; + } + + if (freeplayDJ.anim.curSymbol.name == freeplayDJ.playableCharData.getAnimationPrefix('charSelect')) { + trace("[Funker Selector] Loading texture atlas: " + playableCharData.getAtlasPath()); + freeplayDJ.loadAtlas(playableCharData.getAtlasPath()); + freeplayDJ.characterId = playableCharId; + freeplayDJ.playableCharData = playableCharData; + + freeplayDJ.currentState = FreeplayDJState.Intro; + + var animPrefix = playableCharData.getAnimationPrefix('intro'); + freeplayDJ.playFlashAnimation(animPrefix, true, false, false, introFrame); + + if (FlxG.state.subState.backingCard.funnyScroll != null) reapplyBackingText(FlxG.state.subState, playableChar.getFreeplayDJText(1), + playableChar.getFreeplayDJText(2), playableChar.getFreeplayDJText(3)); + FlxG.state.subState.busy = false; + + var endTime = Timer.stamp(); + var elapsedTime = Math.round((endTime - startTime) * 100) / 100; + trace("[Funker Selector] replaceDJNoIntro took " + elapsedTime + " seconds to complete."); + } + } + + /** + * A modified version of replaceDJNoIntro() that replaces the DJ quicker. + * This is used when *entering* Freeplay. + */ + function replaceDJIntro():Void { + var freeplayDJ = FlxG.state.subState.dj; + var characterID:String = PlayerRegistry.instance.isCharacterOwned(saveData.characterIDs.bf) ? saveData.characterIDs.bf : FlxG.state.subState.currentCharacterId; + var playableCharId = PlayerRegistry.instance.getCharacterOwnerId(characterID); + var playableChar = PlayerRegistry.instance.fetchEntry(playableCharId); + var playableCharData = playableChar != null ? playableChar.getFreeplayDJData() : null; + + var startTime = Timer.stamp(); + + if (playableCharData == null) { + trace('[WARNING] Tried to replace Freeplay DJ with invalid data.'); + trace(characterID); + return; + } + + trace("[Funker Selector] Loading texture atlas: " + playableCharData.getAtlasPath()); + freeplayDJ.loadAtlas(playableCharData.getAtlasPath()); + freeplayDJ.characterId = playableCharId; + freeplayDJ.playableCharData = playableCharData; + + if (FlxG.state.subState.backingCard.funnyScroll != null) reapplyBackingText(FlxG.state.subState, playableChar.getFreeplayDJText(1), + playableChar.getFreeplayDJText(2), playableChar.getFreeplayDJText(3)); + + var endTime = Timer.stamp(); + var elapsedTime = Math.round((endTime - startTime) * 100) / 100; + trace("[Funker Selector] replaceDJIntro took " + elapsedTime + " seconds to complete."); + } + + /** + * Replaces the backing text. + * @param freeplay The Freeplay SubState we're using. + * @param text1 The 1st string. + * @param text2 The 2nd string. + * @param text3 The 3rd string. + */ + function reapplyBackingText(freeplay:FreeplayState, text1:String, text2:String, text3:String) { + trace("[Funker Selector] Applying backing text..."); + trace(text1); + trace(text2); + trace(text3); + + var funnyScrollArray:Array = [ + freeplay.backingCard.funnyScroll, + freeplay.backingCard.funnyScroll2, + freeplay.backingCard.funnyScroll3 + ]; + var moreWaysArray:Array = [freeplay.backingCard.moreWays, freeplay.backingCard.moreWays2]; + for (scrollText in funnyScrollArray) { + trace("[Funker Selector] Setting up text group with text " + text1); + setupTextGroup(scrollText, 60, FlxG.width / 2, text1, -3.8, false); + } + + for (scrollText in moreWaysArray) { + trace("[Funker Selector] Setting up text group with text " + text2); + setupTextGroup(scrollText, 43, FlxG.width, text2, 6.8, true); + } + + trace("[Funker Selector] Setting up text group with text " + text3); + setupTextGroup(freeplay.backingCard.txtNuts, 43, FlxG.width / 2, text3, 3.5, true); + + freeplay.backingCard.funnyScroll.funnyColor = 0xFFFF9963; + freeplay.backingCard.funnyScroll2.funnyColor = 0xFFFF9963; + freeplay.backingCard.funnyScroll3.funnyColor = 0xFFFEA400; + freeplay.backingCard.moreWays.funnyColor = 0xFFFFF383; + freeplay.backingCard.moreWays2.funnyColor = 0xFFFFF383; + } + + /** + * Sets up `grpTexts` for a `BGScrollingText` object + * @param textObject The object we are using + * @param size The text size + * @param widthShit The width or something IDK + * @param text The text itself + * @param speed The speed of the text scrolling + * @param bold Wether or not the text is bold + */ + function setupTextGroup(textObject:Dynamic, size:Int = 10, widthShit:Int = 1, text:String = "FUCK", speed:Float = 1.0, bold:Bool = false):Void { + textObject.grpTexts.clear(); + var testText:FlxText = new FlxText(0, 0, 0, text, size); + testText.font = "5by7"; + testText.bold = bold; + testText.updateHitbox(); + textObject.grpTexts.add(testText); + + var needed:Int = Math.ceil(widthShit / testText.frameWidth) + 1; + + for (i in 0...needed) { + var lmfao:Int = i + 1; + + var coolText:FlxText = new FlxText((lmfao * testText.frameWidth) + (lmfao * 20), 0, 0, text, size); + + coolText.font = "5by7"; + coolText.bold = bold; + coolText.updateHitbox(); + trace("[Funker Selector] Adding to text group: " + coolText.text); + textObject.grpTexts.add(coolText); + } + + textObject.speed = speed; + } +} diff --git a/scripts/states/CharacterMenu.hxc b/scripts/states/CharacterMenu.hxc index f1ddb77..1b57dc4 100644 --- a/scripts/states/CharacterMenu.hxc +++ b/scripts/states/CharacterMenu.hxc @@ -6,9 +6,11 @@ import flixel.tweens.FlxTween; import flixel.util.FlxTimer; import funkin.audio.FunkinSound; import funkin.data.freeplay.player.PlayerRegistry; +import funkin.data.song.SongRegistry; import funkin.graphics.FunkinCamera; import funkin.graphics.FunkinSprite; import funkin.modding.PolymodErrorHandler; +import funkin.modding.events.ScriptEvent; import funkin.modding.module.ModuleHandler; import funkin.play.GameOverSubState; import funkin.play.PauseSubState; @@ -21,868 +23,1240 @@ import funkin.ui.charSelect.Lock; import funkin.ui.freeplay.charselect.PlayableCharacter; import funkin.util.WindowUtil; import funkin.util.assets.DataAssets; +import flixel.group.FlxTypedGroup; +import funkin.util.MathUtil; import haxe.Json; import haxe.ds.StringMap; +import lime.app.Application; import Std; /** * This is a substate that acts as a Character Selection screen. */ class CharacterSelectSubState extends MusicBeatSubState { - var charName:FlxText; - var charDesc:FlxText; - var selected:FlxText; - var numberThing:FlxText; - - var leftDifficultyArrow:FunkinSprite; - var rightDifficultyArrow:FunkinSprite; - var keyBox:FunkinSprite; - var newGraphic:FunkinSprite; - - // NOTE: This is either a `FunkinSprite`, or a `Lock` - // depending on if "Simplify UI" is enabled or not. - var lockedChill:Null; - - var unlockSound:FunkinSound; - - var hotkeyCam:FunkinCamera; - var uiCam:FunkinCamera; - var spriteCam:FunkinCamera; - - /** - * A map containing every character. - */ - var characterMap:StringMap> = ModuleHandler.getModule("CharacterHandler").scriptGet('characterMap'); - - /** - * A filtered character array for the current page. - * We still use this array since `CharacterMenu` is built around Arrays. - */ - var filteredKeys:Array = []; - - /** - * An array of unlock animations we've seen already. - * This ensures the unlock animation only plays once per save file. - */ - var seenUnlocks:Array = ModuleHandler.getModule("CharacterHandler").scriptGet('seenUnlocks'); - - // NOTE: For writing, access characterIDs directly. This is only for reading. - var characterIDsArray:Array = [characterIDs.bf, characterIDs.dad, characterIDs.gf]; - - /** - * A map of all the locked characters. - */ - var lockedCharMap:StringMap = ModuleHandler.getModule("CharacterHandler").scriptGet('lockedCharMap'); - - /** - * The JSON character cache, which is retrieved from `charHandler`. - */ - var jsonCharacterCache:StringMap = ModuleHandler.getModule("CharacterHandler").scriptGet('jsonCharacterCache'); - - // For the top text in the menu. - var topText = ["PLAYABLE CHARACTERS", "OPPONENT CHARACTERS", "GIRLFRIEND CHARACTERS"]; - var topTextColor = [0xFF2DB2D2, 0xFFAF66CE, 0xFFA7004C]; - - /** - * Pages. - * - * This is just an array, their index is used to control a lot of things in the menu. - */ - var pages:Array = ['bf', 'dad', 'gf']; - - var charIcon:HealthIcon; - var iconGrid:Array = []; - - var charIndex:Int = 0; - var pageIndex:Int = 0; - - /** - * The current song BPM in Freeplay. - */ - var songBPM:Float = ModuleHandler.getModule("CharacterHandler").scriptGet('freeplayBPM'); - - /** - * The current character ID. - */ - var curCharID:String = 'default'; - - var characterSprite:BaseCharacter; - - /** - * These are retrieved from the save file. - */ - var characterIDs:Dynamic = Save.instance.modOptions.get("FunkerSelector"); - - var simplifyUI:Bool = Save.instance.modOptions.get("FunkerSelectorSettings").potatoMode; - var preferredSFX:String = Save.instance.modOptions.get("FunkerSelectorSettings").preferredSFX; - - /** - * If enabled, the menu will stop responding to inputs. - * This is used for the unlock animation - */ - var busy:Bool = false; - - public function new() { - super(); - } - - public override function create():Void { - super.create(); - - setupUIStuff(); - updateFilteredKeys(); - jumpToCurSelected(); - } - - public override function update(elapsed:Float):Void { - super.update(elapsed); - - conductorInUse.update(); - - if (!busy) - handleKeyShit(); - } - - function beatHit():Void { - bopDaThing(); - if (characterSprite != null) - characterSprite.dance(); - } - - /** - * Updates the `filteredKeys` array based on the current `pageIndex`. - */ - function updateFilteredKeys():Void { - trace('[Funker Selector] Updating filteredKeys'); - filteredKeys = ['default']; // Ensures 'default' is always the first thing. - - tempArray = []; - - for (key in characterMap.keys()) { - tempArray.push(key); - } - - iHateMyLife = tempArray.filter(function(shit) { - characterType = characterMap.get(shit)[1]; - switch (pageIndex) { - case 0: - if (characterType is Array) - return characterType.indexOf('bf') != -1 || characterType.indexOf('player') != -1; - else - return characterType == 'bf' || characterType == 'player'; - case 1: - if (characterType is Array) - return characterType.indexOf('dad') != -1 || characterType.indexOf('opponent') != -1; - else - return characterType == 'dad' || characterType == 'opponent'; - case 2: - if (characterType is Array) - return characterType.indexOf('gf') != -1; - else - return characterType == 'gf'; - } - }); - - iHateMyLife.sort(function(a:String, b:String):Int { - characterA = jsonCharacterCache.get(a); - characterB = jsonCharacterCache.get(b); - - characterDataA = characterA[1]; - characterDataB = characterB[1]; - - nameA = characterDataA.name.toUpperCase(); - nameB = characterDataB.name.toUpperCase(); - - if (nameA < nameB) { - return -1; - } else if (nameA > nameB) { - return 1; - } else { - return 0; - } - }); - - filteredKeys = filteredKeys.concat(iHateMyLife); - - numberThing.text = "< " + (charIndex + 1) + " / " + filteredKeys.length + " >"; - numberThing.screenCenter(0x01); - } - - /** - * Make a super awesome cool icon grid! - * @param spacing The spacing of the icons, I guess. - * - * This is disabled if "Simplify UI" is turned on in the Options Menu. - */ - function createIconGrid(spacing:Int, ?alpha:Float = null):Void { - var charIconData = curCharID != 'default' ? jsonCharacterCache.get(curCharID)[1] : null; - var x = (FlxG.width - Math.ceil(FlxG.width / spacing) * spacing) / 2; - var y = (FlxG.height - Math.ceil(FlxG.height / spacing) * spacing) / 2; - for (i in 0...Math.ceil(FlxG.height / spacing)) { - for (j in 0...Math.ceil(FlxG.width / spacing)) { - var icon = new HealthIcon('dad', 0); - icon.x = x + j * spacing; - icon.y = y + i * spacing; - icon.alpha = (alpha != null ? alpha : 0.2); - - if (charIconData != null) { - icon.configure(charIconData.healthIcon); - } else { - icon.configure('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); - } - - add(icon); - iconGrid.push(icon); - icon.camera = spriteCam; - } - } - } - - /** - * Check if the JSON file in `data/characters/` ACTUALLY exists. - * - * I wrote this function because OpenFL Assets is case-insensitive - * on Windows and macOS. This is for an edge case where the JSON filename - * in data/characters/ has different capitalization than the Character ID - * specified in the Funker Selector JSON. Because OpenFL is case insensitive, it will - * always return true, which confuses Funker Selector! - * - * This was reported in issue [#1](https://github.com/AbnormalPoof/FunkerSelector/issues/1)! - * - * This should hopefully solve any and all issues like that one. - * - * @param string The character ID we're comparing. - * @return Returns a true or false if the file exists. - */ - function charJSONCheck(string:String):Bool { - if (Assets.exists(Paths.json('characters/' + string))) { - var characterFiles = DataAssets.listDataFilesInPath('characters/'); - return characterFiles.indexOf(string) != -1; - } - return false; - } - - /** - * Configuring the JSON character, setting up position, scale, flipping, etc. - */ - function configureChar(data:Null):Void { - var position = data.position != null ? data.position : [0, 0]; - var scale = data.scale != null ? (data.isPixel ? data.scale * 6 : data.scale) : (data.isPixel ? 6.0 : 1.0); - characterSprite.x += position[0]; - characterSprite.y += position[1]; - characterSprite.flipX = data.flipX != null ? data.flipX : false; - characterSprite.scale.set(scale, scale); - characterSprite.antialiasing = data.isPixel != null ? !data.isPixel : true; - } - - /** - * Set up UI stuff like the BG and text. - */ - function setupUIStuff():Void { - spriteCam = new FunkinCamera('SpriteCam'); - FlxG.cameras.add(spriteCam, false); - spriteCam.bgColor = 0x0; - - uiCam = new FunkinCamera('UIElements'); - FlxG.cameras.add(uiCam, false); - uiCam.bgColor = 0x0; - - hotkeyCam = new FunkinCamera('HotkeyMenu'); - FlxG.cameras.add(hotkeyCam, false); - hotkeyCam.bgColor = 0x0; - hotkeyCam.visible = false; - - bg = new FunkinSprite(0, 0); - bg.makeSolidColor(FlxG.width, FlxG.height, 0xFF000000); - bg.alpha = 0; - bg.camera = spriteCam; - - selectText = configureText(null, null, [170, 10], 50, Paths.font("PhantomMuff.ttf")); - selectText.screenCenter(0x01); - selectText.camera = uiCam; - - numberThing = configureText(null, null, [170, 60], 45, Paths.font("PhantomMuff.ttf")); - numberThing.color = 0xFFE3E1E1; - numberThing.camera = uiCam; - - selected = configureText(null, '(SELECTED)', [720, 220], 50, Paths.font("PhantomMuff.ttf")); - selected.color = 0xFF53FF38; - selected.visible = false; - selected.camera = uiCam; - - hotkeyText = configureText(null, 'D - Hotkeys', [990, 15], 45, Paths.font("PhantomMuff.ttf")); - hotkeyText.color = 0xFFE3E1E1; - hotkeyText.camera = uiCam; - - hotkeys = configureText(null, - 'F - Reset everything to default.\n\nJ - Jump to the currently selected character.\n\nQ - Switch to the previous page.\n\nE - Switch to the next page.\n\nESC - Close this.', - [350, 80], 40, Paths.font("PhantomMuff.ttf")); - hotkeys.camera = hotkeyCam; - hotkeys.wordWrap = true; - hotkeys.fieldWidth = 600; - - charName = configureText(null, null, [170, 110], 70, Paths.font("PhantomMuff.ttf")); - charName.camera = uiCam; - - leftDifficultyArrow = FunkinSprite.createSparrow(20, 300, 'storymenu/ui/arrows'); - leftDifficultyArrow.animation.addByPrefix('idle', 'leftIdle0'); - leftDifficultyArrow.animation.addByPrefix('press', 'leftConfirm0'); - leftDifficultyArrow.animation.play('idle'); - leftDifficultyArrow.scale.set(1.5, 1.5); - leftDifficultyArrow.camera = uiCam; - - rightDifficultyArrow = FunkinSprite.createSparrow(1200, leftDifficultyArrow.y, 'storymenu/ui/arrows'); - rightDifficultyArrow.animation.addByPrefix('idle', 'rightIdle0'); - rightDifficultyArrow.animation.addByPrefix('press', 'rightConfirm0'); - rightDifficultyArrow.animation.play('idle'); - rightDifficultyArrow.scale.set(1.5, 1.5); - rightDifficultyArrow.camera = uiCam; - - if (!simplifyUI) { - lockedChill = FunkinSprite.createSparrow(200, 250, 'funkerSelector/locked_character'); - lockedChill.animation.addByPrefix('idle', 'LOCKED MAN0', 24, false); - lockedChill.animation.addByPrefix('denied', 'cannot select0', 24, false); - lockedChill.animation.addByPrefix('death', 'locked man explodes0', 24, false); - lockedChill.animation.play('idle'); - lockedChill.animation.finishCallback = function(_) { - lockedChill.offset.set(); - lockedChill.animation.play('idle'); - }; - lockedChill.scale.set(1.2, 1.2); - } else { - lockedChill = new Lock(300, 270, FlxG.random.int(1, 9)); - lockedChill.onAnimationComplete.add(function(_) { - lockedChill.playAnimation('idle'); - }); - lockedChill.playAnimation('idle'); - lockedChill.scale.set(2, 2); - } - lockedChill.camera = spriteCam; - lockedChill.visible = false; - - newGraphic = FunkinSprite.createSparrow(840, 250, 'freeplay/freeplayCapsule/new'); - newGraphic.animation.addByPrefix('new', 'NEW notif', 24); - newGraphic.animation.play('new'); - newGraphic.camera = uiCam; - newGraphic.scale.set(1.8, 1.8); - newGraphic.visible = false; - - keyBox = new FunkinSprite(0, 0); - keyBox.makeSolidColor(600, 600, 0xFF000000); - keyBox.camera = hotkeyCam; - keyBox.alpha = 0.8; - keyBox.screenCenter(); - - charIcon = new HealthIcon('dad', 0); - charIcon.camera = uiCam; - - unlockSound = new FunkinSound(); - unlockSound.loadEmbedded(Paths.sound('funkerSelector/charUnlock')); - unlockSound.volume = 1; - - add(bg); - if (!simplifyUI) - createIconGrid(150, 0.2); - add(leftDifficultyArrow); - add(rightDifficultyArrow); - add(selectText); - add(numberThing); - add(selected); - add(hotkeyText); - add(charIcon); - add(charName); - add(lockedChill); - add(keyBox); - add(hotkeys); - add(newGraphic); - - WindowUtil.setWindowTitle('Friday Night Funkin\' - Funker Selector Menu'); - - FlxTween.tween(bg, {alpha: 0.5}, 0.5, {ease: FlxEase.quartOut}); - - trace('[Funker Selector] UI has been set up.'); - - conductorInUse.forceBPM(songBPM); - } - - /** - * Bopping the icons, because why not? - */ - function bopDaThing():Void { - if (charIcon != null) { - if (charIcon.width > charIcon.height) { - charIcon.setGraphicSize(Std.int(charIcon.width + (150 * charIcon.size.x * 0.2)), 0); - } else { - charIcon.setGraphicSize(0, Std.int(charIcon.height + (150 * charIcon.size.y * 0.2))); - } - - charIcon.angle += 1; - - charIcon.updateHitbox(); - charIcon.updatePosition(); - } - } - - /** - * Flash the screen with a specific color. - */ - function flashScreen(color:FlxColor, duration:Float, ?alpha:Float = 1) { - var duration:Float = duration; - var alpha:Float = alpha; - - var white = new FunkinSprite(0, 0); - white.makeSolidColor(FlxG.width, FlxG.height, color); - white.alpha = 0; - white.camera = uiCam; - this.add(white); - - FlxTween.tween(white, {alpha: alpha}, duration, { - ease: FlxEase.quadOut, - onComplete: function(twn:FlxTween) { - FlxTween.tween(white, {alpha: alpha}, duration, { - ease: FlxEase.quadOut, - onComplete: function(twn:FlxTween) { - this.remove(white); - } - }); - } - }); - } - - /** - * Reponsible for handling inputs in the menu. - */ - function handleKeyShit():Void { - if (FlxG.keys.justPressed.ESCAPE) { - if (hotkeyCam.visible) { - hotkeyCam.visible = false; - } else { - FunkinSound.playOnce(Paths.sound('cancelMenu')); - - // Scripted characters overwrite the suffixes when - // they're shown. So we want to manually reset them! - PauseSubState.musicSuffix = ''; - GameOverSubState.musicSuffix = ''; - GameOverSubState.blueBallSuffix = ''; - - WindowUtil.setWindowTitle('Friday Night Funkin\''); - close(); - } - } - - if (!hotkeyCam.visible) { - if (FlxG.keys.justPressed.LEFT || FlxG.keys.justPressed.RIGHT) { - if (preferredSFX == "charSelect") { - FunkinSound.playOnce(Paths.sound('CS_select'), 0.2); - } else { - FunkinSound.playOnce(Paths.sound('scrollMenu'), 0.4); - } - charIndex = (charIndex + (FlxG.keys.justPressed.RIGHT ? 1 : -1) + filteredKeys.length) % filteredKeys.length; - numberThing.text = "< " + (charIndex + 1) + " / " + filteredKeys.length + " >"; - numberThing.screenCenter(0x01); - curCharID = filteredKeys[charIndex]; - updateCharInfo(); - } - - leftDifficultyArrow.animation.play(FlxG.keys.pressed.LEFT ? 'press' : 'idle'); - rightDifficultyArrow.animation.play(FlxG.keys.pressed.RIGHT ? 'press' : 'idle'); - - if (FlxG.keys.justPressed.Q || FlxG.keys.justPressed.E) { - if (preferredSFX == "charSelect") { - FunkinSound.playOnce(Paths.sound('CS_select'), 0.2); - } else { - FunkinSound.playOnce(Paths.sound('scrollMenu'), 0.4); - } - pageIndex = (pageIndex + (FlxG.keys.justPressed.E ? 1 : -1) + pages.length) % pages.length; - charIndex = 0; - updateFilteredKeys(); - curCharID = filteredKeys[charIndex]; - updateCharInfo(); - } - - if (FlxG.keys.justPressed.J) { - if (preferredSFX == "charSelect") { - FunkinSound.playOnce(Paths.sound('CS_select'), 0.2); - } else { - FunkinSound.playOnce(Paths.sound('scrollMenu'), 0.4); - } - jumpToCurSelected(); - } - - if (FlxG.keys.justPressed.F) { - var shouldPlaySound:Bool = false; - if (characterIDs.bf != 'default') { - characterIDs.bf = 'default'; - shouldPlaySound = true; - } - if (characterIDs.dad != 'default') { - characterIDs.dad = 'default'; - shouldPlaySound = true; - } - if (characterIDs.gf != 'default') { - characterIDs.gf = 'default'; - shouldPlaySound = true; - } - if (shouldPlaySound) { - FunkinSound.playOnce(Paths.sound('ranks/great'), 0.4); - selected.visible = selectedTextVisibility(); - Save.instance.modOptions.set("FunkerSelector", characterIDs); - Save.instance.flush(); - - // Updating the array with the latest changes - characterIDsArray = [characterIDs.bf, characterIDs.dad, characterIDs.gf]; - } - } - - if (FlxG.keys.justPressed.D) { - hotkeyCam.visible = true; - } - - if (FlxG.keys.justPressed.ENTER) { - var shouldConfirm:Bool = false; - if (curCharID != characterIDsArray[pageIndex]) { - if (!lockedCharMap.exists(curCharID)) { - switch (pageIndex) { - case 0: - if (characterIDs.bf != curCharID) { - characterIDs.bf = curCharID; - shouldConfirm = true; - } - case 1: - if (characterIDs.dad != curCharID) { - characterIDs.dad = curCharID; - shouldConfirm = true; - } - case 2: - if (characterIDs.gf != curCharID) { - characterIDs.gf = curCharID; - shouldConfirm = true; - } - } - } - } - if (shouldConfirm) { - confirmThing(); - selected.visible = selectedTextVisibility(); - newGraphic.visible = false; - Save.instance.modOptions.set("FunkerSelector", characterIDs); - Save.instance.flush(); - - // Updating the array with the latest changes - characterIDsArray = [characterIDs.bf, characterIDs.dad, characterIDs.gf]; - } else { - if (preferredSFX == "charSelect") { - FunkinSound.playOnce(Paths.sound('CS_locked'), 0.4); - } else { - FunkinSound.playOnce(Paths.sound('cancelMenu')); - } - flashScreen(0xFFFF0000, 0.03, 0.1); - if (lockedChill != null) { - if (!simplifyUI) - lockedChill.animation.play('denied', true); - else - lockedChill.playAnimation("clicked", true); - } - } - } - } - } - - /** - * Jumps to the currently selected character. - */ - function jumpToCurSelected():Void { - // In the event that the saved character ID no longer exists, we fallback to default. - if (!characterMap.exists(characterIDsArray[pageIndex]) && characterIDsArray[pageIndex] != 'default') { - trace('[Funker Selector] Saved character ID doesn\'t exist! Resetting to default.'); - switch (pageIndex) { - case 0: - characterIDs.bf = 'default'; - case 1: - characterIDs.dad = 'default'; - case 2: - characterIDs.gf = 'default'; - } - // Save everything - Save.instance.modOptions.set("FunkerSelector", characterIDs); - Save.instance.flush(); - - // Updating the array with the latest changes - characterIDsArray = [characterIDs.bf, characterIDs.dad, characterIDs.gf]; - } - charIndex = filteredKeys.indexOf(characterIDsArray[pageIndex]); - curCharID = characterIDsArray[pageIndex]; - numberThing.text = "< " + (charIndex + 1) + " / " + filteredKeys.length + " >"; - numberThing.screenCenter(0x01); - updateCharInfo(); - } - - function confirmThing():Void { - if (preferredSFX == "charSelect") { - FunkinSound.playOnce(Paths.sound('CS_confirm'), 0.4); - } else { - FunkinSound.playOnce(Paths.sound('confirmMenu')); - } - if (!simplifyUI) { - characterData = jsonCharacterCache.get(curCharID) != null ? jsonCharacterCache.get(curCharID)[0] : null; - animation = characterData?.characterMenu?.selectedAnim != null ? characterData.characterMenu.selectedAnim : 'hey'; - if (characterSprite != null) - characterSprite.playAnimation(animation, true, true); - } - if (charIcon?.hasAnimation('winning')) { - // Play the winning animation if it's available. - charIcon?.playAnimation('winning'); - - // I wanted this really cool effect where the icons in the icon grid would also - // play the winning animation! - // But let's not play that if "Simplify UI" is turned on. - if (!simplifyUI && iconGrid != null) { - for (i in 0...iconGrid.length) { - var icon = iconGrid[i]; - new FlxTimer().start(0.01 * i, function() { - icon?.playAnimation('winning'); - }); - } - new FlxTimer().start(0.3, function() { - for (i in 0...iconGrid.length) { - var icon = iconGrid[i]; - new FlxTimer().start(0.01 * i, function() { - icon?.playAnimation('idle'); - }); - } - }); - } - } - - flashScreen(0xFFFFFFFF, 0.03, 0.1); - } - - function selectedTextVisibility():Bool { - switch (pageIndex) { - case 0: - return curCharID == characterIDs.bf; - case 1: - return curCharID == characterIDs.dad; - case 2: - return curCharID == characterIDs.gf; - default: - return false; - } - } - - /** - * Helper function for configuring an FlxText - * @param object The FlxText object - * @param text The text itself - * @param offsets The offsets as an array - * @param size The size of the text - * @param font The font - * @return A FlxText object - */ - function configureText(object:FlxText, ?text:String = "", ?offsets:Array, ?size:Int = 38, ?font:String = null):FlxText { - if (text == null) - text = ""; - if (offsets == null) - offsets = [0, 0]; - if (size == null) - size = 38; - if (object == null) - object = new FlxText(0, 0, 0, "PLACEHOLDER"); - - object.text = text; - object.x += offsets[0]; - object.y += offsets[1]; - if (font != null) - object.setFormat(font, size, null, 'center'); - - return object; - } - - /** - * Does a really fucking cool unlock animation. - */ - function doUnlockAnimation():Void { - busy = true; - FlxG.sound.music?.fadeOut(); - if (!simplifyUI) { - lockedChill.offset.set(); - lockedChill.animation.play('idle', true); - } else { - lockedChill.playAnimation('idle', true); - } - FlxTween.tween(uiCam, {alpha: 0}, 1, { - ease: FlxEase.quartOut, - onComplete: function(twn:FlxTween) { - unlockSound.play(true); - new FlxTimer().start(1.45, function() { - if (!simplifyUI) { - lockedChill.offset.set(170, 220); - lockedChill.animation.play('death'); - lockedChill.animation.callback = function(name, frame) { - if (name == 'death' && frame == 36) { - flashScreen(0xFFFFFFFF, 0.05, 1); - new FlxTimer().start(0.025, function() { - updateCharInfo(); - if (selected.visible) - selected.visible = false; - newGraphic.visible = true; - uiCam.shake(0.01, 0.2); - spriteCam.shake(0.01, 0.2); - FlxG.state.subState.funnyCam.shake(0.01, 0.2); - }); - if (characterSprite != null && !characterSprite.visible) { - characterSprite.visible = true; - } - busy = false; - uiCam.alpha = 1; - FlxG.sound.music?.fadeIn(); - } - } - } else { - lockedChill.playAnimation('unlock'); - lockedChill.onAnimationFrame.add(function(name, frame) { - if (name == 'unlock' && frame == 36) { - flashScreen(0xFFFFFFFF, 0.05, 1); - new FlxTimer().start(0.025, function() { - updateCharInfo(); - if (selected.visible) - selected.visible = false; - newGraphic.visible = true; - lockedChill.playAnimation('idle', true); - uiCam.shake(0.01, 0.2); - spriteCam.shake(0.01, 0.2); - FlxG.state.subState.funnyCam.shake(0.01, 0.2); - }); - busy = false; - uiCam.alpha = 1; - FlxG.sound.music?.fadeIn(); - } - }); - } - }); - } - }); - } - - /** - * Update character information. - */ - function updateCharInfo():Void { - if (characterSprite != null) - remove(characterSprite); - - if (charDesc != null) - remove(charDesc); - - selectText.text = topText[pageIndex]; - selectText.color = topTextColor[pageIndex]; - selectText.screenCenter(0x01); - selected.visible = selectedTextVisibility(); - - newGraphic.visible = false; - - charDesc = new FlxText(600, 300, 0, "PLACEHOLDER"); - charDesc.wordWrap = true; - charDesc.fieldWidth = 550; - charDesc.camera = uiCam; - - if (curCharID != 'default') { - characterInfo = characterMap.get(curCharID); - ownerJSONname = characterInfo[0]; - characterData = jsonCharacterCache.get(ownerJSONname)[0]; - charIconData = (curCharID != 'default') ? jsonCharacterCache.get(ownerJSONname)[1] : null; - } - - // If "Simplify UI" is enabled in the Options Menu, the sprites will not load. - if (!simplifyUI) { - characterSprite = CharacterDataParser.fetchCharacter(curCharID, true); - if (characterSprite != null) { - characterSprite.dance(); - if (characterData.characterMenu != null) { - configureChar(characterData.characterMenu); - } - add(characterSprite); - characterSprite.camera = spriteCam; - } - } - - switch (curCharID) { - case 'default': - charIcon.configure('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); - charName.text = "DEFAULT"; - configureText(charDesc, "This is the Default character defined by the song.", [-20, 0], 50, Paths.font("PhantomMuff.ttf")); - default: - if (lockedCharMap.exists(curCharID)) { - charName.text = "LOCKED"; - - if (characterSprite != null) - characterSprite.visible = false; - - var unlockHint = characterData.description != null - && characterData.description.unlockCondition != null ? "\n\nUnlock Condition: " + characterData.description.unlockCondition : ""; - - configureText(charDesc, "This character is locked! I wonder who it could be..." + unlockHint, null, null, Paths.font("PhantomMuff.ttf")); - } else { - charName.text = charIconData?.name; - charIcon.configure(charIconData?.healthIcon); - charIcon.size.set(1, 1); - charIcon.setPosition(550, 160); - - var descText:String = (characterData.description?.text != null) ? characterData.description.text : "No description was specified in the JSON file."; - var offsets:Array = [ - characterData.description?.offsets != null ? characterData.description?.offsets[0] : 0, - characterData.description?.offsets != null ? characterData.description?.offsets[1] : 0 - ]; - - configureText(charDesc, descText, offsets, characterData.description?.size, Paths.font("PhantomMuff.ttf")); - } - } - - // Making it not look so awkward when "Simplify UI" is turned on. - if (simplifyUI || curCharID == 'default') { - charIcon?.setPosition(250, 270); - charIcon?.size.set(2, 2); - } - - if (!simplifyUI) { - for (icon in iconGrid) { - if (curCharID != 'default' && !lockedCharMap.exists(curCharID)) { - icon?.configure(charIconData?.healthIcon); - } else { - icon?.configure('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); - } - } - } - - if (lockedCharMap.exists(curCharID)) { - if (charIcon != null) - charIcon.visible = false; - lockedChill.visible = true; - } else { - if (charIcon != null) - charIcon.visible = true; - lockedChill.visible = false; - } - - charName.screenCenter(0x01); - selected.y = charDesc.y - 60; - - add(charDesc); - - // Do the unlock animation if we haven't seen it yet. - if (lockedCharMap.exists(curCharID) - && ModuleHandler.getModule("CharacterHandler").scriptCall('isCharacterUnlocked', [curCharID, characterData.unlockMethod])) { - if (seenUnlocks.indexOf(curCharID) == -1) { - lockedCharMap.remove(curCharID); - seenUnlocks.push(curCharID); - Save.instance.modOptions.set("FunkerSelector-SeenChars", seenUnlocks); - Save.instance.flush(); - ModuleHandler.getModule("CharacterHandler").scriptSet('lockedCharMap', lockedCharMap); - ModuleHandler.getModule("CharacterHandler").scriptSet('seenUnlocks', seenUnlocks); - doUnlockAnimation(); - } - } - - trace('[Funker Selector] Updated character information.'); - } + var charName:FlxText; + var variationText:FlxText; + var charDesc:FlxText; + var selected:FlxText; + var numberThing:FlxText; + + var leftDifficultyArrow:FunkinSprite; + var rightDifficultyArrow:FunkinSprite; + var keyBox:FunkinSprite; + var descBox:FunkinSprite; + var newGraphic:FunkinSprite; + + // NOTE: This is either a `FunkinSprite`, or a `Lock` + // depending on if "Simplify UI" is enabled or not. + var lockedChill:Null; + + var unlockSound:FunkinSound; + + var hotkeyCam:FunkinCamera; + var uiCam:FunkinCamera; + var spriteCam:FunkinCamera; + + /** + * A map containing every character. + */ + var characterMap:StringMap> = ModuleHandler.getModule("CharacterHandler").scriptGet('characterMap'); + + /** + * A filtered character array for the current page. + * We still use this array since `CharacterMenu` is built around Arrays. + */ + var filteredKeys:Array = []; + + /** + * The save data for Funker Selector. + */ + var saveData:Dynamic = ModuleHandler.getModule("CharacterHandler").scriptGet('saveData'); + + // NOTE: For writing, access characterIDs directly. This is only for reading. + var characterIDsArray:Array = [saveData.characterIDs.bf, saveData.characterIDs.dad, saveData.characterIDs.gf]; + + /** + * A map of all the locked characters. + */ + var lockedCharMap:StringMap = ModuleHandler.getModule("CharacterHandler").scriptGet('lockedCharMap'); + + /** + * The JSON character cache, which is retrieved from `CharacterHandler`. + */ + var jsonCharacterCache:StringMap, Array> = ModuleHandler.getModule("CharacterHandler").scriptGet('jsonCharacterCache'); + + // For the top text in the menu. + var topText = ["PLAYABLE CHARACTERS", "OPPONENT CHARACTERS", "SPEAKER CHARACTERS"]; + var topTextColor = [0xFF2DB2D2, 0xFFAF66CE, 0xFFA7004C]; + + /** + * Pages. + * + * This is just an array, their index is used to control a lot of things in the menu. + */ + var pages:Array = ['bf', 'dad', 'gf']; + + /** + * Stuff related to the icon grid. + */ + var charIcon:HealthIcon; + + var iconGrid:Array = []; + + /** + * Indexes for both the current page and currently selected character. + */ + var currentCharacterIndex:Int = 0; + + var currentPageIndex:Int = 0; + + /** + * This is a `FlxTypedGroup` for the character wheel. + * This is a bit janky but it works so whatever. + */ + var characterWheel:FlxTypedGroup = new FlxTypedGroup(); + + /** + * The current song BPM in Freeplay. + */ + var songBPM:Float = ModuleHandler.getModule("CharacterHandler").scriptGet('freeplayBPM'); + + /** + * The current character ID. + */ + var currentCharacterID:String = 'default'; + + /** + * If enabled, the menu will stop responding to inputs. + * This is used for the unlock animation + */ + var busy:Bool = false; + + /** + * Script Event called when this SubState is opened. + */ + var FS_OPENED_SUBSTATE:String = "FS_OPENED_SUBSTATE"; + + /** + * Script Event called when this SubState is closed. + */ + var FS_EXITED_SUBSTATE:String = "FS_EXITED_SUBSTATE"; + + /** + * The base scroll speed for the icon grid. + */ + var ICON_SCROLL_SPEED:Float = 0.3; + + /** + * Stuff related to the typewriter effect for the character description. + */ + var typewriterTimer:FlxTimer = null; + + var typewriterIndex:Int = 0; + + var spamTimer:Float = 0; + var spamming:Bool = false; + + var characterHandler:Module; + + public function new() { + super(); + } + + public override function create():Void { + super.create(); + + characterHandler = ModuleHandler.getModule("CharacterHandler"); + + setupUIStuff(); + updateFilteredKeys(); + if (!saveData.preferences.get("potatoMode")) buildCharacterWheel(); + jumpToCurSelected(); + + ModuleHandler.callEvent(new ScriptEvent(FS_OPENED_SUBSTATE)); + } + + public override function update(elapsed:Float):Void { + super.update(elapsed); + + conductorInUse.update(); + + // Adding some useful stuff to the variable watch window. + if (isDebugBuild()) { + FlxG.watch.addQuick("currentPageIndex", currentPageIndex); + FlxG.watch.addQuick("currentCharacterIndex", currentCharacterIndex); + FlxG.watch.addQuick("currentCharacterID", currentCharacterID); + FlxG.watch.addQuick("filteredKeys", filteredKeys); + FlxG.watch.addQuick("bf", saveData.characterIDs.bf); + FlxG.watch.addQuick("gf", saveData.characterIDs.gf); + FlxG.watch.addQuick("dad", saveData.characterIDs.dad); + } + + if (!busy) handleKeyShit(); + + for (icon in iconGrid) { + // Speed is capped at 300 BPM, I don't want motion sickness. + icon.x -= ICON_SCROLL_SPEED * (Math.min(songBPM, 300) / 60); + icon.y += (ICON_SCROLL_SPEED * (Math.min(songBPM, 300) / 60)) / 2; // Vertical speed is halved. + + if (icon.x < -icon.width) { + icon.x += (FlxG.width + icon.width) + 70; + } + + if (icon.y > FlxG.height) { + icon.y -= (FlxG.height + icon.height) + 30; + } + } + + if (!saveData.preferences.get("potatoMode")) { + for (index in 0...characterWheel.members.length) { + var item:Dynamic = characterWheel.members[index]; + if (item != null) { + if (item is BaseCharacter) { + fs_characterData = characterHandler.scriptCall('getCharacterData', [item.characterId]); + } + else { + // Assume the object is `lockedChill` + fs_characterData = { + characterMenu: { + position: [200, 0] + } + }; + } + } + + var xOffset:Float = fs_characterData?.characterMenu != null ? fs_characterData.characterMenu.position[0] : 0; + + item.x = MathUtil.smoothLerp(item.x, ((index - (currentCharacterIndex - 1)) * 500) + xOffset + 250, FlxG.elapsed, 0.20); + } + } + + if (uiCam != null && spriteCam != null) { + uiCam.zoom = MathUtil.smoothLerp(uiCam.zoom, 1, FlxG.elapsed, 0.45); + spriteCam.zoom = MathUtil.smoothLerp(spriteCam.zoom, 1, FlxG.elapsed, 0.45); + } + } + + function buildCharacterWheel():Void { + characterWheel.clear(); + + for (characterIndex in 0...filteredKeys.length) { + var characterID:String = filteredKeys[characterIndex]; + var characterSprite:BaseCharacter = (characterID != 'default' ? CharacterDataParser.fetchCharacter(characterID) : null); + var lockedChill:FunkinSprite; + + if (characterID != 'default') { + fs_characterData = characterHandler.scriptCall('getCharacterData', [characterID]); + } + + if (characterSprite != null) { + characterSprite.camera = spriteCam; + if (lockedCharMap.exists(characterID)) { + lockedChill = FunkinSprite.createSparrow(200, 250, 'funkerSelector/locked_character'); + lockedChill.animation.addByPrefix('idle', 'LOCKED MAN0', 24, false); + lockedChill.animation.addByPrefix('denied', 'cannot select0', 24, false); + lockedChill.animation.addByPrefix('death', 'locked man explodes0', 24, false); + lockedChill.animation.play('idle'); + lockedChill.animation.finishCallback = function(_) { + lockedChill.offset.set(); + lockedChill.animation.play('idle'); + }; + lockedChill.scale.set(1.2, 1.2); + lockedChill.camera = spriteCam; + characterWheel.add(lockedChill); + } + else { + trace("[Funker Selector] Configuring sprite..."); + configureChar(characterSprite, fs_characterData?.characterMenu); + characterWheel.add(characterSprite); + } + } + else { + characterWheel.add(null); + } + } + + changeCharacter(); + } + + /** + * Stolen from `StoryMenuState.hx` LOL + * + * Changes the selected character. + * @param change +1 (right), -1 (left) + */ + function changeCharacter(change:Int = 0):Void { + var prevIndex:Int = currentCharacterIndex; + currentCharacterIndex = (prevIndex + (change + filteredKeys.length)) % filteredKeys.length; + + currentCharacterID = filteredKeys[currentCharacterIndex]; + + if (!saveData.preferences.get("potatoMode")) { + for (index in 0...characterWheel.members.length) { + var item:Dynamic = characterWheel.members[index]; + + if (index == currentCharacterIndex - 1) { + item.alpha = 1.0; + } + else { + item.alpha = 0.5; + } + } + } + + if (currentCharacterIndex != prevIndex) { + if (saveData.preferences.get("preferredSFX") == "charSelect") { + FunkinSound.playOnce(Paths.sound('CS_select'), 0.2); + } + else { + FunkinSound.playOnce(Paths.sound('scrollMenu'), 0.4); + } + } + + updateCharInfo(); + + numberThing.text = "< " + (currentCharacterIndex + 1) + " / " + filteredKeys.length + " >"; + numberThing.screenCenter(0x01); + } + + /** + * Changes the current page. + * @param change +1 (right), -1 (left) + */ + function changePage(change:Int = 0):Void { + var prevIndex:Int = currentPageIndex; + currentPageIndex = (prevIndex + (change + pages.length)) % pages.length; + + // Reset currentCharacterIndex + currentCharacterIndex = 0; + + updateFilteredKeys(); + if (!saveData.preferences.get("potatoMode")) { + buildCharacterWheel(); + } + changeCharacter(); + } + + function beatHit():Void { + bopDaThing(); + if (!saveData.preferences.get("potatoMode")) { + for (index in 0...characterWheel.members.length) { + var item:Dynamic = characterWheel.members[index]; + if (index == currentCharacterIndex - 1 && item is BaseCharacter) item.dance(); + } + } + + if (conductorInUse.currentBeat % 4 == 0) { + if (uiCam != null && spriteCam != null) { + uiCam.zoom = 1.02; + spriteCam.zoom = 1.02; + } + } + } + + /** + * Updates the `filteredKeys` array based on the current `currentPageIndex`. + */ + function updateFilteredKeys():Void { + trace('[Funker Selector] Sorting the Character Map...'); + filteredKeys = ['default']; // Ensures 'default' is always the first thing. + + tempArray = []; + + for (key in characterMap.keys()) { + tempArray.push(key); + } + + iHateMyLife = tempArray.filter(function(shit) { + characterType = characterMap.get(shit)[1]; + switch (currentPageIndex) { + case 0: + if (characterType is Array) return characterType.contains('bf') || characterType.contains('player'); + else + return characterType == 'bf' || characterType == 'player'; + case 1: + if (characterType is Array) return characterType.contains('dad') || characterType.contains('opponent'); + else + return characterType == 'dad' || characterType == 'opponent'; + case 2: + if (characterType is Array) return characterType.contains('gf') || characterType.contains('speaker'); + else + return characterType == 'gf' || characterType == 'speaker'; + } + }); + + iHateMyLife.sort(function(a:String, b:String):Int { + characterOwnerA = characterMap.get(a)[0]; + characterOwnerB = characterMap.get(b)[0]; + characterA = jsonCharacterCache.get(characterOwnerA); + characterB = jsonCharacterCache.get(characterOwnerB); + + characterDataA = characterA[1]; + characterDataB = characterB[1]; + + nameA = characterDataA.name.toUpperCase(); + nameB = characterDataB.name.toUpperCase(); + + if (nameA < nameB) { + return -1; + } + else if (nameA > nameB) { + return 1; + } + else { + return 0; + } + }); + + filteredKeys = filteredKeys.concat(iHateMyLife); + + trace("[Funker Selector] Updated the character list:\n" + filteredKeys); + } + + /** + * Make a super awesome cool icon grid! + * @param spacing The spacing of the icons, I guess. + * + * This is disabled if "Simplify UI" is turned on in the Options Menu. + */ + function createIconGrid(spacing:Int, ?alpha:Float = null):Void { + var characterData = currentCharacterID != 'default' ? jsonCharacterCache.get(currentCharacterID)[1] : null; + var columns = Math.ceil(FlxG.width / (spacing * 2)); + var rows = Math.ceil(FlxG.height / (spacing * 2)); + var xStart = (FlxG.width - columns * spacing * 2) / 2; + var yStart = (FlxG.height - rows * spacing * 2) / 2; + + for (i in 0...rows) { + for (j in 0...columns) { + for (k in 0...2) { + for (l in 0...2) { + var icon = new HealthIcon('dad', 0); + icon.x = xStart + (j * 2 + k) * spacing; + icon.y = yStart + (i * 2 + l) * spacing; + icon.alpha = (alpha != null ? alpha : 0.2); + + if (characterData != null) { + icon.configure(characterData.healthIcon); + } + else { + icon.configure('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + } + + add(icon); + iconGrid.push(icon); + icon.camera = spriteCam; + } + } + } + } + } + + /** + * Check if the JSON file in `data/characters/` ACTUALLY exists. + * + * I wrote this function because OpenFL Assets is case-insensitive + * on Windows and macOS. This is for an edge case where the JSON filename + * in data/characters/ has different capitalization than the Character ID + * specified in the Funker Selector JSON. Because OpenFL is case insensitive, it will + * always return true, which confuses Funker Selector! + * + * This was reported in issue [#1](https://github.com/AbnormalPoof/FunkerSelector/issues/1)! + * + * This should hopefully solve any and all issues like that one. + * + * @param string The character ID we're comparing. + * @return Returns a true or false if the file exists. + */ + function charJSONCheck(string:String):Bool { + if (Assets.exists(Paths.json('characters/' + string))) { + var characterFiles = DataAssets.listDataFilesInPath('characters/'); + return characterFiles.contains(string); + } + return false; + } + + /** + * Configuring the JSON character, setting up position, scale, flipping, etc. + */ + function configureChar(characterSprite:BaseCharacter, data:Null):BaseCharacter { + if (data == null) { + trace('[Funker Selector] ERROR: Data is null! Can\'t configure characterSprite.'); + return; + } + var position = data.position != null ? data.position : [0, 0]; + var scale = data.scale != null ? (data.isPixel ? data.scale * 6 : data.scale) : (data.isPixel ? 6.0 : 1.0); + characterSprite.y += position[1]; + characterSprite.flipX = data.flipX != null ? data.flipX : false; + characterSprite.scale.set(scale, scale); + characterSprite.antialiasing = data.isPixel != null ? !data.isPixel : true; + characterSprite.pixelPerfectRender = data?.isPixel; + + return characterSprite; + } + + /** + * Set up UI stuff like the BG and text. + */ + function setupUIStuff():Void { + spriteCam = new FunkinCamera('SpriteCam'); + FlxG.cameras.add(spriteCam, false); + spriteCam.bgColor = 0x0; + + uiCam = new FunkinCamera('UIElements'); + FlxG.cameras.add(uiCam, false); + uiCam.bgColor = 0x0; + + hotkeyCam = new FunkinCamera('HotkeyMenu'); + FlxG.cameras.add(hotkeyCam, false); + hotkeyCam.bgColor = 0x0; + hotkeyCam.visible = false; + + bg = new FunkinSprite(0, 0); + bg.makeSolidColor(FlxG.width, FlxG.height, 0xFF000000); + bg.alpha = 0; + bg.camera = spriteCam; + + selectText = configureText(null, null, [170, 10], 50, Paths.font("PhantomMuff.ttf")); + selectText.screenCenter(0x01); + selectText.camera = uiCam; + + numberThing = configureText(null, null, [170, 60], 45, Paths.font("PhantomMuff.ttf")); + numberThing.color = 0xFFE3E1E1; + numberThing.camera = uiCam; + + charDesc = configureText(null, "PLACEHOLDER", [250, 510], null, Paths.font("PhantomMuff.ttf")); + charDesc.wordWrap = true; + charDesc.fieldWidth = 800; + charDesc.camera = uiCam; + + selected = configureText(null, '(SELECTED)', [720, 220], 50, Paths.font("PhantomMuff.ttf")); + selected.color = 0xFF53FF38; + selected.visible = false; + selected.camera = uiCam; + + keyBox = new FunkinSprite(0, 0); + keyBox.makeSolidColor(600, 600, 0xFF000000); + keyBox.camera = hotkeyCam; + keyBox.alpha = 0.8; + keyBox.screenCenter(); + + hotkeyText = configureText(null, controls.getDialogueNameFromToken("RESET", true) + ' - Hotkeys', [990, 15], 45, Paths.font("PhantomMuff.ttf")); + hotkeyText.color = 0xFFE3E1E1; + hotkeyText.camera = uiCam; + + hotkeys = configureText(null, + controls.getDialogueNameFromToken("FREEPLAY_FAVORITE", true) + + ' - Reset everything to default.\n\n' + + controls.getDialogueNameFromToken("FREEPLAY_CHAR_SELECT", true) + + ' - Jump to the currently selected character.\n\n' + + controls.getDialogueNameFromToken("FREEPLAY_LEFT", true) + + ' - Switch to the previous page.\n\n' + + controls.getDialogueNameFromToken("FREEPLAY_RIGHT", true) + + ' - Switch to the next page.\n\n' + + controls.getDialogueNameFromToken("BACK", true) + + ' - Close this.\n\n' + + controls.getDialogueNameFromToken("DEBUG_MENU", true) + + ' - Show available character variations.', + [350, 100], 35, Paths.font("PhantomMuff.ttf")); + hotkeys.camera = hotkeyCam; + hotkeys.wordWrap = true; + hotkeys.fieldWidth = 600; + + charName = configureText(null, null, [170, 110], 70, Paths.font("PhantomMuff.ttf")); + charName.camera = uiCam; + + variationText = configureText(null, null, [170, 180], 35, Paths.font("PhantomMuff.ttf")); + variationText.color = 0xFFFF8F2E; + variationText.camera = uiCam; + + leftDifficultyArrow = FunkinSprite.createSparrow(20, 300, 'storymenu/ui/arrows'); + leftDifficultyArrow.animation.addByPrefix('idle', 'leftIdle0'); + leftDifficultyArrow.animation.addByPrefix('press', 'leftConfirm0'); + leftDifficultyArrow.animation.play('idle'); + leftDifficultyArrow.scale.set(1.5, 1.5); + leftDifficultyArrow.camera = uiCam; + + rightDifficultyArrow = FunkinSprite.createSparrow(1200, leftDifficultyArrow.y, 'storymenu/ui/arrows'); + rightDifficultyArrow.animation.addByPrefix('idle', 'rightIdle0'); + rightDifficultyArrow.animation.addByPrefix('press', 'rightConfirm0'); + rightDifficultyArrow.animation.play('idle'); + rightDifficultyArrow.scale.set(1.5, 1.5); + rightDifficultyArrow.camera = uiCam; + + if (saveData.preferences.get("potatoMode")) { + lockedChill = new Lock(530, 270, FlxG.random.int(1, 9)); + lockedChill.onAnimationComplete.add(function(name) { + if (name != 'selected') lockedChill.playAnimation('selected'); + }); + lockedChill.playAnimation('selected'); + lockedChill.scale.set(2, 2); + lockedChill.camera = spriteCam; + lockedChill.visible = false; + } + + newGraphic = FunkinSprite.createSparrow(840, 460, 'freeplay/freeplayCapsule/new'); + newGraphic.animation.addByPrefix('new', 'NEW notif', 24); + newGraphic.animation.play('new'); + newGraphic.camera = uiCam; + newGraphic.scale.set(1.8, 1.8); + newGraphic.visible = false; + + descBox = new FunkinSprite(0, 500); + descBox.makeSolidColor(850, 200, 0xFF000000); + descBox.camera = uiCam; + descBox.alpha = 0.6; + descBox.screenCenter(0x01); + + charIcon = new HealthIcon('dad', 0); + charIcon.camera = uiCam; + + unlockSound = new FunkinSound(); + unlockSound.loadEmbedded(Paths.sound('funkerSelector/charUnlock')); + unlockSound.volume = 1; + + add(bg); + if (!saveData.preferences.get("potatoMode")) createIconGrid(150, 0.2); + add(leftDifficultyArrow); + add(rightDifficultyArrow); + if (!saveData.preferences.get("potatoMode")) add(characterWheel); + add(selectText); + add(numberThing); + add(selected); + add(hotkeyText); + add(charIcon); + add(charName); + add(variationText); + if (saveData.preferences.get("potatoMode")) add(lockedChill); + add(keyBox); + add(hotkeys); + add(newGraphic); + add(descBox); + add(charDesc); + + WindowUtil.setWindowTitle('Friday Night Funkin\' - Funker Selector Menu'); + + FlxTween.tween(bg, {alpha: 0.5}, 0.5, {ease: FlxEase.quartOut}); + + trace('[Funker Selector] UI has been set up.'); + + conductorInUse.forceBPM(songBPM); + } + + /** + * Bopping the icons, because why not? + */ + function bopDaThing():Void { + if (charIcon != null) { + if (charIcon.width > charIcon.height) { + charIcon.setGraphicSize(Std.int(charIcon.width + (150 * charIcon.size.x * 0.2)), 0); + } + else { + charIcon.setGraphicSize(0, Std.int(charIcon.height + (150 * charIcon.size.y * 0.2))); + } + + charIcon.angle += 1; + + charIcon.updateHitbox(); + charIcon.updatePosition(); + } + } + + /** + * Flash the screen with a specific color. + */ + function flashScreen(color:FlxColor, duration:Float, ?alpha:Float = 1) { + var duration:Float = duration; + var alpha:Float = alpha; + + var white = new FunkinSprite(0, 0); + white.makeSolidColor(FlxG.width, FlxG.height, color); + white.alpha = 0; + white.camera = uiCam; + this.add(white); + + FlxTween.tween(white, {alpha: alpha}, duration, { + ease: FlxEase.quadOut, + onComplete: function(twn:FlxTween) { + FlxTween.tween(white, {alpha: alpha}, duration, { + ease: FlxEase.quadOut, + onComplete: function(twn:FlxTween) { + this.remove(white); + } + }); + } + }); + } + + /** + * Reponsible for handling inputs in the menu. + */ + function handleKeyShit():Void { + if (controls.BACK) { + if (hotkeyCam.visible) { + hotkeyCam.visible = false; + } + else { + FunkinSound.playOnce(Paths.sound('cancelMenu')); + + // Scripted characters overwrite the suffixes when + // they're shown. So we want to manually reset them! + PauseSubState.musicSuffix = ''; + GameOverSubState.musicSuffix = ''; + GameOverSubState.blueBallSuffix = ''; + + WindowUtil.setWindowTitle('Friday Night Funkin\''); + ModuleHandler.callEvent(new ScriptEvent(FS_EXITED_SUBSTATE)); + + if (typewriterTimer != null) typewriterTimer.cancel(); + close(); + } + } + + if (!hotkeyCam.visible) { + if (controls.UI_LEFT || controls.UI_RIGHT) { + if ((spamming && spamTimer >= 0.07) || (!spamming && spamTimer <= 0)) { + spamTimer = 0; + changeCharacter(controls.UI_LEFT ? -1 : 1); + } + else if (!spamming && spamTimer >= 0.9) { + spamming = true; + } + + spamTimer += elapsed; + } + else { + spamming = false; + spamTimer = 0; + } + + leftDifficultyArrow.animation.play(controls.UI_LEFT ? 'press' : 'idle'); + rightDifficultyArrow.animation.play(controls.UI_RIGHT ? 'press' : 'idle'); + + if (controls.FREEPLAY_LEFT || controls.FREEPLAY_RIGHT) { + changePage(controls.FREEPLAY_LEFT ? -1 : 1); + } + + // Mouse scrolling: + // Up = Left + // Down = Right + if (FlxG.mouse.wheel < 0) { + changeCharacter(1); + } + else if (FlxG.mouse.wheel > 0) { + changeCharacter(-1); + } + + if (controls.FREEPLAY_CHAR_SELECT) { + if (saveData.preferences.get("preferredSFX") == "charSelect") { + FunkinSound.playOnce(Paths.sound('CS_select'), 0.2); + } + else { + FunkinSound.playOnce(Paths.sound('scrollMenu'), 0.4); + } + jumpToCurSelected(); + } + + if (controls.FREEPLAY_FAVORITE) { + var shouldPlaySound:Bool = false; + if (saveData.characterIDs.bf != 'default') { + saveData.characterIDs.bf = 'default'; + shouldPlaySound = true; + } + if (saveData.characterIDs.dad != 'default') { + saveData.characterIDs.dad = 'default'; + shouldPlaySound = true; + } + if (saveData.characterIDs.gf != 'default') { + saveData.characterIDs.gf = 'default'; + shouldPlaySound = true; + } + if (shouldPlaySound) { + FunkinSound.playOnce(Paths.sound('ranks/great'), 0.4); + selected.visible = selectedTextVisibility(); + Save.instance.modOptions.set("FunkerSelector", saveData); + Save.instance.flush(); + + // Updating the array with the latest changes + characterIDsArray = [saveData.characterIDs.bf, saveData.characterIDs.dad, saveData.characterIDs.gf]; + + // Jump to default. + jumpToCurSelected(); + } + } + + if (controls.RESET) { + hotkeyCam.visible = true; + } + + if (controls.DEBUG_MENU) { + showVariations(currentCharacterID); + } + + if (controls.ACCEPT) { + var shouldConfirm:Bool = false; + if (currentCharacterID != characterIDsArray[currentPageIndex]) { + if (!lockedCharMap.exists(currentCharacterID)) { + switch (currentPageIndex) { + case 0: + if (saveData.characterIDs.bf != currentCharacterID) { + saveData.characterIDs.bf = currentCharacterID; + shouldConfirm = true; + } + case 1: + if (saveData.characterIDs.dad != currentCharacterID) { + saveData.characterIDs.dad = currentCharacterID; + shouldConfirm = true; + } + case 2: + if (saveData.characterIDs.gf != currentCharacterID) { + saveData.characterIDs.gf = currentCharacterID; + shouldConfirm = true; + } + } + } + } + if (shouldConfirm) { + confirmThing(); + selected.visible = selectedTextVisibility(); + newGraphic.visible = false; + Save.instance.modOptions.set("FunkerSelector", saveData); + Save.instance.flush(); + + // Updating the array with the latest changes + characterIDsArray = [saveData.characterIDs.bf, saveData.characterIDs.dad, saveData.characterIDs.gf]; + } + else { + if (saveData.preferences.get("preferredSFX") == "charSelect") { + FunkinSound.playOnce(Paths.sound('CS_locked'), 0.4); + } + else { + FunkinSound.playOnce(Paths.sound('cancelMenu')); + } + flashScreen(0xFFFF0000, 0.03, 0.1); + if (!saveData.preferences.get("potatoMode")) { + for (index in 0...characterWheel.members.length) { + var item = characterWheel.members[index]; + if (index == currentCharacterIndex - 1 && item is FunkinSprite) item.animation.play('denied', true); + } + } + else { + if (lockedChill != null) lockedChill.playAnimation("clicked", true); + } + } + } + } + } + + /** + * Jumps to the currently selected character. + */ + function jumpToCurSelected():Void { + // In the event that the saved character ID no longer exists, we fallback to default. + if (!characterMap.exists(characterIDsArray[currentPageIndex]) && characterIDsArray[currentPageIndex] != 'default') { + trace('[Funker Selector] Saved character ID doesn\'t exist! Resetting to default.'); + switch (currentPageIndex) { + case 0: + saveData.characterIDs.bf = 'default'; + case 1: + saveData.characterIDs.dad = 'default'; + case 2: + saveData.characterIDs.gf = 'default'; + } + // Save everything + Save.instance.modOptions.set("FunkerSelector", saveData); + Save.instance.flush(); + + // Updating the array with the latest changes + characterIDsArray = [saveData.characterIDs.bf, saveData.characterIDs.dad, saveData.characterIDs.gf]; + } + currentCharacterIndex = filteredKeys.indexOf(characterIDsArray[currentPageIndex]); + currentCharacterID = characterIDsArray[currentPageIndex]; + changeCharacter(); + updateCharInfo(); + } + + function confirmThing():Void { + if (saveData.preferences.get("preferredSFX") == "charSelect") { + FunkinSound.playOnce(Paths.sound('CS_confirm'), 0.4); + } + else { + FunkinSound.playOnce(Paths.sound('confirmMenu')); + } + if (!saveData.preferences.get("potatoMode")) { + fs_characterData = characterHandler.scriptCall('getCharacterData', [currentCharacterID]); + animation = fs_characterData?.characterMenu?.selectedAnim != null ? fs_characterData.characterMenu.selectedAnim : 'hey'; + var item:Dynamic = characterWheel.members[currentCharacterIndex - 1]; + if (item != null && item is BaseCharacter) item.playAnimation(animation, true, true); + } + if (charIcon.hasAnimation('winning')) { + // Play the winning animation if it's available. + charIcon.playAnimation('winning'); + + // I wanted this really cool effect where the icons in the icon grid would also + // play the winning animation! + // But let's not play that if "Simplify UI" is turned on. + if (!saveData.preferences.get("potatoMode") && iconGrid != null) { + for (i in 0...iconGrid.length) { + var icon = iconGrid[i]; + new FlxTimer().start(0.01 * i, function() { + icon.playAnimation('winning'); + }); + } + new FlxTimer().start(0.3, function() { + for (i in 0...iconGrid.length) { + var icon = iconGrid[i]; + new FlxTimer().start(0.01 * i, function() { + icon.playAnimation('idle'); + }); + } + }); + } + } + + flashScreen(0xFFFFFFFF, 0.03, 0.1); + } + + function selectedTextVisibility():Bool { + switch (currentPageIndex) { + case 0: + return currentCharacterID == saveData.characterIDs.bf; + case 1: + return currentCharacterID == saveData.characterIDs.dad; + case 2: + return currentCharacterID == saveData.characterIDs.gf; + default: + return false; + } + } + + /** + * Helper function for configuring an FlxText + * @param object The FlxText object + * @param text The text itself + * @param offsets The offsets as an array + * @param size The size of the text + * @param font The font + * @return A FlxText object + */ + function configureText(object:FlxText, ?text:String = "", ?offsets:Array, ?size:Int = 38, ?font:String = null):FlxText { + if (text == null) text = ""; + if (offsets == null) offsets = [0, 0]; + if (size == null) size = 35; + if (object == null) object = new FlxText(0, 0, 0, "PLACEHOLDER"); + + object.text = text; + object.x += offsets[0]; + object.y += offsets[1]; + if (font != null) object.setFormat(font, size, null, 'center'); + + return object; + } + + /** + * Displays the available character variations. + * @param characterID The character ID we're using. + */ + function showVariations(characterID:String) { + var fs_characterData:Dynamic = characterHandler.scriptCall('getCharacterData', [characterID]); + var variationCount:Int = fs_characterData?.characterVariations != null ? fs_characterData.characterVariations.length : 0; + var alertBody = ""; + + if (variationCount <= 0) return; + + for (variation in fs_characterData.characterVariations) { + var characterName = ""; + var songNames = []; + + for (songID in variation.songID) { + var songData = SongRegistry.instance.parseEntryMetadata(songID, variation.songVariation); + var characterData = CharacterDataParser.parseCharacterData(variation.characterID); + characterName = characterData.name; + songNames.push(songData.songName); + } + + alertBody += "Name: " + characterName + "\n"; + alertBody += "Songs: " + songNames.join(", ") + "\n\n"; + } + + PolymodErrorHandler.showAlert("Available character variations", alertBody); + } + + /** + * Copypasted from CharacterHandler. + */ + function isDebugBuild():Bool { + // Debug builds use a different background color. + return Application.current.window.context.attributes.background == 0xFFFF00FF; + } + + /** + * Does a really fucking cool unlock animation. + */ + function doUnlockAnimation(?skip:Bool):Void { + if (!saveData.preferences.get("potatoMode")) { + for (index in 0...characterWheel.members.length) { + var item = characterWheel.members[index]; + if (index == currentCharacterIndex - 1 && item is FunkinSprite) { + lockedChill = item; + break; + } + } + } + + if (lockedChill is FunkinSprite) { + lockedChill.animation.callback = function(name, frame) { + if (name == 'death' && frame == 36) { + finishUnlockAnim(false); + } + } + } + else { + lockedChill.onAnimationFrame.add(function(name, frame) { + if (name == 'unlock' && frame == 36) { + finishUnlockAnim(true); + } + }); + } + + if (skip) { + skipUnlockAnim(lockedChill); + return; + } + + if (FlxG.sound.music != null) FlxG.sound.music.fadeOut(); + + if (!saveData.preferences.get("potatoMode")) { + lockedChill.offset.set(); + lockedChill.animation.play('idle', true); + } + else { + lockedChill.playAnimation('idle', true); + } + + FlxTween.tween(uiCam, {alpha: 0}, 1, { + ease: FlxEase.quartOut, + onComplete: function(twn:FlxTween) { + unlockSound.play(true); + new FlxTimer().start(1.45, function() { + if (!saveData.preferences.get("potatoMode")) { + lockedChill.offset.set(170, 220); + lockedChill.animation.play('death'); + } + else { + lockedChill.playAnimation('unlock'); + } + }); + } + }); + } + + /** + * Replaces and updates the Character. + */ + function finishUnlockAnim(simplifyUI:Bool):Void { + flashScreen(0xFFFFFFFF, 0.05, 1); + new FlxTimer().start(0.025, function() { + updateCharInfo(); + if (selected.visible) selected.visible = false; + newGraphic.visible = true; + uiCam.shake(0.01, 0.2); + spriteCam.shake(0.01, 0.2); + FlxG.state.subState.funnyCam.shake(0.01, 0.2); + }); + + if (!simplifyUI) { + var characterSprite:BaseCharacter = CharacterDataParser.fetchCharacter(currentCharacterID); + fs_characterData = characterHandler.scriptCall('getCharacterData', [currentCharacterID]); + characterWheel.replace(lockedChill, characterSprite); + characterSprite.camera = spriteCam; + configureChar(characterSprite, fs_characterData?.characterMenu); + } + + busy = false; + uiCam.alpha = 1; + + if (FlxG.sound.music != null) FlxG.sound.music.fadeIn(); + } + + /** + * Skips the unlock animation. + */ + function skipUnlockAnim(lockedChill:Dynamic):Void { + if (lockedChill is FunkinSprite) { + lockedChill.offset.set(170, 220); + lockedChill.animation.play('death'); + lockedChill.animation.pause(); + lockedChill.animation.curAnim.curFrame = 36; + lockedChill.animation.resume(); + + if (saveData.preferences.get("preferredSFX") == "charSelect") { + FunkinSound.playOnce(Paths.sound('CS_confirm'), 0.4); + } + else { + FunkinSound.playOnce(Paths.sound('confirmMenu')); + } + } + else { + lockedChill.playAnimation('unlock'); + lockedChill.anim.pause(); + lockedChill.anim.curFrame = 34; + lockedChill.anim.resume(); + + if (saveData.preferences.get("preferredSFX") == "charSelect") { + FunkinSound.playOnce(Paths.sound('CS_confirm'), 0.4); + } + else { + FunkinSound.playOnce(Paths.sound('confirmMenu')); + } + } + } + + /** + * Adjusts the description size relative to the description text box. + * @param charDesc The `charDesc` FlxText object. + */ + function adjustFontSize(charDesc:FlxText):Void { + var currentFontSize = charDesc.size; + var maxHeight = descBox.height - 10; + if (charDesc.fieldHeight > maxHeight) { + currentFontSize--; + + charDesc.size = currentFontSize; + charDesc.letterSpacing -= 0.16 * (currentFontSize / 10); + + if (currentFontSize <= 8) return; + } + } + + /** + * Update character information. + */ + function updateCharInfo():Void { + selectText.text = topText[currentPageIndex]; + selectText.color = topTextColor[currentPageIndex]; + selectText.screenCenter(0x01); + selected.visible = selectedTextVisibility(); + + newGraphic.visible = false; + + var descText:String; + var variationCount:Int = 0; + + if (currentCharacterID != 'default') { + fs_characterData = characterHandler.scriptCall('getCharacterData', [currentCharacterID]); + characterData = characterHandler.scriptCall('getCharacterData', [currentCharacterID, 1]); + variationCount = characterHandler.scriptCall('getCharacterData', [currentCharacterID, 2]); + } + + switch (currentCharacterID) { + case 'default': + charIcon.configure('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + charName.text = "DEFAULT"; + descText = "This is the Default character defined by the song."; + default: + if (lockedCharMap.exists(currentCharacterID)) { + charName.text = "LOCKED"; + + var unlockHint = fs_characterData.description?.unlockCondition != null ? "\n\nUnlock Condition: " + + fs_characterData.description.unlockCondition : ""; + + descText = "This character is locked! I wonder who it could be..." + unlockHint; + } + else { + charName.text = characterData?.name; + charIcon.configure(characterData?.healthIcon); + charIcon.size.set(1, 1); + charIcon.setPosition(270, 380); + + descText = fs_characterData.description?.text != null ? fs_characterData.description.text : "No description was specified in the JSON file."; + } + } + + if (typewriterTimer != null) typewriterTimer.cancel(); + + typewriterIndex = 0; + + // Reset the description size and letter spacing. + charDesc.size = 35; + charDesc.letterSpacing = 0; + + typewriterTimer = new FlxTimer(); + typewriterTimer.start(0.01, function(timer:FlxTimer) { + if ((typewriterIndex + 1) <= descText.length) { + charDesc.text = descText.substring(0, typewriterIndex + 1); + adjustFontSize(charDesc); + typewriterIndex++; + } + else { + // Forcibly set the text if for some reason the text cuts off. + if (charDesc.text != descText) { + trace("[Funker Selector] Description text doesn't match up!"); + trace(charDesc.text); + trace(descText); + charDesc.text = descText; + } + timer.cancel(); + typewriterIndex = 0; + } + }, descText.length * 2); + + // Making it not look so awkward when "Simplify UI" is turned on. + if (saveData.preferences.get("potatoMode") || currentCharacterID == 'default') { + charIcon.setPosition(500, 230); + charIcon.size.set(1.7, 1.7); + } + + if (!saveData.preferences.get("potatoMode")) { + for (icon in iconGrid) { + if (currentCharacterID == 'default' || lockedCharMap.exists(currentCharacterID)) { + icon.configure('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + } + else if (icon.characterId != characterData?.healthIcon.id) { + icon.configure(characterData?.healthIcon); + } + } + } + + if (lockedCharMap.exists(currentCharacterID)) { + if (charIcon != null) charIcon.visible = false; + if (saveData.preferences.get("potatoMode")) lockedChill.visible = true; + } + else { + if (charIcon != null) charIcon.visible = true; + if (saveData.preferences.get("potatoMode")) lockedChill.visible = false; + } + + charName.screenCenter(0x01); + + variationText.screenCenter(0x01); + variationText.text = "This character has " + variationCount + " available " + (variationCount == 1 ? "variation" : "variations"); + + selected.y = charDesc.y - 60; + + // Do the unlock animation if we haven't seen it yet. + if (lockedCharMap.exists(currentCharacterID) + && characterHandler.scriptCall('isCharacterUnlocked', [currentCharacterID, fs_characterData.unlockMethod])) { + if (!saveData.seenUnlocks.contains(currentCharacterID)) { + lockedCharMap.remove(currentCharacterID); + saveData.seenUnlocks.push(currentCharacterID); + + characterHandler.scriptSet('saveData', saveData); + Save.instance.modOptions.set("FunkerSelector", saveData); + Save.instance.flush(); + + characterHandler.scriptSet('lockedCharMap', lockedCharMap); + + busy = true; + doUnlockAnimation(FlxG.keys.pressed.SHIFT); + } + } + + if (variationCount > 0) { + trace("[Funker Selector] Variations found for character ID " + currentCharacterID); + variationText.visible = true; + } + else { + variationText.visible = false; + } + } }