diff --git a/.gitattributes b/.gitattributes
index 94f480de9..e7a218c89 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1 +1,3 @@
-* text=auto eol=lf
\ No newline at end of file
+* text=auto eol=lf
+*.hxc linguist-language=Haxe
+*.hxp linguist-language=Haxe
diff --git a/.github/actions/setup-haxe/action.yml b/.github/actions/setup-haxe/action.yml
index 5a9f7b293..a6c21f6a9 100644
--- a/.github/actions/setup-haxe/action.yml
+++ b/.github/actions/setup-haxe/action.yml
@@ -44,7 +44,7 @@ runs:
g++ \
libx11-dev libxi-dev libxext-dev libxinerama-dev libxrandr-dev \
libgl-dev libgl1-mesa-dev \
- libasound2-dev
+ libasound2-dev libpulse-dev
ln -s /usr/lib/x86_64-linux-gnu/libffi.so.8 /usr/lib/x86_64-linux-gnu/libffi.so.6 || true
- name: Install linux-specific dependencies
if: ${{ runner.os == 'Linux' && contains(inputs.targets, 'linux') }}
@@ -56,12 +56,17 @@ runs:
shell: bash
run: |
echo "TIMER_HAXELIB=$(date +%s)" >> "$GITHUB_ENV"
- haxelib --debug --never install haxelib 4.1.0 --global
- haxelib --debug --never deleterepo || true
+ haxelib fixrepo --global || true
+ haxelib --debug --never --global install haxelib 4.1.0
+ haxelib --debug --global set haxelib 4.1.0
+ haxelib --global remove haxelib git || true
+ haxelib --global remove hmm || true
+ rm -rf .haxelib
+ haxelib --debug --never --global git haxelib https://github.com/FunkinCrew/haxelib.git funkin-patches --skip-dependencies
+ haxelib --debug --never --global git hmm https://github.com/FunkinCrew/hmm funkin-patches
haxelib --debug --never newrepo
+ haxelib version
echo "HAXEPATH=$(haxelib config)" >> "$GITHUB_ENV"
- haxelib --debug --never git haxelib https://github.com/HaxeFoundation/haxelib.git master
- haxelib --debug --global install hmm
echo "TIMER_DEPS=$(date +%s)" >> "$GITHUB_ENV"
- name: Restore cached dependencies
@@ -75,7 +80,12 @@ runs:
name: Prep git for dependency install
uses: gacts/run-and-post-run@v1
with:
- run: git config --global 'url.https://x-access-token:${{ inputs.gh-token }}@github.com/.insteadOf' https://github.com/
+ run: |
+ git config --global --name-only --get-regexp 'url\.https\:\/\/x-access-token:.+@github\.com\/\.insteadOf' \
+ | xargs -I {} git config --global --unset {}
+
+ git config -l --show-scope --show-origin
+ git config --global 'url.https://x-access-token:${{ inputs.gh-token }}@github.com/.insteadOf' https://github.com/
post: git config --global --unset 'url.https://x-access-token:${{ inputs.gh-token }}@github.com/.insteadOf'
- if: ${{ steps.cache-hmm.outputs.cache-hit != 'true' }}
diff --git a/.github/labeler.yml b/.github/labeler.yml
index e8e490865..e8250b4e7 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -1,7 +1,6 @@
# Add Documentation tag to PR's changing markdown files, or anyhting in the docs folder
Documentation:
- changed-files:
- - any-glob-to-any-file:
- any-glob-to-any-file:
- docs/*
- '**/*.md'
diff --git a/.github/workflows/build-game.yml b/.github/workflows/build-game.yml
index 07802557c..dff9a369d 100644
--- a/.github/workflows/build-game.yml
+++ b/.github/workflows/build-game.yml
@@ -45,7 +45,11 @@ jobs:
uses: ./.github/actions/setup-haxe
with:
gh-token: ${{ steps.app_token.outputs.token }}
-
+ - name: Setup HXCPP dev commit
+ run: |
+ cd .haxelib/hxcpp/git/tools/hxcpp
+ haxe compile.hxml
+ cd ../../../../..
- name: Build game
if: ${{ matrix.target == 'windows' }}
run: |
@@ -107,7 +111,9 @@ jobs:
name: Install dependencies
run: |
git config --global 'url.https://x-access-token:${{ steps.app_token.outputs.token }}@github.com/.insteadOf' https://github.com/
+ git config --global advice.detachedHead false
haxelib --global run hmm install -q
+ cd .haxelib/hxcpp/git/tools/hxcpp && haxe compile.hxml
- if: ${{ matrix.target != 'html5' }}
name: Restore hxcpp cache
diff --git a/.gitignore b/.gitignore
index 84585eee0..ae402bdee 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,4 @@ shitAudio/
node_modules/
package.json
package-lock.json
+.aider*
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 26fe0b042..227cb94ec 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -94,12 +94,12 @@
{
"label": "Windows / Debug",
"target": "windows",
- "args": ["-debug", "-DFORCE_DEBUG_VERSION"]
+ "args": ["-debug", "-DFEATURE_DEBUG_FUNCTIONS"]
},
{
"label": "Linux / Debug",
"target": "linux",
- "args": ["-debug", "-DFORCE_DEBUG_VERSION"]
+ "args": ["-debug", "-DFEATURE_DEBUG_FUNCTIONS"]
},
{
"label": "HashLink / Debug",
@@ -109,7 +109,7 @@
{
"label": "Windows / Debug (FlxAnimate Test)",
"target": "windows",
- "args": ["-debug", "-DANIMATE", "-DFORCE_DEBUG_VERSION"]
+ "args": ["-debug", "-DANIMATE", "-DFEATURE_DEBUG_FUNCTIONS"]
},
{
"label": "HashLink / Debug (FlxAnimate Test)",
@@ -119,7 +119,7 @@
{
"label": "Windows / Debug (Straight to Freeplay)",
"target": "windows",
- "args": ["-debug", "-DFREEPLAY", "-DFORCE_DEBUG_VERSION"]
+ "args": ["-debug", "-DFREEPLAY", "-DFEATURE_DEBUG_FUNCTIONS"]
},
{
"label": "HashLink / Debug (Straight to Freeplay)",
@@ -132,13 +132,13 @@
"args": [
"-debug",
"-DSONG=bopeebo -DDIFFICULTY=normal",
- "-DFORCE_DEBUG_VERSION"
+ "-DFEATURE_DEBUG_FUNCTIONS"
]
},
{
"label": "Windows / Debug (Straight to Play - 2hot)",
"target": "windows",
- "args": ["-debug", "-DSONG=2hot", "-DFORCE_DEBUG_VERSION"]
+ "args": ["-debug", "-DSONG=2hot", "-DFEATURE_DEBUG_FUNCTIONS"]
},
{
"label": "HashLink / Debug (Straight to Play - Bopeebo Normal)",
@@ -148,7 +148,7 @@
{
"label": "Windows / Debug (Conversation Test)",
"target": "windows",
- "args": ["-debug", "-DDIALOGUE", "-DFORCE_DEBUG_VERSION"]
+ "args": ["-debug", "-DDIALOGUE", "-DFEATURE_DEBUG_FUNCTIONS"]
},
{
"label": "HashLink / Debug (Conversation Test)",
@@ -163,7 +163,7 @@
{
"label": "Windows / Debug (Straight to Chart Editor)",
"target": "windows",
- "args": ["-debug", "-DCHARTING", "-DFORCE_DEBUG_VERSION"]
+ "args": ["-debug", "-DCHARTING", "-DFEATURE_DEBUG_FUNCTIONS"]
},
{
"label": "HashLink / Debug (Straight to Chart Editor)",
@@ -173,12 +173,12 @@
{
"label": "Windows / Debug (Straight to Animation Editor)",
"target": "windows",
- "args": ["-debug", "-DANIMDEBUG", "-DFORCE_DEBUG_VERSION"]
+ "args": ["-debug", "-DANIMDEBUG", "-DFEATURE_DEBUG_FUNCTIONS"]
},
{
"label": "Windows / Debug (Debug hxCodec)",
"target": "windows",
- "args": ["-debug", "-DHXC_LIBVLC_LOGGING", "-DFORCE_DEBUG_VERSION"]
+ "args": ["-debug", "-DHXC_LIBVLC_LOGGING", "-DFEATURE_DEBUG_FUNCTIONS"]
},
{
"label": "HashLink / Debug (Straight to Animation Editor)",
@@ -188,7 +188,7 @@
{
"label": "Windows / Debug (Latency Test)",
"target": "windows",
- "args": ["-debug", "-DLATENCY", "-DFORCE_DEBUG_VERSION"]
+ "args": ["-debug", "-DLATENCY", "-DFEATURE_DEBUG_FUNCTIONS"]
},
{
"label": "HashLink / Debug (Latency Test)",
@@ -198,7 +198,7 @@
{
"label": "Windows / Debug (Waveform Test)",
"target": "windows",
- "args": ["-debug", "-DWAVEFORM", "-DFORCE_DEBUG_VERSION"]
+ "args": ["-debug", "-DWAVEFORM", "-DFEATURE_DEBUG_FUNCTIONS"]
},
{
"label": "Windows / Release",
@@ -218,17 +218,17 @@
{
"label": "HTML5 / Debug",
"target": "html5",
- "args": ["-debug", "-DFORCE_DEBUG_VERSION"]
+ "args": ["-debug", "-DFEATURE_DEBUG_FUNCTIONS"]
},
{
"label": "HTML5 / Debug (Watch)",
"target": "html5",
- "args": ["-debug", "-watch", "-DFORCE_DEBUG_VERSION"]
+ "args": ["-debug", "-watch", "-DFEATURE_DEBUG_FUNCTIONS"]
},
{
"label": "macOS / Debug",
"target": "mac",
- "args": ["-debug", "-DFORCE_DEBUG_VERSION"]
+ "args": ["-debug", "-DFEATURE_DEBUG_FUNCTIONS"]
},
{
"label": "macOS / Release",
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 653ee203f..a2031ba24 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,83 @@ All notable changes will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.5.0] - 2024-09-12
+### Added
+- Added a new Character Select screen to switch between playable characters in Freeplay
+ - Modding isn't 100% there but we're working on it!
+- Added Pico as a playable character! Unlock him by completing Weekend 1 (if you haven't already done that)
+ - The songs from Weekend 1 have moved; you must now switch to Pico in Freeplay to access them
+- Added 10 new Pico remixes! Access them by selecting Pico from in the Character Select screen
+ - Bopeebo (Pico Mix)
+ - Fresh (Pico Mix)
+ - DadBattle (Pico Mix)
+ - Spookeez (Pico Mix)
+ - South (Pico Mix)
+ - Philly Nice (Pico Mix)
+ - Blammed (Pico Mix)
+ - Eggnog (Pico Mix)
+ - Ugh (Pico Mix)
+ - Guns (Pico Mix)
+- Added 1 new Boyfriend remix! Access it by selecting Pico from in the Character Select screen
+ - Darnell (BF Mix)
+- Added 2 new Erect remixes! Access them by switching difficulty on the song
+ - Cocoa Erect
+ - Ugh Erect
+- Implemented support for a new Instrumental Selector in Freeplay
+ - Beating a Pico remix lets you use that instrumental when playing as Boyfriend
+- Added the first batch of Erect Stages! These graphical overhauls of the original stages will be used when playing Erect remixes and Pico remixes
+ - Week 1 Erect Stage
+ - Week 2 Erect Stage
+ - Week 3 Erect Stage
+ - Week 4 Erect Stage
+ - Week 5 Erect Stage
+ - Weekend 1 Erect Stage
+- Implemented alternate animations and music for Pico in the results screen.
+ - These display on Pico remixes, as well as when playing Weekend 1.
+- Implemented support for scripted Note Kinds. You can use HScript define a different note style to display for these notes as well as custom behavior. (community feature by lemz1)
+- Implemented support for Numeric and Selector options in the Options menu. (community feature by FlooferLand)
+## Changed
+- Girlfriend and Nene now perform previously unused animations when you achieve a large combo, or drop a large combo.
+- The pixel character icons in the Freeplay menu now display an animation!
+- Altered how Week 6 displays sprites to make things look more retro.
+- Character offsets are now independent of the character's scale.
+ - This should resolve issues with offsets when porting characters from older mods.
+ - Pixel character offsets have been modified to compensate.
+- Note style data can now specify custom combo count graphics, judgement graphics, countdown graphics, and countdown audio. (community feature by anysad)
+ - These were previously using hardcoded values based on whether the stage was `school` or `schoolEvil`.
+- The `danceEvery` property of characters and stage props can now use values with a precision of `0.25`, to play their idle animation up to four times per beat.
+- Reworked the JSON merging system in Polymod; you can now include JSONPatch files under `_merge` in your mod folder to add, modify, or remove values in a JSON without replacing it entirely!
+- Cutscenes now automatically pause when tabbing out (community fix by AbnormalPoof)
+- Characters will now respect the `danceEvery` property (community fix by gamerbross)
+- The F5 function now reloads the current song's chart data from disc (community feature by gamerbross)
+- Refactored the compilation guide and added common troubleshooting steps (community fix by Hundrec)
+- Made several layout improvements and fixes to the Animation Offsets editor in the Debug menu (community fix by gamerbross)
+- Fixed a bug where the Back sound would be not played when leaving the Story menu and Options menu (community fix by AppleHair)
+- Animation offsets no longer directly modify the `x` and `y` position of props, which makes props work better with tweens (community fix by Sword352)
+- The YEAH! events in Tutorial now use chart events rather than being hard-coded (community fix by anysad)
+- The player's Score now displays commas in it (community fix by loggo)
+## Fixed
+- Fixed an issue where songs with no notes would crash on the Results screen.
+- Fixed an issue where the old icon easter egg would not work properly on pixel levels.
+- Fixed an issue where you could play notes during the Thorns cutscene.
+- Fixed an issue where the Heart icon when favoriting a song in Freeplay would be malformed.
+- Fixed an issue where Pico's death animation displays a faint blue background (community fix by doggogit)
+- Fixed an issue where mod songs would not play a preview in the Freeplay menu (community fix by KarimAkra)
+- Fixed an issue where the Memory Usage counter could overflow and display a negative number (community fix by KarimAkra)
+- Fixed an issue where pressing the Chart Editor keybind while playtesting a chart would reset the chart editor (community fix by gamerbross)
+- Fixed a crash bug when pressing F5 after seeing the sticker transition (community fix by gamerbross)
+- Fixed an issue where the Story Mode menu couldn't be scrolled with a mouse (community fix by JVNpixels)
+- Fixed an issue causing the song to majorly desync sometimes (community fix by Burgerballs)
+- Fixed an issue where the Freeplay song preview would not respect the instrumental ID specified in the song metadata (community fix by AppleHair)
+- Fixed an issue where Tankman's icon wouldn't display in the Chart Editor (community fix by hundrec)
+- Fixed an issue where pausing the game during a camera zoom would zoom the pause menu. (community fix by gamerbros)
+- Fixed an issue where certain UI elements would not flash at a consistent rate (community fix by cyn0x8)
+- Fixed an issue where the game would not use the placeholder health icon as a fallback (community fix by gamerbross)
+- Fixed an issue where the chart editor could get stuck creating a hold note when using Live Inputs (community fix by gamerbross)
+- Fixed an issue where character graphics could not be placed in week folders (community fix by 7oltan)
+- Fixed a crash issue when a Freeplay song has no `Normal` difficulty (community fix by Applehair and gamerbross)
+- Fixed an issue in Story Mode where a song that isn't valid for the current variation could be selected (community fix by Applehair)
+
## [0.4.1] - 2024-06-12
### Added
- Pressing ESCAPE on the title screen on desktop now exits the game, allowing you to exit the game while in fullscreen on desktop
diff --git a/Project.xml b/Project.xml
deleted file mode 100644
index fae9c768b..000000000
--- a/Project.xml
+++ /dev/null
@@ -1,269 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -->
- -->
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/art b/art
index faeba700c..bfca2ea98 160000
--- a/art
+++ b/art
@@ -1 +1 @@
-Subproject commit faeba700c5526bd4fd57ccc927d875c82b9d3553
+Subproject commit bfca2ea98d11a0f4dee4a27b9390951fbc5701ea
diff --git a/assets b/assets
index 2e1594ee4..bc7009b42 160000
--- a/assets
+++ b/assets
@@ -1 +1 @@
-Subproject commit 2e1594ee4c04c7148628bae471bdd061c9deb6b7
+Subproject commit bc7009b4242691faa5c4552f7ca8a2f28e8cb1d2
diff --git a/build/Dockerfile b/build/Dockerfile
index c545d1364..318870166 100644
--- a/build/Dockerfile
+++ b/build/Dockerfile
@@ -83,7 +83,7 @@ apt-fast install -y --no-install-recommends \
libc6-dev libffi-dev \
libx11-dev libxi-dev libxext-dev libxinerama-dev libxrandr-dev \
libgl-dev libgl1-mesa-dev \
- libasound2-dev \
+ libasound2-dev libpulse-dev \
libvlc-dev libvlccore-dev
EOF
@@ -137,8 +137,8 @@ ENV PATH="$HAXEPATH:$PATH"
RUN <` and `lime rebuild -debug`
+10. `lime test ` to build and launch the game for your platform (for example, `lime test windows`)
-# Troubleshooting - GO THROUGH THESE STEPS BEFORE OPENING ISSUES ON GITHUB!
+## Build Flags
-- During the cloning process, you may experience an error along the lines of `error: RPC failed; curl 92 HTTP/2 stream 0 was not closed cleanly: PROTOCOL_ERROR (err 1)` due to poor connectivity. A common fix is to run ` git config --global http.postBuffer 4096M`.
-- Make sure your game directory has an `assets` folder! If it's missing, copy the path to your `funkin` folder and run `cd the\path\you\copied`. Then follow the guide starting from **Step 4**.
-- Check that your `assets` folder is not empty! If it is, go back to **Step 4** and follow the guide from there.
-- The compilation process often fails due to having the wrong versions of the required libraries. Many errors can be resolved by deleting the `.haxelib` folder and following the guide starting from **Step 5**.
+There are several useful build flags you can add to a build to affect how it works. A full list can be found in `project.hxp`, but here's information on some of them:
+
+- `-debug` to build the game in debug mode. This automatically enables several useful debug features.
+ - This includes enabling in-game debug functions, disables compile-time optimizations, enabling asset redirection (see below), and enabling the VSCode debug server (which can slow the game on some machines but allows for powerful debugging through breakpoints).
+ - `-DGITHUB_BUILD` will enable in-game debug functions (such as the ability to time travel in a song by pressing `PgUp`/`PgDn`), without enabling the other stuff
+- `-DFEATURE_POLYMOD_MODS` or `-DNO_FEATURE_POLYMOD_MODS` to forcibly enable or disable modding support.
+- `-DREDIRECT_ASSETS_FOLDER` or `-DNO_REDIRECT_ASSETS_FOLDER` to forcibly enable or disable asset redirection.
+ - This feature causes the game to load exported assets from the project's assets folder rather than the exported one. Great for fast iteration, but the game
+- `-DFEATURE_DISCORD_RPC` or `-DNO_FEATURE_DISCORD_RPC` to forcibly enable or disable support for Discord Rich Presence.
+- `-DFEATURE_VIDEO_PLAYBACK` or `-DNO_FEATURE_VIDEO_PLAYBACK` to forcibly enable or disable video cutscene support.
+- `-DFEATURE_CHART_EDITOR` or `-DNO_FEATURE_CHART_EDITOR` to forcibly enable or disable the chart editor in the Debug menu.
+- `-DFEATURE_STAGE_EDITOR` to forcibly enable the experimental stage editor.
+- `-DFEATURE_GHOST_TAPPING` to forcibly enable an experimental gameplay change to the anti-mash system.
+
+# Troubleshooting
+
+If you experience any issues during the compilation process, DO NOT open an issue on GitHub. Instead, check the [Troubleshooting Guide](TROUBLESHOOTING.md) for steps on how to resolve common problems.
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
index 3b93bab64..54ba396c4 100644
--- a/docs/troubleshooting.md
+++ b/docs/troubleshooting.md
@@ -1,4 +1,4 @@
-# Troubleshooting Common Issues
+# Troubleshooting Common Compilation Issues
- Weird macro error with a very tall call stack: Restart Visual Studio Code
- NOTE: This is caused by Polymod somewhere, and seems to only occur when there is another compile error somewhere in the program. There is a bounty up for it.
@@ -13,3 +13,11 @@
- `LINK : fatal error LNK1201: error writing to program database ''; check for insufficient disk space, invalid path, or insufficient privilege`
- This error occurs if the PDB file located in your `export` folder is in use or exceeds 4 GB. Try deleting the `export` folder and building again from scratch.
+
+- `error: RPC failed; curl 92 HTTP/2 stream 0 was not closed cleanly: PROTOCOL_ERROR (err 1)`
+ - This error can happen during cloning as a result of poor network connectivity. A common fix is to run ` git config --global http.postBuffer 4096M` in your terminal.
+
+- Repository is missing an `assets` folder, or `assets` folder is empty.
+ - You did not clone the repository correctly! Copy the path to your `funkin` folder and run `cd the\path\you\copied`. Then follow the compilation guide starting from **Step 4**.
+
+- Other compilation issues may be caused by installing bad library versions. Try deleting the `.haxelib` folder and following the guide starting from **Step 5**.
diff --git a/hmm.json b/hmm.json
index 68e0c5cb0..d967a69b3 100644
--- a/hmm.json
+++ b/hmm.json
@@ -1,51 +1,53 @@
{
"dependencies": [
+ {
+ "name": "FlxPartialSound",
+ "type": "git",
+ "dir": null,
+ "ref": "a1eab7b9bf507b87200a3341719054fe427f3b15",
+ "url": "https://github.com/FunkinCrew/FlxPartialSound.git"
+ },
{
"name": "discord_rpc",
"type": "git",
"dir": null,
"ref": "2d83fa863ef0c1eace5f1cf67c3ac315d1a3a8a5",
- "url": "https://github.com/Aidan63/linc_discord-rpc"
+ "url": "https://github.com/FunkinCrew/linc_discord-rpc"
},
{
"name": "flixel",
"type": "git",
"dir": null,
- "ref": "a7d8e3bad89a0a3506a4714121f73d8e34522c49",
+ "ref": "f2b090d6c608471e730b051c8ee22b8b378964b1",
"url": "https://github.com/FunkinCrew/flixel"
},
{
"name": "flixel-addons",
"type": "git",
"dir": null,
- "ref": "a523c3b56622f0640933944171efed46929e360e",
+ "ref": "9c6fb47968e894eb36bf10e94725cd7640c49281",
"url": "https://github.com/FunkinCrew/flixel-addons"
},
{
"name": "flixel-text-input",
- "type": "haxelib",
- "version": "1.1.0"
+ "type": "git",
+ "dir": null,
+ "ref": "951a0103a17bfa55eed86703ce50b4fb0d7590bc",
+ "url": "https://github.com/FunkinCrew/flixel-text-input"
},
{
"name": "flixel-ui",
"type": "git",
"dir": null,
- "ref": "719b4f10d94186ed55f6fef1b6618d32abec8c15",
+ "ref": "27f1ba626f80a6282fa8a187115e79a4a2133dc2",
"url": "https://github.com/HaxeFlixel/flixel-ui"
},
{
"name": "flxanimate",
"type": "git",
"dir": null,
- "ref": "17e0d59fdbc2b6283a5c0e4df41f1c7f27b71c49",
- "url": "https://github.com/FunkinCrew/flxanimate"
- },
- {
- "name": "FlxPartialSound",
- "type": "git",
- "dir": null,
- "ref": "f986332ba5ab02abd386ce662578baf04904604a",
- "url": "https://github.com/FunkinCrew/FlxPartialSound.git"
+ "ref": "0654797e5eb7cd7de0c1b2dbaa1efe5a1e1d9412",
+ "url": "https://github.com/Dot-Stuff/flxanimate"
},
{
"name": "format",
@@ -56,7 +58,7 @@
"name": "funkin.vis",
"type": "git",
"dir": null,
- "ref": "38261833590773cb1de34ac5d11e0825696fc340",
+ "ref": "22b1ce089dd924f15cdc4632397ef3504d464e90",
"url": "https://github.com/FunkinCrew/funkVis"
},
{
@@ -75,20 +77,22 @@
"name": "haxeui-core",
"type": "git",
"dir": null,
- "ref": "0212d8fdfcafeb5f0d5a41e1ddba8ff21d0e183b",
+ "ref": "22f7c5a8ffca90d4677cffd6e570f53761709fbc",
"url": "https://github.com/haxeui/haxeui-core"
},
{
"name": "haxeui-flixel",
"type": "git",
"dir": null,
- "ref": "63a906a6148958dbfde8c7b48d90b0693767fd95",
+ "ref": "28bb710d0ae5d94b5108787593052165be43b980",
"url": "https://github.com/haxeui/haxeui-flixel"
},
{
"name": "hscript",
- "type": "haxelib",
- "version": "2.5.0"
+ "type": "git",
+ "dir": null,
+ "ref": "12785398e2f07082f05034cb580682e5671442a2",
+ "url": "https://github.com/FunkinCrew/hscript"
},
{
"name": "hxCodec",
@@ -99,8 +103,10 @@
},
{
"name": "hxcpp",
- "type": "haxelib",
- "version": "4.3.2"
+ "type": "git",
+ "dir": null,
+ "ref": "904ea40643b050a5a154c5e4c33a83fd2aec18b1",
+ "url": "https://github.com/HaxeFoundation/hxcpp"
},
{
"name": "hxcpp-debug-server",
@@ -109,10 +115,17 @@
"ref": "147294123f983e35f50a966741474438069a7a8f",
"url": "https://github.com/FunkinCrew/hxcpp-debugger"
},
+ {
+ "name": "hxjsonast",
+ "type": "git",
+ "dir": null,
+ "ref": "20e72cc68c823496359775ac1f06500e67f189d5",
+ "url": "https://github.com/nadako/hxjsonast/"
+ },
{
"name": "hxp",
"type": "haxelib",
- "version": "1.2.2"
+ "version": "1.3.0"
},
{
"name": "json2object",
@@ -121,11 +134,25 @@
"ref": "a8c26f18463c98da32f744c214fe02273e1823fa",
"url": "https://github.com/FunkinCrew/json2object"
},
+ {
+ "name": "jsonpatch",
+ "type": "git",
+ "dir": null,
+ "ref": "f9b83215acd586dc28754b4ae7f69d4c06c3b4d3",
+ "url": "https://github.com/EliteMasterEric/jsonpatch"
+ },
+ {
+ "name": "jsonpath",
+ "type": "git",
+ "dir": null,
+ "ref": "7a24193717b36393458c15c0435bb7c4470ecdda",
+ "url": "https://github.com/EliteMasterEric/jsonpath"
+ },
{
"name": "lime",
"type": "git",
"dir": null,
- "ref": "872ff6db2f2d27c0243d4ff76802121ded550dd7",
+ "ref": "fe3368f611a84a19afc03011353945ae4da8fffd",
"url": "https://github.com/FunkinCrew/lime"
},
{
@@ -160,29 +187,29 @@
"name": "openfl",
"type": "git",
"dir": null,
- "ref": "228c1b5063911e2ad75cef6e3168ef0a4b9f9134",
+ "ref": "8306425c497766739510ab29e876059c96f77bd2",
"url": "https://github.com/FunkinCrew/openfl"
},
{
"name": "polymod",
"type": "git",
"dir": null,
- "ref": "bfbe30d81601b3543d80dce580108ad6b7e182c7",
+ "ref": "0fbdf27fe124549730accd540cec8a183f8652c0",
"url": "https://github.com/larsiusprime/polymod"
},
{
"name": "thx.core",
"type": "git",
"dir": null,
- "ref": "22605ff44f01971d599641790d6bae4869f7d9f4",
- "url": "https://github.com/FunkinCrew/thx.core"
+ "ref": "76d87418fadd92eb8e1b61f004cff27d656e53dd",
+ "url": "https://github.com/fponticelli/thx.core"
},
{
"name": "thx.semver",
"type": "git",
"dir": null,
- "ref": "cf8d213589a2c7ce4a59b0fdba9e8ff36bc029fa",
- "url": "https://github.com/FunkinCrew/thx.semver"
+ "ref": "bdb191fe7cf745c02a980749906dbf22719e200b",
+ "url": "https://github.com/fponticelli/thx.semver"
}
]
}
diff --git a/project.hxp b/project.hxp
new file mode 100644
index 000000000..1193a9cd4
--- /dev/null
+++ b/project.hxp
@@ -0,0 +1,1109 @@
+package;
+
+// I don't think we can import `funkin` classes here. Macros? Recursion? IDK.
+import hxp.*;
+import lime.tools.*;
+import sys.FileSystem;
+
+using StringTools;
+
+/**
+ * This HXP performs the functions of a Lime `project.xml` file,
+ * but it's written in Haxe rather than XML!
+ *
+ * This makes it far easier to organize, reuse, and refactor,
+ * and improves management of feature flag logic.
+ */
+@:nullSafety
+class Project extends HXProject {
+ //
+ // METADATA
+ //
+
+ /**
+ * The game's version number, as a Semantic Versioning string with no prefix.
+ * REMEMBER TO CHANGE THIS WHEN THE GAME UPDATES!
+ * You only have to change it here, the rest of the game will query this value.
+ */
+ static final VERSION:String = "0.5.0";
+
+ /**
+ * The game's name. Used as the default window title.
+ */
+ static final TITLE:String = "Friday Night Funkin'";
+
+ /**
+ * The name of the generated executable file.
+ * For example, `"Funkin"` will create a file called `Funkin.exe`.
+ */
+ static final EXECUTABLE_NAME:String = "Funkin";
+
+ /**
+ * The relative location of the source code.
+ */
+ static final SOURCE_DIR:String = "source";
+
+ /**
+ * The fully qualified class path for the game's preloader.
+ * Particularly important on HTML5 but we use it on all platforms.
+ */
+ static final PRELOADER:String = "funkin.ui.transition.preload.FunkinPreloader";
+
+ /**
+ * A package name used for identifying the app on various app stores.
+ */
+ static final PACKAGE_NAME:String = "me.funkin.fnf";
+
+ /**
+ * The fully qualified class path for the entry point class to execute when launching the game.
+ * It's where `public static function main():Void` goes.
+ */
+ static final MAIN_CLASS:String = "Main";
+
+ /**
+ * The company name for the game.
+ * This appears in metadata in places I think.
+ */
+ static final COMPANY:String = "The Funkin' Crew";
+
+ /**
+ * Path to the Haxe script run before building the game.
+ */
+ static final PREBUILD_HX:String = "source/Prebuild.hx";
+
+ /**
+ * Path to the Haxe script run after building the game.
+ */
+ static final POSTBUILD_HX:String = "source/Postbuild.hx";
+
+ /**
+ * Asset path globs to always exclude from asset libraries.
+ */
+ static final EXCLUDE_ASSETS:Array = [".*", "cvs", "thumbs.db", "desktop.ini", "*.hash", "*.md"];
+
+ /**
+ * Asset path globs to exclude on web platforms.
+ */
+ static final EXCLUDE_ASSETS_WEB:Array = ["*.ogg"];
+ /**
+ * Asset path globs to exclude on native platforms.
+ */
+ static final EXCLUDE_ASSETS_NATIVE:Array = ["*.mp3"];
+
+ //
+ // FEATURE FLAGS
+ // Inverse feature flags are automatically populated.
+ //
+
+ /**
+ * `-DGITHUB_BUILD`
+ * If this flag is enabled, the game will use the configuration used by GitHub Actions
+ * to generate playtest builds to be pushed to the launcher.
+ *
+ * This is generally used to forcibly enable debugging features,
+ * even when the game is built in release mode for performance reasons.
+ */
+ static final GITHUB_BUILD:FeatureFlag = "GITHUB_BUILD";
+
+ /**
+ * `-DREDIRECT_ASSETS_FOLDER`
+ * If this flag is enabled, the game will redirect the `assets` folder from the `export` folder
+ * to the `assets` folder at the root of the workspace.
+ * This is useful for ensuring hot reloaded changes don't get lost when rebuilding the game.
+ */
+ static final REDIRECT_ASSETS_FOLDER:FeatureFlag = "REDIRECT_ASSETS_FOLDER";
+
+ /**
+ * `-DTOUCH_HERE_TO_PLAY`
+ * If this flag is enabled, the game will display a prompt to the user after the preloader completes,
+ * requiring them to click anywhere on the screen to start the game.
+ * This is done to ensure that the audio context can initialize properly on HTML5. Not necessary on desktop.
+ */
+ static final TOUCH_HERE_TO_PLAY:FeatureFlag = "TOUCH_HERE_TO_PLAY";
+
+ /**
+ * `-DPRELOAD_ALL`
+ * Whether to preload all asset libraries.
+ * Disabled on web, enabled on desktop.
+ */
+ static final PRELOAD_ALL:FeatureFlag = "PRELOAD_ALL";
+
+ /**
+ * `-DEMBED_ASSETS`
+ * Whether to embed all asset libraries into the executable.
+ */
+ static final EMBED_ASSETS:FeatureFlag = "EMBED_ASSETS";
+
+ /**
+ * `-DHARDCODED_CREDITS`
+ * If this flag is enabled, the credits will be parsed and encoded in the game at compile time,
+ * rather than read from JSON data at runtime.
+ */
+ static final HARDCODED_CREDITS:FeatureFlag = "HARDCODED_CREDITS";
+
+ /**
+ * `-DFEATURE_DEBUG_FUNCTIONS`
+ * If this flag is enabled, the game will have all playtester-only debugging functionality enabled.
+ * This includes debug hotkeys like time travel in the Play State.
+ * By default, enabled on debug builds or playtester builds and disabled on release builds.
+ */
+ static final FEATURE_DEBUG_FUNCTIONS:FeatureFlag = "FEATURE_DEBUG_FUNCTIONS";
+
+ /**
+ * `-DFEATURE_DISCORD_RPC`
+ * If this flag is enabled, the game will enable the Discord Remote Procedure Call library.
+ * This is used to provide Discord Rich Presence support.
+ */
+ static final FEATURE_DISCORD_RPC:FeatureFlag = "FEATURE_DISCORD_RPC";
+
+ /**
+ * `-DFEATURE_NEWGROUNDS`
+ * If this flag is enabled, the game will enable the Newgrounds library.
+ * This is used to provide Medal and Leaderboard support.
+ */
+ static final FEATURE_NEWGROUNDS:FeatureFlag = "FEATURE_NEWGROUNDS";
+
+ /**
+ * `-DFEATURE_FUNKVIS`
+ * If this flag is enabled, the game will enable the Funkin Visualizer library.
+ * This is used to provide audio visualization like Nene's speaker.
+ * Disabling this will make some waveforms inactive.
+ */
+ static final FEATURE_FUNKVIS:FeatureFlag = "FEATURE_FUNKVIS";
+
+ /**
+ * `-DFEATURE_PARTIAL_SOUNDS`
+ * If this flag is enabled, the game will enable the FlxPartialSound library.
+ * This is used to provide audio previews in Freeplay.
+ * Disabling this will make those previews not play.
+ */
+ static final FEATURE_PARTIAL_SOUNDS:FeatureFlag = "FEATURE_PARTIAL_SOUNDS";
+
+ /**
+ * `-DFEATURE_VIDEO_PLAYBACK`
+ * If this flag is enabled, the game will enable support for video playback.
+ * This requires the hxCodec library on desktop platforms.
+ */
+ static final FEATURE_VIDEO_PLAYBACK:FeatureFlag = "FEATURE_VIDEO_PLAYBACK";
+
+ /**
+ * `-DFEATURE_FILE_DROP`
+ * If this flag is enabled, the game will support dragging and dropping files onto it for various features.
+ * Disabled on MacOS.
+ */
+ static final FEATURE_FILE_DROP:FeatureFlag = "FEATURE_FILE_DROP";
+
+ /**
+ * `-DFEATURE_OPEN_URL`
+ * If this flag is enabled, the game will support opening URLs (such as the merch page).
+ */
+ static final FEATURE_OPEN_URL:FeatureFlag = "FEATURE_OPEN_URL";
+
+ /**
+ * `-DFEATURE_CHART_EDITOR`
+ * If this flag is enabled, the Chart Editor will be accessible from the debug menu.
+ */
+ static final FEATURE_CHART_EDITOR:FeatureFlag = "FEATURE_CHART_EDITOR";
+
+ /**
+ * `-DFEATURE_STAGE_EDITOR`
+ * If this flag is enabled, the Stage Editor will be accessible from the debug menu.
+ */
+ static final FEATURE_STAGE_EDITOR:FeatureFlag = "FEATURE_STAGE_EDITOR";
+
+ /**
+ * `-DFEATURE_POLYMOD_MODS`
+ * If this flag is enabled, the game will enable the Polymod library's support for atomic mod loading from the `./mods` folder.
+ * If this flag is disabled, no mods will be loaded.
+ */
+ static final FEATURE_POLYMOD_MODS:FeatureFlag = "FEATURE_POLYMOD_MODS";
+
+ /**
+ * `-DFEATURE_GHOST_TAPPING`
+ * If this flag is enabled, misses will not be counted when it is not the player's turn.
+ * Misses are still counted when the player has notes to hit.
+ */
+ static final FEATURE_GHOST_TAPPING:FeatureFlag = "FEATURE_GHOST_TAPPING";
+
+ //
+ // CONFIGURATION FUNCTIONS
+ //
+
+ public function new() {
+ super();
+
+ flair();
+ configureApp();
+
+ displayTarget();
+ configureFeatureFlags();
+ configureCompileDefines();
+ configureIncludeMacros();
+ configureCustomMacros();
+ configureOutputDir();
+ configurePolymod();
+ configureHaxelibs();
+ configureAssets();
+ configureIcons();
+ }
+
+ /**
+ * Do something before building, display some ASCII or something IDK
+ */
+ function flair() {
+ // TODO: Implement this.
+ info("Friday Night Funkin'");
+ info("Initializing build...");
+
+ info("Target Version: " + VERSION);
+ info("Git Branch: " + getGitBranch());
+ info("Git Commit: " + getGitCommit());
+ info("Git Modified? " + getGitModified());
+ info("Display? " + isDisplay());
+ }
+
+ /**
+ * Apply basic project metadata, such as the game title and version number,
+ * as well as info like the package name and company (used by various app stores).
+ */
+ function configureApp() {
+ this.meta.title = TITLE;
+ this.meta.version = VERSION;
+ this.meta.packageName = PACKAGE_NAME;
+ this.meta.company = COMPANY;
+
+ this.app.main = MAIN_CLASS;
+ this.app.file = EXECUTABLE_NAME;
+ this.app.preloader = PRELOADER;
+
+ // Tell Lime where to look for the game's source code.
+ // If for some reason we have multiple source directories, we can add more entries here.
+ this.sources.push(SOURCE_DIR);
+
+ // Tell Lime to run some prebuild and postbuild scripts.
+ this.preBuildCallbacks.push(buildHaxeCLICommand(PREBUILD_HX));
+ this.postBuildCallbacks.push(buildHaxeCLICommand(POSTBUILD_HX));
+
+ // TODO: Should we provide this?
+ // this.meta.buildNumber = 0;
+
+ // These values are only used by the SWF target I think.
+ // this.app.path
+ // this.app.init
+ // this.app.swfVersion
+ // this.app.url
+
+ // These values are only used by... FIREFOX MARKETPLACE WHAT?
+ // this.meta.description = "";
+ // this.meta.companyId = COMPANY;
+ // this.meta.companyUrl = COMPANY;
+
+ // Configure the window.
+ // Automatically configure FPS.
+ this.window.fps = 60;
+ // Set the window size.
+ this.window.width = 1280;
+ this.window.height = 720;
+ // Black background on release builds, magenta on debug builds.
+ this.window.background = FEATURE_DEBUG_FUNCTIONS.isEnabled(this) ? 0xFFFF00FF : 0xFF000000;
+
+ this.window.hardware = true;
+ this.window.vsync = false;
+
+ if (isWeb()) {
+ this.window.resizable = true;
+ }
+
+ if (isDesktop()) {
+ this.window.orientation = Orientation.LANDSCAPE;
+ this.window.fullscreen = false;
+ this.window.resizable = true;
+ this.window.vsync = false;
+ }
+
+ if (isMobile()) {
+ this.window.orientation = Orientation.LANDSCAPE;
+ this.window.fullscreen = false;
+ this.window.resizable = false;
+ this.window.width = 0;
+ this.window.height = 0;
+ }
+ }
+
+ /**
+ * Log information about the configured target platform.
+ */
+ function displayTarget() {
+ // Display the target operating system.
+ switch (this.target) {
+ case Platform.WINDOWS:
+ info('Target Platform: Windows');
+ case Platform.MAC:
+ info('Target Platform: MacOS');
+ case Platform.LINUX:
+ info('Target Platform: Linux');
+ case Platform.ANDROID:
+ info('Target Platform: Android');
+ case Platform.IOS:
+ info('Target Platform: IOS');
+ case Platform.HTML5:
+ info('Target Platform: HTML5');
+ // See lime.tools.Platform for a full list.
+ // case Platform.EMSCRITEN: // A WebAssembly build might be interesting...
+ // case Platform.AIR:
+ // case Platform.BLACKBERRY:
+ // case Platform.CONSOLE_PC:
+ // case Platform.FIREFOX:
+ // case Platform.FLASH:
+ // case Platform.PS3:
+ // case Platform.PS4:
+ // case Platform.TIZEN:
+ // case Platform.TVOS:
+ // case Platform.VITA:
+ // case Platform.WEBOS:
+ // case Platform.WIIU:
+ // case Platform.XBOX1:
+ default:
+ error('Unsupported platform (got ${target})');
+ }
+
+ switch (this.platformType) {
+ case PlatformType.DESKTOP:
+ info('Platform Type: Desktop');
+ case PlatformType.MOBILE:
+ info('Platform Type: Mobile');
+ case PlatformType.WEB:
+ info('Platform Type: Web');
+ case PlatformType.CONSOLE:
+ info('Platform Type: Console');
+ default:
+ error('Unknown platform type (got ${platformType})');
+ }
+
+ // Print whether we are using HXCPP, HashLink, or something else.
+ if (isWeb()) {
+ info('Target Language: JavaScript (HTML5)');
+ } else if (isHashLink()) {
+ info('Target Language: HashLink');
+ } else if (isNeko()) {
+ info('Target Language: Neko');
+ } else if (isJava()) {
+ info('Target Language: Java');
+ } else if (isNodeJS()) {
+ info('Target Language: JavaScript (NodeJS)');
+ } else if (isCSharp()) {
+ info('Target Language: C#');
+ } else {
+ info('Target Language: C++');
+ }
+
+ for (arch in this.architectures) {
+ // Display the list of target architectures.
+ switch (arch) {
+ case Architecture.X86:
+ info('Architecture: x86');
+ case Architecture.X64:
+ info('Architecture: x64');
+ case Architecture.ARMV5:
+ info('Architecture: ARMv5');
+ case Architecture.ARMV6:
+ info('Architecture: ARMv6');
+ case Architecture.ARMV7:
+ info('Architecture: ARMv7');
+ case Architecture.ARMV7S:
+ info('Architecture: ARMv7S');
+ case Architecture.ARM64:
+ info('Architecture: ARMx64');
+ case Architecture.MIPS:
+ info('Architecture: MIPS');
+ case Architecture.MIPSEL:
+ info('Architecture: MIPSEL');
+ case null:
+ if (!isWeb()) {
+ error('Unsupported architecture (got null on non-web platform)');
+ } else {
+ info('Architecture: Web');
+ }
+ default:
+ error('Unsupported architecture (got ${arch})');
+ }
+ }
+ }
+
+ /**
+ * Apply various feature flags based on the target platform and the user-provided build flags.
+ */
+ function configureFeatureFlags() {
+ // You can explicitly override any of these.
+ // For example, `-DGITHUB_BUILD` or `-DNO_HARDCODED_CREDITS`
+
+ // Should be false unless explicitly requested.
+ GITHUB_BUILD.apply(this, false);
+ FEATURE_STAGE_EDITOR.apply(this, false);
+ FEATURE_NEWGROUNDS.apply(this, false);
+ FEATURE_GHOST_TAPPING.apply(this, false);
+
+ // Should be true unless explicitly requested.
+ HARDCODED_CREDITS.apply(this, true);
+ FEATURE_OPEN_URL.apply(this, true);
+ FEATURE_POLYMOD_MODS.apply(this, true);
+ FEATURE_FUNKVIS.apply(this, true);
+ FEATURE_PARTIAL_SOUNDS.apply(this, true);
+ FEATURE_VIDEO_PLAYBACK.apply(this, true);
+
+ // Should be true on debug builds or if GITHUB_BUILD is enabled.
+ FEATURE_DEBUG_FUNCTIONS.apply(this, isDebug() || GITHUB_BUILD.isEnabled(this));
+
+ // Should default to true on workspace builds and false on release builds.
+ REDIRECT_ASSETS_FOLDER.apply(this, isDebug() && isDesktop());
+
+ // Should be true on release, non-tester builds.
+ // We don't want testers to accidentally leak songs to their Discord friends!
+ // TODO: Re-enable this.
+ FEATURE_DISCORD_RPC.apply(this, false && !FEATURE_DEBUG_FUNCTIONS.isEnabled(this));
+
+ // Should be true only on web builds.
+ // Audio context issues only exist there.
+ TOUCH_HERE_TO_PLAY.apply(this, isWeb());
+
+ // Should be true only on web builds.
+ // Enabling embedding and preloading is required to preload assets properly.
+ EMBED_ASSETS.apply(this, isWeb());
+ PRELOAD_ALL.apply(this, !isWeb());
+
+ // Should be true except on MacOS.
+ // File drop doesn't work there.
+ FEATURE_FILE_DROP.apply(this, !isMac());
+
+ // Should be true except on web builds.
+ // Chart editor doesn't work there.
+ FEATURE_CHART_EDITOR.apply(this, !isWeb());
+ }
+
+ /**
+ * Set compilation flags which are not feature flags.
+ */
+ function configureCompileDefines() {
+ // Enable OpenFL's error handler. Required for the crash logger.
+ setHaxedef("openfl-enable-handle-error");
+
+ // Enable stack trace tracking. Good for debugging but has a (minor) performance impact.
+ setHaxedef("HXCPP_CHECK_POINTER");
+ setHaxedef("HXCPP_STACK_LINE");
+ setHaxedef("HXCPP_STACK_TRACE");
+ setHaxedef("hscriptPos");
+
+ setHaxedef("safeMode");
+
+ // If we aren't using the Flixel debugger, strip it out.
+ if (FEATURE_DEBUG_FUNCTIONS.isDisabled(this)) {
+ setHaxedef("FLX_NO_DEBUG");
+ }
+
+ // Disable the built in pause screen when unfocusing the game.
+ setHaxedef("FLX_NO_FOCUS_LOST_SCREEN");
+
+ // HaxeUI configuration.
+ setHaxedef("haxeui_no_mouse_reset");
+ setHaxedef("haxeui_focus_out_on_click"); // Unfocus a dialog when clicking out of it
+ setHaxedef("haxeui_dont_impose_base_class"); // Suppress a macro error
+
+ if (isRelease()) {
+ // Improve performance on Nape
+ // TODO: Do we even use Nape?
+ setHaxedef("NAPE_RELEASE_BUILD");
+ }
+
+ // Cleaner looking compiler errors.
+ setHaxedef("message.reporting", "pretty");
+ }
+
+ /**
+ * Set compilation flags which manage dead code elimination.
+ */
+ function configureIncludeMacros() {
+ // Disable dead code elimination.
+ // This prevents functions that are unused by the base game from being unavailable to HScript.
+ addHaxeFlag("-dce no");
+
+ // Forcibly include all Funkin' classes in builds.
+ // This prevents classes that are unused by the base game from being unavailable to HScript.
+ addHaxeMacro("include('funkin')");
+
+ // Ensure all HaxeUI components are available at runtime.
+ addHaxeMacro("include('haxe.ui.backend.flixel.components')");
+ addHaxeMacro("include('haxe.ui.core')");
+ addHaxeMacro("include('haxe.ui.components')");
+ addHaxeMacro("include('haxe.ui.containers')");
+ addHaxeMacro("include('haxe.ui.containers.dialogs')");
+ addHaxeMacro("include('haxe.ui.containers.menus')");
+ addHaxeMacro("include('haxe.ui.containers.properties')");
+
+ // Ensure all Flixel classes are available at runtime.
+ // Explicitly ignore packages which require additional dependencies.
+ addHaxeMacro("include('flixel', true, [ 'flixel.addons.editors.spine.*', 'flixel.addons.nape.*', 'flixel.system.macros.*' ])");
+ }
+
+ /**
+ * Set compilation flags which manage bespoke build-time macros.
+ */
+ function configureCustomMacros() {
+ // This macro allows addition of new functionality to existing Flixel. -->
+ addHaxeMacro("addMetadata('@:build(funkin.util.macro.FlxMacro.buildFlxBasic())', 'flixel.FlxBasic')");
+ }
+
+ function configureOutputDir() {
+ // Set the output directory. Depends on the target platform and build type.
+
+ var buildDir = 'export/${isDebug() ? 'debug' : 'release'}/';
+
+ info('Output directory: $buildDir');
+ // setenv('BUILD_DIR', buildDir);
+ app.path = buildDir;
+ }
+
+ function configurePolymod() {
+ // The file extension to use for script files.
+ setHaxedef("POLYMOD_SCRIPT_EXT", ".hscript");
+ // Which asset library to use for scripts.
+ setHaxedef("POLYMOD_SCRIPT_LIBRARY", "scripts");
+ // The base path from which scripts should be accessed.
+ setHaxedef("POLYMOD_ROOT_PATH", "scripts/");
+ // Determines the subdirectory of the mod folder used for file appending.
+ setHaxedef("POLYMOD_APPEND_FOLDER", "_append");
+ // Determines the subdirectory of the mod folder used for file merges.
+ setHaxedef("POLYMOD_MERGE_FOLDER", "_merge");
+ // Determines the file in the mod folder used for metadata.
+ setHaxedef("POLYMOD_MOD_METADATA_FILE", "_polymod_meta.json");
+ // Determines the file in the mod folder used for the icon.
+ setHaxedef("POLYMOD_MOD_ICON_FILE", "_polymod_icon.png");
+
+ if (isDebug()) {
+ // Turns on additional debug logging.
+ setHaxedef("POLYMOD_DEBUG");
+ }
+ }
+
+ function configureHaxelibs() {
+ // Don't enforce
+ addHaxelib('lime'); // Game engine backend
+ addHaxelib('openfl'); // Game engine backend
+
+ addHaxelib('flixel'); // Game engine
+
+ addHaxelib('flixel-addons'); // Additional utilities for Flixel
+ addHaxelib('hscript'); // Scripting
+ // addHaxelib('flixel-ui'); // UI framework (DEPRECATED)
+ addHaxelib('haxeui-core'); // UI framework
+ addHaxelib('haxeui-flixel'); // Integrate HaxeUI with Flixel
+ addHaxelib('flixel-text-input'); // Improved text field rendering for HaxeUI
+
+ addHaxelib('polymod'); // Modding framework
+ addHaxelib('flxanimate'); // Texture atlas rendering
+
+ addHaxelib('json2object'); // JSON parsing
+ addHaxelib('jsonpath'); // JSON parsing
+ addHaxelib('jsonpatch'); // JSON parsing
+ addHaxelib('thx.core'); // General utility library, "the lodash of Haxe"
+ addHaxelib('thx.semver'); // Version string handling
+
+ if (isDebug()) {
+ addHaxelib('hxcpp-debug-server'); // VSCode debug support
+ }
+
+ if (isDesktop() && !isHashLink() && FEATURE_VIDEO_PLAYBACK.isEnabled(this)) {
+ // hxCodec doesn't function on HashLink or non-desktop platforms
+ // It's also unnecessary if video playback is disabled
+ addHaxelib('hxCodec'); // Video playback
+ }
+
+ if (FEATURE_DISCORD_RPC.isEnabled(this)) {
+ addHaxelib('discord_rpc'); // Discord API
+ }
+
+ if (FEATURE_NEWGROUNDS.isEnabled(this)) {
+ addHaxelib('newgrounds'); // Newgrounds API
+ }
+
+ if (FEATURE_FUNKVIS.isEnabled(this)) {
+ addHaxelib('funkin.vis'); // Audio visualization
+ addHaxelib('grig.audio'); // Audio data utilities
+ }
+
+ if (FEATURE_PARTIAL_SOUNDS.isEnabled(this)) {
+ addHaxelib('FlxPartialSound'); // Partial sound
+ }
+ }
+
+ function configureAssets() {
+ var exclude = EXCLUDE_ASSETS.concat(isWeb() ? EXCLUDE_ASSETS_WEB : EXCLUDE_ASSETS_NATIVE);
+ var shouldPreload = PRELOAD_ALL.isEnabled(this);
+ var shouldEmbed = EMBED_ASSETS.isEnabled(this);
+
+ if (shouldEmbed) {
+ info('Embedding assets into executable...');
+ } else {
+ info('Including assets alongside executable...');
+ }
+
+ // Default asset library
+ var shouldPreloadDefault = true;
+ addAssetLibrary("default", shouldEmbed, shouldPreloadDefault);
+ addAssetPath("assets/preload", "assets", "default", ["*"], exclude, shouldEmbed);
+
+ // Font assets
+ var shouldEmbedFonts = true;
+ addAssetPath("assets/fonts", null, "default", ["*"], exclude, shouldEmbedFonts);
+
+ // Shared asset libraries
+ addAssetLibrary("songs", shouldEmbed, shouldPreload);
+ addAssetPath("assets/songs", "assets/songs", "songs", ["*"], exclude, shouldEmbed);
+ addAssetLibrary("shared", shouldEmbed, shouldPreload);
+ addAssetPath("assets/shared", "assets/shared", "shared", ["*"], exclude, shouldEmbed);
+ if (FEATURE_VIDEO_PLAYBACK.isEnabled(this)) {
+ var shouldEmbedVideos = false;
+ addAssetLibrary("videos", shouldEmbedVideos, shouldPreload);
+ addAssetPath("assets/videos", "assets/videos", "videos", ["*"], exclude, shouldEmbedVideos);
+ }
+
+ // Level asset libraries
+ addAssetLibrary("tutorial", shouldEmbed, shouldPreload);
+ addAssetPath("assets/tutorial", "assets/tutorial", "tutorial", ["*"], exclude, shouldEmbed);
+ addAssetLibrary("week1", shouldEmbed, shouldPreload);
+ addAssetPath("assets/week1", "assets/week1", "week1", ["*"], exclude, shouldEmbed);
+ addAssetLibrary("week2", shouldEmbed, shouldPreload);
+ addAssetPath("assets/week2", "assets/week2", "week2", ["*"], exclude, shouldEmbed);
+ addAssetLibrary("week3", shouldEmbed, shouldPreload);
+ addAssetPath("assets/week3", "assets/week3", "week3", ["*"], exclude, shouldEmbed);
+ addAssetLibrary("week4", shouldEmbed, shouldPreload);
+ addAssetPath("assets/week4", "assets/week4", "week4", ["*"], exclude, shouldEmbed);
+ addAssetLibrary("week5", shouldEmbed, shouldPreload);
+ addAssetPath("assets/week5", "assets/week5", "week5", ["*"], exclude, shouldEmbed);
+ addAssetLibrary("week6", shouldEmbed, shouldPreload);
+ addAssetPath("assets/week6", "assets/week6", "week6", ["*"], exclude, shouldEmbed);
+ addAssetLibrary("week7", shouldEmbed, shouldPreload);
+ addAssetPath("assets/week7", "assets/week7", "week7", ["*"], exclude, shouldEmbed);
+ addAssetLibrary("weekend1", shouldEmbed, shouldPreload);
+ addAssetPath("assets/weekend1", "assets/weekend1", "weekend1", ["*"], exclude, shouldEmbed);
+
+ // Art asset library (where README and CHANGELOG pull from)
+ var shouldEmbedArt = false;
+ var shouldPreloadArt = false;
+ addAssetLibrary("art", shouldEmbedArt, shouldPreloadArt);
+ addAsset("art/readme.txt", "do NOT readme.txt", "art", shouldEmbedArt);
+ addAsset("LICENSE.md", "LICENSE.md", "art", shouldEmbedArt);
+ addAsset("CHANGELOG.md", "CHANGELOG.md", "art", shouldEmbedArt);
+ }
+
+ /**
+ * Configure the application's favicon and executable icon.
+ */
+ function configureIcons() {
+ addIcon("art/icon16.png", 16);
+ addIcon("art/icon32.png", 32);
+ addIcon("art/icon64.png", 64);
+ addIcon("art/iconOG.png");
+ }
+
+ //
+ // HELPER FUNCTIONS
+ // Easy functions to make the code more readable.
+ //
+
+ public function isWeb():Bool {
+ return this.platformType == PlatformType.WEB;
+ }
+
+ public function isMobile():Bool {
+ return this.platformType == PlatformType.MOBILE;
+ }
+
+ public function isDesktop():Bool {
+ return this.platformType == PlatformType.DESKTOP;
+ }
+
+ public function isConsole():Bool {
+ return this.platformType == PlatformType.CONSOLE;
+ }
+
+ public function is32Bit():Bool {
+ return this.architectures.contains(Architecture.X86);
+ }
+
+ public function is64Bit():Bool {
+ return this.architectures.contains(Architecture.X64);
+ }
+
+ public function isWindows():Bool {
+ return this.target == Platform.WINDOWS;
+ }
+
+ public function isMac():Bool {
+ return this.target == Platform.MAC;
+ }
+
+ public function isLinux():Bool {
+ return this.target == Platform.LINUX;
+ }
+
+ public function isAndroid():Bool {
+ return this.target == Platform.ANDROID;
+ }
+
+ public function isIOS():Bool {
+ return this.target == Platform.IOS;
+ }
+
+ public function isHashLink():Bool {
+ return this.targetFlags.exists("hl");
+ }
+
+ public function isNeko():Bool {
+ return this.targetFlags.exists("neko");
+ }
+
+ public function isJava():Bool {
+ return this.targetFlags.exists("java");
+ }
+
+ public function isNodeJS():Bool {
+ return this.targetFlags.exists("nodejs");
+ }
+
+ public function isCSharp():Bool {
+ return this.targetFlags.exists("cs");
+ }
+
+ public function isDisplay():Bool {
+ return this.command == "display";
+ }
+
+ public function isDebug():Bool {
+ return this.debug;
+ }
+
+ public function isRelease():Bool {
+ return !isDebug();
+ }
+
+ public function getHaxedef(name:String):Null {
+ return this.haxedefs.get(name);
+ }
+
+ public function setHaxedef(name:String, ?value:String):Void {
+ if (value == null) value = "";
+
+ this.haxedefs.set(name, value);
+ }
+
+ public function unsetHaxedef(name:String):Void {
+ this.haxedefs.remove(name);
+ }
+
+ public function getDefine(name:String):Null {
+ return this.defines.get(name);
+ }
+
+ public function hasDefine(name:String):Bool {
+ return this.defines.exists(name);
+ }
+
+ /**
+ * Add a library to the list of dependencies for the project.
+ * @param name The name of the library to add.
+ * @param version The version of the library to add. Optional.
+ */
+ public function addHaxelib(name:String, version:String = ""):Void {
+ this.haxelibs.push(new Haxelib(name, version));
+ }
+
+ /**
+ * Add a `haxeflag` to the project.
+ */
+ public function addHaxeFlag(value:String):Void {
+ this.haxeflags.push(value);
+ }
+
+ /**
+ * Call a Haxe build macro.
+ */
+ public function addHaxeMacro(value:String):Void {
+ addHaxeFlag('--macro ${value}');
+ }
+
+ /**
+ * Add an icon to the project.
+ * @param icon The path to the icon.
+ * @param size The size of the icon. Optional.
+ */
+ public function addIcon(icon:String, ?size:Int):Void {
+ this.icons.push(new Icon(icon, size));
+ }
+
+ /**
+ * Add an asset to the game build.
+ * @param path The path the asset is located at.
+ * @param rename The path the asset should be placed.
+ * @param library The asset library to add the asset to. `null` = "default"
+ * @param embed Whether to embed the asset in the executable.
+ */
+ public function addAsset(path:String, ?rename:String, ?library:String, embed:Bool = false):Void {
+ // path, rename, type, embed, setDefaults
+ var asset = new Asset(path, rename, null, embed, true);
+ @:nullSafety(Off)
+ {
+ asset.library = library ?? "default";
+ }
+ this.assets.push(asset);
+ }
+
+ /**
+ * Add an entire path of assets to the game build.
+ * @param path The path the assets are located at.
+ * @param rename The path the assets should be placed.
+ * @param library The asset library to add the assets to. `null` = "default"
+ * @param include An optional array to include specific asset names.
+ * @param exclude An optional array to exclude specific asset names.
+ * @param embed Whether to embed the assets in the executable.
+ */
+ public function addAssetPath(path:String, ?rename:String, library:String, ?include:Array, ?exclude:Array, embed:Bool = false):Void {
+ // Argument parsing.
+ if (path == "") return;
+
+ if (include == null) include = [];
+
+ if (exclude == null) exclude = [];
+
+ var targetPath = rename ?? path;
+ if (targetPath != "") targetPath += "/";
+
+ // Validate path.
+ if (!sys.FileSystem.exists(path)) {
+ error('Could not find asset path "${path}".');
+ } else if (!sys.FileSystem.isDirectory(path)) {
+ error('Could not parse asset path "${path}", expected a directory.');
+ } else {
+ // info(' Found asset path "${path}".');
+ }
+
+ for (file in sys.FileSystem.readDirectory(path)) {
+ if (sys.FileSystem.isDirectory('${path}/${file}')) {
+ // Attempt to recursively add all assets in the directory.
+ if (this.filter(file, ["*"], exclude)) {
+ addAssetPath('${path}/${file}', '${targetPath}${file}', library, include, exclude, embed);
+ }
+ } else {
+ if (this.filter(file, include, exclude)) {
+ addAsset('${path}/${file}', '${targetPath}${file}', library, embed);
+ }
+ }
+ }
+ }
+
+ /**
+ * Add an asset library to the game build.
+ * @param name The name of the library.
+ * @param embed
+ * @param preload
+ */
+ public function addAssetLibrary(name:String, embed:Bool = false, preload:Bool = false):Void {
+ // sourcePath, name, type, embed, preload, generate, prefix
+ var sourcePath = '';
+ this.libraries.push(new Library(sourcePath, name, null, embed, preload, false, ""));
+ }
+
+ //
+ // PROCESS FUNCTIONS
+ //
+
+ /**
+ * A CLI command to run a command in the shell.
+ */
+ public function buildCLICommand(cmd:String):CLICommand {
+ return CommandHelper.fromSingleString(cmd);
+ }
+
+ /**
+ * A CLI command to run a Haxe script via `--interp`.
+ */
+ public function buildHaxeCLICommand(path:String):CLICommand {
+ return CommandHelper.interpretHaxe(path);
+ }
+
+ public function getGitCommit():String {
+ // Cannibalized from GitCommit.hx
+ var process = new sys.io.Process('git', ['rev-parse', 'HEAD']);
+ if (process.exitCode() != 0) {
+ var message = process.stderr.readAll().toString();
+ error('[ERROR] Could not determine current git commit; is this a proper Git repository?');
+ }
+
+ var commitHash:String = process.stdout.readLine();
+ var commitHashSplice:String = commitHash.substr(0, 7);
+
+ return commitHashSplice;
+ }
+
+ public function getGitBranch():String {
+ // Cannibalized from GitCommit.hx
+ var branchProcess = new sys.io.Process('git', ['rev-parse', '--abbrev-ref', 'HEAD']);
+
+ if (branchProcess.exitCode() != 0) {
+ var message = branchProcess.stderr.readAll().toString();
+ error('Could not determine current git branch; is this a proper Git repository?');
+ }
+
+ var branchName:String = branchProcess.stdout.readLine();
+
+ return branchName;
+ }
+
+ public function getGitModified():Bool {
+ var branchProcess = new sys.io.Process('git', ['status', '--porcelain']);
+
+ if (branchProcess.exitCode() != 0) {
+ var message = branchProcess.stderr.readAll().toString();
+ error('Could not determine current git status; is this a proper Git repository?');
+ }
+
+ var output:String = '';
+ try {
+ output = branchProcess.stdout.readLine();
+ } catch (e) {
+ if (e.message == 'Eof') {
+ // Do nothing.
+ // Eof = No output.
+ } else {
+ // Rethrow other exceptions.
+ throw e;
+ }
+ }
+
+ return output.length > 0;
+ }
+
+ //
+ // LOGGING FUNCTIONS
+ //
+
+ /**
+ * Display an error message. This should stop the build process.
+ */
+ public function error(message:String):Void {
+ Log.error('${message}');
+ }
+
+ /**
+ * Display an info message. This should not interfere with the build process.
+ */
+ public function info(message:String):Void {
+ // CURSED: We have to disable info() log calls because of a bug.
+ // https://github.com/haxelime/lime-vscode-extension/issues/88
+
+ // Log.info('[INFO] ${message}');
+
+ // trace(message);
+ // Sys.println(message);
+ // Sys.stdout().writeString(message);
+ // Sys.stderr().writeString(message);
+ }
+}
+
+/**
+ * An object representing a feature flag, which can be enabled or disabled.
+ * Includes features such as automatic generation of compile defines and inversion.
+ */
+abstract FeatureFlag(String) {
+ static final INVERSE_PREFIX:String = "NO_";
+
+ public function new(input:String) {
+ this = input;
+ }
+
+ @:from
+ public static function fromString(input:String):FeatureFlag {
+ return new FeatureFlag(input);
+ }
+
+ /**
+ * Enable/disable a feature flag if it is unset, and handle the inverse flag.
+ * Doesn't override a feature flag that was set explicitly.
+ * @param enableByDefault Whether to enable this feature flag if it is unset.
+ */
+ public function apply(project:Project, enableByDefault:Bool = false):Void {
+ // TODO: Name this function better?
+
+ if (isEnabled(project)) {
+ // If this flag was already enabled, disable the inverse.
+ project.info('Enabling feature flag ${this}');
+ getInverse().disable(project, false);
+ } else if (getInverse().isEnabled(project)) {
+ // If the inverse flag was already enabled, disable this flag.
+ project.info('Disabling feature flag ${this}');
+ disable(project, false);
+ } else {
+ if (enableByDefault) {
+ // Enable this flag if it was unset, and disable the inverse.
+ project.info('Enabling feature flag ${this}');
+ enable(project, true);
+ } else {
+ // Disable this flag if it was unset, and enable the inverse.
+ project.info('Disabling feature flag ${this}');
+ disable(project, true);
+ }
+ }
+ }
+
+ /**
+ * Enable this feature flag by setting the appropriate compile define.
+ *
+ * @param project The project to modify.
+ * @param andInverse Also disable the feature flag's inverse.
+ */
+ public function enable(project:Project, andInverse:Bool = true) {
+ project.setHaxedef(this, "");
+ if (andInverse) {
+ getInverse().disable(project, false);
+ }
+ }
+
+ /**
+ * Disable this feature flag by removing the appropriate compile define.
+ *
+ * @param project The project to modify.
+ * @param andInverse Also enable the feature flag's inverse.
+ */
+ public function disable(project:Project, andInverse:Bool = true) {
+ project.unsetHaxedef(this);
+ if (andInverse) {
+ getInverse().enable(project, false);
+ }
+ }
+
+ /**
+ * Query if this feature flag is enabled.
+ * @param project The project to query.
+ */
+ public function isEnabled(project:Project):Bool {
+ // Check both Haxedefs and Defines for this flag.
+ return project.haxedefs.exists(this) || project.defines.exists(this);
+ }
+
+ /**
+ * Query if this feature flag's inverse is enabled.
+ */
+ public function isDisabled(project:Project):Bool {
+ return getInverse().isEnabled(project);
+ }
+
+ /**
+ * Return the inverse of this feature flag.
+ * @return A new feature flag that is the inverse of this one.
+ */
+ public function getInverse():FeatureFlag {
+ if (this.startsWith(INVERSE_PREFIX)) {
+ return this.substring(INVERSE_PREFIX.length);
+ }
+ return INVERSE_PREFIX + this;
+ }
+}
diff --git a/source/Main.hx b/source/Main.hx
index add5bbc67..724b118f8 100644
--- a/source/Main.hx
+++ b/source/Main.hx
@@ -22,7 +22,7 @@ class Main extends Sprite
var gameHeight:Int = 720; // Height of the game in pixels (might be less / more in actual pixels depending on your zoom).
var initialState:Class = funkin.InitState; // The FlxState the game starts with.
var zoom:Float = -1; // If -1, zoom is automatically calculated to fit the window dimensions.
- #if web
+ #if (web || CHEEMS)
var framerate:Int = 60; // How many frames per second the game should run at.
#else
// TODO: This should probably be in the options menu?
@@ -66,6 +66,12 @@ class Main extends Sprite
function init(?event:Event):Void
{
+ #if web
+ // set this variable (which is a function) from the lime version at lime/_internal/backend/html5/HTML5Application.hx
+ // The framerate cap will more thoroughly initialize via Preferences in InitState.hx
+ funkin.Preferences.lockedFramerateFunction = untyped js.Syntax.code("window.requestAnimationFrame");
+ #end
+
if (hasEventListener(Event.ADDED_TO_STAGE))
{
removeEventListener(Event.ADDED_TO_STAGE, init);
@@ -113,7 +119,7 @@ class Main extends Sprite
addChild(game);
- #if debug
+ #if FEATURE_DEBUG_FUNCTIONS
game.debugger.interaction.addTool(new funkin.util.TrackerToolButtonUtil());
#end
diff --git a/source/funkin/Assets.hx b/source/funkin/Assets.hx
new file mode 100644
index 000000000..5351676d4
--- /dev/null
+++ b/source/funkin/Assets.hx
@@ -0,0 +1,38 @@
+package funkin;
+
+/**
+ * A wrapper around `openfl.utils.Assets` which disallows access to the harmful functions.
+ * Later we'll add Funkin-specific caching to this.
+ */
+class Assets
+{
+ public static function getText(path:String):String
+ {
+ return openfl.utils.Assets.getText(path);
+ }
+
+ public static function getMusic(path:String):openfl.media.Sound
+ {
+ return openfl.utils.Assets.getMusic(path);
+ }
+
+ public static function getBitmapData(path:String):openfl.display.BitmapData
+ {
+ return openfl.utils.Assets.getBitmapData(path);
+ }
+
+ public static function getBytes(path:String):haxe.io.Bytes
+ {
+ return openfl.utils.Assets.getBytes(path);
+ }
+
+ public static function exists(path:String, ?type:openfl.utils.AssetType):Bool
+ {
+ return openfl.utils.Assets.exists(path, type);
+ }
+
+ public static function list(type:openfl.utils.AssetType):Array
+ {
+ return openfl.utils.Assets.list(type);
+ }
+}
diff --git a/source/funkin/InitState.hx b/source/funkin/InitState.hx
index 49b15ddf6..f71de00f4 100644
--- a/source/funkin/InitState.hx
+++ b/source/funkin/InitState.hx
@@ -1,5 +1,6 @@
package funkin;
+import funkin.data.freeplay.player.PlayerRegistry;
import funkin.ui.debug.charting.ChartEditorState;
import funkin.ui.transition.LoadingState;
import flixel.FlxState;
@@ -18,6 +19,7 @@ import funkin.play.PlayStatePlaylist;
import openfl.display.BitmapData;
import funkin.data.story.level.LevelRegistry;
import funkin.data.notestyle.NoteStyleRegistry;
+import funkin.data.freeplay.style.FreeplayStyleRegistry;
import funkin.data.event.SongEventRegistry;
import funkin.data.stage.StageRegistry;
import funkin.data.dialogue.conversation.ConversationRegistry;
@@ -26,13 +28,14 @@ import funkin.data.dialogue.speaker.SpeakerRegistry;
import funkin.data.freeplay.album.AlbumRegistry;
import funkin.data.song.SongRegistry;
import funkin.play.character.CharacterData.CharacterDataParser;
+import funkin.play.notes.notekind.NoteKindManager;
import funkin.modding.module.ModuleHandler;
import funkin.ui.title.TitleState;
import funkin.util.CLIUtil;
import funkin.util.CLIUtil.CLIParams;
import funkin.util.TimerUtil;
import funkin.util.TrackerUtil;
-#if discord_rpc
+#if FEATURE_DISCORD_RPC
import Discord.DiscordClient;
#end
@@ -121,7 +124,7 @@ class InitState extends FlxState
//
// DISCORD API SETUP
//
- #if discord_rpc
+ #if FEATURE_DISCORD_RPC
DiscordClient.initialize();
Application.current.onExit.add(function(exitCode) {
@@ -142,7 +145,7 @@ class InitState extends FlxState
// Plugins provide a useful interface for globally active Flixel objects,
// that receive update events regardless of the current state.
// TODO: Move scripted Module behavior to a Flixel plugin.
- #if debug
+ #if FEATURE_DEBUG_FUNCTIONS
funkin.util.plugins.MemoryGCPlugin.initialize();
#end
funkin.util.plugins.EvacuateDebugPlugin.initialize();
@@ -164,9 +167,11 @@ class InitState extends FlxState
SongRegistry.instance.loadEntries();
LevelRegistry.instance.loadEntries();
NoteStyleRegistry.instance.loadEntries();
+ PlayerRegistry.instance.loadEntries();
ConversationRegistry.instance.loadEntries();
DialogueBoxRegistry.instance.loadEntries();
SpeakerRegistry.instance.loadEntries();
+ FreeplayStyleRegistry.instance.loadEntries();
AlbumRegistry.instance.loadEntries();
StageRegistry.instance.loadEntries();
@@ -174,6 +179,8 @@ class InitState extends FlxState
// Move it to use a BaseRegistry.
CharacterDataParser.loadCharacterCache();
+ NoteKindManager.loadScripts();
+
ModuleHandler.buildModuleCallbacks();
ModuleHandler.loadModuleCache();
ModuleHandler.callOnCreate();
@@ -218,9 +225,10 @@ class InitState extends FlxState
// -DRESULTS
FlxG.switchState(() -> new funkin.play.ResultState(
{
- storyMode: false,
+ storyMode: true,
title: "Cum Song Erect by Kawai Sprite",
songId: "cum",
+ characterId: "pico-playable",
difficultyId: "nightmare",
isNewHighscore: true,
scoreData:
@@ -236,8 +244,13 @@ class InitState extends FlxState
combo: 69,
maxCombo: 69,
totalNotesHit: 140,
- totalNotes: 200 // 0,
+ totalNotes: 190
}
+ // 2400 total notes = 7% = LOSS
+ // 240 total notes = 79% = GOOD
+ // 230 total notes = 82% = GREAT
+ // 210 total notes = 91% = EXCELLENT
+ // 190 total notes = PERFECT
},
}));
#elseif ANIMDEBUG
@@ -363,11 +376,16 @@ class InitState extends FlxState
//
// FLIXEL DEBUG SETUP
//
- #if (debug || FORCE_DEBUG_VERSION)
- // Make errors and warnings less annoying.
- // Forcing this always since I have never been happy to have the debugger to pop up
+ #if FEATURE_DEBUG_FUNCTIONS
+ trace('Initializing Flixel debugger...');
+
+ #if !debug
+ // Make errors less annoying on release builds.
LogStyle.ERROR.openConsole = false;
LogStyle.ERROR.errorSound = null;
+ #end
+
+ // Make errors and warnings less annoying.
LogStyle.WARNING.openConsole = false;
LogStyle.WARNING.errorSound = null;
diff --git a/source/funkin/Paths.hx b/source/funkin/Paths.hx
index b0a97c4fa..285af7ca2 100644
--- a/source/funkin/Paths.hx
+++ b/source/funkin/Paths.hx
@@ -11,9 +11,16 @@ class Paths
{
static var currentLevel:Null = null;
- public static function setCurrentLevel(name:String):Void
+ public static function setCurrentLevel(name:Null):Void
{
- currentLevel = name.toLowerCase();
+ if (name == null)
+ {
+ currentLevel = null;
+ }
+ else
+ {
+ currentLevel = name.toLowerCase();
+ }
}
public static function stripLibrary(path:String):String
diff --git a/source/funkin/Preferences.hx b/source/funkin/Preferences.hx
index b2050c6a2..daeded897 100644
--- a/source/funkin/Preferences.hx
+++ b/source/funkin/Preferences.hx
@@ -128,6 +128,48 @@ class Preferences
return value;
}
+ public static var unlockedFramerate(get, set):Bool;
+
+ static function get_unlockedFramerate():Bool
+ {
+ return Save?.instance?.options?.unlockedFramerate;
+ }
+
+ static function set_unlockedFramerate(value:Bool):Bool
+ {
+ if (value != Save.instance.options.unlockedFramerate)
+ {
+ #if web
+ toggleFramerateCap(value);
+ #end
+ }
+
+ var save:Save = Save.instance;
+ save.options.unlockedFramerate = value;
+ save.flush();
+ return value;
+ }
+
+ #if web
+ // We create a haxe version of this just for readability.
+ // We use these to override `window.requestAnimationFrame` in Javascript to uncap the framerate / "animation" request rate
+ // Javascript is crazy since u can just do stuff like that lol
+
+ public static function unlockedFramerateFunction(callback, element)
+ {
+ var currTime = Date.now().getTime();
+ var timeToCall = 0;
+ var id = js.Browser.window.setTimeout(function() {
+ callback(currTime + timeToCall);
+ }, timeToCall);
+ return id;
+ }
+
+ // Lime already implements their own little framerate cap, so we can just use that
+ // This also gets set in the init function in Main.hx, since we need to definitely override it
+ public static var lockedFramerateFunction = untyped js.Syntax.code("window.requestAnimationFrame");
+ #end
+
/**
* Loads the user's preferences from the save data and apply them.
*/
@@ -137,6 +179,17 @@ class Preferences
FlxG.autoPause = Preferences.autoPause;
// Apply the debugDisplay setting (enables the FPS and RAM display).
toggleDebugDisplay(Preferences.debugDisplay);
+ #if web
+ toggleFramerateCap(Preferences.unlockedFramerate);
+ #end
+ }
+
+ static function toggleFramerateCap(unlocked:Bool):Void
+ {
+ #if web
+ var framerateFunction = unlocked ? unlockedFramerateFunction : lockedFramerateFunction;
+ untyped js.Syntax.code("window.requestAnimationFrame = framerateFunction;");
+ #end
}
static function toggleDebugDisplay(show:Bool):Void
diff --git a/source/funkin/api/discord/Discord.hx b/source/funkin/api/discord/Discord.hx
index a4d65684e..9dd513bf7 100644
--- a/source/funkin/api/discord/Discord.hx
+++ b/source/funkin/api/discord/Discord.hx
@@ -1,13 +1,13 @@
package funkin.api.discord;
import Sys.sleep;
-#if discord_rpc
+#if FEATURE_DISCORD_RPC
import discord_rpc.DiscordRpc;
#end
class DiscordClient
{
- #if discord_rpc
+ #if FEATURE_DISCORD_RPC
public function new()
{
trace("Discord Client starting...");
diff --git a/source/funkin/api/newgrounds/NGUnsafe.hx b/source/funkin/api/newgrounds/NGUnsafe.hx
index 77e44bd1d..81da7359e 100644
--- a/source/funkin/api/newgrounds/NGUnsafe.hx
+++ b/source/funkin/api/newgrounds/NGUnsafe.hx
@@ -24,7 +24,7 @@ class NGUnsafe
NG.core.calls.event.logEvent(event).send();
trace('should have logged: ' + event);
#else
- #if debug
+ #if FEATURE_DEBUG_FUNCTIONS
trace('event:$event - not logged, missing NG.io lib');
#end
#end
@@ -39,7 +39,7 @@ class NGUnsafe
if (!medal.unlocked) medal.sendUnlock();
}
#else
- #if debug
+ #if FEATURE_DEBUG_FUNCTIONS
trace('medal:$id - not unlocked, missing NG.io lib');
#end
#end
@@ -63,7 +63,7 @@ class NGUnsafe
}
}
#else
- #if debug
+ #if FEATURE_DEBUG_FUNCTIONS
trace('Song:$song, Score:$score - not posted, missing NG.io lib');
#end
#end
diff --git a/source/funkin/api/newgrounds/NGio.hx b/source/funkin/api/newgrounds/NGio.hx
index 3f5fc078a..a866783c1 100644
--- a/source/funkin/api/newgrounds/NGio.hx
+++ b/source/funkin/api/newgrounds/NGio.hx
@@ -239,7 +239,7 @@ class NGio
NG.core.calls.event.logEvent(event).send();
trace('should have logged: ' + event);
#else
- #if debug
+ #if FEATURE_DEBUG_FUNCTIONS
trace('event:$event - not logged, missing NG.io lib');
#end
#end
@@ -254,7 +254,7 @@ class NGio
if (!medal.unlocked) medal.sendUnlock();
}
#else
- #if debug
+ #if FEATURE_DEBUG_FUNCTIONS
trace('medal:$id - not unlocked, missing NG.io lib');
#end
#end
@@ -278,7 +278,7 @@ class NGio
}
}
#else
- #if debug
+ #if FEATURE_DEBUG_FUNCTIONS
trace('Song:$song, Score:$score - not posted, missing NG.io lib');
#end
#end
diff --git a/source/funkin/audio/FunkinSound.hx b/source/funkin/audio/FunkinSound.hx
index c70f195d2..dae31cd07 100644
--- a/source/funkin/audio/FunkinSound.hx
+++ b/source/funkin/audio/FunkinSound.hx
@@ -340,6 +340,8 @@ class FunkinSound extends FlxSound implements ICloneable
if (songMusicData != null)
{
Conductor.instance.mapTimeChanges(songMusicData.timeChanges);
+
+ if (songMusicData.looped != null && params.loop == null) params.loop = songMusicData.looped;
}
else
{
@@ -388,7 +390,7 @@ class FunkinSound extends FlxSound implements ICloneable
}
else
{
- var music = FunkinSound.load(pathToUse, params?.startingVolume ?? 1.0, params.loop ?? true, false, true);
+ var music = FunkinSound.load(pathToUse, params?.startingVolume ?? 1.0, params.loop ?? true, false, true, params.onComplete);
if (music != null)
{
FlxG.sound.music = music;
@@ -396,6 +398,8 @@ class FunkinSound extends FlxSound implements ICloneable
// Prevent repeat update() and onFocus() calls.
FlxG.sound.list.remove(FlxG.sound.music);
+ if (FlxG.sound.music != null && params.onLoad != null) params.onLoad();
+
return true;
}
else
@@ -491,8 +495,10 @@ class FunkinSound extends FlxSound implements ICloneable
var promise:lime.app.Promise> = new lime.app.Promise>();
// split the path and get only after first :
- // we are bypassing the openfl/lime asset library fuss
+ // we are bypassing the openfl/lime asset library fuss on web only
+ #if web
path = Paths.stripLibrary(path);
+ #end
var soundRequest = FlxPartialSound.partialLoadFromFile(path, start, end);
@@ -533,11 +539,12 @@ class FunkinSound extends FlxSound implements ICloneable
* Play a sound effect once, then destroy it.
* @param key
* @param volume
- * @return static function construct():FunkinSound
+ * @return A `FunkinSound` object, or `null` if the sound could not be loaded.
*/
- public static function playOnce(key:String, volume:Float = 1.0, ?onComplete:Void->Void, ?onLoad:Void->Void):Void
+ public static function playOnce(key:String, volume:Float = 1.0, ?onComplete:Void->Void, ?onLoad:Void->Void):Null
{
var result = FunkinSound.load(key, volume, false, true, true, onComplete, onLoad);
+ return result;
}
/**
@@ -562,6 +569,14 @@ class FunkinSound extends FlxSound implements ICloneable
return sound;
}
+
+ /**
+ * Produces a string representation suitable for debugging.
+ */
+ public override function toString():String
+ {
+ return 'FunkinSound(${this._label})';
+ }
}
/**
diff --git a/source/funkin/audio/SoundGroup.hx b/source/funkin/audio/SoundGroup.hx
index 5fc2abe0e..5d53fedd6 100644
--- a/source/funkin/audio/SoundGroup.hx
+++ b/source/funkin/audio/SoundGroup.hx
@@ -113,6 +113,11 @@ class SoundGroup extends FlxTypedGroup
public function play(forceRestart:Bool = false, startTime:Float = 0.0, ?endTime:Float)
{
forEachAlive(function(sound:FunkinSound) {
+ if (sound.length < startTime)
+ {
+ // trace('Queuing sound (${sound.toString()} past its length! Skipping...)');
+ return;
+ }
sound.play(forceRestart, startTime, endTime);
});
}
diff --git a/source/funkin/audio/visualize/ABotVis.hx b/source/funkin/audio/visualize/ABotVis.hx
index cf43a8add..4d2243b7c 100644
--- a/source/funkin/audio/visualize/ABotVis.hx
+++ b/source/funkin/audio/visualize/ABotVis.hx
@@ -54,7 +54,7 @@ class ABotVis extends FlxTypedSpriteGroup
public function initAnalyzer()
{
@:privateAccess
- analyzer = new SpectralAnalyzer(snd._channel.__source, 7, 0.1, 40);
+ analyzer = new SpectralAnalyzer(snd._channel.__audioSource, 7, 0.1, 40);
#if desktop
// On desktop it uses FFT stuff that isn't as optimized as the direct browser stuff we use on HTML5
@@ -102,7 +102,9 @@ class ABotVis extends FlxTypedSpriteGroup
var animFrame:Int = Math.round(levels[i].value * 5);
#if desktop
- animFrame = Math.round(animFrame * FlxG.sound.volume);
+ // Web version scales with the Flixel volume level.
+ // This line brings platform parity but looks worse.
+ // animFrame = Math.round(animFrame * FlxG.sound.volume);
#end
animFrame = Math.floor(Math.min(5, animFrame));
diff --git a/source/funkin/audio/visualize/VisShit.hx b/source/funkin/audio/visualize/VisShit.hx
index ba235fe89..83b9496ac 100644
--- a/source/funkin/audio/visualize/VisShit.hx
+++ b/source/funkin/audio/visualize/VisShit.hx
@@ -117,7 +117,7 @@ class VisShit
{
// Math.pow3
@:privateAccess
- var buf = snd._channel.__source.buffer;
+ var buf = snd._channel.__audioSource.buffer;
// @:privateAccess
audioData = cast buf.data; // jank and hacky lol! kinda busted on HTML5 also!!
diff --git a/source/funkin/audio/waveform/WaveformDataParser.hx b/source/funkin/audio/waveform/WaveformDataParser.hx
index 5aa54d744..ca421581b 100644
--- a/source/funkin/audio/waveform/WaveformDataParser.hx
+++ b/source/funkin/audio/waveform/WaveformDataParser.hx
@@ -16,7 +16,7 @@ class WaveformDataParser
// Method 1. This only works if the sound has been played before.
@:privateAccess
- var soundBuffer:Null = sound?._channel?.__source?.buffer;
+ var soundBuffer:Null = sound?._channel?.__audioSource?.buffer;
if (soundBuffer == null)
{
diff --git a/source/funkin/data/BaseRegistry.hx b/source/funkin/data/BaseRegistry.hx
index 118516bec..83413ad00 100644
--- a/source/funkin/data/BaseRegistry.hx
+++ b/source/funkin/data/BaseRegistry.hx
@@ -263,7 +263,7 @@ abstract class BaseRegistry & Constructible
+ public function parseEntryDataWithMigration(id:String, version:Null):Null
{
if (version == null)
{
diff --git a/source/funkin/data/event/SongEventRegistry.hx b/source/funkin/data/event/SongEventRegistry.hx
index 9b0163557..5ee2d39fa 100644
--- a/source/funkin/data/event/SongEventRegistry.hx
+++ b/source/funkin/data/event/SongEventRegistry.hx
@@ -46,7 +46,7 @@ class SongEventRegistry
if (event != null)
{
- trace(' Loaded built-in song event: (${event.id})');
+ trace(' Loaded built-in song event: ${event.id}');
eventCache.set(event.id, event);
}
else
@@ -59,9 +59,9 @@ class SongEventRegistry
static function registerScriptedEvents()
{
var scriptedEventClassNames:Array = ScriptedSongEvent.listScriptClasses();
+ trace('Instantiating ${scriptedEventClassNames.length} scripted song events...');
if (scriptedEventClassNames == null || scriptedEventClassNames.length == 0) return;
- trace('Instantiating ${scriptedEventClassNames.length} scripted song events...');
for (eventCls in scriptedEventClassNames)
{
var event:SongEvent = ScriptedSongEvent.init(eventCls, "UKNOWN");
diff --git a/source/funkin/data/freeplay/player/CHANGELOG.md b/source/funkin/data/freeplay/player/CHANGELOG.md
new file mode 100644
index 000000000..7a31e11ca
--- /dev/null
+++ b/source/funkin/data/freeplay/player/CHANGELOG.md
@@ -0,0 +1,9 @@
+# Freeplay Playable Character Data Schema Changelog
+
+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.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [1.0.0]
+Initial release.
diff --git a/source/funkin/data/freeplay/player/PlayerData.hx b/source/funkin/data/freeplay/player/PlayerData.hx
new file mode 100644
index 000000000..de293c24e
--- /dev/null
+++ b/source/funkin/data/freeplay/player/PlayerData.hx
@@ -0,0 +1,380 @@
+package funkin.data.freeplay.player;
+
+import funkin.data.animation.AnimationData;
+
+@:nullSafety
+class PlayerData
+{
+ /**
+ * The sematic version number of the player data JSON format.
+ * Supports fancy comparisons like NPM does it's neat.
+ */
+ @:default(funkin.data.freeplay.player.PlayerRegistry.PLAYER_DATA_VERSION)
+ public var version:String;
+
+ /**
+ * A readable name for this playable character.
+ */
+ public var name:String = 'Unknown';
+
+ /**
+ * The character IDs this character is associated with.
+ * Only songs that use these characters will show up in Freeplay.
+ */
+ @:default([])
+ public var ownedChars:Array = [];
+
+ /**
+ * Whether to show songs with character IDs that aren't associated with any specific character.
+ */
+ @:optional
+ @:default(false)
+ public var showUnownedChars:Bool = false;
+
+ /**
+ * Which freeplay style to use for this character.
+ */
+ @:optional
+ @:default("bf")
+ public var freeplayStyle:String = Constants.DEFAULT_FREEPLAY_STYLE;
+
+ /**
+ * Data for displaying this character in the Freeplay menu.
+ * If null, display no DJ.
+ */
+ @:optional
+ public var freeplayDJ:Null = null;
+
+ /**
+ * Data for displaying this character in the Character Select menu.
+ * If null, exclude from Character Select.
+ */
+ @:optional
+ public var charSelect:Null = null;
+
+ /**
+ * Data for displaying this character in the results screen.
+ */
+ @:optional
+ public var results:Null = null;
+
+ /**
+ * Whether this character is unlocked by default.
+ * Use a ScriptedPlayableCharacter to add custom logic.
+ */
+ @:optional
+ @:default(true)
+ public var unlocked:Bool = true;
+
+ public function new()
+ {
+ this.version = PlayerRegistry.PLAYER_DATA_VERSION;
+ }
+
+ /**
+ * Convert this StageData into a JSON string.
+ */
+ public function serialize(pretty:Bool = true):String
+ {
+ // Update generatedBy and version before writing.
+ updateVersionToLatest();
+
+ var writer = new json2object.JsonWriter();
+ return writer.write(this, pretty ? ' ' : null);
+ }
+
+ public function updateVersionToLatest():Void
+ {
+ this.version = PlayerRegistry.PLAYER_DATA_VERSION;
+ }
+}
+
+class PlayerFreeplayDJData
+{
+ var assetPath:String;
+ var animations:Array;
+
+ @:optional
+ @:default("BOYFRIEND")
+ var text1:String;
+
+ @:optional
+ @:default("HOT BLOODED IN MORE WAYS THAN ONE")
+ var text2:String;
+
+ @:optional
+ @:default("PROTECT YO NUTS")
+ var text3:String;
+
+ @:jignored
+ var animationMap:Map;
+
+ @:jignored
+ var prefixToOffsetsMap:Map>;
+
+ @:optional
+ var charSelect:Null;
+
+ @:optional
+ var cartoon:Null;
+
+ @:optional
+ var fistPump:Null;
+
+ public function new()
+ {
+ animationMap = new Map();
+ }
+
+ function mapAnimations()
+ {
+ if (animationMap == null) animationMap = new Map();
+ if (prefixToOffsetsMap == null) prefixToOffsetsMap = new Map();
+
+ animationMap.clear();
+ prefixToOffsetsMap.clear();
+ for (anim in animations)
+ {
+ animationMap.set(anim.name, anim);
+ prefixToOffsetsMap.set(anim.prefix, anim.offsets);
+ }
+ }
+
+ public function getAtlasPath():String
+ {
+ return Paths.animateAtlas(assetPath);
+ }
+
+ public function getFreeplayDJText(index:Int):String
+ {
+ switch (index)
+ {
+ case 1:
+ return text1;
+ case 2:
+ return text2;
+ case 3:
+ return text3;
+ default:
+ return '';
+ }
+ }
+
+ public function getAnimationPrefix(name:String):Null
+ {
+ if (animationMap.size() == 0) mapAnimations();
+
+ var anim = animationMap.get(name);
+ if (anim == null) return null;
+ return anim.prefix;
+ }
+
+ public function getAnimationOffsetsByPrefix(?prefix:String):Array
+ {
+ if (prefixToOffsetsMap.size() == 0) mapAnimations();
+ if (prefix == null) return [0, 0];
+ return prefixToOffsetsMap.get(prefix);
+ }
+
+ public function getAnimationOffsets(name:String):Array
+ {
+ return getAnimationOffsetsByPrefix(getAnimationPrefix(name));
+ }
+
+ // TODO: These should really be frame labels, ehe.
+
+ public function getCartoonSoundClickFrame():Int
+ {
+ return cartoon?.soundClickFrame ?? 80;
+ }
+
+ public function getCartoonSoundCartoonFrame():Int
+ {
+ return cartoon?.soundCartoonFrame ?? 85;
+ }
+
+ public function getCartoonLoopBlinkFrame():Int
+ {
+ return cartoon?.loopBlinkFrame ?? 112;
+ }
+
+ public function getCartoonLoopFrame():Int
+ {
+ return cartoon?.loopFrame ?? 166;
+ }
+
+ public function getCartoonChannelChangeFrame():Int
+ {
+ return cartoon?.channelChangeFrame ?? 60;
+ }
+
+ public function getFistPumpIntroStartFrame():Int
+ {
+ return fistPump?.introStartFrame ?? 0;
+ }
+
+ public function getFistPumpIntroEndFrame():Int
+ {
+ return fistPump?.introEndFrame ?? 0;
+ }
+
+ public function getFistPumpLoopStartFrame():Int
+ {
+ return fistPump?.loopStartFrame ?? 0;
+ }
+
+ public function getFistPumpLoopEndFrame():Int
+ {
+ return fistPump?.loopEndFrame ?? 0;
+ }
+
+ public function getFistPumpIntroBadStartFrame():Int
+ {
+ return fistPump?.introBadStartFrame ?? 0;
+ }
+
+ public function getFistPumpIntroBadEndFrame():Int
+ {
+ return fistPump?.introBadEndFrame ?? 0;
+ }
+
+ public function getFistPumpLoopBadStartFrame():Int
+ {
+ return fistPump?.loopBadStartFrame ?? 0;
+ }
+
+ public function getFistPumpLoopBadEndFrame():Int
+ {
+ return fistPump?.loopBadEndFrame ?? 0;
+ }
+
+ public function getCharSelectTransitionDelay():Float
+ {
+ return charSelect?.transitionDelay ?? 0.25;
+ }
+}
+
+class PlayerCharSelectData
+{
+ /**
+ * A zero-indexed number for the character's preferred position in the grid.
+ * 0 = top left, 4 = center, 8 = bottom right
+ * In the event of a conflict, the first character alphabetically gets it,
+ * and others get shifted over.
+ */
+ @:optional
+ public var position:Null;
+}
+
+typedef PlayerResultsData =
+{
+ var music:PlayerResultsMusicData;
+
+ var perfect:Array;
+ var excellent:Array;
+ var great:Array;
+ var good:Array;
+ var loss:Array;
+};
+
+typedef PlayerResultsMusicData =
+{
+ @:optional
+ var PERFECT_GOLD:String;
+
+ @:optional
+ var PERFECT:String;
+
+ @:optional
+ var EXCELLENT:String;
+
+ @:optional
+ var GREAT:String;
+
+ @:optional
+ var GOOD:String;
+
+ @:optional
+ var SHIT:String;
+}
+
+typedef PlayerResultsAnimationData =
+{
+ /**
+ * `sparrow` or `animate` or whatever
+ */
+ var renderType:String;
+
+ var assetPath:String;
+
+ @:optional
+ @:default([0, 0])
+ var offsets:Array;
+
+ @:optional
+ @:default(500)
+ var zIndex:Int;
+
+ @:optional
+ @:default(0.0)
+ var delay:Float;
+
+ @:optional
+ @:default(1.0)
+ var scale:Float;
+
+ @:optional
+ @:default('')
+ var startFrameLabel:Null;
+
+ @:optional
+ @:default(true)
+ var looped:Bool;
+
+ @:optional
+ var loopFrame:Null;
+
+ @:optional
+ var loopFrameLabel:Null;
+};
+
+typedef PlayerFreeplayDJCharSelectData =
+{
+ var transitionDelay:Float;
+}
+
+typedef PlayerFreeplayDJCartoonData =
+{
+ var soundClickFrame:Int;
+ var soundCartoonFrame:Int;
+ var loopBlinkFrame:Int;
+ var loopFrame:Int;
+ var channelChangeFrame:Int;
+}
+
+typedef PlayerFreeplayDJFistPumpData =
+{
+ @:default(0)
+ var introStartFrame:Int;
+
+ @:default(4)
+ var introEndFrame:Int;
+
+ @:default(4)
+ var loopStartFrame:Int;
+
+ @:default(-1)
+ var loopEndFrame:Int;
+
+ @:default(0)
+ var introBadStartFrame:Int;
+
+ @:default(4)
+ var introBadEndFrame:Int;
+
+ @:default(4)
+ var loopBadStartFrame:Int;
+
+ @:default(-1)
+ var loopBadEndFrame:Int;
+};
diff --git a/source/funkin/data/freeplay/player/PlayerRegistry.hx b/source/funkin/data/freeplay/player/PlayerRegistry.hx
new file mode 100644
index 000000000..76b1c25c1
--- /dev/null
+++ b/source/funkin/data/freeplay/player/PlayerRegistry.hx
@@ -0,0 +1,208 @@
+package funkin.data.freeplay.player;
+
+import funkin.data.freeplay.player.PlayerData;
+import funkin.ui.freeplay.charselect.PlayableCharacter;
+import funkin.ui.freeplay.charselect.ScriptedPlayableCharacter;
+import funkin.save.Save;
+
+class PlayerRegistry extends BaseRegistry
+{
+ /**
+ * The current version string for the stage data format.
+ * Handle breaking changes by incrementing this value
+ * and adding migration to the `migratePlayerData()` function.
+ */
+ public static final PLAYER_DATA_VERSION:thx.semver.Version = "1.0.0";
+
+ public static final PLAYER_DATA_VERSION_RULE:thx.semver.VersionRule = "1.0.x";
+
+ public static var instance(get, never):PlayerRegistry;
+ static var _instance:Null = null;
+
+ static function get_instance():PlayerRegistry
+ {
+ if (_instance == null) _instance = new PlayerRegistry();
+ return _instance;
+ }
+
+ /**
+ * A mapping between stage character IDs and Freeplay playable character IDs.
+ */
+ var ownedCharacterIds:Map = [];
+
+ public function new()
+ {
+ super('PLAYER', 'players', PLAYER_DATA_VERSION_RULE);
+ }
+
+ public override function loadEntries():Void
+ {
+ super.loadEntries();
+
+ for (playerId in listEntryIds())
+ {
+ var player = fetchEntry(playerId);
+ if (player == null) continue;
+
+ var currentPlayerCharIds = player.getOwnedCharacterIds();
+ for (characterId in currentPlayerCharIds)
+ {
+ ownedCharacterIds.set(characterId, playerId);
+ }
+ }
+
+ log('Loaded ${countEntries()} playable characters with ${ownedCharacterIds.size()} associations.');
+ }
+
+ public function countUnlockedCharacters():Int
+ {
+ var count = 0;
+
+ for (charId in listEntryIds())
+ {
+ var player = fetchEntry(charId);
+ if (player == null) continue;
+
+ if (player.isUnlocked()) count++;
+ }
+
+ return count;
+ }
+
+ public function hasNewCharacter():Bool
+ {
+ var charactersSeen = Save.instance.charactersSeen.clone();
+
+ for (charId in listEntryIds())
+ {
+ var player = fetchEntry(charId);
+ if (player == null) continue;
+
+ if (!player.isUnlocked()) continue;
+ if (charactersSeen.contains(charId)) continue;
+
+ // This character is unlocked but we haven't seen them in Freeplay yet.
+ return true;
+ }
+
+ // Fallthrough case.
+ return false;
+ }
+
+ public function listNewCharacters():Array
+ {
+ var charactersSeen = Save.instance.charactersSeen.clone();
+ var result = [];
+
+ for (charId in listEntryIds())
+ {
+ var player = fetchEntry(charId);
+ if (player == null) continue;
+
+ if (!player.isUnlocked()) continue;
+ if (charactersSeen.contains(charId)) continue;
+
+ // This character is unlocked but we haven't seen them in Freeplay yet.
+ result.push(charId);
+ }
+
+ return result;
+ }
+
+ /**
+ * Get the playable character associated with a given stage character.
+ * @param characterId The stage character ID.
+ * @return The playable character.
+ */
+ public function getCharacterOwnerId(characterId:Null):Null
+ {
+ if (characterId == null) return null;
+ return ownedCharacterIds[characterId];
+ }
+
+ /**
+ * Return true if the given stage character is associated with a specific playable character.
+ * If so, the level should only appear if that character is selected in Freeplay.
+ * @param characterId The stage character ID.
+ * @return Whether the character is owned by any one character.
+ */
+ public function isCharacterOwned(characterId:String):Bool
+ {
+ return ownedCharacterIds.exists(characterId);
+ }
+
+ /**
+ * Read, parse, and validate the JSON data and produce the corresponding data object.
+ */
+ public function parseEntryData(id:String):Null
+ {
+ // JsonParser does not take type parameters,
+ // otherwise this function would be in BaseRegistry.
+ var parser = new json2object.JsonParser();
+ parser.ignoreUnknownVariables = false;
+
+ switch (loadEntryFile(id))
+ {
+ case {fileName: fileName, contents: contents}:
+ parser.fromJson(contents, fileName);
+ default:
+ return null;
+ }
+
+ if (parser.errors.length > 0)
+ {
+ printErrors(parser.errors, id);
+ return null;
+ }
+ return parser.value;
+ }
+
+ /**
+ * Parse and validate the JSON data and produce the corresponding data object.
+ *
+ * NOTE: Must be implemented on the implementation class.
+ * @param contents The JSON as a string.
+ * @param fileName An optional file name for error reporting.
+ */
+ public function parseEntryDataRaw(contents:String, ?fileName:String):Null
+ {
+ var parser = new json2object.JsonParser();
+ parser.ignoreUnknownVariables = false;
+ parser.fromJson(contents, fileName);
+
+ if (parser.errors.length > 0)
+ {
+ printErrors(parser.errors, fileName);
+ return null;
+ }
+ return parser.value;
+ }
+
+ function createScriptedEntry(clsName:String):PlayableCharacter
+ {
+ return ScriptedPlayableCharacter.init(clsName, "unknown");
+ }
+
+ function getScriptedClassNames():Array
+ {
+ return ScriptedPlayableCharacter.listScriptClasses();
+ }
+
+ /**
+ * A list of all the playable characters from the base game, in order.
+ */
+ public function listBaseGamePlayerIds():Array
+ {
+ return ["bf", "pico"];
+ }
+
+ /**
+ * A list of all installed playable characters that are not from the base game.
+ */
+ public function listModdedPlayerIds():Array
+ {
+ return listEntryIds().filter(function(id:String):Bool {
+ return listBaseGamePlayerIds().indexOf(id) == -1;
+ });
+ }
+}
diff --git a/source/funkin/data/freeplay/style/CHANGELOG.md b/source/funkin/data/freeplay/style/CHANGELOG.md
new file mode 100644
index 000000000..8fe9f7eb8
--- /dev/null
+++ b/source/funkin/data/freeplay/style/CHANGELOG.md
@@ -0,0 +1,9 @@
+# Freeplay Style Data Schema Changelog
+
+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.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [1.0.0]
+Initial release.
diff --git a/source/funkin/data/freeplay/style/FreeplayStyleData.hx b/source/funkin/data/freeplay/style/FreeplayStyleData.hx
new file mode 100644
index 000000000..1af198217
--- /dev/null
+++ b/source/funkin/data/freeplay/style/FreeplayStyleData.hx
@@ -0,0 +1,48 @@
+package funkin.data.freeplay.style;
+
+import funkin.data.animation.AnimationData;
+
+/**
+ * A type definition for the data for an album of songs.
+ * It includes things like what graphics to display in Freeplay.
+ * @see https://lib.haxe.org/p/json2object/
+ */
+typedef FreeplayStyleData =
+{
+ /**
+ * Semantic version for style data.
+ */
+ public var version:String;
+
+ /**
+ * Asset key for the background image.
+ */
+ public var bgAsset:String;
+
+ /**
+ * Asset key for the difficulty selector image.
+ */
+ public var selectorAsset:String;
+
+ /**
+ * Asset key for the numbers shown at the top right of the screen.
+ */
+ public var numbersAsset:String;
+
+ /**
+ * Asset key for the freeplay capsules.
+ */
+ public var capsuleAsset:String;
+
+ /**
+ * Color data for the capsule text outline.
+ * the order of this array goes as follows: [DESELECTED, SELECTED]
+ */
+ public var capsuleTextColors:Array;
+
+ /**
+ * Delay time after confirming a song selection, before entering PlayState.
+ * Useful for letting longer animations play out.
+ */
+ public var startDelay:Float;
+}
diff --git a/source/funkin/data/freeplay/style/FreeplayStyleRegistry.hx b/source/funkin/data/freeplay/style/FreeplayStyleRegistry.hx
new file mode 100644
index 000000000..626e2ac39
--- /dev/null
+++ b/source/funkin/data/freeplay/style/FreeplayStyleRegistry.hx
@@ -0,0 +1,84 @@
+package funkin.data.freeplay.style;
+
+import funkin.ui.freeplay.FreeplayStyle;
+import funkin.data.freeplay.style.FreeplayStyleData;
+import funkin.ui.freeplay.ScriptedFreeplayStyle;
+
+class FreeplayStyleRegistry extends BaseRegistry
+{
+ /**
+ * The current version string for the style data format.
+ * Handle breaking changes by incrementing this value
+ * and adding migration to the `migrateStyleData()` function.
+ */
+ public static final FREEPLAYSTYLE_DATA_VERSION:thx.semver.Version = '1.0.0';
+
+ public static final FREEPLAYSTYLE_DATA_VERSION_RULE:thx.semver.VersionRule = '1.0.x';
+
+ public static final instance:FreeplayStyleRegistry = new FreeplayStyleRegistry();
+
+ public function new()
+ {
+ super('FREEPLAYSTYLE', 'ui/freeplay/styles', FREEPLAYSTYLE_DATA_VERSION_RULE);
+ }
+
+ /**
+ * Read, parse, and validate the JSON data and produce the corresponding data object.
+ * @param id The ID of the entry to load.
+ * @return The parsed data object.
+ */
+ public function parseEntryData(id:String):Null
+ {
+ // JsonParser does not take type parameters,
+ // otherwise this function would be in BaseRegistry.
+ var parser:json2object.JsonParser = new json2object.JsonParser();
+ parser.ignoreUnknownVariables = false;
+
+ switch (loadEntryFile(id))
+ {
+ case {fileName: fileName, contents: contents}:
+ parser.fromJson(contents, fileName);
+ default:
+ return null;
+ }
+
+ if (parser.errors.length > 0)
+ {
+ printErrors(parser.errors, id);
+ return null;
+ }
+ return parser.value;
+ }
+
+ /**
+ * Parse and validate the JSON data and produce the corresponding data object.
+ *
+ * NOTE: Must be implemented on the implementation class.
+ * @param contents The JSON as a string.
+ * @param fileName An optional file name for error reporting.
+ * @return The parsed data object.
+ */
+ public function parseEntryDataRaw(contents:String, ?fileName:String):Null
+ {
+ var parser:json2object.JsonParser = new json2object.JsonParser();
+ parser.ignoreUnknownVariables = false;
+ parser.fromJson(contents, fileName);
+
+ if (parser.errors.length > 0)
+ {
+ printErrors(parser.errors, fileName);
+ return null;
+ }
+ return parser.value;
+ }
+
+ function createScriptedEntry(clsName:String):FreeplayStyle
+ {
+ return ScriptedFreeplayStyle.init(clsName, 'unknown');
+ }
+
+ function getScriptedClassNames():Array
+ {
+ return ScriptedFreeplayStyle.listScriptClasses();
+ }
+}
diff --git a/source/funkin/data/notestyle/CHANGELOG.md b/source/funkin/data/notestyle/CHANGELOG.md
new file mode 100644
index 000000000..d85c11cad
--- /dev/null
+++ b/source/funkin/data/notestyle/CHANGELOG.md
@@ -0,0 +1,31 @@
+# Note Style Data Schema Changelog
+
+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.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [1.1.0]
+### Added
+- Added several new `assets`:
+ - `countdownThree`
+ - `countdownTwo`
+ - `countdownOne`
+ - `countdownGo`
+ - `judgementSick`
+ - `judgementGood`
+ - `judgementBad`
+ - `judgementShit`
+ - `comboNumber0`
+ - `comboNumber1`
+ - `comboNumber2`
+ - `comboNumber3`
+ - `comboNumber4`
+ - `comboNumber5`
+ - `comboNumber6`
+ - `comboNumber7`
+ - `comboNumber8`
+ - `comboNumber9`
+
+## [1.0.0]
+Initial version.
diff --git a/source/funkin/data/notestyle/NoteStyleData.hx b/source/funkin/data/notestyle/NoteStyleData.hx
index 04fda67ca..39162b896 100644
--- a/source/funkin/data/notestyle/NoteStyleData.hx
+++ b/source/funkin/data/notestyle/NoteStyleData.hx
@@ -74,6 +74,84 @@ typedef NoteStyleAssetsData =
*/
@:optional
var holdNoteCover:NoteStyleAssetData;
+
+ /**
+ * The THREE sound (and an optional pre-READY graphic).
+ */
+ @:optional
+ var countdownThree:NoteStyleAssetData;
+
+ /**
+ * The TWO sound and READY graphic.
+ */
+ @:optional
+ var countdownTwo:NoteStyleAssetData;
+
+ /**
+ * The ONE sound and SET graphic.
+ */
+ @:optional
+ var countdownOne:NoteStyleAssetData;
+
+ /**
+ * The GO sound and GO! graphic.
+ */
+ @:optional
+ var countdownGo:NoteStyleAssetData;
+
+ /**
+ * The SICK! judgement.
+ */
+ @:optional
+ var judgementSick:NoteStyleAssetData;
+
+ /**
+ * The GOOD! judgement.
+ */
+ @:optional
+ var judgementGood:NoteStyleAssetData;
+
+ /**
+ * The BAD! judgement.
+ */
+ @:optional
+ var judgementBad:NoteStyleAssetData;
+
+ /**
+ * The SHIT! judgement.
+ */
+ @:optional
+ var judgementShit:NoteStyleAssetData;
+
+ @:optional
+ var comboNumber0:NoteStyleAssetData;
+
+ @:optional
+ var comboNumber1:NoteStyleAssetData;
+
+ @:optional
+ var comboNumber2:NoteStyleAssetData;
+
+ @:optional
+ var comboNumber3:NoteStyleAssetData;
+
+ @:optional
+ var comboNumber4:NoteStyleAssetData;
+
+ @:optional
+ var comboNumber5:NoteStyleAssetData;
+
+ @:optional
+ var comboNumber6:NoteStyleAssetData;
+
+ @:optional
+ var comboNumber7:NoteStyleAssetData;
+
+ @:optional
+ var comboNumber8:NoteStyleAssetData;
+
+ @:optional
+ var comboNumber9:NoteStyleAssetData;
}
/**
@@ -109,10 +187,19 @@ typedef NoteStyleAssetData =
@:optional
var isPixel:Bool;
+ /**
+ * If true, animations will be played on the graphic.
+ * @default `false` to save performance.
+ */
+ @:default(false)
+ @:optional
+ var animated:Bool;
+
/**
* The structure of this data depends on the asset.
*/
- var data:T;
+ @:optional
+ var data:Null;
}
typedef NoteStyleData_Note =
@@ -123,7 +210,14 @@ typedef NoteStyleData_Note =
var right:UnnamedAnimationData;
}
+typedef NoteStyleData_Countdown =
+{
+ var audioPath:String;
+}
+
typedef NoteStyleData_HoldNote = {}
+typedef NoteStyleData_Judgement = {}
+typedef NoteStyleData_ComboNum = {}
/**
* Data on animations for each direction of the strumline.
diff --git a/source/funkin/data/notestyle/NoteStyleRegistry.hx b/source/funkin/data/notestyle/NoteStyleRegistry.hx
index 5e9fa9a3d..36d1b9200 100644
--- a/source/funkin/data/notestyle/NoteStyleRegistry.hx
+++ b/source/funkin/data/notestyle/NoteStyleRegistry.hx
@@ -11,9 +11,9 @@ class NoteStyleRegistry extends BaseRegistry
* Handle breaking changes by incrementing this value
* and adding migration to the `migrateNoteStyleData()` function.
*/
- public static final NOTE_STYLE_DATA_VERSION:thx.semver.Version = "1.0.0";
+ public static final NOTE_STYLE_DATA_VERSION:thx.semver.Version = "1.1.0";
- public static final NOTE_STYLE_DATA_VERSION_RULE:thx.semver.VersionRule = "1.0.x";
+ public static final NOTE_STYLE_DATA_VERSION_RULE:thx.semver.VersionRule = "1.1.x";
public static var instance(get, never):NoteStyleRegistry;
static var _instance:Null = null;
diff --git a/source/funkin/data/song/CHANGELOG.md b/source/funkin/data/song/CHANGELOG.md
index 4f1c66ade..86c9f6912 100644
--- a/source/funkin/data/song/CHANGELOG.md
+++ b/source/funkin/data/song/CHANGELOG.md
@@ -5,6 +5,15 @@ 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.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [2.2.4]
+### Added
+- Added `playData.characters.opponentVocals` to specify which vocal track(s) to play for the opponent.
+ - If the value isn't present, it will use the `playData.characters.opponent`, but if it is present, it will be used (even if it's empty, in which case no vocals will be used for the opponent)
+- Added `playData.characters.playerVocals` to specify which vocal track(s) to play for the player.
+ - If the value isn't present, it will use the `playData.characters.player`, but if it is present, it will be used (even if it's empty, in which case no vocals will be used for the player)
+- Added `offsets.altVocals` field to apply vocal offsets when alternate instrumentals are used.
+
+
## [2.2.3]
### Added
- Added `charter` field to denote authorship of a chart.
diff --git a/source/funkin/data/song/SongData.hx b/source/funkin/data/song/SongData.hx
index 769af8f08..074ed0b44 100644
--- a/source/funkin/data/song/SongData.hx
+++ b/source/funkin/data/song/SongData.hx
@@ -257,18 +257,27 @@ class SongOffsets implements ICloneable
public var altInstrumentals:Map;
/**
- * The offset, in milliseconds, to apply to the song's vocals, relative to the chart.
+ * The offset, in milliseconds, to apply to the song's vocals, relative to the song's base instrumental.
* These are applied ON TOP OF the instrumental offset.
*/
@:optional
@:default([])
public var vocals:Map;
- public function new(instrumental:Float = 0.0, ?altInstrumentals:Map, ?vocals:Map)
+ /**
+ * The offset, in milliseconds, to apply to the songs vocals, relative to each alternate instrumental.
+ * This is useful for the circumstance where, for example, an alt instrumental has a few seconds of lead in before the song starts.
+ */
+ @:optional
+ @:default([])
+ public var altVocals:Map>;
+
+ public function new(instrumental:Float = 0.0, ?altInstrumentals:Map, ?vocals:Map, ?altVocals:Map>)
{
this.instrumental = instrumental;
this.altInstrumentals = altInstrumentals == null ? new Map() : altInstrumentals;
this.vocals = vocals == null ? new Map() : vocals;
+ this.altVocals = altVocals == null ? new Map>() : altVocals;
}
public function getInstrumentalOffset(?instrumental:String):Float
@@ -293,11 +302,19 @@ class SongOffsets implements ICloneable
return value;
}
- public function getVocalOffset(charId:String):Float
+ public function getVocalOffset(charId:String, ?instrumental:String):Float
{
- if (!this.vocals.exists(charId)) return 0.0;
-
- return this.vocals.get(charId);
+ if (instrumental == null)
+ {
+ if (!this.vocals.exists(charId)) return 0.0;
+ return this.vocals.get(charId);
+ }
+ else
+ {
+ if (!this.altVocals.exists(instrumental)) return 0.0;
+ if (!this.altVocals.get(instrumental).exists(charId)) return 0.0;
+ return this.altVocals.get(instrumental).get(charId);
+ }
}
public function setVocalOffset(charId:String, value:Float):Float
@@ -320,7 +337,7 @@ class SongOffsets implements ICloneable
*/
public function toString():String
{
- return 'SongOffsets(${this.instrumental}ms, ${this.altInstrumentals}, ${this.vocals})';
+ return 'SongOffsets(${this.instrumental}ms, ${this.altInstrumentals}, ${this.vocals}, ${this.altVocals})';
}
}
@@ -529,12 +546,26 @@ class SongCharacterData implements ICloneable
@:default([])
public var altInstrumentals:Array = [];
- public function new(player:String = '', girlfriend:String = '', opponent:String = '', instrumental:String = '')
+ @:optional
+ public var opponentVocals:Null> = null;
+
+ @:optional
+ public var playerVocals:Null> = null;
+
+ public function new(player:String = '', girlfriend:String = '', opponent:String = '', instrumental:String = '', ?altInstrumentals:Array,
+ ?opponentVocals:Array, ?playerVocals:Array)
{
this.player = player;
this.girlfriend = girlfriend;
this.opponent = opponent;
this.instrumental = instrumental;
+
+ this.altInstrumentals = altInstrumentals;
+ this.opponentVocals = opponentVocals;
+ this.playerVocals = playerVocals;
+
+ if (opponentVocals == null) this.opponentVocals = [opponent];
+ if (playerVocals == null) this.playerVocals = [player];
}
public function clone():SongCharacterData
@@ -722,18 +753,6 @@ class SongEventDataRaw implements ICloneable
{
return new SongEventDataRaw(this.time, this.eventKind, this.value);
}
-}
-
-/**
- * Wrap SongEventData in an abstract so we can overload operators.
- */
-@:forward(time, eventKind, value, activated, getStepTime, clone)
-abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataRaw
-{
- public function new(time:Float, eventKind:String, value:Dynamic = null)
- {
- this = new SongEventDataRaw(time, eventKind, value);
- }
public function valueAsStruct(?defaultKey:String = "key"):Dynamic
{
@@ -757,27 +776,27 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR
}
}
- public inline function getHandler():Null
+ public function getHandler():Null
{
return SongEventRegistry.getEvent(this.eventKind);
}
- public inline function getSchema():Null
+ public function getSchema():Null
{
return SongEventRegistry.getEventSchema(this.eventKind);
}
- public inline function getDynamic(key:String):Null
+ public function getDynamic(key:String):Null
{
return this.value == null ? null : Reflect.field(this.value, key);
}
- public inline function getBool(key:String):Null
+ public function getBool(key:String):Null
{
return this.value == null ? null : cast Reflect.field(this.value, key);
}
- public inline function getInt(key:String):Null
+ public function getInt(key:String):Null
{
if (this.value == null) return null;
var result = Reflect.field(this.value, key);
@@ -787,7 +806,7 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR
return cast result;
}
- public inline function getFloat(key:String):Null
+ public function getFloat(key:String):Null
{
if (this.value == null) return null;
var result = Reflect.field(this.value, key);
@@ -797,17 +816,17 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR
return cast result;
}
- public inline function getString(key:String):String
+ public function getString(key:String):String
{
return this.value == null ? null : cast Reflect.field(this.value, key);
}
- public inline function getArray(key:String):Array
+ public function getArray(key:String):Array
{
return this.value == null ? null : cast Reflect.field(this.value, key);
}
- public inline function getBoolArray(key:String):Array
+ public function getBoolArray(key:String):Array
{
return this.value == null ? null : cast Reflect.field(this.value, key);
}
@@ -839,6 +858,19 @@ abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataR
return result;
}
+}
+
+/**
+ * Wrap SongEventData in an abstract so we can overload operators.
+ */
+@:forward(time, eventKind, value, activated, getStepTime, clone, getHandler, getSchema, getDynamic, getBool, getInt, getFloat, getString, getArray,
+ getBoolArray, buildTooltip, valueAsStruct)
+abstract SongEventData(SongEventDataRaw) from SongEventDataRaw to SongEventDataRaw
+{
+ public function new(time:Float, eventKind:String, value:Dynamic = null)
+ {
+ this = new SongEventDataRaw(time, eventKind, value);
+ }
public function clone():SongEventData
{
@@ -951,12 +983,18 @@ class SongNoteDataRaw implements ICloneable
return this.kind = value;
}
- public function new(time:Float, data:Int, length:Float = 0, kind:String = '')
+ @:alias("p")
+ @:default([])
+ @:optional
+ public var params:Array;
+
+ public function new(time:Float, data:Int, length:Float = 0, kind:String = '', ?params:Array)
{
this.time = time;
this.data = data;
this.length = length;
this.kind = kind;
+ this.params = params ?? [];
}
/**
@@ -1051,9 +1089,19 @@ class SongNoteDataRaw implements ICloneable
_stepLength = null;
}
+ public function cloneParams():Array
+ {
+ var params:Array = [];
+ for (param in this.params)
+ {
+ params.push(param.clone());
+ }
+ return params;
+ }
+
public function clone():SongNoteDataRaw
{
- return new SongNoteDataRaw(this.time, this.data, this.length, this.kind);
+ return new SongNoteDataRaw(this.time, this.data, this.length, this.kind, cloneParams());
}
public function toString():String
@@ -1069,9 +1117,9 @@ class SongNoteDataRaw implements ICloneable
@:forward
abstract SongNoteData(SongNoteDataRaw) from SongNoteDataRaw to SongNoteDataRaw
{
- public function new(time:Float, data:Int, length:Float = 0, kind:String = '')
+ public function new(time:Float, data:Int, length:Float = 0, kind:String = '', ?params:Array)
{
- this = new SongNoteDataRaw(time, data, length, kind);
+ this = new SongNoteDataRaw(time, data, length, kind, params);
}
public static function buildDirectionName(data:Int, strumlineSize:Int = 4):String
@@ -1115,7 +1163,7 @@ abstract SongNoteData(SongNoteDataRaw) from SongNoteDataRaw to SongNoteDataRaw
if (other.kind == '' || this.kind == null) return false;
}
- return this.time == other.time && this.data == other.data && this.length == other.length;
+ return this.time == other.time && this.data == other.data && this.length == other.length && this.params == other.params;
}
@:op(A != B)
@@ -1134,7 +1182,7 @@ abstract SongNoteData(SongNoteDataRaw) from SongNoteDataRaw to SongNoteDataRaw
if (other.kind == '') return true;
}
- return this.time != other.time || this.data != other.data || this.length != other.length;
+ return this.time != other.time || this.data != other.data || this.length != other.length || this.params != other.params;
}
@:op(A > B)
@@ -1171,7 +1219,7 @@ abstract SongNoteData(SongNoteDataRaw) from SongNoteDataRaw to SongNoteDataRaw
public function clone():SongNoteData
{
- return new SongNoteData(this.time, this.data, this.length, this.kind);
+ return new SongNoteData(this.time, this.data, this.length, this.kind, this.params);
}
/**
@@ -1183,3 +1231,30 @@ abstract SongNoteData(SongNoteDataRaw) from SongNoteDataRaw to SongNoteDataRaw
+ (this.kind != '' ? ' [kind: ${this.kind}])' : ')');
}
}
+
+class NoteParamData implements ICloneable
+{
+ @:alias("n")
+ public var name:String;
+
+ @:alias("v")
+ @:jcustomparse(funkin.data.DataParse.dynamicValue)
+ @:jcustomwrite(funkin.data.DataWrite.dynamicValue)
+ public var value:Dynamic;
+
+ public function new(name:String, value:Dynamic)
+ {
+ this.name = name;
+ this.value = value;
+ }
+
+ public function clone():NoteParamData
+ {
+ return new NoteParamData(this.name, this.value);
+ }
+
+ public function toString():String
+ {
+ return 'NoteParamData(${this.name}, ${this.value})';
+ }
+}
diff --git a/source/funkin/data/song/SongRegistry.hx b/source/funkin/data/song/SongRegistry.hx
index a3305c4ec..e7cab246c 100644
--- a/source/funkin/data/song/SongRegistry.hx
+++ b/source/funkin/data/song/SongRegistry.hx
@@ -20,7 +20,7 @@ class SongRegistry extends BaseRegistry
* Handle breaking changes by incrementing this value
* and adding migration to the `migrateStageData()` function.
*/
- public static final SONG_METADATA_VERSION:thx.semver.Version = "2.2.3";
+ public static final SONG_METADATA_VERSION:thx.semver.Version = "2.2.4";
public static final SONG_METADATA_VERSION_RULE:thx.semver.VersionRule = "2.2.x";
diff --git a/source/funkin/data/song/importer/FNFLegacyImporter.hx b/source/funkin/data/song/importer/FNFLegacyImporter.hx
index acbb99342..96a1051cc 100644
--- a/source/funkin/data/song/importer/FNFLegacyImporter.hx
+++ b/source/funkin/data/song/importer/FNFLegacyImporter.hx
@@ -199,6 +199,8 @@ class FNFLegacyImporter
{
// Handle the dumb logic for mustHitSection.
var noteData = note.data;
+ if (noteData < 0) continue; // Exclude Psych event notes.
+ if (noteData > (STRUMLINE_SIZE * 2)) noteData = noteData % (2 * STRUMLINE_SIZE); // Handle other engine event notes.
// Flip notes if mustHitSection is FALSE (not true lol).
if (!mustHitSection)
diff --git a/source/funkin/data/stage/StageData.hx b/source/funkin/data/stage/StageData.hx
index bebd86d02..eda8e3148 100644
--- a/source/funkin/data/stage/StageData.hx
+++ b/source/funkin/data/stage/StageData.hx
@@ -140,12 +140,12 @@ typedef StageDataProp =
* If not zero, this prop will play an animation every X beats of the song.
* This requires animations to be defined. If `danceLeft` and `danceRight` are defined,
* they will alternated between, otherwise the `idle` animation will be used.
- *
- * @default 0
+ * Supports up to 0.25 precision.
+ * @default 0.0
*/
- @:default(0)
+ @:default(0.0)
@:optional
- var danceEvery:Int;
+ var danceEvery:Float;
/**
* How much the prop scrolls relative to the camera. Used to create a parallax effect.
diff --git a/source/funkin/data/stage/StageRegistry.hx b/source/funkin/data/stage/StageRegistry.hx
index a03371296..87113ef05 100644
--- a/source/funkin/data/stage/StageRegistry.hx
+++ b/source/funkin/data/stage/StageRegistry.hx
@@ -93,8 +93,8 @@ class StageRegistry extends BaseRegistry
public function listBaseGameStageIds():Array
{
return [
- "mainStage", "spookyMansion", "phillyTrain", "limoRide", "mallXmas", "mallEvil", "school", "schoolEvil", "tankmanBattlefield", "phillyStreets",
- "phillyBlazin",
+ "mainStage", "mainStageErect", "spookyMansion", "phillyTrain", "phillyTrainErect", "limoRide", "limoRideErect", "mallXmas", "mallXmasErect", "mallEvil",
+ "school", "schoolEvil", "tankmanBattlefield", "phillyStreets", "phillyStreetsErect", "phillyBlazin",
];
}
diff --git a/source/funkin/data/story/level/LevelData.hx b/source/funkin/data/story/level/LevelData.hx
index ceb2cc054..d1b00bbe6 100644
--- a/source/funkin/data/story/level/LevelData.hx
+++ b/source/funkin/data/story/level/LevelData.hx
@@ -91,11 +91,13 @@ typedef LevelPropData =
/**
* The frequency to bop at, in beats.
- * @default 1 = every beat, 2 = every other beat, etc.
+ * 1 = every beat, 2 = every other beat, etc.
+ * Supports up to 0.25 precision.
+ * @default 1.0
*/
- @:default(1)
+ @:default(1.0)
@:optional
- var danceEvery:Int;
+ var danceEvery:Float;
/**
* The offset on the position to render the prop at.
diff --git a/source/funkin/effects/RetroCameraFade.hx b/source/funkin/effects/RetroCameraFade.hx
new file mode 100644
index 000000000..d4c1da5ef
--- /dev/null
+++ b/source/funkin/effects/RetroCameraFade.hx
@@ -0,0 +1,106 @@
+package funkin.effects;
+
+import flixel.util.FlxTimer;
+import flixel.FlxCamera;
+import openfl.filters.ColorMatrixFilter;
+
+class RetroCameraFade
+{
+ // im lazy, but we only use this for week 6
+ // and also sorta yoinked for djflixel, lol !
+ public static function fadeWhite(camera:FlxCamera, camSteps:Int = 5, time:Float = 1):Void
+ {
+ var steps:Int = 0;
+ var stepsTotal:Int = camSteps;
+
+ new FlxTimer().start(time / stepsTotal, _ -> {
+ var V:Float = (1 / stepsTotal) * steps;
+ if (steps == stepsTotal) V = 1;
+
+ var matrix = [
+ 1, 0, 0, 0, V * 255,
+ 0, 1, 0, 0, V * 255,
+ 0, 0, 1, 0, V * 255,
+ 0, 0, 0, 1, 0
+ ];
+ camera.filters = [new ColorMatrixFilter(matrix)];
+ steps++;
+ }, stepsTotal + 1);
+ }
+
+ public static function fadeFromWhite(camera:FlxCamera, camSteps:Int = 5, time:Float = 1):Void
+ {
+ var steps:Int = camSteps;
+ var stepsTotal:Int = camSteps;
+
+ var matrixDerp = [
+ 1, 0, 0, 0, 1.0 * 255,
+ 0, 1, 0, 0, 1.0 * 255,
+ 0, 0, 1, 0, 1.0 * 255,
+ 0, 0, 0, 1, 0
+ ];
+ camera.filters = [new ColorMatrixFilter(matrixDerp)];
+
+ new FlxTimer().start(time / stepsTotal, _ -> {
+ var V:Float = (1 / stepsTotal) * steps;
+ if (steps == stepsTotal) V = 1;
+
+ var matrix = [
+ 1, 0, 0, 0, V * 255,
+ 0, 1, 0, 0, V * 255,
+ 0, 0, 1, 0, V * 255,
+ 0, 0, 0, 1, 0
+ ];
+ camera.filters = [new ColorMatrixFilter(matrix)];
+ steps--;
+ }, camSteps);
+ }
+
+ public static function fadeToBlack(camera:FlxCamera, camSteps:Int = 5, time:Float = 1):Void
+ {
+ var steps:Int = 0;
+ var stepsTotal:Int = camSteps;
+
+ new FlxTimer().start(time / stepsTotal, _ -> {
+ var V:Float = (1 / stepsTotal) * steps;
+ if (steps == stepsTotal) V = 1;
+
+ var matrix = [
+ 1, 0, 0, 0, -V * 255,
+ 0, 1, 0, 0, -V * 255,
+ 0, 0, 1, 0, -V * 255,
+ 0, 0, 0, 1, 0
+ ];
+ camera.filters = [new ColorMatrixFilter(matrix)];
+ steps++;
+ }, camSteps);
+ }
+
+ public static function fadeBlack(camera:FlxCamera, camSteps:Int = 5, time:Float = 1):Void
+ {
+ var steps:Int = camSteps;
+ var stepsTotal:Int = camSteps;
+
+ var matrixDerp = [
+ 1, 0, 0, 0, -1.0 * 255,
+ 0, 1, 0, 0, -1.0 * 255,
+ 0, 0, 1, 0, -1.0 * 255,
+ 0, 0, 0, 1, 0
+ ];
+ camera.filters = [new ColorMatrixFilter(matrixDerp)];
+
+ new FlxTimer().start(time / stepsTotal, _ -> {
+ var V:Float = (1 / stepsTotal) * steps;
+ if (steps == stepsTotal) V = 1;
+
+ var matrix = [
+ 1, 0, 0, 0, -V * 255,
+ 0, 1, 0, 0, -V * 255,
+ 0, 0, 1, 0, -V * 255,
+ 0, 0, 0, 1, 0
+ ];
+ camera.filters = [new ColorMatrixFilter(matrix)];
+ steps--;
+ }, camSteps + 1);
+ }
+}
diff --git a/source/funkin/graphics/FlxFilteredSprite.hx b/source/funkin/graphics/FlxFilteredSprite.hx
new file mode 100644
index 000000000..ea0376c3d
--- /dev/null
+++ b/source/funkin/graphics/FlxFilteredSprite.hx
@@ -0,0 +1,419 @@
+package funkin.graphics;
+
+import flixel.FlxBasic;
+import flixel.FlxCamera;
+import flixel.FlxG;
+import flixel.FlxSprite;
+import flixel.graphics.FlxGraphic;
+import flixel.graphics.frames.FlxFrame;
+import flixel.math.FlxMatrix;
+import flixel.math.FlxPoint;
+import flixel.math.FlxRect;
+import flixel.util.FlxColor;
+import lime.graphics.cairo.Cairo;
+import openfl.display.BitmapData;
+import openfl.display.BlendMode;
+import openfl.display.DisplayObjectRenderer;
+import openfl.display.Graphics;
+import openfl.display.OpenGLRenderer;
+import openfl.display._internal.Context3DGraphics;
+import openfl.display3D.Context3D;
+import openfl.display3D.Context3DClearMask;
+import openfl.filters.BitmapFilter;
+import openfl.filters.BlurFilter;
+import openfl.geom.ColorTransform;
+import openfl.geom.Matrix;
+import openfl.geom.Point;
+import openfl.geom.Rectangle;
+#if (js && html5)
+import lime._internal.graphics.ImageCanvasUtil;
+import openfl.display.CanvasRenderer;
+import openfl.display._internal.CanvasGraphics as GfxRenderer;
+#else
+import openfl.display.CairoRenderer;
+import openfl.display._internal.CairoGraphics as GfxRenderer;
+#end
+
+/**
+ * A modified `FlxSprite` that supports filters.
+ * The name's pretty much self-explanatory.
+ */
+@:access(openfl.geom.Rectangle)
+@:access(openfl.filters.BitmapFilter)
+@:access(flixel.graphics.frames.FlxFrame)
+class FlxFilteredSprite extends FlxSprite
+{
+ @:noCompletion var _renderer:FlxAnimateFilterRenderer = new FlxAnimateFilterRenderer();
+
+ @:noCompletion var _filterMatrix:FlxMatrix;
+
+ /**
+ * An `Array` of shader filters (aka `BitmapFilter`).
+ */
+ public var filters(default, set):Array;
+
+ /**
+ * a flag to update the image with the filters.
+ * Useful when trying to render a shader at all times.
+ */
+ public var filterDirty:Bool = false;
+
+ @:noCompletion var filtered:Bool;
+
+ @:noCompletion var _blankFrame:FlxFrame;
+
+ var _filterBmp1:BitmapData;
+ var _filterBmp2:BitmapData;
+
+ override public function update(elapsed:Float)
+ {
+ super.update(elapsed);
+ if (!filterDirty && filters != null)
+ {
+ for (filter in filters)
+ {
+ if (filter.__renderDirty)
+ {
+ filterDirty = true;
+ break;
+ }
+ }
+ }
+ }
+
+ @:noCompletion
+ override function initVars():Void
+ {
+ super.initVars();
+ _filterMatrix = new FlxMatrix();
+ filters = null;
+ filtered = false;
+ }
+
+ override public function draw():Void
+ {
+ checkEmptyFrame();
+
+ if (alpha == 0 || _frame.type == FlxFrameType.EMPTY) return;
+
+ if (dirty) // rarely
+ calcFrame(useFramePixels);
+
+ if (filterDirty) filterFrame();
+
+ for (camera in cameras)
+ {
+ if (!camera.visible || !camera.exists || !isOnScreen(camera)) continue;
+
+ getScreenPosition(_point, camera).subtractPoint(offset);
+
+ if (isSimpleRender(camera)) drawSimple(camera);
+ else
+ drawComplex(camera);
+
+ #if FLX_DEBUG
+ FlxBasic.visibleCount++;
+ #end
+ }
+
+ #if FLX_DEBUG
+ if (FlxG.debugger.drawDebug) drawDebug();
+ #end
+ }
+
+ @:noCompletion
+ override function drawComplex(camera:FlxCamera):Void
+ {
+ _frame.prepareMatrix(_matrix, FlxFrameAngle.ANGLE_0, checkFlipX(), checkFlipY());
+ _matrix.concat(_filterMatrix);
+ _matrix.translate(-origin.x, -origin.y);
+ _matrix.scale(scale.x, scale.y);
+
+ if (bakedRotationAngle <= 0)
+ {
+ updateTrig();
+
+ if (angle != 0) _matrix.rotateWithTrig(_cosAngle, _sinAngle);
+ }
+
+ _point.add(origin.x, origin.y);
+ _matrix.translate(_point.x, _point.y);
+
+ if (isPixelPerfectRender(camera))
+ {
+ _matrix.tx = Math.floor(_matrix.tx);
+ _matrix.ty = Math.floor(_matrix.ty);
+ }
+
+ camera.drawPixels((filtered) ? _blankFrame : _frame, framePixels, _matrix, colorTransform, blend, antialiasing, shader);
+ }
+
+ @:noCompletion
+ function filterFrame()
+ {
+ filterDirty = false;
+ _filterMatrix.identity();
+
+ if (filters != null && filters.length > 0)
+ {
+ _flashRect.setEmpty();
+
+ for (filter in filters)
+ {
+ _flashRect.__expand(-filter.__leftExtension,
+ -filter.__topExtension, filter.__leftExtension
+ + filter.__rightExtension,
+ filter.__topExtension
+ + filter.__bottomExtension);
+ }
+ _flashRect.width += frameWidth;
+ _flashRect.height += frameHeight;
+ if (_blankFrame == null) _blankFrame = new FlxFrame(null);
+
+ if (_blankFrame.parent == null || _flashRect.width > _blankFrame.parent.width || _flashRect.height > _blankFrame.parent.height)
+ {
+ if (_blankFrame.parent != null)
+ {
+ _blankFrame.parent.destroy();
+ _filterBmp1.dispose();
+ _filterBmp2.dispose();
+ }
+
+ _blankFrame.parent = FlxGraphic.fromRectangle(Math.ceil(_flashRect.width * 1.25), Math.ceil(_flashRect.height * 1.25), 0, true);
+ _filterBmp1 = new BitmapData(_blankFrame.parent.width, _blankFrame.parent.height, 0);
+ _filterBmp2 = new BitmapData(_blankFrame.parent.width, _blankFrame.parent.height, 0);
+ }
+ _blankFrame.offset.copyFrom(_frame.offset);
+ _blankFrame.parent.bitmap = _renderer.applyFilter(_blankFrame.parent.bitmap, _filterBmp1, _filterBmp2, frame.parent.bitmap, filters, _flashRect,
+ frame.frame.copyToFlash());
+ _blankFrame.frame = FlxRect.get(0, 0, _blankFrame.parent.bitmap.width, _blankFrame.parent.bitmap.height);
+ _filterMatrix.translate(_flashRect.x, _flashRect.y);
+ _frame = _blankFrame.copyTo();
+ filtered = true;
+ }
+ else
+ {
+ resetFrame();
+ filtered = false;
+ }
+ }
+
+ @:noCompletion
+ function set_filters(value:Array)
+ {
+ if (filters != value) filterDirty = true;
+
+ return filters = value;
+ }
+
+ @:noCompletion
+ override function set_frame(value:FlxFrame)
+ {
+ if (value != frame) filterDirty = true;
+
+ return super.set_frame(value);
+ }
+
+ override public function destroy()
+ {
+ super.destroy();
+ }
+}
+
+@:noCompletion
+@:access(openfl.display.OpenGLRenderer)
+@:access(openfl.filters.BitmapFilter)
+@:access(openfl.geom.Rectangle)
+@:access(openfl.display.Stage)
+@:access(openfl.display.Graphics)
+@:access(openfl.display.Shader)
+@:access(openfl.display.BitmapData)
+@:access(openfl.geom.ColorTransform)
+@:access(openfl.display.DisplayObject)
+@:access(openfl.display3D.Context3D)
+@:access(openfl.display.CanvasRenderer)
+@:access(openfl.display.CairoRenderer)
+@:access(openfl.display3D.Context3D)
+class FlxAnimateFilterRenderer
+{
+ var renderer:OpenGLRenderer;
+ var context:Context3D;
+
+ public function new()
+ {
+ // context = new openfl.display3D.Context3D(null);
+ renderer = new OpenGLRenderer(FlxG.game.stage.context3D);
+ renderer.__worldTransform = new Matrix();
+ renderer.__worldColorTransform = new ColorTransform();
+ }
+
+ @:noCompletion function setRenderer(renderer:DisplayObjectRenderer, rect:Rectangle)
+ {
+ @:privateAccess
+ if (true)
+ {
+ var displayObject = FlxG.game;
+ var pixelRatio = FlxG.game.stage.__renderer.__pixelRatio;
+
+ var offsetX = rect.x > 0 ? Math.ceil(rect.x) : Math.floor(rect.x);
+ var offsetY = rect.y > 0 ? Math.ceil(rect.y) : Math.floor(rect.y);
+ if (renderer.__worldTransform == null)
+ {
+ renderer.__worldTransform = new Matrix();
+ renderer.__worldColorTransform = new ColorTransform();
+ }
+ if (displayObject.__cacheBitmapColorTransform == null) displayObject.__cacheBitmapColorTransform = new ColorTransform();
+
+ renderer.__stage = displayObject.stage;
+
+ renderer.__allowSmoothing = true;
+ renderer.__setBlendMode(NORMAL);
+ renderer.__worldAlpha = 1 / displayObject.__worldAlpha;
+
+ renderer.__worldTransform.identity();
+ renderer.__worldTransform.invert();
+ renderer.__worldTransform.concat(new Matrix());
+ renderer.__worldTransform.tx -= offsetX;
+ renderer.__worldTransform.ty -= offsetY;
+ renderer.__worldTransform.scale(pixelRatio, pixelRatio);
+
+ renderer.__pixelRatio = pixelRatio;
+ }
+ }
+
+ public function applyFilter(target:BitmapData = null, target1:BitmapData = null, target2:BitmapData = null, bmp:BitmapData, filters:Array,
+ rect:Rectangle, bmpRect:Rectangle)
+ {
+ if (filters == null || filters.length == 0) return bmp;
+
+ renderer.__setBlendMode(NORMAL);
+ renderer.__worldAlpha = 1;
+
+ if (renderer.__worldTransform == null)
+ {
+ renderer.__worldTransform = new Matrix();
+ renderer.__worldColorTransform = new ColorTransform();
+ }
+ renderer.__worldTransform.identity();
+ renderer.__worldColorTransform.__identity();
+
+ var bitmap:BitmapData = (target == null) ? new BitmapData(Math.ceil(rect.width * 1.25), Math.ceil(rect.height * 1.25), true, 0) : target;
+
+ var bitmap2 = (target1 == null) ? new BitmapData(Math.ceil(rect.width * 1.25), Math.ceil(rect.height * 1.25), true, 0) : target1,
+ bitmap3 = (target2 == null) ? bitmap2.clone() : target2;
+ renderer.__setRenderTarget(bitmap);
+
+ bmp.__renderTransform.translate(Math.abs(rect.x) - bmpRect.x, Math.abs(rect.y) - bmpRect.y);
+ bmpRect.x = Math.abs(rect.x);
+ bmpRect.y = Math.abs(rect.y);
+
+ var bestResolution = renderer.__context3D.__backBufferWantsBestResolution;
+ renderer.__context3D.__backBufferWantsBestResolution = false;
+ renderer.__scissorRect(bmpRect);
+ renderer.__renderFilterPass(bmp, renderer.__defaultDisplayShader, true);
+ renderer.__scissorRect();
+
+ renderer.__context3D.__backBufferWantsBestResolution = bestResolution;
+
+ bmp.__renderTransform.identity();
+
+ var shader, cacheBitmap = null;
+ for (filter in filters)
+ {
+ if (filter.__preserveObject)
+ {
+ renderer.__setRenderTarget(bitmap3);
+ renderer.__renderFilterPass(bitmap, renderer.__defaultDisplayShader, filter.__smooth);
+ }
+
+ for (i in 0...filter.__numShaderPasses)
+ {
+ shader = filter.__initShader(renderer, i, (filter.__preserveObject) ? bitmap3 : null);
+ renderer.__setBlendMode(filter.__shaderBlendMode);
+ renderer.__setRenderTarget(bitmap2);
+ renderer.__renderFilterPass(bitmap, shader, filter.__smooth);
+
+ cacheBitmap = bitmap;
+ bitmap = bitmap2;
+ bitmap2 = cacheBitmap;
+ }
+ filter.__renderDirty = false;
+ }
+ if (target1 == null) bitmap2.dispose();
+ if (target2 == null) bitmap3.dispose();
+
+ // var gl = renderer.__gl;
+
+ // var renderBuffer = bitmap.getTexture(renderer.__context3D);
+ // @:privateAccess
+ // gl.readPixels(0, 0, bitmap.width, bitmap.height, renderBuffer.__format, gl.UNSIGNED_BYTE, bitmap.image.data);
+ // bitmap.image.version = 0;
+ // @:privateAccess
+ // bitmap.__textureVersion = -1;
+
+ return bitmap;
+ }
+
+ public function applyBlend(blend:BlendMode, bitmap:BitmapData)
+ {
+ bitmap.__update(false, true);
+ var bmp = new BitmapData(bitmap.width, bitmap.height, 0);
+
+ #if (js && html5)
+ ImageCanvasUtil.convertToCanvas(bmp.image);
+ @:privateAccess
+ var renderer = new CanvasRenderer(bmp.image.buffer.__srcContext);
+ #else
+ var renderer = new CairoRenderer(new Cairo(bmp.getSurface()));
+ #end
+
+ // setRenderer(renderer, bmp.rect);
+
+ var m = new Matrix();
+ var c = new ColorTransform();
+ renderer.__allowSmoothing = true;
+ renderer.__overrideBlendMode = blend;
+ renderer.__worldTransform = m;
+ renderer.__worldAlpha = 1;
+ renderer.__worldColorTransform = c;
+
+ renderer.__setBlendMode(blend);
+ #if (js && html5)
+ bmp.__drawCanvas(bitmap, renderer);
+ #else
+ bmp.__drawCairo(bitmap, renderer);
+ #end
+
+ return bitmap;
+ }
+
+ public function graphicstoBitmapData(gfx:Graphics)
+ {
+ if (gfx.__bounds == null) return null;
+ // var cacheRTT = renderer.__context3D.__state.renderToTexture;
+ // var cacheRTTDepthStencil = renderer.__context3D.__state.renderToTextureDepthStencil;
+ // var cacheRTTAntiAlias = renderer.__context3D.__state.renderToTextureAntiAlias;
+ // var cacheRTTSurfaceSelector = renderer.__context3D.__state.renderToTextureSurfaceSelector;
+
+ // var bmp = new BitmapData(Math.ceil(gfx.__width), Math.ceil(gfx.__height), 0);
+ // renderer.__context3D.setRenderToTexture(bmp.getTexture(renderer.__context3D));
+ // gfx.__owner.__renderTransform.identity();
+ // gfx.__renderTransform.identity();
+ // Context3DGraphics.render(gfx, renderer);
+ GfxRenderer.render(gfx, cast renderer.__softwareRenderer);
+ var bmp = gfx.__bitmap;
+
+ gfx.__bitmap = null;
+
+ // if (cacheRTT != null)
+ // {
+ // renderer.__context3D.setRenderToTexture(cacheRTT, cacheRTTDepthStencil, cacheRTTAntiAlias, cacheRTTSurfaceSelector);
+ // }
+ // else
+ // {
+ // renderer.__context3D.setRenderToBackBuffer();
+ // }
+
+ return bmp;
+ }
+}
diff --git a/source/funkin/graphics/FunkinSprite.hx b/source/funkin/graphics/FunkinSprite.hx
index bfd2e8028..521553527 100644
--- a/source/funkin/graphics/FunkinSprite.hx
+++ b/source/funkin/graphics/FunkinSprite.hx
@@ -7,6 +7,10 @@ import flixel.tweens.FlxTween;
import openfl.display3D.textures.TextureBase;
import funkin.graphics.framebuffer.FixedBitmapData;
import openfl.display.BitmapData;
+import flixel.math.FlxRect;
+import flixel.math.FlxPoint;
+import flixel.graphics.frames.FlxFrame;
+import flixel.FlxCamera;
/**
* An FlxSprite with additional functionality.
@@ -269,6 +273,103 @@ class FunkinSprite extends FlxSprite
return result;
}
+ @:access(flixel.FlxCamera)
+ override function getBoundingBox(camera:FlxCamera):FlxRect
+ {
+ getScreenPosition(_point, camera);
+
+ _rect.set(_point.x, _point.y, width, height);
+ _rect = camera.transformRect(_rect);
+
+ if (isPixelPerfectRender(camera))
+ {
+ _rect.width = _rect.width / this.scale.x;
+ _rect.height = _rect.height / this.scale.y;
+ _rect.x = _rect.x / this.scale.x;
+ _rect.y = _rect.y / this.scale.y;
+ _rect.floor();
+ _rect.x = _rect.x * this.scale.x;
+ _rect.y = _rect.y * this.scale.y;
+ _rect.width = _rect.width * this.scale.x;
+ _rect.height = _rect.height * this.scale.y;
+ }
+
+ return _rect;
+ }
+
+ /**
+ * Returns the screen position of this object.
+ *
+ * @param result Optional arg for the returning point
+ * @param camera The desired "screen" coordinate space. If `null`, `FlxG.camera` is used.
+ * @return The screen position of this object.
+ */
+ public override function getScreenPosition(?result:FlxPoint, ?camera:FlxCamera):FlxPoint
+ {
+ if (result == null) result = FlxPoint.get();
+
+ if (camera == null) camera = FlxG.camera;
+
+ result.set(x, y);
+ if (pixelPerfectPosition)
+ {
+ _rect.width = _rect.width / this.scale.x;
+ _rect.height = _rect.height / this.scale.y;
+ _rect.x = _rect.x / this.scale.x;
+ _rect.y = _rect.y / this.scale.y;
+ _rect.round();
+ _rect.x = _rect.x * this.scale.x;
+ _rect.y = _rect.y * this.scale.y;
+ _rect.width = _rect.width * this.scale.x;
+ _rect.height = _rect.height * this.scale.y;
+ }
+
+ return result.subtract(camera.scroll.x * scrollFactor.x, camera.scroll.y * scrollFactor.y);
+ }
+
+ override function drawSimple(camera:FlxCamera):Void
+ {
+ getScreenPosition(_point, camera).subtractPoint(offset);
+ if (isPixelPerfectRender(camera))
+ {
+ _point.x = _point.x / this.scale.x;
+ _point.y = _point.y / this.scale.y;
+ _point.round();
+
+ _point.x = _point.x * this.scale.x;
+ _point.y = _point.y * this.scale.y;
+ }
+
+ _point.copyToFlash(_flashPoint);
+ camera.copyPixels(_frame, framePixels, _flashRect, _flashPoint, colorTransform, blend, antialiasing);
+ }
+
+ override function drawComplex(camera:FlxCamera):Void
+ {
+ _frame.prepareMatrix(_matrix, FlxFrameAngle.ANGLE_0, checkFlipX(), checkFlipY());
+ _matrix.translate(-origin.x, -origin.y);
+ _matrix.scale(scale.x, scale.y);
+
+ if (bakedRotationAngle <= 0)
+ {
+ updateTrig();
+
+ if (angle != 0) _matrix.rotateWithTrig(_cosAngle, _sinAngle);
+ }
+
+ getScreenPosition(_point, camera).subtractPoint(offset);
+ _point.add(origin.x, origin.y);
+ _matrix.translate(_point.x, _point.y);
+
+ if (isPixelPerfectRender(camera))
+ {
+ _matrix.tx = Math.round(_matrix.tx / this.scale.x) * this.scale.x;
+ _matrix.ty = Math.round(_matrix.ty / this.scale.y) * this.scale.y;
+ }
+
+ camera.drawPixels(_frame, framePixels, _matrix, colorTransform, blend, antialiasing, shader);
+ }
+
public override function destroy():Void
{
frames = null;
diff --git a/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx b/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx
index 8a77c1c85..952fa8b71 100644
--- a/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx
+++ b/source/funkin/graphics/adobeanimate/FlxAtlasSprite.hx
@@ -4,8 +4,12 @@ import flixel.util.FlxSignal.FlxTypedSignal;
import flxanimate.FlxAnimate;
import flxanimate.FlxAnimate.Settings;
import flxanimate.frames.FlxAnimateFrames;
+import flixel.graphics.frames.FlxFrame;
+import flixel.system.FlxAssets.FlxGraphicAsset;
import openfl.display.BitmapData;
import openfl.utils.Assets;
+import flixel.math.FlxPoint;
+import flxanimate.animate.FlxKeyFrame;
/**
* A sprite which provides convenience functions for rendering a texture atlas with animations.
@@ -18,16 +22,21 @@ class FlxAtlasSprite extends FlxAnimate
FrameRate: 24.0,
Reversed: false,
// ?OnComplete:Void -> Void,
- ShowPivot: #if debug false #else false #end,
+ ShowPivot: false,
Antialiasing: true,
ScrollFactor: null,
// Offset: new FlxPoint(0, 0), // This is just FlxSprite.offset
};
/**
- * Signal dispatched when an animation finishes playing.
+ * Signal dispatched when an animation advances to the next frame.
*/
- public var onAnimationFinish:FlxTypedSignalVoid> = new FlxTypedSignalVoid>();
+ public var onAnimationFrame:FlxTypedSignalInt->Void> = new FlxTypedSignal();
+
+ /**
+ * Signal dispatched when a non-looping animation finishes playing.
+ */
+ public var onAnimationComplete:FlxTypedSignalVoid> = new FlxTypedSignal();
var currentAnimation:String;
@@ -42,19 +51,28 @@ class FlxAtlasSprite extends FlxAnimate
throw 'Null path specified for FlxAtlasSprite!';
}
+ // Validate asset path.
+ if (!Assets.exists('${path}/Animation.json'))
+ {
+ throw 'FlxAtlasSprite does not have an Animation.json file at the specified path (${path})';
+ }
+
super(x, y, path, settings);
- if (this.anim.curInstance == null)
+ if (this.anim.stageInstance == null)
{
throw 'FlxAtlasSprite not initialized properly. Are you sure the path (${path}) exists?';
}
- onAnimationFinish.add(cleanupAnimation);
+ onAnimationComplete.add(cleanupAnimation);
// This defaults the sprite to play the first animation in the atlas,
// then pauses it. This ensures symbols are intialized properly.
this.anim.play('');
this.anim.pause();
+
+ this.anim.onComplete.add(_onAnimationComplete);
+ this.anim.onFrame.add(_onAnimationFrame);
}
/**
@@ -62,9 +80,13 @@ class FlxAtlasSprite extends FlxAnimate
*/
public function listAnimations():Array
{
- if (this.anim == null) return [];
- return this.anim.getFrameLabels();
- // return [""];
+ var mainSymbol = this.anim.symbolDictionary[this.anim.stageInstance.symbol.name];
+ if (mainSymbol == null)
+ {
+ FlxG.log.error('FlxAtlasSprite does not have its main symbol!');
+ return [];
+ }
+ return mainSymbol.getFrameLabels().map(keyFrame -> keyFrame.name).filterNull();
}
/**
@@ -73,7 +95,7 @@ class FlxAtlasSprite extends FlxAnimate
*/
public function hasAnimation(id:String):Bool
{
- return getLabelIndex(id) != -1;
+ return getLabelIndex(id) != -1 || anim.symbolDictionary.exists(id);
}
/**
@@ -84,22 +106,13 @@ class FlxAtlasSprite extends FlxAnimate
return this.currentAnimation;
}
- /**
- * `anim.finished` always returns false on looping animations,
- * but this function will return true if we are on the last frame of the looping animation.
- */
- public function isLoopFinished():Bool
- {
- if (this.anim == null) return false;
- if (!this.anim.isPlaying) return false;
+ var _completeAnim:Bool = false;
- // Reverse animation finished.
- if (this.anim.reversed && this.anim.curFrame == 0) return true;
- // Forward animation finished.
- if (!this.anim.reversed && this.anim.curFrame >= (this.anim.length - 1)) return true;
+ var fr:FlxKeyFrame = null;
- return false;
- }
+ var looping:Bool = false;
+
+ public var ignoreExclusionPref:Array = [];
/**
* Plays an animation.
@@ -107,61 +120,86 @@ class FlxAtlasSprite extends FlxAnimate
* @param restart Whether to restart the animation if it is already playing.
* @param ignoreOther Whether to ignore all other animation inputs, until this one is done playing
* @param loop Whether to loop the animation
+ * @param startFrame The frame to start the animation on
* NOTE: `loop` and `ignoreOther` are not compatible with each other!
*/
- public function playAnimation(id:String, restart:Bool = false, ignoreOther:Bool = false, ?loop:Bool = false):Void
+ public function playAnimation(id:String, restart:Bool = false, ignoreOther:Bool = false, loop:Bool = false, startFrame:Int = 0):Void
{
- if (loop == null) loop = false;
-
// Skip if not allowed to play animations.
- if ((!canPlayOtherAnims && !ignoreOther)) return;
+ if ((!canPlayOtherAnims))
+ {
+ if (this.currentAnimation == id && restart) {}
+ else if (ignoreExclusionPref != null && ignoreExclusionPref.length > 0)
+ {
+ var detected:Bool = false;
+ for (entry in ignoreExclusionPref)
+ {
+ if (StringTools.startsWith(id, entry))
+ {
+ detected = true;
+ break;
+ }
+ }
+ if (!detected) return;
+ }
+ else
+ return;
+ }
+
+ if (anim == null) return;
if (id == null || id == '') id = this.currentAnimation;
if (this.currentAnimation == id && !restart)
{
- if (anim.isPlaying)
+ if (!anim.isPlaying)
{
- // Skip if animation is already playing.
- return;
- }
- else
- {
- // Resume animation if it's paused.
- anim.play('', false, false);
- }
- }
+ if (fr != null) anim.curFrame = fr.index + startFrame;
+ else
+ anim.curFrame = startFrame;
- // Skip if the animation doesn't exist
- if (!hasAnimation(id))
+ // Resume animation if it's paused.
+ anim.resume();
+ }
+
+ return;
+ }
+ else if (!hasAnimation(id))
{
+ // Skip if the animation doesn't exist
trace('Animation ' + id + ' not found');
return;
}
- anim.callback = function(_, frame:Int) {
- var offset = loop ? 0 : -1;
+ this.currentAnimation = id;
+ anim.onComplete.removeAll();
+ anim.onComplete.add(function() {
+ _onAnimationComplete();
+ });
- var frameLabel = anim.getFrameLabel(id);
- if (frame == (frameLabel.duration + offset) + frameLabel.index)
- {
- if (loop)
- {
- playAnimation(id, true, false, true);
- }
- else
- {
- onAnimationFinish.dispatch(id);
- }
- }
- };
+ looping = loop;
// Prevent other animations from playing if `ignoreOther` is true.
if (ignoreOther) canPlayOtherAnims = false;
// Move to the first frame of the animation.
- goToFrameLabel(id);
- this.currentAnimation = id;
+ // goToFrameLabel(id);
+ trace('Playing animation $id');
+ if ((id == null || id == "") || this.anim.symbolDictionary.exists(id) || (this.anim.getByName(id) != null))
+ {
+ this.anim.play(id, restart, false, startFrame);
+
+ this.currentAnimation = anim.curSymbol.name;
+
+ fr = null;
+ }
+ // Only call goToFrameLabel if there is a frame label with that name. This prevents annoying warnings!
+ if (getFrameLabelNames().indexOf(id) != -1)
+ {
+ goToFrameLabel(id);
+ fr = anim.getFrameLabel(id);
+ anim.curFrame += startFrame;
+ }
}
override public function update(elapsed:Float)
@@ -169,6 +207,29 @@ class FlxAtlasSprite extends FlxAnimate
super.update(elapsed);
}
+ /**
+ * Returns true if the animation has finished playing.
+ * Never true if animation is configured to loop.
+ */
+ public function isAnimationFinished():Bool
+ {
+ return this.anim.finished;
+ }
+
+ /**
+ * Returns true if the animation has reached the last frame.
+ * Can be true even if animation is configured to loop.
+ */
+ public function isLoopComplete():Bool
+ {
+ if (this.anim == null) return false;
+ if (!this.anim.isPlaying) return false;
+
+ if (fr != null) return (anim.reversed && anim.curFrame < fr.index || !anim.reversed && anim.curFrame >= (fr.index + fr.duration));
+
+ return (anim.reversed && anim.curFrame == 0 || !(anim.reversed) && (anim.curFrame) >= (anim.length - 1));
+ }
+
/**
* Stops the current animation.
*/
@@ -192,6 +253,18 @@ class FlxAtlasSprite extends FlxAnimate
this.anim.goToFrameLabel(label);
}
+ function getFrameLabelNames(?layer:haxe.extern.EitherType = null)
+ {
+ var labels = this.anim.getFrameLabels(layer);
+ var array = [];
+ for (label in labels)
+ {
+ array.push(label.name);
+ }
+
+ return array;
+ }
+
function getNextFrameLabel(label:String):String
{
return listAnimations()[(getLabelIndex(label) + 1) % listAnimations().length];
@@ -213,4 +286,95 @@ class FlxAtlasSprite extends FlxAnimate
// this.currentAnimation = null;
this.anim.pause();
}
+
+ function _onAnimationFrame(frame:Int):Void
+ {
+ if (currentAnimation != null)
+ {
+ onAnimationFrame.dispatch(currentAnimation, frame);
+
+ if (isLoopComplete())
+ {
+ anim.pause();
+ _onAnimationComplete();
+
+ if (looping)
+ {
+ anim.curFrame = (fr != null) ? fr.index : 0;
+ anim.resume();
+ }
+ else if (fr != null && anim.curFrame != anim.length - 1)
+ {
+ anim.curFrame--;
+ }
+ }
+ }
+ }
+
+ function _onAnimationComplete():Void
+ {
+ if (currentAnimation != null)
+ {
+ onAnimationComplete.dispatch(currentAnimation);
+ }
+ else
+ {
+ onAnimationComplete.dispatch('');
+ }
+ }
+
+ var prevFrames:Map = [];
+
+ public function replaceFrameGraphic(index:Int, ?graphic:FlxGraphicAsset):Void
+ {
+ if (graphic == null || !Assets.exists(graphic))
+ {
+ var prevFrame:Null = prevFrames.get(index);
+ if (prevFrame == null) return;
+
+ prevFrame.copyTo(frames.getByIndex(index));
+ return;
+ }
+
+ var prevFrame:FlxFrame = prevFrames.get(index) ?? frames.getByIndex(index).copyTo();
+ prevFrames.set(index, prevFrame);
+
+ var frame = FlxG.bitmap.add(graphic).imageFrame.frame;
+ frame.copyTo(frames.getByIndex(index));
+
+ // Additional sizing fix.
+ @:privateAccess
+ if (true)
+ {
+ var frame = frames.getByIndex(index);
+ frame.tileMatrix[0] = prevFrame.frame.width / frame.frame.width;
+ frame.tileMatrix[3] = prevFrame.frame.height / frame.frame.height;
+ }
+ }
+
+ public function getBasePosition():Null
+ {
+ // var stagePos = new FlxPoint(anim.stageInstance.matrix.tx, anim.stageInstance.matrix.ty);
+ var instancePos = new FlxPoint(anim.curInstance.matrix.tx, anim.curInstance.matrix.ty);
+ var firstElement = anim.curSymbol.timeline?.get(0)?.get(0)?.get(0);
+ if (firstElement == null) return instancePos;
+ var firstElementPos = new FlxPoint(firstElement.matrix.tx, firstElement.matrix.ty);
+
+ return instancePos + firstElementPos;
+ }
+
+ public function getPivotPosition():Null
+ {
+ return anim.curInstance.symbol.transformationPoint;
+ }
+
+ public override function destroy():Void
+ {
+ for (prevFrameId in prevFrames.keys())
+ {
+ replaceFrameGraphic(prevFrameId, null);
+ }
+
+ super.destroy();
+ }
}
diff --git a/source/funkin/graphics/shaders/AdjustColorShader.hx b/source/funkin/graphics/shaders/AdjustColorShader.hx
new file mode 100644
index 000000000..2b0970eeb
--- /dev/null
+++ b/source/funkin/graphics/shaders/AdjustColorShader.hx
@@ -0,0 +1,55 @@
+package funkin.graphics.shaders;
+
+import flixel.addons.display.FlxRuntimeShader;
+import funkin.Paths;
+import openfl.utils.Assets;
+
+class AdjustColorShader extends FlxRuntimeShader
+{
+ public var hue(default, set):Float;
+ public var saturation(default, set):Float;
+ public var brightness(default, set):Float;
+ public var contrast(default, set):Float;
+
+ public function new()
+ {
+ super(Assets.getText(Paths.frag('adjustColor')));
+ // FlxG.debugger.addTrackerProfile(new TrackerProfile(HSVShader, ['hue', 'saturation', 'brightness', 'contrast']));
+ hue = 0;
+ saturation = 0;
+ brightness = 0;
+ contrast = 0;
+ }
+
+ function set_hue(value:Float):Float
+ {
+ this.setFloat('hue', value);
+ this.hue = value;
+
+ return this.hue;
+ }
+
+ function set_saturation(value:Float):Float
+ {
+ this.setFloat('saturation', value);
+ this.saturation = value;
+
+ return this.saturation;
+ }
+
+ function set_brightness(value:Float):Float
+ {
+ this.setFloat('brightness', value);
+ this.brightness = value;
+
+ return this.brightness;
+ }
+
+ function set_contrast(value:Float):Float
+ {
+ this.setFloat('contrast', value);
+ this.contrast = value;
+
+ return this.contrast;
+ }
+}
diff --git a/source/funkin/graphics/shaders/AngleMask.hx b/source/funkin/graphics/shaders/AngleMask.hx
index c5ef87b72..ce27311cd 100644
--- a/source/funkin/graphics/shaders/AngleMask.hx
+++ b/source/funkin/graphics/shaders/AngleMask.hx
@@ -1,12 +1,25 @@
package funkin.graphics.shaders;
import flixel.system.FlxAssets.FlxShader;
+import flixel.util.FlxColor;
class AngleMask extends FlxShader
{
+ public var extraColor(default, set):FlxColor = 0xFFFFFFFF;
+
+ function set_extraColor(value:FlxColor):FlxColor
+ {
+ extraTint.value = [value.redFloat, value.greenFloat, value.blueFloat];
+ this.extraColor = value;
+
+ return this.extraColor;
+ }
+
@:glFragmentSource('
#pragma header
+ uniform vec3 extraTint;
+
uniform vec2 endPosition;
vec2 hash22(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * vec3(.1031, .1030, .0973));
@@ -69,6 +82,7 @@ class AngleMask extends FlxShader
void main() {
vec4 col = antialias(openfl_TextureCoordv);
+ col.xyz = col.xyz * extraTint.xyz;
// col.xyz = gamma(col.xyz);
gl_FragColor = col;
}')
@@ -77,5 +91,6 @@ class AngleMask extends FlxShader
super();
endPosition.value = [90, 100]; // 100 AS DEFAULT WORKS NICELY FOR FREEPLAY?
+ extraTint.value = [1, 1, 1];
}
}
diff --git a/source/funkin/graphics/shaders/BlueFade.hx b/source/funkin/graphics/shaders/BlueFade.hx
new file mode 100644
index 000000000..f57bcfbf1
--- /dev/null
+++ b/source/funkin/graphics/shaders/BlueFade.hx
@@ -0,0 +1,51 @@
+package funkin.graphics.shaders;
+
+import flixel.system.FlxAssets.FlxShader;
+import flixel.tweens.FlxEase;
+import flixel.tweens.FlxTween;
+
+class BlueFade extends FlxShader
+{
+ public var fadeVal(default, set):Float;
+
+ function set_fadeVal(val:Float):Float
+ {
+ fadeAmt.value = [val];
+ fadeVal = val;
+ // trace(fadeVal);
+
+ return val;
+ }
+
+ public function fade(startAmt:Float = 0, targetAmt:Float = 1, duration:Float, _options:TweenOptions):Void
+ {
+ fadeVal = startAmt;
+ FlxTween.tween(this, {fadeVal: targetAmt}, duration, _options);
+ }
+
+ @:glFragmentSource('
+ #pragma header
+
+ // Value from (0, 1)
+ uniform float fadeAmt;
+
+ // fade the image to blue as it fades to black
+
+ void main()
+ {
+ vec4 tex = flixel_texture2D(bitmap, openfl_TextureCoordv);
+
+ vec4 finalColor = mix(vec4(vec4(0.0, 0.0, tex.b, tex.a) * fadeAmt), vec4(tex * fadeAmt), fadeAmt);
+
+ // Output to screen
+ gl_FragColor = finalColor;
+ }
+
+ ')
+ public function new()
+ {
+ super();
+
+ this.fadeVal = 1;
+ }
+}
diff --git a/source/funkin/graphics/shaders/MosaicEffect.hx b/source/funkin/graphics/shaders/MosaicEffect.hx
new file mode 100644
index 000000000..fc3737aff
--- /dev/null
+++ b/source/funkin/graphics/shaders/MosaicEffect.hx
@@ -0,0 +1,23 @@
+package funkin.graphics.shaders;
+
+import flixel.addons.display.FlxRuntimeShader;
+import openfl.utils.Assets;
+import funkin.Paths;
+import flixel.math.FlxPoint;
+
+class MosaicEffect extends FlxRuntimeShader
+{
+ public var blockSize:FlxPoint = FlxPoint.get(1.0, 1.0);
+
+ public function new()
+ {
+ super(Assets.getText(Paths.frag('mosaic')));
+ setBlockSize(1.0, 1.0);
+ }
+
+ public function setBlockSize(w:Float, h:Float)
+ {
+ blockSize.set(w, h);
+ setFloatArray("uBlocksize", [w, h]);
+ }
+}
diff --git a/source/funkin/graphics/shaders/RuntimePostEffectShader.hx b/source/funkin/graphics/shaders/RuntimePostEffectShader.hx
index 9f49da075..d39f57efe 100644
--- a/source/funkin/graphics/shaders/RuntimePostEffectShader.hx
+++ b/source/funkin/graphics/shaders/RuntimePostEffectShader.hx
@@ -2,6 +2,7 @@ package funkin.graphics.shaders;
import flixel.FlxCamera;
import flixel.FlxG;
+import flixel.graphics.frames.FlxFrame;
import flixel.addons.display.FlxRuntimeShader;
import lime.graphics.opengl.GLProgram;
import lime.utils.Log;
@@ -32,6 +33,9 @@ class RuntimePostEffectShader extends FlxRuntimeShader
// equals (camera.viewLeft, camera.viewTop, camera.viewRight, camera.viewBottom)
uniform vec4 uCameraBounds;
+ // equals (frame.left, frame.top, frame.right, frame.bottom)
+ uniform vec4 uFrameBounds;
+
// screen coord -> world coord conversion
// returns world coord in px
vec2 screenToWorld(vec2 screenCoord) {
@@ -56,6 +60,25 @@ class RuntimePostEffectShader extends FlxRuntimeShader
return (worldCoord - offset) / scale;
}
+ // screen coord -> frame coord conversion
+ // returns normalized frame coord
+ vec2 screenToFrame(vec2 screenCoord) {
+ float left = uFrameBounds.x;
+ float top = uFrameBounds.y;
+ float right = uFrameBounds.z;
+ float bottom = uFrameBounds.w;
+ float width = right - left;
+ float height = bottom - top;
+
+ float clampedX = clamp(screenCoord.x, left, right);
+ float clampedY = clamp(screenCoord.y, top, bottom);
+
+ return vec2(
+ (clampedX - left) / (width),
+ (clampedY - top) / (height)
+ );
+ }
+
// internally used to get the maximum `openfl_TextureCoordv`
vec2 bitmapCoordScale() {
return openfl_TextureCoordv / screenCoord;
@@ -80,6 +103,8 @@ class RuntimePostEffectShader extends FlxRuntimeShader
{
super(fragmentSource, null, glVersion);
uScreenResolution.value = [FlxG.width, FlxG.height];
+ uCameraBounds.value = [0, 0, FlxG.width, FlxG.height];
+ uFrameBounds.value = [0, 0, FlxG.width, FlxG.height];
}
// basically `updateViewInfo(FlxG.width, FlxG.height, FlxG.camera)` is good
@@ -89,6 +114,12 @@ class RuntimePostEffectShader extends FlxRuntimeShader
uCameraBounds.value = [camera.viewLeft, camera.viewTop, camera.viewRight, camera.viewBottom];
}
+ public function updateFrameInfo(frame:FlxFrame)
+ {
+ // NOTE: uv.width is actually the right pos and uv.height is the bottom pos
+ uFrameBounds.value = [frame.uv.x, frame.uv.y, frame.uv.width, frame.uv.height];
+ }
+
override function __createGLProgram(vertexSource:String, fragmentSource:String):GLProgram
{
try
diff --git a/source/funkin/graphics/shaders/RuntimeRainShader.hx b/source/funkin/graphics/shaders/RuntimeRainShader.hx
index 239276bbe..d0c036623 100644
--- a/source/funkin/graphics/shaders/RuntimeRainShader.hx
+++ b/source/funkin/graphics/shaders/RuntimeRainShader.hx
@@ -4,6 +4,7 @@ import flixel.system.FlxAssets.FlxShader;
import openfl.display.BitmapData;
import openfl.display.ShaderParameter;
import openfl.display.ShaderParameterType;
+import flixel.util.FlxColor;
import openfl.utils.Assets;
typedef Light =
@@ -32,6 +33,14 @@ class RuntimeRainShader extends RuntimePostEffectShader
return time = value;
}
+ public var spriteMode(default, set):Bool = false;
+
+ function set_spriteMode(value:Bool):Bool
+ {
+ this.setBool('uSpriteMode', value);
+ return spriteMode = value;
+ }
+
// The scale of the rain depends on the world coordinate system, so higher resolution makes
// the raindrops smaller. This parameter can be used to adjust the total scale of the scene.
// The size of the raindrops is proportional to the value of this parameter.
@@ -86,6 +95,14 @@ class RuntimeRainShader extends RuntimePostEffectShader
return mask = value;
}
+ public var rainColor(default, set):FlxColor;
+
+ function set_rainColor(color:FlxColor):FlxColor
+ {
+ this.setFloatArray("uRainColor", [color.red / 255, color.green / 255, color.blue / 255]);
+ return rainColor = color;
+ }
+
public var lightMap(default, set):BitmapData;
function set_lightMap(value:BitmapData):BitmapData
@@ -105,6 +122,7 @@ class RuntimeRainShader extends RuntimePostEffectShader
public function new()
{
super(Assets.getText(Paths.frag('rain')));
+ this.rainColor = 0xFF6680cc;
}
public function update(elapsed:Float):Void
diff --git a/source/funkin/graphics/shaders/TextureSwap.hx b/source/funkin/graphics/shaders/TextureSwap.hx
new file mode 100644
index 000000000..65de87ea3
--- /dev/null
+++ b/source/funkin/graphics/shaders/TextureSwap.hx
@@ -0,0 +1,48 @@
+package funkin.graphics.shaders;
+
+import flixel.system.FlxAssets.FlxShader;
+import flixel.util.FlxColor;
+import openfl.display.BitmapData;
+
+class TextureSwap extends FlxShader
+{
+ public var swappedImage(default, set):BitmapData;
+ public var amount(default, set):Float;
+
+ function set_swappedImage(_bitmapData:BitmapData):BitmapData
+ {
+ image.input = _bitmapData;
+
+ return _bitmapData;
+ }
+
+ function set_amount(val:Float):Float
+ {
+ fadeAmount.value = [val];
+
+ return val;
+ }
+
+ @:glFragmentSource('
+ #pragma header
+
+ uniform sampler2D image;
+ uniform float fadeAmount;
+
+ void main()
+ {
+ vec4 tex = flixel_texture2D(bitmap, openfl_TextureCoordv);
+ vec4 tex2 = flixel_texture2D(image, openfl_TextureCoordv);
+
+ vec4 finalColor = mix(tex, vec4(tex2.rgb, tex.a), fadeAmount);
+
+ gl_FragColor = finalColor;
+ }
+ ')
+ public function new()
+ {
+ super();
+
+ this.amount = 1;
+ }
+}
diff --git a/source/funkin/input/Controls.hx b/source/funkin/input/Controls.hx
index f6c881f6d..da5aaac58 100644
--- a/source/funkin/input/Controls.hx
+++ b/source/funkin/input/Controls.hx
@@ -64,6 +64,7 @@ class Controls extends FlxActionSet
var _freeplay_favorite = new FunkinAction(Action.FREEPLAY_FAVORITE);
var _freeplay_left = new FunkinAction(Action.FREEPLAY_LEFT);
var _freeplay_right = new FunkinAction(Action.FREEPLAY_RIGHT);
+ var _freeplay_char_select = new FunkinAction(Action.FREEPLAY_CHAR_SELECT);
var _cutscene_advance = new FunkinAction(Action.CUTSCENE_ADVANCE);
var _debug_menu = new FunkinAction(Action.DEBUG_MENU);
var _debug_chart = new FunkinAction(Action.DEBUG_CHART);
@@ -262,6 +263,11 @@ class Controls extends FlxActionSet
inline function get_FREEPLAY_RIGHT()
return _freeplay_right.check();
+ public var FREEPLAY_CHAR_SELECT(get, never):Bool;
+
+ inline function get_FREEPLAY_CHAR_SELECT()
+ return _freeplay_char_select.check();
+
public var CUTSCENE_ADVANCE(get, never):Bool;
inline function get_CUTSCENE_ADVANCE()
@@ -318,6 +324,7 @@ class Controls extends FlxActionSet
add(_freeplay_favorite);
add(_freeplay_left);
add(_freeplay_right);
+ add(_freeplay_char_select);
add(_cutscene_advance);
add(_debug_menu);
add(_debug_chart);
@@ -349,9 +356,10 @@ class Controls extends FlxActionSet
public function check(name:Action, trigger:FlxInputState = JUST_PRESSED, gamepadOnly:Bool = false):Bool
{
- #if debug
+ #if FEATURE_DEBUG_FUNCTIONS
if (!byName.exists(name)) throw 'Invalid name: $name';
#end
+
var action = byName[name];
if (gamepadOnly) return action.checkFiltered(trigger, GAMEPAD);
else
@@ -360,7 +368,7 @@ class Controls extends FlxActionSet
public function getKeysForAction(name:Action):Array
{
- #if debug
+ #if FEATURE_DEBUG_FUNCTIONS
if (!byName.exists(name)) throw 'Invalid name: $name';
#end
@@ -375,7 +383,7 @@ class Controls extends FlxActionSet
public function getButtonsForAction(name:Action):Array
{
- #if debug
+ #if FEATURE_DEBUG_FUNCTIONS
if (!byName.exists(name)) throw 'Invalid name: $name';
#end
@@ -387,20 +395,37 @@ class Controls extends FlxActionSet
return result;
}
- public function getDialogueName(action:FlxActionDigital):String
+ public function getDialogueName(action:FlxActionDigital, ?ignoreSurrounding:Bool = false):String
{
var input = action.inputs[0];
- return switch (input.device)
+ if (ignoreSurrounding == false)
{
- case KEYBOARD: return '[${(input.inputID : FlxKey)}]';
- case GAMEPAD: return '(${(input.inputID : FlxGamepadInputID)})';
- case device: throw 'unhandled device: $device';
+ return switch (input.device)
+ {
+ case KEYBOARD: return '[${(input.inputID : FlxKey)}]';
+ case GAMEPAD: return '(${(input.inputID : FlxGamepadInputID)})';
+ case device: throw 'unhandled device: $device';
+ }
+ }
+ else
+ {
+ return switch (input.device)
+ {
+ case KEYBOARD: return '${(input.inputID : FlxKey)}';
+ case GAMEPAD: return '${(input.inputID : FlxGamepadInputID)}';
+ case device: throw 'unhandled device: $device';
+ }
}
}
- public function getDialogueNameFromToken(token:String):String
+ public function getDialogueNameFromToken(token:String, ?ignoreSurrounding:Bool = false):String
{
- return getDialogueName(getActionFromControl(Control.createByName(token.toUpperCase())));
+ return getDialogueName(getActionFromControl(Control.createByName(token.toUpperCase())), ignoreSurrounding);
+ }
+
+ public function getDialogueNameFromControl(control:Control, ?ignoreSurrounding:Bool = false):String
+ {
+ return getDialogueName(getActionFromControl(control), ignoreSurrounding);
}
function getActionFromControl(control:Control):FlxActionDigital
@@ -424,6 +449,7 @@ class Controls extends FlxActionSet
case FREEPLAY_FAVORITE: _freeplay_favorite;
case FREEPLAY_LEFT: _freeplay_left;
case FREEPLAY_RIGHT: _freeplay_right;
+ case FREEPLAY_CHAR_SELECT: _freeplay_char_select;
case CUTSCENE_ADVANCE: _cutscene_advance;
case DEBUG_MENU: _debug_menu;
case DEBUG_CHART: _debug_chart;
@@ -500,6 +526,8 @@ class Controls extends FlxActionSet
func(_freeplay_left, JUST_PRESSED);
case FREEPLAY_RIGHT:
func(_freeplay_right, JUST_PRESSED);
+ case FREEPLAY_CHAR_SELECT:
+ func(_freeplay_char_select, JUST_PRESSED);
case CUTSCENE_ADVANCE:
func(_cutscene_advance, JUST_PRESSED);
case DEBUG_MENU:
@@ -721,6 +749,7 @@ class Controls extends FlxActionSet
bindKeys(Control.FREEPLAY_FAVORITE, getDefaultKeybinds(scheme, Control.FREEPLAY_FAVORITE));
bindKeys(Control.FREEPLAY_LEFT, getDefaultKeybinds(scheme, Control.FREEPLAY_LEFT));
bindKeys(Control.FREEPLAY_RIGHT, getDefaultKeybinds(scheme, Control.FREEPLAY_RIGHT));
+ bindKeys(Control.FREEPLAY_CHAR_SELECT, getDefaultKeybinds(scheme, Control.FREEPLAY_CHAR_SELECT));
bindKeys(Control.CUTSCENE_ADVANCE, getDefaultKeybinds(scheme, Control.CUTSCENE_ADVANCE));
bindKeys(Control.DEBUG_MENU, getDefaultKeybinds(scheme, Control.DEBUG_MENU));
bindKeys(Control.DEBUG_CHART, getDefaultKeybinds(scheme, Control.DEBUG_CHART));
@@ -756,6 +785,7 @@ class Controls extends FlxActionSet
case Control.FREEPLAY_FAVORITE: return [F]; // Favorite a song on the menu
case Control.FREEPLAY_LEFT: return [Q]; // Switch tabs on the menu
case Control.FREEPLAY_RIGHT: return [E]; // Switch tabs on the menu
+ case Control.FREEPLAY_CHAR_SELECT: return [TAB];
case Control.CUTSCENE_ADVANCE: return [Z, ENTER];
case Control.DEBUG_MENU: return [GRAVEACCENT];
case Control.DEBUG_CHART: return [];
@@ -784,6 +814,7 @@ class Controls extends FlxActionSet
case Control.FREEPLAY_FAVORITE: return [F]; // Favorite a song on the menu
case Control.FREEPLAY_LEFT: return [Q]; // Switch tabs on the menu
case Control.FREEPLAY_RIGHT: return [E]; // Switch tabs on the menu
+ case Control.FREEPLAY_CHAR_SELECT: return [TAB];
case Control.CUTSCENE_ADVANCE: return [G, Z];
case Control.DEBUG_MENU: return [GRAVEACCENT];
case Control.DEBUG_CHART: return [];
@@ -812,6 +843,7 @@ class Controls extends FlxActionSet
case Control.FREEPLAY_FAVORITE: return [];
case Control.FREEPLAY_LEFT: return [];
case Control.FREEPLAY_RIGHT: return [];
+ case Control.FREEPLAY_CHAR_SELECT: return [];
case Control.CUTSCENE_ADVANCE: return [ENTER];
case Control.DEBUG_MENU: return [];
case Control.DEBUG_CHART: return [];
@@ -1548,6 +1580,7 @@ enum Control
FREEPLAY_FAVORITE;
FREEPLAY_LEFT;
FREEPLAY_RIGHT;
+ FREEPLAY_CHAR_SELECT;
// WINDOW
WINDOW_SCREENSHOT;
WINDOW_FULLSCREEN;
@@ -1602,6 +1635,7 @@ enum abstract Action(String) to String from String
var FREEPLAY_FAVORITE = "freeplay_favorite";
var FREEPLAY_LEFT = "freeplay_left";
var FREEPLAY_RIGHT = "freeplay_right";
+ var FREEPLAY_CHAR_SELECT = "freeplay_char_select";
// VOLUME
var VOLUME_UP = "volume_up";
var VOLUME_DOWN = "volume_down";
diff --git a/source/funkin/modding/IScriptedClass.hx b/source/funkin/modding/IScriptedClass.hx
index 5f2ff2b9e..14aa6b494 100644
--- a/source/funkin/modding/IScriptedClass.hx
+++ b/source/funkin/modding/IScriptedClass.hx
@@ -73,6 +73,22 @@ interface INoteScriptedClass extends IScriptedClass
public function onNoteMiss(event:NoteScriptEvent):Void;
}
+/**
+ * Defines a set of callbacks available to scripted classes which represent sprites synced with the BPM.
+ */
+interface IBPMSyncedScriptedClass extends IScriptedClass
+{
+ /**
+ * Called once every step of the song.
+ */
+ public function onStepHit(event:SongTimeScriptEvent):Void;
+
+ /**
+ * Called once every beat of the song.
+ */
+ public function onBeatHit(event:SongTimeScriptEvent):Void;
+}
+
/**
* Developer note:
*
@@ -86,7 +102,7 @@ interface INoteScriptedClass extends IScriptedClass
/**
* Defines a set of callbacks available to scripted classes that involve the lifecycle of the Play State.
*/
-interface IPlayStateScriptedClass extends INoteScriptedClass
+interface IPlayStateScriptedClass extends INoteScriptedClass extends IBPMSyncedScriptedClass
{
/**
* Called when the game is paused.
@@ -136,16 +152,6 @@ interface IPlayStateScriptedClass extends INoteScriptedClass
*/
public function onSongEvent(event:SongEventScriptEvent):Void;
- /**
- * Called once every step of the song.
- */
- public function onStepHit(event:SongTimeScriptEvent):Void;
-
- /**
- * Called once every beat of the song.
- */
- public function onBeatHit(event:SongTimeScriptEvent):Void;
-
/**
* Called when the countdown of the song starts.
*/
diff --git a/source/funkin/modding/PolymodHandler.hx b/source/funkin/modding/PolymodHandler.hx
index ae754b780..75c69e506 100644
--- a/source/funkin/modding/PolymodHandler.hx
+++ b/source/funkin/modding/PolymodHandler.hx
@@ -7,7 +7,9 @@ import funkin.data.dialogue.speaker.SpeakerRegistry;
import funkin.data.event.SongEventRegistry;
import funkin.data.story.level.LevelRegistry;
import funkin.data.notestyle.NoteStyleRegistry;
+import funkin.play.notes.notekind.NoteKindManager;
import funkin.data.song.SongRegistry;
+import funkin.data.freeplay.player.PlayerRegistry;
import funkin.data.stage.StageRegistry;
import funkin.data.freeplay.album.AlbumRegistry;
import funkin.modding.module.ModuleHandler;
@@ -26,11 +28,10 @@ class PolymodHandler
{
/**
* The API version that mods should comply with.
- * Format this with Semantic Versioning; ...
- * Bug fixes increment the patch version, new features increment the minor version.
- * Changes that break old mods increment the major version.
+ * Indicates which mods are compatible with this version of the game.
+ * Minor updates rarely impact mods but major versions often do.
*/
- static final API_VERSION:String = '0.1.0';
+ static final API_VERSION:String = "0.5.0"; // Constants.VERSION;
/**
* Where relative to the executable that mods are located.
@@ -176,7 +177,7 @@ class PolymodHandler
loadedModIds.push(mod.id);
}
- #if debug
+ #if FEATURE_DEBUG_FUNCTIONS
var fileList:Array = Polymod.listModFiles(PolymodAssetType.IMAGE);
trace('Installed mods have replaced ${fileList.length} images.');
for (item in fileList)
@@ -232,6 +233,12 @@ class PolymodHandler
// NOTE: Scripted classes are automatically aliased to their parent class.
Polymod.addImportAlias('flixel.math.FlxPoint', flixel.math.FlxPoint.FlxBasePoint);
+ Polymod.addImportAlias('funkin.data.event.SongEventSchema', funkin.data.event.SongEventSchema.SongEventSchemaRaw);
+
+ // `lime.utils.Assets` literally just has a private `resolveClass` function for some reason? so we replace it with our own.
+ Polymod.addImportAlias('lime.utils.Assets', funkin.Assets);
+ Polymod.addImportAlias('openfl.utils.Assets', funkin.Assets);
+
// Add blacklisting for prohibited classes and packages.
// `Sys`
@@ -250,8 +257,28 @@ class PolymodHandler
// Lib.load() can load malicious DLLs
Polymod.blacklistImport('cpp.Lib');
+ // `Unserializer`
+ // Unserializerr.DEFAULT_RESOLVER.resolveClass() can access blacklisted packages
+ Polymod.blacklistImport('Unserializer');
+
+ // `lime.system.CFFI`
+ // Can load and execute compiled binaries.
+ Polymod.blacklistImport('lime.system.CFFI');
+
+ // `lime.system.JNI`
+ // Can load and execute compiled binaries.
+ Polymod.blacklistImport('lime.system.JNI');
+
+ // `lime.system.System`
+ // System.load() can load malicious DLLs
+ Polymod.blacklistImport('lime.system.System');
+
+ // `openfl.desktop.NativeProcess`
+ // Can load native processes on the host operating system.
+ Polymod.blacklistImport('openfl.desktop.NativeProcess');
+
// `polymod.*`
- // You can probably unblacklist a module
+ // Contains functions which may allow for un-blacklisting other modules.
for (cls in ClassMacro.listClassesInPackage('polymod'))
{
if (cls == null) continue;
@@ -260,6 +287,7 @@ class PolymodHandler
}
// `sys.*`
+ // Access to system utilities such as the file system.
for (cls in ClassMacro.listClassesInPackage('sys'))
{
if (cls == null) continue;
@@ -369,16 +397,20 @@ class PolymodHandler
// These MUST be imported at the top of the file and not referred to by fully qualified name,
// to ensure build macros work properly.
+ SongEventRegistry.loadEventCache();
+
SongRegistry.instance.loadEntries();
LevelRegistry.instance.loadEntries();
NoteStyleRegistry.instance.loadEntries();
- SongEventRegistry.loadEventCache();
+ PlayerRegistry.instance.loadEntries();
ConversationRegistry.instance.loadEntries();
DialogueBoxRegistry.instance.loadEntries();
SpeakerRegistry.instance.loadEntries();
AlbumRegistry.instance.loadEntries();
StageRegistry.instance.loadEntries();
+
CharacterDataParser.loadCharacterCache(); // TODO: Migrate characters to BaseRegistry.
+ NoteKindManager.loadScripts();
ModuleHandler.loadModuleCache();
}
}
diff --git a/source/funkin/modding/base/ScriptedFlxUIState.hx b/source/funkin/modding/base/ScriptedFlxUIState.hx
deleted file mode 100644
index c58fc294f..000000000
--- a/source/funkin/modding/base/ScriptedFlxUIState.hx
+++ /dev/null
@@ -1,8 +0,0 @@
-package funkin.modding.base;
-
-/**
- * A script that can be tied to an FlxUIState.
- * Create a scripted class that extends FlxUIState to use this.
- */
-@:hscriptClass
-class ScriptedFlxUIState extends flixel.addons.ui.FlxUIState implements HScriptedClass {}
diff --git a/source/funkin/modding/events/ScriptEvent.hx b/source/funkin/modding/events/ScriptEvent.hx
index dd55de23b..70055b262 100644
--- a/source/funkin/modding/events/ScriptEvent.hx
+++ b/source/funkin/modding/events/ScriptEvent.hx
@@ -151,7 +151,8 @@ class HitNoteScriptEvent extends NoteScriptEvent
public var hitDiff:Float = 0;
/**
- * If the hit causes a notesplash
+ * Whether this note hit causes a note splash to display.
+ * Defaults to true only on "sick" notes.
*/
public var doesNotesplash:Bool = false;
diff --git a/source/funkin/modding/events/ScriptEventDispatcher.hx b/source/funkin/modding/events/ScriptEventDispatcher.hx
index c262c311d..7e19173c4 100644
--- a/source/funkin/modding/events/ScriptEventDispatcher.hx
+++ b/source/funkin/modding/events/ScriptEventDispatcher.hx
@@ -94,6 +94,21 @@ class ScriptEventDispatcher
}
}
+ if (Std.isOfType(target, IBPMSyncedScriptedClass))
+ {
+ var t:IBPMSyncedScriptedClass = cast(target, IBPMSyncedScriptedClass);
+ switch (event.type)
+ {
+ case SONG_BEAT_HIT:
+ t.onBeatHit(cast event);
+ return;
+ case SONG_STEP_HIT:
+ t.onStepHit(cast event);
+ return;
+ default: // Continue;
+ }
+ }
+
if (Std.isOfType(target, IPlayStateScriptedClass))
{
var t:IPlayStateScriptedClass = cast(target, IPlayStateScriptedClass);
@@ -102,12 +117,6 @@ class ScriptEventDispatcher
case NOTE_GHOST_MISS:
t.onNoteGhostMiss(cast event);
return;
- case SONG_BEAT_HIT:
- t.onBeatHit(cast event);
- return;
- case SONG_STEP_HIT:
- t.onStepHit(cast event);
- return;
case SONG_START:
t.onSongStart(event);
return;
diff --git a/source/funkin/modding/events/ScriptEventType.hx b/source/funkin/modding/events/ScriptEventType.hx
index eeeb8ef29..6ac85649f 100644
--- a/source/funkin/modding/events/ScriptEventType.hx
+++ b/source/funkin/modding/events/ScriptEventType.hx
@@ -20,7 +20,7 @@ enum abstract ScriptEventType(String) from String to String
var DESTROY = 'DESTROY';
/**
- * Called when the relevent object is added to the game state.
+ * Called when the relevant object is added to the game state.
* This assumes all data is loaded and ready to go.
*
* This event is not cancelable.
diff --git a/source/funkin/play/Countdown.hx b/source/funkin/play/Countdown.hx
index 10636afdf..643883a43 100644
--- a/source/funkin/play/Countdown.hx
+++ b/source/funkin/play/Countdown.hx
@@ -9,7 +9,11 @@ import funkin.modding.module.ModuleHandler;
import funkin.modding.events.ScriptEvent;
import funkin.modding.events.ScriptEvent.CountdownScriptEvent;
import flixel.util.FlxTimer;
+import funkin.util.EaseUtil;
import funkin.audio.FunkinSound;
+import openfl.utils.Assets;
+import funkin.data.notestyle.NoteStyleRegistry;
+import funkin.play.notes.notestyle.NoteStyle;
class Countdown
{
@@ -18,6 +22,24 @@ class Countdown
*/
public static var countdownStep(default, null):CountdownStep = BEFORE;
+ /**
+ * Which alternate graphic/sound on countdown to use.
+ * This is set via the current notestyle.
+ * For example, in Week 6 it is `pixel`.
+ */
+ public static var soundSuffix:String = '';
+
+ /**
+ * Which alternate graphic on countdown to use.
+ * You can set this via script.
+ * For example, in Week 6 it is `-pixel`.
+ */
+ public static var graphicSuffix:String = '';
+
+ static var noteStyle:NoteStyle;
+
+ static var fallbackNoteStyle:Null;
+
/**
* The currently running countdown. This will be null if there is no countdown running.
*/
@@ -29,7 +51,7 @@ class Countdown
* This will automatically stop and restart the countdown if it is already running.
* @returns `false` if the countdown was cancelled by a script.
*/
- public static function performCountdown(isPixelStyle:Bool):Bool
+ public static function performCountdown():Bool
{
countdownStep = BEFORE;
var cancelled:Bool = propagateCountdownEvent(countdownStep);
@@ -64,10 +86,10 @@ class Countdown
// PlayState.instance.dispatchEvent(new SongTimeScriptEvent(SONG_BEAT_HIT, 0, 0));
// Countdown graphic.
- showCountdownGraphic(countdownStep, isPixelStyle);
+ showCountdownGraphic(countdownStep);
// Countdown sound.
- playCountdownSound(countdownStep, isPixelStyle);
+ playCountdownSound(countdownStep);
// Event handling bullshit.
var cancelled:Bool = propagateCountdownEvent(countdownStep);
@@ -117,7 +139,7 @@ class Countdown
*
* If you want to call this from a module, it's better to use the event system and cancel the onCountdownStep event.
*/
- public static function pauseCountdown()
+ public static function pauseCountdown():Void
{
if (countdownTimer != null && !countdownTimer.finished)
{
@@ -130,7 +152,7 @@ class Countdown
*
* If you want to call this from a module, it's better to use the event system and cancel the onCountdownStep event.
*/
- public static function resumeCountdown()
+ public static function resumeCountdown():Void
{
if (countdownTimer != null && !countdownTimer.finished)
{
@@ -143,7 +165,7 @@ class Countdown
*
* If you want to call this from a module, it's better to use the event system and cancel the onCountdownStart event.
*/
- public static function stopCountdown()
+ public static function stopCountdown():Void
{
if (countdownTimer != null)
{
@@ -156,7 +178,7 @@ class Countdown
/**
* Stops the current countdown, then starts the song for you.
*/
- public static function skipCountdown()
+ public static function skipCountdown():Void
{
stopCountdown();
// This will trigger PlayState.startSong()
@@ -176,114 +198,69 @@ class Countdown
}
/**
- * Retrieves the graphic to use for this step of the countdown.
- * TODO: Make this less dumb. Unhardcode it? Use modules? Use notestyles?
- *
- * This is public so modules can do lol funny shit.
+ * Reset the countdown configuration to the default.
*/
- public static function showCountdownGraphic(index:CountdownStep, isPixelStyle:Bool):Void
+ public static function reset()
{
- var spritePath:String = null;
+ noteStyle = null;
+ }
- if (isPixelStyle)
- {
- switch (index)
- {
- case TWO:
- spritePath = 'weeb/pixelUI/ready-pixel';
- case ONE:
- spritePath = 'weeb/pixelUI/set-pixel';
- case GO:
- spritePath = 'weeb/pixelUI/date-pixel';
- default:
- // null
- }
- }
- else
- {
- switch (index)
- {
- case TWO:
- spritePath = 'ready';
- case ONE:
- spritePath = 'set';
- case GO:
- spritePath = 'go';
- default:
- // null
- }
- }
+ /**
+ * Retrieve the note style data (if we haven't already)
+ * @param noteStyleId The id of the note style to fetch. Defaults to the one used by the current PlayState.
+ * @param force Fetch the note style from the registry even if we've already fetched it.
+ */
+ static function fetchNoteStyle(?noteStyleId:String, force:Bool = false):Void
+ {
+ if (noteStyle != null && !force) return;
- if (spritePath == null) return;
+ if (noteStyleId == null) noteStyleId = PlayState.instance?.currentChart?.noteStyle;
- var countdownSprite:FunkinSprite = FunkinSprite.create(spritePath);
- countdownSprite.scrollFactor.set(0, 0);
+ noteStyle = NoteStyleRegistry.instance.fetchEntry(noteStyleId);
+ if (noteStyle == null) noteStyle = NoteStyleRegistry.instance.fetchDefault();
+ }
- if (isPixelStyle) countdownSprite.setGraphicSize(Std.int(countdownSprite.width * Constants.PIXEL_ART_SCALE));
+ /**
+ * Retrieves the graphic to use for this step of the countdown.
+ */
+ public static function showCountdownGraphic(index:CountdownStep):Void
+ {
+ fetchNoteStyle();
- countdownSprite.antialiasing = !isPixelStyle;
+ var countdownSprite = noteStyle.buildCountdownSprite(index);
+ if (countdownSprite == null) return;
- countdownSprite.updateHitbox();
- countdownSprite.screenCenter();
+ var fadeEase = FlxEase.cubeInOut;
+ if (noteStyle.isCountdownSpritePixel(index)) fadeEase = EaseUtil.stepped(8);
// Fade sprite in, then out, then destroy it.
- FlxTween.tween(countdownSprite, {y: countdownSprite.y += 100, alpha: 0}, Conductor.instance.beatLengthMs / 1000,
+ FlxTween.tween(countdownSprite, {alpha: 0}, Conductor.instance.beatLengthMs / 1000,
{
- ease: FlxEase.cubeInOut,
+ ease: fadeEase,
onComplete: function(twn:FlxTween) {
countdownSprite.destroy();
}
});
+ countdownSprite.cameras = [PlayState.instance.camHUD];
PlayState.instance.add(countdownSprite);
+ countdownSprite.screenCenter();
+
+ var offsets = noteStyle.getCountdownSpriteOffsets(index);
+ countdownSprite.x += offsets[0];
+ countdownSprite.y += offsets[1];
}
/**
* Retrieves the sound file to use for this step of the countdown.
- * TODO: Make this less dumb. Unhardcode it? Use modules? Use notestyles?
- *
- * This is public so modules can do lol funny shit.
*/
- public static function playCountdownSound(index:CountdownStep, isPixelStyle:Bool):Void
+ public static function playCountdownSound(step:CountdownStep):FunkinSound
{
- var soundPath:String = null;
+ fetchNoteStyle();
+ var path = noteStyle.getCountdownSoundPath(step);
+ if (path == null) return null;
- if (isPixelStyle)
- {
- switch (index)
- {
- case THREE:
- soundPath = 'intro3-pixel';
- case TWO:
- soundPath = 'intro2-pixel';
- case ONE:
- soundPath = 'intro1-pixel';
- case GO:
- soundPath = 'introGo-pixel';
- default:
- // null
- }
- }
- else
- {
- switch (index)
- {
- case THREE:
- soundPath = 'intro3';
- case TWO:
- soundPath = 'intro2';
- case ONE:
- soundPath = 'intro1';
- case GO:
- soundPath = 'introGo';
- default:
- // null
- }
- }
-
- if (soundPath == null) return;
-
- FunkinSound.playOnce(Paths.sound(soundPath), Constants.COUNTDOWN_VOLUME);
+ return FunkinSound.playOnce(path, Constants.COUNTDOWN_VOLUME);
}
public static function decrement(step:CountdownStep):CountdownStep
diff --git a/source/funkin/play/GameOverSubState.hx b/source/funkin/play/GameOverSubState.hx
index c84d5b154..f6a7148f8 100644
--- a/source/funkin/play/GameOverSubState.hx
+++ b/source/funkin/play/GameOverSubState.hx
@@ -16,6 +16,8 @@ import funkin.ui.MusicBeatSubState;
import funkin.ui.story.StoryMenuState;
import funkin.util.MathUtil;
import openfl.utils.Assets;
+import funkin.effects.RetroCameraFade;
+import flixel.math.FlxPoint;
/**
* A substate which renders over the PlayState when the player dies.
@@ -144,6 +146,7 @@ class GameOverSubState extends MusicBeatSubState
else
{
boyfriend = PlayState.instance.currentStage.getBoyfriend(true);
+ boyfriend.canPlayOtherAnims = true;
boyfriend.isDead = true;
add(boyfriend);
boyfriend.resetCharacter();
@@ -166,8 +169,8 @@ class GameOverSubState extends MusicBeatSubState
// Assign a camera follow point to the boyfriend's position.
cameraFollowPoint = new FlxObject(PlayState.instance.cameraFollowPoint.x, PlayState.instance.cameraFollowPoint.y, 1, 1);
- cameraFollowPoint.x = boyfriend.getGraphicMidpoint().x;
- cameraFollowPoint.y = boyfriend.getGraphicMidpoint().y;
+ cameraFollowPoint.x = getMidPointOld(boyfriend).x;
+ cameraFollowPoint.y = getMidPointOld(boyfriend).y;
var offsets:Array = boyfriend.getDeathCameraOffsets();
cameraFollowPoint.x += offsets[0];
cameraFollowPoint.y += offsets[1];
@@ -178,6 +181,21 @@ class GameOverSubState extends MusicBeatSubState
targetCameraZoom = (PlayState?.instance?.currentStage?.camZoom ?? 1.0) * boyfriend.getDeathCameraZoom();
}
+ /**
+ * FlxSprite.getMidpoint(); calculations changed in this git commit
+ * https://github.com/HaxeFlixel/flixel/commit/1553b5af0871462fcefedc091b7885437d6c36d2
+ * https://github.com/HaxeFlixel/flixel/pull/3125
+ *
+ * So we use this to do the old math that gets the midpoint of our graphics
+ * Luckily, we don't use getGraphicMidpoint() much in the code, so it's fine being in GameoverSubState here.
+ * @return FlxPoint
+ */
+ function getMidPointOld(spr:FlxSprite, ?point:FlxPoint):FlxPoint
+ {
+ if (point == null) point = FlxPoint.get();
+ return point.set(spr.x + spr.frameWidth * 0.5 * spr.scale.x, spr.y + spr.frameHeight * 0.5 * spr.scale.y);
+ }
+
/**
* Forcibly reset the camera zoom level to that of the current stage.
* This prevents camera zoom events from adversely affecting the game over state.
@@ -331,9 +349,12 @@ class GameOverSubState extends MusicBeatSubState
// After the animation finishes...
new FlxTimer().start(0.7, function(tmr:FlxTimer) {
// ...fade out the graphics. Then after that happens...
- FlxG.camera.fade(FlxColor.BLACK, 2, false, function() {
+
+ var resetPlaying = function(pixel:Bool = false) {
// ...close the GameOverSubState.
- FlxG.camera.fade(FlxColor.BLACK, 1, true, null, true);
+ if (pixel) RetroCameraFade.fadeBlack(FlxG.camera, 10, 1);
+ else
+ FlxG.camera.fade(FlxColor.BLACK, 1, true, null, true);
PlayState.instance.needsReset = true;
if (PlayState.instance.isMinimalMode || boyfriend == null) {}
@@ -350,7 +371,22 @@ class GameOverSubState extends MusicBeatSubState
// Close the substate.
close();
- });
+ };
+
+ if (musicSuffix == '-pixel')
+ {
+ RetroCameraFade.fadeToBlack(FlxG.camera, 10, 2);
+ new FlxTimer().start(2, _ -> {
+ FlxG.camera.filters = [];
+ resetPlaying(true);
+ });
+ }
+ else
+ {
+ FlxG.camera.fade(FlxColor.BLACK, 2, false, function() {
+ resetPlaying();
+ });
+ }
});
}
}
diff --git a/source/funkin/play/PauseSubState.hx b/source/funkin/play/PauseSubState.hx
index d0c759b16..9ff70bee5 100644
--- a/source/funkin/play/PauseSubState.hx
+++ b/source/funkin/play/PauseSubState.hx
@@ -306,7 +306,7 @@ class PauseSubState extends MusicBeatSubState
metadataDifficulty.setFormat(Paths.font('vcr.ttf'), 32, FlxColor.WHITE, FlxTextAlign.RIGHT);
if (PlayState.instance?.currentDifficulty != null)
{
- metadataDifficulty.text += PlayState.instance.currentDifficulty.toTitleCase();
+ metadataDifficulty.text += PlayState.instance.currentDifficulty.replace('-', ' ').toTitleCase();
}
metadataDifficulty.scrollFactor.set(0, 0);
metadata.add(metadataDifficulty);
@@ -430,7 +430,7 @@ class PauseSubState extends MusicBeatSubState
resume(this);
}
- #if (debug || FORCE_DEBUG_VERSION)
+ #if FEATURE_DEBUG_FUNCTIONS
// to pause the game and get screenshots easy, press H on pause menu!
if (FlxG.keys.justPressed.H)
{
diff --git a/source/funkin/play/PlayState.hx b/source/funkin/play/PlayState.hx
index 20b8d784b..0b2b8846d 100644
--- a/source/funkin/play/PlayState.hx
+++ b/source/funkin/play/PlayState.hx
@@ -16,6 +16,7 @@ import flixel.tweens.FlxTween;
import flixel.ui.FlxBar;
import flixel.util.FlxColor;
import flixel.util.FlxTimer;
+import flixel.util.FlxStringUtil;
import funkin.api.newgrounds.NGio;
import funkin.audio.FunkinSound;
import funkin.audio.VoicesGroup;
@@ -48,6 +49,7 @@ import funkin.play.notes.NoteSprite;
import funkin.play.notes.notestyle.NoteStyle;
import funkin.play.notes.Strumline;
import funkin.play.notes.SustainTrail;
+import funkin.play.notes.notekind.NoteKindManager;
import funkin.play.scoring.Scoring;
import funkin.play.song.Song;
import funkin.play.stage.Stage;
@@ -65,7 +67,7 @@ import lime.ui.Haptic;
import openfl.display.BitmapData;
import openfl.geom.Rectangle;
import openfl.Lib;
-#if discord_rpc
+#if FEATURE_DISCORD_RPC
import Discord.DiscordClient;
#end
@@ -443,7 +445,7 @@ class PlayState extends MusicBeatSubState
*/
public var vocals:VoicesGroup;
- #if discord_rpc
+ #if FEATURE_DISCORD_RPC
// Discord RPC variables
var storyDifficultyText:String = '';
var iconRPC:String = '';
@@ -502,7 +504,13 @@ class PlayState extends MusicBeatSubState
public var camGame:FlxCamera;
/**
- * The camera which contains, and controls visibility of, a video cutscene.
+ * Simple helper debug variable, to be able to move the camera around for debug purposes
+ * without worrying about the camera tweening back to the follow point.
+ */
+ public var debugUnbindCameraZoom:Bool = false;
+
+ /**
+ * The camera which contains, and controls visibility of, a video cutscene, dialogue, pause menu and sticker transition.
*/
public var camCutscene:FlxCamera;
@@ -577,7 +585,8 @@ class PlayState extends MusicBeatSubState
// TODO: Refactor or document
var generatedMusic:Bool = false;
- var perfectMode:Bool = false;
+
+ var skipEndingTransition:Bool = false;
static final BACKGROUND_COLOR:FlxColor = FlxColor.BLACK;
@@ -665,7 +674,7 @@ class PlayState extends MusicBeatSubState
// Prepare the current song's instrumental and vocals to be played.
if (!overrideMusic && currentChart != null)
{
- currentChart.cacheInst();
+ currentChart.cacheInst(currentInstrumental);
currentChart.cacheVocals();
}
@@ -674,7 +683,7 @@ class PlayState extends MusicBeatSubState
if (currentChart.offsets != null)
{
- Conductor.instance.instrumentalOffset = currentChart.offsets.getInstrumentalOffset();
+ Conductor.instance.instrumentalOffset = currentChart.offsets.getInstrumentalOffset(currentInstrumental);
}
Conductor.instance.mapTimeChanges(currentChart.timeChanges);
@@ -693,14 +702,9 @@ class PlayState extends MusicBeatSubState
initMinimalMode();
}
initStrumlines();
+ initPopups();
- // Initialize the judgements and combo meter.
- comboPopUps = new PopUpStuff();
- comboPopUps.zIndex = 900;
- add(comboPopUps);
- comboPopUps.cameras = [camHUD];
-
- #if discord_rpc
+ #if FEATURE_DISCORD_RPC
// Initialize Discord Rich Presence.
initDiscord();
#end
@@ -740,7 +744,7 @@ class PlayState extends MusicBeatSubState
rightWatermarkText.cameras = [camHUD];
// Initialize some debug stuff.
- #if (debug || FORCE_DEBUG_VERSION)
+ #if FEATURE_DEBUG_FUNCTIONS
// Display the version number (and git commit hash) in the bottom right corner.
this.rightWatermarkText.text = Constants.VERSION;
@@ -862,7 +866,7 @@ class PlayState extends MusicBeatSubState
{
// Stop the vocals if they already exist.
if (vocals != null) vocals.stop();
- vocals = currentChart.buildVocals();
+ vocals = currentChart.buildVocals(currentInstrumental);
if (vocals.members.length == 0)
{
@@ -899,7 +903,7 @@ class PlayState extends MusicBeatSubState
health = Constants.HEALTH_STARTING;
songScore = 0;
Highscore.tallies.combo = 0;
- Countdown.performCountdown(currentStageId.startsWith('school'));
+ Countdown.performCountdown();
needsReset = false;
}
@@ -974,12 +978,12 @@ class PlayState extends MusicBeatSubState
FlxTransitionableState.skipNextTransIn = true;
FlxTransitionableState.skipNextTransOut = true;
- pauseSubState.camera = camHUD;
+ pauseSubState.camera = camCutscene;
openSubState(pauseSubState);
// boyfriendPos.put(); // TODO: Why is this here?
}
- #if discord_rpc
+ #if FEATURE_DISCORD_RPC
DiscordClient.changePresence(detailsPausedText, currentSong.song + ' (' + storyDifficultyText + ')', iconRPC);
#end
}
@@ -994,7 +998,7 @@ class PlayState extends MusicBeatSubState
{
cameraBopMultiplier = FlxMath.lerp(1.0, cameraBopMultiplier, 0.95); // Lerp bop multiplier back to 1.0x
var zoomPlusBop = currentCameraZoom * cameraBopMultiplier; // Apply camera bop multiplier.
- FlxG.camera.zoom = zoomPlusBop; // Actually apply the zoom to the camera.
+ if (!debugUnbindCameraZoom) FlxG.camera.zoom = zoomPlusBop; // Actually apply the zoom to the camera.
camHUD.zoom = FlxMath.lerp(defaultHUDCameraZoom, camHUD.zoom, 0.95);
}
@@ -1038,7 +1042,7 @@ class PlayState extends MusicBeatSubState
// Disable updates, preventing animations in the background from playing.
persistentUpdate = false;
- #if (debug || FORCE_DEBUG_VERSION)
+ #if FEATURE_DEBUG_FUNCTIONS
if (FlxG.keys.pressed.THREE)
{
// TODO: Change the key or delete this?
@@ -1049,7 +1053,7 @@ class PlayState extends MusicBeatSubState
{
#end
persistentDraw = false;
- #if (debug || FORCE_DEBUG_VERSION)
+ #if FEATURE_DEBUG_FUNCTIONS
}
#end
@@ -1068,7 +1072,7 @@ class PlayState extends MusicBeatSubState
moveToGameOver();
}
- #if discord_rpc
+ #if FEATURE_DISCORD_RPC
// Game Over doesn't get his own variable because it's only used here
DiscordClient.changePresence('Game Over - ' + detailsText, currentSong.song + ' (' + storyDifficultyText + ')', iconRPC);
#end
@@ -1164,6 +1168,9 @@ class PlayState extends MusicBeatSubState
// super.dispatchEvent(event) dispatches event to module scripts.
super.dispatchEvent(event);
+ // Dispatch event to note kind scripts
+ NoteKindManager.callEvent(event);
+
// Dispatch event to stage script.
ScriptEventDispatcher.callEvent(currentStage, event);
@@ -1175,14 +1182,12 @@ class PlayState extends MusicBeatSubState
// Dispatch event to conversation script.
ScriptEventDispatcher.callEvent(currentConversation, event);
-
- // TODO: Dispatch event to note scripts
}
/**
- * Function called before opening a new substate.
- * @param subState The substate to open.
- */
+ * Function called before opening a new substate.
+ * @param subState The substate to open.
+ */
public override function openSubState(subState:FlxSubState):Void
{
// If there is a substate which requires the game to continue,
@@ -1238,9 +1243,9 @@ class PlayState extends MusicBeatSubState
}
/**
- * Function called before closing the current substate.
- * @param subState
- */
+ * Function called before closing the current substate.
+ * @param subState
+ */
public override function closeSubState():Void
{
if (Std.isOfType(subState, PauseSubState))
@@ -1279,7 +1284,7 @@ class PlayState extends MusicBeatSubState
// Resume the countdown.
Countdown.resumeCountdown();
- #if discord_rpc
+ #if FEATURE_DISCORD_RPC
if (startTimer.finished)
{
DiscordClient.changePresence(detailsText, '${currentChart.songName} ($storyDifficultyText)', iconRPC, true,
@@ -1301,12 +1306,18 @@ class PlayState extends MusicBeatSubState
super.closeSubState();
}
- #if discord_rpc
/**
- * Function called when the game window gains focus.
- */
+ * Function called when the game window gains focus.
+ */
public override function onFocus():Void
{
+ if (VideoCutscene.isPlaying() && FlxG.autoPause && isGamePaused) VideoCutscene.pauseVideo();
+ #if html5
+ else
+ VideoCutscene.resumeVideo();
+ #end
+
+ #if FEATURE_DISCORD_RPC
if (health > Constants.HEALTH_MIN && !paused && FlxG.autoPause)
{
if (Conductor.instance.songPosition > 0.0) DiscordClient.changePresence(detailsText, currentSong.song
@@ -1318,81 +1329,36 @@ class PlayState extends MusicBeatSubState
else
DiscordClient.changePresence(detailsText, currentSong.song + ' (' + storyDifficultyText + ')', iconRPC);
}
+ #end
super.onFocus();
}
/**
- * Function called when the game window loses focus.
- */
+ * Function called when the game window loses focus.
+ */
public override function onFocusLost():Void
{
+ #if html5
+ if (FlxG.autoPause) VideoCutscene.pauseVideo();
+ #end
+
+ #if FEATURE_DISCORD_RPC
if (health > Constants.HEALTH_MIN && !paused && FlxG.autoPause) DiscordClient.changePresence(detailsPausedText,
currentSong.song + ' (' + storyDifficultyText + ')', iconRPC);
+ #end
super.onFocusLost();
}
- #end
/**
- * Removes any references to the current stage, then clears the stage cache,
- * then reloads all the stages.
- *
- * This is useful for when you want to edit a stage without reloading the whole game.
- * Reloading works on both the JSON and the HXC, if applicable.
- *
- * Call this by pressing F5 on a debug build.
- */
- override function debug_refreshModules():Void
+ * Call this by pressing F5 on a debug build.
+ */
+ override function reloadAssets():Void
{
- // Prevent further gameplay updates, which will try to reference dead objects.
- criticalFailure = true;
-
- // Remove the current stage. If the stage gets deleted while it's still in use,
- // it'll probably crash the game or something.
- if (this.currentStage != null)
- {
- remove(currentStage);
- var event:ScriptEvent = new ScriptEvent(DESTROY, false);
- ScriptEventDispatcher.callEvent(currentStage, event);
- currentStage = null;
- }
-
- if (!overrideMusic)
- {
- // Stop the instrumental.
- if (FlxG.sound.music != null)
- {
- FlxG.sound.music.destroy();
- FlxG.sound.music = null;
- }
-
- // Stop the vocals.
- if (vocals != null && vocals.exists)
- {
- vocals.destroy();
- vocals = null;
- }
- }
- else
- {
- // Stop the instrumental.
- if (FlxG.sound.music != null)
- {
- FlxG.sound.music.stop();
- }
-
- // Stop the vocals.
- if (vocals != null && vocals.exists)
- {
- vocals.stop();
- }
- }
-
- super.debug_refreshModules();
-
- var event:ScriptEvent = new ScriptEvent(CREATE, false);
- ScriptEventDispatcher.callEvent(currentSong, event);
+ funkin.modding.PolymodHandler.forceReloadAssets();
+ lastParams.targetSong = SongRegistry.instance.fetchEntry(currentSong.id);
+ LoadingState.loadPlayState(lastParams);
}
override function stepHit():Bool
@@ -1404,17 +1370,6 @@ class PlayState extends MusicBeatSubState
if (isGamePaused) return false;
- if (!startingSong
- && FlxG.sound.music != null
- && (Math.abs(FlxG.sound.music.time - (Conductor.instance.songPosition + Conductor.instance.instrumentalOffset)) > 200
- || Math.abs(vocals.checkSyncError(Conductor.instance.songPosition + Conductor.instance.instrumentalOffset)) > 200))
- {
- trace("VOCALS NEED RESYNC");
- if (vocals != null) trace(vocals.checkSyncError(Conductor.instance.songPosition + Conductor.instance.instrumentalOffset));
- trace(FlxG.sound.music.time - (Conductor.instance.songPosition + Conductor.instance.instrumentalOffset));
- resyncVocals();
- }
-
if (iconP1 != null) iconP1.onStepHit(Std.int(Conductor.instance.currentStep));
if (iconP2 != null) iconP2.onStepHit(Std.int(Conductor.instance.currentStep));
@@ -1436,6 +1391,17 @@ class PlayState extends MusicBeatSubState
// activeNotes.sort(SortUtil.byStrumtime, FlxSort.DESCENDING);
}
+ if (!startingSong
+ && FlxG.sound.music != null
+ && (Math.abs(FlxG.sound.music.time - (Conductor.instance.songPosition + Conductor.instance.instrumentalOffset)) > 100
+ || Math.abs(vocals.checkSyncError(Conductor.instance.songPosition + Conductor.instance.instrumentalOffset)) > 100))
+ {
+ trace("VOCALS NEED RESYNC");
+ if (vocals != null) trace(vocals.checkSyncError(Conductor.instance.songPosition + Conductor.instance.instrumentalOffset));
+ trace(FlxG.sound.music.time - (Conductor.instance.songPosition + Conductor.instance.instrumentalOffset));
+ resyncVocals();
+ }
+
// Only bop camera if zoom level is below 135%
if (Preferences.zoomCamera
&& FlxG.camera.zoom < (1.35 * FlxCamera.defaultZoom)
@@ -1488,9 +1454,6 @@ class PlayState extends MusicBeatSubState
if (playerStrumline != null) playerStrumline.onBeatHit();
if (opponentStrumline != null) opponentStrumline.onBeatHit();
- // Make the characters dance on the beat
- danceOnBeat();
-
return true;
}
@@ -1501,29 +1464,16 @@ class PlayState extends MusicBeatSubState
super.destroy();
}
- /**
- * Handles characters dancing to the beat of the current song.
- *
- * TODO: Move some of this logic into `Bopper.hx`, or individual character scripts.
- */
- function danceOnBeat():Void
+ public override function initConsoleHelpers():Void
{
- if (currentStage == null) return;
-
- // TODO: Add HEY! song events to Tutorial.
- if (Conductor.instance.currentBeat % 16 == 15
- && currentStage.getDad().characterId == 'gf'
- && Conductor.instance.currentBeat > 16
- && Conductor.instance.currentBeat < 48)
- {
- currentStage.getBoyfriend().playAnimation('hey', true);
- currentStage.getDad().playAnimation('cheer', true);
- }
- }
+ FlxG.console.registerFunction("debugUnbindCameraZoom", () -> {
+ debugUnbindCameraZoom = !debugUnbindCameraZoom;
+ });
+ };
/**
- * Initializes the game and HUD cameras.
- */
+ * Initializes the game and HUD cameras.
+ */
function initCameras():Void
{
camGame = new FunkinCamera('playStateCamGame');
@@ -1547,8 +1497,8 @@ class PlayState extends MusicBeatSubState
}
/**
- * Initializes the health bar on the HUD.
- */
+ * Initializes the health bar on the HUD.
+ */
function initHealthBar():Void
{
var healthBarYPos:Float = Preferences.downscroll ? FlxG.height * 0.1 : FlxG.height * 0.9;
@@ -1579,8 +1529,8 @@ class PlayState extends MusicBeatSubState
}
/**
- * Generates the stage and all its props.
- */
+ * Generates the stage and all its props.
+ */
function initStage():Void
{
loadStage(currentStageId);
@@ -1600,10 +1550,10 @@ class PlayState extends MusicBeatSubState
}
/**
- * Loads stage data from cache, assembles the props,
- * and adds it to the state.
- * @param id
- */
+ * Loads stage data from cache, assembles the props,
+ * and adds it to the state.
+ * @param id
+ */
function loadStage(id:String):Void
{
currentStage = StageRegistry.instance.fetchEntry(id);
@@ -1621,7 +1571,7 @@ class PlayState extends MusicBeatSubState
// Add the stage to the scene.
this.add(currentStage);
- #if (debug || FORCE_DEBUG_VERSION)
+ #if FEATURE_DEBUG_FUNCTIONS
FlxG.console.registerObject('stage', currentStage);
#end
}
@@ -1644,8 +1594,8 @@ class PlayState extends MusicBeatSubState
}
/**
- * Generates the character sprites and adds them to the stage.
- */
+ * Generates the character sprites and adds them to the stage.
+ */
function initCharacters():Void
{
if (currentSong == null || currentChart == null)
@@ -1724,7 +1674,7 @@ class PlayState extends MusicBeatSubState
{
currentStage.addCharacter(girlfriend, GF);
- #if (debug || FORCE_DEBUG_VERSION)
+ #if FEATURE_DEBUG_FUNCTIONS
FlxG.console.registerObject('gf', girlfriend);
#end
}
@@ -1733,7 +1683,7 @@ class PlayState extends MusicBeatSubState
{
currentStage.addCharacter(boyfriend, BF);
- #if (debug || FORCE_DEBUG_VERSION)
+ #if FEATURE_DEBUG_FUNCTIONS
FlxG.console.registerObject('bf', boyfriend);
#end
}
@@ -1744,7 +1694,7 @@ class PlayState extends MusicBeatSubState
// Camera starts at dad.
cameraFollowPoint.setPosition(dad.cameraFocusPoint.x, dad.cameraFocusPoint.y);
- #if (debug || FORCE_DEBUG_VERSION)
+ #if FEATURE_DEBUG_FUNCTIONS
FlxG.console.registerObject('dad', dad);
#end
}
@@ -1755,8 +1705,8 @@ class PlayState extends MusicBeatSubState
}
/**
- * Constructs the strumlines for each player.
- */
+ * Constructs the strumlines for each player.
+ */
function initStrumlines():Void
{
var noteStyleId:String = currentChart.noteStyle;
@@ -1783,19 +1733,31 @@ class PlayState extends MusicBeatSubState
opponentStrumline.zIndex = 1000;
opponentStrumline.cameras = [camHUD];
- if (!PlayStatePlaylist.isStoryMode)
- {
- playerStrumline.fadeInArrows();
- opponentStrumline.fadeInArrows();
- }
+ playerStrumline.fadeInArrows();
+ opponentStrumline.fadeInArrows();
}
/**
- * Initializes the Discord Rich Presence.
- */
+ * Configures the judgement and combo popups.
+ */
+ function initPopups():Void
+ {
+ var noteStyleId:String = currentChart.noteStyle;
+ var noteStyle:NoteStyle = NoteStyleRegistry.instance.fetchEntry(noteStyleId);
+ if (noteStyle == null) noteStyle = NoteStyleRegistry.instance.fetchDefault();
+ // Initialize the judgements and combo meter.
+ comboPopUps = new PopUpStuff(noteStyle);
+ comboPopUps.zIndex = 900;
+ add(comboPopUps);
+ comboPopUps.cameras = [camHUD];
+ }
+
+ /**
+ * Initializes the Discord Rich Presence.
+ */
function initDiscord():Void
{
- #if discord_rpc
+ #if FEATURE_DISCORD_RPC
storyDifficultyText = difficultyString();
iconRPC = currentSong.player2;
@@ -1826,9 +1788,9 @@ class PlayState extends MusicBeatSubState
}
/**
- * Initializes the song (applying the chart, generating the notes, etc.)
- * Should be done before the countdown starts.
- */
+ * Initializes the song (applying the chart, generating the notes, etc.)
+ * Should be done before the countdown starts.
+ */
function generateSong():Void
{
if (currentChart == null)
@@ -1842,7 +1804,7 @@ class PlayState extends MusicBeatSubState
{
// Stop the vocals if they already exist.
if (vocals != null) vocals.stop();
- vocals = currentChart.buildVocals();
+ vocals = currentChart.buildVocals(currentInstrumental);
if (vocals.members.length == 0)
{
@@ -1859,8 +1821,8 @@ class PlayState extends MusicBeatSubState
}
/**
- * Read note data from the chart and generate the notes.
- */
+ * Read note data from the chart and generate the notes.
+ */
function regenNoteData(startTime:Float = 0):Void
{
Highscore.tallies.combo = 0;
@@ -1913,27 +1875,26 @@ class PlayState extends MusicBeatSubState
}
/**
- * Prepares to start the countdown.
- * Ends any running cutscenes, creates the strumlines, and starts the countdown.
- * This is public so that scripts can call it.
- */
+ * Prepares to start the countdown.
+ * Ends any running cutscenes, creates the strumlines, and starts the countdown.
+ * This is public so that scripts can call it.
+ */
public function startCountdown():Void
{
// If Countdown.performCountdown returns false, then the countdown was canceled by a script.
- var result:Bool = Countdown.performCountdown(currentStageId.startsWith('school'));
+ var result:Bool = Countdown.performCountdown();
if (!result) return;
isInCutscene = false;
- camCutscene.visible = false;
// TODO: Maybe tween in the camera after any cutscenes.
camHUD.visible = true;
}
/**
- * Displays a dialogue cutscene with the given ID.
- * This is used by song scripts to display dialogue.
- */
+ * Displays a dialogue cutscene with the given ID.
+ * This is used by song scripts to display dialogue.
+ */
public function startConversation(conversationId:String):Void
{
isInCutscene = true;
@@ -1953,8 +1914,8 @@ class PlayState extends MusicBeatSubState
}
/**
- * Handler function called when a conversation ends.
- */
+ * Handler function called when a conversation ends.
+ */
function onConversationComplete():Void
{
isInCutscene = false;
@@ -1973,8 +1934,8 @@ class PlayState extends MusicBeatSubState
}
/**
- * Starts playing the song after the countdown has completed.
- */
+ * Starts playing the song after the countdown has completed.
+ */
function startSong():Void
{
startingSong = false;
@@ -1990,7 +1951,9 @@ class PlayState extends MusicBeatSubState
return;
}
- FlxG.sound.music.onComplete = endSong.bind(false);
+ FlxG.sound.music.onComplete = function() {
+ endSong(skipEndingTransition);
+ };
// A negative instrumental offset means the song skips the first few milliseconds of the track.
// This just gets added into the startTimestamp behavior so we don't need to do anything extra.
FlxG.sound.music.play(true, startTimestamp - Conductor.instance.instrumentalOffset);
@@ -2007,7 +1970,7 @@ class PlayState extends MusicBeatSubState
vocals.pitch = playbackRate;
resyncVocals();
- #if discord_rpc
+ #if FEATURE_DISCORD_RPC
// Updating Discord Rich Presence (with Time Left)
DiscordClient.changePresence(detailsText, '${currentChart.songName} ($storyDifficultyText)', iconRPC, true, currentSongLengthMs);
#end
@@ -2022,26 +1985,28 @@ class PlayState extends MusicBeatSubState
}
/**
- * Resyncronize the vocal tracks if they have become offset from the instrumental.
- */
+ * Resyncronize the vocal tracks if they have become offset from the instrumental.
+ */
function resyncVocals():Void
{
if (vocals == null) return;
// Skip this if the music is paused (GameOver, Pause menu, start-of-song offset, etc.)
- if (!FlxG.sound.music.playing) return;
-
+ if (!(FlxG.sound.music?.playing ?? false)) return;
+ var timeToPlayAt:Float = Conductor.instance.songPosition - Conductor.instance.instrumentalOffset;
+ FlxG.sound.music.pause();
vocals.pause();
- FlxG.sound.music.play(FlxG.sound.music.time);
+ FlxG.sound.music.time = timeToPlayAt;
+ FlxG.sound.music.play(false, timeToPlayAt);
- vocals.time = FlxG.sound.music.time;
- vocals.play(false, FlxG.sound.music.time);
+ vocals.time = timeToPlayAt;
+ vocals.play(false, timeToPlayAt);
}
/**
- * Updates the position and contents of the score display.
- */
+ * Updates the position and contents of the score display.
+ */
function updateScoreText():Void
{
// TODO: Add functionality for modules to update the score text.
@@ -2051,13 +2016,15 @@ class PlayState extends MusicBeatSubState
}
else
{
- scoreText.text = 'Score:' + songScore;
+ // TODO: Add an option for this maybe?
+ var commaSeparated:Bool = true;
+ scoreText.text = 'Score: ${FlxStringUtil.formatMoney(songScore, false, commaSeparated)}';
}
}
/**
- * Updates the values of the health bar.
- */
+ * Updates the values of the health bar.
+ */
function updateHealthBar():Void
{
if (isBotPlayMode)
@@ -2071,8 +2038,8 @@ class PlayState extends MusicBeatSubState
}
/**
- * Callback executed when one of the note keys is pressed.
- */
+ * Callback executed when one of the note keys is pressed.
+ */
function onKeyPress(event:PreciseInputEvent):Void
{
if (isGamePaused) return;
@@ -2082,8 +2049,8 @@ class PlayState extends MusicBeatSubState
}
/**
- * Callback executed when one of the note keys is released.
- */
+ * Callback executed when one of the note keys is released.
+ */
function onKeyRelease(event:PreciseInputEvent):Void
{
if (isGamePaused) return;
@@ -2093,8 +2060,8 @@ class PlayState extends MusicBeatSubState
}
/**
- * Handles opponent note hits and player note misses.
- */
+ * Handles opponent note hits and player note misses.
+ */
function processNotes(elapsed:Float):Void
{
if (playerStrumline?.notes?.members == null || opponentStrumline?.notes?.members == null) return;
@@ -2267,10 +2234,14 @@ class PlayState extends MusicBeatSubState
// Calling event.cancelEvent() skips all the other logic! Neat!
if (event.eventCanceled) continue;
- // Judge the miss.
- // NOTE: This is what handles the scoring.
- trace('Missed note! ${note.noteData}');
- onNoteMiss(note, event.playSound, event.healthChange);
+ // Skip handling the miss in botplay!
+ if (!isBotPlayMode)
+ {
+ // Judge the miss.
+ // NOTE: This is what handles the scoring.
+ trace('Missed note! ${note.noteData}');
+ onNoteMiss(note, event.playSound, event.healthChange);
+ }
note.handledMiss = true;
}
@@ -2312,8 +2283,8 @@ class PlayState extends MusicBeatSubState
}
/**
- * Spitting out the input for ravy 🙇♂️!!
- */
+ * Spitting out the input for ravy 🙇♂️!!
+ */
var inputSpitter:Array = [];
function handleSkippedNotes():Void
@@ -2337,9 +2308,9 @@ class PlayState extends MusicBeatSubState
}
/**
- * PreciseInputEvents are put into a queue between update() calls,
- * and then processed here.
- */
+ * PreciseInputEvents are put into a queue between update() calls,
+ * and then processed here.
+ */
function processInputQueue():Void
{
if (inputPressQueue.length + inputReleaseQueue.length == 0) return;
@@ -2367,9 +2338,16 @@ class PlayState extends MusicBeatSubState
playerStrumline.pressKey(input.noteDirection);
+ // Don't credit or penalize inputs in Bot Play.
+ if (isBotPlayMode) continue;
+
var notesInDirection:Array = notesByDirection[input.noteDirection];
- if (!Constants.GHOST_TAPPING && notesInDirection.length == 0)
+ #if FEATURE_GHOST_TAPPING
+ if ((!playerStrumline.mayGhostTap()) && notesInDirection.length == 0)
+ #else
+ if (notesInDirection.length == 0)
+ #end
{
// Pressed a wrong key with no notes nearby.
// Perform a ghost miss (anti-spam).
@@ -2379,41 +2357,31 @@ class PlayState extends MusicBeatSubState
playerStrumline.playPress(input.noteDirection);
trace('PENALTY Score: ${songScore}');
}
- else if (Constants.GHOST_TAPPING && (!playerStrumline.mayGhostTap()) && notesInDirection.length == 0)
- {
- // Pressed a wrong key with notes visible on-screen.
- // Perform a ghost miss (anti-spam).
- ghostNoteMiss(input.noteDirection, notesInRange.length > 0);
+ else if (notesInDirection.length == 0)
+ {
+ // Press a key with no penalty.
- // Play the strumline animation.
- playerStrumline.playPress(input.noteDirection);
- trace('PENALTY Score: ${songScore}');
- }
- else if (notesInDirection.length == 0)
- {
- // Press a key with no penalty.
+ // Play the strumline animation.
+ playerStrumline.playPress(input.noteDirection);
+ trace('NO PENALTY Score: ${songScore}');
+ }
+ else
+ {
+ // Choose the first note, deprioritizing low priority notes.
+ var targetNote:Null = notesInDirection.find((note) -> !note.lowPriority);
+ if (targetNote == null) targetNote = notesInDirection[0];
+ if (targetNote == null) continue;
- // Play the strumline animation.
- playerStrumline.playPress(input.noteDirection);
- trace('NO PENALTY Score: ${songScore}');
- }
- else
- {
- // Choose the first note, deprioritizing low priority notes.
- var targetNote:Null = notesInDirection.find((note) -> !note.lowPriority);
- if (targetNote == null) targetNote = notesInDirection[0];
- if (targetNote == null) continue;
+ // Judge and hit the note.
+ trace('Hit note! ${targetNote.noteData}');
+ goodNoteHit(targetNote, input);
+ trace('Score: ${songScore}');
- // Judge and hit the note.
- trace('Hit note! ${targetNote.noteData}');
- goodNoteHit(targetNote, input);
- trace('Score: ${songScore}');
+ notesInDirection.remove(targetNote);
- notesInDirection.remove(targetNote);
-
- // Play the strumline animation.
- playerStrumline.playConfirm(input.noteDirection);
- }
+ // Play the strumline animation.
+ playerStrumline.playConfirm(input.noteDirection);
+ }
}
while (inputReleaseQueue.length > 0)
@@ -2481,9 +2449,9 @@ class PlayState extends MusicBeatSubState
}
/**
- * Called when a note leaves the screen and is considered missed by the player.
- * @param note
- */
+ * Called when a note leaves the screen and is considered missed by the player.
+ * @param note
+ */
function onNoteMiss(note:NoteSprite, playSound:Bool = false, healthChange:Float):Void
{
// If we are here, we already CALLED the onNoteMiss script hook!
@@ -2539,13 +2507,13 @@ class PlayState extends MusicBeatSubState
}
/**
- * Called when a player presses a key with no note present.
- * Scripts can modify the amount of health/score lost, whether player animations or sounds are used,
- * or even cancel the event entirely.
- *
- * @param direction
- * @param hasPossibleNotes
- */
+ * Called when a player presses a key with no note present.
+ * Scripts can modify the amount of health/score lost, whether player animations or sounds are used,
+ * or even cancel the event entirely.
+ *
+ * @param direction
+ * @param hasPossibleNotes
+ */
function ghostNoteMiss(direction:NoteDirection, hasPossibleNotes:Bool = true):Void
{
var event:GhostMissNoteScriptEvent = new GhostMissNoteScriptEvent(direction, // Direction missed in.
@@ -2594,17 +2562,11 @@ class PlayState extends MusicBeatSubState
}
/**
- * Debug keys. Disabled while in cutscenes.
- */
+ * Debug keys. Disabled while in cutscenes.
+ */
function debugKeyShit():Void
{
- #if !debug
- perfectMode = false;
- #else
- if (FlxG.keys.justPressed.H) camHUD.visible = !camHUD.visible;
- #end
-
- #if CHART_EDITOR_SUPPORTED
+ #if FEATURE_CHART_EDITOR
// Open the stage editor overlaying the current state.
if (controls.DEBUG_STAGE)
{
@@ -2619,14 +2581,25 @@ class PlayState extends MusicBeatSubState
{
disableKeys = true;
persistentUpdate = false;
- FlxG.switchState(() -> new ChartEditorState(
- {
- targetSongId: currentSong.id,
- }));
+ if (isChartingMode)
+ {
+ FlxG.sound.music?.pause();
+ this.close();
+ }
+ else
+ {
+ FlxG.switchState(() -> new ChartEditorState(
+ {
+ targetSongId: currentSong.id,
+ }));
+ }
}
#end
- #if (debug || FORCE_DEBUG_VERSION)
+ #if FEATURE_DEBUG_FUNCTIONS
+ // H: Hide the HUD.
+ if (FlxG.keys.justPressed.H) camHUD.visible = !camHUD.visible;
+
// 1: End the song immediately.
if (FlxG.keys.justPressed.ONE) endSong(true);
@@ -2640,7 +2613,7 @@ class PlayState extends MusicBeatSubState
// 9: Toggle the old icon.
if (FlxG.keys.justPressed.NINE) iconP1.toggleOldIcon();
- #if (debug || FORCE_DEBUG_VERSION)
+ #if FEATURE_DEBUG_FUNCTIONS
// PAGEUP: Skip forward two sections.
// SHIFT+PAGEUP: Skip forward twenty sections.
if (FlxG.keys.justPressed.PAGEUP) changeSection(FlxG.keys.pressed.SHIFT ? 20 : 2);
@@ -2653,8 +2626,8 @@ class PlayState extends MusicBeatSubState
}
/**
- * Handles applying health, score, and ratings.
- */
+ * Handles applying health, score, and ratings.
+ */
function applyScore(score:Int, daRating:String, healthChange:Float, isComboBreak:Bool)
{
switch (daRating)
@@ -2686,8 +2659,8 @@ class PlayState extends MusicBeatSubState
}
/**
- * Handles rating popups when a note is hit.
- */
+ * Handles rating popups when a note is hit.
+ */
function popUpScore(daRating:String, ?combo:Int):Void
{
if (daRating == 'miss')
@@ -2744,10 +2717,10 @@ class PlayState extends MusicBeatSubState
}
/**
- * Handle keyboard inputs during cutscenes.
- * This includes advancing conversations and skipping videos.
- * @param elapsed Time elapsed since last game update.
- */
+ * Handle keyboard inputs during cutscenes.
+ * This includes advancing conversations and skipping videos.
+ * @param elapsed Time elapsed since last game update.
+ */
function handleCutsceneKeys(elapsed:Float):Void
{
if (isGamePaused) return;
@@ -2791,20 +2764,20 @@ class PlayState extends MusicBeatSubState
}
/**
- * Handle logic for actually skipping a video cutscene after it has been held.
- */
+ * Handle logic for actually skipping a video cutscene after it has been held.
+ */
function skipVideoCutscene():Void
{
VideoCutscene.finishVideo();
}
/**
- * End the song. Handle saving high scores and transitioning to the results screen.
- *
- * Broadcasts an `onSongEnd` event, which can be cancelled to prevent the song from ending (for a cutscene or something).
- * Remember to call `endSong` again when the song should actually end!
- * @param rightGoddamnNow If true, don't play the fancy animation where you zoom onto Girlfriend. Used after a cutscene.
- */
+ * End the song. Handle saving high scores and transitioning to the results screen.
+ *
+ * Broadcasts an `onSongEnd` event, which can be cancelled to prevent the song from ending (for a cutscene or something).
+ * Remember to call `endSong` again when the song should actually end!
+ * @param rightGoddamnNow If true, don't play the fancy animation where you zoom onto Girlfriend. Used after a cutscene.
+ */
public function endSong(rightGoddamnNow:Bool = false):Void
{
if (FlxG.sound.music != null) FlxG.sound.music.volume = 0;
@@ -3019,8 +2992,8 @@ class PlayState extends MusicBeatSubState
}
/**
- * Perform necessary cleanup before leaving the PlayState.
- */
+ * Perform necessary cleanup before leaving the PlayState.
+ */
function performCleanup():Void
{
// If the camera is being tweened, stop it.
@@ -3071,14 +3044,15 @@ class PlayState extends MusicBeatSubState
GameOverSubState.reset();
PauseSubState.reset();
+ Countdown.reset();
// Clear the static reference to this state.
instance = null;
}
/**
- * Play the camera zoom animation and then move to the results screen once it's done.
- */
+ * Play the camera zoom animation and then move to the results screen once it's done.
+ */
function zoomIntoResultsScreen(isNewHighscore:Bool, ?prevScoreData:SaveScoreData):Void
{
trace('WENT TO RESULTS SCREEN!');
@@ -3142,17 +3116,17 @@ class PlayState extends MusicBeatSubState
// Zoom over to the Results screen.
// TODO: Re-enable this.
/*
- FlxTween.tween(FlxG.camera, {zoom: 1200}, 1.1,
- {
- ease: FlxEase.expoIn,
- });
- */
+ FlxTween.tween(FlxG.camera, {zoom: 1200}, 1.1,
+ {
+ ease: FlxEase.expoIn,
+ });
+ */
});
}
/**
- * Move to the results screen right goddamn now.
- */
+ * Move to the results screen right goddamn now.
+ */
function moveToResultsScreen(isNewHighscore:Bool, ?prevScoreData:SaveScoreData):Void
{
persistentUpdate = false;
@@ -3166,6 +3140,7 @@ class PlayState extends MusicBeatSubState
storyMode: PlayStatePlaylist.isStoryMode,
songId: currentChart.song.id,
difficultyId: currentDifficulty,
+ characterId: currentChart.characters.player,
title: PlayStatePlaylist.isStoryMode ? ('${PlayStatePlaylist.campaignTitle}') : ('${currentChart.songName} by ${currentChart.songArtist}'),
prevScoreData: prevScoreData,
scoreData:
@@ -3191,8 +3166,8 @@ class PlayState extends MusicBeatSubState
}
/**
- * Pauses music and vocals easily.
- */
+ * Pauses music and vocals easily.
+ */
public function pauseMusic():Void
{
if (FlxG.sound.music != null) FlxG.sound.music.pause();
@@ -3200,8 +3175,8 @@ class PlayState extends MusicBeatSubState
}
/**
- * Resets the camera's zoom level and focus point.
- */
+ * Resets the camera's zoom level and focus point.
+ */
public function resetCamera(?resetZoom:Bool = true, ?cancelTweens:Bool = true):Void
{
// Cancel camera tweens if any are active.
@@ -3223,8 +3198,8 @@ class PlayState extends MusicBeatSubState
}
/**
- * Sets the camera follow point's position and tweens the camera there.
- */
+ * Sets the camera follow point's position and tweens the camera there.
+ */
public function tweenCameraToPosition(?x:Float, ?y:Float, ?duration:Float, ?ease:NullFloat>):Void
{
cameraFollowPoint.setPosition(x, y);
@@ -3232,8 +3207,8 @@ class PlayState extends MusicBeatSubState
}
/**
- * Disables camera following and tweens the camera to the follow point manually.
- */
+ * Disables camera following and tweens the camera to the follow point manually.
+ */
public function tweenCameraToFollowPoint(?duration:Float, ?ease:NullFloat>):Void
{
// Cancel the current tween if it's active.
@@ -3270,8 +3245,8 @@ class PlayState extends MusicBeatSubState
}
/**
- * Tweens the camera zoom to the desired amount.
- */
+ * Tweens the camera zoom to the desired amount.
+ */
public function tweenCameraZoom(?zoom:Float, ?duration:Float, ?direct:Bool, ?ease:NullFloat>):Void
{
// Cancel the current tween if it's active.
@@ -3302,8 +3277,8 @@ class PlayState extends MusicBeatSubState
}
/**
- * Cancel all active camera tweens simultaneously.
- */
+ * Cancel all active camera tweens simultaneously.
+ */
public function cancelAllCameraTweens()
{
cancelCameraFollowTween();
@@ -3313,8 +3288,8 @@ class PlayState extends MusicBeatSubState
var prevScrollTargets:Array = []; // used to snap scroll speed when things go unruely
/**
- * The magical function that shall tween the scroll speed.
- */
+ * The magical function that shall tween the scroll speed.
+ */
public function tweenScrollSpeed(?speed:Float, ?duration:Float, ?ease:NullFloat>, strumlines:Array):Void
{
// Cancel the current tween if it's active.
@@ -3364,12 +3339,12 @@ class PlayState extends MusicBeatSubState
scrollSpeedTweens = [];
}
- #if (debug || FORCE_DEBUG_VERSION)
+ #if FEATURE_DEBUG_FUNCTIONS
/**
- * Jumps forward or backward a number of sections in the song.
- * Accounts for BPM changes, does not prevent death from skipped notes.
- * @param sections The number of sections to jump, negative to go backwards.
- */
+ * Jumps forward or backward a number of sections in the song.
+ * Accounts for BPM changes, does not prevent death from skipped notes.
+ * @param sections The number of sections to jump, negative to go backwards.
+ */
function changeSection(sections:Int):Void
{
// FlxG.sound.music.pause();
diff --git a/source/funkin/play/ResultState.hx b/source/funkin/play/ResultState.hx
index 48fb3b04e..739df167d 100644
--- a/source/funkin/play/ResultState.hx
+++ b/source/funkin/play/ResultState.hx
@@ -4,6 +4,8 @@ import funkin.util.MathUtil;
import funkin.ui.story.StoryMenuState;
import funkin.graphics.adobeanimate.FlxAtlasSprite;
import flixel.FlxSprite;
+import flixel.FlxState;
+import flixel.FlxSubState;
import funkin.graphics.FunkinSprite;
import flixel.effects.FlxFlicker;
import flixel.graphics.frames.FlxBitmapFont;
@@ -14,6 +16,9 @@ import flixel.math.FlxRect;
import flixel.text.FlxBitmapText;
import funkin.ui.freeplay.FreeplayScore;
import flixel.text.FlxText;
+import funkin.data.freeplay.player.PlayerRegistry;
+import funkin.data.freeplay.player.PlayerData;
+import funkin.ui.freeplay.charselect.PlayableCharacter;
import flixel.util.FlxColor;
import flixel.tweens.FlxEase;
import funkin.graphics.FunkinCamera;
@@ -55,14 +60,19 @@ class ResultState extends MusicBeatSubState
final highscoreNew:FlxSprite;
final score:ResultScore;
- var bfPerfect:Null = null;
- var heartsPerfect:Null = null;
- var bfExcellent:Null = null;
- var bfGreat:Null = null;
- var gfGreat:Null = null;
- var bfGood:Null = null;
- var gfGood:Null = null;
- var bfShit:Null = null;
+ var characterAtlasAnimations:Array<
+ {
+ sprite:FlxAtlasSprite,
+ delay:Float,
+ forceLoop:Bool
+ }> = [];
+ var characterSparrowAnimations:Array<
+ {
+ sprite:FunkinSprite,
+ delay:Float
+ }> = [];
+
+ var playerCharacterId:Null;
var rankBg:FunkinSprite;
final cameraBG:FunkinCamera;
@@ -157,118 +167,98 @@ class ResultState extends MusicBeatSubState
soundSystem.zIndex = 1100;
add(soundSystem);
- switch (rank)
+ // Fetch playable character data. Default to BF on the results screen if we can't find it.
+ playerCharacterId = PlayerRegistry.instance.getCharacterOwnerId(params.characterId);
+ var playerCharacter:Null = PlayerRegistry.instance.fetchEntry(playerCharacterId ?? 'bf');
+
+ trace('Got playable character: ${playerCharacter?.getName()}');
+ // Query JSON data based on the rank, then use that to build the animation(s) the player sees.
+ var playerAnimationDatas:Array = playerCharacter != null ? playerCharacter.getResultsAnimationDatas(rank) : [];
+
+ for (animData in playerAnimationDatas)
{
- case PERFECT | PERFECT_GOLD:
- heartsPerfect = new FlxAtlasSprite(1342, 370, Paths.animateAtlas("resultScreen/results-bf/resultsPERFECT/hearts", "shared"));
- heartsPerfect.visible = false;
- heartsPerfect.zIndex = 501;
- add(heartsPerfect);
+ if (animData == null) continue;
- heartsPerfect.anim.onComplete = () -> {
- if (heartsPerfect != null)
+ var animPath:String = Paths.stripLibrary(animData.assetPath);
+ var animLibrary:String = Paths.getLibrary(animData.assetPath);
+ var offsets = animData.offsets ?? [0, 0];
+ switch (animData.renderType)
+ {
+ case 'animateatlas':
+ var animation:FlxAtlasSprite = new FlxAtlasSprite(offsets[0], offsets[1], Paths.animateAtlas(animPath, animLibrary));
+ animation.zIndex = animData.zIndex ?? 500;
+
+ animation.scale.set(animData.scale ?? 1.0, animData.scale ?? 1.0);
+
+ if (!(animData.looped ?? true))
{
- // bfPerfect.anim.curFrame = 137;
- heartsPerfect.anim.curFrame = 43;
- heartsPerfect.anim.play(); // unpauses this anim, since it's on PlayOnce!
+ // Animation is not looped.
+ animation.onAnimationComplete.add((_name:String) -> {
+ trace("AHAHAH 2");
+ if (animation != null)
+ {
+ animation.anim.pause();
+ }
+ });
}
- };
-
- bfPerfect = new FlxAtlasSprite(1342, 370, Paths.animateAtlas("resultScreen/results-bf/resultsPERFECT", "shared"));
- bfPerfect.visible = false;
- bfPerfect.zIndex = 500;
- add(bfPerfect);
-
- bfPerfect.anim.onComplete = () -> {
- if (bfPerfect != null)
+ else if (animData.loopFrameLabel != null)
{
- // bfPerfect.anim.curFrame = 137;
- bfPerfect.anim.curFrame = 137;
- bfPerfect.anim.play(); // unpauses this anim, since it's on PlayOnce!
+ animation.onAnimationComplete.add((_name:String) -> {
+ trace("AHAHAH 2");
+ if (animation != null)
+ {
+ animation.playAnimation(animData.loopFrameLabel ?? '', true, false, true); // unpauses this anim, since it's on PlayOnce!
+ }
+ });
}
- };
-
- case EXCELLENT:
- bfExcellent = new FlxAtlasSprite(1329, 429, Paths.animateAtlas("resultScreen/results-bf/resultsEXCELLENT", "shared"));
- bfExcellent.visible = false;
- bfExcellent.zIndex = 500;
- add(bfExcellent);
-
- bfExcellent.anim.onComplete = () -> {
- if (bfExcellent != null)
+ else if (animData.loopFrame != null)
{
- bfExcellent.anim.curFrame = 28;
- bfExcellent.anim.play(); // unpauses this anim, since it's on PlayOnce!
+ animation.onAnimationComplete.add((_name:String) -> {
+ if (animation != null)
+ {
+ trace("AHAHAH");
+ animation.anim.curFrame = animData.loopFrame ?? 0;
+ animation.anim.play(); // unpauses this anim, since it's on PlayOnce!
+ }
+ });
}
- };
- case GREAT:
- gfGreat = new FlxAtlasSprite(802, 331, Paths.animateAtlas("resultScreen/results-bf/resultsGREAT/gf", "shared"));
- gfGreat.visible = false;
- gfGreat.zIndex = 499;
- add(gfGreat);
+ // Hide until ready to play.
+ animation.visible = false;
+ // Queue to play.
+ characterAtlasAnimations.push(
+ {
+ sprite: animation,
+ delay: animData.delay ?? 0.0,
+ forceLoop: (animData.loopFrame ?? -1) == 0
+ });
+ // Add to the scene.
+ add(animation);
+ case 'sparrow':
+ var animation:FunkinSprite = FunkinSprite.createSparrow(offsets[0], offsets[1], animPath);
+ animation.animation.addByPrefix('idle', '', 24, false, false, false);
- gfGreat.scale.set(0.93, 0.93);
-
- gfGreat.anim.onComplete = () -> {
- if (gfGreat != null)
+ if (animData.loopFrame != null)
{
- gfGreat.anim.curFrame = 9;
- gfGreat.anim.play(); // unpauses this anim, since it's on PlayOnce!
+ animation.animation.finishCallback = (_name:String) -> {
+ if (animation != null)
+ {
+ animation.animation.play('idle', true, false, animData.loopFrame ?? 0);
+ }
+ }
}
- };
- bfGreat = new FlxAtlasSprite(929, 363, Paths.animateAtlas("resultScreen/results-bf/resultsGREAT/bf", "shared"));
- bfGreat.visible = false;
- bfGreat.zIndex = 500;
- add(bfGreat);
-
- bfGreat.scale.set(0.93, 0.93);
-
- bfGreat.anim.onComplete = () -> {
- if (bfGreat != null)
- {
- bfGreat.anim.curFrame = 15;
- bfGreat.anim.play(); // unpauses this anim, since it's on PlayOnce!
- }
- };
-
- case GOOD:
- gfGood = FunkinSprite.createSparrow(625, 325, 'resultScreen/results-bf/resultsGOOD/resultGirlfriendGOOD');
- gfGood.animation.addByPrefix("clap", "Girlfriend Good Anim", 24, false);
- gfGood.visible = false;
- gfGood.zIndex = 500;
- gfGood.animation.finishCallback = _ -> {
- if (gfGood != null)
- {
- gfGood.animation.play('clap', true, false, 9);
- }
- };
- add(gfGood);
-
- bfGood = FunkinSprite.createSparrow(640, -200, 'resultScreen/results-bf/resultsGOOD/resultBoyfriendGOOD');
- bfGood.animation.addByPrefix("fall", "Boyfriend Good Anim0", 24, false);
- bfGood.visible = false;
- bfGood.zIndex = 501;
- bfGood.animation.finishCallback = function(_) {
- if (bfGood != null)
- {
- bfGood.animation.play('fall', true, false, 14);
- }
- };
- add(bfGood);
-
- case SHIT:
- bfShit = new FlxAtlasSprite(0, 20, Paths.animateAtlas("resultScreen/results-bf/resultsSHIT", "shared"));
- bfShit.visible = false;
- bfShit.zIndex = 500;
- add(bfShit);
- bfShit.onAnimationFinish.add((animName) -> {
- if (bfShit != null)
- {
- bfShit.playAnimation('Loop Start');
- }
- });
+ // Hide until ready to play.
+ animation.visible = false;
+ // Queue to play.
+ characterSparrowAnimations.push(
+ {
+ sprite: animation,
+ delay: animData.delay ?? 0.0
+ });
+ // Add to the scene.
+ add(animation);
+ }
}
var diffSpr:String = 'diff_${params?.difficultyId ?? 'Normal'}';
@@ -419,28 +409,26 @@ class ResultState extends MusicBeatSubState
// }
new FlxTimer().start(rank.getMusicDelay(), _ -> {
- if (rank.hasMusicIntro())
+ var introMusic:String = Paths.music(getMusicPath(playerCharacter, rank) + '/' + getMusicPath(playerCharacter, rank) + '-intro');
+ if (Assets.exists(introMusic))
{
// Play the intro music.
- var introMusic:String = Paths.music(rank.getMusicPath() + '/' + rank.getMusicPath() + '-intro');
FunkinSound.load(introMusic, 1.0, false, true, true, () -> {
- FunkinSound.playMusic(rank.getMusicPath(),
+ FunkinSound.playMusic(getMusicPath(playerCharacter, rank),
{
startingVolume: 1.0,
overrideExisting: true,
- restartTrack: true,
- loop: rank.shouldMusicLoop()
+ restartTrack: true
});
});
}
else
{
- FunkinSound.playMusic(rank.getMusicPath(),
+ FunkinSound.playMusic(getMusicPath(playerCharacter, rank),
{
startingVolume: 1.0,
overrideExisting: true,
- restartTrack: true,
- loop: rank.shouldMusicLoop()
+ restartTrack: true
});
}
});
@@ -456,6 +444,11 @@ class ResultState extends MusicBeatSubState
super.create();
}
+ function getMusicPath(playerCharacter:Null, rank:ScoringRank):String
+ {
+ return playerCharacter?.getResultsMusicPath(rank) ?? 'resultsNORMAL';
+ }
+
var rankTallyTimer:Null = null;
var clearPercentTarget:Int = 100;
var clearPercentLerp:Int = 0;
@@ -464,7 +457,9 @@ class ResultState extends MusicBeatSubState
{
bgFlash.visible = true;
FlxTween.tween(bgFlash, {alpha: 0}, 5 / 24);
- var clearPercentFloat = (params.scoreData.tallies.sick + params.scoreData.tallies.good) / params.scoreData.tallies.totalNotes * 100;
+ // NOTE: Only divide if totalNotes > 0 to prevent divide-by-zero errors.
+ var clearPercentFloat = params.scoreData.tallies.totalNotes == 0 ? 0.0 : (params.scoreData.tallies.sick +
+ params.scoreData.tallies.good) / params.scoreData.tallies.totalNotes * 100;
clearPercentTarget = Math.floor(clearPercentFloat);
// Prevent off-by-one errors.
@@ -585,94 +580,22 @@ class ResultState extends MusicBeatSubState
{
showSmallClearPercent();
- switch (rank)
+ for (atlas in characterAtlasAnimations)
{
- case PERFECT | PERFECT_GOLD:
- if (bfPerfect == null)
- {
- trace("Could not build PERFECT animation!");
- }
- else
- {
- bfPerfect.visible = true;
- bfPerfect.playAnimation('');
- }
- new FlxTimer().start(106 / 24, _ -> {
- if (heartsPerfect == null)
- {
- trace("Could not build heartsPerfect animation!");
- }
- else
- {
- heartsPerfect.visible = true;
- heartsPerfect.playAnimation('');
- }
- });
- case EXCELLENT:
- if (bfExcellent == null)
- {
- trace("Could not build EXCELLENT animation!");
- }
- else
- {
- bfExcellent.visible = true;
- bfExcellent.playAnimation('');
- }
- case GREAT:
- if (bfGreat == null)
- {
- trace("Could not build GREAT animation!");
- }
- else
- {
- bfGreat.visible = true;
- bfGreat.playAnimation('');
- }
+ new FlxTimer().start(atlas.delay, _ -> {
+ if (atlas.sprite == null) return;
+ atlas.sprite.visible = true;
+ atlas.sprite.playAnimation('');
+ });
+ }
- new FlxTimer().start(6 / 24, _ -> {
- if (gfGreat == null)
- {
- trace("Could not build GREAT animation for gf!");
- }
- else
- {
- gfGreat.visible = true;
- gfGreat.playAnimation('');
- }
- });
- case SHIT:
- if (bfShit == null)
- {
- trace("Could not build SHIT animation!");
- }
- else
- {
- bfShit.visible = true;
- bfShit.playAnimation('Intro');
- }
- case GOOD:
- if (bfGood == null)
- {
- trace("Could not build GOOD animation!");
- }
- else
- {
- bfGood.animation.play('fall');
- bfGood.visible = true;
- new FlxTimer().start((1 / 24) * 22, _ -> {
- // plays about 22 frames (at 24fps timing) after bf spawns in
- if (gfGood != null)
- {
- gfGood.animation.play('clap', true);
- gfGood.visible = true;
- }
- else
- {
- trace("Could not build GOOD animation!");
- }
- });
- }
- default:
+ for (sprite in characterSparrowAnimations)
+ {
+ new FlxTimer().start(sprite.delay, _ -> {
+ if (sprite.sprite == null) return;
+ sprite.sprite.visible = true;
+ sprite.sprite.animation.play('idle', true);
+ });
}
}
@@ -774,52 +697,6 @@ class ResultState extends MusicBeatSubState
// }));
// }
- // if(heartsPerfect != null){
- // if (FlxG.keys.justPressed.I)
- // {
- // heartsPerfect.y -= 1;
- // trace(heartsPerfect.x, heartsPerfect.y);
- // }
- // if (FlxG.keys.justPressed.J)
- // {
- // heartsPerfect.x -= 1;
- // trace(heartsPerfect.x, heartsPerfect.y);
- // }
- // if (FlxG.keys.justPressed.L)
- // {
- // heartsPerfect.x += 1;
- // trace(heartsPerfect.x, heartsPerfect.y);
- // }
- // if (FlxG.keys.justPressed.K)
- // {
- // heartsPerfect.y += 1;
- // trace(heartsPerfect.x, heartsPerfect.y);
- // }
- // }
-
- // if(bfGreat != null){
- // if (FlxG.keys.justPressed.W)
- // {
- // bfGreat.y -= 1;
- // trace(bfGreat.x, bfGreat.y);
- // }
- // if (FlxG.keys.justPressed.A)
- // {
- // bfGreat.x -= 1;
- // trace(bfGreat.x, bfGreat.y);
- // }
- // if (FlxG.keys.justPressed.D)
- // {
- // bfGreat.x += 1;
- // trace(bfGreat.x, bfGreat.y);
- // }
- // if (FlxG.keys.justPressed.S)
- // {
- // bfGreat.y += 1;
- // trace(bfGreat.x, bfGreat.y);
- // }
- // }
-
// maskShaderSongName.swagSprX = songName.x;
maskShaderDifficulty.swagSprX = difficulty.x;
@@ -857,53 +734,93 @@ class ResultState extends MusicBeatSubState
}
});
}
+
+ // Determining the target state(s) to go to.
+ // Default to main menu because that's better than `null`.
+ var targetState:flixel.FlxState = new funkin.ui.mainmenu.MainMenuState();
+ var shouldTween = false;
+ var shouldUseSubstate = false;
+
if (params.storyMode)
{
- openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> new StoryMenuState(sticker)));
+ if (PlayerRegistry.instance.hasNewCharacter())
+ {
+ // New character, display the notif.
+ targetState = new StoryMenuState(null);
+
+ var newCharacters = PlayerRegistry.instance.listNewCharacters();
+
+ for (charId in newCharacters)
+ {
+ shouldTween = true;
+ // This works recursively, ehe!
+ targetState = new funkin.ui.charSelect.CharacterUnlockState(charId, targetState);
+ }
+ }
+ else
+ {
+ // No new characters.
+ shouldTween = false;
+ shouldUseSubstate = true;
+ targetState = new funkin.ui.transition.StickerSubState(null, (sticker) -> new StoryMenuState(sticker));
+ }
}
else
{
- var rigged:Bool = true;
- if (rank > Scoring.calculateRank(params?.prevScoreData)) // if (rigged)
+ if (rank > Scoring.calculateRank(params?.prevScoreData))
{
trace('THE RANK IS Higher.....');
- FlxTween.tween(rankBg, {alpha: 1}, 0.5,
+ shouldTween = true;
+ targetState = FreeplayState.build(
{
- ease: FlxEase.expoOut,
- onComplete: function(_) {
- FlxG.switchState(FreeplayState.build(
+ {
+ character: playerCharacterId ?? "bf",
+ fromResults:
{
- {
- fromResults:
- {
- oldRank: Scoring.calculateRank(params?.prevScoreData),
- newRank: rank,
- songId: params.songId,
- difficultyId: params.difficultyId,
- playRankAnim: true
- }
- }
- }));
+ oldRank: Scoring.calculateRank(params?.prevScoreData),
+ newRank: rank,
+ songId: params.songId,
+ difficultyId: params.difficultyId,
+ playRankAnim: true
+ }
}
});
}
else
{
- trace('rank is lower...... and/or equal');
- openSubState(new funkin.ui.transition.StickerSubState(null, (sticker) -> FreeplayState.build(
- {
+ shouldTween = false;
+ shouldUseSubstate = true;
+ targetState = new funkin.ui.transition.StickerSubState(null, (sticker) -> FreeplayState.build(null, sticker));
+ }
+ }
+
+ if (shouldTween)
+ {
+ FlxTween.tween(rankBg, {alpha: 1}, 0.5,
+ {
+ ease: FlxEase.expoOut,
+ onComplete: function(_) {
+ if (shouldUseSubstate && targetState is FlxSubState)
{
- fromResults:
- {
- oldRank: null,
- playRankAnim: false,
- newRank: rank,
- songId: params.songId,
- difficultyId: params.difficultyId
- }
+ openSubState(cast targetState);
}
- }, sticker)));
+ else
+ {
+ FlxG.switchState(targetState);
+ }
+ }
+ });
+ }
+ else
+ {
+ if (shouldUseSubstate && targetState is FlxSubState)
+ {
+ openSubState(cast targetState);
+ }
+ else
+ {
+ FlxG.switchState(targetState);
}
}
}
@@ -920,12 +837,22 @@ typedef ResultsStateParams =
var storyMode:Bool;
/**
+ * A readable title for the song we just played.
* Either "Song Name by Artist Name" or "Week Name"
*/
var title:String;
+ /**
+ * The internal song ID for the song we just played.
+ */
var songId:String;
+ /**
+ * The character ID for the song we just played.
+ * @default `bf`
+ */
+ var ?characterId:String;
+
/**
* Whether the displayed score is a new highscore
*/
diff --git a/source/funkin/play/character/AnimateAtlasCharacter.hx b/source/funkin/play/character/AnimateAtlasCharacter.hx
index ed58b92b5..b78aed983 100644
--- a/source/funkin/play/character/AnimateAtlasCharacter.hx
+++ b/source/funkin/play/character/AnimateAtlasCharacter.hx
@@ -109,8 +109,6 @@ class AnimateAtlasCharacter extends BaseCharacter
var loop:Bool = animData.looped;
this.mainSprite.playAnimation(prefix, restart, ignoreOther, loop);
-
- animFinished = false;
}
public override function hasAnimation(name:String):Bool
@@ -124,17 +122,21 @@ class AnimateAtlasCharacter extends BaseCharacter
*/
public override function isAnimationFinished():Bool
{
- return animFinished;
+ return mainSprite?.isAnimationFinished() ?? false;
}
function loadAtlasSprite():FlxAtlasSprite
{
trace('[ATLASCHAR] Loading sprite atlas for ${characterId}.');
- var sprite:FlxAtlasSprite = new FlxAtlasSprite(0, 0, Paths.animateAtlas(_data.assetPath, 'shared'));
+ var animLibrary:String = Paths.getLibrary(_data.assetPath);
+ var animPath:String = Paths.stripLibrary(_data.assetPath);
+ var assetPath:String = Paths.animateAtlas(animPath, animLibrary);
- sprite.onAnimationFinish.removeAll();
- sprite.onAnimationFinish.add(this.onAnimationFinished);
+ var sprite:FlxAtlasSprite = new FlxAtlasSprite(0, 0, assetPath);
+
+ // sprite.onAnimationComplete.removeAll();
+ sprite.onAnimationComplete.add(this.onAnimationFinished);
return sprite;
}
@@ -152,7 +154,6 @@ class AnimateAtlasCharacter extends BaseCharacter
// Make the game hold on the last frame.
this.mainSprite.cleanupAnimation(prefix);
// currentAnimName = null;
- animFinished = true;
// Fallback to idle!
// playAnimation('idle', true, false);
@@ -165,6 +166,13 @@ class AnimateAtlasCharacter extends BaseCharacter
this.mainSprite = sprite;
+ mainSprite.ignoreExclusionPref = ["sing"];
+
+ // This forces the atlas to recalcuate its width and height
+ this.mainSprite.alpha = 0.0001;
+ this.mainSprite.draw();
+ this.mainSprite.alpha = 1.0;
+
var feetPos:FlxPoint = feetPosition;
this.updateHitbox();
diff --git a/source/funkin/play/character/BaseCharacter.hx b/source/funkin/play/character/BaseCharacter.hx
index 4ef86c6a9..365c8d112 100644
--- a/source/funkin/play/character/BaseCharacter.hx
+++ b/source/funkin/play/character/BaseCharacter.hx
@@ -118,22 +118,6 @@ class BaseCharacter extends Bopper
*/
public var cameraFocusPoint(default, null):FlxPoint = new FlxPoint(0, 0);
- override function set_animOffsets(value:Array):Array
- {
- if (animOffsets == null) value = [0, 0];
- if ((animOffsets[0] == value[0]) && (animOffsets[1] == value[1])) return value;
-
- // Make sure animOffets are halved when scale is 0.5.
- var xDiff = (animOffsets[0] * this.scale.x / (this.isPixel ? 6 : 1)) - value[0];
- var yDiff = (animOffsets[1] * this.scale.y / (this.isPixel ? 6 : 1)) - value[1];
-
- // Call the super function so that camera focus point is not affected.
- super.set_x(this.x + xDiff);
- super.set_y(this.y + yDiff);
-
- return animOffsets = value;
- }
-
/**
* If the x position changes, other than via changing the animation offset,
* then we need to update the camera focus point.
@@ -164,9 +148,11 @@ class BaseCharacter extends Bopper
public function new(id:String, renderType:CharacterRenderType)
{
- super();
+ super(CharacterDataParser.DEFAULT_DANCEEVERY);
this.characterId = id;
+ ignoreExclusionPref = ["sing"];
+
_data = CharacterDataParser.fetchCharacterData(this.characterId);
if (_data == null)
{
@@ -180,6 +166,7 @@ class BaseCharacter extends Bopper
{
this.characterName = _data.name;
this.name = _data.name;
+ this.danceEvery = _data.danceEvery;
this.singTimeSteps = _data.singTime;
this.globalOffsets = _data.offsets;
this.flipX = _data.flipX;
@@ -308,13 +295,26 @@ class BaseCharacter extends Bopper
// so we can query which ones are available.
this.comboNoteCounts = findCountAnimations('combo'); // example: combo50
this.dropNoteCounts = findCountAnimations('drop'); // example: drop50
- // trace('${this.animation.getNameList()}');
- // trace('Combo note counts: ' + this.comboNoteCounts);
- // trace('Drop note counts: ' + this.dropNoteCounts);
+ if (comboNoteCounts.length > 0) trace('Combo note counts: ' + this.comboNoteCounts);
+ if (dropNoteCounts.length > 0) trace('Drop note counts: ' + this.dropNoteCounts);
super.onCreate(event);
}
+ override function onAnimationFinished(animationName:String):Void
+ {
+ super.onAnimationFinished(animationName);
+
+ trace('${characterId} has finished animation: ${animationName}');
+ if ((animationName.endsWith(Constants.ANIMATION_END_SUFFIX) && !animationName.startsWith('idle') && !animationName.startsWith('dance'))
+ || animationName.startsWith('combo')
+ || animationName.startsWith('drop'))
+ {
+ // Force the character to play the idle after the animation ends.
+ this.dance(true);
+ }
+ }
+
function resetCameraFocusPoint():Void
{
// Calculate the camera focus point
@@ -368,9 +368,18 @@ class BaseCharacter extends Bopper
// and Darnell (this keeps the flame on his lighter flickering).
// Works for idle, singLEFT/RIGHT/UP/DOWN, alt singing animations, and anything else really.
- if (!getCurrentAnimation().endsWith('-hold') && hasAnimation(getCurrentAnimation() + '-hold') && isAnimationFinished())
+ if (isAnimationFinished()
+ && !getCurrentAnimation().endsWith(Constants.ANIMATION_HOLD_SUFFIX)
+ && hasAnimation(getCurrentAnimation() + Constants.ANIMATION_HOLD_SUFFIX))
{
- playAnimation(getCurrentAnimation() + '-hold');
+ playAnimation(getCurrentAnimation() + Constants.ANIMATION_HOLD_SUFFIX);
+ }
+ else
+ {
+ if (isAnimationFinished())
+ {
+ // trace('Not playing hold (${getCurrentAnimation()}) (${isAnimationFinished()}, ${getCurrentAnimation().endsWith(Constants.ANIMATION_HOLD_SUFFIX)}, ${hasAnimation(getCurrentAnimation() + Constants.ANIMATION_HOLD_SUFFIX)})');
+ }
}
// Handle character note hold time.
@@ -395,7 +404,24 @@ class BaseCharacter extends Bopper
{
trace('holdTimer reached ${holdTimer}sec (> ${singTimeSec}), stopping sing animation');
holdTimer = 0;
- dance(true);
+
+ var currentAnimation:String = getCurrentAnimation();
+ // Strip "-hold" from the end.
+ if (currentAnimation.endsWith(Constants.ANIMATION_HOLD_SUFFIX)) currentAnimation = currentAnimation.substring(0,
+ currentAnimation.length - Constants.ANIMATION_HOLD_SUFFIX.length);
+
+ var endAnimation:String = currentAnimation + Constants.ANIMATION_END_SUFFIX;
+ if (hasAnimation(endAnimation))
+ {
+ // Play the '-end' animation, if one exists.
+ trace('${characterId}: playing ${endAnimation}');
+ playAnimation(endAnimation);
+ }
+ else
+ {
+ // Play the idle animation.
+ dance(true);
+ }
}
}
else
@@ -408,7 +434,8 @@ class BaseCharacter extends Bopper
public function isSinging():Bool
{
- return getCurrentAnimation().startsWith('sing');
+ var currentAnimation:String = getCurrentAnimation();
+ return currentAnimation.startsWith('sing') && !currentAnimation.endsWith(Constants.ANIMATION_END_SUFFIX);
}
override function dance(force:Bool = false):Void
@@ -418,15 +445,14 @@ class BaseCharacter extends Bopper
if (!force)
{
+ // Prevent dancing while a singing animation is playing.
if (isSinging()) return;
+ // Prevent dancing while a non-idle special animation is playing.
var currentAnimation:String = getCurrentAnimation();
- if ((currentAnimation == 'hey' || currentAnimation == 'cheer') && !isAnimationFinished()) return;
+ if (!currentAnimation.startsWith('dance') && !currentAnimation.startsWith('idle') && !isAnimationFinished()) return;
}
- // Prevent dancing while another animation is playing.
- if (!force && isSinging()) return;
-
// Otherwise, fallback to the super dance() method, which handles playing the idle animation.
super.dance();
}
@@ -487,6 +513,9 @@ class BaseCharacter extends Bopper
{
super.onNoteHit(event);
+ // If another script cancelled the event, don't do anything.
+ if (event.eventCanceled) return;
+
if (event.note.noteData.getMustHitNote() && characterType == BF)
{
// If the note is from the same strumline, play the sing animation.
@@ -499,6 +528,16 @@ class BaseCharacter extends Bopper
this.playSingAnimation(event.note.noteData.getDirection(), false);
holdTimer = 0;
}
+ else if (characterType == GF && event.note.noteData.getMustHitNote())
+ {
+ switch (event.judgement)
+ {
+ case 'sick' | 'good':
+ playComboAnimation(event.comboCount);
+ default:
+ playComboDropAnimation(event.comboCount);
+ }
+ }
}
/**
@@ -509,6 +548,9 @@ class BaseCharacter extends Bopper
{
super.onNoteMiss(event);
+ // If another script cancelled the event, don't do anything.
+ if (event.eventCanceled) return;
+
if (event.note.noteData.getMustHitNote() && characterType == BF)
{
// If the note is from the same strumline, play the sing animation.
@@ -521,31 +563,46 @@ class BaseCharacter extends Bopper
}
else if (event.note.noteData.getMustHitNote() && characterType == GF)
{
- var dropAnim = '';
+ playComboDropAnimation(Highscore.tallies.combo);
+ }
+ }
- // Choose the combo drop anim to play.
- // If there are several (for example, drop10 and drop50) the highest one will be used.
- // If the combo count is too low, no animation will be played.
- for (count in dropNoteCounts)
- {
- if (event.comboCount >= count)
- {
- dropAnim = 'drop${count}';
- }
- }
+ function playComboAnimation(comboCount:Int):Void
+ {
+ var comboAnim = 'combo${comboCount}';
+ if (hasAnimation(comboAnim))
+ {
+ trace('Playing GF combo animation: ${comboAnim}');
+ this.playAnimation(comboAnim, true, true);
+ }
+ }
- if (dropAnim != '')
+ function playComboDropAnimation(comboCount:Int):Void
+ {
+ var dropAnim:Null = null;
+
+ // Choose the combo drop anim to play.
+ // If there are several (for example, drop10 and drop50) the highest one will be used.
+ // If the combo count is too low, no animation will be played.
+ for (count in dropNoteCounts)
+ {
+ if (comboCount >= count)
{
- trace('Playing GF combo drop animation: ${dropAnim}');
- this.playAnimation(dropAnim, true, true);
+ dropAnim = 'drop${count}';
}
}
+
+ if (dropAnim != null)
+ {
+ trace('Playing GF combo drop animation: ${dropAnim}');
+ this.playAnimation(dropAnim, true, true);
+ }
}
/**
* Every time a wrong key is pressed, play the miss animation if we are Boyfriend.
*/
- public override function onNoteGhostMiss(event:GhostMissNoteScriptEvent)
+ public override function onNoteGhostMiss(event:GhostMissNoteScriptEvent):Void
{
super.onNoteGhostMiss(event);
@@ -579,12 +636,12 @@ class BaseCharacter extends Bopper
var anim:String = 'sing${dir.nameUpper}${miss ? 'miss' : ''}${suffix != '' ? '-${suffix}' : ''}';
// restart even if already playing, because the character might sing the same note twice.
+ trace('Playing ${anim}...');
playAnimation(anim, true);
}
public override function playAnimation(name:String, restart:Bool = false, ignoreOther:Bool = false, reversed:Bool = false):Void
{
- // FlxG.watch.addQuick('playAnim(${characterName})', name);
super.playAnimation(name, restart, ignoreOther, reversed);
}
}
diff --git a/source/funkin/play/character/CharacterData.hx b/source/funkin/play/character/CharacterData.hx
index 7d3d6cfb9..bac2c7141 100644
--- a/source/funkin/play/character/CharacterData.hx
+++ b/source/funkin/play/character/CharacterData.hx
@@ -305,8 +305,10 @@ class CharacterDataParser
icon = "darnell";
case "senpai-angry":
icon = "senpai";
- case "tankman" | "tankman-atlas":
- icon = "tankmen";
+ case "spooky-dark":
+ icon = "spooky";
+ case "tankman-atlas":
+ icon = "tankman";
}
var path = Paths.image("freeplay/icons/" + icon + "pixel");
@@ -383,21 +385,21 @@ class CharacterDataParser
* Values that are too high will cause the character to hold their singing pose for too long after they're done.
* @default `8 steps`
*/
- static final DEFAULT_SINGTIME:Float = 8.0;
+ public static final DEFAULT_SINGTIME:Float = 8.0;
- static final DEFAULT_DANCEEVERY:Int = 1;
- static final DEFAULT_FLIPX:Bool = false;
- static final DEFAULT_FLIPY:Bool = false;
- static final DEFAULT_FRAMERATE:Int = 24;
- static final DEFAULT_ISPIXEL:Bool = false;
- static final DEFAULT_LOOP:Bool = false;
- static final DEFAULT_NAME:String = 'Untitled Character';
- static final DEFAULT_OFFSETS:Array = [0, 0];
- static final DEFAULT_HEALTHICON_OFFSETS:Array = [0, 25];
- static final DEFAULT_RENDERTYPE:CharacterRenderType = CharacterRenderType.Sparrow;
- static final DEFAULT_SCALE:Float = 1;
- static final DEFAULT_SCROLL:Array = [0, 0];
- static final DEFAULT_STARTINGANIM:String = 'idle';
+ public static final DEFAULT_DANCEEVERY:Float = 1.0;
+ public static final DEFAULT_FLIPX:Bool = false;
+ public static final DEFAULT_FLIPY:Bool = false;
+ public static final DEFAULT_FRAMERATE:Int = 24;
+ public static final DEFAULT_ISPIXEL:Bool = false;
+ public static final DEFAULT_LOOP:Bool = false;
+ public static final DEFAULT_NAME:String = 'Untitled Character';
+ public static final DEFAULT_OFFSETS:Array = [0, 0];
+ public static final DEFAULT_HEALTHICON_OFFSETS:Array = [0, 25];
+ public static final DEFAULT_RENDERTYPE:CharacterRenderType = CharacterRenderType.Sparrow;
+ public static final DEFAULT_SCALE:Float = 1;
+ public static final DEFAULT_SCROLL:Array = [0, 0];
+ public static final DEFAULT_STARTINGANIM:String = 'idle';
/**
* Set unspecified parameters to their defaults.
@@ -665,10 +667,12 @@ typedef CharacterData =
/**
* The frequency at which the character will play its idle animation, in beats.
* Increasing this number will make the character dance less often.
- *
- * @default 1
+ * Supports up to `0.25` precision.
+ * @default `1.0` on characters
*/
- var danceEvery:Null;
+ @:optional
+ @:default(1.0)
+ var danceEvery:Null;
/**
* The minimum duration that a character will play a note animation for, in beats.
diff --git a/source/funkin/play/character/MultiSparrowCharacter.hx b/source/funkin/play/character/MultiSparrowCharacter.hx
index 48c5afb58..759d17a6b 100644
--- a/source/funkin/play/character/MultiSparrowCharacter.hx
+++ b/source/funkin/play/character/MultiSparrowCharacter.hx
@@ -41,6 +41,8 @@ class MultiSparrowCharacter extends BaseCharacter
{
this.isPixel = true;
this.antialiasing = false;
+ pixelPerfectRender = true;
+ pixelPerfectPosition = true;
}
else
{
@@ -60,11 +62,13 @@ class MultiSparrowCharacter extends BaseCharacter
}
}
- var texture:FlxAtlasFrames = Paths.getSparrowAtlas(_data.assetPath, 'shared');
+ var texture:FlxAtlasFrames = Paths.getSparrowAtlas(_data.assetPath);
if (texture == null)
{
trace('Multi-Sparrow atlas could not load PRIMARY texture: ${_data.assetPath}');
+ FlxG.log.error('Multi-Sparrow atlas could not load PRIMARY texture: ${_data.assetPath}');
+ return;
}
else
{
@@ -74,7 +78,7 @@ class MultiSparrowCharacter extends BaseCharacter
for (asset in assetList)
{
- var subTexture:FlxAtlasFrames = Paths.getSparrowAtlas(asset, 'shared');
+ var subTexture:FlxAtlasFrames = Paths.getSparrowAtlas(asset);
// If we don't do this, the unused textures will be removed as soon as they're loaded.
if (subTexture == null)
diff --git a/source/funkin/play/character/PackerCharacter.hx b/source/funkin/play/character/PackerCharacter.hx
index 2bfac800a..5d004606c 100644
--- a/source/funkin/play/character/PackerCharacter.hx
+++ b/source/funkin/play/character/PackerCharacter.hx
@@ -30,7 +30,7 @@ class PackerCharacter extends BaseCharacter
{
trace('[PACKERCHAR] Loading spritesheet ${_data.assetPath} for ${characterId}');
- var tex:FlxFramesCollection = Paths.getPackerAtlas(_data.assetPath, 'shared');
+ var tex:FlxFramesCollection = Paths.getPackerAtlas(_data.assetPath);
if (tex == null)
{
trace('Could not load Packer sprite: ${_data.assetPath}');
@@ -43,6 +43,8 @@ class PackerCharacter extends BaseCharacter
{
this.isPixel = true;
this.antialiasing = false;
+ pixelPerfectRender = true;
+ pixelPerfectPosition = true;
}
else
{
diff --git a/source/funkin/play/character/SparrowCharacter.hx b/source/funkin/play/character/SparrowCharacter.hx
index a36aed84d..eacf799d8 100644
--- a/source/funkin/play/character/SparrowCharacter.hx
+++ b/source/funkin/play/character/SparrowCharacter.hx
@@ -33,7 +33,7 @@ class SparrowCharacter extends BaseCharacter
{
trace('[SPARROWCHAR] Loading spritesheet ${_data.assetPath} for ${characterId}');
- var tex:FlxFramesCollection = Paths.getSparrowAtlas(_data.assetPath, 'shared');
+ var tex:FlxFramesCollection = Paths.getSparrowAtlas(_data.assetPath);
if (tex == null)
{
trace('Could not load Sparrow sprite: ${_data.assetPath}');
@@ -46,6 +46,8 @@ class SparrowCharacter extends BaseCharacter
{
this.isPixel = true;
this.antialiasing = false;
+ pixelPerfectRender = true;
+ pixelPerfectPosition = true;
}
else
{
diff --git a/source/funkin/play/components/HealthIcon.hx b/source/funkin/play/components/HealthIcon.hx
index 2442b0dc5..358f39fe5 100644
--- a/source/funkin/play/components/HealthIcon.hx
+++ b/source/funkin/play/components/HealthIcon.hx
@@ -33,7 +33,7 @@ class HealthIcon extends FunkinSprite
* The character this icon is representing.
* Setting this variable will automatically update the graphic.
*/
- public var characterId(default, set):Null;
+ public var characterId(default, set):String = Constants.DEFAULT_HEALTH_ICON;
/**
* Whether this health icon should automatically update its state based on the character's health.
@@ -49,7 +49,7 @@ class HealthIcon extends FunkinSprite
* this value allows you to set a relative scale for the icon.
* @default 1x scale = 150px width and height.
*/
- public var size:FlxPoint = new FlxPoint(1, 1);
+ public var size:FlxPoint;
/**
* Apply the "bop" animation once every X steps.
@@ -116,18 +116,22 @@ class HealthIcon extends FunkinSprite
*/
static final POSITION_OFFSET:Int = 26;
- public function new(char:String = 'bf', playerId:Int = 0)
+ public function new(char:Null, playerId:Int = 0)
{
super(0, 0);
this.playerId = playerId;
+ this.size = new FlxCallbackPoint(onSetSize);
this.scrollFactor.set();
-
+ size.set(1.0, 1.0);
this.characterId = char;
-
- initTargetSize();
}
- function set_characterId(value:Null):Null
+ function onSetSize(value:FlxPoint):Void
+ {
+ snapToTargetSize();
+ }
+
+ function set_characterId(value:Null):String
{
if (value == characterId) return value;
@@ -150,13 +154,17 @@ class HealthIcon extends FunkinSprite
{
if (characterId == 'bf-old')
{
+ isPixel = PlayState.instance.currentStage.getBoyfriend().isPixel;
PlayState.instance.currentStage.getBoyfriend().initHealthIcon(false);
}
else
{
characterId = 'bf-old';
+ isPixel = false;
loadCharacter(characterId);
}
+
+ lerpIconSize(true);
}
/**
@@ -200,31 +208,61 @@ class HealthIcon extends FunkinSprite
if (bopEvery != 0)
{
- // Lerp the health icon back to its normal size,
- // while maintaining aspect ratio.
- if (this.width > this.height)
- {
- // Apply linear interpolation while accounting for frame rate.
- var targetSize:Int = Std.int(MathUtil.coolLerp(this.width, HEALTH_ICON_SIZE * this.size.x, 0.15));
-
- setGraphicSize(targetSize, 0);
- }
- else
- {
- var targetSize:Int = Std.int(MathUtil.coolLerp(this.height, HEALTH_ICON_SIZE * this.size.y, 0.15));
-
- setGraphicSize(0, targetSize);
- }
+ lerpIconSize();
// Lerp the health icon back to its normal angle.
this.angle = MathUtil.coolLerp(this.angle, 0, 0.15);
-
- this.updateHitbox();
}
this.updatePosition();
}
+ /**
+ * Does the calculation to lerp the icon size. Usually called every frame, but can be forced to the target size.
+ * Mainly forced when changing to old icon to not have a weird lerp related to changing from pixel icon to non-pixel old icon
+ * @param force Force the icon immedialtely to be the target size. Defaults to false.
+ */
+ function lerpIconSize(force:Bool = false):Void
+ {
+ // Lerp the health icon back to its normal size,
+ // while maintaining aspect ratio.
+ if (this.width > this.height)
+ {
+ // Apply linear interpolation while accounting for frame rate.
+ var targetSize:Int = Std.int(MathUtil.coolLerp(this.width, HEALTH_ICON_SIZE * this.size.x, 0.15));
+
+ if (force) targetSize = Std.int(HEALTH_ICON_SIZE * this.size.x);
+
+ setGraphicSize(targetSize, 0);
+ }
+ else
+ {
+ var targetSize:Int = Std.int(MathUtil.coolLerp(this.height, HEALTH_ICON_SIZE * this.size.y, 0.15));
+
+ if (force) targetSize = Std.int(HEALTH_ICON_SIZE * this.size.y);
+
+ setGraphicSize(0, targetSize);
+ }
+
+ this.updateHitbox();
+ }
+
+ /*
+ * Immediately snap the health icon to its target size without lerping.
+ */
+ public function snapToTargetSize():Void
+ {
+ if (this.width > this.height)
+ {
+ setGraphicSize(Std.int(HEALTH_ICON_SIZE * this.size.x), 0);
+ }
+ else
+ {
+ setGraphicSize(0, Std.int(HEALTH_ICON_SIZE * this.size.y));
+ }
+ updateHitbox();
+ }
+
/**
* Update the position (and status) of the health icon.
*/
@@ -283,12 +321,6 @@ class HealthIcon extends FunkinSprite
}
}
- inline function initTargetSize():Void
- {
- setGraphicSize(HEALTH_ICON_SIZE);
- updateHitbox();
- }
-
function updateHealthIcon(health:Float):Void
{
// We want to efficiently handle animation playback
@@ -380,20 +412,9 @@ class HealthIcon extends FunkinSprite
}
}
- function correctCharacterId(charId:Null):String
+ function iconExists(charId:String):Bool
{
- if (charId == null)
- {
- return Constants.DEFAULT_HEALTH_ICON;
- }
-
- if (!Assets.exists(Paths.image('icons/icon-$charId')))
- {
- FlxG.log.warn('No icon for character: $charId : using default placeholder face instead!');
- return Constants.DEFAULT_HEALTH_ICON;
- }
-
- return charId;
+ return Assets.exists(Paths.image('icons/icon-$charId'));
}
function isNewSpritesheet(charId:String):Bool
@@ -403,15 +424,17 @@ class HealthIcon extends FunkinSprite
function loadCharacter(charId:Null):Void
{
- if (charId == null || correctCharacterId(charId) != charId)
+ if (charId == null || !iconExists(charId))
{
- // This will recursively trigger loadCharacter to be called again.
- characterId = correctCharacterId(charId);
- return;
+ FlxG.log.warn('No icon for character: $charId : using default placeholder face instead!');
+ characterId = Constants.DEFAULT_HEALTH_ICON;
+ charId = characterId;
}
isLegacyStyle = !isNewSpritesheet(charId);
+ trace(' Loading health icon for character: $charId (legacy: $isLegacyStyle)');
+
if (!isLegacyStyle)
{
loadSparrow('icons/icon-$charId');
diff --git a/source/funkin/play/components/PopUpStuff.hx b/source/funkin/play/components/PopUpStuff.hx
index b7e206e97..a02291e4e 100644
--- a/source/funkin/play/components/PopUpStuff.hx
+++ b/source/funkin/play/components/PopUpStuff.hx
@@ -7,53 +7,60 @@ import flixel.util.FlxDirection;
import funkin.graphics.FunkinSprite;
import funkin.play.PlayState;
import funkin.util.TimerUtil;
+import funkin.util.EaseUtil;
+import openfl.utils.Assets;
+import funkin.data.notestyle.NoteStyleRegistry;
+import funkin.play.notes.notestyle.NoteStyle;
-class PopUpStuff extends FlxTypedGroup
+@:nullSafety
+class PopUpStuff extends FlxTypedGroup
{
- public var offsets:Array = [0, 0];
+ /**
+ * The current note style to use. This determines which graphics to display.
+ * For example, Week 6 uses the `pixel` note style, and mods can create their own.
+ */
+ var noteStyle:NoteStyle;
- override public function new()
+ /**
+ * Offsets that are applied to all elements, independent of the note style.
+ * Used to allow scripts to reposition the elements.
+ */
+ var offsets:Array = [0, 0];
+
+ override public function new(noteStyle:NoteStyle)
{
super();
+
+ this.noteStyle = noteStyle;
}
- public function displayRating(daRating:String)
+ public function displayRating(daRating:Null)
{
- var perfStart:Float = TimerUtil.start();
-
if (daRating == null) daRating = "good";
- var ratingPath:String = daRating;
-
- if (PlayState.instance.currentStageId.startsWith('school')) ratingPath = "weeb/pixelUI/" + ratingPath + "-pixel";
-
- var rating:FunkinSprite = FunkinSprite.create(0, 0, ratingPath);
- rating.scrollFactor.set(0.2, 0.2);
+ var rating:Null = noteStyle.buildJudgementSprite(daRating);
+ if (rating == null) return;
rating.zIndex = 1000;
- rating.x = (FlxG.width * 0.474) + offsets[0];
- // rating.x -= FlxG.camera.scroll.x * 0.2;
- rating.y = (FlxG.camera.height * 0.45 - 60) + offsets[1];
+
+ rating.x = (FlxG.width * 0.474);
+ rating.x -= rating.width / 2;
+ rating.y = (FlxG.camera.height * 0.45 - 60);
+ rating.y -= rating.height / 2;
+
+ rating.x += offsets[0];
+ rating.y += offsets[1];
+ var styleOffsets = noteStyle.getJudgementSpriteOffsets(daRating);
+ rating.x += styleOffsets[0];
+ rating.y += styleOffsets[1];
+
rating.acceleration.y = 550;
rating.velocity.y -= FlxG.random.int(140, 175);
rating.velocity.x -= FlxG.random.int(0, 10);
add(rating);
- if (PlayState.instance.currentStageId.startsWith('school'))
- {
- rating.setGraphicSize(Std.int(rating.width * Constants.PIXEL_ART_SCALE * 0.7));
- rating.antialiasing = false;
- }
- else
- {
- rating.setGraphicSize(Std.int(rating.width * 0.65));
- rating.antialiasing = true;
- }
- rating.updateHitbox();
-
- rating.x -= rating.width / 2;
- rating.y -= rating.height / 2;
+ var fadeEase = noteStyle.isJudgementSpritePixel(daRating) ? EaseUtil.stepped(2) : null;
FlxTween.tween(rating, {alpha: 0}, 0.2,
{
@@ -61,58 +68,13 @@ class PopUpStuff extends FlxTypedGroup
remove(rating, true);
rating.destroy();
},
- startDelay: Conductor.instance.beatLengthMs * 0.001
+ startDelay: Conductor.instance.beatLengthMs * 0.001,
+ ease: fadeEase
});
-
- trace('displayRating took: ${TimerUtil.seconds(perfStart)}');
}
- public function displayCombo(?combo:Int = 0):Int
+ public function displayCombo(combo:Int = 0):Void
{
- var perfStart:Float = TimerUtil.start();
-
- if (combo == null) combo = 0;
-
- var pixelShitPart1:String = "";
- var pixelShitPart2:String = '';
-
- if (PlayState.instance.currentStageId.startsWith('school'))
- {
- pixelShitPart1 = 'weeb/pixelUI/';
- pixelShitPart2 = '-pixel';
- }
- var comboSpr:FunkinSprite = FunkinSprite.create(pixelShitPart1 + 'combo' + pixelShitPart2);
- comboSpr.y = (FlxG.camera.height * 0.44) + offsets[1];
- comboSpr.x = (FlxG.width * 0.507) + offsets[0];
- // comboSpr.x -= FlxG.camera.scroll.x * 0.2;
-
- comboSpr.acceleration.y = 600;
- comboSpr.velocity.y -= 150;
- comboSpr.velocity.x += FlxG.random.int(1, 10);
-
- // add(comboSpr);
-
- if (PlayState.instance.currentStageId.startsWith('school'))
- {
- comboSpr.setGraphicSize(Std.int(comboSpr.width * Constants.PIXEL_ART_SCALE * 0.7));
- comboSpr.antialiasing = false;
- }
- else
- {
- comboSpr.setGraphicSize(Std.int(comboSpr.width * 0.7));
- comboSpr.antialiasing = true;
- }
- comboSpr.updateHitbox();
-
- FlxTween.tween(comboSpr, {alpha: 0}, 0.2,
- {
- onComplete: function(tween:FlxTween) {
- remove(comboSpr, true);
- comboSpr.destroy();
- },
- startDelay: Conductor.instance.beatLengthMs * 0.001
- });
-
var seperatedScore:Array = [];
var tempCombo:Int = combo;
@@ -127,43 +89,40 @@ class PopUpStuff extends FlxTypedGroup
// seperatedScore.reverse();
var daLoop:Int = 1;
- for (i in seperatedScore)
+ for (digit in seperatedScore)
{
- var numScore:FunkinSprite = FunkinSprite.create(0, comboSpr.y, pixelShitPart1 + 'num' + Std.int(i) + pixelShitPart2);
+ var numScore:Null = noteStyle.buildComboNumSprite(digit);
+ if (numScore == null) continue;
- if (PlayState.instance.currentStageId.startsWith('school'))
- {
- numScore.setGraphicSize(Std.int(numScore.width * Constants.PIXEL_ART_SCALE * 0.7));
- numScore.antialiasing = false;
- }
- else
- {
- numScore.setGraphicSize(Std.int(numScore.width * 0.45));
- numScore.antialiasing = true;
- }
- numScore.updateHitbox();
+ numScore.x = (FlxG.width * 0.507) - (36 * daLoop) - 65;
+ trace('numScore($daLoop) = ${numScore.x}');
+ numScore.y = (FlxG.camera.height * 0.44);
+
+ numScore.x += offsets[0];
+ numScore.y += offsets[1];
+ var styleOffsets = noteStyle.getComboNumSpriteOffsets(digit);
+ numScore.x += styleOffsets[0];
+ numScore.y += styleOffsets[1];
- numScore.x = comboSpr.x - (36 * daLoop) - 65; //- 90;
numScore.acceleration.y = FlxG.random.int(250, 300);
numScore.velocity.y -= FlxG.random.int(130, 150);
numScore.velocity.x = FlxG.random.float(-5, 5);
add(numScore);
+ var fadeEase = noteStyle.isComboNumSpritePixel(digit) ? EaseUtil.stepped(2) : null;
+
FlxTween.tween(numScore, {alpha: 0}, 0.2,
{
onComplete: function(tween:FlxTween) {
remove(numScore, true);
numScore.destroy();
},
- startDelay: Conductor.instance.beatLengthMs * 0.002
+ startDelay: Conductor.instance.beatLengthMs * 0.002,
+ ease: fadeEase
});
daLoop++;
}
-
- trace('displayCombo took: ${TimerUtil.seconds(perfStart)}');
-
- return combo;
}
}
diff --git a/source/funkin/play/cutscene/VideoCutscene.hx b/source/funkin/play/cutscene/VideoCutscene.hx
index 01a492a77..60454b881 100644
--- a/source/funkin/play/cutscene/VideoCutscene.hx
+++ b/source/funkin/play/cutscene/VideoCutscene.hx
@@ -81,7 +81,6 @@ class VideoCutscene
// Trigger the cutscene. Don't play the song in the background.
PlayState.instance.isInCutscene = true;
PlayState.instance.camHUD.visible = false;
- PlayState.instance.camCutscene.visible = true;
// Display a black screen to hide the game while the video is playing.
blackScreen = new FlxSprite(-200, -200).makeGraphic(FlxG.width * 2, FlxG.height * 2, FlxColor.BLACK);
@@ -145,7 +144,7 @@ class VideoCutscene
{
vid.zIndex = 0;
vid.bitmap.onEndReached.add(finishVideo.bind(0.5));
- vid.autoPause = false;
+ vid.autoPause = FlxG.autoPause;
vid.cameras = [PlayState.instance.camCutscene];
@@ -305,7 +304,6 @@ class VideoCutscene
vid = null;
#end
- PlayState.instance.camCutscene.visible = true;
PlayState.instance.camHUD.visible = true;
FlxTween.tween(blackScreen, {alpha: 0}, transitionTime,
diff --git a/source/funkin/play/event/ZoomCameraSongEvent.hx b/source/funkin/play/event/ZoomCameraSongEvent.hx
index 748abda19..ee2eea8ad 100644
--- a/source/funkin/play/event/ZoomCameraSongEvent.hx
+++ b/source/funkin/play/event/ZoomCameraSongEvent.hx
@@ -114,7 +114,7 @@ class ZoomCameraSongEvent extends SongEvent
name: 'zoom',
title: 'Zoom Level',
defaultValue: 1.0,
- step: 0.1,
+ step: 0.05,
type: SongEventFieldType.FLOAT,
units: 'x'
},
diff --git a/source/funkin/play/notes/NoteSprite.hx b/source/funkin/play/notes/NoteSprite.hx
index b16b88466..e8cacaa4d 100644
--- a/source/funkin/play/notes/NoteSprite.hx
+++ b/source/funkin/play/notes/NoteSprite.hx
@@ -1,6 +1,7 @@
package funkin.play.notes;
import funkin.data.song.SongData.SongNoteData;
+import funkin.data.song.SongData.NoteParamData;
import funkin.play.notes.notestyle.NoteStyle;
import flixel.graphics.frames.FlxAtlasFrames;
import flixel.FlxSprite;
@@ -65,6 +66,22 @@ class NoteSprite extends FunkinSprite
return this.noteData.kind = value;
}
+ /**
+ * An array of custom parameters for this note
+ */
+ public var params(get, set):Array;
+
+ function get_params():Array
+ {
+ return this.noteData?.params ?? [];
+ }
+
+ function set_params(value:Array):Array
+ {
+ if (this.noteData == null) return value;
+ return this.noteData.params = value;
+ }
+
/**
* The data of the note (i.e. the direction.)
*/
@@ -74,7 +91,7 @@ class NoteSprite extends FunkinSprite
{
if (frames == null) return value;
- animation.play(DIRECTION_COLORS[value] + 'Scroll');
+ playNoteAnimation(value);
this.direction = value;
return this.direction;
@@ -135,19 +152,37 @@ class NoteSprite extends FunkinSprite
this.hsvShader = new HSVShader();
setupNoteGraphic(noteStyle);
-
- // Disables the update() function for performance.
- this.active = false;
}
- function setupNoteGraphic(noteStyle:NoteStyle):Void
+ /**
+ * Creates frames and animations
+ * @param noteStyle The `NoteStyle` instance
+ */
+ public function setupNoteGraphic(noteStyle:NoteStyle):Void
{
noteStyle.buildNoteSprite(this);
- setGraphicSize(Strumline.STRUMLINE_SIZE);
- updateHitbox();
-
this.shader = hsvShader;
+
+ // `false` disables the update() function for performance.
+ this.active = noteStyle.isNoteAnimated();
+ }
+
+ /**
+ * Retrieve the value of the param with the given name
+ * @param name Name of the param
+ * @return Null
+ */
+ public function getParam(name:String):Null
+ {
+ for (param in params)
+ {
+ if (param.name == name)
+ {
+ return param.value;
+ }
+ }
+ return null;
}
#if FLX_DEBUG
@@ -173,6 +208,11 @@ class NoteSprite extends FunkinSprite
}
#end
+ function playNoteAnimation(value:Int):Void
+ {
+ animation.play(DIRECTION_COLORS[value] + 'Scroll');
+ }
+
public function desaturate():Void
{
this.hsvShader.saturation = 0.2;
diff --git a/source/funkin/play/notes/Strumline.hx b/source/funkin/play/notes/Strumline.hx
index 0e4b6645f..e894f9c62 100644
--- a/source/funkin/play/notes/Strumline.hx
+++ b/source/funkin/play/notes/Strumline.hx
@@ -16,6 +16,7 @@ import funkin.data.song.SongData.SongNoteData;
import funkin.ui.options.PreferencesMenu;
import funkin.util.SortUtil;
import funkin.modding.events.ScriptEvent;
+import funkin.play.notes.notekind.NoteKindManager;
/**
* A group of sprites which handles the receptor, the note splashes, and the notes (with sustains) for a given player.
@@ -37,7 +38,7 @@ class Strumline extends FlxSpriteGroup
static function get_RENDER_DISTANCE_MS():Float
{
- return FlxG.height / 0.45;
+ return FlxG.height / Constants.PIXELS_PER_MS;
}
/**
@@ -93,6 +94,10 @@ class Strumline extends FlxSpriteGroup
final noteStyle:NoteStyle;
+ #if FEATURE_GHOST_TAPPING
+ var ghostTapTimer:Float = 0.0;
+ #end
+
/**
* The note data for the song. Should NOT be altered after the song starts,
* so we can easily rewind.
@@ -178,21 +183,36 @@ class Strumline extends FlxSpriteGroup
super.update(elapsed);
updateNotes();
+
+ #if FEATURE_GHOST_TAPPING
+ updateGhostTapTimer(elapsed);
+ #end
}
+ #if FEATURE_GHOST_TAPPING
/**
* Returns `true` if no notes are in range of the strumline and the player can spam without penalty.
*/
public function mayGhostTap():Bool
{
- // TODO: Refine this. Only querying "can be hit" is too tight but "is being rendered" is too loose.
- // Also, if you just hit a note, there should be a (short) period where this is off so you can't spam.
+ // Any notes in range of the strumline.
+ if (getNotesMayHit().length > 0)
+ {
+ return false;
+ }
+ // Any hold notes in range of the strumline.
+ if (getHoldNotesHitOrMissed().length > 0)
+ {
+ return false;
+ }
- // If there are any notes on screen, we can't ghost tap.
- return notes.members.filter(function(note:NoteSprite) {
- return note != null && note.alive && !note.hasBeenHit;
- }).length == 0;
+ // Note has been hit recently.
+ if (ghostTapTimer > 0.0) return false;
+
+ // **yippee**
+ return true;
}
+ #end
/**
* Return notes that are within `Constants.HIT_WINDOW` ms of the strumline.
@@ -491,6 +511,32 @@ class Strumline extends FlxSpriteGroup
}
}
+ /**
+ * Return notes that are within, or way after, `Constants.HIT_WINDOW` ms of the strumline.
+ * @return An array of `NoteSprite` objects.
+ */
+ public function getNotesOnScreen():Array
+ {
+ return notes.members.filter(function(note:NoteSprite) {
+ return note != null && note.alive && !note.hasBeenHit;
+ });
+ }
+
+ #if FEATURE_GHOST_TAPPING
+ function updateGhostTapTimer(elapsed:Float):Void
+ {
+ // If it's still our turn, don't update the ghost tap timer.
+ if (getNotesOnScreen().length > 0) return;
+
+ ghostTapTimer -= elapsed;
+
+ if (ghostTapTimer <= 0)
+ {
+ ghostTapTimer = 0;
+ }
+ }
+ #end
+
/**
* Called when the PlayState skips a large amount of time forward or backward.
*/
@@ -562,6 +608,10 @@ class Strumline extends FlxSpriteGroup
playStatic(dir);
}
resetScrollSpeed();
+
+ #if FEATURE_GHOST_TAPPING
+ ghostTapTimer = 0;
+ #end
}
public function applyNoteData(data:Array):Void
@@ -598,10 +648,13 @@ class Strumline extends FlxSpriteGroup
{
note.holdNoteSprite.hitNote = true;
note.holdNoteSprite.missedNote = false;
- note.holdNoteSprite.alpha = 1.0;
note.holdNoteSprite.sustainLength = (note.holdNoteSprite.strumTime + note.holdNoteSprite.fullSustainLength) - conductorInUse.songPosition;
}
+
+ #if FEATURE_GHOST_TAPPING
+ ghostTapTimer = Constants.GHOST_TAP_DELAY;
+ #end
}
public function killNote(note:NoteSprite):Void
@@ -709,11 +762,15 @@ class Strumline extends FlxSpriteGroup
if (noteSprite != null)
{
+ var noteKindStyle:NoteStyle = NoteKindManager.getNoteStyle(note.kind, this.noteStyle.id) ?? this.noteStyle;
+ noteSprite.setupNoteGraphic(noteKindStyle);
+
noteSprite.direction = note.getDirection();
noteSprite.noteData = note;
noteSprite.x = this.x;
noteSprite.x += getXPos(DIRECTIONS[note.getDirection() % KEY_COUNT]);
+ noteSprite.x -= (noteSprite.width - Strumline.STRUMLINE_SIZE) / 2; // Center it
noteSprite.x -= NUDGE;
// noteSprite.x += INITIAL_OFFSET;
noteSprite.y = -9999;
@@ -728,6 +785,9 @@ class Strumline extends FlxSpriteGroup
if (holdNoteSprite != null)
{
+ var noteKindStyle:NoteStyle = NoteKindManager.getNoteStyle(note.kind, this.noteStyle.id) ?? this.noteStyle;
+ holdNoteSprite.setupHoldNoteGraphic(noteKindStyle);
+
holdNoteSprite.parentStrumline = this;
holdNoteSprite.noteData = note;
holdNoteSprite.strumTime = note.time;
diff --git a/source/funkin/play/notes/StrumlineNote.hx b/source/funkin/play/notes/StrumlineNote.hx
index 40d893255..d8230aa28 100644
--- a/source/funkin/play/notes/StrumlineNote.hx
+++ b/source/funkin/play/notes/StrumlineNote.hx
@@ -75,6 +75,13 @@ class StrumlineNote extends FlxSprite
function setup(noteStyle:NoteStyle):Void
{
+ if (noteStyle == null)
+ {
+ // If you get an exception on this line, check the debug console.
+ // You probably have a parsing error in your note style's JSON file.
+ throw "FATAL ERROR: Attempted to initialize PlayState with an invalid NoteStyle.";
+ }
+
noteStyle.applyStrumlineFrames(this);
noteStyle.applyStrumlineAnimations(this, this.direction);
diff --git a/source/funkin/play/notes/SustainTrail.hx b/source/funkin/play/notes/SustainTrail.hx
index b358d7f03..90b36b009 100644
--- a/source/funkin/play/notes/SustainTrail.hx
+++ b/source/funkin/play/notes/SustainTrail.hx
@@ -99,7 +99,27 @@ class SustainTrail extends FlxSprite
*/
public function new(noteDirection:NoteDirection, sustainLength:Float, noteStyle:NoteStyle)
{
- super(0, 0, noteStyle.getHoldNoteAssetPath());
+ super(0, 0);
+
+ // BASIC SETUP
+ this.sustainLength = sustainLength;
+ this.fullSustainLength = sustainLength;
+ this.noteDirection = noteDirection;
+
+ setupHoldNoteGraphic(noteStyle);
+
+ indices = new DrawData(12, true, TRIANGLE_VERTEX_INDICES);
+
+ this.active = true; // This NEEDS to be true for the note to be drawn!
+ }
+
+ /**
+ * Creates hold note graphic and applies correct zooming
+ * @param noteStyle The note style
+ */
+ public function setupHoldNoteGraphic(noteStyle:NoteStyle):Void
+ {
+ loadGraphic(noteStyle.getHoldNoteAssetPath());
antialiasing = true;
@@ -109,13 +129,14 @@ class SustainTrail extends FlxSprite
endOffset = bottomClip = 1;
antialiasing = false;
}
+ else
+ {
+ endOffset = 0.5;
+ bottomClip = 0.9;
+ }
+
+ zoom = 1.0;
zoom *= noteStyle.fetchHoldNoteScale();
-
- // BASIC SETUP
- this.sustainLength = sustainLength;
- this.fullSustainLength = sustainLength;
- this.noteDirection = noteDirection;
-
zoom *= 0.7;
// CALCULATE SIZE
@@ -131,9 +152,6 @@ class SustainTrail extends FlxSprite
updateColorTransform();
updateClipping();
- indices = new DrawData(12, true, TRIANGLE_VERTEX_INDICES);
-
- this.active = true; // This NEEDS to be true for the note to be drawn!
}
function getBaseScrollSpeed()
@@ -160,7 +178,7 @@ class SustainTrail extends FlxSprite
*/
public static inline function sustainHeight(susLength:Float, scroll:Float)
{
- return (susLength * 0.45 * scroll);
+ return (susLength * Constants.PIXELS_PER_MS * scroll);
}
function set_sustainLength(s:Float):Float
@@ -195,6 +213,11 @@ class SustainTrail extends FlxSprite
*/
public function updateClipping(songTime:Float = 0):Void
{
+ if (graphic == null)
+ {
+ return;
+ }
+
var clipHeight:Float = FlxMath.bound(sustainHeight(sustainLength - (songTime - strumTime), parentStrumline?.scrollSpeed ?? 1.0), 0, graphicHeight);
if (clipHeight <= 0.1)
{
diff --git a/source/funkin/play/notes/notekind/NoteKind.hx b/source/funkin/play/notes/notekind/NoteKind.hx
new file mode 100644
index 000000000..c1c6e815a
--- /dev/null
+++ b/source/funkin/play/notes/notekind/NoteKind.hx
@@ -0,0 +1,119 @@
+package funkin.play.notes.notekind;
+
+import funkin.modding.IScriptedClass.INoteScriptedClass;
+import funkin.modding.events.ScriptEvent;
+import flixel.math.FlxMath;
+
+/**
+ * Class for note scripts
+ */
+class NoteKind implements INoteScriptedClass
+{
+ /**
+ * The name of the note kind
+ */
+ public var noteKind:String;
+
+ /**
+ * Description used in chart editor
+ */
+ public var description:String;
+
+ /**
+ * Custom note style
+ */
+ public var noteStyleId:Null;
+
+ /**
+ * Custom parameters for the chart editor
+ */
+ public var params:Array;
+
+ public function new(noteKind:String, description:String = "", ?noteStyleId:String, ?params:Array)
+ {
+ this.noteKind = noteKind;
+ this.description = description;
+ this.noteStyleId = noteStyleId;
+ this.params = params ?? [];
+ }
+
+ public function toString():String
+ {
+ return noteKind;
+ }
+
+ /**
+ * Retrieve all notes of this kind
+ * @return Array
+ */
+ function getNotes():Array
+ {
+ var allNotes:Array = PlayState.instance.playerStrumline.notes.members.concat(PlayState.instance.opponentStrumline.notes.members);
+ return allNotes.filter(function(note:NoteSprite) {
+ return note != null && note.noteData.kind == this.noteKind;
+ });
+ }
+
+ public function onScriptEvent(event:ScriptEvent):Void {}
+
+ public function onCreate(event:ScriptEvent):Void {}
+
+ public function onDestroy(event:ScriptEvent):Void {}
+
+ public function onUpdate(event:UpdateScriptEvent):Void {}
+
+ public function onNoteIncoming(event:NoteScriptEvent):Void {}
+
+ public function onNoteHit(event:HitNoteScriptEvent):Void {}
+
+ public function onNoteMiss(event:NoteScriptEvent):Void {}
+}
+
+/**
+ * Abstract for setting the type of the `NoteKindParam`
+ * This was supposed to be an enum but polymod kept being annoying
+ */
+abstract NoteKindParamType(String) from String to String
+{
+ public static final STRING:String = 'String';
+
+ public static final INT:String = 'Int';
+
+ public static final FLOAT:String = 'Float';
+}
+
+typedef NoteKindParamData =
+{
+ /**
+ * If `min` is null, there is no minimum
+ */
+ ?min:Null,
+
+ /**
+ * If `max` is null, there is no maximum
+ */
+ ?max:Null,
+
+ /**
+ * If `step` is null, it will use 1.0
+ */
+ ?step:Null,
+
+ /**
+ * If `precision` is null, there will be 0 decimal places
+ */
+ ?precision:Null,
+
+ ?defaultValue:Dynamic
+}
+
+/**
+ * Typedef for creating custom parameters in the chart editor
+ */
+typedef NoteKindParam =
+{
+ name:String,
+ description:String,
+ type:NoteKindParamType,
+ ?data:NoteKindParamData
+}
diff --git a/source/funkin/play/notes/notekind/NoteKindManager.hx b/source/funkin/play/notes/notekind/NoteKindManager.hx
new file mode 100644
index 000000000..e17e103d1
--- /dev/null
+++ b/source/funkin/play/notes/notekind/NoteKindManager.hx
@@ -0,0 +1,121 @@
+package funkin.play.notes.notekind;
+
+import funkin.modding.events.ScriptEventDispatcher;
+import funkin.modding.events.ScriptEvent;
+import funkin.ui.debug.charting.util.ChartEditorDropdowns;
+import funkin.data.notestyle.NoteStyleRegistry;
+import funkin.play.notes.notestyle.NoteStyle;
+import funkin.play.notes.notekind.ScriptedNoteKind;
+import funkin.play.notes.notekind.NoteKind.NoteKindParam;
+
+class NoteKindManager
+{
+ static var noteKinds:Map = [];
+
+ public static function loadScripts():Void
+ {
+ var scriptedClassName:Array = ScriptedNoteKind.listScriptClasses();
+ if (scriptedClassName.length > 0)
+ {
+ trace('Instantiating ${scriptedClassName.length} scripted note kind(s)...');
+ for (scriptedClass in scriptedClassName)
+ {
+ try
+ {
+ var script:NoteKind = ScriptedNoteKind.init(scriptedClass, "unknown");
+ trace(' Initialized scripted note kind: ${script.noteKind}');
+ noteKinds.set(script.noteKind, script);
+ ChartEditorDropdowns.NOTE_KINDS.set(script.noteKind, script.description);
+ }
+ catch (e)
+ {
+ trace(' FAILED to instantiate scripted note kind: ${scriptedClass}');
+ trace(e);
+ }
+ }
+ }
+ }
+
+ /**
+ * Calls the given event for note kind scripts
+ * @param event The event
+ */
+ public static function callEvent(event:ScriptEvent):Void
+ {
+ // if it is a note script event,
+ // then only call the event for the specific note kind script
+ if (Std.isOfType(event, NoteScriptEvent))
+ {
+ var noteEvent:NoteScriptEvent = cast(event, NoteScriptEvent);
+
+ var noteKind:NoteKind = noteKinds.get(noteEvent.note.kind);
+
+ if (noteKind != null)
+ {
+ ScriptEventDispatcher.callEvent(noteKind, event);
+ }
+ }
+ else // call the event for all note kind scripts
+ {
+ for (noteKind in noteKinds.iterator())
+ {
+ ScriptEventDispatcher.callEvent(noteKind, event);
+ }
+ }
+ }
+
+ /**
+ * Retrieve the note style from the given note kind
+ * @param noteKind note kind name
+ * @param suffix Used for song note styles
+ * @return NoteStyle
+ */
+ public static function getNoteStyle(noteKind:String, ?suffix:String):Null
+ {
+ var noteStyleId:Null = getNoteStyleId(noteKind, suffix);
+
+ if (noteStyleId == null)
+ {
+ return null;
+ }
+
+ return NoteStyleRegistry.instance.fetchEntry(noteStyleId);
+ }
+
+ /**
+ * Retrieve the note style id from the given note kind
+ * @param noteKind Note kind name
+ * @param suffix Used for song note styles
+ * @return Null
+ */
+ public static function getNoteStyleId(noteKind:String, ?suffix:String):Null
+ {
+ if (suffix == '')
+ {
+ suffix = null;
+ }
+
+ var noteStyleId:Null = noteKinds.get(noteKind)?.noteStyleId;
+ if (noteStyleId != null && suffix != null)
+ {
+ noteStyleId = NoteStyleRegistry.instance.hasEntry('$noteStyleId-$suffix') ? '$noteStyleId-$suffix' : noteStyleId;
+ }
+
+ return noteStyleId;
+ }
+
+ /**
+ * Retrive custom params of the given note kind
+ * @param noteKind Name of the note kind
+ * @return Array
+ */
+ public static function getParams(noteKind:Null):Array