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;
+ }
+ }
}