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 + { + if (noteKind == null) + { + return []; + } + + return noteKinds.get(noteKind)?.params ?? []; + } +} diff --git a/source/funkin/play/notes/notekind/ScriptedNoteKind.hx b/source/funkin/play/notes/notekind/ScriptedNoteKind.hx new file mode 100644 index 000000000..cd1781394 --- /dev/null +++ b/source/funkin/play/notes/notekind/ScriptedNoteKind.hx @@ -0,0 +1,9 @@ +package funkin.play.notes.notekind; + +/** + * A script that can be tied to a NoteKind. + * Create a scripted class that extends NoteKind, + * then call `super('noteKind')` in the constructor to use this. + */ +@:hscriptClass +class ScriptedNoteKind extends NoteKind implements polymod.hscript.HScriptedClass {} diff --git a/source/funkin/play/notes/notestyle/NoteStyle.hx b/source/funkin/play/notes/notestyle/NoteStyle.hx index d0cc09f6a..ee07703f1 100644 --- a/source/funkin/play/notes/notestyle/NoteStyle.hx +++ b/source/funkin/play/notes/notestyle/NoteStyle.hx @@ -1,5 +1,6 @@ package funkin.play.notes.notestyle; +import funkin.play.Countdown; import flixel.graphics.frames.FlxAtlasFrames; import flixel.graphics.frames.FlxFramesCollection; import funkin.data.animation.AnimationData; @@ -16,6 +17,7 @@ using funkin.data.animation.AnimationData.AnimationDataUtil; * Holds the data for what assets to use for a note style, * and provides convenience methods for building sprites based on them. */ +@:nullSafety class NoteStyle implements IRegistryEntry { /** @@ -42,12 +44,8 @@ class NoteStyle implements IRegistryEntry this.id = id; _data = _fetchData(id); - if (_data == null) - { - throw 'Could not parse note style data for id: $id'; - } - - this.fallback = NoteStyleRegistry.instance.fetchEntry(getFallbackID()); + var fallbackID = _data.fallback; + if (fallbackID != null) this.fallback = NoteStyleRegistry.instance.fetchEntry(fallbackID); } /** @@ -72,7 +70,7 @@ class NoteStyle implements IRegistryEntry * Get the note style ID of the parent note style. * @return The string ID, or `null` if there is no parent. */ - function getFallbackID():Null + public function getFallbackID():Null { return _data.fallback; } @@ -80,7 +78,7 @@ class NoteStyle implements IRegistryEntry public function buildNoteSprite(target:NoteSprite):Void { // Apply the note sprite frames. - var atlas:FlxAtlasFrames = buildNoteFrames(false); + var atlas:Null = buildNoteFrames(false); if (atlas == null) { @@ -89,29 +87,40 @@ class NoteStyle implements IRegistryEntry target.frames = atlas; - target.scale.x = _data.assets.note.scale; - target.scale.y = _data.assets.note.scale; - target.antialiasing = !_data.assets.note.isPixel; + target.antialiasing = !(_data.assets?.note?.isPixel ?? false); // Apply the animations. buildNoteAnimations(target); + + // Set the scale. + target.setGraphicSize(Strumline.STRUMLINE_SIZE * getNoteScale()); + target.updateHitbox(); } - var noteFrames:FlxAtlasFrames = null; + var noteFrames:Null = null; - function buildNoteFrames(force:Bool = false):FlxAtlasFrames + function buildNoteFrames(force:Bool = false):Null { - if (!FunkinSprite.isTextureCached(Paths.image(getNoteAssetPath()))) + var noteAssetPath = getNoteAssetPath(); + if (noteAssetPath == null) return null; + + if (!FunkinSprite.isTextureCached(Paths.image(noteAssetPath))) { - FlxG.log.warn('Note texture is not cached: ${getNoteAssetPath()}'); + FlxG.log.warn('Note texture is not cached: ${noteAssetPath}'); } // Purge the note frames if the cached atlas is invalid. - if (noteFrames?.parent?.isDestroyed ?? false) noteFrames = null; + @:nullSafety(Off) + { + if (noteFrames?.parent?.isDestroyed ?? false) noteFrames = null; + } if (noteFrames != null && !force) return noteFrames; - noteFrames = Paths.getSparrowAtlas(getNoteAssetPath(), getNoteAssetLibrary()); + var noteAssetPath = getNoteAssetPath(); + if (noteAssetPath == null) return null; + + noteFrames = Paths.getSparrowAtlas(noteAssetPath, getNoteAssetLibrary()); if (noteFrames == null) { @@ -121,17 +130,18 @@ class NoteStyle implements IRegistryEntry return noteFrames; } - function getNoteAssetPath(raw:Bool = false):String + function getNoteAssetPath(raw:Bool = false):Null { if (raw) { var rawPath:Null = _data?.assets?.note?.assetPath; - if (rawPath == null) return fallback.getNoteAssetPath(true); + if (rawPath == null && fallback != null) return fallback.getNoteAssetPath(true); return rawPath; } // library:path - var parts = getNoteAssetPath(true).split(Constants.LIBRARY_SEPARATOR); + var parts = getNoteAssetPath(true)?.split(Constants.LIBRARY_SEPARATOR) ?? []; + if (parts.length == 0) return null; if (parts.length == 1) return getNoteAssetPath(true); return parts[1]; } @@ -139,47 +149,63 @@ class NoteStyle implements IRegistryEntry function getNoteAssetLibrary():Null { // library:path - var parts = getNoteAssetPath(true).split(Constants.LIBRARY_SEPARATOR); + var parts = getNoteAssetPath(true)?.split(Constants.LIBRARY_SEPARATOR) ?? []; + if (parts.length == 0) return null; if (parts.length == 1) return null; return parts[0]; } function buildNoteAnimations(target:NoteSprite):Void { - var leftData:AnimationData = fetchNoteAnimationData(LEFT); - target.animation.addByPrefix('purpleScroll', leftData.prefix, leftData.frameRate, leftData.looped, leftData.flipX, leftData.flipY); - var downData:AnimationData = fetchNoteAnimationData(DOWN); - target.animation.addByPrefix('blueScroll', downData.prefix, downData.frameRate, downData.looped, downData.flipX, downData.flipY); - var upData:AnimationData = fetchNoteAnimationData(UP); - target.animation.addByPrefix('greenScroll', upData.prefix, upData.frameRate, upData.looped, upData.flipX, upData.flipY); - var rightData:AnimationData = fetchNoteAnimationData(RIGHT); - target.animation.addByPrefix('redScroll', rightData.prefix, rightData.frameRate, rightData.looped, rightData.flipX, rightData.flipY); + var leftData:Null = fetchNoteAnimationData(LEFT); + if (leftData != null) target.animation.addByPrefix('purpleScroll', leftData.prefix ?? '', leftData.frameRate ?? 24, leftData.looped ?? false, + leftData.flipX, leftData.flipY); + var downData:Null = fetchNoteAnimationData(DOWN); + if (downData != null) target.animation.addByPrefix('blueScroll', downData.prefix ?? '', downData.frameRate ?? 24, downData.looped ?? false, + downData.flipX, downData.flipY); + var upData:Null = fetchNoteAnimationData(UP); + if (upData != null) target.animation.addByPrefix('greenScroll', upData.prefix ?? '', upData.frameRate ?? 24, upData.looped ?? false, upData.flipX, + upData.flipY); + var rightData:Null = fetchNoteAnimationData(RIGHT); + if (rightData != null) target.animation.addByPrefix('redScroll', rightData.prefix ?? '', rightData.frameRate ?? 24, rightData.looped ?? false, + rightData.flipX, rightData.flipY); } - function fetchNoteAnimationData(dir:NoteDirection):AnimationData + public function isNoteAnimated():Bool + { + return _data.assets?.note?.animated ?? false; + } + + public function getNoteScale():Float + { + return _data.assets?.note?.scale ?? 1.0; + } + + function fetchNoteAnimationData(dir:NoteDirection):Null { var result:Null = switch (dir) { - case LEFT: _data.assets.note.data.left.toNamed(); - case DOWN: _data.assets.note.data.down.toNamed(); - case UP: _data.assets.note.data.up.toNamed(); - case RIGHT: _data.assets.note.data.right.toNamed(); + case LEFT: _data.assets?.note?.data?.left?.toNamed(); + case DOWN: _data.assets?.note?.data?.down?.toNamed(); + case UP: _data.assets?.note?.data?.up?.toNamed(); + case RIGHT: _data.assets?.note?.data?.right?.toNamed(); }; - return (result == null) ? fallback.fetchNoteAnimationData(dir) : result; + return (result == null && fallback != null) ? fallback.fetchNoteAnimationData(dir) : result; } - public function getHoldNoteAssetPath(raw:Bool = false):String + public function getHoldNoteAssetPath(raw:Bool = false):Null { if (raw) { // TODO: figure out why ?. didn't work here var rawPath:Null = (_data?.assets?.holdNote == null) ? null : _data?.assets?.holdNote?.assetPath; - return (rawPath == null) ? fallback.getHoldNoteAssetPath(true) : rawPath; + return (rawPath == null && fallback != null) ? fallback.getHoldNoteAssetPath(true) : rawPath; } // library:path - var parts = getHoldNoteAssetPath(true).split(Constants.LIBRARY_SEPARATOR); + var parts = getHoldNoteAssetPath(true)?.split(Constants.LIBRARY_SEPARATOR) ?? []; + if (parts.length == 0) return null; if (parts.length == 1) return Paths.image(parts[0]); return Paths.image(parts[1], parts[0]); } @@ -187,15 +213,15 @@ class NoteStyle implements IRegistryEntry public function isHoldNotePixel():Bool { var data = _data?.assets?.holdNote; - if (data == null) return fallback.isHoldNotePixel(); - return data.isPixel; + if (data == null && fallback != null) return fallback.isHoldNotePixel(); + return data?.isPixel ?? false; } public function fetchHoldNoteScale():Float { var data = _data?.assets?.holdNote; - if (data == null) return fallback.fetchHoldNoteScale(); - return data.scale; + if (data == null && fallback != null) return fallback.fetchHoldNoteScale(); + return data?.scale ?? 1.0; } public function applyStrumlineFrames(target:StrumlineNote):Void @@ -203,7 +229,7 @@ class NoteStyle implements IRegistryEntry // TODO: Add support for multi-Sparrow. // Will be less annoying after this is merged: https://github.com/HaxeFlixel/flixel/pull/2772 - var atlas:FlxAtlasFrames = Paths.getSparrowAtlas(getStrumlineAssetPath(), getStrumlineAssetLibrary()); + var atlas:FlxAtlasFrames = Paths.getSparrowAtlas(getStrumlineAssetPath() ?? '', getStrumlineAssetLibrary()); if (atlas == null) { @@ -212,31 +238,30 @@ class NoteStyle implements IRegistryEntry target.frames = atlas; - target.scale.x = _data.assets.noteStrumline.scale; - target.scale.y = _data.assets.noteStrumline.scale; - target.antialiasing = !_data.assets.noteStrumline.isPixel; + target.scale.set(_data.assets.noteStrumline?.scale ?? 1.0); + target.antialiasing = !(_data.assets.noteStrumline?.isPixel ?? false); } - function getStrumlineAssetPath(raw:Bool = false):String + function getStrumlineAssetPath(raw:Bool = false):Null { if (raw) { var rawPath:Null = _data?.assets?.noteStrumline?.assetPath; - if (rawPath == null) return fallback.getStrumlineAssetPath(true); + if (rawPath == null && fallback != null) return fallback.getStrumlineAssetPath(true); return rawPath; } // library:path - var parts = getStrumlineAssetPath(true).split(Constants.LIBRARY_SEPARATOR); - if (parts.length == 1) return getStrumlineAssetPath(true); + var parts = getStrumlineAssetPath(true)?.split(Constants.LIBRARY_SEPARATOR) ?? []; + if (parts.length <= 1) return getStrumlineAssetPath(true); return parts[1]; } function getStrumlineAssetLibrary():Null { // library:path - var parts = getStrumlineAssetPath(true).split(Constants.LIBRARY_SEPARATOR); - if (parts.length == 1) return null; + var parts = getStrumlineAssetPath(true)?.split(Constants.LIBRARY_SEPARATOR) ?? []; + if (parts.length <= 1) return null; return parts[0]; } @@ -247,60 +272,592 @@ class NoteStyle implements IRegistryEntry function getStrumlineAnimationData(dir:NoteDirection):Array { - var result:Array = switch (dir) + var result:Array> = switch (dir) { case NoteDirection.LEFT: [ - _data.assets.noteStrumline.data.leftStatic.toNamed('static'), - _data.assets.noteStrumline.data.leftPress.toNamed('press'), - _data.assets.noteStrumline.data.leftConfirm.toNamed('confirm'), - _data.assets.noteStrumline.data.leftConfirmHold.toNamed('confirm-hold'), + _data.assets.noteStrumline?.data?.leftStatic?.toNamed('static'), + _data.assets.noteStrumline?.data?.leftPress?.toNamed('press'), + _data.assets.noteStrumline?.data?.leftConfirm?.toNamed('confirm'), + _data.assets.noteStrumline?.data?.leftConfirmHold?.toNamed('confirm-hold'), ]; case NoteDirection.DOWN: [ - _data.assets.noteStrumline.data.downStatic.toNamed('static'), - _data.assets.noteStrumline.data.downPress.toNamed('press'), - _data.assets.noteStrumline.data.downConfirm.toNamed('confirm'), - _data.assets.noteStrumline.data.downConfirmHold.toNamed('confirm-hold'), + _data.assets.noteStrumline?.data?.downStatic?.toNamed('static'), + _data.assets.noteStrumline?.data?.downPress?.toNamed('press'), + _data.assets.noteStrumline?.data?.downConfirm?.toNamed('confirm'), + _data.assets.noteStrumline?.data?.downConfirmHold?.toNamed('confirm-hold'), ]; case NoteDirection.UP: [ - _data.assets.noteStrumline.data.upStatic.toNamed('static'), - _data.assets.noteStrumline.data.upPress.toNamed('press'), - _data.assets.noteStrumline.data.upConfirm.toNamed('confirm'), - _data.assets.noteStrumline.data.upConfirmHold.toNamed('confirm-hold'), + _data.assets.noteStrumline?.data?.upStatic?.toNamed('static'), + _data.assets.noteStrumline?.data?.upPress?.toNamed('press'), + _data.assets.noteStrumline?.data?.upConfirm?.toNamed('confirm'), + _data.assets.noteStrumline?.data?.upConfirmHold?.toNamed('confirm-hold'), ]; case NoteDirection.RIGHT: [ - _data.assets.noteStrumline.data.rightStatic.toNamed('static'), - _data.assets.noteStrumline.data.rightPress.toNamed('press'), - _data.assets.noteStrumline.data.rightConfirm.toNamed('confirm'), - _data.assets.noteStrumline.data.rightConfirmHold.toNamed('confirm-hold'), + _data.assets.noteStrumline?.data?.rightStatic?.toNamed('static'), + _data.assets.noteStrumline?.data?.rightPress?.toNamed('press'), + _data.assets.noteStrumline?.data?.rightConfirm?.toNamed('confirm'), + _data.assets.noteStrumline?.data?.rightConfirmHold?.toNamed('confirm-hold'), ]; + default: []; }; - return result; + return thx.Arrays.filterNull(result); } - public function applyStrumlineOffsets(target:StrumlineNote) + public function applyStrumlineOffsets(target:StrumlineNote):Void { - target.x += _data.assets.noteStrumline.offsets[0]; - target.y += _data.assets.noteStrumline.offsets[1]; + var offsets = _data?.assets?.noteStrumline?.offsets ?? [0.0, 0.0]; + target.x += offsets[0]; + target.y += offsets[1]; } public function getStrumlineScale():Float { - return _data.assets.noteStrumline.scale; + return _data?.assets?.noteStrumline?.scale ?? 1.0; } public function isNoteSplashEnabled():Bool { var data = _data?.assets?.noteSplash?.data; - if (data == null) return fallback.isNoteSplashEnabled(); - return data.enabled; + if (data == null) return fallback?.isNoteSplashEnabled() ?? false; + return data.enabled ?? false; } public function isHoldNoteCoverEnabled():Bool { var data = _data?.assets?.holdNoteCover?.data; - if (data == null) return fallback.isHoldNoteCoverEnabled(); - return data.enabled; + if (data == null) return fallback?.isHoldNoteCoverEnabled() ?? false; + return data.enabled ?? false; + } + + /** + * Build a sprite for the given step of the countdown. + * @param step + * @return A `FunkinSprite`, or `null` if no graphic is available for this step. + */ + public function buildCountdownSprite(step:Countdown.CountdownStep):Null + { + var result = new FunkinSprite(); + + switch (step) + { + case THREE: + if (_data.assets.countdownThree == null) return fallback?.buildCountdownSprite(step); + var assetPath = buildCountdownSpritePath(step); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.countdownThree?.scale ?? 1.0; + result.scale.y = _data.assets.countdownThree?.scale ?? 1.0; + case TWO: + if (_data.assets.countdownTwo == null) return fallback?.buildCountdownSprite(step); + var assetPath = buildCountdownSpritePath(step); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.countdownTwo?.scale ?? 1.0; + result.scale.y = _data.assets.countdownTwo?.scale ?? 1.0; + case ONE: + if (_data.assets.countdownOne == null) return fallback?.buildCountdownSprite(step); + var assetPath = buildCountdownSpritePath(step); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.countdownOne?.scale ?? 1.0; + result.scale.y = _data.assets.countdownOne?.scale ?? 1.0; + case GO: + if (_data.assets.countdownGo == null) return fallback?.buildCountdownSprite(step); + var assetPath = buildCountdownSpritePath(step); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.countdownGo?.scale ?? 1.0; + result.scale.y = _data.assets.countdownGo?.scale ?? 1.0; + default: + // TODO: Do something here? + return null; + } + + result.scrollFactor.set(0, 0); + result.antialiasing = !isCountdownSpritePixel(step); + result.updateHitbox(); + + return result; + } + + function buildCountdownSpritePath(step:Countdown.CountdownStep):Null + { + var basePath:Null = null; + switch (step) + { + case THREE: + basePath = _data.assets.countdownThree?.assetPath; + case TWO: + basePath = _data.assets.countdownTwo?.assetPath; + case ONE: + basePath = _data.assets.countdownOne?.assetPath; + case GO: + basePath = _data.assets.countdownGo?.assetPath; + default: + basePath = null; + } + + if (basePath == null) return fallback?.buildCountdownSpritePath(step); + + var parts = basePath?.split(Constants.LIBRARY_SEPARATOR) ?? []; + if (parts.length < 1) return null; + if (parts.length == 1) return parts[0]; + + return parts[1]; + } + + function buildCountdownSpriteLibrary(step:Countdown.CountdownStep):Null + { + var basePath:Null = null; + switch (step) + { + case THREE: + basePath = _data.assets.countdownThree?.assetPath; + case TWO: + basePath = _data.assets.countdownTwo?.assetPath; + case ONE: + basePath = _data.assets.countdownOne?.assetPath; + case GO: + basePath = _data.assets.countdownGo?.assetPath; + default: + basePath = null; + } + + if (basePath == null) return fallback?.buildCountdownSpriteLibrary(step); + + var parts = basePath?.split(Constants.LIBRARY_SEPARATOR) ?? []; + if (parts.length <= 1) return null; + + return parts[0]; + } + + public function isCountdownSpritePixel(step:Countdown.CountdownStep):Bool + { + switch (step) + { + case THREE: + var result = _data.assets.countdownThree?.isPixel; + if (result == null && fallback != null) result = fallback.isCountdownSpritePixel(step); + return result ?? false; + case TWO: + var result = _data.assets.countdownTwo?.isPixel; + if (result == null && fallback != null) result = fallback.isCountdownSpritePixel(step); + return result ?? false; + case ONE: + var result = _data.assets.countdownOne?.isPixel; + if (result == null && fallback != null) result = fallback.isCountdownSpritePixel(step); + return result ?? false; + case GO: + var result = _data.assets.countdownGo?.isPixel; + if (result == null && fallback != null) result = fallback.isCountdownSpritePixel(step); + return result ?? false; + default: + return false; + } + } + + public function getCountdownSpriteOffsets(step:Countdown.CountdownStep):Array + { + switch (step) + { + case THREE: + var result = _data.assets.countdownThree?.offsets; + if (result == null && fallback != null) result = fallback.getCountdownSpriteOffsets(step); + return result ?? [0, 0]; + case TWO: + var result = _data.assets.countdownTwo?.offsets; + if (result == null && fallback != null) result = fallback.getCountdownSpriteOffsets(step); + return result ?? [0, 0]; + case ONE: + var result = _data.assets.countdownOne?.offsets; + if (result == null && fallback != null) result = fallback.getCountdownSpriteOffsets(step); + return result ?? [0, 0]; + case GO: + var result = _data.assets.countdownGo?.offsets; + if (result == null && fallback != null) result = fallback.getCountdownSpriteOffsets(step); + return result ?? [0, 0]; + default: + return [0, 0]; + } + } + + public function getCountdownSoundPath(step:Countdown.CountdownStep, raw:Bool = false):Null + { + if (raw) + { + // TODO: figure out why ?. didn't work here + var rawPath:Null = switch (step) + { + case Countdown.CountdownStep.THREE: + _data.assets.countdownThree?.data?.audioPath; + case Countdown.CountdownStep.TWO: + _data.assets.countdownTwo?.data?.audioPath; + case Countdown.CountdownStep.ONE: + _data.assets.countdownOne?.data?.audioPath; + case Countdown.CountdownStep.GO: + _data.assets.countdownGo?.data?.audioPath; + default: + null; + } + + return (rawPath == null && fallback != null) ? fallback.getCountdownSoundPath(step, true) : rawPath; + } + + // library:path + var parts = getCountdownSoundPath(step, true)?.split(Constants.LIBRARY_SEPARATOR) ?? []; + if (parts.length == 0) return null; + if (parts.length == 1) return Paths.image(parts[0]); + return Paths.sound(parts[1], parts[0]); + } + + public function buildJudgementSprite(rating:String):Null + { + var result = new FunkinSprite(); + + switch (rating) + { + case "sick": + if (_data.assets.judgementSick == null) return fallback?.buildJudgementSprite(rating); + var assetPath = buildJudgementSpritePath(rating); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.judgementSick?.scale ?? 1.0; + result.scale.y = _data.assets.judgementSick?.scale ?? 1.0; + case "good": + if (_data.assets.judgementGood == null) return fallback?.buildJudgementSprite(rating); + var assetPath = buildJudgementSpritePath(rating); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.judgementGood?.scale ?? 1.0; + result.scale.y = _data.assets.judgementGood?.scale ?? 1.0; + case "bad": + if (_data.assets.judgementBad == null) return fallback?.buildJudgementSprite(rating); + var assetPath = buildJudgementSpritePath(rating); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.judgementBad?.scale ?? 1.0; + result.scale.y = _data.assets.judgementBad?.scale ?? 1.0; + case "shit": + if (_data.assets.judgementShit == null) return fallback?.buildJudgementSprite(rating); + var assetPath = buildJudgementSpritePath(rating); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.judgementShit?.scale ?? 1.0; + result.scale.y = _data.assets.judgementShit?.scale ?? 1.0; + default: + return null; + } + + result.scrollFactor.set(0.2, 0.2); + var isPixel = isJudgementSpritePixel(rating); + result.antialiasing = !isPixel; + result.pixelPerfectRender = isPixel; + result.pixelPerfectPosition = isPixel; + result.updateHitbox(); + + return result; + } + + public function isJudgementSpritePixel(rating:String):Bool + { + switch (rating) + { + case "sick": + var result = _data.assets.judgementSick?.isPixel; + if (result == null && fallback != null) result = fallback.isJudgementSpritePixel(rating); + return result ?? false; + case "good": + var result = _data.assets.judgementGood?.isPixel; + if (result == null && fallback != null) result = fallback.isJudgementSpritePixel(rating); + return result ?? false; + case "bad": + var result = _data.assets.judgementBad?.isPixel; + if (result == null && fallback != null) result = fallback.isJudgementSpritePixel(rating); + return result ?? false; + case "GO": + var result = _data.assets.judgementShit?.isPixel; + if (result == null && fallback != null) result = fallback.isJudgementSpritePixel(rating); + return result ?? false; + default: + return false; + } + } + + function buildJudgementSpritePath(rating:String):Null + { + var basePath:Null = null; + switch (rating) + { + case "sick": + basePath = _data.assets.judgementSick?.assetPath; + case "good": + basePath = _data.assets.judgementGood?.assetPath; + case "bad": + basePath = _data.assets.judgementBad?.assetPath; + case "shit": + basePath = _data.assets.judgementShit?.assetPath; + default: + basePath = null; + } + + if (basePath == null) return fallback?.buildJudgementSpritePath(rating); + + var parts = basePath?.split(Constants.LIBRARY_SEPARATOR) ?? []; + if (parts.length < 1) return null; + if (parts.length == 1) return parts[0]; + + return parts[1]; + } + + public function getJudgementSpriteOffsets(rating:String):Array + { + switch (rating) + { + case "sick": + var result = _data.assets.judgementSick?.offsets; + if (result == null && fallback != null) result = fallback.getJudgementSpriteOffsets(rating); + return result ?? [0, 0]; + case "good": + var result = _data.assets.judgementGood?.offsets; + if (result == null && fallback != null) result = fallback.getJudgementSpriteOffsets(rating); + return result ?? [0, 0]; + case "bad": + var result = _data.assets.judgementBad?.offsets; + if (result == null && fallback != null) result = fallback.getJudgementSpriteOffsets(rating); + return result ?? [0, 0]; + case "shit": + var result = _data.assets.judgementShit?.offsets; + if (result == null && fallback != null) result = fallback.getJudgementSpriteOffsets(rating); + return result ?? [0, 0]; + default: + return [0, 0]; + } + } + + public function buildComboNumSprite(digit:Int):Null + { + var result = new FunkinSprite(); + + switch (digit) + { + case 0: + if (_data.assets.comboNumber0 == null) return fallback?.buildComboNumSprite(digit); + var assetPath = buildComboNumSpritePath(digit); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.comboNumber0?.scale ?? 1.0; + result.scale.y = _data.assets.comboNumber0?.scale ?? 1.0; + case 1: + if (_data.assets.comboNumber1 == null) return fallback?.buildComboNumSprite(digit); + var assetPath = buildComboNumSpritePath(digit); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.comboNumber1?.scale ?? 1.0; + result.scale.y = _data.assets.comboNumber1?.scale ?? 1.0; + case 2: + if (_data.assets.comboNumber2 == null) return fallback?.buildComboNumSprite(digit); + var assetPath = buildComboNumSpritePath(digit); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.comboNumber2?.scale ?? 1.0; + result.scale.y = _data.assets.comboNumber2?.scale ?? 1.0; + case 3: + if (_data.assets.comboNumber3 == null) return fallback?.buildComboNumSprite(digit); + var assetPath = buildComboNumSpritePath(digit); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.comboNumber3?.scale ?? 1.0; + result.scale.y = _data.assets.comboNumber3?.scale ?? 1.0; + case 4: + if (_data.assets.comboNumber4 == null) return fallback?.buildComboNumSprite(digit); + var assetPath = buildComboNumSpritePath(digit); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.comboNumber4?.scale ?? 1.0; + result.scale.y = _data.assets.comboNumber4?.scale ?? 1.0; + case 5: + if (_data.assets.comboNumber5 == null) return fallback?.buildComboNumSprite(digit); + var assetPath = buildComboNumSpritePath(digit); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.comboNumber5?.scale ?? 1.0; + result.scale.y = _data.assets.comboNumber5?.scale ?? 1.0; + case 6: + if (_data.assets.comboNumber6 == null) return fallback?.buildComboNumSprite(digit); + var assetPath = buildComboNumSpritePath(digit); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.comboNumber6?.scale ?? 1.0; + result.scale.y = _data.assets.comboNumber6?.scale ?? 1.0; + case 7: + if (_data.assets.comboNumber7 == null) return fallback?.buildComboNumSprite(digit); + var assetPath = buildComboNumSpritePath(digit); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.comboNumber7?.scale ?? 1.0; + result.scale.y = _data.assets.comboNumber7?.scale ?? 1.0; + case 8: + if (_data.assets.comboNumber8 == null) return fallback?.buildComboNumSprite(digit); + var assetPath = buildComboNumSpritePath(digit); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.comboNumber8?.scale ?? 1.0; + result.scale.y = _data.assets.comboNumber8?.scale ?? 1.0; + case 9: + if (_data.assets.comboNumber9 == null) return fallback?.buildComboNumSprite(digit); + var assetPath = buildComboNumSpritePath(digit); + if (assetPath == null) return null; + result.loadTexture(assetPath); + result.scale.x = _data.assets.comboNumber9?.scale ?? 1.0; + result.scale.y = _data.assets.comboNumber9?.scale ?? 1.0; + default: + return null; + } + + var isPixel = isComboNumSpritePixel(digit); + result.antialiasing = !isPixel; + result.pixelPerfectRender = isPixel; + result.pixelPerfectPosition = isPixel; + result.updateHitbox(); + + return result; + } + + public function isComboNumSpritePixel(digit:Int):Bool + { + switch (digit) + { + case 0: + var result = _data.assets.comboNumber0?.isPixel; + if (result == null && fallback != null) result = fallback.isComboNumSpritePixel(digit); + return result ?? false; + case 1: + var result = _data.assets.comboNumber1?.isPixel; + if (result == null && fallback != null) result = fallback.isComboNumSpritePixel(digit); + return result ?? false; + case 2: + var result = _data.assets.comboNumber2?.isPixel; + if (result == null && fallback != null) result = fallback.isComboNumSpritePixel(digit); + return result ?? false; + case 3: + var result = _data.assets.comboNumber3?.isPixel; + if (result == null && fallback != null) result = fallback.isComboNumSpritePixel(digit); + return result ?? false; + case 4: + var result = _data.assets.comboNumber4?.isPixel; + if (result == null && fallback != null) result = fallback.isComboNumSpritePixel(digit); + return result ?? false; + case 5: + var result = _data.assets.comboNumber5?.isPixel; + if (result == null && fallback != null) result = fallback.isComboNumSpritePixel(digit); + return result ?? false; + case 6: + var result = _data.assets.comboNumber6?.isPixel; + if (result == null && fallback != null) result = fallback.isComboNumSpritePixel(digit); + return result ?? false; + case 7: + var result = _data.assets.comboNumber7?.isPixel; + if (result == null && fallback != null) result = fallback.isComboNumSpritePixel(digit); + return result ?? false; + case 8: + var result = _data.assets.comboNumber8?.isPixel; + if (result == null && fallback != null) result = fallback.isComboNumSpritePixel(digit); + return result ?? false; + case 9: + var result = _data.assets.comboNumber9?.isPixel; + if (result == null && fallback != null) result = fallback.isComboNumSpritePixel(digit); + return result ?? false; + default: + return false; + } + } + + function buildComboNumSpritePath(digit:Int):Null + { + var basePath:Null = null; + switch (digit) + { + case 0: + basePath = _data.assets.comboNumber0?.assetPath; + case 1: + basePath = _data.assets.comboNumber1?.assetPath; + case 2: + basePath = _data.assets.comboNumber2?.assetPath; + case 3: + basePath = _data.assets.comboNumber3?.assetPath; + case 4: + basePath = _data.assets.comboNumber4?.assetPath; + case 5: + basePath = _data.assets.comboNumber5?.assetPath; + case 6: + basePath = _data.assets.comboNumber6?.assetPath; + case 7: + basePath = _data.assets.comboNumber7?.assetPath; + case 8: + basePath = _data.assets.comboNumber8?.assetPath; + case 9: + basePath = _data.assets.comboNumber9?.assetPath; + default: + basePath = null; + } + + if (basePath == null) return fallback?.buildComboNumSpritePath(digit); + + var parts = basePath?.split(Constants.LIBRARY_SEPARATOR) ?? []; + if (parts.length < 1) return null; + if (parts.length == 1) return parts[0]; + + return parts[1]; + } + + public function getComboNumSpriteOffsets(digit:Int):Array + { + switch (digit) + { + case 0: + var result = _data.assets.comboNumber0?.offsets; + if (result == null && fallback != null) result = fallback.getComboNumSpriteOffsets(digit); + return result ?? [0, 0]; + case 1: + var result = _data.assets.comboNumber1?.offsets; + if (result == null && fallback != null) result = fallback.getComboNumSpriteOffsets(digit); + return result ?? [0, 0]; + case 2: + var result = _data.assets.comboNumber2?.offsets; + if (result == null && fallback != null) result = fallback.getComboNumSpriteOffsets(digit); + return result ?? [0, 0]; + case 3: + var result = _data.assets.comboNumber3?.offsets; + if (result == null && fallback != null) result = fallback.getComboNumSpriteOffsets(digit); + return result ?? [0, 0]; + case 4: + var result = _data.assets.comboNumber4?.offsets; + if (result == null && fallback != null) result = fallback.getComboNumSpriteOffsets(digit); + return result ?? [0, 0]; + case 5: + var result = _data.assets.comboNumber5?.offsets; + if (result == null && fallback != null) result = fallback.getComboNumSpriteOffsets(digit); + return result ?? [0, 0]; + case 6: + var result = _data.assets.comboNumber6?.offsets; + if (result == null && fallback != null) result = fallback.getComboNumSpriteOffsets(digit); + return result ?? [0, 0]; + case 7: + var result = _data.assets.comboNumber7?.offsets; + if (result == null && fallback != null) result = fallback.getComboNumSpriteOffsets(digit); + return result ?? [0, 0]; + case 8: + var result = _data.assets.comboNumber8?.offsets; + if (result == null && fallback != null) result = fallback.getComboNumSpriteOffsets(digit); + return result ?? [0, 0]; + case 9: + var result = _data.assets.comboNumber9?.offsets; + if (result == null && fallback != null) result = fallback.getComboNumSpriteOffsets(digit); + return result ?? [0, 0]; + default: + return [0, 0]; + } } public function destroy():Void {} @@ -310,8 +867,17 @@ class NoteStyle implements IRegistryEntry return 'NoteStyle($id)'; } - static function _fetchData(id:String):Null + static function _fetchData(id:String):NoteStyleData { - return NoteStyleRegistry.instance.parseEntryDataWithMigration(id, NoteStyleRegistry.instance.fetchEntryVersion(id)); + var result = NoteStyleRegistry.instance.parseEntryDataWithMigration(id, NoteStyleRegistry.instance.fetchEntryVersion(id)); + + if (result == null) + { + throw 'Could not parse note style data for id: $id'; + } + else + { + return result; + } } } diff --git a/source/funkin/play/scoring/Scoring.hx b/source/funkin/play/scoring/Scoring.hx index dc2c40647..dae098638 100644 --- a/source/funkin/play/scoring/Scoring.hx +++ b/source/funkin/play/scoring/Scoring.hx @@ -556,41 +556,7 @@ enum abstract ScoringRank(String) } } - public function getMusicPath():String - { - switch (abstract) - { - case PERFECT_GOLD: - return 'resultsPERFECT'; - case PERFECT: - return 'resultsPERFECT'; - case EXCELLENT: - return 'resultsEXCELLENT'; - case GREAT: - return 'resultsNORMAL'; - case GOOD: - return 'resultsNORMAL'; - case SHIT: - return 'resultsSHIT'; - default: - return 'resultsNORMAL'; - } - } - - public function hasMusicIntro():Bool - { - switch (abstract) - { - case EXCELLENT: - return true; - case SHIT: - return true; - default: - return false; - } - } - - public function getFreeplayRankIconAsset():Null + public function getFreeplayRankIconAsset():String { switch (abstract) { @@ -607,20 +573,7 @@ enum abstract ScoringRank(String) case SHIT: return 'LOSS'; default: - return null; - } - } - - public function shouldMusicLoop():Bool - { - switch (abstract) - { - case PERFECT_GOLD | PERFECT | EXCELLENT | GREAT | GOOD: - return true; - case SHIT: - return false; - default: - return false; + return 'LOSS'; } } diff --git a/source/funkin/play/song/Song.hx b/source/funkin/play/song/Song.hx index dde5ee7b8..9d35902b0 100644 --- a/source/funkin/play/song/Song.hx +++ b/source/funkin/play/song/Song.hx @@ -14,6 +14,7 @@ import funkin.data.song.SongData.SongTimeFormat; import funkin.data.song.SongRegistry; import funkin.modding.IScriptedClass.IPlayStateScriptedClass; import funkin.modding.events.ScriptEvent; +import funkin.ui.freeplay.charselect.PlayableCharacter; import funkin.util.SortUtil; import openfl.utils.Assets; @@ -276,7 +277,8 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry):Null + public function getFirstValidVariation(?diffId:String, ?currentCharacter:PlayableCharacter, ?possibleVariations:Array):Null { if (possibleVariations == null) { - possibleVariations = variations; + possibleVariations = getVariationsByCharacter(currentCharacter); possibleVariations.sort(SortUtil.defaultsThenAlphabetically.bind(Constants.DEFAULT_VARIATION_LIST)); } if (diffId == null) diffId = listDifficulties(null, possibleVariations)[0]; @@ -422,22 +424,29 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry + public function getVariationsByCharacter(?char:PlayableCharacter):Array { - if (charId == null) charId = Constants.DEFAULT_CHARACTER; + if (char == null) return variations; - if (variations.contains(charId)) + var result = []; + trace('Evaluating variations for ${this.id} ${char.id}: ${this.variations}'); + for (variation in variations) { - return [charId]; - } - else - { - // TODO: How to exclude character variations while keeping other custom variations? - return variations; + var metadata = _metadata.get(variation); + + var playerCharId = metadata?.playData?.characters?.player; + if (playerCharId == null) continue; + + if (char.shouldShowCharacter(playerCharId)) + { + result.push(variation); + } } + + return result; } /** @@ -455,6 +464,8 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry, ?showLocked:Bool, ?showHidden:Bool):Array + { + var result = []; + + for (variation in variationIds) + { + var difficulties = listDifficulties(variation, null, showLocked, showHidden); + for (difficulty in difficulties) + { + var suffixedDifficulty = (variation != Constants.DEFAULT_VARIATION + && variation != 'erect') ? '$difficulty-${variation}' : difficulty; + result.push(suffixedDifficulty); + } + } + + return result; + } + public function hasDifficulty(diffId:String, ?variationId:String, ?variationIds:Array):Bool { if (variationIds == null) variationIds = []; @@ -504,6 +533,28 @@ class Song implements IPlayStateScriptedClass implements IRegistryEntry + { + var targetDifficulty:Null = getDifficulty(difficultyId, variationId); + if (targetDifficulty == null) return []; + + return targetDifficulty?.characters?.altInstrumentals ?? []; + } + + public function getBaseInstrumentalId(difficultyId:String, variationId:String):String + { + var targetDifficulty:Null = getDifficulty(difficultyId, variationId); + if (targetDifficulty == null) return ''; + + return targetDifficulty?.characters?.instrumental ?? ''; + } + /** * Purge the cached chart data for each difficulty of this song. */ @@ -696,10 +747,11 @@ class SongDifficulty * Cache the vocals for a given character. * @param id The character we are about to play. */ - public inline function cacheVocals():Void + public function cacheVocals():Void { for (voice in buildVoiceList()) { + trace('Caching vocal track: $voice'); FlxG.sound.cache(voice); } } @@ -711,6 +763,20 @@ class SongDifficulty * @param id The character we are about to play. */ public function buildVoiceList():Array + { + var result:Array = []; + result = result.concat(buildPlayerVoiceList()); + result = result.concat(buildOpponentVoiceList()); + if (result.length == 0) + { + var suffix:String = (variation != null && variation != '' && variation != 'default') ? '-$variation' : ''; + // Try to use `Voices.ogg` if no other voices are found. + if (Assets.exists(Paths.voices(this.song.id, ''))) result.push(Paths.voices(this.song.id, '$suffix')); + } + return result; + } + + public function buildPlayerVoiceList():Array { var suffix:String = (variation != null && variation != '' && variation != 'default') ? '-$variation' : ''; @@ -718,62 +784,88 @@ class SongDifficulty // For example, if `Voices-bf-car-erect.ogg` does not exist, check for `Voices-bf-erect.ogg`. // Then, check for `Voices-bf-car.ogg`, then `Voices-bf.ogg`. - var playerId:String = characters.player; - var voicePlayer:String = Paths.voices(this.song.id, '-$playerId$suffix'); - while (voicePlayer != null && !Assets.exists(voicePlayer)) + if (characters.playerVocals == null) { - // Remove the last suffix. - // For example, bf-car becomes bf. - playerId = playerId.split('-').slice(0, -1).join('-'); - // Try again. - voicePlayer = playerId == '' ? null : Paths.voices(this.song.id, '-${playerId}$suffix'); - } - if (voicePlayer == null) - { - // Try again without $suffix. - playerId = characters.player; - voicePlayer = Paths.voices(this.song.id, '-${playerId}'); - while (voicePlayer != null && !Assets.exists(voicePlayer)) + var playerId:String = characters.player; + var playerVoice:String = Paths.voices(this.song.id, '-${playerId}$suffix'); + + while (playerVoice != null && !Assets.exists(playerVoice)) { // Remove the last suffix. + // For example, bf-car becomes bf. playerId = playerId.split('-').slice(0, -1).join('-'); // Try again. - voicePlayer = playerId == '' ? null : Paths.voices(this.song.id, '-${playerId}$suffix'); + playerVoice = playerId == '' ? null : Paths.voices(this.song.id, '-${playerId}$suffix'); + } + if (playerVoice == null) + { + // Try again without $suffix. + playerId = characters.player; + playerVoice = Paths.voices(this.song.id, '-${playerId}'); + while (playerVoice != null && !Assets.exists(playerVoice)) + { + // Remove the last suffix. + playerId = playerId.split('-').slice(0, -1).join('-'); + // Try again. + playerVoice = playerId == '' ? null : Paths.voices(this.song.id, '-${playerId}$suffix'); + } } - } - var opponentId:String = characters.opponent; - var voiceOpponent:String = Paths.voices(this.song.id, '-${opponentId}$suffix'); - while (voiceOpponent != null && !Assets.exists(voiceOpponent)) - { - // Remove the last suffix. - opponentId = opponentId.split('-').slice(0, -1).join('-'); - // Try again. - voiceOpponent = opponentId == '' ? null : Paths.voices(this.song.id, '-${opponentId}$suffix'); + return playerVoice != null ? [playerVoice] : []; } - if (voiceOpponent == null) + else { - // Try again without $suffix. - opponentId = characters.opponent; - voiceOpponent = Paths.voices(this.song.id, '-${opponentId}'); - while (voiceOpponent != null && !Assets.exists(voiceOpponent)) + // The metadata explicitly defines the list of voices. + var playerIds:Array = characters?.playerVocals ?? [characters.player]; + var playerVoices:Array = playerIds.map((id) -> Paths.voices(this.song.id, '-$id$suffix')); + + return playerVoices; + } + } + + public function buildOpponentVoiceList():Array + { + var suffix:String = (variation != null && variation != '' && variation != 'default') ? '-$variation' : ''; + + // Automatically resolve voices by removing suffixes. + // For example, if `Voices-bf-car-erect.ogg` does not exist, check for `Voices-bf-erect.ogg`. + // Then, check for `Voices-bf-car.ogg`, then `Voices-bf.ogg`. + + if (characters.opponentVocals == null) + { + var opponentId:String = characters.opponent; + var opponentVoice:String = Paths.voices(this.song.id, '-${opponentId}$suffix'); + while (opponentVoice != null && !Assets.exists(opponentVoice)) { // Remove the last suffix. opponentId = opponentId.split('-').slice(0, -1).join('-'); // Try again. - voiceOpponent = opponentId == '' ? null : Paths.voices(this.song.id, '-${opponentId}$suffix'); + opponentVoice = opponentId == '' ? null : Paths.voices(this.song.id, '-${opponentId}$suffix'); + } + if (opponentVoice == null) + { + // Try again without $suffix. + opponentId = characters.opponent; + opponentVoice = Paths.voices(this.song.id, '-${opponentId}'); + while (opponentVoice != null && !Assets.exists(opponentVoice)) + { + // Remove the last suffix. + opponentId = opponentId.split('-').slice(0, -1).join('-'); + // Try again. + opponentVoice = opponentId == '' ? null : Paths.voices(this.song.id, '-${opponentId}$suffix'); + } } - } - var result:Array = []; - if (voicePlayer != null) result.push(voicePlayer); - if (voiceOpponent != null) result.push(voiceOpponent); - if (voicePlayer == null && voiceOpponent == null) - { - // Try to use `Voices.ogg` if no other voices are found. - if (Assets.exists(Paths.voices(this.song.id, ''))) result.push(Paths.voices(this.song.id, '$suffix')); + return opponentVoice != null ? [opponentVoice] : []; + } + else + { + // The metadata explicitly defines the list of voices. + var opponentIds:Array = characters?.opponentVocals ?? [characters.opponent]; + var opponentVoices:Array = opponentIds.map((id) -> Paths.voices(this.song.id, '-$id$suffix')); + + return opponentVoices; } - return result; } /** @@ -781,34 +873,27 @@ class SongDifficulty * @param charId The player ID. * @return The generated vocal group. */ - public function buildVocals():VoicesGroup + public function buildVocals(?instId:String = ''):VoicesGroup { var result:VoicesGroup = new VoicesGroup(); - var voiceList:Array = buildVoiceList(); - - if (voiceList.length == 0) - { - trace('Could not find any voices for song ${this.song.id}'); - return result; - } + var playerVoiceList:Array = this.buildPlayerVoiceList(); + var opponentVoiceList:Array = this.buildOpponentVoiceList(); // Add player vocals. - if (voiceList[0] != null) result.addPlayerVoice(FunkinSound.load(voiceList[0])); - // Add opponent vocals. - if (voiceList[1] != null) result.addOpponentVoice(FunkinSound.load(voiceList[1])); - - // Add additional vocals. - if (voiceList.length > 2) + for (playerVoice in playerVoiceList) { - for (i in 2...voiceList.length) - { - result.add(FunkinSound.load(Assets.getSound(voiceList[i]))); - } + result.addPlayerVoice(FunkinSound.load(playerVoice)); } - result.playerVoicesOffset = offsets.getVocalOffset(characters.player); - result.opponentVoicesOffset = offsets.getVocalOffset(characters.opponent); + // Add opponent vocals. + for (opponentVoice in opponentVoiceList) + { + result.addOpponentVoice(FunkinSound.load(opponentVoice)); + } + + result.playerVoicesOffset = offsets.getVocalOffset(characters.player, instId); + result.opponentVoicesOffset = offsets.getVocalOffset(characters.opponent, instId); return result; } diff --git a/source/funkin/play/stage/Bopper.hx b/source/funkin/play/stage/Bopper.hx index 262aff7bc..721a60517 100644 --- a/source/funkin/play/stage/Bopper.hx +++ b/source/funkin/play/stage/Bopper.hx @@ -1,6 +1,7 @@ package funkin.play.stage; import flixel.FlxSprite; +import flixel.FlxCamera; import flixel.math.FlxPoint; import flixel.util.FlxTimer; import funkin.modding.IScriptedClass.IPlayStateScriptedClass; @@ -19,8 +20,10 @@ class Bopper extends StageProp implements IPlayStateScriptedClass /** * The bopper plays the dance animation once every `danceEvery` beats. * Set to 0 to disable idle animation. + * Supports up to 0.25 precision. + * @default 0.0 on props, 1.0 on characters */ - public var danceEvery:Int = 1; + public var danceEvery:Float = 0.0; /** * Whether the bopper should dance left and right. @@ -43,8 +46,8 @@ class Bopper extends StageProp implements IPlayStateScriptedClass public var idleSuffix(default, set):String = ''; /** - * If this bopper is rendered with pixel art, - * disable anti-aliasing and render at 6x scale. + * If this bopper is rendered with pixel art, disable anti-aliasing. + * @default `false` */ public var isPixel(default, set):Bool = false; @@ -77,11 +80,6 @@ class Bopper extends StageProp implements IPlayStateScriptedClass if (globalOffsets == null) globalOffsets = [0, 0]; if (globalOffsets == value) return value; - var xDiff = globalOffsets[0] - value[0]; - var yDiff = globalOffsets[1] - value[1]; - - this.x += xDiff; - this.y += yDiff; return globalOffsets = value; } @@ -95,12 +93,6 @@ class Bopper extends StageProp implements IPlayStateScriptedClass if (animOffsets == null) animOffsets = [0, 0]; if ((animOffsets[0] == value[0]) && (animOffsets[1] == value[1])) return value; - var xDiff = animOffsets[0] - value[0]; - var yDiff = animOffsets[1] - value[1]; - - this.x += xDiff; - this.y += yDiff; - return animOffsets = value; } @@ -110,7 +102,7 @@ class Bopper extends StageProp implements IPlayStateScriptedClass */ var hasDanced:Bool = false; - public function new(danceEvery:Int = 1) + public function new(danceEvery:Float = 0.0) { super(); this.danceEvery = danceEvery; @@ -171,16 +163,18 @@ class Bopper extends StageProp implements IPlayStateScriptedClass } /** - * Called once every beat of the song. + * Called once every step of the song. */ - public function onBeatHit(event:SongTimeScriptEvent):Void + public function onStepHit(event:SongTimeScriptEvent) { - if (danceEvery > 0 && event.beat % danceEvery == 0) + if (danceEvery > 0 && (event.step % (danceEvery * Constants.STEPS_PER_BEAT)) == 0) { dance(shouldBop); } } + public function onBeatHit(event:SongTimeScriptEvent):Void {} + /** * Called every `danceEvery` beats of the song. */ @@ -200,12 +194,10 @@ class Bopper extends StageProp implements IPlayStateScriptedClass { if (hasDanced) { - trace('DanceRight (alternate)'); playAnimation('danceRight$idleSuffix', forceRestart); } else { - trace('DanceLeft (alternate)'); playAnimation('danceLeft$idleSuffix', forceRestart); } hasDanced = !hasDanced; @@ -268,6 +260,8 @@ class Bopper extends StageProp implements IPlayStateScriptedClass public var canPlayOtherAnims:Bool = true; + public var ignoreExclusionPref:Array = []; + /** * @param name The name of the animation to play. * @param restart Whether to restart the animation if it is already playing. @@ -276,7 +270,26 @@ class Bopper extends StageProp implements IPlayStateScriptedClass */ public function playAnimation(name:String, restart:Bool = false, ignoreOther:Bool = false, reversed:Bool = false):Void { - if (!canPlayOtherAnims && !ignoreOther) return; + if ((!canPlayOtherAnims)) + { + var id = name; + if (getCurrentAnimation() == 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; + } var correctName = correctAnimationName(name); if (correctName == null) return; @@ -318,19 +331,12 @@ class Bopper extends StageProp implements IPlayStateScriptedClass function applyAnimationOffsets(name:String):Void { var offsets = animationOffsets.get(name); - if (offsets != null && !(offsets[0] == 0 && offsets[1] == 0)) - { - this.animOffsets = [offsets[0] + globalOffsets[0], offsets[1] + globalOffsets[1]]; - } - else - { - this.animOffsets = globalOffsets; - } + this.animOffsets = offsets; } public function isAnimationFinished():Bool { - return this.animation.finished; + return this.animation?.finished ?? false; } public function setAnimationOffsets(name:String, xOffset:Float, yOffset:Float):Void @@ -349,6 +355,15 @@ class Bopper extends StageProp implements IPlayStateScriptedClass return this.animation.curAnim.name; } + // override getScreenPosition (used by FlxSprite's draw method) to account for animation offsets. + override function getScreenPosition(?result:FlxPoint, ?camera:FlxCamera):FlxPoint + { + var output:FlxPoint = super.getScreenPosition(result, camera); + output.x -= (animOffsets[0] - globalOffsets[0]) * this.scale.x; + output.y -= (animOffsets[1] - globalOffsets[1]) * this.scale.y; + return output; + } + public function onPause(event:PauseScriptEvent) {} public function onResume(event:ScriptEvent) {} @@ -369,8 +384,6 @@ class Bopper extends StageProp implements IPlayStateScriptedClass public function onNoteGhostMiss(event:GhostMissNoteScriptEvent) {} - public function onStepHit(event:SongTimeScriptEvent) {} - public function onCountdownStart(event:CountdownScriptEvent) {} public function onCountdownStep(event:CountdownScriptEvent) {} diff --git a/source/funkin/play/stage/Stage.hx b/source/funkin/play/stage/Stage.hx index 4f8ab4434..c42e41cad 100644 --- a/source/funkin/play/stage/Stage.hx +++ b/source/funkin/play/stage/Stage.hx @@ -249,6 +249,10 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements // If pixel, disable antialiasing. propSprite.antialiasing = !dataProp.isPixel; + // If pixel, we render it pixel perfect so there's less "mixels" + propSprite.pixelPerfectRender = dataProp.isPixel; + propSprite.pixelPerfectPosition = dataProp.isPixel; + propSprite.scrollFactor.x = dataProp.scroll[0]; propSprite.scrollFactor.y = dataProp.scroll[1]; @@ -382,7 +386,7 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements { if (character == null) return; - #if debug + #if FEATURE_DEBUG_FUNCTIONS // Temporary marker that shows where the character's location is relative to. // Should display at the stage position of the character (before any offsets). // TODO: Make this a toggle? It's useful to turn on from time to time. @@ -432,8 +436,9 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements // Start with the per-stage character position. // Subtracting the origin ensures characters are positioned relative to their feet. // Subtracting the global offset allows positioning on a per-character basis. - character.x = stageCharData.position[0] - character.characterOrigin.x + character.globalOffsets[0]; - character.y = stageCharData.position[1] - character.characterOrigin.y + character.globalOffsets[1]; + // We previously applied the global offset here but that is now done elsewhere. + character.x = stageCharData.position[0] - character.characterOrigin.x; + character.y = stageCharData.position[1] - character.characterOrigin.y; @:privateAccess(funkin.play.stage.Bopper) { @@ -447,7 +452,7 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements character.cameraFocusPoint.x += stageCharData.cameraOffsets[0]; character.cameraFocusPoint.y += stageCharData.cameraOffsets[1]; - #if debug + #if FEATURE_DEBUG_FUNCTIONS // Draw the debug icon at the character's feet. if (charType == BF || charType == DAD) { @@ -464,7 +469,7 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements ScriptEventDispatcher.callEvent(character, new ScriptEvent(ADDED, false)); - #if debug + #if FEATURE_DEBUG_FUNCTIONS debugIconGroup.add(debugIcon); debugIconGroup.add(debugIcon2); #end @@ -771,16 +776,7 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements * A function that gets called once per beat in the song (once every four steps). * @param curStep The current beat number. */ - public function onBeatHit(event:SongTimeScriptEvent):Void - { - // Override me in your scripted stage to perform custom behavior! - // Make sure to call super.onBeatHit(event) if you want to keep the boppers dancing. - - for (bopper in boppers) - { - ScriptEventDispatcher.callEvent(bopper, event); - } - } + public function onBeatHit(event:SongTimeScriptEvent):Void {} public function onUpdate(event:UpdateScriptEvent) {} @@ -862,7 +858,15 @@ class Stage extends FlxSpriteGroup implements IPlayStateScriptedClass implements return StageRegistry.instance.parseEntryDataWithMigration(id, StageRegistry.instance.fetchEntryVersion(id)); } - public function onScriptEvent(event:ScriptEvent) {} + public function onScriptEvent(event:ScriptEvent) + { + // Ensure all custom events get broadcast to the elements of the stage. + // If we do it here, we don't have to add a handler to EACH script event function. + for (bopper in boppers) + { + ScriptEventDispatcher.callEvent(bopper, event); + } + } public function onPause(event:PauseScriptEvent) {} diff --git a/source/funkin/save/Save.hx b/source/funkin/save/Save.hx index 2900ce2be..2bbda15c0 100644 --- a/source/funkin/save/Save.hx +++ b/source/funkin/save/Save.hx @@ -97,6 +97,7 @@ class Save autoPause: true, inputOffset: 0, audioVisualOffset: 0, + unlockedFramerate: false, controls: { @@ -121,6 +122,13 @@ class Save modOptions: [], }, + unlocks: + { + // Default to having seen the default character. + charactersSeen: ["bf"], + oldChar: false + }, + optionsChartEditor: { // Reasonable defaults. @@ -393,6 +401,43 @@ class Save return data.optionsChartEditor.playbackSpeed; } + public var charactersSeen(get, never):Array; + + function get_charactersSeen():Array + { + return data.unlocks.charactersSeen; + } + + /** + * Marks whether the player has seen the spotlight animation, which should only display once per save file ever. + */ + public var oldChar(get, set):Bool; + + function get_oldChar():Bool + { + return data.unlocks.oldChar; + } + + function set_oldChar(value:Bool):Bool + { + data.unlocks.oldChar = value; + flush(); + return data.unlocks.oldChar; + } + + /** + * When we've seen a character unlock, add it to the list of characters seen. + * @param character + */ + public function addCharacterSeen(character:String):Void + { + if (!data.unlocks.charactersSeen.contains(character)) + { + data.unlocks.charactersSeen.push(character); + flush(); + } + } + /** * Return the score the user achieved for a given level on a given difficulty. * @@ -471,10 +516,18 @@ class Save for (difficulty in difficultyList) { var score:Null = getLevelScore(levelId, difficulty); - // TODO: Do we need to check accuracy/score here? if (score != null) { - return true; + if (score.score > 0) + { + // Level has score data, which means we cleared it! + return true; + } + else + { + // Level has score data, but the score is 0. + return false; + } } } return false; @@ -630,10 +683,18 @@ class Save for (difficulty in difficultyList) { var score:Null = getSongScore(songId, difficulty); - // TODO: Do we need to check accuracy/score here? if (score != null) { - return true; + if (score.score > 0) + { + // Level has score data, which means we cleared it! + return true; + } + else + { + // Level has score data, but the score is 0. + return false; + } } } return false; @@ -956,6 +1017,8 @@ typedef RawSaveData = */ var options:SaveDataOptions; + var unlocks:SaveDataUnlocks; + /** * The user's favorited songs in the Freeplay menu, * as a list of song IDs. @@ -980,6 +1043,21 @@ typedef SaveApiNewgroundsData = var sessionId:Null; } +typedef SaveDataUnlocks = +{ + /** + * Every time we see the unlock animation for a character, + * add it to this list so that we don't show it again. + */ + var charactersSeen:Array; + + /** + * This is a conditional when the player enters the character state + * For the first time ever + */ + var oldChar:Bool; +} + /** * An anoymous structure containing options about the user's high scores. */ @@ -1103,6 +1181,12 @@ typedef SaveDataOptions = */ var audioVisualOffset:Int; + /** + * If we want the framerate to be unlocked on HTML5. + * @default `false + */ + var unlockedFramerate:Bool; + var controls: { var p1: diff --git a/source/funkin/ui/AtlasText.hx b/source/funkin/ui/AtlasText.hx index 186d87c2a..ef74abc1e 100644 --- a/source/funkin/ui/AtlasText.hx +++ b/source/funkin/ui/AtlasText.hx @@ -152,6 +152,32 @@ class AtlasText extends FlxTypedSpriteGroup } } + public function getWidth():Int + { + var width = 0; + for (char in this.text.split("")) + { + switch (char) + { + case " ": + { + width += 40; + } + case "\n": + {} + case char: + { + var sprite = new AtlasChar(atlas, char); + sprite.revive(); + sprite.char = char; + sprite.alpha = 1; + width += Std.int(sprite.width); + } + } + } + return width; + } + override function toString() { return "InputItem, " + FlxStringUtil.getDebugString([ diff --git a/source/funkin/ui/MenuItem.hx b/source/funkin/ui/MenuItem.hx index ba5cc066b..2a483ea78 100644 --- a/source/funkin/ui/MenuItem.hx +++ b/source/funkin/ui/MenuItem.hx @@ -11,7 +11,6 @@ class MenuItem extends FlxSpriteGroup { public var targetY:Float = 0; public var week:FlxSprite; - public var flashingInt:Int = 0; public function new(x:Float, y:Float, weekNum:Int = 0, weekType:WeekType) { @@ -30,28 +29,28 @@ class MenuItem extends FlxSpriteGroup } var isFlashing:Bool = false; + var flashTick:Float = 0; + final flashFramerate:Float = 20; public function startFlashing():Void { isFlashing = true; } - // if it runs at 60fps, fake framerate will be 6 - // if it runs at 144 fps, fake framerate will be like 14, and will update the graphic every 0.016666 * 3 seconds still??? - // so it runs basically every so many seconds, not dependant on framerate?? - // I'm still learning how math works thanks whoever is reading this lol - var fakeFramerate:Int = Math.round((1 / FlxG.elapsed) / 10); - override function update(elapsed:Float) { super.update(elapsed); y = MathUtil.coolLerp(y, (targetY * 120) + 480, 0.17); - if (isFlashing) flashingInt += 1; - - if (flashingInt % fakeFramerate >= Math.floor(fakeFramerate / 2)) week.color = 0xFF33ffff; - else - week.color = FlxColor.WHITE; + if (isFlashing) + { + flashTick += elapsed; + if (flashTick >= 1 / flashFramerate) + { + flashTick %= 1 / flashFramerate; + week.color = (week.color == FlxColor.WHITE) ? 0xFF33ffff : FlxColor.WHITE; + } + } } } diff --git a/source/funkin/ui/MusicBeatState.hx b/source/funkin/ui/MusicBeatState.hx index 92169df75..8668b64c1 100644 --- a/source/funkin/ui/MusicBeatState.hx +++ b/source/funkin/ui/MusicBeatState.hx @@ -78,9 +78,6 @@ class MusicBeatState extends FlxTransitionableState implements IEventHandler { // Emergency exit button. if (FlxG.keys.justPressed.F4) FlxG.switchState(() -> new MainMenuState()); - - // This can now be used in EVERY STATE YAY! - if (FlxG.keys.justPressed.F5) debug_refreshModules(); } override function update(elapsed:Float) @@ -114,12 +111,10 @@ class MusicBeatState extends FlxTransitionableState implements IEventHandler ModuleHandler.callEvent(event); } - function debug_refreshModules() + function reloadAssets() { PolymodHandler.forceReloadAssets(); - this.destroy(); - // Create a new instance of the current state, so old data is cleared. FlxG.resetState(); } diff --git a/source/funkin/ui/MusicBeatSubState.hx b/source/funkin/ui/MusicBeatSubState.hx index 9035d12ff..02cebeb45 100644 --- a/source/funkin/ui/MusicBeatSubState.hx +++ b/source/funkin/ui/MusicBeatSubState.hx @@ -56,6 +56,8 @@ class MusicBeatSubState extends FlxSubState implements IEventHandler Conductor.beatHit.add(this.beatHit); Conductor.stepHit.add(this.stepHit); + + initConsoleHelpers(); } public override function destroy():Void @@ -72,9 +74,6 @@ class MusicBeatSubState extends FlxSubState implements IEventHandler // Emergency exit button. if (FlxG.keys.justPressed.F4) FlxG.switchState(() -> new MainMenuState()); - // This can now be used in EVERY STATE YAY! - if (FlxG.keys.justPressed.F5) debug_refreshModules(); - // Display Conductor info in the watch window. FlxG.watch.addQuick("musicTime", FlxG.sound.music?.time ?? 0.0); Conductor.watchQuick(conductorInUse); @@ -82,7 +81,9 @@ class MusicBeatSubState extends FlxSubState implements IEventHandler dispatchEvent(new UpdateScriptEvent(elapsed)); } - function debug_refreshModules() + public function initConsoleHelpers():Void {} + + function reloadAssets() { PolymodHandler.forceReloadAssets(); diff --git a/source/funkin/ui/PixelatedIcon.hx b/source/funkin/ui/PixelatedIcon.hx new file mode 100644 index 000000000..4252c9695 --- /dev/null +++ b/source/funkin/ui/PixelatedIcon.hx @@ -0,0 +1,92 @@ +package funkin.ui; + +import flixel.FlxSprite; +import funkin.graphics.FlxFilteredSprite; + +/** + * The icon that gets used for Freeplay capsules and char select + * NOT to be confused with the CharIcon class, which is for the in-game icons + */ +class PixelatedIcon extends FlxFilteredSprite +{ + public function new(x:Float, y:Float) + { + super(x, y); + this.makeGraphic(32, 32, 0x00000000); + this.antialiasing = false; + this.active = false; + } + + public function setCharacter(char:String):Void + { + var charPath:String = "freeplay/icons/"; + + switch (char) + { + case "bf-christmas" | "bf-car" | "bf-pixel" | "bf-holding-gf": + charPath += "bfpixel"; + case "monster-christmas": + charPath += "monsterpixel"; + case "mom" | "mom-car": + charPath += "mommypixel"; + case "pico-blazin" | "pico-playable" | "pico-speaker": + charPath += "picopixel"; + case "gf-christmas" | "gf-car" | "gf-pixel" | "gf-tankmen": + charPath += "gfpixel"; + case "dad": + charPath += "dadpixel"; + case "darnell-blazin": + charPath += "darnellpixel"; + case "senpai-angry": + charPath += "senpaipixel"; + case "spooky-dark": + charPath += "spookypixel"; + case "tankman-atlas": + charPath += "tankmanpixel"; + default: + charPath += '${char}pixel'; + } + + if (!openfl.utils.Assets.exists(Paths.image(charPath))) + { + trace('[WARN] Character ${char} has no freeplay icon.'); + return; + } + + var isAnimated = openfl.utils.Assets.exists(Paths.file('images/$charPath.xml')); + + if (isAnimated) + { + this.frames = Paths.getSparrowAtlas(charPath); + } + else + { + this.loadGraphic(Paths.image(charPath)); + } + + this.scale.x = this.scale.y = 2; + + switch (char) + { + case 'parents-christmas': + this.origin.x = 140; + default: + this.origin.x = 100; + } + + if (isAnimated) + { + this.active = true; + this.animation.addByPrefix('idle', 'idle0', 10, true); + this.animation.addByPrefix('confirm', 'confirm0', 10, false); + this.animation.addByPrefix('confirm-hold', 'confirm-hold0', 10, true); + + this.animation.finishCallback = function(name:String):Void { + trace('Finish pixel animation: ${name}'); + if (name == 'confirm') this.animation.play('confirm-hold'); + }; + + this.animation.play('idle'); + } + } +} diff --git a/source/funkin/ui/charSelect/CharIcon.hx b/source/funkin/ui/charSelect/CharIcon.hx new file mode 100644 index 000000000..6d6274286 --- /dev/null +++ b/source/funkin/ui/charSelect/CharIcon.hx @@ -0,0 +1,17 @@ +package funkin.ui.charSelect; + +import flixel.FlxSprite; + +class CharIcon extends FlxSprite +{ + public var locked:Bool = false; + + public function new(x:Float, y:Float, locked:Bool = false) + { + super(x, y); + + this.locked = locked; + + makeGraphic(128, 128); + } +} diff --git a/source/funkin/ui/charSelect/CharIconCharacter.hx b/source/funkin/ui/charSelect/CharIconCharacter.hx new file mode 100644 index 000000000..7f7b5c212 --- /dev/null +++ b/source/funkin/ui/charSelect/CharIconCharacter.hx @@ -0,0 +1,49 @@ +package funkin.ui.charSelect; + +import openfl.display.BitmapData; +import openfl.filters.DropShadowFilter; +import openfl.filters.ConvolutionFilter; +import funkin.graphics.shaders.StrokeShader; + +class CharIconCharacter extends CharIcon +{ + public var dropShadowFilter:DropShadowFilter; + + var matrixFilter:Array = [ + 1, 1, 1, + 1, 1, 1, + 1, 1, 1 + ]; + + var divisor:Int = 1; + var bias:Int = 0; + var convolutionFilter:ConvolutionFilter; + + public var noDropShadow:BitmapData; + public var withDropShadow:BitmapData; + + var strokeShader:StrokeShader; + + public function new(path:String) + { + super(0, 0, false); + + loadGraphic(Paths.image('freeplay/icons/' + path + 'pixel')); + setGraphicSize(128, 128); + updateHitbox(); + antialiasing = false; + + strokeShader = new StrokeShader(); + // shader = strokeShader; + + // noDropShadow = pixels.clone(); + + // dropShadowFilter = new DropShadowFilter(5, 45, 0, 1, 0, 0); + // convolutionFilter = new ConvolutionFilter(3, 3, matrixFilter, divisor, bias); + // pixels.applyFilter(pixels, pixels.rect, new openfl.geom.Point(0, 0), dropShadowFilter); + // pixels.applyFilter(pixels, pixels.rect, new openfl.geom.Point(0, 0), convolutionFilter); + // withDropShadow = pixels.clone(); + + // pixels = noDropShadow.clone(); + } +} diff --git a/source/funkin/ui/charSelect/CharIconLocked.hx b/source/funkin/ui/charSelect/CharIconLocked.hx new file mode 100644 index 000000000..dbe84a6ce --- /dev/null +++ b/source/funkin/ui/charSelect/CharIconLocked.hx @@ -0,0 +1,3 @@ +package funkin.ui.charSelect; + +class CharIconLocked extends CharIcon {} diff --git a/source/funkin/ui/charSelect/CharSelectGF.hx b/source/funkin/ui/charSelect/CharSelectGF.hx new file mode 100644 index 000000000..e8eeded40 --- /dev/null +++ b/source/funkin/ui/charSelect/CharSelectGF.hx @@ -0,0 +1,221 @@ +package funkin.ui.charSelect; + +import funkin.graphics.adobeanimate.FlxAtlasSprite; +import flixel.tweens.FlxTween; +import flixel.tweens.FlxEase; +import flixel.math.FlxMath; +import funkin.util.FramesJSFLParser; +import funkin.util.FramesJSFLParser.FramesJSFLInfo; +import funkin.util.FramesJSFLParser.FramesJSFLFrame; +import funkin.modding.IScriptedClass.IBPMSyncedScriptedClass; +import flixel.math.FlxMath; +import funkin.modding.events.ScriptEvent; +import funkin.vis.dsp.SpectralAnalyzer; + +class CharSelectGF extends FlxAtlasSprite implements IBPMSyncedScriptedClass +{ + var fadeTimer:Float = 0; + var fadingStatus:FadeStatus = OFF; + var fadeAnimIndex:Int = 0; + + var animInInfo:FramesJSFLInfo; + var animOutInfo:FramesJSFLInfo; + + var intendedYPos:Float = 0; + var intendedAlpha:Float = 0; + var list:Array = []; + + var analyzer:SpectralAnalyzer; + + var curGF:GFChar = GF; + + public function new() + { + super(0, 0, Paths.animateAtlas("charSelect/gfChill")); + + list = anim.curSymbol.getFrameLabelNames(); + + switchGF("bf"); + } + + override public function update(elapsed:Float):Void + { + super.update(elapsed); + + switch (fadingStatus) + { + case OFF: + // do nothing if it's off! + // or maybe force position to be 0,0? + // maybe reset timers? + resetFadeAnimParams(); + case FADE_OUT: + doFade(animOutInfo); + case FADE_IN: + doFade(animInInfo); + default: + } + + #if FEATURE_DEBUG_FUNCTIONS + if (FlxG.keys.justPressed.J) + { + alpha = 1; + x = y = 0; + fadingStatus = FADE_OUT; + } + if (FlxG.keys.justPressed.K) + { + alpha = 0; + fadingStatus = FADE_IN; + } + #end + } + + public function onStepHit(event:SongTimeScriptEvent):Void {} + + var danceEvery:Int = 2; + + public function onBeatHit(event:SongTimeScriptEvent):Void + { + // TODO: There's a minor visual bug where there's a little stutter. + // This happens because the animation is getting restarted while it's already playing. + // I tried make this not interrupt an existing idle, + // but isAnimationFinished() and isLoopComplete() both don't work! What the hell? + // danceEvery isn't necessary if that gets fixed. + if (getCurrentAnimation() == "idle" && (event.beat % danceEvery == 0)) + { + trace('GF beat hit'); + playAnimation("idle", true, false, false); + } + }; + + override public function draw() + { + if (analyzer != null) drawFFT(); + super.draw(); + } + + function drawFFT() + { + if (curGF == NENE) + { + var levels = analyzer.getLevels(); + var frame = anim.curSymbol.timeline.get("VIZ_bars").get(anim.curFrame); + var elements = frame.getList(); + var len:Int = cast Math.min(elements.length, 7); + + for (i in 0...len) + { + var animFrame:Int = Math.round(levels[i].value * 12); + + #if desktop + // 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(12, animFrame)); + animFrame = Math.floor(Math.max(0, animFrame)); + + animFrame = Std.int(Math.abs(animFrame - 12)); // shitty dumbass flip, cuz dave got da shit backwards lol! + + elements[i].symbol.firstFrame = animFrame; + } + } + } + + /** + * @param animInfo Should not be confused with animInInfo! + * This is merely a local var for the function! + */ + function doFade(animInfo:FramesJSFLInfo):Void + { + fadeTimer += FlxG.elapsed; + if (fadeTimer >= 1 / 24) + { + fadeTimer -= FlxG.elapsed; + // only inc the index for the first frame, used for reference of where to "start" + if (fadeAnimIndex == 0) + { + fadeAnimIndex++; + return; + } + + var curFrame:FramesJSFLFrame = animInfo.frames[fadeAnimIndex]; + var prevFrame:FramesJSFLFrame = animInfo.frames[fadeAnimIndex - 1]; + + var xDiff:Float = curFrame.x - prevFrame.x; + var yDiff:Float = curFrame.y - prevFrame.y; + var alphaDiff:Float = curFrame.alpha - prevFrame.alpha; + alphaDiff /= 100; // flash exports alpha as a whole number + + alpha += alphaDiff; + alpha = FlxMath.bound(alpha, 0, 1); + x += xDiff; + y += yDiff; + + fadeAnimIndex++; + } + + if (fadeAnimIndex >= animInfo.frames.length) fadingStatus = OFF; + } + + function resetFadeAnimParams() + { + fadeTimer = 0; + fadeAnimIndex = 0; + } + + /** + * For switching between "GFs" such as gf, nene, etc + * @param bf Which BF we are selecting, so that we know the accompyaning GF + */ + public function switchGF(bf:String):Void + { + var prevGF:GFChar = curGF; + switch (bf) + { + case "pico": + curGF = NENE; + case "bf": + curGF = GF; + default: + curGF = GF; + } + + // We don't need to update any anims if we didn't change GF + if (prevGF != curGF) + { + loadAtlas(Paths.animateAtlas("charSelect/" + curGF + "Chill")); + + animInInfo = FramesJSFLParser.parse(Paths.file("images/charSelect/" + curGF + "AnimInfo/" + curGF + "In.txt")); + animOutInfo = FramesJSFLParser.parse(Paths.file("images/charSelect/" + curGF + "AnimInfo/" + curGF + "Out.txt")); + } + + playAnimation("idle", true, false, false); + // addFrameCallback(getNextFrameLabel("idle"), () -> playAnimation("idle", true, false, false)); + + updateHitbox(); + } + + public function onScriptEvent(event:ScriptEvent):Void {}; + + public function onCreate(event:ScriptEvent):Void {}; + + public function onDestroy(event:ScriptEvent):Void {}; + + public function onUpdate(event:UpdateScriptEvent):Void {}; +} + +enum FadeStatus +{ + OFF; + FADE_OUT; + FADE_IN; +} + +enum abstract GFChar(String) from String to String +{ + var GF = "gf"; + var NENE = "nene"; +} diff --git a/source/funkin/ui/charSelect/CharSelectPlayer.hx b/source/funkin/ui/charSelect/CharSelectPlayer.hx new file mode 100644 index 000000000..b6319f16d --- /dev/null +++ b/source/funkin/ui/charSelect/CharSelectPlayer.hx @@ -0,0 +1,91 @@ +package funkin.ui.charSelect; + +import flixel.FlxSprite; +import funkin.graphics.adobeanimate.FlxAtlasSprite; +import flxanimate.animate.FlxKeyFrame; +import funkin.modding.IScriptedClass.IBPMSyncedScriptedClass; +import funkin.modding.events.ScriptEvent; + +class CharSelectPlayer extends FlxAtlasSprite implements IBPMSyncedScriptedClass +{ + public function new(x:Float, y:Float) + { + super(x, y, Paths.animateAtlas("charSelect/bfChill")); + + onAnimationComplete.add(function(animLabel:String) { + switch (animLabel) + { + case "slidein": + if (hasAnimation("slidein idle point")) + { + playAnimation("slidein idle point", true, false, false); + } + else + { + playAnimation("idle", true, false, false); + } + case "deselect": + playAnimation("deselect loop start", true, false, true); + + case "slidein idle point", "cannot select Label", "unlock": + playAnimation("idle", true, false, false); + case "idle": + trace('Waiting for onBeatHit'); + } + }); + } + + public function onStepHit(event:SongTimeScriptEvent):Void {} + + public function onBeatHit(event:SongTimeScriptEvent):Void + { + // TODO: There's a minor visual bug where there's a little stutter. + // This happens because the animation is getting restarted while it's already playing. + // I tried make this not interrupt an existing idle, + // but isAnimationFinished() and isLoopComplete() both don't work! What the hell? + // danceEvery isn't necessary if that gets fixed. + // + if (getCurrentAnimation() == "idle") + { + trace('Player beat hit'); + playAnimation("idle", true, false, false); + } + }; + + public function updatePosition(str:String) + { + switch (str) + { + case "bf": + x = 0; + y = 0; + case "pico": + x = 0; + y = 0; + case "random": + } + } + + public function switchChar(str:String) + { + switch str + { + default: + loadAtlas(Paths.animateAtlas("charSelect/" + str + "Chill")); + } + + playAnimation("slidein", true, false, false); + + updateHitbox(); + + updatePosition(str); + } + + public function onScriptEvent(event:ScriptEvent):Void {}; + + public function onCreate(event:ScriptEvent):Void {}; + + public function onDestroy(event:ScriptEvent):Void {}; + + public function onUpdate(event:UpdateScriptEvent):Void {}; +} diff --git a/source/funkin/ui/charSelect/CharSelectSubState.hx b/source/funkin/ui/charSelect/CharSelectSubState.hx new file mode 100644 index 000000000..3109dc8f1 --- /dev/null +++ b/source/funkin/ui/charSelect/CharSelectSubState.hx @@ -0,0 +1,1089 @@ +package funkin.ui.charSelect; + +import openfl.filters.BitmapFilter; +import flixel.FlxObject; +import flixel.FlxSprite; +import flixel.group.FlxGroup; +import flixel.group.FlxGroup.FlxTypedGroup; +import flixel.group.FlxSpriteGroup; +import flixel.math.FlxPoint; +import flixel.sound.FlxSound; +import flixel.system.debug.watch.Tracker.TrackerProfile; +import flixel.text.FlxText; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import flixel.util.FlxTimer; +import funkin.audio.FunkinSound; +import funkin.data.freeplay.player.PlayerData; +import funkin.data.freeplay.player.PlayerRegistry; +import funkin.graphics.adobeanimate.FlxAtlasSprite; +import openfl.filters.DropShadowFilter; +import funkin.graphics.FunkinCamera; +import funkin.graphics.shaders.BlueFade; +import funkin.modding.events.ScriptEvent; +import funkin.modding.events.ScriptEventDispatcher; +import funkin.play.stage.Stage; +import funkin.save.Save; +import funkin.ui.freeplay.charselect.PlayableCharacter; +import funkin.ui.freeplay.FreeplayState; +import funkin.ui.PixelatedIcon; +import funkin.util.MathUtil; +import funkin.vis.dsp.SpectralAnalyzer; +import openfl.display.BlendMode; +import funkin.save.Save; +import openfl.filters.ShaderFilter; +import funkin.util.FramesJSFLParser; +import funkin.util.FramesJSFLParser.FramesJSFLInfo; +import funkin.util.FramesJSFLParser.FramesJSFLFrame; +import funkin.graphics.FunkinSprite; + +class CharSelectSubState extends MusicBeatSubState +{ + var cursor:FlxSprite; + + var cursorBlue:FlxSprite; + var cursorDarkBlue:FlxSprite; + var grpCursors:FlxTypedGroup; + var cursorConfirmed:FlxSprite; + var cursorDenied:FlxSprite; + var cursorX:Int = 0; + var cursorY:Int = 0; + var cursorFactor:Float = 110; + var cursorOffsetX:Float = -16; + var cursorOffsetY:Float = -48; + var cursorLocIntended:FlxPoint = new FlxPoint(0, 0); + var lerpAmnt:Float = 0.95; + var tmrFrames:Int = 60; + var currentStage:Stage; + var playerChill:CharSelectPlayer; + var playerChillOut:CharSelectPlayer; + var gfChill:CharSelectGF; + var gfChillOut:CharSelectGF; + var barthing:FlxAtlasSprite; + var dipshitBacking:FlxSprite; + var chooseDipshit:FlxSprite; + var dipshitBlur:FlxSprite; + var transitionGradient:FlxSprite; + var curChar(default, set):String = "pico"; + var nametag:Nametag; + var camFollow:FlxObject; + var autoFollow:Bool = false; + var availableChars:Map = new Map(); + var pressedSelect:Bool = false; + var selectTimer:FlxTimer = new FlxTimer(); + + var selectSound:FunkinSound; + var unlockSound:FunkinSound; + var lockedSound:FunkinSound; + var introSound:FunkinSound; + var staticSound:FunkinSound; + + var charSelectCam:FunkinCamera; + + var selectedBizz:Array = [ + new DropShadowFilter(0, 0, 0xFFFFFF, 1, 2, 2, 19, 1, false, false, false), + new DropShadowFilter(5, 45, 0x000000, 1, 2, 2, 1, 1, false, false, false) + ]; + + var bopInfo:FramesJSFLInfo; + var blackScreen:FunkinSprite; + + public function new() + { + super(); + loadAvailableCharacters(); + } + + function loadAvailableCharacters():Void + { + var playerIds:Array = PlayerRegistry.instance.listEntryIds(); + + for (playerId in playerIds) + { + var player:Null = PlayerRegistry.instance.fetchEntry(playerId); + if (player == null) continue; + var playerData = player.getCharSelectData(); + if (playerData == null) continue; + + var targetPosition:Int = playerData.position ?? 0; + while (availableChars.exists(targetPosition)) + { + targetPosition += 1; + } + + trace('Placing player ${playerId} at position ${targetPosition}'); + availableChars.set(targetPosition, playerId); + } + } + + var fadeShader:BlueFade = new BlueFade(); + + override public function create():Void + { + super.create(); + + bopInfo = FramesJSFLParser.parse(Paths.file("images/charSelect/iconBopInfo/iconBopInfo.txt")); + + var bg:FlxSprite = new FlxSprite(-153, -140); + bg.loadGraphic(Paths.image('charSelect/charSelectBG')); + bg.scrollFactor.set(0.1, 0.1); + add(bg); + + var crowd:FlxAtlasSprite = new FlxAtlasSprite(0, 0, Paths.animateAtlas("charSelect/crowd")); + crowd.anim.play(); + crowd.anim.onComplete.add(function() { + crowd.anim.play(); + }); + crowd.scrollFactor.set(0.3, 0.3); + add(crowd); + + var stageSpr:FlxSprite = new FlxSprite(-40, 391); + stageSpr.frames = Paths.getSparrowAtlas("charSelect/charSelectStage"); + stageSpr.animation.addByPrefix("idle", "stage full instance 1", 24, true); + stageSpr.animation.play("idle"); + add(stageSpr); + + var curtains:FlxSprite = new FlxSprite(-47, -49); + curtains.loadGraphic(Paths.image('charSelect/curtains')); + curtains.scrollFactor.set(1.4, 1.4); + add(curtains); + + barthing = new FlxAtlasSprite(0, 0, Paths.animateAtlas("charSelect/barThing")); + barthing.anim.play(""); + barthing.anim.onComplete.add(function() { + barthing.anim.play(""); + }); + barthing.blend = BlendMode.MULTIPLY; + barthing.scrollFactor.set(0, 0); + add(barthing); + + barthing.y += 80; + FlxTween.tween(barthing, {y: barthing.y - 80}, 1.3, {ease: FlxEase.expoOut}); + + var charLight:FlxSprite = new FlxSprite(800, 250); + charLight.loadGraphic(Paths.image('charSelect/charLight')); + add(charLight); + + var charLightGF:FlxSprite = new FlxSprite(180, 240); + charLightGF.loadGraphic(Paths.image('charSelect/charLight')); + add(charLightGF); + + gfChill = new CharSelectGF(); + gfChill.switchGF("bf"); + add(gfChill); + + playerChillOut = new CharSelectPlayer(0, 0); + playerChillOut.switchChar("bf"); + add(playerChillOut); + + playerChill = new CharSelectPlayer(0, 0); + playerChill.switchChar("bf"); + add(playerChill); + + var speakers:FlxAtlasSprite = new FlxAtlasSprite(0, 0, Paths.animateAtlas("charSelect/charSelectSpeakers")); + speakers.anim.play(""); + speakers.anim.onComplete.add(function() { + speakers.anim.play(""); + }); + speakers.scrollFactor.set(1.8, 1.8); + add(speakers); + + var fgBlur:FlxSprite = new FlxSprite(-125, 170); + fgBlur.loadGraphic(Paths.image('charSelect/foregroundBlur')); + fgBlur.blend = openfl.display.BlendMode.MULTIPLY; + add(fgBlur); + + dipshitBlur = new FlxSprite(419, -65); + dipshitBlur.frames = Paths.getSparrowAtlas("charSelect/dipshitBlur"); + dipshitBlur.animation.addByPrefix('idle', "CHOOSE vertical offset instance 1", 24, true); + dipshitBlur.blend = BlendMode.ADD; + dipshitBlur.animation.play("idle"); + add(dipshitBlur); + + dipshitBacking = new FlxSprite(423, -17); + dipshitBacking.frames = Paths.getSparrowAtlas("charSelect/dipshitBacking"); + dipshitBacking.animation.addByPrefix('idle', "CHOOSE horizontal offset instance 1", 24, true); + dipshitBacking.blend = BlendMode.ADD; + dipshitBacking.animation.play("idle"); + add(dipshitBacking); + + dipshitBacking.y += 210; + FlxTween.tween(dipshitBacking, {y: dipshitBacking.y - 210}, 1.1, {ease: FlxEase.expoOut}); + + chooseDipshit = new FlxSprite(426, -13); + chooseDipshit.loadGraphic(Paths.image('charSelect/chooseDipshit')); + add(chooseDipshit); + + chooseDipshit.y += 200; + FlxTween.tween(chooseDipshit, {y: chooseDipshit.y - 200}, 1, {ease: FlxEase.expoOut}); + + dipshitBlur.y += 220; + FlxTween.tween(dipshitBlur, {y: dipshitBlur.y - 220}, 1.2, {ease: FlxEase.expoOut}); + + chooseDipshit.scrollFactor.set(); + dipshitBacking.scrollFactor.set(); + dipshitBlur.scrollFactor.set(); + + nametag = new Nametag(); + add(nametag); + + nametag.scrollFactor.set(); + + FlxG.debugger.addTrackerProfile(new TrackerProfile(FlxSprite, ["x", "y", "alpha", "scale", "blend"])); + FlxG.debugger.addTrackerProfile(new TrackerProfile(FlxAtlasSprite, ["x", "y"])); + FlxG.debugger.addTrackerProfile(new TrackerProfile(FlxSound, ["pitch", "volume"])); + + // FlxG.debugger.track(crowd); + // FlxG.debugger.track(stageSpr, "stageSpr"); + // FlxG.debugger.track(bfChill, "bf chill"); + // FlxG.debugger.track(playerChill, "player"); + // FlxG.debugger.track(nametag, "nametag"); + // FlxG.debugger.track(selectSound, "selectSound"); + // FlxG.debugger.track(chooseDipshit, "choose dipshit"); + // FlxG.debugger.track(barthing, "barthing"); + // FlxG.debugger.track(fgBlur, "fgBlur"); + // FlxG.debugger.track(dipshitBlur, "dipshitBlur"); + // FlxG.debugger.track(dipshitBacking, "dipshitBacking"); + // FlxG.debugger.track(charLightGF, "charLight"); + // FlxG.debugger.track(gfChill, "gfChill"); + + grpCursors = new FlxTypedGroup(); + add(grpCursors); + + cursor = new FlxSprite(0, 0); + cursor.loadGraphic(Paths.image('charSelect/charSelector')); + cursor.color = 0xFFFFFF00; + + // FFCC00 + + cursorBlue = new FlxSprite(0, 0); + cursorBlue.loadGraphic(Paths.image('charSelect/charSelector')); + cursorBlue.color = 0xFF3EBBFF; + + cursorDarkBlue = new FlxSprite(0, 0); + cursorDarkBlue.loadGraphic(Paths.image('charSelect/charSelector')); + cursorDarkBlue.color = 0xFF3C74F7; + + cursorBlue.blend = BlendMode.SCREEN; + cursorDarkBlue.blend = BlendMode.SCREEN; + + cursorConfirmed = new FlxSprite(0, 0); + cursorConfirmed.scrollFactor.set(); + cursorConfirmed.frames = Paths.getSparrowAtlas("charSelect/charSelectorConfirm"); + cursorConfirmed.animation.addByPrefix("idle", "cursor ACCEPTED instance 1", 24, true); + cursorConfirmed.visible = false; + add(cursorConfirmed); + + cursorDenied = new FlxSprite(0, 0); + cursorDenied.scrollFactor.set(); + cursorDenied.frames = Paths.getSparrowAtlas("charSelect/charSelectorDenied"); + cursorDenied.animation.addByPrefix("idle", "cursor DENIED instance 1", 24, false); + cursorDenied.visible = false; + add(cursorDenied); + + grpCursors.add(cursorDarkBlue); + grpCursors.add(cursorBlue); + grpCursors.add(cursor); + + selectSound = new FunkinSound(); + selectSound.loadEmbedded(Paths.sound('CS_select')); + selectSound.pitch = 1; + selectSound.volume = 0.7; + + FlxG.sound.defaultSoundGroup.add(selectSound); + FlxG.sound.list.add(selectSound); + + unlockSound = new FunkinSound(); + unlockSound.loadEmbedded(Paths.sound('CS_unlock')); + unlockSound.pitch = 1; + + unlockSound.volume = 0; + unlockSound.play(true); + + FlxG.sound.defaultSoundGroup.add(unlockSound); + FlxG.sound.list.add(unlockSound); + + lockedSound = new FunkinSound(); + lockedSound.loadEmbedded(Paths.sound('CS_locked')); + lockedSound.pitch = 1; + + lockedSound.volume = 1.; + + FlxG.sound.defaultSoundGroup.add(lockedSound); + FlxG.sound.list.add(lockedSound); + + staticSound = new FunkinSound(); + staticSound.loadEmbedded(Paths.sound('static loop')); + staticSound.pitch = 1; + + staticSound.looped = true; + + staticSound.volume = 0.6; + + FlxG.sound.defaultSoundGroup.add(staticSound); + FlxG.sound.list.add(staticSound); + + // playing it here to preload it. not doing this makes a super awkward pause at the end of the intro + // TODO: probably make an intro thing for funkinSound itself that preloads the next audio? + FunkinSound.playMusic('stayFunky', + { + startingVolume: 0, + overrideExisting: true, + restartTrack: true, + }); + + initLocks(); + + for (index => member in grpIcons.members) + { + member.y += 300; + FlxTween.tween(member, {y: member.y - 300}, 1, {ease: FlxEase.expoOut}); + } + + cursor.scrollFactor.set(); + cursorBlue.scrollFactor.set(); + cursorDarkBlue.scrollFactor.set(); + + FlxTween.color(cursor, 0.2, 0xFFFFFF00, 0xFFFFCC00, {type: PINGPONG}); + + // FlxG.debugger.track(cursor); + + FlxG.debugger.addTrackerProfile(new TrackerProfile(CharSelectSubState, ["curChar", "grpXSpread", "grpYSpread"])); + FlxG.debugger.track(this); + + camFollow = new FlxObject(0, 0, 1, 1); + add(camFollow); + camFollow.screenCenter(); + + // FlxG.camera.follow(camFollow, LOCKON, 0.01); + FlxG.camera.follow(camFollow, LOCKON); + + var fadeShaderFilter:ShaderFilter = new ShaderFilter(fadeShader); + FlxG.camera.filters = [fadeShaderFilter]; + + var temp:FlxSprite = new FlxSprite(); + temp.loadGraphic(Paths.image('charSelect/placement')); + add(temp); + temp.alpha = 0.0; + + Conductor.stepHit.add(spamOnStep); + // FlxG.debugger.track(temp, "tempBG"); + + transitionGradient = new FlxSprite(0, 0).loadGraphic(Paths.image('freeplay/transitionGradient')); + transitionGradient.scale.set(1280, 1); + transitionGradient.flipY = true; + transitionGradient.updateHitbox(); + FlxTween.tween(transitionGradient, {y: -720}, 1, {ease: FlxEase.expoOut}); + add(transitionGradient); + + camFollow.screenCenter(); + camFollow.y -= 150; + fadeShader.fade(0.0, 1.0, 0.8, {ease: FlxEase.quadOut}); + FlxTween.tween(camFollow, {y: camFollow.y + 150}, 1.5, + { + ease: FlxEase.expoOut, + onComplete: function(_) { + autoFollow = true; + FlxG.camera.follow(camFollow, LOCKON, 0.01); + } + }); + + var blackScreen = new FunkinSprite().makeSolidColor(FlxG.width * 2, FlxG.height * 2, 0xFF000000); + blackScreen.x = -(FlxG.width * 0.5); + blackScreen.y = -(FlxG.height * 0.5); + add(blackScreen); + + introSound = new FunkinSound(); + introSound.loadEmbedded(Paths.sound('CS_Lights')); + introSound.pitch = 1; + introSound.volume = 0; + + FlxG.sound.defaultSoundGroup.add(introSound); + FlxG.sound.list.add(introSound); + + openSubState(new IntroSubState()); + subStateClosed.addOnce((_) -> { + remove(blackScreen); + if (!Save.instance.oldChar) + { + camera.flash(); + + introSound.volume = 1; + introSound.play(true); + } + checkNewChar(); + + Save.instance.oldChar = true; + }); + } + + function checkNewChar():Void + { + if (nonLocks.length > 0) selectTimer.start(2, (_) -> { + unLock(); + }); + else + { + FunkinSound.playMusic('stayFunky', + { + startingVolume: 1, + overrideExisting: true, + restartTrack: true, + onLoad: function() { + @:privateAccess + gfChill.analyzer = new SpectralAnalyzer(FlxG.sound.music._channel.__audioSource, 7, 0.1); + #if desktop + // On desktop it uses FFT stuff that isn't as optimized as the direct browser stuff we use on HTML5 + // So we want to manually change it! + @:privateAccess + gfChill.analyzer.fftN = 512; + #end + } + }); + } + } + + var grpIcons:FlxSpriteGroup; + var grpXSpread(default, set):Float = 107; + var grpYSpread(default, set):Float = 127; + var nonLocks = []; + + function initLocks():Void + { + grpIcons = new FlxSpriteGroup(); + add(grpIcons); + + FlxG.debugger.addTrackerProfile(new TrackerProfile(FlxSpriteGroup, ["x", "y"])); + // FlxG.debugger.track(grpIcons, "iconGrp"); + + for (i in 0...9) + { + if (availableChars.exists(i) && Save.instance.charactersSeen.contains(availableChars[i])) + { + var path:String = availableChars.get(i); + var temp:PixelatedIcon = new PixelatedIcon(0, 0); + temp.setCharacter(path); + temp.setGraphicSize(128, 128); + temp.updateHitbox(); + temp.ID = 0; + grpIcons.add(temp); + } + else + { + if (availableChars.exists(i)) nonLocks.push(i); + + var temp:Lock = new Lock(0, 0, i); + temp.ID = 1; + + // temp.onAnimationComplete.add(function(anim) { + // if (anim == "unlock") playerChill.playAnimation("unlock", true); + // }); + + grpIcons.add(temp); + } + } + + updateIconPositions(); + + grpIcons.scrollFactor.set(); + } + + function unLock() + { + var index = nonLocks[0]; + + pressedSelect = true; + + var copy = 3; + + var yThing = -1; + + while ((index + 1) > copy) + { + yThing++; + copy += 3; + } + + var xThing = (copy - index - 2) * -1; + // Look, I'd write better code but I had better aneurysms, my bad - Cheems + cursorY = yThing; + cursorX = xThing; + + selectSound.play(true); + + nonLocks.shift(); + + selectTimer.start(0.5, function(_) { + var lock:Lock = cast grpIcons.group.members[index]; + + lock.anim.getFrameLabel("unlockAnim").add(function() { + playerChillOut.playAnimation("death"); + }); + + lock.playAnimation("unlock"); + + unlockSound.volume = 0.7; + unlockSound.play(true); + + syncLock = lock; + + sync = true; + + lock.onAnimationComplete.addOnce(function(_) { + syncLock = null; + var char = availableChars.get(index); + camera.flash(0xFFFFFFFF, 0.1); + playerChill.playAnimation("unlock"); + playerChill.visible = true; + + var id = grpIcons.members.indexOf(lock); + + nametag.switchChar(char); + gfChill.switchGF(char); + + var icon = new PixelatedIcon(0, 0); + icon.setCharacter(char); + icon.setGraphicSize(128, 128); + icon.updateHitbox(); + grpIcons.insert(id, icon); + grpIcons.remove(lock, true); + icon.ID = 0; + + bopPlay = true; + + updateIconPositions(); + playerChillOut.onAnimationComplete.addOnce((_) -> if (_ == "death") + { + // sync = false; + playerChillOut.visible = false; + playerChillOut.switchChar(char); + }); + + Save.instance.addCharacterSeen(char); + if (nonLocks.length == 0) + { + pressedSelect = false; + @:bypassAccessor curChar = char; + + staticSound.stop(); + + FunkinSound.playMusic('stayFunky', + { + startingVolume: 1, + overrideExisting: true, + restartTrack: true, + onLoad: function() { + @:privateAccess + gfChill.analyzer = new SpectralAnalyzer(FlxG.sound.music._channel.__audioSource, 7, 0.1); + #if desktop + // On desktop it uses FFT stuff that isn't as optimized as the direct browser stuff we use on HTML5 + // So we want to manually change it! + @:privateAccess + gfChill.analyzer.fftN = 512; + #end + } + }); + } + else + playerChill.onAnimationComplete.addOnce((_) -> unLock()); + }); + + playerChill.visible = false; + playerChill.switchChar(availableChars[index]); + + playerChillOut.visible = true; + }); + } + + function updateIconPositions() + { + grpIcons.x = 450; + grpIcons.y = 120; + for (index => member in grpIcons.members) + { + var posX:Float = (index % 3); + var posY:Float = Math.floor(index / 3); + + member.x = posX * grpXSpread; + member.y = posY * grpYSpread; + + member.x += grpIcons.x; + member.y += grpIcons.y; + } + } + + var sync:Bool = false; + var syncLock:Lock = null; + var audioBizz:Float = 0; + + function syncAudio(elapsed:Float):Void + { + @:privateAccess + if (sync && unlockSound.time > 0) + { + // if (playerChillOut.anim.framerate > 0) + // { + // if (syncLock != null) syncLock.anim.framerate = 0; + // playerChillOut.anim.framerate = 0; + // } + + playerChillOut.anim._tick = 0; + if (syncLock != null) syncLock.anim._tick = 0; + + if ((unlockSound.time - audioBizz) >= ((delay) * 100)) + { + if (syncLock != null) syncLock.anim._tick = delay; + + playerChillOut.anim._tick = delay; + audioBizz += delay * 100; + } + } + } + + function goToFreeplay():Void + { + autoFollow = false; + + FlxTween.tween(cursor, {alpha: 0}, 0.8, {ease: FlxEase.expoOut}); + FlxTween.tween(cursorBlue, {alpha: 0}, 0.8, {ease: FlxEase.expoOut}); + FlxTween.tween(cursorDarkBlue, {alpha: 0}, 0.8, {ease: FlxEase.expoOut}); + FlxTween.tween(cursorConfirmed, {alpha: 0}, 0.8, {ease: FlxEase.expoOut}); + + FlxTween.tween(barthing, {y: barthing.y + 80}, 0.8, {ease: FlxEase.backIn}); + FlxTween.tween(dipshitBacking, {y: dipshitBacking.y + 210}, 0.8, {ease: FlxEase.backIn}); + FlxTween.tween(chooseDipshit, {y: chooseDipshit.y + 200}, 0.8, {ease: FlxEase.backIn}); + FlxTween.tween(dipshitBlur, {y: dipshitBlur.y + 220}, 0.8, {ease: FlxEase.backIn}); + for (index => member in grpIcons.members) + { + // member.y += 300; + FlxTween.tween(member, {y: member.y + 300}, 0.8, {ease: FlxEase.backIn}); + } + FlxG.camera.follow(camFollow, LOCKON); + FlxTween.tween(transitionGradient, {y: -150}, 0.8, {ease: FlxEase.backIn}); + fadeShader.fade(1.0, 0, 0.8, {ease: FlxEase.quadIn}); + FlxTween.tween(camFollow, {y: camFollow.y - 150}, 0.8, + { + ease: FlxEase.backIn, + onComplete: function(_) { + FlxG.switchState(FreeplayState.build( + { + { + character: curChar, + fromCharSelect: true + } + })); + } + }); + } + + var holdTmrUp:Float = 0; + var holdTmrDown:Float = 0; + var holdTmrLeft:Float = 0; + var holdTmrRight:Float = 0; + var spamUp:Bool = false; + var spamDown:Bool = false; + var spamLeft:Bool = false; + var spamRight:Bool = false; + + override public function update(elapsed:Float):Void + { + super.update(elapsed); + + Conductor.instance.update(); + + if (controls.UI_UP_R || controls.UI_DOWN_R || controls.UI_LEFT_R || controls.UI_RIGHT_R) selectSound.pitch = 1; + + syncAudio(elapsed); + + if (!pressedSelect) + { + if (controls.UI_UP) holdTmrUp += elapsed; + if (controls.UI_UP_R) + { + holdTmrUp = 0; + spamUp = false; + } + + if (controls.UI_DOWN) holdTmrDown += elapsed; + if (controls.UI_DOWN_R) + { + holdTmrDown = 0; + spamDown = false; + } + + if (controls.UI_LEFT) holdTmrLeft += elapsed; + if (controls.UI_LEFT_R) + { + holdTmrLeft = 0; + spamLeft = false; + } + + if (controls.UI_RIGHT) holdTmrRight += elapsed; + if (controls.UI_RIGHT_R) + { + holdTmrRight = 0; + spamRight = false; + } + + var initSpam = 0.5; + + if (holdTmrUp >= initSpam) spamUp = true; + if (holdTmrDown >= initSpam) spamDown = true; + if (holdTmrLeft >= initSpam) spamLeft = true; + if (holdTmrRight >= initSpam) spamRight = true; + + if (controls.UI_UP_P) + { + cursorY -= 1; + cursorDenied.visible = false; + + holdTmrUp = 0; + + selectSound.play(true); + } + if (controls.UI_DOWN_P) + { + cursorY += 1; + cursorDenied.visible = false; + holdTmrDown = 0; + selectSound.play(true); + } + if (controls.UI_LEFT_P) + { + cursorX -= 1; + cursorDenied.visible = false; + + holdTmrLeft = 0; + selectSound.play(true); + } + if (controls.UI_RIGHT_P) + { + cursorX += 1; + cursorDenied.visible = false; + holdTmrRight = 0; + selectSound.play(true); + } + } + + if (cursorX < -1) + { + cursorX = 1; + } + if (cursorX > 1) + { + cursorX = -1; + } + if (cursorY < -1) + { + cursorY = 1; + } + if (cursorY > 1) + { + cursorY = -1; + } + + if (autoFollow + && availableChars.exists(getCurrentSelected()) + && Save.instance.charactersSeen.contains(availableChars[getCurrentSelected()])) + { + gfChill.visible = true; + curChar = availableChars.get(getCurrentSelected()); + + if (!pressedSelect && controls.ACCEPT) + { + cursorConfirmed.visible = true; + cursorConfirmed.x = cursor.x - 2; + cursorConfirmed.y = cursor.y - 4; + cursorConfirmed.animation.play("idle", true); + + grpCursors.visible = false; + + FlxG.sound.play(Paths.sound('CS_confirm')); + + FlxTween.tween(FlxG.sound.music, {pitch: 0.1}, 1, {ease: FlxEase.quadInOut}); + FlxTween.tween(FlxG.sound.music, {volume: 0.0}, 1.5, {ease: FlxEase.quadInOut}); + playerChill.playAnimation("select"); + gfChill.playAnimation("confirm", true, false, true); + pressedSelect = true; + selectTimer.start(1.5, (_) -> { + // pressedSelect = false; + // FlxG.switchState(FreeplayState.build( + // { + // { + // character: curChar + // } + // })); + goToFreeplay(); + }); + } + + if (pressedSelect && controls.BACK) + { + cursorConfirmed.visible = false; + grpCursors.visible = true; + + FlxTween.globalManager.cancelTweensOf(FlxG.sound.music); + FlxTween.tween(FlxG.sound.music, {pitch: 1.0, volume: 1.0}, 1, {ease: FlxEase.quartInOut}); + playerChill.playAnimation("deselect"); + gfChill.playAnimation("deselect"); + pressedSelect = false; + FlxTween.tween(FlxG.sound.music, {pitch: 1.0}, 1, + { + ease: FlxEase.quartInOut, + onComplete: (_) -> { + if (playerChill.getCurrentAnimation() == "deselect loop start" || playerChill.getCurrentAnimation() == "deselect") + { + playerChill.playAnimation("idle", true, false, true); + gfChill.playAnimation("idle", true, false, true); + } + } + }); + selectTimer.cancel(); + } + } + else if (autoFollow) + { + curChar = "locked"; + + gfChill.visible = false; + + if (controls.ACCEPT) + { + cursorDenied.visible = true; + cursorDenied.x = cursor.x - 2; + cursorDenied.y = cursor.y - 4; + + playerChill.playAnimation("cannot select Label", true); + + lockedSound.play(true); + + cursorDenied.animation.play("idle", true); + cursorDenied.animation.finishCallback = (_) -> { + cursorDenied.visible = false; + }; + } + } + + updateLockAnims(); + + if (autoFollow == true) + { + camFollow.screenCenter(); + camFollow.x += cursorX * 10; + camFollow.y += cursorY * 10; + } + + cursorLocIntended.x = (cursorFactor * cursorX) + (FlxG.width / 2) - cursor.width / 2; + cursorLocIntended.y = (cursorFactor * cursorY) + (FlxG.height / 2) - cursor.height / 2; + + cursorLocIntended.x += cursorOffsetX; + cursorLocIntended.y += cursorOffsetY; + + cursor.x = MathUtil.coolLerp(cursor.x, cursorLocIntended.x, lerpAmnt); + cursor.y = MathUtil.coolLerp(cursor.y, cursorLocIntended.y, lerpAmnt); + + cursorBlue.x = MathUtil.coolLerp(cursorBlue.x, cursor.x, lerpAmnt * 0.4); + cursorBlue.y = MathUtil.coolLerp(cursorBlue.y, cursor.y, lerpAmnt * 0.4); + + cursorDarkBlue.x = MathUtil.coolLerp(cursorDarkBlue.x, cursorLocIntended.x, lerpAmnt * 0.2); + cursorDarkBlue.y = MathUtil.coolLerp(cursorDarkBlue.y, cursorLocIntended.y, lerpAmnt * 0.2); + } + + var bopTimer:Float = 0; + var delay = 1 / 24; + var bopFr = 0; + var bopPlay:Bool = false; + var bopRefX:Float = 0; + var bopRefY:Float = 0; + + function doBop(icon:PixelatedIcon, elapsed:Float):Void + { + if (bopFr >= bopInfo.frames.length) + { + bopRefX = 0; + bopRefY = 0; + bopPlay = false; + bopFr = 0; + return; + } + bopTimer += elapsed; + + if (bopTimer >= delay) + { + bopTimer -= bopTimer; + + var refFrame = bopInfo.frames[bopInfo.frames.length - 1]; + var curFrame = bopInfo.frames[bopFr]; + if (bopFr >= 13) icon.filters = selectedBizz; + + var scaleXDiff:Float = curFrame.scaleX - refFrame.scaleX; + var scaleYDiff:Float = curFrame.scaleY - refFrame.scaleY; + + icon.scale.set(2.6, 2.6); + icon.scale.add(scaleXDiff, scaleYDiff); + + bopFr++; + } + } + + public override function dispatchEvent(event:ScriptEvent):Void + { + // super.dispatchEvent(event) dispatches event to module scripts. + super.dispatchEvent(event); + + // Dispatch events (like onBeatHit) to props + ScriptEventDispatcher.callEvent(playerChill, event); + ScriptEventDispatcher.callEvent(gfChill, event); + } + + function spamOnStep():Void + { + if (spamUp || spamDown || spamLeft || spamRight) + { + // selectSound.changePitchBySemitone(1); + if (selectSound.pitch > 5) selectSound.pitch = 5; + selectSound.play(true); + + cursorDenied.visible = false; + + if (spamUp) + { + cursorY -= 1; + holdTmrUp = 0; + } + if (spamDown) + { + cursorY += 1; + holdTmrDown = 0; + } + if (spamLeft) + { + cursorX -= 1; + holdTmrLeft = 0; + } + if (spamRight) + { + cursorX += 1; + holdTmrRight = 0; + } + } + } + + private function updateLockAnims():Void + { + for (index => member in grpIcons.group.members) + { + switch (member.ID) + { + case 1: + var lock:Lock = cast member; + if (index == getCurrentSelected()) + { + switch (lock.getCurrentAnimation()) + { + case "idle": + lock.playAnimation("selected"); + case "selected" | "clicked": + if (controls.ACCEPT) lock.playAnimation("clicked", true); + } + } + else + { + lock.playAnimation("idle"); + } + case 0: + var memb:PixelatedIcon = cast member; + + if (index == getCurrentSelected()) + { + // memb.pixels = memb.withDropShadow.clone(); + + if (bopPlay) + { + if (bopRefX == 0) + { + bopRefX = memb.x; + bopRefY = memb.y; + } + doBop(memb, FlxG.elapsed); + } + else + { + memb.filters = selectedBizz; + memb.scale.set(2.6, 2.6); + } + if (pressedSelect && memb.animation.curAnim.name == "idle") memb.animation.play("confirm"); + if (autoFollow && !pressedSelect && memb.animation.curAnim.name != "idle") + { + memb.animation.play("confirm", false, true); + member.animation.finishCallback = (_) -> { + member.animation.play("idle"); + member.animation.finishCallback = null; + }; + } + } + else + { + // memb.pixels = memb.noDropShadow.clone(); + memb.filters = null; + memb.scale.set(2, 2); + } + } + } + } + + function getCurrentSelected():Int + { + var tempX:Int = cursorX + 1; + var tempY:Int = cursorY + 1; + var gridPosition:Int = tempX + tempY * 3; + return gridPosition; + } + + function set_curChar(value:String):String + { + if (curChar == value) return value; + + curChar = value; + + if (value == "locked") staticSound.play(); + else + staticSound.stop(); + + nametag.switchChar(value); + playerChill.visible = false; + playerChillOut.visible = true; + playerChillOut.playAnimation("slideout"); + var index = playerChillOut.anim.getFrameLabel("slideout").index; + playerChillOut.onAnimationFrame.add((_, frame:Int) -> { + if (frame == index + 1) + { + playerChill.visible = true; + playerChill.switchChar(value); + gfChill.switchGF(value); + } + if (frame == index + 2) + { + playerChillOut.switchChar(value); + playerChillOut.visible = false; + playerChillOut.onAnimationFrame.removeAll(); + } + }); + + return value; + } + + function set_grpXSpread(value:Float):Float + { + grpXSpread = value; + updateIconPositions(); + return value; + } + + function set_grpYSpread(value:Float):Float + { + grpYSpread = value; + updateIconPositions(); + return value; + } +} diff --git a/source/funkin/ui/charSelect/CharacterUnlockState.hx b/source/funkin/ui/charSelect/CharacterUnlockState.hx new file mode 100644 index 000000000..b32a35145 --- /dev/null +++ b/source/funkin/ui/charSelect/CharacterUnlockState.hx @@ -0,0 +1,128 @@ +package funkin.ui.charSelect; + +import flixel.FlxSprite; +import flixel.FlxState; +import flixel.group.FlxSpriteGroup; +import flixel.text.FlxText; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import flixel.util.FlxColor; +import funkin.play.character.CharacterData; +import funkin.play.character.CharacterData.CharacterDataParser; +import funkin.play.components.HealthIcon; +import funkin.ui.freeplay.charselect.PlayableCharacter; +import funkin.data.freeplay.player.PlayerData; +import funkin.data.freeplay.player.PlayerRegistry; +import funkin.ui.mainmenu.MainMenuState; + +using flixel.util.FlxSpriteUtil; + +/** + * When you want the player to unlock a character, call `CharacterUnlockState.unlock(characterName)`. + * It handles both the act of unlocking the character and displaying the dialog. + */ +class CharacterUnlockState extends MusicBeatState +{ + public var targetCharacterId:String = ""; + public var targetCharacterData:Null; + + var nextState:FlxState; + + static final DIALOG_BG_COLOR:FlxColor = 0xFF000000; // Iconic + static final DIALOG_COLOR:FlxColor = 0xFF4344F6; // Iconic + static final DIALOG_FONT_COLOR:FlxColor = 0xFFFFFFFF; // Iconic + + var busy:Bool = false; + + public function new(targetPlayableCharacter:String, ?nextState:FlxState) + { + super(); + + this.targetCharacterId = targetPlayableCharacter; + this.targetCharacterData = PlayerRegistry.instance.fetchEntry(targetCharacterId); + this.nextState = nextState == null ? new MainMenuState() : nextState; + } + + override function create():Void + { + super.create(); + + handleMusic(); + + bgColor = DIALOG_BG_COLOR; + + var dialogContainer:FlxSpriteGroup = new FlxSpriteGroup(); + add(dialogContainer); + + // Build the graphic for the text... + var charName:String = targetCharacterData != null ? targetCharacterData.getName() : targetCharacterId.toTitleCase(); + // var dialogText:FlxText = new FlxText(0, 0, 0, 'You can now play as $charName.\n\nCheck it out in Freeplay!'); + var dialogText:FlxText = new FlxText(0, 0, 0, 'You can now play as $charName.'); + dialogText.setFormat("VCR OSD Mono", 32, DIALOG_FONT_COLOR, LEFT); + + // THEN we can size the dialog to match... + var dialogBG:FlxSprite = new FlxSprite(0, 0); + dialogBG.makeGraphic(Std.int(dialogText.width + 32), Std.int(dialogText.height + 32), FlxColor.TRANSPARENT); + dialogBG.drawRoundRect(0, 0, dialogBG.width, dialogBG.height, 16, 16, DIALOG_COLOR); + dialogContainer.add(dialogBG); + + dialogBG.screenCenter(XY); + + // THEN we can position the text inside that. + dialogText.x = dialogBG.x + 16; + dialogText.y = dialogBG.y + 16; + dialogContainer.add(dialogText); + + // HealthIcon handles getting the right frames for us, + // but it has a bunch of overhead in it that makes it gross to work with outside the health bar. + var healthIconCharacterId = targetCharacterData.getOwnedCharacterIds()[0]; + var baseCharacter = CharacterDataParser.fetchCharacter(healthIconCharacterId); + var healthIcon:HealthIcon = new HealthIcon(healthIconCharacterId); + @:privateAccess + healthIcon.configure(baseCharacter._data.healthIcon); + healthIcon.autoUpdate = false; + healthIcon.bopEvery = 0; // You can increase this number later once the animation is done. + healthIcon.size.set(0.5, 0.5); + healthIcon.x = dialogBG.x + 390; + healthIcon.y = dialogBG.y + 6; + healthIcon.flipX = true; + healthIcon.snapToTargetSize(); + dialogContainer.add(healthIcon); + + dialogContainer.scale.set(0, 0); + FlxTween.num(0.0, 1.0, 0.75, + { + ease: FlxEase.elasticOut, + }, function(curScale) { + dialogContainer.scale.set(curScale, curScale); + healthIcon.size.set(0.5 * curScale, 0.5 * curScale); + }); + + // performUnlock(); + } + + function handleMusic():Void + { + FlxG.sound.music?.stop(); + FlxG.sound.play(Paths.sound('confirmMenu')); + } + + override function update(elapsed:Float):Void + { + super.update(elapsed); + + if (controls.ACCEPT || controls.BACK && !busy) + { + busy = true; + startClose(); + } + } + + function startClose():Void + { + // Fade to black, then switch state. + FlxG.camera.fade(FlxColor.BLACK, 0.75, false, () -> { + FlxG.switchState(nextState); + }); + } +} diff --git a/source/funkin/ui/charSelect/IntroSubState.hx b/source/funkin/ui/charSelect/IntroSubState.hx new file mode 100644 index 000000000..2c2908473 --- /dev/null +++ b/source/funkin/ui/charSelect/IntroSubState.hx @@ -0,0 +1,133 @@ +package funkin.ui.charSelect; + +#if html5 +import funkin.graphics.video.FlxVideo; +#end +#if hxCodec +import hxcodec.flixel.FlxVideoSprite; +#end +import funkin.ui.MusicBeatSubState; +import funkin.audio.FunkinSound; +import funkin.save.Save; + +/** + * When you first enter the character select state, it will play an introductory video opening up the lights + */ +class IntroSubState extends MusicBeatSubState +{ + static final LIGHTS_VIDEO_PATH:String = Paths.stripLibrary(Paths.videos('introSelect')); + + public override function create():Void + { + if (Save.instance.oldChar) + { + onLightsEnd(); + return; + } + // Pause existing music. + if (FlxG.sound.music != null) + { + FlxG.sound.music.destroy(); + FlxG.sound.music = null; + } + + #if html5 + trace('Playing web video ${LIGHTS_VIDEO_PATH}'); + playVideoHTML5(LIGHTS_VIDEO_PATH); + #end + + #if hxCodec + trace('Playing native video ${LIGHTS_VIDEO_PATH}'); + playVideoNative(LIGHTS_VIDEO_PATH); + #end + + // // Im TOO lazy to even care, so uh, yep + // FlxG.camera.zoom = 0.66666666666666666666666666666667; + // vid.x = -(FlxG.width - (FlxG.width * FlxG.camera.zoom)); + // vid.y = -((FlxG.height - (FlxG.height * FlxG.camera.zoom)) * 0.75); + } + + #if html5 + var vid:FlxVideo; + + function playVideoHTML5(filePath:String):Void + { + // Video displays OVER the FlxState. + vid = new FlxVideo(filePath); + + vid.scrollFactor.set(); + if (vid != null) + { + vid.zIndex = 0; + + vid.finishCallback = onLightsEnd; + + add(vid); + } + else + { + trace('ALERT: Video is null! Could not play cutscene!'); + } + } + #end + + #if hxCodec + var vid:FlxVideoSprite; + + function playVideoNative(filePath:String):Void + { + // Video displays OVER the FlxState. + vid = new FlxVideoSprite(0, 0); + + vid.scrollFactor.set(); + + if (vid != null) + { + vid.zIndex = 0; + vid.bitmap.onEndReached.add(onLightsEnd); + + add(vid); + vid.play(filePath, false); + } + else + { + trace('ALERT: Video is null! Could not play cutscene!'); + } + } + #end + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + // if (!introSound.paused) + // { + // #if html5 + // @:privateAccess + // vid.netStream.seek(introSound.time); + // #elseif hxCodec + // vid.bitmap.time = Std.int(introSound.time); + // #end + // } + } + + /** + * When the lights video finishes, it will close the substate + */ + function onLightsEnd():Void + { + if (vid != null) + { + #if hxCodec + vid.stop(); + #end + remove(vid); + vid.destroy(); + vid = null; + } + + FlxG.camera.zoom = 1; + + close(); + } +} diff --git a/source/funkin/ui/charSelect/Lock.hx b/source/funkin/ui/charSelect/Lock.hx new file mode 100644 index 000000000..982145d5c --- /dev/null +++ b/source/funkin/ui/charSelect/Lock.hx @@ -0,0 +1,34 @@ +package funkin.ui.charSelect; + +import flixel.util.FlxColor; +import flxanimate.effects.FlxTint; +import funkin.graphics.adobeanimate.FlxAtlasSprite; + +class Lock extends FlxAtlasSprite +{ + var colors:Array = [ + 0x31F2A5, 0x20ECCD, 0x24D9E8, + 0x20ECCD, 0x20C8D4, 0x209BDD, + 0x209BDD, 0x2362C9, 0x243FB9 + ]; // lock colors, in a nx3 matrix format + + public function new(x:Float = 0, y:Float = 0, index:Int) + { + super(x, y, Paths.animateAtlas("charSelect/lock")); + + var tint:FlxTint = new FlxTint(colors[index], 1); + + var arr:Array = ["lock", "lock top 1", "lock top 2", "lock top 3", "lock base fuck it"]; + + var func = function(name) { + var symbol = anim.symbolDictionary[name]; + if (symbol != null && symbol.timeline.get("color") != null) symbol.timeline.get("color").get(0).colorEffect = tint; + } + for (symbol in arr) + { + func(symbol); + } + + playAnimation("idle"); + } +} diff --git a/source/funkin/ui/charSelect/Nametag.hx b/source/funkin/ui/charSelect/Nametag.hx new file mode 100644 index 000000000..b6cedb0c7 --- /dev/null +++ b/source/funkin/ui/charSelect/Nametag.hx @@ -0,0 +1,101 @@ +package funkin.ui.charSelect; + +import flixel.FlxSprite; +import funkin.graphics.shaders.MosaicEffect; +import flixel.util.FlxTimer; + +class Nametag extends FlxSprite +{ + var midpointX(default, set):Float = 1008; + var midpointY(default, set):Float = 100; + var mosaicShader:MosaicEffect; + + public function new(?x:Float = 0, ?y:Float = 0) + { + super(x, y); + + mosaicShader = new MosaicEffect(); + shader = mosaicShader; + + switchChar("bf"); + + FlxG.debugger.addTrackerProfile(new TrackerProfile(Nametag, ["midpointX", "midpointY"])); + FlxG.debugger.track(this, "Nametag"); + } + + public function updatePosition():Void + { + var offsetX:Float = getMidpoint().x - midpointX; + var offsetY:Float = getMidpoint().y - midpointY; + + x -= offsetX; + y -= offsetY; + } + + public function switchChar(str:String):Void + { + shaderEffect(); + + new FlxTimer().start(4 / 30, _ -> { + var path:String = str; + switch str + { + case "bf": + path = "boyfriend"; + } + + loadGraphic(Paths.image('charSelect/' + path + "Nametag")); + updateHitbox(); + scale.x = scale.y = 0.77; + + updatePosition(); + shaderEffect(true); + }); + } + + function shaderEffect(fadeOut:Bool = false):Void + { + if (fadeOut) + { + setBlockTimer(0, 1, 1); + setBlockTimer(1, width / 27, height / 26); + setBlockTimer(2, width / 10, height / 10); + + setBlockTimer(3, 1, 1); + } + else + { + setBlockTimer(0, (width / 10), (height / 10)); + setBlockTimer(1, width / 73, height / 6); + setBlockTimer(2, width / 10, height / 10); + } + } + + function setBlockTimer(frame:Int, ?forceX:Float, ?forceY:Float) + { + var daX:Float = 10 * FlxG.random.int(1, 4); + var daY:Float = 10 * FlxG.random.int(1, 4); + + if (forceX != null) daX = forceX; + + if (forceY != null) daY = forceY; + + new FlxTimer().start(frame / 30, _ -> { + mosaicShader.setBlockSize(daX, daY); + }); + } + + function set_midpointX(val:Float):Float + { + this.midpointX = val; + updatePosition(); + return val; + } + + function set_midpointY(val:Float):Float + { + this.midpointY = val; + updatePosition(); + return val; + } +} diff --git a/source/funkin/ui/credits/CreditsState.hx b/source/funkin/ui/credits/CreditsState.hx index 44769e9b3..b37985650 100644 --- a/source/funkin/ui/credits/CreditsState.hx +++ b/source/funkin/ui/credits/CreditsState.hx @@ -34,7 +34,13 @@ class CreditsState extends MusicBeatState * To use a font from the `assets` folder, use `Paths.font(...)`. * Choose something that will render Unicode properly. */ + #if windows static final CREDITS_FONT = 'Consolas'; + #elseif mac + static final CREDITS_FONT = 'Menlo'; + #else + static final CREDITS_FONT = "Courier New"; + #end /** * The size of the font. diff --git a/source/funkin/ui/debug/DebugMenuSubState.hx b/source/funkin/ui/debug/DebugMenuSubState.hx index f8b1be9d2..fc5f3aa37 100644 --- a/source/funkin/ui/debug/DebugMenuSubState.hx +++ b/source/funkin/ui/debug/DebugMenuSubState.hx @@ -54,8 +54,11 @@ class DebugMenuSubState extends MusicBeatSubState // Create each menu item. // Call onMenuChange when the first item is created to move the camera . + #if FEATURE_CHART_EDITOR onMenuChange(createItem("CHART EDITOR", openChartEditor)); + #end // createItem("Input Offset Testing", openInputOffsetTesting); + createItem("CHARACTER SELECT", openCharSelect, true); createItem("ANIMATION EDITOR", openAnimationEditor); // createItem("STAGE EDITOR", openStageEditor); // createItem("TEST STICKERS", testStickers); @@ -102,6 +105,11 @@ class DebugMenuSubState extends MusicBeatSubState trace('Input Offset Testing'); } + function openCharSelect() + { + FlxG.switchState(new funkin.ui.charSelect.CharSelectSubState()); + } + function openAnimationEditor() { FlxG.switchState(() -> new funkin.ui.debug.anim.DebugBoundingState()); diff --git a/source/funkin/ui/debug/MemoryCounter.hx b/source/funkin/ui/debug/MemoryCounter.hx index b25b55645..50421f398 100644 --- a/source/funkin/ui/debug/MemoryCounter.hx +++ b/source/funkin/ui/debug/MemoryCounter.hx @@ -36,7 +36,7 @@ class MemoryCounter extends TextField @:noCompletion #if !flash override #end function __enterFrame(deltaTime:Float):Void { - var mem:Float = Math.round(MemoryUtil.getMemoryUsed() / BYTES_PER_MEG / ROUND_TO) * ROUND_TO; + var mem:Float = Math.fround(MemoryUtil.getMemoryUsed() / BYTES_PER_MEG / ROUND_TO) * ROUND_TO; if (mem > memPeak) memPeak = mem; diff --git a/source/funkin/ui/debug/anim/DebugBoundingState.hx b/source/funkin/ui/debug/anim/DebugBoundingState.hx index 04784a5b7..7bb42c89e 100644 --- a/source/funkin/ui/debug/anim/DebugBoundingState.hx +++ b/source/funkin/ui/debug/anim/DebugBoundingState.hx @@ -1,33 +1,26 @@ package funkin.ui.debug.anim; +import flixel.addons.display.FlxBackdrop; import flixel.addons.display.FlxGridOverlay; -import flixel.addons.ui.FlxInputText; -import flixel.addons.ui.FlxUIDropDownMenu; import flixel.FlxCamera; import flixel.FlxSprite; import flixel.FlxState; -import flixel.graphics.frames.FlxAtlasFrames; import flixel.graphics.frames.FlxFrame; import flixel.group.FlxGroup; import flixel.math.FlxPoint; import flixel.text.FlxText; import flixel.util.FlxColor; -import flixel.util.FlxSpriteUtil; -import flixel.util.FlxTimer; -import funkin.audio.FunkinSound; import funkin.input.Cursor; import funkin.play.character.BaseCharacter; import funkin.play.character.CharacterData; import funkin.play.character.CharacterData.CharacterDataParser; -import funkin.play.character.SparrowCharacter; import funkin.ui.mainmenu.MainMenuState; import funkin.util.MouseUtil; import funkin.util.SerializerUtil; import funkin.util.SortUtil; import haxe.ui.components.DropDown; -import haxe.ui.core.Component; +import haxe.ui.containers.dialogs.CollapsibleDialog; import haxe.ui.core.Screen; -import haxe.ui.events.ItemEvent; import haxe.ui.events.UIEvent; import haxe.ui.RuntimeComponentBuilder; import lime.utils.Assets as LimeAssets; @@ -36,9 +29,6 @@ import openfl.events.Event; import openfl.events.IOErrorEvent; import openfl.geom.Rectangle; import openfl.net.FileReference; -import openfl.net.URLLoader; -import openfl.net.URLRequest; -import openfl.utils.ByteArray; using flixel.util.FlxSpriteUtil; @@ -55,10 +45,10 @@ class DebugBoundingState extends FlxState TODAY'S TO-DO - Cleaner UI */ - var bg:FlxSprite; + var bg:FlxBackdrop; var fileInfo:FlxText; - var txtGrp:FlxGroup; + var txtGrp:FlxTypedGroup; var hudCam:FlxCamera; @@ -66,16 +56,23 @@ class DebugBoundingState extends FlxState var spriteSheetView:FlxGroup; var offsetView:FlxGroup; - var animDropDownMenu:FlxUIDropDownMenu; var dropDownSetup:Bool = false; var onionSkinChar:FlxSprite; var txtOffsetShit:FlxText; - var uiStuff:Component; + var offsetEditorDialog:CollapsibleDialog; + var offsetAnimationDropdown:DropDown; var haxeUIFocused(get, default):Bool = false; + var currentAnimationName(get, never):String; + + function get_currentAnimationName():String + { + return offsetAnimationDropdown?.value?.id ?? "idle"; + } + function get_haxeUIFocused():Bool { // get the screen position, according to the HUD camera, temp default to FlxG.camera juuust in case? @@ -87,46 +84,35 @@ class DebugBoundingState extends FlxState { Paths.setCurrentLevel('week1'); - // lv. - // lv.onChange = function(e:UIEvent) - // { - // trace(e.type); - // // trace(e.data.curView); - // // var item:haxe.ui.core.ItemRenderer = cast e.target; - // trace(e.target); - // // if (e.type == "change") - // // { - // // curView = cast e.data; - // // } - // }; - hudCam = new FlxCamera(); hudCam.bgColor.alpha = 0; - bg = FlxGridOverlay.create(10, 10); - // bg = new FlxSprite().makeGraphic(FlxG.width, FlxG.height, FlxColor.GREEN); - - bg.scrollFactor.set(); + bg = new FlxBackdrop(FlxGridOverlay.createGrid(10, 10, FlxG.width, FlxG.height, true, 0xffe7e6e6, 0xffd9d5d5)); add(bg); // we are setting this as the default draw camera only temporarily, to trick haxeui FlxG.cameras.add(hudCam); var str = Paths.xml('ui/animation-editor/offset-editor-view'); - uiStuff = RuntimeComponentBuilder.fromAsset(str); + offsetEditorDialog = cast RuntimeComponentBuilder.fromAsset(str); - // uiStuff.findComponent("btnViewSpriteSheet").onClick = _ -> curView = SPRITESHEET; - var dropdown:DropDown = cast uiStuff.findComponent("swapper"); - dropdown.onChange = function(e:UIEvent) { + // offsetEditorDialog.findComponent("btnViewSpriteSheet").onClick = _ -> curView = SPRITESHEET; + var viewDropdown:DropDown = offsetEditorDialog.findComponent("swapper", DropDown); + viewDropdown.onChange = function(e:UIEvent) { trace(e.type); curView = cast e.data.curView; trace(e.data); // trace(e.data); }; - uiStuff.cameras = [hudCam]; + offsetAnimationDropdown = offsetEditorDialog.findComponent("animationDropdown", DropDown); - add(uiStuff); + offsetEditorDialog.cameras = [hudCam]; + + add(offsetEditorDialog); + + // Anchor to the right side by default + // offsetEditorDialog.x = FlxG.width - offsetEditorDialog.width; // sets the default camera back to FlxG.camera, since we set it to hudCamera for haxeui stuf FlxG.cameras.setDefaultDrawTarget(FlxG.camera, true); @@ -159,7 +145,7 @@ class DebugBoundingState extends FlxState generateOutlines(tex.frames); - txtGrp = new FlxGroup(); + txtGrp = new FlxTypedGroup(); txtGrp.cameras = [hudCam]; spriteSheetView.add(txtGrp); @@ -168,64 +154,6 @@ class DebugBoundingState extends FlxState addInfo('Height', bf.height); spriteSheetView.add(swagOutlines); - - FlxG.stage.window.onDropFile.add(function(path:String) { - // WACKY ASS TESTING SHIT FOR WEB FILE LOADING?? - #if web - var swagList:FileList = cast path; - - var objShit = js.html.URL.createObjectURL(swagList.item(0)); - trace(objShit); - - var funnysound = new FunkinSound().loadStream('https://cdn.discordapp.com/attachments/767500676166451231/817821618251759666/Flutter.mp3', false, false, - null, function() { - trace('LOADED SHIT??'); - }); - - funnysound.volume = 1; - funnysound.play(); - - var urlShit = new URLLoader(new URLRequest(objShit)); - - new FlxTimer().start(3, function(tmr:FlxTimer) { - // music lol! - if (urlShit.dataFormat == BINARY) - { - // var daSwagBytes:ByteArray = urlShit.data; - - // FlxG.sound.playMusic(); - - // trace('is binary!!'); - } - trace(urlShit.dataFormat); - }); - - // remove(bf); - // FlxG.bitmap.removeByKey(Paths.image('characters/temp')); - // Assets.cache.clear(); - - // bf.loadGraphic(objShit); - // add(bf); - - // trace(swagList.item(0).name); - // var urlShit = js.html.URL.createObjectURL(path); - #end - - #if sys - trace("DROPPED FILE FROM: " + Std.string(path)); - var newPath = "./" + Paths.image('characters/temp'); - File.copy(path, newPath); - - var swag = Paths.image('characters/temp'); - - if (bf != null) remove(bf); - FlxG.bitmap.removeByKey(Paths.image('characters/temp')); - Assets.cache.clear(); - - bf.loadGraphic(Paths.image('characters/temp')); - add(bf); - #end - }); } function generateOutlines(frameShit:Array):Void @@ -260,15 +188,9 @@ class DebugBoundingState extends FlxState txtOffsetShit = new FlxText(20, 20, 0, "", 20); txtOffsetShit.setFormat(Paths.font("vcr.ttf"), 26, FlxColor.WHITE, LEFT, FlxTextBorderStyle.OUTLINE, FlxColor.BLACK); txtOffsetShit.cameras = [hudCam]; + txtOffsetShit.y = FlxG.height - 20 - txtOffsetShit.height; offsetView.add(txtOffsetShit); - animDropDownMenu = new FlxUIDropDownMenu(0, 0, FlxUIDropDownMenu.makeStrIdLabelArray(['weed'], true)); - animDropDownMenu.cameras = [hudCam]; - // Move to bottom right corner - animDropDownMenu.x = FlxG.width - animDropDownMenu.width - 20; - animDropDownMenu.y = FlxG.height - animDropDownMenu.height - 20; - offsetView.add(animDropDownMenu); - var characters:Array = CharacterDataParser.listCharacterIds(); characters = characters.filter(function(charId:String) { var char = CharacterDataParser.fetchCharacterData(charId); @@ -276,7 +198,7 @@ class DebugBoundingState extends FlxState }); characters.sort(SortUtil.alphabetically); - var charDropdown:DropDown = cast uiStuff.findComponent('characterDropdown'); + var charDropdown:DropDown = offsetEditorDialog.findComponent('characterDropdown', DropDown); for (char in characters) { charDropdown.dataSource.add({text: char}); @@ -289,32 +211,47 @@ class DebugBoundingState extends FlxState public var mouseOffset:FlxPoint = FlxPoint.get(0, 0); public var oldPos:FlxPoint = FlxPoint.get(0, 0); + public var movingCharacter:Bool = false; function mouseOffsetMovement() { if (swagChar != null) { - if (FlxG.mouse.justPressed) + if (FlxG.mouse.justPressed && !haxeUIFocused) { + movingCharacter = true; mouseOffset.set(FlxG.mouse.x - -swagChar.animOffsets[0], FlxG.mouse.y - -swagChar.animOffsets[1]); } + if (!movingCharacter) return; + if (FlxG.mouse.pressed) { swagChar.animOffsets = [(FlxG.mouse.x - mouseOffset.x) * -1, (FlxG.mouse.y - mouseOffset.y) * -1]; - swagChar.animationOffsets.set(animDropDownMenu.selectedLabel, swagChar.animOffsets); + swagChar.animationOffsets.set(offsetAnimationDropdown.value.id, swagChar.animOffsets); txtOffsetShit.text = 'Offset: ' + swagChar.animOffsets; + txtOffsetShit.y = FlxG.height - 20 - txtOffsetShit.height; + } + + if (FlxG.mouse.justReleased) + { + movingCharacter = false; } } } function addInfo(str:String, value:Dynamic) { - var swagText:FlxText = new FlxText(10, 10 + (28 * txtGrp.length)); + var swagText:FlxText = new FlxText(10, FlxG.height - 32); swagText.setFormat(Paths.font("vcr.ttf"), 26, FlxColor.WHITE, LEFT, FlxTextBorderStyle.OUTLINE, FlxColor.BLACK); swagText.scrollFactor.set(); + + for (text in txtGrp.members) + { + text.y -= swagText.height; + } txtGrp.add(swagText); swagText.text = str + ": " + Std.string(value); @@ -345,14 +282,14 @@ class DebugBoundingState extends FlxState { if (FlxG.keys.justPressed.ONE) { - var lv:DropDown = cast uiStuff.findComponent("swapper"); + var lv:DropDown = offsetEditorDialog.findComponent("swapper", DropDown); lv.selectedIndex = 0; curView = SPRITESHEET; } if (FlxG.keys.justReleased.TWO) { - var lv:DropDown = cast uiStuff.findComponent("swapper"); + var lv:DropDown = offsetEditorDialog.findComponent("swapper", DropDown); lv.selectedIndex = 1; curView = ANIMATIONS; if (swagChar != null) @@ -368,12 +305,14 @@ class DebugBoundingState extends FlxState spriteSheetView.visible = true; offsetView.visible = false; offsetView.active = false; + offsetAnimationDropdown.visible = false; case ANIMATIONS: spriteSheetView.visible = false; offsetView.visible = true; offsetView.active = true; + offsetAnimationDropdown.visible = true; offsetControls(); - if (!haxeUIFocused) mouseOffsetMovement(); + mouseOffsetMovement(); } if (FlxG.keys.justPressed.H) hudCam.visible = !hudCam.visible; @@ -395,24 +334,36 @@ class DebugBoundingState extends FlxState { if (FlxG.keys.justPressed.RBRACKET || FlxG.keys.justPressed.E) { - if (Std.parseInt(animDropDownMenu.selectedId) + 1 <= animDropDownMenu.length) - animDropDownMenu.selectedId = Std.string(Std.parseInt(animDropDownMenu.selectedId) - + 1); + if (offsetAnimationDropdown.selectedIndex + 1 <= offsetAnimationDropdown.dataSource.size) + { + offsetAnimationDropdown.selectedIndex += 1; + } else - animDropDownMenu.selectedId = Std.string(0); - playCharacterAnimation(animDropDownMenu.selectedId, true); + { + offsetAnimationDropdown.selectedIndex = 0; + } + trace(offsetAnimationDropdown.selectedIndex); + trace(offsetAnimationDropdown.dataSource.size); + trace(offsetAnimationDropdown.value); + trace(currentAnimationName); + playCharacterAnimation(currentAnimationName, true); } if (FlxG.keys.justPressed.LBRACKET || FlxG.keys.justPressed.Q) { - if (Std.parseInt(animDropDownMenu.selectedId) - 1 >= 0) animDropDownMenu.selectedId = Std.string(Std.parseInt(animDropDownMenu.selectedId) - 1); + if (offsetAnimationDropdown.selectedIndex - 1 >= 0) + { + offsetAnimationDropdown.selectedIndex -= 1; + } else - animDropDownMenu.selectedId = Std.string(animDropDownMenu.length - 1); - playCharacterAnimation(animDropDownMenu.selectedId, true); + { + offsetAnimationDropdown.selectedIndex = offsetAnimationDropdown.dataSource.size - 1; + } + playCharacterAnimation(currentAnimationName, true); } // Keyboards controls for general WASD "movement" - // modifies the animDropDownMenu so that it's properly updated and shit - // and then it's just played and updated from the animDropDownMenu callback, which is set in the loadAnimShit() function probabbly + // modifies the animDrooffsetAnimationDropdownpDownMenu so that it's properly updated and shit + // and then it's just played and updated from the offsetAnimationDropdown callback, which is set in the loadAnimShit() function probabbly if (FlxG.keys.justPressed.W || FlxG.keys.justPressed.S || FlxG.keys.justPressed.D || FlxG.keys.justPressed.A) { var suffix:String = ''; @@ -425,18 +376,19 @@ class DebugBoundingState extends FlxState if (FlxG.keys.justPressed.A) targetLabel = 'singLEFT$suffix'; if (FlxG.keys.justPressed.D) targetLabel = 'singRIGHT$suffix'; - if (targetLabel != animDropDownMenu.selectedLabel) + if (targetLabel != currentAnimationName) { + offsetAnimationDropdown.value = {id: targetLabel, text: targetLabel}; + // Play the new animation if the IDs are the different. // Override the onion skin. - animDropDownMenu.selectedLabel = targetLabel; - playCharacterAnimation(animDropDownMenu.selectedId, true); + playCharacterAnimation(currentAnimationName, true); } else { // Replay the current animation if the IDs are the same. // Don't override the onion skin. - playCharacterAnimation(animDropDownMenu.selectedId, false); + playCharacterAnimation(currentAnimationName, false); } } @@ -448,16 +400,20 @@ class DebugBoundingState extends FlxState // Plays the idle animation if (FlxG.keys.justPressed.SPACE) { - animDropDownMenu.selectedLabel = 'idle'; - playCharacterAnimation(animDropDownMenu.selectedId, true); + offsetAnimationDropdown.value = {id: 'idle', text: 'idle'}; + + playCharacterAnimation(currentAnimationName, true); } // Playback the animation - if (FlxG.keys.justPressed.ENTER) playCharacterAnimation(animDropDownMenu.selectedId, false); + if (FlxG.keys.justPressed.ENTER) + { + playCharacterAnimation(currentAnimationName, false); + } if (FlxG.keys.justPressed.RIGHT || FlxG.keys.justPressed.LEFT || FlxG.keys.justPressed.UP || FlxG.keys.justPressed.DOWN) { - var animName = animDropDownMenu.selectedLabel; + var animName = currentAnimationName; var coolValues:Array = swagChar.animationOffsets.get(animName).copy(); var multiplier:Int = 5; @@ -471,10 +427,11 @@ class DebugBoundingState extends FlxState else if (FlxG.keys.justPressed.UP) coolValues[1] += 1 * multiplier; else if (FlxG.keys.justPressed.DOWN) coolValues[1] -= 1 * multiplier; - swagChar.animationOffsets.set(animDropDownMenu.selectedLabel, coolValues); + swagChar.animationOffsets.set(currentAnimationName, coolValues); swagChar.playAnimation(animName); txtOffsetShit.text = 'Offset: ' + coolValues; + txtOffsetShit.y = FlxG.height - 20 - txtOffsetShit.height; trace(animName); } @@ -529,7 +486,7 @@ class DebugBoundingState extends FlxState swagChar = CharacterDataParser.fetchCharacter(char); swagChar.x = 100; swagChar.y = 100; - // swagChar.debugMode = true; + swagChar.debug = true; offsetView.add(swagChar); if (swagChar == null || swagChar.frames == null) @@ -554,11 +511,25 @@ class DebugBoundingState extends FlxState trace(swagChar.animationOffsets[i]); } - animDropDownMenu.setData(FlxUIDropDownMenu.makeStrIdLabelArray(characterAnimNames, true)); - animDropDownMenu.callback = function(str:String) { - playCharacterAnimation(str, true); - }; + offsetAnimationDropdown.dataSource.clear(); + + for (charAnim in characterAnimNames) + { + trace('Adding ${charAnim} to HaxeUI dropdown'); + offsetAnimationDropdown.dataSource.add({id: charAnim, text: charAnim}); + } + + offsetAnimationDropdown.selectedIndex = 0; + + trace('Added ${offsetAnimationDropdown.dataSource.size} to HaxeUI dropdown'); + + offsetAnimationDropdown.onChange = function(event:UIEvent) { + trace('Selected animation ${event?.data?.id}'); + playCharacterAnimation(event.data.id, true); + } + txtOffsetShit.text = 'Offset: ' + swagChar.animOffsets; + txtOffsetShit.y = FlxG.height - 20 - txtOffsetShit.height; dropDownSetup = true; } @@ -575,11 +546,13 @@ class DebugBoundingState extends FlxState onionSkinChar.alpha = 0.6; } - var animName = characterAnimNames[Std.parseInt(str)]; + // var animName = characterAnimNames[Std.parseInt(str)]; + var animName = str; swagChar.playAnimation(animName, true); // trace(); trace(swagChar.animationOffsets.get(animName)); txtOffsetShit.text = 'Offset: ' + swagChar.animOffsets; + txtOffsetShit.y = FlxG.height - 20 - txtOffsetShit.height; } var _file:FileReference; diff --git a/source/funkin/ui/debug/anim/FlxAnimateTest.hx b/source/funkin/ui/debug/anim/FlxAnimateTest.hx index c83d2c370..44917e2a5 100644 --- a/source/funkin/ui/debug/anim/FlxAnimateTest.hx +++ b/source/funkin/ui/debug/anim/FlxAnimateTest.hx @@ -22,28 +22,18 @@ class FlxAnimateTest extends MusicBeatState { super.create(); - sprite = new FlxAtlasSprite(0, 0, 'shared:assets/shared/images/characters/tankman'); + sprite = new FlxAtlasSprite(0, 0, 'assets/images/charSelect/maskTest'); add(sprite); - - sprite.playAnimation('idle'); + sprite.playAnimation(null, false, false, true); } public override function update(elapsed:Float):Void { super.update(elapsed); - if (FlxG.keys.justPressed.SPACE) sprite.playAnimation('idle'); + if (FlxG.keys.justPressed.SPACE) ((sprite.anim.isPlaying) ? sprite.anim.pause() : sprite.playAnimation(null, false, false, true)); - if (FlxG.keys.justPressed.W) sprite.playAnimation('singUP'); - - if (FlxG.keys.justPressed.A) sprite.playAnimation('singLEFT'); - - if (FlxG.keys.justPressed.S) sprite.playAnimation('singDOWN'); - - if (FlxG.keys.justPressed.D) sprite.playAnimation('singRIGHT'); - - if (FlxG.keys.justPressed.J) sprite.playAnimation('hehPrettyGood'); - - if (FlxG.keys.justPressed.K) sprite.playAnimation('ugh'); + if (FlxG.keys.anyJustPressed([A, LEFT])) sprite.anim.curFrame--; + if (FlxG.keys.anyJustPressed([D, RIGHT])) sprite.anim.curFrame++; } } diff --git a/source/funkin/ui/debug/charting/ChartEditorState.hx b/source/funkin/ui/debug/charting/ChartEditorState.hx index f72cca77f..811e08e5d 100644 --- a/source/funkin/ui/debug/charting/ChartEditorState.hx +++ b/source/funkin/ui/debug/charting/ChartEditorState.hx @@ -35,6 +35,7 @@ import funkin.data.song.SongData.SongEventData; import funkin.data.song.SongData.SongMetadata; import funkin.data.song.SongData.SongNoteData; import funkin.data.song.SongData.SongOffsets; +import funkin.data.song.SongData.NoteParamData; import funkin.data.song.SongDataUtils; import funkin.data.song.SongRegistry; import funkin.data.stage.StageData; @@ -45,6 +46,7 @@ import funkin.input.TurboActionHandler; import funkin.input.TurboButtonHandler; import funkin.input.TurboKeyHandler; import funkin.modding.events.ScriptEvent; +import funkin.play.notes.notekind.NoteKindManager; import funkin.play.character.BaseCharacter.CharacterType; import funkin.play.character.CharacterData; import funkin.play.character.CharacterData.CharacterDataParser; @@ -282,6 +284,21 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState */ public static final WELCOME_MUSIC_FADE_IN_DURATION:Float = 10.0; + /** + * A map of the keys for every live input style. + */ + public static final LIVE_INPUT_KEYS:Map> = [ + NumberKeys => [ + FIVE, SIX, SEVEN, EIGHT, + ONE, TWO, THREE, FOUR + ], + WASDKeys => [ + LEFT, DOWN, UP, RIGHT, + A, S, W, D + ], + None => [] + ]; + /** * INSTANCE DATA */ @@ -538,6 +555,11 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState */ var noteKindToPlace:Null = null; + /** + * The note params to use for notes being placed in the chart. Defaults to `[]`. + */ + var noteParamsToPlace:Array = []; + /** * The event type to use for events being placed in the chart. Defaults to `''`. */ @@ -1401,7 +1423,9 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState function get_currentSongNoteStyle():String { - if (currentSongMetadata.playData.noteStyle == null) + if (currentSongMetadata.playData.noteStyle == null + || currentSongMetadata.playData.noteStyle == '' + || currentSongMetadata.playData.noteStyle == 'item') { // Initialize to the default value if not set. currentSongMetadata.playData.noteStyle = Constants.DEFAULT_NOTE_STYLE; @@ -2436,7 +2460,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState gridGhostNote = new ChartEditorNoteSprite(this); gridGhostNote.alpha = 0.6; - gridGhostNote.noteData = new SongNoteData(0, 0, 0, ""); + gridGhostNote.noteData = new SongNoteData(0, 0, 0, "", []); gridGhostNote.visible = false; add(gridGhostNote); gridGhostNote.zIndex = 11; @@ -3303,7 +3327,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState handleTestKeybinds(); handleHelpKeybinds(); - #if (debug || FORCE_DEBUG_VERSION) + #if FEATURE_DEBUG_FUNCTIONS handleQuickWatch(); #end @@ -3584,6 +3608,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // The note sprite handles animation playback and positioning. noteSprite.noteData = noteData; + noteSprite.noteStyle = NoteKindManager.getNoteStyleId(noteData.kind, currentSongNoteStyle) ?? currentSongNoteStyle; noteSprite.overrideStepTime = null; noteSprite.overrideData = null; @@ -3607,6 +3632,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState holdNoteSprite.setHeightDirectly(noteLengthPixels); + holdNoteSprite.noteStyle = NoteKindManager.getNoteStyleId(noteSprite.noteData.kind, currentSongNoteStyle) ?? currentSongNoteStyle; + holdNoteSprite.updateHoldNotePosition(renderedHoldNotes); trace(holdNoteSprite.x + ', ' + holdNoteSprite.y + ', ' + holdNoteSprite.width + ', ' + holdNoteSprite.height); @@ -3669,9 +3696,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState holdNoteSprite.noteData = noteData; holdNoteSprite.noteDirection = noteData.getDirection(); - holdNoteSprite.setHeightDirectly(noteLengthPixels); + holdNoteSprite.noteStyle = NoteKindManager.getNoteStyleId(noteData.kind, currentSongNoteStyle) ?? currentSongNoteStyle; + holdNoteSprite.updateHoldNotePosition(renderedHoldNotes); displayedHoldNoteData.push(noteData); @@ -4569,7 +4597,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState gridGhostHoldNote.noteData = currentPlaceNoteData; gridGhostHoldNote.noteDirection = currentPlaceNoteData.getDirection(); gridGhostHoldNote.setHeightDirectly(dragLengthPixels, true); - + gridGhostHoldNote.noteStyle = NoteKindManager.getNoteStyleId(currentPlaceNoteData.kind, currentSongNoteStyle) ?? currentSongNoteStyle; gridGhostHoldNote.updateHoldNotePosition(renderedHoldNotes); } else @@ -4726,7 +4754,8 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState else { // Create a note and place it in the chart. - var newNoteData:SongNoteData = new SongNoteData(cursorSnappedMs, cursorColumn, 0, noteKindToPlace); + var newNoteData:SongNoteData = new SongNoteData(cursorSnappedMs, cursorColumn, 0, noteKindToPlace, + ChartEditorState.cloneNoteParams(noteParamsToPlace)); performCommand(new AddNotesCommand([newNoteData], FlxG.keys.pressed.CONTROL)); @@ -4885,12 +4914,15 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState if (gridGhostNote == null) throw "ERROR: Tried to handle cursor, but gridGhostNote is null! Check ChartEditorState.buildGrid()"; - var noteData:SongNoteData = gridGhostNote.noteData != null ? gridGhostNote.noteData : new SongNoteData(cursorMs, cursorColumn, 0, noteKindToPlace); + var noteData:SongNoteData = gridGhostNote.noteData != null ? gridGhostNote.noteData : new SongNoteData(cursorMs, cursorColumn, 0, noteKindToPlace, + ChartEditorState.cloneNoteParams(noteParamsToPlace)); - if (cursorColumn != noteData.data || noteKindToPlace != noteData.kind) + if (cursorColumn != noteData.data || noteKindToPlace != noteData.kind || noteParamsToPlace != noteData.params) { noteData.kind = noteKindToPlace; + noteData.params = noteParamsToPlace; noteData.data = cursorColumn; + gridGhostNote.noteStyle = NoteKindManager.getNoteStyleId(noteData.kind, currentSongNoteStyle) ?? currentSongNoteStyle; gridGhostNote.playNoteAnimation(); } noteData.time = cursorSnappedMs; @@ -5129,46 +5161,10 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState function handlePlayhead():Void { // Place notes at the playhead with the keyboard. - switch (currentLiveInputStyle) + for (note => key in LIVE_INPUT_KEYS[currentLiveInputStyle]) { - case ChartEditorLiveInputStyle.WASDKeys: - if (FlxG.keys.justPressed.A) placeNoteAtPlayhead(4); - if (FlxG.keys.justReleased.A) finishPlaceNoteAtPlayhead(4); - if (FlxG.keys.justPressed.S) placeNoteAtPlayhead(5); - if (FlxG.keys.justReleased.S) finishPlaceNoteAtPlayhead(5); - if (FlxG.keys.justPressed.W) placeNoteAtPlayhead(6); - if (FlxG.keys.justReleased.W) finishPlaceNoteAtPlayhead(6); - if (FlxG.keys.justPressed.D) placeNoteAtPlayhead(7); - if (FlxG.keys.justReleased.D) finishPlaceNoteAtPlayhead(7); - - if (FlxG.keys.justPressed.LEFT) placeNoteAtPlayhead(0); - if (FlxG.keys.justReleased.LEFT) finishPlaceNoteAtPlayhead(0); - if (FlxG.keys.justPressed.DOWN) placeNoteAtPlayhead(1); - if (FlxG.keys.justReleased.DOWN) finishPlaceNoteAtPlayhead(1); - if (FlxG.keys.justPressed.UP) placeNoteAtPlayhead(2); - if (FlxG.keys.justReleased.UP) finishPlaceNoteAtPlayhead(2); - if (FlxG.keys.justPressed.RIGHT) placeNoteAtPlayhead(3); - if (FlxG.keys.justReleased.RIGHT) finishPlaceNoteAtPlayhead(3); - case ChartEditorLiveInputStyle.NumberKeys: - // Flipped because Dad is on the left but represents data 0-3. - if (FlxG.keys.justPressed.ONE) placeNoteAtPlayhead(4); - if (FlxG.keys.justReleased.ONE) finishPlaceNoteAtPlayhead(4); - if (FlxG.keys.justPressed.TWO) placeNoteAtPlayhead(5); - if (FlxG.keys.justReleased.TWO) finishPlaceNoteAtPlayhead(5); - if (FlxG.keys.justPressed.THREE) placeNoteAtPlayhead(6); - if (FlxG.keys.justReleased.THREE) finishPlaceNoteAtPlayhead(6); - if (FlxG.keys.justPressed.FOUR) placeNoteAtPlayhead(7); - if (FlxG.keys.justReleased.FOUR) finishPlaceNoteAtPlayhead(7); - - if (FlxG.keys.justPressed.FIVE) placeNoteAtPlayhead(0); - if (FlxG.keys.justReleased.FIVE) finishPlaceNoteAtPlayhead(0); - if (FlxG.keys.justPressed.SIX) placeNoteAtPlayhead(1); - if (FlxG.keys.justPressed.SEVEN) placeNoteAtPlayhead(2); - if (FlxG.keys.justReleased.SEVEN) finishPlaceNoteAtPlayhead(2); - if (FlxG.keys.justPressed.EIGHT) placeNoteAtPlayhead(3); - if (FlxG.keys.justReleased.EIGHT) finishPlaceNoteAtPlayhead(3); - case ChartEditorLiveInputStyle.None: - // Do nothing. + if (FlxG.keys.checkStatus(key, JUST_PRESSED)) placeNoteAtPlayhead(note) + else if (FlxG.keys.checkStatus(key, JUST_RELEASED)) finishPlaceNoteAtPlayhead(note); } // Place events at playhead. @@ -5196,7 +5192,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState if (notesAtPos.length == 0 && !removeNoteInstead) { trace('Placing note. ${column}'); - var newNoteData:SongNoteData = new SongNoteData(playheadPosSnappedMs, column, 0, noteKindToPlace); + var newNoteData:SongNoteData = new SongNoteData(playheadPosSnappedMs, column, 0, noteKindToPlace, ChartEditorState.cloneNoteParams(noteParamsToPlace)); performCommand(new AddNotesCommand([newNoteData], FlxG.keys.pressed.CONTROL)); currentLiveInputPlaceNoteData[column] = newNoteData; } @@ -5282,6 +5278,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState ghostHold.visible = true; ghostHold.alpha = 0.6; ghostHold.setHeightDirectly(0); + ghostHold.noteStyle = NoteKindManager.getNoteStyleId(ghostHold.noteData.kind, currentSongNoteStyle) ?? currentSongNoteStyle; ghostHold.updateHoldNotePosition(renderedHoldNotes); } @@ -5648,6 +5645,7 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState FlxG.watch.addQuick('musicTime', audioInstTrack?.time ?? 0.0); FlxG.watch.addQuick('noteKindToPlace', noteKindToPlace); + FlxG.watch.addQuick('noteParamsToPlace', noteParamsToPlace); FlxG.watch.addQuick('eventKindToPlace', eventKindToPlace); FlxG.watch.addQuick('scrollPosInPixels', scrollPositionInPixels); @@ -5701,21 +5699,21 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState // TODO: Rework asset system so we can remove this jank. switch (currentSongStage) { - case 'mainStage': + case 'mainStage' | 'mainStageErect': PlayStatePlaylist.campaignId = 'week1'; - case 'spookyMansion': + case 'spookyMansion' | 'spookyMansionErect': PlayStatePlaylist.campaignId = 'week2'; - case 'phillyTrain': + case 'phillyTrain' | 'phillyTrainErect': PlayStatePlaylist.campaignId = 'week3'; - case 'limoRide': + case 'limoRide' | 'limoRideErect': PlayStatePlaylist.campaignId = 'week4'; - case 'mallXmas' | 'mallEvil': + case 'mallXmas' | 'mallXmasErect' | 'mallEvil': PlayStatePlaylist.campaignId = 'week5'; case 'school' | 'schoolEvil': PlayStatePlaylist.campaignId = 'week6'; case 'tankmanBattlefield': PlayStatePlaylist.campaignId = 'week7'; - case 'phillyStreets' | 'phillyBlazin' | 'phillyBlazin2': + case 'phillyStreets' | 'phillyStreetsErect' | 'phillyBlazin' | 'phillyBlazin2': PlayStatePlaylist.campaignId = 'weekend1'; } Paths.setCurrentLevel(PlayStatePlaylist.campaignId); @@ -6511,6 +6509,16 @@ class ChartEditorState extends UIState // UIState derives from MusicBeatState } return input; } + + public static function cloneNoteParams(paramsToClone:Array):Array + { + var params:Array = []; + for (param in paramsToClone) + { + params.push(param.clone()); + } + return params; + } } /** diff --git a/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx b/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx index ded48abe3..ff8446c49 100644 --- a/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx +++ b/source/funkin/ui/debug/charting/components/ChartEditorHoldNoteSprite.hx @@ -2,6 +2,7 @@ package funkin.ui.debug.charting.components; import funkin.play.notes.Strumline; import funkin.data.notestyle.NoteStyleRegistry; +import funkin.play.notes.notestyle.NoteStyle; import flixel.FlxObject; import flixel.FlxSprite; import flixel.graphics.frames.FlxFramesCollection; @@ -15,6 +16,7 @@ import flixel.math.FlxMath; * A sprite that can be used to display the trail of a hold note in a chart. * Designed to be used and reused efficiently. Has no gameplay functionality. */ +@:access(funkin.ui.debug.charting.ChartEditorState) @:nullSafety class ChartEditorHoldNoteSprite extends SustainTrail { @@ -23,6 +25,22 @@ class ChartEditorHoldNoteSprite extends SustainTrail */ public var parentState:ChartEditorState; + @:isVar + public var noteStyle(get, set):Null; + + function get_noteStyle():Null + { + return this.noteStyle ?? this.parentState.currentSongNoteStyle; + } + + @:nullSafety(Off) + function set_noteStyle(value:Null):Null + { + this.noteStyle = value; + this.updateHoldNoteGraphic(); + return value; + } + public function new(parent:ChartEditorState) { var noteStyle = NoteStyleRegistry.instance.fetchDefault(); @@ -30,14 +48,52 @@ class ChartEditorHoldNoteSprite extends SustainTrail super(0, 100, noteStyle); this.parentState = parent; + } + + @:nullSafety(Off) + function updateHoldNoteGraphic():Void + { + var bruhStyle:Null = NoteStyleRegistry.instance.fetchEntry(noteStyle); + if (bruhStyle == null) bruhStyle = NoteStyleRegistry.instance.fetchDefault(); + setupHoldNoteGraphic(bruhStyle); + } + + override function setupHoldNoteGraphic(noteStyle:NoteStyle):Void + { + var graphicPath = noteStyle.getHoldNoteAssetPath(); + if (graphicPath == null) return; + loadGraphic(graphicPath); + + antialiasing = true; + + this.isPixel = noteStyle.isHoldNotePixel(); + if (isPixel) + { + endOffset = bottomClip = 1; + antialiasing = false; + } + else + { + endOffset = 0.5; + bottomClip = 0.9; + } zoom = 1.0; zoom *= noteStyle.fetchHoldNoteScale(); zoom *= 0.7; zoom *= ChartEditorState.GRID_SIZE / Strumline.STRUMLINE_SIZE; + graphicWidth = graphic.width / 8 * zoom; // amount of notes * 2 + graphicHeight = sustainLength * 0.45; // sustainHeight + flipY = false; + alpha = 1.0; + + updateColorTransform(); + + updateClipping(); + setup(); } diff --git a/source/funkin/ui/debug/charting/components/ChartEditorNoteSprite.hx b/source/funkin/ui/debug/charting/components/ChartEditorNoteSprite.hx index 98f5a47aa..c8f40da62 100644 --- a/source/funkin/ui/debug/charting/components/ChartEditorNoteSprite.hx +++ b/source/funkin/ui/debug/charting/components/ChartEditorNoteSprite.hx @@ -7,7 +7,11 @@ import flixel.graphics.frames.FlxAtlasFrames; import flixel.graphics.frames.FlxFrame; import flixel.graphics.frames.FlxTileFrames; import flixel.math.FlxPoint; +import funkin.data.animation.AnimationData; import funkin.data.song.SongData.SongNoteData; +import funkin.data.notestyle.NoteStyleRegistry; +import funkin.play.notes.notestyle.NoteStyle; +import funkin.play.notes.NoteDirection; /** * A sprite that can be used to display a note in a chart. @@ -36,7 +40,8 @@ class ChartEditorNoteSprite extends FlxSprite /** * The name of the note style currently in use. */ - public var noteStyle(get, never):String; + @:isVar + public var noteStyle(get, set):Null; public var overrideStepTime(default, set):Null = null; @@ -66,72 +71,80 @@ class ChartEditorNoteSprite extends FlxSprite this.parentState = parent; + var entries:Array = NoteStyleRegistry.instance.listEntryIds(); + if (noteFrameCollection == null) { - initFrameCollection(); + buildEmptyFrameCollection(); + + for (entry in entries) + { + addNoteStyleFrames(fetchNoteStyle(entry)); + } } if (noteFrameCollection == null) throw 'ERROR: Could not initialize note sprite animations.'; this.frames = noteFrameCollection; - // Initialize all the animations, not just the one we're going to use immediately, - // so that later we can reuse the sprite without having to initialize more animations during scrolling. - this.animation.addByPrefix('tapLeftFunkin', 'purple instance'); - this.animation.addByPrefix('tapDownFunkin', 'blue instance'); - this.animation.addByPrefix('tapUpFunkin', 'green instance'); - this.animation.addByPrefix('tapRightFunkin', 'red instance'); - - this.animation.addByPrefix('holdLeftFunkin', 'LeftHoldPiece'); - this.animation.addByPrefix('holdDownFunkin', 'DownHoldPiece'); - this.animation.addByPrefix('holdUpFunkin', 'UpHoldPiece'); - this.animation.addByPrefix('holdRightFunkin', 'RightHoldPiece'); - - this.animation.addByPrefix('holdEndLeftFunkin', 'LeftHoldEnd'); - this.animation.addByPrefix('holdEndDownFunkin', 'DownHoldEnd'); - this.animation.addByPrefix('holdEndUpFunkin', 'UpHoldEnd'); - this.animation.addByPrefix('holdEndRightFunkin', 'RightHoldEnd'); - - this.animation.addByPrefix('tapLeftPixel', 'pixel4'); - this.animation.addByPrefix('tapDownPixel', 'pixel5'); - this.animation.addByPrefix('tapUpPixel', 'pixel6'); - this.animation.addByPrefix('tapRightPixel', 'pixel7'); + for (entry in entries) + { + addNoteStyleAnimations(fetchNoteStyle(entry)); + } } static var noteFrameCollection:Null = null; - /** - * We load all the note frames once, then reuse them. - */ - static function initFrameCollection():Void + function fetchNoteStyle(noteStyleId:String):NoteStyle { - buildEmptyFrameCollection(); - if (noteFrameCollection == null) return; + var result = NoteStyleRegistry.instance.fetchEntry(noteStyleId); + if (result != null) return result; + return NoteStyleRegistry.instance.fetchDefault(); + } - // TODO: Automatically iterate over the list of note skins. + @:access(funkin.play.notes.notestyle.NoteStyle) + @:nullSafety(Off) + static function addNoteStyleFrames(noteStyle:NoteStyle):Void + { + var prefix:String = noteStyle.id.toTitleCase(); - // Normal notes - var frameCollectionNormal:FlxAtlasFrames = Paths.getSparrowAtlas('NOTE_assets'); - - for (frame in frameCollectionNormal.frames) + var frameCollection:FlxAtlasFrames = Paths.getSparrowAtlas(noteStyle.getNoteAssetPath(), noteStyle.getNoteAssetLibrary()); + if (frameCollection == null) { - noteFrameCollection.pushFrame(frame); + trace('Could not retrieve frame collection for ${noteStyle}: ${Paths.image(noteStyle.getNoteAssetPath(), noteStyle.getNoteAssetLibrary())}'); + FlxG.log.error('Could not retrieve frame collection for ${noteStyle}: ${Paths.image(noteStyle.getNoteAssetPath(), noteStyle.getNoteAssetLibrary())}'); + return; } - - // Pixel notes - var graphicPixel = FlxG.bitmap.add(Paths.image('weeb/pixelUI/arrows-pixels', 'week6'), false, null); - if (graphicPixel == null) trace('ERROR: Could not load graphic: ' + Paths.image('weeb/pixelUI/arrows-pixels', 'week6')); - var frameCollectionPixel = FlxTileFrames.fromGraphic(graphicPixel, new FlxPoint(17, 17)); - for (i in 0...frameCollectionPixel.frames.length) + for (frame in frameCollection.frames) { - var frame:Null = frameCollectionPixel.frames[i]; - if (frame == null) continue; - - frame.name = 'pixel' + i; - noteFrameCollection.pushFrame(frame); + // cloning the frame because else + // we will fuck up the frame data used in game + var clonedFrame:FlxFrame = frame.copyTo(); + clonedFrame.name = '$prefix${clonedFrame.name}'; + noteFrameCollection.pushFrame(clonedFrame); } } + @:access(funkin.play.notes.notestyle.NoteStyle) + @:nullSafety(Off) + function addNoteStyleAnimations(noteStyle:NoteStyle):Void + { + var prefix:String = noteStyle.id.toTitleCase(); + var suffix:String = noteStyle.id.toTitleCase(); + + var leftData:AnimationData = noteStyle.fetchNoteAnimationData(NoteDirection.LEFT); + this.animation.addByPrefix('tapLeft$suffix', '$prefix${leftData.prefix}', leftData.frameRate, leftData.looped, leftData.flipX, leftData.flipY); + + var downData:AnimationData = noteStyle.fetchNoteAnimationData(NoteDirection.DOWN); + this.animation.addByPrefix('tapDown$suffix', '$prefix${downData.prefix}', downData.frameRate, downData.looped, downData.flipX, downData.flipY); + + var upData:AnimationData = noteStyle.fetchNoteAnimationData(NoteDirection.UP); + this.animation.addByPrefix('tapUp$suffix', '$prefix${upData.prefix}', upData.frameRate, upData.looped, upData.flipX, upData.flipY); + + var rightData:AnimationData = noteStyle.fetchNoteAnimationData(NoteDirection.RIGHT); + this.animation.addByPrefix('tapRight$suffix', '$prefix${rightData.prefix}', rightData.frameRate, rightData.looped, rightData.flipX, rightData.flipY); + } + @:nullSafety(Off) static function buildEmptyFrameCollection():Void { @@ -185,12 +198,24 @@ class ChartEditorNoteSprite extends FlxSprite } } - function get_noteStyle():String + function get_noteStyle():Null { - // Fall back to Funkin' if it's not a valid note style. - return if (NOTE_STYLES.contains(this.parentState.currentSongNoteStyle)) this.parentState.currentSongNoteStyle else 'funkin'; + if (this.noteStyle == null) + { + var result = this.parentState.currentSongNoteStyle; + return result; + } + return this.noteStyle; } + function set_noteStyle(value:Null):Null + { + this.noteStyle = value; + this.playNoteAnimation(); + return value; + } + + @:nullSafety(Off) public function playNoteAnimation():Void { if (this.noteData == null) return; @@ -200,6 +225,7 @@ class ChartEditorNoteSprite extends FlxSprite // Play the appropriate animation for the type, direction, and skin. var dirName:String = overrideData != null ? SongNoteData.buildDirectionName(overrideData) : this.noteData.getDirectionName(); + var noteStyleSuffix:String = this.noteStyle?.toTitleCase() ?? Constants.DEFAULT_NOTE_STYLE.toTitleCase(); var animationName:String = '${baseAnimationName}${dirName}${this.noteStyle.toTitleCase()}'; this.animation.play(animationName); @@ -209,12 +235,12 @@ class ChartEditorNoteSprite extends FlxSprite switch (baseAnimationName) { case 'tap': - this.setGraphicSize(0, ChartEditorState.GRID_SIZE); + this.setGraphicSize(ChartEditorState.GRID_SIZE, 0); + this.updateHitbox(); } - this.updateHitbox(); - // TODO: Make this an attribute of the note skin. - this.antialiasing = (this.parentState.currentSongNoteStyle != 'Pixel'); + var bruhStyle:NoteStyle = fetchNoteStyle(this.noteStyle); + this.antialiasing = !bruhStyle._data?.assets?.note?.isPixel ?? true; } /** diff --git a/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx index b84c68f8d..ab13da1d9 100644 --- a/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx +++ b/source/funkin/ui/debug/charting/handlers/ChartEditorDialogHandler.hx @@ -808,8 +808,11 @@ class ChartEditorDialogHandler } songVariationMetadataEntry.onClick = onClickMetadataVariation.bind(variation).bind(songVariationMetadataEntryLabel); #if FILE_DROP_SUPPORTED - state.addDropHandler({component: songVariationMetadataEntry, handler: onDropFileMetadataVariation.bind(variation) - .bind(songVariationMetadataEntryLabel)}); + state.addDropHandler( + { + component: songVariationMetadataEntry, + handler: onDropFileMetadataVariation.bind(variation).bind(songVariationMetadataEntryLabel) + }); #end chartContainerB.addComponent(songVariationMetadataEntry); diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx index 8f021840a..70580300e 100644 --- a/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx +++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorEventDataToolbox.hx @@ -190,8 +190,8 @@ class ChartEditorEventDataToolbox extends ChartEditorBaseToolbox var numberStepper:NumberStepper = new NumberStepper(); numberStepper.id = field.name; numberStepper.step = field.step ?? 1.0; - numberStepper.min = field.min ?? 0.0; - numberStepper.max = field.max ?? 10.0; + if (field.min != null) numberStepper.min = field.min; + if (field.min != null) numberStepper.max = field.max; if (field.defaultValue != null) numberStepper.value = field.defaultValue; input = numberStepper; case FLOAT: diff --git a/source/funkin/ui/debug/charting/toolboxes/ChartEditorNoteDataToolbox.hx b/source/funkin/ui/debug/charting/toolboxes/ChartEditorNoteDataToolbox.hx index d4fc69fc1..100654a02 100644 --- a/source/funkin/ui/debug/charting/toolboxes/ChartEditorNoteDataToolbox.hx +++ b/source/funkin/ui/debug/charting/toolboxes/ChartEditorNoteDataToolbox.hx @@ -2,8 +2,16 @@ package funkin.ui.debug.charting.toolboxes; import haxe.ui.components.DropDown; import haxe.ui.components.TextField; +import haxe.ui.components.Label; +import haxe.ui.components.NumberStepper; +import haxe.ui.containers.Grid; +import haxe.ui.core.Component; import haxe.ui.events.UIEvent; import funkin.ui.debug.charting.util.ChartEditorDropdowns; +import funkin.play.notes.notekind.NoteKindManager; +import funkin.play.notes.notekind.NoteKind.NoteKindParam; +import funkin.play.notes.notekind.NoteKind.NoteKindParamType; +import funkin.data.song.SongData.NoteParamData; /** * The toolbox which allows modifying information like Note Kind. @@ -12,8 +20,22 @@ import funkin.ui.debug.charting.util.ChartEditorDropdowns; @:build(haxe.ui.ComponentBuilder.build("assets/exclude/data/ui/chart-editor/toolboxes/note-data.xml")) class ChartEditorNoteDataToolbox extends ChartEditorBaseToolbox { + // 100 is the height used in note-data.xml + static final DIALOG_HEIGHT:Int = 100; + + // toolboxNotesGrid.height + 45 + // this is what i found out by printing this.height and grid.height + // and then seeing that this.height is 100 and grid.height is 55 + static final HEIGHT_OFFSET:Int = 45; + + // minimizing creates a gray bar the bottom, which would obscure the components, + // which is why we use an extra offset of 20 + static final MINIMIZE_FIX:Int = 20; + + var toolboxNotesGrid:Grid; var toolboxNotesNoteKind:DropDown; var toolboxNotesCustomKind:TextField; + var toolboxNotesParams:Array = []; var _initializing:Bool = true; @@ -54,12 +76,35 @@ class ChartEditorNoteDataToolbox extends ChartEditorBaseToolbox toolboxNotesCustomKind.value = chartEditorState.noteKindToPlace; } + createNoteKindParams(noteKind); + if (!_initializing && chartEditorState.currentNoteSelection.length > 0) { - // Edit the note data of any selected notes. for (note in chartEditorState.currentNoteSelection) { + // Edit the note data of any selected notes. note.kind = chartEditorState.noteKindToPlace; + note.params = ChartEditorState.cloneNoteParams(chartEditorState.noteParamsToPlace); + + // update note sprites + for (noteSprite in chartEditorState.renderedNotes.members) + { + if (noteSprite.noteData == note) + { + noteSprite.noteStyle = NoteKindManager.getNoteStyleId(note.kind) ?? chartEditorState.currentSongNoteStyle; + break; + } + } + + // update hold note sprites + for (holdNoteSprite in chartEditorState.renderedHoldNotes.members) + { + if (holdNoteSprite.noteData == note) + { + holdNoteSprite.noteStyle = NoteKindManager.getNoteStyleId(note.kind) ?? chartEditorState.currentSongNoteStyle; + break; + } + } } chartEditorState.saveDataDirty = true; chartEditorState.noteDisplayDirty = true; @@ -94,6 +139,8 @@ class ChartEditorNoteDataToolbox extends ChartEditorBaseToolbox toolboxNotesNoteKind.value = ChartEditorDropdowns.lookupNoteKind(chartEditorState.noteKindToPlace); toolboxNotesCustomKind.value = chartEditorState.noteKindToPlace; + + createNoteKindParams(chartEditorState.noteKindToPlace); } function showCustom():Void @@ -108,8 +155,149 @@ class ChartEditorNoteDataToolbox extends ChartEditorBaseToolbox toolboxNotesCustomKind.hidden = true; } + function createNoteKindParams(noteKind:Null):Void + { + clearNoteKindParams(); + + var setParamsToPlace:Bool = false; + if (!_initializing) + { + for (note in chartEditorState.currentNoteSelection) + { + if (note.kind == chartEditorState.noteKindToPlace) + { + chartEditorState.noteParamsToPlace = ChartEditorState.cloneNoteParams(note.params); + setParamsToPlace = true; + break; + } + } + } + + var noteKindParams:Array = NoteKindManager.getParams(noteKind); + + for (i in 0...noteKindParams.length) + { + var param:NoteKindParam = noteKindParams[i]; + + var paramLabel:Label = new Label(); + paramLabel.value = param.description; + paramLabel.verticalAlign = "center"; + paramLabel.horizontalAlign = "right"; + + var paramComponent:Component = null; + + switch (param.type) + { + case NoteKindParamType.INT | NoteKindParamType.FLOAT: + var paramStepper:NumberStepper = new NumberStepper(); + paramStepper.value = (setParamsToPlace ? chartEditorState.noteParamsToPlace[i].value : param.data?.defaultValue) ?? 0.0; + paramStepper.percentWidth = 100; + paramStepper.step = param.data?.step ?? 1.0; + + // this check should be unnecessary but for some reason + // even when these are null it will set it to 0 + if (param.data?.min != null) + { + paramStepper.min = param.data.min; + } + if (param.data?.max != null) + { + paramStepper.max = param.data.max; + } + if (param.data?.precision != null) + { + paramStepper.precision = param.data.precision; + } + paramComponent = paramStepper; + + case NoteKindParamType.STRING: + var paramTextField:TextField = new TextField(); + paramTextField.value = (setParamsToPlace ? chartEditorState.noteParamsToPlace[i].value : param.data?.defaultValue) ?? ''; + paramTextField.percentWidth = 100; + paramComponent = paramTextField; + } + + if (paramComponent == null) + { + continue; + } + + paramComponent.onChange = function(event:UIEvent) { + chartEditorState.noteParamsToPlace[i].value = paramComponent.value; + + for (note in chartEditorState.currentNoteSelection) + { + if (note.params.length != noteKindParams.length) + { + break; + } + + if (note.params[i].name == param.name) + { + note.params[i].value = paramComponent.value; + } + } + } + + addNoteKindParam(paramLabel, paramComponent); + } + + if (!setParamsToPlace) + { + var noteParamData:Array = []; + for (i in 0...noteKindParams.length) + { + noteParamData.push(new NoteParamData(noteKindParams[i].name, toolboxNotesParams[i].component.value)); + } + chartEditorState.noteParamsToPlace = noteParamData; + } + } + + function addNoteKindParam(label:Label, component:Component):Void + { + toolboxNotesParams.push({label: label, component: component}); + toolboxNotesGrid.addComponent(label); + toolboxNotesGrid.addComponent(component); + + this.height = Math.max(DIALOG_HEIGHT, DIALOG_HEIGHT - 30 + toolboxNotesParams.length * 30); + } + + function clearNoteKindParams():Void + { + for (param in toolboxNotesParams) + { + toolboxNotesGrid.removeComponent(param.component); + toolboxNotesGrid.removeComponent(param.label); + } + toolboxNotesParams = []; + this.height = DIALOG_HEIGHT; + } + + override function update(elapsed:Float):Void + { + super.update(elapsed); + + // current dialog is minimized, dont change the height + if (this.minimized) + { + return; + } + + var heightToSet:Int = Std.int(Math.max(DIALOG_HEIGHT, (toolboxNotesGrid?.height ?? 50.0) + HEIGHT_OFFSET)) + MINIMIZE_FIX; + if (this.height != heightToSet) + { + this.height = heightToSet; + } + } + public static function build(chartEditorState:ChartEditorState):ChartEditorNoteDataToolbox { return new ChartEditorNoteDataToolbox(chartEditorState); } } + +typedef ToolboxNoteKindParam = +{ + var label:Label; + var component:Component; +} diff --git a/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx b/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx index 55aab0ab0..21938b005 100644 --- a/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx +++ b/source/funkin/ui/debug/charting/util/ChartEditorDropdowns.hx @@ -135,6 +135,14 @@ class ChartEditorDropdowns var noteStyle:Null = NoteStyleRegistry.instance.fetchEntry(noteStyleId); if (noteStyle == null) continue; + // check if the note style has all necessary assets (strums, notes, holdNotes) + if (noteStyle._data?.assets?.noteStrumline == null + || noteStyle._data?.assets?.note == null + || noteStyle._data?.assets?.holdNote == null) + { + continue; + } + var value = {id: noteStyleId, text: noteStyle.getName()}; if (startingStyleId == noteStyleId) returnValue = value; @@ -146,7 +154,7 @@ class ChartEditorDropdowns return returnValue; } - static final NOTE_KINDS:Map = [ + public static final NOTE_KINDS:Map = [ // Base "" => "Default", "~CUSTOM~" => "Custom", @@ -187,11 +195,11 @@ class ChartEditorDropdowns { dropDown.dataSource.clear(); - var returnValue:DropDownEntry = lookupNoteKind('~CUSTOM'); + var returnValue:DropDownEntry = lookupNoteKind(''); for (noteKindId in NOTE_KINDS.keys()) { - var noteKind:String = NOTE_KINDS.get(noteKindId) ?? 'Default'; + var noteKind:String = NOTE_KINDS.get(noteKindId) ?? 'Unknown'; var value:DropDownEntry = {id: noteKindId, text: noteKind}; if (startingKindId == noteKindId) returnValue = value; @@ -208,7 +216,7 @@ class ChartEditorDropdowns { if (noteKindId == null) return lookupNoteKind(''); if (!NOTE_KINDS.exists(noteKindId)) return {id: '~CUSTOM~', text: 'Custom'}; - return {id: noteKindId ?? '', text: NOTE_KINDS.get(noteKindId) ?? 'Default'}; + return {id: noteKindId ?? '', text: NOTE_KINDS.get(noteKindId) ?? 'Unknown'}; } /** diff --git a/source/funkin/ui/freeplay/AlbumRoll.hx b/source/funkin/ui/freeplay/AlbumRoll.hx index 49c588722..4107369d2 100644 --- a/source/funkin/ui/freeplay/AlbumRoll.hx +++ b/source/funkin/ui/freeplay/AlbumRoll.hx @@ -37,9 +37,11 @@ class AlbumRoll extends FlxSpriteGroup } var newAlbumArt:FlxAtlasSprite; + var albumTitle:FunkinSprite; var difficultyStars:DifficultyStars; var _exitMovers:Null; + var _exitMoversCharSel:Null; var albumData:Album; @@ -59,24 +61,27 @@ class AlbumRoll extends FlxSpriteGroup { super(); - newAlbumArt = new FlxAtlasSprite(0, 0, Paths.animateAtlas("freeplay/albumRoll/freeplayAlbum")); + newAlbumArt = new FlxAtlasSprite(640, 360, Paths.animateAtlas("freeplay/albumRoll/freeplayAlbum")); newAlbumArt.visible = false; - newAlbumArt.onAnimationFinish.add(onAlbumFinish); + newAlbumArt.onAnimationComplete.add(onAlbumFinish); add(newAlbumArt); difficultyStars = new DifficultyStars(140, 39); difficultyStars.visible = false; add(difficultyStars); + + buildAlbumTitle("freeplay/albumRoll/volume1-text"); + albumTitle.visible = false; } function onAlbumFinish(animName:String):Void { // Play the idle animation for the current album. - newAlbumArt.playAnimation(animNames.get('$albumId-idle'), false, false, true); - - // End on the last frame and don't continue until playAnimation is called again. - // newAlbumArt.anim.pause(); + if (animName != "idle") + { + newAlbumArt.playAnimation('idle', true); + } } /** @@ -104,6 +109,12 @@ class AlbumRoll extends FlxSpriteGroup return; }; + // Update the album art. + var albumGraphic = Paths.image(albumData.getAlbumArtAssetKey()); + newAlbumArt.replaceFrameGraphic(0, albumGraphic); + + buildAlbumTitle(albumData.getAlbumTitleAssetKey()); + applyExitMovers(); refresh(); @@ -118,7 +129,7 @@ class AlbumRoll extends FlxSpriteGroup * Apply exit movers for the album roll. * @param exitMovers The exit movers to apply. */ - public function applyExitMovers(?exitMovers:FreeplayState.ExitMoverData):Void + public function applyExitMovers(?exitMovers:FreeplayState.ExitMoverData, ?exitMoversCharSel:FreeplayState.ExitMoverData):Void { if (exitMovers == null) { @@ -131,12 +142,30 @@ class AlbumRoll extends FlxSpriteGroup if (exitMovers == null) return; + if (exitMoversCharSel == null) + { + exitMoversCharSel = _exitMoversCharSel; + } + else + { + _exitMoversCharSel = exitMoversCharSel; + } + + if (exitMoversCharSel == null) return; + exitMovers.set([newAlbumArt, difficultyStars], { x: FlxG.width, speed: 0.4, wait: 0 }); + + exitMoversCharSel.set([newAlbumArt, difficultyStars], + { + y: -175, + speed: 0.8, + wait: 0.1 + }); } var titleTimer:Null = null; @@ -146,19 +175,64 @@ class AlbumRoll extends FlxSpriteGroup */ public function playIntro():Void { + albumTitle.visible = false; newAlbumArt.visible = true; - newAlbumArt.playAnimation(animNames.get('$albumId-active'), false, false, false); + newAlbumArt.playAnimation('intro', true); difficultyStars.visible = false; new FlxTimer().start(0.75, function(_) { - // showTitle(); + showTitle(); showStars(); + albumTitle.animation.play('switch'); }); } public function skipIntro():Void { - newAlbumArt.playAnimation(animNames.get('$albumId-trans'), false, false, false); + // Weird workaround + newAlbumArt.playAnimation('switch', true); + albumTitle.animation.play('switch'); + } + + public function showTitle():Void + { + albumTitle.visible = true; + } + + public function buildAlbumTitle(assetKey:String):Void + { + if (albumTitle != null) + { + remove(albumTitle); + albumTitle = null; + } + + albumTitle = FunkinSprite.createSparrow(925, 500, assetKey); + albumTitle.visible = albumTitle.frames != null && newAlbumArt.visible; + albumTitle.animation.addByPrefix('idle', 'idle0', 24, true); + albumTitle.animation.addByPrefix('switch', 'switch0', 24, false); + add(albumTitle); + + albumTitle.animation.finishCallback = (function(name) { + if (name == 'switch') albumTitle.animation.play('idle'); + }); + albumTitle.animation.play('idle'); + + albumTitle.zIndex = 1000; + + if (_exitMovers != null) _exitMovers.set([albumTitle], + { + x: FlxG.width, + speed: 0.4, + wait: 0 + }); + + if (_exitMoversCharSel != null) _exitMoversCharSel.set([albumTitle], + { + y: -190, + speed: 0.8, + wait: 0.1 + }); } public function setDifficultyStars(?difficulty:Int):Void diff --git a/source/funkin/ui/freeplay/CapsuleOptionsMenu.hx b/source/funkin/ui/freeplay/CapsuleOptionsMenu.hx new file mode 100644 index 000000000..cb0fa7b28 --- /dev/null +++ b/source/funkin/ui/freeplay/CapsuleOptionsMenu.hx @@ -0,0 +1,176 @@ +package funkin.ui.freeplay; + +import funkin.graphics.shaders.PureColor; +import funkin.input.Controls; +import flixel.group.FlxSpriteGroup; +import funkin.graphics.FunkinSprite; +import flixel.util.FlxColor; +import flixel.util.FlxTimer; +import flixel.text.FlxText; +import flixel.text.FlxText.FlxTextAlign; + +@:nullSafety +class CapsuleOptionsMenu extends FlxSpriteGroup +{ + var capsuleMenuBG:FunkinSprite; + var parent:FreeplayState; + + var queueDestroy:Bool = false; + + var instrumentalIds:Array = ['']; + var currentInstrumentalIndex:Int = 0; + + var currentInstrumental:FlxText; + + public function new(parent:FreeplayState, x:Float = 0, y:Float = 0, instIds:Array):Void + { + super(x, y); + + this.parent = parent; + this.instrumentalIds = instIds; + + capsuleMenuBG = FunkinSprite.createSparrow(0, 0, 'freeplay/instBox/instBox'); + + capsuleMenuBG.animation.addByPrefix('open', 'open0', 24, false); + capsuleMenuBG.animation.addByPrefix('idle', 'idle0', 24, true); + capsuleMenuBG.animation.addByPrefix('open', 'open0', 24, false); + + currentInstrumental = new FlxText(0, 36, capsuleMenuBG.width, ''); + currentInstrumental.setFormat('VCR OSD Mono', 40, FlxTextAlign.CENTER, true); + + final PAD = 4; + var leftArrow = new InstrumentalSelector(parent, PAD, 30, false, parent.getControls()); + var rightArrow = new InstrumentalSelector(parent, capsuleMenuBG.width - leftArrow.width - PAD, 30, true, parent.getControls()); + + var label:FlxText = new FlxText(0, 5, capsuleMenuBG.width, 'INSTRUMENTAL'); + label.setFormat('VCR OSD Mono', 24, FlxTextAlign.CENTER, true); + + add(capsuleMenuBG); + add(leftArrow); + add(rightArrow); + add(label); + add(currentInstrumental); + + capsuleMenuBG.animation.finishCallback = function(_) { + capsuleMenuBG.animation.play('idle', true); + }; + capsuleMenuBG.animation.play('open', true); + } + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + if (queueDestroy) + { + destroy(); + return; + } + @:privateAccess + if (parent.controls.BACK) + { + close(); + return; + } + + var changedInst = false; + if (parent.getControls().UI_LEFT_P) + { + currentInstrumentalIndex = (currentInstrumentalIndex + 1) % instrumentalIds.length; + changedInst = true; + } + if (parent.getControls().UI_RIGHT_P) + { + currentInstrumentalIndex = (currentInstrumentalIndex - 1 + instrumentalIds.length) % instrumentalIds.length; + changedInst = true; + } + if (!changedInst && currentInstrumental.text == '') changedInst = true; + + if (changedInst) + { + currentInstrumental.text = instrumentalIds[currentInstrumentalIndex].toTitleCase() ?? ''; + if (currentInstrumental.text == '') currentInstrumental.text = 'Default'; + } + + if (parent.getControls().ACCEPT) + { + onConfirm(instrumentalIds[currentInstrumentalIndex] ?? ''); + } + } + + public function close():Void + { + // Play in reverse. + capsuleMenuBG.animation.play('open', true, true); + capsuleMenuBG.animation.finishCallback = function(_) { + parent.cleanupCapsuleOptionsMenu(); + queueDestroy = true; + }; + } + + /** + * Override this with `capsuleOptionsMenu.onConfirm = myFunction;` + */ + public dynamic function onConfirm(targetInstId:String):Void + { + throw 'onConfirm not implemented!'; + } +} + +/** + * The difficulty selector arrows to the left and right of the difficulty. + */ +class InstrumentalSelector extends FunkinSprite +{ + var controls:Controls; + var whiteShader:PureColor; + + var parent:FreeplayState; + + var baseScale:Float = 0.6; + + public function new(parent:FreeplayState, x:Float, y:Float, flipped:Bool, controls:Controls) + { + super(x, y); + + this.parent = parent; + + this.controls = controls; + + frames = Paths.getSparrowAtlas('freeplay/freeplaySelector'); + animation.addByPrefix('shine', 'arrow pointer loop', 24); + animation.play('shine'); + + whiteShader = new PureColor(FlxColor.WHITE); + + shader = whiteShader; + + flipX = flipped; + + scale.x = scale.y = 1 * baseScale; + updateHitbox(); + } + + override function update(elapsed:Float):Void + { + if (flipX && controls.UI_RIGHT_P) moveShitDown(); + if (!flipX && controls.UI_LEFT_P) moveShitDown(); + + super.update(elapsed); + } + + function moveShitDown():Void + { + offset.y -= 5; + + whiteShader.colorSet = true; + + scale.x = scale.y = 0.5 * baseScale; + + new FlxTimer().start(2 / 24, function(tmr) { + scale.x = scale.y = 1 * baseScale; + whiteShader.colorSet = false; + updateHitbox(); + }); + } +} diff --git a/source/funkin/ui/freeplay/CapsuleText.hx b/source/funkin/ui/freeplay/CapsuleText.hx index c3bcdb09b..aae72544e 100644 --- a/source/funkin/ui/freeplay/CapsuleText.hx +++ b/source/funkin/ui/freeplay/CapsuleText.hx @@ -10,6 +10,7 @@ import flixel.tweens.FlxEase; import flixel.util.FlxTimer; import flixel.tweens.FlxTween; import openfl.display.BlendMode; +import flixel.util.FlxColor; class CapsuleText extends FlxSpriteGroup { @@ -25,6 +26,8 @@ class CapsuleText extends FlxSpriteGroup public var tooLong:Bool = false; + var glowColor:FlxColor = 0xFF00ccff; + // 255, 27 normal // 220, 27 favourited @@ -38,7 +41,7 @@ class CapsuleText extends FlxSpriteGroup // whiteText.shader = new GaussianBlurShader(0.3); text = songTitle; - blurredText.color = 0xFF00ccff; + blurredText.color = glowColor; whiteText.color = 0xFFFFFFFF; add(blurredText); add(whiteText); @@ -51,6 +54,16 @@ class CapsuleText extends FlxSpriteGroup return text; } + public function applyStyle(styleData:FreeplayStyle):Void + { + glowColor = styleData.getCapsuleSelCol(); + blurredText.color = glowColor; + whiteText.textField.filters = [ + new openfl.filters.GlowFilter(glowColor, 1, 5, 5, 210, BitmapFilterQuality.MEDIUM), + // new openfl.filters.BlurFilter(5, 5, BitmapFilterQuality.LOW) + ]; + } + // ???? none // 255, 27 normal // 220, 27 favourited @@ -99,7 +112,7 @@ class CapsuleText extends FlxSpriteGroup whiteText.text = value; checkClipWidth(); whiteText.textField.filters = [ - new openfl.filters.GlowFilter(0x00ccff, 1, 5, 5, 210, BitmapFilterQuality.MEDIUM), + new openfl.filters.GlowFilter(glowColor, 1, 5, 5, 210, BitmapFilterQuality.MEDIUM), // new openfl.filters.BlurFilter(5, 5, BitmapFilterQuality.LOW) ]; @@ -186,7 +199,7 @@ class CapsuleText extends FlxSpriteGroup } else { - blurredText.color = 0xFF00aadd; + blurredText.color = glowColor; whiteText.color = 0xFFDDDDDD; whiteText.textField.filters = [ new openfl.filters.GlowFilter(0xDDDDDD, 1, 5, 5, 210, BitmapFilterQuality.MEDIUM), diff --git a/source/funkin/ui/freeplay/DJBoyfriend.hx b/source/funkin/ui/freeplay/DJBoyfriend.hx deleted file mode 100644 index bbf043dd4..000000000 --- a/source/funkin/ui/freeplay/DJBoyfriend.hx +++ /dev/null @@ -1,371 +0,0 @@ -package funkin.ui.freeplay; - -import flixel.FlxSprite; -import flixel.util.FlxSignal; -import funkin.util.assets.FlxAnimationUtil; -import funkin.graphics.adobeanimate.FlxAtlasSprite; -import funkin.audio.FunkinSound; -import flixel.util.FlxTimer; -import funkin.audio.FunkinSound; -import funkin.audio.FlxStreamSound; - -class DJBoyfriend extends FlxAtlasSprite -{ - // Represents the sprite's current status. - // Without state machines I would have driven myself crazy years ago. - public var currentState:DJBoyfriendState = Intro; - - // A callback activated when the intro animation finishes. - public var onIntroDone:FlxSignal = new FlxSignal(); - - // A callback activated when Boyfriend gets spooked. - public var onSpook:FlxSignal = new FlxSignal(); - - // playAnim stolen from Character.hx, cuz im lazy lol! - // TODO: Switch this class to use SwagSprite instead. - public var animOffsets:Map>; - - var gotSpooked:Bool = false; - - static final SPOOK_PERIOD:Float = 60.0; - static final TV_PERIOD:Float = 120.0; - - // Time since dad last SPOOKED you. - var timeSinceSpook:Float = 0; - - public function new(x:Float, y:Float) - { - super(x, y, Paths.animateAtlas("freeplay/freeplay-boyfriend", "preload")); - - animOffsets = new Map>(); - - anim.callback = function(name, number) { - switch (name) - { - case "Boyfriend DJ watchin tv OG": - if (number == 80) - { - FunkinSound.playOnce(Paths.sound('remote_click')); - } - if (number == 85) - { - runTvLogic(); - } - default: - } - }; - - setupAnimations(); - - FlxG.debugger.track(this); - FlxG.console.registerObject("dj", this); - - anim.onComplete = onFinishAnim; - - FlxG.console.registerFunction("tv", function() { - currentState = TV; - }); - } - - /* - [remote hand under,boyfriend top head,brim piece,arm cringe l,red lazer,dj arm in,bf fist pump arm,hand raised right,forearm left,fist shaking,bf smile eyes closed face,arm cringe r,bf clenched face,face shrug,boyfriend falling,blue tint 1,shirt sleeve,bf clenched fist,head BF relaxed,blue tint 2,hand down left,blue tint 3,blue tint 4,head less smooshed,blue tint 5,boyfriend freeplay,BF head slight turn,blue tint 6,arm shrug l,blue tint 7,shoulder raised w sleeve,blue tint 8,fist pump face,blue tint 9,foot rested light,hand turnaround,arm chill right,Boyfriend DJ,arm shrug r,head back bf,hat top piece,dad bod,face surprise snap,Boyfriend DJ fist pump,office chair,foot rested right,chest down,office chair upright,body chill,bf dj afk,head mouth open dad,BF Head defalt HAIR BLOWING,hand shrug l,face piece,foot wag,turn table,shoulder up left,turntable lights,boyfriend dj body shirt blowing,body chunk turned,hand down right,dj arm out,hand shrug r,body chest out,rave hand,palm,chill face default,head back semi bf,boyfriend bottom head,DJ arm,shoulder right dad,bf surprise,boyfriend dj body,hs1,Boyfriend DJ watchin tv OG,spinning disk,hs2,arm chill left,boyfriend dj intro,hs3,hs4,chill face extra,hs5,remote hand upright,hs6,pant over table,face surprise,bf arm peace,arm turnaround,bf eyes 1,arm slammed table,eye squit,leg BF,head mid piece,arm backing,arm swoopin in,shoe right lowering,forearm right,hand out,blue tint 10,body falling back,remote thumb press,shoulder,hair spike single,bf bent - arm,crt,foot raised right,dad hand,chill face 1,chill face 2,clenched fist,head SMOOSHED,shoulder left dad,df1,body chunk upright,df2,df3,df4,hat front piece,df5,foot rested right 2,hand in,arm spun,shoe raised left,bf 1 finger hand,bf mouth 1,Boyfriend DJ confirm,forearm down ,hand raised left,remote thumb up] - */ - override public function listAnimations():Array - { - var anims:Array = []; - @:privateAccess - for (animKey in anim.symbolDictionary) - { - anims.push(animKey.name); - } - return anims; - } - - var lowPumpLoopPoint:Int = 4; - - public override function update(elapsed:Float):Void - { - super.update(elapsed); - - switch (currentState) - { - case Intro: - // Play the intro animation then leave this state immediately. - if (getCurrentAnimation() != 'boyfriend dj intro') playFlashAnimation('boyfriend dj intro', true); - timeSinceSpook = 0; - case Idle: - // We are in this state the majority of the time. - if (getCurrentAnimation() != 'Boyfriend DJ') - { - playFlashAnimation('Boyfriend DJ', true); - } - - if (getCurrentAnimation() == 'Boyfriend DJ' && this.isLoopFinished()) - { - if (timeSinceSpook >= SPOOK_PERIOD && !gotSpooked) - { - currentState = Spook; - } - else if (timeSinceSpook >= TV_PERIOD) - { - currentState = TV; - } - } - timeSinceSpook += elapsed; - case Confirm: - if (getCurrentAnimation() != 'Boyfriend DJ confirm') playFlashAnimation('Boyfriend DJ confirm', false); - timeSinceSpook = 0; - case PumpIntro: - if (getCurrentAnimation() != 'Boyfriend DJ fist pump') playFlashAnimation('Boyfriend DJ fist pump', false); - if (getCurrentAnimation() == 'Boyfriend DJ fist pump' && anim.curFrame >= 4) - { - anim.play("Boyfriend DJ fist pump", true, false, 0); - } - case FistPump: - - case Spook: - if (getCurrentAnimation() != 'bf dj afk') - { - onSpook.dispatch(); - playFlashAnimation('bf dj afk', false); - gotSpooked = true; - } - timeSinceSpook = 0; - case TV: - if (getCurrentAnimation() != 'Boyfriend DJ watchin tv OG') playFlashAnimation('Boyfriend DJ watchin tv OG', true); - timeSinceSpook = 0; - default: - // I shit myself. - } - - if (FlxG.keys.pressed.CONTROL) - { - if (FlxG.keys.justPressed.LEFT) - { - this.offsetX -= FlxG.keys.pressed.ALT ? 0.1 : (FlxG.keys.pressed.SHIFT ? 10.0 : 1.0); - } - - if (FlxG.keys.justPressed.RIGHT) - { - this.offsetX += FlxG.keys.pressed.ALT ? 0.1 : (FlxG.keys.pressed.SHIFT ? 10.0 : 1.0); - } - - if (FlxG.keys.justPressed.UP) - { - this.offsetY -= FlxG.keys.pressed.ALT ? 0.1 : (FlxG.keys.pressed.SHIFT ? 10.0 : 1.0); - } - - if (FlxG.keys.justPressed.DOWN) - { - this.offsetY += FlxG.keys.pressed.ALT ? 0.1 : (FlxG.keys.pressed.SHIFT ? 10.0 : 1.0); - } - - if (FlxG.keys.justPressed.SPACE) - { - currentState = (currentState == Idle ? TV : Idle); - } - } - } - - function onFinishAnim():Void - { - var name = anim.curSymbol.name; - switch (name) - { - case "boyfriend dj intro": - // trace('Finished intro'); - currentState = Idle; - onIntroDone.dispatch(); - case "Boyfriend DJ": - // trace('Finished idle'); - case "bf dj afk": - // trace('Finished spook'); - currentState = Idle; - case "Boyfriend DJ confirm": - - case "Boyfriend DJ fist pump": - currentState = Idle; - - case "Boyfriend DJ loss reaction 1": - currentState = Idle; - - case "Boyfriend DJ watchin tv OG": - var frame:Int = FlxG.random.bool(33) ? 112 : 166; - - // BF switches channels when the video ends, or at a 10% chance each time his idle loops. - if (FlxG.random.bool(5)) - { - frame = 60; - // boyfriend switches channel code? - // runTvLogic(); - } - trace('Replay idle: ${frame}'); - anim.play("Boyfriend DJ watchin tv OG", true, false, frame); - // trace('Finished confirm'); - } - } - - public function resetAFKTimer():Void - { - timeSinceSpook = 0; - gotSpooked = false; - } - - var offsetX:Float = 0.0; - var offsetY:Float = 0.0; - - function setupAnimations():Void - { - // Intro - addOffset('boyfriend dj intro', 8.0 - 1.3, 3.0 - 0.4); - - // Idle - addOffset('Boyfriend DJ', 0, 0); - - // Confirm - addOffset('Boyfriend DJ confirm', 0, 0); - - // AFK: Spook - addOffset('bf dj afk', 649.5, 58.5); - - // AFK: TV - addOffset('Boyfriend DJ watchin tv OG', 0, 0); - } - - var cartoonSnd:Null = null; - - public var playingCartoon:Bool = false; - - public function runTvLogic() - { - if (cartoonSnd == null) - { - // tv is OFF, but getting turned on - FunkinSound.playOnce(Paths.sound('tv_on'), 1.0, function() { - loadCartoon(); - }); - } - else - { - // plays it smidge after the click - FunkinSound.playOnce(Paths.sound('channel_switch'), 1.0, function() { - cartoonSnd.destroy(); - loadCartoon(); - }); - } - - // loadCartoon(); - } - - function loadCartoon() - { - cartoonSnd = FunkinSound.load(Paths.sound(getRandomFlashToon()), 1.0, false, true, true, function() { - anim.play("Boyfriend DJ watchin tv OG", true, false, 60); - }); - - // Fade out music to 40% volume over 1 second. - // This helps make the TV a bit more audible. - FlxG.sound.music.fadeOut(1.0, 0.1); - - // Play the cartoon at a random time between the start and 5 seconds from the end. - cartoonSnd.time = FlxG.random.float(0, Math.max(cartoonSnd.length - (5 * Constants.MS_PER_SEC), 0.0)); - } - - final cartoonList:Array = openfl.utils.Assets.list().filter(function(path) return path.startsWith("assets/sounds/cartoons/")); - - function getRandomFlashToon():String - { - var randomFile = FlxG.random.getObject(cartoonList); - - // Strip folder prefix - randomFile = randomFile.replace("assets/sounds/", ""); - // Strip file extension - randomFile = randomFile.substring(0, randomFile.length - 4); - - return randomFile; - } - - public function confirm():Void - { - currentState = Confirm; - } - - public function fistPump():Void - { - currentState = PumpIntro; - } - - public function pumpFist():Void - { - currentState = FistPump; - anim.play("Boyfriend DJ fist pump", true, false, 4); - } - - public function pumpFistBad():Void - { - currentState = FistPump; - anim.play("Boyfriend DJ loss reaction 1", true, false, 4); - } - - public inline function addOffset(name:String, x:Float = 0, y:Float = 0) - { - animOffsets[name] = [x, y]; - } - - override public function getCurrentAnimation():String - { - if (this.anim == null || this.anim.curSymbol == null) return ""; - return this.anim.curSymbol.name; - } - - public function playFlashAnimation(id:String, ?Force:Bool = false, ?Reverse:Bool = false, ?Frame:Int = 0):Void - { - anim.play(id, Force, Reverse, Frame); - applyAnimOffset(); - } - - function applyAnimOffset() - { - var AnimName = getCurrentAnimation(); - var daOffset = animOffsets.get(AnimName); - if (animOffsets.exists(AnimName)) - { - var xValue = daOffset[0]; - var yValue = daOffset[1]; - if (AnimName == "Boyfriend DJ watchin tv OG") - { - xValue += offsetX; - yValue += offsetY; - } - - offset.set(xValue, yValue); - } - else - { - offset.set(0, 0); - } - } - - public override function destroy():Void - { - super.destroy(); - - if (cartoonSnd != null) - { - cartoonSnd.destroy(); - cartoonSnd = null; - } - } -} - -enum DJBoyfriendState -{ - Intro; - Idle; - Confirm; - PumpIntro; - FistPump; - Spook; - TV; -} diff --git a/source/funkin/ui/freeplay/FreeplayDJ.hx b/source/funkin/ui/freeplay/FreeplayDJ.hx new file mode 100644 index 000000000..13b0d853d --- /dev/null +++ b/source/funkin/ui/freeplay/FreeplayDJ.hx @@ -0,0 +1,560 @@ +package funkin.ui.freeplay; + +import flixel.FlxSprite; +import flixel.util.FlxSignal; +import funkin.util.assets.FlxAnimationUtil; +import funkin.graphics.adobeanimate.FlxAtlasSprite; +import funkin.audio.FunkinSound; +import flixel.util.FlxTimer; +import funkin.data.freeplay.player.PlayerRegistry; +import funkin.data.freeplay.player.PlayerData.PlayerFreeplayDJData; +import funkin.audio.FunkinSound; +import funkin.audio.FlxStreamSound; + +class FreeplayDJ extends FlxAtlasSprite +{ + // Represents the sprite's current status. + // Without state machines I would have driven myself crazy years ago. + // Made this PRIVATE so we can keep track of everything that can alter the state! + // Add a function to this class if you want to edit this value from outside. + private var currentState:FreeplayDJState = Intro; + + // A callback activated when the intro animation finishes. + public var onIntroDone:FlxSignal = new FlxSignal(); + + // A callback activated when the idle easter egg plays. + public var onIdleEasterEgg:FlxSignal = new FlxSignal(); + + var seenIdleEasterEgg:Bool = false; + + static final IDLE_EGG_PERIOD:Float = 60.0; + static final IDLE_CARTOON_PERIOD:Float = 120.0; + + // Time since last special idle animation you. + var timeIdling:Float = 0; + + final characterId:String = Constants.DEFAULT_CHARACTER; + final playableCharData:PlayerFreeplayDJData; + + public function new(x:Float, y:Float, characterId:String) + { + this.characterId = characterId; + + var playableChar = PlayerRegistry.instance.fetchEntry(characterId); + playableCharData = playableChar.getFreeplayDJData(); + + super(x, y, playableCharData.getAtlasPath()); + + onAnimationFrame.add(function(name, number) { + if (name == playableCharData.getAnimationPrefix('cartoon')) + { + if (number == playableCharData.getCartoonSoundClickFrame()) + { + FunkinSound.playOnce(Paths.sound('remote_click')); + } + if (number == playableCharData.getCartoonSoundCartoonFrame()) + { + runTvLogic(); + } + } + }); + + FlxG.debugger.track(this); + FlxG.console.registerObject("dj", this); + + onAnimationComplete.add(onFinishAnim); + + FlxG.console.registerFunction("freeplayCartoon", function() { + currentState = Cartoon; + }); + } + + override public function listAnimations():Array + { + var anims:Array = []; + @:privateAccess + for (animKey in anim.symbolDictionary) + { + anims.push(animKey.name); + } + return anims; + } + + var lowPumpLoopPoint:Int = 4; + + public override function update(elapsed:Float):Void + { + super.update(elapsed); + + switch (currentState) + { + case Intro: + // Play the intro animation then leave this state immediately. + var animPrefix = playableCharData.getAnimationPrefix('intro'); + if (getCurrentAnimation() != animPrefix) playFlashAnimation(animPrefix, true); + timeIdling = 0; + case Idle: + // We are in this state the majority of the time. + var animPrefix = playableCharData.getAnimationPrefix('idle'); + if (getCurrentAnimation() != animPrefix) + { + playFlashAnimation(animPrefix, true, false, true); + } + + if (getCurrentAnimation() == animPrefix && this.isLoopComplete()) + { + if (timeIdling >= IDLE_EGG_PERIOD && !seenIdleEasterEgg) + { + currentState = IdleEasterEgg; + } + else if (timeIdling >= IDLE_CARTOON_PERIOD) + { + currentState = Cartoon; + } + } + timeIdling += elapsed; + case NewUnlock: + var animPrefix = playableCharData.getAnimationPrefix('newUnlock'); + if (!hasAnimation(animPrefix)) + { + currentState = Idle; + } + if (getCurrentAnimation() != animPrefix) + { + playFlashAnimation(animPrefix, true, false, true); + } + case Confirm: + var animPrefix = playableCharData.getAnimationPrefix('confirm'); + if (getCurrentAnimation() != animPrefix) playFlashAnimation(animPrefix, false); + timeIdling = 0; + case FistPumpIntro: + var animPrefixA = playableCharData.getAnimationPrefix('fistPump'); + var animPrefixB = playableCharData.getAnimationPrefix('loss'); + + if (getCurrentAnimation() == animPrefixA) + { + var endFrame = playableCharData.getFistPumpIntroEndFrame(); + if (endFrame > -1 && anim.curFrame >= endFrame) + { + playFlashAnimation(animPrefixA, true, false, false, playableCharData.getFistPumpIntroStartFrame()); + } + } + else if (getCurrentAnimation() == animPrefixB) + { + trace("Loss Intro"); + var endFrame = playableCharData.getFistPumpIntroBadEndFrame(); + if (endFrame > -1 && anim.curFrame >= endFrame) + { + playFlashAnimation(animPrefixB, true, false, false, playableCharData.getFistPumpIntroBadStartFrame()); + } + } + else + { + FlxG.log.warn("Unrecognized animation in FistPumpIntro: " + getCurrentAnimation()); + } + + case FistPump: + var animPrefixA = playableCharData.getAnimationPrefix('fistPump'); + var animPrefixB = playableCharData.getAnimationPrefix('loss'); + + if (getCurrentAnimation() == animPrefixA) + { + var endFrame = playableCharData.getFistPumpLoopEndFrame(); + if (endFrame > -1 && anim.curFrame >= endFrame) + { + playFlashAnimation(animPrefixA, true, false, false, playableCharData.getFistPumpLoopStartFrame()); + } + } + else if (getCurrentAnimation() == animPrefixB) + { + trace("Loss GYATT"); + var endFrame = playableCharData.getFistPumpLoopBadEndFrame(); + if (endFrame > -1 && anim.curFrame >= endFrame) + { + playFlashAnimation(animPrefixB, true, false, false, playableCharData.getFistPumpLoopBadStartFrame()); + } + } + else + { + FlxG.log.warn("Unrecognized animation in FistPump: " + getCurrentAnimation()); + } + + case IdleEasterEgg: + var animPrefix = playableCharData.getAnimationPrefix('idleEasterEgg'); + if (getCurrentAnimation() != animPrefix) + { + onIdleEasterEgg.dispatch(); + playFlashAnimation(animPrefix, false); + seenIdleEasterEgg = true; + } + timeIdling = 0; + case Cartoon: + var animPrefix = playableCharData.getAnimationPrefix('cartoon'); + if (animPrefix == null) + { + currentState = IdleEasterEgg; + } + else + { + if (getCurrentAnimation() != animPrefix) playFlashAnimation(animPrefix, true); + timeIdling = 0; + } + default: + // I shit myself. + } + + #if FEATURE_DEBUG_FUNCTIONS + if (FlxG.keys.pressed.CONTROL) + { + if (FlxG.keys.justPressed.LEFT) + { + this.offsetX -= FlxG.keys.pressed.ALT ? 0.1 : (FlxG.keys.pressed.SHIFT ? 10.0 : 1.0); + } + + if (FlxG.keys.justPressed.RIGHT) + { + this.offsetX += FlxG.keys.pressed.ALT ? 0.1 : (FlxG.keys.pressed.SHIFT ? 10.0 : 1.0); + } + + if (FlxG.keys.justPressed.UP) + { + this.offsetY -= FlxG.keys.pressed.ALT ? 0.1 : (FlxG.keys.pressed.SHIFT ? 10.0 : 1.0); + } + + if (FlxG.keys.justPressed.DOWN) + { + this.offsetY += FlxG.keys.pressed.ALT ? 0.1 : (FlxG.keys.pressed.SHIFT ? 10.0 : 1.0); + } + + if (FlxG.keys.justPressed.C) + { + currentState = (currentState == Idle ? Cartoon : Idle); + } + } + #end + } + + function onFinishAnim(name:String):Void + { + // var name = anim.curSymbol.name; + + if (name == playableCharData.getAnimationPrefix('intro')) + { + if (PlayerRegistry.instance.hasNewCharacter()) + { + currentState = NewUnlock; + } + else + { + currentState = Idle; + } + onIntroDone.dispatch(); + } + else if (name == playableCharData.getAnimationPrefix('idle')) + { + // trace('Finished idle'); + } + else if (name == playableCharData.getAnimationPrefix('confirm')) + { + // trace('Finished confirm'); + } + else if (name == playableCharData.getAnimationPrefix('fistPump')) + { + // trace('Finished fist pump'); + currentState = Idle; + } + else if (name == playableCharData.getAnimationPrefix('idleEasterEgg')) + { + // trace('Finished spook'); + currentState = Idle; + } + else if (name == playableCharData.getAnimationPrefix('loss')) + { + // trace('Finished loss reaction'); + currentState = Idle; + } + else if (name == playableCharData.getAnimationPrefix('cartoon')) + { + // trace('Finished cartoon'); + + var frame:Int = FlxG.random.bool(33) ? playableCharData.getCartoonLoopBlinkFrame() : playableCharData.getCartoonLoopFrame(); + + // Character switches channels when the video ends, or at a 10% chance each time his idle loops. + if (FlxG.random.bool(5)) + { + frame = playableCharData.getCartoonChannelChangeFrame(); + // boyfriend switches channel code? + // runTvLogic(); + } + trace('Replay idle: ${frame}'); + playFlashAnimation(playableCharData.getAnimationPrefix('cartoon'), true, false, false, frame); + // trace('Finished confirm'); + } + else if (name == playableCharData.getAnimationPrefix('newUnlock')) + { + // Animation should loop. + } + else if (name == playableCharData.getAnimationPrefix('charSelect')) + { + onCharSelectComplete(); + } + else + { + trace('Finished ${name}'); + } + } + + public function resetAFKTimer():Void + { + timeIdling = 0; + seenIdleEasterEgg = false; + } + + /** + * Dynamic function, it's actually a variable you can reassign! + * `dj.onCharSelectComplete = function() {};` + */ + public dynamic function onCharSelectComplete():Void + { + trace('onCharSelectComplete()'); + } + + var offsetX:Float = 0.0; + var offsetY:Float = 0.0; + + var cartoonSnd:Null = null; + + public var playingCartoon:Bool = false; + + public function runTvLogic() + { + if (cartoonSnd == null) + { + // tv is OFF, but getting turned on + FunkinSound.playOnce(Paths.sound('tv_on'), 1.0, function() { + loadCartoon(); + }); + } + else + { + // plays it smidge after the click + FunkinSound.playOnce(Paths.sound('channel_switch'), 1.0, function() { + cartoonSnd.destroy(); + loadCartoon(); + }); + } + + // loadCartoon(); + } + + function loadCartoon() + { + cartoonSnd = FunkinSound.load(Paths.sound(getRandomFlashToon()), 1.0, false, true, true, function() { + playFlashAnimation(playableCharData.getAnimationPrefix('cartoon'), true, false, false, 60); + }); + + // Fade out music to 40% volume over 1 second. + // This helps make the TV a bit more audible. + FlxG.sound.music.fadeOut(1.0, 0.1); + + // Play the cartoon at a random time between the start and 5 seconds from the end. + cartoonSnd.time = FlxG.random.float(0, Math.max(cartoonSnd.length - (5 * Constants.MS_PER_SEC), 0.0)); + } + + final cartoonList:Array = openfl.utils.Assets.list().filter(function(path) return path.startsWith("assets/sounds/cartoons/")); + + function getRandomFlashToon():String + { + var randomFile = FlxG.random.getObject(cartoonList); + + // Strip folder prefix + randomFile = randomFile.replace("assets/sounds/", ""); + // Strip file extension + randomFile = randomFile.substring(0, randomFile.length - 4); + + return randomFile; + } + + public function confirm():Void + { + // We really don't want to play anything but the new character animation here. + if (PlayerRegistry.instance.hasNewCharacter()) + { + currentState = NewUnlock; + return; + } + + currentState = Confirm; + } + + public function toCharSelect():Void + { + if (hasAnimation(playableCharData.getAnimationPrefix('charSelect'))) + { + currentState = CharSelect; + var animPrefix = playableCharData.getAnimationPrefix('charSelect'); + playFlashAnimation(animPrefix, true, false, false, 0); + } + else + { + FlxG.log.warn("Freeplay character does not have 'charSelect' animation!"); + currentState = Confirm; + // Call this immediately; otherwise, we get locked out of Character Select. + onCharSelectComplete(); + } + } + + public function fistPumpIntro():Void + { + // We really don't want to play anything but the new character animation here. + if (PlayerRegistry.instance.hasNewCharacter()) + { + currentState = NewUnlock; + return; + } + + currentState = FistPumpIntro; + var animPrefix = playableCharData.getAnimationPrefix('fistPump'); + playFlashAnimation(animPrefix, true, false, false, playableCharData.getFistPumpIntroStartFrame()); + } + + public function fistPump():Void + { + // We really don't want to play anything but the new character animation here. + if (PlayerRegistry.instance.hasNewCharacter()) + { + currentState = NewUnlock; + return; + } + + currentState = FistPump; + var animPrefix = playableCharData.getAnimationPrefix('fistPump'); + playFlashAnimation(animPrefix, true, false, false, playableCharData.getFistPumpLoopStartFrame()); + } + + public function fistPumpLossIntro():Void + { + // We really don't want to play anything but the new character animation here. + if (PlayerRegistry.instance.hasNewCharacter()) + { + currentState = NewUnlock; + return; + } + + currentState = FistPumpIntro; + var animPrefix = playableCharData.getAnimationPrefix('loss'); + playFlashAnimation(animPrefix, true, false, false, playableCharData.getFistPumpIntroBadStartFrame()); + } + + public function fistPumpLoss():Void + { + // We really don't want to play anything but the new character animation here. + if (PlayerRegistry.instance.hasNewCharacter()) + { + currentState = NewUnlock; + return; + } + + currentState = FistPump; + var animPrefix = playableCharData.getAnimationPrefix('loss'); + playFlashAnimation(animPrefix, true, false, false, playableCharData.getFistPumpLoopBadStartFrame()); + } + + override public function getCurrentAnimation():String + { + if (this.anim == null || this.anim.curSymbol == null) return ""; + return this.anim.curSymbol.name; + } + + public function playFlashAnimation(id:String, Force:Bool = false, Reverse:Bool = false, Loop:Bool = false, Frame:Int = 0):Void + { + playAnimation(id, Force, Reverse, Loop, Frame); + applyAnimOffset(); + } + + function applyAnimOffset() + { + var AnimName = getCurrentAnimation(); + var daOffset = playableCharData.getAnimationOffsetsByPrefix(AnimName); + if (daOffset != null) + { + var xValue = daOffset[0]; + var yValue = daOffset[1]; + if (AnimName == "Boyfriend DJ watchin tv OG") + { + xValue += offsetX; + yValue += offsetY; + } + + trace('Successfully applied offset ($AnimName): ' + xValue + ', ' + yValue); + offset.set(xValue, yValue); + } + else + { + trace('No offset found ($AnimName), defaulting to: 0, 0'); + offset.set(0, 0); + } + } + + public override function destroy():Void + { + super.destroy(); + + if (cartoonSnd != null) + { + cartoonSnd.destroy(); + cartoonSnd = null; + } + } +} + +enum FreeplayDJState +{ + /** + * Character enters the frame and transitions to Idle. + */ + Intro; + + /** + * Character loops in idle. + */ + Idle; + + /** + * Plays an easter egg animation after a period in Idle, then reverts to Idle. + */ + IdleEasterEgg; + + /** + * Plays an elaborate easter egg animation. Does not revert until another animation is triggered. + */ + Cartoon; + + /** + * Player has selected a song. + */ + Confirm; + + /** + * Character preps to play the fist pump animation; plays after the Results screen. + * The actual frame label that gets played may vary based on the player's success. + */ + FistPumpIntro; + + /** + * Character plays the fist pump animation. + * The actual frame label that gets played may vary based on the player's success. + */ + FistPump; + + /** + * Plays an animation to indicate that the player has a new unlock in Character Select. + * Overrides all idle animations as well as the fist pump. Only Confirm and CharSelect will override this. + */ + NewUnlock; + + /** + * Plays an animation to transition to the Character Select screen. + */ + CharSelect; +} diff --git a/source/funkin/ui/freeplay/FreeplayScore.hx b/source/funkin/ui/freeplay/FreeplayScore.hx index da4c9f5d4..fee55ce7c 100644 --- a/source/funkin/ui/freeplay/FreeplayScore.hx +++ b/source/funkin/ui/freeplay/FreeplayScore.hx @@ -42,13 +42,20 @@ class FreeplayScore extends FlxTypedSpriteGroup return val; } - public function new(x:Float, y:Float, digitCount:Int, scoreShit:Int = 100) + public function new(x:Float, y:Float, digitCount:Int, scoreShit:Int = 100, ?styleData:FreeplayStyle) { super(x, y); for (i in 0...digitCount) { - add(new ScoreNum(x + (45 * i), y, 0)); + if (styleData == null) + { + add(new ScoreNum(x + (45 * i), y, 0)); + } + else + { + add(new ScoreNum(x + (45 * i), y, 0, styleData)); + } } this.scoreShit = scoreShit; @@ -76,16 +83,16 @@ class ScoreNum extends FlxSprite case 1: offset.x -= 15; case 5: - // set offsets - // offset.x += 0; - // offset.y += 10; + // set offsets + // offset.x += 0; + // offset.y += 10; case 7: - // offset.y += 6; + // offset.y += 6; case 4: - // offset.y += 5; + // offset.y += 5; case 9: - // offset.y += 5; + // offset.y += 5; default: centerOffsets(false); } @@ -99,14 +106,21 @@ class ScoreNum extends FlxSprite var numToString:Array = ["ZERO", "ONE", "TWO", "THREE", "FOUR", "FIVE", "SIX", "SEVEN", "EIGHT", "NINE"]; - public function new(x:Float, y:Float, ?initDigit:Int = 0) + public function new(x:Float, y:Float, ?initDigit:Int = 0, ?styleData:FreeplayStyle) { super(x, y); baseY = y; baseX = x; - frames = Paths.getSparrowAtlas('digital_numbers'); + if (styleData == null) + { + frames = Paths.getSparrowAtlas('digital_numbers'); + } + else + { + frames = Paths.getSparrowAtlas(styleData.getNumbersAssetKey()); + } for (i in 0...10) { diff --git a/source/funkin/ui/freeplay/FreeplayState.hx b/source/funkin/ui/freeplay/FreeplayState.hx index 0caaf4591..af0a9b841 100644 --- a/source/funkin/ui/freeplay/FreeplayState.hx +++ b/source/funkin/ui/freeplay/FreeplayState.hx @@ -1,52 +1,60 @@ package funkin.ui.freeplay; -import funkin.graphics.adobeanimate.FlxAtlasSprite; +import funkin.ui.freeplay.backcards.*; import flixel.addons.transition.FlxTransitionableState; -import flixel.addons.ui.FlxInputText; import flixel.FlxCamera; import flixel.FlxSprite; import flixel.group.FlxGroup; -import funkin.graphics.shaders.GaussianBlurShader; import flixel.group.FlxGroup.FlxTypedGroup; import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; import flixel.input.touch.FlxTouch; import flixel.math.FlxAngle; +import flixel.math.FlxMath; import flixel.math.FlxPoint; -import openfl.display.BlendMode; import flixel.system.debug.watch.Tracker.TrackerProfile; import flixel.text.FlxText; import flixel.tweens.FlxEase; import flixel.tweens.FlxTween; +import flixel.tweens.misc.ShakeTween; import flixel.util.FlxColor; import flixel.util.FlxSpriteUtil; import flixel.util.FlxTimer; import funkin.audio.FunkinSound; -import funkin.data.story.level.LevelRegistry; +import funkin.data.freeplay.player.PlayerRegistry; import funkin.data.song.SongRegistry; +import funkin.data.story.level.LevelRegistry; +import funkin.effects.IntervalShake; +import funkin.graphics.adobeanimate.FlxAtlasSprite; import funkin.graphics.FunkinCamera; import funkin.graphics.FunkinSprite; import funkin.graphics.shaders.AngleMask; +import funkin.graphics.shaders.GaussianBlurShader; import funkin.graphics.shaders.HSVShader; import funkin.graphics.shaders.PureColor; +import funkin.graphics.shaders.BlueFade; import funkin.graphics.shaders.StrokeShader; +import openfl.filters.ShaderFilter; import funkin.input.Controls; import funkin.play.PlayStatePlaylist; +import funkin.play.scoring.Scoring; +import funkin.play.scoring.Scoring.ScoringRank; import funkin.play.song.Song; -import funkin.ui.story.Level; import funkin.save.Save; import funkin.save.Save.SaveScoreData; import funkin.ui.AtlasText; -import funkin.play.scoring.Scoring; -import funkin.play.scoring.Scoring.ScoringRank; +import funkin.ui.freeplay.charselect.PlayableCharacter; +import funkin.ui.freeplay.SongMenuItem.FreeplayRank; import funkin.ui.mainmenu.MainMenuState; import funkin.ui.MusicBeatSubState; +import funkin.ui.story.Level; import funkin.ui.transition.LoadingState; import funkin.ui.transition.StickerSubState; import funkin.util.MathUtil; +import funkin.util.SortUtil; import lime.utils.Assets; -import flixel.tweens.misc.ShakeTween; -import funkin.effects.IntervalShake; -import funkin.ui.freeplay.SongMenuItem.FreeplayRank; +import openfl.display.BlendMode; +import funkin.data.freeplay.style.FreeplayStyleRegistry; +import funkin.data.song.SongData.SongMusicData; /** * Parameters used to initialize the FreeplayState. @@ -55,6 +63,8 @@ typedef FreeplayStateParams = { ?character:String, + ?fromCharSelect:Bool, + ?fromResults:FromResultsParams, }; @@ -92,6 +102,7 @@ typedef FromResultsParams = /** * The state for the freeplay menu, allowing the player to select any song to play. */ +@:nullSafety class FreeplayState extends MusicBeatSubState { // @@ -102,7 +113,9 @@ class FreeplayState extends MusicBeatSubState * The current character for this FreeplayState. * You can't change this without transitioning to a new FreeplayState. */ - final currentCharacter:String; + final currentCharacterId:String; + + final currentCharacter:PlayableCharacter; /** * For the audio preview, the duration of the fade-in effect. @@ -138,7 +151,8 @@ class FreeplayState extends MusicBeatSubState var curSelected:Int = 0; var currentDifficulty:String = Constants.DEFAULT_DIFFICULTY; - var fp:FreeplayScore; + public var fp:FreeplayScore; + var txtCompletion:AtlasText; var lerpCompletion:Float = 0; var intendedCompletion:Float = 0; @@ -160,52 +174,71 @@ class FreeplayState extends MusicBeatSubState var grpSongs:FlxTypedGroup; var grpCapsules:FlxTypedGroup; - var curCapsule:SongMenuItem; var curPlaying:Bool = false; - var dj:DJBoyfriend; + var dj:Null = null; var ostName:FlxText; var albumRoll:AlbumRoll; + var charSelectHint:FlxText; + var letterSort:LetterSort; var exitMovers:ExitMoverData = new Map(); - var stickerSubState:StickerSubState; + var exitMoversCharSel:ExitMoverData = new Map(); - public static var rememberedDifficulty:Null = Constants.DEFAULT_DIFFICULTY; + var stickerSubState:Null = null; + + /** + * The difficulty we were on when this menu was last accessed. + */ + public static var rememberedDifficulty:String = Constants.DEFAULT_DIFFICULTY; + + /** + * The song we were on when this menu was last accessed. + * NOTE: `null` if the last song was `Random`. + */ public static var rememberedSongId:Null = 'tutorial'; + /** + * The character we were on when this menu was last accessed. + */ + public static var rememberedCharacterId:String = Constants.DEFAULT_CHARACTER; + var funnyCam:FunkinCamera; var rankCamera:FunkinCamera; var rankBg:FunkinSprite; var rankVignette:FlxSprite; - var backingTextYeah:FlxAtlasSprite; - var orangeBackShit:FunkinSprite; - var alsoOrangeLOL:FunkinSprite; - var pinkBack:FunkinSprite; - var confirmGlow:FlxSprite; - var confirmGlow2:FlxSprite; - var confirmTextGlow:FlxSprite; + var backingCard:Null = null; - var moreWays:BGScrollingText; - var funnyScroll:BGScrollingText; - var txtNuts:BGScrollingText; - var funnyScroll2:BGScrollingText; - var moreWays2:BGScrollingText; - var funnyScroll3:BGScrollingText; - - var bgDad:FlxSprite; - var cardGlow:FlxSprite; + public var bgDad:FlxSprite; var fromResultsParams:Null = null; var prepForNewRank:Bool = false; + var styleData:Null = null; + + var fromCharSelect:Null = null; + public function new(?params:FreeplayStateParams, ?stickers:StickerSubState) { - currentCharacter = params?.character ?? Constants.DEFAULT_CHARACTER; + currentCharacterId = params?.character ?? rememberedCharacterId; + styleData = FreeplayStyleRegistry.instance.fetchEntry(currentCharacterId); + var fetchPlayableCharacter = function():PlayableCharacter { + var targetCharId = params?.character ?? rememberedCharacterId; + var result = PlayerRegistry.instance.fetchEntry(targetCharId); + if (result == null) throw 'No valid playable character with id ${targetCharId}'; + return result; + }; + currentCharacter = fetchPlayableCharacter(); + + styleData = FreeplayStyleRegistry.instance.fetchEntry(currentCharacter.getFreeplayStyleID()); + rememberedCharacterId = currentCharacter?.id ?? Constants.DEFAULT_CHARACTER; + + fromCharSelect = params?.fromCharSelect; fromResultsParams = params?.fromResults; @@ -214,14 +247,50 @@ class FreeplayState extends MusicBeatSubState prepForNewRank = true; } - if (stickers != null) + super(FlxColor.TRANSPARENT); + + if (stickers?.members != null) { stickerSubState = stickers; } - super(FlxColor.TRANSPARENT); + switch (currentCharacterId) + { + case(PlayerRegistry.instance.hasNewCharacter()) => true: + backingCard = new NewCharacterCard(currentCharacter); + case 'bf': + backingCard = new BoyfriendCard(currentCharacter); + case 'pico': + backingCard = new PicoCard(currentCharacter); + default: + backingCard = new BackingCard(currentCharacter); + } + + // We build a bunch of sprites BEFORE create() so we can guarantee they aren't null later on. + albumRoll = new AlbumRoll(); + fp = new FreeplayScore(460, 60, 7, 100, styleData); + rankCamera = new FunkinCamera('rankCamera', 0, 0, FlxG.width, FlxG.height); + funnyCam = new FunkinCamera('freeplayFunny', 0, 0, FlxG.width, FlxG.height); + grpCapsules = new FlxTypedGroup(); + grpDifficulties = new FlxTypedSpriteGroup(-300, 80); + letterSort = new LetterSort(400, 75); + grpSongs = new FlxTypedGroup(); + rankBg = new FunkinSprite(0, 0); + rankVignette = new FlxSprite(0, 0).loadGraphic(Paths.image('freeplay/rankVignette')); + sparks = new FlxSprite(0, 0); + sparksADD = new FlxSprite(0, 0); + txtCompletion = new AtlasText(1185, 87, '69', AtlasFont.FREEPLAY_CLEAR); + + ostName = new FlxText(8, 8, FlxG.width - 8 - 8, 'OFFICIAL OST', 48); + charSelectHint = new FlxText(-40, 18, FlxG.width - 8 - 8, 'Press [ LOL ] to change characters', 32); + + bgDad = new FlxSprite(backingCard.pinkBack.width * 0.74, 0).loadGraphic(styleData == null ? 'freeplay/freeplayBGdad' : styleData.getBgAssetGraphic()); } + var fadeShader:BlueFade = new BlueFade(); + + public var angleMaskShader:AngleMask = new AngleMask(); + override function create():Void { super.create(); @@ -230,11 +299,8 @@ class FreeplayState extends MusicBeatSubState FlxTransitionableState.skipNextTransIn = true; - // dedicated camera for the state so we don't need to fuk around with camera scrolls from the mainmenu / elsewhere - funnyCam = new FunkinCamera('freeplayFunny', 0, 0, FlxG.width, FlxG.height); - funnyCam.bgColor = FlxColor.TRANSPARENT; - FlxG.cameras.add(funnyCam, false); - this.cameras = [funnyCam]; + var fadeShaderFilter:ShaderFilter = new ShaderFilter(fadeShader); + funnyCam.filters = [fadeShaderFilter]; if (stickerSubState != null) { @@ -245,25 +311,19 @@ class FreeplayState extends MusicBeatSubState stickerSubState.degenStickers(); } - #if discord_rpc + #if FEATURE_DISCORD_RPC // Updating Discord Rich Presence DiscordClient.changePresence('In the Menus', null); #end var isDebug:Bool = false; - #if debug + #if FEATURE_DEBUG_FUNCTIONS isDebug = true; #end - if (prepForNewRank == false) - { - FunkinSound.playMusic('freakyMenu', - { - overrideExisting: true, - restartTrack: false - }); - } + // Block input until the intro finishes. + busy = true; // Add a null entry that represents the RANDOM option songs.push(null); @@ -271,7 +331,7 @@ class FreeplayState extends MusicBeatSubState // programmatically adds the songs via LevelRegistry and SongRegistry for (levelId in LevelRegistry.instance.listSortedLevelIds()) { - var level:Level = LevelRegistry.instance.fetchEntry(levelId); + var level:Null = LevelRegistry.instance.fetchEntry(levelId); if (level == null) { @@ -281,7 +341,7 @@ class FreeplayState extends MusicBeatSubState for (songId in level.getSongs()) { - var song:Song = SongRegistry.instance.fetchEntry(songId); + var song:Null = SongRegistry.instance.fetchEntry(songId); if (song == null) { @@ -290,15 +350,15 @@ class FreeplayState extends MusicBeatSubState } // Only display songs which actually have available difficulties for the current character. - var displayedVariations = song.getVariationsByCharId(currentCharacter); - trace(songId); - trace(displayedVariations); - var availableDifficultiesForSong:Array = song.listDifficulties(displayedVariations, false); - trace(availableDifficultiesForSong); + var displayedVariations = song.getVariationsByCharacter(currentCharacter); + trace('Displayed Variations (${songId}): $displayedVariations'); + var availableDifficultiesForSong:Array = song.listSuffixedDifficulties(displayedVariations, false, false); + var unsuffixedDifficulties = song.listDifficulties(displayedVariations, false, false); + trace('Available Difficulties: $availableDifficultiesForSong'); if (availableDifficultiesForSong.length == 0) continue; songs.push(new FreeplaySongData(levelId, songId, song, displayedVariations)); - for (difficulty in availableDifficultiesForSong) + for (difficulty in unsuffixedDifficulties) { diffIdsTotal.pushUnique(difficulty); } @@ -314,152 +374,32 @@ class FreeplayState extends MusicBeatSubState trace(FlxG.camera.initialZoom); trace(FlxCamera.defaultZoom); - pinkBack = FunkinSprite.create('freeplay/pinkBack'); - pinkBack.color = 0xFFFFD4E9; // sets it to pink! - pinkBack.x -= pinkBack.width; + if (backingCard != null) + { + add(backingCard); + backingCard.init(); + backingCard.applyExitMovers(exitMovers, exitMoversCharSel); + backingCard.instance = this; + } - FlxTween.tween(pinkBack, {x: 0}, 0.6, {ease: FlxEase.quartOut}); - add(pinkBack); + if (currentCharacter?.getFreeplayDJData() != null) + { + dj = new FreeplayDJ(640, 366, currentCharacterId); + exitMovers.set([dj], + { + x: -dj.width * 1.6, + speed: 0.5 + }); + add(dj); + exitMoversCharSel.set([dj], + { + y: -175, + speed: 0.8, + wait: 0.1 + }); + } - orangeBackShit = new FunkinSprite(84, 440).makeSolidColor(Std.int(pinkBack.width), 75, 0xFFFEDA00); - add(orangeBackShit); - - alsoOrangeLOL = new FunkinSprite(0, orangeBackShit.y).makeSolidColor(100, Std.int(orangeBackShit.height), 0xFFFFD400); - add(alsoOrangeLOL); - - exitMovers.set([pinkBack, orangeBackShit, alsoOrangeLOL], - { - x: -pinkBack.width, - y: pinkBack.y, - speed: 0.4, - wait: 0 - }); - - FlxSpriteUtil.alphaMaskFlxSprite(orangeBackShit, pinkBack, orangeBackShit); - orangeBackShit.visible = false; - alsoOrangeLOL.visible = false; - - confirmTextGlow = new FlxSprite(-8, 115).loadGraphic(Paths.image('freeplay/glowingText')); - confirmTextGlow.blend = BlendMode.ADD; - confirmTextGlow.visible = false; - - confirmGlow = new FlxSprite(-30, 240).loadGraphic(Paths.image('freeplay/confirmGlow')); - confirmGlow.blend = BlendMode.ADD; - - confirmGlow2 = new FlxSprite(confirmGlow.x, confirmGlow.y).loadGraphic(Paths.image('freeplay/confirmGlow2')); - - confirmGlow.visible = false; - confirmGlow2.visible = false; - - add(confirmGlow2); - add(confirmGlow); - - add(confirmTextGlow); - - var grpTxtScrolls:FlxGroup = new FlxGroup(); - add(grpTxtScrolls); - grpTxtScrolls.visible = false; - - FlxG.debugger.addTrackerProfile(new TrackerProfile(BGScrollingText, ['x', 'y', 'speed', 'size'])); - - moreWays = new BGScrollingText(0, 160, 'HOT BLOODED IN MORE WAYS THAN ONE', FlxG.width, true, 43); - moreWays.funnyColor = 0xFFFFF383; - moreWays.speed = 6.8; - grpTxtScrolls.add(moreWays); - - exitMovers.set([moreWays], - { - x: FlxG.width * 2, - speed: 0.4, - }); - - funnyScroll = new BGScrollingText(0, 220, 'BOYFRIEND', FlxG.width / 2, false, 60); - funnyScroll.funnyColor = 0xFFFF9963; - funnyScroll.speed = -3.8; - grpTxtScrolls.add(funnyScroll); - - exitMovers.set([funnyScroll], - { - x: -funnyScroll.width * 2, - y: funnyScroll.y, - speed: 0.4, - wait: 0 - }); - - txtNuts = new BGScrollingText(0, 285, 'PROTECT YO NUTS', FlxG.width / 2, true, 43); - txtNuts.speed = 3.5; - grpTxtScrolls.add(txtNuts); - exitMovers.set([txtNuts], - { - x: FlxG.width * 2, - speed: 0.4, - }); - - funnyScroll2 = new BGScrollingText(0, 335, 'BOYFRIEND', FlxG.width / 2, false, 60); - funnyScroll2.funnyColor = 0xFFFF9963; - funnyScroll2.speed = -3.8; - grpTxtScrolls.add(funnyScroll2); - - exitMovers.set([funnyScroll2], - { - x: -funnyScroll2.width * 2, - speed: 0.5, - }); - - moreWays2 = new BGScrollingText(0, 397, 'HOT BLOODED IN MORE WAYS THAN ONE', FlxG.width, true, 43); - moreWays2.funnyColor = 0xFFFFF383; - moreWays2.speed = 6.8; - grpTxtScrolls.add(moreWays2); - - exitMovers.set([moreWays2], - { - x: FlxG.width * 2, - speed: 0.4 - }); - - funnyScroll3 = new BGScrollingText(0, orangeBackShit.y + 10, 'BOYFRIEND', FlxG.width / 2, 60); - funnyScroll3.funnyColor = 0xFFFEA400; - funnyScroll3.speed = -3.8; - grpTxtScrolls.add(funnyScroll3); - - exitMovers.set([funnyScroll3], - { - x: -funnyScroll3.width * 2, - speed: 0.3 - }); - - backingTextYeah = new FlxAtlasSprite(640, 370, Paths.animateAtlas("freeplay/backing-text-yeah"), - { - FrameRate: 24.0, - Reversed: false, - // ?OnComplete:Void -> Void, - ShowPivot: false, - Antialiasing: true, - ScrollFactor: new FlxPoint(1, 1), - }); - - add(backingTextYeah); - - cardGlow = new FlxSprite(-30, -30).loadGraphic(Paths.image('freeplay/cardGlow')); - cardGlow.blend = BlendMode.ADD; - cardGlow.visible = false; - - add(cardGlow); - - dj = new DJBoyfriend(640, 366); - exitMovers.set([dj], - { - x: -dj.width * 1.6, - speed: 0.5 - }); - - // TODO: Replace this. - if (currentCharacter == 'pico') dj.visible = false; - - add(dj); - - bgDad = new FlxSprite(pinkBack.width * 0.74, 0).loadGraphic(Paths.image('freeplay/freeplayBGdad')); - bgDad.shader = new AngleMask(); + bgDad.shader = angleMaskShader; bgDad.visible = false; var blackOverlayBullshitLOLXD:FlxSprite = new FlxSprite(FlxG.width).makeGraphic(Std.int(bgDad.width), Std.int(bgDad.height), FlxColor.BLACK); @@ -479,22 +419,25 @@ class FreeplayState extends MusicBeatSubState wait: 0 }); + exitMoversCharSel.set([blackOverlayBullshitLOLXD, bgDad], + { + y: -100, + speed: 0.8, + wait: 0.1 + }); + add(bgDad); - FlxTween.tween(blackOverlayBullshitLOLXD, {x: pinkBack.width * 0.74}, 0.7, {ease: FlxEase.quintOut}); + // backingCard.pinkBack.width * 0.74 blackOverlayBullshitLOLXD.shader = bgDad.shader; - rankBg = new FunkinSprite(0, 0); rankBg.makeSolidColor(FlxG.width, FlxG.height, 0xD3000000); add(rankBg); - grpSongs = new FlxTypedGroup(); add(grpSongs); - grpCapsules = new FlxTypedGroup(); add(grpCapsules); - grpDifficulties = new FlxTypedSpriteGroup(-300, 80); add(grpDifficulties); exitMovers.set([grpDifficulties], @@ -504,6 +447,13 @@ class FreeplayState extends MusicBeatSubState wait: 0 }); + exitMoversCharSel.set([grpDifficulties], + { + y: -270, + speed: 0.8, + wait: 0.1 + }); + for (diffId in diffIdsTotal) { var diffSprite:DifficultySprite = new DifficultySprite(diffId); @@ -521,27 +471,41 @@ class FreeplayState extends MusicBeatSubState if (diffSprite.difficultyId == currentDifficulty) diffSprite.visible = true; } - albumRoll = new AlbumRoll(); albumRoll.albumId = null; add(albumRoll); - albumRoll.applyExitMovers(exitMovers); - - var overhangStuff:FlxSprite = new FlxSprite().makeGraphic(FlxG.width, 64, FlxColor.BLACK); + var overhangStuff:FlxSprite = new FlxSprite().makeGraphic(FlxG.width, 164, FlxColor.BLACK); overhangStuff.y -= overhangStuff.height; - add(overhangStuff); - FlxTween.tween(overhangStuff, {y: 0}, 0.3, {ease: FlxEase.quartOut}); + + if (fromCharSelect == true) + { + blackOverlayBullshitLOLXD.x = 387.76; + overhangStuff.y = -100; + backingCard?.skipIntroTween(); + } + else + { + albumRoll.applyExitMovers(exitMovers, exitMoversCharSel); + FlxTween.tween(overhangStuff, {y: -100}, 0.3, {ease: FlxEase.quartOut}); + FlxTween.tween(blackOverlayBullshitLOLXD, {x: 387.76}, 0.7, {ease: FlxEase.quintOut}); + } var fnfFreeplay:FlxText = new FlxText(8, 8, 0, 'FREEPLAY', 48); fnfFreeplay.font = 'VCR OSD Mono'; fnfFreeplay.visible = false; - ostName = new FlxText(8, 8, FlxG.width - 8 - 8, 'OFFICIAL OST', 48); ostName.font = 'VCR OSD Mono'; ostName.alignment = RIGHT; ostName.visible = false; - exitMovers.set([overhangStuff, fnfFreeplay, ostName], + charSelectHint.alignment = CENTER; + charSelectHint.font = "5by7"; + charSelectHint.color = 0xFF5F5F5F; + charSelectHint.text = 'Press [ ${controls.getDialogueNameFromControl(FREEPLAY_CHAR_SELECT, true)} ] to change characters'; + charSelectHint.y -= 100; + FlxTween.tween(charSelectHint, {y: charSelectHint.y + 100}, 0.8, {ease: FlxEase.quartOut}); + + exitMovers.set([overhangStuff, fnfFreeplay, ostName, charSelectHint], { y: -overhangStuff.height, x: 0, @@ -549,11 +513,16 @@ class FreeplayState extends MusicBeatSubState wait: 0 }); + exitMoversCharSel.set([overhangStuff, fnfFreeplay, ostName, charSelectHint], + { + y: -300, + speed: 0.8, + wait: 0.1 + }); + var sillyStroke:StrokeShader = new StrokeShader(0xFFFFFFFF, 2, 2); fnfFreeplay.shader = sillyStroke; ostName.shader = sillyStroke; - add(fnfFreeplay); - add(ostName); var fnfHighscoreSpr:FlxSprite = new FlxSprite(860, 70); fnfHighscoreSpr.frames = Paths.getSparrowAtlas('freeplay/highscore'); @@ -568,7 +537,6 @@ class FreeplayState extends MusicBeatSubState tmr.time = FlxG.random.float(20, 60); }, 0); - fp = new FreeplayScore(460, 60, 7, 100); fp.visible = false; add(fp); @@ -576,11 +544,9 @@ class FreeplayState extends MusicBeatSubState clearBoxSprite.visible = false; add(clearBoxSprite); - txtCompletion = new AtlasText(1185, 87, '69', AtlasFont.FREEPLAY_CLEAR); txtCompletion.visible = false; add(txtCompletion); - letterSort = new LetterSort(400, 75); add(letterSort); letterSort.visible = false; @@ -590,6 +556,13 @@ class FreeplayState extends MusicBeatSubState speed: 0.3 }); + exitMoversCharSel.set([letterSort], + { + y: -270, + speed: 0.8, + wait: 0.1 + }); + letterSort.changeSelectionCallback = (str) -> { switch (str) { @@ -619,19 +592,54 @@ class FreeplayState extends MusicBeatSubState speed: 0.3 }); - var diffSelLeft:DifficultySelector = new DifficultySelector(20, grpDifficulties.y - 10, false, controls); - var diffSelRight:DifficultySelector = new DifficultySelector(325, grpDifficulties.y - 10, true, controls); + exitMoversCharSel.set([fp, txtCompletion, fnfHighscoreSpr, txtCompletion, clearBoxSprite], + { + y: -270, + speed: 0.8, + wait: 0.1 + }); + + var diffSelLeft:DifficultySelector = new DifficultySelector(this, 20, grpDifficulties.y - 10, false, controls, styleData); + var diffSelRight:DifficultySelector = new DifficultySelector(this, 325, grpDifficulties.y - 10, true, controls, styleData); diffSelLeft.visible = false; diffSelRight.visible = false; add(diffSelLeft); add(diffSelRight); + // putting these here to fix the layering + add(overhangStuff); + add(fnfFreeplay); + add(ostName); + + if (PlayerRegistry.instance.hasNewCharacter() == true) + { + add(charSelectHint); + } + // be careful not to "add()" things in here unless it's to a group that's already added to the state // otherwise it won't be properly attatched to funnyCamera (relavent code should be at the bottom of create()) - dj.onIntroDone.add(function() { + var onDJIntroDone = function() { + busy = false; + // when boyfriend hits dat shiii albumRoll.playIntro(); + var daSong = grpCapsules.members[curSelected].songData; + albumRoll.albumId = daSong?.albumId; + + if (fromCharSelect == null) + { + // render optimisation + if (_parentState != null) _parentState.persistentDraw = false; + + FlxTween.color(bgDad, 0.6, 0xFF000000, 0xFFFFFFFF, + { + ease: FlxEase.expoOut, + onUpdate: function(_) { + angleMaskShader.extraColor = bgDad.color; + } + }); + } FlxTween.tween(grpDifficulties, {x: 90}, 0.6, {ease: FlxEase.quartOut}); @@ -645,6 +653,13 @@ class FreeplayState extends MusicBeatSubState speed: 0.26 }); + exitMoversCharSel.set([diffSelLeft, diffSelRight], + { + y: -270, + speed: 0.8, + wait: 0.1 + }); + new FlxTimer().start(1 / 24, function(handShit) { fnfHighscoreSpr.visible = true; fnfFreeplay.visible = true; @@ -663,32 +678,30 @@ class FreeplayState extends MusicBeatSubState }); }); - pinkBack.color = 0xFFFFD863; bgDad.visible = true; - orangeBackShit.visible = true; - alsoOrangeLOL.visible = true; - grpTxtScrolls.visible = true; + backingCard?.introDone(); - // render optimisation - if (_parentState != null) _parentState.persistentDraw = false; - - cardGlow.visible = true; - FlxTween.tween(cardGlow, {alpha: 0, "scale.x": 1.2, "scale.y": 1.2}, 0.45, {ease: FlxEase.sineOut}); - - if (prepForNewRank) + if (prepForNewRank && fromResultsParams != null) { rankAnimStart(fromResultsParams); } - }); + }; + + if (dj != null) + { + dj.onIntroDone.add(onDJIntroDone); + } + else + { + onDJIntroDone(); + } generateSongList(null, false); // dedicated camera for the state so we don't need to fuk around with camera scrolls from the mainmenu / elsewhere - funnyCam = new FunkinCamera('freeplayFunny', 0, 0, FlxG.width, FlxG.height); funnyCam.bgColor = FlxColor.TRANSPARENT; FlxG.cameras.add(funnyCam, false); - rankVignette = new FlxSprite(0, 0).loadGraphic(Paths.image('freeplay/rankVignette')); rankVignette.scale.set(2, 2); rankVignette.updateHitbox(); rankVignette.blend = BlendMode.ADD; @@ -700,7 +713,6 @@ class FreeplayState extends MusicBeatSubState bs.cameras = [funnyCam]; }); - rankCamera = new FunkinCamera('rankCamera', 0, 0, FlxG.width, FlxG.height); rankCamera.bgColor = FlxColor.TRANSPARENT; FlxG.cameras.add(rankCamera, false); rankBg.cameras = [rankCamera]; @@ -710,10 +722,16 @@ class FreeplayState extends MusicBeatSubState { rankCamera.fade(0xFF000000, 0, false, null, true); } + + if (fromCharSelect == true) + { + enterFromCharSel(); + onDJIntroDone(); + } } - var currentFilter:SongFilter = null; - var currentFilteredSongs:Array = []; + var currentFilter:Null = null; + var currentFilteredSongs:Array> = []; /** * Given the current filter, rebuild the current song list. @@ -724,13 +742,10 @@ class FreeplayState extends MusicBeatSubState */ public function generateSongList(filterStuff:Null, force:Bool = false, onlyIfChanged:Bool = true):Void { - var tempSongs:Array = songs; + var tempSongs:Array> = songs; // Remember just the difficulty because it's important for song sorting. - if (rememberedDifficulty != null) - { - currentDifficulty = rememberedDifficulty; - } + currentDifficulty = rememberedDifficulty; if (filterStuff != null) tempSongs = sortSongs(tempSongs, filterStuff); @@ -768,7 +783,7 @@ class FreeplayState extends MusicBeatSubState var hsvShader:HSVShader = new HSVShader(); var randomCapsule:SongMenuItem = grpCapsules.recycle(SongMenuItem); - randomCapsule.init(FlxG.width, 0, null); + randomCapsule.init(FlxG.width, 0, null, styleData); randomCapsule.onConfirm = function() { capsuleOnConfirmRandom(randomCapsule); }; @@ -780,27 +795,35 @@ class FreeplayState extends MusicBeatSubState randomCapsule.favIconBlurred.visible = false; randomCapsule.ranking.visible = false; randomCapsule.blurredRanking.visible = false; - randomCapsule.initJumpIn(0, force); + if (fromCharSelect == false) + { + randomCapsule.initJumpIn(0, force); + } + else + { + randomCapsule.forcePosition(); + } randomCapsule.hsvShader = hsvShader; grpCapsules.add(randomCapsule); for (i in 0...tempSongs.length) { - if (tempSongs[i] == null) continue; + var tempSong = tempSongs[i]; + if (tempSong == null) continue; var funnyMenu:SongMenuItem = grpCapsules.recycle(SongMenuItem); - funnyMenu.init(FlxG.width, 0, tempSongs[i]); + funnyMenu.init(FlxG.width, 0, tempSong, styleData); funnyMenu.onConfirm = function() { - capsuleOnConfirmDefault(funnyMenu); + capsuleOnOpenDefault(funnyMenu); }; funnyMenu.y = funnyMenu.intendedY(i + 1) + 10; funnyMenu.targetPos.x = funnyMenu.x; funnyMenu.ID = i; funnyMenu.capsule.alpha = 0.5; funnyMenu.songText.visible = false; - funnyMenu.favIcon.visible = tempSongs[i].isFav; - funnyMenu.favIconBlurred.visible = tempSongs[i].isFav; + funnyMenu.favIcon.visible = tempSong.isFav; + funnyMenu.favIconBlurred.visible = tempSong.isFav; funnyMenu.hsvShader = hsvShader; funnyMenu.newText.animation.curAnim.curFrame = 45 - ((i * 4) % 45); @@ -824,13 +847,10 @@ class FreeplayState extends MusicBeatSubState * @param songFilter The filter to apply * @return Array */ - public function sortSongs(songsToFilter:Array, songFilter:SongFilter):Array + public function sortSongs(songsToFilter:Array>, songFilter:SongFilter):Array> { - var filterAlphabetically = function(a:FreeplaySongData, b:FreeplaySongData):Int { - if (a?.songName.toLowerCase() < b?.songName.toLowerCase()) return -1; - else if (a?.songName.toLowerCase() > b?.songName.toLowerCase()) return 1; - else - return 0; + var filterAlphabetically = function(a:Null, b:Null):Int { + return SortUtil.alphabetically(a?.songName ?? '', b?.songName ?? ''); }; switch (songFilter.filterType) @@ -854,10 +874,10 @@ class FreeplayState extends MusicBeatSubState songsToFilter = songsToFilter.filter(str -> { if (str == null) return true; // Random - return str.songName.toLowerCase().startsWith(songFilter.filterData); + return str.songName.toLowerCase().startsWith(songFilter.filterData ?? ''); }); case ALL: - // no filter! + // no filter! case FAVORITE: songsToFilter = songsToFilter.filter(str -> { if (str == null) return true; // Random @@ -876,32 +896,36 @@ class FreeplayState extends MusicBeatSubState var sparks:FlxSprite; var sparksADD:FlxSprite; - function rankAnimStart(fromResults:Null):Void + function rankAnimStart(fromResults:FromResultsParams):Void { busy = true; grpCapsules.members[curSelected].sparkle.alpha = 0; // grpCapsules.members[curSelected].forcePosition(); - if (fromResults != null) + rememberedSongId = fromResults.songId; + rememberedDifficulty = fromResults.difficultyId; + changeSelection(); + changeDiff(); + + if (fromResultsParams?.newRank == SHIT) { - rememberedSongId = fromResults.songId; - rememberedDifficulty = fromResults.difficultyId; - changeSelection(); - changeDiff(); + if (dj != null) dj.fistPumpLossIntro(); + } + else + { + if (dj != null) dj.fistPumpIntro(); } - dj.fistPump(); // rankCamera.fade(FlxColor.BLACK, 0.5, true); rankCamera.fade(0xFF000000, 0.5, true, null, true); if (FlxG.sound.music != null) FlxG.sound.music.volume = 0; rankBg.alpha = 1; - if (fromResults?.oldRank != null) + if (fromResults.oldRank != null) { grpCapsules.members[curSelected].fakeRanking.rank = fromResults.oldRank; grpCapsules.members[curSelected].fakeBlurredRanking.rank = fromResults.oldRank; - sparks = new FlxSprite(0, 0); sparks.frames = Paths.getSparrowAtlas('freeplay/sparks'); sparks.animation.addByPrefix('sparks', 'sparks', 24, false); sparks.visible = false; @@ -911,7 +935,6 @@ class FreeplayState extends MusicBeatSubState add(sparks); sparks.cameras = [rankCamera]; - sparksADD = new FlxSprite(0, 0); sparksADD.visible = false; sparksADD.frames = Paths.getSparrowAtlas('freeplay/sparksadd'); sparksADD.animation.addByPrefix('sparks add', 'sparks add', 24, false); @@ -976,14 +999,14 @@ class FreeplayState extends MusicBeatSubState grpCapsules.members[curSelected].ranking.scale.set(20, 20); grpCapsules.members[curSelected].blurredRanking.scale.set(20, 20); - if (fromResults?.newRank != null) + if (fromResults != null && fromResults.newRank != null) { grpCapsules.members[curSelected].ranking.animation.play(fromResults.newRank.getFreeplayRankIconAsset(), true); } FlxTween.tween(grpCapsules.members[curSelected].ranking, {"scale.x": 1, "scale.y": 1}, 0.1); - if (fromResults?.newRank != null) + if (fromResults != null && fromResults.newRank != null) { grpCapsules.members[curSelected].blurredRanking.animation.play(fromResults.newRank.getFreeplayRankIconAsset(), true); } @@ -1074,11 +1097,11 @@ class FreeplayState extends MusicBeatSubState if (fromResultsParams?.newRank == SHIT) { - dj.pumpFistBad(); + if (dj != null) dj.fistPumpLoss(); } else { - dj.pumpFist(); + if (dj != null) dj.fistPump(); } rankCamera.zoom = 0.8; @@ -1110,7 +1133,7 @@ class FreeplayState extends MusicBeatSubState // NOW we can interact with the menu busy = false; - grpCapsules.members[curSelected].sparkle.alpha = 0.7; + capsule.sparkle.alpha = 0.7; playCurSongPreview(capsule); }, null); @@ -1164,6 +1187,158 @@ class FreeplayState extends MusicBeatSubState }); } + function tryOpenCharSelect():Void + { + // Check if we have ACCESS to character select! + trace('Is Pico unlocked? ${PlayerRegistry.instance.fetchEntry('pico')?.isUnlocked()}'); + trace('Number of characters: ${PlayerRegistry.instance.countUnlockedCharacters()}'); + + if (PlayerRegistry.instance.countUnlockedCharacters() > 1) + { + trace('Opening character select!'); + } + else + { + trace('Not enough characters unlocked to open character select!'); + FunkinSound.playOnce(Paths.sound('cancelMenu')); + return; + } + + busy = true; + + FunkinSound.playOnce(Paths.sound('confirmMenu')); + + if (dj != null) + { + dj.toCharSelect(); + } + + // Get this character's transition delay, with a reasonable default. + var transitionDelay:Float = currentCharacter.getFreeplayDJData()?.getCharSelectTransitionDelay() ?? 0.25; + + new FlxTimer().start(transitionDelay, _ -> { + transitionToCharSelect(); + }); + } + + function transitionToCharSelect():Void + { + var transitionGradient = new FlxSprite(0, 720).loadGraphic(Paths.image('freeplay/transitionGradient')); + transitionGradient.scale.set(1280, 1); + transitionGradient.updateHitbox(); + transitionGradient.cameras = [rankCamera]; + exitMoversCharSel.set([transitionGradient], + { + y: -720, + speed: 0.8, + wait: 0.1 + }); + add(transitionGradient); + for (index => capsule in grpCapsules.members) + { + var distFromSelected:Float = Math.abs(index - curSelected) - 1; + if (distFromSelected < 5) + { + capsule.doLerp = false; + exitMoversCharSel.set([capsule], + { + y: -250, + speed: 0.8, + wait: 0.1 + }); + } + } + fadeShader.fade(1.0, 0.0, 0.8, {ease: FlxEase.quadIn}); + FlxG.sound.music.fadeOut(0.9, 0); + new FlxTimer().start(0.9, _ -> { + FlxG.switchState(new funkin.ui.charSelect.CharSelectSubState()); + }); + for (grpSpr in exitMoversCharSel.keys()) + { + var moveData:Null = exitMoversCharSel.get(grpSpr); + if (moveData == null) continue; + + for (spr in grpSpr) + { + if (spr == null) continue; + + var funnyMoveShit:MoveData = moveData; + + var moveDataY = funnyMoveShit.y ?? spr.y; + var moveDataSpeed = funnyMoveShit.speed ?? 0.2; + var moveDataWait = funnyMoveShit.wait ?? 0.0; + + FlxTween.tween(spr, {y: moveDataY + spr.y}, moveDataSpeed, {ease: FlxEase.backIn}); + } + } + backingCard?.enterCharSel(); + } + + function enterFromCharSel():Void + { + busy = true; + if (_parentState != null) _parentState.persistentDraw = false; + + var transitionGradient = new FlxSprite(0, 720).loadGraphic(Paths.image('freeplay/transitionGradient')); + transitionGradient.scale.set(1280, 1); + transitionGradient.updateHitbox(); + transitionGradient.cameras = [rankCamera]; + exitMoversCharSel.set([transitionGradient], + { + y: -720, + speed: 1.5, + wait: 0.1 + }); + add(transitionGradient); + // FlxTween.tween(transitionGradient, {alpha: 0}, 1, {ease: FlxEase.circIn}); + // for (index => capsule in grpCapsules.members) + // { + // var distFromSelected:Float = Math.abs(index - curSelected) - 1; + // if (distFromSelected < 5) + // { + // capsule.doLerp = false; + // exitMoversCharSel.set([capsule], + // { + // y: -250, + // speed: 0.8, + // wait: 0.1 + // }); + // } + // } + fadeShader.fade(0.0, 1.0, 0.8, {ease: FlxEase.quadIn}); + for (grpSpr in exitMoversCharSel.keys()) + { + var moveData:Null = exitMoversCharSel.get(grpSpr); + if (moveData == null) continue; + + for (spr in grpSpr) + { + if (spr == null) continue; + + var funnyMoveShit:MoveData = moveData; + + var moveDataY = funnyMoveShit.y ?? spr.y; + var moveDataSpeed = funnyMoveShit.speed ?? 0.2; + var moveDataWait = funnyMoveShit.wait ?? 0.0; + + spr.y += moveDataY; + FlxTween.tween(spr, {y: spr.y - moveDataY}, moveDataSpeed * 1.2, + { + ease: FlxEase.expoOut, + onComplete: function(_) { + for (index => capsule in grpCapsules.members) + { + capsule.doLerp = true; + fromCharSelect = false; + busy = false; + albumRoll.applyExitMovers(exitMovers, exitMoversCharSel); + } + } + }); + } + } + } + var touchY:Float = 0; var touchX:Float = 0; var dxTouch:Float = 0; @@ -1181,18 +1356,43 @@ class FreeplayState extends MusicBeatSubState /** * If true, disable interaction with the interface. */ - var busy:Bool = false; + public var busy:Bool = false; var originalPos:FlxPoint = new FlxPoint(); + var hintTimer:Float = 0; + override function update(elapsed:Float):Void { super.update(elapsed); - #if debug + if (charSelectHint != null) + { + hintTimer += elapsed * 2; + var targetAmt:Float = (Math.sin(hintTimer) + 1) / 2; + charSelectHint.alpha = FlxMath.lerp(0.3, 0.9, targetAmt); + } + + #if FEATURE_DEBUG_FUNCTIONS + if (FlxG.keys.justPressed.P) + { + FlxG.switchState(FreeplayState.build( + { + { + character: currentCharacterId == "pico" ? Constants.DEFAULT_CHARACTER : "pico", + } + })); + } + if (FlxG.keys.justPressed.T) { - rankAnimStart(fromResultsParams); + rankAnimStart(fromResultsParams ?? + { + playRankAnim: true, + newRank: PERFECT_GOLD, + songId: "tutorial", + difficultyId: "hard" + }); } // if (FlxG.keys.justPressed.H) @@ -1204,7 +1404,12 @@ class FreeplayState extends MusicBeatSubState // { // rankAnimSlam(fromResultsParams); // } - #end + #end // ^<-- FEATURE_DEBUG_FUNCTIONS + + if (controls.FREEPLAY_CHAR_SELECT && !busy) + { + tryOpenCharSelect(); + } if (controls.FREEPLAY_FAVORITE && !busy) { @@ -1296,15 +1501,17 @@ class FreeplayState extends MusicBeatSubState } handleInputs(elapsed); + + if (dj != null) FlxG.watch.addQuick('dj-anim', dj.getCurrentAnimation()); } function handleInputs(elapsed:Float):Void { if (busy) return; - var upP:Bool = controls.UI_UP_P && !FlxG.keys.pressed.CONTROL; - var downP:Bool = controls.UI_DOWN_P && !FlxG.keys.pressed.CONTROL; - var accepted:Bool = controls.ACCEPT && !FlxG.keys.pressed.CONTROL; + var upP:Bool = controls.UI_UP_P; + var downP:Bool = controls.UI_DOWN_P; + var accepted:Bool = controls.ACCEPT; if (FlxG.onMobile) { @@ -1378,7 +1585,7 @@ class FreeplayState extends MusicBeatSubState } #end - if (!FlxG.keys.pressed.CONTROL && (controls.UI_UP || controls.UI_DOWN)) + if ((controls.UI_UP || controls.UI_DOWN)) { if (spamming) { @@ -1413,7 +1620,7 @@ class FreeplayState extends MusicBeatSubState } spamTimer += elapsed; - dj.resetAFKTimer(); + if (dj != null) dj.resetAFKTimer(); } else { @@ -1424,67 +1631,52 @@ class FreeplayState extends MusicBeatSubState #if !html5 if (FlxG.mouse.wheel != 0) { - dj.resetAFKTimer(); + if (dj != null) dj.resetAFKTimer(); changeSelection(-Math.round(FlxG.mouse.wheel)); } #else if (FlxG.mouse.wheel < 0) { - dj.resetAFKTimer(); + if (dj != null) dj.resetAFKTimer(); changeSelection(-Math.round(FlxG.mouse.wheel / 8)); } else if (FlxG.mouse.wheel > 0) { - dj.resetAFKTimer(); + if (dj != null) dj.resetAFKTimer(); changeSelection(-Math.round(FlxG.mouse.wheel / 8)); } #end - if (controls.UI_LEFT_P && !FlxG.keys.pressed.CONTROL) + if (controls.UI_LEFT_P) { - dj.resetAFKTimer(); + if (dj != null) dj.resetAFKTimer(); changeDiff(-1); generateSongList(currentFilter, true); } - if (controls.UI_RIGHT_P && !FlxG.keys.pressed.CONTROL) + if (controls.UI_RIGHT_P) { - dj.resetAFKTimer(); + if (dj != null) dj.resetAFKTimer(); changeDiff(1); generateSongList(currentFilter, true); } - if (controls.BACK) + if (controls.BACK && !busy) { busy = true; FlxTween.globalManager.clear(); FlxTimer.globalManager.clear(); - dj.onIntroDone.removeAll(); + if (dj != null) dj.onIntroDone.removeAll(); FunkinSound.playOnce(Paths.sound('cancelMenu')); var longestTimer:Float = 0; - // FlxTween.color(bgDad, 0.33, 0xFFFFFFFF, 0xFF555555, {ease: FlxEase.quadOut}); - FlxTween.color(pinkBack, 0.25, 0xFFFFD863, 0xFFFFD0D5, {ease: FlxEase.quadOut}); - - cardGlow.visible = true; - cardGlow.alpha = 1; - cardGlow.scale.set(1, 1); - FlxTween.tween(cardGlow, {alpha: 0, "scale.x": 1.2, "scale.y": 1.2}, 0.25, {ease: FlxEase.sineOut}); - - orangeBackShit.visible = false; - alsoOrangeLOL.visible = false; - - moreWays.visible = false; - funnyScroll.visible = false; - txtNuts.visible = false; - funnyScroll2.visible = false; - moreWays2.visible = false; - funnyScroll3.visible = false; + backingCard?.disappear(); for (grpSpr in exitMovers.keys()) { - var moveData:MoveData = exitMovers.get(grpSpr); + var moveData:Null = exitMovers.get(grpSpr); + if (moveData == null) continue; for (spr in grpSpr) { @@ -1492,14 +1684,14 @@ class FreeplayState extends MusicBeatSubState var funnyMoveShit:MoveData = moveData; - if (moveData.x == null) funnyMoveShit.x = spr.x; - if (moveData.y == null) funnyMoveShit.y = spr.y; - if (moveData.speed == null) funnyMoveShit.speed = 0.2; - if (moveData.wait == null) funnyMoveShit.wait = 0; + var moveDataX = funnyMoveShit.x ?? spr.x; + var moveDataY = funnyMoveShit.y ?? spr.y; + var moveDataSpeed = funnyMoveShit.speed ?? 0.2; + var moveDataWait = funnyMoveShit.wait ?? 0.0; - FlxTween.tween(spr, {x: funnyMoveShit.x, y: funnyMoveShit.y}, funnyMoveShit.speed, {ease: FlxEase.expoIn}); + FlxTween.tween(spr, {x: moveDataX, y: moveDataY}, moveDataSpeed, {ease: FlxEase.expoIn}); - longestTimer = Math.max(longestTimer, funnyMoveShit.speed + funnyMoveShit.wait); + longestTimer = Math.max(longestTimer, moveDataSpeed + moveDataWait); } } @@ -1542,6 +1734,13 @@ class FreeplayState extends MusicBeatSubState } } + override function beatHit():Bool + { + backingCard?.beatHit(); + + return super.beatHit(); + } + public override function destroy():Void { super.destroy(); @@ -1572,27 +1771,27 @@ class FreeplayState extends MusicBeatSubState var daSong:Null = grpCapsules.members[curSelected].songData; if (daSong != null) { - // TODO: Make this actually be the variation you're focused on. We don't need to fetch the song metadata just to calculate it. - var targetSong:Song = SongRegistry.instance.fetchEntry(grpCapsules.members[curSelected].songData.songId); + var targetSong:Null = SongRegistry.instance.fetchEntry(daSong.songId); if (targetSong == null) { - FlxG.log.warn('WARN: could not find song with id (${grpCapsules.members[curSelected].songData.songId})'); + FlxG.log.warn('WARN: could not find song with id (${daSong.songId})'); return; } - var targetVariation:String = targetSong.getFirstValidVariation(currentDifficulty); + var targetVariation:String = targetSong.getFirstValidVariation(currentDifficulty) ?? ''; // TODO: This line of code makes me sad, but you can't really fix it without a breaking migration. var suffixedDifficulty = (targetVariation != Constants.DEFAULT_VARIATION && targetVariation != 'erect') ? '$currentDifficulty-${targetVariation}' : currentDifficulty; - var songScore:SaveScoreData = Save.instance.getSongScore(grpCapsules.members[curSelected].songData.songId, suffixedDifficulty); + var songScore:Null = Save.instance.getSongScore(daSong.songId, suffixedDifficulty); intendedScore = songScore?.score ?? 0; intendedCompletion = songScore == null ? 0.0 : ((songScore.tallies.sick + songScore.tallies.good) / songScore.tallies.totalNotes); - rememberedDifficulty = currentDifficulty; + rememberedDifficulty = suffixedDifficulty; } else { intendedScore = 0; intendedCompletion = 0.0; + rememberedDifficulty = currentDifficulty; } if (intendedCompletion == Math.POSITIVE_INFINITY || intendedCompletion == Math.NEGATIVE_INFINITY || Math.isNaN(intendedCompletion)) @@ -1643,10 +1842,13 @@ class FreeplayState extends MusicBeatSubState songCapsule.init(null, null, null); } } + + // Reset the song preview in case we changed variations (normal->erect etc) + playCurSongPreview(); } // Set the album graphic and play the animation if relevant. - var newAlbumId:String = daSong?.albumId; + var newAlbumId:Null = daSong?.albumId; if (albumRoll.albumId != newAlbumId) { albumRoll.albumId = newAlbumId; @@ -1684,7 +1886,7 @@ class FreeplayState extends MusicBeatSubState }); trace('Available songs: ${availableSongCapsules.map(function(cap) { - return cap.songData.songName; + return cap?.songData?.songName; })}'); if (availableSongCapsules.length == 0) @@ -1706,78 +1908,131 @@ class FreeplayState extends MusicBeatSubState capsuleOnConfirmDefault(targetSong); } - function capsuleOnConfirmDefault(cap:SongMenuItem):Void + /** + * Called when hitting ENTER to open the instrumental list. + */ + function capsuleOnOpenDefault(cap:SongMenuItem):Void { - busy = true; - letterSort.inputEnabled = false; - - PlayStatePlaylist.isStoryMode = false; - - var targetSong:Song = SongRegistry.instance.fetchEntry(cap.songData.songId); - if (targetSong == null) + var targetSongId:String = cap?.songData?.songId ?? 'unknown'; + var targetSongNullable:Null = SongRegistry.instance.fetchEntry(targetSongId); + if (targetSongNullable == null) { - FlxG.log.warn('WARN: could not find song with id (${cap.songData.songId})'); + FlxG.log.warn('WARN: could not find song with id (${targetSongId})'); return; } + var targetSong:Song = targetSongNullable; var targetDifficultyId:String = currentDifficulty; - var targetVariation:String = targetSong.getFirstValidVariation(targetDifficultyId); - PlayStatePlaylist.campaignId = cap.songData.levelId; + var targetVariation:Null = targetSong.getFirstValidVariation(targetDifficultyId, currentCharacter); + var targetLevelId:Null = cap?.songData?.levelId; + PlayStatePlaylist.campaignId = targetLevelId ?? null; - var targetDifficulty:SongDifficulty = targetSong.getDifficulty(targetDifficultyId, targetVariation); + var targetDifficulty:Null = targetSong.getDifficulty(targetDifficultyId, targetVariation); if (targetDifficulty == null) { FlxG.log.warn('WARN: could not find difficulty with id (${targetDifficultyId})'); return; } - // TODO: Change this with alternate instrumentals - var targetInstId:String = targetDifficulty.characters.instrumental; + trace('target difficulty: ${targetDifficultyId}'); + trace('target variation: ${targetDifficulty?.variation ?? Constants.DEFAULT_VARIATION}'); + + var baseInstrumentalId:String = targetSong.getBaseInstrumentalId(targetDifficultyId, targetDifficulty?.variation ?? Constants.DEFAULT_VARIATION) ?? ''; + var altInstrumentalIds:Array = targetSong.listAltInstrumentalIds(targetDifficultyId, + targetDifficulty?.variation ?? Constants.DEFAULT_VARIATION) ?? []; + + if (altInstrumentalIds.length > 0) + { + var instrumentalIds = [baseInstrumentalId].concat(altInstrumentalIds); + openInstrumentalList(cap, instrumentalIds); + } + else + { + trace('NO ALTS'); + capsuleOnConfirmDefault(cap); + } + } + + public function getControls():Controls + { + return controls; + } + + function openInstrumentalList(cap:SongMenuItem, instrumentalIds:Array):Void + { + busy = true; + + capsuleOptionsMenu = new CapsuleOptionsMenu(this, cap.x + 175, cap.y + 115, instrumentalIds); + capsuleOptionsMenu.cameras = [funnyCam]; + capsuleOptionsMenu.zIndex = 10000; + add(capsuleOptionsMenu); + + capsuleOptionsMenu.onConfirm = function(targetInstId:String) { + capsuleOnConfirmDefault(cap, targetInstId); + }; + } + + var capsuleOptionsMenu:Null = null; + + public function cleanupCapsuleOptionsMenu():Void + { + this.busy = false; + + if (capsuleOptionsMenu != null) + { + remove(capsuleOptionsMenu); + capsuleOptionsMenu = null; + } + } + + /** + * Called when hitting ENTER to play the song. + */ + function capsuleOnConfirmDefault(cap:SongMenuItem, ?targetInstId:String):Void + { + busy = true; + letterSort.inputEnabled = false; + + PlayStatePlaylist.isStoryMode = false; + + var targetSongId:String = cap?.songData?.songId ?? 'unknown'; + var targetSongNullable:Null = SongRegistry.instance.fetchEntry(targetSongId); + if (targetSongNullable == null) + { + FlxG.log.warn('WARN: could not find song with id (${targetSongId})'); + return; + } + var targetSong:Song = targetSongNullable; + var targetDifficultyId:String = currentDifficulty; + var targetVariation:Null = targetSong.getFirstValidVariation(targetDifficultyId, currentCharacter); + var targetLevelId:Null = cap?.songData?.levelId; + PlayStatePlaylist.campaignId = targetLevelId ?? null; + + var targetDifficulty:Null = targetSong.getDifficulty(targetDifficultyId, targetVariation); + if (targetDifficulty == null) + { + FlxG.log.warn('WARN: could not find difficulty with id (${targetDifficultyId})'); + return; + } + + if (targetInstId == null) + { + var baseInstrumentalId:String = targetSong?.getBaseInstrumentalId(targetDifficultyId, targetDifficulty.variation ?? Constants.DEFAULT_VARIATION) ?? ''; + targetInstId = baseInstrumentalId; + } // Visual and audio effects. FunkinSound.playOnce(Paths.sound('confirmMenu')); - dj.confirm(); + if (dj != null) dj.confirm(); grpCapsules.members[curSelected].forcePosition(); - grpCapsules.members[curSelected].songText.flickerText(); + grpCapsules.members[curSelected].confirm(); - // FlxTween.color(bgDad, 0.33, 0xFFFFFFFF, 0xFF555555, {ease: FlxEase.quadOut}); - FlxTween.color(pinkBack, 0.33, 0xFFFFD0D5, 0xFF171831, {ease: FlxEase.quadOut}); - orangeBackShit.visible = false; - alsoOrangeLOL.visible = false; + backingCard?.confirm(); - confirmGlow.visible = true; - confirmGlow2.visible = true; - - backingTextYeah.anim.play("BF back card confirm raw", false, false, 0); - confirmGlow2.alpha = 0; - confirmGlow.alpha = 0; - - FlxTween.tween(confirmGlow2, {alpha: 0.5}, 0.33, - { - ease: FlxEase.quadOut, - onComplete: function(_) { - confirmGlow2.alpha = 0.6; - confirmGlow.alpha = 1; - confirmTextGlow.visible = true; - confirmTextGlow.alpha = 1; - FlxTween.tween(confirmTextGlow, {alpha: 0.4}, 0.5); - FlxTween.tween(confirmGlow, {alpha: 0}, 0.5); - } - }); - - // confirmGlow - - moreWays.visible = false; - funnyScroll.visible = false; - txtNuts.visible = false; - funnyScroll2.visible = false; - moreWays2.visible = false; - funnyScroll3.visible = false; - - new FlxTimer().start(1, function(tmr:FlxTimer) { + new FlxTimer().start(styleData?.getStartDelay(), function(tmr:FlxTimer) { FunkinSound.emptyPartialQueue(); - Paths.setCurrentLevel(cap.songData.levelId); + Paths.setCurrentLevel(cap?.songData?.levelId); LoadingState.loadPlayState( { targetSong: targetSong, @@ -1787,7 +2042,7 @@ class FreeplayState extends MusicBeatSubState practiceMode: false, minimalMode: false, - #if (debug || FORCE_DEBUG_VERSION) + #if FEATURE_DEBUG_FUNCTIONS botPlayMode: FlxG.keys.pressed.SHIFT, #else botPlayMode: false, @@ -1832,7 +2087,7 @@ class FreeplayState extends MusicBeatSubState var daSongCapsule:SongMenuItem = grpCapsules.members[curSelected]; if (daSongCapsule.songData != null) { - var songScore:SaveScoreData = Save.instance.getSongScore(daSongCapsule.songData.songId, currentDifficulty); + var songScore:Null = Save.instance.getSongScore(daSongCapsule.songData.songId, currentDifficulty); intendedScore = songScore?.score ?? 0; intendedCompletion = songScore == null ? 0.0 : ((songScore.tallies.sick + songScore.tallies.good) / songScore.tallies.totalNotes); diffIdsCurrent = daSongCapsule.songData.songDifficulties; @@ -1845,7 +2100,7 @@ class FreeplayState extends MusicBeatSubState intendedCompletion = 0.0; diffIdsCurrent = diffIdsTotal; rememberedSongId = null; - rememberedDifficulty = null; + rememberedDifficulty = Constants.DEFAULT_DIFFICULTY; albumRoll.albumId = null; } @@ -1868,8 +2123,10 @@ class FreeplayState extends MusicBeatSubState } } - public function playCurSongPreview(daSongCapsule:SongMenuItem):Void + public function playCurSongPreview(?daSongCapsule:SongMenuItem):Void { + if (daSongCapsule == null) daSongCapsule = grpCapsules.members[curSelected]; + if (curSelected == 0) { FunkinSound.playMusic('freeplayRandom', @@ -1882,24 +2139,56 @@ class FreeplayState extends MusicBeatSubState } else { - var potentiallyErect:String = (currentDifficulty == "erect") || (currentDifficulty == "nightmare") ? "-erect" : ""; - FunkinSound.playMusic(daSongCapsule.songData.songId, + var previewSongId:Null = daSongCapsule?.songData?.songId; + if (previewSongId == null) return; + + var previewSong:Null = SongRegistry.instance.fetchEntry(previewSongId); + var currentVariation = previewSong?.getVariationsByCharacter(currentCharacter) ?? Constants.DEFAULT_VARIATION_LIST; + var songDifficulty:Null = previewSong?.getDifficulty(currentDifficulty, + previewSong?.getVariationsByCharacter(currentCharacter) ?? Constants.DEFAULT_VARIATION_LIST); + + var baseInstrumentalId:String = previewSong?.getBaseInstrumentalId(currentDifficulty, songDifficulty?.variation ?? Constants.DEFAULT_VARIATION) ?? ''; + var altInstrumentalIds:Array = previewSong?.listAltInstrumentalIds(currentDifficulty, + songDifficulty?.variation ?? Constants.DEFAULT_VARIATION) ?? []; + + var instSuffix:String = baseInstrumentalId; + + // TODO: Make this a UI element. + #if FEATURE_DEBUG_FUNCTIONS + if (altInstrumentalIds.length > 0 && FlxG.keys.pressed.CONTROL) + { + instSuffix = altInstrumentalIds[0]; + } + #end + + instSuffix = (instSuffix != '') ? '-$instSuffix' : ''; + + trace('Attempting to play partial preview: ${previewSongId}:${instSuffix}'); + + FunkinSound.playMusic(previewSongId, { startingVolume: 0.0, overrideExisting: true, restartTrack: false, + mapTimeChanges: false, // The music metadata is not alongside the audio file so this won't work. pathsFunction: INST, - suffix: potentiallyErect, + suffix: instSuffix, partialParams: { loadPartial: true, - start: 0.05, - end: 0.25 + start: 0, + end: 0.2 }, onLoad: function() { FlxG.sound.music.fadeIn(2, 0, 0.4); } }); + + if (songDifficulty != null) + { + Conductor.instance.mapTimeChanges(songDifficulty.timeChanges); + Conductor.instance.update(FlxG.sound?.music?.time ?? 0.0); + } } } @@ -1910,10 +2199,7 @@ class FreeplayState extends MusicBeatSubState public static function build(?params:FreeplayStateParams, ?stickers:StickerSubState):MusicBeatState { var result:MainMenuState; - if (params?.fromResults.playRankAnim) result = new MainMenuState(true); - else - result = new MainMenuState(false); - + result = new MainMenuState(true); result.openSubState(new FreeplayState(params, stickers)); result.persistentUpdate = false; result.persistentDraw = true; @@ -1929,13 +2215,16 @@ class DifficultySelector extends FlxSprite var controls:Controls; var whiteShader:PureColor; - public function new(x:Float, y:Float, flipped:Bool, controls:Controls) + var parent:FreeplayState; + + public function new(parent:FreeplayState, x:Float, y:Float, flipped:Bool, controls:Controls, ?styleData:FreeplayStyle = null) { super(x, y); + this.parent = parent; this.controls = controls; - frames = Paths.getSparrowAtlas('freeplay/freeplaySelector'); + frames = Paths.getSparrowAtlas(styleData == null ? 'freeplay/freeplaySelector' : styleData.getSelectorAssetKey()); animation.addByPrefix('shine', 'arrow pointer loop', 24); animation.play('shine'); @@ -1948,8 +2237,8 @@ class DifficultySelector extends FlxSprite override function update(elapsed:Float):Void { - if (flipX && controls.UI_RIGHT_P && !FlxG.keys.pressed.CONTROL) moveShitDown(); - if (!flipX && controls.UI_LEFT_P && !FlxG.keys.pressed.CONTROL) moveShitDown(); + if (flipX && controls.UI_RIGHT_P && !parent.busy) moveShitDown(); + if (!flipX && controls.UI_LEFT_P && !parent.busy) moveShitDown(); super.update(elapsed); } @@ -2038,6 +2327,8 @@ class FreeplaySongData function set_currentDifficulty(value:String):String { + if (currentDifficulty == value) return value; + currentDifficulty = value; updateValues(displayedVariations); return value; @@ -2077,7 +2368,13 @@ class FreeplaySongData function updateValues(variations:Array):Void { this.songDifficulties = song.listDifficulties(null, variations, false, false); - if (!this.songDifficulties.contains(currentDifficulty)) currentDifficulty = Constants.DEFAULT_DIFFICULTY; + if (!this.songDifficulties.contains(currentDifficulty)) + { + currentDifficulty = Constants.DEFAULT_DIFFICULTY; + // This method gets called again by the setter-method + // or the difficulty didn't change, so there's no need to continue. + return; + } var songDifficulty:SongDifficulty = song.getDifficulty(currentDifficulty, null, variations); if (songDifficulty == null) return; @@ -2095,9 +2392,14 @@ class FreeplaySongData this.albumId = songDifficulty.album; } - this.scoringRank = Save.instance.getSongRank(songId, currentDifficulty); + // TODO: This line of code makes me sad, but you can't really fix it without a breaking migration. + // `easy`, `erect`, `normal-pico`, etc. + var suffixedDifficulty = (songDifficulty.variation != Constants.DEFAULT_VARIATION + && songDifficulty.variation != 'erect') ? '$currentDifficulty-${songDifficulty.variation}' : currentDifficulty; - this.isNew = song.isSongNew(currentDifficulty); + this.scoringRank = Save.instance.getSongRank(songId, suffixedDifficulty); + + this.isNew = song.isSongNew(suffixedDifficulty); } } @@ -2133,15 +2435,31 @@ class DifficultySprite extends FlxSprite difficultyId = diffId; - if (Assets.exists(Paths.file('images/freeplay/freeplay${diffId}.xml'))) + var assetDiffId:String = diffId; + while (!Assets.exists(Paths.image('freeplay/freeplay${assetDiffId}'))) { - this.frames = Paths.getSparrowAtlas('freeplay/freeplay${diffId}'); + // Remove the last suffix of the difficulty id until we find an asset or there are no more suffixes. + var assetDiffIdParts:Array = assetDiffId.split('-'); + assetDiffIdParts.pop(); + if (assetDiffIdParts.length == 0) + { + trace('Could not find difficulty asset: freeplay/freeplay${diffId} (from ${diffId})'); + return; + }; + assetDiffId = assetDiffIdParts.join('-'); + } + + // Check for an XML to use an animation instead of an image. + if (Assets.exists(Paths.file('images/freeplay/freeplay${assetDiffId}.xml'))) + { + this.frames = Paths.getSparrowAtlas('freeplay/freeplay${assetDiffId}'); this.animation.addByPrefix('idle', 'idle0', 24, true); if (Preferences.flashingLights) this.animation.play('idle'); } else { - this.loadGraphic(Paths.image('freeplay/freeplay' + diffId)); + this.loadGraphic(Paths.image('freeplay/freeplay' + assetDiffId)); + trace('Loaded difficulty asset: freeplay/freeplay${assetDiffId} (from ${diffId})'); } } } diff --git a/source/funkin/ui/freeplay/FreeplayStyle.hx b/source/funkin/ui/freeplay/FreeplayStyle.hx new file mode 100644 index 000000000..5ced51e1c --- /dev/null +++ b/source/funkin/ui/freeplay/FreeplayStyle.hx @@ -0,0 +1,121 @@ +package funkin.ui.freeplay; + +import funkin.data.freeplay.style.FreeplayStyleData; +import funkin.data.freeplay.style.FreeplayStyleRegistry; +import funkin.data.animation.AnimationData; +import funkin.data.IRegistryEntry; +import flixel.graphics.FlxGraphic; +import flixel.util.FlxColor; + +/** + * A class representing the data for a style of the Freeplay menu. + */ +class FreeplayStyle implements IRegistryEntry +{ + /** + * The internal ID for this freeplay style. + */ + public final id:String; + + /** + * The full data for a freeplay style. + */ + public final _data:FreeplayStyleData; + + public function new(id:String) + { + this.id = id; + this._data = _fetchData(id); + + if (_data == null) + { + throw 'Could not parse album data for id: $id'; + } + } + + /** + * Get the background art as a graphic, ready to apply to a sprite. + * @return The built graphic + */ + public function getBgAssetGraphic():FlxGraphic + { + return FlxG.bitmap.add(Paths.image(getBgAssetKey())); + } + + /** + * Get the asset key for the background. + * @return The asset key + */ + public function getBgAssetKey():String + { + return _data.bgAsset; + } + + /** + * Get the asset key for the background. + * @return The asset key + */ + public function getSelectorAssetKey():String + { + return _data.selectorAsset; + } + + /** + * Get the asset key for the number assets. + * @return The asset key + */ + public function getCapsuleAssetKey():String + { + return _data.capsuleAsset; + } + + /** + * Get the asset key for the capsule art. + * @return The asset key + */ + public function getNumbersAssetKey():String + { + return _data.numbersAsset; + } + + /** + * Return the deselected color of the text outline + * for freeplay capsules. + * @return The deselected color + */ + public function getCapsuleDeselCol():FlxColor + { + return FlxColor.fromString(_data.capsuleTextColors[0]); + } + + /** + * Return the song selection transition delay. + * @return The start delay + */ + public function getStartDelay():Float + { + return _data.startDelay; + } + + public function toString():String + { + return 'Style($id)'; + } + + /** + * Return the selected color of the text outline + * for freeplay capsules. + * @return The selected color + */ + public function getCapsuleSelCol():FlxColor + { + return FlxColor.fromString(_data.capsuleTextColors[1]); + } + + public function destroy():Void {} + + static function _fetchData(id:String):Null + { + return FreeplayStyleRegistry.instance.parseEntryDataWithMigration(id, FreeplayStyleRegistry.instance.fetchEntryVersion(id)); + } +} diff --git a/source/funkin/ui/freeplay/ScriptedFreeplayStyle.hx b/source/funkin/ui/freeplay/ScriptedFreeplayStyle.hx new file mode 100644 index 000000000..b7013a6b2 --- /dev/null +++ b/source/funkin/ui/freeplay/ScriptedFreeplayStyle.hx @@ -0,0 +1,9 @@ +package funkin.ui.freeplay; + +/** + * A script that can be tied to a Freeplay style. + * Create a scripted class that extends FreeplayStyle to use this. + * This allows you to customize how a specific style works. + */ +@:hscriptClass +class ScriptedFreeplayStyle extends funkin.ui.freeplay.FreeplayStyle implements polymod.hscript.HScriptedClass {} diff --git a/source/funkin/ui/freeplay/SongMenuItem.hx b/source/funkin/ui/freeplay/SongMenuItem.hx index 7708b3bcf..11ca44d54 100644 --- a/source/funkin/ui/freeplay/SongMenuItem.hx +++ b/source/funkin/ui/freeplay/SongMenuItem.hx @@ -24,12 +24,13 @@ import funkin.play.scoring.Scoring.ScoringRank; import funkin.save.Save; import funkin.save.Save.SaveScoreData; import flixel.util.FlxColor; +import funkin.ui.PixelatedIcon; class SongMenuItem extends FlxSpriteGroup { public var capsule:FlxSprite; - var pixelIcon:FlxSprite; + var pixelIcon:PixelatedIcon; /** * Modify this by calling `init()` @@ -87,7 +88,7 @@ class SongMenuItem extends FlxSpriteGroup super(x, y); capsule = new FlxSprite(); - capsule.frames = Paths.getSparrowAtlas('freeplay/freeplayCapsule'); + capsule.frames = Paths.getSparrowAtlas('freeplay/freeplayCapsule/capsule/freeplayCapsule'); capsule.animation.addByPrefix('selected', 'mp3 capsule w backing0', 24); capsule.animation.addByPrefix('unselected', 'mp3 capsule w backing NOT SELECTED', 24); // capsule.animation @@ -161,7 +162,7 @@ class SongMenuItem extends FlxSpriteGroup sparkle = new FlxSprite(ranking.x, ranking.y); sparkle.frames = Paths.getSparrowAtlas('freeplay/sparkle'); - sparkle.animation.addByPrefix('sparkle', 'sparkle', 24, false); + sparkle.animation.addByPrefix('sparkle', 'sparkle Export0', 24, false); sparkle.animation.play('sparkle', true); sparkle.scale.set(0.8, 0.8); sparkle.blend = BlendMode.ADD; @@ -201,11 +202,7 @@ class SongMenuItem extends FlxSpriteGroup // TODO: Use value from metadata instead of random. updateDifficultyRating(FlxG.random.int(0, 20)); - pixelIcon = new FlxSprite(160, 35); - - pixelIcon.makeGraphic(32, 32, 0x00000000); - pixelIcon.antialiasing = false; - pixelIcon.active = false; + pixelIcon = new PixelatedIcon(160, 35); add(pixelIcon); grpHide.add(pixelIcon); @@ -213,6 +210,7 @@ class SongMenuItem extends FlxSpriteGroup favIconBlurred.frames = Paths.getSparrowAtlas('freeplay/favHeart'); favIconBlurred.animation.addByPrefix('fav', 'favorite heart', 24, false); favIconBlurred.animation.play('fav'); + favIconBlurred.setGraphicSize(50, 50); favIconBlurred.blend = BlendMode.ADD; favIconBlurred.shader = new GaussianBlurShader(1.2); @@ -502,73 +500,40 @@ class SongMenuItem extends FlxSpriteGroup updateSelected(); } - public function init(?x:Float, ?y:Float, songData:Null):Void + public function init(?x:Float, ?y:Float, songData:Null, ?styleData:FreeplayStyle = null):Void { if (x != null) this.x = x; if (y != null) this.y = y; this.songData = songData; + // im so mad i have to do this but im pretty sure with the capsules recycling i cant call the new function properly :/ + // if thats possible someone Please change the new function to be something like + // capsule.frames = Paths.getSparrowAtlas(styleData == null ? 'freeplay/freeplayCapsule/capsule/freeplayCapsule' : styleData.getCapsuleAssetKey()); thank u luv u + if (styleData != null) + { + capsule.frames = Paths.getSparrowAtlas(styleData.getCapsuleAssetKey()); + capsule.animation.addByPrefix('selected', 'mp3 capsule w backing0', 24); + capsule.animation.addByPrefix('unselected', 'mp3 capsule w backing NOT SELECTED', 24); + songText.applyStyle(styleData); + } + // Update capsule text. songText.text = songData?.songName ?? 'Random'; // Update capsule character. - if (songData?.songCharacter != null) setCharacter(songData.songCharacter); + if (songData?.songCharacter != null) pixelIcon.setCharacter(songData.songCharacter); updateBPM(Std.int(songData?.songStartingBpm) ?? 0); updateDifficultyRating(songData?.difficultyRating ?? 0); updateScoringRank(songData?.scoringRank); newText.visible = songData?.isNew; + favIcon.animation.curAnim.curFrame = favIcon.animation.curAnim.numFrames - 1; + favIconBlurred.animation.curAnim.curFrame = favIconBlurred.animation.curAnim.numFrames - 1; + // Update opacity, offsets, etc. updateSelected(); checkWeek(songData?.songId); } - /** - * Set the character displayed next to this song in the freeplay menu. - * @param char The character ID used by this song. - * If the character has no freeplay icon, a warning will be thrown and nothing will display. - */ - public function setCharacter(char:String):Void - { - var charPath:String = "freeplay/icons/"; - - // TODO: Put this in the character metadata where it belongs. - // TODO: Also, can use CharacterDataParser.getCharPixelIconAsset() - switch (char) - { - case 'monster-christmas': - charPath += 'monsterpixel'; - case 'mom-car': - charPath += 'mommypixel'; - case 'dad': - charPath += 'daddypixel'; - case 'darnell-blazin': - charPath += 'darnellpixel'; - case 'senpai-angry': - charPath += 'senpaipixel'; - default: - charPath += '${char}pixel'; - } - - if (!openfl.utils.Assets.exists(Paths.image(charPath))) - { - trace('[WARN] Character ${char} has no freeplay icon.'); - return; - } - - pixelIcon.loadGraphic(Paths.image(charPath)); - pixelIcon.scale.x = pixelIcon.scale.y = 2; - - switch (char) - { - case 'parents-christmas': - pixelIcon.origin.x = 140; - default: - pixelIcon.origin.x = 100; - } - // pixelIcon.origin.x = capsule.origin.x; - // pixelIcon.offset.x -= pixelIcon.origin.x; - } - var frameInTicker:Float = 0; var frameInTypeBeat:Int = 0; @@ -707,6 +672,18 @@ class SongMenuItem extends FlxSpriteGroup super.update(elapsed); } + /** + * Play any animations associated with selecting this song. + */ + public function confirm():Void + { + if (songText != null) songText.flickerText(); + if (pixelIcon != null) + { + pixelIcon.animation.play('confirm'); + } + } + public function intendedY(index:Int):Float { return (index * ((height * realScaled) + 10)) + 120; @@ -761,7 +738,7 @@ class FreeplayRank extends FlxSprite switch (val) { case SHIT: - // offset.x -= 1; + // offset.x -= 1; case GOOD: // offset.x -= 1; offset.y -= 8; @@ -769,11 +746,11 @@ class FreeplayRank extends FlxSprite // offset.x -= 1; offset.y -= 8; case EXCELLENT: - // offset.y += 5; + // offset.y += 5; case PERFECT: - // offset.y += 5; + // offset.y += 5; case PERFECT_GOLD: - // offset.y += 5; + // offset.y += 5; default: centerOffsets(false); this.visible = false; @@ -830,9 +807,9 @@ class CapsuleNumber extends FlxSprite case 6: case 4: - // offset.y += 5; + // offset.y += 5; case 9: - // offset.y += 5; + // offset.y += 5; default: centerOffsets(false); } diff --git a/source/funkin/ui/freeplay/backcards/BackingCard.hx b/source/funkin/ui/freeplay/backcards/BackingCard.hx new file mode 100644 index 000000000..bb662cc8d --- /dev/null +++ b/source/funkin/ui/freeplay/backcards/BackingCard.hx @@ -0,0 +1,249 @@ +package funkin.ui.freeplay.backcards; + +import funkin.ui.freeplay.FreeplayState; +import flixel.FlxCamera; +import flixel.FlxSprite; +import flixel.group.FlxGroup; +import flixel.group.FlxGroup.FlxTypedGroup; +import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; +import flixel.math.FlxAngle; +import flixel.math.FlxPoint; +import flixel.text.FlxText; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import flixel.util.FlxColor; +import flixel.util.FlxSpriteUtil; +import flixel.util.FlxTimer; +import funkin.graphics.adobeanimate.FlxAtlasSprite; +import funkin.graphics.FunkinSprite; +import funkin.ui.freeplay.charselect.PlayableCharacter; +import funkin.ui.MusicBeatSubState; +import lime.utils.Assets; +import openfl.display.BlendMode; +import flixel.group.FlxSpriteGroup; + +/** + * A class for the backing cards so they dont have to be part of freeplayState...... + */ +class BackingCard extends FlxSpriteGroup +{ + public var backingTextYeah:FlxAtlasSprite; + public var orangeBackShit:FunkinSprite; + public var alsoOrangeLOL:FunkinSprite; + public var pinkBack:FunkinSprite; + public var confirmGlow:FlxSprite; + public var confirmGlow2:FlxSprite; + public var confirmTextGlow:FlxSprite; + public var cardGlow:FlxSprite; + + var _exitMovers:Null; + var _exitMoversCharSel:Null; + + public var instance:FreeplayState; + + public function new(currentCharacter:PlayableCharacter, ?_instance:FreeplayState) + { + super(); + + if (_instance != null) instance = _instance; + + cardGlow = new FlxSprite(-30, -30).loadGraphic(Paths.image('freeplay/cardGlow')); + confirmGlow = new FlxSprite(-30, 240).loadGraphic(Paths.image('freeplay/confirmGlow')); + confirmTextGlow = new FlxSprite(-8, 115).loadGraphic(Paths.image('freeplay/glowingText')); + pinkBack = FunkinSprite.create('freeplay/pinkBack'); + orangeBackShit = new FunkinSprite(84, 440).makeSolidColor(Std.int(pinkBack.width), 75, 0xFFFEDA00); + alsoOrangeLOL = new FunkinSprite(0, orangeBackShit.y).makeSolidColor(100, Std.int(orangeBackShit.height), 0xFFFFD400); + confirmGlow2 = new FlxSprite(confirmGlow.x, confirmGlow.y).loadGraphic(Paths.image('freeplay/confirmGlow2')); + backingTextYeah = new FlxAtlasSprite(640, 370, Paths.animateAtlas("freeplay/backing-text-yeah"), + { + FrameRate: 24.0, + Reversed: false, + // ?OnComplete:Void -> Void, + ShowPivot: false, + Antialiasing: true, + ScrollFactor: new FlxPoint(1, 1), + }); + + pinkBack.color = 0xFFFFD4E9; // sets it to pink! + pinkBack.x -= pinkBack.width; + } + + /** + * Apply exit movers for the pieces of the backing card. + * @param exitMovers The exit movers to apply. + */ + public function applyExitMovers(?exitMovers:FreeplayState.ExitMoverData, ?exitMoversCharSel:FreeplayState.ExitMoverData):Void + { + if (exitMovers == null) + { + exitMovers = _exitMovers; + } + else + { + _exitMovers = exitMovers; + } + + if (exitMovers == null) return; + + if (exitMoversCharSel == null) + { + exitMoversCharSel = _exitMoversCharSel; + } + else + { + _exitMoversCharSel = exitMoversCharSel; + } + + if (exitMoversCharSel == null) return; + + exitMovers.set([pinkBack, orangeBackShit, alsoOrangeLOL], + { + x: -pinkBack.width, + y: pinkBack.y, + speed: 0.4, + wait: 0 + }); + + exitMoversCharSel.set([pinkBack], + { + y: -100, + speed: 0.8, + wait: 0.1 + }); + + exitMoversCharSel.set([orangeBackShit, alsoOrangeLOL], + { + y: -40, + speed: 0.8, + wait: 0.1 + }); + } + + /** + * Helper function to snap the back of the card to its final position. + * Used when returning from character select, as we dont want to play the full animation of everything sliding in. + */ + public function skipIntroTween():Void + { + FlxTween.cancelTweensOf(pinkBack); + pinkBack.x = 0; + } + + /** + * Called in create. Adds sprites and tweens. + */ + public function init():Void + { + FlxTween.tween(pinkBack, {x: 0}, 0.6, {ease: FlxEase.quartOut}); + add(pinkBack); + + add(orangeBackShit); + + add(alsoOrangeLOL); + + FlxSpriteUtil.alphaMaskFlxSprite(orangeBackShit, pinkBack, orangeBackShit); + orangeBackShit.visible = false; + alsoOrangeLOL.visible = false; + + confirmTextGlow.blend = BlendMode.ADD; + confirmTextGlow.visible = false; + + confirmGlow.blend = BlendMode.ADD; + + confirmGlow.visible = false; + confirmGlow2.visible = false; + + add(confirmGlow2); + add(confirmGlow); + + add(confirmTextGlow); + + add(backingTextYeah); + + cardGlow.blend = BlendMode.ADD; + cardGlow.visible = false; + + add(cardGlow); + } + + /** + * Called after the dj finishes their start animation. + */ + public function introDone():Void + { + pinkBack.color = 0xFFFFD863; + orangeBackShit.visible = true; + alsoOrangeLOL.visible = true; + cardGlow.visible = true; + FlxTween.tween(cardGlow, {alpha: 0, "scale.x": 1.2, "scale.y": 1.2}, 0.45, {ease: FlxEase.sineOut}); + } + + /** + * Called when selecting a song. + */ + public function confirm():Void + { + FlxTween.color(pinkBack, 0.33, 0xFFFFD0D5, 0xFF171831, {ease: FlxEase.quadOut}); + orangeBackShit.visible = false; + alsoOrangeLOL.visible = false; + + confirmGlow.visible = true; + confirmGlow2.visible = true; + + backingTextYeah.anim.play(""); + confirmGlow2.alpha = 0; + confirmGlow.alpha = 0; + + FlxTween.color(instance.bgDad, 0.5, 0xFFA8A8A8, 0xFF646464, + { + onUpdate: function(_) { + instance.angleMaskShader.extraColor = instance.bgDad.color; + } + }); + FlxTween.tween(confirmGlow2, {alpha: 0.5}, 0.33, + { + ease: FlxEase.quadOut, + onComplete: function(_) { + confirmGlow2.alpha = 0.6; + confirmGlow.alpha = 1; + confirmTextGlow.visible = true; + confirmTextGlow.alpha = 1; + FlxTween.tween(confirmTextGlow, {alpha: 0.4}, 0.5); + FlxTween.tween(confirmGlow, {alpha: 0}, 0.5); + FlxTween.color(instance.bgDad, 2, 0xFFCDCDCD, 0xFF555555, + { + ease: FlxEase.expoOut, + onUpdate: function(_) { + instance.angleMaskShader.extraColor = instance.bgDad.color; + } + }); + } + }); + } + + /** + * Called when entering character select, does nothing by default. + */ + public function enterCharSel():Void {} + + /** + * Called on each beat in freeplay state. + */ + public function beatHit():Void {} + + /** + * Called when exiting the freeplay menu. + */ + public function disappear():Void + { + FlxTween.color(pinkBack, 0.25, 0xFFFFD863, 0xFFFFD0D5, {ease: FlxEase.quadOut}); + + cardGlow.visible = true; + cardGlow.alpha = 1; + cardGlow.scale.set(1, 1); + FlxTween.tween(cardGlow, {alpha: 0, "scale.x": 1.2, "scale.y": 1.2}, 0.25, {ease: FlxEase.sineOut}); + + orangeBackShit.visible = false; + alsoOrangeLOL.visible = false; + } +} diff --git a/source/funkin/ui/freeplay/backcards/BoyfriendCard.hx b/source/funkin/ui/freeplay/backcards/BoyfriendCard.hx new file mode 100644 index 000000000..597fd1a34 --- /dev/null +++ b/source/funkin/ui/freeplay/backcards/BoyfriendCard.hx @@ -0,0 +1,238 @@ +package funkin.ui.freeplay.backcards; + +import funkin.ui.freeplay.FreeplayState; +import flixel.FlxCamera; +import flixel.FlxSprite; +import flixel.group.FlxGroup; +import flixel.group.FlxGroup.FlxTypedGroup; +import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; +import flixel.math.FlxAngle; +import flixel.math.FlxPoint; +import flixel.text.FlxText; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import flixel.util.FlxColor; +import flixel.util.FlxSpriteUtil; +import flixel.util.FlxTimer; +import funkin.graphics.adobeanimate.FlxAtlasSprite; +import funkin.graphics.FunkinSprite; +import funkin.ui.freeplay.charselect.PlayableCharacter; +import funkin.ui.MusicBeatSubState; +import lime.utils.Assets; +import openfl.display.BlendMode; +import flixel.group.FlxSpriteGroup; + +class BoyfriendCard extends BackingCard +{ + public var moreWays:BGScrollingText; + public var funnyScroll:BGScrollingText; + public var txtNuts:BGScrollingText; + public var funnyScroll2:BGScrollingText; + public var moreWays2:BGScrollingText; + public var funnyScroll3:BGScrollingText; + + var glow:FlxSprite; + var glowDark:FlxSprite; + + public override function applyExitMovers(?exitMovers:FreeplayState.ExitMoverData, ?exitMoversCharSel:FreeplayState.ExitMoverData):Void + { + super.applyExitMovers(exitMovers, exitMoversCharSel); + if (exitMovers == null || exitMoversCharSel == null) return; + exitMovers.set([moreWays], + { + x: FlxG.width * 2, + speed: 0.4, + }); + exitMovers.set([funnyScroll], + { + x: -funnyScroll.width * 2, + y: funnyScroll.y, + speed: 0.4, + wait: 0 + }); + exitMovers.set([txtNuts], + { + x: FlxG.width * 2, + speed: 0.4, + }); + exitMovers.set([funnyScroll2], + { + x: -funnyScroll2.width * 2, + speed: 0.5, + }); + exitMovers.set([moreWays2], + { + x: FlxG.width * 2, + speed: 0.4 + }); + exitMovers.set([funnyScroll3], + { + x: -funnyScroll3.width * 2, + speed: 0.3 + }); + + exitMoversCharSel.set([moreWays, funnyScroll, txtNuts, funnyScroll2, moreWays2, funnyScroll3], + { + y: -60, + speed: 0.8, + wait: 0.1 + }); + } + + public override function enterCharSel():Void + { + FlxTween.tween(funnyScroll, {speed: 0}, 0.8, {ease: FlxEase.sineIn}); + FlxTween.tween(funnyScroll2, {speed: 0}, 0.8, {ease: FlxEase.sineIn}); + FlxTween.tween(moreWays, {speed: 0}, 0.8, {ease: FlxEase.sineIn}); + FlxTween.tween(moreWays2, {speed: 0}, 0.8, {ease: FlxEase.sineIn}); + FlxTween.tween(txtNuts, {speed: 0}, 0.8, {ease: FlxEase.sineIn}); + FlxTween.tween(funnyScroll3, {speed: 0}, 0.8, {ease: FlxEase.sineIn}); + } + + public override function new(currentCharacter:PlayableCharacter) + { + super(currentCharacter); + + funnyScroll = new BGScrollingText(0, 220, currentCharacter.getFreeplayDJText(1), FlxG.width / 2, false, 60); + funnyScroll2 = new BGScrollingText(0, 335, currentCharacter.getFreeplayDJText(1), FlxG.width / 2, false, 60); + moreWays = new BGScrollingText(0, 160, currentCharacter.getFreeplayDJText(2), FlxG.width, true, 43); + moreWays2 = new BGScrollingText(0, 397, currentCharacter.getFreeplayDJText(2), FlxG.width, true, 43); + txtNuts = new BGScrollingText(0, 285, currentCharacter.getFreeplayDJText(3), FlxG.width / 2, true, 43); + funnyScroll3 = new BGScrollingText(0, orangeBackShit.y + 10, currentCharacter.getFreeplayDJText(1), FlxG.width / 2, 60); + } + + public override function init():Void + { + FlxTween.tween(pinkBack, {x: 0}, 0.6, {ease: FlxEase.quartOut}); + add(pinkBack); + + add(orangeBackShit); + + add(alsoOrangeLOL); + + FlxSpriteUtil.alphaMaskFlxSprite(orangeBackShit, pinkBack, orangeBackShit); + orangeBackShit.visible = false; + alsoOrangeLOL.visible = false; + + confirmTextGlow.blend = BlendMode.ADD; + confirmTextGlow.visible = false; + + confirmGlow.blend = BlendMode.ADD; + + confirmGlow.visible = false; + confirmGlow2.visible = false; + + add(confirmGlow2); + add(confirmGlow); + + add(confirmTextGlow); + + add(backingTextYeah); + + cardGlow.blend = BlendMode.ADD; + cardGlow.visible = false; + + moreWays.visible = false; + funnyScroll.visible = false; + txtNuts.visible = false; + funnyScroll2.visible = false; + moreWays2.visible = false; + funnyScroll3.visible = false; + + moreWays.funnyColor = 0xFFFFF383; + moreWays.speed = 6.8; + add(moreWays); + + funnyScroll.funnyColor = 0xFFFF9963; + funnyScroll.speed = -3.8; + add(funnyScroll); + + txtNuts.speed = 3.5; + add(txtNuts); + + funnyScroll2.funnyColor = 0xFFFF9963; + funnyScroll2.speed = -3.8; + add(funnyScroll2); + + moreWays2.funnyColor = 0xFFFFF383; + moreWays2.speed = 6.8; + add(moreWays2); + + funnyScroll3.funnyColor = 0xFFFEA400; + funnyScroll3.speed = -3.8; + add(funnyScroll3); + + glowDark = new FlxSprite(-300, 330).loadGraphic(Paths.image('freeplay/beatglow')); + glowDark.blend = BlendMode.MULTIPLY; + add(glowDark); + + glow = new FlxSprite(-300, 330).loadGraphic(Paths.image('freeplay/beatglow')); + glow.blend = BlendMode.ADD; + add(glow); + + glowDark.visible = false; + glow.visible = false; + + add(cardGlow); + } + + var beatFreq:Int = 1; + var beatFreqList:Array = [1,2,4,8]; + + public override function beatHit():Void { + // increases the amount of beats that need to go by to pulse the glow because itd flash like craazy at high bpms..... + beatFreq = beatFreqList[Math.floor(Conductor.instance.bpm/140)]; + + if(Conductor.instance.currentBeat % beatFreq != 0) return; + FlxTween.cancelTweensOf(glow); + FlxTween.cancelTweensOf(glowDark); + + glow.alpha = 0.8; + FlxTween.tween(glow, {alpha: 0}, 16/24, {ease: FlxEase.quartOut}); + glowDark.alpha = 0; + FlxTween.tween(glowDark, {alpha: 0.6}, 18/24, {ease: FlxEase.quartOut}); + } + + public override function introDone():Void + { + super.introDone(); + moreWays.visible = true; + funnyScroll.visible = true; + txtNuts.visible = true; + funnyScroll2.visible = true; + moreWays2.visible = true; + funnyScroll3.visible = true; + // grpTxtScrolls.visible = true; + glowDark.visible = true; + glow.visible = true; + } + + public override function confirm():Void + { + super.confirm(); + // FlxTween.color(bgDad, 0.33, 0xFFFFFFFF, 0xFF555555, {ease: FlxEase.quadOut}); + + moreWays.visible = false; + funnyScroll.visible = false; + txtNuts.visible = false; + funnyScroll2.visible = false; + moreWays2.visible = false; + funnyScroll3.visible = false; + glowDark.visible = false; + glow.visible = false; + } + + public override function disappear():Void + { + super.disappear(); + + moreWays.visible = false; + funnyScroll.visible = false; + txtNuts.visible = false; + funnyScroll2.visible = false; + moreWays2.visible = false; + funnyScroll3.visible = false; + glowDark.visible = false; + glow.visible = false; + } +} diff --git a/source/funkin/ui/freeplay/backcards/NewCharacterCard.hx b/source/funkin/ui/freeplay/backcards/NewCharacterCard.hx new file mode 100644 index 000000000..a44ff88a6 --- /dev/null +++ b/source/funkin/ui/freeplay/backcards/NewCharacterCard.hx @@ -0,0 +1,278 @@ +package funkin.ui.freeplay.backcards; + +import funkin.ui.freeplay.FreeplayState; +import flash.display.BitmapData; +import flixel.FlxCamera; +import flixel.math.FlxMath; +import flixel.FlxSprite; +import flixel.group.FlxGroup; +import flixel.group.FlxGroup.FlxTypedGroup; +import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; +import flixel.math.FlxAngle; +import flixel.math.FlxPoint; +import flixel.text.FlxText; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import flixel.util.FlxColor; +import flixel.util.FlxSpriteUtil; +import flixel.util.FlxTimer; +import funkin.graphics.adobeanimate.FlxAtlasSprite; +import funkin.graphics.FunkinSprite; +import funkin.ui.freeplay.charselect.PlayableCharacter; +import funkin.ui.MusicBeatSubState; +import lime.utils.Assets; +import openfl.display.BlendMode; +import flixel.group.FlxSpriteGroup; +import funkin.graphics.shaders.AdjustColorShader; +import flixel.addons.display.FlxTiledSprite; +import flixel.addons.display.FlxBackdrop; + +class NewCharacterCard extends BackingCard +{ + var confirmAtlas:FlxAtlasSprite; + + var darkBg:FlxSprite; + var lightLayer:FlxSprite; + var multiply1:FlxSprite; + var multiply2:FlxSprite; + var lightLayer2:FlxSprite; + var lightLayer3:FlxSprite; + var yellow:FlxSprite; + var multiplyBar:FlxSprite; + + var bruh:FlxSprite; + + public var friendFoe:BGScrollingText; + public var newUnlock1:BGScrollingText; + public var waiting:BGScrollingText; + public var newUnlock2:BGScrollingText; + public var friendFoe2:BGScrollingText; + public var newUnlock3:BGScrollingText; + + public override function applyExitMovers(?exitMovers:FreeplayState.ExitMoverData, ?exitMoversCharSel:FreeplayState.ExitMoverData):Void + { + super.applyExitMovers(exitMovers, exitMoversCharSel); + if (exitMovers == null || exitMoversCharSel == null) return; + exitMovers.set([friendFoe], + { + x: FlxG.width * 2, + speed: 0.4, + }); + exitMovers.set([newUnlock1], + { + x: -newUnlock1.width * 2, + y: newUnlock1.y, + speed: 0.4, + wait: 0 + }); + exitMovers.set([waiting], + { + x: FlxG.width * 2, + speed: 0.4, + }); + exitMovers.set([newUnlock2], + { + x: -newUnlock2.width * 2, + speed: 0.5, + }); + exitMovers.set([friendFoe2], + { + x: FlxG.width * 2, + speed: 0.4 + }); + exitMovers.set([newUnlock3], + { + x: -newUnlock3.width * 2, + speed: 0.3 + }); + + exitMoversCharSel.set([friendFoe, newUnlock1, waiting, newUnlock2, friendFoe2, newUnlock3, multiplyBar], { + y: -60, + speed: 0.8, + wait: 0.1 + }); + } + + public override function introDone():Void + { + // pinkBack.color = 0xFFFFD863; + + darkBg.visible = true; + friendFoe.visible = true; + newUnlock1.visible = true; + waiting.visible = true; + newUnlock2.visible = true; + friendFoe2.visible = true; + newUnlock3.visible = true; + multiplyBar.visible = true; + lightLayer.visible = true; + multiply1.visible = true; + multiply2.visible = true; + lightLayer2.visible = true; + yellow.visible = true; + lightLayer3.visible = true; + + cardGlow.visible = true; + FlxTween.tween(cardGlow, {alpha: 0, "scale.x": 1.2, "scale.y": 1.2}, 0.45, {ease: FlxEase.sineOut}); + } + + public override function enterCharSel():Void + { + FlxTween.tween(friendFoe, {speed: 0}, 0.8, {ease: FlxEase.sineIn}); + FlxTween.tween(newUnlock1, {speed: 0}, 0.8, {ease: FlxEase.sineIn}); + FlxTween.tween(waiting, {speed: 0}, 0.8, {ease: FlxEase.sineIn}); + FlxTween.tween(newUnlock2, {speed: 0}, 0.8, {ease: FlxEase.sineIn}); + FlxTween.tween(friendFoe2, {speed: 0}, 0.8, {ease: FlxEase.sineIn}); + FlxTween.tween(newUnlock3, {speed: 0}, 0.8, {ease: FlxEase.sineIn}); + } + + public override function init():Void + { + FlxTween.tween(pinkBack, {x: 0}, 0.6, {ease: FlxEase.quartOut}); + add(pinkBack); + + confirmTextGlow.blend = BlendMode.ADD; + confirmTextGlow.visible = false; + + confirmGlow.blend = BlendMode.ADD; + + confirmGlow.visible = false; + confirmGlow2.visible = false; + + friendFoe = new BGScrollingText(0, 163, "COULD IT BE A NEW FRIEND? OR FOE??", FlxG.width, true, 43); + newUnlock1 = new BGScrollingText(-440, 215, 'NEW UNLOCK!', FlxG.width / 2, true, 80); + waiting = new BGScrollingText(0, 286, "SOMEONE'S WAITING!", FlxG.width / 2, true, 43); + newUnlock2 = new BGScrollingText(-220, 331, 'NEW UNLOCK!', FlxG.width / 2, true, 80); + friendFoe2 = new BGScrollingText(0, 402, 'COULD IT BE A NEW FRIEND? OR FOE??', FlxG.width, true, 43); + newUnlock3 = new BGScrollingText(0, 458, 'NEW UNLOCK!', FlxG.width / 2, true, 80); + + darkBg = new FlxSprite(0, 0).loadGraphic(Paths.image('freeplay/backingCards/newCharacter/darkback')); + add(darkBg); + + friendFoe.funnyColor = 0xFF139376; + friendFoe.speed = -4; + add(friendFoe); + + newUnlock1.funnyColor = 0xFF99BDF2; + newUnlock1.speed = 2; + add(newUnlock1); + + waiting.funnyColor = 0xFF40EA84; + waiting.speed = -2; + add(waiting); + + newUnlock2.funnyColor = 0xFF99BDF2; + newUnlock2.speed = 2; + add(newUnlock2); + + friendFoe2.funnyColor = 0xFF139376; + friendFoe2.speed = -4; + add(friendFoe2); + + newUnlock3.funnyColor = 0xFF99BDF2; + newUnlock3.speed = 2; + add(newUnlock3); + + multiplyBar = new FlxSprite(-10, 440).loadGraphic(Paths.image('freeplay/backingCards/newCharacter/multiplyBar')); + multiplyBar.blend = BlendMode.MULTIPLY; + add(multiplyBar); + + lightLayer = new FlxSprite(-360, 230).loadGraphic(Paths.image('freeplay/backingCards/newCharacter/orange gradient')); + lightLayer.blend = BlendMode.ADD; + add(lightLayer); + + multiply1 = new FlxSprite(-15, -125).loadGraphic(Paths.image('freeplay/backingCards/newCharacter/red')); + multiply1.blend = BlendMode.MULTIPLY; + add(multiply1); + + multiply2 = new FlxSprite(-15, -125).loadGraphic(Paths.image('freeplay/backingCards/newCharacter/red')); + multiply2.blend = BlendMode.MULTIPLY; + add(multiply2); + + lightLayer2 = new FlxSprite(-360, 230).loadGraphic(Paths.image('freeplay/backingCards/newCharacter/orange gradient')); + lightLayer2.blend = BlendMode.ADD; + add(lightLayer2); + + yellow = new FlxSprite(0, 0).loadGraphic(Paths.image('freeplay/backingCards/newCharacter/yellow bg piece')); + yellow.blend = BlendMode.MULTIPLY; + add(yellow); + + lightLayer3 = new FlxSprite(-360, 290).loadGraphic(Paths.image('freeplay/backingCards/newCharacter/red gradient')); + lightLayer3.blend = BlendMode.ADD; + add(lightLayer3); + + cardGlow.blend = BlendMode.ADD; + cardGlow.visible = false; + + add(cardGlow); + + darkBg.visible = false; + friendFoe.visible = false; + newUnlock1.visible = false; + waiting.visible = false; + newUnlock2.visible = false; + friendFoe2.visible = false; + newUnlock3.visible = false; + multiplyBar.visible = false; + lightLayer.visible = false; + multiply1.visible = false; + multiply2.visible = false; + lightLayer2.visible = false; + yellow.visible = false; + lightLayer3.visible = false; + } + + var _timer:Float = 0; + + override public function update(elapsed:Float):Void + { + super.update(elapsed); + + _timer += elapsed * 2; + var sinTest:Float = (Math.sin(_timer) + 1) / 2; + lightLayer.alpha = FlxMath.lerp(0.4, 1, sinTest); + lightLayer2.alpha = FlxMath.lerp(0.2, 0.5, sinTest); + lightLayer3.alpha = FlxMath.lerp(0.1, 0.7, sinTest); + + multiply1.alpha = FlxMath.lerp(1, 0.21, sinTest); + multiply2.alpha = FlxMath.lerp(1, 0.21, sinTest); + + yellow.alpha = FlxMath.lerp(0.2, 0.72, sinTest); + + if (instance != null) + { + instance.angleMaskShader.extraColor = FlxColor.interpolate(0xFF2E2E46, 0xFF60607B, sinTest); + } + } + + public override function disappear():Void + { + FlxTween.color(pinkBack, 0.25, 0xFF05020E, 0xFFFFD0D5, {ease: FlxEase.quadOut}); + + darkBg.visible = false; + friendFoe.visible = false; + newUnlock1.visible = false; + waiting.visible = false; + newUnlock2.visible = false; + friendFoe2.visible = false; + newUnlock3.visible = false; + multiplyBar.visible = false; + lightLayer.visible = false; + multiply1.visible = false; + multiply2.visible = false; + lightLayer2.visible = false; + yellow.visible = false; + lightLayer3.visible = false; + + cardGlow.visible = true; + cardGlow.alpha = 1; + cardGlow.scale.set(1, 1); + FlxTween.tween(cardGlow, {alpha: 0, "scale.x": 1.2, "scale.y": 1.2}, 0.25, {ease: FlxEase.sineOut}); + } + + override public function confirm():Void + { + // confirmAtlas.visible = true; + // confirmAtlas.anim.play(""); + } +} diff --git a/source/funkin/ui/freeplay/backcards/PicoCard.hx b/source/funkin/ui/freeplay/backcards/PicoCard.hx new file mode 100644 index 000000000..f5db1ccc3 --- /dev/null +++ b/source/funkin/ui/freeplay/backcards/PicoCard.hx @@ -0,0 +1,314 @@ +package funkin.ui.freeplay.backcards; + +import funkin.ui.freeplay.FreeplayState; +import flash.display.BitmapData; +import flixel.FlxCamera; +import flixel.math.FlxMath; +import flixel.FlxSprite; +import flixel.group.FlxGroup; +import flixel.group.FlxGroup.FlxTypedGroup; +import flixel.group.FlxSpriteGroup.FlxTypedSpriteGroup; +import flixel.math.FlxAngle; +import flixel.math.FlxPoint; +import flixel.text.FlxText; +import flixel.tweens.FlxEase; +import flixel.tweens.FlxTween; +import flixel.util.FlxColor; +import flixel.util.FlxSpriteUtil; +import flixel.util.FlxTimer; +import funkin.graphics.adobeanimate.FlxAtlasSprite; +import funkin.graphics.FunkinSprite; +import funkin.ui.freeplay.charselect.PlayableCharacter; +import funkin.ui.MusicBeatSubState; +import lime.utils.Assets; +import openfl.display.BlendMode; +import flixel.group.FlxSpriteGroup; +import funkin.graphics.shaders.AdjustColorShader; +import flixel.addons.display.FlxTiledSprite; +import flixel.addons.display.FlxBackdrop; + +class PicoCard extends BackingCard +{ + var scrollBack:FlxBackdrop; + var scrollLower:FlxBackdrop; + var scrollTop:FlxBackdrop; + var scrollMiddle:FlxBackdrop; + + var glow:FlxSprite; + var glowDark:FlxSprite; + var blueBar:FlxSprite; + + var confirmAtlas:FlxAtlasSprite; + + public override function enterCharSel():Void + { + FlxTween.tween(scrollBack.velocity, {x: 0}, 0.8, {ease: FlxEase.sineIn}); + FlxTween.tween(scrollLower.velocity, {x: 0}, 0.8, {ease: FlxEase.sineIn}); + FlxTween.tween(scrollTop.velocity, {x: 0}, 0.8, {ease: FlxEase.sineIn}); + FlxTween.tween(scrollMiddle.velocity, {x: 0}, 0.8, {ease: FlxEase.sineIn}); + } + + public override function applyExitMovers(?exitMovers:FreeplayState.ExitMoverData, ?exitMoversCharSel:FreeplayState.ExitMoverData):Void + { + super.applyExitMovers(exitMovers, exitMoversCharSel); + if (exitMovers == null || exitMoversCharSel == null) return; + + exitMoversCharSel.set([scrollTop], + { + y: -90, + speed: 0.8, + wait: 0.1 + }); + + exitMoversCharSel.set([scrollMiddle], + { + y: -80, + speed: 0.8, + wait: 0.1 + }); + + exitMoversCharSel.set([blueBar], + { + y: -70, + speed: 0.8, + wait: 0.1 + }); + + exitMoversCharSel.set([scrollLower], + { + y: -60, + speed: 0.8, + wait: 0.1 + }); + + exitMoversCharSel.set([scrollBack], + { + y: -50, + speed: 0.8, + wait: 0.1 + }); + } + + public override function init():Void + { + FlxTween.tween(pinkBack, {x: 0}, 0.6, {ease: FlxEase.quartOut}); + add(pinkBack); + + confirmTextGlow.blend = BlendMode.ADD; + confirmTextGlow.visible = false; + + confirmGlow.blend = BlendMode.ADD; + + confirmGlow.visible = false; + confirmGlow2.visible = false; + + scrollBack = new FlxBackdrop(Paths.image('freeplay/backingCards/pico/lowerLoop'), X, 20); + scrollBack.setPosition(0, 200); + scrollBack.flipX = true; + scrollBack.alpha = 0.39; + scrollBack.velocity.x = 110; + add(scrollBack); + + scrollLower = new FlxBackdrop(Paths.image('freeplay/backingCards/pico/lowerLoop'), X, 20); + scrollLower.setPosition(0, 406); + scrollLower.velocity.x = -110; + add(scrollLower); + + blueBar = new FlxSprite(0, 239).loadGraphic(Paths.image('freeplay/backingCards/pico/blueBar')); + blueBar.blend = BlendMode.MULTIPLY; + blueBar.alpha = 0.4; + add(blueBar); + + scrollTop = new FlxBackdrop(null, X, 20); + scrollTop.setPosition(0, 80); + scrollTop.velocity.x = -220; + + scrollTop.frames = Paths.getSparrowAtlas('freeplay/backingCards/pico/topLoop'); + scrollTop.animation.addByPrefix('uzi', 'uzi info', 24, false); + scrollTop.animation.addByPrefix('sniper', 'sniper info', 24, false); + scrollTop.animation.addByPrefix('rocket launcher', 'rocket launcher info', 24, false); + scrollTop.animation.addByPrefix('rifle', 'rifle info', 24, false); + scrollTop.animation.addByPrefix('base', 'base', 24, false); + scrollTop.animation.play('base'); + + add(scrollTop); + + scrollMiddle = new FlxBackdrop(Paths.image('freeplay/backingCards/pico/middleLoop'), X, 15); + scrollMiddle.setPosition(0, 346); + add(scrollMiddle); + scrollMiddle.velocity.x = 220; + + glowDark = new FlxSprite(-300, 330).loadGraphic(Paths.image('freeplay/backingCards/pico/glow')); + glowDark.blend = BlendMode.MULTIPLY; + add(glowDark); + + glow = new FlxSprite(-300, 330).loadGraphic(Paths.image('freeplay/backingCards/pico/glow')); + glow.blend = BlendMode.ADD; + add(glow); + + blueBar.visible = false; + scrollBack.visible = false; + scrollLower.visible = false; + scrollTop.visible = false; + scrollMiddle.visible = false; + glow.visible = false; + glowDark.visible = false; + + confirmAtlas = new FlxAtlasSprite(5, 55, Paths.animateAtlas("freeplay/backingCards/pico/pico-confirm")); + confirmAtlas.visible = false; + add(confirmAtlas); + + cardGlow.blend = BlendMode.ADD; + cardGlow.visible = false; + add(cardGlow); + } + + override public function confirm():Void + { + confirmAtlas.visible = true; + confirmAtlas.anim.play(""); + + FlxTween.color(instance.bgDad, 10 / 24, 0xFFFFFFFF, 0xFF8A8A8A, + { + ease: FlxEase.expoOut, + onUpdate: function(_) { + instance.angleMaskShader.extraColor = instance.bgDad.color; + } + }); + + new FlxTimer().start(10 / 24, function(_) { + // shoot + FlxTween.color(instance.bgDad, 3 / 24, 0xFF343036, 0xFF696366, + { + ease: FlxEase.expoOut, + onUpdate: function(_) { + instance.angleMaskShader.extraColor = instance.bgDad.color; + } + }); + }); + + new FlxTimer().start(14 / 24, function(_) { + // shoot + FlxTween.color(instance.bgDad, 3 / 24, 0xFF27292D, 0xFF686A6F, + { + ease: FlxEase.expoOut, + onUpdate: function(_) { + instance.angleMaskShader.extraColor = instance.bgDad.color; + } + }); + }); + + new FlxTimer().start(18 / 24, function(_) { + // shoot + FlxTween.color(instance.bgDad, 3 / 24, 0xFF2D282D, 0xFF676164, + { + ease: FlxEase.expoOut, + onUpdate: function(_) { + instance.angleMaskShader.extraColor = instance.bgDad.color; + } + }); + }); + + new FlxTimer().start(21 / 24, function(_) { + // shoot + FlxTween.color(instance.bgDad, 3 / 24, 0xFF29292F, 0xFF62626B, + { + ease: FlxEase.expoOut, + onUpdate: function(_) { + instance.angleMaskShader.extraColor = instance.bgDad.color; + } + }); + }); + + new FlxTimer().start(24 / 24, function(_) { + // shoot + FlxTween.color(instance.bgDad, 3 / 24, 0xFF29232C, 0xFF808080, + { + ease: FlxEase.expoOut, + onUpdate: function(_) { + instance.angleMaskShader.extraColor = instance.bgDad.color; + } + }); + }); + } + + var beatFreq:Int = 1; + var beatFreqList:Array = [1,2,4,8]; + + public override function beatHit():Void { + // increases the amount of beats that need to go by to pulse the glow because itd flash like craazy at high bpms..... + beatFreq = beatFreqList[Math.floor(Conductor.instance.bpm/140)]; + + if(Conductor.instance.currentBeat % beatFreq != 0) return; + FlxTween.cancelTweensOf(glow); + FlxTween.cancelTweensOf(glowDark); + + glow.alpha = 1; + FlxTween.tween(glow, {alpha: 0}, 16/24, {ease: FlxEase.quartOut}); + glowDark.alpha = 0; + FlxTween.tween(glowDark, {alpha: 1}, 18/24, {ease: FlxEase.quartOut}); + } + + public override function introDone():Void + { + pinkBack.color = 0xFF98A2F3; + + blueBar.visible = true; + scrollBack.visible = true; + scrollLower.visible = true; + scrollTop.visible = true; + scrollMiddle.visible = true; + glowDark.visible = true; + glow.visible = true; + + cardGlow.visible = true; + FlxTween.tween(cardGlow, {alpha: 0, "scale.x": 1.2, "scale.y": 1.2}, 0.45, {ease: FlxEase.sineOut}); + } + + public override function disappear():Void + { + FlxTween.color(pinkBack, 0.25, 0xFF98A2F3, 0xFFFFD0D5, {ease: FlxEase.quadOut}); + + blueBar.visible = false; + scrollBack.visible = false; + scrollLower.visible = false; + scrollTop.visible = false; + scrollMiddle.visible = false; + glowDark.visible = false; + glow.visible = false; + + cardGlow.visible = true; + cardGlow.alpha = 1; + cardGlow.scale.set(1, 1); + FlxTween.tween(cardGlow, {alpha: 0, "scale.x": 1.2, "scale.y": 1.2}, 0.25, {ease: FlxEase.sineOut}); + } + + override public function update(elapsed:Float):Void + { + super.update(elapsed); + var scrollProgress:Float = Math.abs(scrollTop.x % (scrollTop.frameWidth + 20)); + + if (scrollTop.animation.curAnim.finished == true) + { + if (FlxMath.inBounds(scrollProgress, 500, 700) && scrollTop.animation.curAnim.name != 'sniper') + { + scrollTop.animation.play('sniper', true, false); + } + + if (FlxMath.inBounds(scrollProgress, 700, 1300) && scrollTop.animation.curAnim.name != 'rifle') + { + scrollTop.animation.play('rifle', true, false); + } + + if (FlxMath.inBounds(scrollProgress, 1450, 2000) && scrollTop.animation.curAnim.name != 'rocket launcher') + { + scrollTop.animation.play('rocket launcher', true, false); + } + + if (FlxMath.inBounds(scrollProgress, 0, 300) && scrollTop.animation.curAnim.name != 'uzi') + { + scrollTop.animation.play('uzi', true, false); + } + } + } +} diff --git a/source/funkin/ui/freeplay/charselect/PlayableCharacter.hx b/source/funkin/ui/freeplay/charselect/PlayableCharacter.hx new file mode 100644 index 000000000..93d643ae4 --- /dev/null +++ b/source/funkin/ui/freeplay/charselect/PlayableCharacter.hx @@ -0,0 +1,178 @@ +package funkin.ui.freeplay.charselect; + +import funkin.data.IRegistryEntry; +import funkin.data.freeplay.player.PlayerData; +import funkin.data.freeplay.player.PlayerRegistry; +import funkin.play.scoring.Scoring.ScoringRank; + +/** + * An object used to retrieve data about a playable character (also known as "weeks"). + * Can be scripted to override each function, for custom behavior. + */ +@:nullSafety +class PlayableCharacter implements IRegistryEntry +{ + /** + * The ID of the playable character. + */ + public final id:String; + + /** + * Playable character data as parsed from the JSON file. + */ + public final _data:Null; + + /** + * @param id The ID of the JSON file to parse. + */ + public function new(id:String) + { + this.id = id; + _data = _fetchData(id); + + if (_data == null) + { + throw 'Could not parse playable character data for id: $id'; + } + } + + /** + * Retrieve the readable name of the playable character. + */ + public function getName():String + { + // TODO: Maybe add localization support? + return _data?.name ?? "Unknown"; + } + + /** + * Retrieve the list of stage character IDs associated with this playable character. + * @return The list of associated character IDs + */ + public function getOwnedCharacterIds():Array + { + return _data?.ownedChars ?? []; + } + + /** + * Return `true` if, when this character is selected in Freeplay, + * songs unassociated with a specific character should appear. + */ + public function shouldShowUnownedChars():Bool + { + return _data?.showUnownedChars ?? false; + } + + public function shouldShowCharacter(id:String):Bool + { + if (getOwnedCharacterIds().contains(id)) + { + return true; + } + + if (shouldShowUnownedChars()) + { + var result = !PlayerRegistry.instance.isCharacterOwned(id); + return result; + } + + return false; + } + + public function getFreeplayStyleID():String + { + return _data?.freeplayStyle ?? Constants.DEFAULT_FREEPLAY_STYLE; + } + + public function getFreeplayDJData():Null + { + return _data?.freeplayDJ; + } + + public function getFreeplayDJText(index:Int):String + { + // Silly little placeholder + return _data?.freeplayDJ?.getFreeplayDJText(index) ?? 'GET FREAKY ON A FRIDAY'; + } + + public function getCharSelectData():Null + { + return _data?.charSelect; + } + + /** + * @param rank Which rank to get info for + * @return An array of animations. For example, BF Great has two animations, one for BF and one for GF + */ + public function getResultsAnimationDatas(rank:ScoringRank):Array + { + if (_data == null || _data.results == null) + { + return []; + } + + switch (rank) + { + case PERFECT | PERFECT_GOLD: + return _data.results.perfect; + case EXCELLENT: + return _data.results.excellent; + case GREAT: + return _data.results.great; + case GOOD: + return _data.results.good; + case SHIT: + return _data.results.loss; + } + } + + public function getResultsMusicPath(rank:ScoringRank):String + { + switch (rank) + { + case PERFECT_GOLD: + return _data?.results?.music?.PERFECT_GOLD ?? "resultsPERFECT"; + case PERFECT: + return _data?.results?.music?.PERFECT ?? "resultsPERFECT"; + case EXCELLENT: + return _data?.results?.music?.EXCELLENT ?? "resultsEXCELLENT"; + case GREAT: + return _data?.results?.music?.GREAT ?? "resultsNORMAL"; + case GOOD: + return _data?.results?.music?.GOOD ?? "resultsNORMAL"; + case SHIT: + return _data?.results?.music?.SHIT ?? "resultsSHIT"; + default: + return _data?.results?.music?.GOOD ?? "resultsNORMAL"; + } + } + + /** + * Returns whether this character is unlocked. + */ + public function isUnlocked():Bool + { + return _data?.unlocked ?? true; + } + + /** + * Called when the character is destroyed. + * TODO: Document when this gets called + */ + public function destroy():Void {} + + public function toString():String + { + return 'PlayableCharacter($id)'; + } + + /** + * Retrieve and parse the JSON data for a playable character by ID. + * @param id The ID of the character + * @return The parsed player data, or null if not found or invalid + */ + static function _fetchData(id:String):Null + { + return PlayerRegistry.instance.parseEntryDataWithMigration(id, PlayerRegistry.instance.fetchEntryVersion(id)); + } +} diff --git a/source/funkin/ui/freeplay/charselect/ScriptedPlayableCharacter.hx b/source/funkin/ui/freeplay/charselect/ScriptedPlayableCharacter.hx new file mode 100644 index 000000000..f75a58092 --- /dev/null +++ b/source/funkin/ui/freeplay/charselect/ScriptedPlayableCharacter.hx @@ -0,0 +1,8 @@ +package funkin.ui.freeplay.charselect; + +/** + * A script that can be tied to a PlayableCharacter. + * Create a scripted class that extends PlayableCharacter to use this. + */ +@:hscriptClass +class ScriptedPlayableCharacter extends funkin.ui.freeplay.charselect.PlayableCharacter implements polymod.hscript.HScriptedClass {} diff --git a/source/funkin/ui/mainmenu/MainMenuState.hx b/source/funkin/ui/mainmenu/MainMenuState.hx index d09536eea..13d68da6d 100644 --- a/source/funkin/ui/mainmenu/MainMenuState.hx +++ b/source/funkin/ui/mainmenu/MainMenuState.hx @@ -27,7 +27,7 @@ import funkin.ui.title.TitleState; import funkin.ui.story.StoryMenuState; import funkin.ui.Prompt; import funkin.util.WindowUtil; -#if discord_rpc +#if FEATURE_DISCORD_RPC import Discord.DiscordClient; #end #if newgrounds @@ -54,7 +54,7 @@ class MainMenuState extends MusicBeatState override function create():Void { - #if discord_rpc + #if FEATURE_DISCORD_RPC // Updating Discord Rich Presence DiscordClient.changePresence("In the Menus", null); #end @@ -64,7 +64,7 @@ class MainMenuState extends MusicBeatState transIn = FlxTransitionableState.defaultTransIn; transOut = FlxTransitionableState.defaultTransOut; - if (overrideMusic == false) playMenuMusic(); + if (!overrideMusic) playMenuMusic(); // We want the state to always be able to begin with being able to accept inputs and show the anims of the menu items. persistentUpdate = true; @@ -98,14 +98,7 @@ class MainMenuState extends MusicBeatState add(menuItems); menuItems.onChange.add(onMenuItemChange); menuItems.onAcceptPress.add(function(_) { - if (_.name == 'freeplay') - { - magenta.visible = true; - } - else - { - FlxFlicker.flicker(magenta, 1.1, 0.15, false, true); - } + FlxFlicker.flicker(magenta, 1.1, 0.15, false, true); }); menuItems.enabled = true; // can move on intro @@ -117,7 +110,17 @@ class MainMenuState extends MusicBeatState FlxTransitionableState.skipNextTransIn = true; FlxTransitionableState.skipNextTransOut = true; - openSubState(new FreeplayState()); + #if FEATURE_DEBUG_FUNCTIONS + // Debug function: Hold SHIFT when selecting Freeplay to swap character without the char select menu + var targetCharacter:Null = (FlxG.keys.pressed.SHIFT) ? (FreeplayState.rememberedCharacterId == "pico" ? "bf" : "pico") : null; + #else + var targetCharacter:Null = null; + #end + + openSubState(new FreeplayState( + { + character: targetCharacter + })); }); #if CAN_OPEN_LINKS @@ -153,6 +156,9 @@ class MainMenuState extends MusicBeatState resetCamStuff(); + // reset camera when debug menu is closed + subStateClosed.add(_ -> resetCamStuff(false)); + subStateOpened.add(sub -> { if (Type.getClass(sub) == FreeplayState) { @@ -182,10 +188,11 @@ class MainMenuState extends MusicBeatState }); } - function resetCamStuff():Void + function resetCamStuff(?snap:Bool = true):Void { FlxG.camera.follow(camFollow, null, 0.06); - FlxG.camera.snapToTarget(); + + if (snap) FlxG.camera.snapToTarget(); } function createMenuItem(name:String, atlas:String, callback:Void->Void, fireInstantly:Bool = false):Void @@ -338,20 +345,30 @@ class MainMenuState extends MusicBeatState } // Open the debug menu, defaults to ` / ~ - #if CHART_EDITOR_SUPPORTED + // This includes stuff like the Chart Editor, so it should be present on all builds. if (controls.DEBUG_MENU) { persistentUpdate = false; FlxG.state.openSubState(new DebugMenuSubState()); - // reset camera when debug menu is closed - subStateClosed.addOnce(_ -> resetCamStuff()); } - #end - #if (debug || FORCE_DEBUG_VERSION) + #if FEATURE_DEBUG_FUNCTIONS + // Ctrl+Alt+Shift+P = Character Unlock screen + // Ctrl+Alt+Shift+W = Meet requirements for Pico Unlock + // Ctrl+Alt+Shift+L = Revoke requirements for Pico Unlock + // Ctrl+Alt+Shift+R = Score/Rank conflict test + // Ctrl+Alt+Shift+N = Mark all characters as not seen + // Ctrl+Alt+Shift+E = Dump save data + + if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.ALT && FlxG.keys.pressed.SHIFT && FlxG.keys.justPressed.P) + { + FlxG.switchState(() -> new funkin.ui.charSelect.CharacterUnlockState('pico')); + } + if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.ALT && FlxG.keys.pressed.SHIFT && FlxG.keys.justPressed.W) { + FunkinSound.playOnce(Paths.sound('confirmMenu')); // Give the user a score of 1 point on Weekend 1 story mode. // This makes the level count as cleared and displays the songs in Freeplay. funkin.save.Save.instance.setLevelScore('weekend1', 'easy', @@ -372,6 +389,29 @@ class MainMenuState extends MusicBeatState }); } + if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.ALT && FlxG.keys.pressed.SHIFT && FlxG.keys.justPressed.L) + { + FunkinSound.playOnce(Paths.sound('confirmMenu')); + // Give the user a score of 0 points on Weekend 1 story mode. + // This makes the level count as uncleared and no longer displays the songs in Freeplay. + funkin.save.Save.instance.setLevelScore('weekend1', 'easy', + { + score: 1, + tallies: + { + sick: 0, + good: 0, + bad: 0, + shit: 0, + missed: 0, + combo: 0, + maxCombo: 0, + totalNotesHit: 0, + totalNotes: 0, + } + }); + } + if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.ALT && FlxG.keys.pressed.SHIFT && FlxG.keys.justPressed.R) { // Give the user a hypothetical overridden score, @@ -394,6 +434,15 @@ class MainMenuState extends MusicBeatState }); } + if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.ALT && FlxG.keys.pressed.SHIFT && FlxG.keys.justPressed.N) + { + @:privateAccess + { + funkin.save.Save.instance.data.unlocks.charactersSeen = ["bf"]; + funkin.save.Save.instance.data.unlocks.oldChar = false; + } + } + if (FlxG.keys.pressed.CONTROL && FlxG.keys.pressed.ALT && FlxG.keys.pressed.SHIFT && FlxG.keys.justPressed.E) { funkin.save.Save.instance.debug_dumpSave(); @@ -409,8 +458,8 @@ class MainMenuState extends MusicBeatState if (controls.BACK && menuItems.enabled && !menuItems.busy) { - FunkinSound.playOnce(Paths.sound('cancelMenu')); FlxG.switchState(() -> new TitleState()); + FunkinSound.playOnce(Paths.sound('cancelMenu')); } } } diff --git a/source/funkin/ui/options/FunkinSoundTray.hx b/source/funkin/ui/options/FunkinSoundTray.hx index 792e38fc4..170ad8497 100644 --- a/source/funkin/ui/options/FunkinSoundTray.hx +++ b/source/funkin/ui/options/FunkinSoundTray.hx @@ -120,7 +120,7 @@ class FunkinSoundTray extends FlxSoundTray lerpYPos = 10; visible = true; active = true; - var globalVolume:Int = Math.round(FlxG.sound.volume * 10); + var globalVolume:Int = Math.round(FlxG.sound.logToLinear(FlxG.sound.volume) * 10); if (FlxG.sound.muted) { diff --git a/source/funkin/ui/options/MenuItemEnums.hx b/source/funkin/ui/options/MenuItemEnums.hx new file mode 100644 index 000000000..4513a92af --- /dev/null +++ b/source/funkin/ui/options/MenuItemEnums.hx @@ -0,0 +1,10 @@ +package funkin.ui.options; + +// Add enums for use with `EnumPreferenceItem` here! +/* Example: + class MyOptionEnum + { + public static inline var YuhUh = "true"; // "true" is the value's ID + public static inline var NuhUh = "false"; + } + */ diff --git a/source/funkin/ui/options/OptionsState.hx b/source/funkin/ui/options/OptionsState.hx index 40308d96b..a2301e6a3 100644 --- a/source/funkin/ui/options/OptionsState.hx +++ b/source/funkin/ui/options/OptionsState.hx @@ -145,8 +145,8 @@ class Page extends FlxGroup { if (canExit && controls.BACK) { - FunkinSound.playOnce(Paths.sound('cancelMenu')); exit(); + FunkinSound.playOnce(Paths.sound('cancelMenu')); } } diff --git a/source/funkin/ui/options/PreferencesMenu.hx b/source/funkin/ui/options/PreferencesMenu.hx index 783aef0ba..eb7b88792 100644 --- a/source/funkin/ui/options/PreferencesMenu.hx +++ b/source/funkin/ui/options/PreferencesMenu.hx @@ -8,6 +8,11 @@ import funkin.ui.AtlasText.AtlasFont; import funkin.ui.options.OptionsState.Page; import funkin.graphics.FunkinCamera; import funkin.ui.TextMenuList.TextMenuItem; +import funkin.audio.FunkinSound; +import funkin.ui.options.MenuItemEnums; +import funkin.ui.options.items.CheckboxPreferenceItem; +import funkin.ui.options.items.NumberPreferenceItem; +import funkin.ui.options.items.EnumPreferenceItem; class PreferencesMenu extends Page { @@ -67,13 +72,59 @@ class PreferencesMenu extends Page createPrefItemCheckbox('Auto Pause', 'Automatically pause the game when it loses focus', function(value:Bool):Void { Preferences.autoPause = value; }, Preferences.autoPause); + + #if web + createPrefItemCheckbox('Unlocked Framerate', 'Enable to unlock the framerate', function(value:Bool):Void { + Preferences.unlockedFramerate = value; + }, Preferences.unlockedFramerate); + #end } + override function update(elapsed:Float):Void + { + super.update(elapsed); + + // Indent the selected item. + items.forEach(function(daItem:TextMenuItem) { + var thyOffset:Int = 0; + + // Initializing thy text width (if thou text present) + var thyTextWidth:Int = 0; + if (Std.isOfType(daItem, EnumPreferenceItem)) thyTextWidth = cast(daItem, EnumPreferenceItem).lefthandText.getWidth(); + else if (Std.isOfType(daItem, NumberPreferenceItem)) thyTextWidth = cast(daItem, NumberPreferenceItem).lefthandText.getWidth(); + + if (thyTextWidth != 0) + { + // Magic number because of the weird offset thats being added by default + thyOffset += thyTextWidth - 75; + } + + if (items.selectedItem == daItem) + { + thyOffset += 150; + } + else + { + thyOffset += 120; + } + + daItem.x = thyOffset; + }); + } + + // - Preference item creation methods - + // Should be moved into a separate PreferenceItems class but you can't access PreferencesMenu.items and PreferencesMenu.preferenceItems from outside. + + /** + * Creates a pref item that works with booleans + * @param onChange Gets called every time the player changes the value; use this to apply the value + * @param defaultValue The value that is loaded in when the pref item is created (usually your Preferences.settingVariable) + */ function createPrefItemCheckbox(prefName:String, prefDesc:String, onChange:Bool->Void, defaultValue:Bool):Void { var checkbox:CheckboxPreferenceItem = new CheckboxPreferenceItem(0, 120 * (items.length - 1 + 1), defaultValue); - items.createItem(120, (120 * items.length) + 30, prefName, AtlasFont.BOLD, function() { + items.createItem(0, (120 * items.length) + 30, prefName, AtlasFont.BOLD, function() { var value = !checkbox.currentValue; onChange(value); checkbox.currentValue = value; @@ -82,62 +133,54 @@ class PreferencesMenu extends Page preferenceItems.add(checkbox); } - override function update(elapsed:Float) + /** + * Creates a pref item that works with general numbers + * @param onChange Gets called every time the player changes the value; use this to apply the value + * @param valueFormatter Will get called every time the game needs to display the float value; use this to change how the displayed value looks + * @param defaultValue The value that is loaded in when the pref item is created (usually your Preferences.settingVariable) + * @param min Minimum value (example: 0) + * @param max Maximum value (example: 10) + * @param step The value to increment/decrement by (default = 0.1) + * @param precision Rounds decimals up to a `precision` amount of digits (ex: 4 -> 0.1234, 2 -> 0.12) + */ + function createPrefItemNumber(prefName:String, prefDesc:String, onChange:Float->Void, ?valueFormatter:Float->String, defaultValue:Int, min:Int, max:Int, + step:Float = 0.1, precision:Int):Void { - super.update(elapsed); + var item = new NumberPreferenceItem(0, (120 * items.length) + 30, prefName, defaultValue, min, max, step, precision, onChange, valueFormatter); + items.addItem(prefName, item); + preferenceItems.add(item.lefthandText); + } - // Indent the selected item. - // TODO: Only do this on menu change? - items.forEach(function(daItem:TextMenuItem) { - if (items.selectedItem == daItem) daItem.x = 150; - else - daItem.x = 120; - }); - } -} - -class CheckboxPreferenceItem extends FlxSprite -{ - public var currentValue(default, set):Bool; - - public function new(x:Float, y:Float, defaultValue:Bool = false) - { - super(x, y); - - frames = Paths.getSparrowAtlas('checkboxThingie'); - animation.addByPrefix('static', 'Check Box unselected', 24, false); - animation.addByPrefix('checked', 'Check Box selecting animation', 24, false); - - setGraphicSize(Std.int(width * 0.7)); - updateHitbox(); - - this.currentValue = defaultValue; - } - - override function update(elapsed:Float) - { - super.update(elapsed); - - switch (animation.curAnim.name) - { - case 'static': - offset.set(); - case 'checked': - offset.set(17, 70); - } - } - - function set_currentValue(value:Bool):Bool - { - if (value) - { - animation.play('checked', true); - } - else - { - animation.play('static'); - } - - return currentValue = value; + /** + * Creates a pref item that works with number percentages + * @param onChange Gets called every time the player changes the value; use this to apply the value + * @param defaultValue The value that is loaded in when the pref item is created (usually your Preferences.settingVariable) + * @param min Minimum value (default = 0) + * @param max Maximum value (default = 100) + */ + function createPrefItemPercentage(prefName:String, prefDesc:String, onChange:Int->Void, defaultValue:Int, min:Int = 0, max:Int = 100):Void + { + var newCallback = function(value:Float) { + onChange(Std.int(value)); + }; + var formatter = function(value:Float) { + return '${value}%'; + }; + var item = new NumberPreferenceItem(0, (120 * items.length) + 30, prefName, defaultValue, min, max, 10, 0, newCallback, formatter); + items.addItem(prefName, item); + preferenceItems.add(item.lefthandText); + } + + /** + * Creates a pref item that works with enums + * @param values Maps enum values to display strings _(ex: `NoteHitSoundType.PingPong => "Ping pong"`)_ + * @param onChange Gets called every time the player changes the value; use this to apply the value + * @param defaultValue The value that is loaded in when the pref item is created (usually your Preferences.settingVariable) + */ + function createPrefItemEnum(prefName:String, prefDesc:String, values:Map, onChange:String->Void, defaultValue:String):Void + { + var item = new EnumPreferenceItem(0, (120 * items.length) + 30, prefName, values, defaultValue, onChange); + items.addItem(prefName, item); + preferenceItems.add(item.lefthandText); } } diff --git a/source/funkin/ui/options/items/CheckboxPreferenceItem.hx b/source/funkin/ui/options/items/CheckboxPreferenceItem.hx new file mode 100644 index 000000000..88c4fb6b0 --- /dev/null +++ b/source/funkin/ui/options/items/CheckboxPreferenceItem.hx @@ -0,0 +1,49 @@ +package funkin.ui.options.items; + +import flixel.FlxSprite.FlxSprite; + +class CheckboxPreferenceItem extends FlxSprite +{ + public var currentValue(default, set):Bool; + + public function new(x:Float, y:Float, defaultValue:Bool = false) + { + super(x, y); + + frames = Paths.getSparrowAtlas('checkboxThingie'); + animation.addByPrefix('static', 'Check Box unselected', 24, false); + animation.addByPrefix('checked', 'Check Box selecting animation', 24, false); + + setGraphicSize(Std.int(width * 0.7)); + updateHitbox(); + + this.currentValue = defaultValue; + } + + override function update(elapsed:Float) + { + super.update(elapsed); + + switch (animation.curAnim.name) + { + case 'static': + offset.set(); + case 'checked': + offset.set(17, 70); + } + } + + function set_currentValue(value:Bool):Bool + { + if (value) + { + animation.play('checked', true); + } + else + { + animation.play('static'); + } + + return currentValue = value; + } +} diff --git a/source/funkin/ui/options/items/EnumPreferenceItem.hx b/source/funkin/ui/options/items/EnumPreferenceItem.hx new file mode 100644 index 000000000..02a273353 --- /dev/null +++ b/source/funkin/ui/options/items/EnumPreferenceItem.hx @@ -0,0 +1,84 @@ +package funkin.ui.options.items; + +import funkin.ui.TextMenuList; +import funkin.ui.AtlasText; +import funkin.input.Controls; +import funkin.ui.options.MenuItemEnums; +import haxe.EnumTools; + +/** + * Preference item that allows the player to pick a value from an enum (list of values) + */ +class EnumPreferenceItem extends TextMenuItem +{ + function controls():Controls + { + return PlayerSettings.player1.controls; + } + + public var lefthandText:AtlasText; + + public var currentValue:String; + public var onChangeCallback:NullVoid>; + public var map:Map; + public var keys:Array = []; + + var index = 0; + + public function new(x:Float, y:Float, name:String, map:Map, defaultValue:String, ?callback:String->Void) + { + super(x, y, name, function() { + callback(this.currentValue); + }); + + updateHitbox(); + + this.map = map; + this.currentValue = defaultValue; + this.onChangeCallback = callback; + + var i:Int = 0; + for (key in map.keys()) + { + this.keys.push(key); + if (this.currentValue == key) index = i; + i += 1; + } + + lefthandText = new AtlasText(15, y, formatted(defaultValue), AtlasFont.DEFAULT); + } + + override function update(elapsed:Float):Void + { + super.update(elapsed); + + // var fancyTextFancyColor:Color; + if (selected) + { + var shouldDecrease:Bool = controls().UI_LEFT_P; + var shouldIncrease:Bool = controls().UI_RIGHT_P; + + if (shouldDecrease) index -= 1; + if (shouldIncrease) index += 1; + + if (index > keys.length - 1) index = 0; + if (index < 0) index = keys.length - 1; + + currentValue = keys[index]; + if (onChangeCallback != null && (shouldIncrease || shouldDecrease)) + { + onChangeCallback(currentValue); + } + } + + lefthandText.text = formatted(currentValue); + } + + function formatted(value:String):String + { + // FIXME: Can't add arrows around the text because the font doesn't support < > + // var leftArrow:String = selected ? '<' : ''; + // var rightArrow:String = selected ? '>' : ''; + return '${map.get(value) ?? value}'; + } +} diff --git a/source/funkin/ui/options/items/NumberPreferenceItem.hx b/source/funkin/ui/options/items/NumberPreferenceItem.hx new file mode 100644 index 000000000..f3cd3cd46 --- /dev/null +++ b/source/funkin/ui/options/items/NumberPreferenceItem.hx @@ -0,0 +1,136 @@ +package funkin.ui.options.items; + +import funkin.ui.TextMenuList; +import funkin.ui.AtlasText; +import funkin.input.Controls; + +/** + * Preference item that allows the player to pick a value between min and max + */ +class NumberPreferenceItem extends TextMenuItem +{ + function controls():Controls + { + return PlayerSettings.player1.controls; + } + + // Widgets + public var lefthandText:AtlasText; + + // Constants + static final HOLD_DELAY:Float = 0.3; // seconds + static final CHANGE_RATE:Float = 0.08; // seconds + + // Constructor-initialized variables + public var currentValue:Float; + public var min:Float; + public var max:Float; + public var step:Float; + public var precision:Int; + public var onChangeCallback:NullVoid>; + public var valueFormatter:NullString>; + + // Variables + var holdDelayTimer:Float = HOLD_DELAY; // seconds + var changeRateTimer:Float = 0.0; // seconds + + /** + * @param min Minimum value (example: 0) + * @param max Maximum value (example: 100) + * @param step The value to increment/decrement by (example: 10) + * @param callback Will get called every time the user changes the setting; use this to apply/save the setting. + * @param valueFormatter Will get called every time the game needs to display the float value; use this to change how the displayed string looks + */ + public function new(x:Float, y:Float, name:String, defaultValue:Float, min:Float, max:Float, step:Float, precision:Int, ?callback:Float->Void, + ?valueFormatter:Float->String):Void + { + super(x, y, name, function() { + callback(this.currentValue); + }); + lefthandText = new AtlasText(15, y, formatted(defaultValue), AtlasFont.DEFAULT); + + updateHitbox(); + + this.currentValue = defaultValue; + this.min = min; + this.max = max; + this.step = step; + this.precision = precision; + this.onChangeCallback = callback; + this.valueFormatter = valueFormatter; + } + + override function update(elapsed:Float):Void + { + super.update(elapsed); + + // var fancyTextFancyColor:Color; + if (selected) + { + holdDelayTimer -= elapsed; + if (holdDelayTimer <= 0.0) + { + changeRateTimer -= elapsed; + } + + var jpLeft:Bool = controls().UI_LEFT_P; + var jpRight:Bool = controls().UI_RIGHT_P; + + if (jpLeft || jpRight) + { + holdDelayTimer = HOLD_DELAY; + changeRateTimer = 0.0; + } + + var shouldDecrease:Bool = jpLeft; + var shouldIncrease:Bool = jpRight; + + if (controls().UI_LEFT && holdDelayTimer <= 0.0 && changeRateTimer <= 0.0) + { + shouldDecrease = true; + changeRateTimer = CHANGE_RATE; + } + else if (controls().UI_RIGHT && holdDelayTimer <= 0.0 && changeRateTimer <= 0.0) + { + shouldIncrease = true; + changeRateTimer = CHANGE_RATE; + } + + // Actually increasing/decreasing the value + if (shouldDecrease) + { + var isBelowMin:Bool = currentValue - step < min; + currentValue = (currentValue - step).clamp(min, max); + if (onChangeCallback != null && !isBelowMin) onChangeCallback(currentValue); + } + else if (shouldIncrease) + { + var isAboveMax:Bool = currentValue + step > max; + currentValue = (currentValue + step).clamp(min, max); + if (onChangeCallback != null && !isAboveMax) onChangeCallback(currentValue); + } + } + + lefthandText.text = formatted(currentValue); + } + + /** Turns the float into a string */ + function formatted(value:Float):String + { + var float:Float = toFixed(value); + if (valueFormatter != null) + { + return valueFormatter(float); + } + else + { + return '${float}'; + } + } + + function toFixed(value:Float):Float + { + var multiplier:Float = Math.pow(10, precision); + return Math.floor(value * multiplier) / multiplier; + } +} diff --git a/source/funkin/ui/story/LevelProp.hx b/source/funkin/ui/story/LevelProp.hx index 0547404a1..dfb11dd20 100644 --- a/source/funkin/ui/story/LevelProp.hx +++ b/source/funkin/ui/story/LevelProp.hx @@ -16,7 +16,7 @@ class LevelProp extends Bopper this.propData = value; this.visible = this.propData != null; - danceEvery = this.propData?.danceEvery ?? 0; + danceEvery = this.propData?.danceEvery ?? 1.0; applyData(); } @@ -32,7 +32,7 @@ class LevelProp extends Bopper public function playConfirm():Void { - playAnimation('confirm', true, true); + if (hasAnimation('confirm')) playAnimation('confirm', true, true); } function applyData():Void diff --git a/source/funkin/ui/story/LevelTitle.hx b/source/funkin/ui/story/LevelTitle.hx index e6f989016..2be2da154 100644 --- a/source/funkin/ui/story/LevelTitle.hx +++ b/source/funkin/ui/story/LevelTitle.hx @@ -13,13 +13,10 @@ class LevelTitle extends FlxSpriteGroup public final level:Level; public var targetY:Float; - public var isFlashing:Bool = false; var title:FlxSprite; var lock:FlxSprite; - var flashingInt:Int = 0; - public function new(x:Int, y:Int, level:Level) { super(x, y); @@ -46,20 +43,23 @@ class LevelTitle extends FlxSpriteGroup } } - // if it runs at 60fps, fake framerate will be 6 - // if it runs at 144 fps, fake framerate will be like 14, and will update the graphic every 0.016666 * 3 seconds still??? - // so it runs basically every so many seconds, not dependant on framerate?? - // I'm still learning how math works thanks whoever is reading this lol - var fakeFramerate:Int = Math.round((1 / FlxG.elapsed) / 10); + public var isFlashing:Bool = false; + var flashTick:Float = 0; + final flashFramerate:Float = 20; public override function update(elapsed:Float):Void { this.y = MathUtil.coolLerp(y, targetY, 0.17); - if (isFlashing) flashingInt += 1; - if (flashingInt % fakeFramerate >= Math.floor(fakeFramerate / 2)) title.color = 0xFF33ffff; - else - title.color = FlxColor.WHITE; + if (isFlashing) + { + flashTick += elapsed; + if (flashTick >= 1 / flashFramerate) + { + flashTick %= 1 / flashFramerate; + title.color = (title.color == FlxColor.WHITE) ? 0xFF33ffff : FlxColor.WHITE; + } + } } public function showLock():Void diff --git a/source/funkin/ui/story/StoryMenuState.hx b/source/funkin/ui/story/StoryMenuState.hx index 06a83ab4d..18614d414 100644 --- a/source/funkin/ui/story/StoryMenuState.hx +++ b/source/funkin/ui/story/StoryMenuState.hx @@ -113,7 +113,7 @@ class StoryMenuState extends MusicBeatState { super(); - if (stickers != null) + if (stickers?.members != null) { stickerSubState = stickers; } @@ -216,7 +216,7 @@ class StoryMenuState extends MusicBeatState changeLevel(); refresh(); - #if discord_rpc + #if FEATURE_DISCORD_RPC // Updating Discord Rich Presence DiscordClient.changePresence('In the Menus', null); #end @@ -336,6 +336,22 @@ class StoryMenuState extends MusicBeatState changeDifficulty(0); } + #if !html5 + if (FlxG.mouse.wheel != 0) + { + changeLevel(-Math.round(FlxG.mouse.wheel)); + } + #else + if (FlxG.mouse.wheel < 0) + { + changeLevel(-Math.round(FlxG.mouse.wheel / 8)); + } + else if (FlxG.mouse.wheel > 0) + { + changeLevel(-Math.round(FlxG.mouse.wheel / 8)); + } + #end + // TODO: Querying UI_RIGHT_P (justPressed) after UI_RIGHT always returns false. Fix it! if (controls.UI_RIGHT_P) { @@ -374,9 +390,9 @@ class StoryMenuState extends MusicBeatState if (controls.BACK && !exitingMenu && !selectedLevel) { - FunkinSound.playOnce(Paths.sound('cancelMenu')); exitingMenu = true; FlxG.switchState(() -> new MainMenuState()); + FunkinSound.playOnce(Paths.sound('cancelMenu')); } } diff --git a/source/funkin/ui/title/TitleState.hx b/source/funkin/ui/title/TitleState.hx index 8087916cb..f5c641d0c 100644 --- a/source/funkin/ui/title/TitleState.hx +++ b/source/funkin/ui/title/TitleState.hx @@ -32,6 +32,7 @@ import openfl.media.Video; import openfl.net.NetStream; import funkin.api.newgrounds.NGio; import openfl.display.BlendMode; +import funkin.save.Save; #if desktop #end @@ -272,6 +273,11 @@ class TitleState extends MusicBeatState } #end + if (Save.instance.charactersSeen.contains("pico")) + { + Save.instance.charactersSeen.remove("pico"); + Save.instance.oldChar = false; + } Conductor.instance.update(); /* if (FlxG.onMobile) @@ -520,7 +526,8 @@ class TitleState extends MusicBeatState remove(ngSpr); FlxG.camera.flash(FlxColor.WHITE, initialized ? 1 : 4); - remove(credGroup); + + if (credGroup != null) remove(credGroup); skippedIntro = true; } } diff --git a/source/funkin/ui/transition/LoadingState.hx b/source/funkin/ui/transition/LoadingState.hx index bc26ad97a..5b82cc741 100644 --- a/source/funkin/ui/transition/LoadingState.hx +++ b/source/funkin/ui/transition/LoadingState.hx @@ -174,7 +174,7 @@ class LoadingState extends MusicBeatSubState FlxG.watch.addQuick('percentage?', callbacks.numRemaining / callbacks.length); } - #if debug + #if FEATURE_DEBUG_FUNCTIONS if (FlxG.keys.justPressed.SPACE) trace('fired: ' + callbacks.getFired() + ' unfired:' + callbacks.getUnfired()); #end } @@ -291,29 +291,51 @@ class LoadingState extends MusicBeatSubState FunkinSprite.preparePurgeCache(); FunkinSprite.cacheTexture(Paths.image('healthBar')); FunkinSprite.cacheTexture(Paths.image('menuDesat')); - FunkinSprite.cacheTexture(Paths.image('combo')); - FunkinSprite.cacheTexture(Paths.image('num0')); - FunkinSprite.cacheTexture(Paths.image('num1')); - FunkinSprite.cacheTexture(Paths.image('num2')); - FunkinSprite.cacheTexture(Paths.image('num3')); - FunkinSprite.cacheTexture(Paths.image('num4')); - FunkinSprite.cacheTexture(Paths.image('num5')); - FunkinSprite.cacheTexture(Paths.image('num6')); - FunkinSprite.cacheTexture(Paths.image('num7')); - FunkinSprite.cacheTexture(Paths.image('num8')); - FunkinSprite.cacheTexture(Paths.image('num9')); + // Lord have mercy on me and this caching -anysad + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/combo')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/num0')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/num1')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/num2')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/num3')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/num4')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/num5')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/num6')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/num7')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/num8')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/num9')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/combo')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/num0')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/num1')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/num2')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/num3')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/num4')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/num5')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/num6')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/num7')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/num8')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/num9')); + FunkinSprite.cacheTexture(Paths.image('notes', 'shared')); FunkinSprite.cacheTexture(Paths.image('noteSplashes', 'shared')); FunkinSprite.cacheTexture(Paths.image('noteStrumline', 'shared')); FunkinSprite.cacheTexture(Paths.image('NOTE_hold_assets')); - FunkinSprite.cacheTexture(Paths.image('ready', 'shared')); - FunkinSprite.cacheTexture(Paths.image('set', 'shared')); - FunkinSprite.cacheTexture(Paths.image('go', 'shared')); - FunkinSprite.cacheTexture(Paths.image('sick', 'shared')); - FunkinSprite.cacheTexture(Paths.image('good', 'shared')); - FunkinSprite.cacheTexture(Paths.image('bad', 'shared')); - FunkinSprite.cacheTexture(Paths.image('shit', 'shared')); - FunkinSprite.cacheTexture(Paths.image('miss', 'shared')); // TODO: remove this + + FunkinSprite.cacheTexture(Paths.image('ui/countdown/funkin/ready', 'shared')); + FunkinSprite.cacheTexture(Paths.image('ui/countdown/funkin/set', 'shared')); + FunkinSprite.cacheTexture(Paths.image('ui/countdown/funkin/go', 'shared')); + FunkinSprite.cacheTexture(Paths.image('ui/countdown/pixel/ready', 'shared')); + FunkinSprite.cacheTexture(Paths.image('ui/countdown/pixel/set', 'shared')); + FunkinSprite.cacheTexture(Paths.image('ui/countdown/pixel/go', 'shared')); + + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/sick')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/good')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/bad')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/funkin/shit')); + + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/sick')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/good')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/bad')); + FunkinSprite.cacheTexture(Paths.image('ui/popup/pixel/shit')); // List all image assets in the level's library. // This is crude and I want to remove it when we have a proper asset caching system. @@ -346,7 +368,7 @@ class LoadingState extends MusicBeatSubState return 'Done precaching ${path}'; }, true); - trace("Queued ${path} for precaching"); + trace('Queued ${path} for precaching'); // FunkinSprite.cacheTexture(path); } diff --git a/source/funkin/util/Constants.hx b/source/funkin/util/Constants.hx index 1e0978839..57fc484b8 100644 --- a/source/funkin/util/Constants.hx +++ b/source/funkin/util/Constants.hx @@ -41,9 +41,9 @@ class Constants * A suffix to add to the game version. * Add a suffix to prototype builds and remove it for releases. */ - public static final VERSION_SUFFIX:String = #if (DEBUG || FORCE_DEBUG_VERSION) ' PROTOTYPE' #else '' #end; + public static final VERSION_SUFFIX:String = #if FEATURE_DEBUG_FUNCTIONS ' PROTOTYPE' #else '' #end; - #if (debug || FORCE_DEBUG_VERSION) + #if FEATURE_DEBUG_FUNCTIONS static function get_VERSION():String { return 'v${Application.current.meta.get('version')} (${GIT_BRANCH} : ${GIT_HASH}${GIT_HAS_LOCAL_CHANGES ? ' : MODIFIED' : ''})' + VERSION_SUFFIX; @@ -63,7 +63,7 @@ class Constants /** * Link to buy merch for the game. */ - public static final URL_MERCH:String = 'https://needlejuicerecords.com/pages/friday-night-funkin'; + public static final URL_MERCH:String = 'https://www.makeship.com/shop/creator/friday-night-funkin'; /** * Preloader sitelock. @@ -258,6 +258,16 @@ class Constants */ public static final DEFAULT_NOTE_STYLE:String = 'funkin'; + /** + * The default freeplay style for characters. + */ + public static final DEFAULT_FREEPLAY_STYLE:String = 'bf'; + + /** + * The default pixel note style for songs. + */ + public static final DEFAULT_PIXEL_NOTE_STYLE:String = 'pixel'; + /** * The default album for songs in Freeplay. */ @@ -283,6 +293,21 @@ class Constants */ public static final DEFAULT_TIME_SIGNATURE_DEN:Int = 4; + /** + * ANIMATIONS + */ + // ============================== + + /** + * A suffix used for animations played when an animation would loop. + */ + public static final ANIMATION_HOLD_SUFFIX:String = '-hold'; + + /** + * A suffix used for animations played when an animation would end before transitioning to another. + */ + public static final ANIMATION_END_SUFFIX:String = '-end'; + /** * TIMING */ @@ -364,11 +389,7 @@ class Constants * 1 = The preloader waits for 1 second before moving to the next step. * The progress bare is automatically rescaled to match. */ - #if debug - public static final PRELOADER_MIN_STAGE_TIME:Float = 0.0; - #else public static final PRELOADER_MIN_STAGE_TIME:Float = 0.1; - #end /** * HEALTH VALUES @@ -508,12 +529,16 @@ class Constants * OTHER */ // ============================== + #if FEATURE_GHOST_TAPPING + // Hey there, Eric here. + // This feature is currently still in development. You can test it out by creating a special debug build! + // lime build windows -DFEATURE_GHOST_TAPPING /** - * If true, the player will not receive the ghost miss penalty if there are no notes within the hit window. - * This is the thing people have been begging for forever lolol. + * Duration, in seconds, after the player's section ends before the player can spam without penalty. */ - public static final GHOST_TAPPING:Bool = false; + public static final GHOST_TAP_DELAY:Float = 3 / 8; + #end /** * The maximum number of previous file paths for the Chart Editor to remember. diff --git a/source/funkin/util/FlxColorUtil.hx b/source/funkin/util/FlxColorUtil.hx new file mode 100644 index 000000000..429d536d8 --- /dev/null +++ b/source/funkin/util/FlxColorUtil.hx @@ -0,0 +1,22 @@ +package funkin.util; + +import flixel.util.FlxColor; + +/** + * Non inline FlxColor functions for use in hscript files + */ +class FlxColorUtil +{ + /** + * Get an interpolated color based on two different colors. + * + * @param Color1 The first color + * @param Color2 The second color + * @param Factor Value from 0 to 1 representing how much to shift Color1 toward Color2 + * @return The interpolated color + */ + public static function interpolate(Color1:FlxColor, Color2:FlxColor, Factor:Float = 0.5):FlxColor + { + return FlxColor.interpolate(Color1, Color2, Factor); + } +} diff --git a/source/funkin/util/FramesJSFLParser.hx b/source/funkin/util/FramesJSFLParser.hx new file mode 100644 index 000000000..3a9ff8d0a --- /dev/null +++ b/source/funkin/util/FramesJSFLParser.hx @@ -0,0 +1,63 @@ +package funkin.util; + +import openfl.Assets; + +/** + * See `funScripts/jsfl/frames.jsfl` for more information in the art repo/folder! + * Homemade dipshit proprietary format to get simple animation info out of flash! + * Pure convienience! + */ +class FramesJSFLParser +{ + public static function parse(path:String):FramesJSFLInfo + { + var text:String = Assets.getText(path); + + // TODO: error handle if text is null + + var output:FramesJSFLInfo = {frames: []}; + + var frames:Array = text.split("\n"); + + for (frame in frames) + { + var frameInfo:Array = frame.split(" "); + + var x:Float = Std.parseFloat(frameInfo[0]); + var y:Float = Std.parseFloat(frameInfo[1]); + var alpha:Float = (frameInfo[2] != "undefined") ? Std.parseFloat(frameInfo[2]) : 100; + + var scaleX:Float = 1; + var scaleY:Float = 1; + + if (frameInfo[3] != null) scaleX = Std.parseFloat(frameInfo[4]); + if (frameInfo[4] != null) scaleY = Std.parseFloat(frameInfo[4]); + + var shit:FramesJSFLFrame = + { + x: x, + y: y, + alpha: alpha, + scaleX: scaleX, + scaleY: scaleY + }; + output.frames.push(shit); + } + + return output; + } +} + +typedef FramesJSFLInfo = +{ + var frames:Array; +} + +typedef FramesJSFLFrame = +{ + var x:Float; + var y:Float; + var alpha:Float; + var scaleX:Float; + var scaleY:Float; +} diff --git a/source/funkin/util/MemoryUtil.hx b/source/funkin/util/MemoryUtil.hx index f5935ed67..18fd41472 100644 --- a/source/funkin/util/MemoryUtil.hx +++ b/source/funkin/util/MemoryUtil.hx @@ -48,11 +48,11 @@ class MemoryUtil * Calculate the total memory usage of the program, in bytes. * @return Int */ - public static function getMemoryUsed():Int + public static function getMemoryUsed():#if cpp Float #else Int #end { #if cpp // There is also Gc.MEM_INFO_RESERVED, MEM_INFO_CURRENT, and MEM_INFO_LARGE. - return cpp.vm.Gc.memInfo(cpp.vm.Gc.MEM_INFO_USAGE); + return cpp.vm.Gc.memInfo64(cpp.vm.Gc.MEM_INFO_USAGE); #else return openfl.system.System.totalMemory; #end diff --git a/source/funkin/util/ReflectUtil.hx b/source/funkin/util/ReflectUtil.hx index 830edd31d..da98c820b 100644 --- a/source/funkin/util/ReflectUtil.hx +++ b/source/funkin/util/ReflectUtil.hx @@ -33,4 +33,19 @@ class ReflectUtil { return Type.getClassName(Type.getClass(obj)); } + + public static function getAnonymousFieldsOf(obj:Dynamic):Array + { + return Reflect.fields(obj); + } + + public static function getAnonymousField(obj:Dynamic, name:String):Dynamic + { + return Reflect.field(obj, name); + } + + public static function hasAnonymousField(obj:Dynamic, name:String):Bool + { + return Reflect.hasField(obj, name); + } } diff --git a/source/funkin/util/SortUtil.hx b/source/funkin/util/SortUtil.hx index c5ac175be..f6d3721f0 100644 --- a/source/funkin/util/SortUtil.hx +++ b/source/funkin/util/SortUtil.hx @@ -97,7 +97,7 @@ class SortUtil * @param b The second string to compare. * @return 1 if `a` comes before `b`, -1 if `b` comes before `a`, 0 if they are equal */ - public static function alphabetically(a:String, b:String):Int + public static function alphabetically(?a:String, ?b:String):Int { a = a.toUpperCase(); b = b.toUpperCase(); diff --git a/source/funkin/util/VersionUtil.hx b/source/funkin/util/VersionUtil.hx index 832ce008a..9bf46a188 100644 --- a/source/funkin/util/VersionUtil.hx +++ b/source/funkin/util/VersionUtil.hx @@ -24,7 +24,6 @@ class VersionUtil try { var versionRaw:thx.semver.Version.SemVer = version; - trace('${versionRaw} satisfies (${versionRule})? ${version.satisfies(versionRule)}'); return version.satisfies(versionRule); } catch (e) diff --git a/source/funkin/util/logging/AnsiTrace.hx b/source/funkin/util/logging/AnsiTrace.hx index 2c18d494d..322a66820 100644 --- a/source/funkin/util/logging/AnsiTrace.hx +++ b/source/funkin/util/logging/AnsiTrace.hx @@ -6,6 +6,9 @@ class AnsiTrace // but adds nice cute ANSI things public static function trace(v:Dynamic, ?info:haxe.PosInfos) { + #if TREMOVE + return; + #end var str = formatOutput(v, info); #if js if (js.Syntax.typeof(untyped console) != "undefined" && (untyped console).log != null) (untyped console).log(str); diff --git a/source/funkin/util/logging/CrashHandler.hx b/source/funkin/util/logging/CrashHandler.hx index 71d1ad394..1b607ddfd 100644 --- a/source/funkin/util/logging/CrashHandler.hx +++ b/source/funkin/util/logging/CrashHandler.hx @@ -265,9 +265,10 @@ class CrashHandler static function renderMethod():String { - try + var outputStr:String = 'UNKNOWN'; + outputStr = try { - return switch (FlxG.renderMethod) + switch (FlxG.renderMethod) { case FlxRenderMethod.DRAW_TILES: 'DRAW_TILES'; case FlxRenderMethod.BLITTING: 'BLITTING'; @@ -276,7 +277,9 @@ class CrashHandler } catch (e) { - return 'ERROR ON QUERY RENDER METHOD: ${e}'; + 'ERROR ON QUERY RENDER METHOD: ${e}'; } + + return outputStr; } } diff --git a/source/funkin/util/plugins/ReloadAssetsDebugPlugin.hx b/source/funkin/util/plugins/ReloadAssetsDebugPlugin.hx index f69609531..0e1e238ac 100644 --- a/source/funkin/util/plugins/ReloadAssetsDebugPlugin.hx +++ b/source/funkin/util/plugins/ReloadAssetsDebugPlugin.hx @@ -1,6 +1,9 @@ package funkin.util.plugins; +import flixel.FlxG; import flixel.FlxBasic; +import funkin.ui.MusicBeatState; +import funkin.ui.MusicBeatSubState; /** * A plugin which adds functionality to press `F5` to reload all game assets, then reload the current state. @@ -28,10 +31,15 @@ class ReloadAssetsDebugPlugin extends FlxBasic if (FlxG.keys.justPressed.F5) #end { - funkin.modding.PolymodHandler.forceReloadAssets(); + var state:Dynamic = FlxG.state; + if (state is MusicBeatState || state is MusicBeatSubState) state.reloadAssets(); + else + { + funkin.modding.PolymodHandler.forceReloadAssets(); - // Create a new instance of the current state, so old data is cleared. - FlxG.resetState(); + // Create a new instance of the current state, so old data is cleared. + FlxG.resetState(); + } } } diff --git a/source/funkin/util/tools/MapTools.hx b/source/funkin/util/tools/MapTools.hx index b98cb0adf..807f0aebd 100644 --- a/source/funkin/util/tools/MapTools.hx +++ b/source/funkin/util/tools/MapTools.hx @@ -14,6 +14,7 @@ class MapTools */ public static function size(map:Map):Int { + if (map == null) return 0; return map.keys().array().length; } @@ -22,6 +23,7 @@ class MapTools */ public static function values(map:Map):Array { + if (map == null) return []; return [for (i in map.iterator()) i]; } @@ -30,6 +32,7 @@ class MapTools */ public static function clone(map:Map):Map { + if (map == null) return null; return map.copy(); } @@ -76,6 +79,7 @@ class MapTools */ public static function keyValues(map:Map):Array { + if (map == null) return []; return map.keys().array(); } }